/** * 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 {InputState, LexicalCommand, LexicalEditor} from './LexicalEditor'; import type {KeyboardShortcutMatch} from './LexicalKeyboardShortcuts'; import type {NodeKey} from './LexicalNode'; import type {ElementNode} from './nodes/LexicalElementNode'; import type {TextNode} from './nodes/LexicalTextNode'; import invariant from '@lexical/internal/invariant'; import warnOnlyOnce from '@lexical/internal/warnOnlyOnce'; import { $createTextNode, $getPreviousSelection, $getRoot, $getSelection, $isBlockElementNode, $isDecoratorNode, $isElementNode, $isLineBreakNode, $isNodeSelection, $isRangeSelection, $isRootNode, $isTabNode, $isTextNode, $setCompositionKey, BLUR_COMMAND, CLICK_COMMAND, COMMAND_PRIORITY_EDITOR, COMPOSITION_END_TAG, COMPOSITION_START_TAG, 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, PASTE_COMMAND, REDO_COMMAND, REMOVE_TEXT_COMMAND, SELECTION_CHANGE_COMMAND, SKIP_SELECTION_FOCUS_TAG, UNDO_COMMAND, } from '.'; import { CAN_USE_BEFORE_INPUT, IS_ANDROID_CHROME, IS_APPLE, IS_APPLE_WEBKIT, IS_FIREFOX, IS_IOS, IS_SAFARI, } from './environment'; import { BEFORE_INPUT_COMMAND, COMPOSITION_END_COMMAND, COMPOSITION_START_COMMAND, INPUT_COMMAND, KEY_MODIFIER_COMMAND, SELECT_ALL_COMMAND, } from './LexicalCommands'; import { COMPOSITION_START_CHAR, DOUBLE_LINE_BREAK, IS_ALL_FORMATTING, } from './LexicalConstants'; import { compileKeyboardShortcuts, CONTROL_OR_ALT, CONTROL_OR_META, } from './LexicalKeyboardShortcuts'; import {createRefCountedRegistry} from './LexicalRefCountedRegistry'; import { $internalCreateRangeSelection, type RangeSelection, } from './LexicalSelection'; import {getActiveEditor, updateEditorSync} from './LexicalUpdates'; import { $addUpdateTag, $findMatchingParent, $flushMutations, $getAdjacentNode, $getDOMTextNode, $getNodeByKey, $isTokenOrSegmented, $isTokenOrTab, $setSelection, $shouldInsertTextAfterOrBeforeTextNode, $updateSelectedTextFromDOM, $updateTextNodeFromDOMContent, dispatchCommand, doesContainSurrogatePair, type DOMSelectionBoundaryPoints, getActiveElementDeep, getAnchorTextFromDOM, getComposedEventTarget, getDOMOwnerDocument, getDOMSelection, getDOMSelectionFromTarget, getDOMSelectionPoints, getEditorPropertyFromDOMNode, getEditorsToPropagate, getNearestEditorFromDOMNode, getWindow, isBackspace, isDOMCapturingSelection, isDOMNode, isDOMShadowRoot, isDOMTextNode, isFirefoxClipboardEvents, isHTMLElement, isLexicalEditor, isModifier, isSelectionWithinEditor, type KeyboardEventModifierMask, } from './LexicalUtils'; import {registerEventListener} from './utils/registerEventListener'; type RootElementRemoveHandles = (() => void)[]; type RootElementEvents = [ 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), ]); } // Node can be moved between documents (for example using createPortal), so we // need to track the document each root element was originally registered on. const rootElementToDocument = new WeakMap(); // Per-document state read by the shared `selectionchange` handler, keyed by the // document each root element was registered against: // - `editors` is the candidate set `onDocumentSelectionChange` attributes the // event to, using each editor's shadow-aware anchor rather than guessing from // `Selection.anchorNode` (retargeted to a light-DOM ancestor inside a shadow // tree). // - `hasShadowEditor` caches whether any editor here is shadow-mounted // (`undefined` = needs recompute), so the handler avoids an O(editors) // `getRootNode()` scan per selectionchange. Invalidated whenever the editor // set changes — which, via setRootElement, is where an editor's root (and // thus its shadow-mounted status) is rebound. interface DocumentRegistration { editors: Set; hasShadowEditor: boolean | undefined; } const documentRegistrations = new WeakMap(); // The single shared `selectionchange` listener per document, reference counted // across all editors registered against that document: attached when the first // root element is registered and removed when the last one is unregistered. const documentSelectionChange = createRefCountedRegistry((doc: Document) => { doc.addEventListener('selectionchange', onDocumentSelectionChange); return () => doc.removeEventListener('selectionchange', onDocumentSelectionChange); }); // 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, cachedDOMSelectionPoints?: DOMSelectionBoundaryPoints | null, ): boolean { const anchor = selection.anchor; const focus = selection.focus; const anchorNode = anchor.getNode(); const editor = getActiveEditor(); let domSelectionPoints: DOMSelectionBoundaryPoints | null; if (cachedDOMSelectionPoints !== undefined) { domSelectionPoints = cachedDOMSelectionPoints; } else { const domSelection = getDOMSelection(getWindow(editor)); domSelectionPoints = domSelection !== null ? getDOMSelectionPoints(domSelection, editor._rootElement) : null; } const domAnchorNode = domSelectionPoints !== null ? domSelectionPoints.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'). editor._inputState.lastBeforeInputInsertTextTimeStamp < timeStamp + 50)) || (anchorNode.isDirty() && textLength < 2) || // TODO consider if there are other scenarios when multiple code units // should be addressed here doesContainSurrogatePair(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(anchorNode, backingAnchorElement, editor)) || // If TargetRange is not the same as the DOM selection; browser trying to edit random parts // of the editor. (domSelectionPoints !== null && domTargetRange !== null && (!domTargetRange.collapsed || domTargetRange.startContainer !== domSelectionPoints.anchorNode || domTargetRange.startOffset !== domSelectionPoints.anchorOffset)) || // Check if we're changing from bold to italics, or some other format. (!anchorNode.isComposing() && (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 ( isDOMTextNode(domNode) && domNode.nodeValue !== null && offset !== 0 && offset !== domNode.nodeValue.length ); } function onSelectionChange( domSelection: Selection, editor: LexicalEditor, isActive: boolean, ): void { // Shadow-aware boundary points so isSelectionWithinEditor below isn't // fooled by the retargeted shadow host into dropping the selection. const { anchorNode: anchorDOM, anchorOffset, focusNode: focusDOM, focusOffset, } = getDOMSelectionPoints(domSelection, editor._rootElement); const inputState = editor._inputState; if (inputState.isSelectionChangeFromDOMUpdate) { inputState.isSelectionChangeFromDOMUpdate = false; const appliedPoints = inputState.selectionChangeFromDOMUpdatePoints; inputState.selectionChangeFromDOMUpdatePoints = null; // 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. // // The skip is only safe when this event actually observes the selection // the reconciler applied. The flag can outlive its own event — WebKit // fires no selectionchange when the applied selection matches what the // DOM already had — and then the next real user selection (e.g. a click // into text after select-all) would be swallowed here. if ( shouldSkipSelectionChange(anchorDOM, anchorOffset) && shouldSkipSelectionChange(focusDOM, focusOffset) && !inputState.postDeleteSelectionToRestore && (appliedPoints === null || (appliedPoints.anchorNode === anchorDOM && appliedPoints.anchorOffset === anchorOffset && appliedPoints.focusNode === focusDOM && appliedPoints.focusOffset === focusOffset)) ) { return; } } updateEditorSync(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; } let selection = $getSelection(); // Restore selection in the event of incorrect rightward shift after deletion if ( inputState.postDeleteSelectionToRestore && $isRangeSelection(selection) && selection.isCollapsed() ) { const curAnchor = selection.anchor; const prevAnchor = inputState.postDeleteSelectionToRestore.anchor; if ( // Rightward shift in same node (curAnchor.key === prevAnchor.key && curAnchor.offset === prevAnchor.offset + 1) || // Or rightward shift into sibling node (curAnchor.offset === 1 && prevAnchor.getNode().is(curAnchor.getNode().getPreviousSibling())) ) { // Restore selection selection = inputState.postDeleteSelectionToRestore.clone(); $setSelection(selection); } } inputState.postDeleteSelectionToRestore = null; // 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' && anchorDOM === focusDOM) { 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 { format: lastFormat, style: lastStyle, offset: lastOffset, key: lastKey, timeStamp, } = inputState.collapsedSelectionFormat; const root = $getRoot(); const isRootTextContentEmpty = editor.isComposing() === false && root.getTextContent() === ''; if ( currentTimeStamp < timeStamp + 200 && anchor.offset === lastOffset && anchor.key === lastKey ) { $updateSelectionFormatStyle(selection, lastFormat, lastStyle); } else { if (anchor.type === 'text') { invariant( $isTextNode(anchorNode), 'Point.getNode() must return TextNode when type is text', ); $updateSelectionFormatStyleFromTextNode(selection, anchorNode); } else if (anchor.type === 'element' && !isRootTextContentEmpty) { invariant( $isElementNode(anchorNode), 'Point.getNode() must return ElementNode when type is element', ); const lastNode = anchor.getNode(); if ( // This previously applied to all ParagraphNode lastNode.isEmpty() ) { $updateSelectionFormatStyleFromElementNode(selection, lastNode); } else { $updateSelectionFormatStyle(selection, selection.format, ''); } } } } 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); }); } function $updateSelectionFormatStyle( selection: RangeSelection, format: number, style: string, ) { if (selection.format !== format || selection.style !== style) { selection.format = format; selection.style = style; selection.dirty = true; } } function $updateSelectionFormatStyleFromTextNode( selection: RangeSelection, node: TextNode, ) { const format = node.getFormat(); const style = node.getStyle(); $updateSelectionFormatStyle(selection, format, style); } function $updateSelectionFormatStyleFromElementNode( selection: RangeSelection, node: ElementNode, ) { const format = node.getTextFormat(); const style = node.getTextStyle(); $updateSelectionFormatStyle(selection, format, style); } // 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 { updateEditorSync(editor, () => { const selection = $getSelection(); const domSelection = getDOMSelection(getWindow(editor)); 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.pointerType === 'touch' || event.pointerType === 'pen') { // This is used to update the selection on touch devices (including Apple Pencil) when the user clicks on text after a // node selection. See isSelectionChangeFromMouseDown for the inverse const domSelectionPoints = getDOMSelectionPoints( domSelection, editor._rootElement, ); const domAnchorNode = domSelectionPoints.anchorNode; // 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 (isHTMLElement(domAnchorNode) || isDOMTextNode(domAnchorNode)) { const newSelection = $internalCreateRangeSelection( lastSelection, domSelection, editor, event, ); $setSelection(newSelection); } } } // Firefox produces no DOM range when clicking between block-level // decorators (rangeCount === 0). Use click coordinates to compute // the correct child offset. Only act when the click landed directly // on the root element (not inside a child like a table cell). if (IS_FIREFOX && domSelection !== null && domSelection.rangeCount === 0) { const rootElement = editor._rootElement; if (rootElement !== null && event.target === rootElement) { const clientY = event.clientY; let offset = rootElement.childNodes.length; for (let i = 0; i < rootElement.childNodes.length; i++) { const child = rootElement.childNodes[i]; if (isHTMLElement(child)) { const rect = child.getBoundingClientRect(); if (clientY <= (rect.top + rect.bottom) / 2) { offset = i; break; } } } domSelection.setBaseAndExtent(rootElement, offset, rootElement, offset); const newSelection = $internalCreateRangeSelection( lastSelection, domSelection, editor, event, ); if (newSelection !== null) { $setSelection(newSelection); } else { domSelection.removeAllRanges(); } } } dispatchCommand(editor, CLICK_COMMAND, event); }); } function onPointerDown(event: PointerEvent, editor: LexicalEditor) { // TODO implement text drag & drop // Resolve to the composed target so a pointerdown inside a decorator's // open shadow root reports the real internal element rather than the // outer shadow host the engine retargets to. const target = getComposedEventTarget(event); const pointerType = event.pointerType; if ( isDOMNode(target) && pointerType !== 'touch' && pointerType !== 'pen' && event.button === 0 ) { updateEditorSync(editor, () => { // Drag & drop should not recompute selection until mouse up; otherwise the initially // selected content is lost. if (!isDOMCapturingSelection(target, editor)) { editor._inputState.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]; } // When a macOS text replacement is accepted, Chrome and Firefox fire input events for the key press that // triggered the acceptance *before* the one for the replacement text. This causes the caret to be placed // before the acceptance boundary. This function moves the caret past the acceptance boundary. function $maybeMoveSelectionPastTrailingAcceptanceBoundary( insertedText: string | null | undefined, ): void { const {lastKeyCode} = getActiveEditor()._inputState; if (insertedText == null || insertedText.length <= 1 || lastKeyCode == null) { return; } const characterToSearchFor = lastKeyCode.length === 1 ? lastKeyCode : lastKeyCode === 'Enter' ? '\n' : lastKeyCode === 'Tab' ? '\t' : null; if (!characterToSearchFor) { return; } const selection = $getSelection(); if (!$isRangeSelection(selection) || !selection.isCollapsed()) { return; } const anchorNode = selection.anchor.getNode(); if (!$isTextNode(anchorNode)) { return; } const {offset} = selection.anchor; if (anchorNode.getTextContentSize() === offset) { const nextSibling = anchorNode.getNextSibling(); if (characterToSearchFor === '\n') { // iOS fires insertReplacementText *before* the Enter's insertParagraph, so no // acceptance boundary exists yet; moving here lands the caret in the block that // already followed, and Enter then splits that one instead. if (IS_IOS) { return; } if ($isLineBreakNode(nextSibling)) { nextSibling.selectEnd(); } else if (!nextSibling) { const block = $findMatchingParent(anchorNode, $isBlockElementNode); const nextBlock = block && block.getNextSibling(); if ($isElementNode(nextBlock)) { nextBlock.selectStart(); } } } else if (characterToSearchFor === '\t') { if ($isTabNode(nextSibling)) { nextSibling.selectEnd(); } } else if ( $isTextNode(nextSibling) && nextSibling.getTextContent()[0] === characterToSearchFor ) { nextSibling.select(1, 1); } } else if (anchorNode.getTextContent()[offset] === characterToSearchFor) { anchorNode.select(offset + 1, offset + 1); } } function $canRemoveText( anchorNode: TextNode | ElementNode, focusNode: TextNode | ElementNode, ): boolean { return ( anchorNode !== focusNode || $isElementNode(anchorNode) || $isElementNode(focusNode) || !$isTokenOrTab(anchorNode) || !$isTokenOrTab(focusNode) ); } function isPossiblyAndroidKeyPress( inputState: InputState, timeStamp: number, ): boolean { return ( inputState.lastKeyCode === 'MediaLast' && timeStamp < inputState.lastKeyDownTimeStamp + ANDROID_COMPOSITION_LATENCY ); } function clearHandledSelectionCommandInsertText(inputState: InputState): void { inputState.isInsertTextAfterHandledSelectionCommand = false; if (inputState.handledSelectionCommandTimeoutId !== null) { clearTimeout(inputState.handledSelectionCommandTimeoutId); inputState.handledSelectionCommandTimeoutId = null; } } function markHandledSelectionCommandInsertText(inputState: InputState): void { clearHandledSelectionCommandInsertText(inputState); inputState.isInsertTextAfterHandledSelectionCommand = true; inputState.handledSelectionCommandTimeoutId = setTimeout( () => clearHandledSelectionCommandInsertText(inputState), 0, ); } export function registerDefaultCommandHandlers(editor: LexicalEditor) { editor.registerCommand( BEFORE_INPUT_COMMAND, $handleBeforeInput, COMMAND_PRIORITY_EDITOR, ); editor.registerCommand(INPUT_COMMAND, $handleInput, COMMAND_PRIORITY_EDITOR); editor.registerCommand( COMPOSITION_START_COMMAND, $handleCompositionStart, COMMAND_PRIORITY_EDITOR, ); editor.registerCommand( COMPOSITION_END_COMMAND, $handleCompositionEnd, COMMAND_PRIORITY_EDITOR, ); editor.registerCommand( KEY_DOWN_COMMAND, $handleKeyDown, COMMAND_PRIORITY_EDITOR, ); } /** * Returns true when a `beforeinput` / `input` event belongs to a native * control (e.g. an `` or `