diff --git a/packages/lexical-rich-text/src/__tests__/browser/RichTextNodeSelectionArrow.test.ts b/packages/lexical-rich-text/src/__tests__/browser/RichTextNodeSelectionArrow.test.ts new file mode 100644 index 00000000000..f68b67c8ea5 --- /dev/null +++ b/packages/lexical-rich-text/src/__tests__/browser/RichTextNodeSelectionArrow.test.ts @@ -0,0 +1,257 @@ +/** + * 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 {RichTextExtension} from '@lexical/rich-text'; +import { + $createNodeSelection, + $createParagraphNode, + $createTextNode, + $getRoot, + $getSelection, + $isRangeSelection, + $setSelection, + DecoratorNode, + type LexicalEditor, + type LexicalNode, + type Point, +} from 'lexical'; +import {assert, describe, expect, onTestFinished, test} from 'vitest'; +import {userEvent} from 'vitest/browser'; + +class TestBlockDecoratorNode extends DecoratorNode { + $config() { + return this.config('test_block_decorator', {extends: DecoratorNode}); + } + + createDOM(): HTMLElement { + return document.createElement('div'); + } + + updateDOM(): false { + return false; + } + + decorate(): null { + return null; + } + + isInline(): false { + return false; + } +} + +function $createTestBlockDecoratorNode(): TestBlockDecoratorNode { + return new TestBlockDecoratorNode(); +} + +function mountEditor({ + adjacentElement = false, + range, + selectedIndex = 0, +}: { + adjacentElement?: boolean; + /** Seed a RangeSelection over these root offsets instead of a NodeSelection */ + range?: [anchor: number, focus: number]; + selectedIndex?: number; +} = {}) { + const rootElement = document.createElement('div'); + rootElement.contentEditable = 'true'; + document.body.appendChild(rootElement); + const editor = buildEditorFromExtensions({ + $initialEditorState: () => { + const nodes = [$createTestBlockDecoratorNode()]; + if (!adjacentElement) { + nodes.push($createTestBlockDecoratorNode()); + } + $getRoot() + .clear() + .append( + ...nodes, + $createParagraphNode().append($createTextNode('text')), + ); + }, + dependencies: [RichTextExtension], + name: 'test', + nodes: [TestBlockDecoratorNode], + }); + editor.setRootElement(rootElement); + rootElement.focus(); + editor.update( + () => { + if (range) { + $getRoot().select(range[0], range[1]); + } else { + const decorator = $getRoot().getChildAtIndex(selectedIndex)!; + const selection = $createNodeSelection(); + selection.add(decorator.getKey()); + $setSelection(selection); + } + }, + {discrete: true}, + ); + + onTestFinished(() => { + editor.setRootElement(null); + rootElement.remove(); + editor.dispose(); + }); + + return editor; +} + +/** + * Node keys differ between editors, so describe every node by its index path + * from the root to compare selections across two editors. + */ +function $getPath(node: LexicalNode): number[] { + const path = []; + for ( + let current: LexicalNode | null = node; + current !== null && current.getParent() !== null; + current = current.getParent() + ) { + path.unshift(current.getIndexWithinParent()); + } + return path; +} + +function $describePoint(point: Point) { + return { + offset: point.offset, + path: $getPath(point.getNode()), + type: point.type, + }; +} + +/** A key-independent description of the editor's RangeSelection. */ +function describeSelection(editor: LexicalEditor) { + return editor.read(() => { + const selection = $getSelection(); + assert($isRangeSelection(selection)); + return { + anchor: $describePoint(selection.anchor), + focus: $describePoint(selection.focus), + isCollapsed: selection.isCollapsed(), + nodes: selection.getNodes().map($getPath), + }; + }); +} + +/** + * A contiguous NodeSelection is converted to the RangeSelection covering the + * same siblings, oriented toward the arrow key, and then the regular + * RangeSelection handling runs. `converted` is that RangeSelection, expressed + * as root offsets, so each case can be compared against pressing the same key + * with that selection already in place. + */ +const CASES: { + converted: [anchor: number, focus: number]; + key: string; + selectedIndex: number; +}[] = [ + {converted: [0, 1], key: 'ArrowRight', selectedIndex: 0}, + {converted: [0, 1], key: 'ArrowDown', selectedIndex: 0}, + {converted: [2, 1], key: 'ArrowLeft', selectedIndex: 1}, + {converted: [2, 1], key: 'ArrowUp', selectedIndex: 1}, +]; + +describe('Shift+Arrow on a NodeSelection (#9062)', () => { + // The regression from #9062: every arrow key collapsed the NodeSelection to + // a caret next to the decorator, even with shift held. `describeSelection` + // also asserts that the selection is a RangeSelection rather than a + // NodeSelection. Which nodes end up selected is asserted per direction + // below, because the browsers do not agree on vertical extension. + test.for(CASES)( + '$key on the decorator at index $selectedIndex leaves a non-collapsed RangeSelection', + async ({key, selectedIndex}) => { + const editor = mountEditor({selectedIndex}); + + await userEvent.keyboard(`{Shift>}{${key}}{/Shift}`); + + expect(describeSelection(editor).isCollapsed).toBe(false); + }, + ); + + test.for( + CASES.filter(({key}) => key === 'ArrowLeft' || key === 'ArrowRight'), + )( + '$key extends over the adjacent decorator like the equivalent RangeSelection', + async ({converted, key, selectedIndex}) => { + const nodeSelectionEditor = mountEditor({selectedIndex}); + + await userEvent.keyboard(`{Shift>}{${key}}{/Shift}`); + + nodeSelectionEditor.read(() => { + const selection = $getSelection(); + assert($isRangeSelection(selection)); + const selectedNodes = selection.getNodes(); + expect(selectedNodes).toContain($getRoot().getChildAtIndex(0)); + expect(selectedNodes).toContain($getRoot().getChildAtIndex(1)); + expect(selectedNodes).not.toContain($getRoot().getChildAtIndex(2)); + }); + + const rangeSelectionEditor = mountEditor({range: converted}); + + await userEvent.keyboard(`{Shift>}{${key}}{/Shift}`); + + expect(describeSelection(rangeSelectionEditor)).toEqual( + describeSelection(nodeSelectionEditor), + ); + }, + ); + + /** + * Vertical extension is left to the browser's default action, and where it + * lands depends on the rendered layout (the browsers do not agree), so the + * outcome is only asserted against pressing the same key with the converted + * RangeSelection already in place. This is what the synchronous commit in + * `$convertContiguousNodeSelection` buys: without it Firefox extends nothing + * on the first press, because the conversion would reach the DOM only after + * the keydown listeners return. + */ + test.for(CASES.filter(({key}) => key === 'ArrowUp' || key === 'ArrowDown'))( + '$key extends like the equivalent RangeSelection', + async ({converted, key, selectedIndex}) => { + const nodeSelectionEditor = mountEditor({selectedIndex}); + + await userEvent.keyboard(`{Shift>}{${key}}{/Shift}`); + + const nodeSelectionResult = describeSelection(nodeSelectionEditor); + + const rangeSelectionEditor = mountEditor({range: converted}); + + await userEvent.keyboard(`{Shift>}{${key}}{/Shift}`); + + expect(describeSelection(rangeSelectionEditor)).toEqual( + nodeSelectionResult, + ); + }, + ); + + test('ArrowRight extends from a decorator into an adjacent element', async () => { + const editor = mountEditor({adjacentElement: true}); + + await userEvent.keyboard('{Shift>}{ArrowRight}{/Shift}'); + + editor.read(() => { + const selection = $getSelection(); + assert($isRangeSelection(selection)); + expect(selection.isCollapsed()).toBe(false); + const selectedNodes = selection.getNodes(); + const decorator = $getRoot().getChildAtIndex(0)!; + const paragraph = $getRoot().getChildAtIndex(1)!; + expect(selectedNodes).toContain(decorator); + expect( + selectedNodes.some( + node => node === paragraph || node.getParent() === paragraph, + ), + ).toBe(true); + }); + }); +}); diff --git a/packages/lexical-rich-text/src/__tests__/unit/RichTextNodeSelectionArrow.test.ts b/packages/lexical-rich-text/src/__tests__/unit/RichTextNodeSelectionArrow.test.ts new file mode 100644 index 00000000000..56b7664e119 --- /dev/null +++ b/packages/lexical-rich-text/src/__tests__/unit/RichTextNodeSelectionArrow.test.ts @@ -0,0 +1,235 @@ +/** + * 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 {RichTextExtension} from '@lexical/rich-text'; +import { + $createNodeSelection, + $createParagraphNode, + $createTextNode, + $getRoot, + $getSelection, + $isRangeSelection, + $setSelection, + KEY_ARROW_DOWN_COMMAND, + KEY_ARROW_LEFT_COMMAND, + KEY_ARROW_RIGHT_COMMAND, + KEY_ARROW_UP_COMMAND, + type LexicalCommand, + type LexicalEditor, +} from 'lexical'; +import { + $createTestDecoratorNode, + TestDecoratorNode, +} from 'lexical/src/__tests__/utils'; +import {assert, describe, expect, test} from 'vitest'; + +function createEditor() { + return buildEditorFromExtensions({ + $initialEditorState: () => { + $getRoot() + .clear() + .append( + $createTestDecoratorNode().setIsInline(false), + $createTestDecoratorNode().setIsInline(false), + $createTestDecoratorNode().setIsInline(false), + $createParagraphNode().append($createTextNode('text')), + ); + }, + dependencies: [RichTextExtension], + name: 'test', + nodes: [TestDecoratorNode], + }); +} + +function makeArrowEvent(key: string, shiftKey = false): KeyboardEvent { + return new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key, + shiftKey, + }); +} + +function selectNodes(editor: LexicalEditor, indexes: number[]): void { + editor.update( + () => { + const selection = $createNodeSelection(); + for (const index of indexes) { + selection.add($getRoot().getChildAtIndex(index)!.getKey()); + } + $setSelection(selection); + }, + {discrete: true}, + ); +} + +function selectRoot( + editor: LexicalEditor, + anchorOffset: number, + focusOffset: number, +): void { + editor.update( + () => { + $getRoot().select(anchorOffset, focusOffset); + }, + {discrete: true}, + ); +} + +function dispatchShiftArrow( + editor: LexicalEditor, + command: LexicalCommand, + key: string, +): {defaultPrevented: boolean; handled: boolean} { + const event = makeArrowEvent(key, true); + const handled = editor.dispatchCommand(command, event); + return {defaultPrevented: event.defaultPrevented, handled}; +} + +function expectRootRange( + editor: LexicalEditor, + anchorOffset: number, + focusOffset: number, +): void { + editor.read(() => { + const selection = $getSelection(); + assert($isRangeSelection(selection)); + expect(selection.anchor).toMatchObject({ + key: $getRoot().getKey(), + offset: anchorOffset, + type: 'element', + }); + expect(selection.focus).toMatchObject({ + key: $getRoot().getKey(), + offset: focusOffset, + type: 'element', + }); + }); +} + +describe('Shift+Arrow on a NodeSelection (#9062)', () => { + /** + * A contiguous NodeSelection is converted to the RangeSelection that covers + * the same siblings, oriented so that the focus is on the side the arrow key + * moves toward, and then the regular RangeSelection handling runs. So each + * case asserts both the conversion (`converted`) and that the outcome is + * identical to pressing the same key with that RangeSelection already in + * place — including whether the handler consumes the event at all. + * + * ArrowLeft/ArrowRight are consumed by `$moveCharacter`, which extends the + * focus across the adjacent decorator. ArrowUp/ArrowDown are not handled by + * rich text (except at the root edges); the browser performs the vertical + * extension natively, so the handler leaves the converted selection alone, + * returns `false`, and does not preventDefault. + */ + test.for<{ + command: LexicalCommand; + converted: [anchor: number, focus: number]; + expected: [anchor: number, focus: number]; + handled: boolean; + key: string; + selectedIndexes: number[]; + }>([ + { + command: KEY_ARROW_RIGHT_COMMAND, + converted: [0, 1], + expected: [0, 2], + handled: true, + key: 'ArrowRight', + selectedIndexes: [0], + }, + { + command: KEY_ARROW_DOWN_COMMAND, + converted: [0, 1], + expected: [0, 1], + handled: false, + key: 'ArrowDown', + selectedIndexes: [0], + }, + { + command: KEY_ARROW_LEFT_COMMAND, + converted: [2, 1], + expected: [2, 0], + handled: true, + key: 'ArrowLeft', + selectedIndexes: [1], + }, + { + command: KEY_ARROW_UP_COMMAND, + converted: [2, 1], + expected: [2, 1], + handled: false, + key: 'ArrowUp', + selectedIndexes: [1], + }, + { + // The converted focus is already at the start of the root, so rich text + // consumes the event instead of letting the browser move past it. + command: KEY_ARROW_UP_COMMAND, + converted: [1, 0], + expected: [1, 0], + handled: true, + key: 'ArrowUp', + selectedIndexes: [0], + }, + { + // Out of document order: the NodeSelection is sorted before conversion. + command: KEY_ARROW_RIGHT_COMMAND, + converted: [0, 2], + expected: [0, 3], + handled: true, + key: 'ArrowRight', + selectedIndexes: [1, 0], + }, + ])( + '$key with $selectedIndexes selected converts to $converted and matches the equivalent RangeSelection', + ({command, converted, expected, handled, key, selectedIndexes}) => { + using nodeSelectionEditor = createEditor(); + selectNodes(nodeSelectionEditor, selectedIndexes); + const nodeSelectionResult = dispatchShiftArrow( + nodeSelectionEditor, + command, + key, + ); + + expect(nodeSelectionResult).toEqual({ + defaultPrevented: handled, + handled, + }); + expectRootRange(nodeSelectionEditor, ...expected); + + // Starting from the converted RangeSelection produces the same outcome. + using rangeSelectionEditor = createEditor(); + selectRoot(rangeSelectionEditor, ...converted); + const rangeSelectionResult = dispatchShiftArrow( + rangeSelectionEditor, + command, + key, + ); + + expect(rangeSelectionResult).toEqual(nodeSelectionResult); + expectRootRange(rangeSelectionEditor, ...expected); + }, + ); + + test('does not convert a discontiguous NodeSelection to a range', () => { + using editor = createEditor(); + selectNodes(editor, [0, 2]); + + const {defaultPrevented, handled} = dispatchShiftArrow( + editor, + KEY_ARROW_RIGHT_COMMAND, + 'ArrowRight', + ); + + expect(handled).toBe(true); + expect(defaultPrevented).toBe(true); + expectRootRange(editor, 1, 1); + }); +}); diff --git a/packages/lexical-rich-text/src/index.ts b/packages/lexical-rich-text/src/index.ts index 469934ec013..c79da2d9f26 100644 --- a/packages/lexical-rich-text/src/index.ts +++ b/packages/lexical-rich-text/src/index.ts @@ -37,8 +37,10 @@ import { $createTabNode, $extendCaretToRange, $findMatchingParent, + $flushSyncAfterUpdate, $formatText, $getCaretRange, + $getCaretRangeInDirection, $getChildCaret, $getCollapsedCaretRange, $getDocument, @@ -64,6 +66,7 @@ import { $needsBlockCursorBeside, $normalizeCaret, $normalizeSelection__EXPERIMENTAL, + $rewindSiblingCaret, $selectAll, $setDirectionFromDOM, $setFormatFromDOM, @@ -1069,6 +1072,49 @@ function $exitNodeSelectionToward( } } +/** + * Convert a contiguous NodeSelection to a RangeSelection that covers the same + * siblings. Discontiguous NodeSelections cannot be represented as a range + * without selecting the nodes between them, so they retain the existing + * collapse behavior in the arrow handlers. + */ +function $convertContiguousNodeSelection( + selection: NodeSelection, + direction: CaretDirection, +): boolean { + const carets = selection + .getNodes() + .map(node => $getSiblingCaret(node, 'next')) + .sort($comparePointCaretNext); + // At least one node + const firstCaret = carets[0]; + const lastCaret = carets[carets.length - 1]; + if (!firstCaret || !lastCaret) { + return false; + } + // Check that all nodes are contiguous + for (let i = 0; i < carets.length - 1; i++) { + if (!carets[i + 1].origin.is(carets[i].getNodeAtCaret())) { + return false; + } + } + $setSelectionFromCaretRange( + $getCaretRangeInDirection( + $getCaretRange($rewindSiblingCaret(firstCaret), lastCaret), + direction, + ), + ); + // The arrow handlers fall through to the RangeSelection paths after this, + // and the vertical ones leave the extension to the browser's default action + // for this keydown. That action reads the DOM selection, but this update + // would otherwise be committed in a microtask, and Firefox does not pick up + // a selection that lands after the keydown listeners return — it would + // extend nothing on the first press. Commit synchronously so every browser + // extends the converted selection. + $flushSyncAfterUpdate(); + return true; +} + /** * Collapse a NodeSelection to a caret at the surrounding block's edge for * MOVE_TO_START / MOVE_TO_END. Picks the document-order first node for @@ -1389,17 +1435,26 @@ export function registerRichText( editor.registerCommand( KEY_ARROW_UP_COMMAND, event => { - const selection = $getSelection(); + let selection = $getSelection(); if ($isNodeSelection(selection)) { // If selection is on a node, let's try and move selection // back to being a range selection. const nodes = selection.getNodes(); if (nodes.length > 0) { - event.preventDefault(); - $exitNodeSelectionToward(nodes[0], 'previous'); - return true; + if ( + event.shiftKey && + $convertContiguousNodeSelection(selection, 'previous') + ) { + // Fallthrough + selection = $getSelection(); + } else { + event.preventDefault(); + $exitNodeSelectionToward(nodes[0], 'previous'); + return true; + } } - } else if ($isRangeSelection(selection)) { + } + if ($isRangeSelection(selection)) { if ($isSelectionAtStartOfRoot(selection)) { event.preventDefault(); return true; @@ -1430,17 +1485,26 @@ export function registerRichText( editor.registerCommand( KEY_ARROW_DOWN_COMMAND, event => { - const selection = $getSelection(); + let selection = $getSelection(); if ($isNodeSelection(selection)) { // If selection is on a node, let's try and move selection // back to being a range selection. const nodes = selection.getNodes(); if (nodes.length > 0) { - event.preventDefault(); - $exitNodeSelectionToward(nodes[0], 'next'); - return true; + if ( + event.shiftKey && + $convertContiguousNodeSelection(selection, 'next') + ) { + // Fallthrough + selection = $getSelection(); + } else { + event.preventDefault(); + $exitNodeSelectionToward(nodes[0], 'next'); + return true; + } } - } else if ($isRangeSelection(selection)) { + } + if ($isRangeSelection(selection)) { if ($isSelectionAtEndOfRoot(selection)) { event.preventDefault(); return true; @@ -1474,18 +1538,24 @@ export function registerRichText( editor.registerCommand( KEY_ARROW_LEFT_COMMAND, event => { - const selection = $getSelection(); + let selection = $getSelection(); if ($isNodeSelection(selection)) { // If selection is on a node, let's try and move selection // back to being a range selection. const nodes = selection.getNodes(); if (nodes.length > 0) { - event.preventDefault(); - $exitNodeSelectionToward( - nodes[0], - $isParentRTL(nodes[0]) ? 'next' : 'previous', - ); - return true; + const direction = $isParentRTL(nodes[0]) ? 'next' : 'previous'; + if ( + event.shiftKey && + $convertContiguousNodeSelection(selection, direction) + ) { + // Fallthrough + selection = $getSelection(); + } else { + event.preventDefault(); + $exitNodeSelectionToward(nodes[0], direction); + return true; + } } } if (!$isRangeSelection(selection)) { @@ -1522,18 +1592,24 @@ export function registerRichText( editor.registerCommand( KEY_ARROW_RIGHT_COMMAND, event => { - const selection = $getSelection(); + let selection = $getSelection(); if ($isNodeSelection(selection)) { // If selection is on a node, let's try and move selection // back to being a range selection. const nodes = selection.getNodes(); if (nodes.length > 0) { - event.preventDefault(); - $exitNodeSelectionToward( - nodes[0], - $isParentRTL(nodes[0]) ? 'previous' : 'next', - ); - return true; + const direction = $isParentRTL(nodes[0]) ? 'previous' : 'next'; + if ( + event.shiftKey && + $convertContiguousNodeSelection(selection, direction) + ) { + // Fallthrough + selection = $getSelection(); + } else { + event.preventDefault(); + $exitNodeSelectionToward(nodes[0], direction); + return true; + } } } if (!$isRangeSelection(selection)) { diff --git a/packages/lexical/src/LexicalUpdates.ts b/packages/lexical/src/LexicalUpdates.ts index f0a28d6d33c..09c5d23a033 100644 --- a/packages/lexical/src/LexicalUpdates.ts +++ b/packages/lexical/src/LexicalUpdates.ts @@ -1110,6 +1110,18 @@ function $processNestedUpdates( return skipTransforms; } +/** + * Equivalent to setting `{discrete: true}` on the containing `editor.update`, + * generally used to ensure that the DOM is updated before returning from + * an event listener where the browser is expected to natively finish handling + * the event. + */ +export function $flushSyncAfterUpdate() { + const editorState = getActiveEditorState(); + errorOnReadOnly(); + editorState._flushSync = true; +} + function $beginUpdate( editor: LexicalEditor, updateFn: () => void, diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index ad83861600d..3bbb3a7af40 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -294,6 +294,7 @@ export { } from './LexicalSlot'; export { $assumeActiveEditor, + $flushSyncAfterUpdate, $fullReconcile, $parseSerializedNode, isCurrentlyReadOnlyMode,