diff --git a/packages/lexical-playground/__tests__/regression/8153-safari-ime-delete-selection.spec.mjs b/packages/lexical-playground/__tests__/regression/8153-safari-ime-delete-selection.spec.mjs new file mode 100644 index 00000000000..4da7f7f8026 --- /dev/null +++ b/packages/lexical-playground/__tests__/regression/8153-safari-ime-delete-selection.spec.mjs @@ -0,0 +1,105 @@ +/** + * 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, + assertSelection, + focusEditor, + html, + initialize, + test, +} from '../utils/index.mjs'; + +/** + * Safari fires compositionend before keydown (unlike Chrome/Firefox). + * Dispatching this event sets isSafariEndingComposition = true in LexicalEvents.ts, + * which is the stale flag that causes the bug. + */ +async function dispatchCompositionEnd(page) { + await page.evaluate(() => { + document.querySelector('[contenteditable="true"]').dispatchEvent( + new CompositionEvent('compositionend', { + bubbles: true, + cancelable: false, + data: 'あああ', + }), + ); + }); +} + +test.describe('Regression #8153', () => { + test.beforeEach(({isCollab, page}) => initialize({isCollab, page})); + + test('Can delete all text selected with Cmd+A after IME composition end on Safari', async ({ + page, + browserName, + isPlainText, + isCollab, + }) => { + test.skip(browserName !== 'webkit'); + test.skip(isPlainText); + test.skip(isCollab); + + await focusEditor(page); + await page.keyboard.type('Hello'); + await page.keyboard.press('Enter'); + await page.keyboard.type('World'); + + await dispatchCompositionEnd(page); + + await selectAll(page); + await page.keyboard.press('Backspace'); + + await assertHTML( + page, + html` +


+ `, + ); + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [0], + focusOffset: 0, + focusPath: [0], + }); + }); + + test('Can delete multi-paragraph selection with Shift+ArrowUp after IME composition end on Safari', async ({ + page, + browserName, + isPlainText, + isCollab, + }) => { + test.skip(browserName !== 'webkit'); + test.skip(isPlainText); + test.skip(isCollab); + + await focusEditor(page); + await page.keyboard.type('Hello'); + await page.keyboard.press('Enter'); + await page.keyboard.type('World'); + await page.keyboard.press('Enter'); + await page.keyboard.type('あああ'); + + await dispatchCompositionEnd(page); + + await page.keyboard.press('Shift+ArrowUp'); + await page.keyboard.press('Shift+ArrowUp'); + await page.keyboard.press('Backspace'); + + await assertHTML( + page, + html` +

+ Hello +

+ `, + ); + }); +}); diff --git a/packages/lexical/src/LexicalEvents.ts b/packages/lexical/src/LexicalEvents.ts index 24e3ef743c9..e8a8f0efffa 100644 --- a/packages/lexical/src/LexicalEvents.ts +++ b/packages/lexical/src/LexicalEvents.ts @@ -1193,13 +1193,17 @@ function $handleKeyDown(event: KeyboardEvent): boolean { if (event.key == null) { return true; } - if (isSafariEndingComposition && isBackspace(event)) { - updateEditorSync(editor, () => { - $onCompositionEndImpl(editor, safariEndCompositionEventData); - }); + if (isSafariEndingComposition) { + if (isBackspace(event)) { + updateEditorSync(editor, () => { + $onCompositionEndImpl(editor, safariEndCompositionEventData); + }); + isSafariEndingComposition = false; + safariEndCompositionEventData = ''; + return true; + } isSafariEndingComposition = false; safariEndCompositionEventData = ''; - return true; } if (isMoveForward(event)) { diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index b4f70f95216..506f04382f2 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -2979,6 +2979,19 @@ function setDOMSelectionBaseAndExtent( } } +function getElementAndOffsetForPoint( + editor: LexicalEditor, + node: LexicalNode, + offset: number, +): [HTMLElement, number] { + const element = getElementByKeyOrThrow(editor, node.getKey()); + if ($isElementNode(node)) { + const slot = node.getDOMSlot(element); + return [slot.element, offset + slot.getFirstChildOffset()]; + } + return [element, offset]; +} + export function updateDOMSelection( prevSelection: BaseSelection | null, nextSelection: BaseSelection | null, @@ -3021,12 +3034,18 @@ export function updateDOMSelection( const anchor = nextSelection.anchor; const focus = nextSelection.focus; - const anchorKey = anchor.key; - const focusKey = focus.key; - const anchorDOM = getElementByKeyOrThrow(editor, anchorKey); - const focusDOM = getElementByKeyOrThrow(editor, focusKey); - const nextAnchorOffset = anchor.offset; - const nextFocusOffset = focus.offset; + const anchorNode = anchor.getNode(); + const focusNode = focus.getNode(); + const [anchorDOM, nextAnchorOffset] = getElementAndOffsetForPoint( + editor, + anchorNode, + anchor.offset, + ); + const [focusDOM, nextFocusOffset] = getElementAndOffsetForPoint( + editor, + focusNode, + focus.offset, + ); const nextFormat = nextSelection.format; const nextStyle = nextSelection.style; const isCollapsed = nextSelection.isCollapsed(); @@ -3036,7 +3055,6 @@ export function updateDOMSelection( if (anchor.type === 'text') { nextAnchorNode = getDOMTextNode(anchorDOM); - const anchorNode = anchor.getNode(); anchorFormatOrStyleChanged = anchorNode.getFormat() !== nextFormat || anchorNode.getStyle() !== nextStyle; @@ -3069,7 +3087,7 @@ export function updateDOMSelection( nextFormat, nextStyle, nextAnchorOffset, - anchorKey, + anchor.key, performance.now(), ); } diff --git a/packages/lexical/src/nodes/__tests__/unit/LexicalElementNode.test.tsx b/packages/lexical/src/nodes/__tests__/unit/LexicalElementNode.test.tsx index a8ea37a6b87..bb4178ef3bc 100644 --- a/packages/lexical/src/nodes/__tests__/unit/LexicalElementNode.test.tsx +++ b/packages/lexical/src/nodes/__tests__/unit/LexicalElementNode.test.tsx @@ -785,6 +785,28 @@ describe('getDOMSlot tests', () => { `

`, ); }); + + test('DOM selection uses getDOMSlot element for element selections', () => { + editor.update( + () => { + const wrapper = $createWrapperElementNode().append( + $createParagraphNode().append($createTextNode('A')), + $createParagraphNode().append($createTextNode('B')), + $createParagraphNode().append($createTextNode('C')), + ); + $getRoot().clear().append(wrapper); + // Create element-type selection on wrapper + wrapper.select(0, wrapper.getChildrenSize()); + }, + {discrete: true}, + ); + + const domSelection = window.getSelection(); + expect(domSelection!.anchorNode!.nodeName).toBe('SECTION'); + expect(domSelection!.anchorOffset).toBe(0); + expect(domSelection!.focusNode!.nodeName).toBe('SECTION'); + expect(domSelection!.focusOffset).toBe(3); + }); }); describe('indexPath', () => {