mirror of
https://github.com/zadam/trilium.git
synced 2026-01-06 14:44: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;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user