UNPKG

lexical

Version:

Lexical is an extensible text editor framework that provides excellent reliability, accessible and performance.

1,523 lines (1,395 loc) 56.1 kB
/** * 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 invariant from '@lexical/internal/invariant'; import warnOnlyOnce from '@lexical/internal/warnOnlyOnce'; import { $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_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 { $internalCreateRangeSelection, RangeSelection, } from './LexicalSelection'; import {getActiveEditor, updateEditorSync} from './LexicalUpdates'; import { $addUpdateTag, $findMatchingParent, $flushMutations, $getAdjacentNode, $getDOMTextNode, $getNodeByKey, $isSelectionCapturedInDecorator, $isTokenOrSegmented, $isTokenOrTab, $setSelection, $shouldInsertTextAfterOrBeforeTextNode, $updateSelectedTextFromDOM, $updateTextNodeFromDOMContent, dispatchCommand, doesContainSurrogatePair, getAnchorTextFromDOM, getDOMSelection, getDOMSelectionFromTarget, getEditorPropertyFromDOMNode, getEditorsToPropagate, getNearestEditorFromDOMNode, getWindow, isBackspace, isBold, isCopy, isCut, isDelete, isDeleteBackward, isDeleteForward, isDeleteLineBackward, isDeleteLineForward, isDeleteWordBackward, isDeleteWordForward, isDOMNode, isDOMTextNode, isEscape, isFirefoxClipboardEvents, isHTMLElement, 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<string, unknown> | ((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; let isInsertTextAfterHandledSelectionCommand = false; let handledSelectionCommandTimeoutId: null | ReturnType<typeof setTimeout> = null; // 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<HTMLElement, Document>(); const rootElementsRegistered = new WeakMap<Document, number>(); let isSelectionChangeFromDOMUpdate = false; let isSelectionChangeFromMouseDown = false; let isInsertLineBreak = false; let isFirefoxEndingComposition = false; let isSafariEndingComposition = false; let safariEndCompositionEventData = ''; let postDeleteSelectionToRestore: RangeSelection | null = null; 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(getWindow(editor)); 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) || // 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. (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.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 { 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) && !postDeleteSelectionToRestore ) { 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 ( postDeleteSelectionToRestore && $isRangeSelection(selection) && selection.isCollapsed() ) { const curAnchor = selection.anchor; const prevAnchor = 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 = postDeleteSelectionToRestore.clone(); $setSelection(selection); } } 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' && 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 ) { $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, undefined); }); } 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 domAnchorNode = domSelection.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); } } } 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 ( 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 (!$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]; } // 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 { 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') { 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(timeStamp: number): boolean { return ( lastKeyCode === 'MediaLast' && timeStamp < lastKeyDownTimeStamp + ANDROID_COMPOSITION_LATENCY ); } function clearHandledSelectionCommandInsertText(): void { isInsertTextAfterHandledSelectionCommand = false; if (handledSelectionCommandTimeoutId !== null) { clearTimeout(handledSelectionCommandTimeoutId); handledSelectionCommandTimeoutId = null; } } function markHandledSelectionCommandInsertText(): void { clearHandledSelectionCommandInsertText(); isInsertTextAfterHandledSelectionCommand = true; handledSelectionCommandTimeoutId = setTimeout( clearHandledSelectionCommandInsertText, 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, ); } function onBeforeInput(event: InputEvent, editor: LexicalEditor): void { const inputType = event.inputType; // 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; } dispatchCommand(editor, BEFORE_INPUT_COMMAND, event); } function $handleBeforeInput(event: InputEvent): boolean { const inputType = event.inputType; const targetRange = getTargetRange(event); const editor = getActiveEditor(); const selection = $getSelection(); // On Chrome on macOS, some handled selection commands may accept a pending text replacement. This behavior // is not desirable, so we check for this case and prevent bogus text replacements from happening. if ( inputType === 'insertText' && event.data && isInsertTextAfterHandledSelectionCommand ) { clearHandledSelectionCommandInsertText(); event.preventDefault(); if ($isRangeSelection(selection) && !selection.isCollapsed()) { const point = selection.isBackward() ? selection.anchor : selection.focus; selection.anchor.set(point.key, point.offset, point.type); selection.focus.set(point.key, point.offset, point.type); } return true; } if (inputType === 'deleteContentBackward') { if (selection === null) { // Use previous selection const prevSelection = $getPreviousSelection(); if (!$isRangeSelection(prevSelection)) { return true; } $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(() => { updateEditorSync(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'); $updateSelectionFormatStyleFromTextNode(selection, anchorNode); } } else { $setCompositionKey(null); // iOS 10-key Korean IME (천지인/Chunjiin) does not fire compositionstart / // compositionend events. Instead it sends a deleteContentBackward with a // non-collapsed targetRange to delete the current composing jamo, immediately // followed by insertText with the updated syllable. // // Because editor.isComposing() is always false for this keyboard type, Lexical // would otherwise dispatch DELETE_CHARACTER_COMMAND, which ignores the // targetRange entirely and deletes only one character before the cursor. This // leaves orphaned jamo in the editor state that accumulate and corrupt output // (e.g. typing "안녕하세요" produces "안녕하ᄉ세ᄋᄋ요"). // // Fix: when on iOS with a non-collapsed targetRange, apply the range directly // to the Lexical selection and delete the matched text. If applyDOMRange cannot // resolve the range (returns a collapsed selection), fall through to the default // Lexical deletion path. if (IS_IOS && targetRange !== null && !targetRange.collapsed) { selection.applyDOMRange(targetRange); if (!selection.isCollapsed()) { event.preventDefault(); selection.removeText(); return true; } } 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 selectedNode = selection.anchor.getNode(); const selectedNodeText = selectedNode.getTextContent(); // When the target node has `canInsertTextAfter` set to false, the first deletion // doesn't have an effect, so we need to handle it with Lexical. const selectedNodeCanInsertTextAfter = selectedNode.canInsertTextAfter(); const hasSelectedAllTextInNode = selection.anchor.offset === 0 && selection.focus.offset === selectedNodeText.length; let shouldLetBrowserHandleDelete = IS_ANDROID_CHROME && isSelectionAnchorSameAsFocus && !hasSelectedAllTextInNode && selectedNodeCanInsertTextAfter; // Check if selection is collapsed and if the previous node is a decorator node // If so, the browser will not be able to handle the deletion if (shouldLetBrowserHandleDelete && selection.isCollapsed()) { shouldLetBrowserHandleDelete = !$isDecoratorNode( $getAdjacentNode(selection.anchor, true), ); } if (!shouldLetBrowserHandleDelete) { dispatchCommand(editor, DELETE_CHARACTER_COMMAND, true); // When deleting across paragraphs, Chrome on Android incorrectly shifts the selection rightwards // We save the correct selection to restore later during handling of selectionchange event const selectionAfterDelete = $getSelection(); if ( IS_ANDROID_CHROME && $isRangeSelection(selectionAfterDelete) && selectionAfterDelete.isCollapsed() ) { postDeleteSelectionToRestore = selectionAfterDelete; // Cleanup in case selectionchange does not fire setTimeout(() => (postDeleteSelectionToRestore = null)); } } } return true; } } if (!$isRangeSelection(selection)) { return true; } 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); $maybeMoveSelectionPastTrailingAcceptanceBoundary(data); } else { unprocessedBeforeInputData = data; } lastBeforeInputInsertTextTimeStamp = event.timeStamp; return true; } // 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); const textFromDataTransfer = event.dataTransfer ? event.dataTransfer.getData('text/plain') : null; $maybeMoveSelectionPastTrailingAcceptanceBoundary( textFromDataTransfer ?? event.data, ); 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': { // The drop target is taking over focus and the document selection; // suppress this editor's own attempt to focus its root or move the DOM // selection back to the post-removal point during reconciliation. $addUpdateTag(SKIP_SELECTION_FOCUS_TAG); dispatchCommand(editor, REMOVE_TEXT_COMMAND, event); break; } 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 } return true; } function onInput(event: InputEvent, editor: LexicalEditor): void { // Note that the MutationObserver may or may not have already fired, // but the the DOM and selection may have already changed. // See also: // - https://github.com/facebook/lexical/issues/7028 // - https://github.com/facebook/lexical/pull/794 // We don't want the onInput to bubble, in the case of nested editors. event.stopPropagation(); clearHandledSelectionCommandInsertText(); updateEditorSync( editor, () => { editor.dispatchCommand(INPUT_COMMAND, event); }, {event}, ); unprocessedBeforeInputData = null; } function $handleInput(event: InputEvent): boolean { if ( isHTMLElement(event.target) && $isSelectionCapturedInDecorator(event.target) ) { return true; } const editor = getActiveEditor(); 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(getWindow(editor)); if (domSelection === null) { return true; } 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; selection._cachedNodes = null; selection._cachedIsBackward = null; } // This ensures consistency on Android. if (IS_ANDROID_CHROME && 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(); return true; } function onCompositionStart( event: CompositionEvent, editor: LexicalEditor, ): void { dispatchCommand(editor, COMPOSITION_START_COMMAND, event); } function $handleCompositionStart(event: CompositionEvent): boolean { const editor = getActiveEditor(); const selection = $getSelection(); if ($isRangeSelection(selection) && !editor.isComposing()) { const anchor = selection.anchor; const node = selection.anchor.getNode(); $setCompositionKey(anchor.key); $addUpdateTag(COMPOSITION_START_TAG); 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() || node.getFormat() !== selection.format || ($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, ); } } return true; } function $handleCompositionEnd(event: CompositionEvent): boolean { const editor = getActiveEditor(); $onCompositionEndImpl(editor, event.data); $addUpdateTag(COMPOSITION_END_TAG); return true; } 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 domElement = editor.getElementByKey(compositionKey); const textNode = domElement !== null && $isTextNode(node) ? $getDOMTextNode(node, domElement, editor) : null; if ( textNode !== null && textNode.nodeValue !== null && $isTextNode(node) ) { const domSelection = getDOMSelection(getWindow(editor)); let anchorOffset = null; let focusOffset = null; if (domSelection !== null && domSelection.anchorNode === textNode) { anchorOffset = domSelection.anchorOffset; focusOffset = domSelection.focusOffset; } $updateTextNodeFromDOMContent( node, textNode.nodeValue, anchorOffset, focusOffset, true, ); } return; } else if (data[data.length - 1] === '\n') { const selection = $getSelection(); if ($isRangeSelection(selection) || $isNodeSelection(selection)) { // If the last character is a line break, we also need to insert // a line break. if ($isRangeSelection(selection)) { 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 if (!IS_IOS && (IS_SAFARI || IS_APPLE_WEBKIT)) { // Fix:https://github.com/facebook/lexical/pull/7061 // In safari, onCompositionEnd triggers before keydown // This will cause an extra character to be deleted when exiting the IME // Therefore, a flag is used to mark that the keydown event is triggered after onCompositionEnd // Ensure that an extra character is not deleted due to the backspace event being triggered in the keydown event. isSafariEndingComposition = true; safariEndCompositionEventData = event.data; } else { dispatchCommand(editor, COMPOSITION_END_COMMAND, event); } } function onKeyDown(event: KeyboardEvent, editor: LexicalEditor): void { lastKeyDownTimeStamp = event.timeStamp; lastKeyCode = event.key; if (event.key !== 'Backspace') { clearHandledSelectionCommandInsertText(); } if (editor.isComposing()) { return; } dispatchCommand(editor, KEY_DOWN_COMMAND, event); } function $handleKeyDown(event: KeyboardEvent): boolean { const editor = getActiveEditor(); if (event.key == null) { return true; } if (isSafariEndingComposition) { if (isBackspace(event)) { updateEditorSync(editor, () => { $onCompositionEndImpl(editor, safariEndCompositionEventData); }); isSafariEndingComposition = false; safariEndCompositionEventData = ''; return true; } isSafariEndingComposition = false; safariEndCompositionEventData = ''; } if (isMoveForward(event)) { dispatchCommand(editor, KEY_ARROW_RIGHT_COMMAND, event); } else if (isMoveToEnd(event)) { dispatchCommand(editor, MOVE_TO_END, event); } else if (isMoveBackward(event)) { dispatchCommand(editor, KEY_ARROW_LEFT_COMMAND, event); } else if (isMoveToStart(event)) { dispatchCommand(editor, MOVE_TO_START, event); } else if (isMoveUp(event)) { dispatchCommand(editor, KEY_ARROW_UP_COMMAND, event); } else if (isMoveDown(event)) { dispatchCommand(editor, KEY_ARROW_DOWN_COMMAND, event); } else if (isLineBreak(event)) { isInsertLineBreak = true; dispatchCommand(editor, KEY_ENTER_COMMAND, event); } else if (isSpace(event)) { dispatchCommand(editor, KEY_SPACE_COMMAND, event); } else if (isOpenLineBreak(event)) { event.preventDefault(); isInsertLineBreak = true; dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, true); } else if (isParagraph(event)) { isInsertLineBreak = false; dispatchCommand(editor, KEY_ENTER_COMMAND, event); } else if (isDeleteBackward(event)) { if (isBackspace(event)) { if (dispatchCommand(editor, KEY_BACKSPACE_COMMAND, event)) { markHandledSelectionCommandInsertText(); } } else { event.preventDefault(); dispatchCommand(editor, DELETE_CHARACTER_COMMAND, true); } } else if (isEscape(event)) { dispatchCommand(editor, KEY_ESCAPE_COMMAND, event); } else if (isDeleteForward(event)) { if (isDelete(event)) { dispatchCommand(editor, KEY_DELETE_COMMAND, event); } else { event.preventDefault(); dispatchCommand(editor, DELETE_CHARACTER_COMMAND, false); } } else if (isDeleteWordBackward(event)) { event.preventDefault(); dispatchCommand(editor, DELETE_WORD_COMMAND, true); } else if (isDeleteWordForward(event)) { event.preventDefault(); dispatchCommand(editor, DELETE_WORD_COMMAND, false); } else if (isDeleteLineBackward(event)) { event.preventDefault(); dispatchCommand(editor, DELETE_LINE_COMMAND, true); } else if (isDeleteLineForward(event)) { event.preventDefault(); dispatchCommand(editor, DELETE_LINE_COMMAND, false); } else if (isBold(event)) { event.preventDefault(); dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'bold'); } else if (isUnderline(event)) { event.preventDefault(); dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'underline'); } else if (isItalic(event)) { event.preventDefault(); dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'italic'); } else if (isTab(event)) { dispatchCommand(editor, KEY_TAB_COMMAND, event); } else if (isUndo(event)) { event.preventDefault(); dispatchCommand(editor, UNDO_COMMAND, undefined); } else if (isRedo(event)) { event.preventDefault(); dispatchCommand(editor, REDO_COMMAND, undefined); } else { const prevSelection = editor._editorState._selection; if (isSelectAll(event)) { event.preventDefault(); if (dispatchCommand(editor, SELECT_ALL_COMMAND, event)) { markHandledSelectionCommandInsertText(); } } else if (prevSelection !== null && !$isRangeSelection(prevSelection)) { // Only RangeSelection can use the native cut/copy/select all if (isCopy(event)) { event.preventDefault(); dispatchCommand(editor, COPY_COMMAND, event); } else if (isCut(event)) { event.preventDefault(); dispatchCommand(editor, CUT_COMMAND, event); } } } if (isModifier(event)) { editor.dispatchCommand(KEY_MODIFIER_COMMAND, event); } return true; } 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<string, LexicalEditor> = new Map(); function onDocumentSelectionChange(event: Event): void { const domSelection = getDOMSelectionFromTarget(event.target); if (domSelection === null) { return; } const nextActiveEditor = getNearestEditorFromDOMNode(domSelection.anchorNode); if (nextActiveEditor === null) { return; } if (isSelectionChangeFromMouseDown) { isSelectionChangeFromMouseDown = false; updateEditorSync(nextActiveEditor, () => { const lastSelection = $getPreviousSelection(); const domAnchorNode = domSelection.anchorNode; if (isHTMLElement(domAnchorNode) || isDOMTextNode(domAnchorNode)) { // 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 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); } } /** @internal */ export 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; } functi