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; hyphens: auto;
} }
.toc li.active > .item-content {
font-weight: bold;
color: var(--main-text-color);
}
.toc > ol { .toc > ol {
--toc-depth-level: 1; --toc-depth-level: 1;
} }

View File

@ -24,6 +24,7 @@ interface HeadingsWithNesting extends RawHeading {
export interface HeadingContext { export interface HeadingContext {
scrollToHeading(heading: RawHeading): void; scrollToHeading(heading: RawHeading): void;
headings: RawHeading[]; headings: RawHeading[];
activeHeadingId?: string | null;
} }
export default function TableOfContents() { export default function TableOfContents() {
@ -48,20 +49,22 @@ function PdfTableOfContents() {
<AbstractTableOfContents <AbstractTableOfContents
headings={data?.headings || []} headings={data?.headings || []}
scrollToHeading={data?.scrollToHeading || (() => {})} scrollToHeading={data?.scrollToHeading || (() => {})}
activeHeadingId={data?.activeHeadingId}
/> />
); );
} }
function AbstractTableOfContents<T extends RawHeading>({ headings, scrollToHeading }: { function AbstractTableOfContents<T extends RawHeading>({ headings, scrollToHeading, activeHeadingId }: {
headings: T[]; headings: T[];
scrollToHeading(heading: T): void; scrollToHeading(heading: T): void;
activeHeadingId?: string | null;
}) { }) {
const nestedHeadings = buildHeadingTree(headings); const nestedHeadings = buildHeadingTree(headings);
return ( return (
<span className="toc"> <span className="toc">
{nestedHeadings.length > 0 ? ( {nestedHeadings.length > 0 ? (
<ol> <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> </ol>
) : ( ) : (
<div className="no-headings">{t("toc.no_headings")}</div> <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; heading: HeadingsWithNesting;
scrollToHeading(heading: RawHeading): void; scrollToHeading(heading: RawHeading): void;
activeHeadingId?: string | null;
}) { }) {
const [ collapsed, setCollapsed ] = useState(false); const [ collapsed, setCollapsed ] = useState(false);
const isActive = heading.id === activeHeadingId;
return ( return (
<> <>
<li className={clsx(collapsed && "collapsed")}> <li className={clsx(collapsed && "collapsed", isActive && "active")}>
{heading.children.length > 0 && ( {heading.children.length > 0 && (
<Icon <Icon
className="collapse-button" className="collapse-button"
@ -92,7 +97,7 @@ function TableOfContentsHeading({ heading, scrollToHeading }: {
</li> </li>
{heading.children && ( {heading.children && (
<ol> <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> </ol>
)} )}
</> </>

View File

@ -43,6 +43,7 @@ export default function PdfPreview({ note, blob, componentId, noteContext }: {
const headings = convertPdfOutlineToHeadings(event.data.data); const headings = convertPdfOutlineToHeadings(event.data.data);
noteContext.setContextData("toc", { noteContext.setContextData("toc", {
headings, headings,
activeHeadingId: null,
scrollToHeading: (heading) => { scrollToHeading: (heading) => {
iframeRef.current?.contentWindow?.postMessage({ iframeRef.current?.contentWindow?.postMessage({
type: "trilium-scroll-to-heading", type: "trilium-scroll-to-heading",
@ -54,10 +55,21 @@ export default function PdfPreview({ note, blob, componentId, noteContext }: {
// No ToC available, use empty headings // No ToC available, use empty headings
noteContext.setContextData("toc", { noteContext.setContextData("toc", {
headings: [], headings: [],
activeHeadingId: null,
scrollToHeading: () => {} 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); window.addEventListener("message", handleMessage);

View File

@ -1,5 +1,5 @@
import interceptPersistence from "./persistence"; import interceptPersistence from "./persistence";
import { extractAndSendToc, setupScrollToHeading } from "./toc"; import { extractAndSendToc, setupScrollToHeading, setupActiveHeadingTracking } from "./toc";
const LOG_EVENT_BUS = false; const LOG_EVENT_BUS = false;
@ -20,6 +20,7 @@ async function main() {
manageSave(); manageSave();
extractAndSendToc(); extractAndSendToc();
setupScrollToHeading(); setupScrollToHeading();
setupActiveHeadingTracking();
}); });
await app.initializedPromise; await app.initializedPromise;
}; };

View File

@ -1,4 +1,5 @@
let outlineMap: Map<string, any> | null = null; let outlineMap: Map<string, any> | null = null;
let headingPositions: Array<{ id: string; pageIndex: number; y: number }> | null = null;
export async function extractAndSendToc() { export async function extractAndSendToc() {
const app = window.PDFViewerApplication; const app = window.PDFViewerApplication;
@ -16,8 +17,12 @@ export async function extractAndSendToc() {
// Store outline items with their destinations for later scrolling // Store outline items with their destinations for later scrolling
outlineMap = new Map(); outlineMap = new Map();
headingPositions = [];
const toc = convertOutlineToToc(outline, 0, outlineMap); const toc = convertOutlineToToc(outline, 0, outlineMap);
// Build position mapping for active heading detection
await buildPositionMapping(outlineMap);
window.parent.postMessage({ window.parent.postMessage({
type: "pdfjs-viewer-toc", type: "pdfjs-viewer-toc",
data: 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();
}