More things
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user