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