]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/rich-text/LexicalHeadingNode.ts
Lexical: Extracted & merged heading & quote nodes
[bookstack] / resources / js / wysiwyg / lexical / rich-text / LexicalHeadingNode.ts
1 import {
2     $applyNodeReplacement,
3     $createParagraphNode,
4     type DOMConversionMap,
5     DOMConversionOutput,
6     type DOMExportOutput,
7     type EditorConfig,
8     isHTMLElement,
9     type LexicalEditor,
10     type LexicalNode,
11     type NodeKey,
12     type ParagraphNode,
13     type RangeSelection,
14     type SerializedElementNode,
15     type Spread
16 } from "lexical";
17 import {addClassNamesToElement} from "@lexical/utils";
18 import {CommonBlockNode, copyCommonBlockProperties} from "lexical/nodes/CommonBlockNode";
19 import {
20     commonPropertiesDifferent, deserializeCommonBlockNode,
21     SerializedCommonBlockNode, setCommonBlockPropsFromElement,
22     updateElementWithCommonBlockProps
23 } from "../../nodes/_common";
24
25 export type HeadingTagType = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
26
27 export type SerializedHeadingNode = Spread<
28     {
29         tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
30     },
31     SerializedCommonBlockNode
32 >;
33
34 /** @noInheritDoc */
35 export class HeadingNode extends CommonBlockNode {
36     /** @internal */
37     __tag: HeadingTagType;
38
39     static getType(): string {
40         return 'heading';
41     }
42
43     static clone(node: HeadingNode): HeadingNode {
44         const clone = new HeadingNode(node.__tag, node.__key);
45         copyCommonBlockProperties(node, clone);
46         return clone;
47     }
48
49     constructor(tag: HeadingTagType, key?: NodeKey) {
50         super(key);
51         this.__tag = tag;
52     }
53
54     getTag(): HeadingTagType {
55         return this.__tag;
56     }
57
58     // View
59
60     createDOM(config: EditorConfig): HTMLElement {
61         const tag = this.__tag;
62         const element = document.createElement(tag);
63         const theme = config.theme;
64         const classNames = theme.heading;
65         if (classNames !== undefined) {
66             const className = classNames[tag];
67             addClassNamesToElement(element, className);
68         }
69         updateElementWithCommonBlockProps(element, this);
70         return element;
71     }
72
73     updateDOM(prevNode: HeadingNode, dom: HTMLElement): boolean {
74         return commonPropertiesDifferent(prevNode, this);
75     }
76
77     static importDOM(): DOMConversionMap | null {
78         return {
79             h1: (node: Node) => ({
80                 conversion: $convertHeadingElement,
81                 priority: 0,
82             }),
83             h2: (node: Node) => ({
84                 conversion: $convertHeadingElement,
85                 priority: 0,
86             }),
87             h3: (node: Node) => ({
88                 conversion: $convertHeadingElement,
89                 priority: 0,
90             }),
91             h4: (node: Node) => ({
92                 conversion: $convertHeadingElement,
93                 priority: 0,
94             }),
95             h5: (node: Node) => ({
96                 conversion: $convertHeadingElement,
97                 priority: 0,
98             }),
99             h6: (node: Node) => ({
100                 conversion: $convertHeadingElement,
101                 priority: 0,
102             }),
103         };
104     }
105
106     exportDOM(editor: LexicalEditor): DOMExportOutput {
107         const {element} = super.exportDOM(editor);
108
109         if (element && isHTMLElement(element)) {
110             if (this.isEmpty()) {
111                 element.append(document.createElement('br'));
112             }
113         }
114
115         return {
116             element,
117         };
118     }
119
120     static importJSON(serializedNode: SerializedHeadingNode): HeadingNode {
121         const node = $createHeadingNode(serializedNode.tag);
122         deserializeCommonBlockNode(serializedNode, node);
123         return node;
124     }
125
126     exportJSON(): SerializedHeadingNode {
127         return {
128             ...super.exportJSON(),
129             tag: this.getTag(),
130             type: 'heading',
131             version: 1,
132         };
133     }
134
135     // Mutation
136     insertNewAfter(
137         selection?: RangeSelection,
138         restoreSelection = true,
139     ): ParagraphNode | HeadingNode {
140         const anchorOffet = selection ? selection.anchor.offset : 0;
141         const lastDesc = this.getLastDescendant();
142         const isAtEnd =
143             !lastDesc ||
144             (selection &&
145                 selection.anchor.key === lastDesc.getKey() &&
146                 anchorOffet === lastDesc.getTextContentSize());
147         const newElement =
148             isAtEnd || !selection
149                 ? $createParagraphNode()
150                 : $createHeadingNode(this.getTag());
151         const direction = this.getDirection();
152         newElement.setDirection(direction);
153         this.insertAfter(newElement, restoreSelection);
154         if (anchorOffet === 0 && !this.isEmpty() && selection) {
155             const paragraph = $createParagraphNode();
156             paragraph.select();
157             this.replace(paragraph, true);
158         }
159         return newElement;
160     }
161
162     collapseAtStart(): true {
163         const newElement = !this.isEmpty()
164             ? $createHeadingNode(this.getTag())
165             : $createParagraphNode();
166         const children = this.getChildren();
167         children.forEach((child) => newElement.append(child));
168         this.replace(newElement);
169         return true;
170     }
171
172     extractWithChild(): boolean {
173         return true;
174     }
175 }
176
177 function $convertHeadingElement(element: HTMLElement): DOMConversionOutput {
178     const nodeName = element.nodeName.toLowerCase();
179     let node = null;
180     if (
181         nodeName === 'h1' ||
182         nodeName === 'h2' ||
183         nodeName === 'h3' ||
184         nodeName === 'h4' ||
185         nodeName === 'h5' ||
186         nodeName === 'h6'
187     ) {
188         node = $createHeadingNode(nodeName);
189         setCommonBlockPropsFromElement(element, node);
190     }
191     return {node};
192 }
193
194 export function $createHeadingNode(headingTag: HeadingTagType): HeadingNode {
195     return $applyNodeReplacement(new HeadingNode(headingTag));
196 }
197
198 export function $isHeadingNode(
199     node: LexicalNode | null | undefined,
200 ): node is HeadingNode {
201     return node instanceof HeadingNode;
202 }