diff --git a/packages/lexical/src/LexicalMutations.ts b/packages/lexical/src/LexicalMutations.ts index 15e4e510d39..18b705a2eb6 100644 --- a/packages/lexical/src/LexicalMutations.ts +++ b/packages/lexical/src/LexicalMutations.ts @@ -8,6 +8,7 @@ import type {TextNode} from '.'; import type {LexicalEditor} from './LexicalEditor'; +import type {LexicalPrivateDOM} from './LexicalNode'; import type {BaseSelection} from './LexicalSelection'; import {IS_FIREFOX} from 'shared/environment'; @@ -53,11 +54,10 @@ 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 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..4c895bb326e 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'; @@ -165,11 +165,33 @@ function setElementFormat(dom: HTMLElement, format: number): void { } } -function $createNode( - key: NodeKey, - parentDOM: null | HTMLElement, - insertDOM: null | Node, -): HTMLElement { +function insertAtSlot(slot: ElementDOMSlot, dom: Node) { + const element: HTMLElement & LexicalPrivateDOM = slot.element; + const before = slot.before || element.__lexicalLineBreak; + if (before) { + element.insertBefore(dom, before); + } else { + element.appendChild(dom); + } +} + +function removeLineBreak(slot: ElementDOMSlot) { + const element: HTMLElement & LexicalPrivateDOM = slot.element; + const br = element.__lexicalLineBreak; + if (br && br.parentElement) { + br.parentElement.removeChild(br); + } + element.__lexicalLineBreak = null; +} + +function insertLineBreak(slot: ElementDOMSlot) { + const element: HTMLElement & LexicalPrivateDOM = slot.element; + const br = document.createElement('br'); + insertAtSlot(slot, br); + element.__lexicalLineBreak = br; +} + +function $createNode(key: NodeKey, slot: ElementDOMSlot | null): HTMLElement { const node = activeNextNodeMap.get(key); if (node === undefined) { @@ -231,19 +253,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) { + insertAtSlot(slot, dom); } if (__DEV__) { @@ -269,25 +280,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 +311,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 +328,7 @@ function isLastChildLineBreakOrDecorator( function reconcileElementTerminatingLineBreak( prevElement: null | ElementNode, nextElement: ElementNode, - dom: HTMLElement, + dom: HTMLElement & LexicalPrivateDOM, ): void { const prevLineBreak = prevElement !== null && @@ -334,34 +344,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 +377,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 +444,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 +458,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,12 +485,13 @@ 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; @@ -511,7 +500,7 @@ function $reconcileChildren( $reconcileNode(prevFirstChildKey, dom); } else { const lastDOM = getPrevElementByKeyOrThrow(prevFirstChildKey); - const replacementDOM = $createNode(nextFrstChildKey, null, null); + const replacementDOM = $createNode(nextFrstChildKey, null); try { dom.replaceChild(replacementDOM, lastDOM); } catch (error) { @@ -550,15 +539,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 +568,7 @@ function $reconcileChildren( nextChildren, prevChildrenSize, nextChildrenSize, - dom, + slot, ); } } @@ -587,7 +577,6 @@ function $reconcileChildren( subTreeTextContent += DOUBLE_LINE_BREAK; } - // @ts-expect-error: internal field dom.__lexicalTextContent = subTreeTextContent; subTreeTextContent = previousSubTreeTextContent + subTreeTextContent; } @@ -610,14 +599,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 +616,6 @@ function $reconcileNode( editorTextContent += previousSubTreeTextContent; } - // @ts-expect-error: internal field const previousSubTreeDirectionTextContent = dom.__lexicalDirTextContent; if (previousSubTreeDirectionTextContent !== undefined) { @@ -658,7 +648,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,8 +735,8 @@ function reconcileDecorator(key: NodeKey, decorator: unknown): void { pendingDecorators[key] = decorator; } -function getFirstChild(element: HTMLElement): Node | null { - return element.firstChild; +function getFirstChild(slot: ElementDOMSlot): Node | null { + return slot.after || slot.element.firstChild; } function getNextSibling(element: HTMLElement): Node | null { @@ -766,13 +756,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 = getFirstChild(slot); let prevIndex = 0; let nextIndex = 0; @@ -781,7 +771,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 +789,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); + insertAtSlot(slot.withBefore(siblingDOM), childDOM); + $reconcileNode(nextKey, slot.element); } prevIndex++; @@ -851,11 +836,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); } } 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..35360809e8b 100644 --- a/packages/lexical/src/nodes/LexicalElementNode.ts +++ b/packages/lexical/src/nodes/LexicalElementNode.ts @@ -68,6 +68,27 @@ 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); + } +} + /** @noInheritDoc */ // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export class ElementNode extends LexicalNode { @@ -98,6 +119,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 +557,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..420f961b71e 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,79 @@ 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]}); + 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; + } + 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(`

`); + }); +});