More things

This commit is contained in:
2026-10-01 02:29:21 +02:00
parent e1df4d3ae1
commit 13b659662e
54 changed files with 4409 additions and 911 deletions

View File

@@ -4,8 +4,10 @@
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.
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
@@ -31,7 +33,7 @@ const { open: actionsMenuOpen } = useArticleActionsMenu()
const { collapsed } = useSidebar()
const activeTab = computed(() => tabAt(tabs.value, route.path))
const activeSlug = computed(() => activeTab.value?.slug ?? null)
const activeIdentity = computed(() => activeTab.value?.identity ?? null)
const visible = computed(() => isReaderPath(route.path) && tabs.value.length > 0)
const trackEl = ref<HTMLElement | null>(null)
@@ -44,8 +46,8 @@ function liesFlat() {
/** 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)
const identity = activeIdentity.value
return identity === null ? -1 : tabs.value.findIndex((t) => t.identity === identity)
})
/**
@@ -57,10 +59,10 @@ const openIndex = computed(() => {
async function alignToActive(behavior: ScrollBehavior = 'smooth') {
await nextTick()
const track = trackEl.value
const slug = activeSlug.value
if (!track || !slug || !liesFlat()) return
const identity = activeIdentity.value
if (!track || !identity || !liesFlat()) return
const page = track.querySelector<HTMLElement>(`[data-slug="${CSS.escape(slug)}"]`)
const page = track.querySelector<HTMLElement>(`[data-tab="${CSS.escape(identity)}"]`)
const i = Number(page?.dataset.index)
if (!page || !Number.isFinite(i)) return
@@ -77,10 +79,10 @@ async function alignToActive(behavior: ScrollBehavior = 'smooth') {
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[]>([])
/** 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) => waitingSlugs.value.includes(t.slug)))
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
@@ -91,22 +93,24 @@ function measureDock() {
const track = trackEl.value
const open = openIndex.value
if (!track || open === -1 || !liesFlat()) {
waitingSlugs.value = []
waitingPages.value = []
return
}
const edge = track.getBoundingClientRect().right
const spine = parseFloat(getComputedStyle(track).getPropertyValue('--spine-w')) || 0
const held = new Set(waitingSlugs.value)
const held = new Set(waitingPages.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 identity = page.dataset.tab
if (!identity || !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)
if (left > edge - 6) held.add(identity)
else if (left < edge - spine - 10) held.delete(identity)
}
waitingSlugs.value = tabs.value.filter((t, i) => i > open && held.has(t.slug)).map((t) => t.slug)
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 */
@@ -121,7 +125,7 @@ function watchDock() {
onBeforeUnmount(() => cancelAnimationFrame(measuring))
/* turning to an article slides the book round to it */
watch(activeSlug, () => void alignToActive('smooth'))
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))
@@ -162,18 +166,21 @@ watch(
{ 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))
function turnTo(identity: string) {
const tab = tabs.value.find((t) => t.identity === identity)
if (tab && identity !== activeIdentity.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
/* 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(slug)}`, { method: 'DELETE' })
forget(slug)
closeTab(slug)
await $fetch(`${api}/articles/slug/${encodeURIComponent(tab.slug)}`, { method: 'DELETE' })
forget(tab.slug)
closeTab(identity)
} catch {
window.alert(t('book.deleteFailed'))
}
@@ -185,7 +192,7 @@ 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
const current = tabAt(tabs.value, route.path)?.identity
if (current) closeTab(current)
}
onMounted(() => window.addEventListener('keydown', onGlobalKeydown))
@@ -202,18 +209,18 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
>
<div
v-for="tab in tabs"
:key="tab.slug"
:key="tab.identity"
class="book-chip"
:class="{ active: tab.slug === activeSlug }"
:class="{ active: tab.identity === activeIdentity }"
role="tab"
tabindex="0"
:aria-selected="tab.slug === activeSlug"
:aria-selected="tab.identity === activeIdentity"
:title="t('book.chipHint', { title: tab.title })"
@click="turnTo(tab.slug)"
@keydown.enter.prevent="turnTo(tab.slug)"
@click="turnTo(tab.identity)"
@keydown.enter.prevent="turnTo(tab.identity)"
>
<span class="chip-title">{{ tab.title }}</span>
<button type="button" class="chip-close" :aria-label="t('common.closeTab')" @click.stop="closeTab(tab.slug)">
<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>
@@ -224,28 +231,29 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
<div ref="trackEl" class="book-track" @scroll.passive="watchDock">
<ArticlePane
v-for="(tab, i) in tabs"
:key="tab.slug"
:key="tab.identity"
:identity="tab.identity"
:slug="tab.slug"
:index="i"
:active="tab.slug === activeSlug"
@activate="turnTo(tab.slug)"
@close="closeTab(tab.slug)"
@remove="removeArticle(tab.slug)"
:active="tab.identity === activeIdentity"
@activate="turnTo(tab.identity)"
@close="closeTab(tab.identity)"
@remove="removeArticle(tab.identity)"
/>
</div>
<nav v-if="waiting.length" class="book-dock" :aria-label="t('book.dockAria')">
<div
v-for="tab in waiting"
:key="tab.slug"
:key="tab.identity"
class="dock-spine"
role="button"
tabindex="0"
:title="t('book.dockHint', { title: tab.title })"
@click="turnTo(tab.slug)"
@keydown.enter.prevent="turnTo(tab.slug)"
@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.slug)">
<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>