mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -1,5 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { defineComponent, h } from 'vue'
|
||||||
import { MatchFlag, type SearchResult } from '@embedpdf/models'
|
import { MatchFlag, type SearchResult } from '@embedpdf/models'
|
||||||
import type { AnnotationItem } from '@bookorbit/types'
|
import type { AnnotationItem } from '@bookorbit/types'
|
||||||
|
|
||||||
@@ -25,16 +26,27 @@ const mocks = vi.hoisted(() => ({
|
|||||||
scrollScope: {
|
scrollScope: {
|
||||||
scrollToPage: vi.fn<(options: Record<string, unknown>) => void>(),
|
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 () => {
|
vi.mock('@embedpdf/plugin-search/vue', async () => {
|
||||||
const { defineComponent, h } = await import('vue')
|
const { defineComponent: define, h: create } = await import('vue')
|
||||||
return {
|
return {
|
||||||
SearchLayer: defineComponent({
|
SearchLayer: define({
|
||||||
setup:
|
setup:
|
||||||
(_, { slots }) =>
|
(_, { slots }) =>
|
||||||
() =>
|
() =>
|
||||||
h('div', slots.default?.()),
|
create('div', slots.default?.()),
|
||||||
}),
|
}),
|
||||||
useSearch: () => ({ state: mocks.searchState, provides: { value: mocks.searchScope } }),
|
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', () => ({
|
vi.mock('@embedpdf/plugin-scroll/vue', () => ({
|
||||||
useScroll: () => ({
|
useScroll: () => ({
|
||||||
state: { __v_isRef: true, value: { currentPage: 1, totalPages: 10 } },
|
state: mocks.scrollState,
|
||||||
provides: { value: mocks.scrollScope },
|
provides: { value: mocks.scrollScope },
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@embedpdf/plugin-bookmark/vue', () => ({
|
vi.mock('@embedpdf/plugin-bookmark/vue', () => ({
|
||||||
useBookmarkCapability: () => ({ provides: { value: null } }),
|
useBookmarkCapability: () => ({ provides: mocks.bookmarkCapability }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@embedpdf/plugin-annotation/vue', () => ({
|
vi.mock('@embedpdf/plugin-annotation/vue', () => ({
|
||||||
useAnnotationCapability: () => ({ provides: { value: null } }),
|
useAnnotationCapability: () => ({ provides: { value: null } }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@embedpdf/core/vue', () => ({
|
||||||
|
useDocumentState: () => mocks.documentState,
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@embedpdf/plugin-thumbnail/vue', async () => {
|
vi.mock('@embedpdf/plugin-thumbnail/vue', async () => {
|
||||||
const { defineComponent, h } = await import('vue')
|
const { defineComponent: define, h: create } = await import('vue')
|
||||||
return {
|
return {
|
||||||
ThumbImg: defineComponent({ setup: () => () => h('img') }),
|
ThumbImg: define({ setup: () => () => create('img') }),
|
||||||
ThumbnailsPane: defineComponent({
|
|
||||||
setup:
|
|
||||||
(_, { slots }) =>
|
|
||||||
() =>
|
|
||||||
h('div', slots.default?.()),
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
vi.mock('vue-virtual-scroller', async () => {
|
vi.mock('vue-virtual-scroller', async () => {
|
||||||
const { defineComponent, h } = await import('vue')
|
const { defineComponent: define, h: create } = await import('vue')
|
||||||
return {
|
const listStub = (fallbackTestId: string) =>
|
||||||
RecycleScroller: defineComponent({
|
define({
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
props: { items: { type: Array, required: true } },
|
props: { items: { type: Array, required: true } },
|
||||||
setup(props, { attrs, slots }) {
|
setup(props, { attrs, slots }) {
|
||||||
return () =>
|
return () =>
|
||||||
h(
|
create(
|
||||||
'div',
|
'div',
|
||||||
{ ...attrs, 'data-testid': 'search-results' },
|
{ ...attrs, 'data-testid': attrs['data-testid'] ?? fallbackTestId },
|
||||||
props.items.map((item, index) => slots.default?.({ item, index })),
|
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'
|
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 {
|
function makeResult(rects: SearchResult['rects'] = []): SearchResult {
|
||||||
return {
|
return {
|
||||||
pageIndex: 4,
|
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 {
|
return {
|
||||||
id,
|
id,
|
||||||
bookId: 9,
|
bookId: 9,
|
||||||
@@ -121,9 +169,24 @@ function makeAnnotation(id: number): AnnotationItem {
|
|||||||
chapterIndex: null,
|
chapterIndex: null,
|
||||||
createdAt: '2026-01-01T00:00:00.000Z',
|
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 }] },
|
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', () => {
|
describe('PdfReaderSidebar', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
@@ -138,10 +201,13 @@ describe('PdfReaderSidebar', () => {
|
|||||||
loading: false,
|
loading: false,
|
||||||
active: 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 () => {
|
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"]')
|
const input = wrapper.get('input[type="search"]')
|
||||||
mocks.searchScope.stopSearch.mockClear()
|
mocks.searchScope.stopSearch.mockClear()
|
||||||
|
|
||||||
@@ -160,7 +226,7 @@ describe('PdfReaderSidebar', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('does not search for a single character', async () => {
|
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()
|
mocks.searchScope.stopSearch.mockClear()
|
||||||
|
|
||||||
await wrapper.get('input[type="search"]').setValue('a')
|
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 () => {
|
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.setFlags).toHaveBeenCalledWith([MatchFlag.MatchCase])
|
||||||
expect(mocks.searchScope.searchAllPages).not.toHaveBeenCalled()
|
expect(mocks.searchScope.searchAllPages).not.toHaveBeenCalled()
|
||||||
@@ -187,7 +253,7 @@ describe('PdfReaderSidebar', () => {
|
|||||||
total: 1,
|
total: 1,
|
||||||
active: true,
|
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('aside').classes()).toContain('overflow-hidden')
|
||||||
expect(wrapper.get('[data-testid="search-results"]').classes()).toContain('overflow-x-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 })
|
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 () => {
|
it('reports full-document search progress', async () => {
|
||||||
mocks.searchState.value = {
|
mocks.searchState.value = {
|
||||||
...mocks.searchState.value,
|
...mocks.searchState.value,
|
||||||
@@ -208,7 +288,7 @@ describe('PdfReaderSidebar', () => {
|
|||||||
onProgress: (callback: (progress: { page: number }) => void) => callback({ page: 4 }),
|
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)
|
await vi.advanceTimersByTimeAsync(250)
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('Searching page 5 of 10')
|
expect(wrapper.text()).toContain('Searching page 5 of 10')
|
||||||
@@ -222,7 +302,7 @@ describe('PdfReaderSidebar', () => {
|
|||||||
activeResultIndex: 0,
|
activeResultIndex: 0,
|
||||||
active: true,
|
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"]')
|
const input = wrapper.get('input[type="search"]')
|
||||||
|
|
||||||
await input.trigger('keydown', { key: 'Enter' })
|
await input.trigger('keydown', { key: 'Enter' })
|
||||||
@@ -233,11 +313,8 @@ describe('PdfReaderSidebar', () => {
|
|||||||
expect(mocks.searchScope.goToResult).toHaveBeenLastCalledWith(0)
|
expect(mocks.searchScope.goToResult).toHaveBeenLastCalledWith(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('clears the query, cancels search work, and restores input focus', async () => {
|
it('clears the query and cancels search work', async () => {
|
||||||
const wrapper = mount(PdfReaderSidebar, {
|
const wrapper = mountSidebar()
|
||||||
props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true, annotations: [] },
|
|
||||||
attachTo: document.body,
|
|
||||||
})
|
|
||||||
const input = wrapper.get('input[type="search"]')
|
const input = wrapper.get('input[type="search"]')
|
||||||
await input.setValue('distance')
|
await input.setValue('distance')
|
||||||
mocks.searchScope.stopSearch.mockClear()
|
mocks.searchScope.stopSearch.mockClear()
|
||||||
@@ -246,14 +323,11 @@ describe('PdfReaderSidebar', () => {
|
|||||||
|
|
||||||
expect((input.element as HTMLInputElement).value).toBe('')
|
expect((input.element as HTMLInputElement).value).toBe('')
|
||||||
expect(mocks.searchScope.stopSearch).toHaveBeenCalled()
|
expect(mocks.searchScope.stopSearch).toHaveBeenCalled()
|
||||||
expect(document.activeElement).toBe(input.element)
|
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows an accessible retry state instead of an empty highlight list after load failure', async () => {
|
it('shows an accessible retry state instead of an empty highlight list after load failure', async () => {
|
||||||
const wrapper = mount(PdfReaderSidebar, {
|
const wrapper = mountSidebar({ activeTab: 'highlights', loadError: true })
|
||||||
props: { documentId: 'doc-1', activeTab: 'highlights', headerVisible: true, annotations: [], loadError: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(wrapper.get('[role="alert"]').text()).toContain('Could not load highlights')
|
expect(wrapper.get('[role="alert"]').text()).toContain('Could not load highlights')
|
||||||
expect(wrapper.text()).not.toContain('No highlights yet')
|
expect(wrapper.text()).not.toContain('No highlights yet')
|
||||||
@@ -261,41 +335,60 @@ describe('PdfReaderSidebar', () => {
|
|||||||
expect(wrapper.emitted('retryHighlights')).toHaveLength(1)
|
expect(wrapper.emitted('retryHighlights')).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('virtualizes highlight rows and keeps delete available without hover', async () => {
|
it('virtualizes highlight rows and keeps delete available without hover', () => {
|
||||||
const wrapper = mount(PdfReaderSidebar, {
|
const wrapper = mountSidebar({ activeTab: 'highlights', annotations: [makeAnnotation(1)] })
|
||||||
props: { documentId: 'doc-1', activeTab: 'highlights', headerVisible: true, annotations: [makeAnnotation(1)] },
|
|
||||||
})
|
|
||||||
|
|
||||||
const deleteButton = wrapper.get('button[aria-label="Delete highlight"]')
|
const deleteButton = wrapper.get('button[aria-label="Delete highlight"]')
|
||||||
expect(deleteButton.classes()).toContain('opacity-100')
|
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', () => {
|
it('sizes highlight rows to their content instead of a fixed row height', () => {
|
||||||
const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true, annotations: [] } })
|
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"]')
|
const tabs = wrapper.findAll('[role="tab"]')
|
||||||
expect(tabs).toHaveLength(4)
|
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('aria-selected')).toBe('true')
|
||||||
expect(active.attributes('tabindex')).toBe('0')
|
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"]')
|
const panel = wrapper.get('[role="tabpanel"]')
|
||||||
expect(panel.attributes('id')).toBe(active.attributes('aria-controls'))
|
expect(panel.attributes('id')).toBe(active.attributes('aria-controls'))
|
||||||
expect(panel.attributes('aria-labelledby')).toBe(active.attributes('id'))
|
expect(panel.attributes('aria-labelledby')).toBe(active.attributes('id'))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('moves between tabs with arrow keys', async () => {
|
it('moves between rail destinations with arrow keys', async () => {
|
||||||
const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true, annotations: [] } })
|
const wrapper = mountSidebar()
|
||||||
const tabList = wrapper.get('[role="tablist"]')
|
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'])
|
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'])
|
expect(wrapper.emitted('update:activeTab')?.at(-1)).toEqual(['contents'])
|
||||||
|
|
||||||
await tabList.trigger('keydown', { key: 'Home' })
|
await tabList.trigger('keydown', { key: 'Home' })
|
||||||
@@ -308,8 +401,110 @@ describe('PdfReaderSidebar', () => {
|
|||||||
expect(wrapper.emitted('update:activeTab')).toHaveLength(4)
|
expect(wrapper.emitted('update:activeTab')).toHaveLength(4)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('prompts for a query instead of showing an empty search list', async () => {
|
it('keeps the rail available while the panel is closed', () => {
|
||||||
const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true, annotations: [] } })
|
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.text()).toContain('Type a term to search this PDF.')
|
||||||
expect(wrapper.find('[data-testid="search-results"]').exists()).toBe(false)
|
expect(wrapper.find('[data-testid="search-results"]').exists()).toBe(false)
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ function mountToolbar(overrides: Partial<InstanceType<typeof PdfReaderToolbar>['
|
|||||||
totalPages: 240,
|
totalPages: 240,
|
||||||
zoomPercent: 100,
|
zoomPercent: 100,
|
||||||
sidebarOpen: false,
|
sidebarOpen: false,
|
||||||
searchOpen: false,
|
showSidebarToggle: true,
|
||||||
highlightsOpen: false,
|
|
||||||
settingsOpen: false,
|
settingsOpen: false,
|
||||||
panActive: true,
|
panActive: true,
|
||||||
fullscreen: false,
|
fullscreen: false,
|
||||||
@@ -55,22 +54,36 @@ describe('PdfReaderToolbar', () => {
|
|||||||
expect((input.element as HTMLInputElement).value).toBe('240')
|
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()
|
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)
|
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')
|
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() === 'Select text')!.trigger('click')
|
||||||
await buttons.find((button) => button.text().trim() === 'Pan')!.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('selectTool')).toHaveLength(1)
|
||||||
expect(wrapper.emitted('togglePan')).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 () => {
|
it('shows the header pin only while fullscreen', async () => {
|
||||||
const windowed = mountToolbar()
|
const windowed = mountToolbar()
|
||||||
expect(windowed.find('button[aria-label="Pin reader header"]').exists()).toBe(false)
|
expect(windowed.find('button[aria-label="Pin reader header"]').exists()).toBe(false)
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
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 { ScrollStrategy } from '@embedpdf/plugin-scroll'
|
||||||
import { SpreadMode } from '@embedpdf/plugin-spread'
|
import { SpreadMode } from '@embedpdf/plugin-spread'
|
||||||
import { ZoomMode } from '@embedpdf/plugin-zoom'
|
import { ZoomMode } from '@embedpdf/plugin-zoom'
|
||||||
import type { PdfReaderSettings } from '@bookorbit/types'
|
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 = {
|
const settings: PdfReaderSettings = {
|
||||||
scrollMode: 'vertical',
|
scrollMode: 'vertical',
|
||||||
@@ -14,6 +24,10 @@ const settings: PdfReaderSettings = {
|
|||||||
rotation: 0,
|
rotation: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function destination(pageIndex: number): PdfDestinationObject {
|
||||||
|
return { pageIndex, zoom: { mode: PdfZoomMode.FitPage }, view: [] }
|
||||||
|
}
|
||||||
|
|
||||||
describe('PDF viewer settings mapping', () => {
|
describe('PDF viewer settings mapping', () => {
|
||||||
it('maps page mode to the vertical strategy used by paginated navigation', () => {
|
it('maps page mode to the vertical strategy used by paginated navigation', () => {
|
||||||
expect(toScrollStrategy('page')).toBe(ScrollStrategy.Vertical)
|
expect(toScrollStrategy('page')).toBe(ScrollStrategy.Vertical)
|
||||||
@@ -59,6 +73,37 @@ describe('flattenPdfBookmarks', () => {
|
|||||||
['Section', 2],
|
['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', () => {
|
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 PdfDocumentViewport from './PdfDocumentViewport.vue'
|
||||||
import PdfPasswordPrompt from './PdfPasswordPrompt.vue'
|
import PdfPasswordPrompt from './PdfPasswordPrompt.vue'
|
||||||
import PdfReaderSettingsPanel from './PdfReaderSettingsPanel.vue'
|
import PdfReaderSettingsPanel from './PdfReaderSettingsPanel.vue'
|
||||||
import PdfReaderSidebar, { type PdfSidebarTab } from './PdfReaderSidebar.vue'
|
import PdfReaderSidebar from './PdfReaderSidebar.vue'
|
||||||
import PdfReaderToolbar from './PdfReaderToolbar.vue'
|
import PdfReaderToolbar from './PdfReaderToolbar.vue'
|
||||||
import { fromRotation, safeExternalPdfUrl } from '../pdf-viewer-utils'
|
import { fromRotation, safeExternalPdfUrl } from '../pdf-viewer-utils'
|
||||||
import { usePdfFullscreenChrome } from '../composables/usePdfFullscreenChrome'
|
import { usePdfFullscreenChrome } from '../composables/usePdfFullscreenChrome'
|
||||||
import { usePdfHighlights } from '../composables/usePdfHighlights'
|
import { usePdfHighlights } from '../composables/usePdfHighlights'
|
||||||
import { usePdfPagination } from '../composables/usePdfPagination'
|
import { usePdfPagination } from '../composables/usePdfPagination'
|
||||||
import { usePdfResponsiveSpread } from '../composables/usePdfResponsiveSpread'
|
import { usePdfResponsiveSpread } from '../composables/usePdfResponsiveSpread'
|
||||||
|
import { usePdfSidebarLayout, type PdfSidebarTab } from '../composables/usePdfSidebarLayout'
|
||||||
import NoteDialog from '../../shared/components/NoteDialog.vue'
|
import NoteDialog from '../../shared/components/NoteDialog.vue'
|
||||||
import PdfSelectionPopup from './PdfSelectionPopup.vue'
|
import PdfSelectionPopup from './PdfSelectionPopup.vue'
|
||||||
|
|
||||||
@@ -56,8 +57,7 @@ const { provides: selectionCapability } = useSelectionCapability()
|
|||||||
const { provides: annotationCapability } = useAnnotationCapability()
|
const { provides: annotationCapability } = useAnnotationCapability()
|
||||||
const { isFullscreen, isFullscreenSupported, toggleFullscreen } = useFullscreen()
|
const { isFullscreen, isFullscreenSupported, toggleFullscreen } = useFullscreen()
|
||||||
|
|
||||||
const sidebarOpen = ref(false)
|
const sidebar = usePdfSidebarLayout()
|
||||||
const sidebarTab = ref<PdfSidebarTab>('thumbnails')
|
|
||||||
const settingsOpen = ref(false)
|
const settingsOpen = ref(false)
|
||||||
const pendingExternalUrl = ref<URL | null>(null)
|
const pendingExternalUrl = ref<URL | null>(null)
|
||||||
const restoredInitialPage = ref(false)
|
const restoredInitialPage = ref(false)
|
||||||
@@ -75,9 +75,7 @@ const currentZoomMode = computed<PdfReaderSettings['zoomMode']>(() => {
|
|||||||
})
|
})
|
||||||
const currentRotation = computed(() => fromRotation(rotation.value))
|
const currentRotation = computed(() => fromRotation(rotation.value))
|
||||||
const externalHost = computed(() => pendingExternalUrl.value?.hostname ?? '')
|
const externalHost = computed(() => pendingExternalUrl.value?.hostname ?? '')
|
||||||
const searchOpen = computed(() => sidebarOpen.value && sidebarTab.value === 'search')
|
const hasOpenUi = computed(() => sidebar.open.value || settingsOpen.value || pendingExternalUrl.value !== null)
|
||||||
const highlightsOpen = computed(() => sidebarOpen.value && sidebarTab.value === 'highlights')
|
|
||||||
const hasOpenUi = computed(() => sidebarOpen.value || settingsOpen.value || pendingExternalUrl.value !== null)
|
|
||||||
const {
|
const {
|
||||||
pinned: headerPinned,
|
pinned: headerPinned,
|
||||||
visible: headerVisible,
|
visible: headerVisible,
|
||||||
@@ -121,38 +119,19 @@ function handleZoomIn() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleToggleSidebar() {
|
function handleToggleSidebar() {
|
||||||
if (sidebarOpen.value && sidebarTab.value !== 'search') {
|
sidebar.setOpen(!sidebar.open.value)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSidebarClose() {
|
function handleSidebarClose() {
|
||||||
sidebarOpen.value = false
|
sidebar.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSidebarTab(tab: PdfSidebarTab) {
|
function handleSidebarTab(tab: PdfSidebarTab) {
|
||||||
sidebarTab.value = tab
|
sidebar.selectTab(tab)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSidebarWidth(width: number) {
|
||||||
|
sidebar.setWidth(width)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTogglePan() {
|
function handleTogglePan() {
|
||||||
@@ -197,7 +176,7 @@ function handleHighlightDismiss() {
|
|||||||
|
|
||||||
function handleNavigateHighlight(annotation: AnnotationItem) {
|
function handleNavigateHighlight(annotation: AnnotationItem) {
|
||||||
highlights.navigateTo(annotation)
|
highlights.navigateTo(annotation)
|
||||||
if (window.innerWidth < 768) sidebarOpen.value = false
|
if (sidebar.layout.value === 'sheet') sidebar.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDeleteHighlight(id: number) {
|
function handleDeleteHighlight(id: number) {
|
||||||
@@ -284,7 +263,7 @@ function handleKeydown(event: KeyboardEvent) {
|
|||||||
|
|
||||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'f') {
|
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'f') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
handleToggleSearch()
|
sidebar.selectTab('search')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'c' && !editing) {
|
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'c' && !editing) {
|
||||||
@@ -295,7 +274,7 @@ function handleKeydown(event: KeyboardEvent) {
|
|||||||
|
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
if (pendingExternalUrl.value) handleCancelExternalLink()
|
if (pendingExternalUrl.value) handleCancelExternalLink()
|
||||||
else if (sidebarOpen.value) handleSidebarClose()
|
else if (sidebar.open.value) handleSidebarClose()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.key === 'ArrowLeft' || event.key === 'PageUp') {
|
if (event.key === 'ArrowLeft' || event.key === 'PageUp') {
|
||||||
@@ -430,9 +409,8 @@ onUnmounted(() => {
|
|||||||
:current-page-end="pageRange.end"
|
:current-page-end="pageRange.end"
|
||||||
:total-pages="scrollState.totalPages"
|
:total-pages="scrollState.totalPages"
|
||||||
:zoom-percent="zoomPercent"
|
:zoom-percent="zoomPercent"
|
||||||
:sidebar-open="sidebarOpen"
|
:sidebar-open="sidebar.open.value"
|
||||||
:search-open="searchOpen"
|
:show-sidebar-toggle="sidebar.layout.value === 'sheet'"
|
||||||
:highlights-open="highlightsOpen"
|
|
||||||
:settings-open="settingsOpen"
|
:settings-open="settingsOpen"
|
||||||
:pan-active="isPanning"
|
:pan-active="isPanning"
|
||||||
:fullscreen="isFullscreen"
|
:fullscreen="isFullscreen"
|
||||||
@@ -446,8 +424,6 @@ onUnmounted(() => {
|
|||||||
@zoom-out="handleZoomOut"
|
@zoom-out="handleZoomOut"
|
||||||
@zoom-in="handleZoomIn"
|
@zoom-in="handleZoomIn"
|
||||||
@toggle-sidebar="handleToggleSidebar"
|
@toggle-sidebar="handleToggleSidebar"
|
||||||
@toggle-search="handleToggleSearch"
|
|
||||||
@toggle-highlights="handleToggleHighlights"
|
|
||||||
@toggle-pan="handleTogglePan"
|
@toggle-pan="handleTogglePan"
|
||||||
@select-tool="handleSelectTool"
|
@select-tool="handleSelectTool"
|
||||||
@toggle-fullscreen="toggleFullscreen"
|
@toggle-fullscreen="toggleFullscreen"
|
||||||
@@ -474,10 +450,11 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<div class="relative flex min-h-0 flex-1 overflow-hidden">
|
<div class="relative flex min-h-0 flex-1 overflow-hidden">
|
||||||
<PdfReaderSidebar
|
<PdfReaderSidebar
|
||||||
v-if="sidebarOpen"
|
|
||||||
:document-id="props.documentId"
|
:document-id="props.documentId"
|
||||||
:active-tab="sidebarTab"
|
:active-tab="sidebar.activeTab.value"
|
||||||
:header-visible="headerVisible"
|
:open="sidebar.open.value"
|
||||||
|
:layout="sidebar.layout.value"
|
||||||
|
:width="sidebar.width.value"
|
||||||
:annotations="highlights.annotations.value"
|
:annotations="highlights.annotations.value"
|
||||||
:load-error="highlights.loadError.value"
|
:load-error="highlights.loadError.value"
|
||||||
:loading="highlights.loading.value"
|
:loading="highlights.loading.value"
|
||||||
@@ -485,6 +462,7 @@ onUnmounted(() => {
|
|||||||
:has-more="highlights.hasMore.value"
|
:has-more="highlights.hasMore.value"
|
||||||
@close="handleSidebarClose"
|
@close="handleSidebarClose"
|
||||||
@update:active-tab="handleSidebarTab"
|
@update:active-tab="handleSidebarTab"
|
||||||
|
@update:width="handleSidebarWidth"
|
||||||
@navigate-highlight="handleNavigateHighlight"
|
@navigate-highlight="handleNavigateHighlight"
|
||||||
@delete-highlight="handleDeleteHighlight"
|
@delete-highlight="handleDeleteHighlight"
|
||||||
@retry-highlights="handleRetryHighlights"
|
@retry-highlights="handleRetryHighlights"
|
||||||
|
|||||||
@@ -1,146 +1,123 @@
|
|||||||
<script setup lang="ts">
|
<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 { useI18n } from 'vue-i18n'
|
||||||
import { BookOpen, ChevronLeft, ChevronRight, FileText, Highlighter, LoaderCircle, Search, StickyNote, Trash2, X } from '@lucide/vue'
|
import { BookOpen, FileText, Highlighter, Search, 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 { useAnnotationCapability } from '@embedpdf/plugin-annotation/vue'
|
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/vue'
|
||||||
import { useBookmarkCapability } from '@embedpdf/plugin-bookmark/vue'
|
import { useBookmarkCapability } from '@embedpdf/plugin-bookmark/vue'
|
||||||
import { useScroll } from '@embedpdf/plugin-scroll/vue'
|
import { useScroll } from '@embedpdf/plugin-scroll/vue'
|
||||||
import { useSearch } from '@embedpdf/plugin-search/vue'
|
import type { AnnotationItem } from '@bookorbit/types'
|
||||||
import { ThumbImg, ThumbnailsPane } from '@embedpdf/plugin-thumbnail/vue'
|
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 { 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 = withDefaults(
|
||||||
|
defineProps<{
|
||||||
const props = defineProps<{
|
documentId: string
|
||||||
documentId: string
|
activeTab: PdfSidebarTab
|
||||||
activeTab: PdfSidebarTab
|
open?: boolean
|
||||||
headerVisible: boolean
|
layout?: PdfSidebarLayout
|
||||||
annotations: AnnotationItem[]
|
width?: number
|
||||||
loadError?: boolean
|
annotations: AnnotationItem[]
|
||||||
loading?: boolean
|
loadError?: boolean
|
||||||
loadingMore?: boolean
|
loading?: boolean
|
||||||
hasMore?: boolean
|
loadingMore?: boolean
|
||||||
}>()
|
hasMore?: boolean
|
||||||
|
}>(),
|
||||||
const { t } = useI18n()
|
{ open: true, layout: 'dock', width: 304 },
|
||||||
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
close: []
|
close: []
|
||||||
'update:activeTab': [tab: PdfSidebarTab]
|
'update:activeTab': [tab: PdfSidebarTab]
|
||||||
|
'update:width': [width: number]
|
||||||
navigateHighlight: [annotation: AnnotationItem]
|
navigateHighlight: [annotation: AnnotationItem]
|
||||||
deleteHighlight: [id: number]
|
deleteHighlight: [id: number]
|
||||||
retryHighlights: []
|
retryHighlights: []
|
||||||
loadMoreHighlights: []
|
loadMoreHighlights: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const { state: scrollState, provides: scroll } = useScroll(() => props.documentId)
|
const { state: scrollState, provides: scroll } = useScroll(() => props.documentId)
|
||||||
const { state: searchState, provides: search } = useSearch(() => props.documentId)
|
|
||||||
const { provides: bookmarksCapability } = useBookmarkCapability()
|
const { provides: bookmarksCapability } = useBookmarkCapability()
|
||||||
const { provides: annotationCapability } = useAnnotationCapability()
|
const { provides: annotationCapability } = useAnnotationCapability()
|
||||||
|
|
||||||
const searchQuery = ref(searchState.value.query ?? '')
|
const sidebarId = `pdf-sidebar-${useId()}`
|
||||||
const searchPending = ref(false)
|
const sheetSnap = ref<ReaderSheetSnap>('peek')
|
||||||
const searchProgressPage = ref(0)
|
|
||||||
const searchInput = ref<HTMLInputElement | null>(null)
|
|
||||||
const bookmarks = ref<FlatPdfBookmark[]>([])
|
const bookmarks = ref<FlatPdfBookmark[]>([])
|
||||||
const bookmarksLoading = ref(false)
|
const bookmarksLoading = ref(false)
|
||||||
const bookmarksLoaded = ref(false)
|
const bookmarksLoaded = ref(false)
|
||||||
|
|
||||||
const normalizedSearchQuery = computed(() => searchQuery.value.trim())
|
const TAB_ICONS = { thumbnails: FileText, contents: BookOpen, search: Search, highlights: Highlighter } as const
|
||||||
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))
|
|
||||||
|
|
||||||
function getSearchResultKey(_result: SearchResult, index: number) {
|
const tabIds = computed(() => Object.fromEntries(PDF_SIDEBAR_TABS.map((tab) => [tab, `${sidebarId}-tab-${tab}`])) as Record<PdfSidebarTab, string>)
|
||||||
return index
|
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() {
|
function handleClose() {
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabs = computed(() => [
|
function handleSheetOpen(open: boolean) {
|
||||||
{ id: 'thumbnails' as const, icon: FileText, label: t('reader.pdf.sidebar.pages') },
|
if (!open) emit('close')
|
||||||
{ 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 panelId(tab: PdfSidebarTab) {
|
function handleSheetSnap(snap: ReaderSheetSnap) {
|
||||||
return `${sidebarId}-panel-${tab}`
|
sheetSnap.value = snap
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectTab(tab: PdfSidebarTab) {
|
function handleSheetTabKeydown(event: KeyboardEvent) {
|
||||||
emit('update:activeTab', tab)
|
const current = PDF_SIDEBAR_TABS.indexOf(props.activeTab)
|
||||||
}
|
|
||||||
|
|
||||||
function handleTabKeydown(event: KeyboardEvent) {
|
|
||||||
const options = tabs.value
|
|
||||||
const current = options.findIndex((tab) => tab.id === props.activeTab)
|
|
||||||
let next: number
|
let next: number
|
||||||
if (event.key === 'ArrowRight') next = (current + 1) % options.length
|
if (event.key === 'ArrowRight') next = (current + 1) % PDF_SIDEBAR_TABS.length
|
||||||
else if (event.key === 'ArrowLeft') next = (current - 1 + options.length) % options.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 === '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
|
else return
|
||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const nextTab = options[next]
|
const nextTab = PDF_SIDEBAR_TABS[next]
|
||||||
if (!nextTab) return
|
if (nextTab) selectTab(nextTab)
|
||||||
selectTab(nextTab.id)
|
|
||||||
void nextTick(() => tabListEl.value?.querySelectorAll<HTMLElement>('[role="tab"]')[next]?.focus())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const highlightQuery = ref('')
|
function handleThumbnail(pageIndex: number) {
|
||||||
const highlightColorFilter = ref('all')
|
scroll.value?.scrollToPage({ pageNumber: pageIndex + 1, behavior: 'smooth' })
|
||||||
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 })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const highlightColorOptions = computed(() =>
|
function handleBookmark(entry: FlatPdfBookmark) {
|
||||||
Array.from(new Set(props.annotations.map((annotation) => annotation.color)))
|
const target = entry.bookmark.target
|
||||||
.map((hex) => ({ hex, label: getHighlightColorLabel(hex) }))
|
if (!target) return
|
||||||
.sort((a, b) => a.label.localeCompare(b.label)),
|
annotationCapability.value?.forDocument(props.documentId).navigateTarget(target)
|
||||||
)
|
|
||||||
|
|
||||||
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 handleNavigateHighlight(annotation: AnnotationItem) {
|
function handleNavigateHighlight(annotation: AnnotationItem) {
|
||||||
@@ -159,94 +136,6 @@ function handleLoadMoreHighlights() {
|
|||||||
emit('loadMoreHighlights')
|
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() {
|
function loadBookmarks() {
|
||||||
if (bookmarksLoaded.value || bookmarksLoading.value || !bookmarksCapability.value) return
|
if (bookmarksLoaded.value || bookmarksLoading.value || !bookmarksCapability.value) return
|
||||||
bookmarksLoading.value = true
|
bookmarksLoading.value = true
|
||||||
@@ -267,324 +156,185 @@ function loadBookmarks() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[searchQuery, () => props.activeTab],
|
() => [props.activeTab, props.open, bookmarksCapability.value] as const,
|
||||||
([query, activeTab], _previous, onCleanup) => {
|
([tab, open]) => {
|
||||||
const normalized = query.trim()
|
if (open && tab === 'contents') loadBookmarks()
|
||||||
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())
|
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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
|
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="relative flex h-full min-h-0 shrink-0 flex-col overflow-hidden border-e border-border bg-card text-card-foreground"
|
||||||
:class="props.headerVisible ? 'top-11' : 'top-0'"
|
:class="props.layout === 'overlay' ? 'absolute inset-y-0 start-14 z-30 shadow-2xl' : ''"
|
||||||
@click.self="handleClose"
|
:style="{ width: `${props.width}px` }"
|
||||||
>
|
>
|
||||||
<aside
|
<div class="flex h-11 shrink-0 items-center gap-2 border-b border-border ps-3 pe-1">
|
||||||
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]"
|
<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">
|
<PdfSidebarPanes
|
||||||
<div
|
:document-id="props.documentId"
|
||||||
ref="tabListEl"
|
:active-tab="props.activeTab"
|
||||||
role="tablist"
|
:active="props.open"
|
||||||
:aria-label="t('reader.pdf.sidebar.tabsLabel')"
|
:current-page="scrollState.currentPage"
|
||||||
class="flex min-w-0 flex-1 items-stretch"
|
:total-pages="scrollState.totalPages"
|
||||||
@keydown="handleTabKeydown"
|
:bookmarks="bookmarks"
|
||||||
>
|
:bookmarks-loading="bookmarksLoading"
|
||||||
<button
|
:annotations="props.annotations"
|
||||||
v-for="tab in tabs"
|
:load-error="props.loadError"
|
||||||
:id="tabId(tab.id)"
|
:loading="props.loading"
|
||||||
:key="tab.id"
|
:loading-more="props.loadingMore"
|
||||||
type="button"
|
:has-more="props.hasMore"
|
||||||
role="tab"
|
@select-page="handleThumbnail"
|
||||||
:aria-selected="props.activeTab === tab.id"
|
@navigate-bookmark="handleBookmark"
|
||||||
:aria-controls="panelId(tab.id)"
|
@navigate-highlight="handleNavigateHighlight"
|
||||||
:tabindex="props.activeTab === tab.id ? 0 : -1"
|
@delete-highlight="handleDeleteHighlight"
|
||||||
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"
|
@retry-highlights="handleRetryHighlights"
|
||||||
:class="
|
@load-more-highlights="handleLoadMoreHighlights"
|
||||||
props.activeTab === tab.id
|
/>
|
||||||
? 'text-primary'
|
</div>
|
||||||
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground focus-visible:text-foreground'
|
</ReaderBottomSheet>
|
||||||
"
|
|
||||||
@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>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Hand,
|
Hand,
|
||||||
Highlighter,
|
|
||||||
Maximize,
|
Maximize,
|
||||||
Minimize,
|
Minimize,
|
||||||
Minus,
|
Minus,
|
||||||
@@ -14,7 +13,6 @@ import {
|
|||||||
Pin,
|
Pin,
|
||||||
PinOff,
|
PinOff,
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
|
||||||
Settings,
|
Settings,
|
||||||
} from '@lucide/vue'
|
} from '@lucide/vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
@@ -27,8 +25,7 @@ const props = defineProps<{
|
|||||||
totalPages: number
|
totalPages: number
|
||||||
zoomPercent: number
|
zoomPercent: number
|
||||||
sidebarOpen: boolean
|
sidebarOpen: boolean
|
||||||
searchOpen: boolean
|
showSidebarToggle: boolean
|
||||||
highlightsOpen: boolean
|
|
||||||
settingsOpen: boolean
|
settingsOpen: boolean
|
||||||
panActive: boolean
|
panActive: boolean
|
||||||
fullscreen: boolean
|
fullscreen: boolean
|
||||||
@@ -47,8 +44,6 @@ const emit = defineEmits<{
|
|||||||
zoomOut: []
|
zoomOut: []
|
||||||
zoomIn: []
|
zoomIn: []
|
||||||
toggleSidebar: []
|
toggleSidebar: []
|
||||||
toggleSearch: []
|
|
||||||
toggleHighlights: []
|
|
||||||
togglePan: []
|
togglePan: []
|
||||||
selectTool: []
|
selectTool: []
|
||||||
toggleFullscreen: []
|
toggleFullscreen: []
|
||||||
@@ -92,13 +87,6 @@ function handleZoomIn() {
|
|||||||
function handleToggleSidebar() {
|
function handleToggleSidebar() {
|
||||||
emit('toggleSidebar')
|
emit('toggleSidebar')
|
||||||
}
|
}
|
||||||
function handleToggleSearch() {
|
|
||||||
emit('toggleSearch')
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleToggleHighlights() {
|
|
||||||
emit('toggleHighlights')
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTogglePan() {
|
function handleTogglePan() {
|
||||||
emit('togglePan')
|
emit('togglePan')
|
||||||
@@ -137,49 +125,24 @@ function handleStartReading() {
|
|||||||
<TooltipContent>{{ t('reader.header.goBack') }}</TooltipContent>
|
<TooltipContent>{{ t('reader.header.goBack') }}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<div class="viewer-sep" />
|
<template v-if="props.showSidebarToggle">
|
||||||
|
<div class="viewer-sep" />
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
<button
|
<button
|
||||||
class="viewer-btn"
|
class="viewer-btn"
|
||||||
:class="props.sidebarOpen ? '!bg-muted !text-foreground' : ''"
|
:class="props.sidebarOpen ? '!bg-muted !text-foreground' : ''"
|
||||||
:aria-label="t('reader.pdf.toolbar.navigation')"
|
:aria-label="t('reader.pdf.toolbar.navigation')"
|
||||||
@click="handleToggleSidebar"
|
:aria-expanded="props.sidebarOpen"
|
||||||
>
|
@click="handleToggleSidebar"
|
||||||
<PanelLeft :size="18" />
|
>
|
||||||
</button>
|
<PanelLeft :size="18" />
|
||||||
</TooltipTrigger>
|
</button>
|
||||||
<TooltipContent>{{ t('reader.pdf.toolbar.navigation') }}</TooltipContent>
|
</TooltipTrigger>
|
||||||
</Tooltip>
|
<TooltipContent>{{ t('reader.pdf.toolbar.navigation') }}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
<Tooltip>
|
</template>
|
||||||
<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>
|
|
||||||
</div>
|
</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">
|
<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>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" side="bottom" :side-offset="8" class="w-56 border-border bg-card p-1.5 shadow-xl">
|
<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
|
<button
|
||||||
class="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-xs hover:bg-muted"
|
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' : ''"
|
: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 { ScrollStrategy } from '@embedpdf/plugin-scroll'
|
||||||
import { SpreadMode } from '@embedpdf/plugin-spread'
|
import { SpreadMode } from '@embedpdf/plugin-spread'
|
||||||
import { ZoomMode, type ZoomLevel } from '@embedpdf/plugin-zoom'
|
import { ZoomMode, type ZoomLevel } from '@embedpdf/plugin-zoom'
|
||||||
@@ -7,6 +7,7 @@ import type { PdfReaderSettings } from '@bookorbit/types'
|
|||||||
export interface FlatPdfBookmark {
|
export interface FlatPdfBookmark {
|
||||||
bookmark: PdfBookmarkObject
|
bookmark: PdfBookmarkObject
|
||||||
depth: number
|
depth: number
|
||||||
|
pageIndex: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toScrollStrategy(mode: PdfReaderSettings['scrollMode']): ScrollStrategy {
|
export function toScrollStrategy(mode: PdfReaderSettings['scrollMode']): ScrollStrategy {
|
||||||
@@ -40,8 +41,34 @@ export function fromRotation(rotation: Rotation): PdfReaderSettings['rotation']
|
|||||||
return 0
|
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[] {
|
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 {
|
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>
|
||||||
@@ -1932,7 +1932,6 @@
|
|||||||
"wordSpacingHint": "Space between words. Book default preserves the publisher's spacing.",
|
"wordSpacingHint": "Space between words. Book default preserves the publisher's spacing.",
|
||||||
"textIndent": "First-line indent",
|
"textIndent": "First-line indent",
|
||||||
"textIndentHint": "Indent at the start of ordinary paragraphs. Book default preserves the publisher's indentation.",
|
"textIndentHint": "Indent at the start of ordinary paragraphs. Book default preserves the publisher's indentation.",
|
||||||
"bookDefault": "Book default",
|
|
||||||
"custom": "Custom",
|
"custom": "Custom",
|
||||||
"emValue": "{value} em",
|
"emValue": "{value} em",
|
||||||
"justify": "Justify text",
|
"justify": "Justify text",
|
||||||
@@ -8641,12 +8640,17 @@
|
|||||||
"searchPrompt": "Type a term to search this PDF.",
|
"searchPrompt": "Type a term to search this PDF.",
|
||||||
"searchTooShort": "Enter at least {min} characters to search this PDF.",
|
"searchTooShort": "Enter at least {min} characters to search this PDF.",
|
||||||
"noSearchResults": "No searchable text matched your query.",
|
"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": {
|
"toolbar": {
|
||||||
"navigation": "Document navigation",
|
"navigation": "Document navigation",
|
||||||
"search": "Search document",
|
|
||||||
"searchShort": "Search",
|
|
||||||
"previousPage": "Previous page",
|
"previousPage": "Previous page",
|
||||||
"currentPage": "Current page",
|
"currentPage": "Current page",
|
||||||
"nextPage": "Next page",
|
"nextPage": "Next page",
|
||||||
@@ -8957,6 +8961,10 @@
|
|||||||
"playerSettings": "Player settings",
|
"playerSettings": "Player settings",
|
||||||
"skipBack": "Skip back",
|
"skipBack": "Skip back",
|
||||||
"skipForward": "Skip forward"
|
"skipForward": "Skip forward"
|
||||||
|
},
|
||||||
|
"sheet": {
|
||||||
|
"expand": "Expand panel",
|
||||||
|
"collapse": "Collapse panel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scanner": {
|
"scanner": {
|
||||||
|
|||||||
Reference in New Issue
Block a user