diff --git a/.changeset/leave-a-cut-whose-range-has-no-end.md b/.changeset/leave-a-cut-whose-range-has-no-end.md new file mode 100644 index 00000000000..5308fee1e45 --- /dev/null +++ b/.changeset/leave-a-cut-whose-range-has-no-end.md @@ -0,0 +1,7 @@ +--- +'@lexical/table': patch +--- + +[fix] Leave a cut whose range has no end in a table to the editor's own cut and to other CUT_COMMAND listeners, instead of the table answering every range selection once the document holds a table + +Verify: in a document that holds a table, cut a range with neither end in the table (for example Select All over a heading, a table and a paragraph) and check that `CUT_COMMAND` listeners below `COMMAND_PRIORITY_HIGH` run, that the removal arrives in an update tagged `CUT_TAG` once the clipboard write resolves, and that an empty paragraph rather than an empty heading is left. In a document with two tables, cutting from a cell of the second table to the text after it removes that table whole. diff --git a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts index b2233c7362e..d17c714fd7f 100644 --- a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts +++ b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts @@ -967,6 +967,14 @@ export function applyTableHandlers( if (!($isTableSelection(selection) || $isRangeSelection(selection))) { return false; } + // A range with no end in this table is not the table's to cut + if ( + $isRangeSelection(selection) && + !tableNode.isParentOf(selection.anchor.getNode()) && + !tableNode.isParentOf(selection.focus.getNode()) + ) { + return false; + } // Copying to the clipboard is async so we must capture the data // before we delete it void copyToClipboard( diff --git a/packages/lexical-table/src/__tests__/unit/CutOutsideTable.test.ts b/packages/lexical-table/src/__tests__/unit/CutOutsideTable.test.ts new file mode 100644 index 00000000000..bb2acdd90e1 --- /dev/null +++ b/packages/lexical-table/src/__tests__/unit/CutOutsideTable.test.ts @@ -0,0 +1,202 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {buildEditorFromExtensions} from '@lexical/extension'; +import {$createHeadingNode, RichTextExtension} from '@lexical/rich-text'; +import { + $computeTableMapSkipCellCheck, + $createTableNodeWithDimensions, + $isTableNode, + TableExtension, +} from '@lexical/table'; +import { + $createParagraphNode, + $createRangeSelection, + $createTextNode, + $getRoot, + $isElementNode, + $selectAll, + $setSelection, + COMMAND_PRIORITY_LOW, + CUT_COMMAND, + CUT_TAG, + defineExtension, + type LexicalEditor, +} from 'lexical'; +import {$assertNodeType} from 'lexical/src/__tests__/utils'; +import {describe, expect, onTestFinished, test, vi} from 'vitest'; + +const TableTestThemeExtension = defineExtension({ + dependencies: [TableExtension], + name: 'CutOutsideTableTestThemeExtension', + theme: {tableScrollableWrapper: ''}, +}); + +function setup() { + const editor = buildEditorFromExtensions(TableTestThemeExtension); + onTestFinished(() => editor[Symbol.dispose]()); + const root = document.createElement('div'); + document.body.append(root); + onTestFinished(() => root.remove()); + editor.setRootElement(root); + let lowPriorityCuts = 0; + editor.registerCommand( + CUT_COMMAND, + () => { + lowPriorityCuts++; + return false; + }, + COMMAND_PRIORITY_LOW, + ); + editor.update( + () => { + $getRoot() + .clear() + .append( + $createParagraphNode().append($createTextNode('before')), + $createTableNodeWithDimensions(1, 2, false), + $createParagraphNode().append($createTextNode('x y')), + ); + }, + {discrete: true}, + ); + return {editor, getLowPriorityCuts: () => lowPriorityCuts}; +} + +function cut(editor: LexicalEditor, select: () => void) { + editor.update(select, {discrete: true}); + editor.dispatchCommand(CUT_COMMAND, new ClipboardEvent('cut')); +} + +describe('CUT_COMMAND in a document that holds a table', () => { + test('a range outside the table is left to the listeners below the table', () => { + const {editor, getLowPriorityCuts} = setup(); + cut(editor, () => { + const last = $assertNodeType($getRoot().getLastChild(), $isElementNode); + const text = last.getFirstChildOrThrow(); + const selection = $createRangeSelection(); + selection.anchor.set(text.getKey(), 0, 'text'); + selection.focus.set(text.getKey(), 1, 'text'); + $setSelection(selection); + }); + expect(getLowPriorityCuts()).toBe(1); + }); + + test('a range in a cell is answered by the table', () => { + const {editor, getLowPriorityCuts} = setup(); + cut(editor, () => { + const table = $assertNodeType( + $getRoot().getChildAtIndex(1), + $isTableNode, + ); + const [map] = $computeTableMapSkipCellCheck(table, null, null); + const paragraph = $assertNodeType( + map[0][0].cell.getFirstChild(), + $isElementNode, + ); + paragraph.append($createTextNode('abc')); + const text = paragraph.getFirstChildOrThrow(); + const selection = $createRangeSelection(); + selection.anchor.set(text.getKey(), 0, 'text'); + selection.focus.set(text.getKey(), 2, 'text'); + $setSelection(selection); + }); + expect(getLowPriorityCuts()).toBe(0); + }); +}); + +describe('CUT_COMMAND with rich text in a document that holds tables', () => { + function setupRichText() { + const editor = buildEditorFromExtensions( + defineExtension({ + dependencies: [RichTextExtension, TableTestThemeExtension], + name: 'CutOutsideTableRichText', + }), + ); + onTestFinished(() => editor[Symbol.dispose]()); + const root = document.createElement('div'); + document.body.append(root); + onTestFinished(() => root.remove()); + editor.setRootElement(root); + const tags: ReadonlySet[] = []; + editor.registerUpdateListener(({tags: updateTags}) => { + tags.push(updateTags); + }); + return {editor, tags}; + } + + const rootTypes = (editor: LexicalEditor) => + editor.read(() => + $getRoot() + .getChildren() + .map(node => node.getType()), + ); + + test('a whole-document cut leaves one paragraph, in updates tagged CUT_TAG', async () => { + const {editor, tags} = setupRichText(); + editor.update( + () => { + $getRoot() + .clear() + .append( + $createHeadingNode('h1').append($createTextNode('Title')), + $createTableNodeWithDimensions(1, 2, false), + $createParagraphNode().append($createTextNode('x y')), + ); + $selectAll(); + }, + {discrete: true}, + ); + editor.dispatchCommand(CUT_COMMAND, new ClipboardEvent('cut')); + await vi.waitFor(() => expect(rootTypes(editor)).toEqual(['paragraph'])); + expect(tags.some(updateTags => updateTags.has(CUT_TAG))).toBe(true); + }); + + test('the table a range ends in cuts it whole when the document holds another table', async () => { + const {editor} = setupRichText(); + editor.update( + () => { + $getRoot() + .clear() + .append( + $createTableNodeWithDimensions(1, 1, false), + $createTableNodeWithDimensions(2, 2, false), + $createParagraphNode().append($createTextNode('tail')), + ); + const second = $assertNodeType( + $getRoot().getChildAtIndex(1), + $isTableNode, + ); + const [map] = $computeTableMapSkipCellCheck(second, null, null); + const paragraph = $assertNodeType( + map[0][0].cell.getFirstChild(), + $isElementNode, + ); + paragraph.append($createTextNode('cell')); + const tailParagraph = $assertNodeType( + $getRoot().getLastChild(), + $isElementNode, + ); + const tail = tailParagraph.getFirstChildOrThrow(); + const selection = $createRangeSelection(); + selection.anchor.set( + paragraph.getFirstChildOrThrow().getKey(), + 1, + 'text', + ); + selection.focus.set(tail.getKey(), 2, 'text'); + $setSelection(selection); + }, + {discrete: true}, + ); + editor.dispatchCommand(CUT_COMMAND, new ClipboardEvent('cut')); + await vi.waitFor(() => + expect(rootTypes(editor)).toEqual(['table', 'paragraph']), + ); + }); +}); diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx b/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx index 6a331d8d2b2..8a384b3b2d4 100644 --- a/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx +++ b/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx @@ -11,6 +11,7 @@ import type {TableNode} from '@lexical/table'; import {$insertDataTransferForRichText} from '@lexical/clipboard'; import {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html'; import {TablePlugin} from '@lexical/react/LexicalTablePlugin'; +import {registerRichText} from '@lexical/rich-text'; import { $createTableCellNode, $createTableNode, @@ -46,7 +47,7 @@ import { polyfillContentEditable, } from 'lexical/src/__tests__/utils'; import {act, useState} from 'react'; -import {assert, beforeEach, describe, expect, test} from 'vitest'; +import {assert, beforeEach, describe, expect, test, vi} from 'vitest'; const editorConfig = Object.freeze({ namespace: '', @@ -854,8 +855,11 @@ describe('LexicalTableNode tests', () => { ); }, 15000); - test('Cut table in the middle of a range selection', () => { + test('Cut table in the middle of a range selection', async () => { const {editor} = testEnv; + // A range with both ends outside the table is cut by the editor's + // own cut handler, not by the table + const unregisterRichText = registerRichText(editor); editor.update( () => { @@ -888,12 +892,16 @@ describe('LexicalTableNode tests', () => { {discrete: true}, ); - expectHtmlToBeEqual( - testEnv.innerHTML, - html` -


- `, + // Rich text removes the range after its copy to the clipboard resolves + await vi.waitFor(() => + expectHtmlToBeEqual( + testEnv.innerHTML, + html` +


+ `, + ), ); + unregisterRichText(); }); test('Cut table as last node in range selection ', () => {