export interface ArticleTab { slug: string title: string /** the language version this page holds */ lang?: string /** the article this document is a version of — its canonical slug */ identity?: string } const MAX_TABS = 10 /** The address a page of the book was read from: the article's own URL for * the canonical version, /wiki// for the others. */ export function tabHref(tab: Pick): string { const identity = tab.identity ?? tab.slug return versionHref(identity, { language: tab.lang, slug: tab.slug }) } /** Which page of the stack an address points at, if any. */ export function tabAt(tabs: readonly ArticleTab[], path: string): ArticleTab | null { const loc = parseReaderPath(path) if (!loc) return null const exact = tabs.find((t) => { const identity = t.identity ?? t.slug if (loc.lang) return identity === loc.identity && (t.lang ?? 'en') === loc.lang return identity === loc.identity && t.slug === loc.identity }) /* an address naming a document directly (older /slug-fr form) still finds it */ return exact ?? tabs.find((t) => t.slug === loc.identity) ?? null } /** * The session stack of articles opened in the reader — the pages of the * book, in the order they were opened, newest at the right. The page * currently in view is simply wherever the route points; the stack lives in * shared state and survives every client navigation (reset on reload), so the * open pages are still there when you come back to the reading surface. */ export function useArticleTabs() { const route = useRoute() const router = useRouter() const tabs = useState('article-tabs', () => []) /* the last page seen outside the reading surface, so closing the last tab returns there instead of always going home */ const origin = useState('article-tabs:origin', () => '') /* the page last in view, so the stack can be found again from any page that hides the reading surface — the editor, a new article, the graph */ const lastSlug = useState('article-tabs:last', () => '') function ensureTab( slug: string, title: string, meta?: { lang?: string; identity?: string } ) { const existing = tabs.value.find((t) => t.slug === slug) if (existing) { existing.title = title // follow the tab label after a rename if (meta?.lang) existing.lang = meta.lang if (meta?.identity) existing.identity = meta.identity return } const stack = [...tabs.value, { slug, title, lang: meta?.lang, identity: meta?.identity }] if (stack.length > MAX_TABS) { /* don't stack forever — retire the oldest tab that isn't in view */ const drop = stack.findIndex((t) => t.slug !== slug) if (drop !== -1) stack.splice(drop, 1) } tabs.value = stack } /* Close a page. Closing one that isn't in view just takes it out of the stack and the rest settle; closing the page in view turns back to the page before it — or returns to the last page outside the book once the last page is gone. */ function closeTab(slug: string) { const at = tabs.value.findIndex((t) => t.slug === slug) const isActive = tabAt(tabs.value, route.path)?.slug === slug tabs.value = tabs.value.filter((t) => t.slug !== slug) if (isActive) { const back = tabs.value[Math.max(0, Math.min(at - 1, tabs.value.length - 1))] void router.push(back ? tabHref(back) : origin.value || '/') } } return { tabs, origin, lastSlug, ensureTab, closeTab } } /** * Keeps the book's stack of pages in step with the route: which page was last * in view, and the last plain page outside the reading surface. The stack * itself is left alone — it survives the whole session, so wandering off to * the graph or the article list and coming back finds the pages as they were. * Call once, high in the app. */ export function useArticleTabsSync() { const route = useRoute() const { tabs, lastSlug, origin } = useArticleTabs() watch( () => route.fullPath, (to) => { /* filed under the tab's own slug, so a language version is found again as the version it was, not merely as the article */ if (isReaderPath(to)) lastSlug.value = tabAt(tabs.value, to)?.slug ?? wikiSlugFromPath(to) ?? '' else if (!isReadingSurface(to)) origin.value = to }, { immediate: true } ) }