258 lines
9.6 KiB
Vue
258 lines
9.6 KiB
Vue
<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/<slug> URL — and useArticleTabs keeps
|
||
the stack of pages.
|
||
|
||
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. */
|
||
|
||
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 activeSlug = computed(() => activeTab.value?.slug ?? null)
|
||
const visible = computed(() => isReaderPath(route.path) && tabs.value.length > 0)
|
||
|
||
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 slug = activeSlug.value
|
||
return slug === null ? -1 : tabs.value.findIndex((t) => t.slug === slug)
|
||
})
|
||
|
||
/**
|
||
* 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 slug = activeSlug.value
|
||
if (!track || !slug || !liesFlat()) return
|
||
|
||
const page = track.querySelector<HTMLElement>(`[data-slug="${CSS.escape(slug)}"]`)
|
||
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 slugs of the pages whose spines are in the dock. */
|
||
const waitingSlugs = ref<string[]>([])
|
||
/** Those pages, in the order they were opened. */
|
||
const waiting = computed(() => tabs.value.filter((t) => waitingSlugs.value.includes(t.slug)))
|
||
|
||
/**
|
||
* 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()) {
|
||
waitingSlugs.value = []
|
||
return
|
||
}
|
||
|
||
const edge = track.getBoundingClientRect().right
|
||
const spine = parseFloat(getComputedStyle(track).getPropertyValue('--spine-w')) || 0
|
||
const held = new Set(waitingSlugs.value)
|
||
for (const page of Array.from(track.querySelectorAll<HTMLElement>('.book-pane'))) {
|
||
const i = Number(page.dataset.index)
|
||
const slug = page.dataset.slug
|
||
if (!slug || !Number.isFinite(i) || i <= open) continue
|
||
const left = page.getBoundingClientRect().left
|
||
if (left > edge - 6) held.add(slug)
|
||
else if (left < edge - spine - 10) held.delete(slug)
|
||
}
|
||
waitingSlugs.value = tabs.value.filter((t, i) => i > open && held.has(t.slug)).map((t) => t.slug)
|
||
}
|
||
|
||
/* 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(activeSlug, () => 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) */
|
||
watch(
|
||
tabs,
|
||
() => {
|
||
for (const t of tabs.value) void load(t.slug)
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
|
||
function turnTo(slug: string) {
|
||
const tab = tabs.value.find((t) => t.slug === slug)
|
||
if (tab && slug !== activeSlug.value) void router.push(tabHref(tab))
|
||
}
|
||
|
||
async function removeArticle(slug: string) {
|
||
const title = tabs.value.find((t) => t.slug === slug)?.title ?? slug
|
||
if (!window.confirm(t('book.deleteConfirm', { title }))) return
|
||
try {
|
||
await $fetch(`${api}/articles/slug/${encodeURIComponent(slug)}`, { method: 'DELETE' })
|
||
forget(slug)
|
||
closeTab(slug)
|
||
} 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)?.slug
|
||
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.slug"
|
||
class="book-chip"
|
||
:class="{ active: tab.slug === activeSlug }"
|
||
role="tab"
|
||
tabindex="0"
|
||
:aria-selected="tab.slug === activeSlug"
|
||
:title="t('book.chipHint', { title: tab.title })"
|
||
@click="turnTo(tab.slug)"
|
||
@keydown.enter.prevent="turnTo(tab.slug)"
|
||
>
|
||
<span class="chip-title">{{ tab.title }}</span>
|
||
<button type="button" class="chip-close" :aria-label="t('common.closeTab')" @click.stop="closeTab(tab.slug)">
|
||
<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">
|
||
<ArticlePane
|
||
v-for="(tab, i) in tabs"
|
||
:key="tab.slug"
|
||
:slug="tab.slug"
|
||
:index="i"
|
||
:active="tab.slug === activeSlug"
|
||
@activate="turnTo(tab.slug)"
|
||
@close="closeTab(tab.slug)"
|
||
@remove="removeArticle(tab.slug)"
|
||
/>
|
||
</div>
|
||
|
||
<nav v-if="waiting.length" class="book-dock" :aria-label="t('book.dockAria')">
|
||
<div
|
||
v-for="tab in waiting"
|
||
:key="tab.slug"
|
||
class="dock-spine"
|
||
role="button"
|
||
tabindex="0"
|
||
:title="t('book.dockHint', { title: tab.title })"
|
||
@click="turnTo(tab.slug)"
|
||
@keydown.enter.prevent="turnTo(tab.slug)"
|
||
>
|
||
<button type="button" class="spine-close" :aria-label="t('common.closeTab')" @click.stop="closeTab(tab.slug)">
|
||
<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">{{ tab.title }}</span>
|
||
</div>
|
||
</nav>
|
||
</div>
|
||
</template>
|