diff --git a/client/src/features/reader/pdf-v4/__tests__/PdfReaderSidebar.spec.ts b/client/src/features/reader/pdf-v4/__tests__/PdfReaderSidebar.spec.ts index a49c9e08b..bd4b53b08 100644 --- a/client/src/features/reader/pdf-v4/__tests__/PdfReaderSidebar.spec.ts +++ b/client/src/features/reader/pdf-v4/__tests__/PdfReaderSidebar.spec.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { mount } from '@vue/test-utils' +import { defineComponent, h } from 'vue' import { MatchFlag, type SearchResult } from '@embedpdf/models' import type { AnnotationItem } from '@bookorbit/types' @@ -25,16 +26,27 @@ const mocks = vi.hoisted(() => ({ scrollScope: { scrollToPage: vi.fn<(options: Record) => void>(), }, + scrollState: { + __v_isRef: true, + value: { currentPage: 1, totalPages: 10 }, + }, + bookmarkCapability: { + __v_isRef: true, + value: null as unknown, + }, + documentState: { + value: null as unknown, + }, })) vi.mock('@embedpdf/plugin-search/vue', async () => { - const { defineComponent, h } = await import('vue') + const { defineComponent: define, h: create } = await import('vue') return { - SearchLayer: defineComponent({ + SearchLayer: define({ setup: (_, { slots }) => () => - h('div', slots.default?.()), + create('div', slots.default?.()), }), useSearch: () => ({ state: mocks.searchState, provides: { value: mocks.searchScope } }), } @@ -42,52 +54,88 @@ vi.mock('@embedpdf/plugin-search/vue', async () => { vi.mock('@embedpdf/plugin-scroll/vue', () => ({ useScroll: () => ({ - state: { __v_isRef: true, value: { currentPage: 1, totalPages: 10 } }, + state: mocks.scrollState, provides: { value: mocks.scrollScope }, }), })) vi.mock('@embedpdf/plugin-bookmark/vue', () => ({ - useBookmarkCapability: () => ({ provides: { value: null } }), + useBookmarkCapability: () => ({ provides: mocks.bookmarkCapability }), })) vi.mock('@embedpdf/plugin-annotation/vue', () => ({ useAnnotationCapability: () => ({ provides: { value: null } }), })) +vi.mock('@embedpdf/core/vue', () => ({ + useDocumentState: () => mocks.documentState, +})) + vi.mock('@embedpdf/plugin-thumbnail/vue', async () => { - const { defineComponent, h } = await import('vue') + const { defineComponent: define, h: create } = await import('vue') return { - ThumbImg: defineComponent({ setup: () => () => h('img') }), - ThumbnailsPane: defineComponent({ - setup: - (_, { slots }) => - () => - h('div', slots.default?.()), - }), + ThumbImg: define({ setup: () => () => create('img') }), } }) vi.mock('vue-virtual-scroller', async () => { - const { defineComponent, h } = await import('vue') - return { - RecycleScroller: defineComponent({ + const { defineComponent: define, h: create } = await import('vue') + const listStub = (fallbackTestId: string) => + define({ inheritAttrs: false, props: { items: { type: Array, required: true } }, setup(props, { attrs, slots }) { return () => - h( + create( 'div', - { ...attrs, 'data-testid': 'search-results' }, - props.items.map((item, index) => slots.default?.({ item, index })), + { ...attrs, 'data-testid': attrs['data-testid'] ?? fallbackTestId }, + props.items.map((item, index) => slots.default?.({ item, index, active: true })), ) }, + }) + return { + RecycleScroller: listStub('recycle-scroller'), + DynamicScroller: listStub('dynamic-scroller'), + DynamicScrollerItem: define({ + setup: + (_, { slots }) => + () => + create('div', slots.default?.()), }), } }) import PdfReaderSidebar from '../components/PdfReaderSidebar.vue' +const passthrough = defineComponent({ + setup: + (_, { slots }) => + () => + h('div', slots.default?.()), +}) + +const sheetStub = defineComponent({ + props: { open: { type: Boolean, default: false } }, + setup: + (props, { slots }) => + () => + props.open ? h('div', { 'data-testid': 'bottom-sheet' }, slots.default?.()) : null, +}) + +function mountSidebar(props: Record = {}) { + return mount(PdfReaderSidebar, { + props: { documentId: 'doc-1', activeTab: 'search', annotations: [], ...props }, + global: { + stubs: { + Tooltip: passthrough, + TooltipTrigger: passthrough, + TooltipContent: passthrough, + ReaderBottomSheet: sheetStub, + }, + }, + }) +} + function makeResult(rects: SearchResult['rects'] = []): SearchResult { return { pageIndex: 4, @@ -104,7 +152,7 @@ function makeResult(rects: SearchResult['rects'] = []): SearchResult { } } -function makeAnnotation(id: number): AnnotationItem { +function makeAnnotation(id: number, overrides: Partial = {}): AnnotationItem { return { id, bookId: 9, @@ -121,9 +169,24 @@ function makeAnnotation(id: number): AnnotationItem { chapterIndex: null, createdAt: '2026-01-01T00:00:00.000Z', pdf: { page: 2, rect: { x: 10, y: 20, width: 30, height: 8 }, rects: [{ x: 10, y: 20, width: 30, height: 8 }] }, + ...overrides, } } +function outlineCapability(bookmarks: unknown[]) { + return { + forDocument: () => ({ + getBookmarks: () => ({ + wait: (resolve: (value: { bookmarks: unknown[] }) => void) => resolve({ bookmarks }), + }), + }), + } +} + +function destination(pageIndex: number) { + return { type: 'destination', destination: { pageIndex, zoom: { mode: 2 }, view: [] } } +} + describe('PdfReaderSidebar', () => { beforeEach(() => { vi.useFakeTimers() @@ -138,10 +201,13 @@ describe('PdfReaderSidebar', () => { loading: false, active: false, } + mocks.scrollState.value = { currentPage: 1, totalPages: 10 } + mocks.bookmarkCapability.value = null + mocks.documentState.value = null }) it('debounces full-document searches and cancels superseded work', async () => { - const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true, annotations: [] } }) + const wrapper = mountSidebar() const input = wrapper.get('input[type="search"]') mocks.searchScope.stopSearch.mockClear() @@ -160,7 +226,7 @@ describe('PdfReaderSidebar', () => { }) it('does not search for a single character', async () => { - const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true, annotations: [] } }) + const wrapper = mountSidebar() mocks.searchScope.stopSearch.mockClear() await wrapper.get('input[type="search"]').setValue('a') @@ -172,9 +238,9 @@ describe('PdfReaderSidebar', () => { }) it('lets the plugin restart an active search once when flags change', async () => { - const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true, annotations: [] } }) + const wrapper = mountSidebar() - await wrapper.get('input[type="checkbox"]').setValue(true) + await wrapper.get('button[aria-pressed="false"]').trigger('click') expect(mocks.searchScope.setFlags).toHaveBeenCalledWith([MatchFlag.MatchCase]) expect(mocks.searchScope.searchAllPages).not.toHaveBeenCalled() @@ -187,7 +253,7 @@ describe('PdfReaderSidebar', () => { total: 1, active: true, } - const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true, annotations: [] } }) + const wrapper = mountSidebar() expect(wrapper.get('aside').classes()).toContain('overflow-hidden') expect(wrapper.get('[data-testid="search-results"]').classes()).toContain('overflow-x-hidden') @@ -198,6 +264,20 @@ describe('PdfReaderSidebar', () => { expect(mocks.scrollScope.scrollToPage).toHaveBeenCalledWith({ pageNumber: 5 }) }) + it('labels a run of results with its page only once', () => { + mocks.searchState.value = { + ...mocks.searchState.value, + results: [makeResult(), makeResult()], + total: 2, + active: true, + } + const wrapper = mountSidebar() + + const headings = wrapper.findAll('[data-testid="search-results"] p') + expect(headings).toHaveLength(1) + expect(headings[0]!.text()).toBe('Page 5') + }) + it('reports full-document search progress', async () => { mocks.searchState.value = { ...mocks.searchState.value, @@ -208,7 +288,7 @@ describe('PdfReaderSidebar', () => { onProgress: (callback: (progress: { page: number }) => void) => callback({ page: 4 }), }) - const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true, annotations: [] } }) + const wrapper = mountSidebar() await vi.advanceTimersByTimeAsync(250) expect(wrapper.text()).toContain('Searching page 5 of 10') @@ -222,7 +302,7 @@ describe('PdfReaderSidebar', () => { activeResultIndex: 0, active: true, } - const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true, annotations: [] } }) + const wrapper = mountSidebar() const input = wrapper.get('input[type="search"]') await input.trigger('keydown', { key: 'Enter' }) @@ -233,11 +313,8 @@ describe('PdfReaderSidebar', () => { expect(mocks.searchScope.goToResult).toHaveBeenLastCalledWith(0) }) - it('clears the query, cancels search work, and restores input focus', async () => { - const wrapper = mount(PdfReaderSidebar, { - props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true, annotations: [] }, - attachTo: document.body, - }) + it('clears the query and cancels search work', async () => { + const wrapper = mountSidebar() const input = wrapper.get('input[type="search"]') await input.setValue('distance') mocks.searchScope.stopSearch.mockClear() @@ -246,14 +323,11 @@ describe('PdfReaderSidebar', () => { expect((input.element as HTMLInputElement).value).toBe('') expect(mocks.searchScope.stopSearch).toHaveBeenCalled() - expect(document.activeElement).toBe(input.element) wrapper.unmount() }) it('shows an accessible retry state instead of an empty highlight list after load failure', async () => { - const wrapper = mount(PdfReaderSidebar, { - props: { documentId: 'doc-1', activeTab: 'highlights', headerVisible: true, annotations: [], loadError: true }, - }) + const wrapper = mountSidebar({ activeTab: 'highlights', loadError: true }) expect(wrapper.get('[role="alert"]').text()).toContain('Could not load highlights') expect(wrapper.text()).not.toContain('No highlights yet') @@ -261,41 +335,60 @@ describe('PdfReaderSidebar', () => { expect(wrapper.emitted('retryHighlights')).toHaveLength(1) }) - it('virtualizes highlight rows and keeps delete available without hover', async () => { - const wrapper = mount(PdfReaderSidebar, { - props: { documentId: 'doc-1', activeTab: 'highlights', headerVisible: true, annotations: [makeAnnotation(1)] }, - }) + it('virtualizes highlight rows and keeps delete available without hover', () => { + const wrapper = mountSidebar({ activeTab: 'highlights', annotations: [makeAnnotation(1)] }) const deleteButton = wrapper.get('button[aria-label="Delete highlight"]') expect(deleteButton.classes()).toContain('opacity-100') - expect(wrapper.find('[data-testid="search-results"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="pdf-notes-list"]').exists()).toBe(true) }) - it('exposes tabs as a labelled tablist wired to the visible panel', () => { - const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true, annotations: [] } }) + it('sizes highlight rows to their content instead of a fixed row height', () => { + const wrapper = mountSidebar({ activeTab: 'highlights', annotations: [makeAnnotation(1)] }) + + expect(wrapper.get('[data-testid="pdf-notes-list"]').attributes('min-item-size')).toBe('72') + expect(wrapper.find('[class*="h-[120px]"]').exists()).toBe(false) + }) + + it('filters highlights down to the ones carrying a note', async () => { + const wrapper = mountSidebar({ + activeTab: 'highlights', + annotations: [makeAnnotation(1), makeAnnotation(2, { color: '#4ADE80', note: 'keep this' })], + }) + + expect(wrapper.text()).toContain('Selection 1') + + await wrapper.get('button[aria-pressed="false"][class*="rounded-full"]:last-of-type').trigger('click') + + expect(wrapper.text()).toContain('Selection 2') + expect(wrapper.text()).not.toContain('Selection 1') + }) + + it('exposes rail destinations as a labelled tablist wired to the visible panel', () => { + const wrapper = mountSidebar() const tabs = wrapper.findAll('[role="tab"]') expect(tabs).toHaveLength(4) - expect(tabs.map((tab) => tab.text())).toEqual(['Pages', 'Contents', 'Search', 'Notes']) + expect(tabs.map((tab) => tab.attributes('aria-label'))).toEqual(['Pages', 'Contents', 'Search', 'Notes']) - const active = tabs[2] + const active = tabs[2]! expect(active.attributes('aria-selected')).toBe('true') expect(active.attributes('tabindex')).toBe('0') - expect(tabs[0].attributes('tabindex')).toBe('-1') + expect(tabs[0]!.attributes('tabindex')).toBe('-1') const panel = wrapper.get('[role="tabpanel"]') expect(panel.attributes('id')).toBe(active.attributes('aria-controls')) expect(panel.attributes('aria-labelledby')).toBe(active.attributes('id')) }) - it('moves between tabs with arrow keys', async () => { - const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true, annotations: [] } }) + it('moves between rail destinations with arrow keys', async () => { + const wrapper = mountSidebar() const tabList = wrapper.get('[role="tablist"]') - await tabList.trigger('keydown', { key: 'ArrowRight' }) + await tabList.trigger('keydown', { key: 'ArrowDown' }) expect(wrapper.emitted('update:activeTab')?.at(-1)).toEqual(['highlights']) - await tabList.trigger('keydown', { key: 'ArrowLeft' }) + await tabList.trigger('keydown', { key: 'ArrowUp' }) expect(wrapper.emitted('update:activeTab')?.at(-1)).toEqual(['contents']) await tabList.trigger('keydown', { key: 'Home' }) @@ -308,8 +401,110 @@ describe('PdfReaderSidebar', () => { expect(wrapper.emitted('update:activeTab')).toHaveLength(4) }) - it('prompts for a query instead of showing an empty search list', async () => { - const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true, annotations: [] } }) + it('keeps the rail available while the panel is closed', () => { + const wrapper = mountSidebar({ open: false }) + + expect(wrapper.findAll('[role="tab"]')).toHaveLength(4) + expect(wrapper.find('aside').exists()).toBe(false) + expect(wrapper.get('[role="tab"][aria-label="Search"]').attributes('aria-selected')).toBe('false') + }) + + it('shows a highlight count badge on the notes destination', () => { + const wrapper = mountSidebar({ annotations: [makeAnnotation(1), makeAnnotation(2)] }) + + expect(wrapper.get('[role="tab"][aria-label="Notes"]').text()).toBe('2') + }) + + it('swaps the rail for a bottom sheet on phones', () => { + const wrapper = mountSidebar({ layout: 'sheet', activeTab: 'highlights' }) + + expect(wrapper.find('aside').exists()).toBe(false) + expect(wrapper.find('[data-testid="bottom-sheet"]').exists()).toBe(true) + expect(wrapper.findAll('[role="tab"]').map((tab) => tab.text())).toEqual(['Pages', 'Contents', 'Search', 'Notes']) + }) + + it('resizes the docked panel with the keyboard within its bounds', async () => { + const wrapper = mountSidebar({ width: 300 }) + const grip = wrapper.get('[role="separator"]') + + await grip.trigger('keydown', { key: 'ArrowRight' }) + expect(wrapper.emitted('update:width')?.at(-1)).toEqual([316]) + + await grip.trigger('keydown', { key: 'ArrowLeft', shiftKey: true }) + expect(wrapper.emitted('update:width')?.at(-1)).toEqual([260]) + }) + + it('highlights and reveals the outline entry containing the current page', async () => { + const scrollIntoView = vi.fn<() => void>() + Object.defineProperty(Element.prototype, 'scrollIntoView', { configurable: true, value: scrollIntoView }) + mocks.scrollState.value = { currentPage: 74, totalPages: 100 } + mocks.bookmarkCapability.value = outlineCapability([ + { title: 'Chapter five', target: destination(50) }, + { + title: 'Chapter six', + target: destination(73), + children: [{ title: 'Aggregates', target: destination(78) }], + }, + ]) + + const wrapper = mountSidebar({ activeTab: 'contents' }) + await wrapper.vm.$nextTick() + await wrapper.vm.$nextTick() + + const active = wrapper.get('[data-pdf-active-bookmark]') + expect(active.text()).toContain('Chapter six') + expect(active.attributes('aria-current')).toBe('location') + expect(active.classes()).toContain('bg-primary/10') + const inactive = wrapper.findAll('button').find((button) => button.text().includes('Chapter five')) + expect(inactive?.classes()).toContain('text-foreground') + expect(scrollIntoView).toHaveBeenCalledWith({ block: 'center', inline: 'nearest' }) + }) + + it('collapses an outline branch and hides its descendants', async () => { + mocks.bookmarkCapability.value = outlineCapability([ + { + title: 'Chapter one', + target: destination(4), + children: [{ title: 'Nested topic', target: destination(6) }], + }, + ]) + + const wrapper = mountSidebar({ activeTab: 'contents' }) + await wrapper.vm.$nextTick() + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('Nested topic') + + await wrapper.get('button[aria-label="Collapse Chapter one"]').trigger('click') + expect(wrapper.text()).not.toContain('Nested topic') + }) + + it('filters the outline and shows page numbers', async () => { + mocks.bookmarkCapability.value = outlineCapability([ + { title: 'Installing Docker', target: destination(19) }, + { title: 'Quick Recap', target: destination(27) }, + ]) + + const wrapper = mountSidebar({ activeTab: 'contents' }) + await wrapper.vm.$nextTick() + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('20') + + await wrapper.get('input[aria-label="Filter outline"]').setValue('recap') + + expect(wrapper.text()).toContain('Quick Recap') + expect(wrapper.text()).not.toContain('Installing Docker') + }) + + it('offers a jump-to-page field on the pages destination', () => { + const wrapper = mountSidebar({ activeTab: 'thumbnails' }) + + expect(wrapper.find('input[aria-label="Jump to page"]').exists()).toBe(true) + }) + + it('prompts for a query instead of showing an empty search list', () => { + const wrapper = mountSidebar() expect(wrapper.text()).toContain('Type a term to search this PDF.') expect(wrapper.find('[data-testid="search-results"]').exists()).toBe(false) diff --git a/client/src/features/reader/pdf-v4/__tests__/PdfReaderToolbar.spec.ts b/client/src/features/reader/pdf-v4/__tests__/PdfReaderToolbar.spec.ts index d9a203ea4..de63ac965 100644 --- a/client/src/features/reader/pdf-v4/__tests__/PdfReaderToolbar.spec.ts +++ b/client/src/features/reader/pdf-v4/__tests__/PdfReaderToolbar.spec.ts @@ -18,8 +18,7 @@ function mountToolbar(overrides: Partial[' totalPages: 240, zoomPercent: 100, sidebarOpen: false, - searchOpen: false, - highlightsOpen: false, + showSidebarToggle: true, settingsOpen: false, panActive: true, fullscreen: false, @@ -55,22 +54,36 @@ describe('PdfReaderToolbar', () => { expect((input.element as HTMLInputElement).value).toBe('240') }) - it('keeps search available outside fullscreen and exposes responsive tools', async () => { + it('exposes responsive tools without duplicating sidebar destinations', async () => { const wrapper = mountToolbar() - expect(wrapper.findAll('button[aria-label="Search document"]')).toHaveLength(1) expect(wrapper.find('button[aria-label="More PDF tools"]').exists()).toBe(true) + // Search and Highlights are rail/sheet destinations now, so the toolbar must not offer them. + expect(wrapper.find('button[aria-label="Search document"]').exists()).toBe(false) + expect(wrapper.find('button[aria-label="Highlights"]').exists()).toBe(false) const buttons = wrapper.findAll('button') - await buttons.find((button) => button.text().trim() === 'Search')!.trigger('click') + expect(buttons.some((button) => button.text().trim() === 'Search')).toBe(false) + await buttons.find((button) => button.text().trim() === 'Select text')!.trigger('click') await buttons.find((button) => button.text().trim() === 'Pan')!.trigger('click') - expect(wrapper.emitted('toggleSearch')).toHaveLength(1) expect(wrapper.emitted('selectTool')).toHaveLength(1) expect(wrapper.emitted('togglePan')).toHaveLength(1) }) + it('only offers the navigation toggle where there is no rail', async () => { + const withRail = mountToolbar({ showSidebarToggle: false }) + expect(withRail.find('button[aria-label="Document navigation"]').exists()).toBe(false) + + const sheet = mountToolbar({ showSidebarToggle: true }) + const toggle = sheet.get('button[aria-label="Document navigation"]') + expect(toggle.attributes('aria-expanded')).toBe('false') + + await toggle.trigger('click') + expect(sheet.emitted('toggleSidebar')).toHaveLength(1) + }) + it('shows the header pin only while fullscreen', async () => { const windowed = mountToolbar() expect(windowed.find('button[aria-label="Pin reader header"]').exists()).toBe(false) diff --git a/client/src/features/reader/pdf-v4/__tests__/pdf-viewer-utils.spec.ts b/client/src/features/reader/pdf-v4/__tests__/pdf-viewer-utils.spec.ts index ad9dba8d2..779e51f05 100644 --- a/client/src/features/reader/pdf-v4/__tests__/pdf-viewer-utils.spec.ts +++ b/client/src/features/reader/pdf-v4/__tests__/pdf-viewer-utils.spec.ts @@ -1,10 +1,20 @@ import { describe, expect, it } from 'vitest' -import { Rotation } from '@embedpdf/models' +import { PdfActionType, PdfZoomMode, Rotation, type PdfDestinationObject } from '@embedpdf/models' import { ScrollStrategy } from '@embedpdf/plugin-scroll' import { SpreadMode } from '@embedpdf/plugin-spread' import { ZoomMode } from '@embedpdf/plugin-zoom' import type { PdfReaderSettings } from '@bookorbit/types' -import { flattenPdfBookmarks, fromRotation, safeExternalPdfUrl, toRotation, toScrollStrategy, toSpreadMode, toZoomLevel } from '../pdf-viewer-utils' +import { + findActivePdfBookmarkIndex, + flattenPdfBookmarks, + fromRotation, + getPdfBookmarkPageIndex, + safeExternalPdfUrl, + toRotation, + toScrollStrategy, + toSpreadMode, + toZoomLevel, +} from '../pdf-viewer-utils' const settings: PdfReaderSettings = { scrollMode: 'vertical', @@ -14,6 +24,10 @@ const settings: PdfReaderSettings = { rotation: 0, } +function destination(pageIndex: number): PdfDestinationObject { + return { pageIndex, zoom: { mode: PdfZoomMode.FitPage }, view: [] } +} + describe('PDF viewer settings mapping', () => { it('maps page mode to the vertical strategy used by paginated navigation', () => { expect(toScrollStrategy('page')).toBe(ScrollStrategy.Vertical) @@ -59,6 +73,37 @@ describe('flattenPdfBookmarks', () => { ['Section', 2], ]) }) + + it('resolves direct and local goto destinations to page indexes', () => { + expect(getPdfBookmarkPageIndex({ title: 'Direct', target: { type: 'destination', destination: destination(4) } })).toBe(4) + expect( + getPdfBookmarkPageIndex({ + title: 'Action', + target: { type: 'action', action: { type: PdfActionType.Goto, destination: destination(7) } }, + }), + ).toBe(7) + expect( + getPdfBookmarkPageIndex({ title: 'Website', target: { type: 'action', action: { type: PdfActionType.URI, uri: 'https://example.com' } } }), + ).toBeNull() + }) + + it('selects the nearest outline destination at or before the current page', () => { + const entries = flattenPdfBookmarks([ + { title: 'Chapter five', target: { type: 'destination', destination: destination(50) } }, + { + title: 'Chapter six', + target: { type: 'destination', destination: destination(73) }, + children: [ + { title: 'Aggregates', target: { type: 'destination', destination: destination(78) } }, + { title: 'Factories', target: { type: 'destination', destination: destination(82) } }, + ], + }, + ]) + + expect(entries[findActivePdfBookmarkIndex(entries, 74) ?? -1]?.bookmark.title).toBe('Chapter six') + expect(entries[findActivePdfBookmarkIndex(entries, 80) ?? -1]?.bookmark.title).toBe('Aggregates') + expect(findActivePdfBookmarkIndex(entries, 1)).toBeNull() + }) }) describe('safeExternalPdfUrl', () => { diff --git a/client/src/features/reader/pdf-v4/components/PdfNavRail.vue b/client/src/features/reader/pdf-v4/components/PdfNavRail.vue new file mode 100644 index 000000000..c7400afa7 --- /dev/null +++ b/client/src/features/reader/pdf-v4/components/PdfNavRail.vue @@ -0,0 +1,102 @@ + + + diff --git a/client/src/features/reader/pdf-v4/components/PdfNoteCard.vue b/client/src/features/reader/pdf-v4/components/PdfNoteCard.vue new file mode 100644 index 000000000..249d076cc --- /dev/null +++ b/client/src/features/reader/pdf-v4/components/PdfNoteCard.vue @@ -0,0 +1,64 @@ + + + diff --git a/client/src/features/reader/pdf-v4/components/PdfNotesPanel.vue b/client/src/features/reader/pdf-v4/components/PdfNotesPanel.vue new file mode 100644 index 000000000..c08f19b32 --- /dev/null +++ b/client/src/features/reader/pdf-v4/components/PdfNotesPanel.vue @@ -0,0 +1,202 @@ + + + + + diff --git a/client/src/features/reader/pdf-v4/components/PdfOutlineTree.vue b/client/src/features/reader/pdf-v4/components/PdfOutlineTree.vue new file mode 100644 index 000000000..0484bf9e6 --- /dev/null +++ b/client/src/features/reader/pdf-v4/components/PdfOutlineTree.vue @@ -0,0 +1,171 @@ + + + diff --git a/client/src/features/reader/pdf-v4/components/PdfReaderContent.vue b/client/src/features/reader/pdf-v4/components/PdfReaderContent.vue index 7fbed9ce4..4f65a1a7f 100644 --- a/client/src/features/reader/pdf-v4/components/PdfReaderContent.vue +++ b/client/src/features/reader/pdf-v4/components/PdfReaderContent.vue @@ -17,13 +17,14 @@ import { useFullscreen } from '../../shared/composables/useFullscreen' import PdfDocumentViewport from './PdfDocumentViewport.vue' import PdfPasswordPrompt from './PdfPasswordPrompt.vue' import PdfReaderSettingsPanel from './PdfReaderSettingsPanel.vue' -import PdfReaderSidebar, { type PdfSidebarTab } from './PdfReaderSidebar.vue' +import PdfReaderSidebar from './PdfReaderSidebar.vue' import PdfReaderToolbar from './PdfReaderToolbar.vue' import { fromRotation, safeExternalPdfUrl } from '../pdf-viewer-utils' import { usePdfFullscreenChrome } from '../composables/usePdfFullscreenChrome' import { usePdfHighlights } from '../composables/usePdfHighlights' import { usePdfPagination } from '../composables/usePdfPagination' import { usePdfResponsiveSpread } from '../composables/usePdfResponsiveSpread' +import { usePdfSidebarLayout, type PdfSidebarTab } from '../composables/usePdfSidebarLayout' import NoteDialog from '../../shared/components/NoteDialog.vue' import PdfSelectionPopup from './PdfSelectionPopup.vue' @@ -56,8 +57,7 @@ const { provides: selectionCapability } = useSelectionCapability() const { provides: annotationCapability } = useAnnotationCapability() const { isFullscreen, isFullscreenSupported, toggleFullscreen } = useFullscreen() -const sidebarOpen = ref(false) -const sidebarTab = ref('thumbnails') +const sidebar = usePdfSidebarLayout() const settingsOpen = ref(false) const pendingExternalUrl = ref(null) const restoredInitialPage = ref(false) @@ -75,9 +75,7 @@ const currentZoomMode = computed(() => { }) const currentRotation = computed(() => fromRotation(rotation.value)) const externalHost = computed(() => pendingExternalUrl.value?.hostname ?? '') -const searchOpen = computed(() => sidebarOpen.value && sidebarTab.value === 'search') -const highlightsOpen = computed(() => sidebarOpen.value && sidebarTab.value === 'highlights') -const hasOpenUi = computed(() => sidebarOpen.value || settingsOpen.value || pendingExternalUrl.value !== null) +const hasOpenUi = computed(() => sidebar.open.value || settingsOpen.value || pendingExternalUrl.value !== null) const { pinned: headerPinned, visible: headerVisible, @@ -121,38 +119,19 @@ function handleZoomIn() { } function handleToggleSidebar() { - if (sidebarOpen.value && sidebarTab.value !== 'search') { - sidebarOpen.value = false - return - } - sidebarTab.value = 'thumbnails' - sidebarOpen.value = true -} - -function handleToggleSearch() { - if (sidebarOpen.value && sidebarTab.value === 'search') { - sidebarOpen.value = false - return - } - sidebarTab.value = 'search' - sidebarOpen.value = true -} - -function handleToggleHighlights() { - if (sidebarOpen.value && sidebarTab.value === 'highlights') { - sidebarOpen.value = false - return - } - sidebarTab.value = 'highlights' - sidebarOpen.value = true + sidebar.setOpen(!sidebar.open.value) } function handleSidebarClose() { - sidebarOpen.value = false + sidebar.close() } function handleSidebarTab(tab: PdfSidebarTab) { - sidebarTab.value = tab + sidebar.selectTab(tab) +} + +function handleSidebarWidth(width: number) { + sidebar.setWidth(width) } function handleTogglePan() { @@ -197,7 +176,7 @@ function handleHighlightDismiss() { function handleNavigateHighlight(annotation: AnnotationItem) { highlights.navigateTo(annotation) - if (window.innerWidth < 768) sidebarOpen.value = false + if (sidebar.layout.value === 'sheet') sidebar.close() } function handleDeleteHighlight(id: number) { @@ -284,7 +263,7 @@ function handleKeydown(event: KeyboardEvent) { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'f') { event.preventDefault() - handleToggleSearch() + sidebar.selectTab('search') return } if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'c' && !editing) { @@ -295,7 +274,7 @@ function handleKeydown(event: KeyboardEvent) { if (event.key === 'Escape') { if (pendingExternalUrl.value) handleCancelExternalLink() - else if (sidebarOpen.value) handleSidebarClose() + else if (sidebar.open.value) handleSidebarClose() return } if (event.key === 'ArrowLeft' || event.key === 'PageUp') { @@ -430,9 +409,8 @@ onUnmounted(() => { :current-page-end="pageRange.end" :total-pages="scrollState.totalPages" :zoom-percent="zoomPercent" - :sidebar-open="sidebarOpen" - :search-open="searchOpen" - :highlights-open="highlightsOpen" + :sidebar-open="sidebar.open.value" + :show-sidebar-toggle="sidebar.layout.value === 'sheet'" :settings-open="settingsOpen" :pan-active="isPanning" :fullscreen="isFullscreen" @@ -446,8 +424,6 @@ onUnmounted(() => { @zoom-out="handleZoomOut" @zoom-in="handleZoomIn" @toggle-sidebar="handleToggleSidebar" - @toggle-search="handleToggleSearch" - @toggle-highlights="handleToggleHighlights" @toggle-pan="handleTogglePan" @select-tool="handleSelectTool" @toggle-fullscreen="toggleFullscreen" @@ -474,10 +450,11 @@ onUnmounted(() => {
{ :has-more="highlights.hasMore.value" @close="handleSidebarClose" @update:active-tab="handleSidebarTab" + @update:width="handleSidebarWidth" @navigate-highlight="handleNavigateHighlight" @delete-highlight="handleDeleteHighlight" @retry-highlights="handleRetryHighlights" diff --git a/client/src/features/reader/pdf-v4/components/PdfReaderSidebar.vue b/client/src/features/reader/pdf-v4/components/PdfReaderSidebar.vue index df4b867cc..41abcd763 100644 --- a/client/src/features/reader/pdf-v4/components/PdfReaderSidebar.vue +++ b/client/src/features/reader/pdf-v4/components/PdfReaderSidebar.vue @@ -1,146 +1,123 @@ diff --git a/client/src/features/reader/pdf-v4/components/PdfReaderToolbar.vue b/client/src/features/reader/pdf-v4/components/PdfReaderToolbar.vue index d070567b6..cce055ceb 100644 --- a/client/src/features/reader/pdf-v4/components/PdfReaderToolbar.vue +++ b/client/src/features/reader/pdf-v4/components/PdfReaderToolbar.vue @@ -4,7 +4,6 @@ import { ChevronLeft, ChevronRight, Hand, - Highlighter, Maximize, Minimize, Minus, @@ -14,7 +13,6 @@ import { Pin, PinOff, Plus, - Search, Settings, } from '@lucide/vue' import { useI18n } from 'vue-i18n' @@ -27,8 +25,7 @@ const props = defineProps<{ totalPages: number zoomPercent: number sidebarOpen: boolean - searchOpen: boolean - highlightsOpen: boolean + showSidebarToggle: boolean settingsOpen: boolean panActive: boolean fullscreen: boolean @@ -47,8 +44,6 @@ const emit = defineEmits<{ zoomOut: [] zoomIn: [] toggleSidebar: [] - toggleSearch: [] - toggleHighlights: [] togglePan: [] selectTool: [] toggleFullscreen: [] @@ -92,13 +87,6 @@ function handleZoomIn() { function handleToggleSidebar() { emit('toggleSidebar') } -function handleToggleSearch() { - emit('toggleSearch') -} - -function handleToggleHighlights() { - emit('toggleHighlights') -} function handleTogglePan() { emit('togglePan') @@ -137,49 +125,24 @@ function handleStartReading() { {{ t('reader.header.goBack') }} -
+
@@ -304,9 +267,6 @@ function handleStartReading() { -