From 506ef8946779083fc2e9801779b68cb92b517a19 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Tue, 12 Nov 2024 20:16:31 -0800 Subject: [PATCH] [lexical][lexical-table] Feature: Scrollable tables with experimental getDOMSlot API (#6759) --- .../__tests__/e2e/Collaboration.spec.mjs | 32 +- .../html/TablesHTMLCopyAndPaste.spec.mjs | 4 +- .../lexical/ContextMenuCopyAndPaste.spec.mjs | 3 - .../__tests__/e2e/Indentation.spec.mjs | 4 +- .../__tests__/e2e/Selection.spec.mjs | 4 +- .../__tests__/e2e/Tables.spec.mjs | 146 ++- .../__tests__/e2e/TextEntry.spec.mjs | 1 - .../__tests__/e2e/Toolbar.spec.mjs | 7 +- .../4661-insert-column-selection.spec.mjs | 4 +- .../__tests__/utils/index.mjs | 61 +- packages/lexical-playground/src/Editor.tsx | 2 + packages/lexical-playground/src/Settings.tsx | 14 +- .../lexical-playground/src/appSettings.ts | 1 + packages/lexical-playground/src/index.css | 3 +- .../plugins/TableActionMenuPlugin/index.tsx | 17 +- .../src/plugins/TableCellResizer/index.tsx | 6 +- .../plugins/TableHoverActionsPlugin/index.tsx | 64 +- .../src/themes/PlaygroundEditorTheme.css | 8 + .../src/themes/PlaygroundEditorTheme.ts | 1 + .../lexical-react/src/LexicalTablePlugin.ts | 93 +- .../lexical-table/flow/LexicalTable.js.flow | 1 + .../lexical-table/src/LexicalTableNode.ts | 114 ++- .../lexical-table/src/LexicalTableObserver.ts | 218 ++-- .../src/LexicalTableSelectionHelpers.ts | 110 +- .../__tests__/unit/LexicalTableNode.test.tsx | 969 +++++++++++------- packages/lexical-table/src/index.ts | 5 +- .../lexical-website/docs/react/plugins.md | 2 + packages/lexical/flow/Lexical.js.flow | 28 + packages/lexical/src/LexicalEditor.ts | 2 + packages/lexical/src/LexicalMutations.ts | 98 +- packages/lexical/src/LexicalNode.ts | 19 + packages/lexical/src/LexicalReconciler.ts | 207 ++-- packages/lexical/src/LexicalSelection.ts | 82 +- packages/lexical/src/LexicalUtils.ts | 63 +- .../lexical/src/__tests__/utils/index.tsx | 21 +- packages/lexical/src/index.ts | 3 + .../lexical/src/nodes/LexicalElementNode.ts | 265 +++++ .../unit/LexicalElementNode.test.tsx | 88 ++ 38 files changed, 1922 insertions(+), 848 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. 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/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-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..d9362c28aed 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,28 @@ async function fillTablePartiallyWithText(page) { await page.keyboard.press('c'); } +async function assertHTML( + page, + expectedHtml, + expectedHtmlFrameRight = undefined, + options = undefined, + ...args +) { + return await rawAssertHTML( + page, + IS_TABLE_HORIZONTAL_SCROLL + ? wrapTableHtml(expectedHtml, options) + : expectedHtml, + IS_TABLE_HORIZONTAL_SCROLL && expectedHtmlFrameRight !== undefined + ? wrapTableHtml(expectedHtmlFrameRight, options) + : expectedHtmlFrameRight, + options, + ...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 +205,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 +221,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 +252,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 +269,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 +297,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 +326,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 +371,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 +407,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, { @@ -514,9 +550,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], }); }); @@ -566,57 +602,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], }); }); @@ -633,25 +669,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], }); }); @@ -669,9 +705,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`); @@ -679,9 +715,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( 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__/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 2b461f5fb05..9f43f61e5fd 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,21 @@ function wrapAndSlowDown(method, delay) { }; } +export function wrapTableHtml(expected, {ignoreClasses = false} = {}) { + return html` + ${expected + .replace( + //g, '
')} + `; +} + export async function initialize({ page, isCollab, @@ -64,6 +81,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 +94,8 @@ export async function initialize({ appSettings.isRichText = IS_RICH_TEXT; appSettings.emptyEditor = true; appSettings.disableBeforeInput = LEGACY_EVENTS; + appSettings.tableHorizontalScroll = + tableHorizontalScroll ?? IS_TABLE_HORIZONTAL_SCROLL; if (isCollab) { appSettings.isCollab = isCollab; appSettings.collabId = randomUUID(); @@ -175,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 */ @@ -191,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, @@ -338,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-playground/src/Editor.tsx b/packages/lexical-playground/src/Editor.tsx index 2c4f0419575..3fd409b5774 100644 --- a/packages/lexical-playground/src/Editor.tsx +++ b/packages/lexical-playground/src/Editor.tsx @@ -94,6 +94,7 @@ export default function Editor(): JSX.Element { shouldPreserveNewLinesInMarkdown, tableCellMerge, tableCellBackgroundColor, + tableHorizontalScroll, }, } = useSettings(); const isEditable = useLexicalEditable(); @@ -199,6 +200,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..2a126f8d0db 100644 --- a/packages/lexical-playground/src/Settings.tsx +++ b/packages/lexical-playground/src/Settings.tsx @@ -28,10 +28,11 @@ export default function Settings(): JSX.Element { isAutocomplete, showTreeView, showNestedEditorTreeView, - disableBeforeInput, + // disableBeforeInput, showTableOfContents, shouldUseLexicalContextMenu, shouldPreserveNewLinesInMarkdown, + // tableHorizontalScroll, }, } = useSettings(); useEffect(() => { @@ -132,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); @@ -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..ab489c3e668 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: true, } as const; // These are mutated in setupEnv diff --git a/packages/lexical-playground/src/index.css b/packages/lexical-playground/src/index.css index e5362290c69..31718a446af 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..43f1f8aa5d2 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, @@ -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,13 +230,15 @@ function TableActionMenu({ editor.update(() => { if (tableCellNode.isAttached()) { const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode); - const tableElement = editor.getElementByKey( - tableNode.getKey(), - ) as HTMLTableElementWithWithTableSelectionState; + const tableElement = getTableElement( + tableNode, + editor.getElementByKey(tableNode.getKey()), + ); - if (!tableElement) { - throw new Error('Expected to find tableElement in DOM'); - } + 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..92a26ff0015 100644 --- a/packages/lexical-playground/src/plugins/TableHoverActionsPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/TableHoverActionsPlugin/index.tsx @@ -9,12 +9,14 @@ import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import {useLexicalEditable} from '@lexical/react/useLexicalEditable'; import { + $getTableAndElementByKey, $getTableColumnIndexFromTableCellNode, $getTableRowIndexFromTableCellNode, $insertTableColumn__EXPERIMENTAL, $insertTableRow__EXPERIMENTAL, $isTableCellNode, $isTableNode, + getTableElement, TableCellNode, TableNode, TableRowNode, @@ -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(); @@ -163,36 +168,37 @@ function TableHoverActionsContainer({ editor.registerMutationListener( TableNode, (mutations) => { - editor.getEditorState().read(() => { - for (const [key, type] of mutations) { - const tableDOMElement = 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; + } + case 'destroyed': { + tableSetRef.current.delete(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 tableElement = editor.getElementByKey(tableKey); - if (tableElement) { - tableResizeObserver.observe(tableElement); - } - }); - break; - - default: - 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-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-react/src/LexicalTablePlugin.ts b/packages/lexical-react/src/LexicalTablePlugin.ts index e8b512eb790..a5c43d17c65 100644 --- a/packages/lexical-react/src/LexicalTablePlugin.ts +++ b/packages/lexical-react/src/LexicalTablePlugin.ts @@ -20,11 +20,13 @@ import { $createTableCellNode, $createTableNodeWithDimensions, $getNodeTriplet, + $getTableAndElementByKey, $isTableCellNode, - $isTableNode, $isTableRowNode, applyTableHandlers, + getTableElement, INSERT_TABLE_COMMAND, + setScrollableTablesActive, TableCellNode, TableNode, TableRowNode, @@ -36,24 +38,50 @@ import { } from '@lexical/utils'; import { $createParagraphNode, - $getNodeByKey, $isTextNode, COMMAND_PRIORITY_EDITOR, } from 'lexical'; 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, -}: { - hasCellMerge?: boolean; - hasCellBackgroundColor?: boolean; - hasTabHandler?: boolean; -}): JSX.Element | null { + hasHorizontalScroll = false, +}: TablePluginProps): JSX.Element | null { const [editor] = useLexicalComposerContext(); + useEffect(() => { + setScrollableTablesActive(editor, hasHorizontalScroll); + }, [editor, hasHorizontalScroll]); + useEffect(() => { if (!editor.hasNodes([TableNode, TableCellNode, TableRowNode])) { invariant( @@ -122,7 +150,7 @@ export function TablePlugin({ nodeKey: NodeKey, dom: HTMLElement, ) => { - const tableElement = dom as HTMLTableElementWithWithTableSelectionState; + const tableElement = getTableElement(tableNode, dom); const tableSelection = applyTableHandlers( tableNode, tableElement, @@ -135,34 +163,31 @@ 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); - } - if (dom !== null) { - // Create a new TableObserver - editor.getEditorState().read(() => { - const tableNode = $getNodeByKey(nodeKey); - if ($isTableNode(tableNode)) { - initializeTableNode(tableNode, nodeKey, dom); - } - }); + 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) { + 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); + } + } else if (mutation === 'destroyed') { + if (tableSelection !== undefined) { + tableSelection[0].removeListeners(); + tableSelections.delete(nodeKey); + } } } - } 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/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..838758e2e83 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,9 +13,22 @@ import { } from '@lexical/utils'; import { $applyNodeReplacement, + $getEditor, $getNearestNodeFromDOMNode, + DOMConversionMap, + DOMConversionOutput, + DOMExportOutput, + EditorConfig, + ElementDOMSlot, ElementNode, + LexicalEditor, + LexicalNode, + NodeKey, + SerializedElementNode, + setDOMUnmanaged, + Spread, } from 'lexical'; +import invariant from 'shared/invariant'; import {PIXEL_VALUE_REG_EXP} from './constants'; import {$isTableCellNode, TableCellNode} from './LexicalTableCellNode'; @@ -79,6 +80,30 @@ 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) { + 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); + } +} + /** @noInheritDoc */ export class TableNode extends ElementNode { /** @internal */ @@ -142,6 +167,19 @@ export class TableNode extends ElementNode { }; } + getDOMSlot(element: HTMLElement): ElementDOMSlot { + const tableElement = + (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')); + } + createDOM(config: EditorConfig, editor?: LexicalEditor): HTMLElement { const tableElement = document.createElement('table'); const colGroup = document.createElement('colgroup'); @@ -152,11 +190,23 @@ 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(editor)) { + const wrapperElement = document.createElement('div'); + const classes = config.theme.tableScrollableWrapper; + if (classes) { + addClassNamesToElement(wrapperElement, classes); + } else { + wrapperElement.style.cssText = 'overflow-x: auto;'; + } + wrapperElement.appendChild(tableElement); + return wrapperElement; + } return tableElement; } @@ -177,21 +227,24 @@ export class TableNode extends ElementNode { return { ...super.exportDOM(editor), after: (tableElement) => { - if (tableElement) { - const newElement = tableElement.cloneNode() as ParentNode; - const colGroup = document.createElement('colgroup'); + if ( + tableElement && + isHTMLElement(tableElement) && + tableElement.nodeName !== 'TABLE' + ) { + tableElement = tableElement.querySelector('table'); + } + 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; }, }; } @@ -344,12 +397,11 @@ export function $getElementForTableNode( tableNode: TableNode, ): TableDOMTable { const tableElement = editor.getElementByKey(tableNode.getKey()); - - if (tableElement == null) { - throw new Error('Table Element Not Found'); - } - - return getTable(tableElement); + invariant( + tableElement !== null, + '$getElementForTableNode: Table Element Not Found', + ); + 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..9d3ffbbc690 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, @@ -39,6 +40,8 @@ import { $updateDOMForSelection, getDOMSelection, getTable, + getTableElement, + HTMLTableElementWithWithTableSelectionState, } from './LexicalTableSelectionHelpers'; export type TableDOMCell = { @@ -57,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; @@ -74,6 +102,7 @@ export class TableObserver { tableSelection: TableSelection | null; hasHijackedSelectionStyles: boolean; isSelecting: boolean; + shouldCheckSelection: boolean; abortController: AbortController; listenerOptions: {signal: AbortSignal}; @@ -97,10 +126,11 @@ export class TableObserver { this.anchorCell = null; this.focusCell = null; this.hasHijackedSelectionStyles = false; - this.trackTable(); this.isSelecting = false; + this.shouldCheckSelection = false; this.abortController = new AbortController(); this.listenerOptions = {signal: this.abortController.signal}; + this.trackTable(); } getTable(): TableDOMTable { @@ -115,54 +145,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 tableElement = this.editor.getElementByKey(this.tableNodeKey); - - if (!tableElement) { - throw new Error('Expected to find TableElement in DOM'); - } - - this.table = getTable(tableElement); - }); - }); - this.editor.update(() => { - const tableElement = this.editor.getElementByKey(this.tableNodeKey); - if (!tableElement) { - throw new Error('Expected to find TableElement in DOM'); - } + if (!gridNeedsRedraw) { + return; + } - this.table = getTable(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() { @@ -182,19 +215,8 @@ export class TableObserver { this.enableHighlightStyle(); 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'); - } - - const grid = getTable(tableElement); + const {tableNode, tableElement} = this.$lookup(); + const grid = getTable(tableNode, tableElement); $updateDOMForSelection(editor, grid, null); $setSelection(null); editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined); @@ -203,34 +225,34 @@ 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 {tableElement} = this.$lookup(); + + 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 {tableElement} = this.$lookup(); + addClassNamesToElement( + tableElement, + editor._config.theme.tableSelection, + ); + this.hasHijackedSelectionStyles = true; + }, + {editor}, + ); } updateTableTableSelection(selection: TableSelection | null): void { @@ -248,20 +270,32 @@ 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 + * use this property to "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(() => { - 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'); - } + 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 f85709749b4..f629d1feb0d 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,11 @@ import {CAN_USE_DOM} from 'shared/canUseDOM'; import invariant from 'shared/invariant'; import {$isTableCellNode} from './LexicalTableCellNode'; -import {$isTableNode} from './LexicalTableNode'; +import { + $isScrollableTablesActive, + $isTableNode, + TableNode, +} from './LexicalTableNode'; import {TableDOMTable, TableObserver} from './LexicalTableObserver'; import {$isTableRowNode} from './LexicalTableRowNode'; import {$isTableSelection} from './LexicalTableSelection'; @@ -85,9 +88,27 @@ const isMouseDownOnEvent = (event: MouseEvent) => { return (event.buttons & 1) === 1; }; +export function getTableElement( + tableNode: TableNode, + dom: T, +): HTMLTableElementWithWithTableSelectionState | (T & null) { + if (!dom) { + return dom as T & null; + } + const element = ( + dom.nodeName === 'TABLE' ? dom : tableNode.getDOMSlot(dom).element + ) as HTMLTableElementWithWithTableSelectionState; + invariant( + element.nodeName === 'TABLE', + 'getTableElement: Expecting table in as DOM node for TableNode, not %s', + dom.nodeName, + ); + return element; +} + export function applyTableHandlers( tableNode: TableNode, - tableElement: HTMLTableElementWithWithTableSelectionState, + element: HTMLElement, editor: LexicalEditor, hasTabHandler: boolean, ): TableObserver { @@ -100,9 +121,10 @@ 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), + detatchTableObserverFromTableElement(tableElement, tableObserver), ); const createMouseHandlers = () => { @@ -744,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; @@ -944,7 +989,7 @@ export type HTMLTableElementWithWithTableSelectionState = HTMLTableElement & { [LEXICAL_ELEMENT_KEY]?: TableObserver | undefined; }; -export function deatatchTableObserverFromTableElement( +export function detatchTableObserverFromTableElement( tableElement: HTMLTableElementWithWithTableSelectionState, tableObserver: TableObserver, ) { @@ -1006,7 +1051,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, @@ -1538,6 +1587,10 @@ function $handleArrowKey( } } } + if (direction === 'down' && $isScrollableTablesActive(editor)) { + // Enable Firefox workaround + tableObserver.setShouldCheckSelection(); + } return false; } @@ -1559,11 +1612,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, @@ -1675,8 +1729,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) || @@ -1688,7 +1747,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, @@ -1882,14 +1941,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; } diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx b/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx index 570ac0e9ed2..df23bdcf843 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'; @@ -68,386 +71,612 @@ const editorConfig = Object.freeze({ theme: { table: 'test-table-class', tableRowStriping: 'test-table-row-striping-class', + tableScrollableWrapper: 'table-scrollable-wrapper', }, }); -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); + }); + }); + }, + {theme: editorConfig.theme}, + , + ); + }); + }); }); diff --git a/packages/lexical-table/src/index.ts b/packages/lexical-table/src/index.ts index 2429eb608a9..be452681b98 100644 --- a/packages/lexical-table/src/index.ts +++ b/packages/lexical-table/src/index.ts @@ -22,11 +22,13 @@ export type {SerializedTableNode} from './LexicalTableNode'; export { $createTableNode, $getElementForTableNode, + $isScrollableTablesActive, $isTableNode, + setScrollableTablesActive, TableNode, } from './LexicalTableNode'; export type {TableDOMCell} from './LexicalTableObserver'; -export {TableObserver} from './LexicalTableObserver'; +export {$getTableAndElementByKey, TableObserver} from './LexicalTableObserver'; export type {SerializedTableRowNode} from './LexicalTableRowNode'; export { $createTableRowNode, @@ -49,6 +51,7 @@ export { $findTableNode, applyTableHandlers, getDOMCellFromTarget, + getTableElement, getTableObserverFromTableElement, } from './LexicalTableSelectionHelpers'; export { 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 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 */ 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; diff --git a/packages/lexical/src/LexicalMutations.ts b/packages/lexical/src/LexicalMutations.ts index 15e4e510d39..fa58ebde193 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'; @@ -15,7 +17,6 @@ import {IS_FIREFOX} from 'shared/environment'; import { $getSelection, $isDecoratorNode, - $isElementNode, $isRangeSelection, $isTextNode, $setSelection, @@ -23,12 +24,15 @@ import { import {DOM_TEXT_TYPE} from './LexicalConstants'; import {updateEditor} from './LexicalUpdates'; import { - $getNearestNodeFromDOMNode, + $getNodeByKey, $getNodeFromDOMNode, $updateTextNodeFromDOMContent, getDOMSelection, + getNodeKeyFromDOMNode, + getParentElement, getWindow, internalGetRoot, + isDOMUnmanaged, isFirefoxClipboardEvents, } from './LexicalUtils'; // The time between a text entry event and the mutation observer firing. @@ -53,14 +57,16 @@ function initTextEntryListener(editor: LexicalEditor): void { function isManagedLineBreak( dom: Node, - target: Node, + target: Node & LexicalPrivateDOM, editor: LexicalEditor, ): boolean { + const isBR = dom.nodeName === 'BR'; + const lexicalLineBreak = target.__lexicalLineBreak; return ( - // @ts-expect-error: internal field - target.__lexicalLineBreak === dom || - // @ts-ignore We intentionally add this to the Node. - dom[`__lexicalKey_${editor._key}`] !== undefined + (lexicalLineBreak && + (dom === lexicalLineBreak || + (isBR && dom.previousSibling === lexicalLineBreak))) || + (isBR && getNodeKeyFromDOMNode(dom, editor) !== undefined) ); } @@ -108,6 +114,30 @@ function shouldUpdateTextNodeFromMutation( return targetDOM.nodeType === DOM_TEXT_TYPE && targetNode.isAttached(); } +function $getNearestManagedNodePairFromDOMNode( + startingDOM: Node, + editor: LexicalEditor, + editorState: EditorState, + 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) { + // All decorator nodes are unmanaged + return $isDecoratorNode(node) ? undefined : [dom as HTMLElement, node]; + } + } else if (dom === rootElement) { + return [rootElement, internalGetRoot(editorState)]; + } + } +} + export function $flushMutations( editor: LexicalEditor, mutations: Array, @@ -120,7 +150,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,17 +163,16 @@ export function $flushMutations( const mutation = mutations[i]; const type = mutation.type; const targetDOM = mutation.target; - let targetNode = $getNearestNodeFromDOMNode( + const pair = $getNearestManagedNodePairFromDOMNode( targetDOM, + editor, currentEditorState, + rootElement, ); - - 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 @@ -176,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 = @@ -202,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); @@ -212,11 +239,7 @@ export function $flushMutations( } if (removedDOMsLength !== unremovedBRs) { - if (targetDOM === rootElement) { - targetNode = internalGetRoot(currentEditorState); - } - - badDOMTargets.set(targetDOM, targetNode); + badDOMTargets.set(nodeDOM, targetNode); } } } @@ -227,31 +250,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(); - let currentDOM = targetDOM.firstChild; - - for (let s = 0; s < childKeys.length; s++) { - const key = childKeys[s]; - const correctDOM = editor.getElementByKey(key); - - if (correctDOM === null) { - continue; - } - - if (currentDOM == null) { - targetDOM.appendChild(correctDOM); - currentDOM = correctDOM; - } else if (currentDOM !== correctDOM) { - targetDOM.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 564989cdc2e..0aa1d1ca487 100644 --- a/packages/lexical/src/LexicalNode.ts +++ b/packages/lexical/src/LexicalNode.ts @@ -56,6 +56,15 @@ export type SerializedLexicalNode = { version: number; }; +/** @internal */ +export interface LexicalPrivateDOM { + __lexicalTextContent?: string | undefined | null; + __lexicalLineBreak?: HTMLBRElement | HTMLImageElement | undefined | null; + __lexicalDirTextContent?: string | undefined | null; + __lexicalDir?: 'ltr' | 'rtl' | null | undefined; + __lexicalUnmanaged?: boolean | undefined; +} + export function $removeNode( nodeToRemove: LexicalNode, restoreSelection: boolean, @@ -1160,6 +1169,16 @@ export class LexicalNode { markDirty(): void { this.getWritable(); } + + /** + * @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(); + } } function errorOnTypeKlassMismatch( diff --git a/packages/lexical/src/LexicalReconciler.ts b/packages/lexical/src/LexicalReconciler.ts index 0ad9cf2c911..6fa946f61d6 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,7 @@ function setElementFormat(dom: HTMLElement, format: number): void { } } -function $createNode( - key: NodeKey, - parentDOM: null | HTMLElement, - insertDOM: null | Node, -): HTMLElement { +function $createNode(key: NodeKey, slot: ElementDOMSlot | null): HTMLElement { const node = activeNextNodeMap.get(key); if (node === undefined) { @@ -231,19 +228,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 +255,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,67 +286,49 @@ 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; } +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
function reconcileElementTerminatingLineBreak( prevElement: null | ElementNode, nextElement: ElementNode, - dom: HTMLElement, + 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, - ); - - 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; - } - } else if (nextLineBreak) { - const element = document.createElement('br'); - // @ts-expect-error: internal field - dom.__lexicalLineBreak = element; - dom.appendChild(element); + const prevLineBreak = isLastChildLineBreakOrDecorator( + prevElement, + activePrevNodeMap, + ); + const nextLineBreak = isLastChildLineBreakOrDecorator( + nextElement, + activeNextNodeMap, + ); + if (prevLineBreak !== nextLineBreak) { + nextElement.getDOMSlot(dom).setManagedLineBreak(nextLineBreak); } } @@ -388,12 +355,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 +422,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 +436,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 +463,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 +487,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 +497,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(); @@ -542,6 +509,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) { @@ -550,15 +525,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 +554,7 @@ function $reconcileChildren( nextChildren, prevChildrenSize, nextChildrenSize, - dom, + slot, ); } } @@ -587,7 +563,6 @@ function $reconcileChildren( subTreeTextContent += DOUBLE_LINE_BREAK; } - // @ts-expect-error: internal field dom.__lexicalTextContent = subTreeTextContent; subTreeTextContent = previousSubTreeTextContent + subTreeTextContent; } @@ -610,14 +585,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 +602,6 @@ function $reconcileNode( editorTextContent += previousSubTreeTextContent; } - // @ts-expect-error: internal field const previousSubTreeDirectionTextContent = dom.__lexicalDirTextContent; if (previousSubTreeDirectionTextContent !== undefined) { @@ -658,7 +634,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 +721,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 +738,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 +753,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 +771,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 +818,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 +889,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/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index 3af4b30d0db..fb7a62dbea0 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -2109,10 +2109,29 @@ 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', + ); + if ( + moveSelectionToEnd && + resolvedOffset >= resolvedElement.getChildrenSize() + ) { + resolvedOffset = Math.max(0, resolvedElement.getChildrenSize() - 1); + } let child = resolvedElement.getChildAtIndex(resolvedOffset); if ( $isElementNode(child) && @@ -2140,7 +2159,11 @@ function $internalResolveSelectionPoint( moveSelectionToEnd && !hasBlockCursor ) { - resolvedOffset++; + invariant($isElementNode(resolvedElement), 'invariant'); + resolvedOffset = Math.min( + resolvedElement.getChildrenSize(), + resolvedOffset + 1, + ); } } else { const index = resolvedElement.getIndexWithinParent(); @@ -2297,6 +2320,9 @@ function $internalResolveSelectionPoints( if (resolvedAnchorPoint === null) { return null; } + if (__DEV__) { + $validatePoint(editor, 'anchor', resolvedAnchorPoint); + } const resolvedFocusPoint = $internalResolveSelectionPoint( focusDOM, focusOffset, @@ -2306,6 +2332,9 @@ function $internalResolveSelectionPoints( if (resolvedFocusPoint === null) { return null; } + if (__DEV__) { + $validatePoint(editor, 'focus', resolvedAnchorPoint); + } if ( resolvedAnchorPoint.type === 'element' && resolvedFocusPoint.type === 'element' @@ -2475,6 +2504,51 @@ export function $internalCreateRangeSelection( ); } +function $validatePoint( + editor: LexicalEditor, + name: 'anchor' | 'focus', + point: PointType, +): void { + const node = $getNodeByKey(point.key); + invariant( + node !== undefined, + '$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', + 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/LexicalUtils.ts b/packages/lexical/src/LexicalUtils.ts index b1a409a9f36..a4a3af63118 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, @@ -441,14 +446,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 +558,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 +576,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; } @@ -1562,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); } } } @@ -1842,3 +1860,24 @@ export function setNodeIndentFromDOM( const indent = indentSize / 40; elementNode.setIndent(indent); } + +/** + * @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; +} + +/** + * @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; +} 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 { diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index d5c94e23453..2898a73a9b7 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -58,6 +58,7 @@ export type { TextPointType as TextPoint, } from './LexicalSelection'; export type { + ElementDOMSlot, ElementFormatType, SerializedElementNode, } from './nodes/LexicalElementNode'; @@ -182,6 +183,7 @@ export { getNearestEditorFromDOMNode, isBlockDomNode, isDocumentFragment, + isDOMUnmanaged, isHTMLAnchorElement, isHTMLElement, isInlineDomNode, @@ -189,6 +191,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 65bc77aec5a..285a546545a 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'; @@ -18,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'; @@ -68,6 +70,225 @@ 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 before = this.before || this.getManagedLineBreak(); + invariant( + before === null || before.parentElement === this.element, + 'ElementDOMSlot.insertChild: before is not in element', + ); + 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, + 'ElementDOMSlot.removeChild: dom is not in element', + ); + 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, + 'ElementDOMSlot.replaceChild: prevDom is not in element', + ); + 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 || + firstChild === this.getManagedLineBreak() + ? null + : firstChild; + } + /** + * @internal + */ + getManagedLineBreak(): Exclude< + LexicalPrivateDOM['__lexicalLineBreak'], + undefined + > { + 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(); + 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(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'); + element.insertBefore(br, before); + 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; + } + } + + /** + * @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 */ // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export class ElementNode extends LexicalNode { @@ -406,6 +627,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 = []; @@ -528,6 +756,17 @@ export class ElementNode extends LexicalNode { return writableSelf; } + /** + * @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 new ElementDOMSlot(element); + } exportDOM(editor: LexicalEditor): DOMExportOutput { const {element} = super.exportDOM(editor); if (element && isHTMLElement(element)) { @@ -633,6 +872,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( diff --git a/packages/lexical/src/nodes/__tests__/unit/LexicalElementNode.test.tsx b/packages/lexical/src/nodes/__tests__/unit/LexicalElementNode.test.tsx index 21e9ed3c899..6736ce72edc 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 super.getDOMSlot(dom).withElement(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(`

`); + }); +});