mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user