From 4f0e5a142c2f9772160568ae21c00c00946e6482 Mon Sep 17 00:00:00 2001 From: kirandash Date: Sun, 9 Mar 2025 19:55:09 +0800 Subject: [PATCH] fix: layout deletion behavior - Added function in LayoutPlugin to handle layout deletion for all delete operations (character, word, line) - Added command registrations for DELETE_CHARACTER_COMMAND, DELETE_WORD_COMMAND, and DELETE_LINE_COMMAND - Added new e2e tests in Layout.spec.mjs: - Layout deletion when it's the first node - Layout deletion with surrounding content - Testing different delete operations (character, word, line) Fixes #6938 --- .../__tests__/e2e/Layout.spec.mjs | 352 ++++++++++++++++++ .../__tests__/utils/index.mjs | 15 + .../src/plugins/LayoutPlugin/LayoutPlugin.tsx | 82 +++- 3 files changed, 448 insertions(+), 1 deletion(-) create mode 100644 packages/lexical-playground/__tests__/e2e/Layout.spec.mjs diff --git a/packages/lexical-playground/__tests__/e2e/Layout.spec.mjs b/packages/lexical-playground/__tests__/e2e/Layout.spec.mjs new file mode 100644 index 00000000000..b08e81d90ba --- /dev/null +++ b/packages/lexical-playground/__tests__/e2e/Layout.spec.mjs @@ -0,0 +1,352 @@ +/** + * 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 {selectAll} from '../keyboardShortcuts/index.mjs'; +import { + assertHTML, + click, + focusEditor, + html, + initialize, + insertLayoutInFirstCell, + test, +} from '../utils/index.mjs'; + +test.describe('Layout', () => { + test.beforeEach(({isCollab, page}) => initialize({isCollab, page})); + + test('Can delete layout with Backspace', async ({page, isPlainText}) => { + test.skip(isPlainText); + + await focusEditor(page); + await insertLayoutInFirstCell(page, '1fr 1fr'); + await page.keyboard.type('Left column'); + await click(page, '.PlaygroundEditorTheme__layoutItem:nth-child(2)'); + await page.keyboard.type('Right column'); + await assertHTML( + page, + html` +


+
+
+

+ Left column +

+
+
+

+ Right column +

+
+
+


+ `, + ); + + await selectAll(page); + await page.keyboard.press('Backspace'); + await assertHTML( + page, + html` +


+ `, + ); + }); + + test('Can delete layout with Delete', async ({page, isPlainText}) => { + test.skip(isPlainText); + + await focusEditor(page); + await insertLayoutInFirstCell(page, '1fr 1fr'); + await page.keyboard.type('Left column'); + await click(page, '.PlaygroundEditorTheme__layoutItem:nth-child(2)'); + await page.keyboard.type('Right column'); + await assertHTML( + page, + html` +


+
+
+

+ Left column +

+
+
+

+ Right column +

+
+
+


+ `, + ); + + await selectAll(page); + await page.keyboard.press('Delete'); + await assertHTML( + page, + html` +


+ `, + ); + }); + + test('Can delete layout with word delete', async ({page, isPlainText}) => { + test.skip(isPlainText); + + await focusEditor(page); + await insertLayoutInFirstCell(page, '1fr 1fr'); + await page.keyboard.type('Left column'); + await click(page, '.PlaygroundEditorTheme__layoutItem:nth-child(2)'); + await page.keyboard.type('Right column'); + await assertHTML( + page, + html` +


+
+
+

+ Left column +

+
+
+

+ Right column +

+
+
+


+ `, + ); + + await selectAll(page); + // Ctrl+Backspace for word delete + await page.keyboard.down('Control'); + await page.keyboard.press('Backspace'); + await page.keyboard.up('Control'); + await assertHTML( + page, + html` +


+ `, + ); + }); + + test('Can delete layout with line delete', async ({page, isPlainText}) => { + test.skip(isPlainText); + + await focusEditor(page); + await insertLayoutInFirstCell(page, '1fr 1fr'); + await page.keyboard.type('Left column'); + await click(page, '.PlaygroundEditorTheme__layoutItem:nth-child(2)'); + await page.keyboard.type('Right column'); + await assertHTML( + page, + html` +


+
+
+

+ Left column +

+
+
+

+ Right column +

+
+
+


+ `, + ); + + await selectAll(page); + // Cmd+Backspace for line delete + await page.keyboard.down('Meta'); + await page.keyboard.press('Backspace'); + await page.keyboard.up('Meta'); + await assertHTML( + page, + html` +


+ `, + ); + }); + + // Reference: https://github.com/facebook/lexical/issues/6938 + test('Can delete layout when it is the first node', async ({ + page, + isPlainText, + }) => { + test.skip(isPlainText); + + await focusEditor(page); + await insertLayoutInFirstCell(page, '1fr 1fr'); + + // Remove the paragraph before the layout to make layout the first node + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('Backspace'); + + await page.keyboard.type('Left column'); + await click(page, '.PlaygroundEditorTheme__layoutItem:nth-child(2)'); + await page.keyboard.type('Right column'); + + await assertHTML( + page, + html` +
+
+

+ Left column +

+
+
+

+ Right column +

+
+
+


