2 * Copyright (c) Meta Platforms, Inc. and affiliates.
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
19 import {$sliceSelectedTextNodeContent} from '@lexical/selection';
20 import {isBlockDomNode, isHTMLElement} from '@lexical/utils';
30 ArtificialNode__DO_NOT_USE,
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.
40 export function $generateNodesFromDOM(
41 editor: LexicalEditor,
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(
56 if (lexicalNode !== null) {
57 lexicalNodes = lexicalNodes.concat(lexicalNode);
61 $unwrapArtificalNodes(allArtificialNodes);
66 export function $generateHtmlFromNodes(
67 editor: LexicalEditor,
68 selection?: BaseSelection | null,
71 typeof document === 'undefined' ||
72 (typeof window === 'undefined' && typeof global.window === 'undefined')
75 'To use $generateHtmlFromNodes in headless mode please initialize a headless browser implementation such as JSDom before calling this function.',
79 const container = document.createElement('div');
80 const root = $getRoot();
81 const topLevelChildren = root.getChildren();
83 for (let i = 0; i < topLevelChildren.length; i++) {
84 const topLevelNode = topLevelChildren[i];
85 $appendNodesToHTML(editor, topLevelNode, container, selection);
88 return container.innerHTML;
91 function $appendNodesToHTML(
92 editor: LexicalEditor,
93 currentNode: LexicalNode,
94 parentElement: HTMLElement | DocumentFragment,
95 selection: BaseSelection | null = null,
98 selection !== null ? currentNode.isSelected(selection) : true;
100 $isElementNode(currentNode) && currentNode.excludeFromCopy('html');
101 let target = currentNode;
103 if (selection !== null) {
104 let clone = $cloneWithProperties(currentNode);
106 $isTextNode(clone) && selection !== null
107 ? $sliceSelectedTextNodeContent(selection, clone)
111 const children = $isElementNode(target) ? target.getChildren() : [];
112 const registeredNode = editor._nodes.get(target.getType());
115 // Use HTMLConfig overrides, if available.
116 if (registeredNode && registeredNode.exportDOM !== undefined) {
117 exportOutput = registeredNode.exportDOM(editor, target);
119 exportOutput = target.exportDOM(editor);
122 const {element, after} = exportOutput;
128 const fragment = document.createDocumentFragment();
130 for (let i = 0; i < children.length; i++) {
131 const childNode = children[i];
132 const shouldIncludeChild = $appendNodesToHTML(
141 $isElementNode(currentNode) &&
142 shouldIncludeChild &&
143 currentNode.extractWithChild(childNode, selection, 'html')
145 shouldInclude = true;
149 if (shouldInclude && !shouldExclude) {
150 if (isHTMLElement(element)) {
151 element.append(fragment);
153 parentElement.append(element);
156 const newElement = after.call(target, element);
158 element.replaceWith(newElement);
162 parentElement.append(fragment);
165 return shouldInclude;
168 function getConversionFunction(
170 editor: LexicalEditor,
171 ): DOMConversionFn | null {
172 const {nodeName} = domNode;
174 const cachedConversions = editor._htmlConversions.get(nodeName.toLowerCase());
176 let currentConversion: DOMConversion | null = null;
178 if (cachedConversions !== undefined) {
179 for (const cachedConversion of cachedConversions) {
180 const domConversion = cachedConversion(domNode);
182 domConversion !== null &&
183 (currentConversion === null ||
184 (currentConversion.priority || 0) < (domConversion.priority || 0))
186 currentConversion = domConversion;
191 return currentConversion !== null ? currentConversion.conversion : null;
194 const IGNORE_TAGS = new Set(['STYLE', 'SCRIPT']);
196 function $createNodesFromDOM(
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> = [];
206 if (IGNORE_TAGS.has(node.nodeName)) {
210 let currentLexicalNode = null;
211 const transformFunction = getConversionFunction(node, editor);
212 const transformOutput = transformFunction
213 ? transformFunction(node as HTMLElement)
215 let postTransform = null;
217 if (transformOutput !== null) {
218 postTransform = transformOutput.after;
219 const transformNodes = transformOutput.node;
220 currentLexicalNode = Array.isArray(transformNodes)
221 ? transformNodes[transformNodes.length - 1]
224 if (currentLexicalNode !== null) {
225 for (const [, forChildFunction] of forChildMap) {
226 currentLexicalNode = forChildFunction(
231 if (!currentLexicalNode) {
236 if (currentLexicalNode) {
238 ...(Array.isArray(transformNodes)
240 : [currentLexicalNode]),
245 if (transformOutput.forChild != null) {
246 forChildMap.set(node.nodeName, transformOutput.forChild);
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 = [];
255 const hasBlockAncestorLexicalNodeForChildren =
256 currentLexicalNode != null && $isRootOrShadowRoot(currentLexicalNode)
258 : (currentLexicalNode != null &&
259 $isBlockElementNode(currentLexicalNode)) ||
260 hasBlockAncestorLexicalNode;
262 for (let i = 0; i < children.length; i++) {
263 childLexicalNodes.push(
264 ...$createNodesFromDOM(
268 hasBlockAncestorLexicalNodeForChildren,
269 new Map(forChildMap),
275 if (postTransform != null) {
276 childLexicalNodes = postTransform(childLexicalNodes);
279 if (isBlockDomNode(node)) {
280 if (!hasBlockAncestorLexicalNodeForChildren) {
281 childLexicalNodes = wrapContinuousInlines(
284 $createParagraphNode,
287 childLexicalNodes = wrapContinuousInlines(node, childLexicalNodes, () => {
288 const artificialNode = new ArtificialNode__DO_NOT_USE();
289 allArtificialNodes.push(artificialNode);
290 return artificialNode;
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);
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());
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);
317 function wrapContinuousInlines(
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);
335 continuousInlines.push(node);
337 i === nodes.length - 1 ||
338 (i < nodes.length - 1 && $isBlockElementNode(nodes[i + 1]))
340 const wrapper = createWrapperFn();
341 wrapper.setFormat(textAlign);
342 wrapper.append(...continuousInlines);
344 continuousInlines = [];
351 function $unwrapArtificalNodes(
352 allArtificialNodes: Array<ArtificialNode__DO_NOT_USE>,
354 for (const node of allArtificialNodes) {
355 if (node.getNextSibling() instanceof ArtificialNode__DO_NOT_USE) {
356 node.insertAfter($createLineBreakNode());
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);
369 function isDomNodeBetweenTwoInlineNodes(node: Node): boolean {
370 if (node.nextSibling == null || node.previousSibling == null) {
374 isInlineDomNode(node.nextSibling) && isInlineDomNode(node.previousSibling)