From 4639f778907d2c23493049bc8417c87ca82edf5c Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 23 Oct 2024 16:07:00 -0700 Subject: [PATCH 01/28] 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 b1a409a9f36..678d82ba0b3 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 0bc18239b37..28f04f6040f 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(`

`); + }); +}); From f9b537acd1c8794288616522c5be846700164037 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Tue, 29 Oct 2024 14:57:12 -0700 Subject: [PATCH 02/28] Refactor mutation observer --- packages/lexical/src/LexicalMutations.ts | 66 ++++++------------- packages/lexical/src/LexicalNode.ts | 6 ++ packages/lexical/src/LexicalUtils.ts | 18 ++++- packages/lexical/src/index.ts | 2 + .../lexical/src/nodes/LexicalElementNode.ts | 26 ++++++++ 5 files changed, 72 insertions(+), 46 deletions(-) diff --git a/packages/lexical/src/LexicalMutations.ts b/packages/lexical/src/LexicalMutations.ts index 8b61dd8f20b..33ced2f5d4e 100644 --- a/packages/lexical/src/LexicalMutations.ts +++ b/packages/lexical/src/LexicalMutations.ts @@ -17,7 +17,6 @@ import {IS_FIREFOX} from 'shared/environment'; import { $getSelection, $isDecoratorNode, - $isElementNode, $isRangeSelection, $isTextNode, $setSelection, @@ -33,6 +32,7 @@ import { getParentElement, getWindow, internalGetRoot, + isDOMUnmanaged, isFirefoxClipboardEvents, } from './LexicalUtils'; // The time between a text entry event and the mutation observer firing. @@ -110,21 +110,28 @@ function shouldUpdateTextNodeFromMutation( return targetDOM.nodeType === DOM_TEXT_TYPE && targetNode.isAttached(); } -export function $getNearestNodePairFromDOMNode( +function $getNearestManagedNodePairFromDOMNode( startingDOM: Node, editor: LexicalEditor, editorState: EditorState, -): [Node, LexicalNode] | [null, null] { - for (let dom: Node | null = startingDOM; dom; dom = getParentElement(dom)) { + rootElement: HTMLElement | null, +): [HTMLElement, LexicalNode] | undefined { + for ( + let dom: Node | null = startingDOM; + dom && !isDOMUnmanaged(dom); + dom = getParentElement(dom) + ) { const key = getNodeKeyFromDOMNode(dom, editor); if (key !== undefined) { const node = $getNodeByKey(key, editorState); if (node) { - return [dom, node]; + // All decorator nodes are unmanaged + return $isDecoratorNode(node) ? undefined : [dom as HTMLElement, node]; } + } else if (dom === rootElement) { + return [rootElement, internalGetRoot(editorState)]; } } - return [null, null]; } export function $flushMutations( @@ -139,7 +146,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". @@ -152,19 +159,16 @@ export function $flushMutations( const mutation = mutations[i]; const type = mutation.type; const targetDOM = mutation.target; - const pair = $getNearestNodePairFromDOMNode( + const pair = $getNearestManagedNodePairFromDOMNode( targetDOM, editor, currentEditorState, + rootElement, ); - let targetNode = pair[1]; - - if ( - (targetNode === null && targetDOM !== rootElement) || - $isDecoratorNode(targetNode) - ) { + if (!pair) { continue; } + const [nodeDOM, targetNode] = pair; if (type === 'characterData') { // Text mutations are deferred and passed to mutation listeners to be @@ -233,11 +237,7 @@ export function $flushMutations( } if (removedDOMsLength !== unremovedBRs) { - if (targetDOM === rootElement) { - targetNode = internalGetRoot(currentEditorState); - } - - badDOMTargets.set(pair[0] || targetDOM, targetNode); + badDOMTargets.set(nodeDOM, targetNode); } } } @@ -248,32 +248,8 @@ export function $flushMutations( // is Lexical's "current" editor state. This is basically like // an internal revert on the DOM. if (badDOMTargets.size > 0) { - for (const [targetDOM, targetNode] of badDOMTargets) { - if ($isElementNode(targetNode)) { - const childKeys = targetNode.getChildrenKeys(); - const slot = targetNode.getDOMSlot(targetDOM as HTMLElement); - let currentDOM = slot.getFirstChild(); - - for (let s = 0; s < childKeys.length; s++) { - const key = childKeys[s]; - const correctDOM = editor.getElementByKey(key); - - if (correctDOM === null) { - continue; - } - - if (currentDOM == null) { - slot.insertChild(correctDOM); - currentDOM = correctDOM; - } else if (currentDOM !== correctDOM) { - slot.replaceChild(correctDOM, currentDOM); - } - - currentDOM = currentDOM.nextSibling; - } - } else if ($isTextNode(targetNode)) { - targetNode.markDirty(); - } + for (const [nodeDOM, targetNode] of badDOMTargets) { + targetNode.reconcileObservedMutation(nodeDOM, editor); } } diff --git a/packages/lexical/src/LexicalNode.ts b/packages/lexical/src/LexicalNode.ts index 65eb19068b9..7717627438a 100644 --- a/packages/lexical/src/LexicalNode.ts +++ b/packages/lexical/src/LexicalNode.ts @@ -62,6 +62,7 @@ export interface LexicalPrivateDOM { __lexicalLineBreak?: HTMLBRElement | undefined | null; __lexicalDirTextContent?: string | undefined | null; __lexicalDir?: 'ltr' | 'rtl' | null | undefined; + __lexicalUnmanaged?: boolean | undefined; } export function $removeNode( @@ -1168,6 +1169,11 @@ export class LexicalNode { markDirty(): void { this.getWritable(); } + + /** @internal */ + reconcileObservedMutation(dom: HTMLElement, editor: LexicalEditor): void { + this.markDirty(); + } } function errorOnTypeKlassMismatch( diff --git a/packages/lexical/src/LexicalUtils.ts b/packages/lexical/src/LexicalUtils.ts index 678d82ba0b3..02ecd7d991f 100644 --- a/packages/lexical/src/LexicalUtils.ts +++ b/packages/lexical/src/LexicalUtils.ts @@ -20,7 +20,12 @@ import type { Spread, } from './LexicalEditor'; import type {EditorState} from './LexicalEditorState'; -import type {LexicalNode, NodeKey, NodeMap} from './LexicalNode'; +import type { + LexicalNode, + LexicalPrivateDOM, + NodeKey, + NodeMap, +} from './LexicalNode'; import type { BaseSelection, PointType, @@ -1857,3 +1862,14 @@ export function setNodeIndentFromDOM( const indent = indentSize / 40; elementNode.setIndent(indent); } + +/** @internal */ +export function setDOMUnmanaged(elementDom: HTMLElement): void { + const el: HTMLElement & LexicalPrivateDOM = elementDom; + el.__lexicalUnmanaged = true; +} + +export function isDOMUnmanaged(elementDom: Node): boolean { + const el: Node & LexicalPrivateDOM = elementDom; + return el.__lexicalUnmanaged === true; +} diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index 28f04f6040f..070907e5f05 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -182,6 +182,7 @@ export { getNearestEditorFromDOMNode, isBlockDomNode, isDocumentFragment, + isDOMUnmanaged, isHTMLAnchorElement, isHTMLElement, isInlineDomNode, @@ -189,6 +190,7 @@ export { isSelectionCapturedInDecoratorInput, isSelectionWithinEditor, resetRandomKey, + setDOMUnmanaged, setNodeIndentFromDOM, } from './LexicalUtils'; export {ArtificialNode__DO_NOT_USE} from './nodes/ArtificialNode'; diff --git a/packages/lexical/src/nodes/LexicalElementNode.ts b/packages/lexical/src/nodes/LexicalElementNode.ts index 5df8a8b46c8..cd55c542a4b 100644 --- a/packages/lexical/src/nodes/LexicalElementNode.ts +++ b/packages/lexical/src/nodes/LexicalElementNode.ts @@ -702,6 +702,32 @@ export class ElementNode extends LexicalNode { canMergeWhenEmpty(): boolean { return false; } + + /** @internal */ + reconcileObservedMutation(dom: HTMLElement, editor: LexicalEditor): void { + const slot = this.getDOMSlot(dom); + let currentDOM = slot.getFirstChild(); + for ( + let currentNode = this.getFirstChild(); + currentNode; + currentNode = currentNode.getNextSibling() + ) { + const correctDOM = editor.getElementByKey(currentNode.getKey()); + + if (correctDOM === null) { + continue; + } + + if (currentDOM == null) { + slot.insertChild(correctDOM); + currentDOM = correctDOM; + } else if (currentDOM !== correctDOM) { + slot.replaceChild(correctDOM, currentDOM); + } + + currentDOM = currentDOM.nextSibling; + } + } } export function $isElementNode( From 351dc2f9b72a447bfa173138c09865dd635bfacd Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Tue, 5 Nov 2024 12:29:16 -0800 Subject: [PATCH 03/28] Mock up horizontal table scroll implementation --- packages/lexical-playground/src/Editor.tsx | 2 + packages/lexical-playground/src/Settings.tsx | 8 +++ .../lexical-playground/src/appSettings.ts | 1 + packages/lexical-playground/src/index.css | 3 +- .../plugins/TableActionMenuPlugin/index.tsx | 9 ++- .../lexical-react/src/LexicalTablePlugin.ts | 7 ++ .../lexical-table/flow/LexicalTable.js.flow | 1 + .../lexical-table/src/LexicalTableNode.ts | 68 +++++++++++++++---- .../src/LexicalTableSelectionHelpers.ts | 20 +++++- packages/lexical-table/src/index.ts | 3 + 10 files changed, 103 insertions(+), 19 deletions(-) diff --git a/packages/lexical-playground/src/Editor.tsx b/packages/lexical-playground/src/Editor.tsx index 31d8a38d433..a112120f17a 100644 --- a/packages/lexical-playground/src/Editor.tsx +++ b/packages/lexical-playground/src/Editor.tsx @@ -92,6 +92,7 @@ export default function Editor(): JSX.Element { shouldPreserveNewLinesInMarkdown, tableCellMerge, tableCellBackgroundColor, + tableHorizontalScroll, }, } = useSettings(); const isEditable = useLexicalEditable(); @@ -182,6 +183,7 @@ export default function Editor(): JSX.Element { diff --git a/packages/lexical-playground/src/Settings.tsx b/packages/lexical-playground/src/Settings.tsx index b015f570d9d..a11bc794032 100644 --- a/packages/lexical-playground/src/Settings.tsx +++ b/packages/lexical-playground/src/Settings.tsx @@ -32,6 +32,7 @@ export default function Settings(): JSX.Element { showTableOfContents, shouldUseLexicalContextMenu, shouldPreserveNewLinesInMarkdown, + tableHorizontalScroll, }, } = useSettings(); useEffect(() => { @@ -167,6 +168,13 @@ export default function Settings(): JSX.Element { checked={shouldPreserveNewLinesInMarkdown} text="Preserve newlines in Markdown" /> + { + setOption('tableHorizontalScroll', !tableHorizontalScroll); + }} + checked={tableHorizontalScroll} + text="Tables have horizontal scroll" + /> ) : null} diff --git a/packages/lexical-playground/src/appSettings.ts b/packages/lexical-playground/src/appSettings.ts index d698af382c2..696fb961144 100644 --- a/packages/lexical-playground/src/appSettings.ts +++ b/packages/lexical-playground/src/appSettings.ts @@ -29,6 +29,7 @@ export const DEFAULT_SETTINGS = { showTreeView: true, tableCellBackgroundColor: true, tableCellMerge: true, + tableHorizontalScroll: false, } as const; // These are mutated in setupEnv diff --git a/packages/lexical-playground/src/index.css b/packages/lexical-playground/src/index.css index 40b443d0976..6ca4b2786c7 100644 --- a/packages/lexical-playground/src/index.css +++ b/packages/lexical-playground/src/index.css @@ -74,17 +74,18 @@ header h1 { .editor-scroller { min-height: 150px; + max-width: 100%; border: 0; display: flex; position: relative; outline: 0; z-index: 0; - overflow: auto; resize: vertical; } .editor { flex: auto; + max-width: 100%; position: relative; resize: vertical; z-index: -1; diff --git a/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx b/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx index 60a09ab8a09..49830462235 100644 --- a/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx @@ -24,8 +24,8 @@ import { $isTableRowNode, $isTableSelection, $unmergeCell, + getTableElement, getTableObserverFromTableElement, - HTMLTableElementWithWithTableSelectionState, TableCellHeaderStates, TableCellNode, TableRowNode, @@ -229,13 +229,12 @@ function TableActionMenu({ editor.update(() => { if (tableCellNode.isAttached()) { const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode); - const tableElement = editor.getElementByKey( - tableNode.getKey(), - ) as HTMLTableElementWithWithTableSelectionState; + const tableNodeElement = editor.getElementByKey(tableNode.getKey()); - if (!tableElement) { + if (!tableNodeElement) { throw new Error('Expected to find tableElement in DOM'); } + const tableElement = getTableElement(tableNode, tableNodeElement); const tableObserver = getTableObserverFromTableElement(tableElement); if (tableObserver !== null) { diff --git a/packages/lexical-react/src/LexicalTablePlugin.ts b/packages/lexical-react/src/LexicalTablePlugin.ts index e8b512eb790..6b8a8027b05 100644 --- a/packages/lexical-react/src/LexicalTablePlugin.ts +++ b/packages/lexical-react/src/LexicalTablePlugin.ts @@ -25,6 +25,7 @@ import { $isTableRowNode, applyTableHandlers, INSERT_TABLE_COMMAND, + setScrollableTablesActive, TableCellNode, TableNode, TableRowNode, @@ -47,13 +48,19 @@ export function TablePlugin({ hasCellMerge = true, hasCellBackgroundColor = true, hasTabHandler = true, + hasHorizontalScroll = false, }: { hasCellMerge?: boolean; hasCellBackgroundColor?: boolean; hasTabHandler?: boolean; + hasHorizontalScroll?: boolean; }): JSX.Element | null { const [editor] = useLexicalComposerContext(); + useEffect(() => { + setScrollableTablesActive(editor, hasHorizontalScroll); + }, [editor, hasHorizontalScroll]); + useEffect(() => { if (!editor.hasNodes([TableNode, TableCellNode, TableRowNode])) { invariant( diff --git a/packages/lexical-table/flow/LexicalTable.js.flow b/packages/lexical-table/flow/LexicalTable.js.flow index 8ec1b813b10..19014ebd897 100644 --- a/packages/lexical-table/flow/LexicalTable.js.flow +++ b/packages/lexical-table/flow/LexicalTable.js.flow @@ -162,6 +162,7 @@ declare export function applyTableHandlers( tableNode: TableNode, tableElement: HTMLElement, editor: LexicalEditor, + hasTabHandler: boolean, ): TableObserver; declare export function $getElementForTableNode( diff --git a/packages/lexical-table/src/LexicalTableNode.ts b/packages/lexical-table/src/LexicalTableNode.ts index be988c5af96..47f9d0812f7 100644 --- a/packages/lexical-table/src/LexicalTableNode.ts +++ b/packages/lexical-table/src/LexicalTableNode.ts @@ -6,18 +6,6 @@ * */ -import type { - DOMConversionMap, - DOMConversionOutput, - DOMExportOutput, - EditorConfig, - LexicalEditor, - LexicalNode, - NodeKey, - SerializedElementNode, - Spread, -} from 'lexical'; - import { addClassNamesToElement, isHTMLElement, @@ -25,8 +13,20 @@ import { } from '@lexical/utils'; import { $applyNodeReplacement, + $getEditor, $getNearestNodeFromDOMNode, + DOMConversionMap, + DOMConversionOutput, + DOMExportOutput, + EditorConfig, + ElementDOMSlot, ElementNode, + LexicalEditor, + LexicalNode, + NodeKey, + SerializedElementNode, + setDOMUnmanaged, + Spread, } from 'lexical'; import {PIXEL_VALUE_REG_EXP} from './constants'; @@ -79,6 +79,25 @@ function setRowStriping( } } +const scrollableEditors = new WeakSet(); + +export function $isScrollableTablesActive( + editor: LexicalEditor = $getEditor(), +): boolean { + return scrollableEditors.has(editor); +} + +export function setScrollableTablesActive( + editor: LexicalEditor, + active: boolean, +) { + if (active) { + scrollableEditors.add(editor); + } else { + scrollableEditors.delete(editor); + } +} + /** @noInheritDoc */ export class TableNode extends ElementNode { /** @internal */ @@ -142,6 +161,16 @@ export class TableNode extends ElementNode { }; } + getDOMSlot(element: HTMLElement): ElementDOMSlot { + const tableElement = + element.dataset.lexicalScrollable === 'true' + ? element.querySelector('table') || element + : element; + return super + .getDOMSlot(tableElement) + .withAfter(tableElement.querySelector('colgroup')); + } + createDOM(config: EditorConfig, editor?: LexicalEditor): HTMLElement { const tableElement = document.createElement('table'); const colGroup = document.createElement('colgroup'); @@ -152,11 +181,19 @@ export class TableNode extends ElementNode { this.getColumnCount(), this.getColWidths(), ); + setDOMUnmanaged(colGroup); addClassNamesToElement(tableElement, config.theme.table); if (this.__rowStriping) { setRowStriping(tableElement, config, true); } + if ($isScrollableTablesActive()) { + const wrapperElement = document.createElement('div'); + wrapperElement.dataset.lexicalScrollable = 'true'; + wrapperElement.style.overflowX = 'auto'; + wrapperElement.appendChild(tableElement); + return wrapperElement; + } return tableElement; } @@ -177,6 +214,13 @@ export class TableNode extends ElementNode { return { ...super.exportDOM(editor), after: (tableElement) => { + if ( + tableElement && + isHTMLElement(tableElement) && + tableElement.dataset.lexicalScrollable === 'true' + ) { + tableElement = tableElement.querySelector('table'); + } if (tableElement) { const newElement = tableElement.cloneNode() as ParentNode; const colGroup = document.createElement('colgroup'); diff --git a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts index f85709749b4..f15496ad5f2 100644 --- a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts +++ b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts @@ -85,9 +85,26 @@ const isMouseDownOnEvent = (event: MouseEvent) => { return (event.buttons & 1) === 1; }; +export function getTableElement( + tableNode: TableNode, + dom: HTMLElement, +): HTMLTableElementWithWithTableSelectionState { + if (dom.tagName === 'TABLE') { + return dom as HTMLTableElementWithWithTableSelectionState; + } + const element = tableNode.getDOMSlot(dom) + .element as HTMLTableElementWithWithTableSelectionState; + invariant( + element.tagName === 'TABLE', + 'getTableElement: Expecting table in as DOM node for TableNode, not %s', + dom.tagName, + ); + return element; +} + export function applyTableHandlers( tableNode: TableNode, - tableElement: HTMLTableElementWithWithTableSelectionState, + element: HTMLElement, editor: LexicalEditor, hasTabHandler: boolean, ): TableObserver { @@ -100,6 +117,7 @@ export function applyTableHandlers( const tableObserver = new TableObserver(editor, tableNode.getKey()); const editorWindow = editor._window || window; + const tableElement = getTableElement(tableNode, element); attachTableObserverToTableElement(tableElement, tableObserver); tableObserver.listenersToRemove.add(() => deatatchTableObserverFromTableElement(tableElement, tableObserver), diff --git a/packages/lexical-table/src/index.ts b/packages/lexical-table/src/index.ts index 2429eb608a9..e849000ed68 100644 --- a/packages/lexical-table/src/index.ts +++ b/packages/lexical-table/src/index.ts @@ -22,7 +22,9 @@ export type {SerializedTableNode} from './LexicalTableNode'; export { $createTableNode, $getElementForTableNode, + $isScrollableTablesActive, $isTableNode, + setScrollableTablesActive, TableNode, } from './LexicalTableNode'; export type {TableDOMCell} from './LexicalTableObserver'; @@ -49,6 +51,7 @@ export { $findTableNode, applyTableHandlers, getDOMCellFromTarget, + getTableElement, getTableObserverFromTableElement, } from './LexicalTableSelectionHelpers'; export { From 5214e0af46e35d7d013c2b3d97894f8f52f751c0 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Tue, 5 Nov 2024 13:55:48 -0800 Subject: [PATCH 04/28] remove cast --- packages/lexical-react/src/LexicalTablePlugin.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/lexical-react/src/LexicalTablePlugin.ts b/packages/lexical-react/src/LexicalTablePlugin.ts index 6b8a8027b05..29912aa032c 100644 --- a/packages/lexical-react/src/LexicalTablePlugin.ts +++ b/packages/lexical-react/src/LexicalTablePlugin.ts @@ -24,6 +24,7 @@ import { $isTableNode, $isTableRowNode, applyTableHandlers, + getTableElement, INSERT_TABLE_COMMAND, setScrollableTablesActive, TableCellNode, @@ -129,7 +130,7 @@ export function TablePlugin({ nodeKey: NodeKey, dom: HTMLElement, ) => { - const tableElement = dom as HTMLTableElementWithWithTableSelectionState; + const tableElement = getTableElement(tableNode, dom); const tableSelection = applyTableHandlers( tableNode, tableElement, From 4447e64fe96e1d7b9365ef33c4edab6f05cb1286 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Tue, 5 Nov 2024 22:13:06 -0800 Subject: [PATCH 05/28] Default to horizontal scroll except in tests --- .../__tests__/utils/index.mjs | 2 + packages/lexical-playground/src/Settings.tsx | 12 +++--- .../lexical-playground/src/appSettings.ts | 2 +- .../lexical-react/src/LexicalTablePlugin.ts | 2 +- .../lexical-table/src/LexicalTableNode.ts | 12 ++++-- .../lexical-table/src/LexicalTableObserver.ts | 12 +++--- .../src/LexicalTableSelectionHelpers.ts | 39 ++++++++++++------- .../__tests__/unit/LexicalTableNode.test.tsx | 2 +- 8 files changed, 51 insertions(+), 32 deletions(-) diff --git a/packages/lexical-playground/__tests__/utils/index.mjs b/packages/lexical-playground/__tests__/utils/index.mjs index 2b461f5fb05..2f248182320 100644 --- a/packages/lexical-playground/__tests__/utils/index.mjs +++ b/packages/lexical-playground/__tests__/utils/index.mjs @@ -64,6 +64,7 @@ export async function initialize({ tableCellMerge, tableCellBackgroundColor, shouldUseLexicalContextMenu, + tableHorizontalScroll, }) { // Tests with legacy events often fail to register keypress, so // slowing it down to reduce flakiness @@ -76,6 +77,7 @@ export async function initialize({ appSettings.isRichText = IS_RICH_TEXT; appSettings.emptyEditor = true; appSettings.disableBeforeInput = LEGACY_EVENTS; + appSettings.tableHorizontalScroll = !!tableHorizontalScroll; if (isCollab) { appSettings.isCollab = isCollab; appSettings.collabId = randomUUID(); diff --git a/packages/lexical-playground/src/Settings.tsx b/packages/lexical-playground/src/Settings.tsx index a11bc794032..2a126f8d0db 100644 --- a/packages/lexical-playground/src/Settings.tsx +++ b/packages/lexical-playground/src/Settings.tsx @@ -28,11 +28,11 @@ export default function Settings(): JSX.Element { isAutocomplete, showTreeView, showNestedEditorTreeView, - disableBeforeInput, + // disableBeforeInput, showTableOfContents, shouldUseLexicalContextMenu, shouldPreserveNewLinesInMarkdown, - tableHorizontalScroll, + // tableHorizontalScroll, }, } = useSettings(); useEffect(() => { @@ -133,14 +133,14 @@ export default function Settings(): JSX.Element { checked={isAutocomplete} text="Autocomplete" /> - { setOption('disableBeforeInput', !disableBeforeInput); setTimeout(() => window.location.reload(), 500); }} checked={disableBeforeInput} text="Legacy Events" - /> + /> */} { setOption('showTableOfContents', !showTableOfContents); @@ -168,13 +168,13 @@ export default function Settings(): JSX.Element { checked={shouldPreserveNewLinesInMarkdown} text="Preserve newlines in Markdown" /> - { setOption('tableHorizontalScroll', !tableHorizontalScroll); }} checked={tableHorizontalScroll} text="Tables have horizontal scroll" - /> + /> */} ) : null} diff --git a/packages/lexical-playground/src/appSettings.ts b/packages/lexical-playground/src/appSettings.ts index 696fb961144..ab489c3e668 100644 --- a/packages/lexical-playground/src/appSettings.ts +++ b/packages/lexical-playground/src/appSettings.ts @@ -29,7 +29,7 @@ export const DEFAULT_SETTINGS = { showTreeView: true, tableCellBackgroundColor: true, tableCellMerge: true, - tableHorizontalScroll: false, + tableHorizontalScroll: true, } as const; // These are mutated in setupEnv diff --git a/packages/lexical-react/src/LexicalTablePlugin.ts b/packages/lexical-react/src/LexicalTablePlugin.ts index 29912aa032c..06f6ccea06a 100644 --- a/packages/lexical-react/src/LexicalTablePlugin.ts +++ b/packages/lexical-react/src/LexicalTablePlugin.ts @@ -49,7 +49,7 @@ export function TablePlugin({ hasCellMerge = true, hasCellBackgroundColor = true, hasTabHandler = true, - hasHorizontalScroll = false, + hasHorizontalScroll = true, }: { hasCellMerge?: boolean; hasCellBackgroundColor?: boolean; diff --git a/packages/lexical-table/src/LexicalTableNode.ts b/packages/lexical-table/src/LexicalTableNode.ts index 47f9d0812f7..3389093e805 100644 --- a/packages/lexical-table/src/LexicalTableNode.ts +++ b/packages/lexical-table/src/LexicalTableNode.ts @@ -28,6 +28,7 @@ import { setDOMUnmanaged, Spread, } from 'lexical'; +import invariant from 'shared/invariant'; import {PIXEL_VALUE_REG_EXP} from './constants'; import {$isTableCellNode, TableCellNode} from './LexicalTableCellNode'; @@ -163,9 +164,12 @@ export class TableNode extends ElementNode { getDOMSlot(element: HTMLElement): ElementDOMSlot { const tableElement = - element.dataset.lexicalScrollable === 'true' - ? element.querySelector('table') || element - : element; + (element.nodeName !== 'TABLE' && element.querySelector('table')) || + element; + invariant( + tableElement.nodeName === 'TABLE', + 'TableNode.getDOMSlot: createDOM() did not return a table', + ); return super .getDOMSlot(tableElement) .withAfter(tableElement.querySelector('colgroup')); @@ -393,7 +397,7 @@ export function $getElementForTableNode( throw new Error('Table Element Not Found'); } - return getTable(tableElement); + return getTable(tableNode, tableElement); } export function $convertTableElement( diff --git a/packages/lexical-table/src/LexicalTableObserver.ts b/packages/lexical-table/src/LexicalTableObserver.ts index 03030509a52..3e761f837ae 100644 --- a/packages/lexical-table/src/LexicalTableObserver.ts +++ b/packages/lexical-table/src/LexicalTableObserver.ts @@ -140,23 +140,25 @@ export class TableObserver { return; } + const tableNode = $getNodeByKey(this.tableNodeKey); const tableElement = this.editor.getElementByKey(this.tableNodeKey); - if (!tableElement) { + if (!tableElement || !$isTableNode(tableNode)) { throw new Error('Expected to find TableElement in DOM'); } - this.table = getTable(tableElement); + this.table = getTable(tableNode, tableElement); }); }); this.editor.update(() => { + const tableNode = $getNodeByKey(this.tableNodeKey); const tableElement = this.editor.getElementByKey(this.tableNodeKey); - if (!tableElement) { + if (!tableElement || !$isTableNode(tableNode)) { throw new Error('Expected to find TableElement in DOM'); } - this.table = getTable(tableElement); + this.table = getTable(tableNode, tableElement); observer.observe(tableElement, { attributes: true, childList: true, @@ -194,7 +196,7 @@ export class TableObserver { throw new Error('Expected to find TableElement in DOM'); } - const grid = getTable(tableElement); + const grid = getTable(tableNode, tableElement); $updateDOMForSelection(editor, grid, null); $setSelection(null); editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined); diff --git a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts index f15496ad5f2..14a829341d8 100644 --- a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts +++ b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts @@ -7,7 +7,6 @@ */ import type {TableCellNode} from './LexicalTableCellNode'; -import type {TableNode} from './LexicalTableNode'; import type {TableDOMCell, TableDOMRows} from './LexicalTableObserver'; import type { TableMapType, @@ -68,7 +67,7 @@ import {CAN_USE_DOM} from 'shared/canUseDOM'; import invariant from 'shared/invariant'; import {$isTableCellNode} from './LexicalTableCellNode'; -import {$isTableNode} from './LexicalTableNode'; +import {$isTableNode, TableNode} from './LexicalTableNode'; import {TableDOMTable, TableObserver} from './LexicalTableObserver'; import {$isTableRowNode} from './LexicalTableRowNode'; import {$isTableSelection} from './LexicalTableSelection'; @@ -85,12 +84,14 @@ const isMouseDownOnEvent = (event: MouseEvent) => { return (event.buttons & 1) === 1; }; -export function getTableElement( +export function getTableElement( tableNode: TableNode, - dom: HTMLElement, -): HTMLTableElementWithWithTableSelectionState { - if (dom.tagName === 'TABLE') { - return dom as HTMLTableElementWithWithTableSelectionState; + dom: T, +): HTMLTableElementWithWithTableSelectionState | (T & null) { + if (!dom || dom.tagName === 'TABLE') { + return dom as unknown as + | HTMLTableElementWithWithTableSelectionState + | (T & null); } const element = tableNode.getDOMSlot(dom) .element as HTMLTableElementWithWithTableSelectionState; @@ -1024,7 +1025,11 @@ export function doesTargetContainText(node: Node): boolean { return false; } -export function getTable(tableElement: HTMLElement): TableDOMTable { +export function getTable( + tableNode: TableNode, + dom: HTMLElement, +): TableDOMTable { + const tableElement = getTableElement(tableNode, dom); const domRows: TableDOMRows = []; const grid = { columns: 0, @@ -1577,11 +1582,12 @@ function $handleArrowKey( } const anchorCellTable = $findTableNode(anchorCellNode); if (anchorCellTable !== tableNode && anchorCellTable != null) { - const anchorCellTableElement = editor.getElementByKey( - anchorCellTable.getKey(), + const anchorCellTableElement = getTableElement( + anchorCellTable, + editor.getElementByKey(anchorCellTable.getKey()), ); if (anchorCellTableElement != null) { - tableObserver.table = getTable(anchorCellTableElement); + tableObserver.table = getTable(anchorCellTable, anchorCellTableElement); return $handleArrowKey( editor, event, @@ -1693,8 +1699,13 @@ function $handleArrowKey( ); const [tableNodeFromSelection] = selection.getNodes(); - const tableElement = editor.getElementByKey( - tableNodeFromSelection.getKey(), + invariant( + $isTableNode(tableNodeFromSelection), + '$handleArrowKey: TableSelection.getNodes()[0] expected to be TableNode', + ); + const tableElement = getTableElement( + tableNodeFromSelection, + editor.getElementByKey(tableNodeFromSelection.getKey()), ); if ( !$isTableCellNode(anchorCellNode) || @@ -1706,7 +1717,7 @@ function $handleArrowKey( } tableObserver.updateTableTableSelection(selection); - const grid = getTable(tableElement); + const grid = getTable(tableNodeFromSelection, tableElement); const cordsAnchor = tableNode.getCordsFromCellNode(anchorCellNode, grid); const anchorCell = tableNode.getDOMCellFromCordsOrThrow( cordsAnchor.x, diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx b/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx index 570ac0e9ed2..b7eed9a871b 100644 --- a/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx +++ b/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx @@ -448,6 +448,6 @@ describe('LexicalTableNode tests', () => { }); }, undefined, - , + , ); }); From 38f364ca239b8ef7a8be99d9cc8c692ab3864add Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Tue, 5 Nov 2024 22:44:41 -0800 Subject: [PATCH 06/28] Add some docstrings for DOMSlot --- .../lexical-table/src/LexicalTableNode.ts | 4 +- packages/lexical/src/LexicalNode.ts | 7 ++- .../lexical/src/nodes/LexicalElementNode.ts | 62 ++++++++++++++----- .../unit/LexicalElementNode.test.tsx | 2 +- 4 files changed, 58 insertions(+), 17 deletions(-) diff --git a/packages/lexical-table/src/LexicalTableNode.ts b/packages/lexical-table/src/LexicalTableNode.ts index 3389093e805..a0773fec9a4 100644 --- a/packages/lexical-table/src/LexicalTableNode.ts +++ b/packages/lexical-table/src/LexicalTableNode.ts @@ -191,8 +191,10 @@ export class TableNode extends ElementNode { if (this.__rowStriping) { setRowStriping(tableElement, config, true); } - if ($isScrollableTablesActive()) { + if ($isScrollableTablesActive(editor)) { const wrapperElement = document.createElement('div'); + tableElement.contentEditable = 'true'; + wrapperElement.contentEditable = 'false'; wrapperElement.dataset.lexicalScrollable = 'true'; wrapperElement.style.overflowX = 'auto'; wrapperElement.appendChild(tableElement); diff --git a/packages/lexical/src/LexicalNode.ts b/packages/lexical/src/LexicalNode.ts index 7717627438a..3b1141034c1 100644 --- a/packages/lexical/src/LexicalNode.ts +++ b/packages/lexical/src/LexicalNode.ts @@ -1170,7 +1170,12 @@ export class LexicalNode { this.getWritable(); } - /** @internal */ + /** + * @internal + * + * When the reconciler detects that a node was mutated, this method + * may be called to restore the node to a known good state. + */ reconcileObservedMutation(dom: HTMLElement, editor: LexicalEditor): void { this.markDirty(); } diff --git a/packages/lexical/src/nodes/LexicalElementNode.ts b/packages/lexical/src/nodes/LexicalElementNode.ts index cd55c542a4b..8ac0b75f93c 100644 --- a/packages/lexical/src/nodes/LexicalElementNode.ts +++ b/packages/lexical/src/nodes/LexicalElementNode.ts @@ -69,28 +69,47 @@ export interface ElementNode { getTopLevelElementOrThrow(): ElementNode; } +/** + * A utility class for managing the DOM children of an ElementNode + */ export class ElementDOMSlot { element: HTMLElement; before: Node | null; after: Node | null; constructor( + /** The element returned by createDOM */ element: HTMLElement, + /** All managed children will be inserted before this node, if defined */ before?: Node | undefined | null, + /** All managed children will be inserted after this node, if defined */ after?: Node | undefined | null, ) { this.element = element; this.before = before || null; this.after = after || null; } + /** + * Return a new ElementDOMSlot where all managed children will be inserted before this node + */ withBefore(before: Node | undefined | null): ElementDOMSlot { return new ElementDOMSlot(this.element, before, this.after); } + /** + * Return a new ElementDOMSlot where all managed children will be inserted after this node + */ withAfter(after: Node | undefined | null): ElementDOMSlot { return new ElementDOMSlot(this.element, this.before, after); } + /** + * Return a new ElementDOMSlot with an updated root element + */ withElement(element: HTMLElement): ElementDOMSlot { return new ElementDOMSlot(element, this.before, this.after); } + /** + * Insert the given child before this.before and any reconciler managed line break node, + * or append it if this.before is not defined + */ insertChild(dom: Node): this { const element: HTMLElement & LexicalPrivateDOM = this.element; const before = this.before || element.__lexicalLineBreak || null; @@ -101,6 +120,9 @@ export class ElementDOMSlot { this.element.insertBefore(dom, before); return this; } + /** + * Remove the managed child from this container, will throw if it was not already there + */ removeChild(dom: Node): this { invariant( dom.parentElement === this.element, @@ -109,6 +131,12 @@ export class ElementDOMSlot { this.element.removeChild(dom); return this; } + /** + * Replace managed child prevDom with dom. Will throw if prevDom is not a child + * + * @param dom The new node to replace prevDom + * @param prevDom the node that will be replaced + */ replaceChild(dom: Node, prevDom: Node): this { invariant( prevDom.parentElement === this.element, @@ -117,11 +145,18 @@ export class ElementDOMSlot { this.element.replaceChild(dom, prevDom); return this; } + /** + * Returns the first managed child of this node, + * which will either be this.after.nextSibling or this.element.firstChild, + * and will never be this.before if it is defined. + */ getFirstChild(): ChildNode | null { - const firstChild = this.after - ? this.after.nextSibling - : this.element.firstChild; - return firstChild === this.before ? null : firstChild; + const element: HTMLElement & LexicalPrivateDOM = this.element; + const firstChild = this.after ? this.after.nextSibling : element.firstChild; + return firstChild === this.before || + firstChild === element.__lexicalLineBreak + ? null + : firstChild; } } @@ -155,14 +190,6 @@ 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; @@ -593,9 +620,16 @@ export class ElementNode extends LexicalNode { return writableSelf; } - /** @internal */ + /** + * @internal + * + * An experimental API that an ElementNode can override to control where its + * children are inserted into the DOM, this is useful to add a wrapping node + * or accessory nodes before or after the children. The root of the node returned + * by createDOM must still be exactly one HTMLElement. + */ getDOMSlot(element: HTMLElement): ElementDOMSlot { - return ElementNode.buildDOMSlot(element); + return new ElementDOMSlot(element); } exportDOM(editor: LexicalEditor): DOMExportOutput { const {element} = super.exportDOM(editor); diff --git a/packages/lexical/src/nodes/__tests__/unit/LexicalElementNode.test.tsx b/packages/lexical/src/nodes/__tests__/unit/LexicalElementNode.test.tsx index 366b7c83117..6736ce72edc 100644 --- a/packages/lexical/src/nodes/__tests__/unit/LexicalElementNode.test.tsx +++ b/packages/lexical/src/nodes/__tests__/unit/LexicalElementNode.test.tsx @@ -676,7 +676,7 @@ describe('getDOMSlot tests', () => { return false; } getDOMSlot(dom: HTMLElement): ElementDOMSlot { - return ElementNode.buildDOMSlot(dom.querySelector('section')!); + return super.getDOMSlot(dom).withElement(dom.querySelector('section')!); } exportJSON(): SerializedElementNode { throw new Error('Not implemented'); From 03752b2af8a5c651d26f7a5b60fdfc6da0f983b5 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Tue, 5 Nov 2024 23:11:07 -0800 Subject: [PATCH 07/28] document setDOMUnmanaged --- packages/lexical/src/LexicalUtils.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/lexical/src/LexicalUtils.ts b/packages/lexical/src/LexicalUtils.ts index 02ecd7d991f..c6928e8ce23 100644 --- a/packages/lexical/src/LexicalUtils.ts +++ b/packages/lexical/src/LexicalUtils.ts @@ -1863,7 +1863,12 @@ export function setNodeIndentFromDOM( elementNode.setIndent(indent); } -/** @internal */ +/** + * @internal + * + * Mark this node as unmanaged by lexical's mutation observer like + * decorator nodes + */ export function setDOMUnmanaged(elementDom: HTMLElement): void { const el: HTMLElement & LexicalPrivateDOM = elementDom; el.__lexicalUnmanaged = true; From 498f25526808a7b413717346112fbcc06bff98e0 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 6 Nov 2024 07:26:43 -0800 Subject: [PATCH 08/28] Make table export more generic and improve nested table compatibility --- .../lexical-table/src/LexicalTableNode.ts | 24 ++++++++----------- packages/lexical/src/LexicalUtils.ts | 5 ++++ 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/packages/lexical-table/src/LexicalTableNode.ts b/packages/lexical-table/src/LexicalTableNode.ts index a0773fec9a4..2a86b4b150c 100644 --- a/packages/lexical-table/src/LexicalTableNode.ts +++ b/packages/lexical-table/src/LexicalTableNode.ts @@ -223,25 +223,21 @@ export class TableNode extends ElementNode { if ( tableElement && isHTMLElement(tableElement) && - tableElement.dataset.lexicalScrollable === 'true' + tableElement.nodeName !== 'TABLE' ) { tableElement = tableElement.querySelector('table'); } - if (tableElement) { - const newElement = tableElement.cloneNode() as ParentNode; - const colGroup = document.createElement('colgroup'); + if (!tableElement || !isHTMLElement(tableElement)) { + return null; + } + // Wrap direct descendant rows in a tbody for export + const rows = tableElement.querySelectorAll(':scope > tr'); + if (rows.length > 0) { const tBody = document.createElement('tbody'); - if (isHTMLElement(tableElement)) { - const cols = tableElement.querySelectorAll('col'); - colGroup.append(...cols); - const rows = tableElement.querySelectorAll('tr'); - tBody.append(...rows); - } - - newElement.replaceChildren(colGroup, tBody); - - return newElement as HTMLElement; + tBody.append(...rows); + tableElement.append(tBody); } + return tableElement; }, }; } diff --git a/packages/lexical/src/LexicalUtils.ts b/packages/lexical/src/LexicalUtils.ts index c6928e8ce23..fe359101910 100644 --- a/packages/lexical/src/LexicalUtils.ts +++ b/packages/lexical/src/LexicalUtils.ts @@ -1874,6 +1874,11 @@ export function setDOMUnmanaged(elementDom: HTMLElement): void { el.__lexicalUnmanaged = true; } +/** + * @internal + * + * True if this DOM node was marked with {@link setDOMUnmanaged} + */ export function isDOMUnmanaged(elementDom: Node): boolean { const el: Node & LexicalPrivateDOM = elementDom; return el.__lexicalUnmanaged === true; From 34e14cf4266c74fdb706dfc9eae2b6de2c92334b Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 6 Nov 2024 07:57:13 -0800 Subject: [PATCH 09/28] unit test for table wrapping --- .../__tests__/unit/LexicalTableNode.test.tsx | 971 +++++++++++------- .../lexical/src/__tests__/utils/index.tsx | 21 +- 2 files changed, 620 insertions(+), 372 deletions(-) diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx b/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx index b7eed9a871b..16940c53a2e 100644 --- a/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx +++ b/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx @@ -27,8 +27,11 @@ import { } from 'lexical'; import { DataTransferMock, + expectHtmlToBeEqual, + html, initializeUnitTest, invariant, + polyfillContentEditable, } from 'lexical/src/__tests__/utils'; import {$getElementForTableNode, TableNode} from '../../LexicalTableNode'; @@ -71,383 +74,611 @@ const editorConfig = Object.freeze({ }, }); -describe('LexicalTableNode tests', () => { - initializeUnitTest( - (testEnv) => { - beforeEach(async () => { - const {editor} = testEnv; - await editor.update(() => { - const root = $getRoot(); - const paragraph = $createParagraphNode(); - root.append(paragraph); - paragraph.select(); - }); - }); - - test('TableNode.constructor', async () => { - const {editor} = testEnv; - - await editor.update(() => { - const tableNode = $createTableNode(); - - expect(tableNode).not.toBe(null); - }); - - expect(() => $createTableNode()).toThrow(); - }); - - test('TableNode.createDOM()', async () => { - const {editor} = testEnv; - - await editor.update(() => { - const tableNode = $createTableNode(); - - expect(tableNode.createDOM(editorConfig).outerHTML).toBe( - `
`, - ); - }); - }); - - test('Copy table from an external source', async () => { - const {editor} = testEnv; - - const dataTransfer = new DataTransferMock(); - dataTransfer.setData( - 'text/html', - '

Hello there

General Kenobi!

Lexical is nice


', - ); - await editor.update(() => { - const selection = $getSelection(); - invariant( - $isRangeSelection(selection), - 'isRangeSelection(selection)', - ); - $insertDataTransferForRichText(dataTransfer, selection, editor); - }); - // Make sure paragraph is inserted inside empty cells - const emptyCell = '


'; - expect(testEnv.innerHTML).toBe( - `${emptyCell}

Hello there

General Kenobi!

Lexical is nice

`, - ); - }); +function wrapTableHtml(expected: string): string { + return expected + .replace( + /
/g, '
'); +} - test('Copy table from an external source like gdoc with formatting', async () => { - const {editor} = testEnv; +polyfillContentEditable(); - const dataTransfer = new DataTransferMock(); - dataTransfer.setData( - 'text/html', - '
SurfaceMWP_WORK_LS_COMPOSER77349
LexicalXDS_RICH_TEXT_AREAsdvd sdfvsfs
', - ); - await editor.update(() => { - const selection = $getSelection(); - invariant( - $isRangeSelection(selection), - 'isRangeSelection(selection)', - ); - $insertDataTransferForRichText(dataTransfer, selection, editor); - }); - expect(testEnv.innerHTML).toBe( - `

Surface

MWP_WORK_LS_COMPOSER

77349

Lexical

XDS_RICH_TEXT_AREA

sdvd sdfvsfs

`, +describe('LexicalTableNode tests', () => { + [false, true].forEach((hasHorizontalScroll) => { + describe(`hasHorizontalScroll={${hasHorizontalScroll}}`, () => { + function expectTableHtmlToBeEqual( + actual: string, + expected: string, + ): void { + return expectHtmlToBeEqual( + actual, + hasHorizontalScroll ? wrapTableHtml(expected) : expected, ); - }); - - test('Cut table in the middle of a range selection', async () => { - const {editor} = testEnv; - - await editor.update(() => { - const root = $getRoot(); - const paragraph = root.getFirstChild(); - const beforeText = $createTextNode('text before the table'); - const table = $createTableNodeWithDimensions(4, 4, true); - const afterText = $createTextNode('text after the table'); - - paragraph?.append(beforeText); - paragraph?.append(table); - paragraph?.append(afterText); - }); - await editor.update(() => { - editor.focus(); - $selectAll(); - }); - await editor.update(() => { - editor.dispatchCommand(CUT_COMMAND, {} as ClipboardEvent); - }); - - expect(testEnv.innerHTML).toBe(`


`); - }); - - test('Cut table as last node in range selection ', async () => { - const {editor} = testEnv; - - await editor.update(() => { - const root = $getRoot(); - const paragraph = root.getFirstChild(); - const beforeText = $createTextNode('text before the table'); - const table = $createTableNodeWithDimensions(4, 4, true); - - paragraph?.append(beforeText); - paragraph?.append(table); - }); - await editor.update(() => { - editor.focus(); - $selectAll(); - }); - await editor.update(() => { - editor.dispatchCommand(CUT_COMMAND, {} as ClipboardEvent); - }); - - expect(testEnv.innerHTML).toBe(`


`); - }); - - test('Cut table as first node in range selection ', async () => { - const {editor} = testEnv; - - await editor.update(() => { - const root = $getRoot(); - const paragraph = root.getFirstChild(); - const table = $createTableNodeWithDimensions(4, 4, true); - const afterText = $createTextNode('text after the table'); - - paragraph?.append(table); - paragraph?.append(afterText); - }); - await editor.update(() => { - editor.focus(); - $selectAll(); - }); - await editor.update(() => { - editor.dispatchCommand(CUT_COMMAND, {} as ClipboardEvent); - }); - - expect(testEnv.innerHTML).toBe(`


`); - }); - - test('Cut table is whole selection, should remove it', async () => { - const {editor} = testEnv; - - await editor.update(() => { - const root = $getRoot(); - const table = $createTableNodeWithDimensions(4, 4, true); - root.append(table); - }); - await editor.update(() => { - const root = $getRoot(); - const table = root.getLastChild(); - if (table) { - const DOMTable = $getElementForTableNode(editor, table); - if (DOMTable) { - table - ?.getCellNodeFromCords(0, 0, DOMTable) - ?.getLastChild() - ?.append($createTextNode('some text')); - const selection = $createTableSelection(); - selection.set( - table.__key, - table?.getCellNodeFromCords(0, 0, DOMTable)?.__key || '', - table?.getCellNodeFromCords(3, 3, DOMTable)?.__key || '', + } + initializeUnitTest( + (testEnv) => { + beforeEach(async () => { + const {editor} = testEnv; + await editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + root.append(paragraph); + paragraph.select(); + }); + }); + + test('TableNode.constructor', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const tableNode = $createTableNode(); + + expect(tableNode).not.toBe(null); + }); + + expect(() => $createTableNode()).toThrow(); + }); + + test('TableNode.createDOM()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const tableNode = $createTableNode(); + + expectTableHtmlToBeEqual( + tableNode.createDOM(editorConfig).outerHTML, + html` + + +
+ `, ); - $setSelection(selection); - editor.dispatchCommand(CUT_COMMAND, { - preventDefault: () => {}, - stopPropagation: () => {}, - } as ClipboardEvent); - } - } - }); - - expect(testEnv.innerHTML).toBe(`


`); - }); - - test('Cut subsection of table cells, should just clear contents', async () => { - const {editor} = testEnv; - - await editor.update(() => { - const root = $getRoot(); - const table = $createTableNodeWithDimensions(4, 4, true); - root.append(table); - }); - await editor.update(() => { - const root = $getRoot(); - const table = root.getLastChild(); - if (table) { - const DOMTable = $getElementForTableNode(editor, table); - if (DOMTable) { - table - ?.getCellNodeFromCords(0, 0, DOMTable) - ?.getLastChild() - ?.append($createTextNode('some text')); + }); + }); + + test('Copy table from an external source', async () => { + const {editor} = testEnv; + + const dataTransfer = new DataTransferMock(); + dataTransfer.setData( + 'text/html', + '

Hello there

General Kenobi!

Lexical is nice


', + ); + await editor.update(() => { + const selection = $getSelection(); + invariant( + $isRangeSelection(selection), + 'isRangeSelection(selection)', + ); + $insertDataTransferForRichText(dataTransfer, selection, editor); + }); + // Make sure paragraph is inserted inside empty cells + const emptyCell = '


'; + expectTableHtmlToBeEqual( + testEnv.innerHTML, + html` + + + + + + + + + + + + ${emptyCell} + +
+

+ Hello there +

+
+

+ General Kenobi! +

+
+

+ Lexical is nice +

+
+ `, + ); + }); + + test('Copy table from an external source like gdoc with formatting', async () => { + const {editor} = testEnv; + + const dataTransfer = new DataTransferMock(); + dataTransfer.setData( + 'text/html', + '
SurfaceMWP_WORK_LS_COMPOSER77349
LexicalXDS_RICH_TEXT_AREAsdvd sdfvsfs
', + ); + await editor.update(() => { + const selection = $getSelection(); + invariant( + $isRangeSelection(selection), + 'isRangeSelection(selection)', + ); + $insertDataTransferForRichText(dataTransfer, selection, editor); + }); + expectTableHtmlToBeEqual( + testEnv.innerHTML, + html` + + + + + + + + + + + + + + + + +
+

+ Surface +

+
+

+ MWP_WORK_LS_COMPOSER +

+
+

+ 77349 +

+
+

+ Lexical +

+
+

+ XDS_RICH_TEXT_AREA +

+
+

+ sdvd + sdfvsfs +

+
+ `, + ); + }); + + test('Cut table in the middle of a range selection', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const root = $getRoot(); + const paragraph = root.getFirstChild(); + const beforeText = $createTextNode('text before the table'); + const table = $createTableNodeWithDimensions(4, 4, true); + const afterText = $createTextNode('text after the table'); + + paragraph?.append(beforeText); + paragraph?.append(table); + paragraph?.append(afterText); + }); + await editor.update(() => { + editor.focus(); + $selectAll(); + }); + await editor.update(() => { + editor.dispatchCommand(CUT_COMMAND, {} as ClipboardEvent); + }); + + expectHtmlToBeEqual( + testEnv.innerHTML, + html` +


+ `, + ); + }); + + test('Cut table as last node in range selection ', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const root = $getRoot(); + const paragraph = root.getFirstChild(); + const beforeText = $createTextNode('text before the table'); + const table = $createTableNodeWithDimensions(4, 4, true); + + paragraph?.append(beforeText); + paragraph?.append(table); + }); + await editor.update(() => { + editor.focus(); + $selectAll(); + }); + await editor.update(() => { + editor.dispatchCommand(CUT_COMMAND, {} as ClipboardEvent); + }); + + expectHtmlToBeEqual( + testEnv.innerHTML, + html` +


+ `, + ); + }); + + test('Cut table as first node in range selection ', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const root = $getRoot(); + const paragraph = root.getFirstChild(); + const table = $createTableNodeWithDimensions(4, 4, true); + const afterText = $createTextNode('text after the table'); + + paragraph?.append(table); + paragraph?.append(afterText); + }); + await editor.update(() => { + editor.focus(); + $selectAll(); + }); + await editor.update(() => { + editor.dispatchCommand(CUT_COMMAND, {} as ClipboardEvent); + }); + + expectHtmlToBeEqual( + testEnv.innerHTML, + html` +


+ `, + ); + }); + + test('Cut table is whole selection, should remove it', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const root = $getRoot(); + const table = $createTableNodeWithDimensions(4, 4, true); + root.append(table); + }); + await editor.update(() => { + const root = $getRoot(); + const table = root.getLastChild(); + if (table) { + const DOMTable = $getElementForTableNode(editor, table); + if (DOMTable) { + table + ?.getCellNodeFromCords(0, 0, DOMTable) + ?.getLastChild() + ?.append($createTextNode('some text')); + const selection = $createTableSelection(); + selection.set( + table.__key, + table?.getCellNodeFromCords(0, 0, DOMTable)?.__key || '', + table?.getCellNodeFromCords(3, 3, DOMTable)?.__key || '', + ); + $setSelection(selection); + editor.dispatchCommand(CUT_COMMAND, { + preventDefault: () => {}, + stopPropagation: () => {}, + } as ClipboardEvent); + } + } + }); + + expectHtmlToBeEqual( + testEnv.innerHTML, + html` +


+ `, + ); + }); + + test('Cut subsection of table cells, should just clear contents', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const root = $getRoot(); + const table = $createTableNodeWithDimensions(4, 4, true); + root.append(table); + }); + await editor.update(() => { + const root = $getRoot(); + const table = root.getLastChild(); + if (table) { + const DOMTable = $getElementForTableNode(editor, table); + if (DOMTable) { + table + ?.getCellNodeFromCords(0, 0, DOMTable) + ?.getLastChild() + ?.append($createTextNode('some text')); + const selection = $createTableSelection(); + selection.set( + table.__key, + table?.getCellNodeFromCords(0, 0, DOMTable)?.__key || '', + table?.getCellNodeFromCords(2, 2, DOMTable)?.__key || '', + ); + $setSelection(selection); + editor.dispatchCommand(CUT_COMMAND, { + preventDefault: () => {}, + stopPropagation: () => {}, + } as ClipboardEvent); + } + } + }); + + expectTableHtmlToBeEqual( + testEnv.innerHTML, + html` +


+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+ `, + ); + }); + + test('Table plain text output validation', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const root = $getRoot(); + const table = $createTableNodeWithDimensions(4, 4, true); + root.append(table); + }); + await editor.update(() => { + const root = $getRoot(); + const table = root.getLastChild(); + if (table) { + const DOMTable = $getElementForTableNode(editor, table); + if (DOMTable) { + table + ?.getCellNodeFromCords(0, 0, DOMTable) + ?.getLastChild() + ?.append($createTextNode('1')); + table + ?.getCellNodeFromCords(1, 0, DOMTable) + ?.getLastChild() + ?.append($createTextNode('')); + table + ?.getCellNodeFromCords(2, 0, DOMTable) + ?.getLastChild() + ?.append($createTextNode('2')); + table + ?.getCellNodeFromCords(0, 1, DOMTable) + ?.getLastChild() + ?.append($createTextNode('3')); + table + ?.getCellNodeFromCords(1, 1, DOMTable) + ?.getLastChild() + ?.append($createTextNode('4')); + table + ?.getCellNodeFromCords(2, 1, DOMTable) + ?.getLastChild() + ?.append($createTextNode('')); + const selection = $createTableSelection(); + selection.set( + table.__key, + table?.getCellNodeFromCords(0, 0, DOMTable)?.__key || '', + table?.getCellNodeFromCords(2, 1, DOMTable)?.__key || '', + ); + expect(selection.getTextContent()).toBe(`1\t\t2\n3\t4\t\n`); + } + } + }); + }); + + test('Toggle row striping ON/OFF', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const root = $getRoot(); + const table = $createTableNodeWithDimensions(4, 4, true); + root.append(table); + }); + await editor.update(() => { + const root = $getRoot(); + const table = root.getLastChild(); + if (table) { + table.setRowStriping(true); + } + }); + + await editor.update(() => { + const root = $getRoot(); + const table = root.getLastChild(); + expectTableHtmlToBeEqual( + table!.createDOM(editorConfig).outerHTML, + html` + + + + + + + +
+ `, + ); + }); + + await editor.update(() => { + const root = $getRoot(); + const table = root.getLastChild(); + if (table) { + table.setRowStriping(false); + } + }); + + await editor.update(() => { + const root = $getRoot(); + const table = root.getLastChild(); + expectTableHtmlToBeEqual( + table!.createDOM(editorConfig).outerHTML, + html` + + + + + + + +
+ `, + ); + }); + }); + + test('Update column widths', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const root = $getRoot(); + const table = $createTableNodeWithDimensions(4, 2, true); + root.append(table); + }); + + // Set widths + await editor.update(() => { + const root = $getRoot(); + const table = root.getLastChild(); + table!.setColWidths([50, 50]); + }); + + await editor.update(() => { + const root = $getRoot(); + const table = root.getLastChild(); + expectTableHtmlToBeEqual( + table!.createDOM(editorConfig).outerHTML, + html` + + + + + +
+ `, + ); + const colWidths = table!.getColWidths(); + + // colwidths should be immutable in DEV + expect(() => { + (colWidths as number[]).push(100); + }).toThrow(); + expect(table!.getColWidths()).toStrictEqual([50, 50]); + expect(table!.getColumnCount()).toBe(2); + }); + + // Add a column + await editor.update(() => { + const root = $getRoot(); + const table = root.getLastChild(); + const DOMTable = $getElementForTableNode(editor, table!); const selection = $createTableSelection(); selection.set( - table.__key, - table?.getCellNodeFromCords(0, 0, DOMTable)?.__key || '', - table?.getCellNodeFromCords(2, 2, DOMTable)?.__key || '', + table!.__key, + table!.getCellNodeFromCords(0, 0, DOMTable)?.__key || '', + table!.getCellNodeFromCords(0, 0, DOMTable)?.__key || '', ); $setSelection(selection); - editor.dispatchCommand(CUT_COMMAND, { - preventDefault: () => {}, - stopPropagation: () => {}, - } as ClipboardEvent); - } - } - }); - - expect(testEnv.innerHTML).toBe( - `


















`, - ); - }); - - test('Table plain text output validation', async () => { - const {editor} = testEnv; - - await editor.update(() => { - const root = $getRoot(); - const table = $createTableNodeWithDimensions(4, 4, true); - root.append(table); - }); - await editor.update(() => { - const root = $getRoot(); - const table = root.getLastChild(); - if (table) { - const DOMTable = $getElementForTableNode(editor, table); - if (DOMTable) { - table - ?.getCellNodeFromCords(0, 0, DOMTable) - ?.getLastChild() - ?.append($createTextNode('1')); - table - ?.getCellNodeFromCords(1, 0, DOMTable) - ?.getLastChild() - ?.append($createTextNode('')); - table - ?.getCellNodeFromCords(2, 0, DOMTable) - ?.getLastChild() - ?.append($createTextNode('2')); - table - ?.getCellNodeFromCords(0, 1, DOMTable) - ?.getLastChild() - ?.append($createTextNode('3')); - table - ?.getCellNodeFromCords(1, 1, DOMTable) - ?.getLastChild() - ?.append($createTextNode('4')); - table - ?.getCellNodeFromCords(2, 1, DOMTable) - ?.getLastChild() - ?.append($createTextNode('')); - const selection = $createTableSelection(); - selection.set( - table.__key, - table?.getCellNodeFromCords(0, 0, DOMTable)?.__key || '', - table?.getCellNodeFromCords(2, 1, DOMTable)?.__key || '', + $insertTableColumn__EXPERIMENTAL(); + table!.setColWidths([50, 50, 100]); + }); + + await editor.update(() => { + const root = $getRoot(); + const table = root.getLastChild(); + expectTableHtmlToBeEqual( + table!.createDOM(editorConfig).outerHTML, + html` + + + + + + +
+ `, ); - expect(selection.getTextContent()).toBe(`1\t\t2\n3\t4\t\n`); - } - } - }); - }); - - test('Toggle row striping ON/OFF', async () => { - const {editor} = testEnv; - - await editor.update(() => { - const root = $getRoot(); - const table = $createTableNodeWithDimensions(4, 4, true); - root.append(table); - }); - await editor.update(() => { - const root = $getRoot(); - const table = root.getLastChild(); - if (table) { - table.setRowStriping(true); - } - }); - - await editor.update(() => { - const root = $getRoot(); - const table = root.getLastChild(); - expect(table!.createDOM(editorConfig).outerHTML).toBe( - `
`, - ); - }); - - await editor.update(() => { - const root = $getRoot(); - const table = root.getLastChild(); - if (table) { - table.setRowStriping(false); - } - }); - - await editor.update(() => { - const root = $getRoot(); - const table = root.getLastChild(); - expect(table!.createDOM(editorConfig).outerHTML).toBe( - `
`, - ); - }); - }); - - test('Update column widths', async () => { - const {editor} = testEnv; - - await editor.update(() => { - const root = $getRoot(); - const table = $createTableNodeWithDimensions(4, 2, true); - root.append(table); - }); - - // Set widths - await editor.update(() => { - const root = $getRoot(); - const table = root.getLastChild(); - table!.setColWidths([50, 50]); - }); - - await editor.update(() => { - const root = $getRoot(); - const table = root.getLastChild(); - expect(table!.createDOM(editorConfig).outerHTML).toBe( - `
`, - ); - const colWidths = table!.getColWidths(); - - // colwidths should be immutable in DEV - expect(() => { - (colWidths as number[]).push(100); - }).toThrow(); - expect(table!.getColWidths()).toStrictEqual([50, 50]); - expect(table!.getColumnCount()).toBe(2); - }); - - // Add a column - await editor.update(() => { - const root = $getRoot(); - const table = root.getLastChild(); - const DOMTable = $getElementForTableNode(editor, table!); - const selection = $createTableSelection(); - selection.set( - table!.__key, - table!.getCellNodeFromCords(0, 0, DOMTable)?.__key || '', - table!.getCellNodeFromCords(0, 0, DOMTable)?.__key || '', - ); - $setSelection(selection); - $insertTableColumn__EXPERIMENTAL(); - table!.setColWidths([50, 50, 100]); - }); - - await editor.update(() => { - const root = $getRoot(); - const table = root.getLastChild(); - expect(table!.createDOM(editorConfig).outerHTML).toBe( - `
`, - ); - expect(table!.getColWidths()).toStrictEqual([50, 50, 100]); - expect(table!.getColumnCount()).toBe(3); - }); - }); - }, - undefined, - , - ); + expect(table!.getColWidths()).toStrictEqual([50, 50, 100]); + expect(table!.getColumnCount()).toBe(3); + }); + }); + }, + undefined, + , + ); + }); + }); }); diff --git a/packages/lexical/src/__tests__/utils/index.tsx b/packages/lexical/src/__tests__/utils/index.tsx index 5292fdd5a5f..dff3b2adaee 100644 --- a/packages/lexical/src/__tests__/utils/index.tsx +++ b/packages/lexical/src/__tests__/utils/index.tsx @@ -796,8 +796,25 @@ export function html( return output; } -export function expectHtmlToBeEqual(expected: string, actual: string): void { - expect(prettifyHtml(expected)).toBe(prettifyHtml(actual)); +export function polyfillContentEditable() { + const div = document.createElement('div'); + div.contentEditable = 'true'; + if (/contenteditable/.test(div.outerHTML)) { + return; + } + Object.defineProperty(HTMLElement.prototype, 'contentEditable', { + get() { + return this.getAttribute('contenteditable'); + }, + + set(value) { + this.setAttribute('contenteditable', value); + }, + }); +} + +export function expectHtmlToBeEqual(actual: string, expected: string): void { + expect(prettifyHtml(actual)).toBe(prettifyHtml(expected)); } export function prettifyHtml(s: string): string { From 6e9f425681219a2869e6d17f1311b5e4cad9ef5c Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 6 Nov 2024 15:00:37 -0800 Subject: [PATCH 10/28] use backwards compatible default --- packages/lexical-react/src/LexicalTablePlugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lexical-react/src/LexicalTablePlugin.ts b/packages/lexical-react/src/LexicalTablePlugin.ts index 06f6ccea06a..29912aa032c 100644 --- a/packages/lexical-react/src/LexicalTablePlugin.ts +++ b/packages/lexical-react/src/LexicalTablePlugin.ts @@ -49,7 +49,7 @@ export function TablePlugin({ hasCellMerge = true, hasCellBackgroundColor = true, hasTabHandler = true, - hasHorizontalScroll = true, + hasHorizontalScroll = false, }: { hasCellMerge?: boolean; hasCellBackgroundColor?: boolean; From 3c1e805adf5c5e29f1e277acc37cf5925b5d46e5 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Thu, 7 Nov 2024 12:05:36 -0800 Subject: [PATCH 11/28] selection quirks --- .../html/TablesHTMLCopyAndPaste.spec.mjs | 4 +- .../__tests__/e2e/Indentation.spec.mjs | 4 +- .../__tests__/e2e/Selection.spec.mjs | 4 +- .../__tests__/e2e/Tables.spec.mjs | 82 ++++++++----- .../__tests__/e2e/Toolbar.spec.mjs | 7 +- .../4661-insert-column-selection.spec.mjs | 4 +- .../__tests__/utils/index.mjs | 16 ++- .../plugins/TableActionMenuPlugin/index.tsx | 14 ++- .../src/plugins/TableCellResizer/index.tsx | 6 +- .../plugins/TableHoverActionsPlugin/index.tsx | 31 ++++- .../lexical-table/src/LexicalTableNode.ts | 11 +- .../lexical-table/src/LexicalTableObserver.ts | 116 +++++++++++------- .../__tests__/unit/LexicalTableNode.test.tsx | 2 +- packages/lexical/src/LexicalReconciler.ts | 8 ++ packages/lexical/src/LexicalSelection.ts | 77 +++++++++++- .../lexical/src/nodes/LexicalElementNode.ts | 74 +++++++++++ 16 files changed, 360 insertions(+), 100 deletions(-) diff --git a/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/TablesHTMLCopyAndPaste.spec.mjs b/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/TablesHTMLCopyAndPaste.spec.mjs index 520b08552c5..8ac28284f78 100644 --- a/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/TablesHTMLCopyAndPaste.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/TablesHTMLCopyAndPaste.spec.mjs @@ -17,7 +17,9 @@ import { } from '../../../utils/index.mjs'; test.describe('HTML Tables CopyAndPaste', () => { - test.beforeEach(({isCollab, page}) => initialize({isCollab, page})); + test.beforeEach(({isCollab, page}) => + initialize({isCollab, page, tableHorizontalScroll: false}), + ); test('Copy + paste (Table - Google Docs)', async ({ page, diff --git a/packages/lexical-playground/__tests__/e2e/Indentation.spec.mjs b/packages/lexical-playground/__tests__/e2e/Indentation.spec.mjs index 3a856bd19f3..58a0b41af91 100644 --- a/packages/lexical-playground/__tests__/e2e/Indentation.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Indentation.spec.mjs @@ -18,7 +18,9 @@ import { } from '../utils/index.mjs'; test.describe('Identation', () => { - test.beforeEach(({isCollab, page}) => initialize({isCollab, page})); + test.beforeEach(({isCollab, page}) => + initialize({isCollab, page, tableHorizontalScroll: false}), + ); test(`Can create content and indent and outdent it all`, async ({ page, diff --git a/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs b/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs index 026cd94d92d..71bba6fa5da 100644 --- a/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs @@ -53,7 +53,9 @@ import { } from '../utils/index.mjs'; test.describe.parallel('Selection', () => { - test.beforeEach(({isCollab, page}) => initialize({isCollab, page})); + test.beforeEach(({isCollab, page}) => + initialize({isCollab, page, tableHorizontalScroll: false}), + ); test('does not focus the editor on load', async ({page}) => { const editorHasFocus = async () => await evaluate(page, () => { diff --git a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs index adbfbc73be2..9be72a229d6 100644 --- a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs @@ -18,7 +18,7 @@ import { selectCharacters, } from '../keyboardShortcuts/index.mjs'; import { - assertHTML, + assertHTML as rawAssertHTML, assertSelection, click, clickSelectors, @@ -37,6 +37,7 @@ import { insertTableRowBelow, IS_COLLAB, IS_LINUX, + IS_TABLE_HORIZONTAL_SCROLL, IS_WINDOWS, LEGACY_EVENTS, mergeTableCells, @@ -52,6 +53,7 @@ import { unmergeTableCell, waitForSelector, withExclusiveClipboardAccess, + wrapTableHtml, } from '../utils/index.mjs'; async function fillTablePartiallyWithText(page) { @@ -75,6 +77,16 @@ async function fillTablePartiallyWithText(page) { await page.keyboard.press('c'); } +async function assertHTML(page, expected, ...args) { + return await rawAssertHTML( + page, + IS_TABLE_HORIZONTAL_SCROLL ? wrapTableHtml(expected) : expected, + ...args, + ); +} + +const WRAPPER = IS_TABLE_HORIZONTAL_SCROLL ? [0] : []; + test.describe.parallel('Tables', () => { test(`Can a table be inserted from the toolbar`, async ({ page, @@ -181,12 +193,13 @@ test.describe.parallel('Tables', () => { await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 1, 0, 0], + anchorPath: [1, ...WRAPPER, 1, 0, 0], focusOffset: 0, - focusPath: [1, 1, 0, 0], + focusPath: [1, ...WRAPPER, 1, 0, 0], }); await moveLeft(page, 1); + await assertSelection(page, { anchorOffset: 0, anchorPath: [0], @@ -196,19 +209,20 @@ test.describe.parallel('Tables', () => { await moveRight(page, 1); await page.keyboard.type('ab'); + await assertSelection(page, { anchorOffset: 2, - anchorPath: [1, 1, 0, 0, 0, 0], + anchorPath: [1, ...WRAPPER, 1, 0, 0, 0, 0], focusOffset: 2, - focusPath: [1, 1, 0, 0, 0, 0], + focusPath: [1, ...WRAPPER, 1, 0, 0, 0, 0], }); await moveRight(page, 3); await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 2, 1, 0], + anchorPath: [1, ...WRAPPER, 2, 1, 0], focusOffset: 0, - focusPath: [1, 2, 1, 0], + focusPath: [1, ...WRAPPER, 2, 1, 0], }); }); @@ -226,9 +240,9 @@ test.describe.parallel('Tables', () => { await moveRight(page, 3); await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 2, 1, 0], + anchorPath: [1, ...WRAPPER, 2, 1, 0], focusOffset: 0, - focusPath: [1, 2, 1, 0], + focusPath: [1, ...WRAPPER, 2, 1, 0], }); await moveRight(page, 1); @@ -243,9 +257,9 @@ test.describe.parallel('Tables', () => { await page.keyboard.type('ab'); await assertSelection(page, { anchorOffset: 2, - anchorPath: [1, 2, 1, 0, 0, 0], + anchorPath: [1, ...WRAPPER, 2, 1, 0, 0, 0], focusOffset: 2, - focusPath: [1, 2, 1, 0, 0, 0], + focusPath: [1, ...WRAPPER, 2, 1, 0, 0, 0], }); await moveRight(page, 3); @@ -271,17 +285,17 @@ test.describe.parallel('Tables', () => { await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 1, 0, 1, 1, 0, 0], + anchorPath: [1, ...WRAPPER, 1, 0, 1, ...WRAPPER, 1, 0, 0], focusOffset: 0, - focusPath: [1, 1, 0, 1, 1, 0, 0], + focusPath: [1, ...WRAPPER, 1, 0, 1, ...WRAPPER, 1, 0, 0], }); await moveLeft(page, 1); await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 1, 0, 0], + anchorPath: [1, ...WRAPPER, 1, 0, 0], focusOffset: 0, - focusPath: [1, 1, 0, 0], + focusPath: [1, ...WRAPPER, 1, 0, 0], }); }); @@ -300,17 +314,17 @@ test.describe.parallel('Tables', () => { await moveRight(page, 3); await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 1, 0, 1, 2, 1, 0], + anchorPath: [1, ...WRAPPER, 1, 0, 1, ...WRAPPER, 2, 1, 0], focusOffset: 0, - focusPath: [1, 1, 0, 1, 2, 1, 0], + focusPath: [1, ...WRAPPER, 1, 0, 1, ...WRAPPER, 2, 1, 0], }); await moveRight(page, 1); await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 1, 0, 2], + anchorPath: [1, ...WRAPPER, 1, 0, 2], focusOffset: 0, - focusPath: [1, 1, 0, 2], + focusPath: [1, ...WRAPPER, 1, 0, 2], }); }); }); @@ -345,9 +359,9 @@ test.describe.parallel('Tables', () => { await deleteBackward(page); await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 2, 1, 0], + anchorPath: [1, ...WRAPPER, 2, 1, 0], focusOffset: 0, - focusPath: [1, 2, 1, 0], + focusPath: [1, ...WRAPPER, 2, 1, 0], }); await assertHTML( page, @@ -381,14 +395,24 @@ test.describe.parallel('Tables', () => { ); await moveRight(page, 1); - // The native window selection should be on the root, whereas - // the editor selection should be on the last cell of the table. - await assertSelection(page, { - anchorOffset: 2, - anchorPath: [], - focusOffset: 2, - focusPath: [], - }); + if (WRAPPER.length === 0) { + // The native window selection should be on the root, whereas + // the editor selection should be on the last cell of the table. + await assertSelection(page, { + anchorOffset: 2, + anchorPath: [], + focusOffset: 2, + focusPath: [], + }); + } else { + // The native window selection is in the wrapper after the table + await assertSelection(page, { + anchorOffset: WRAPPER[0] + 1, + anchorPath: [1], + focusOffset: WRAPPER[0] + 1, + focusPath: [1], + }); + } await page.keyboard.press('Enter'); await assertSelection(page, { diff --git a/packages/lexical-playground/__tests__/e2e/Toolbar.spec.mjs b/packages/lexical-playground/__tests__/e2e/Toolbar.spec.mjs index 12cec473d34..232dfa8dd68 100644 --- a/packages/lexical-playground/__tests__/e2e/Toolbar.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Toolbar.spec.mjs @@ -31,7 +31,12 @@ import { test.describe('Toolbar', () => { test.beforeEach(({isCollab, page}) => - initialize({isCollab, page, showNestedEditorTreeView: false}), + initialize({ + isCollab, + page, + showNestedEditorTreeView: false, + tableHorizontalScroll: false, + }), ); test( diff --git a/packages/lexical-playground/__tests__/regression/4661-insert-column-selection.spec.mjs b/packages/lexical-playground/__tests__/regression/4661-insert-column-selection.spec.mjs index abcd0492817..d2f29d9aade 100644 --- a/packages/lexical-playground/__tests__/regression/4661-insert-column-selection.spec.mjs +++ b/packages/lexical-playground/__tests__/regression/4661-insert-column-selection.spec.mjs @@ -20,7 +20,9 @@ import { } from '../utils/index.mjs'; test.describe('Regression test #4661', () => { - test.beforeEach(({isCollab, page}) => initialize({isCollab, page})); + test.beforeEach(({isCollab, page}) => + initialize({isCollab, page, tableHorizontalScroll: false}), + ); test('inserting 2 columns before inserts before selection', async ({ page, isPlainText, diff --git a/packages/lexical-playground/__tests__/utils/index.mjs b/packages/lexical-playground/__tests__/utils/index.mjs index 2f248182320..c3821bba480 100644 --- a/packages/lexical-playground/__tests__/utils/index.mjs +++ b/packages/lexical-playground/__tests__/utils/index.mjs @@ -34,6 +34,8 @@ export const IS_COLLAB = const IS_RICH_TEXT = process.env.E2E_EDITOR_MODE !== 'plain-text'; const IS_PLAIN_TEXT = process.env.E2E_EDITOR_MODE === 'plain-text'; export const LEGACY_EVENTS = process.env.E2E_EVENTS_MODE === 'legacy-events'; +export const IS_TABLE_HORIZONTAL_SCROLL = + process.env.E2E_TABLE_MODE !== 'legacy'; export const SAMPLE_IMAGE_URL = E2E_PORT === 3000 ? '/src/images/yellow-flower.jpg' @@ -52,6 +54,17 @@ function wrapAndSlowDown(method, delay) { }; } +export function wrapTableHtml(expected) { + return html` + ${expected + .replace( + //g, '
')} + `; +} + export async function initialize({ page, isCollab, @@ -77,7 +90,8 @@ export async function initialize({ appSettings.isRichText = IS_RICH_TEXT; appSettings.emptyEditor = true; appSettings.disableBeforeInput = LEGACY_EVENTS; - appSettings.tableHorizontalScroll = !!tableHorizontalScroll; + appSettings.tableHorizontalScroll = + tableHorizontalScroll ?? IS_TABLE_HORIZONTAL_SCROLL; if (isCollab) { appSettings.isCollab = isCollab; appSettings.collabId = randomUUID(); diff --git a/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx b/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx index 49830462235..43f1f8aa5d2 100644 --- a/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx @@ -43,6 +43,7 @@ import { import * as React from 'react'; import {ReactPortal, useCallback, useEffect, useRef, useState} from 'react'; import {createPortal} from 'react-dom'; +import invariant from 'shared/invariant'; import useModal from '../../hooks/useModal'; import ColorPicker from '../../ui/ColorPicker'; @@ -229,12 +230,15 @@ function TableActionMenu({ editor.update(() => { if (tableCellNode.isAttached()) { const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode); - const tableNodeElement = editor.getElementByKey(tableNode.getKey()); + const tableElement = getTableElement( + tableNode, + editor.getElementByKey(tableNode.getKey()), + ); - if (!tableNodeElement) { - throw new Error('Expected to find tableElement in DOM'); - } - const tableElement = getTableElement(tableNode, tableNodeElement); + invariant( + tableElement !== null, + 'TableActionMenu: Expected to find tableElement in DOM', + ); const tableObserver = getTableObserverFromTableElement(tableElement); if (tableObserver !== null) { diff --git a/packages/lexical-playground/src/plugins/TableCellResizer/index.tsx b/packages/lexical-playground/src/plugins/TableCellResizer/index.tsx index f0446ee6b1f..7f586262645 100644 --- a/packages/lexical-playground/src/plugins/TableCellResizer/index.tsx +++ b/packages/lexical-playground/src/plugins/TableCellResizer/index.tsx @@ -19,6 +19,7 @@ import { $isTableCellNode, $isTableRowNode, getDOMCellFromTarget, + getTableElement, TableNode, } from '@lexical/table'; import {calculateZoomLevel} from '@lexical/utils'; @@ -115,7 +116,10 @@ function TableCellResizer({editor}: {editor: LexicalEditor}): JSX.Element { const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode); - const tableElement = editor.getElementByKey(tableNode.getKey()); + const tableElement = getTableElement( + tableNode, + editor.getElementByKey(tableNode.getKey()), + ); if (!tableElement) { throw new Error('TableCellResizer: Table element not found.'); diff --git a/packages/lexical-playground/src/plugins/TableHoverActionsPlugin/index.tsx b/packages/lexical-playground/src/plugins/TableHoverActionsPlugin/index.tsx index 44fc3368a02..0ae093bd474 100644 --- a/packages/lexical-playground/src/plugins/TableHoverActionsPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/TableHoverActionsPlugin/index.tsx @@ -15,15 +15,17 @@ import { $insertTableRow__EXPERIMENTAL, $isTableCellNode, $isTableNode, + getTableElement, TableCellNode, TableNode, TableRowNode, } from '@lexical/table'; import {$findMatchingParent, mergeRegister} from '@lexical/utils'; -import {$getNearestNodeFromDOMNode, NodeKey} from 'lexical'; +import {$getNearestNodeFromDOMNode, $getNodeByKey, NodeKey} from 'lexical'; import {useEffect, useMemo, useRef, useState} from 'react'; import * as React from 'react'; import {createPortal} from 'react-dom'; +import invariant from 'shared/invariant'; import {useDebounce} from '../CodeActionMenuPlugin/utils'; @@ -75,7 +77,10 @@ function TableHoverActionsContainer({ return; } - tableDOMElement = editor.getElementByKey(table?.getKey()); + tableDOMElement = getTableElement( + table, + editor.getElementByKey(table.getKey()), + ); if (tableDOMElement) { const rowCount = table.getChildrenSize(); @@ -165,7 +170,15 @@ function TableHoverActionsContainer({ (mutations) => { editor.getEditorState().read(() => { for (const [key, type] of mutations) { - const tableDOMElement = editor.getElementByKey(key); + const tableNode = $getNodeByKey(key); + invariant( + $isTableNode(tableNode), + 'TableHoverActionsPlugin: Expecting TableNode in mutation listener', + ); + const tableDOMElement = getTableElement( + tableNode, + editor.getElementByKey(key), + ); switch (type) { case 'created': tableSetRef.current.add(key); @@ -181,9 +194,15 @@ function TableHoverActionsContainer({ // Reset resize observers tableResizeObserver.disconnect(); tableSetRef.current.forEach((tableKey: NodeKey) => { - const tableElement = editor.getElementByKey(tableKey); - if (tableElement) { - tableResizeObserver.observe(tableElement); + const otherTableNode = $getNodeByKey(tableKey); + if ($isTableNode(otherTableNode)) { + const tableElement = getTableElement( + otherTableNode, + editor.getElementByKey(tableKey), + ); + if (tableElement) { + tableResizeObserver.observe(tableElement); + } } }); break; diff --git a/packages/lexical-table/src/LexicalTableNode.ts b/packages/lexical-table/src/LexicalTableNode.ts index 2a86b4b150c..7e2823ee580 100644 --- a/packages/lexical-table/src/LexicalTableNode.ts +++ b/packages/lexical-table/src/LexicalTableNode.ts @@ -193,8 +193,6 @@ export class TableNode extends ElementNode { } if ($isScrollableTablesActive(editor)) { const wrapperElement = document.createElement('div'); - tableElement.contentEditable = 'true'; - wrapperElement.contentEditable = 'false'; wrapperElement.dataset.lexicalScrollable = 'true'; wrapperElement.style.overflowX = 'auto'; wrapperElement.appendChild(tableElement); @@ -390,11 +388,10 @@ export function $getElementForTableNode( tableNode: TableNode, ): TableDOMTable { const tableElement = editor.getElementByKey(tableNode.getKey()); - - if (tableElement == null) { - throw new Error('Table Element Not Found'); - } - + invariant( + tableElement !== null, + '$getElementForTableNode: Table Element Not Found', + ); return getTable(tableNode, tableElement); } diff --git a/packages/lexical-table/src/LexicalTableObserver.ts b/packages/lexical-table/src/LexicalTableObserver.ts index 3e761f837ae..005f3e06810 100644 --- a/packages/lexical-table/src/LexicalTableObserver.ts +++ b/packages/lexical-table/src/LexicalTableObserver.ts @@ -39,6 +39,7 @@ import { $updateDOMForSelection, getDOMSelection, getTable, + getTableElement, } from './LexicalTableSelectionHelpers'; export type TableDOMCell = { @@ -186,15 +187,20 @@ export class TableObserver { editor.update(() => { const tableNode = $getNodeByKey(this.tableNodeKey); - if (!$isTableNode(tableNode)) { - throw new Error('Expected TableNode.'); - } + invariant( + $isTableNode(tableNode), + 'LexicalTableObserver: Expected TableNode', + ); - const tableElement = editor.getElementByKey(this.tableNodeKey); + const tableElement = getTableElement( + tableNode, + editor.getElementByKey(this.tableNodeKey), + ); - if (!tableElement) { - throw new Error('Expected to find TableElement in DOM'); - } + invariant( + tableElement !== null, + 'LexicalTableObserver: Expected to find TableElement in DOM', + ); const grid = getTable(tableNode, tableElement); $updateDOMForSelection(editor, grid, null); @@ -205,34 +211,60 @@ export class TableObserver { enableHighlightStyle() { const editor = this.editor; - editor.update(() => { - const tableElement = editor.getElementByKey(this.tableNodeKey); - - if (!tableElement) { - throw new Error('Expected to find TableElement in DOM'); - } - - removeClassNamesFromElement( - tableElement, - editor._config.theme.tableSelection, - ); - tableElement.classList.remove('disable-selection'); - this.hasHijackedSelectionStyles = false; - }); + editor.getEditorState().read( + () => { + const tableNode = $getNodeByKey(this.tableNodeKey); + invariant( + $isTableNode(tableNode), + 'LexicalTableObserver: Expected TableNode', + ); + const tableElement = getTableElement( + tableNode, + editor.getElementByKey(this.tableNodeKey), + ); + + invariant( + tableElement !== null, + 'LexicalTableObserver: Expected to find TableElement in DOM', + ); + + removeClassNamesFromElement( + tableElement, + editor._config.theme.tableSelection, + ); + tableElement.classList.remove('disable-selection'); + this.hasHijackedSelectionStyles = false; + }, + {editor}, + ); } disableHighlightStyle() { const editor = this.editor; - editor.update(() => { - const tableElement = editor.getElementByKey(this.tableNodeKey); - - if (!tableElement) { - throw new Error('Expected to find TableElement in DOM'); - } - - addClassNamesToElement(tableElement, editor._config.theme.tableSelection); - this.hasHijackedSelectionStyles = true; - }); + editor.getEditorState().read( + () => { + const tableNode = $getNodeByKey(this.tableNodeKey); + invariant( + $isTableNode(tableNode), + 'LexicalTableObserver: Expected TableNode', + ); + const tableElement = getTableElement( + tableNode, + editor.getElementByKey(this.tableNodeKey), + ); + invariant( + tableElement !== null, + 'LexicalTableObserver: Expected to find TableElement in DOM', + ); + + addClassNamesToElement( + tableElement, + editor._config.theme.tableSelection, + ); + this.hasHijackedSelectionStyles = true; + }, + {editor}, + ); } updateTableTableSelection(selection: TableSelection | null): void { @@ -254,16 +286,18 @@ export class TableObserver { const editor = this.editor; editor.update(() => { const tableNode = $getNodeByKey(this.tableNodeKey); - - if (!$isTableNode(tableNode)) { - throw new Error('Expected TableNode.'); - } - - const tableElement = editor.getElementByKey(this.tableNodeKey); - - if (!tableElement) { - throw new Error('Expected to find TableElement in DOM'); - } + invariant( + $isTableNode(tableNode), + 'LexicalTableObserver: Expected TableNode', + ); + const tableElement = getTableElement( + tableNode, + editor.getElementByKey(this.tableNodeKey), + ); + invariant( + tableElement !== null, + 'LexicalTableObserver: Expected to find TableElement in DOM', + ); const cellX = cell.x; const cellY = cell.y; diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx b/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx index 16940c53a2e..0129446a7c7 100644 --- a/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx +++ b/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx @@ -78,7 +78,7 @@ function wrapTableHtml(expected: string): string { return expected .replace( /
/g, '
'); } diff --git a/packages/lexical/src/LexicalReconciler.ts b/packages/lexical/src/LexicalReconciler.ts index d3319fd6d16..801ece8de8e 100644 --- a/packages/lexical/src/LexicalReconciler.ts +++ b/packages/lexical/src/LexicalReconciler.ts @@ -522,6 +522,14 @@ function $reconcileChildren( } else { const prevChildren = createChildrenArray(prevElement, activePrevNodeMap); const nextChildren = createChildrenArray(nextElement, activeNextNodeMap); + invariant( + prevChildren.length === prevChildrenSize, + '$reconcileChildren: prevChildren.length !== prevChildrenSize', + ); + invariant( + nextChildren.length === nextChildrenSize, + '$reconcileChildren: nextChildren.length !== nextChildrenSize', + ); if (prevChildrenSize === 0) { if (nextChildrenSize !== 0) { diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index 3af4b30d0db..9852f426de9 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -2109,9 +2109,22 @@ function $internalResolveSelectionPoint( return null; } if ($isElementNode(resolvedElement)) { - resolvedOffset = Math.min( - resolvedElement.getChildrenSize(), - resolvedOffset, + const elementDOM = editor.getElementByKey(resolvedElement.getKey()); + invariant( + elementDOM !== null, + '$internalResolveSelectionPoint: node in DOM but not keyToDOMMap', + ); + const slot = resolvedElement.getDOMSlot(elementDOM); + [resolvedElement, resolvedOffset] = slot.resolveChildIndex( + resolvedElement, + elementDOM, + dom, + offset, + ); + // This is just a typescript workaround, it is true but lost due to mutability + invariant( + $isElementNode(resolvedElement), + '$internalResolveSelectionPoint: resolvedElement is not an ElementNode', ); let child = resolvedElement.getChildAtIndex(resolvedOffset); if ( @@ -2140,7 +2153,12 @@ function $internalResolveSelectionPoint( moveSelectionToEnd && !hasBlockCursor ) { - resolvedOffset++; + invariant($isElementNode(resolvedElement), 'invariant'); + // TODO: WTF + // resolvedOffset = Math.min( + // resolvedElement.getChildrenSize(), + // resolvedOffset + 1, + // ); } } else { const index = resolvedElement.getIndexWithinParent(); @@ -2297,6 +2315,9 @@ function $internalResolveSelectionPoints( if (resolvedAnchorPoint === null) { return null; } + if (__DEV__) { + validatePoint(editor, 'anchor', resolvedAnchorPoint); + } const resolvedFocusPoint = $internalResolveSelectionPoint( focusDOM, focusOffset, @@ -2306,6 +2327,9 @@ function $internalResolveSelectionPoints( if (resolvedFocusPoint === null) { return null; } + if (__DEV__) { + validatePoint(editor, 'focus', resolvedAnchorPoint); + } if ( resolvedAnchorPoint.type === 'element' && resolvedFocusPoint.type === 'element' @@ -2475,6 +2499,51 @@ export function $internalCreateRangeSelection( ); } +function validatePoint( + editor: LexicalEditor, + name: 'anchor' | 'focus', + point: PointType, +): void { + const node = editor.getEditorState()._nodeMap.get(point.key); + invariant( + node !== undefined, + 'validatePoint: %s key %s not found in editorState', + name, + point.key, + ); + if (point.type === 'text') { + invariant( + $isTextNode(node), + 'validatePoint: %s key %s is not a TextNode', + name, + point.key, + ); + const size = node.getTextContentSize(); + invariant( + point.offset <= size, + 'validatePoint: %s point.offset > node.getTextContentSize() (%s > %s)', + name, + String(point.offset), + String(size), + ); + } else { + invariant( + $isElementNode(node), + 'validatePoint: %s key %s is not an ElementNode', + name, + point.key, + ); + const size = node.getChildrenSize(); + invariant( + point.offset <= size, + 'validatePoint: %s point.offset > node.getChildrenSize() (%s > %s)', + name, + String(point.offset), + String(size), + ); + } +} + export function $getSelection(): null | BaseSelection { const editorState = getActiveEditorState(); return editorState._selection; diff --git a/packages/lexical/src/nodes/LexicalElementNode.ts b/packages/lexical/src/nodes/LexicalElementNode.ts index 8ac0b75f93c..27998e82e5e 100644 --- a/packages/lexical/src/nodes/LexicalElementNode.ts +++ b/packages/lexical/src/nodes/LexicalElementNode.ts @@ -158,6 +158,73 @@ export class ElementDOMSlot { ? null : firstChild; } + /** + * @internal + * + * Returns the offset of the first child + */ + getFirstChildOffset(): number { + let i = 0; + for (let node = this.after; node !== null; node = node.previousSibling) { + i++; + } + return i; + } + + /** + * @internal + */ + resolveChildIndex( + element: ElementNode, + elementDOM: HTMLElement, + initialDOM: Node, + initialOffset: number, + ): [node: ElementNode, idx: number] { + if (initialDOM === this.element) { + const firstChildOffset = this.getFirstChildOffset(); + return [ + element, + Math.min( + firstChildOffset + element.getChildrenSize(), + Math.max(firstChildOffset, initialOffset), + ), + ]; + } + // The resolved offset must be before or after the children + const initialPath = indexPath(elementDOM, initialDOM); + initialPath.push(initialOffset); + const elementPath = indexPath(elementDOM, this.element); + let offset = element.getIndexWithinParent(); + for (let i = 0; i < elementPath.length; i++) { + const target = initialPath[i]; + const source = elementPath[i]; + if (target === undefined || target < source) { + break; + } else if (target > source) { + offset += 1; + break; + } + } + return [element.getParentOrThrow(), offset]; + } +} + +function indexPath(root: HTMLElement, child: Node): number[] { + const path: number[] = []; + let node: Node | null = child; + for (; node !== root && node !== null; node = child.parentNode) { + let i = 0; + for ( + let sibling = node.previousSibling; + sibling !== null; + sibling = node.previousSibling + ) { + i++; + } + path.push(i); + } + invariant(node === root, 'indexPath: root is not a parent of child'); + return path.reverse(); } /** @noInheritDoc */ @@ -498,6 +565,13 @@ export class ElementNode extends LexicalNode { const nodesToInsertLength = nodesToInsert.length; const oldSize = this.getChildrenSize(); const writableSelf = this.getWritable(); + invariant( + start + deleteCount <= oldSize, + 'ElementNode.splice: start + deleteCount > oldSize (%s + %s > %s)', + String(start), + String(deleteCount), + String(oldSize), + ); const writableSelfKey = writableSelf.__key; const nodesToInsertKeys = []; const nodesToRemoveKeys = []; From f79f445772607bb66ea9002baadfb5f264e1143e Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Thu, 7 Nov 2024 13:25:26 -0800 Subject: [PATCH 12/28] remove debug --- packages/lexical/src/LexicalSelection.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index 9852f426de9..ba81b533c4b 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -2154,11 +2154,10 @@ function $internalResolveSelectionPoint( !hasBlockCursor ) { invariant($isElementNode(resolvedElement), 'invariant'); - // TODO: WTF - // resolvedOffset = Math.min( - // resolvedElement.getChildrenSize(), - // resolvedOffset + 1, - // ); + resolvedOffset = Math.min( + resolvedElement.getChildrenSize(), + resolvedOffset + 1, + ); } } else { const index = resolvedElement.getIndexWithinParent(); From 62169b8a5bf0d527a891739fc56be6b21cfda2fb Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Thu, 7 Nov 2024 17:12:10 -0800 Subject: [PATCH 13/28] more test fixes --- .../__tests__/e2e/Tables.spec.mjs | 55 ++++++++++--------- 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs index 9be72a229d6..bf87fd8c3f6 100644 --- a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs @@ -538,9 +538,9 @@ test.describe.parallel('Tables', () => { await moveLeft(page, 1); await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 2, 1, 0], + anchorPath: [1, ...WRAPPER, 2, 1, 0], focusOffset: 0, - focusPath: [1, 2, 1, 0], + focusPath: [1, ...WRAPPER, 2, 1, 0], }); }); @@ -590,57 +590,57 @@ test.describe.parallel('Tables', () => { await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 1, 0, 0], + anchorPath: [1, ...WRAPPER, 1, 0, 0], focusOffset: 0, - focusPath: [1, 1, 0, 0], + focusPath: [1, ...WRAPPER, 1, 0, 0], }); await moveRight(page, 1); await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 1, 1, 0], + anchorPath: [1, ...WRAPPER, 1, 1, 0], focusOffset: 0, - focusPath: [1, 1, 1, 0], + focusPath: [1, ...WRAPPER, 1, 1, 0], }); await moveRight(page, 1); await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 2, 0, 0], + anchorPath: [1, ...WRAPPER, 2, 0, 0], focusOffset: 0, - focusPath: [1, 2, 0, 0], + focusPath: [1, ...WRAPPER, 2, 0, 0], }); await moveRight(page, 1); await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 2, 1, 0], + anchorPath: [1, ...WRAPPER, 2, 1, 0], focusOffset: 0, - focusPath: [1, 2, 1, 0], + focusPath: [1, ...WRAPPER, 2, 1, 0], }); await moveLeft(page, 1); await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 2, 0, 0], + anchorPath: [1, ...WRAPPER, 2, 0, 0], focusOffset: 0, - focusPath: [1, 2, 0, 0], + focusPath: [1, ...WRAPPER, 2, 0, 0], }); await moveLeft(page, 1); await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 1, 1, 0], + anchorPath: [1, ...WRAPPER, 1, 1, 0], focusOffset: 0, - focusPath: [1, 1, 1, 0], + focusPath: [1, ...WRAPPER, 1, 1, 0], }); await moveLeft(page, 1); await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 1, 0, 0], + anchorPath: [1, ...WRAPPER, 1, 0, 0], focusOffset: 0, - focusPath: [1, 1, 0, 0], + focusPath: [1, ...WRAPPER, 1, 0, 0], }); }); @@ -657,25 +657,25 @@ test.describe.parallel('Tables', () => { await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 1, 0, 0], + anchorPath: [1, ...WRAPPER, 1, 0, 0], focusOffset: 0, - focusPath: [1, 1, 0, 0], + focusPath: [1, ...WRAPPER, 1, 0, 0], }); await moveDown(page, 1); await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 2, 0, 0], + anchorPath: [1, ...WRAPPER, 2, 0, 0], focusOffset: 0, - focusPath: [1, 2, 0, 0], + focusPath: [1, ...WRAPPER, 2, 0, 0], }); await moveUp(page, 1); await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 1, 0, 0], + anchorPath: [1, ...WRAPPER, 1, 0, 0], focusOffset: 0, - focusPath: [1, 1, 0, 0], + focusPath: [1, ...WRAPPER, 1, 0, 0], }); }); @@ -693,9 +693,9 @@ test.describe.parallel('Tables', () => { await page.keyboard.type('@A'); await assertSelection(page, { anchorOffset: 2, - anchorPath: [1, 1, 0, 0, 0, 0], + anchorPath: [1, ...WRAPPER, 1, 0, 0, 0, 0], focusOffset: 2, - focusPath: [1, 1, 0, 0, 0, 0], + focusPath: [1, ...WRAPPER, 1, 0, 0, 0, 0], }); await waitForSelector(page, `#typeahead-menu ul li:first-child.selected`); @@ -703,9 +703,9 @@ test.describe.parallel('Tables', () => { await moveDown(page, 1); await assertSelection(page, { anchorOffset: 2, - anchorPath: [1, 1, 0, 0, 0, 0], + anchorPath: [1, ...WRAPPER, 1, 0, 0, 0, 0], focusOffset: 2, - focusPath: [1, 1, 0, 0, 0, 0], + focusPath: [1, ...WRAPPER, 1, 0, 0, 0, 0], }); await waitForSelector( @@ -3036,6 +3036,7 @@ test.describe.parallel('Tables', () => { await insertTable(page, 2, 2); await click(page, '.PlaygroundEditorTheme__tableCell'); + await page.pause(); await selectCellsFromTableCords( page, {x: 0, y: 0}, @@ -3043,7 +3044,9 @@ test.describe.parallel('Tables', () => { true, true, ); + await page.pause(); await insertTableColumnBefore(page); + await page.pause(); await assertHTML( page, From 7052b50136fce33f942676bf3ea2a05b5d4ee01c Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Fri, 8 Nov 2024 09:39:53 -0800 Subject: [PATCH 14/28] more table fixes --- .../plugins/TableHoverActionsPlugin/index.tsx | 75 +++----- .../lexical-react/src/LexicalTablePlugin.ts | 53 +++-- .../lexical-table/src/LexicalTableObserver.ts | 182 ++++++++---------- .../src/LexicalTableSelectionHelpers.ts | 19 +- packages/lexical-table/src/index.ts | 2 +- 5 files changed, 144 insertions(+), 187 deletions(-) diff --git a/packages/lexical-playground/src/plugins/TableHoverActionsPlugin/index.tsx b/packages/lexical-playground/src/plugins/TableHoverActionsPlugin/index.tsx index 0ae093bd474..92a26ff0015 100644 --- a/packages/lexical-playground/src/plugins/TableHoverActionsPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/TableHoverActionsPlugin/index.tsx @@ -9,6 +9,7 @@ import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import {useLexicalEditable} from '@lexical/react/useLexicalEditable'; import { + $getTableAndElementByKey, $getTableColumnIndexFromTableCellNode, $getTableRowIndexFromTableCellNode, $insertTableColumn__EXPERIMENTAL, @@ -21,11 +22,10 @@ import { TableRowNode, } from '@lexical/table'; import {$findMatchingParent, mergeRegister} from '@lexical/utils'; -import {$getNearestNodeFromDOMNode, $getNodeByKey, NodeKey} from 'lexical'; +import {$getNearestNodeFromDOMNode, NodeKey} from 'lexical'; import {useEffect, useMemo, useRef, useState} from 'react'; import * as React from 'react'; import {createPortal} from 'react-dom'; -import invariant from 'shared/invariant'; import {useDebounce} from '../CodeActionMenuPlugin/utils'; @@ -168,50 +168,37 @@ function TableHoverActionsContainer({ editor.registerMutationListener( TableNode, (mutations) => { - editor.getEditorState().read(() => { - for (const [key, type] of mutations) { - const tableNode = $getNodeByKey(key); - invariant( - $isTableNode(tableNode), - 'TableHoverActionsPlugin: Expecting TableNode in mutation listener', - ); - const tableDOMElement = getTableElement( - tableNode, - editor.getElementByKey(key), - ); - switch (type) { - case 'created': - tableSetRef.current.add(key); - setShouldListenMouseMove(tableSetRef.current.size > 0); - if (tableDOMElement) { - tableResizeObserver.observe(tableDOMElement); + editor.getEditorState().read( + () => { + let resetObserver = false; + for (const [key, type] of mutations) { + switch (type) { + case 'created': { + tableSetRef.current.add(key); + resetObserver = true; + break; } - break; - - case 'destroyed': - tableSetRef.current.delete(key); - setShouldListenMouseMove(tableSetRef.current.size > 0); - // Reset resize observers - tableResizeObserver.disconnect(); - tableSetRef.current.forEach((tableKey: NodeKey) => { - const otherTableNode = $getNodeByKey(tableKey); - if ($isTableNode(otherTableNode)) { - const tableElement = getTableElement( - otherTableNode, - editor.getElementByKey(tableKey), - ); - if (tableElement) { - tableResizeObserver.observe(tableElement); - } - } - }); - break; - - default: - break; + case 'destroyed': { + tableSetRef.current.delete(key); + resetObserver = true; + break; + } + default: + break; + } } - } - }); + if (resetObserver) { + // Reset resize observers + tableResizeObserver.disconnect(); + for (const tableKey of tableSetRef.current) { + const {tableElement} = $getTableAndElementByKey(tableKey); + tableResizeObserver.observe(tableElement); + } + setShouldListenMouseMove(tableSetRef.current.size > 0); + } + }, + {editor}, + ); }, {skipInitialization: false}, ), diff --git a/packages/lexical-react/src/LexicalTablePlugin.ts b/packages/lexical-react/src/LexicalTablePlugin.ts index 29912aa032c..e0d67fc1e94 100644 --- a/packages/lexical-react/src/LexicalTablePlugin.ts +++ b/packages/lexical-react/src/LexicalTablePlugin.ts @@ -20,8 +20,8 @@ import { $createTableCellNode, $createTableNodeWithDimensions, $getNodeTriplet, + $getTableAndElementByKey, $isTableCellNode, - $isTableNode, $isTableRowNode, applyTableHandlers, getTableElement, @@ -38,7 +38,6 @@ import { } from '@lexical/utils'; import { $createParagraphNode, - $getNodeByKey, $isTextNode, COMMAND_PRIORITY_EDITOR, } from 'lexical'; @@ -143,34 +142,32 @@ export function TablePlugin({ const unregisterMutationListener = editor.registerMutationListener( TableNode, (nodeMutations) => { - for (const [nodeKey, mutation] of nodeMutations) { - if (mutation === 'created' || mutation === 'updated') { - const tableSelection = tableSelections.get(nodeKey); - const dom = editor.getElementByKey(nodeKey); - if (!(tableSelection && dom === tableSelection[1])) { - // The update created a new DOM node, destroy the existing TableObserver - if (tableSelection) { - tableSelection[0].removeListeners(); - tableSelections.delete(nodeKey); + editor.getEditorState().read( + () => { + for (const [nodeKey, mutation] of nodeMutations) { + const tableSelection = tableSelections.get(nodeKey); + if (mutation === 'created' || mutation === 'updated') { + const {tableNode, tableElement} = + $getTableAndElementByKey(nodeKey); + if ( + tableSelection !== undefined && + tableElement !== tableSelection[1] + ) { + // The update created a new DOM node, destroy the existing TableObserver + tableSelection[0].removeListeners(); + tableSelections.delete(nodeKey); + } + initializeTableNode(tableNode, nodeKey, tableElement); + } else if (mutation === 'destroyed') { + if (tableSelection !== undefined) { + tableSelection[0].removeListeners(); + tableSelections.delete(nodeKey); + } } - if (dom !== null) { - // Create a new TableObserver - editor.getEditorState().read(() => { - const tableNode = $getNodeByKey(nodeKey); - if ($isTableNode(tableNode)) { - initializeTableNode(tableNode, nodeKey, dom); - } - }); - } - } - } else if (mutation === 'destroyed') { - const tableSelection = tableSelections.get(nodeKey); - if (tableSelection !== undefined) { - tableSelection[0].removeListeners(); - tableSelections.delete(nodeKey); } - } - } + }, + {editor}, + ); }, {skipInitialization: false}, ); diff --git a/packages/lexical-table/src/LexicalTableObserver.ts b/packages/lexical-table/src/LexicalTableObserver.ts index 005f3e06810..131f7c07090 100644 --- a/packages/lexical-table/src/LexicalTableObserver.ts +++ b/packages/lexical-table/src/LexicalTableObserver.ts @@ -16,6 +16,7 @@ import { $createParagraphNode, $createRangeSelection, $createTextNode, + $getEditor, $getNearestNodeFromDOMNode, $getNodeByKey, $getRoot, @@ -28,7 +29,7 @@ import { import invariant from 'shared/invariant'; import {$isTableCellNode, TableCellNode} from './LexicalTableCellNode'; -import {$isTableNode} from './LexicalTableNode'; +import {$isTableNode, TableNode} from './LexicalTableNode'; import { $createTableSelection, $isTableSelection, @@ -40,6 +41,7 @@ import { getDOMSelection, getTable, getTableElement, + HTMLTableElementWithWithTableSelectionState, } from './LexicalTableSelectionHelpers'; export type TableDOMCell = { @@ -58,6 +60,31 @@ export type TableDOMTable = { rows: number; }; +export function $getTableAndElementByKey( + tableNodeKey: NodeKey, + editor: LexicalEditor = $getEditor(), +): { + tableNode: TableNode; + tableElement: HTMLTableElementWithWithTableSelectionState; +} { + const tableNode = $getNodeByKey(tableNodeKey); + invariant( + $isTableNode(tableNode), + 'TableObserver: Expected tableNodeKey %s to be a TableNode', + tableNodeKey, + ); + const tableElement = getTableElement( + tableNode, + editor.getElementByKey(tableNodeKey), + ); + invariant( + tableElement !== null, + 'TableObserver: Expected to find TableElement in DOM for key %s', + tableNodeKey, + ); + return {tableElement, tableNode}; +} + export class TableObserver { focusX: number; focusY: number; @@ -98,10 +125,10 @@ export class TableObserver { this.anchorCell = null; this.focusCell = null; this.hasHijackedSelectionStyles = false; - this.trackTable(); this.isSelecting = false; this.abortController = new AbortController(); this.listenerOptions = {signal: this.abortController.signal}; + this.trackTable(); } getTable(): TableDOMTable { @@ -116,56 +143,57 @@ export class TableObserver { this.listenersToRemove.clear(); } + $lookup(): { + tableNode: TableNode; + tableElement: HTMLTableElementWithWithTableSelectionState; + } { + return $getTableAndElementByKey(this.tableNodeKey, this.editor); + } + trackTable() { const observer = new MutationObserver((records) => { - this.editor.update(() => { - let gridNeedsRedraw = false; - - for (let i = 0; i < records.length; i++) { - const record = records[i]; - const target = record.target; - const nodeName = target.nodeName; - - if ( - nodeName === 'TABLE' || - nodeName === 'TBODY' || - nodeName === 'THEAD' || - nodeName === 'TR' - ) { - gridNeedsRedraw = true; - break; + this.editor.getEditorState().read( + () => { + let gridNeedsRedraw = false; + + for (let i = 0; i < records.length; i++) { + const record = records[i]; + const target = record.target; + const nodeName = target.nodeName; + + if ( + nodeName === 'TABLE' || + nodeName === 'TBODY' || + nodeName === 'THEAD' || + nodeName === 'TR' + ) { + gridNeedsRedraw = true; + break; + } } - } - - if (!gridNeedsRedraw) { - return; - } - - const tableNode = $getNodeByKey(this.tableNodeKey); - const tableElement = this.editor.getElementByKey(this.tableNodeKey); - - if (!tableElement || !$isTableNode(tableNode)) { - throw new Error('Expected to find TableElement in DOM'); - } - - this.table = getTable(tableNode, tableElement); - }); - }); - this.editor.update(() => { - const tableNode = $getNodeByKey(this.tableNodeKey); - const tableElement = this.editor.getElementByKey(this.tableNodeKey); - if (!tableElement || !$isTableNode(tableNode)) { - throw new Error('Expected to find TableElement in DOM'); - } + if (!gridNeedsRedraw) { + return; + } - this.table = getTable(tableNode, tableElement); - observer.observe(tableElement, { - attributes: true, - childList: true, - subtree: true, - }); + const {tableNode, tableElement} = this.$lookup(); + this.table = getTable(tableNode, tableElement); + }, + {editor: this.editor}, + ); }); + this.editor.getEditorState().read( + () => { + const {tableNode, tableElement} = this.$lookup(); + this.table = getTable(tableNode, tableElement); + observer.observe(tableElement, { + attributes: true, + childList: true, + subtree: true, + }); + }, + {editor: this.editor}, + ); } clearHighlight() { @@ -185,23 +213,7 @@ export class TableObserver { this.enableHighlightStyle(); editor.update(() => { - const tableNode = $getNodeByKey(this.tableNodeKey); - - invariant( - $isTableNode(tableNode), - 'LexicalTableObserver: Expected TableNode', - ); - - const tableElement = getTableElement( - tableNode, - editor.getElementByKey(this.tableNodeKey), - ); - - invariant( - tableElement !== null, - 'LexicalTableObserver: Expected to find TableElement in DOM', - ); - + const {tableNode, tableElement} = this.$lookup(); const grid = getTable(tableNode, tableElement); $updateDOMForSelection(editor, grid, null); $setSelection(null); @@ -213,20 +225,7 @@ export class TableObserver { const editor = this.editor; editor.getEditorState().read( () => { - const tableNode = $getNodeByKey(this.tableNodeKey); - invariant( - $isTableNode(tableNode), - 'LexicalTableObserver: Expected TableNode', - ); - const tableElement = getTableElement( - tableNode, - editor.getElementByKey(this.tableNodeKey), - ); - - invariant( - tableElement !== null, - 'LexicalTableObserver: Expected to find TableElement in DOM', - ); + const {tableElement} = this.$lookup(); removeClassNamesFromElement( tableElement, @@ -243,20 +242,7 @@ export class TableObserver { const editor = this.editor; editor.getEditorState().read( () => { - const tableNode = $getNodeByKey(this.tableNodeKey); - invariant( - $isTableNode(tableNode), - 'LexicalTableObserver: Expected TableNode', - ); - const tableElement = getTableElement( - tableNode, - editor.getElementByKey(this.tableNodeKey), - ); - invariant( - tableElement !== null, - 'LexicalTableObserver: Expected to find TableElement in DOM', - ); - + const {tableElement} = this.$lookup(); addClassNamesToElement( tableElement, editor._config.theme.tableSelection, @@ -285,19 +271,7 @@ export class TableObserver { setFocusCellForSelection(cell: TableDOMCell, ignoreStart = false) { const editor = this.editor; editor.update(() => { - const tableNode = $getNodeByKey(this.tableNodeKey); - invariant( - $isTableNode(tableNode), - 'LexicalTableObserver: Expected TableNode', - ); - const tableElement = getTableElement( - tableNode, - editor.getElementByKey(this.tableNodeKey), - ); - invariant( - tableElement !== null, - 'LexicalTableObserver: Expected to find TableElement in DOM', - ); + const {tableNode} = this.$lookup(); const cellX = cell.x; const cellY = cell.y; diff --git a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts index 14a829341d8..8f3fb425168 100644 --- a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts +++ b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts @@ -88,17 +88,16 @@ export function getTableElement( tableNode: TableNode, dom: T, ): HTMLTableElementWithWithTableSelectionState | (T & null) { - if (!dom || dom.tagName === 'TABLE') { - return dom as unknown as - | HTMLTableElementWithWithTableSelectionState - | (T & null); + if (!dom) { + return dom as T & null; } - const element = tableNode.getDOMSlot(dom) - .element as HTMLTableElementWithWithTableSelectionState; + const element = ( + dom.nodeName === 'TABLE' ? dom : tableNode.getDOMSlot(dom).element + ) as HTMLTableElementWithWithTableSelectionState; invariant( - element.tagName === 'TABLE', + element.nodeName === 'TABLE', 'getTableElement: Expecting table in as DOM node for TableNode, not %s', - dom.tagName, + dom.nodeName, ); return element; } @@ -121,7 +120,7 @@ export function applyTableHandlers( const tableElement = getTableElement(tableNode, element); attachTableObserverToTableElement(tableElement, tableObserver); tableObserver.listenersToRemove.add(() => - deatatchTableObserverFromTableElement(tableElement, tableObserver), + detatchTableObserverFromTableElement(tableElement, tableObserver), ); const createMouseHandlers = () => { @@ -963,7 +962,7 @@ export type HTMLTableElementWithWithTableSelectionState = HTMLTableElement & { [LEXICAL_ELEMENT_KEY]?: TableObserver | undefined; }; -export function deatatchTableObserverFromTableElement( +export function detatchTableObserverFromTableElement( tableElement: HTMLTableElementWithWithTableSelectionState, tableObserver: TableObserver, ) { diff --git a/packages/lexical-table/src/index.ts b/packages/lexical-table/src/index.ts index e849000ed68..be452681b98 100644 --- a/packages/lexical-table/src/index.ts +++ b/packages/lexical-table/src/index.ts @@ -28,7 +28,7 @@ export { TableNode, } from './LexicalTableNode'; export type {TableDOMCell} from './LexicalTableObserver'; -export {TableObserver} from './LexicalTableObserver'; +export {$getTableAndElementByKey, TableObserver} from './LexicalTableObserver'; export type {SerializedTableRowNode} from './LexicalTableRowNode'; export { $createTableRowNode, From 3783c883d404a40292840f7df571e384f320471a Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Fri, 8 Nov 2024 09:58:20 -0800 Subject: [PATCH 15/28] clean up mutation listener --- packages/lexical-react/src/LexicalTablePlugin.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/lexical-react/src/LexicalTablePlugin.ts b/packages/lexical-react/src/LexicalTablePlugin.ts index e0d67fc1e94..db5a30e5140 100644 --- a/packages/lexical-react/src/LexicalTablePlugin.ts +++ b/packages/lexical-react/src/LexicalTablePlugin.ts @@ -149,15 +149,14 @@ export function TablePlugin({ if (mutation === 'created' || mutation === 'updated') { const {tableNode, tableElement} = $getTableAndElementByKey(nodeKey); - if ( - tableSelection !== undefined && - tableElement !== tableSelection[1] - ) { + if (tableSelection === undefined) { + initializeTableNode(tableNode, nodeKey, tableElement); + } else if (tableElement !== tableSelection[1]) { // The update created a new DOM node, destroy the existing TableObserver tableSelection[0].removeListeners(); tableSelections.delete(nodeKey); + initializeTableNode(tableNode, nodeKey, tableElement); } - initializeTableNode(tableNode, nodeKey, tableElement); } else if (mutation === 'destroyed') { if (tableSelection !== undefined) { tableSelection[0].removeListeners(); From abdbd5292bf1569c7d3e376160ea138c57238ef6 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Fri, 8 Nov 2024 12:24:38 -0800 Subject: [PATCH 16/28] reconcile text node selection --- .../CopyAndPaste/lexical/ContextMenuCopyAndPaste.spec.mjs | 3 --- packages/lexical/src/LexicalSelection.ts | 6 ++++++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/lexical-playground/__tests__/e2e/CopyAndPaste/lexical/ContextMenuCopyAndPaste.spec.mjs b/packages/lexical-playground/__tests__/e2e/CopyAndPaste/lexical/ContextMenuCopyAndPaste.spec.mjs index 108cd0f0617..db9aba8103b 100644 --- a/packages/lexical-playground/__tests__/e2e/CopyAndPaste/lexical/ContextMenuCopyAndPaste.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/CopyAndPaste/lexical/ContextMenuCopyAndPaste.spec.mjs @@ -32,9 +32,7 @@ test.describe('ContextMenuCopyAndPaste', () => { await page.keyboard.type('hello'); await click(page, '.lock'); - await page.pause(); await doubleClick(page, 'div[contenteditable="false"] span'); - await page.pause(); await withExclusiveClipboardAccess(async () => { await click(page, 'div[contenteditable="false"] span', {button: 'right'}); await click(page, '#typeahead-menu [role="option"] :text("Copy")'); @@ -72,7 +70,6 @@ test.describe('ContextMenuCopyAndPaste', () => { await click(page, '.font-increment'); await focusEditor(page); await page.keyboard.type('MLH Fellowship'); - //await page.pause(); await moveToLineEnd(page); await page.keyboard.press('Enter'); await page.keyboard.type('Fall 2024'); diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index ba81b533c4b..aaafe8fe0ef 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -2126,6 +2126,12 @@ function $internalResolveSelectionPoint( $isElementNode(resolvedElement), '$internalResolveSelectionPoint: resolvedElement is not an ElementNode', ); + if ( + moveSelectionToEnd && + resolvedOffset >= resolvedElement.getChildrenSize() + ) { + resolvedOffset = Math.max(0, resolvedElement.getChildrenSize() - 1); + } let child = resolvedElement.getChildAtIndex(resolvedOffset); if ( $isElementNode(child) && From 13d0c5e2e67ba512ad75d74a04ddaf03bca1ef1d Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Fri, 8 Nov 2024 14:00:33 -0800 Subject: [PATCH 17/28] Clean up $getTableEdgeCursorPosition --- .../src/LexicalTableSelectionHelpers.ts | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts index 8f3fb425168..9a24c950d8e 100644 --- a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts +++ b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts @@ -1910,14 +1910,29 @@ function $getTableEdgeCursorPosition( return undefined; } - const tableNodeParentDOM = editor.getElementByKey(tableNodeParent.getKey()); - if (!tableNodeParentDOM) { - return undefined; - } - // TODO: Add support for nested tables const domSelection = window.getSelection(); - if (!domSelection || domSelection.anchorNode !== tableNodeParentDOM) { + if (!domSelection) { + return undefined; + } + const domAnchorNode = domSelection.anchorNode; + const tableNodeParentDOM = editor.getElementByKey(tableNodeParent.getKey()); + const tableElement = getTableElement( + tableNode, + editor.getElementByKey(tableNode.getKey()), + ); + // We are only interested in the scenario where the + // native selection anchor is: + // - at or inside the table's parent DOM + // - and NOT at or inside the table DOM + // It may be adjacent to the table DOM (e.g. in a wrapper) + if ( + !domAnchorNode || + !tableNodeParentDOM || + !tableElement || + !tableNodeParentDOM.contains(domAnchorNode) || + tableElement.contains(domAnchorNode) + ) { return undefined; } From 4f4cc343dadacdd168208aad51609ac995d6871b Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Fri, 8 Nov 2024 17:08:28 -0800 Subject: [PATCH 18/28] relax $validatePoint --- packages/lexical/src/LexicalSelection.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index aaafe8fe0ef..fb7a62dbea0 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -2321,7 +2321,7 @@ function $internalResolveSelectionPoints( return null; } if (__DEV__) { - validatePoint(editor, 'anchor', resolvedAnchorPoint); + $validatePoint(editor, 'anchor', resolvedAnchorPoint); } const resolvedFocusPoint = $internalResolveSelectionPoint( focusDOM, @@ -2333,7 +2333,7 @@ function $internalResolveSelectionPoints( return null; } if (__DEV__) { - validatePoint(editor, 'focus', resolvedAnchorPoint); + $validatePoint(editor, 'focus', resolvedAnchorPoint); } if ( resolvedAnchorPoint.type === 'element' && @@ -2504,29 +2504,29 @@ export function $internalCreateRangeSelection( ); } -function validatePoint( +function $validatePoint( editor: LexicalEditor, name: 'anchor' | 'focus', point: PointType, ): void { - const node = editor.getEditorState()._nodeMap.get(point.key); + const node = $getNodeByKey(point.key); invariant( node !== undefined, - 'validatePoint: %s key %s not found in editorState', + '$validatePoint: %s key %s not found in current editorState', name, point.key, ); if (point.type === 'text') { invariant( $isTextNode(node), - 'validatePoint: %s key %s is not a TextNode', + '$validatePoint: %s key %s is not a TextNode', name, point.key, ); const size = node.getTextContentSize(); invariant( point.offset <= size, - 'validatePoint: %s point.offset > node.getTextContentSize() (%s > %s)', + '$validatePoint: %s point.offset > node.getTextContentSize() (%s > %s)', name, String(point.offset), String(size), @@ -2534,14 +2534,14 @@ function validatePoint( } else { invariant( $isElementNode(node), - 'validatePoint: %s key %s is not an ElementNode', + '$validatePoint: %s key %s is not an ElementNode', name, point.key, ); const size = node.getChildrenSize(); invariant( point.offset <= size, - 'validatePoint: %s point.offset > node.getChildrenSize() (%s > %s)', + '$validatePoint: %s point.offset > node.getChildrenSize() (%s > %s)', name, String(point.offset), String(size), From 50891425f94c673a7dd9709582db5692e6a045be Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sat, 9 Nov 2024 09:12:22 -0800 Subject: [PATCH 19/28] firefox workaround --- packages/lexical-playground/__tests__/utils/index.mjs | 2 +- packages/lexical-table/src/LexicalTableNode.ts | 3 +++ .../lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/lexical-playground/__tests__/utils/index.mjs b/packages/lexical-playground/__tests__/utils/index.mjs index c3821bba480..aa2af19f64c 100644 --- a/packages/lexical-playground/__tests__/utils/index.mjs +++ b/packages/lexical-playground/__tests__/utils/index.mjs @@ -59,7 +59,7 @@ export function wrapTableHtml(expected) { ${expected .replace( //g, '
')} `; diff --git a/packages/lexical-table/src/LexicalTableNode.ts b/packages/lexical-table/src/LexicalTableNode.ts index 7e2823ee580..03cc7283244 100644 --- a/packages/lexical-table/src/LexicalTableNode.ts +++ b/packages/lexical-table/src/LexicalTableNode.ts @@ -195,6 +195,9 @@ export class TableNode extends ElementNode { const wrapperElement = document.createElement('div'); wrapperElement.dataset.lexicalScrollable = 'true'; wrapperElement.style.overflowX = 'auto'; + // Without this, keyboard based caret navigation does not work + // as expected with Firefox (down arrow it will skip over the table) + wrapperElement.style.display = 'table'; wrapperElement.appendChild(tableElement); return wrapperElement; } diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx b/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx index 0129446a7c7..722f4965f56 100644 --- a/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx +++ b/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx @@ -78,7 +78,7 @@ function wrapTableHtml(expected: string): string { return expected .replace( //g, '
'); } From 6d5baef6028b46c5dc81e5ef624252221ab362a4 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sat, 9 Nov 2024 21:39:46 -0800 Subject: [PATCH 20/28] CSS + theme approach, firefox workaround --- .../__tests__/utils/index.mjs | 2 +- .../src/themes/PlaygroundEditorTheme.css | 8 +++++ .../src/themes/PlaygroundEditorTheme.ts | 1 + .../lexical-table/src/LexicalTableNode.ts | 16 ++++++--- .../lexical-table/src/LexicalTableObserver.ts | 24 ++++++++++++++ .../src/LexicalTableSelectionHelpers.ts | 33 ++++++++++++++++++- .../__tests__/unit/LexicalTableNode.test.tsx | 6 ++-- packages/lexical/src/LexicalEditor.ts | 2 ++ 8 files changed, 81 insertions(+), 11 deletions(-) diff --git a/packages/lexical-playground/__tests__/utils/index.mjs b/packages/lexical-playground/__tests__/utils/index.mjs index aa2af19f64c..45f43841cb5 100644 --- a/packages/lexical-playground/__tests__/utils/index.mjs +++ b/packages/lexical-playground/__tests__/utils/index.mjs @@ -59,7 +59,7 @@ export function wrapTableHtml(expected) { ${expected .replace( //g, '
')} `; diff --git a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css index 22d27e4145e..cbed93864d1 100644 --- a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css +++ b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css @@ -116,6 +116,14 @@ text-align: right; min-width: 25px; } +.PlaygroundEditorTheme__tableScrollableWrapper { + overflow-x: auto; + margin: 0px 25px 30px 0px; +} +.PlaygroundEditorTheme__tableScrollableWrapper > .PlaygroundEditorTheme__table { + /* Remove the table's margin and put it on the wrapper */ + margin: 0; +} .PlaygroundEditorTheme__table { border-collapse: collapse; border-spacing: 0; diff --git a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.ts b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.ts index c29d9d1434d..e1c87638895 100644 --- a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.ts +++ b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.ts @@ -103,6 +103,7 @@ const theme: EditorThemeClasses = { tableCellSortedIndicator: 'PlaygroundEditorTheme__tableCellSortedIndicator', tableResizeRuler: 'PlaygroundEditorTheme__tableCellResizeRuler', tableRowStriping: 'PlaygroundEditorTheme__tableRowStriping', + tableScrollableWrapper: 'PlaygroundEditorTheme__tableScrollableWrapper', tableSelected: 'PlaygroundEditorTheme__tableSelected', tableSelection: 'PlaygroundEditorTheme__tableSelection', text: { diff --git a/packages/lexical-table/src/LexicalTableNode.ts b/packages/lexical-table/src/LexicalTableNode.ts index 03cc7283244..838758e2e83 100644 --- a/packages/lexical-table/src/LexicalTableNode.ts +++ b/packages/lexical-table/src/LexicalTableNode.ts @@ -93,6 +93,11 @@ export function setScrollableTablesActive( active: boolean, ) { if (active) { + if (__DEV__ && !editor._config.theme.tableScrollableWrapper) { + console.warn( + 'TableNode: hasHorizontalScroll is active but theme.tableScrollableWrapper is not defined.', + ); + } scrollableEditors.add(editor); } else { scrollableEditors.delete(editor); @@ -193,11 +198,12 @@ export class TableNode extends ElementNode { } if ($isScrollableTablesActive(editor)) { const wrapperElement = document.createElement('div'); - wrapperElement.dataset.lexicalScrollable = 'true'; - wrapperElement.style.overflowX = 'auto'; - // Without this, keyboard based caret navigation does not work - // as expected with Firefox (down arrow it will skip over the table) - wrapperElement.style.display = 'table'; + const classes = config.theme.tableScrollableWrapper; + if (classes) { + addClassNamesToElement(wrapperElement, classes); + } else { + wrapperElement.style.cssText = 'overflow-x: auto;'; + } wrapperElement.appendChild(tableElement); return wrapperElement; } diff --git a/packages/lexical-table/src/LexicalTableObserver.ts b/packages/lexical-table/src/LexicalTableObserver.ts index 131f7c07090..447aecf1045 100644 --- a/packages/lexical-table/src/LexicalTableObserver.ts +++ b/packages/lexical-table/src/LexicalTableObserver.ts @@ -102,6 +102,7 @@ export class TableObserver { tableSelection: TableSelection | null; hasHijackedSelectionStyles: boolean; isSelecting: boolean; + shouldCheckSelection: boolean; abortController: AbortController; listenerOptions: {signal: AbortSignal}; @@ -126,6 +127,7 @@ export class TableObserver { this.focusCell = null; this.hasHijackedSelectionStyles = false; this.isSelecting = false; + this.shouldCheckSelection = false; this.abortController = new AbortController(); this.listenerOptions = {signal: this.abortController.signal}; this.trackTable(); @@ -268,6 +270,28 @@ export class TableObserver { } } + /** + * @internal + * Firefox has a strange behavior where pressing the down arrow key from + * above the table will move the caret after the table and then lexical + * will select the last cell instead of the first. + * We do still want to let the browser handle caret movement but we will + * tag the update so that we can recheck the selection after the event + * is processed. + */ + setShouldCheckSelection(): void { + this.shouldCheckSelection = true; + } + /** + * @internal + */ + getAndClearShouldCheckSelection(): boolean { + if (this.shouldCheckSelection) { + this.shouldCheckSelection = false; + return true; + } + return false; + } setFocusCellForSelection(cell: TableDOMCell, ignoreStart = false) { const editor = this.editor; editor.update(() => { diff --git a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts index 9a24c950d8e..f629d1feb0d 100644 --- a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts +++ b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts @@ -67,7 +67,11 @@ import {CAN_USE_DOM} from 'shared/canUseDOM'; import invariant from 'shared/invariant'; import {$isTableCellNode} from './LexicalTableCellNode'; -import {$isTableNode, TableNode} from './LexicalTableNode'; +import { + $isScrollableTablesActive, + $isTableNode, + TableNode, +} from './LexicalTableNode'; import {TableDOMTable, TableObserver} from './LexicalTableObserver'; import {$isTableRowNode} from './LexicalTableRowNode'; import {$isTableSelection} from './LexicalTableSelection'; @@ -762,6 +766,29 @@ export function applyTableHandlers( () => { const selection = $getSelection(); const prevSelection = $getPreviousSelection(); + // If they pressed the down arrow with the selection outside of the + // table, and then the selection ends up in the table but not in the + // first cell, then move the selection to the first cell. + if ( + tableObserver.getAndClearShouldCheckSelection() && + $isRangeSelection(prevSelection) && + $isRangeSelection(selection) && + selection.isCollapsed() + ) { + const anchor = selection.anchor.getNode(); + const firstRow = tableNode.getFirstChild(); + const anchorCell = $findCellNode(anchor); + if (anchorCell !== null && $isTableRowNode(firstRow)) { + const firstCell = firstRow.getFirstChild(); + if ( + $isTableCellNode(firstCell) && + !$findMatchingParent(anchorCell, (node) => node.is(firstCell)) + ) { + firstCell.selectStart(); + return true; + } + } + } if ($isRangeSelection(selection)) { const {anchor, focus} = selection; @@ -1560,6 +1587,10 @@ function $handleArrowKey( } } } + if (direction === 'down' && $isScrollableTablesActive(editor)) { + // Enable Firefox workaround + tableObserver.setShouldCheckSelection(); + } return false; } diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx b/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx index 722f4965f56..b4809146035 100644 --- a/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx +++ b/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx @@ -71,15 +71,13 @@ const editorConfig = Object.freeze({ theme: { table: 'test-table-class', tableRowStriping: 'test-table-row-striping-class', + tableScrollableWraooer: 'table-scrollable-wrapper', }, }); function wrapTableHtml(expected: string): string { return expected - .replace( - //g, '
'); } diff --git a/packages/lexical/src/LexicalEditor.ts b/packages/lexical/src/LexicalEditor.ts index 6016ae84956..174b18cb62c 100644 --- a/packages/lexical/src/LexicalEditor.ts +++ b/packages/lexical/src/LexicalEditor.ts @@ -144,7 +144,9 @@ export type EditorThemeClasses = { tableCellSortedIndicator?: EditorThemeClassName; tableResizeRuler?: EditorThemeClassName; tableRow?: EditorThemeClassName; + tableScrollableWrapper?: EditorThemeClassName; tableSelected?: EditorThemeClassName; + tableSelection?: EditorThemeClassName; text?: TextNodeThemeClasses; embedBlock?: { base?: EditorThemeClassName; From 1e644d1a820bec31b14ea7dccb3266708a154e13 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sat, 9 Nov 2024 21:59:32 -0800 Subject: [PATCH 21/28] fix typo --- .../src/__tests__/unit/LexicalTableNode.test.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx b/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx index b4809146035..df23bdcf843 100644 --- a/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx +++ b/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx @@ -71,7 +71,7 @@ const editorConfig = Object.freeze({ theme: { table: 'test-table-class', tableRowStriping: 'test-table-row-striping-class', - tableScrollableWraooer: 'table-scrollable-wrapper', + tableScrollableWrapper: 'table-scrollable-wrapper', }, }); @@ -157,7 +157,7 @@ describe('LexicalTableNode tests', () => { expectTableHtmlToBeEqual( testEnv.innerHTML, html` - +
@@ -206,7 +206,7 @@ describe('LexicalTableNode tests', () => { expectTableHtmlToBeEqual( testEnv.innerHTML, html` -
+
@@ -416,7 +416,7 @@ describe('LexicalTableNode tests', () => { testEnv.innerHTML, html`


-
+
@@ -674,7 +674,7 @@ describe('LexicalTableNode tests', () => { }); }); }, - undefined, + {theme: editorConfig.theme}, , ); }); From 3f629f156db9ad9c01b3d6be7498e7c85a679c48 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 10 Nov 2024 07:49:36 -0800 Subject: [PATCH 22/28] fix collab tests --- .../__tests__/e2e/Tables.spec.mjs | 16 ++++++++++++++-- .../lexical-playground/__tests__/utils/index.mjs | 8 ++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs index bf87fd8c3f6..caaff357b09 100644 --- a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs @@ -77,10 +77,22 @@ async function fillTablePartiallyWithText(page) { await page.keyboard.press('c'); } -async function assertHTML(page, expected, ...args) { +async function assertHTML( + page, + expectedHtml, + expectedHtmlFrameRight = undefined, + options = undefined, + ...args +) { return await rawAssertHTML( page, - IS_TABLE_HORIZONTAL_SCROLL ? wrapTableHtml(expected) : expected, + IS_TABLE_HORIZONTAL_SCROLL + ? wrapTableHtml(expectedHtml, options) + : expectedHtml, + IS_TABLE_HORIZONTAL_SCROLL && expectedHtmlFrameRight !== undefined + ? wrapTableHtml(expectedHtmlFrameRight, options) + : expectedHtmlFrameRight, + options, ...args, ); } diff --git a/packages/lexical-playground/__tests__/utils/index.mjs b/packages/lexical-playground/__tests__/utils/index.mjs index 45f43841cb5..4bca788af43 100644 --- a/packages/lexical-playground/__tests__/utils/index.mjs +++ b/packages/lexical-playground/__tests__/utils/index.mjs @@ -54,12 +54,16 @@ function wrapAndSlowDown(method, delay) { }; } -export function wrapTableHtml(expected) { +export function wrapTableHtml(expected, {ignoreClasses = false} = {}) { return html` ${expected .replace( /
/g, '
')} `; From f068536af19d3b2a846f14dfedfd23493c903bf2 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 10 Nov 2024 10:08:17 -0800 Subject: [PATCH 23/28] Add an img linebreak hack for Safari --- .../__tests__/utils/index.mjs | 20 +++++-- packages/lexical/src/LexicalMutations.ts | 14 ++--- packages/lexical/src/LexicalNode.ts | 2 +- packages/lexical/src/LexicalReconciler.ts | 20 +------ packages/lexical/src/LexicalUtils.ts | 8 ++- .../lexical/src/nodes/LexicalElementNode.ts | 54 +++++++++++++++++-- 6 files changed, 79 insertions(+), 39 deletions(-) diff --git a/packages/lexical-playground/__tests__/utils/index.mjs b/packages/lexical-playground/__tests__/utils/index.mjs index 4bca788af43..74073e8124e 100644 --- a/packages/lexical-playground/__tests__/utils/index.mjs +++ b/packages/lexical-playground/__tests__/utils/index.mjs @@ -195,6 +195,16 @@ export async function clickSelectors(page, selectors) { await click(page, selectors[i]); } } + +function removeSafariLinebreakImgHack(actualHtml) { + return E2E_BROWSER === 'webkit' + ? actualHtml.replaceAll( + /]+ )?data-lexical-linebreak="true"(?: [^>]+)?>/g, + '', + ) + : actualHtml; +} + /** * @param {import('@playwright/test').Page | import('@playwright/test').Frame} pageOrFrame */ @@ -211,10 +221,12 @@ async function assertHTMLOnPageOrFrame( ignoreInlineStyles, }); return await expect(async () => { - const actualHtml = await pageOrFrame - .locator('div[contenteditable="true"]') - .first() - .innerHTML(); + const actualHtml = removeSafariLinebreakImgHack( + await pageOrFrame + .locator('div[contenteditable="true"]') + .first() + .innerHTML(), + ); let actual = prettifyHTML(actualHtml.replace(/\n/gm, ''), { ignoreClasses, ignoreInlineStyles, diff --git a/packages/lexical/src/LexicalMutations.ts b/packages/lexical/src/LexicalMutations.ts index 33ced2f5d4e..fa58ebde193 100644 --- a/packages/lexical/src/LexicalMutations.ts +++ b/packages/lexical/src/LexicalMutations.ts @@ -60,9 +60,13 @@ function isManagedLineBreak( target: Node & LexicalPrivateDOM, editor: LexicalEditor, ): boolean { + const isBR = dom.nodeName === 'BR'; + const lexicalLineBreak = target.__lexicalLineBreak; return ( - target.__lexicalLineBreak === dom || - getNodeKeyFromDOMNode(dom, editor) !== undefined + (lexicalLineBreak && + (dom === lexicalLineBreak || + (isBR && dom.previousSibling === lexicalLineBreak))) || + (isBR && getNodeKeyFromDOMNode(dom, editor) !== undefined) ); } @@ -201,8 +205,7 @@ export function $flushMutations( parentDOM != null && addedDOM !== blockCursorElement && node === null && - (addedDOM.nodeName !== 'BR' || - !isManagedLineBreak(addedDOM, parentDOM, editor)) + !isManagedLineBreak(addedDOM, parentDOM, editor) ) { if (IS_FIREFOX) { const possibleText = @@ -227,8 +230,7 @@ export function $flushMutations( const removedDOM = removedDOMs[s]; if ( - (removedDOM.nodeName === 'BR' && - isManagedLineBreak(removedDOM, targetDOM, editor)) || + isManagedLineBreak(removedDOM, targetDOM, editor) || blockCursorElement === removedDOM ) { targetDOM.appendChild(removedDOM); diff --git a/packages/lexical/src/LexicalNode.ts b/packages/lexical/src/LexicalNode.ts index 3b1141034c1..0aa1d1ca487 100644 --- a/packages/lexical/src/LexicalNode.ts +++ b/packages/lexical/src/LexicalNode.ts @@ -59,7 +59,7 @@ export type SerializedLexicalNode = { /** @internal */ export interface LexicalPrivateDOM { __lexicalTextContent?: string | undefined | null; - __lexicalLineBreak?: HTMLBRElement | undefined | null; + __lexicalLineBreak?: HTMLBRElement | HTMLImageElement | undefined | null; __lexicalDirTextContent?: string | undefined | null; __lexicalDir?: 'ltr' | 'rtl' | null | undefined; __lexicalUnmanaged?: boolean | undefined; diff --git a/packages/lexical/src/LexicalReconciler.ts b/packages/lexical/src/LexicalReconciler.ts index 801ece8de8e..2c0cb980d3c 100644 --- a/packages/lexical/src/LexicalReconciler.ts +++ b/packages/lexical/src/LexicalReconciler.ts @@ -166,22 +166,6 @@ function setElementFormat(dom: HTMLElement, format: number): void { } } -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); @@ -338,9 +322,9 @@ function reconcileElementTerminatingLineBreak( if (prevLineBreak !== nextLineBreak) { const slot = nextElement.getDOMSlot(dom); if (prevLineBreak) { - removeLineBreak(slot); + slot.removeManagedLineBreak(); } else { - insertLineBreak(slot); + slot.insertManagedLineBreak(); } } } diff --git a/packages/lexical/src/LexicalUtils.ts b/packages/lexical/src/LexicalUtils.ts index fe359101910..a4a3af63118 100644 --- a/packages/lexical/src/LexicalUtils.ts +++ b/packages/lexical/src/LexicalUtils.ts @@ -1582,13 +1582,11 @@ export function updateDOMBlockCursorElement( } } else { const child = elementNode.getChildAtIndex(offset); - if (needsBlockCursor(child)) { - const sibling = (child as LexicalNode).getPreviousSibling(); + if (child !== null && needsBlockCursor(child)) { + const sibling = child.getPreviousSibling(); if (sibling === null || needsBlockCursor(sibling)) { isBlockCursor = true; - insertBeforeElement = editor.getElementByKey( - (child as LexicalNode).__key, - ); + insertBeforeElement = editor.getElementByKey(child.__key); } } } diff --git a/packages/lexical/src/nodes/LexicalElementNode.ts b/packages/lexical/src/nodes/LexicalElementNode.ts index 27998e82e5e..80d718d4ebe 100644 --- a/packages/lexical/src/nodes/LexicalElementNode.ts +++ b/packages/lexical/src/nodes/LexicalElementNode.ts @@ -19,6 +19,7 @@ import type { } from '../LexicalSelection'; import type {KlassConstructor, LexicalEditor, Spread} from 'lexical'; +import {IS_IOS, IS_SAFARI} from 'shared/environment'; import invariant from 'shared/invariant'; import {$isTextNode, TextNode} from '../index'; @@ -111,8 +112,7 @@ export class ElementDOMSlot { * or append it if this.before is not defined */ insertChild(dom: Node): this { - const element: HTMLElement & LexicalPrivateDOM = this.element; - const before = this.before || element.__lexicalLineBreak || null; + const before = this.before || this.getManagedLineBreak(); invariant( before === null || before.parentElement === this.element, 'ElementDOMSlot.insertChild: before is not in element', @@ -151,13 +151,57 @@ export class ElementDOMSlot { * and will never be this.before if it is defined. */ getFirstChild(): ChildNode | null { - const element: HTMLElement & LexicalPrivateDOM = this.element; - const firstChild = this.after ? this.after.nextSibling : element.firstChild; + const firstChild = this.after + ? this.after.nextSibling + : this.element.firstChild; return firstChild === this.before || - firstChild === element.__lexicalLineBreak + firstChild === this.getManagedLineBreak() ? null : firstChild; } + /** + * @internal + */ + getManagedLineBreak(): Exclude< + LexicalPrivateDOM['__lexicalLineBreak'], + undefined + > { + const element: HTMLElement & LexicalPrivateDOM = this.element; + return element.__lexicalLineBreak || null; + } + /** @internal */ + removeManagedLineBreak(): void { + const br = this.getManagedLineBreak(); + if (br) { + const element: HTMLElement & LexicalPrivateDOM = this.element; + const sibling = br.nodeName === 'IMG' ? br.nextSibling : null; + if (sibling) { + element.removeChild(sibling); + } + element.removeChild(br); + element.__lexicalLineBreak = undefined; + } + } + /** @internal */ + insertManagedLineBreak(): void { + if (this.getManagedLineBreak()) { + return; + } + const element: HTMLElement & LexicalPrivateDOM = this.element; + const before = this.before; + const br = document.createElement('br'); + const img = IS_IOS || IS_SAFARI ? document.createElement('img') : null; + element.insertBefore(br, before); + if (img) { + img.setAttribute('data-lexical-linebreak', 'true'); + img.style.cssText = + 'display: inline !important; border: 0px !important; margin: 0px !important;'; + img.alt = ''; + element.insertBefore(img, br); + } + element.__lexicalLineBreak = img || br; + } + /** * @internal * From 6d81cdc7b6f6bb74f651459a83cf047d8be693dd Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 10 Nov 2024 13:38:11 -0800 Subject: [PATCH 24/28] empty From aa44e023e54e652d2e5ec3c3c4761fba2ccf40d2 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 10 Nov 2024 17:46:02 -0800 Subject: [PATCH 25/28] isolate img+br hack to specific decorator situation --- .../__tests__/e2e/TextEntry.spec.mjs | 1 - .../__tests__/utils/index.mjs | 21 +++++++- packages/lexical/src/LexicalReconciler.ts | 51 ++++++++++--------- .../lexical/src/nodes/LexicalElementNode.ts | 30 ++++++++--- 4 files changed, 70 insertions(+), 33 deletions(-) diff --git a/packages/lexical-playground/__tests__/e2e/TextEntry.spec.mjs b/packages/lexical-playground/__tests__/e2e/TextEntry.spec.mjs index 6a88dd49de3..c2c60760526 100644 --- a/packages/lexical-playground/__tests__/e2e/TextEntry.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/TextEntry.spec.mjs @@ -641,7 +641,6 @@ test.describe('TextEntry', () => {


`, ); - await page.pause(); await assertSelection(page, { anchorOffset: 0, anchorPath: [1], diff --git a/packages/lexical-playground/__tests__/utils/index.mjs b/packages/lexical-playground/__tests__/utils/index.mjs index 74073e8124e..9f43f61e5fd 100644 --- a/packages/lexical-playground/__tests__/utils/index.mjs +++ b/packages/lexical-playground/__tests__/utils/index.mjs @@ -370,13 +370,30 @@ async function assertSelectionOnPageOrFrame(page, expected) { return path.reverse(); }; + const fixOffset = (node, offset) => { + // If the selection offset is at the br of a webkit img+br linebreak + // then move the offset to the img so the tests are consistent across + // browsers + if (node && node.nodeType === Node.ELEMENT_NODE && offset > 0) { + const child = node.children[offset - 1]; + if ( + child && + child.nodeType === Node.ELEMENT_NODE && + child.getAttribute('data-lexical-linebreak') === 'true' + ) { + return offset - 1; + } + } + return offset; + }; + const {anchorNode, anchorOffset, focusNode, focusOffset} = window.getSelection(); return { - anchorOffset, + anchorOffset: fixOffset(anchorNode, anchorOffset), anchorPath: getPathFromNode(anchorNode), - focusOffset, + focusOffset: fixOffset(focusNode, focusOffset), focusPath: getPathFromNode(focusNode), }; }, expected); diff --git a/packages/lexical/src/LexicalReconciler.ts b/packages/lexical/src/LexicalReconciler.ts index 2c0cb980d3c..6fa946f61d6 100644 --- a/packages/lexical/src/LexicalReconciler.ts +++ b/packages/lexical/src/LexicalReconciler.ts @@ -291,12 +291,26 @@ function $createChildren( subTreeTextContent = previousSubTreeTextContent + subTreeTextContent; } +type LastChildState = 'line-break' | 'decorator' | 'empty'; function isLastChildLineBreakOrDecorator( - childKey: NodeKey, + element: null | ElementNode, nodeMap: NodeMap, -): boolean { - const node = nodeMap.get(childKey); - return $isLineBreakNode(node) || ($isDecoratorNode(node) && node.isInline()); +): null | LastChildState { + if (element) { + const lastKey = element.__last; + if (lastKey) { + const node = nodeMap.get(lastKey); + if (node) { + return $isLineBreakNode(node) + ? 'line-break' + : $isDecoratorNode(node) && node.isInline() + ? 'decorator' + : null; + } + } + return 'empty'; + } + return null; } // If we end an element with a LineBreakNode, then we need to add an additional
@@ -305,27 +319,16 @@ function reconcileElementTerminatingLineBreak( nextElement: ElementNode, dom: HTMLElement & LexicalPrivateDOM, ): void { - const prevLineBreak = - prevElement !== null && - (prevElement.__size === 0 || - isLastChildLineBreakOrDecorator( - prevElement.__last as NodeKey, - activePrevNodeMap, - )); - const nextLineBreak = - nextElement.__size === 0 || - isLastChildLineBreakOrDecorator( - nextElement.__last as NodeKey, - activeNextNodeMap, - ); - + const prevLineBreak = isLastChildLineBreakOrDecorator( + prevElement, + activePrevNodeMap, + ); + const nextLineBreak = isLastChildLineBreakOrDecorator( + nextElement, + activeNextNodeMap, + ); if (prevLineBreak !== nextLineBreak) { - const slot = nextElement.getDOMSlot(dom); - if (prevLineBreak) { - slot.removeManagedLineBreak(); - } else { - slot.insertManagedLineBreak(); - } + nextElement.getDOMSlot(dom).setManagedLineBreak(nextLineBreak); } } diff --git a/packages/lexical/src/nodes/LexicalElementNode.ts b/packages/lexical/src/nodes/LexicalElementNode.ts index 80d718d4ebe..285a546545a 100644 --- a/packages/lexical/src/nodes/LexicalElementNode.ts +++ b/packages/lexical/src/nodes/LexicalElementNode.ts @@ -169,6 +169,18 @@ export class ElementDOMSlot { const element: HTMLElement & LexicalPrivateDOM = this.element; return element.__lexicalLineBreak || null; } + /** @internal */ + setManagedLineBreak( + lineBreakType: null | 'empty' | 'line-break' | 'decorator', + ): void { + if (lineBreakType === null) { + this.removeManagedLineBreak(); + } else { + const webkitHack = lineBreakType === 'decorator' && (IS_IOS || IS_SAFARI); + this.insertManagedLineBreak(webkitHack); + } + } + /** @internal */ removeManagedLineBreak(): void { const br = this.getManagedLineBreak(); @@ -183,23 +195,29 @@ export class ElementDOMSlot { } } /** @internal */ - insertManagedLineBreak(): void { - if (this.getManagedLineBreak()) { - return; + insertManagedLineBreak(webkitHack: boolean): void { + const prevBreak = this.getManagedLineBreak(); + if (prevBreak) { + if (webkitHack === (prevBreak.nodeName === 'IMG')) { + return; + } + this.removeManagedLineBreak(); } const element: HTMLElement & LexicalPrivateDOM = this.element; const before = this.before; const br = document.createElement('br'); - const img = IS_IOS || IS_SAFARI ? document.createElement('img') : null; element.insertBefore(br, before); - if (img) { + if (webkitHack) { + const img = document.createElement('img'); img.setAttribute('data-lexical-linebreak', 'true'); img.style.cssText = 'display: inline !important; border: 0px !important; margin: 0px !important;'; img.alt = ''; element.insertBefore(img, br); + element.__lexicalLineBreak = img; + } else { + element.__lexicalLineBreak = br; } - element.__lexicalLineBreak = img || br; } /** From 1e9bc54bbb5e124208b9064d6b705a490af69e18 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 10 Nov 2024 21:39:12 -0800 Subject: [PATCH 26/28] Make collab test less flaky --- .../__tests__/e2e/Collaboration.spec.mjs | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs b/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs index b47b3c04f6a..296d9ac21a0 100644 --- a/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs @@ -322,17 +322,43 @@ test.describe('Collaboration', () => { // Left collaborator types two pieces of text in the same paragraph, but with different styling. await focusEditor(page); await page.keyboard.type('normal'); + await assertHTML( + page, + html` +

+ normal +

+ `, + ); await sleep(1050); await toggleBold(page); await page.keyboard.type('bold'); + await assertHTML( + page, + html` +

+ normal + + bold + +

+ `, + ); + const boldSleep = sleep(1050); + // Right collaborator types at the end of the paragraph. - await sleep(50); await page .frameLocator('iframe[name="right"]') .locator('[data-lexical-editor="true"]') .focus(); - await page.keyboard.press('ArrowDown'); // Move caret to end of paragraph + await page.keyboard.press('ArrowDown', {delay: 50}); // Move caret to end of paragraph await page.keyboard.type('BOLD'); await assertHTML( @@ -352,7 +378,7 @@ test.describe('Collaboration', () => { ); // Left collaborator undoes their bold text. - await sleep(50); + await boldSleep; await page.frameLocator('iframe[name="left"]').getByLabel('Undo').click(); // The undo also removed bold the text node from YJS. From 50dadbcbb87ab8aca20b206c9b1e8aef80c8eadc Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Mon, 11 Nov 2024 08:13:47 -0800 Subject: [PATCH 27/28] Update @lexical/react/TablePlugin documentation --- .../lexical-react/src/LexicalTablePlugin.ts | 33 +++++++++++++++---- .../lexical-website/docs/react/plugins.md | 2 ++ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/packages/lexical-react/src/LexicalTablePlugin.ts b/packages/lexical-react/src/LexicalTablePlugin.ts index db5a30e5140..a5c43d17c65 100644 --- a/packages/lexical-react/src/LexicalTablePlugin.ts +++ b/packages/lexical-react/src/LexicalTablePlugin.ts @@ -44,17 +44,38 @@ import { import {useEffect} from 'react'; import invariant from 'shared/invariant'; +export interface TablePluginProps { + /** + * When `false` (default `true`), merged cell support (colspan and rowspan) will be disabled and all + * tables will be forced into a regular grid with 1x1 table cells. + */ + hasCellMerge?: boolean; + /** + * When `false` (default `true`), the background color of TableCellNode will always be removed. + */ + hasCellBackgroundColor?: boolean; + /** + * When `true` (default `true`), the tab key can be used to navigate table cells. + */ + hasTabHandler?: boolean; + /** + * When `true` (default `false`), tables will be wrapped in a `
` to enable horizontal scrolling + */ + hasHorizontalScroll?: boolean; +} + +/** + * A plugin to enable all of the features of Lexical's TableNode. + * + * @param props - See type for documentation + * @returns An element to render in your LexicalComposer + */ export function TablePlugin({ hasCellMerge = true, hasCellBackgroundColor = true, hasTabHandler = true, hasHorizontalScroll = false, -}: { - hasCellMerge?: boolean; - hasCellBackgroundColor?: boolean; - hasTabHandler?: boolean; - hasHorizontalScroll?: boolean; -}): JSX.Element | null { +}: TablePluginProps): JSX.Element | null { const [editor] = useLexicalComposerContext(); useEffect(() => { diff --git a/packages/lexical-website/docs/react/plugins.md b/packages/lexical-website/docs/react/plugins.md index d2dcb206afb..1e7f60c0294 100644 --- a/packages/lexical-website/docs/react/plugins.md +++ b/packages/lexical-website/docs/react/plugins.md @@ -109,6 +109,8 @@ React wrapper for `@lexical/list` that adds support for check lists. Note that i ### `LexicalTablePlugin` +[![See API Documentation](/img/see-api-documentation.svg)](/docs/api/modules/lexical_react_LexicalTablePlugin) + React wrapper for `@lexical/table` that adds support for tables ```jsx From 9bc9c3321cb955aed3ddf756920e6440e7b10fd0 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Mon, 11 Nov 2024 08:48:33 -0800 Subject: [PATCH 28/28] flow types --- .../__tests__/e2e/Tables.spec.mjs | 3 -- .../lexical-table/src/LexicalTableObserver.ts | 4 +-- packages/lexical/flow/Lexical.js.flow | 28 +++++++++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs index caaff357b09..d9362c28aed 100644 --- a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs @@ -3048,7 +3048,6 @@ test.describe.parallel('Tables', () => { await insertTable(page, 2, 2); await click(page, '.PlaygroundEditorTheme__tableCell'); - await page.pause(); await selectCellsFromTableCords( page, {x: 0, y: 0}, @@ -3056,9 +3055,7 @@ test.describe.parallel('Tables', () => { true, true, ); - await page.pause(); await insertTableColumnBefore(page); - await page.pause(); await assertHTML( page, diff --git a/packages/lexical-table/src/LexicalTableObserver.ts b/packages/lexical-table/src/LexicalTableObserver.ts index 447aecf1045..9d3ffbbc690 100644 --- a/packages/lexical-table/src/LexicalTableObserver.ts +++ b/packages/lexical-table/src/LexicalTableObserver.ts @@ -276,8 +276,8 @@ export class TableObserver { * above the table will move the caret after the table and then lexical * will select the last cell instead of the first. * We do still want to let the browser handle caret movement but we will - * tag the update so that we can recheck the selection after the event - * is processed. + * use this property to "tag" the update so that we can recheck the + * selection after the event is processed. */ setShouldCheckSelection(): void { this.shouldCheckSelection = true; diff --git a/packages/lexical/flow/Lexical.js.flow b/packages/lexical/flow/Lexical.js.flow index bc32e05bff6..dccc5987079 100644 --- a/packages/lexical/flow/Lexical.js.flow +++ b/packages/lexical/flow/Lexical.js.flow @@ -433,6 +433,7 @@ declare export class LexicalNode { selectPrevious(anchorOffset?: number, focusOffset?: number): RangeSelection; selectNext(anchorOffset?: number, focusOffset?: number): RangeSelection; markDirty(): void; + reconcileObservedMutation(dom: HTMLElement, editor: LexicalEditor): void; } export type NodeMap = Map; @@ -790,11 +791,38 @@ declare export class ElementNode extends LexicalNode { nodesToInsert: Array, ): this; exportJSON(): SerializedElementNode; + getDOMSlot(dom: HTMLElement): ElementDOMSlot; } declare export function $isElementNode( node: ?LexicalNode, ): node is ElementNode; +/** + * ElementDOMSlot + */ +declare export class ElementDOMSlot { + element: HTMLElement; + before: Node | null; + after: Node | null; + constructor(element: HTMLElement, before?: Node | null | void, after?: Node | null | void): void; + withBefore(before: Node | null | void): ElementDOMSlot; + withAfter(after: Node | null | void): ElementDOMSlot; + withElement(element: HTMLElement): ElementDOMSlot; + insertChild(dom: Node): this; + removeChild(dom: Node): this; + replaceChild(dom: Node, prevDom: Node): this; + getFirstChild(): Node | null; + // + getManagedLineBreak(): HTMLElement | null; + removeManagedLineBreak(): void; + insertManagedLineBreak(webkitHack: boolean): void; + getFirstChildOffset(): number; + resolveChildIndex(element: ElementNode, elementDOM: HTMLElement, initialDOM: Node, initialOffset: number): [node: ElementNode, idx: number]; +} + +declare export function setDOMUnmanaged(elementDOM: HTMLElement): void; +declare export function isDOMUnmanaged(elementDOM: HTMLElement): boolean; + /** * LexicalDecoratorNode */