mirror of
https://github.com/zadam/trilium.git
synced 2026-01-01 04:04:25 +01:00
feat(client/right_pane): highlight current heading
This commit is contained in:
parent
e2d29aadca
commit
77ad6950e8
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user