feat(reader): add persistent sidebar pin

Keep reader sidebar navigation open for repeated jumps when users pin the panel. Use session-scoped state so each book file can remember the preference.

Closes #497
This commit is contained in:
neon
2026-07-02 01:57:22 +00:00
parent a0dad23087
commit 52bb16c8b8
5 changed files with 246 additions and 27 deletions
+13 -3
View File
@@ -16,6 +16,7 @@ import { useAnnotations } from './epub/composables/useAnnotations'
import { useToc } from './epub/composables/useToc'
import { useSearch, type FoliateView } from './epub/composables/useSearch'
import { useReaderSelection } from './epub/composables/useReaderSelection'
import { useReaderSidebarPin } from './epub/composables/useReaderSidebarPin'
import { useReaderKeyboardShortcuts } from './epub/composables/useReaderKeyboardShortcuts'
import ReaderHeader from './epub/components/ReaderHeader.vue'
import ReaderFooter from './epub/components/ReaderFooter.vue'
@@ -118,6 +119,7 @@ const search = useSearch()
const { results: searchResults, isSearching, search: doSearch, clear: clearSearch } = search
const selection = useReaderSelection()
const { isSidebarPinned, toggleSidebarPinned, shouldCloseAfterNavigation } = useReaderSidebarPin(fileId)
function closeAnyPanel() {
if (showSearch.value) {
@@ -523,6 +525,12 @@ function navigateSearch(cfiTarget: string) {
goTo(cfiTarget)
}
function closeSidebarAfterNavigation() {
if (shouldCloseAfterNavigation()) {
showSidebar.value = false
}
}
async function navigateFromSidebar(cfiTarget: string) {
const goToPromise = goTo(cfiTarget)
if (!goToPromise) return
@@ -530,7 +538,7 @@ async function navigateFromSidebar(cfiTarget: string) {
.then(() => true)
.catch(() => false)
if (!navigated) return
showSidebar.value = false
closeSidebarAfterNavigation()
}
function navigateAnnotationChapterFromSidebar(chapterIndex: number) {
@@ -538,14 +546,14 @@ function navigateAnnotationChapterFromSidebar(chapterIndex: number) {
if (!goToPromise) return
void Promise.resolve(goToPromise)
.then(() => {
showSidebar.value = false
closeSidebarAfterNavigation()
})
.catch(() => undefined)
}
function navigateChapterFromSidebar(href: string) {
goTo(href)
showSidebar.value = false
closeSidebarAfterNavigation()
}
function closeSearch() {
@@ -652,7 +660,9 @@ watch(
:locationMetaByCfi="sidebarLocationMetaByCfi"
:activeHref="activeHref"
:expandedHrefs="expandedHrefs"
:pinned="isSidebarPinned"
@close="showSidebar = false"
@togglePinned="toggleSidebarPinned"
@navigateChapter="navigateChapterFromSidebar"
@navigateBookmark="navigateFromSidebar"
@navigateAnnotation="navigateFromSidebar"
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { Bookmark, BookOpen, Highlighter, Trash2, TriangleAlert } from '@lucide/vue'
import { Bookmark, BookOpen, Highlighter, Pin, PinOff, Trash2, TriangleAlert, X } from '@lucide/vue'
import { ANNOTATION_COLOR_FILTER_OPTIONS } from '@bookorbit/types'
import type { TocItem } from '../composables/useToc'
import type { Bookmark as BookmarkType } from '../composables/useBookmarks'
@@ -16,6 +16,7 @@ const props = defineProps<{
locationMetaByCfi: Record<string, { chapterTitle: string | null; percentage: number | null }>
activeHref: string
expandedHrefs: Set<string>
pinned: boolean
}>()
const emit = defineEmits<{
@@ -27,9 +28,15 @@ const emit = defineEmits<{
deleteBookmark: [id: number]
deleteAnnotation: [id: number]
toggleExpand: [href: string]
togglePinned: []
}>()
type Tab = 'chapters' | 'bookmarks' | 'highlights'
const tabOptions = [
{ id: 'chapters', icon: BookOpen, label: 'TOC', fullLabel: 'Table of contents' },
{ id: 'bookmarks', icon: Bookmark, label: 'Marks', fullLabel: 'Bookmarks' },
{ id: 'highlights', icon: Highlighter, label: 'Notes', fullLabel: 'Highlights and notes' },
] as const
const activeTab = ref<Tab>('chapters')
const scrollContainer = ref<HTMLElement | null>(null)
type SortMode = 'location' | 'newest' | 'oldest'
@@ -88,6 +95,7 @@ const filteredAndSortedHighlights = computed(() => {
const activeBookmarkId = computed(() => findNearestCfi(filteredAndSortedBookmarks.value, props.currentCfi)?.id ?? null)
const activeAnnotationId = computed(() => findNearestCfi(filteredAndSortedHighlights.value, props.currentCfi)?.id ?? null)
const pinLabel = computed(() => (props.pinned ? 'Unpin sidebar' : 'Pin sidebar'))
const activeRowSelectorByTab: Record<Tab, string> = {
chapters: '[data-reader-active-row="chapter"]',
bookmarks: '[data-reader-active-row="bookmark"]',
@@ -171,6 +179,10 @@ function sortByMode<T extends { cfi: string | null | undefined; createdAt: strin
return sorted
}
function selectTab(tab: Tab) {
activeTab.value = tab
}
function navigateBookmark(cfi: string | null | undefined) {
if (!cfi) return
emit('navigateBookmark', cfi)
@@ -197,30 +209,71 @@ function deleteBookmark(id: number) {
function deleteAnnotation(id: number) {
emit('deleteAnnotation', id)
}
function closeSidebar() {
emit('close')
}
function togglePinned() {
emit('togglePinned')
}
</script>
<template>
<div class="fixed inset-0 z-50 flex">
<div class="fixed inset-0 z-50 flex" :class="pinned ? 'pointer-events-none' : ''">
<div
class="sidebar-panel w-[19rem] sm:w-[20rem] md:w-[21.5rem] lg:w-[22.5rem] h-full bg-card text-card-foreground flex flex-col shadow-2xl border-r border-border"
class="sidebar-panel pointer-events-auto w-[17.1rem] sm:w-[18rem] md:w-[19.35rem] lg:w-[20.25rem] h-full bg-card text-card-foreground flex flex-col shadow-2xl border-r border-border"
@click.stop
>
<div class="flex items-stretch border-b border-border shrink-0">
<button
v-for="tab in [
{ id: 'chapters', icon: BookOpen, label: 'Chapters' },
{ id: 'bookmarks', icon: Bookmark, label: 'Bookmarks' },
{ id: 'highlights', icon: Highlighter, label: 'Highlights' },
] as const"
:key="tab.id"
class="flex-1 flex items-center justify-center gap-1 py-2.5 text-[13px] relative transition-colors"
:class="activeTab === tab.id ? 'text-primary' : 'text-muted-foreground hover:text-foreground'"
@click="activeTab = tab.id"
>
<component :is="tab.icon" :size="16" />
{{ tab.label }}
<span v-if="activeTab === tab.id" class="absolute bottom-0 inset-x-0 h-0.5 bg-primary rounded-t-full" />
</button>
<div class="grid min-w-0 flex-1 grid-cols-3">
<Tooltip v-for="tab in tabOptions" :key="tab.id">
<TooltipTrigger as-child>
<button
type="button"
class="relative flex min-w-0 items-center justify-center gap-1 px-1 py-2.5 text-[13px] transition-colors"
:class="activeTab === tab.id ? 'text-primary' : 'text-muted-foreground hover:text-foreground'"
:aria-label="tab.fullLabel"
@click="selectTab(tab.id)"
>
<component :is="tab.icon" :size="16" class="shrink-0" />
<span class="truncate">{{ tab.label }}</span>
<span v-if="activeTab === tab.id" class="absolute bottom-0 inset-x-0 h-0.5 bg-primary rounded-t-full" />
</button>
</TooltipTrigger>
<TooltipContent>{{ tab.fullLabel }}</TooltipContent>
</Tooltip>
</div>
<div class="flex items-center gap-1 border-l border-border px-1.5">
<Tooltip>
<TooltipTrigger as-child>
<button
type="button"
class="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
:class="pinned ? 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary' : ''"
:aria-label="pinLabel"
:aria-pressed="pinned"
@click="togglePinned"
>
<component :is="pinned ? PinOff : Pin" :size="15" />
</button>
</TooltipTrigger>
<TooltipContent>{{ pinLabel }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
type="button"
class="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Close sidebar"
@click="closeSidebar"
>
<X :size="16" />
</button>
</TooltipTrigger>
<TooltipContent>Close sidebar</TooltipContent>
</Tooltip>
</div>
</div>
<div ref="scrollContainer" class="flex-1 overflow-y-auto">
@@ -379,7 +432,7 @@ function deleteAnnotation(id: number) {
</template>
</div>
</div>
<div class="flex-1" @click="emit('close')" />
<div v-if="!pinned" class="flex-1" data-reader-sidebar-backdrop @click="closeSidebar" />
</div>
</template>
@@ -36,6 +36,7 @@ describe('ReaderSidebar', () => {
locationMetaByCfi: {},
activeHref: '',
expandedHrefs: new Set<string>(),
pinned: false,
}
}
@@ -89,6 +90,41 @@ describe('ReaderSidebar', () => {
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'center', inline: 'nearest' })
})
it('shows pin state and emits togglePinned from the sidebar header', async () => {
const wrapper = mount(ReaderSidebar, {
props: makeBaseProps(),
global,
})
await wrapper.get('button[aria-label="Pin sidebar"]').trigger('click')
expect(wrapper.emitted('togglePinned')).toHaveLength(1)
await wrapper.setProps({ pinned: true })
const pinButton = wrapper.get('button[aria-label="Unpin sidebar"]')
expect(pinButton.attributes('aria-pressed')).toBe('true')
await pinButton.trigger('click')
expect(wrapper.emitted('togglePinned')).toHaveLength(2)
})
it('only closes from the backdrop while unpinned', async () => {
const wrapper = mount(ReaderSidebar, {
props: makeBaseProps(),
global,
})
await wrapper.find('[data-reader-sidebar-backdrop]').trigger('click')
expect(wrapper.emitted('close')).toHaveLength(1)
await wrapper.setProps({ pinned: true })
expect(wrapper.find('[data-reader-sidebar-backdrop]').exists()).toBe(false)
await wrapper.get('button[aria-label="Close sidebar"]').trigger('click')
expect(wrapper.emitted('close')).toHaveLength(2)
})
it('navigates and deletes bookmark/annotation entries from sidebar tabs', async () => {
const wrapper = mount(ReaderSidebar, {
props: {
@@ -155,7 +191,7 @@ describe('ReaderSidebar', () => {
const tabButtons = wrapper.findAll('button')
const bookmarksTab = tabButtons.find((btn) => btn.text().includes('Bookmarks'))
const bookmarksTab = tabButtons.find((btn) => btn.text().includes('Marks'))
await bookmarksTab?.trigger('click')
expect(wrapper.text()).toContain('Chapter 1 - 12%')
@@ -169,7 +205,7 @@ describe('ReaderSidebar', () => {
await bookmarkDelete?.trigger('click')
expect(wrapper.emitted('deleteBookmark')?.[0]).toEqual([1])
const highlightsTab = wrapper.findAll('button').find((btn) => btn.text().includes('Highlights'))
const highlightsTab = wrapper.findAll('button').find((btn) => btn.text().includes('Notes'))
await highlightsTab?.trigger('click')
expect(wrapper.text()).toContain('Chapter 3 - 45%')
@@ -228,7 +264,7 @@ describe('ReaderSidebar', () => {
global,
})
const highlightsTab = wrapper.findAll('button').find((btn) => btn.text().includes('Highlights'))
const highlightsTab = wrapper.findAll('button').find((btn) => btn.text().includes('Notes'))
await highlightsTab?.trigger('click')
const searchInput = wrapper.find('input[placeholder="Search highlights..."]')
@@ -273,7 +309,7 @@ describe('ReaderSidebar', () => {
global,
})
const bookmarksTab = wrapper.findAll('button').find((btn) => btn.text().includes('Bookmarks'))
const bookmarksTab = wrapper.findAll('button').find((btn) => btn.text().includes('Marks'))
await bookmarksTab?.trigger('click')
expect(wrapper.text()).toContain('Location unavailable')
@@ -319,7 +355,7 @@ describe('ReaderSidebar', () => {
global,
})
const highlightsTab = wrapper.findAll('button').find((btn) => btn.text().includes('Highlights'))
const highlightsTab = wrapper.findAll('button').find((btn) => btn.text().includes('Notes'))
await highlightsTab?.trigger('click')
expect(wrapper.text()).toContain('Custom (#ABC123)')
@@ -0,0 +1,58 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getReaderSidebarPinStorageKey, useReaderSidebarPin } from '../useReaderSidebarPin'
describe('useReaderSidebarPin', () => {
beforeEach(() => {
sessionStorage.clear()
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('defaults to unpinned and closes after navigation', () => {
const sidebarPin = useReaderSidebarPin(42)
expect(sidebarPin.isSidebarPinned.value).toBe(false)
expect(sidebarPin.shouldCloseAfterNavigation()).toBe(true)
})
it('persists pinned state in session storage per book file', () => {
const key = getReaderSidebarPinStorageKey(42)
const sidebarPin = useReaderSidebarPin(42)
sidebarPin.toggleSidebarPinned()
expect(sidebarPin.isSidebarPinned.value).toBe(true)
expect(sidebarPin.shouldCloseAfterNavigation()).toBe(false)
expect(sessionStorage.getItem(key)).toBe('true')
expect(useReaderSidebarPin(42).isSidebarPinned.value).toBe(true)
expect(useReaderSidebarPin(43).isSidebarPinned.value).toBe(false)
})
it('removes stored state when unpinned', () => {
const key = getReaderSidebarPinStorageKey(42)
sessionStorage.setItem(key, 'true')
const sidebarPin = useReaderSidebarPin(42)
sidebarPin.setSidebarPinned(false)
expect(sidebarPin.isSidebarPinned.value).toBe(false)
expect(sessionStorage.getItem(key)).toBeNull()
})
it('keeps working when session storage is unavailable', () => {
vi.stubGlobal('window', {
get sessionStorage() {
throw new Error('unavailable')
},
})
const sidebarPin = useReaderSidebarPin(42)
expect(sidebarPin.isSidebarPinned.value).toBe(false)
expect(() => sidebarPin.setSidebarPinned(true)).not.toThrow()
expect(sidebarPin.isSidebarPinned.value).toBe(true)
})
})
@@ -0,0 +1,62 @@
import { ref } from 'vue'
const SIDEBAR_PIN_KEY_PREFIX = 'reader:sidebar:pinned'
export function getReaderSidebarPinStorageKey(bookFileId: number): string {
return `${SIDEBAR_PIN_KEY_PREFIX}:${bookFileId}`
}
function getSessionStorage(): Storage | null {
try {
return typeof window === 'undefined' ? null : window.sessionStorage
} catch {
return null
}
}
function readStoredSidebarPinned(key: string): boolean {
try {
return getSessionStorage()?.getItem(key) === 'true'
} catch {
return false
}
}
function writeStoredSidebarPinned(key: string, pinned: boolean): void {
try {
const storage = getSessionStorage()
if (!storage) return
if (pinned) {
storage.setItem(key, 'true')
} else {
storage.removeItem(key)
}
} catch {
// Session storage can be unavailable in private windows.
}
}
export function useReaderSidebarPin(bookFileId: number) {
const storageKey = getReaderSidebarPinStorageKey(bookFileId)
const isSidebarPinned = ref(readStoredSidebarPinned(storageKey))
function setSidebarPinned(pinned: boolean): void {
isSidebarPinned.value = pinned
writeStoredSidebarPinned(storageKey, pinned)
}
function toggleSidebarPinned(): void {
setSidebarPinned(!isSidebarPinned.value)
}
function shouldCloseAfterNavigation(): boolean {
return !isSidebarPinned.value
}
return {
isSidebarPinned,
setSidebarPinned,
toggleSidebarPinned,
shouldCloseAfterNavigation,
}
}