Files
notesnook/packages/editor/src/utils/toc.ts
Copilot 26a9086d96 editor: fix TOC active heading detection (#9413)
* Initial plan

* fix: compute heading offsets relative to editor root for correct TOC highlighting

Headings inside callout blocks had incorrect `offsetTop` values because
`offsetTop` is relative to the nearest positioned ancestor (the callout),
not the editor root. This caused the TOC to highlight wrong sections.

Fix: walk the `offsetParent` chain from the heading up to the editor
content element, accumulating `offsetTop` values to get the correct
absolute position within the editor.

Co-authored-by: thecodrr <7473959+thecodrr@users.noreply.github.com>

* editor: ignore callouts from toc

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: thecodrr <7473959+thecodrr@users.noreply.github.com>
Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
2026-03-02 09:34:53 +05:00

129 lines
3.4 KiB
TypeScript

/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export type TOCItem = {
level: number;
title: string;
id: string;
top: number;
};
const levelsMap: Record<string, number> = {
H1: 1,
H2: 2,
H3: 3,
H4: 4,
H5: 5,
H6: 6
};
function getOffsetTopRelativeTo(
element: HTMLElement,
ancestor: HTMLElement
): number {
let top = 0;
let current: HTMLElement | null = element;
// Walk up the offsetParent chain until we reach ancestor (the editor root).
// This correctly handles elements nested inside positioned containers such as
// callout blocks, where `offsetTop` alone would only be relative to the
// nearest positioned parent instead of the editor root.
while (current && current !== ancestor) {
top += current.offsetTop;
current = current.offsetParent as HTMLElement | null;
}
return top;
}
export function getTableOfContents(content: HTMLElement) {
const tableOfContents: TOCItem[] = [];
let level = -1;
let prevHeading = 0;
for (const heading of content.querySelectorAll<HTMLHeadingElement>(
"h1, h2, h3, h4, h5, h6"
)) {
const title = heading.textContent;
const id = heading.dataset.blockId;
if (!id || !title) continue;
const nodeName = heading.nodeName;
const currentHeading = levelsMap[nodeName];
const isInsideCallout = !!closestWithin(heading, ".callout", content);
if (isInsideCallout) continue;
level =
prevHeading < currentHeading
? level + 1
: prevHeading > currentHeading
? level - (prevHeading - currentHeading)
: level;
level = Math.max(0, level);
prevHeading = currentHeading;
tableOfContents.push({
level,
title,
id,
top: getOffsetTopRelativeTo(heading, content)
});
}
return tableOfContents;
}
export function scrollIntoViewById(blockId: string, optionalStyles = "") {
const element = document.querySelector<HTMLElement>(
`.active [data-block-id=${JSON.stringify(blockId)}]`
);
if (element) {
const css = `.active [data-block-id=${JSON.stringify(blockId)}] {
background-color: var(--shade) !important;
${optionalStyles}
}`;
const stylesheet = document.createElement("style");
stylesheet.innerText = css;
document.head.appendChild(stylesheet);
setTimeout(() => {
stylesheet.remove();
}, 5000);
setTimeout(
() =>
element.scrollIntoView({
behavior: "smooth",
block: "start",
inline: "start"
}),
100
);
}
}
function closestWithin(
element: Element,
selector: string,
boundary: Element
): Element | null {
let current: Element | null = element;
while (current && current !== boundary) {
if (current.matches(selector)) return current;
current = current.parentElement;
}
return null;
}