1
0
mirror of https://github.com/BookStackApp/BookStack.git synced 2025-04-19 18:22:16 +03:00
Dan Brown f3fa63a5ae
Lexical: Merged custom paragraph node, removed old format/indent refs
Start of work to merge custom nodes into lexical, removing old unused
format/indent core logic while extending common block elements where
possible.
2024-12-03 16:24:49 +00:00

1383 lines
44 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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