feat(client/right_pane): highlight current heading

This commit is contained in:
Elian Doran 2025-12-29 22:11:25 +02:00
parent e2d29aadca
commit 77ad6950e8
No known key found for this signature in database
5 changed files with 139 additions and 6 deletions

View File

@ -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;
}

View File

@ -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() {
<AbstractTableOfContents
headings={data?.headings || []}
scrollToHeading={data?.scrollToHeading || (() => {})}
activeHeadingId={data?.activeHeadingId}
/>
);
}
function AbstractTableOfContents<T extends RawHeading>({ headings, scrollToHeading }: {
function AbstractTableOfContents<T extends RawHeading>({ headings, scrollToHeading, activeHeadingId }: {
headings: T[];
scrollToHeading(heading: T): void;
activeHeadingId?: string | null;
}) {
const nestedHeadings = buildHeadingTree(headings);
return (
<span className="toc">
{nestedHeadings.length > 0 ? (
<ol>
{nestedHeadings.map(heading => <TableOfContentsHeading key={heading.id} heading={heading} scrollToHeading={scrollToHeading} />)}
{nestedHeadings.map(heading => <TableOfContentsHeading key={heading.id} heading={heading} scrollToHeading={scrollToHeading} activeHeadingId={activeHeadingId} />)}
</ol>
) : (
<div className="no-headings">{t("toc.no_headings")}</div>
@ -70,14 +73,16 @@ function AbstractTableOfContents<T extends RawHeading>({ 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 (
<>
<li className={clsx(collapsed && "collapsed")}>
<li className={clsx(collapsed && "collapsed", isActive && "active")}>
{heading.children.length > 0 && (
<Icon
className="collapse-button"
@ -92,7 +97,7 @@ function TableOfContentsHeading({ heading, scrollToHeading }: {
</li>
{heading.children && (
<ol>
{heading.children.map(heading => <TableOfContentsHeading key={heading.id} heading={heading} scrollToHeading={scrollToHeading} />)}
{heading.children.map(heading => <TableOfContentsHeading key={heading.id} heading={heading} scrollToHeading={scrollToHeading} activeHeadingId={activeHeadingId} />)}
</ol>
)}
</>

View File

@ -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);

View File

@ -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;
};

View File

@ -1,4 +1,5 @@
let outlineMap: Map<string, any> | 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<string, any>) {
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();
}