/** * 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 type {LexicalEditor} from './LexicalEditor'; import type {NodeKey} from './LexicalNode'; import type {ElementNode} from './nodes/LexicalElementNode'; import type {TextNode} from './nodes/LexicalTextNode'; import { CAN_USE_BEFORE_INPUT, IS_ANDROID_CHROME, IS_APPLE_WEBKIT, IS_FIREFOX, IS_IOS, IS_SAFARI, } from 'lexical/shared/environment'; import invariant from 'lexical/shared/invariant'; import { $getPreviousSelection, $getRoot, $getSelection, $isElementNode, $isNodeSelection, $isRangeSelection, $isRootNode, $isTextNode, $setCompositionKey, BLUR_COMMAND, CLICK_COMMAND, CONTROLLED_TEXT_INSERTION_COMMAND, COPY_COMMAND, CUT_COMMAND, DELETE_CHARACTER_COMMAND, DELETE_LINE_COMMAND, DELETE_WORD_COMMAND, DRAGEND_COMMAND, DRAGOVER_COMMAND, DRAGSTART_COMMAND, DROP_COMMAND, FOCUS_COMMAND, FORMAT_TEXT_COMMAND, INSERT_LINE_BREAK_COMMAND, INSERT_PARAGRAPH_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND, KEY_DOWN_COMMAND, KEY_ENTER_COMMAND, KEY_ESCAPE_COMMAND, KEY_SPACE_COMMAND, KEY_TAB_COMMAND, MOVE_TO_END, MOVE_TO_START, ParagraphNode, PASTE_COMMAND, REDO_COMMAND, REMOVE_TEXT_COMMAND, SELECTION_CHANGE_COMMAND, UNDO_COMMAND, } from '.'; import {KEY_MODIFIER_COMMAND, SELECT_ALL_COMMAND} from './LexicalCommands'; import { COMPOSITION_START_CHAR, DOM_ELEMENT_TYPE, DOM_TEXT_TYPE, DOUBLE_LINE_BREAK, IS_ALL_FORMATTING, } from './LexicalConstants'; import { $internalCreateRangeSelection, RangeSelection, } from './LexicalSelection'; import {getActiveEditor, updateEditor} from './LexicalUpdates'; import { $flushMutations, $getNodeByKey, $isSelectionCapturedInDecorator, $isTokenOrSegmented, $setSelection, $shouldInsertTextAfterOrBeforeTextNode, $updateSelectedTextFromDOM, $updateTextNodeFromDOMContent, dispatchCommand, doesContainGrapheme, getAnchorTextFromDOM, getDOMSelection, getDOMTextNode, getEditorPropertyFromDOMNode, getEditorsToPropagate, getNearestEditorFromDOMNode, getWindow, isBackspace, isBold, isCopy, isCut, isDelete, isDeleteBackward, isDeleteForward, isDeleteLineBackward, isDeleteLineForward, isDeleteWordBackward, isDeleteWordForward, isEscape, isFirefoxClipboardEvents, isItalic, isLexicalEditor, isLineBreak, isModifier, isMoveBackward, isMoveDown, isMoveForward, isMoveToEnd, isMoveToStart, isMoveUp, isOpenLineBreak, isParagraph, isRedo, isSelectAll, isSelectionWithinEditor, isSpace, isTab, isUnderline, isUndo, } from './LexicalUtils'; type RootElementRemoveHandles = Array<() => void>; type RootElementEvents = Array< [ string, Record | ((event: Event, editor: LexicalEditor) => void), ] >; const PASS_THROUGH_COMMAND = Object.freeze({}); const ANDROID_COMPOSITION_LATENCY = 30; const rootElementEvents: RootElementEvents = [ ['keydown', onKeyDown], ['pointerdown', onPointerDown], ['compositionstart', onCompositionStart], ['compositionend', onCompositionEnd], ['input', onInput], ['click', onClick], ['cut', PASS_THROUGH_COMMAND], ['copy', PASS_THROUGH_COMMAND], ['dragstart', PASS_THROUGH_COMMAND], ['dragover', PASS_THROUGH_COMMAND], ['dragend', PASS_THROUGH_COMMAND], ['paste', PASS_THROUGH_COMMAND], ['focus', PASS_THROUGH_COMMAND], ['blur', PASS_THROUGH_COMMAND], ['drop', PASS_THROUGH_COMMAND], ]; if (CAN_USE_BEFORE_INPUT) { rootElementEvents.push([ 'beforeinput', (event, editor) => onBeforeInput(event as InputEvent, editor), ]); } let lastKeyDownTimeStamp = 0; let lastKeyCode: null | string = null; let lastBeforeInputInsertTextTimeStamp = 0; let unprocessedBeforeInputData: null | string = null; const rootElementsRegistered = new WeakMap(); let isSelectionChangeFromDOMUpdate = false; let isSelectionChangeFromMouseDown = false; let isInsertLineBreak = false; let isFirefoxEndingComposition = false; let collapsedSelectionFormat: [number, string, number, NodeKey, number] = [ 0, '', 0, 'root', 0, ]; // This function is used to determine if Lexical should attempt to override // the default browser behavior for insertion of text and use its own internal // heuristics. This is an extremely important function, and makes much of Lexical // work as intended between different browsers and across word, line and character // boundary/formats. It also is important for text replacement, node schemas and // composition mechanics. function $shouldPreventDefaultAndInsertText( selection: RangeSelection, domTargetRange: null | StaticRange, text: string, timeStamp: number, isBeforeInput: boolean, ): boolean { const anchor = selection.anchor; const focus = selection.focus; const anchorNode = anchor.getNode(); const editor = getActiveEditor(); const domSelection = getDOMSelection(editor._window); const domAnchorNode = domSelection !== null ? domSelection.anchorNode : null; const anchorKey = anchor.key; const backingAnchorElement = editor.getElementByKey(anchorKey); const textLength = text.length; return ( anchorKey !== focus.key || // If we're working with a non-text node. !$isTextNode(anchorNode) || // If we are replacing a range with a single character or grapheme, and not composing. (((!isBeforeInput && (!CAN_USE_BEFORE_INPUT || // We check to see if there has been // a recent beforeinput event for "textInput". If there has been one in the last // 50ms then we proceed as normal. However, if there is not, then this is likely // a dangling `input` event caused by execCommand('insertText'). lastBeforeInputInsertTextTimeStamp < timeStamp + 50)) || (anchorNode.isDirty() && textLength < 2) || doesContainGrapheme(text)) && anchor.offset !== focus.offset && !anchorNode.isComposing()) || // Any non standard text node. $isTokenOrSegmented(anchorNode) || // If the text length is more than a single character and we're either // dealing with this in "beforeinput" or where the node has already recently // been changed (thus is dirty). (anchorNode.isDirty() && textLength > 1) || // If the DOM selection element is not the same as the backing node during beforeinput. ((isBeforeInput || !CAN_USE_BEFORE_INPUT) && backingAnchorElement !== null && !anchorNode.isComposing() && domAnchorNode !== getDOMTextNode(backingAnchorElement)) || // If TargetRange is not the same as the DOM selection; browser trying to edit random parts // of the editor. (domSelection !== null && domTargetRange !== null && (!domTargetRange.collapsed || domTargetRange.startContainer !== domSelection.anchorNode || domTargetRange.startOffset !== domSelection.anchorOffset)) || // Check if we're changing from bold to italics, or some other format. anchorNode.getFormat() !== selection.format || anchorNode.getStyle() !== selection.style || // One last set of heuristics to check against. $shouldInsertTextAfterOrBeforeTextNode(selection, anchorNode) ); } function shouldSkipSelectionChange( domNode: null | Node, offset: number, ): boolean { return ( domNode !== null && domNode.nodeValue !== null && domNode.nodeType === DOM_TEXT_TYPE && offset !== 0 && offset !== domNode.nodeValue.length ); } function onSelectionChange( domSelection: Selection, editor: LexicalEditor, isActive: boolean, ): void { const { anchorNode: anchorDOM, anchorOffset, focusNode: focusDOM, focusOffset, } = domSelection; if (isSelectionChangeFromDOMUpdate) { isSelectionChangeFromDOMUpdate = false; // If native DOM selection is on a DOM element, then // we should continue as usual, as Lexical's selection // may have normalized to a better child. If the DOM // element is a text node, we can safely apply this // optimization and skip the selection change entirely. // We also need to check if the offset is at the boundary, // because in this case, we might need to normalize to a // sibling instead. if ( shouldSkipSelectionChange(anchorDOM, anchorOffset) && shouldSkipSelectionChange(focusDOM, focusOffset) ) { return; } } updateEditor(editor, () => { // Non-active editor don't need any extra logic for selection, it only needs update // to reconcile selection (set it to null) to ensure that only one editor has non-null selection. if (!isActive) { $setSelection(null); return; } if (!isSelectionWithinEditor(editor, anchorDOM, focusDOM)) { return; } const selection = $getSelection(); // Update the selection format if ($isRangeSelection(selection)) { const anchor = selection.anchor; const anchorNode = anchor.getNode(); if (selection.isCollapsed()) { // Badly interpreted range selection when collapsed - #1482 if ( domSelection.type === 'Range' && domSelection.anchorNode === domSelection.focusNode ) { selection.dirty = true; } // If we have marked a collapsed selection format, and we're // within the given time range – then attempt to use that format // instead of getting the format from the anchor node. const windowEvent = getWindow(editor).event; const currentTimeStamp = windowEvent ? windowEvent.timeStamp : performance.now(); const [lastFormat, lastStyle, lastOffset, lastKey, timeStamp] = collapsedSelectionFormat; const root = $getRoot(); const isRootTextContentEmpty = editor.isComposing() === false && root.getTextContent() === ''; if ( currentTimeStamp < timeStamp + 200 && anchor.offset === lastOffset && anchor.key === lastKey ) { selection.format = lastFormat; selection.style = lastStyle; } else { if (anchor.type === 'text') { invariant( $isTextNode(anchorNode), 'Point.getNode() must return TextNode when type is text', ); selection.format = anchorNode.getFormat(); selection.style = anchorNode.getStyle(); } else if (anchor.type === 'element' && !isRootTextContentEmpty) { const lastNode = anchor.getNode(); selection.style = ''; if ( lastNode instanceof ParagraphNode && lastNode.getChildrenSize() === 0 ) { selection.style = lastNode.getTextStyle(); } else { selection.format = 0; } } } } else { const anchorKey = anchor.key; const focus = selection.focus; const focusKey = focus.key; const nodes = selection.getNodes(); const nodesLength = nodes.length; const isBackward = selection.isBackward(); const startOffset = isBackward ? focusOffset : anchorOffset; const endOffset = isBackward ? anchorOffset : focusOffset; const startKey = isBackward ? focusKey : anchorKey; const endKey = isBackward ? anchorKey : focusKey; let combinedFormat = IS_ALL_FORMATTING; let hasTextNodes = false; for (let i = 0; i < nodesLength; i++) { const node = nodes[i]; const textContentSize = node.getTextContentSize(); if ( $isTextNode(node) && textContentSize !== 0 && // Exclude empty text nodes at boundaries resulting from user's selection !( (i === 0 && node.__key === startKey && startOffset === textContentSize) || (i === nodesLength - 1 && node.__key === endKey && endOffset === 0) ) ) { // TODO: what about style? hasTextNodes = true; combinedFormat &= node.getFormat(); if (combinedFormat === 0) { break; } } } selection.format = hasTextNodes ? combinedFormat : 0; } } dispatchCommand(editor, SELECTION_CHANGE_COMMAND, undefined); }); } // This is a work-around is mainly Chrome specific bug where if you select // the contents of an empty block, you cannot easily unselect anything. // This results in a tiny selection box that looks buggy/broken. This can // also help other browsers when selection might "appear" lost, when it // really isn't. function onClick(event: PointerEvent, editor: LexicalEditor): void { updateEditor(editor, () => { const selection = $getSelection(); const domSelection = getDOMSelection(editor._window); const lastSelection = $getPreviousSelection(); if (domSelection) { if ($isRangeSelection(selection)) { const anchor = selection.anchor; const anchorNode = anchor.getNode(); if ( anchor.type === 'element' && anchor.offset === 0 && selection.isCollapsed() && !$isRootNode(anchorNode) && $getRoot().getChildrenSize() === 1 && anchorNode.getTopLevelElementOrThrow().isEmpty() && lastSelection !== null && selection.is(lastSelection) ) { domSelection.removeAllRanges(); selection.dirty = true; } else if (event.detail === 3 && !selection.isCollapsed()) { // Tripple click causing selection to overflow into the nearest element. In that // case visually it looks like a single element content is selected, focus node // is actually at the beginning of the next element (if present) and any manipulations // with selection (formatting) are affecting second element as well const focus = selection.focus; const focusNode = focus.getNode(); if (anchorNode !== focusNode) { if ($isElementNode(anchorNode)) { anchorNode.select(0); } else { anchorNode.getParentOrThrow().select(0); } } } } else if (event.pointerType === 'touch') { // This is used to update the selection on touch devices when the user clicks on text after a // node selection. See isSelectionChangeFromMouseDown for the inverse const domAnchorNode = domSelection.anchorNode; if (domAnchorNode !== null) { const nodeType = domAnchorNode.nodeType; // If the user is attempting to click selection back onto text, then // we should attempt create a range selection. // When we click on an empty paragraph node or the end of a paragraph that ends // with an image/poll, the nodeType will be ELEMENT_NODE if (nodeType === DOM_ELEMENT_TYPE || nodeType === DOM_TEXT_TYPE) { const newSelection = $internalCreateRangeSelection( lastSelection, domSelection, editor, event, ); $setSelection(newSelection); } } } } dispatchCommand(editor, CLICK_COMMAND, event); }); } function onPointerDown(event: PointerEvent, editor: LexicalEditor) { // TODO implement text drag & drop const target = event.target; const pointerType = event.pointerType; if (target instanceof Node && pointerType !== 'touch') { updateEditor(editor, () => { // Drag & drop should not recompute selection until mouse up; otherwise the initially // selected content is lost. if (!$isSelectionCapturedInDecorator(target)) { isSelectionChangeFromMouseDown = true; } }); } } function getTargetRange(event: InputEvent): null | StaticRange { if (!event.getTargetRanges) { return null; } const targetRanges = event.getTargetRanges(); if (targetRanges.length === 0) { return null; } return targetRanges[0]; } function $canRemoveText( anchorNode: TextNode | ElementNode, focusNode: TextNode | ElementNode, ): boolean { return ( anchorNode !== focusNode || $isElementNode(anchorNode) || $isElementNode(focusNode) || !anchorNode.isToken() || !focusNode.isToken() ); } function isPossiblyAndroidKeyPress(timeStamp: number): boolean { return ( lastKeyCode === 'MediaLast' && timeStamp < lastKeyDownTimeStamp + ANDROID_COMPOSITION_LATENCY ); } function onBeforeInput(event: InputEvent, editor: LexicalEditor): void { const inputType = event.inputType; const targetRange = getTargetRange(event); // We let the browser do its own thing for composition. if ( inputType === 'deleteCompositionText' || // If we're pasting in FF, we shouldn't get this event // as the `paste` event should have triggered, unless the // user has dom.event.clipboardevents.enabled disabled in // about:config. In that case, we need to process the // pasted content in the DOM mutation phase. (IS_FIREFOX && isFirefoxClipboardEvents(editor)) ) { return; } else if (inputType === 'insertCompositionText') { return; } updateEditor(editor, () => { const selection = $getSelection(); if (inputType === 'deleteContentBackward') { if (selection === null) { // Use previous selection const prevSelection = $getPreviousSelection(); if (!$isRangeSelection(prevSelection)) { return; } $setSelection(prevSelection.clone()); } if ($isRangeSelection(selection)) { const isSelectionAnchorSameAsFocus = selection.anchor.key === selection.focus.key; if ( isPossiblyAndroidKeyPress(event.timeStamp) && editor.isComposing() && isSelectionAnchorSameAsFocus ) { $setCompositionKey(null); lastKeyDownTimeStamp = 0; // Fixes an Android bug where selection flickers when backspacing setTimeout(() => { updateEditor(editor, () => { $setCompositionKey(null); }); }, ANDROID_COMPOSITION_LATENCY); if ($isRangeSelection(selection)) { const anchorNode = selection.anchor.getNode(); anchorNode.markDirty(); invariant( $isTextNode(anchorNode), 'Anchor node must be a TextNode', ); selection.style = anchorNode.getStyle(); } } else { $setCompositionKey(null); event.preventDefault(); // Chromium Android at the moment seems to ignore the preventDefault // on 'deleteContentBackward' and still deletes the content. Which leads // to multiple deletions. So we let the browser handle the deletion in this case. const selectedNodeText = selection.anchor.getNode().getTextContent(); const hasSelectedAllTextInNode = selection.anchor.offset === 0 && selection.focus.offset === selectedNodeText.length; const shouldLetBrowserHandleDelete = IS_ANDROID_CHROME && isSelectionAnchorSameAsFocus && !hasSelectedAllTextInNode; if (!shouldLetBrowserHandleDelete) { dispatchCommand(editor, DELETE_CHARACTER_COMMAND, true); } } return; } } if (!$isRangeSelection(selection)) { return; } 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 || unprocessedBeforeInputData !== null) && selection.isCollapsed() && !$isRootNode(selection.anchor.getNode()) && targetRange !== null ) { selection.applyDOMRange(targetRange); } unprocessedBeforeInputData = null; const anchor = selection.anchor; const focus = selection.focus; const anchorNode = anchor.getNode(); const focusNode = focus.getNode(); if (inputType === 'insertText' || inputType === 'insertTranspose') { if (data === '\n') { event.preventDefault(); dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, false); } else if (data === DOUBLE_LINE_BREAK) { event.preventDefault(); dispatchCommand(editor, INSERT_PARAGRAPH_COMMAND, undefined); } else if (data == null && event.dataTransfer) { // Gets around a Safari text replacement bug. const text = event.dataTransfer.getData('text/plain'); event.preventDefault(); selection.insertRawText(text); } else if ( data != null && $shouldPreventDefaultAndInsertText( selection, targetRange, data, event.timeStamp, true, ) ) { event.preventDefault(); dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, data); } else { unprocessedBeforeInputData = data; } lastBeforeInputInsertTextTimeStamp = event.timeStamp; return; } // Prevent the browser from carrying out // the input event, so we can control the // output. event.preventDefault(); switch (inputType) { case 'insertFromYank': case 'insertFromDrop': case 'insertReplacementText': { dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, event); break; } case 'insertFromComposition': { // This is the end of composition $setCompositionKey(null); dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, event); break; } case 'insertLineBreak': { // Used for Android $setCompositionKey(null); dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, false); break; } case 'insertParagraph': { // Used for Android $setCompositionKey(null); // Safari does not provide the type "insertLineBreak". // So instead, we need to infer it from the keyboard event. // We do not apply this logic to iOS to allow newline auto-capitalization // work without creating linebreaks when pressing Enter if (isInsertLineBreak && !IS_IOS) { isInsertLineBreak = false; dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, false); } else { dispatchCommand(editor, INSERT_PARAGRAPH_COMMAND, undefined); } break; } case 'insertFromPaste': case 'insertFromPasteAsQuotation': { dispatchCommand(editor, PASTE_COMMAND, event); break; } case 'deleteByComposition': { if ($canRemoveText(anchorNode, focusNode)) { dispatchCommand(editor, REMOVE_TEXT_COMMAND, event); } break; } case 'deleteByDrag': case 'deleteByCut': { dispatchCommand(editor, REMOVE_TEXT_COMMAND, event); break; } case 'deleteContent': { dispatchCommand(editor, DELETE_CHARACTER_COMMAND, false); break; } case 'deleteWordBackward': { dispatchCommand(editor, DELETE_WORD_COMMAND, true); break; } case 'deleteWordForward': { dispatchCommand(editor, DELETE_WORD_COMMAND, false); break; } case 'deleteHardLineBackward': case 'deleteSoftLineBackward': { dispatchCommand(editor, DELETE_LINE_COMMAND, true); break; } case 'deleteContentForward': case 'deleteHardLineForward': case 'deleteSoftLineForward': { dispatchCommand(editor, DELETE_LINE_COMMAND, false); break; } case 'formatStrikeThrough': { dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'strikethrough'); break; } case 'formatBold': { dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'bold'); break; } case 'formatItalic': { dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'italic'); break; } case 'formatUnderline': { dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'underline'); break; } case 'historyUndo': { dispatchCommand(editor, UNDO_COMMAND, undefined); break; } case 'historyRedo': { dispatchCommand(editor, REDO_COMMAND, undefined); break; } default: // NO-OP } }); } function onInput(event: InputEvent, editor: LexicalEditor): void { // We don't want the onInput to bubble, in the case of nested editors. event.stopPropagation(); updateEditor(editor, () => { const selection = $getSelection(); const data = event.data; const targetRange = getTargetRange(event); if ( data != null && $isRangeSelection(selection) && $shouldPreventDefaultAndInsertText( selection, targetRange, data, event.timeStamp, false, ) ) { // Given we're over-riding the default behavior, we will need // to ensure to disable composition before dispatching the // insertText command for when changing the sequence for FF. if (isFirefoxEndingComposition) { $onCompositionEndImpl(editor, data); isFirefoxEndingComposition = false; } const anchor = selection.anchor; const anchorNode = anchor.getNode(); const domSelection = getDOMSelection(editor._window); if (domSelection === null) { return; } const isBackward = selection.isBackward(); const startOffset = isBackward ? selection.anchor.offset : selection.focus.offset; const endOffset = isBackward ? selection.focus.offset : selection.anchor.offset; // If the content is the same as inserted, then don't dispatch an insertion. // Given onInput doesn't take the current selection (it uses the previous) // we can compare that against what the DOM currently says. if ( !CAN_USE_BEFORE_INPUT || selection.isCollapsed() || !$isTextNode(anchorNode) || domSelection.anchorNode === null || anchorNode.getTextContent().slice(0, startOffset) + data + anchorNode.getTextContent().slice(startOffset + endOffset) !== getAnchorTextFromDOM(domSelection.anchorNode) ) { dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, data); } const textLength = data.length; // Another hack for FF, as it's possible that the IME is still // open, even though compositionend has already fired (sigh). if ( IS_FIREFOX && textLength > 1 && event.inputType === 'insertCompositionText' && !editor.isComposing() ) { selection.anchor.offset -= textLength; } // This ensures consistency on Android. if (!IS_SAFARI && !IS_IOS && !IS_APPLE_WEBKIT && editor.isComposing()) { lastKeyDownTimeStamp = 0; $setCompositionKey(null); } } else { const characterData = data !== null ? data : undefined; $updateSelectedTextFromDOM(false, editor, characterData); // onInput always fires after onCompositionEnd for FF. if (isFirefoxEndingComposition) { $onCompositionEndImpl(editor, data || undefined); isFirefoxEndingComposition = false; } } // Also flush any other mutations that might have occurred // since the change. $flushMutations(); }); unprocessedBeforeInputData = null; } function onCompositionStart( event: CompositionEvent, editor: LexicalEditor, ): void { updateEditor(editor, () => { const selection = $getSelection(); if ($isRangeSelection(selection) && !editor.isComposing()) { const anchor = selection.anchor; const node = selection.anchor.getNode(); $setCompositionKey(anchor.key); if ( // If it has been 30ms since the last keydown, then we should // apply the empty space heuristic. We can't do this for Safari, // as the keydown fires after composition start. event.timeStamp < lastKeyDownTimeStamp + ANDROID_COMPOSITION_LATENCY || // FF has issues around composing multibyte characters, so we also // need to invoke the empty space heuristic below. anchor.type === 'element' || !selection.isCollapsed() || ($isTextNode(node) && node.getStyle() !== selection.style) ) { // We insert a zero width character, ready for the composition // to get inserted into the new node we create. If // we don't do this, Safari will fail on us because // there is no text node matching the selection. dispatchCommand( editor, CONTROLLED_TEXT_INSERTION_COMMAND, COMPOSITION_START_CHAR, ); } } }); } function $onCompositionEndImpl(editor: LexicalEditor, data?: string): void { const compositionKey = editor._compositionKey; $setCompositionKey(null); // Handle termination of composition. if (compositionKey !== null && data != null) { // Composition can sometimes move to an adjacent DOM node when backspacing. // So check for the empty case. if (data === '') { const node = $getNodeByKey(compositionKey); const textNode = getDOMTextNode(editor.getElementByKey(compositionKey)); if ( textNode !== null && textNode.nodeValue !== null && $isTextNode(node) ) { $updateTextNodeFromDOMContent( node, textNode.nodeValue, null, null, true, ); } return; } // Composition can sometimes be that of a new line. In which case, we need to // handle that accordingly. if (data[data.length - 1] === '\n') { const selection = $getSelection(); if ($isRangeSelection(selection)) { // If the last character is a line break, we also need to insert // a line break. const focus = selection.focus; selection.anchor.set(focus.key, focus.offset, focus.type); dispatchCommand(editor, KEY_ENTER_COMMAND, null); return; } } } $updateSelectedTextFromDOM(true, editor, data); } function onCompositionEnd( event: CompositionEvent, editor: LexicalEditor, ): void { // Firefox fires onCompositionEnd before onInput, but Chrome/Webkit, // fire onInput before onCompositionEnd. To ensure the sequence works // like Chrome/Webkit we use the isFirefoxEndingComposition flag to // defer handling of onCompositionEnd in Firefox till we have processed // the logic in onInput. if (IS_FIREFOX) { isFirefoxEndingComposition = true; } else { updateEditor(editor, () => { $onCompositionEndImpl(editor, event.data); }); } } function onKeyDown(event: KeyboardEvent, editor: LexicalEditor): void { lastKeyDownTimeStamp = event.timeStamp; lastKeyCode = event.key; if (editor.isComposing()) { return; } const {key, shiftKey, ctrlKey, metaKey, altKey} = event; if (dispatchCommand(editor, KEY_DOWN_COMMAND, event)) { return; } if (key == null) { return; } if (isMoveForward(key, ctrlKey, altKey, metaKey)) { dispatchCommand(editor, KEY_ARROW_RIGHT_COMMAND, event); } else if (isMoveToEnd(key, ctrlKey, shiftKey, altKey, metaKey)) { dispatchCommand(editor, MOVE_TO_END, event); } else if (isMoveBackward(key, ctrlKey, altKey, metaKey)) { dispatchCommand(editor, KEY_ARROW_LEFT_COMMAND, event); } else if (isMoveToStart(key, ctrlKey, shiftKey, altKey, metaKey)) { dispatchCommand(editor, MOVE_TO_START, event); } else if (isMoveUp(key, ctrlKey, metaKey)) { dispatchCommand(editor, KEY_ARROW_UP_COMMAND, event); } else if (isMoveDown(key, ctrlKey, metaKey)) { dispatchCommand(editor, KEY_ARROW_DOWN_COMMAND, event); } else if (isLineBreak(key, shiftKey)) { isInsertLineBreak = true; dispatchCommand(editor, KEY_ENTER_COMMAND, event); } else if (isSpace(key)) { dispatchCommand(editor, KEY_SPACE_COMMAND, event); } else if (isOpenLineBreak(key, ctrlKey)) { event.preventDefault(); isInsertLineBreak = true; dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, true); } else if (isParagraph(key, shiftKey)) { isInsertLineBreak = false; dispatchCommand(editor, KEY_ENTER_COMMAND, event); } else if (isDeleteBackward(key, altKey, metaKey, ctrlKey)) { if (isBackspace(key)) { dispatchCommand(editor, KEY_BACKSPACE_COMMAND, event); } else { event.preventDefault(); dispatchCommand(editor, DELETE_CHARACTER_COMMAND, true); } } else if (isEscape(key)) { dispatchCommand(editor, KEY_ESCAPE_COMMAND, event); } else if (isDeleteForward(key, ctrlKey, shiftKey, altKey, metaKey)) { if (isDelete(key)) { dispatchCommand(editor, KEY_DELETE_COMMAND, event); } else { event.preventDefault(); dispatchCommand(editor, DELETE_CHARACTER_COMMAND, false); } } else if (isDeleteWordBackward(key, altKey, ctrlKey)) { event.preventDefault(); dispatchCommand(editor, DELETE_WORD_COMMAND, true); } else if (isDeleteWordForward(key, altKey, ctrlKey)) { event.preventDefault(); dispatchCommand(editor, DELETE_WORD_COMMAND, false); } else if (isDeleteLineBackward(key, metaKey)) { event.preventDefault(); dispatchCommand(editor, DELETE_LINE_COMMAND, true); } else if (isDeleteLineForward(key, metaKey)) { event.preventDefault(); dispatchCommand(editor, DELETE_LINE_COMMAND, false); } else if (isBold(key, altKey, metaKey, ctrlKey)) { event.preventDefault(); dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'bold'); } else if (isUnderline(key, altKey, metaKey, ctrlKey)) { event.preventDefault(); dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'underline'); } else if (isItalic(key, altKey, metaKey, ctrlKey)) { event.preventDefault(); dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'italic'); } else if (isTab(key, altKey, ctrlKey, metaKey)) { dispatchCommand(editor, KEY_TAB_COMMAND, event); } else if (isUndo(key, shiftKey, metaKey, ctrlKey)) { event.preventDefault(); dispatchCommand(editor, UNDO_COMMAND, undefined); } else if (isRedo(key, shiftKey, metaKey, ctrlKey)) { event.preventDefault(); dispatchCommand(editor, REDO_COMMAND, undefined); } else { const prevSelection = editor._editorState._selection; if ($isNodeSelection(prevSelection)) { if (isCopy(key, shiftKey, metaKey, ctrlKey)) { event.preventDefault(); dispatchCommand(editor, COPY_COMMAND, event); } else if (isCut(key, shiftKey, metaKey, ctrlKey)) { event.preventDefault(); dispatchCommand(editor, CUT_COMMAND, event); } else if (isSelectAll(key, metaKey, ctrlKey)) { event.preventDefault(); dispatchCommand(editor, SELECT_ALL_COMMAND, event); } // FF does it well (no need to override behavior) } else if (!IS_FIREFOX && isSelectAll(key, metaKey, ctrlKey)) { event.preventDefault(); dispatchCommand(editor, SELECT_ALL_COMMAND, event); } } if (isModifier(ctrlKey, shiftKey, altKey, metaKey)) { dispatchCommand(editor, KEY_MODIFIER_COMMAND, event); } } function getRootElementRemoveHandles( rootElement: HTMLElement, ): RootElementRemoveHandles { // @ts-expect-error: internal field let eventHandles = rootElement.__lexicalEventHandles; if (eventHandles === undefined) { eventHandles = []; // @ts-expect-error: internal field rootElement.__lexicalEventHandles = eventHandles; } return eventHandles; } // Mapping root editors to their active nested editors, contains nested editors // mapping only, so if root editor is selected map will have no reference to free up memory const activeNestedEditorsMap: Map = new Map(); function onDocumentSelectionChange(event: Event): void { const target = event.target as null | Element | Document; const targetWindow = target == null ? null : target.nodeType === 9 ? (target as Document).defaultView : (target as Element).ownerDocument.defaultView; const domSelection = getDOMSelection(targetWindow); if (domSelection === null) { return; } const nextActiveEditor = getNearestEditorFromDOMNode(domSelection.anchorNode); if (nextActiveEditor === null) { return; } if (isSelectionChangeFromMouseDown) { isSelectionChangeFromMouseDown = false; updateEditor(nextActiveEditor, () => { const lastSelection = $getPreviousSelection(); const domAnchorNode = domSelection.anchorNode; if (domAnchorNode === null) { return; } const nodeType = domAnchorNode.nodeType; // If the user is attempting to click selection back onto text, then // we should attempt create a range selection. // When we click on an empty paragraph node or the end of a paragraph that ends // with an image/poll, the nodeType will be ELEMENT_NODE if (nodeType !== DOM_ELEMENT_TYPE && nodeType !== DOM_TEXT_TYPE) { return; } const newSelection = $internalCreateRangeSelection( lastSelection, domSelection, nextActiveEditor, event, ); $setSelection(newSelection); }); } // When editor receives selection change event, we're checking if // it has any sibling editors (within same parent editor) that were active // before, and trigger selection change on it to nullify selection. const editors = getEditorsToPropagate(nextActiveEditor); const rootEditor = editors[editors.length - 1]; const rootEditorKey = rootEditor._key; const activeNestedEditor = activeNestedEditorsMap.get(rootEditorKey); const prevActiveEditor = activeNestedEditor || rootEditor; if (prevActiveEditor !== nextActiveEditor) { onSelectionChange(domSelection, prevActiveEditor, false); } onSelectionChange(domSelection, nextActiveEditor, true); // If newly selected editor is nested, then add it to the map, clean map otherwise if (nextActiveEditor !== rootEditor) { activeNestedEditorsMap.set(rootEditorKey, nextActiveEditor); } else if (activeNestedEditor) { activeNestedEditorsMap.delete(rootEditorKey); } } function stopLexicalPropagation(event: Event): void { // We attach a special property to ensure the same event doesn't re-fire // for parent editors. // @ts-ignore event._lexicalHandled = true; } function hasStoppedLexicalPropagation(event: Event): boolean { // @ts-ignore const stopped = event._lexicalHandled === true; return stopped; } export type EventHandler = (event: Event, editor: LexicalEditor) => void; export function addRootElementEvents( rootElement: HTMLElement, editor: LexicalEditor, ): void { // We only want to have a single global selectionchange event handler, shared // between all editor instances. const doc = rootElement.ownerDocument; const documentRootElementsCount = rootElementsRegistered.get(doc); if ( documentRootElementsCount === undefined || documentRootElementsCount < 1 ) { doc.addEventListener('selectionchange', onDocumentSelectionChange); } rootElementsRegistered.set(doc, (documentRootElementsCount || 0) + 1); // @ts-expect-error: internal field rootElement.__lexicalEditor = editor; const removeHandles = getRootElementRemoveHandles(rootElement); for (let i = 0; i < rootElementEvents.length; i++) { const [eventName, onEvent] = rootElementEvents[i]; const eventHandler = typeof onEvent === 'function' ? (event: Event) => { if (hasStoppedLexicalPropagation(event)) { return; } stopLexicalPropagation(event); if (editor.isEditable() || eventName === 'click') { onEvent(event, editor); } } : (event: Event) => { if (hasStoppedLexicalPropagation(event)) { return; } stopLexicalPropagation(event); const isEditable = editor.isEditable(); switch (eventName) { case 'cut': return ( isEditable && dispatchCommand(editor, CUT_COMMAND, event as ClipboardEvent) ); case 'copy': return dispatchCommand( editor, COPY_COMMAND, event as ClipboardEvent, ); case 'paste': return ( isEditable && dispatchCommand( editor, PASTE_COMMAND, event as ClipboardEvent, ) ); case 'dragstart': return ( isEditable && dispatchCommand(editor, DRAGSTART_COMMAND, event as DragEvent) ); case 'dragover': return ( isEditable && dispatchCommand(editor, DRAGOVER_COMMAND, event as DragEvent) ); case 'dragend': return ( isEditable && dispatchCommand(editor, DRAGEND_COMMAND, event as DragEvent) ); case 'focus': return ( isEditable && dispatchCommand(editor, FOCUS_COMMAND, event as FocusEvent) ); case 'blur': { return ( isEditable && dispatchCommand(editor, BLUR_COMMAND, event as FocusEvent) ); } case 'drop': return ( isEditable && dispatchCommand(editor, DROP_COMMAND, event as DragEvent) ); } }; rootElement.addEventListener(eventName, eventHandler); removeHandles.push(() => { rootElement.removeEventListener(eventName, eventHandler); }); } } export function removeRootElementEvents(rootElement: HTMLElement): void { const doc = rootElement.ownerDocument; const documentRootElementsCount = rootElementsRegistered.get(doc); invariant( documentRootElementsCount !== undefined, 'Root element not registered', ); // We only want to have a single global selectionchange event handler, shared // between all editor instances. const newCount = documentRootElementsCount - 1; invariant(newCount >= 0, 'Root element count less than 0'); rootElementsRegistered.set(doc, newCount); if (newCount === 0) { doc.removeEventListener('selectionchange', onDocumentSelectionChange); } const editor = getEditorPropertyFromDOMNode(rootElement); if (isLexicalEditor(editor)) { cleanActiveNestedEditorsMap(editor); // @ts-expect-error: internal field rootElement.__lexicalEditor = null; } else if (editor) { invariant( false, 'Attempted to remove event handlers from a node that does not belong to this build of Lexical', ); } const removeHandles = getRootElementRemoveHandles(rootElement); for (let i = 0; i < removeHandles.length; i++) { removeHandles[i](); } // @ts-expect-error: internal field rootElement.__lexicalEventHandles = []; } function cleanActiveNestedEditorsMap(editor: LexicalEditor) { if (editor._parentEditor !== null) { // For nested editor cleanup map if this editor was marked as active const editors = getEditorsToPropagate(editor); const rootEditor = editors[editors.length - 1]; const rootEditorKey = rootEditor._key; if (activeNestedEditorsMap.get(rootEditorKey) === editor) { activeNestedEditorsMap.delete(rootEditorKey); } } else { // For top-level editors cleanup map activeNestedEditorsMap.delete(editor._key); } } export function markSelectionChangeFromDOMUpdate(): void { isSelectionChangeFromDOMUpdate = true; } export function markCollapsedSelectionFormat( format: number, style: string, offset: number, key: NodeKey, timeStamp: number, ): void { collapsedSelectionFormat = [format, style, offset, key, timeStamp]; }