2 * Copyright (c) Meta Platforms, Inc. and affiliates.
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
9 import type {LexicalEditor} from './LexicalEditor';
10 import type {NodeKey} from './LexicalNode';
11 import type {ElementNode} from './nodes/LexicalElementNode';
12 import type {TextNode} from './nodes/LexicalTextNode';
21 } from 'lexical/shared/environment';
22 import invariant from 'lexical/shared/invariant';
25 $getPreviousSelection,
36 CONTROLLED_TEXT_INSERTION_COMMAND,
39 DELETE_CHARACTER_COMMAND,
48 INSERT_LINE_BREAK_COMMAND,
49 INSERT_PARAGRAPH_COMMAND,
50 KEY_ARROW_DOWN_COMMAND,
51 KEY_ARROW_LEFT_COMMAND,
52 KEY_ARROW_RIGHT_COMMAND,
54 KEY_BACKSPACE_COMMAND,
67 SELECTION_CHANGE_COMMAND,
70 import {KEY_MODIFIER_COMMAND, SELECT_ALL_COMMAND} from './LexicalCommands';
72 COMPOSITION_START_CHAR,
77 } from './LexicalConstants';
79 $internalCreateRangeSelection,
81 } from './LexicalSelection';
82 import {getActiveEditor, updateEditor} from './LexicalUpdates';
86 $isSelectionCapturedInDecorator,
89 $shouldInsertTextAfterOrBeforeTextNode,
90 $updateSelectedTextFromDOM,
91 $updateTextNodeFromDOMContent,
97 getEditorPropertyFromDOMNode,
98 getEditorsToPropagate,
99 getNearestEditorFromDOMNode,
108 isDeleteLineBackward,
110 isDeleteWordBackward,
113 isFirefoxClipboardEvents,
128 isSelectionWithinEditor,
133 } from './LexicalUtils';
135 type RootElementRemoveHandles = Array<() => void>;
136 type RootElementEvents = Array<
139 Record<string, unknown> | ((event: Event, editor: LexicalEditor) => void),
142 const PASS_THROUGH_COMMAND = Object.freeze({});
143 const ANDROID_COMPOSITION_LATENCY = 30;
144 const rootElementEvents: RootElementEvents = [
145 ['keydown', onKeyDown],
146 ['pointerdown', onPointerDown],
147 ['compositionstart', onCompositionStart],
148 ['compositionend', onCompositionEnd],
151 ['cut', PASS_THROUGH_COMMAND],
152 ['copy', PASS_THROUGH_COMMAND],
153 ['dragstart', PASS_THROUGH_COMMAND],
154 ['dragover', PASS_THROUGH_COMMAND],
155 ['dragend', PASS_THROUGH_COMMAND],
156 ['paste', PASS_THROUGH_COMMAND],
157 ['focus', PASS_THROUGH_COMMAND],
158 ['blur', PASS_THROUGH_COMMAND],
159 ['drop', PASS_THROUGH_COMMAND],
162 if (CAN_USE_BEFORE_INPUT) {
163 rootElementEvents.push([
165 (event, editor) => onBeforeInput(event as InputEvent, editor),
169 let lastKeyDownTimeStamp = 0;
170 let lastKeyCode: null | string = null;
171 let lastBeforeInputInsertTextTimeStamp = 0;
172 let unprocessedBeforeInputData: null | string = null;
173 const rootElementsRegistered = new WeakMap<Document, number>();
174 let isSelectionChangeFromDOMUpdate = false;
175 let isSelectionChangeFromMouseDown = false;
176 let isInsertLineBreak = false;
177 let isFirefoxEndingComposition = false;
178 let collapsedSelectionFormat: [number, string, number, NodeKey, number] = [
186 // This function is used to determine if Lexical should attempt to override
187 // the default browser behavior for insertion of text and use its own internal
188 // heuristics. This is an extremely important function, and makes much of Lexical
189 // work as intended between different browsers and across word, line and character
190 // boundary/formats. It also is important for text replacement, node schemas and
191 // composition mechanics.
193 function $shouldPreventDefaultAndInsertText(
194 selection: RangeSelection,
195 domTargetRange: null | StaticRange,
198 isBeforeInput: boolean,
200 const anchor = selection.anchor;
201 const focus = selection.focus;
202 const anchorNode = anchor.getNode();
203 const editor = getActiveEditor();
204 const domSelection = getDOMSelection(editor._window);
205 const domAnchorNode = domSelection !== null ? domSelection.anchorNode : null;
206 const anchorKey = anchor.key;
207 const backingAnchorElement = editor.getElementByKey(anchorKey);
208 const textLength = text.length;
211 anchorKey !== focus.key ||
212 // If we're working with a non-text node.
213 !$isTextNode(anchorNode) ||
214 // If we are replacing a range with a single character or grapheme, and not composing.
216 (!CAN_USE_BEFORE_INPUT ||
217 // We check to see if there has been
218 // a recent beforeinput event for "textInput". If there has been one in the last
219 // 50ms then we proceed as normal. However, if there is not, then this is likely
220 // a dangling `input` event caused by execCommand('insertText').
221 lastBeforeInputInsertTextTimeStamp < timeStamp + 50)) ||
222 (anchorNode.isDirty() && textLength < 2) ||
223 doesContainGrapheme(text)) &&
224 anchor.offset !== focus.offset &&
225 !anchorNode.isComposing()) ||
226 // Any non standard text node.
227 $isTokenOrSegmented(anchorNode) ||
228 // If the text length is more than a single character and we're either
229 // dealing with this in "beforeinput" or where the node has already recently
230 // been changed (thus is dirty).
231 (anchorNode.isDirty() && textLength > 1) ||
232 // If the DOM selection element is not the same as the backing node during beforeinput.
233 ((isBeforeInput || !CAN_USE_BEFORE_INPUT) &&
234 backingAnchorElement !== null &&
235 !anchorNode.isComposing() &&
236 domAnchorNode !== getDOMTextNode(backingAnchorElement)) ||
237 // If TargetRange is not the same as the DOM selection; browser trying to edit random parts
239 (domSelection !== null &&
240 domTargetRange !== null &&
241 (!domTargetRange.collapsed ||
242 domTargetRange.startContainer !== domSelection.anchorNode ||
243 domTargetRange.startOffset !== domSelection.anchorOffset)) ||
244 // Check if we're changing from bold to italics, or some other format.
245 anchorNode.getFormat() !== selection.format ||
246 anchorNode.getStyle() !== selection.style ||
247 // One last set of heuristics to check against.
248 $shouldInsertTextAfterOrBeforeTextNode(selection, anchorNode)
252 function shouldSkipSelectionChange(
253 domNode: null | Node,
258 domNode.nodeValue !== null &&
259 domNode.nodeType === DOM_TEXT_TYPE &&
261 offset !== domNode.nodeValue.length
265 function onSelectionChange(
266 domSelection: Selection,
267 editor: LexicalEditor,
271 anchorNode: anchorDOM,
276 if (isSelectionChangeFromDOMUpdate) {
277 isSelectionChangeFromDOMUpdate = false;
279 // If native DOM selection is on a DOM element, then
280 // we should continue as usual, as Lexical's selection
281 // may have normalized to a better child. If the DOM
282 // element is a text node, we can safely apply this
283 // optimization and skip the selection change entirely.
284 // We also need to check if the offset is at the boundary,
285 // because in this case, we might need to normalize to a
288 shouldSkipSelectionChange(anchorDOM, anchorOffset) &&
289 shouldSkipSelectionChange(focusDOM, focusOffset)
294 updateEditor(editor, () => {
295 // Non-active editor don't need any extra logic for selection, it only needs update
296 // to reconcile selection (set it to null) to ensure that only one editor has non-null selection.
302 if (!isSelectionWithinEditor(editor, anchorDOM, focusDOM)) {
306 const selection = $getSelection();
308 // Update the selection format
309 if ($isRangeSelection(selection)) {
310 const anchor = selection.anchor;
311 const anchorNode = anchor.getNode();
313 if (selection.isCollapsed()) {
314 // Badly interpreted range selection when collapsed - #1482
316 domSelection.type === 'Range' &&
317 domSelection.anchorNode === domSelection.focusNode
319 selection.dirty = true;
322 // If we have marked a collapsed selection format, and we're
323 // within the given time range – then attempt to use that format
324 // instead of getting the format from the anchor node.
325 const windowEvent = getWindow(editor).event;
326 const currentTimeStamp = windowEvent
327 ? windowEvent.timeStamp
329 const [lastFormat, lastStyle, lastOffset, lastKey, timeStamp] =
330 collapsedSelectionFormat;
332 const root = $getRoot();
333 const isRootTextContentEmpty =
334 editor.isComposing() === false && root.getTextContent() === '';
337 currentTimeStamp < timeStamp + 200 &&
338 anchor.offset === lastOffset &&
339 anchor.key === lastKey
341 selection.format = lastFormat;
342 selection.style = lastStyle;
344 if (anchor.type === 'text') {
346 $isTextNode(anchorNode),
347 'Point.getNode() must return TextNode when type is text',
349 selection.format = anchorNode.getFormat();
350 selection.style = anchorNode.getStyle();
351 } else if (anchor.type === 'element' && !isRootTextContentEmpty) {
352 const lastNode = anchor.getNode();
353 selection.style = '';
355 lastNode instanceof ParagraphNode &&
356 lastNode.getChildrenSize() === 0
358 selection.format = lastNode.getTextFormat();
359 selection.style = lastNode.getTextStyle();
361 selection.format = 0;
366 const anchorKey = anchor.key;
367 const focus = selection.focus;
368 const focusKey = focus.key;
369 const nodes = selection.getNodes();
370 const nodesLength = nodes.length;
371 const isBackward = selection.isBackward();
372 const startOffset = isBackward ? focusOffset : anchorOffset;
373 const endOffset = isBackward ? anchorOffset : focusOffset;
374 const startKey = isBackward ? focusKey : anchorKey;
375 const endKey = isBackward ? anchorKey : focusKey;
376 let combinedFormat = IS_ALL_FORMATTING;
377 let hasTextNodes = false;
378 for (let i = 0; i < nodesLength; i++) {
379 const node = nodes[i];
380 const textContentSize = node.getTextContentSize();
383 textContentSize !== 0 &&
384 // Exclude empty text nodes at boundaries resulting from user's selection
387 node.__key === startKey &&
388 startOffset === textContentSize) ||
389 (i === nodesLength - 1 &&
390 node.__key === endKey &&
394 // TODO: what about style?
396 combinedFormat &= node.getFormat();
397 if (combinedFormat === 0) {
403 selection.format = hasTextNodes ? combinedFormat : 0;
407 dispatchCommand(editor, SELECTION_CHANGE_COMMAND, undefined);
411 // This is a work-around is mainly Chrome specific bug where if you select
412 // the contents of an empty block, you cannot easily unselect anything.
413 // This results in a tiny selection box that looks buggy/broken. This can
414 // also help other browsers when selection might "appear" lost, when it
416 function onClick(event: PointerEvent, editor: LexicalEditor): void {
417 updateEditor(editor, () => {
418 const selection = $getSelection();
419 const domSelection = getDOMSelection(editor._window);
420 const lastSelection = $getPreviousSelection();
423 if ($isRangeSelection(selection)) {
424 const anchor = selection.anchor;
425 const anchorNode = anchor.getNode();
428 anchor.type === 'element' &&
429 anchor.offset === 0 &&
430 selection.isCollapsed() &&
431 !$isRootNode(anchorNode) &&
432 $getRoot().getChildrenSize() === 1 &&
433 anchorNode.getTopLevelElementOrThrow().isEmpty() &&
434 lastSelection !== null &&
435 selection.is(lastSelection)
437 domSelection.removeAllRanges();
438 selection.dirty = true;
439 } else if (event.detail === 3 && !selection.isCollapsed()) {
440 // Tripple click causing selection to overflow into the nearest element. In that
441 // case visually it looks like a single element content is selected, focus node
442 // is actually at the beginning of the next element (if present) and any manipulations
443 // with selection (formatting) are affecting second element as well
444 const focus = selection.focus;
445 const focusNode = focus.getNode();
446 if (anchorNode !== focusNode) {
447 if ($isElementNode(anchorNode)) {
448 anchorNode.select(0);
450 anchorNode.getParentOrThrow().select(0);
454 } else if (event.pointerType === 'touch') {
455 // This is used to update the selection on touch devices when the user clicks on text after a
456 // node selection. See isSelectionChangeFromMouseDown for the inverse
457 const domAnchorNode = domSelection.anchorNode;
458 if (domAnchorNode !== null) {
459 const nodeType = domAnchorNode.nodeType;
460 // If the user is attempting to click selection back onto text, then
461 // we should attempt create a range selection.
462 // When we click on an empty paragraph node or the end of a paragraph that ends
463 // with an image/poll, the nodeType will be ELEMENT_NODE
464 if (nodeType === DOM_ELEMENT_TYPE || nodeType === DOM_TEXT_TYPE) {
465 const newSelection = $internalCreateRangeSelection(
471 $setSelection(newSelection);
477 dispatchCommand(editor, CLICK_COMMAND, event);
481 function onPointerDown(event: PointerEvent, editor: LexicalEditor) {
482 // TODO implement text drag & drop
483 const target = event.target;
484 const pointerType = event.pointerType;
485 if (target instanceof Node && pointerType !== 'touch') {
486 updateEditor(editor, () => {
487 // Drag & drop should not recompute selection until mouse up; otherwise the initially
488 // selected content is lost.
489 if (!$isSelectionCapturedInDecorator(target)) {
490 isSelectionChangeFromMouseDown = true;
496 function getTargetRange(event: InputEvent): null | StaticRange {
497 if (!event.getTargetRanges) {
500 const targetRanges = event.getTargetRanges();
501 if (targetRanges.length === 0) {
504 return targetRanges[0];
507 function $canRemoveText(
508 anchorNode: TextNode | ElementNode,
509 focusNode: TextNode | ElementNode,
512 anchorNode !== focusNode ||
513 $isElementNode(anchorNode) ||
514 $isElementNode(focusNode) ||
515 !anchorNode.isToken() ||
520 function isPossiblyAndroidKeyPress(timeStamp: number): boolean {
522 lastKeyCode === 'MediaLast' &&
523 timeStamp < lastKeyDownTimeStamp + ANDROID_COMPOSITION_LATENCY
527 function onBeforeInput(event: InputEvent, editor: LexicalEditor): void {
528 const inputType = event.inputType;
529 const targetRange = getTargetRange(event);
531 // We let the browser do its own thing for composition.
533 inputType === 'deleteCompositionText' ||
534 // If we're pasting in FF, we shouldn't get this event
535 // as the `paste` event should have triggered, unless the
536 // user has dom.event.clipboardevents.enabled disabled in
537 // about:config. In that case, we need to process the
538 // pasted content in the DOM mutation phase.
539 (IS_FIREFOX && isFirefoxClipboardEvents(editor))
542 } else if (inputType === 'insertCompositionText') {
546 updateEditor(editor, () => {
547 const selection = $getSelection();
549 if (inputType === 'deleteContentBackward') {
550 if (selection === null) {
551 // Use previous selection
552 const prevSelection = $getPreviousSelection();
554 if (!$isRangeSelection(prevSelection)) {
558 $setSelection(prevSelection.clone());
561 if ($isRangeSelection(selection)) {
562 const isSelectionAnchorSameAsFocus =
563 selection.anchor.key === selection.focus.key;
566 isPossiblyAndroidKeyPress(event.timeStamp) &&
567 editor.isComposing() &&
568 isSelectionAnchorSameAsFocus
570 $setCompositionKey(null);
571 lastKeyDownTimeStamp = 0;
572 // Fixes an Android bug where selection flickers when backspacing
574 updateEditor(editor, () => {
575 $setCompositionKey(null);
577 }, ANDROID_COMPOSITION_LATENCY);
578 if ($isRangeSelection(selection)) {
579 const anchorNode = selection.anchor.getNode();
580 anchorNode.markDirty();
581 selection.format = anchorNode.getFormat();
583 $isTextNode(anchorNode),
584 'Anchor node must be a TextNode',
586 selection.style = anchorNode.getStyle();
589 $setCompositionKey(null);
590 event.preventDefault();
591 // Chromium Android at the moment seems to ignore the preventDefault
592 // on 'deleteContentBackward' and still deletes the content. Which leads
593 // to multiple deletions. So we let the browser handle the deletion in this case.
594 const selectedNodeText = selection.anchor.getNode().getTextContent();
595 const hasSelectedAllTextInNode =
596 selection.anchor.offset === 0 &&
597 selection.focus.offset === selectedNodeText.length;
598 const shouldLetBrowserHandleDelete =
600 isSelectionAnchorSameAsFocus &&
601 !hasSelectedAllTextInNode;
602 if (!shouldLetBrowserHandleDelete) {
603 dispatchCommand(editor, DELETE_CHARACTER_COMMAND, true);
610 if (!$isRangeSelection(selection)) {
614 const data = event.data;
616 // This represents the case when two beforeinput events are triggered at the same time (without a
617 // full event loop ending at input). This happens with MacOS with the default keyboard settings,
618 // a combination of autocorrection + autocapitalization.
619 // Having Lexical run everything in controlled mode would fix the issue without additional code
620 // but this would kill the massive performance win from the most common typing event.
621 // Alternatively, when this happens we can prematurely update our EditorState based on the DOM
622 // content, a job that would usually be the input event's responsibility.
623 if (unprocessedBeforeInputData !== null) {
624 $updateSelectedTextFromDOM(false, editor, unprocessedBeforeInputData);
628 (!selection.dirty || unprocessedBeforeInputData !== null) &&
629 selection.isCollapsed() &&
630 !$isRootNode(selection.anchor.getNode()) &&
633 selection.applyDOMRange(targetRange);
636 unprocessedBeforeInputData = null;
638 const anchor = selection.anchor;
639 const focus = selection.focus;
640 const anchorNode = anchor.getNode();
641 const focusNode = focus.getNode();
643 if (inputType === 'insertText' || inputType === 'insertTranspose') {
645 event.preventDefault();
646 dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, false);
647 } else if (data === DOUBLE_LINE_BREAK) {
648 event.preventDefault();
649 dispatchCommand(editor, INSERT_PARAGRAPH_COMMAND, undefined);
650 } else if (data == null && event.dataTransfer) {
651 // Gets around a Safari text replacement bug.
652 const text = event.dataTransfer.getData('text/plain');
653 event.preventDefault();
654 selection.insertRawText(text);
657 $shouldPreventDefaultAndInsertText(
665 event.preventDefault();
666 dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, data);
668 unprocessedBeforeInputData = data;
670 lastBeforeInputInsertTextTimeStamp = event.timeStamp;
674 // Prevent the browser from carrying out
675 // the input event, so we can control the
677 event.preventDefault();
680 case 'insertFromYank':
681 case 'insertFromDrop':
682 case 'insertReplacementText': {
683 dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, event);
687 case 'insertFromComposition': {
688 // This is the end of composition
689 $setCompositionKey(null);
690 dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, event);
694 case 'insertLineBreak': {
696 $setCompositionKey(null);
697 dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, false);
701 case 'insertParagraph': {
703 $setCompositionKey(null);
705 // Safari does not provide the type "insertLineBreak".
706 // So instead, we need to infer it from the keyboard event.
707 // We do not apply this logic to iOS to allow newline auto-capitalization
708 // work without creating linebreaks when pressing Enter
709 if (isInsertLineBreak && !IS_IOS) {
710 isInsertLineBreak = false;
711 dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, false);
713 dispatchCommand(editor, INSERT_PARAGRAPH_COMMAND, undefined);
719 case 'insertFromPaste':
720 case 'insertFromPasteAsQuotation': {
721 dispatchCommand(editor, PASTE_COMMAND, event);
725 case 'deleteByComposition': {
726 if ($canRemoveText(anchorNode, focusNode)) {
727 dispatchCommand(editor, REMOVE_TEXT_COMMAND, event);
734 case 'deleteByCut': {
735 dispatchCommand(editor, REMOVE_TEXT_COMMAND, event);
739 case 'deleteContent': {
740 dispatchCommand(editor, DELETE_CHARACTER_COMMAND, false);
744 case 'deleteWordBackward': {
745 dispatchCommand(editor, DELETE_WORD_COMMAND, true);
749 case 'deleteWordForward': {
750 dispatchCommand(editor, DELETE_WORD_COMMAND, false);
754 case 'deleteHardLineBackward':
755 case 'deleteSoftLineBackward': {
756 dispatchCommand(editor, DELETE_LINE_COMMAND, true);
760 case 'deleteContentForward':
761 case 'deleteHardLineForward':
762 case 'deleteSoftLineForward': {
763 dispatchCommand(editor, DELETE_LINE_COMMAND, false);
767 case 'formatStrikeThrough': {
768 dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'strikethrough');
773 dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'bold');
777 case 'formatItalic': {
778 dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'italic');
782 case 'formatUnderline': {
783 dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'underline');
787 case 'historyUndo': {
788 dispatchCommand(editor, UNDO_COMMAND, undefined);
792 case 'historyRedo': {
793 dispatchCommand(editor, REDO_COMMAND, undefined);
803 function onInput(event: InputEvent, editor: LexicalEditor): void {
804 // We don't want the onInput to bubble, in the case of nested editors.
805 event.stopPropagation();
806 updateEditor(editor, () => {
807 const selection = $getSelection();
808 const data = event.data;
809 const targetRange = getTargetRange(event);
813 $isRangeSelection(selection) &&
814 $shouldPreventDefaultAndInsertText(
822 // Given we're over-riding the default behavior, we will need
823 // to ensure to disable composition before dispatching the
824 // insertText command for when changing the sequence for FF.
825 if (isFirefoxEndingComposition) {
826 $onCompositionEndImpl(editor, data);
827 isFirefoxEndingComposition = false;
829 const anchor = selection.anchor;
830 const anchorNode = anchor.getNode();
831 const domSelection = getDOMSelection(editor._window);
832 if (domSelection === null) {
835 const isBackward = selection.isBackward();
836 const startOffset = isBackward
837 ? selection.anchor.offset
838 : selection.focus.offset;
839 const endOffset = isBackward
840 ? selection.focus.offset
841 : selection.anchor.offset;
842 // If the content is the same as inserted, then don't dispatch an insertion.
843 // Given onInput doesn't take the current selection (it uses the previous)
844 // we can compare that against what the DOM currently says.
846 !CAN_USE_BEFORE_INPUT ||
847 selection.isCollapsed() ||
848 !$isTextNode(anchorNode) ||
849 domSelection.anchorNode === null ||
850 anchorNode.getTextContent().slice(0, startOffset) +
852 anchorNode.getTextContent().slice(startOffset + endOffset) !==
853 getAnchorTextFromDOM(domSelection.anchorNode)
855 dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, data);
858 const textLength = data.length;
860 // Another hack for FF, as it's possible that the IME is still
861 // open, even though compositionend has already fired (sigh).
865 event.inputType === 'insertCompositionText' &&
866 !editor.isComposing()
868 selection.anchor.offset -= textLength;
871 // This ensures consistency on Android.
872 if (!IS_SAFARI && !IS_IOS && !IS_APPLE_WEBKIT && editor.isComposing()) {
873 lastKeyDownTimeStamp = 0;
874 $setCompositionKey(null);
877 const characterData = data !== null ? data : undefined;
878 $updateSelectedTextFromDOM(false, editor, characterData);
880 // onInput always fires after onCompositionEnd for FF.
881 if (isFirefoxEndingComposition) {
882 $onCompositionEndImpl(editor, data || undefined);
883 isFirefoxEndingComposition = false;
887 // Also flush any other mutations that might have occurred
891 unprocessedBeforeInputData = null;
894 function onCompositionStart(
895 event: CompositionEvent,
896 editor: LexicalEditor,
898 updateEditor(editor, () => {
899 const selection = $getSelection();
901 if ($isRangeSelection(selection) && !editor.isComposing()) {
902 const anchor = selection.anchor;
903 const node = selection.anchor.getNode();
904 $setCompositionKey(anchor.key);
907 // If it has been 30ms since the last keydown, then we should
908 // apply the empty space heuristic. We can't do this for Safari,
909 // as the keydown fires after composition start.
910 event.timeStamp < lastKeyDownTimeStamp + ANDROID_COMPOSITION_LATENCY ||
911 // FF has issues around composing multibyte characters, so we also
912 // need to invoke the empty space heuristic below.
913 anchor.type === 'element' ||
914 !selection.isCollapsed() ||
915 node.getFormat() !== selection.format ||
916 ($isTextNode(node) && node.getStyle() !== selection.style)
918 // We insert a zero width character, ready for the composition
919 // to get inserted into the new node we create. If
920 // we don't do this, Safari will fail on us because
921 // there is no text node matching the selection.
924 CONTROLLED_TEXT_INSERTION_COMMAND,
925 COMPOSITION_START_CHAR,
932 function $onCompositionEndImpl(editor: LexicalEditor, data?: string): void {
933 const compositionKey = editor._compositionKey;
934 $setCompositionKey(null);
936 // Handle termination of composition.
937 if (compositionKey !== null && data != null) {
938 // Composition can sometimes move to an adjacent DOM node when backspacing.
939 // So check for the empty case.
941 const node = $getNodeByKey(compositionKey);
942 const textNode = getDOMTextNode(editor.getElementByKey(compositionKey));
946 textNode.nodeValue !== null &&
949 $updateTextNodeFromDOMContent(
961 // Composition can sometimes be that of a new line. In which case, we need to
962 // handle that accordingly.
963 if (data[data.length - 1] === '\n') {
964 const selection = $getSelection();
966 if ($isRangeSelection(selection)) {
967 // If the last character is a line break, we also need to insert
969 const focus = selection.focus;
970 selection.anchor.set(focus.key, focus.offset, focus.type);
971 dispatchCommand(editor, KEY_ENTER_COMMAND, null);
977 $updateSelectedTextFromDOM(true, editor, data);
980 function onCompositionEnd(
981 event: CompositionEvent,
982 editor: LexicalEditor,
984 // Firefox fires onCompositionEnd before onInput, but Chrome/Webkit,
985 // fire onInput before onCompositionEnd. To ensure the sequence works
986 // like Chrome/Webkit we use the isFirefoxEndingComposition flag to
987 // defer handling of onCompositionEnd in Firefox till we have processed
988 // the logic in onInput.
990 isFirefoxEndingComposition = true;
992 updateEditor(editor, () => {
993 $onCompositionEndImpl(editor, event.data);
998 function onKeyDown(event: KeyboardEvent, editor: LexicalEditor): void {
999 lastKeyDownTimeStamp = event.timeStamp;
1000 lastKeyCode = event.key;
1001 if (editor.isComposing()) {
1005 const {key, shiftKey, ctrlKey, metaKey, altKey} = event;
1007 if (dispatchCommand(editor, KEY_DOWN_COMMAND, event)) {
1015 if (isMoveForward(key, ctrlKey, altKey, metaKey)) {
1016 dispatchCommand(editor, KEY_ARROW_RIGHT_COMMAND, event);
1017 } else if (isMoveToEnd(key, ctrlKey, shiftKey, altKey, metaKey)) {
1018 dispatchCommand(editor, MOVE_TO_END, event);
1019 } else if (isMoveBackward(key, ctrlKey, altKey, metaKey)) {
1020 dispatchCommand(editor, KEY_ARROW_LEFT_COMMAND, event);
1021 } else if (isMoveToStart(key, ctrlKey, shiftKey, altKey, metaKey)) {
1022 dispatchCommand(editor, MOVE_TO_START, event);
1023 } else if (isMoveUp(key, ctrlKey, metaKey)) {
1024 dispatchCommand(editor, KEY_ARROW_UP_COMMAND, event);
1025 } else if (isMoveDown(key, ctrlKey, metaKey)) {
1026 dispatchCommand(editor, KEY_ARROW_DOWN_COMMAND, event);
1027 } else if (isLineBreak(key, shiftKey)) {
1028 isInsertLineBreak = true;
1029 dispatchCommand(editor, KEY_ENTER_COMMAND, event);
1030 } else if (isSpace(key)) {
1031 dispatchCommand(editor, KEY_SPACE_COMMAND, event);
1032 } else if (isOpenLineBreak(key, ctrlKey)) {
1033 event.preventDefault();
1034 isInsertLineBreak = true;
1035 dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, true);
1036 } else if (isParagraph(key, shiftKey)) {
1037 isInsertLineBreak = false;
1038 dispatchCommand(editor, KEY_ENTER_COMMAND, event);
1039 } else if (isDeleteBackward(key, altKey, metaKey, ctrlKey)) {
1040 if (isBackspace(key)) {
1041 dispatchCommand(editor, KEY_BACKSPACE_COMMAND, event);
1043 event.preventDefault();
1044 dispatchCommand(editor, DELETE_CHARACTER_COMMAND, true);
1046 } else if (isEscape(key)) {
1047 dispatchCommand(editor, KEY_ESCAPE_COMMAND, event);
1048 } else if (isDeleteForward(key, ctrlKey, shiftKey, altKey, metaKey)) {
1049 if (isDelete(key)) {
1050 dispatchCommand(editor, KEY_DELETE_COMMAND, event);
1052 event.preventDefault();
1053 dispatchCommand(editor, DELETE_CHARACTER_COMMAND, false);
1055 } else if (isDeleteWordBackward(key, altKey, ctrlKey)) {
1056 event.preventDefault();
1057 dispatchCommand(editor, DELETE_WORD_COMMAND, true);
1058 } else if (isDeleteWordForward(key, altKey, ctrlKey)) {
1059 event.preventDefault();
1060 dispatchCommand(editor, DELETE_WORD_COMMAND, false);
1061 } else if (isDeleteLineBackward(key, metaKey)) {
1062 event.preventDefault();
1063 dispatchCommand(editor, DELETE_LINE_COMMAND, true);
1064 } else if (isDeleteLineForward(key, metaKey)) {
1065 event.preventDefault();
1066 dispatchCommand(editor, DELETE_LINE_COMMAND, false);
1067 } else if (isBold(key, altKey, metaKey, ctrlKey)) {
1068 event.preventDefault();
1069 dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'bold');
1070 } else if (isUnderline(key, altKey, metaKey, ctrlKey)) {
1071 event.preventDefault();
1072 dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'underline');
1073 } else if (isItalic(key, altKey, metaKey, ctrlKey)) {
1074 event.preventDefault();
1075 dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'italic');
1076 } else if (isTab(key, altKey, ctrlKey, metaKey)) {
1077 dispatchCommand(editor, KEY_TAB_COMMAND, event);
1078 } else if (isUndo(key, shiftKey, metaKey, ctrlKey)) {
1079 event.preventDefault();
1080 dispatchCommand(editor, UNDO_COMMAND, undefined);
1081 } else if (isRedo(key, shiftKey, metaKey, ctrlKey)) {
1082 event.preventDefault();
1083 dispatchCommand(editor, REDO_COMMAND, undefined);
1085 const prevSelection = editor._editorState._selection;
1086 if ($isNodeSelection(prevSelection)) {
1087 if (isCopy(key, shiftKey, metaKey, ctrlKey)) {
1088 event.preventDefault();
1089 dispatchCommand(editor, COPY_COMMAND, event);
1090 } else if (isCut(key, shiftKey, metaKey, ctrlKey)) {
1091 event.preventDefault();
1092 dispatchCommand(editor, CUT_COMMAND, event);
1093 } else if (isSelectAll(key, metaKey, ctrlKey)) {
1094 event.preventDefault();
1095 dispatchCommand(editor, SELECT_ALL_COMMAND, event);
1097 // FF does it well (no need to override behavior)
1098 } else if (!IS_FIREFOX && isSelectAll(key, metaKey, ctrlKey)) {
1099 event.preventDefault();
1100 dispatchCommand(editor, SELECT_ALL_COMMAND, event);
1104 if (isModifier(ctrlKey, shiftKey, altKey, metaKey)) {
1105 dispatchCommand(editor, KEY_MODIFIER_COMMAND, event);
1109 function getRootElementRemoveHandles(
1110 rootElement: HTMLElement,
1111 ): RootElementRemoveHandles {
1112 // @ts-expect-error: internal field
1113 let eventHandles = rootElement.__lexicalEventHandles;
1115 if (eventHandles === undefined) {
1117 // @ts-expect-error: internal field
1118 rootElement.__lexicalEventHandles = eventHandles;
1121 return eventHandles;
1124 // Mapping root editors to their active nested editors, contains nested editors
1125 // mapping only, so if root editor is selected map will have no reference to free up memory
1126 const activeNestedEditorsMap: Map<string, LexicalEditor> = new Map();
1128 function onDocumentSelectionChange(event: Event): void {
1129 const target = event.target as null | Element | Document;
1130 const targetWindow =
1133 : target.nodeType === 9
1134 ? (target as Document).defaultView
1135 : (target as Element).ownerDocument.defaultView;
1136 const domSelection = getDOMSelection(targetWindow);
1137 if (domSelection === null) {
1140 const nextActiveEditor = getNearestEditorFromDOMNode(domSelection.anchorNode);
1141 if (nextActiveEditor === null) {
1145 if (isSelectionChangeFromMouseDown) {
1146 isSelectionChangeFromMouseDown = false;
1147 updateEditor(nextActiveEditor, () => {
1148 const lastSelection = $getPreviousSelection();
1149 const domAnchorNode = domSelection.anchorNode;
1150 if (domAnchorNode === null) {
1153 const nodeType = domAnchorNode.nodeType;
1154 // If the user is attempting to click selection back onto text, then
1155 // we should attempt create a range selection.
1156 // When we click on an empty paragraph node or the end of a paragraph that ends
1157 // with an image/poll, the nodeType will be ELEMENT_NODE
1158 if (nodeType !== DOM_ELEMENT_TYPE && nodeType !== DOM_TEXT_TYPE) {
1161 const newSelection = $internalCreateRangeSelection(
1167 $setSelection(newSelection);
1171 // When editor receives selection change event, we're checking if
1172 // it has any sibling editors (within same parent editor) that were active
1173 // before, and trigger selection change on it to nullify selection.
1174 const editors = getEditorsToPropagate(nextActiveEditor);
1175 const rootEditor = editors[editors.length - 1];
1176 const rootEditorKey = rootEditor._key;
1177 const activeNestedEditor = activeNestedEditorsMap.get(rootEditorKey);
1178 const prevActiveEditor = activeNestedEditor || rootEditor;
1180 if (prevActiveEditor !== nextActiveEditor) {
1181 onSelectionChange(domSelection, prevActiveEditor, false);
1184 onSelectionChange(domSelection, nextActiveEditor, true);
1186 // If newly selected editor is nested, then add it to the map, clean map otherwise
1187 if (nextActiveEditor !== rootEditor) {
1188 activeNestedEditorsMap.set(rootEditorKey, nextActiveEditor);
1189 } else if (activeNestedEditor) {
1190 activeNestedEditorsMap.delete(rootEditorKey);
1194 function stopLexicalPropagation(event: Event): void {
1195 // We attach a special property to ensure the same event doesn't re-fire
1196 // for parent editors.
1198 event._lexicalHandled = true;
1201 function hasStoppedLexicalPropagation(event: Event): boolean {
1203 const stopped = event._lexicalHandled === true;
1207 export type EventHandler = (event: Event, editor: LexicalEditor) => void;
1209 export function addRootElementEvents(
1210 rootElement: HTMLElement,
1211 editor: LexicalEditor,
1213 // We only want to have a single global selectionchange event handler, shared
1214 // between all editor instances.
1215 const doc = rootElement.ownerDocument;
1216 const documentRootElementsCount = rootElementsRegistered.get(doc);
1218 documentRootElementsCount === undefined ||
1219 documentRootElementsCount < 1
1221 doc.addEventListener('selectionchange', onDocumentSelectionChange);
1223 rootElementsRegistered.set(doc, (documentRootElementsCount || 0) + 1);
1225 // @ts-expect-error: internal field
1226 rootElement.__lexicalEditor = editor;
1227 const removeHandles = getRootElementRemoveHandles(rootElement);
1229 for (let i = 0; i < rootElementEvents.length; i++) {
1230 const [eventName, onEvent] = rootElementEvents[i];
1231 const eventHandler =
1232 typeof onEvent === 'function'
1233 ? (event: Event) => {
1234 if (hasStoppedLexicalPropagation(event)) {
1237 stopLexicalPropagation(event);
1238 if (editor.isEditable() || eventName === 'click') {
1239 onEvent(event, editor);
1242 : (event: Event) => {
1243 if (hasStoppedLexicalPropagation(event)) {
1246 stopLexicalPropagation(event);
1247 const isEditable = editor.isEditable();
1248 switch (eventName) {
1252 dispatchCommand(editor, CUT_COMMAND, event as ClipboardEvent)
1256 return dispatchCommand(
1259 event as ClipboardEvent,
1268 event as ClipboardEvent,
1275 dispatchCommand(editor, DRAGSTART_COMMAND, event as DragEvent)
1281 dispatchCommand(editor, DRAGOVER_COMMAND, event as DragEvent)
1287 dispatchCommand(editor, DRAGEND_COMMAND, event as DragEvent)
1293 dispatchCommand(editor, FOCUS_COMMAND, event as FocusEvent)
1299 dispatchCommand(editor, BLUR_COMMAND, event as FocusEvent)
1306 dispatchCommand(editor, DROP_COMMAND, event as DragEvent)
1310 rootElement.addEventListener(eventName, eventHandler);
1311 removeHandles.push(() => {
1312 rootElement.removeEventListener(eventName, eventHandler);
1317 export function removeRootElementEvents(rootElement: HTMLElement): void {
1318 const doc = rootElement.ownerDocument;
1319 const documentRootElementsCount = rootElementsRegistered.get(doc);
1321 documentRootElementsCount !== undefined,
1322 'Root element not registered',
1325 // We only want to have a single global selectionchange event handler, shared
1326 // between all editor instances.
1327 const newCount = documentRootElementsCount - 1;
1328 invariant(newCount >= 0, 'Root element count less than 0');
1329 rootElementsRegistered.set(doc, newCount);
1330 if (newCount === 0) {
1331 doc.removeEventListener('selectionchange', onDocumentSelectionChange);
1334 const editor = getEditorPropertyFromDOMNode(rootElement);
1336 if (isLexicalEditor(editor)) {
1337 cleanActiveNestedEditorsMap(editor);
1338 // @ts-expect-error: internal field
1339 rootElement.__lexicalEditor = null;
1340 } else if (editor) {
1343 'Attempted to remove event handlers from a node that does not belong to this build of Lexical',
1347 const removeHandles = getRootElementRemoveHandles(rootElement);
1349 for (let i = 0; i < removeHandles.length; i++) {
1353 // @ts-expect-error: internal field
1354 rootElement.__lexicalEventHandles = [];
1357 function cleanActiveNestedEditorsMap(editor: LexicalEditor) {
1358 if (editor._parentEditor !== null) {
1359 // For nested editor cleanup map if this editor was marked as active
1360 const editors = getEditorsToPropagate(editor);
1361 const rootEditor = editors[editors.length - 1];
1362 const rootEditorKey = rootEditor._key;
1364 if (activeNestedEditorsMap.get(rootEditorKey) === editor) {
1365 activeNestedEditorsMap.delete(rootEditorKey);
1368 // For top-level editors cleanup map
1369 activeNestedEditorsMap.delete(editor._key);
1373 export function markSelectionChangeFromDOMUpdate(): void {
1374 isSelectionChangeFromDOMUpdate = true;
1377 export function markCollapsedSelectionFormat(
1384 collapsedSelectionFormat = [format, style, offset, key, timeStamp];