+ `, + ); + + await selectAll(page); + await page.keyboard.press('Delete'); + + await assertHTML( + page, + html` +


+


+ `, + ); + }); + + test('Can delete layout with surrounding content', async ({ + page, + isPlainText, + }) => { + test.skip(isPlainText); + + await focusEditor(page); + // Add content before layout + await page.keyboard.type('Content before'); + await page.keyboard.press('Enter'); + + // Insert and fill layout + await insertLayoutInFirstCell(page, '1fr 1fr'); + await page.keyboard.type('Left column'); + await click(page, '.PlaygroundEditorTheme__layoutItem:nth-child(2)'); + await page.keyboard.type('Right column'); + + // Add content after layout + await page.keyboard.press('ArrowDown'); + await page.keyboard.type('Content after'); + + await assertHTML( + page, + html` +

+ Content before +

+


+
+
+

+ Left column +

+
+
+

+ Right column +

+
+
+

+ Content after +

+ `, + ); + + // Click second column before select all + await click(page, '.PlaygroundEditorTheme__layoutItem:nth-child(2)'); + await selectAll(page); + await page.keyboard.press('Delete'); + + await assertHTML( + page, + html` +


+ `, + ); + }); +}); diff --git a/packages/lexical-playground/__tests__/utils/index.mjs b/packages/lexical-playground/__tests__/utils/index.mjs index deb209bfda9..d06c65d9a46 100644 --- a/packages/lexical-playground/__tests__/utils/index.mjs +++ b/packages/lexical-playground/__tests__/utils/index.mjs @@ -1104,3 +1104,18 @@ export function createHumanReadableSelection(_overview, dto) { focusPath: dto.focusPath.map((p) => p.value), }; } + +export async function insertLayoutInFirstCell(page, template) { + await selectFromInsertDropdown(page, '.item .columns'); + await click(page, '.toolbar-item.dialog-dropdown'); + const layoutLabels = { + '1fr 1fr': '2 columns (equal width)', + '1fr 1fr 1fr': '3 columns (equal width)', + '1fr 1fr 1fr 1fr': '4 columns (equal width)', + '1fr 2fr 1fr': '3 columns (25% - 50% - 25%)', + '1fr 3fr': '2 columns (25% - 75%)', + }; + const label = layoutLabels[template]; + await click(page, `.item span.text:has-text("${label}")`); + await click(page, '.Button__root:has-text("Insert")'); +} diff --git a/packages/lexical-playground/src/plugins/LayoutPlugin/LayoutPlugin.tsx b/packages/lexical-playground/src/plugins/LayoutPlugin/LayoutPlugin.tsx index dd226849e62..37060a9565a 100644 --- a/packages/lexical-playground/src/plugins/LayoutPlugin/LayoutPlugin.tsx +++ b/packages/lexical-playground/src/plugins/LayoutPlugin/LayoutPlugin.tsx @@ -6,7 +6,13 @@ * */ -import type {ElementNode, LexicalCommand, LexicalNode, NodeKey} from 'lexical'; +import type { + ElementNode, + LexicalCommand, + LexicalNode, + NodeKey, + RangeSelection, +} from 'lexical'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import { @@ -22,6 +28,9 @@ import { COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_LOW, createCommand, + DELETE_CHARACTER_COMMAND, + DELETE_LINE_COMMAND, + DELETE_WORD_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_RIGHT_COMMAND, @@ -116,6 +125,44 @@ export function LayoutPlugin(): null { return false; }; + // Helper function to handle deletion of layout container + const $handleLayoutDelete = (selection: RangeSelection): boolean => { + const anchor = selection.anchor; + const focus = selection.focus; + const anchorNode = anchor.getNode(); + + // Find if we're inside a layout container + const layoutContainer = $findMatchingParent( + anchorNode, + $isLayoutContainerNode, + ); + + if (!layoutContainer) { + return false; + } + + // Check if the selection contains or is within the layout container + const isWithinContainer = + (anchor.key === layoutContainer.getKey() && + focus.key === layoutContainer.getKey()) || + layoutContainer.isSelected() || + (layoutContainer.getDescendantByIndex(0)?.isSelected() && + layoutContainer + .getDescendantByIndex(layoutContainer.getChildrenSize() - 1) + ?.isSelected()); + + if (isWithinContainer) { + // Delete the entire container and replace with an empty paragraph + const paragraph = $createParagraphNode(); + layoutContainer.insertAfter(paragraph); + layoutContainer.remove(); + paragraph.select(); + return true; + } + + return false; + }; + return mergeRegister( // When layout is the last child pressing down/right arrow will insert paragraph // below it to allow adding more content. It's similar what $insertBlockNode @@ -227,6 +274,39 @@ export function LayoutPlugin(): null { node.remove(); } }), + editor.registerCommand( + DELETE_CHARACTER_COMMAND, + (payload) => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return false; + } + return $handleLayoutDelete(selection); + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + DELETE_WORD_COMMAND, + (payload) => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return false; + } + return $handleLayoutDelete(selection); + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + DELETE_LINE_COMMAND, + (payload) => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return false; + } + return $handleLayoutDelete(selection); + }, + COMMAND_PRIORITY_LOW, + ), ); }, [editor]);