feat(reader): improve PDF sidebar navigation

Keep the active PDF outline location visible while providing responsive navigation layouts and focused panels across screen sizes.

Closes #1235
This commit is contained in:
neon
2026-09-01 18:34:05 +00:00
parent 04ec495c25
commit eb60744494
17 changed files with 1964 additions and 663 deletions
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import { MatchFlag, type SearchResult } from '@embedpdf/models'
import type { AnnotationItem } from '@bookorbit/types'
@@ -25,16 +26,27 @@ const mocks = vi.hoisted(() => ({
scrollScope: {
scrollToPage: vi.fn<(options: Record<string, unknown>) => void>(),
},
scrollState: {
__v_isRef: true,
value: { currentPage: 1, totalPages: 10 },
},
bookmarkCapability: {
__v_isRef: true,
value: null as unknown,
},
documentState: {
value: null as unknown,
},
}))
vi.mock('@embedpdf/plugin-search/vue', async () => {
const { defineComponent, h } = await import('vue')
const { defineComponent: define, h: create } = await import('vue')
return {
SearchLayer: defineComponent({
SearchLayer: define({
setup:
(_, { slots }) =>
() =>
h('div', slots.default?.()),
create('div', slots.default?.()),
}),
useSearch: () => ({ state: mocks.searchState, provides: { value: mocks.searchScope } }),
}
@@ -42,52 +54,88 @@ vi.mock('@embedpdf/plugin-search/vue', async () => {
vi.mock('@embedpdf/plugin-scroll/vue', () => ({
useScroll: () => ({
state: { __v_isRef: true, value: { currentPage: 1, totalPages: 10 } },
state: mocks.scrollState,
provides: { value: mocks.scrollScope },
}),
}))
vi.mock('@embedpdf/plugin-bookmark/vue', () => ({
useBookmarkCapability: () => ({ provides: { value: null } }),
useBookmarkCapability: () => ({ provides: mocks.bookmarkCapability }),
}))
vi.mock('@embedpdf/plugin-annotation/vue', () => ({
useAnnotationCapability: () => ({ provides: { value: null } }),
}))
vi.mock('@embedpdf/core/vue', () => ({
useDocumentState: () => mocks.documentState,
}))
vi.mock('@embedpdf/plugin-thumbnail/vue', async () => {
const { defineComponent, h } = await import('vue')
const { defineComponent: define, h: create } = await import('vue')
return {
ThumbImg: defineComponent({ setup: () => () => h('img') }),
ThumbnailsPane: defineComponent({
setup:
(_, { slots }) =>
() =>
h('div', slots.default?.()),
}),
ThumbImg: define({ setup: () => () => create('img') }),
}
})
vi.mock('vue-virtual-scroller', async () => {
const { defineComponent, h } = await import('vue')
return {
RecycleScroller: defineComponent({
const { defineComponent: define, h: create } = await import('vue')
const listStub = (fallbackTestId: string) =>
define({
inheritAttrs: false,
props: { items: { type: Array, required: true } },
setup(props, { attrs, slots }) {
return () =>
h(
create(
'div',
{ ...attrs, 'data-testid': 'search-results' },
props.items.map((item, index) => slots.default?.({ item, index })),
{ ...attrs, 'data-testid': attrs['data-testid'] ?? fallbackTestId },
props.items.map((item, index) => slots.default?.({ item, index, active: true })),
)
},
})
return {
RecycleScroller: listStub('recycle-scroller'),
DynamicScroller: listStub('dynamic-scroller'),
DynamicScrollerItem: define({
setup:
(_, { slots }) =>
() =>
create('div', slots.default?.()),
}),
}
})
import PdfReaderSidebar from '../components/PdfReaderSidebar.vue'
const passthrough = defineComponent({
setup:
(_, { slots }) =>
() =>
h('div', slots.default?.()),
})
const sheetStub = defineComponent({
props: { open: { type: Boolean, default: false } },
setup:
(props, { slots }) =>
() =>
props.open ? h('div', { 'data-testid': 'bottom-sheet' }, slots.default?.()) : null,
})
function mountSidebar(props: Record<string, unknown> = {}) {
return mount(PdfReaderSidebar, {
props: { documentId: 'doc-1', activeTab: 'search', annotations: [], ...props },
global: {
stubs: {
Tooltip: passthrough,
TooltipTrigger: passthrough,
TooltipContent: passthrough,
ReaderBottomSheet: sheetStub,
},
},
})
}
function makeResult(rects: SearchResult['rects'] = []): SearchResult {
return {
pageIndex: 4,
@@ -104,7 +152,7 @@ function makeResult(rects: SearchResult['rects'] = []): SearchResult {
}
}
function makeAnnotation(id: number): AnnotationItem {
function makeAnnotation(id: number, overrides: Partial<AnnotationItem> = {}): AnnotationItem {
return {
id,
bookId: 9,
@@ -121,9 +169,24 @@ function makeAnnotation(id: number): AnnotationItem {
chapterIndex: null,
createdAt: '2026-01-01T00:00:00.000Z',
pdf: { page: 2, rect: { x: 10, y: 20, width: 30, height: 8 }, rects: [{ x: 10, y: 20, width: 30, height: 8 }] },
...overrides,
}
}
function outlineCapability(bookmarks: unknown[]) {
return {
forDocument: () => ({
getBookmarks: () => ({
wait: (resolve: (value: { bookmarks: unknown[] }) => void) => resolve({ bookmarks }),
}),
}),
}
}
function destination(pageIndex: number) {
return { type: 'destination', destination: { pageIndex, zoom: { mode: 2 }, view: [] } }
}
describe('PdfReaderSidebar', () => {
beforeEach(() => {
vi.useFakeTimers()
@@ -138,10 +201,13 @@ describe('PdfReaderSidebar', () => {
loading: false,
active: false,
}
mocks.scrollState.value = { currentPage: 1, totalPages: 10 }
mocks.bookmarkCapability.value = null
mocks.documentState.value = null
})
it('debounces full-document searches and cancels superseded work', async () => {
const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true, annotations: [] } })
const wrapper = mountSidebar()
const input = wrapper.get('input[type="search"]')
mocks.searchScope.stopSearch.mockClear()
@@ -160,7 +226,7 @@ describe('PdfReaderSidebar', () => {
})
it('does not search for a single character', async () => {
const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true, annotations: [] } })
const wrapper = mountSidebar()
mocks.searchScope.stopSearch.mockClear()
await wrapper.get('input[type="search"]').setValue('a')
@@ -172,9 +238,9 @@ describe('PdfReaderSidebar', () => {
})
it('lets the plugin restart an active search once when flags change', async () => {
const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true, annotations: [] } })
const wrapper = mountSidebar()
await wrapper.get('input[type="checkbox"]').setValue(true)
await wrapper.get('button[aria-pressed="false"]').trigger('click')
expect(mocks.searchScope.setFlags).toHaveBeenCalledWith([MatchFlag.MatchCase])
expect(mocks.searchScope.searchAllPages).not.toHaveBeenCalled()
@@ -187,7 +253,7 @@ describe('PdfReaderSidebar', () => {
total: 1,
active: true,
}
const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true, annotations: [] } })
const wrapper = mountSidebar()
expect(wrapper.get('aside').classes()).toContain('overflow-hidden')
expect(wrapper.get('[data-testid="search-results"]').classes()).toContain('overflow-x-hidden')
@@ -198,6 +264,20 @@ describe('PdfReaderSidebar', () => {
expect(mocks.scrollScope.scrollToPage).toHaveBeenCalledWith({ pageNumber: 5 })
})
it('labels a run of results with its page only once', () => {
mocks.searchState.value = {
...mocks.searchState.value,
results: [makeResult(), makeResult()],
total: 2,
active: true,
}
const wrapper = mountSidebar()
const headings = wrapper.findAll('[data-testid="search-results"] p')
expect(headings).toHaveLength(1)
expect(headings[0]!.text()).toBe('Page 5')
})
it('reports full-document search progress', async () => {
mocks.searchState.value = {
...mocks.searchState.value,
@@ -208,7 +288,7 @@ describe('PdfReaderSidebar', () => {
onProgress: (callback: (progress: { page: number }) => void) => callback({ page: 4 }),
})
const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true, annotations: [] } })
const wrapper = mountSidebar()
await vi.advanceTimersByTimeAsync(250)
expect(wrapper.text()).toContain('Searching page 5 of 10')
@@ -222,7 +302,7 @@ describe('PdfReaderSidebar', () => {
activeResultIndex: 0,
active: true,
}
const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true, annotations: [] } })
const wrapper = mountSidebar()
const input = wrapper.get('input[type="search"]')
await input.trigger('keydown', { key: 'Enter' })
@@ -233,11 +313,8 @@ describe('PdfReaderSidebar', () => {
expect(mocks.searchScope.goToResult).toHaveBeenLastCalledWith(0)
})
it('clears the query, cancels search work, and restores input focus', async () => {
const wrapper = mount(PdfReaderSidebar, {
props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true, annotations: [] },
attachTo: document.body,
})
it('clears the query and cancels search work', async () => {
const wrapper = mountSidebar()
const input = wrapper.get('input[type="search"]')
await input.setValue('distance')
mocks.searchScope.stopSearch.mockClear()
@@ -246,14 +323,11 @@ describe('PdfReaderSidebar', () => {
expect((input.element as HTMLInputElement).value).toBe('')
expect(mocks.searchScope.stopSearch).toHaveBeenCalled()
expect(document.activeElement).toBe(input.element)
wrapper.unmount()
})
it('shows an accessible retry state instead of an empty highlight list after load failure', async () => {
const wrapper = mount(PdfReaderSidebar, {
props: { documentId: 'doc-1', activeTab: 'highlights', headerVisible: true, annotations: [], loadError: true },
})
const wrapper = mountSidebar({ activeTab: 'highlights', loadError: true })
expect(wrapper.get('[role="alert"]').text()).toContain('Could not load highlights')
expect(wrapper.text()).not.toContain('No highlights yet')
@@ -261,41 +335,60 @@ describe('PdfReaderSidebar', () => {
expect(wrapper.emitted('retryHighlights')).toHaveLength(1)
})
it('virtualizes highlight rows and keeps delete available without hover', async () => {
const wrapper = mount(PdfReaderSidebar, {
props: { documentId: 'doc-1', activeTab: 'highlights', headerVisible: true, annotations: [makeAnnotation(1)] },
})
it('virtualizes highlight rows and keeps delete available without hover', () => {
const wrapper = mountSidebar({ activeTab: 'highlights', annotations: [makeAnnotation(1)] })
const deleteButton = wrapper.get('button[aria-label="Delete highlight"]')
expect(deleteButton.classes()).toContain('opacity-100')
expect(wrapper.find('[data-testid="search-results"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="pdf-notes-list"]').exists()).toBe(true)
})
it('exposes tabs as a labelled tablist wired to the visible panel', () => {
const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true, annotations: [] } })
it('sizes highlight rows to their content instead of a fixed row height', () => {
const wrapper = mountSidebar({ activeTab: 'highlights', annotations: [makeAnnotation(1)] })
expect(wrapper.get('[data-testid="pdf-notes-list"]').attributes('min-item-size')).toBe('72')
expect(wrapper.find('[class*="h-[120px]"]').exists()).toBe(false)
})
it('filters highlights down to the ones carrying a note', async () => {
const wrapper = mountSidebar({
activeTab: 'highlights',
annotations: [makeAnnotation(1), makeAnnotation(2, { color: '#4ADE80', note: 'keep this' })],
})
expect(wrapper.text()).toContain('Selection 1')
await wrapper.get('button[aria-pressed="false"][class*="rounded-full"]:last-of-type').trigger('click')
expect(wrapper.text()).toContain('Selection 2')
expect(wrapper.text()).not.toContain('Selection 1')
})
it('exposes rail destinations as a labelled tablist wired to the visible panel', () => {
const wrapper = mountSidebar()
const tabs = wrapper.findAll('[role="tab"]')
expect(tabs).toHaveLength(4)
expect(tabs.map((tab) => tab.text())).toEqual(['Pages', 'Contents', 'Search', 'Notes'])
expect(tabs.map((tab) => tab.attributes('aria-label'))).toEqual(['Pages', 'Contents', 'Search', 'Notes'])
const active = tabs[2]
const active = tabs[2]!
expect(active.attributes('aria-selected')).toBe('true')
expect(active.attributes('tabindex')).toBe('0')
expect(tabs[0].attributes('tabindex')).toBe('-1')
expect(tabs[0]!.attributes('tabindex')).toBe('-1')
const panel = wrapper.get('[role="tabpanel"]')
expect(panel.attributes('id')).toBe(active.attributes('aria-controls'))
expect(panel.attributes('aria-labelledby')).toBe(active.attributes('id'))
})
it('moves between tabs with arrow keys', async () => {
const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true, annotations: [] } })
it('moves between rail destinations with arrow keys', async () => {
const wrapper = mountSidebar()
const tabList = wrapper.get('[role="tablist"]')
await tabList.trigger('keydown', { key: 'ArrowRight' })
await tabList.trigger('keydown', { key: 'ArrowDown' })
expect(wrapper.emitted('update:activeTab')?.at(-1)).toEqual(['highlights'])
await tabList.trigger('keydown', { key: 'ArrowLeft' })
await tabList.trigger('keydown', { key: 'ArrowUp' })
expect(wrapper.emitted('update:activeTab')?.at(-1)).toEqual(['contents'])
await tabList.trigger('keydown', { key: 'Home' })
@@ -308,8 +401,110 @@ describe('PdfReaderSidebar', () => {
expect(wrapper.emitted('update:activeTab')).toHaveLength(4)
})
it('prompts for a query instead of showing an empty search list', async () => {
const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true, annotations: [] } })
it('keeps the rail available while the panel is closed', () => {
const wrapper = mountSidebar({ open: false })
expect(wrapper.findAll('[role="tab"]')).toHaveLength(4)
expect(wrapper.find('aside').exists()).toBe(false)
expect(wrapper.get('[role="tab"][aria-label="Search"]').attributes('aria-selected')).toBe('false')
})
it('shows a highlight count badge on the notes destination', () => {
const wrapper = mountSidebar({ annotations: [makeAnnotation(1), makeAnnotation(2)] })
expect(wrapper.get('[role="tab"][aria-label="Notes"]').text()).toBe('2')
})
it('swaps the rail for a bottom sheet on phones', () => {
const wrapper = mountSidebar({ layout: 'sheet', activeTab: 'highlights' })
expect(wrapper.find('aside').exists()).toBe(false)
expect(wrapper.find('[data-testid="bottom-sheet"]').exists()).toBe(true)
expect(wrapper.findAll('[role="tab"]').map((tab) => tab.text())).toEqual(['Pages', 'Contents', 'Search', 'Notes'])
})
it('resizes the docked panel with the keyboard within its bounds', async () => {
const wrapper = mountSidebar({ width: 300 })
const grip = wrapper.get('[role="separator"]')
await grip.trigger('keydown', { key: 'ArrowRight' })
expect(wrapper.emitted('update:width')?.at(-1)).toEqual([316])
await grip.trigger('keydown', { key: 'ArrowLeft', shiftKey: true })
expect(wrapper.emitted('update:width')?.at(-1)).toEqual([260])
})
it('highlights and reveals the outline entry containing the current page', async () => {
const scrollIntoView = vi.fn<() => void>()
Object.defineProperty(Element.prototype, 'scrollIntoView', { configurable: true, value: scrollIntoView })
mocks.scrollState.value = { currentPage: 74, totalPages: 100 }
mocks.bookmarkCapability.value = outlineCapability([
{ title: 'Chapter five', target: destination(50) },
{
title: 'Chapter six',
target: destination(73),
children: [{ title: 'Aggregates', target: destination(78) }],
},
])
const wrapper = mountSidebar({ activeTab: 'contents' })
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
const active = wrapper.get('[data-pdf-active-bookmark]')
expect(active.text()).toContain('Chapter six')
expect(active.attributes('aria-current')).toBe('location')
expect(active.classes()).toContain('bg-primary/10')
const inactive = wrapper.findAll('button').find((button) => button.text().includes('Chapter five'))
expect(inactive?.classes()).toContain('text-foreground')
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'center', inline: 'nearest' })
})
it('collapses an outline branch and hides its descendants', async () => {
mocks.bookmarkCapability.value = outlineCapability([
{
title: 'Chapter one',
target: destination(4),
children: [{ title: 'Nested topic', target: destination(6) }],
},
])
const wrapper = mountSidebar({ activeTab: 'contents' })
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Nested topic')
await wrapper.get('button[aria-label="Collapse Chapter one"]').trigger('click')
expect(wrapper.text()).not.toContain('Nested topic')
})
it('filters the outline and shows page numbers', async () => {
mocks.bookmarkCapability.value = outlineCapability([
{ title: 'Installing Docker', target: destination(19) },
{ title: 'Quick Recap', target: destination(27) },
])
const wrapper = mountSidebar({ activeTab: 'contents' })
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('20')
await wrapper.get('input[aria-label="Filter outline"]').setValue('recap')
expect(wrapper.text()).toContain('Quick Recap')
expect(wrapper.text()).not.toContain('Installing Docker')
})
it('offers a jump-to-page field on the pages destination', () => {
const wrapper = mountSidebar({ activeTab: 'thumbnails' })
expect(wrapper.find('input[aria-label="Jump to page"]').exists()).toBe(true)
})
it('prompts for a query instead of showing an empty search list', () => {
const wrapper = mountSidebar()
expect(wrapper.text()).toContain('Type a term to search this PDF.')
expect(wrapper.find('[data-testid="search-results"]').exists()).toBe(false)
@@ -18,8 +18,7 @@ function mountToolbar(overrides: Partial<InstanceType<typeof PdfReaderToolbar>['
totalPages: 240,
zoomPercent: 100,
sidebarOpen: false,
searchOpen: false,
highlightsOpen: false,
showSidebarToggle: true,
settingsOpen: false,
panActive: true,
fullscreen: false,
@@ -55,22 +54,36 @@ describe('PdfReaderToolbar', () => {
expect((input.element as HTMLInputElement).value).toBe('240')
})
it('keeps search available outside fullscreen and exposes responsive tools', async () => {
it('exposes responsive tools without duplicating sidebar destinations', async () => {
const wrapper = mountToolbar()
expect(wrapper.findAll('button[aria-label="Search document"]')).toHaveLength(1)
expect(wrapper.find('button[aria-label="More PDF tools"]').exists()).toBe(true)
// Search and Highlights are rail/sheet destinations now, so the toolbar must not offer them.
expect(wrapper.find('button[aria-label="Search document"]').exists()).toBe(false)
expect(wrapper.find('button[aria-label="Highlights"]').exists()).toBe(false)
const buttons = wrapper.findAll('button')
await buttons.find((button) => button.text().trim() === 'Search')!.trigger('click')
expect(buttons.some((button) => button.text().trim() === 'Search')).toBe(false)
await buttons.find((button) => button.text().trim() === 'Select text')!.trigger('click')
await buttons.find((button) => button.text().trim() === 'Pan')!.trigger('click')
expect(wrapper.emitted('toggleSearch')).toHaveLength(1)
expect(wrapper.emitted('selectTool')).toHaveLength(1)
expect(wrapper.emitted('togglePan')).toHaveLength(1)
})
it('only offers the navigation toggle where there is no rail', async () => {
const withRail = mountToolbar({ showSidebarToggle: false })
expect(withRail.find('button[aria-label="Document navigation"]').exists()).toBe(false)
const sheet = mountToolbar({ showSidebarToggle: true })
const toggle = sheet.get('button[aria-label="Document navigation"]')
expect(toggle.attributes('aria-expanded')).toBe('false')
await toggle.trigger('click')
expect(sheet.emitted('toggleSidebar')).toHaveLength(1)
})
it('shows the header pin only while fullscreen', async () => {
const windowed = mountToolbar()
expect(windowed.find('button[aria-label="Pin reader header"]').exists()).toBe(false)
@@ -1,10 +1,20 @@
import { describe, expect, it } from 'vitest'
import { Rotation } from '@embedpdf/models'
import { PdfActionType, PdfZoomMode, Rotation, type PdfDestinationObject } from '@embedpdf/models'
import { ScrollStrategy } from '@embedpdf/plugin-scroll'
import { SpreadMode } from '@embedpdf/plugin-spread'
import { ZoomMode } from '@embedpdf/plugin-zoom'
import type { PdfReaderSettings } from '@bookorbit/types'
import { flattenPdfBookmarks, fromRotation, safeExternalPdfUrl, toRotation, toScrollStrategy, toSpreadMode, toZoomLevel } from '../pdf-viewer-utils'
import {
findActivePdfBookmarkIndex,
flattenPdfBookmarks,
fromRotation,
getPdfBookmarkPageIndex,
safeExternalPdfUrl,
toRotation,
toScrollStrategy,
toSpreadMode,
toZoomLevel,
} from '../pdf-viewer-utils'
const settings: PdfReaderSettings = {
scrollMode: 'vertical',
@@ -14,6 +24,10 @@ const settings: PdfReaderSettings = {
rotation: 0,
}
function destination(pageIndex: number): PdfDestinationObject {
return { pageIndex, zoom: { mode: PdfZoomMode.FitPage }, view: [] }
}
describe('PDF viewer settings mapping', () => {
it('maps page mode to the vertical strategy used by paginated navigation', () => {
expect(toScrollStrategy('page')).toBe(ScrollStrategy.Vertical)
@@ -59,6 +73,37 @@ describe('flattenPdfBookmarks', () => {
['Section', 2],
])
})
it('resolves direct and local goto destinations to page indexes', () => {
expect(getPdfBookmarkPageIndex({ title: 'Direct', target: { type: 'destination', destination: destination(4) } })).toBe(4)
expect(
getPdfBookmarkPageIndex({
title: 'Action',
target: { type: 'action', action: { type: PdfActionType.Goto, destination: destination(7) } },
}),
).toBe(7)
expect(
getPdfBookmarkPageIndex({ title: 'Website', target: { type: 'action', action: { type: PdfActionType.URI, uri: 'https://example.com' } } }),
).toBeNull()
})
it('selects the nearest outline destination at or before the current page', () => {
const entries = flattenPdfBookmarks([
{ title: 'Chapter five', target: { type: 'destination', destination: destination(50) } },
{
title: 'Chapter six',
target: { type: 'destination', destination: destination(73) },
children: [
{ title: 'Aggregates', target: { type: 'destination', destination: destination(78) } },
{ title: 'Factories', target: { type: 'destination', destination: destination(82) } },
],
},
])
expect(entries[findActivePdfBookmarkIndex(entries, 74) ?? -1]?.bookmark.title).toBe('Chapter six')
expect(entries[findActivePdfBookmarkIndex(entries, 80) ?? -1]?.bookmark.title).toBe('Aggregates')
expect(findActivePdfBookmarkIndex(entries, 1)).toBeNull()
})
})
describe('safeExternalPdfUrl', () => {
@@ -0,0 +1,102 @@
<script setup lang="ts">
import { nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { BookOpen, FileText, Highlighter, Search } from '@lucide/vue'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { PDF_SIDEBAR_TABS, type PdfSidebarTab } from '../composables/usePdfSidebarLayout'
const props = defineProps<{
activeTab: PdfSidebarTab
open: boolean
highlightCount: number
tabIds: Record<PdfSidebarTab, string>
panelIds: Record<PdfSidebarTab, string>
}>()
const emit = defineEmits<{
select: [tab: PdfSidebarTab]
}>()
const { t } = useI18n()
const railEl = ref<HTMLElement | null>(null)
const TAB_ICONS = {
thumbnails: FileText,
contents: BookOpen,
search: Search,
highlights: Highlighter,
} as const
const TAB_LABEL_KEYS = {
thumbnails: 'reader.pdf.sidebar.pages',
contents: 'reader.pdf.sidebar.contents',
search: 'reader.pdf.sidebar.search',
highlights: 'reader.sidebar.tabs.notesShort',
} as const
function label(tab: PdfSidebarTab) {
return t(TAB_LABEL_KEYS[tab])
}
function handleSelect(tab: PdfSidebarTab) {
emit('select', tab)
}
function handleKeydown(event: KeyboardEvent) {
const current = PDF_SIDEBAR_TABS.indexOf(props.activeTab)
let next: number
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') next = (current + 1) % PDF_SIDEBAR_TABS.length
else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') next = (current - 1 + PDF_SIDEBAR_TABS.length) % PDF_SIDEBAR_TABS.length
else if (event.key === 'Home') next = 0
else if (event.key === 'End') next = PDF_SIDEBAR_TABS.length - 1
else return
event.preventDefault()
const nextTab = PDF_SIDEBAR_TABS[next]
if (!nextTab) return
emit('select', nextTab)
void nextTick(() => railEl.value?.querySelectorAll<HTMLElement>('[role="tab"]')[next]?.focus())
}
</script>
<template>
<div
ref="railEl"
role="tablist"
aria-orientation="vertical"
:aria-label="t('reader.pdf.sidebar.tabsLabel')"
class="flex h-full w-14 shrink-0 flex-col items-center gap-1 border-e border-border bg-card py-2"
@keydown="handleKeydown"
>
<Tooltip v-for="tab in PDF_SIDEBAR_TABS" :key="tab">
<TooltipTrigger as-child>
<button
:id="props.tabIds[tab]"
type="button"
role="tab"
:aria-selected="props.open && props.activeTab === tab"
:aria-controls="props.open ? props.panelIds[tab] : undefined"
:tabindex="props.activeTab === tab ? 0 : -1"
:aria-label="label(tab)"
class="relative flex size-10 items-center justify-center rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
:class="
props.open && props.activeTab === tab ? 'bg-primary/15 text-primary' : 'text-foreground hover:bg-muted focus-visible:text-foreground'
"
@click="handleSelect(tab)"
>
<span v-if="props.open && props.activeTab === tab" aria-hidden="true" class="absolute inset-y-2 -start-2 w-0.5 rounded-e-full bg-primary" />
<component :is="TAB_ICONS[tab]" :size="19" aria-hidden="true" />
<span
v-if="tab === 'highlights' && props.highlightCount > 0"
aria-hidden="true"
class="absolute end-0.5 top-0.5 min-w-4 rounded-full bg-primary px-1 text-[9px] font-bold leading-4 text-primary-foreground"
>
{{ props.highlightCount > 99 ? '99+' : props.highlightCount }}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="right">{{ label(tab) }}</TooltipContent>
</Tooltip>
</div>
</template>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { StickyNote, Trash2 } from '@lucide/vue'
import type { AnnotationItem } from '@bookorbit/types'
const props = defineProps<{
annotation: AnnotationItem
}>()
const emit = defineEmits<{
navigate: [annotation: AnnotationItem]
delete: [id: number]
}>()
const { t } = useI18n()
const pageLabel = computed(() => (props.annotation.pdf ? props.annotation.pdf.page + 1 : props.annotation.pageno))
function handleNavigate() {
emit('navigate', props.annotation)
}
function handleDelete() {
emit('delete', props.annotation.id)
}
</script>
<template>
<div class="group relative mb-1.5 overflow-hidden rounded-lg border border-border bg-background transition-colors hover:border-muted-foreground/50">
<span aria-hidden="true" class="absolute inset-y-2 start-0 w-[3px] rounded-e-full" :style="{ background: props.annotation.color }" />
<button
type="button"
class="block w-full min-w-0 px-3 py-2 text-start focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
@click="handleNavigate"
>
<span class="line-clamp-3 break-words text-xs leading-relaxed text-foreground">{{ props.annotation.text }}</span>
<span
v-if="props.annotation.note"
class="mt-1.5 flex items-start gap-1.5 border-t border-dashed border-border pt-1.5 text-[11px] text-muted-foreground"
>
<StickyNote :size="12" class="mt-0.5 shrink-0" aria-hidden="true" />
<span class="line-clamp-2 break-words italic">{{ props.annotation.note }}</span>
</span>
</button>
<div class="flex items-center gap-2 px-3 pb-2">
<span
v-if="pageLabel != null"
class="rounded-full border border-border px-2 py-px text-[10px] font-semibold tabular-nums text-muted-foreground"
>
{{ t('reader.pdf.sidebar.page', { page: pageLabel }) }}
</span>
<span class="flex-1" />
<button
type="button"
class="flex size-6 items-center justify-center rounded text-muted-foreground opacity-100 transition-opacity hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary [@media(pointer:fine)]:opacity-0 [@media(pointer:fine)]:focus-visible:opacity-100 [@media(pointer:fine)]:group-hover:opacity-100"
:aria-label="t('reader.sidebar.deleteHighlight')"
@click="handleDelete"
>
<Trash2 :size="14" />
</button>
</div>
</div>
</template>
@@ -0,0 +1,202 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { LoaderCircle, Search, StickyNote } from '@lucide/vue'
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'
import { ANNOTATION_HIGHLIGHT_COLORS, type AnnotationItem } from '@bookorbit/types'
import PdfNoteCard from './PdfNoteCard.vue'
const props = defineProps<{
annotations: AnnotationItem[]
loadError?: boolean
loading?: boolean
loadingMore?: boolean
hasMore?: boolean
}>()
const emit = defineEmits<{
navigate: [annotation: AnnotationItem]
delete: [id: number]
retry: []
loadMore: []
}>()
const { t } = useI18n()
const query = ref('')
const colorFilter = ref('all')
const notesOnly = ref(false)
const HIGHLIGHT_COLOR_NAMES: Record<string, string> = Object.fromEntries(
ANNOTATION_HIGHLIGHT_COLORS.map((color) => [color.hex.toUpperCase(), color.name]),
)
function getHighlightColorLabel(color: string): string {
const name = HIGHLIGHT_COLOR_NAMES[color.trim().toUpperCase()]
return name ? t(`annotations.colors.${name}`) : t('reader.sidebar.customColor', { color })
}
const colorOptions = computed(() => {
const counts = new Map<string, number>()
for (const annotation of props.annotations) counts.set(annotation.color, (counts.get(annotation.color) ?? 0) + 1)
return [...counts.entries()]
.map(([hex, count]) => ({ hex, count, label: getHighlightColorLabel(hex) }))
.sort((a, b) => a.label.localeCompare(b.label))
})
const notedCount = computed(() => props.annotations.filter((annotation) => annotation.note?.trim()).length)
const filtered = computed(() => {
const search = query.value.trim().toLowerCase()
return props.annotations
.filter((annotation) => {
if (colorFilter.value !== 'all' && annotation.color !== colorFilter.value) return false
if (notesOnly.value && !annotation.note?.trim()) return false
if (!search) return true
return `${annotation.text} ${annotation.note ?? ''}`.toLowerCase().includes(search)
})
.sort((a, b) => {
const pageA = a.pdf?.page ?? a.pageno ?? 0
const pageB = b.pdf?.page ?? b.pageno ?? 0
if (pageA !== pageB) return pageA - pageB
return (a.pdf?.rect.y ?? 0) - (b.pdf?.rect.y ?? 0)
})
})
function selectAllColors() {
colorFilter.value = 'all'
}
function selectColor(hex: string) {
colorFilter.value = colorFilter.value === hex ? 'all' : hex
}
function toggleNotesOnly() {
notesOnly.value = !notesOnly.value
}
function handleNavigate(annotation: AnnotationItem) {
emit('navigate', annotation)
}
function handleDelete(id: number) {
emit('delete', id)
}
function handleRetry() {
emit('retry')
}
function handleLoadMore() {
emit('loadMore')
}
</script>
<template>
<div class="flex min-h-0 flex-1 flex-col">
<div v-if="props.annotations.length > 0" class="shrink-0 space-y-2 border-b border-border p-3">
<div class="relative">
<Search :size="15" class="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
<input
v-model="query"
type="search"
:placeholder="t('reader.sidebar.searchHighlights')"
:aria-label="t('reader.sidebar.searchHighlights')"
class="h-9 w-full rounded-md border border-border bg-background ps-9 pe-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary"
/>
</div>
<div class="flex flex-wrap gap-1.5">
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
:class="
colorFilter === 'all'
? 'border-primary/45 bg-primary/15 text-primary'
: 'border-border text-muted-foreground hover:bg-muted hover:text-foreground'
"
:aria-pressed="colorFilter === 'all'"
@click="selectAllColors"
>
{{ t('reader.sidebar.allColors') }}
<span class="tabular-nums opacity-75">{{ props.annotations.length }}</span>
</button>
<button
v-for="option in colorOptions"
:key="option.hex"
type="button"
class="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
:class="
colorFilter === option.hex
? 'border-primary/45 bg-primary/15 text-primary'
: 'border-border text-muted-foreground hover:bg-muted hover:text-foreground'
"
:aria-pressed="colorFilter === option.hex"
:aria-label="option.label"
@click="selectColor(option.hex)"
>
<span aria-hidden="true" class="size-2.5 rounded-full" :style="{ background: option.hex }" />
<span class="tabular-nums opacity-75">{{ option.count }}</span>
</button>
<button
v-if="notedCount > 0"
type="button"
class="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
:class="
notesOnly ? 'border-primary/45 bg-primary/15 text-primary' : 'border-border text-muted-foreground hover:bg-muted hover:text-foreground'
"
:aria-pressed="notesOnly"
@click="toggleNotesOnly"
>
<StickyNote :size="11" aria-hidden="true" />
{{ t('reader.sidebar.notesOnly') }}
<span class="tabular-nums opacity-75">{{ notedCount }}</span>
</button>
</div>
</div>
<div v-if="props.loadError" class="px-4 py-8 text-center" role="alert" aria-live="assertive">
<p class="text-xs text-destructive">{{ t('reader.pdf.annotationsLoadError') }}</p>
<button
type="button"
class="mt-3 rounded-md bg-primary px-3 py-2 text-xs font-semibold text-primary-foreground hover:bg-primary/90"
@click="handleRetry"
>
{{ t('reader.retry') }}
</button>
</div>
<div v-else-if="props.loading" class="flex flex-1 items-center justify-center text-muted-foreground" role="status" aria-live="polite">
<LoaderCircle :size="22" class="animate-spin" />
<span class="sr-only">{{ t('reader.pdf.loadingAnnotations') }}</span>
</div>
<p v-else-if="filtered.length === 0" class="px-4 py-8 text-center text-xs text-muted-foreground">
{{ props.annotations.length === 0 ? t('reader.sidebar.noHighlights') : t('reader.sidebar.noHighlightsMatch') }}
</p>
<DynamicScroller
v-else
v-slot="{ item, index, active }"
class="min-h-0 min-w-0 flex-1 overflow-x-hidden p-2"
:items="filtered"
:min-item-size="72"
key-field="id"
data-testid="pdf-notes-list"
>
<DynamicScrollerItem :item="item" :active="active" :data-index="index" :size-dependencies="[item.text, item.note]">
<PdfNoteCard :annotation="item" @navigate="handleNavigate" @delete="handleDelete" />
</DynamicScrollerItem>
</DynamicScroller>
<button
v-if="props.hasMore && !props.loadError"
type="button"
class="m-2 shrink-0 rounded-md border border-border px-3 py-2 text-xs font-semibold text-foreground hover:bg-muted disabled:opacity-50"
:disabled="props.loadingMore"
@click="handleLoadMore"
>
{{ props.loadingMore ? t('reader.pdf.loadingMoreAnnotations') : t('reader.pdf.loadMoreAnnotations') }}
</button>
</div>
</template>
<style>
@import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
</style>
@@ -0,0 +1,171 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ChevronDown, LoaderCircle, Search } from '@lucide/vue'
import { findActivePdfBookmarkIndex, type FlatPdfBookmark } from '../pdf-viewer-utils'
const props = defineProps<{
bookmarks: FlatPdfBookmark[]
loading: boolean
currentPage: number
}>()
const emit = defineEmits<{
navigate: [entry: FlatPdfBookmark]
}>()
const { t } = useI18n()
const query = ref('')
const collapsed = ref(new Set<number>())
const listEl = ref<HTMLElement | null>(null)
const normalizedQuery = computed(() => query.value.trim().toLowerCase())
const activeIndex = computed(() => findActivePdfBookmarkIndex(props.bookmarks, props.currentPage))
const hasChildrenByIndex = computed(() =>
props.bookmarks.map((entry, index) => {
const next = props.bookmarks[index + 1]
return next !== undefined && next.depth > entry.depth
}),
)
/** Ancestors are the nearest preceding entries at each shallower depth. */
function ancestorsOf(index: number): number[] {
const entry = props.bookmarks[index]
if (!entry) return []
const result: number[] = []
let depth = entry.depth
for (let cursor = index - 1; cursor >= 0 && depth > 0; cursor -= 1) {
const candidate = props.bookmarks[cursor]
if (!candidate || candidate.depth >= depth) continue
result.push(cursor)
depth = candidate.depth
}
return result
}
const visibleRows = computed(() => {
const search = normalizedQuery.value
if (search) {
return props.bookmarks
.map((entry, index) => ({ entry, index }))
.filter(({ entry }) => (entry.bookmark.title ?? '').toLowerCase().includes(search))
.map(({ entry, index }) => ({ entry, index, expandable: false, isCollapsed: false }))
}
const rows: { entry: FlatPdfBookmark; index: number; expandable: boolean; isCollapsed: boolean }[] = []
let hideBelowDepth = Number.POSITIVE_INFINITY
for (const [index, entry] of props.bookmarks.entries()) {
if (entry.depth > hideBelowDepth) continue
hideBelowDepth = Number.POSITIVE_INFINITY
const isCollapsed = collapsed.value.has(index)
rows.push({ entry, index, expandable: hasChildrenByIndex.value[index] === true, isCollapsed })
if (isCollapsed) hideBelowDepth = entry.depth
}
return rows
})
function indentStyle(depth: number) {
return { paddingInlineStart: `${Math.min(depth, 6) * 14}px` }
}
function pageLabel(entry: FlatPdfBookmark): number | null {
return entry.pageIndex === null ? null : entry.pageIndex + 1
}
function toggleBranch(index: number) {
const next = new Set(collapsed.value)
if (next.has(index)) next.delete(index)
else next.add(index)
collapsed.value = next
}
function handleNavigate(entry: FlatPdfBookmark) {
emit('navigate', entry)
}
async function scrollActiveIntoView() {
await nextTick()
const active = listEl.value?.querySelector<HTMLElement>('[data-pdf-active-bookmark]')
active?.scrollIntoView({ block: 'center', inline: 'nearest' })
}
// Keep the entry for the current page reachable even if the reader collapsed its branch.
watch(
activeIndex,
(index) => {
if (index === null) return
const hidden = ancestorsOf(index).filter((ancestor) => collapsed.value.has(ancestor))
if (hidden.length === 0) return
const next = new Set(collapsed.value)
for (const ancestor of hidden) next.delete(ancestor)
collapsed.value = next
},
{ immediate: true },
)
watch([activeIndex, normalizedQuery], () => void scrollActiveIntoView(), { immediate: true, flush: 'post' })
</script>
<template>
<div class="flex min-h-0 flex-1 flex-col">
<div v-if="props.bookmarks.length > 0" class="shrink-0 border-b border-border p-3">
<div class="relative">
<Search :size="15" class="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
<input
v-model="query"
type="search"
:placeholder="t('reader.pdf.sidebar.filterOutline')"
:aria-label="t('reader.pdf.sidebar.filterOutline')"
class="h-9 w-full rounded-md border border-border bg-background ps-9 pe-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary"
/>
</div>
</div>
<div v-if="props.loading" class="flex h-32 items-center justify-center text-muted-foreground" role="status" aria-live="polite">
<LoaderCircle :size="22" class="animate-spin" />
<span class="sr-only">{{ t('reader.pdf.sidebar.loadingOutline') }}</span>
</div>
<p v-else-if="props.bookmarks.length === 0" class="px-3 py-8 text-center text-xs text-muted-foreground">
{{ t('reader.pdf.sidebar.noOutline') }}
</p>
<p v-else-if="visibleRows.length === 0" class="px-3 py-8 text-center text-xs text-muted-foreground">
{{ t('reader.pdf.sidebar.noOutlineMatch') }}
</p>
<div v-else ref="listEl" class="min-h-0 flex-1 overflow-y-auto p-2">
<div v-for="row in visibleRows" :key="row.index" class="relative flex items-start rounded-md" :style="indentStyle(row.entry.depth)">
<button
v-if="row.expandable"
type="button"
class="flex size-7 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
:aria-expanded="!row.isCollapsed"
:aria-label="
row.isCollapsed
? t('reader.pdf.sidebar.expandSection', { title: row.entry.bookmark.title })
: t('reader.pdf.sidebar.collapseSection', { title: row.entry.bookmark.title })
"
@click="toggleBranch(row.index)"
>
<ChevronDown :size="14" class="transition-transform" :class="row.isCollapsed ? '-rotate-90 rtl:rotate-90' : ''" />
</button>
<span v-else aria-hidden="true" class="size-7 shrink-0" />
<button
type="button"
class="flex min-w-0 flex-1 items-start gap-2 rounded-md px-2 py-1.5 text-start text-sm transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
:class="row.index === activeIndex ? 'bg-primary/10 font-semibold text-primary' : 'text-foreground'"
:aria-current="row.index === activeIndex ? 'location' : undefined"
:data-pdf-active-bookmark="row.index === activeIndex ? '' : undefined"
@click="handleNavigate(row.entry)"
>
<span v-if="row.index === activeIndex" aria-hidden="true" class="absolute inset-y-1 start-0 w-0.5 rounded-full bg-primary" />
<span class="line-clamp-2 min-w-0 flex-1 break-words">{{ row.entry.bookmark.title }}</span>
<span v-if="pageLabel(row.entry) !== null" class="shrink-0 pt-0.5 text-[11px] tabular-nums text-muted-foreground">
{{ pageLabel(row.entry) }}
</span>
</button>
</div>
</div>
</div>
</template>
@@ -17,13 +17,14 @@ import { useFullscreen } from '../../shared/composables/useFullscreen'
import PdfDocumentViewport from './PdfDocumentViewport.vue'
import PdfPasswordPrompt from './PdfPasswordPrompt.vue'
import PdfReaderSettingsPanel from './PdfReaderSettingsPanel.vue'
import PdfReaderSidebar, { type PdfSidebarTab } from './PdfReaderSidebar.vue'
import PdfReaderSidebar from './PdfReaderSidebar.vue'
import PdfReaderToolbar from './PdfReaderToolbar.vue'
import { fromRotation, safeExternalPdfUrl } from '../pdf-viewer-utils'
import { usePdfFullscreenChrome } from '../composables/usePdfFullscreenChrome'
import { usePdfHighlights } from '../composables/usePdfHighlights'
import { usePdfPagination } from '../composables/usePdfPagination'
import { usePdfResponsiveSpread } from '../composables/usePdfResponsiveSpread'
import { usePdfSidebarLayout, type PdfSidebarTab } from '../composables/usePdfSidebarLayout'
import NoteDialog from '../../shared/components/NoteDialog.vue'
import PdfSelectionPopup from './PdfSelectionPopup.vue'
@@ -56,8 +57,7 @@ const { provides: selectionCapability } = useSelectionCapability()
const { provides: annotationCapability } = useAnnotationCapability()
const { isFullscreen, isFullscreenSupported, toggleFullscreen } = useFullscreen()
const sidebarOpen = ref(false)
const sidebarTab = ref<PdfSidebarTab>('thumbnails')
const sidebar = usePdfSidebarLayout()
const settingsOpen = ref(false)
const pendingExternalUrl = ref<URL | null>(null)
const restoredInitialPage = ref(false)
@@ -75,9 +75,7 @@ const currentZoomMode = computed<PdfReaderSettings['zoomMode']>(() => {
})
const currentRotation = computed(() => fromRotation(rotation.value))
const externalHost = computed(() => pendingExternalUrl.value?.hostname ?? '')
const searchOpen = computed(() => sidebarOpen.value && sidebarTab.value === 'search')
const highlightsOpen = computed(() => sidebarOpen.value && sidebarTab.value === 'highlights')
const hasOpenUi = computed(() => sidebarOpen.value || settingsOpen.value || pendingExternalUrl.value !== null)
const hasOpenUi = computed(() => sidebar.open.value || settingsOpen.value || pendingExternalUrl.value !== null)
const {
pinned: headerPinned,
visible: headerVisible,
@@ -121,38 +119,19 @@ function handleZoomIn() {
}
function handleToggleSidebar() {
if (sidebarOpen.value && sidebarTab.value !== 'search') {
sidebarOpen.value = false
return
}
sidebarTab.value = 'thumbnails'
sidebarOpen.value = true
}
function handleToggleSearch() {
if (sidebarOpen.value && sidebarTab.value === 'search') {
sidebarOpen.value = false
return
}
sidebarTab.value = 'search'
sidebarOpen.value = true
}
function handleToggleHighlights() {
if (sidebarOpen.value && sidebarTab.value === 'highlights') {
sidebarOpen.value = false
return
}
sidebarTab.value = 'highlights'
sidebarOpen.value = true
sidebar.setOpen(!sidebar.open.value)
}
function handleSidebarClose() {
sidebarOpen.value = false
sidebar.close()
}
function handleSidebarTab(tab: PdfSidebarTab) {
sidebarTab.value = tab
sidebar.selectTab(tab)
}
function handleSidebarWidth(width: number) {
sidebar.setWidth(width)
}
function handleTogglePan() {
@@ -197,7 +176,7 @@ function handleHighlightDismiss() {
function handleNavigateHighlight(annotation: AnnotationItem) {
highlights.navigateTo(annotation)
if (window.innerWidth < 768) sidebarOpen.value = false
if (sidebar.layout.value === 'sheet') sidebar.close()
}
function handleDeleteHighlight(id: number) {
@@ -284,7 +263,7 @@ function handleKeydown(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'f') {
event.preventDefault()
handleToggleSearch()
sidebar.selectTab('search')
return
}
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'c' && !editing) {
@@ -295,7 +274,7 @@ function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
if (pendingExternalUrl.value) handleCancelExternalLink()
else if (sidebarOpen.value) handleSidebarClose()
else if (sidebar.open.value) handleSidebarClose()
return
}
if (event.key === 'ArrowLeft' || event.key === 'PageUp') {
@@ -430,9 +409,8 @@ onUnmounted(() => {
:current-page-end="pageRange.end"
:total-pages="scrollState.totalPages"
:zoom-percent="zoomPercent"
:sidebar-open="sidebarOpen"
:search-open="searchOpen"
:highlights-open="highlightsOpen"
:sidebar-open="sidebar.open.value"
:show-sidebar-toggle="sidebar.layout.value === 'sheet'"
:settings-open="settingsOpen"
:pan-active="isPanning"
:fullscreen="isFullscreen"
@@ -446,8 +424,6 @@ onUnmounted(() => {
@zoom-out="handleZoomOut"
@zoom-in="handleZoomIn"
@toggle-sidebar="handleToggleSidebar"
@toggle-search="handleToggleSearch"
@toggle-highlights="handleToggleHighlights"
@toggle-pan="handleTogglePan"
@select-tool="handleSelectTool"
@toggle-fullscreen="toggleFullscreen"
@@ -474,10 +450,11 @@ onUnmounted(() => {
<div class="relative flex min-h-0 flex-1 overflow-hidden">
<PdfReaderSidebar
v-if="sidebarOpen"
:document-id="props.documentId"
:active-tab="sidebarTab"
:header-visible="headerVisible"
:active-tab="sidebar.activeTab.value"
:open="sidebar.open.value"
:layout="sidebar.layout.value"
:width="sidebar.width.value"
:annotations="highlights.annotations.value"
:load-error="highlights.loadError.value"
:loading="highlights.loading.value"
@@ -485,6 +462,7 @@ onUnmounted(() => {
:has-more="highlights.hasMore.value"
@close="handleSidebarClose"
@update:active-tab="handleSidebarTab"
@update:width="handleSidebarWidth"
@navigate-highlight="handleNavigateHighlight"
@delete-highlight="handleDeleteHighlight"
@retry-highlights="handleRetryHighlights"
@@ -1,146 +1,123 @@
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, useId, watch } from 'vue'
import { computed, ref, useId, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { BookOpen, ChevronLeft, ChevronRight, FileText, Highlighter, LoaderCircle, Search, StickyNote, Trash2, X } from '@lucide/vue'
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import { MatchFlag, type SearchResult } from '@embedpdf/models'
import { BookOpen, FileText, Highlighter, Search, X } from '@lucide/vue'
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/vue'
import { useBookmarkCapability } from '@embedpdf/plugin-bookmark/vue'
import { useScroll } from '@embedpdf/plugin-scroll/vue'
import { useSearch } from '@embedpdf/plugin-search/vue'
import { ThumbImg, ThumbnailsPane } from '@embedpdf/plugin-thumbnail/vue'
import type { AnnotationItem } from '@bookorbit/types'
import ReaderBottomSheet, { type ReaderSheetSnap } from '../../shared/components/ReaderBottomSheet.vue'
import PdfNavRail from './PdfNavRail.vue'
import PdfSidebarPanes from './PdfSidebarPanes.vue'
import { flattenPdfBookmarks, type FlatPdfBookmark } from '../pdf-viewer-utils'
import { ANNOTATION_HIGHLIGHT_COLORS, type AnnotationItem } from '@bookorbit/types'
import {
PDF_SIDEBAR_MAX_WIDTH,
PDF_SIDEBAR_MIN_WIDTH,
PDF_SIDEBAR_TABS,
type PdfSidebarLayout,
type PdfSidebarTab,
} from '../composables/usePdfSidebarLayout'
export type PdfSidebarTab = 'thumbnails' | 'contents' | 'search' | 'highlights'
const props = defineProps<{
documentId: string
activeTab: PdfSidebarTab
headerVisible: boolean
annotations: AnnotationItem[]
loadError?: boolean
loading?: boolean
loadingMore?: boolean
hasMore?: boolean
}>()
const { t } = useI18n()
const props = withDefaults(
defineProps<{
documentId: string
activeTab: PdfSidebarTab
open?: boolean
layout?: PdfSidebarLayout
width?: number
annotations: AnnotationItem[]
loadError?: boolean
loading?: boolean
loadingMore?: boolean
hasMore?: boolean
}>(),
{ open: true, layout: 'dock', width: 304 },
)
const emit = defineEmits<{
close: []
'update:activeTab': [tab: PdfSidebarTab]
'update:width': [width: number]
navigateHighlight: [annotation: AnnotationItem]
deleteHighlight: [id: number]
retryHighlights: []
loadMoreHighlights: []
}>()
const { t } = useI18n()
const { state: scrollState, provides: scroll } = useScroll(() => props.documentId)
const { state: searchState, provides: search } = useSearch(() => props.documentId)
const { provides: bookmarksCapability } = useBookmarkCapability()
const { provides: annotationCapability } = useAnnotationCapability()
const searchQuery = ref(searchState.value.query ?? '')
const searchPending = ref(false)
const searchProgressPage = ref(0)
const searchInput = ref<HTMLInputElement | null>(null)
const sidebarId = `pdf-sidebar-${useId()}`
const sheetSnap = ref<ReaderSheetSnap>('peek')
const bookmarks = ref<FlatPdfBookmark[]>([])
const bookmarksLoading = ref(false)
const bookmarksLoaded = ref(false)
const normalizedSearchQuery = computed(() => searchQuery.value.trim())
const matchCase = computed(() => searchState.value.flags.includes(MatchFlag.MatchCase))
const wholeWord = computed(() => searchState.value.flags.includes(MatchFlag.MatchWholeWord))
const activeResultNumber = computed(() => (searchState.value.activeResultIndex >= 0 ? searchState.value.activeResultIndex + 1 : 0))
const TAB_ICONS = { thumbnails: FileText, contents: BookOpen, search: Search, highlights: Highlighter } as const
function getSearchResultKey(_result: SearchResult, index: number) {
return index
const tabIds = computed(() => Object.fromEntries(PDF_SIDEBAR_TABS.map((tab) => [tab, `${sidebarId}-tab-${tab}`])) as Record<PdfSidebarTab, string>)
const panelIds = computed(
() => Object.fromEntries(PDF_SIDEBAR_TABS.map((tab) => [tab, `${sidebarId}-panel-${tab}`])) as Record<PdfSidebarTab, string>,
)
const TAB_LABEL_KEYS = {
thumbnails: 'reader.pdf.sidebar.pages',
contents: 'reader.pdf.sidebar.contents',
search: 'reader.pdf.sidebar.search',
highlights: 'reader.sidebar.tabs.notesShort',
} as const
function tabLabel(tab: PdfSidebarTab) {
return t(TAB_LABEL_KEYS[tab])
}
const panelCount = computed(() => {
if (props.activeTab === 'thumbnails') return scrollState.value.totalPages ? String(scrollState.value.totalPages) : ''
if (props.activeTab === 'highlights') return props.annotations.length ? String(props.annotations.length) : ''
return ''
})
function selectTab(tab: PdfSidebarTab) {
emit('update:activeTab', tab)
}
function handleClose() {
emit('close')
}
const tabs = computed(() => [
{ id: 'thumbnails' as const, icon: FileText, label: t('reader.pdf.sidebar.pages') },
{ id: 'contents' as const, icon: BookOpen, label: t('reader.pdf.sidebar.contents') },
{ id: 'search' as const, icon: Search, label: t('reader.pdf.sidebar.search') },
{ id: 'highlights' as const, icon: Highlighter, label: t('reader.sidebar.tabs.notesShort') },
])
const sidebarId = `pdf-sidebar-${useId()}`
const tabListEl = ref<HTMLElement | null>(null)
function tabId(tab: PdfSidebarTab) {
return `${sidebarId}-tab-${tab}`
function handleSheetOpen(open: boolean) {
if (!open) emit('close')
}
function panelId(tab: PdfSidebarTab) {
return `${sidebarId}-panel-${tab}`
function handleSheetSnap(snap: ReaderSheetSnap) {
sheetSnap.value = snap
}
function selectTab(tab: PdfSidebarTab) {
emit('update:activeTab', tab)
}
function handleTabKeydown(event: KeyboardEvent) {
const options = tabs.value
const current = options.findIndex((tab) => tab.id === props.activeTab)
function handleSheetTabKeydown(event: KeyboardEvent) {
const current = PDF_SIDEBAR_TABS.indexOf(props.activeTab)
let next: number
if (event.key === 'ArrowRight') next = (current + 1) % options.length
else if (event.key === 'ArrowLeft') next = (current - 1 + options.length) % options.length
if (event.key === 'ArrowRight') next = (current + 1) % PDF_SIDEBAR_TABS.length
else if (event.key === 'ArrowLeft') next = (current - 1 + PDF_SIDEBAR_TABS.length) % PDF_SIDEBAR_TABS.length
else if (event.key === 'Home') next = 0
else if (event.key === 'End') next = options.length - 1
else if (event.key === 'End') next = PDF_SIDEBAR_TABS.length - 1
else return
event.preventDefault()
const nextTab = options[next]
if (!nextTab) return
selectTab(nextTab.id)
void nextTick(() => tabListEl.value?.querySelectorAll<HTMLElement>('[role="tab"]')[next]?.focus())
const nextTab = PDF_SIDEBAR_TABS[next]
if (nextTab) selectTab(nextTab)
}
const highlightQuery = ref('')
const highlightColorFilter = ref('all')
const highlightNotesOnly = ref(false)
const HIGHLIGHT_COLOR_NAMES: Record<string, string> = Object.fromEntries(
ANNOTATION_HIGHLIGHT_COLORS.map((color) => [color.hex.toUpperCase(), color.name]),
)
function getHighlightColorLabel(color: string): string {
const name = HIGHLIGHT_COLOR_NAMES[color.trim().toUpperCase()]
return name ? t(`annotations.colors.${name}`) : t('reader.sidebar.customColor', { color })
function handleThumbnail(pageIndex: number) {
scroll.value?.scrollToPage({ pageNumber: pageIndex + 1, behavior: 'smooth' })
}
const highlightColorOptions = computed(() =>
Array.from(new Set(props.annotations.map((annotation) => annotation.color)))
.map((hex) => ({ hex, label: getHighlightColorLabel(hex) }))
.sort((a, b) => a.label.localeCompare(b.label)),
)
const filteredHighlights = computed(() => {
const query = highlightQuery.value.trim().toLowerCase()
return props.annotations
.filter((annotation) => {
if (highlightColorFilter.value !== 'all' && annotation.color !== highlightColorFilter.value) return false
if (highlightNotesOnly.value && !annotation.note?.trim()) return false
if (!query) return true
return `${annotation.text} ${annotation.note ?? ''}`.toLowerCase().includes(query)
})
.sort((a, b) => {
const pageA = a.pdf?.page ?? a.pageno ?? 0
const pageB = b.pdf?.page ?? b.pageno ?? 0
if (pageA !== pageB) return pageA - pageB
return (a.pdf?.rect.y ?? 0) - (b.pdf?.rect.y ?? 0)
})
})
function highlightPageLabel(annotation: AnnotationItem): number | null {
if (annotation.pdf) return annotation.pdf.page + 1
return annotation.pageno
function handleBookmark(entry: FlatPdfBookmark) {
const target = entry.bookmark.target
if (!target) return
annotationCapability.value?.forDocument(props.documentId).navigateTarget(target)
}
function handleNavigateHighlight(annotation: AnnotationItem) {
@@ -159,94 +136,6 @@ function handleLoadMoreHighlights() {
emit('loadMoreHighlights')
}
function handleThumbnail(pageIndex: number) {
scroll.value?.scrollToPage({ pageNumber: pageIndex + 1, behavior: 'smooth' })
}
function scrollToSearchResult(index: number) {
const result = searchState.value.results[index]
if (!result) return
if (result.rects.length === 0) {
scroll.value?.scrollToPage({ pageNumber: result.pageIndex + 1 })
return
}
const coordinates = result.rects.reduce(
(minimum, rect) => ({
x: Math.min(minimum.x, rect.origin.x),
y: Math.min(minimum.y, rect.origin.y),
}),
{ x: Number.POSITIVE_INFINITY, y: Number.POSITIVE_INFINITY },
)
scroll.value?.scrollToPage({
pageNumber: result.pageIndex + 1,
pageCoordinates: coordinates,
alignX: 50,
alignY: 30,
})
}
function handleSearchResult(index: number) {
search.value?.goToResult(index)
scrollToSearchResult(index)
}
function handlePreviousResult() {
const total = searchState.value.total
if (total <= 0) return
const current = searchState.value.activeResultIndex
const index = current <= 0 ? total - 1 : current - 1
search.value?.goToResult(index)
scrollToSearchResult(index)
}
function handleNextResult() {
const total = searchState.value.total
if (total <= 0) return
const current = searchState.value.activeResultIndex
const index = current >= total - 1 ? 0 : current + 1
search.value?.goToResult(index)
scrollToSearchResult(index)
}
function handleSearchKeydown(event: KeyboardEvent) {
if (event.key !== 'Enter') return
event.preventDefault()
if (event.shiftKey) handlePreviousResult()
else handleNextResult()
}
function handleClearSearch() {
searchQuery.value = ''
searchProgressPage.value = 0
search.value?.stopSearch()
void nextTick(() => searchInput.value?.focus())
}
function handleMatchCase(event: Event) {
updateSearchFlag(MatchFlag.MatchCase, (event.target as HTMLInputElement).checked)
}
function handleWholeWord(event: Event) {
updateSearchFlag(MatchFlag.MatchWholeWord, (event.target as HTMLInputElement).checked)
}
function updateSearchFlag(flag: MatchFlag, enabled: boolean) {
const flags = new Set(searchState.value.flags)
if (enabled) flags.add(flag)
else flags.delete(flag)
search.value?.setFlags([...flags])
}
function handleBookmark(entry: FlatPdfBookmark) {
const target = entry.bookmark.target
if (!target) return
annotationCapability.value?.forDocument(props.documentId).navigateTarget(target)
}
function getBookmarkIndent(depth: number) {
return `${10 + Math.min(depth, 6) * 16}px`
}
function loadBookmarks() {
if (bookmarksLoaded.value || bookmarksLoading.value || !bookmarksCapability.value) return
bookmarksLoading.value = true
@@ -267,324 +156,185 @@ function loadBookmarks() {
}
watch(
[searchQuery, () => props.activeTab],
([query, activeTab], _previous, onCleanup) => {
const normalized = query.trim()
searchPending.value = false
searchProgressPage.value = 0
search.value?.stopSearch()
if (activeTab !== 'search' || normalized.length < 2) return
searchPending.value = true
const timer = window.setTimeout(() => {
searchPending.value = false
const task = search.value?.searchAllPages(normalized)
task?.onProgress((progress) => {
searchProgressPage.value = progress.page + 1
})
}, 250)
onCleanup(() => window.clearTimeout(timer))
},
{ flush: 'post', immediate: true },
)
watch(
() => [props.activeTab, bookmarksCapability.value] as const,
([tab]) => {
if (tab === 'contents') {
loadBookmarks()
return
}
if (tab === 'search') void nextTick(() => searchInput.value?.focus())
() => [props.activeTab, props.open, bookmarksCapability.value] as const,
([tab, open]) => {
if (open && tab === 'contents') loadBookmarks()
},
{ immediate: true },
)
onUnmounted(() => search.value?.stopSearch())
// --- resize -------------------------------------------------------------
const resizing = ref(false)
let pointerId: number | null = null
let startX = 0
let startWidth = 0
function handleResizeStart(event: PointerEvent) {
pointerId = event.pointerId
startX = event.clientX
startWidth = props.width
resizing.value = true
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
event.preventDefault()
}
function handleResizeMove(event: PointerEvent) {
if (!resizing.value || event.pointerId !== pointerId) return
// The panel is anchored to the inline start edge, so a rightward drag widens it.
const delta = document.dir === 'rtl' ? startX - event.clientX : event.clientX - startX
emit('update:width', Math.min(Math.max(startWidth + delta, PDF_SIDEBAR_MIN_WIDTH), PDF_SIDEBAR_MAX_WIDTH))
}
function handleResizeEnd(event: PointerEvent) {
if (event.pointerId !== pointerId) return
resizing.value = false
pointerId = null
}
function handleResizeKeydown(event: KeyboardEvent) {
const step = event.shiftKey ? 48 : 16
let delta = 0
if (event.key === 'ArrowRight') delta = step
else if (event.key === 'ArrowLeft') delta = -step
else return
event.preventDefault()
emit('update:width', Math.min(Math.max(props.width + delta, PDF_SIDEBAR_MIN_WIDTH), PDF_SIDEBAR_MAX_WIDTH))
}
</script>
<template>
<div
<PdfNavRail
v-if="props.layout !== 'sheet'"
:active-tab="props.activeTab"
:open="props.open"
:highlight-count="props.annotations.length"
:tab-ids="tabIds"
:panel-ids="panelIds"
@select="selectTab"
/>
<aside
v-if="props.layout !== 'sheet' && props.open"
data-pdf-reader-panel
class="fixed inset-x-0 bottom-0 z-40 flex bg-background/40 md:static md:z-auto md:h-full md:w-[19rem] md:shrink-0 md:bg-transparent"
:class="props.headerVisible ? 'top-11' : 'top-0'"
@click.self="handleClose"
class="relative flex h-full min-h-0 shrink-0 flex-col overflow-hidden border-e border-border bg-card text-card-foreground"
:class="props.layout === 'overlay' ? 'absolute inset-y-0 start-14 z-30 shadow-2xl' : ''"
:style="{ width: `${props.width}px` }"
>
<aside
class="pointer-events-auto flex h-full w-[18rem] max-w-[88vw] flex-col overflow-hidden border-r border-border bg-card text-card-foreground shadow-2xl sm:w-[19rem]"
<div class="flex h-11 shrink-0 items-center gap-2 border-b border-border ps-3 pe-1">
<h2 class="flex min-w-0 flex-1 items-baseline gap-2 text-sm font-semibold text-foreground">
<span class="truncate">{{ tabLabel(props.activeTab) }}</span>
<span v-if="panelCount" class="shrink-0 text-xs font-medium tabular-nums text-muted-foreground">{{ panelCount }}</span>
</h2>
<button type="button" class="viewer-btn" :aria-label="t('reader.pdf.sidebar.closeNavigation')" @click="handleClose">
<X :size="17" />
</button>
</div>
<div :id="panelIds[props.activeTab]" role="tabpanel" :aria-labelledby="tabIds[props.activeTab]" class="flex min-h-0 flex-1 flex-col">
<PdfSidebarPanes
:document-id="props.documentId"
:active-tab="props.activeTab"
:active="props.open"
:current-page="scrollState.currentPage"
:total-pages="scrollState.totalPages"
:bookmarks="bookmarks"
:bookmarks-loading="bookmarksLoading"
:annotations="props.annotations"
:load-error="props.loadError"
:loading="props.loading"
:loading-more="props.loadingMore"
:has-more="props.hasMore"
@select-page="handleThumbnail"
@navigate-bookmark="handleBookmark"
@navigate-highlight="handleNavigateHighlight"
@delete-highlight="handleDeleteHighlight"
@retry-highlights="handleRetryHighlights"
@load-more-highlights="handleLoadMoreHighlights"
/>
</div>
<div
role="separator"
tabindex="0"
aria-orientation="vertical"
:aria-label="t('reader.pdf.sidebar.resizePanel')"
:aria-valuenow="props.width"
:aria-valuemin="PDF_SIDEBAR_MIN_WIDTH"
:aria-valuemax="PDF_SIDEBAR_MAX_WIDTH"
class="absolute inset-y-0 end-0 z-10 w-1.5 cursor-col-resize touch-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
:class="resizing ? 'bg-primary/40' : 'hover:bg-primary/25'"
@pointerdown="handleResizeStart"
@pointermove="handleResizeMove"
@pointerup="handleResizeEnd"
@pointercancel="handleResizeEnd"
@keydown="handleResizeKeydown"
/>
</aside>
<ReaderBottomSheet
v-if="props.layout === 'sheet'"
:open="props.open"
:snap="sheetSnap"
:label="t('reader.pdf.sidebar.tabsLabel')"
@update:open="handleSheetOpen"
@update:snap="handleSheetSnap"
>
<div class="flex shrink-0 items-center gap-2 px-3 pb-2">
<div
role="tablist"
:aria-label="t('reader.pdf.sidebar.tabsLabel')"
class="flex min-w-0 flex-1 gap-0.5 rounded-xl bg-muted p-1"
@keydown="handleSheetTabKeydown"
>
<button
v-for="tab in PDF_SIDEBAR_TABS"
:id="tabIds[tab]"
:key="tab"
type="button"
role="tab"
:aria-selected="props.activeTab === tab"
:aria-controls="panelIds[tab]"
:tabindex="props.activeTab === tab ? 0 : -1"
class="flex min-w-0 flex-1 items-center justify-center gap-1.5 rounded-lg py-2 text-xs font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
:class="props.activeTab === tab ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground'"
@click="selectTab(tab)"
>
<component :is="TAB_ICONS[tab]" :size="14" class="shrink-0" aria-hidden="true" />
<span class="truncate">{{ tabLabel(tab) }}</span>
</button>
</div>
<button type="button" class="viewer-btn" :aria-label="t('reader.pdf.sidebar.closeNavigation')" @click="handleClose">
<X :size="17" />
</button>
</div>
<div
:id="panelIds[props.activeTab]"
role="tabpanel"
:aria-labelledby="tabIds[props.activeTab]"
class="flex min-h-0 flex-1 flex-col border-t border-border"
>
<div class="flex h-14 shrink-0 items-stretch border-b border-border">
<div
ref="tabListEl"
role="tablist"
:aria-label="t('reader.pdf.sidebar.tabsLabel')"
class="flex min-w-0 flex-1 items-stretch"
@keydown="handleTabKeydown"
>
<button
v-for="tab in tabs"
:id="tabId(tab.id)"
:key="tab.id"
type="button"
role="tab"
:aria-selected="props.activeTab === tab.id"
:aria-controls="panelId(tab.id)"
:tabindex="props.activeTab === tab.id ? 0 : -1"
class="relative flex min-w-0 flex-1 flex-col items-center justify-center gap-1 px-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset"
:class="
props.activeTab === tab.id
? 'text-primary'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground focus-visible:text-foreground'
"
@click="selectTab(tab.id)"
>
<component :is="tab.icon" :size="16" class="shrink-0" aria-hidden="true" />
<span class="w-full truncate text-center text-[11px] leading-none">{{ tab.label }}</span>
<span v-if="props.activeTab === tab.id" aria-hidden="true" class="absolute inset-x-0 bottom-0 h-0.5 rounded-t-full bg-primary" />
</button>
</div>
<div class="flex shrink-0 items-center border-l border-border px-1.5">
<button type="button" class="viewer-btn" :aria-label="t('reader.pdf.sidebar.closeNavigation')" @click="handleClose">
<X :size="17" />
</button>
</div>
</div>
<div
v-if="props.activeTab === 'thumbnails'"
:id="panelId('thumbnails')"
role="tabpanel"
:aria-labelledby="tabId('thumbnails')"
class="min-h-0 flex-1 overflow-hidden bg-muted/25"
>
<ThumbnailsPane :document-id="props.documentId" class="h-full w-full">
<template #default="{ meta }">
<button
class="absolute flex w-full cursor-pointer flex-col items-center"
:style="{ height: `${meta.wrapperHeight}px`, top: `${meta.top}px` }"
:aria-label="t('reader.pdf.sidebar.goToPage', { page: meta.pageIndex + 1 })"
@click="handleThumbnail(meta.pageIndex)"
>
<span
class="overflow-hidden rounded border-2 bg-background shadow-sm transition-colors"
:class="
scrollState.currentPage === meta.pageIndex + 1
? 'border-primary ring-2 ring-primary/15'
: 'border-border hover:border-muted-foreground/50'
"
:style="{ width: `${meta.width}px`, height: `${meta.height}px` }"
>
<ThumbImg :document-id="props.documentId" :meta="meta" class="h-full w-full object-contain" />
</span>
<span class="mt-1 text-xs tabular-nums text-muted-foreground">{{ meta.pageIndex + 1 }}</span>
</button>
</template>
</ThumbnailsPane>
</div>
<div
v-else-if="props.activeTab === 'contents'"
:id="panelId('contents')"
role="tabpanel"
:aria-labelledby="tabId('contents')"
class="min-h-0 flex-1 overflow-y-auto p-2"
>
<div v-if="bookmarksLoading" class="flex h-32 items-center justify-center text-muted-foreground">
<LoaderCircle :size="22" class="animate-spin" />
</div>
<p v-else-if="bookmarks.length === 0" class="px-3 py-8 text-center text-xs text-muted-foreground">
{{ t('reader.pdf.sidebar.noOutline') }}
</p>
<button
v-for="(entry, entryIndex) in bookmarks"
:key="entryIndex"
class="flex w-full min-w-0 items-start overflow-hidden rounded-md py-2 pr-2 text-left text-sm text-muted-foreground hover:bg-muted hover:text-foreground"
:style="{ paddingLeft: getBookmarkIndent(entry.depth) }"
@click="handleBookmark(entry)"
>
<span class="line-clamp-2 min-w-0 break-words">{{ entry.bookmark.title }}</span>
</button>
</div>
<div
v-else-if="props.activeTab === 'search'"
:id="panelId('search')"
role="tabpanel"
:aria-labelledby="tabId('search')"
class="flex min-h-0 flex-1 flex-col"
>
<div class="shrink-0 border-b border-border p-3">
<div class="relative">
<Search :size="15" class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
ref="searchInput"
v-model="searchQuery"
type="search"
:placeholder="t('reader.pdf.sidebar.searchPlaceholder')"
class="h-9 w-full rounded-md border border-border bg-background pl-9 pr-9 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary"
@keydown="handleSearchKeydown"
/>
<button
v-if="searchQuery"
class="absolute right-1.5 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded text-muted-foreground hover:text-foreground"
:aria-label="t('reader.pdf.sidebar.clearSearch')"
@click="handleClearSearch"
>
<X :size="14" />
</button>
</div>
<div class="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
<label class="flex items-center gap-1.5">
<input type="checkbox" :checked="matchCase" class="accent-primary" @change="handleMatchCase" />
{{ t('reader.pdf.sidebar.matchCase') }}
</label>
<label class="flex items-center gap-1.5">
<input type="checkbox" :checked="wholeWord" class="accent-primary" @change="handleWholeWord" />
{{ t('reader.pdf.sidebar.wholeWord') }}
</label>
</div>
<div v-if="searchState.active && !searchState.loading" class="mt-2 flex items-center justify-between text-xs text-muted-foreground">
<span>{{ t('reader.pdf.sidebar.resultPosition', { current: activeResultNumber, total: searchState.total }) }}</span>
<div v-if="searchState.total > 1" class="flex items-center">
<button class="viewer-btn !h-7 !w-7" :aria-label="t('reader.pdf.sidebar.previousResult')" @click="handlePreviousResult">
<ChevronLeft :size="15" />
</button>
<button class="viewer-btn !h-7 !w-7" :aria-label="t('reader.pdf.sidebar.nextResult')" @click="handleNextResult">
<ChevronRight :size="15" />
</button>
</div>
</div>
</div>
<div v-if="searchPending || searchState.loading" class="flex flex-1 items-center justify-center text-muted-foreground">
<div class="flex flex-col items-center gap-2" role="status" aria-live="polite">
<LoaderCircle :size="22" class="animate-spin" />
<span class="text-xs">{{
t('reader.pdf.sidebar.searchingPage', { page: searchProgressPage || 1, total: scrollState.totalPages || 1 })
}}</span>
</div>
</div>
<RecycleScroller
v-else-if="searchState.results.length > 0"
v-slot="{ item, index }"
class="min-h-0 min-w-0 flex-1 overflow-x-hidden p-2"
:items="searchState.results"
:item-size="76"
:key-field="getSearchResultKey"
>
<button
class="mb-1.5 h-[70px] w-full min-w-0 overflow-hidden rounded-md border p-2 text-left text-xs transition-colors"
:class="
index === searchState.activeResultIndex
? 'border-primary bg-primary/10 text-foreground'
: 'border-border bg-background text-muted-foreground hover:text-foreground'
"
@click="handleSearchResult(index)"
>
<span class="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{{
t('reader.pdf.sidebar.page', { page: item.pageIndex + 1 })
}}</span>
<span class="line-clamp-2 min-w-0 break-words">
<template v-if="item.context.truncatedLeft">… </template>{{ item.context.before
}}<mark class="rounded-sm bg-primary/20 px-0.5 text-foreground">{{ item.context.match }}</mark
>{{ item.context.after }}<template v-if="item.context.truncatedRight"> …</template>
</span>
</button>
</RecycleScroller>
<div v-else class="flex flex-1 flex-col items-center justify-center gap-2 px-6 text-center">
<Search :size="20" class="text-muted-foreground" aria-hidden="true" />
<p class="text-xs text-muted-foreground">
<template v-if="normalizedSearchQuery.length === 0">{{ t('reader.pdf.sidebar.searchPrompt') }}</template>
<template v-else-if="normalizedSearchQuery.length === 1">{{ t('reader.pdf.sidebar.searchTooShort', { min: 2 }) }}</template>
<template v-else>{{ t('reader.pdf.sidebar.noSearchResults') }}</template>
</p>
</div>
<span class="sr-only" aria-live="polite">{{ t('reader.search.resultCount', { count: searchState.total }) }}</span>
</div>
<div v-else :id="panelId('highlights')" role="tabpanel" :aria-labelledby="tabId('highlights')" class="flex min-h-0 flex-1 flex-col">
<div class="shrink-0 space-y-2 border-b border-border p-3">
<div class="relative">
<Search :size="15" class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
v-model="highlightQuery"
type="search"
:placeholder="t('reader.sidebar.searchHighlights')"
class="h-9 w-full rounded-md border border-border bg-background pl-9 pr-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary"
/>
</div>
<div class="flex items-center gap-2">
<select
v-model="highlightColorFilter"
class="h-8 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-xs text-foreground outline-none focus:border-primary"
>
<option value="all">{{ t('reader.sidebar.allColors') }}</option>
<option v-for="option in highlightColorOptions" :key="option.hex" :value="option.hex">{{ option.label }}</option>
</select>
<label class="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
<input v-model="highlightNotesOnly" type="checkbox" class="accent-primary" />
{{ t('reader.sidebar.notesOnly') }}
</label>
</div>
</div>
<div v-if="props.loadError" class="px-4 py-8 text-center" role="alert" aria-live="assertive">
<p class="text-xs text-destructive">{{ t('reader.pdf.annotationsLoadError') }}</p>
<button class="mt-3 rounded-md bg-primary px-3 py-2 text-xs font-semibold text-primary-foreground" @click="handleRetryHighlights">
{{ t('reader.retry') }}
</button>
</div>
<div v-else-if="props.loading" class="flex flex-1 items-center justify-center text-muted-foreground" role="status" aria-live="polite">
<LoaderCircle :size="22" class="animate-spin" />
<span class="sr-only">{{ t('reader.pdf.loadingAnnotations') }}</span>
</div>
<p v-else-if="filteredHighlights.length === 0" class="px-4 py-8 text-center text-xs text-muted-foreground">
{{ props.annotations.length === 0 ? t('reader.sidebar.noHighlights') : t('reader.sidebar.noHighlightsMatch') }}
</p>
<RecycleScroller
v-else
v-slot="{ item: annotation }"
class="min-h-0 min-w-0 flex-1 overflow-x-hidden p-2"
:items="filteredHighlights"
:item-size="126"
key-field="id"
>
<div
class="group mb-1.5 h-[120px] overflow-hidden rounded-md border border-border bg-background p-2 text-left transition-colors hover:border-muted-foreground/50"
>
<button class="flex w-full min-w-0 gap-2 text-left" @click="handleNavigateHighlight(annotation)">
<span class="mt-0.5 h-4 w-1.5 shrink-0 rounded-full" :style="{ background: annotation.color }" />
<span class="min-w-0 flex-1">
<span class="line-clamp-3 break-words text-xs text-foreground">{{ annotation.text }}</span>
<span v-if="annotation.note" class="mt-1 flex items-start gap-1 text-[11px] text-muted-foreground">
<StickyNote :size="12" class="mt-0.5 shrink-0" />
<span class="line-clamp-2 break-words italic">{{ annotation.note }}</span>
</span>
<span v-if="highlightPageLabel(annotation) != null" class="mt-1 block text-[10px] uppercase tracking-wide text-muted-foreground">
{{ t('reader.pdf.sidebar.page', { page: highlightPageLabel(annotation) }) }}
</span>
</span>
</button>
<div class="mt-1 flex justify-end">
<button
class="flex h-6 w-6 items-center justify-center rounded text-muted-foreground opacity-100 transition-opacity hover:text-destructive [@media(pointer:fine)]:opacity-0 [@media(pointer:fine)]:focus-visible:opacity-100 [@media(pointer:fine)]:group-hover:opacity-100"
:aria-label="t('reader.sidebar.deleteHighlight')"
@click="handleDeleteHighlight(annotation.id)"
>
<Trash2 :size="14" />
</button>
</div>
</div>
</RecycleScroller>
<button
v-if="props.hasMore && !props.loadError"
class="m-2 shrink-0 rounded-md border border-border px-3 py-2 text-xs font-semibold text-foreground hover:bg-muted disabled:opacity-50"
:disabled="props.loadingMore"
@click="handleLoadMoreHighlights"
>
{{ props.loadingMore ? t('reader.pdf.loadingMoreAnnotations') : t('reader.pdf.loadMoreAnnotations') }}
</button>
</div>
</aside>
</div>
<PdfSidebarPanes
:document-id="props.documentId"
:active-tab="props.activeTab"
:active="props.open"
:current-page="scrollState.currentPage"
:total-pages="scrollState.totalPages"
:bookmarks="bookmarks"
:bookmarks-loading="bookmarksLoading"
:annotations="props.annotations"
:load-error="props.loadError"
:loading="props.loading"
:loading-more="props.loadingMore"
:has-more="props.hasMore"
@select-page="handleThumbnail"
@navigate-bookmark="handleBookmark"
@navigate-highlight="handleNavigateHighlight"
@delete-highlight="handleDeleteHighlight"
@retry-highlights="handleRetryHighlights"
@load-more-highlights="handleLoadMoreHighlights"
/>
</div>
</ReaderBottomSheet>
</template>
@@ -4,7 +4,6 @@ import {
ChevronLeft,
ChevronRight,
Hand,
Highlighter,
Maximize,
Minimize,
Minus,
@@ -14,7 +13,6 @@ import {
Pin,
PinOff,
Plus,
Search,
Settings,
} from '@lucide/vue'
import { useI18n } from 'vue-i18n'
@@ -27,8 +25,7 @@ const props = defineProps<{
totalPages: number
zoomPercent: number
sidebarOpen: boolean
searchOpen: boolean
highlightsOpen: boolean
showSidebarToggle: boolean
settingsOpen: boolean
panActive: boolean
fullscreen: boolean
@@ -47,8 +44,6 @@ const emit = defineEmits<{
zoomOut: []
zoomIn: []
toggleSidebar: []
toggleSearch: []
toggleHighlights: []
togglePan: []
selectTool: []
toggleFullscreen: []
@@ -92,13 +87,6 @@ function handleZoomIn() {
function handleToggleSidebar() {
emit('toggleSidebar')
}
function handleToggleSearch() {
emit('toggleSearch')
}
function handleToggleHighlights() {
emit('toggleHighlights')
}
function handleTogglePan() {
emit('togglePan')
@@ -137,49 +125,24 @@ function handleStartReading() {
<TooltipContent>{{ t('reader.header.goBack') }}</TooltipContent>
</Tooltip>
<div class="viewer-sep" />
<template v-if="props.showSidebarToggle">
<div class="viewer-sep" />
<Tooltip>
<TooltipTrigger as-child>
<button
class="viewer-btn"
:class="props.sidebarOpen ? '!bg-muted !text-foreground' : ''"
:aria-label="t('reader.pdf.toolbar.navigation')"
@click="handleToggleSidebar"
>
<PanelLeft :size="18" />
</button>
</TooltipTrigger>
<TooltipContent>{{ t('reader.pdf.toolbar.navigation') }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
class="viewer-btn hidden md:flex"
:class="props.searchOpen ? '!bg-muted !text-foreground' : ''"
:aria-label="t('reader.pdf.toolbar.search')"
@click="handleToggleSearch"
>
<Search :size="17" />
</button>
</TooltipTrigger>
<TooltipContent>{{ t('reader.pdf.toolbar.search') }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
class="viewer-btn"
:class="props.highlightsOpen ? '!bg-muted !text-foreground' : ''"
:aria-label="t('reader.sidebar.tabs.highlights')"
@click="handleToggleHighlights"
>
<Highlighter :size="17" />
</button>
</TooltipTrigger>
<TooltipContent>{{ t('reader.sidebar.tabs.highlights') }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
class="viewer-btn"
:class="props.sidebarOpen ? '!bg-muted !text-foreground' : ''"
:aria-label="t('reader.pdf.toolbar.navigation')"
:aria-expanded="props.sidebarOpen"
@click="handleToggleSidebar"
>
<PanelLeft :size="18" />
</button>
</TooltipTrigger>
<TooltipContent>{{ t('reader.pdf.toolbar.navigation') }}</TooltipContent>
</Tooltip>
</template>
</div>
<div class="mx-auto flex min-w-0 items-center gap-1 sm:absolute sm:inset-x-0 sm:pointer-events-none sm:justify-center">
@@ -304,9 +267,6 @@ function handleStartReading() {
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom" :side-offset="8" class="w-56 border-border bg-card p-1.5 shadow-xl">
<button class="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-xs hover:bg-muted" @click="handleToggleSearch">
<Search :size="15" /> {{ t('reader.pdf.toolbar.searchShort') }}
</button>
<button
class="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-xs hover:bg-muted"
:class="!props.panActive ? 'bg-muted text-foreground' : ''"
@@ -0,0 +1,263 @@
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ChevronLeft, ChevronRight, LoaderCircle, Search, X } from '@lucide/vue'
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'
import { MatchFlag } from '@embedpdf/models'
import { useScroll } from '@embedpdf/plugin-scroll/vue'
import { useSearch } from '@embedpdf/plugin-search/vue'
const MIN_QUERY_LENGTH = 2
const DEBOUNCE_MS = 250
const props = defineProps<{
documentId: string
active: boolean
}>()
const { t } = useI18n()
const { state: scrollState, provides: scroll } = useScroll(() => props.documentId)
const { state: searchState, provides: search } = useSearch(() => props.documentId)
const query = ref(searchState.value.query ?? '')
const pending = ref(false)
const progressPage = ref(0)
const input = ref<HTMLInputElement | null>(null)
const normalizedQuery = computed(() => query.value.trim())
const matchCase = computed(() => searchState.value.flags.includes(MatchFlag.MatchCase))
const wholeWord = computed(() => searchState.value.flags.includes(MatchFlag.MatchWholeWord))
const activeResultNumber = computed(() => (searchState.value.activeResultIndex >= 0 ? searchState.value.activeResultIndex + 1 : 0))
/** Repeating the page label only when it changes gives grouped results without breaking virtualization. */
const rows = computed(() =>
searchState.value.results.map((result, index) => ({
id: index,
result,
startsPage: index === 0 || searchState.value.results[index - 1]?.pageIndex !== result.pageIndex,
})),
)
function scrollToResult(index: number) {
const result = searchState.value.results[index]
if (!result) return
if (result.rects.length === 0) {
scroll.value?.scrollToPage({ pageNumber: result.pageIndex + 1 })
return
}
const coordinates = result.rects.reduce(
(minimum, rect) => ({
x: Math.min(minimum.x, rect.origin.x),
y: Math.min(minimum.y, rect.origin.y),
}),
{ x: Number.POSITIVE_INFINITY, y: Number.POSITIVE_INFINITY },
)
scroll.value?.scrollToPage({ pageNumber: result.pageIndex + 1, pageCoordinates: coordinates, alignX: 50, alignY: 30 })
}
function handleResult(index: number) {
search.value?.goToResult(index)
scrollToResult(index)
}
function handlePreviousResult() {
const total = searchState.value.total
if (total <= 0) return
const current = searchState.value.activeResultIndex
const index = current <= 0 ? total - 1 : current - 1
search.value?.goToResult(index)
scrollToResult(index)
}
function handleNextResult() {
const total = searchState.value.total
if (total <= 0) return
const current = searchState.value.activeResultIndex
const index = current >= total - 1 ? 0 : current + 1
search.value?.goToResult(index)
scrollToResult(index)
}
function handleKeydown(event: KeyboardEvent) {
if (event.key !== 'Enter') return
event.preventDefault()
if (event.shiftKey) handlePreviousResult()
else handleNextResult()
}
function handleClear() {
query.value = ''
progressPage.value = 0
search.value?.stopSearch()
void nextTick(() => input.value?.focus())
}
function updateFlag(flag: MatchFlag, enabled: boolean) {
const flags = new Set(searchState.value.flags)
if (enabled) flags.add(flag)
else flags.delete(flag)
search.value?.setFlags([...flags])
}
function toggleMatchCase() {
updateFlag(MatchFlag.MatchCase, !matchCase.value)
}
function toggleWholeWord() {
updateFlag(MatchFlag.MatchWholeWord, !wholeWord.value)
}
watch(
[query, () => props.active],
([nextQuery, active], _previous, onCleanup) => {
const normalized = nextQuery.trim()
pending.value = false
progressPage.value = 0
search.value?.stopSearch()
if (!active || normalized.length < MIN_QUERY_LENGTH) return
pending.value = true
const timer = window.setTimeout(() => {
pending.value = false
const task = search.value?.searchAllPages(normalized)
task?.onProgress((progress) => {
progressPage.value = progress.page + 1
})
}, DEBOUNCE_MS)
onCleanup(() => window.clearTimeout(timer))
},
{ flush: 'post', immediate: true },
)
watch(
() => props.active,
(active) => {
if (active) void nextTick(() => input.value?.focus())
},
{ immediate: true },
)
onUnmounted(() => search.value?.stopSearch())
</script>
<template>
<div class="flex min-h-0 flex-1 flex-col">
<div class="shrink-0 space-y-2 border-b border-border p-3">
<div class="relative">
<Search :size="15" class="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
<input
ref="input"
v-model="query"
type="search"
:placeholder="t('reader.pdf.sidebar.searchPlaceholder')"
:aria-label="t('reader.pdf.sidebar.searchPlaceholder')"
class="h-9 w-full rounded-md border border-border bg-background ps-9 pe-9 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary"
@keydown="handleKeydown"
/>
<button
v-if="query"
type="button"
class="absolute end-1.5 top-1/2 flex size-6 -translate-y-1/2 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
:aria-label="t('reader.pdf.sidebar.clearSearch')"
@click="handleClear"
>
<X :size="14" />
</button>
</div>
<div class="flex flex-wrap gap-1.5">
<button
type="button"
class="rounded-full border px-2.5 py-1 text-[11px] font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
:class="
matchCase ? 'border-primary/45 bg-primary/15 text-primary' : 'border-border text-muted-foreground hover:bg-muted hover:text-foreground'
"
:aria-pressed="matchCase"
@click="toggleMatchCase"
>
{{ t('reader.pdf.sidebar.matchCase') }}
</button>
<button
type="button"
class="rounded-full border px-2.5 py-1 text-[11px] font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
:class="
wholeWord ? 'border-primary/45 bg-primary/15 text-primary' : 'border-border text-muted-foreground hover:bg-muted hover:text-foreground'
"
:aria-pressed="wholeWord"
@click="toggleWholeWord"
>
{{ t('reader.pdf.sidebar.wholeWord') }}
</button>
</div>
<div v-if="searchState.active && !searchState.loading" class="flex items-center justify-between text-xs text-muted-foreground">
<span>{{ t('reader.pdf.sidebar.resultPosition', { current: activeResultNumber, total: searchState.total }) }}</span>
<div v-if="searchState.total > 1" class="flex items-center">
<button type="button" class="viewer-btn !size-7" :aria-label="t('reader.pdf.sidebar.previousResult')" @click="handlePreviousResult">
<ChevronLeft :size="15" />
</button>
<button type="button" class="viewer-btn !size-7" :aria-label="t('reader.pdf.sidebar.nextResult')" @click="handleNextResult">
<ChevronRight :size="15" />
</button>
</div>
</div>
</div>
<div v-if="pending || searchState.loading" class="flex flex-1 items-center justify-center text-muted-foreground">
<div class="flex flex-col items-center gap-2" role="status" aria-live="polite">
<LoaderCircle :size="22" class="animate-spin" />
<span class="text-xs">
{{ t('reader.pdf.sidebar.searchingPage', { page: progressPage || 1, total: scrollState.totalPages || 1 }) }}
</span>
</div>
</div>
<DynamicScroller
v-else-if="rows.length > 0"
v-slot="{ item, index, active: itemActive }"
class="min-h-0 min-w-0 flex-1 overflow-x-hidden p-2"
:items="rows"
:min-item-size="58"
key-field="id"
data-testid="search-results"
>
<DynamicScrollerItem :item="item" :active="itemActive" :data-index="index" :size-dependencies="[item.result.context.match]">
<p v-if="item.startsPage" class="px-1 pb-1 pt-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground first:pt-0">
{{ t('reader.pdf.sidebar.page', { page: item.result.pageIndex + 1 }) }}
</p>
<button
type="button"
class="mb-1 block w-full min-w-0 rounded-md border px-2.5 py-2 text-start text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
:class="
index === searchState.activeResultIndex
? 'border-primary/45 bg-primary/10 text-foreground'
: 'border-transparent text-muted-foreground hover:bg-muted hover:text-foreground'
"
:aria-current="index === searchState.activeResultIndex ? 'true' : undefined"
@click="handleResult(index)"
>
<span class="line-clamp-3 min-w-0 break-words leading-relaxed">
<template v-if="item.result.context.truncatedLeft">… </template>{{ item.result.context.before
}}<mark class="rounded-sm bg-primary/25 px-0.5 text-foreground">{{ item.result.context.match }}</mark
>{{ item.result.context.after }}<template v-if="item.result.context.truncatedRight"> …</template>
</span>
</button>
</DynamicScrollerItem>
</DynamicScroller>
<div v-else class="flex flex-1 flex-col items-center justify-center gap-2 px-6 text-center">
<Search :size="20" class="text-muted-foreground" aria-hidden="true" />
<p class="text-xs text-muted-foreground">
<template v-if="normalizedQuery.length === 0">{{ t('reader.pdf.sidebar.searchPrompt') }}</template>
<template v-else-if="normalizedQuery.length < MIN_QUERY_LENGTH">
{{ t('reader.pdf.sidebar.searchTooShort', { min: MIN_QUERY_LENGTH }) }}
</template>
<template v-else>{{ t('reader.pdf.sidebar.noSearchResults') }}</template>
</p>
</div>
<span class="sr-only" aria-live="polite">{{ t('reader.search.resultCount', { count: searchState.total }) }}</span>
</div>
</template>
<style>
@import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
</style>
@@ -0,0 +1,87 @@
<script setup lang="ts">
import type { AnnotationItem } from '@bookorbit/types'
import PdfNotesPanel from './PdfNotesPanel.vue'
import PdfOutlineTree from './PdfOutlineTree.vue'
import PdfSearchPanel from './PdfSearchPanel.vue'
import PdfThumbnailGrid from './PdfThumbnailGrid.vue'
import type { FlatPdfBookmark } from '../pdf-viewer-utils'
import type { PdfSidebarTab } from '../composables/usePdfSidebarLayout'
defineProps<{
documentId: string
activeTab: PdfSidebarTab
active: boolean
currentPage: number
totalPages: number
bookmarks: FlatPdfBookmark[]
bookmarksLoading: boolean
annotations: AnnotationItem[]
loadError?: boolean
loading?: boolean
loadingMore?: boolean
hasMore?: boolean
}>()
const emit = defineEmits<{
selectPage: [pageIndex: number]
navigateBookmark: [entry: FlatPdfBookmark]
navigateHighlight: [annotation: AnnotationItem]
deleteHighlight: [id: number]
retryHighlights: []
loadMoreHighlights: []
}>()
function handleSelectPage(pageIndex: number) {
emit('selectPage', pageIndex)
}
function handleNavigateBookmark(entry: FlatPdfBookmark) {
emit('navigateBookmark', entry)
}
function handleNavigateHighlight(annotation: AnnotationItem) {
emit('navigateHighlight', annotation)
}
function handleDeleteHighlight(id: number) {
emit('deleteHighlight', id)
}
function handleRetryHighlights() {
emit('retryHighlights')
}
function handleLoadMoreHighlights() {
emit('loadMoreHighlights')
}
</script>
<template>
<PdfThumbnailGrid
v-if="activeTab === 'thumbnails'"
:document-id="documentId"
:current-page="currentPage"
:total-pages="totalPages"
@select="handleSelectPage"
/>
<PdfOutlineTree
v-else-if="activeTab === 'contents'"
:bookmarks="bookmarks"
:loading="bookmarksLoading"
:current-page="currentPage"
@navigate="handleNavigateBookmark"
/>
<PdfSearchPanel v-else-if="activeTab === 'search'" :document-id="documentId" :active="active" />
<PdfNotesPanel
v-else
:annotations="annotations"
:load-error="loadError"
:loading="loading"
:loading-more="loadingMore"
:has-more="hasMore"
@navigate="handleNavigateHighlight"
@delete="handleDeleteHighlight"
@retry="handleRetryHighlights"
@load-more="handleLoadMoreHighlights"
/>
</template>
@@ -0,0 +1,188 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useElementSize } from '@vueuse/core'
import { Search } from '@lucide/vue'
import { RecycleScroller } from 'vue-virtual-scroller'
import { Rotation } from '@embedpdf/models'
import { useDocumentState } from '@embedpdf/core/vue'
import { ThumbImg } from '@embedpdf/plugin-thumbnail/vue'
const props = defineProps<{
documentId: string
currentPage: number
totalPages: number
}>()
const emit = defineEmits<{
select: [pageIndex: number]
}>()
const { t } = useI18n()
const GAP = 10
const MIN_CELL_WIDTH = 104
const LABEL_HEIGHT = 20
const MAX_COLUMNS = 6
const FALLBACK_ASPECT = 1.414
const documentState = useDocumentState(() => props.documentId)
const scroller = ref<{ scrollToItem: (index: number) => void } | null>(null)
const viewport = ref<HTMLElement | null>(null)
const { width: viewportWidth } = useElementSize(viewport)
const jumpValue = ref('')
const pageAspects = computed(() => {
const pages = documentState.value?.document?.pages ?? []
if (pages.length === 0) return Array.from({ length: props.totalPages }, () => FALLBACK_ASPECT)
return pages.map((page) => {
const quarterTurned = page.rotation === Rotation.Degree90 || page.rotation === Rotation.Degree270
const width = quarterTurned ? page.size.height : page.size.width
const height = quarterTurned ? page.size.width : page.size.height
return width > 0 ? height / width : FALLBACK_ASPECT
})
})
const pageCount = computed(() => pageAspects.value.length)
const columns = computed(() => {
const available = viewportWidth.value - GAP
if (available <= 0) return 2
return Math.min(Math.max(Math.floor(available / (MIN_CELL_WIDTH + GAP)), 1), MAX_COLUMNS)
})
const cellWidth = computed(() => {
const available = viewportWidth.value - GAP * (columns.value + 1)
return available > 0 ? available / columns.value : MIN_CELL_WIDTH
})
// One row height for every row keeps the list uniformly virtualizable; the tallest
// page in the document sets it and shorter pages are centred inside their cell.
const tallestAspect = computed(() => pageAspects.value.reduce((tallest, aspect) => Math.max(tallest, aspect), FALLBACK_ASPECT))
const rowHeight = computed(() => Math.round(cellWidth.value * tallestAspect.value) + LABEL_HEIGHT + GAP)
const rows = computed(() => {
const perRow = columns.value
const result: { id: number; pageIndexes: number[] }[] = []
for (let start = 0; start < pageCount.value; start += perRow) {
result.push({
id: start,
pageIndexes: Array.from({ length: Math.min(perRow, pageCount.value - start) }, (_, offset) => start + offset),
})
}
return result
})
function cellStyle(pageIndex: number) {
const aspect = pageAspects.value[pageIndex] ?? FALLBACK_ASPECT
return {
width: `${cellWidth.value}px`,
height: `${Math.round(cellWidth.value * aspect)}px`,
}
}
function handleSelect(pageIndex: number) {
emit('select', pageIndex)
}
function handleJump(event: Event) {
const input = event.target as HTMLInputElement
const page = Number.parseInt(input.value, 10)
if (!Number.isFinite(page)) {
jumpValue.value = ''
return
}
const clamped = Math.min(Math.max(page, 1), pageCount.value || 1)
jumpValue.value = ''
input.blur()
emit('select', clamped - 1)
}
async function revealCurrentPage() {
await nextTick()
const rowIndex = Math.floor((props.currentPage - 1) / columns.value)
if (rowIndex < 0 || !Number.isFinite(rowIndex)) return
scroller.value?.scrollToItem(rowIndex)
}
watch(
() => [props.currentPage, columns.value] as const,
() => void revealCurrentPage(),
{ immediate: true, flush: 'post' },
)
</script>
<template>
<div class="flex min-h-0 flex-1 flex-col">
<div class="shrink-0 border-b border-border p-3">
<div class="relative">
<Search :size="15" class="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
<input
v-model="jumpValue"
type="search"
inputmode="numeric"
:placeholder="t('reader.pdf.sidebar.jumpToPage')"
:aria-label="t('reader.pdf.sidebar.jumpToPage')"
class="h-9 w-full rounded-md border border-border bg-background ps-9 pe-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary"
@change="handleJump"
@keydown.enter="handleJump"
/>
</div>
</div>
<div ref="viewport" class="min-h-0 flex-1 bg-muted/25">
<RecycleScroller
v-if="rows.length > 0 && viewportWidth > 0"
ref="scroller"
v-slot="{ item }"
class="h-full min-w-0 overflow-x-hidden"
:items="rows"
:item-size="rowHeight"
key-field="id"
>
<div class="flex" :style="{ gap: `${GAP}px`, paddingInline: `${GAP}px`, paddingBlockEnd: `${GAP}px` }">
<button
v-for="pageIndex in item.pageIndexes"
:key="pageIndex"
type="button"
class="flex shrink-0 flex-col items-center gap-1 rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
:style="{ width: `${cellWidth}px` }"
:aria-label="t('reader.pdf.sidebar.goToPage', { page: pageIndex + 1 })"
:aria-current="props.currentPage === pageIndex + 1 ? 'page' : undefined"
@click="handleSelect(pageIndex)"
>
<span
class="flex items-center justify-center overflow-hidden rounded border-2 bg-background transition-colors"
:class="
props.currentPage === pageIndex + 1 ? 'border-primary ring-2 ring-primary/15' : 'border-border hover:border-muted-foreground/50'
"
:style="cellStyle(pageIndex)"
>
<ThumbImg
:document-id="props.documentId"
:meta="{
pageIndex,
width: cellWidth,
height: cellWidth * (pageAspects[pageIndex] ?? 1.414),
wrapperHeight: rowHeight,
top: 0,
labelHeight: 0,
}"
class="h-full w-full object-contain"
alt=""
/>
</span>
<span class="text-xs tabular-nums" :class="props.currentPage === pageIndex + 1 ? 'font-semibold text-primary' : 'text-muted-foreground'">
{{ pageIndex + 1 }}
</span>
</button>
</div>
</RecycleScroller>
</div>
</div>
</template>
<style>
@import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
</style>
@@ -0,0 +1,98 @@
import { computed, ref, watch } from 'vue'
import { breakpointsTailwind, useBreakpoints, useLocalStorage } from '@vueuse/core'
export type PdfSidebarTab = 'thumbnails' | 'contents' | 'search' | 'highlights'
/**
* `sheet` is a phone bottom sheet, `overlay` floats above the page on tablets,
* `dock` sits beside the page and reflows it on wide screens.
*/
export type PdfSidebarLayout = 'sheet' | 'overlay' | 'dock'
export const PDF_SIDEBAR_TABS: readonly PdfSidebarTab[] = ['thumbnails', 'contents', 'search', 'highlights']
export const PDF_SIDEBAR_MIN_WIDTH = 260
export const PDF_SIDEBAR_MAX_WIDTH = 520
const PDF_SIDEBAR_DEFAULT_WIDTH = 304
const WIDTH_KEY = 'reader:pdf:sidebarWidth'
const TAB_KEY = 'reader:pdf:sidebarTab'
const OPEN_KEY = 'reader:pdf:sidebarOpen'
function isSidebarTab(value: unknown): value is PdfSidebarTab {
return typeof value === 'string' && (PDF_SIDEBAR_TABS as readonly string[]).includes(value)
}
export function clampSidebarWidth(width: number): number {
if (!Number.isFinite(width)) return PDF_SIDEBAR_DEFAULT_WIDTH
return Math.min(Math.max(Math.round(width), PDF_SIDEBAR_MIN_WIDTH), PDF_SIDEBAR_MAX_WIDTH)
}
export function usePdfSidebarLayout() {
const breakpoints = useBreakpoints(breakpointsTailwind)
const isTabletUp = breakpoints.greaterOrEqual('md')
const isDesktopUp = breakpoints.greaterOrEqual('lg')
const layout = computed<PdfSidebarLayout>(() => {
if (!isTabletUp.value) return 'sheet'
return isDesktopUp.value ? 'dock' : 'overlay'
})
const storedWidth = useLocalStorage(WIDTH_KEY, PDF_SIDEBAR_DEFAULT_WIDTH)
const storedTab = useLocalStorage<PdfSidebarTab>(TAB_KEY, 'thumbnails')
const storedOpen = useLocalStorage(OPEN_KEY, false)
const width = computed(() => clampSidebarWidth(storedWidth.value))
const activeTab = computed<PdfSidebarTab>(() => (isSidebarTab(storedTab.value) ? storedTab.value : 'thumbnails'))
// A remembered open panel is only restored where it does not cover the page.
const open = ref(layout.value === 'dock' && storedOpen.value)
function setOpen(next: boolean) {
open.value = next
if (layout.value === 'dock') storedOpen.value = next
}
function setTab(tab: PdfSidebarTab) {
storedTab.value = tab
}
function selectTab(tab: PdfSidebarTab) {
if (open.value && activeTab.value === tab) {
setOpen(false)
return
}
setTab(tab)
setOpen(true)
}
function close() {
setOpen(false)
}
function setWidth(next: number) {
storedWidth.value = clampSidebarWidth(next)
}
// Collapsing the viewport must not leave an overlay stranded over the page.
watch(layout, (next, previous) => {
if (next === previous) return
if (next === 'sheet') {
open.value = false
return
}
if (next === 'dock') open.value = storedOpen.value
})
return {
layout,
open,
activeTab,
width,
setOpen,
setTab,
selectTab,
close,
setWidth,
}
}
@@ -1,4 +1,4 @@
import { Rotation, type PdfBookmarkObject } from '@embedpdf/models'
import { PdfActionType, Rotation, type PdfBookmarkObject } from '@embedpdf/models'
import { ScrollStrategy } from '@embedpdf/plugin-scroll'
import { SpreadMode } from '@embedpdf/plugin-spread'
import { ZoomMode, type ZoomLevel } from '@embedpdf/plugin-zoom'
@@ -7,6 +7,7 @@ import type { PdfReaderSettings } from '@bookorbit/types'
export interface FlatPdfBookmark {
bookmark: PdfBookmarkObject
depth: number
pageIndex: number | null
}
export function toScrollStrategy(mode: PdfReaderSettings['scrollMode']): ScrollStrategy {
@@ -40,8 +41,34 @@ export function fromRotation(rotation: Rotation): PdfReaderSettings['rotation']
return 0
}
export function getPdfBookmarkPageIndex(bookmark: PdfBookmarkObject): number | null {
const target = bookmark.target
const destination =
target?.type === 'destination' ? target.destination : target?.action.type === PdfActionType.Goto ? target.action.destination : null
return destination && Number.isInteger(destination.pageIndex) && destination.pageIndex >= 0 ? destination.pageIndex : null
}
export function flattenPdfBookmarks(bookmarks: PdfBookmarkObject[], depth = 0): FlatPdfBookmark[] {
return bookmarks.flatMap((bookmark) => [{ bookmark, depth }, ...flattenPdfBookmarks(bookmark.children ?? [], depth + 1)])
return bookmarks.flatMap((bookmark) => [
{ bookmark, depth, pageIndex: getPdfBookmarkPageIndex(bookmark) },
...flattenPdfBookmarks(bookmark.children ?? [], depth + 1),
])
}
export function findActivePdfBookmarkIndex(bookmarks: FlatPdfBookmark[], currentPage: number): number | null {
if (!Number.isInteger(currentPage) || currentPage < 1) return null
const currentPageIndex = currentPage - 1
let activeIndex: number | null = null
let activePageIndex = -1
for (const [index, bookmark] of bookmarks.entries()) {
if (bookmark.pageIndex === null || bookmark.pageIndex > currentPageIndex || bookmark.pageIndex < activePageIndex) continue
activeIndex = index
activePageIndex = bookmark.pageIndex
}
return activeIndex
}
export function safeExternalPdfUrl(uri: string): URL | null {
@@ -0,0 +1,150 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { usePreferredReducedMotion } from '@vueuse/core'
import { Sheet, SheetContent } from '@/components/ui/sheet'
export type ReaderSheetSnap = 'peek' | 'full'
const PEEK_RATIO = 0.56
const FULL_RATIO = 0.92
const DISMISS_RATIO = 0.3
const SNAP_MIDPOINT = (PEEK_RATIO + FULL_RATIO) / 2
const props = withDefaults(
defineProps<{
open: boolean
label: string
snap?: ReaderSheetSnap
}>(),
{ snap: 'peek' },
)
const emit = defineEmits<{
'update:open': [open: boolean]
'update:snap': [snap: ReaderSheetSnap]
}>()
const reducedMotion = usePreferredReducedMotion()
const draggedHeight = ref<number | null>(null)
const dragging = ref(false)
let pointerId: number | null = null
let startY = 0
let startHeight = 0
const snapRatio = computed(() => (props.snap === 'full' ? FULL_RATIO : PEEK_RATIO))
const sheetStyle = computed(() => ({
height: draggedHeight.value !== null ? `${draggedHeight.value}px` : `${Math.round(snapRatio.value * 100)}dvh`,
}))
function viewportHeight() {
return window.visualViewport?.height ?? window.innerHeight
}
function setSnap(snap: ReaderSheetSnap) {
if (snap !== props.snap) emit('update:snap', snap)
}
function toggleSnap() {
setSnap(props.snap === 'peek' ? 'full' : 'peek')
}
function handleGrabberKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowUp') {
event.preventDefault()
setSnap('full')
return
}
if (event.key === 'ArrowDown') {
event.preventDefault()
setSnap('peek')
return
}
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
toggleSnap()
}
function handlePointerDown(event: PointerEvent) {
if (!event.isPrimary) return
pointerId = event.pointerId
startY = event.clientY
startHeight = snapRatio.value * viewportHeight()
draggedHeight.value = startHeight
dragging.value = true
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
}
function handlePointerMove(event: PointerEvent) {
if (!dragging.value || event.pointerId !== pointerId) return
const available = viewportHeight()
// Dragging up grows the sheet, so the delta is inverted.
draggedHeight.value = Math.min(Math.max(startHeight - (event.clientY - startY), 0), available * FULL_RATIO)
}
function handlePointerUp(event: PointerEvent) {
if (event.pointerId !== pointerId) return
const height = draggedHeight.value
dragging.value = false
pointerId = null
draggedHeight.value = null
if (height === null) return
const ratio = height / viewportHeight()
if (ratio < DISMISS_RATIO) {
emit('update:open', false)
return
}
setSnap(ratio > SNAP_MIDPOINT ? 'full' : 'peek')
}
function handleOpenChange(open: boolean) {
emit('update:open', open)
}
// A sheet reopened after a drag must not inherit the pixel height it was dropped at.
watch(
() => props.open,
(open) => {
if (open) return
dragging.value = false
pointerId = null
draggedHeight.value = null
},
)
</script>
<template>
<Sheet :open="props.open" @update:open="handleOpenChange">
<!--
dvh, not vh: `vh` resolves against the large viewport, so with a phone browser's toolbars on
screen the sheet covers everything and leaves no overlay to tap.
-->
<SheetContent
side="bottom"
hide-close
class="max-h-[92dvh] gap-0 overflow-hidden rounded-t-2xl border-border bg-card p-0"
:class="dragging || reducedMotion === 'reduce' ? '!transition-none !duration-0' : 'transition-[height] duration-200'"
:style="sheetStyle"
:aria-label="props.label"
>
<div
class="flex h-8 shrink-0 cursor-grab touch-none items-center justify-center active:cursor-grabbing"
role="button"
tabindex="0"
:aria-label="props.snap === 'full' ? $t('reader.sheet.collapse') : $t('reader.sheet.expand')"
:aria-expanded="props.snap === 'full'"
@click="toggleSnap"
@keydown="handleGrabberKeydown"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@pointercancel="handlePointerUp"
>
<span aria-hidden="true" class="h-1 w-10 rounded-full bg-border" />
</div>
<slot />
</SheetContent>
</Sheet>
</template>
+12 -4
View File
@@ -1932,7 +1932,6 @@
"wordSpacingHint": "Space between words. Book default preserves the publisher's spacing.",
"textIndent": "First-line indent",
"textIndentHint": "Indent at the start of ordinary paragraphs. Book default preserves the publisher's indentation.",
"bookDefault": "Book default",
"custom": "Custom",
"emValue": "{value} em",
"justify": "Justify text",
@@ -8641,12 +8640,17 @@
"searchPrompt": "Type a term to search this PDF.",
"searchTooShort": "Enter at least {min} characters to search this PDF.",
"noSearchResults": "No searchable text matched your query.",
"page": "Page {page}"
"page": "Page {page}",
"filterOutline": "Filter outline",
"noOutlineMatch": "No outline entries match your filter.",
"loadingOutline": "Loading outline",
"expandSection": "Expand {title}",
"collapseSection": "Collapse {title}",
"jumpToPage": "Jump to page",
"resizePanel": "Resize navigation panel"
},
"toolbar": {
"navigation": "Document navigation",
"search": "Search document",
"searchShort": "Search",
"previousPage": "Previous page",
"currentPage": "Current page",
"nextPage": "Next page",
@@ -8957,6 +8961,10 @@
"playerSettings": "Player settings",
"skipBack": "Skip back",
"skipForward": "Skip forward"
},
"sheet": {
"expand": "Expand panel",
"collapse": "Collapse panel"
}
},
"scanner": {