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
text/typescript
/**
* 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