From 6899a28ce6e356aa6a26ed00c5b8a3da23398110 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 23 Oct 2024 16:07:00 -0700 Subject: [PATCH] WIP getDOMSlot --- packages/lexical/src/LexicalMutations.ts | 46 +++-- packages/lexical/src/LexicalNode.ts | 8 + packages/lexical/src/LexicalReconciler.ts | 174 ++++++++---------- packages/lexical/src/LexicalUtils.ts | 27 ++- packages/lexical/src/index.ts | 1 + .../lexical/src/nodes/LexicalElementNode.ts | 69 +++++++ .../unit/LexicalElementNode.test.tsx | 88 +++++++++ 7 files changed, 293 insertions(+), 120 deletions(-) diff --git a/packages/lexical/src/LexicalMutations.ts b/packages/lexical/src/LexicalMutations.ts index 15e4e510d39..8b61dd8f20b 100644 --- a/packages/lexical/src/LexicalMutations.ts +++ b/packages/lexical/src/LexicalMutations.ts @@ -6,8 +6,10 @@ * */ -import type {TextNode} from '.'; +import type {LexicalNode, TextNode} from '.'; import type {LexicalEditor} from './LexicalEditor'; +import type {EditorState} from './LexicalEditorState'; +import type {LexicalPrivateDOM} from './LexicalNode'; import type {BaseSelection} from './LexicalSelection'; import {IS_FIREFOX} from 'shared/environment'; @@ -23,10 +25,12 @@ import { import {DOM_TEXT_TYPE} from './LexicalConstants'; import {updateEditor} from './LexicalUpdates'; import { - $getNearestNodeFromDOMNode, + $getNodeByKey, $getNodeFromDOMNode, $updateTextNodeFromDOMContent, getDOMSelection, + getNodeKeyFromDOMNode, + getParentElement, getWindow, internalGetRoot, isFirefoxClipboardEvents, @@ -53,14 +57,12 @@ function initTextEntryListener(editor: LexicalEditor): void { function isManagedLineBreak( dom: Node, - target: Node, + target: Node & LexicalPrivateDOM, editor: LexicalEditor, ): boolean { return ( - // @ts-expect-error: internal field target.__lexicalLineBreak === dom || - // @ts-ignore We intentionally add this to the Node. - dom[`__lexicalKey_${editor._key}`] !== undefined + getNodeKeyFromDOMNode(dom, editor) !== undefined ); } @@ -108,6 +110,23 @@ function shouldUpdateTextNodeFromMutation( return targetDOM.nodeType === DOM_TEXT_TYPE && targetNode.isAttached(); } +export function $getNearestNodePairFromDOMNode( + startingDOM: Node, + editor: LexicalEditor, + editorState: EditorState, +): [Node, LexicalNode] | [null, null] { + for (let dom: Node | null = startingDOM; dom; dom = getParentElement(dom)) { + const key = getNodeKeyFromDOMNode(dom, editor); + if (key !== undefined) { + const node = $getNodeByKey(key, editorState); + if (node) { + return [dom, node]; + } + } + } + return [null, null]; +} + export function $flushMutations( editor: LexicalEditor, mutations: Array, @@ -120,7 +139,7 @@ export function $flushMutations( try { updateEditor(editor, () => { const selection = $getSelection() || getLastSelection(editor); - const badDOMTargets = new Map(); + const badDOMTargets = new Map(); const rootElement = editor.getRootElement(); // We use the current editor state, as that reflects what is // actually "on screen". @@ -133,10 +152,12 @@ export function $flushMutations( const mutation = mutations[i]; const type = mutation.type; const targetDOM = mutation.target; - let targetNode = $getNearestNodeFromDOMNode( + const pair = $getNearestNodePairFromDOMNode( targetDOM, + editor, currentEditorState, ); + let targetNode = pair[1]; if ( (targetNode === null && targetDOM !== rootElement) || @@ -216,7 +237,7 @@ export function $flushMutations( targetNode = internalGetRoot(currentEditorState); } - badDOMTargets.set(targetDOM, targetNode); + badDOMTargets.set(pair[0] || targetDOM, targetNode); } } } @@ -230,7 +251,8 @@ export function $flushMutations( for (const [targetDOM, targetNode] of badDOMTargets) { if ($isElementNode(targetNode)) { const childKeys = targetNode.getChildrenKeys(); - let currentDOM = targetDOM.firstChild; + const slot = targetNode.getDOMSlot(targetDOM as HTMLElement); + let currentDOM = slot.getFirstChild(); for (let s = 0; s < childKeys.length; s++) { const key = childKeys[s]; @@ -241,10 +263,10 @@ export function $flushMutations( } if (currentDOM == null) { - targetDOM.appendChild(correctDOM); + slot.insertChild(correctDOM); currentDOM = correctDOM; } else if (currentDOM !== correctDOM) { - targetDOM.replaceChild(correctDOM, currentDOM); + slot.replaceChild(correctDOM, currentDOM); } currentDOM = currentDOM.nextSibling; diff --git a/packages/lexical/src/LexicalNode.ts b/packages/lexical/src/LexicalNode.ts index 564989cdc2e..65eb19068b9 100644 --- a/packages/lexical/src/LexicalNode.ts +++ b/packages/lexical/src/LexicalNode.ts @@ -56,6 +56,14 @@ export type SerializedLexicalNode = { version: number; }; +/** @internal */ +export interface LexicalPrivateDOM { + __lexicalTextContent?: string | undefined | null; + __lexicalLineBreak?: HTMLBRElement | undefined | null; + __lexicalDirTextContent?: string | undefined | null; + __lexicalDir?: 'ltr' | 'rtl' | null | undefined; +} + export function $removeNode( nodeToRemove: LexicalNode, restoreSelection: boolean, diff --git a/packages/lexical/src/LexicalReconciler.ts b/packages/lexical/src/LexicalReconciler.ts index 0ad9cf2c911..d3319fd6d16 100644 --- a/packages/lexical/src/LexicalReconciler.ts +++ b/packages/lexical/src/LexicalReconciler.ts @@ -13,8 +13,8 @@ import type { MutationListeners, RegisteredNodes, } from './LexicalEditor'; -import type {NodeKey, NodeMap} from './LexicalNode'; -import type {ElementNode} from './nodes/LexicalElementNode'; +import type {LexicalPrivateDOM, NodeKey, NodeMap} from './LexicalNode'; +import type {ElementDOMSlot, ElementNode} from './nodes/LexicalElementNode'; import invariant from 'shared/invariant'; import normalizeClassNames from 'shared/normalizeClassNames'; @@ -44,6 +44,7 @@ import { getElementByKeyOrThrow, getTextDirection, setMutatedNode, + setNodeKeyOnDOMNode, } from './LexicalUtils'; type IntentionallyMarkedAsDirtyElement = boolean; @@ -165,11 +166,23 @@ function setElementFormat(dom: HTMLElement, format: number): void { } } -function $createNode( - key: NodeKey, - parentDOM: null | HTMLElement, - insertDOM: null | Node, -): HTMLElement { +function removeLineBreak(slot: ElementDOMSlot) { + const element: HTMLElement & LexicalPrivateDOM = slot.element; + const br = element.__lexicalLineBreak; + if (br) { + slot.removeChild(br); + } + element.__lexicalLineBreak = null; +} + +function insertLineBreak(slot: ElementDOMSlot) { + const element: HTMLElement & LexicalPrivateDOM = slot.element; + const br = document.createElement('br'); + slot.insertChild(br); + element.__lexicalLineBreak = br; +} + +function $createNode(key: NodeKey, slot: ElementDOMSlot | null): HTMLElement { const node = activeNextNodeMap.get(key); if (node === undefined) { @@ -231,19 +244,8 @@ function $createNode( editorTextContent += text; } - if (parentDOM !== null) { - if (insertDOM != null) { - parentDOM.insertBefore(dom, insertDOM); - } else { - // @ts-expect-error: internal field - const possibleLineBreak = parentDOM.__lexicalLineBreak; - - if (possibleLineBreak != null) { - parentDOM.insertBefore(dom, possibleLineBreak); - } else { - parentDOM.appendChild(dom); - } - } + if (slot !== null) { + slot.insertChild(dom); } if (__DEV__) { @@ -269,25 +271,24 @@ function $createChildrenWithDirection( ): void { const previousSubTreeDirectionedTextContent = subTreeDirectionedTextContent; subTreeDirectionedTextContent = ''; - $createChildren(children, element, 0, endIndex, dom, null); + $createChildren(children, element, 0, endIndex, element.getDOMSlot(dom)); reconcileBlockDirection(element, dom); subTreeDirectionedTextContent = previousSubTreeDirectionedTextContent; } function $createChildren( children: Array, - element: ElementNode, + element: ElementNode & LexicalPrivateDOM, _startIndex: number, endIndex: number, - dom: null | HTMLElement, - insertDOM: null | HTMLElement, + slot: ElementDOMSlot, ): void { const previousSubTreeTextContent = subTreeTextContent; subTreeTextContent = ''; let startIndex = _startIndex; for (; startIndex <= endIndex; ++startIndex) { - $createNode(children[startIndex], dom, insertDOM); + $createNode(children[startIndex], slot); const node = activeNextNodeMap.get(children[startIndex]); if (node !== null && $isTextNode(node)) { if (subTreeTextFormat === null) { @@ -301,7 +302,7 @@ function $createChildren( if ($textContentRequiresDoubleLinebreakAtEnd(element)) { subTreeTextContent += DOUBLE_LINE_BREAK; } - // @ts-expect-error: internal field + const dom: HTMLElement & LexicalPrivateDOM = slot.element; dom.__lexicalTextContent = subTreeTextContent; subTreeTextContent = previousSubTreeTextContent + subTreeTextContent; } @@ -318,7 +319,7 @@ function isLastChildLineBreakOrDecorator( function reconcileElementTerminatingLineBreak( prevElement: null | ElementNode, nextElement: ElementNode, - dom: HTMLElement, + dom: HTMLElement & LexicalPrivateDOM, ): void { const prevLineBreak = prevElement !== null && @@ -334,34 +335,13 @@ function reconcileElementTerminatingLineBreak( activeNextNodeMap, ); - if (prevLineBreak) { - if (!nextLineBreak) { - // @ts-expect-error: internal field - const element = dom.__lexicalLineBreak; - - if (element != null) { - try { - dom.removeChild(element); - } catch (error) { - if (typeof error === 'object' && error != null) { - const msg = `${error.toString()} Parent: ${dom.tagName}, child: ${ - element.tagName - }.`; - throw new Error(msg); - } else { - throw error; - } - } - } - - // @ts-expect-error: internal field - dom.__lexicalLineBreak = null; + if (prevLineBreak !== nextLineBreak) { + const slot = nextElement.getDOMSlot(dom); + if (prevLineBreak) { + removeLineBreak(slot); + } else { + insertLineBreak(slot); } - } else if (nextLineBreak) { - const element = document.createElement('br'); - // @ts-expect-error: internal field - dom.__lexicalLineBreak = element; - dom.appendChild(element); } } @@ -388,12 +368,13 @@ function reconcileParagraphStyle(element: ElementNode): void { } } -function reconcileBlockDirection(element: ElementNode, dom: HTMLElement): void { +function reconcileBlockDirection( + element: ElementNode, + dom: HTMLElement & LexicalPrivateDOM, +): void { const previousSubTreeDirectionTextContent: string = - // @ts-expect-error: internal field - dom.__lexicalDirTextContent; - // @ts-expect-error: internal field - const previousDirection: string = dom.__lexicalDir; + dom.__lexicalDirTextContent || ''; + const previousDirection: string = dom.__lexicalDir || ''; if ( previousSubTreeDirectionTextContent !== subTreeDirectionedTextContent || @@ -454,9 +435,7 @@ function reconcileBlockDirection(element: ElementNode, dom: HTMLElement): void { } activeTextDirection = direction; - // @ts-expect-error: internal field dom.__lexicalDirTextContent = subTreeDirectionedTextContent; - // @ts-expect-error: internal field dom.__lexicalDir = direction; } } @@ -470,7 +449,7 @@ function $reconcileChildrenWithDirection( subTreeDirectionedTextContent = ''; subTreeTextFormat = null; subTreeTextStyle = ''; - $reconcileChildren(prevElement, nextElement, dom); + $reconcileChildren(prevElement, nextElement, nextElement.getDOMSlot(dom)); reconcileBlockDirection(nextElement, dom); reconcileParagraphFormat(nextElement); reconcileParagraphStyle(nextElement); @@ -497,21 +476,22 @@ function createChildrenArray( function $reconcileChildren( prevElement: ElementNode, nextElement: ElementNode, - dom: HTMLElement, + slot: ElementDOMSlot, ): void { const previousSubTreeTextContent = subTreeTextContent; const prevChildrenSize = prevElement.__size; const nextChildrenSize = nextElement.__size; subTreeTextContent = ''; + const dom: HTMLElement & LexicalPrivateDOM = slot.element; if (prevChildrenSize === 1 && nextChildrenSize === 1) { - const prevFirstChildKey = prevElement.__first as NodeKey; - const nextFrstChildKey = nextElement.__first as NodeKey; - if (prevFirstChildKey === nextFrstChildKey) { + const prevFirstChildKey: NodeKey = prevElement.__first!; + const nextFirstChildKey: NodeKey = nextElement.__first!; + if (prevFirstChildKey === nextFirstChildKey) { $reconcileNode(prevFirstChildKey, dom); } else { const lastDOM = getPrevElementByKeyOrThrow(prevFirstChildKey); - const replacementDOM = $createNode(nextFrstChildKey, null, null); + const replacementDOM = $createNode(nextFirstChildKey, null); try { dom.replaceChild(replacementDOM, lastDOM); } catch (error) { @@ -520,7 +500,7 @@ function $reconcileChildren( dom.tagName }, new child: {tag: ${ replacementDOM.tagName - } key: ${nextFrstChildKey}}, old child: {tag: ${ + } key: ${nextFirstChildKey}}, old child: {tag: ${ lastDOM.tagName }, key: ${prevFirstChildKey}}.`; throw new Error(msg); @@ -530,7 +510,7 @@ function $reconcileChildren( } destroyNode(prevFirstChildKey, null); } - const nextChildNode = activeNextNodeMap.get(nextFrstChildKey); + const nextChildNode = activeNextNodeMap.get(nextFirstChildKey); if ($isTextNode(nextChildNode)) { if (subTreeTextFormat === null) { subTreeTextFormat = nextChildNode.getFormat(); @@ -550,15 +530,16 @@ function $reconcileChildren( nextElement, 0, nextChildrenSize - 1, - dom, - null, + slot, ); } } else if (nextChildrenSize === 0) { if (prevChildrenSize !== 0) { - // @ts-expect-error: internal field - const lexicalLineBreak = dom.__lexicalLineBreak; - const canUseFastPath = lexicalLineBreak == null; + const canUseFastPath = + slot.after == null && + slot.before == null && + (slot.element as HTMLElement & LexicalPrivateDOM) + .__lexicalLineBreak == null; destroyChildren( prevChildren, 0, @@ -578,7 +559,7 @@ function $reconcileChildren( nextChildren, prevChildrenSize, nextChildrenSize, - dom, + slot, ); } } @@ -587,7 +568,6 @@ function $reconcileChildren( subTreeTextContent += DOUBLE_LINE_BREAK; } - // @ts-expect-error: internal field dom.__lexicalTextContent = subTreeTextContent; subTreeTextContent = previousSubTreeTextContent + subTreeTextContent; } @@ -610,14 +590,16 @@ function $reconcileNode( treatAllNodesAsDirty || activeDirtyLeaves.has(key) || activeDirtyElements.has(key); - const dom = getElementByKeyOrThrow(activeEditor, key); + const dom: HTMLElement & LexicalPrivateDOM = getElementByKeyOrThrow( + activeEditor, + key, + ); // If the node key points to the same instance in both states // and isn't dirty, we just update the text content cache // and return the existing DOM Node. if (prevNode === nextNode && !isDirty) { if ($isElementNode(prevNode)) { - // @ts-expect-error: internal field const previousSubTreeTextContent = dom.__lexicalTextContent; if (previousSubTreeTextContent !== undefined) { @@ -625,7 +607,6 @@ function $reconcileNode( editorTextContent += previousSubTreeTextContent; } - // @ts-expect-error: internal field const previousSubTreeDirectionTextContent = dom.__lexicalDirTextContent; if (previousSubTreeDirectionTextContent !== undefined) { @@ -658,7 +639,7 @@ function $reconcileNode( // Update node. If it returns true, we need to unmount and re-create the node if (nextNode.updateDOM(prevNode, dom, activeEditorConfig)) { - const replacementDOM = $createNode(key, null, null); + const replacementDOM = $createNode(key, null); if (parentDOM === null) { invariant(false, 'reconcileNode: parentDOM is null'); @@ -745,10 +726,6 @@ function reconcileDecorator(key: NodeKey, decorator: unknown): void { pendingDecorators[key] = decorator; } -function getFirstChild(element: HTMLElement): Node | null { - return element.firstChild; -} - function getNextSibling(element: HTMLElement): Node | null { let nextSibling = element.nextSibling; if ( @@ -766,13 +743,13 @@ function $reconcileNodeChildren( nextChildren: Array, prevChildrenLength: number, nextChildrenLength: number, - dom: HTMLElement, + slot: ElementDOMSlot, ): void { const prevEndIndex = prevChildrenLength - 1; const nextEndIndex = nextChildrenLength - 1; let prevChildrenSet: Set | undefined; let nextChildrenSet: Set | undefined; - let siblingDOM: null | Node = getFirstChild(dom); + let siblingDOM: null | Node = slot.getFirstChild(); let prevIndex = 0; let nextIndex = 0; @@ -781,7 +758,7 @@ function $reconcileNodeChildren( const nextKey = nextChildren[nextIndex]; if (prevKey === nextKey) { - siblingDOM = getNextSibling($reconcileNode(nextKey, dom)); + siblingDOM = getNextSibling($reconcileNode(nextKey, slot.element)); prevIndex++; nextIndex++; } else { @@ -799,26 +776,21 @@ function $reconcileNodeChildren( if (!nextHasPrevKey) { // Remove prev siblingDOM = getNextSibling(getPrevElementByKeyOrThrow(prevKey)); - destroyNode(prevKey, dom); + destroyNode(prevKey, slot.element); prevIndex++; } else if (!prevHasNextKey) { // Create next - $createNode(nextKey, dom, siblingDOM); + $createNode(nextKey, slot.withBefore(siblingDOM)); nextIndex++; } else { // Move next const childDOM = getElementByKeyOrThrow(activeEditor, nextKey); if (childDOM === siblingDOM) { - siblingDOM = getNextSibling($reconcileNode(nextKey, dom)); + siblingDOM = getNextSibling($reconcileNode(nextKey, slot.element)); } else { - if (siblingDOM != null) { - dom.insertBefore(childDOM, siblingDOM); - } else { - dom.appendChild(childDOM); - } - - $reconcileNode(nextKey, dom); + slot.withBefore(siblingDOM).insertChild(childDOM); + $reconcileNode(nextKey, slot.element); } prevIndex++; @@ -851,11 +823,10 @@ function $reconcileNodeChildren( nextElement, nextIndex, nextEndIndex, - dom, - insertDOM, + slot.withBefore(insertDOM), ); } else if (removeOldChildren && !appendNewChildren) { - destroyChildren(prevChildren, prevIndex, prevEndIndex, dom); + destroyChildren(prevChildren, prevIndex, prevEndIndex, slot.element); } } @@ -923,8 +894,7 @@ export function storeDOMWithKey( editor: LexicalEditor, ): void { const keyToDOMMap = editor._keyToDOMMap; - // @ts-ignore We intentionally add this to the Node. - dom['__lexicalKey_' + editor._key] = key; + setNodeKeyOnDOMNode(dom, editor, key); keyToDOMMap.set(key, dom); } diff --git a/packages/lexical/src/LexicalUtils.ts b/packages/lexical/src/LexicalUtils.ts index 9039a186c50..f31d5da2b4b 100644 --- a/packages/lexical/src/LexicalUtils.ts +++ b/packages/lexical/src/LexicalUtils.ts @@ -441,14 +441,30 @@ export function $getNodeFromDOMNode( editorState?: EditorState, ): LexicalNode | null { const editor = getActiveEditor(); - // @ts-ignore We intentionally add this to the Node. - const key = dom[`__lexicalKey_${editor._key}`]; + const key = getNodeKeyFromDOMNode(dom, editor); if (key !== undefined) { return $getNodeByKey(key, editorState); } return null; } +export function setNodeKeyOnDOMNode( + dom: Node, + editor: LexicalEditor, + key: NodeKey, +) { + const prop = `__lexicalKey_${editor._key}`; + (dom as Node & Record)[prop] = key; +} + +export function getNodeKeyFromDOMNode( + dom: Node, + editor: LexicalEditor, +): NodeKey | undefined { + const prop = `__lexicalKey_${editor._key}`; + return (dom as Node & Record)[prop]; +} + export function $getNearestNodeFromDOMNode( startingDOM: Node, editorState?: EditorState, @@ -537,7 +553,7 @@ export function $flushMutations(): void { export function $getNodeFromDOM(dom: Node): null | LexicalNode { const editor = getActiveEditor(); - const nodeKey = getNodeKeyFromDOM(dom, editor); + const nodeKey = getNodeKeyFromDOMTree(dom, editor); if (nodeKey === null) { const rootElement = editor.getRootElement(); if (dom === rootElement) { @@ -555,15 +571,14 @@ export function getTextNodeOffset( return moveSelectionToEnd ? node.getTextContentSize() : 0; } -function getNodeKeyFromDOM( +function getNodeKeyFromDOMTree( // Note that node here refers to a DOM Node, not an Lexical Node dom: Node, editor: LexicalEditor, ): NodeKey | null { let node: Node | null = dom; while (node != null) { - // @ts-ignore We intentionally add this to the Node. - const key: NodeKey = node[`__lexicalKey_${editor._key}`]; + const key = getNodeKeyFromDOMNode(node, editor); if (key !== undefined) { return key; } diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index 538440b8195..4243a93393f 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -57,6 +57,7 @@ export type { TextPointType as TextPoint, } from './LexicalSelection'; export type { + ElementDOMSlot, ElementFormatType, SerializedElementNode, } from './nodes/LexicalElementNode'; diff --git a/packages/lexical/src/nodes/LexicalElementNode.ts b/packages/lexical/src/nodes/LexicalElementNode.ts index 65bc77aec5a..5df8a8b46c8 100644 --- a/packages/lexical/src/nodes/LexicalElementNode.ts +++ b/packages/lexical/src/nodes/LexicalElementNode.ts @@ -8,6 +8,7 @@ import type { DOMExportOutput, + LexicalPrivateDOM, NodeKey, SerializedLexicalNode, } from '../LexicalNode'; @@ -68,6 +69,62 @@ export interface ElementNode { getTopLevelElementOrThrow(): ElementNode; } +export class ElementDOMSlot { + element: HTMLElement; + before: Node | null; + after: Node | null; + constructor( + element: HTMLElement, + before?: Node | undefined | null, + after?: Node | undefined | null, + ) { + this.element = element; + this.before = before || null; + this.after = after || null; + } + withBefore(before: Node | undefined | null): ElementDOMSlot { + return new ElementDOMSlot(this.element, before, this.after); + } + withAfter(after: Node | undefined | null): ElementDOMSlot { + return new ElementDOMSlot(this.element, this.before, after); + } + withElement(element: HTMLElement): ElementDOMSlot { + return new ElementDOMSlot(element, this.before, this.after); + } + insertChild(dom: Node): this { + const element: HTMLElement & LexicalPrivateDOM = this.element; + const before = this.before || element.__lexicalLineBreak || null; + invariant( + before === null || before.parentElement === this.element, + 'ElementDOMSlot.insertChild: before is not in element', + ); + this.element.insertBefore(dom, before); + return this; + } + removeChild(dom: Node): this { + invariant( + dom.parentElement === this.element, + 'ElementDOMSlot.removeChild: dom is not in element', + ); + this.element.removeChild(dom); + return this; + } + replaceChild(dom: Node, prevDom: Node): this { + invariant( + prevDom.parentElement === this.element, + 'ElementDOMSlot.replaceChild: prevDom is not in element', + ); + this.element.replaceChild(dom, prevDom); + return this; + } + getFirstChild(): ChildNode | null { + const firstChild = this.after + ? this.after.nextSibling + : this.element.firstChild; + return firstChild === this.before ? null : firstChild; + } +} + /** @noInheritDoc */ // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export class ElementNode extends LexicalNode { @@ -98,6 +155,14 @@ export class ElementNode extends LexicalNode { this.__dir = null; } + static buildDOMSlot( + element: HTMLElement, + before?: Node | undefined | null, + after?: Node | undefined | null, + ): ElementDOMSlot { + return new ElementDOMSlot(element, before, after); + } + afterCloneFrom(prevNode: this) { super.afterCloneFrom(prevNode); this.__first = prevNode.__first; @@ -528,6 +593,10 @@ export class ElementNode extends LexicalNode { return writableSelf; } + /** @internal */ + getDOMSlot(element: HTMLElement): ElementDOMSlot { + return ElementNode.buildDOMSlot(element); + } exportDOM(editor: LexicalEditor): DOMExportOutput { const {element} = super.exportDOM(editor); if (element && isHTMLElement(element)) { diff --git a/packages/lexical/src/nodes/__tests__/unit/LexicalElementNode.test.tsx b/packages/lexical/src/nodes/__tests__/unit/LexicalElementNode.test.tsx index 21e9ed3c899..366b7c83117 100644 --- a/packages/lexical/src/nodes/__tests__/unit/LexicalElementNode.test.tsx +++ b/packages/lexical/src/nodes/__tests__/unit/LexicalElementNode.test.tsx @@ -7,10 +7,13 @@ */ import { + $applyNodeReplacement, $createTextNode, $getRoot, $getSelection, $isRangeSelection, + createEditor, + ElementDOMSlot, ElementNode, LexicalEditor, LexicalNode, @@ -25,6 +28,7 @@ import { $createTestElementNode, createTestEditor, } from '../../../__tests__/utils'; +import {SerializedElementNode} from '../../LexicalElementNode'; describe('LexicalElementNode tests', () => { let container: HTMLElement; @@ -633,3 +637,87 @@ describe('LexicalElementNode tests', () => { }); }); }); + +describe('getDOMSlot tests', () => { + let container: HTMLElement; + let editor: LexicalEditor; + + beforeEach(async () => { + container = document.createElement('div'); + document.body.appendChild(container); + editor = createEditor({ + nodes: [WrapperElementNode], + onError: (error) => { + throw error; + }, + }); + editor.setRootElement(container); + }); + + afterEach(() => { + document.body.removeChild(container); + // @ts-ignore + container = null; + }); + + class WrapperElementNode extends ElementNode { + static getType() { + return 'wrapper'; + } + static clone(node: WrapperElementNode): WrapperElementNode { + return new WrapperElementNode(node.__key); + } + createDOM() { + const el = document.createElement('main'); + el.appendChild(document.createElement('section')); + return el; + } + updateDOM() { + return false; + } + getDOMSlot(dom: HTMLElement): ElementDOMSlot { + return ElementNode.buildDOMSlot(dom.querySelector('section')!); + } + exportJSON(): SerializedElementNode { + throw new Error('Not implemented'); + } + static importJSON(): WrapperElementNode { + throw new Error('Not implemented'); + } + } + function $createWrapperElementNode(): WrapperElementNode { + return $applyNodeReplacement(new WrapperElementNode()); + } + + test('can create wrapper', () => { + let wrapper: WrapperElementNode; + editor.update( + () => { + wrapper = $createWrapperElementNode().append( + $createTextNode('test text').setMode('token'), + ); + $getRoot().clear().append(wrapper); + }, + {discrete: true}, + ); + expect(container.innerHTML).toBe( + `
test text
`, + ); + editor.update( + () => { + wrapper.append($createTextNode('more text').setMode('token')); + }, + {discrete: true}, + ); + expect(container.innerHTML).toBe( + `
test textmore text
`, + ); + editor.update( + () => { + wrapper.clear(); + }, + {discrete: true}, + ); + expect(container.innerHTML).toBe(`

`); + }); +});