diff --git a/packages/lexical-playground/__tests__/e2e/Events.spec.mjs b/packages/lexical-playground/__tests__/e2e/Events.spec.mjs new file mode 100644 index 00000000000..3bf24a417f8 --- /dev/null +++ b/packages/lexical-playground/__tests__/e2e/Events.spec.mjs @@ -0,0 +1,102 @@ +/** + * 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 { + assertHTML, + evaluate, + focusEditor, + html, + initialize, + LEGACY_EVENTS, + test, +} from '../utils/index.mjs'; + +test.describe('Events', () => { + test.beforeEach(({isCollab, page}) => + initialize({isAutocomplete: true, isCollab, page}), + ); + test('Autocapitalization (MacOS specific)', async ({page, isPlainText}) => { + if (LEGACY_EVENTS) { + return; + } + await focusEditor(page); + await page.keyboard.type('i'); + await evaluate(page, () => { + const editable = document.querySelector('[contenteditable="true"]'); + const span = editable.querySelector('span'); + const textNode = span.firstChild; + function singleRangeFn( + startContainer, + startOffset, + endContainer, + endOffset, + ) { + return () => [ + new StaticRange({ + endContainer, + endOffset, + startContainer, + startOffset, + }), + ]; + } + const character = 'S'; // S for space because the space itself gets trimmed in the assertHTML + const replacementCharacter = 'I'; + const dataTransfer = new DataTransfer(); + dataTransfer.setData('text/plain', replacementCharacter); + dataTransfer.setData('text/html', replacementCharacter); + const characterBeforeInputEvent = new InputEvent('beforeinput', { + bubbles: true, + cancelable: true, + data: character, + inputType: 'insertText', + }); + characterBeforeInputEvent.getTargetRanges = singleRangeFn( + textNode, + 1, + textNode, + 1, + ); + const replacementBeforeInputEvent = new InputEvent('beforeinput', { + bubbles: true, + cancelable: true, + clipboardData: dataTransfer, + data: replacementCharacter, + dataTransfer, + inputType: 'insertReplacementText', + }); + replacementBeforeInputEvent.getTargetRanges = singleRangeFn( + textNode, + 0, + textNode, + 1, + ); + const characterInputEvent = new InputEvent('input', { + bubbles: true, + cancelable: true, + data: character, + inputType: 'insertText', + }); + editable.dispatchEvent(characterBeforeInputEvent); + textNode.textContent += character; + editable.dispatchEvent(replacementBeforeInputEvent); + editable.dispatchEvent(characterInputEvent); + }); + + await assertHTML( + page, + html` +
+ IS +
+ `, + ); + }); +}); diff --git a/packages/lexical/src/LexicalEvents.ts b/packages/lexical/src/LexicalEvents.ts index 47c82491544..dc2e5b5b326 100644 --- a/packages/lexical/src/LexicalEvents.ts +++ b/packages/lexical/src/LexicalEvents.ts @@ -159,6 +159,7 @@ if (CAN_USE_BEFORE_INPUT) { let lastKeyDownTimeStamp = 0; let lastKeyCode = 0; let lastBeforeInputInsertTextTimeStamp = 0; +let unprocessedBeforeInputData: null | string = null; let rootElementsRegistered = 0; let isSelectionChangeFromDOMUpdate = false; let isSelectionChangeFromMouseDown = false; @@ -500,14 +501,27 @@ function onBeforeInput(event: InputEvent, editor: LexicalEditor): void { const data = event.data; + // This represents the case when two beforeinput events are triggered at the same time (without a + // full event loop ending at input). This happens with MacOS with the default keyboard settings, + // a combination of autocorrection + autocapitalization. + // Having Lexical run everything in controlled mode would fix the issue without additional code + // but this would kill the massive performance win from the most common typing event. + // Alternatively, when this happens we can prematurely update our EditorState based on the DOM + // content, a job that would usually be the input event's responsibility. + if (unprocessedBeforeInputData !== null) { + $updateSelectedTextFromDOM(false, editor, unprocessedBeforeInputData); + } + if ( - !selection.dirty && + (!selection.dirty || unprocessedBeforeInputData !== null) && selection.isCollapsed() && !$isRootNode(selection.anchor.getNode()) ) { $applyTargetRange(selection, event); } + unprocessedBeforeInputData = null; + const anchor = selection.anchor; const focus = selection.focus; const anchorNode = anchor.getNode(); @@ -536,6 +550,8 @@ function onBeforeInput(event: InputEvent, editor: LexicalEditor): void { ) { event.preventDefault(); dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, data); + } else { + unprocessedBeforeInputData = data; } lastBeforeInputInsertTextTimeStamp = event.timeStamp; return; @@ -748,6 +764,7 @@ function onInput(event: InputEvent, editor: LexicalEditor): void { // since the change. $flushMutations(); }); + unprocessedBeforeInputData = null; } function onCompositionStart(