Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/leave-a-cut-whose-range-has-no-end.md
Original file line number Diff line number Diff line change
@@ -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
Comment thread
therealemmanueloppong marked this conversation as resolved.
Resolved

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.
8 changes: 8 additions & 0 deletions packages/lexical-table/src/LexicalTableSelectionHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
202 changes: 202 additions & 0 deletions packages/lexical-table/src/__tests__/unit/CutOutsideTable.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Comment thread
therealemmanueloppong marked this conversation as resolved.
Resolved
});

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<string>[] = [];
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']),
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: '',
Expand Down Expand Up @@ -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(
() => {
Expand Down Expand Up @@ -888,12 +892,16 @@ describe('LexicalTableNode tests', () => {
{discrete: true},
);

expectHtmlToBeEqual(
testEnv.innerHTML,
html`
<p dir="auto"><br data-lexical-managed-linebreak="true" /></p>
`,
// Rich text removes the range after its copy to the clipboard resolves
await vi.waitFor(() =>
expectHtmlToBeEqual(
testEnv.innerHTML,
html`
<p dir="auto"><br data-lexical-managed-linebreak="true" /></p>
`,
),
);
unregisterRichText();
});

test('Cut table as last node in range selection ', () => {
Expand Down