mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-09-01 19:49:54 +02:00
* 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>
129 lines
3.4 KiB
TypeScript
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;
|
|
}
|