Skip to content
File

Blob: src/client/components/editor/lib/heading-outline.ts

typescript66 lines
1import type { Editor } from "@tiptap/react";
2import { normalizeOutlineText, readOutlineLevel, type OutlineItem } from "@/shared/editor/model/outline";
3 
4export interface HeadingOutlineItem extends OutlineItem {
5 pos: number;
6}
7 
8export function collectHeadings(editor: Editor | null | undefined): HeadingOutlineItem[] {
9 if (!editor) return [];
10 
11 const headings: HeadingOutlineItem[] = [];
12 editor.state.doc.descendants((node, pos) => {
13 if (node.type.name !== "heading") return;
14 
15 const text = normalizeOutlineText(node.textContent);
16 if (!text) return;
17 
18 headings.push({
19 id: `${pos}`,
20 text,
21 level: readOutlineLevel(node.attrs.level),
22 pos,
23 });
24 });
25 
26 return headings;
27}
28 
29export function resolveActiveBySelection(headings: HeadingOutlineItem[], selectionPos: number): number | null {
30 let active: number | null = null;
31 for (const heading of headings) {
32 if (heading.pos <= selectionPos) active = heading.pos;
33 else break;
34 }
35 return active;
36}
37 
38export function resolveSelectionHeading(editor: Editor | null | undefined): number | null {
39 if (!editor) return null;
40 const { $from } = editor.state.selection;
41 for (let depth = $from.depth; depth >= 0; depth -= 1) {
42 if ($from.node(depth).type.name === "heading") {
43 return $from.before(depth);
44 }
45 }
46 return null;
47}
48 
49export function jumpToHeading(editor: Editor, pos: number) {
50 const targetPos = pos + 1;
51 
52 if (editor.isEditable) {
53 editor.chain().focus(null, { scrollIntoView: false }).setTextSelection(targetPos).run();
54 } else {
55 editor.commands.setTextSelection(targetPos);
56 }
57 
58 const node = editor.view.nodeDOM(pos);
59 if (node instanceof Element) {
60 node.scrollIntoView({ block: "center", behavior: "smooth" });
61 return;
62 }
63 
64 editor.commands.scrollIntoView();
65}