UNPKG

@lexical/html

Version:

This package contains HTML helpers and functionality for Lexical.

249 lines (241 loc) 9.75 kB
/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * */ import { $sliceSelectedTextNodeContent } from '@lexical/selection'; import { isHTMLElement, isBlockDomNode } from '@lexical/utils'; import { $getRoot, $isElementNode, $cloneWithProperties, $isTextNode, getRegisteredNode, isDocumentFragment, $isRootOrShadowRoot, $isBlockElementNode, $createLineBreakNode, ArtificialNode__DO_NOT_USE, isInlineDomNode, $createParagraphNode } from 'lexical'; /** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * */ /** * How you parse your html string to get a document is left up to you. In the browser you can use the native * DOMParser API to generate a document (see clipboard.ts), but to use in a headless environment you can use JSDom * or an equivalent library and pass in the document here. */ function $generateNodesFromDOM(editor, dom) { const elements = dom.body ? dom.body.childNodes : []; let lexicalNodes = []; const allArtificialNodes = []; for (let i = 0; i < elements.length; i++) { const element = elements[i]; if (!IGNORE_TAGS.has(element.nodeName)) { const lexicalNode = $createNodesFromDOM(element, editor, allArtificialNodes, false); if (lexicalNode !== null) { lexicalNodes = lexicalNodes.concat(lexicalNode); } } } $unwrapArtificialNodes(allArtificialNodes); return lexicalNodes; } function $generateHtmlFromNodes(editor, selection) { if (typeof document === 'undefined' || typeof window === 'undefined' && typeof global.window === 'undefined') { throw new Error('To use $generateHtmlFromNodes in headless mode please initialize a headless browser implementation such as JSDom before calling this function.'); } const container = document.createElement('div'); const root = $getRoot(); const topLevelChildren = root.getChildren(); for (let i = 0; i < topLevelChildren.length; i++) { const topLevelNode = topLevelChildren[i]; $appendNodesToHTML(editor, topLevelNode, container, selection); } return container.innerHTML; } function $appendNodesToHTML(editor, currentNode, parentElement, selection = null) { let shouldInclude = selection !== null ? currentNode.isSelected(selection) : true; const shouldExclude = $isElementNode(currentNode) && currentNode.excludeFromCopy('html'); let target = currentNode; if (selection !== null) { let clone = $cloneWithProperties(currentNode); clone = $isTextNode(clone) && selection !== null ? $sliceSelectedTextNodeContent(selection, clone) : clone; target = clone; } const children = $isElementNode(target) ? target.getChildren() : []; const registeredNode = getRegisteredNode(editor, target.getType()); let exportOutput; // Use HTMLConfig overrides, if available. if (registeredNode && registeredNode.exportDOM !== undefined) { exportOutput = registeredNode.exportDOM(editor, target); } else { exportOutput = target.exportDOM(editor); } const { element, after } = exportOutput; if (!element) { return false; } const fragment = document.createDocumentFragment(); for (let i = 0; i < children.length; i++) { const childNode = children[i]; const shouldIncludeChild = $appendNodesToHTML(editor, childNode, fragment, selection); if (!shouldInclude && $isElementNode(currentNode) && shouldIncludeChild && currentNode.extractWithChild(childNode, selection, 'html')) { shouldInclude = true; } } if (shouldInclude && !shouldExclude) { if (isHTMLElement(element) || isDocumentFragment(element)) { element.append(fragment); } parentElement.append(element); if (after) { const newElement = after.call(target, element); if (newElement) { if (isDocumentFragment(element)) { element.replaceChildren(newElement); } else { element.replaceWith(newElement); } } } } else { parentElement.append(fragment); } return shouldInclude; } function getConversionFunction(domNode, editor) { const { nodeName } = domNode; const cachedConversions = editor._htmlConversions.get(nodeName.toLowerCase()); let currentConversion = null; if (cachedConversions !== undefined) { for (const cachedConversion of cachedConversions) { const domConversion = cachedConversion(domNode); if (domConversion !== null && (currentConversion === null || // Given equal priority, prefer the last registered importer // which is typically an application custom node or HTMLConfig['import'] (currentConversion.priority || 0) <= (domConversion.priority || 0))) { currentConversion = domConversion; } } } return currentConversion !== null ? currentConversion.conversion : null; } const IGNORE_TAGS = new Set(['STYLE', 'SCRIPT']); function $createNodesFromDOM(node, editor, allArtificialNodes, hasBlockAncestorLexicalNode, forChildMap = new Map(), parentLexicalNode) { let lexicalNodes = []; if (IGNORE_TAGS.has(node.nodeName)) { return lexicalNodes; } let currentLexicalNode = null; const transformFunction = getConversionFunction(node, editor); const transformOutput = transformFunction ? transformFunction(node) : null; let postTransform = null; if (transformOutput !== null) { postTransform = transformOutput.after; const transformNodes = transformOutput.node; currentLexicalNode = Array.isArray(transformNodes) ? transformNodes[transformNodes.length - 1] : transformNodes; if (currentLexicalNode !== null) { for (const [, forChildFunction] of forChildMap) { currentLexicalNode = forChildFunction(currentLexicalNode, parentLexicalNode); if (!currentLexicalNode) { break; } } if (currentLexicalNode) { lexicalNodes.push(...(Array.isArray(transformNodes) ? transformNodes : [currentLexicalNode])); } } if (transformOutput.forChild != null) { forChildMap.set(node.nodeName, transformOutput.forChild); } } // If the DOM node doesn't have a transformer, we don't know what // to do with it but we still need to process any childNodes. const children = node.childNodes; let childLexicalNodes = []; const hasBlockAncestorLexicalNodeForChildren = currentLexicalNode != null && $isRootOrShadowRoot(currentLexicalNode) ? false : currentLexicalNode != null && $isBlockElementNode(currentLexicalNode) || hasBlockAncestorLexicalNode; for (let i = 0; i < children.length; i++) { childLexicalNodes.push(...$createNodesFromDOM(children[i], editor, allArtificialNodes, hasBlockAncestorLexicalNodeForChildren, new Map(forChildMap), currentLexicalNode)); } if (postTransform != null) { childLexicalNodes = postTransform(childLexicalNodes); } if (isBlockDomNode(node)) { if (!hasBlockAncestorLexicalNodeForChildren) { childLexicalNodes = wrapContinuousInlines(node, childLexicalNodes, $createParagraphNode); } else { childLexicalNodes = wrapContinuousInlines(node, childLexicalNodes, () => { const artificialNode = new ArtificialNode__DO_NOT_USE(); allArtificialNodes.push(artificialNode); return artificialNode; }); } } if (currentLexicalNode == null) { if (childLexicalNodes.length > 0) { // If it hasn't been converted to a LexicalNode, we hoist its children // up to the same level as it. lexicalNodes = lexicalNodes.concat(childLexicalNodes); } else { if (isBlockDomNode(node) && isDomNodeBetweenTwoInlineNodes(node)) { // Empty block dom node that hasnt been converted, we replace it with a linebreak if its between inline nodes lexicalNodes = lexicalNodes.concat($createLineBreakNode()); } } } else { if ($isElementNode(currentLexicalNode)) { // If the current node is a ElementNode after conversion, // we can append all the children to it. currentLexicalNode.append(...childLexicalNodes); } } return lexicalNodes; } function wrapContinuousInlines(domNode, nodes, createWrapperFn) { const textAlign = domNode.style.textAlign; const out = []; let continuousInlines = []; // wrap contiguous inline child nodes in para for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; if ($isBlockElementNode(node)) { if (textAlign && !node.getFormat()) { node.setFormat(textAlign); } out.push(node); } else { continuousInlines.push(node); if (i === nodes.length - 1 || i < nodes.length - 1 && $isBlockElementNode(nodes[i + 1])) { const wrapper = createWrapperFn(); wrapper.setFormat(textAlign); wrapper.append(...continuousInlines); out.push(wrapper); continuousInlines = []; } } } return out; } function $unwrapArtificialNodes(allArtificialNodes) { for (const node of allArtificialNodes) { if (node.getNextSibling() instanceof ArtificialNode__DO_NOT_USE) { node.insertAfter($createLineBreakNode()); } } // Replace artificial node with it's children for (const node of allArtificialNodes) { const children = node.getChildren(); for (const child of children) { node.insertBefore(child); } node.remove(); } } function isDomNodeBetweenTwoInlineNodes(node) { if (node.nextSibling == null || node.previousSibling == null) { return false; } return isInlineDomNode(node.nextSibling) && isInlineDomNode(node.previousSibling); } export { $generateHtmlFromNodes, $generateNodesFromDOM };