Files
mathew/frontend/app/components/ArticleBookStack.vue
Aran Roig 68b74eb60b
All checks were successful
Build and Deploy Nuxt / build (push) Successful in 24s
Graph calculator improvmentes
2026-10-02 14:53:00 +02:00

292 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/* The reading surface: an open book of the articles from this session. Each
one is a page laid out beside the last, and you move along them sideways.
A page turned to slides over the older ones, which park along the left
edge with only their spines showing; click anywhere on a page — spine or
body — to turn back to it, or × to close it. Routes stay the source of
truth — every page is a plain /wiki/<lang>/<slug> URL — and useArticleTabs
keeps the stack of pages, one page per article however many languages the
article speaks: turning one to another language turns this page over, it
does not add one to the row.
Pages further along the book than the one in view wait off the right edge
of the desk, where you would have to scroll sideways to reach them. Their
spines are copied into a stack docked at the right edge of the desk instead
— turning one takes you to the page as if you had scrolled to it — and a
spine leaves the stack as its page is brought into view.
Narrow screens fold the book to the one page in view, with its neighbours
as chips along the top. */
import type { ArticleTab } from '~/composables/useArticleTabs'
const api = `${useRuntimeConfig().public.apiBase}/api`
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const { tabs, closeTab } = useArticleTabs()
const { load, forget } = useArticleLibrary()
const { open: searchOpen } = useSearch()
const { open: settingsOpen } = useSettings()
const { open: profileOpen } = useProfileMenu()
const { open: langMenuOpen } = useLanguageMenu()
const { open: actionsMenuOpen } = useArticleActionsMenu()
const { collapsed } = useSidebar()
const activeTab = computed(() => tabAt(tabs.value, route.path))
const activeIdentity = computed(() => activeTab.value?.identity ?? null)
/* the book lies open on a reader route, and on one of the wiki's own pages
while the book holds a page of it — asked plain, those pages draw
themselves instead */
const visible = computed(
() => (isReaderPath(route.path) || tabAt(tabs.value, route.path) !== null) && tabs.value.length > 0
)
/* what a page of the book is called: an article wears its title; one of the
wiki's own pages is called by what it is, in the reader's language */
function tabLabel(tab: ArticleTab): string {
return tab.tool ? t(TOOL_LABEL_KEYS[tab.tool]) : tab.title
}
const trackEl = ref<HTMLElement | null>(null)
/** Whether the book is open flat on the desk (wide) or folded (narrow). */
function liesFlat() {
return import.meta.client && !window.matchMedia('(max-width: 900px)').matches
}
/** Which page of the book is the one in view, or -1 when the route names
* none of them. Only the pages beyond it can be waiting for you. */
const openIndex = computed(() => {
const identity = activeIdentity.value
return identity === null ? -1 : tabs.value.findIndex((t) => t.identity === identity)
})
/**
* Bring the page in view back to its place in the stack. Pages are laid out
* a page-width apart and each one parks `--i` spines from the left edge, so
* scrolling to i * (page width − spine width) sets the whole row: older
* pages fanned into spines on the left, the rest waiting off the right edge.
*/
async function alignToActive(behavior: ScrollBehavior = 'smooth') {
await nextTick()
const track = trackEl.value
const identity = activeIdentity.value
if (!track || !identity || !liesFlat()) return
const page = track.querySelector<HTMLElement>(`[data-tab="${CSS.escape(identity)}"]`)
const i = Number(page?.dataset.index)
if (!page || !Number.isFinite(i)) return
const spine = parseFloat(getComputedStyle(track).getPropertyValue('--spine-w')) || 0
track.scrollTo({ left: i * (page.offsetWidth - spine), behavior })
measureDock()
}
/* ---------- the dock: pages waiting beyond the right edge of the desk -----
A page further along the book than the one in view has its spine off the
right hand side of the screen, and only a sideways scroll reaches it. While
it is there its spine is kept at the right edge of the desk, so the page is
a click away rather than a drag away. Which pages are out there is measured
off the row, not guessed at: slide the book and a spine leaves the stack as
its page comes into view, and joins it again as the page slides out. */
/** The identities of the pages whose spines are in the dock. */
const waitingPages = ref<string[]>([])
/** Those pages, in the order they were opened. */
const waiting = computed(() => tabs.value.filter((t) => waitingPages.value.includes(t.identity)))
/**
* Fill the dock with the pages beyond the one in view whose spines have no
* room left on the desk. A page keeps its place while it straddles the edge,
* so a page sliding past doesn't flicker in and out of the stack.
*/
function measureDock() {
const track = trackEl.value
const open = openIndex.value
if (!track || open === -1 || !liesFlat()) {
waitingPages.value = []
return
}
const edge = track.getBoundingClientRect().right
const spine = parseFloat(getComputedStyle(track).getPropertyValue('--spine-w')) || 0
const held = new Set(waitingPages.value)
for (const page of Array.from(track.querySelectorAll<HTMLElement>('.book-pane'))) {
const i = Number(page.dataset.index)
const identity = page.dataset.tab
if (!identity || !Number.isFinite(i) || i <= open) continue
const left = page.getBoundingClientRect().left
if (left > edge - 6) held.add(identity)
else if (left < edge - spine - 10) held.delete(identity)
}
waitingPages.value = tabs.value
.filter((t, i) => i > open && held.has(t.identity))
.map((t) => t.identity)
}
/* the row moves on scroll, so the dock is measured once a frame while it does */
let measuring = 0
function watchDock() {
if (measuring) return
measuring = requestAnimationFrame(() => {
measuring = 0
measureDock()
})
}
onBeforeUnmount(() => cancelAnimationFrame(measuring))
/* turning to an article slides the book round to it */
watch(activeIdentity, () => void alignToActive('smooth'))
/* and the row changing shape — however it changed — changes which pages are
waiting out beyond the edge of the desk */
watch(tabs, () => void nextTick(measureDock))
/* a page opened or closed changes how wide every page is — settle the row
again so the one in view doesn't drift */
watch(
() => tabs.value.length,
() => void alignToActive('auto')
)
/* pages are sized against the window, so keep them honest as it moves */
function onResize() {
void alignToActive('auto')
measureDock()
}
onMounted(() => {
void alignToActive('auto')
void nextTick(measureDock)
window.addEventListener('resize', onResize)
})
onBeforeUnmount(() => window.removeEventListener('resize', onResize))
/* collapsing the sidebar moves the reading surface's left edge, which
resizes every page — settle the row again once the rail has finished
sliding */
watch(collapsed, () => {
setTimeout(() => void alignToActive('auto'), 260)
})
/* every page of the book should hold its article: pull the ones the route
host hasn't already written in (background pages, client-side only). The
wiki's own pages carry no article to fetch — their surface fetches its
own map when it is turned to */
watch(
tabs,
() => {
for (const t of tabs.value) if (!t.tool) void load(t.slug)
},
{ immediate: true }
)
function turnTo(identity: string) {
const tab = tabs.value.find((t) => t.identity === identity)
if (tab && identity !== activeIdentity.value) void router.push(tabHref(tab))
}
/* the page's × deletes the very version it holds — the document its address
asks for, which for a translation is its own — and closes the page */
async function removeArticle(identity: string) {
const tab = tabs.value.find((t) => t.identity === identity)
if (!tab) return
if (!window.confirm(t('book.deleteConfirm', { title: tab.title }))) return
try {
await $fetch(`${api}/articles/slug/${encodeURIComponent(tab.slug)}`, { method: 'DELETE' })
forget(tab.slug)
closeTab(identity)
} catch {
window.alert(t('book.deleteFailed'))
}
}
/* Esc closes the page in view — unless a popup is open, since those
claim the key first */
function onGlobalKeydown(e: KeyboardEvent) {
if (e.key !== 'Escape') return
if (searchOpen.value || settingsOpen.value || profileOpen.value || langMenuOpen.value || actionsMenuOpen.value)
return
const current = tabAt(tabs.value, route.path)?.identity
if (current) closeTab(current)
}
onMounted(() => window.addEventListener('keydown', onGlobalKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
</script>
<template>
<div v-if="visible" class="book-layer" :style="{ '--panes': tabs.length }">
<nav
v-if="tabs.length > 1"
class="book-chips"
role="tablist"
:aria-label="t('book.openAria')"
>
<div
v-for="tab in tabs"
:key="tab.identity"
class="book-chip"
:class="{ active: tab.identity === activeIdentity }"
role="tab"
tabindex="0"
:aria-selected="tab.identity === activeIdentity"
:title="t('book.chipHint', { title: tabLabel(tab) })"
@click="turnTo(tab.identity)"
@keydown.enter.prevent="turnTo(tab.identity)"
>
<span class="chip-title">{{ tabLabel(tab) }}</span>
<button type="button" class="chip-close" :aria-label="t('common.closeTab')" @click.stop="closeTab(tab.identity)">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
</nav>
<div ref="trackEl" class="book-track" @scroll.passive="watchDock">
<!-- an article's page holds its text; one of the wiki's own pages
holds the live surface itself, the map or the plane -->
<template v-for="(tab, i) in tabs" :key="tab.identity">
<ToolPane
v-if="tab.tool"
:tool="tab.tool"
:index="i"
:active="tab.identity === activeIdentity"
@activate="turnTo(tab.identity)"
@close="closeTab(tab.identity)"
/>
<ArticlePane
v-else
:identity="tab.identity"
:slug="tab.slug"
:index="i"
:active="tab.identity === activeIdentity"
@activate="turnTo(tab.identity)"
@close="closeTab(tab.identity)"
@remove="removeArticle(tab.identity)"
/>
</template>
</div>
<nav v-if="waiting.length" class="book-dock" :aria-label="t('book.dockAria')">
<div
v-for="tab in waiting"
:key="tab.identity"
class="dock-spine"
role="button"
tabindex="0"
:title="t('book.dockHint', { title: tabLabel(tab) })"
@click="turnTo(tab.identity)"
@keydown.enter.prevent="turnTo(tab.identity)"
>
<button type="button" class="spine-close" :aria-label="t('common.closeTab')" @click.stop="closeTab(tab.identity)">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
<span class="spine-title">{{ tabLabel(tab) }}</span>
</div>
</nav>
</div>
</template>