From 77ad6950e81d58220a099cec75cb640262f8aa0c Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 29 Dec 2025 22:11:25 +0200 Subject: [PATCH] feat(client/right_pane): highlight current heading --- .../src/widgets/sidebar/TableOfContents.css | 5 + .../src/widgets/sidebar/TableOfContents.tsx | 15 ++- .../src/widgets/type_widgets/file/Pdf.tsx | 12 ++ packages/pdfjs-viewer/src/custom.ts | 3 +- packages/pdfjs-viewer/src/toc.ts | 110 ++++++++++++++++++ 5 files changed, 139 insertions(+), 6 deletions(-) diff --git a/apps/client/src/widgets/sidebar/TableOfContents.css b/apps/client/src/widgets/sidebar/TableOfContents.css index 79c6ee548..bd66bcdb8 100644 --- a/apps/client/src/widgets/sidebar/TableOfContents.css +++ b/apps/client/src/widgets/sidebar/TableOfContents.css @@ -29,6 +29,11 @@ hyphens: auto; } +.toc li.active > .item-content { + font-weight: bold; + color: var(--main-text-color); +} + .toc > ol { --toc-depth-level: 1; } diff --git a/apps/client/src/widgets/sidebar/TableOfContents.tsx b/apps/client/src/widgets/sidebar/TableOfContents.tsx index 16ccd325b..c072c8599 100644 --- a/apps/client/src/widgets/sidebar/TableOfContents.tsx +++ b/apps/client/src/widgets/sidebar/TableOfContents.tsx @@ -24,6 +24,7 @@ interface HeadingsWithNesting extends RawHeading { export interface HeadingContext { scrollToHeading(heading: RawHeading): void; headings: RawHeading[]; + activeHeadingId?: string | null; } export default function TableOfContents() { @@ -48,20 +49,22 @@ function PdfTableOfContents() { {})} + activeHeadingId={data?.activeHeadingId} /> ); } -function AbstractTableOfContents({ headings, scrollToHeading }: { +function AbstractTableOfContents({ headings, scrollToHeading, activeHeadingId }: { headings: T[]; scrollToHeading(heading: T): void; + activeHeadingId?: string | null; }) { const nestedHeadings = buildHeadingTree(headings); return ( {nestedHeadings.length > 0 ? (
    - {nestedHeadings.map(heading => )} + {nestedHeadings.map(heading => )}
) : (
{t("toc.no_headings")}
@@ -70,14 +73,16 @@ function AbstractTableOfContents({ headings, scrollToHeadi ); } -function TableOfContentsHeading({ heading, scrollToHeading }: { +function TableOfContentsHeading({ heading, scrollToHeading, activeHeadingId }: { heading: HeadingsWithNesting; scrollToHeading(heading: RawHeading): void; + activeHeadingId?: string | null; }) { const [ collapsed, setCollapsed ] = useState(false); + const isActive = heading.id === activeHeadingId; return ( <> -
  • +
  • {heading.children.length > 0 && ( {heading.children && (
      - {heading.children.map(heading => )} + {heading.children.map(heading => )}
    )} diff --git a/apps/client/src/widgets/type_widgets/file/Pdf.tsx b/apps/client/src/widgets/type_widgets/file/Pdf.tsx index e6b7467b0..05b005b13 100644 --- a/apps/client/src/widgets/type_widgets/file/Pdf.tsx +++ b/apps/client/src/widgets/type_widgets/file/Pdf.tsx @@ -43,6 +43,7 @@ export default function PdfPreview({ note, blob, componentId, noteContext }: { const headings = convertPdfOutlineToHeadings(event.data.data); noteContext.setContextData("toc", { headings, + activeHeadingId: null, scrollToHeading: (heading) => { iframeRef.current?.contentWindow?.postMessage({ type: "trilium-scroll-to-heading", @@ -54,10 +55,21 @@ export default function PdfPreview({ note, blob, componentId, noteContext }: { // No ToC available, use empty headings noteContext.setContextData("toc", { headings: [], + activeHeadingId: null, scrollToHeading: () => {} }); } } + + if (event.data.type === "pdfjs-viewer-active-heading") { + const currentToc = noteContext.getContextData("toc"); + if (currentToc) { + noteContext.setContextData("toc", { + ...currentToc, + activeHeadingId: event.data.headingId + }); + } + } } window.addEventListener("message", handleMessage); diff --git a/packages/pdfjs-viewer/src/custom.ts b/packages/pdfjs-viewer/src/custom.ts index 90c24df36..3b0a6c001 100644 --- a/packages/pdfjs-viewer/src/custom.ts +++ b/packages/pdfjs-viewer/src/custom.ts @@ -1,5 +1,5 @@ import interceptPersistence from "./persistence"; -import { extractAndSendToc, setupScrollToHeading } from "./toc"; +import { extractAndSendToc, setupScrollToHeading, setupActiveHeadingTracking } from "./toc"; const LOG_EVENT_BUS = false; @@ -20,6 +20,7 @@ async function main() { manageSave(); extractAndSendToc(); setupScrollToHeading(); + setupActiveHeadingTracking(); }); await app.initializedPromise; }; diff --git a/packages/pdfjs-viewer/src/toc.ts b/packages/pdfjs-viewer/src/toc.ts index fe261e453..a1e7b34d2 100644 --- a/packages/pdfjs-viewer/src/toc.ts +++ b/packages/pdfjs-viewer/src/toc.ts @@ -1,4 +1,5 @@ let outlineMap: Map | null = null; +let headingPositions: Array<{ id: string; pageIndex: number; y: number }> | null = null; export async function extractAndSendToc() { const app = window.PDFViewerApplication; @@ -16,8 +17,12 @@ export async function extractAndSendToc() { // Store outline items with their destinations for later scrolling outlineMap = new Map(); + headingPositions = []; const toc = convertOutlineToToc(outline, 0, outlineMap); + // Build position mapping for active heading detection + await buildPositionMapping(outlineMap); + window.parent.postMessage({ type: "pdfjs-viewer-toc", data: toc @@ -75,3 +80,108 @@ export function setupScrollToHeading() { } }); } + +async function buildPositionMapping(outlineMap: Map) { + const app = window.PDFViewerApplication; + + for (const [id, item] of outlineMap.entries()) { + if (!item.dest) continue; + + try { + const dest = typeof item.dest === 'string' + ? await app.pdfDocument.getDestination(item.dest) + : item.dest; + + if (dest && dest[0]) { + const pageRef = dest[0]; + const pageIndex = await app.pdfDocument.getPageIndex(pageRef); + + // Extract Y coordinate from destination (dest[3] is typically the y-coordinate) + const y = typeof dest[3] === 'number' ? dest[3] : 0; + + headingPositions?.push({ id, pageIndex, y }); + } + } catch (error) { + // Skip items with invalid destinations + } + } + + // Sort by page and then by Y position (descending, since PDF coords are bottom-up) + headingPositions?.sort((a, b) => { + if (a.pageIndex !== b.pageIndex) { + return a.pageIndex - b.pageIndex; + } + return b.y - a.y; // Higher Y comes first (top of page) + }); +} + +export function setupActiveHeadingTracking() { + const app = window.PDFViewerApplication; + let lastActiveHeading: string | null = null; + + // Offset from top of viewport to consider a heading "active" + // This makes the heading active when it's near the top, not when fully scrolled past + const ACTIVE_HEADING_OFFSET = 100; + + function updateActiveHeading() { + if (!headingPositions || headingPositions.length === 0) return; + + const currentPage = app.page - 1; // PDF.js uses 1-based, we need 0-based + const viewer = app.pdfViewer; + const container = viewer.container; + const scrollTop = container.scrollTop; + + // Find the heading closest to the top of the viewport + let activeHeadingId: string | null = null; + let bestDistance = Infinity; + + for (const heading of headingPositions) { + // Get the page view to calculate actual position + const pageView = viewer.getPageView(heading.pageIndex); + if (!pageView || !pageView.div) { + continue; + } + + const pageTop = pageView.div.offsetTop; + const pageHeight = pageView.div.clientHeight; + + // Convert PDF Y coordinate (bottom-up) to screen position (top-down) + const headingScreenY = pageTop + (pageHeight - heading.y); + + // Calculate distance from top of viewport + const distance = Math.abs(headingScreenY - scrollTop); + + // If this heading is closer to the top of viewport, and it's not too far below + if (headingScreenY <= scrollTop + ACTIVE_HEADING_OFFSET && distance < bestDistance) { + activeHeadingId = heading.id; + bestDistance = distance; + } + } + + if (activeHeadingId !== lastActiveHeading) { + lastActiveHeading = activeHeadingId; + window.parent.postMessage({ + type: "pdfjs-viewer-active-heading", + headingId: activeHeadingId + }, "*"); + } + } + + // Debounced scroll handler + let scrollTimeout: number | null = null; + const debouncedUpdate = () => { + if (scrollTimeout) { + clearTimeout(scrollTimeout); + } + scrollTimeout = window.setTimeout(updateActiveHeading, 100); + }; + + app.eventBus.on("pagechanging", debouncedUpdate); + + // Also listen to scroll events for more granular updates within a page + const container = app.pdfViewer.container; + container.addEventListener("scroll", debouncedUpdate); + + // Initial update + updateActiveHeading(); +}