Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions packages/plugin-bookmark/src/lib/active-bookmark.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});
70 changes: 70 additions & 0 deletions packages/plugin-bookmark/src/lib/active-bookmark.ts
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions packages/plugin-bookmark/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ export const BookmarkPluginPackage: PluginPackage<BookmarkPlugin, BookmarkPlugin
export * from './bookmark-plugin';
export * from './types';
export * from './manifest';
export * from './active-bookmark';
104 changes: 67 additions & 37 deletions viewers/snippet/src/components/outline-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { useBookmarkCapability } from '@embedpdf/plugin-bookmark/preact';
import { useScrollCapability } from '@embedpdf/plugin-scroll/preact';
import { useTranslations } from '@embedpdf/plugin-i18n/preact';
import { useState, useEffect, useMemo, useRef } from 'preact/hooks';
import {
PdfBookmarkObject,
PdfZoomMode,
PdfErrorCode,
ignore,
PdfActionType,
PdfDestinationObject,
} from '@embedpdf/models';
useBookmarkCapability,
getActiveBookmarkPath,
resolveBookmarkDestination,
} from '@embedpdf/plugin-bookmark/preact';
import { useScroll } from '@embedpdf/plugin-scroll/preact';
import { useTranslations } from '@embedpdf/plugin-i18n/preact';
import { PdfBookmarkObject, PdfZoomMode, PdfErrorCode, PdfActionType } from '@embedpdf/models';
import { useDocumentState } from '@embedpdf/core/preact';
import { Icon } from './ui/icon';
import { ChevronDownIcon } from './icons/chevron-down';
import { ChevronRightIcon } from './icons/chevron-right';

Expand All @@ -22,12 +18,13 @@ type OutlineSidebarProps = {

export function OutlineSidebar({ documentId }: OutlineSidebarProps) {
const { provides: bookmark } = useBookmarkCapability();
const { provides: scroll } = useScrollCapability();
const { provides: scroll, state: scrollState } = useScroll(documentId);
const { translate } = useTranslations(documentId);
const documentState = useDocumentState(documentId);
const [bookmarks, setBookmarks] = useState<PdfBookmarkObject[]>([]);
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
const [isLoading, setIsLoading] = useState(true);
const activeRef = useRef<HTMLDivElement | null>(null);

useEffect(() => {
if (!bookmark || !documentState?.document) return;
Expand All @@ -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);
},
Expand All @@ -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) {
Expand All @@ -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',
Expand All @@ -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 (
<div key={id} className="select-none">
<div
className="hover:bg-interactive-hover flex cursor-pointer items-center gap-1 px-2 py-1"
ref={isActive ? activeRef : undefined}
aria-current={isActive ? 'true' : undefined}
className={`flex cursor-pointer items-center gap-1 px-2 py-1 ${
isActive ? 'bg-interactive-selected' : 'hover:bg-interactive-hover'
}`}
style={{ paddingLeft: `${level * 16 + 8}px` }}
onClick={() => handleBookmarkClick(bookmark)}
>
Expand All @@ -144,12 +168,18 @@ export function OutlineSidebar({ documentId }: OutlineSidebarProps) {
</button>
)}
{!hasChildren && <div className="w-4" />}
<span className="text-fg-secondary text-sm">{bookmark.title}</span>
<span
className={`text-sm ${
isActive ? 'text-accent-primary font-medium' : 'text-fg-secondary'
}`}
>
{bookmark.title}
</span>
</div>
{hasChildren && isExpanded && (
<div>
{bookmark.children?.map((child, childIndex) =>
renderBookmark(child, childIndex, level + 1),
renderBookmark(child, [...path, childIndex], level + 1),
)}
</div>
)}
Expand Down Expand Up @@ -181,7 +211,7 @@ export function OutlineSidebar({ documentId }: OutlineSidebarProps) {
<div className="bg-bg-surface flex h-full flex-col">
<div className="flex-1 overflow-y-auto">
<div className="outline-tree">
{bookmarks.map((bookmark, index) => renderBookmark(bookmark, index))}
{bookmarks.map((bookmark, index) => renderBookmark(bookmark, [index]))}
</div>
</div>
</div>
Expand Down