diff --git a/packages/plugin-bookmark/src/lib/active-bookmark.test.ts b/packages/plugin-bookmark/src/lib/active-bookmark.test.ts new file mode 100644 index 000000000..7ed398c21 --- /dev/null +++ b/packages/plugin-bookmark/src/lib/active-bookmark.test.ts @@ -0,0 +1,96 @@ +import { PdfActionType, PdfBookmarkObject, PdfZoomMode } from '@embedpdf/models'; +import { getActiveBookmarkPath, resolveBookmarkPageIndex } from './active-bookmark'; + +// A bookmark with a direct destination target. +const dest = ( + title: string, + pageIndex: number, + children?: PdfBookmarkObject[], +): PdfBookmarkObject => ({ + title, + target: { + type: 'destination', + destination: { pageIndex, zoom: { mode: PdfZoomMode.Unknown } }, + }, + children, +}); + +// A header bookmark with no target (e.g. a "Part" grouping node). +const header = (title: string, children?: PdfBookmarkObject[]): PdfBookmarkObject => ({ + title, + children, +}); + +// A bookmark whose target is a URI action (no page). +const uri = (title: string, url: string): PdfBookmarkObject => ({ + title, + target: { type: 'action', action: { type: PdfActionType.URI, uri: url } }, +}); + +describe('resolveBookmarkPageIndex', () => { + test('reads a direct destination target', () => { + expect(resolveBookmarkPageIndex(dest('A', 4))).toBe(4); + }); + + test('reads a Goto action destination', () => { + const bm: PdfBookmarkObject = { + title: 'A', + target: { + type: 'action', + action: { + type: PdfActionType.Goto, + destination: { pageIndex: 7, zoom: { mode: PdfZoomMode.Unknown } }, + }, + }, + }; + expect(resolveBookmarkPageIndex(bm)).toBe(7); + }); + + test('returns undefined for URI actions', () => { + expect(resolveBookmarkPageIndex(uri('Docs', 'https://example.com'))).toBeUndefined(); + }); + + test('returns undefined for a header without a target', () => { + expect(resolveBookmarkPageIndex(header('Part'))).toBeUndefined(); + }); +}); + +describe('getActiveBookmarkPath', () => { + const tree: PdfBookmarkObject[] = [ + dest('Chapter 1', 0, [dest('Section 1.1', 2), dest('Section 1.2', 5)]), + dest('Chapter 2', 10), + ]; + + test('returns null when the page precedes the first bookmark', () => { + expect(getActiveBookmarkPath([dest('Intro', 3)], 1)).toBeNull(); + }); + + test('selects the top-level entry on its own page', () => { + expect(getActiveBookmarkPath(tree, 0)).toEqual([0]); + }); + + test('selects the deepest heading at or before the page', () => { + expect(getActiveBookmarkPath(tree, 2)).toEqual([0, 0]); + expect(getActiveBookmarkPath(tree, 4)).toEqual([0, 0]); + expect(getActiveBookmarkPath(tree, 6)).toEqual([0, 1]); + }); + + test('selects a later top-level entry once its page is reached', () => { + expect(getActiveBookmarkPath(tree, 10)).toEqual([1]); + expect(getActiveBookmarkPath(tree, 12)).toEqual([1]); + }); + + test('on a tie, the later entry in document order wins', () => { + expect(getActiveBookmarkPath([dest('A', 3), dest('B', 3)], 3)).toEqual([1]); + }); + + test('skips target-less headers but selects their targeted children', () => { + const t = [header('Part I', [dest('Chapter 1', 0)])]; + expect(getActiveBookmarkPath(t, 1)).toEqual([0, 0]); + }); + + test('ignores URI bookmarks when choosing the active entry', () => { + const t = [dest('Home', 0), uri('External', 'https://example.com'), dest('End', 8)]; + expect(getActiveBookmarkPath(t, 4)).toEqual([0]); + }); +}); diff --git a/packages/plugin-bookmark/src/lib/active-bookmark.ts b/packages/plugin-bookmark/src/lib/active-bookmark.ts new file mode 100644 index 000000000..0d7b95bf1 --- /dev/null +++ b/packages/plugin-bookmark/src/lib/active-bookmark.ts @@ -0,0 +1,70 @@ +import { PdfActionType, PdfBookmarkObject, PdfDestinationObject } from '@embedpdf/models'; + +/** + * Resolve the navigable destination a bookmark points to. + * + * Handles both direct `destination` targets and `Goto`/`RemoteGoto` actions. + * Returns `undefined` for bookmarks with no navigable page (URI actions, + * unsupported/launch actions, or header nodes with no target). + */ +export function resolveBookmarkDestination( + bookmark: PdfBookmarkObject, +): PdfDestinationObject | undefined { + const target = bookmark.target; + if (!target) return undefined; + + if (target.type === 'destination') { + return target.destination; + } + + if (target.type === 'action') { + const { action } = target; + if (action.type === PdfActionType.Goto || action.type === PdfActionType.RemoteGoto) { + return action.destination; + } + } + + return undefined; +} + +/** + * The 0-based page index a bookmark targets, or `undefined` when it has no + * navigable page. + */ +export function resolveBookmarkPageIndex(bookmark: PdfBookmarkObject): number | undefined { + return resolveBookmarkDestination(bookmark)?.pageIndex; +} + +/** + * Find the "active" bookmark for a given 0-based reading page. + * + * Uses the deepest-heading-≤-page rule: walk the tree in document (pre-order) + * order and return the path of the last bookmark whose resolved page index is + * ≤ `currentPageIndex`. On a tie, the later entry in document order wins. + * Returns `null` when the page precedes every targeted bookmark. + * + * The returned path is the list of child indices from the root to the active + * node (e.g. `[1, 0]` = first child of the second top-level bookmark). + */ +export function getActiveBookmarkPath( + bookmarks: PdfBookmarkObject[], + currentPageIndex: number, +): number[] | null { + let bestPath: number[] | null = null; + + const walk = (nodes: PdfBookmarkObject[], prefix: number[]): void => { + nodes.forEach((node, index) => { + const path = [...prefix, index]; + const pageIndex = resolveBookmarkPageIndex(node); + if (pageIndex !== undefined && pageIndex <= currentPageIndex) { + bestPath = path; + } + if (node.children && node.children.length > 0) { + walk(node.children, path); + } + }); + }; + + walk(bookmarks, []); + return bestPath; +} diff --git a/packages/plugin-bookmark/src/lib/index.ts b/packages/plugin-bookmark/src/lib/index.ts index f46cb0bfd..591f31c19 100644 --- a/packages/plugin-bookmark/src/lib/index.ts +++ b/packages/plugin-bookmark/src/lib/index.ts @@ -13,3 +13,4 @@ export const BookmarkPluginPackage: PluginPackage([]); const [expandedItems, setExpandedItems] = useState>(new Set()); const [isLoading, setIsLoading] = useState(true); + const activeRef = useRef(null); useEffect(() => { if (!bookmark || !documentState?.document) return; @@ -37,8 +34,8 @@ export function OutlineSidebar({ documentId }: OutlineSidebarProps) { task.wait( ({ bookmarks }) => { setBookmarks(bookmarks); - // Auto-expand first level items - const firstLevelIds = bookmarks.map((_, index) => `bookmark-${index}`); + // Auto-expand first level items (ids are path-based: "0", "1", ...). + const firstLevelIds = bookmarks.map((_, index) => `${index}`); setExpandedItems(new Set(firstLevelIds)); setIsLoading(false); }, @@ -55,26 +52,49 @@ export function OutlineSidebar({ documentId }: OutlineSidebarProps) { }; }, [bookmark, documentState?.document]); + // Path of the bookmark that corresponds to the current reading page. + const activePath = useMemo( + () => getActiveBookmarkPath(bookmarks, scrollState.currentPage - 1), + [bookmarks, scrollState.currentPage], + ); + const activeId = activePath ? activePath.join('.') : null; + + // Auto-expand the active entry's ancestors so it is always revealed. + useEffect(() => { + if (!activePath || activePath.length <= 1) return; + setExpandedItems((prev) => { + const next = new Set(prev); + let changed = false; + for (let i = 1; i < activePath.length; i++) { + const ancestorId = activePath.slice(0, i).join('.'); + if (!next.has(ancestorId)) { + next.add(ancestorId); + changed = true; + } + } + return changed ? next : prev; + }); + // Depend on the serialized active id; activePath is a fresh array each render. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeId]); + + // Bring the active entry into view when it changes (or after ancestors expand). + // `block: 'nearest'` is a no-op when the entry is already visible. + useEffect(() => { + if (!activeId) return; + activeRef.current?.scrollIntoView({ block: 'nearest' }); + }, [activeId, expandedItems]); + const handleBookmarkClick = (bookmark: PdfBookmarkObject) => { if (!scroll || !bookmark.target) return; - // Extract destination from either action or direct destination target - let destination: PdfDestinationObject | undefined; - - if (bookmark.target.type === 'action') { - const action = bookmark.target.action; - if (action.type === PdfActionType.Goto || action.type === PdfActionType.RemoteGoto) { - destination = action.destination; - } else if (action.type === PdfActionType.URI) { - // Open URI in new tab - window.open(action.uri, '_blank'); - return; - } - // Other action types (Unsupported, LaunchAppOrOpenFile) are not handled - } else if (bookmark.target.type === 'destination') { - destination = bookmark.target.destination; + // URI actions open in a new tab. + if (bookmark.target.type === 'action' && bookmark.target.action.type === PdfActionType.URI) { + window.open(bookmark.target.action.uri, '_blank'); + return; } + const destination = resolveBookmarkDestination(bookmark); if (!destination) return; if (destination.zoom.mode === PdfZoomMode.XYZ) { @@ -92,7 +112,6 @@ export function OutlineSidebar({ documentId }: OutlineSidebarProps) { behavior: 'smooth', }); } else { - // Handle FitPage, FitH, FitV, FitR, FitB, FitBH, FitBV, etc. scroll.scrollToPage({ pageNumber: destination.pageIndex + 1, behavior: 'smooth', @@ -114,17 +133,22 @@ export function OutlineSidebar({ documentId }: OutlineSidebarProps) { const renderBookmark = ( bookmark: PdfBookmarkObject, - index: number, + path: number[], level: number = 0, ): h.JSX.Element => { - const id = `bookmark-${index}`; + const id = path.join('.'); const hasChildren = bookmark.children && bookmark.children.length > 0; const isExpanded = expandedItems.has(id); + const isActive = id === activeId; return (
handleBookmarkClick(bookmark)} > @@ -144,12 +168,18 @@ export function OutlineSidebar({ documentId }: OutlineSidebarProps) { )} {!hasChildren &&
} - {bookmark.title} + + {bookmark.title} +
{hasChildren && isExpanded && (
{bookmark.children?.map((child, childIndex) => - renderBookmark(child, childIndex, level + 1), + renderBookmark(child, [...path, childIndex], level + 1), )}
)} @@ -181,7 +211,7 @@ export function OutlineSidebar({ documentId }: OutlineSidebarProps) {
- {bookmarks.map((bookmark, index) => renderBookmark(bookmark, index))} + {bookmarks.map((bookmark, index) => renderBookmark(bookmark, [index]))}