import "./TableOfContents.css";
import { CKTextEditor, ModelElement } from "@triliumnext/ckeditor5";
import clsx from "clsx";
import { useEffect, useState } from "preact/hooks";
import { t } from "../../services/i18n";
import { useActiveNoteContext, useIsNoteReadOnly, useNoteProperty, useTextEditor } from "../react/hooks";
import Icon from "../react/Icon";
import RightPanelWidget from "./RightPanelWidget";
//#region Generic impl.
interface RawHeading {
id: string;
level: number;
text: string;
}
interface HeadingsWithNesting extends RawHeading {
children: HeadingsWithNesting[];
}
export default function TableOfContents() {
const { note, noteContext } = useActiveNoteContext();
const noteType = useNoteProperty(note, "type");
const { isReadOnly } = useIsNoteReadOnly(note, noteContext);
return (
{noteType === "text" && !isReadOnly && }
);
}
function AbstractTableOfContents({ headings }: {
headings: RawHeading[];
}) {
const nestedHeadings = buildHeadingTree(headings);
return (
{nestedHeadings.map(heading => )}
);
}
function TableOfContentsHeading({ heading }: { heading: HeadingsWithNesting }) {
const [ collapsed, setCollapsed ] = useState(false);
return (
<>
{heading.children.length > 0 && (
setCollapsed(!collapsed)}
/>
)}
{heading.text}
{heading.children && (
{heading.children.map(heading => )}
)}
>
);
}
function buildHeadingTree(headings: RawHeading[]): HeadingsWithNesting[] {
const root: HeadingsWithNesting = { level: 0, text: "", children: [], id: "_root" };
const stack: HeadingsWithNesting[] = [root];
for (const h of headings) {
const node: HeadingsWithNesting = { ...h, children: [] };
// Pop until we find a parent with lower level
while (stack.length > 1 && stack[stack.length - 1].level >= h.level) {
stack.pop();
}
// Attach to current parent
stack[stack.length - 1].children.push(node);
// This node becomes the new parent
stack.push(node);
}
return root.children;
}
//#endregion
//#region Editable text (CKEditor)
const TOC_ID = 'tocId';
interface CKHeading extends RawHeading {
element: ModelElement;
}
function EditableTextTableOfContents() {
const { note, noteContext } = useActiveNoteContext();
const textEditor = useTextEditor(noteContext);
const [ headings, setHeadings ] = useState([]);
useEffect(() => {
if (!textEditor) return;
const headings = extractTocFromTextEditor(textEditor);
// React to changes.
const changeCallback = () => {
const changes = textEditor.model.document.differ.getChanges();
const affectsHeadings = changes.some( change => {
return (
change.type === 'insert' || change.type === 'remove' || (change.type === 'attribute' && change.attributeKey === 'headingLevel')
);
});
if (affectsHeadings) {
setHeadings(extractTocFromTextEditor(textEditor));
}
};
textEditor.model.document.on("change:data", changeCallback);
setHeadings(headings);
return () => textEditor.model.document.off("change:data", changeCallback);
}, [ textEditor, note ]);
return ;
}
function extractTocFromTextEditor(editor: CKTextEditor) {
const headings: CKHeading[] = [];
const root = editor.model.document.getRoot();
if (!root) return [];
editor.model.change(writer => {
for (const { type, item } of editor.model.createRangeIn(root).getWalker()) {
if (type !== "elementStart" || !item.is('element') || !item.name.startsWith('heading')) continue;
const level = Number(item.name.replace( 'heading', '' ));
const text = Array.from( item.getChildren() )
.map( c => c.is( '$text' ) ? c.data : '' )
.join( '' );
// Assign a unique ID
let tocId = item.getAttribute(TOC_ID) as string | undefined;
if (!tocId) {
tocId = crypto.randomUUID();
writer.setAttribute(TOC_ID, tocId, item);
}
headings.push({ level, text, element: item, id: tocId });
}
});
return headings;
}
//#endregion