]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/html/index.ts
respective book and chapter structure added.
[bookstack] / resources / js / wysiwyg / lexical / html / index.ts
1 /**
2  * Copyright (c) Meta Platforms, Inc. and affiliates.
3  *
4  * This source code is licensed under the MIT license found in the
5  * LICENSE file in the root directory of this source tree.
6  *
7  */
8
9 import type {
10   BaseSelection,
11   DOMChildConversion,
12   DOMConversion,
13   DOMConversionFn,
14   ElementFormatType,
15   LexicalEditor,
16   LexicalNode,
17 } from 'lexical';
18
19 import {$sliceSelectedTextNodeContent} from '@lexical/selection';
20 import {isBlockDomNode, isHTMLElement} from '@lexical/utils';
21 import {
22   $cloneWithProperties,
23   $createLineBreakNode,
24   $createParagraphNode,
25   $getRoot,
26   $isBlockElementNode,
27   $isElementNode,
28   $isRootOrShadowRoot,
29   $isTextNode,
30   ArtificialNode__DO_NOT_USE,
31   ElementNode,
32   isInlineDomNode,
33 } from 'lexical';
34
35 /**
36  * How you parse your html string to get a document is left up to you. In the browser you can use the native
37  * DOMParser API to generate a document (see clipboard.ts), but to use in a headless environment you can use JSDom
38  * or an equivalent library and pass in the document here.
39  */
40 export function $generateNodesFromDOM(
41   editor: LexicalEditor,
42   dom: Document,
43 ): Array<LexicalNode> {
44   const elements = dom.body ? dom.body.childNodes : [];
45   let lexicalNodes: Array<LexicalNode> = [];
46   const allArtificialNodes: Array<ArtificialNode__DO_NOT_USE> = [];
47   for (let i = 0; i < elements.length; i++) {
48     const element = elements[i];
49     if (!IGNORE_TAGS.has(element.nodeName)) {
50       const lexicalNode = $createNodesFromDOM(
51         element,
52         editor,
53         allArtificialNodes,
54         false,
55       );
56       if (lexicalNode !== null) {
57         lexicalNodes = lexicalNodes.concat(lexicalNode);
58       }
59     }
60   }
61   $unwrapArtificalNodes(allArtificialNodes);
62
63   return lexicalNodes;
64 }
65
66 export function $generateHtmlFromNodes(
67   editor: LexicalEditor,
68   selection?: BaseSelection | null,
69 ): string {
70   if (
71     typeof document === 'undefined' ||
72     (typeof window === 'undefined' && typeof global.window === 'undefined')
73   ) {
74     throw new Error(
75       'To use $generateHtmlFromNodes in headless mode please initialize a headless browser implementation such as JSDom before calling this function.',
76     );
77   }
78
79   const container = document.createElement('div');
80   const root = $getRoot();
81   const topLevelChildren = root.getChildren();
82
83   for (let i = 0; i < topLevelChildren.length; i++) {
84     const topLevelNode = topLevelChildren[i];
85     $appendNodesToHTML(editor, topLevelNode, container, selection);
86   }
87
88   return container.innerHTML;
89 }
90
91 function $appendNodesToHTML(
92   editor: LexicalEditor,
93   currentNode: LexicalNode,
94   parentElement: HTMLElement | DocumentFragment,
95   selection: BaseSelection | null = null,
96 ): boolean {
97   let shouldInclude =
98     selection !== null ? currentNode.isSelected(selection) : true;
99   const shouldExclude =
100     $isElementNode(currentNode) && currentNode.excludeFromCopy('html');
101   let target = currentNode;
102
103   if (selection !== null) {
104     let clone = $cloneWithProperties(currentNode);
105     clone =
106       $isTextNode(clone) && selection !== null
107         ? $sliceSelectedTextNodeContent(selection, clone)
108         : clone;
109     target = clone;
110   }
111   const children = $isElementNode(target) ? target.getChildren() : [];
112   const registeredNode = editor._nodes.get(target.getType());
113   let exportOutput;
114
115   // Use HTMLConfig overrides, if available.
116   if (registeredNode && registeredNode.exportDOM !== undefined) {
117     exportOutput = registeredNode.exportDOM(editor, target);
118   } else {
119     exportOutput = target.exportDOM(editor);
120   }
121
122   const {element, after} = exportOutput;
123
124   if (!element) {
125     return false;
126   }
127
128   const fragment = document.createDocumentFragment();
129
130   for (let i = 0; i < children.length; i++) {
131     const childNode = children[i];
132     const shouldIncludeChild = $appendNodesToHTML(
133       editor,
134       childNode,
135       fragment,
136       selection,
137     );
138
139     if (
140       !shouldInclude &&
141       $isElementNode(currentNode) &&
142       shouldIncludeChild &&
143       currentNode.extractWithChild(childNode, selection, 'html')
144     ) {
145       shouldInclude = true;
146     }
147   }
148
149   if (shouldInclude && !shouldExclude) {
150     if (isHTMLElement(element)) {
151       element.append(fragment);
152     }
153     parentElement.append(element);
154
155     if (after) {
156       const newElement = after.call(target, element);
157       if (newElement) {
158         element.replaceWith(newElement);
159       }
160     }
161   } else {
162     parentElement.append(fragment);
163   }
164
165   return shouldInclude;
166 }
167
168 function getConversionFunction(
169   domNode: Node,
170   editor: LexicalEditor,
171 ): DOMConversionFn | null {
172   const {nodeName} = domNode;
173
174   const cachedConversions = editor._htmlConversions.get(nodeName.toLowerCase());
175
176   let currentConversion: DOMConversion | null = null;
177
178   if (cachedConversions !== undefined) {
179     for (const cachedConversion of cachedConversions) {
180       const domConversion = cachedConversion(domNode);
181       if (
182         domConversion !== null &&
183         (currentConversion === null ||
184           (currentConversion.priority || 0) < (domConversion.priority || 0))
185       ) {
186         currentConversion = domConversion;
187       }
188     }
189   }
190
191   return currentConversion !== null ? currentConversion.conversion : null;
192 }
193
194 const IGNORE_TAGS = new Set(['STYLE', 'SCRIPT']);
195
196 function $createNodesFromDOM(
197   node: Node,
198   editor: LexicalEditor,
199   allArtificialNodes: Array<ArtificialNode__DO_NOT_USE>,
200   hasBlockAncestorLexicalNode: boolean,
201   forChildMap: Map<string, DOMChildConversion> = new Map(),
202   parentLexicalNode?: LexicalNode | null | undefined,
203 ): Array<LexicalNode> {
204   let lexicalNodes: Array<LexicalNode> = [];
205
206   if (IGNORE_TAGS.has(node.nodeName)) {
207     return lexicalNodes;
208   }
209
210   let currentLexicalNode = null;
211   const transformFunction = getConversionFunction(node, editor);
212   const transformOutput = transformFunction
213     ? transformFunction(node as HTMLElement)
214     : null;
215   let postTransform = null;
216
217   if (transformOutput !== null) {
218     postTransform = transformOutput.after;
219     const transformNodes = transformOutput.node;
220     currentLexicalNode = Array.isArray(transformNodes)
221       ? transformNodes[transformNodes.length - 1]
222       : transformNodes;
223
224     if (currentLexicalNode !== null) {
225       for (const [, forChildFunction] of forChildMap) {
226         currentLexicalNode = forChildFunction(
227           currentLexicalNode,
228           parentLexicalNode,
229         );
230
231         if (!currentLexicalNode) {
232           break;
233         }
234       }
235
236       if (currentLexicalNode) {
237         lexicalNodes.push(
238           ...(Array.isArray(transformNodes)
239             ? transformNodes
240             : [currentLexicalNode]),
241         );
242       }
243     }
244
245     if (transformOutput.forChild != null) {
246       forChildMap.set(node.nodeName, transformOutput.forChild);
247     }
248   }
249
250   // If the DOM node doesn't have a transformer, we don't know what
251   // to do with it but we still need to process any childNodes.
252   const children = node.childNodes;
253   let childLexicalNodes = [];
254
255   const hasBlockAncestorLexicalNodeForChildren =
256     currentLexicalNode != null && $isRootOrShadowRoot(currentLexicalNode)
257       ? false
258       : (currentLexicalNode != null &&
259           $isBlockElementNode(currentLexicalNode)) ||
260         hasBlockAncestorLexicalNode;
261
262   for (let i = 0; i < children.length; i++) {
263     childLexicalNodes.push(
264       ...$createNodesFromDOM(
265         children[i],
266         editor,
267         allArtificialNodes,
268         hasBlockAncestorLexicalNodeForChildren,
269         new Map(forChildMap),
270         currentLexicalNode,
271       ),
272     );
273   }
274
275   if (postTransform != null) {
276     childLexicalNodes = postTransform(childLexicalNodes);
277   }
278
279   if (isBlockDomNode(node)) {
280     if (!hasBlockAncestorLexicalNodeForChildren) {
281       childLexicalNodes = wrapContinuousInlines(
282         node,
283         childLexicalNodes,
284         $createParagraphNode,
285       );
286     } else {
287       childLexicalNodes = wrapContinuousInlines(node, childLexicalNodes, () => {
288         const artificialNode = new ArtificialNode__DO_NOT_USE();
289         allArtificialNodes.push(artificialNode);
290         return artificialNode;
291       });
292     }
293   }
294
295   if (currentLexicalNode == null) {
296     if (childLexicalNodes.length > 0) {
297       // If it hasn't been converted to a LexicalNode, we hoist its children
298       // up to the same level as it.
299       lexicalNodes = lexicalNodes.concat(childLexicalNodes);
300     } else {
301       if (isBlockDomNode(node) && isDomNodeBetweenTwoInlineNodes(node)) {
302         // Empty block dom node that hasnt been converted, we replace it with a linebreak if its between inline nodes
303         lexicalNodes = lexicalNodes.concat($createLineBreakNode());
304       }
305     }
306   } else {
307     if ($isElementNode(currentLexicalNode)) {
308       // If the current node is a ElementNode after conversion,
309       // we can append all the children to it.
310       currentLexicalNode.append(...childLexicalNodes);
311     }
312   }
313
314   return lexicalNodes;
315 }
316
317 function wrapContinuousInlines(
318   domNode: Node,
319   nodes: Array<LexicalNode>,
320   createWrapperFn: () => ElementNode,
321 ): Array<LexicalNode> {
322   const textAlign = (domNode as HTMLElement).style
323     .textAlign as ElementFormatType;
324   const out: Array<LexicalNode> = [];
325   let continuousInlines: Array<LexicalNode> = [];
326   // wrap contiguous inline child nodes in para
327   for (let i = 0; i < nodes.length; i++) {
328     const node = nodes[i];
329     if ($isBlockElementNode(node)) {
330       if (textAlign && !node.getFormat()) {
331         node.setFormat(textAlign);
332       }
333       out.push(node);
334     } else {
335       continuousInlines.push(node);
336       if (
337         i === nodes.length - 1 ||
338         (i < nodes.length - 1 && $isBlockElementNode(nodes[i + 1]))
339       ) {
340         const wrapper = createWrapperFn();
341         wrapper.setFormat(textAlign);
342         wrapper.append(...continuousInlines);
343         out.push(wrapper);
344         continuousInlines = [];
345       }
346     }
347   }
348   return out;
349 }
350
351 function $unwrapArtificalNodes(
352   allArtificialNodes: Array<ArtificialNode__DO_NOT_USE>,
353 ) {
354   for (const node of allArtificialNodes) {
355     if (node.getNextSibling() instanceof ArtificialNode__DO_NOT_USE) {
356       node.insertAfter($createLineBreakNode());
357     }
358   }
359   // Replace artificial node with it's children
360   for (const node of allArtificialNodes) {
361     const children = node.getChildren();
362     for (const child of children) {
363       node.insertBefore(child);
364     }
365     node.remove();
366   }
367 }
368
369 function isDomNodeBetweenTwoInlineNodes(node: Node): boolean {
370   if (node.nextSibling == null || node.previousSibling == null) {
371     return false;
372   }
373   return (
374     isInlineDomNode(node.nextSibling) && isInlineDomNode(node.previousSibling)
375   );
376 }