UNPKG

lexical

Version:

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

1,699 lines (1,547 loc) 76.1 kB
/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * */ import type { CommandPayloadType, DOMSlotForNode, EditorConfig, EditorDOMRenderConfig, EditorThemeClasses, Klass, LexicalCommand, MutatedNodes, MutationListeners, NodeMutation, RegisteredNode, RegisteredNodes, Spread, } from './LexicalEditor'; import type {EditorState} from './LexicalEditorState'; import type { BaseSelection, PointType, RangeSelection, } from './LexicalSelection'; import type {RootNode} from './nodes/LexicalRootNode'; import invariant from '@lexical/internal/invariant'; import { $createTextNode, $getPreviousSelection, $getSelection, $getTextNodeOffset, $isDecoratorNode, $isElementNode, $isLineBreakNode, $isRangeSelection, $isRootNode, $isTabNode, $isTextNode, DecoratorNode, DEFAULT_EDITOR_DOM_CONFIG, ElementFormatType, ElementNode, HISTORY_MERGE_TAG, LineBreakNode, normalizeClassNames, UpdateTag, } from '.'; import { CAN_USE_DOM, IS_APPLE, IS_APPLE_WEBKIT, IS_IOS, IS_SAFARI, } from './environment'; import { COMPOSITION_START_CHAR, COMPOSITION_SUFFIX, DOM_DOCUMENT_FRAGMENT_TYPE, DOM_DOCUMENT_TYPE, DOM_ELEMENT_TYPE, DOM_TEXT_TYPE, ELEMENT_TYPE_TO_FORMAT, HAS_DIRTY_NODES, LTR_REGEX, NO_DIRTY_NODES, PROTOTYPE_CONFIG_METHOD, RTL_REGEX, TEXT_TYPE_TO_FORMAT, } from './LexicalConstants'; import {DOMSlot, ElementDOMSlot} from './LexicalDOMSlot'; import {LexicalEditor} from './LexicalEditor'; import {flushRootMutations} from './LexicalMutations'; import { $isEphemeral, $markEphemeral, LexicalNode, type LexicalPrivateDOM, type NodeKey, type NodeMap, type StaticNodeConfigValue, } from './LexicalNode'; import {$normalizeSelection} from './LexicalNormalization'; import { errorOnInfiniteTransforms, errorOnReadOnly, getActiveEditor, getActiveEditorState, internalGetActiveEditorState, isCurrentlyReadOnlyMode, triggerCommandListeners, } from './LexicalUpdates'; import {type TextFormatType, TextNode} from './nodes/LexicalTextNode'; const __DEV__ = process.env.NODE_ENV !== 'production'; export const emptyFunction = () => { return; }; let pendingNodeToClone: null | LexicalNode = null; export function setPendingNodeToClone(pendingNode: null | LexicalNode): void { pendingNodeToClone = pendingNode; } export function getPendingNodeToClone(): null | LexicalNode { const node = pendingNodeToClone; pendingNodeToClone = null; return node; } let keyCounter = 1; export function resetRandomKey(): void { keyCounter = 1; } export function generateRandomKey(): string { return '' + keyCounter++; } /** * @internal */ export function getRegisteredNodeOrThrow( editor: LexicalEditor, nodeType: string, ): RegisteredNode { const registeredNode = getRegisteredNode(editor, nodeType); if (registeredNode === undefined) { invariant(false, 'registeredNode: Type %s not found', nodeType); } return registeredNode; } /** * @internal */ export function getRegisteredNode( editor: LexicalEditor, nodeType: string, ): undefined | RegisteredNode { return editor._nodes.get(nodeType); } export const isArray = Array.isArray; /** @internal */ export const scheduleMicroTask: (fn: () => void) => void = typeof queueMicrotask === 'function' ? queueMicrotask : fn => { // No window prefix intended (#1400) Promise.resolve().then(fn); }; export function $isSelectionCapturedInDecorator(node: Node): boolean { return $isDecoratorNode($getNearestNodeFromDOMNode(node)); } export function isSelectionCapturedInDecoratorInput(anchorDOM: Node): boolean { const activeElement = document.activeElement; if (!isHTMLElement(activeElement)) { return false; } const nodeName = activeElement.nodeName; return ( $isDecoratorNode($getNearestNodeFromDOMNode(anchorDOM)) && (nodeName === 'INPUT' || nodeName === 'TEXTAREA' || (activeElement.contentEditable === 'true' && getEditorPropertyFromDOMNode(activeElement) == null)) ); } export function isSelectionWithinEditor( editor: LexicalEditor, anchorDOM: null | Node, focusDOM: null | Node, ): boolean { const rootElement = editor.getRootElement(); try { return ( rootElement !== null && rootElement.contains(anchorDOM) && rootElement.contains(focusDOM) && // Ignore if selection is within nested editor anchorDOM !== null && !isSelectionCapturedInDecoratorInput(anchorDOM) && getNearestEditorFromDOMNode(anchorDOM) === editor ); } catch (_error) { return false; } } /** * @returns true if the given argument is a LexicalEditor instance from this build of Lexical */ export function isLexicalEditor(editor: unknown): editor is LexicalEditor { // Check instanceof to prevent issues with multiple embedded Lexical installations return editor instanceof LexicalEditor; } export function getNearestEditorFromDOMNode( node: Node | null, ): LexicalEditor | null { let currentNode = node; while (currentNode != null) { const editor = getEditorPropertyFromDOMNode(currentNode); if (isLexicalEditor(editor)) { return editor; } currentNode = getParentElement(currentNode); } return null; } /** @internal */ export function getEditorPropertyFromDOMNode(node: Node | null): unknown { // @ts-expect-error: internal field return node ? node.__lexicalEditor : null; } export function getTextDirection(text: string): 'ltr' | 'rtl' | null { if (RTL_REGEX.test(text)) { return 'rtl'; } if (LTR_REGEX.test(text)) { return 'ltr'; } return null; } /** * Return true if the TextNode is a TabNode or is in token mode. */ export function $isTokenOrTab(node: TextNode): boolean { return $isTabNode(node) || node.isToken(); } /** * Return true if the TextNode is a TabNode, or is in token or segmented mode. */ export function $isTokenOrSegmented(node: TextNode): boolean { return $isTokenOrTab(node) || node.isSegmented(); } /** * @param node - The element being tested * @returns Returns true if node is an DOM Text node, false otherwise. */ export function isDOMTextNode(node: unknown): node is Text { return isDOMNode(node) && node.nodeType === DOM_TEXT_TYPE; } /** * @param node - The element being tested * @returns Returns true if node is an DOM Document node, false otherwise. */ export function isDOMDocumentNode(node: unknown): node is Document { return isDOMNode(node) && node.nodeType === DOM_DOCUMENT_TYPE; } export function getDOMTextNode(element: Node | null): Text | null { let node = element; while (node != null) { if (isDOMTextNode(node)) { return node; } node = node.firstChild; } return null; } export function toggleTextFormatType( format: number, type: TextFormatType, alignWithFormat: null | number, ): number { const activeFormat = TEXT_TYPE_TO_FORMAT[type]; if ( alignWithFormat !== null && (format & activeFormat) === (alignWithFormat & activeFormat) ) { return format; } let newFormat = format ^ activeFormat; if (type === 'subscript') { newFormat &= ~TEXT_TYPE_TO_FORMAT.superscript; } else if (type === 'superscript') { newFormat &= ~TEXT_TYPE_TO_FORMAT.subscript; } else if (type === 'lowercase') { newFormat &= ~TEXT_TYPE_TO_FORMAT.uppercase; newFormat &= ~TEXT_TYPE_TO_FORMAT.capitalize; } else if (type === 'uppercase') { newFormat &= ~TEXT_TYPE_TO_FORMAT.lowercase; newFormat &= ~TEXT_TYPE_TO_FORMAT.capitalize; } else if (type === 'capitalize') { newFormat &= ~TEXT_TYPE_TO_FORMAT.lowercase; newFormat &= ~TEXT_TYPE_TO_FORMAT.uppercase; } return newFormat; } export function $isLeafNode( node: LexicalNode | null | undefined, ): node is TextNode | LineBreakNode | DecoratorNode<unknown> { return $isTextNode(node) || $isLineBreakNode(node) || $isDecoratorNode(node); } export function $setNodeKey( node: LexicalNode, existingKey: NodeKey | null | undefined, ): void { const pendingNode = getPendingNodeToClone(); existingKey = existingKey || (pendingNode && pendingNode.__key); if (existingKey != null) { if (__DEV__) { errorOnNodeKeyConstructorMismatch(node, existingKey, pendingNode); } node.__key = existingKey; return; } errorOnReadOnly(); errorOnInfiniteTransforms(); const editor = getActiveEditor(); const editorState = getActiveEditorState(); const key = generateRandomKey(); editorState._nodeMap.set(key, node); // TODO Split this function into leaf/element if ($isElementNode(node)) { editor._dirtyElements.set(key, true); } else { editor._dirtyLeaves.add(key); } editor._cloneNotNeeded.add(key); // Don't downgrade FULL_RECONCILE; upgrade only when nothing has been marked yet. if (editor._dirtyType === NO_DIRTY_NODES) { editor._dirtyType = HAS_DIRTY_NODES; } node.__key = key; } function errorOnNodeKeyConstructorMismatch( node: LexicalNode, existingKey: NodeKey, pendingNode: null | LexicalNode, ) { const editorState = internalGetActiveEditorState(); if (!editorState) { // tests expect to be able to do this kind of clone without an active editor state return; } const existingNode = editorState._nodeMap.get(existingKey); if (pendingNode) { invariant( existingKey === pendingNode.__key, 'Lexical node with constructor %s (type %s) has an incorrect clone implementation, got %s for nodeKey when expecting %s', node.constructor.name, node.getType(), String(existingKey), pendingNode.__key, ); } if (existingNode && existingNode.constructor !== node.constructor) { // Lifted condition to if statement because the inverted logic is a bit confusing if (node.constructor.name !== existingNode.constructor.name) { invariant( false, 'Lexical node with constructor %s attempted to re-use key from node in active editor state with constructor %s. Keys must not be re-used when the type is changed.', node.constructor.name, existingNode.constructor.name, ); } else { invariant( false, 'Lexical node with constructor %s attempted to re-use key from node in active editor state with different constructor with the same name (possibly due to invalid Hot Module Replacement). Keys must not be re-used when the type is changed.', node.constructor.name, ); } } } type IntentionallyMarkedAsDirtyElement = boolean; function internalMarkParentElementsAsDirty( parentKey: NodeKey, nodeMap: NodeMap, dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>, ): void { let nextParentKey: string | null = parentKey; while (nextParentKey !== null) { if (dirtyElements.has(nextParentKey)) { return; } const node = nodeMap.get(nextParentKey); if (node === undefined) { break; } dirtyElements.set(nextParentKey, false); nextParentKey = node.__parent; } } /** * Removes a node from its parent, updating all necessary pointers and links. * @internal * * This function does not adjust the editor's current selection. Callers * that need element-anchored offsets in the old parent to track the child * count change must call `$updateElementSelectionOnCreateDeleteNode` (with * `times = -1`) after invoking this — see `$removeNode`, `replace`, * `insertBefore`, and `insertAfter` for the pattern. * * This function is for internal use of the library. * Please do not use it as it may change in the future. */ export function removeFromParent(node: LexicalNode): void { const oldParent = node.getParent(); if (oldParent !== null) { const writableNode = node.getWritable(); const writableParent = oldParent.getWritable(); const prevSibling = node.getPreviousSibling(); const nextSibling = node.getNextSibling(); // Store sibling keys const nextSiblingKey = nextSibling !== null ? nextSibling.__key : null; const prevSiblingKey = prevSibling !== null ? prevSibling.__key : null; // Get writable siblings once const writablePrevSibling = prevSibling !== null ? prevSibling.getWritable() : null; const writableNextSibling = nextSibling !== null ? nextSibling.getWritable() : null; // Update parent's first/last pointers if (prevSibling === null) { writableParent.__first = nextSiblingKey; } if (nextSibling === null) { writableParent.__last = prevSiblingKey; } // Update sibling links if (writablePrevSibling !== null) { writablePrevSibling.__next = nextSiblingKey; } if (writableNextSibling !== null) { writableNextSibling.__prev = prevSiblingKey; } // Clear node's links writableNode.__prev = null; writableNode.__next = null; writableNode.__parent = null; // Update parent size writableParent.__size--; } } // Never use this function directly! It will break // the cloning heuristic. Instead use node.getWritable(). export function internalMarkNodeAsDirty(node: LexicalNode): void { errorOnInfiniteTransforms(); invariant( !$isEphemeral(node), 'internalMarkNodeAsDirty: Ephemeral nodes must not be marked as dirty (key %s type %s)', node.__key, node.__type, ); const latest = node.getLatest(); const parent = latest.__parent; const editorState = getActiveEditorState(); const editor = getActiveEditor(); const nodeMap = editorState._nodeMap; const dirtyElements = editor._dirtyElements; if (parent !== null) { internalMarkParentElementsAsDirty(parent, nodeMap, dirtyElements); } const key = latest.__key; // Don't downgrade FULL_RECONCILE; upgrade only when nothing has been marked yet. if (editor._dirtyType === NO_DIRTY_NODES) { editor._dirtyType = HAS_DIRTY_NODES; } if ($isElementNode(node)) { dirtyElements.set(key, true); } else { editor._dirtyLeaves.add(key); } } export function internalMarkSiblingsAsDirty(node: LexicalNode) { const previousNode = node.getPreviousSibling(); const nextNode = node.getNextSibling(); if (previousNode !== null) { internalMarkNodeAsDirty(previousNode); } if (nextNode !== null) { internalMarkNodeAsDirty(nextNode); } } export function $setCompositionKey(compositionKey: null | NodeKey): void { errorOnReadOnly(); const editor = getActiveEditor(); const previousCompositionKey = editor._compositionKey; if (compositionKey !== previousCompositionKey) { editor._compositionKey = compositionKey; if (previousCompositionKey !== null) { const node = $getNodeByKey(previousCompositionKey); if (node !== null) { node.getWritable(); } } if (compositionKey !== null) { const node = $getNodeByKey(compositionKey); if (node !== null) { node.getWritable(); } } } } export function $getCompositionKey(): null | NodeKey { if (isCurrentlyReadOnlyMode()) { return null; } const editor = getActiveEditor(); return editor._compositionKey; } export function $getNodeByKey<T extends LexicalNode>( key: NodeKey, _editorState?: EditorState, ): T | null { const editorState = _editorState || getActiveEditorState(); const node = editorState._nodeMap.get(key) as T; if (node === undefined) { return null; } return node; } export function $getNodeFromDOMNode( dom: Node, editorState?: EditorState, ): LexicalNode | null { const editor = getActiveEditor(); const key = getNodeKeyFromDOMNode(dom, editor); if (key !== undefined) { return $getNodeByKey(key, editorState); } return null; } export function setNodeKeyOnDOMNode( dom: Node, editor: LexicalEditor, key: NodeKey, ) { const prop = `__lexicalKey_${editor._key}`; (dom as Node & Record<typeof prop, NodeKey | undefined>)[prop] = key; } export function getNodeKeyFromDOMNode( dom: Node, editor: LexicalEditor, ): NodeKey | undefined { const prop = `__lexicalKey_${editor._key}`; return (dom as Node & Record<typeof prop, NodeKey | undefined>)[prop]; } export function $getNearestNodeFromDOMNode( startingDOM: Node, editorState?: EditorState, ): LexicalNode | null { let dom: Node | null = startingDOM; while (dom != null) { const node = $getNodeFromDOMNode(dom, editorState); if (node !== null) { return node; } dom = getParentElement(dom); } return null; } export function cloneDecorators( editor: LexicalEditor, ): Record<NodeKey, unknown> { const currentDecorators = editor._decorators; const pendingDecorators = Object.assign({}, currentDecorators); editor._pendingDecorators = pendingDecorators; return pendingDecorators; } export function getEditorStateTextContent(editorState: EditorState): string { return editorState.read(() => $getRoot().getTextContent()); } export function markNodesWithTypesAsDirty( editor: LexicalEditor, types: string[], ): void { // We only need to mark nodes dirty if they were in the previous state. // If they aren't, then they are by definition dirty already. const cachedMap = getCachedTypeToNodeMap(editor.getEditorState()); const dirtyNodeMaps: NodeMap[] = []; for (const type of types) { const nodeMap = cachedMap.get(type); if (nodeMap) { // By construction these are non-empty dirtyNodeMaps.push(nodeMap); } } // Nothing to mark dirty, no update necessary if (dirtyNodeMaps.length === 0) { return; } editor.update( () => { for (const nodeMap of dirtyNodeMaps) { for (const nodeKey of nodeMap.keys()) { // We are only concerned with nodes that are still in the latest NodeMap, // if they no longer exist then markDirty would raise an exception const latest = $getNodeByKey(nodeKey); if (latest) { latest.markDirty(); } } } }, editor._pendingEditorState === null ? { tag: HISTORY_MERGE_TAG, } : undefined, ); } export function $getRoot(): RootNode { return internalGetRoot(getActiveEditorState()); } export function internalGetRoot(editorState: EditorState): RootNode { return editorState._nodeMap.get('root') as RootNode; } export function $setSelection(selection: null | BaseSelection): void { errorOnReadOnly(); const editorState = getActiveEditorState(); if (selection !== null) { if (__DEV__) { if (Object.isFrozen(selection)) { invariant( false, '$setSelection called on frozen selection object. Ensure selection is cloned before passing in.', ); } } selection.dirty = true; selection.setCachedNodes(null); } editorState._selection = selection; } export function $flushMutations(): void { errorOnReadOnly(); const editor = getActiveEditor(); flushRootMutations(editor); } export function $getNodeFromDOM(dom: Node): null | LexicalNode { const editor = getActiveEditor(); const nodeKey = getNodeKeyFromDOMTree(dom, editor); if (nodeKey === null) { const rootElement = editor.getRootElement(); if (dom === rootElement) { return $getNodeByKey('root'); } return null; } return $getNodeByKey(nodeKey); } function getNodeKeyFromDOMTree( // Note that node here refers to a DOM Node, not an Lexical Node dom: Node, editor: LexicalEditor, ): NodeKey | null { let node: Node | null = dom; while (node != null) { const key = getNodeKeyFromDOMNode(node, editor); if (key !== undefined) { return key; } node = getParentElement(node); } return null; } /** * Return true if `str` contains any valid surrogate pair. * * See also $updateCaretSelectionForUnicodeCharacter for * a discussion on when and why this is useful. */ export function doesContainSurrogatePair(str: string): boolean { return /[\uD800-\uDBFF][\uDC00-\uDFFF]/g.test(str); } export function getEditorsToPropagate( editor: LexicalEditor, ): Array<LexicalEditor> { const editorsToPropagate: LexicalEditor[] = []; for ( let currentEditor: LexicalEditor | null = editor; currentEditor !== null; currentEditor = currentEditor._parentEditor ) { editorsToPropagate.push(currentEditor); } return editorsToPropagate; } export function createUID(): string { return Math.random() .toString(36) .replace(/[^a-z]+/g, '') .substring(0, 5); } export function getAnchorTextFromDOM(anchorNode: Node): null | string { return isDOMTextNode(anchorNode) ? anchorNode.nodeValue : null; } export function $updateSelectedTextFromDOM( isCompositionEnd: boolean, editor: LexicalEditor, data?: string, ): void { // Update the text content with the latest composition text const domSelection = getDOMSelection(getWindow(editor)); if (domSelection === null) { return; } const anchorNode = domSelection.anchorNode; let {anchorOffset, focusOffset} = domSelection; if (anchorNode !== null) { let textContent = getAnchorTextFromDOM(anchorNode); const node = $getNearestNodeFromDOMNode(anchorNode); if (textContent !== null && $isTextNode(node)) { // Data is intentionally truthy, as we check for boolean, null and empty string. if ( (textContent === COMPOSITION_SUFFIX || textContent === COMPOSITION_START_CHAR) && data ) { const offset = data.length; textContent = data; anchorOffset = offset; focusOffset = offset; } if (textContent !== null) { $updateTextNodeFromDOMContent( node, textContent, anchorOffset, focusOffset, isCompositionEnd, ); } } } } export function $updateTextNodeFromDOMContent( textNode: TextNode, textContent: string, anchorOffset: null | number, focusOffset: null | number, compositionEnd: boolean, ): void { let node = textNode; if (node.isAttached() && (compositionEnd || !node.isDirty())) { const isComposing = node.isComposing(); let normalizedTextContent = textContent; if (isComposing || compositionEnd) { if (textContent.endsWith(COMPOSITION_SUFFIX)) { normalizedTextContent = textContent.slice( 0, -COMPOSITION_SUFFIX.length, ); } if (compositionEnd) { const char = COMPOSITION_START_CHAR; let index; while ((index = normalizedTextContent.indexOf(char)) !== -1) { normalizedTextContent = normalizedTextContent.slice(0, index) + normalizedTextContent.slice(index + char.length); if (anchorOffset !== null && anchorOffset > index) { anchorOffset = Math.max(index, anchorOffset - char.length); } if (focusOffset !== null && focusOffset > index) { focusOffset = Math.max(index, focusOffset - char.length); } } } } const prevTextContent = node.getTextContent(); if (compositionEnd || normalizedTextContent !== prevTextContent) { if (normalizedTextContent === '') { $setCompositionKey(null); if (!IS_SAFARI && !IS_IOS && !IS_APPLE_WEBKIT) { // For composition (mainly Android), we have to remove the node on a later update const editor = getActiveEditor(); setTimeout(() => { editor.update(() => { if (node.isAttached()) { node.remove(); } }); }, 20); } else { node.remove(); } return; } const parent = node.getParent(); const prevSelection = $getPreviousSelection(); const prevTextContentSize = node.getTextContentSize(); const compositionKey = $getCompositionKey(); const nodeKey = node.getKey(); if ( node.isToken() || (compositionKey !== null && nodeKey === compositionKey && !isComposing) || // Check if character was added at the start or boundaries when not insertable, and we need // to clear this input from occurring as that action wasn't permitted. ($isRangeSelection(prevSelection) && ((parent !== null && !parent.canInsertTextBefore() && prevSelection.anchor.offset === 0) || (prevSelection.anchor.key === textNode.__key && prevSelection.anchor.offset === 0 && !node.canInsertTextBefore() && !isComposing) || (prevSelection.focus.key === textNode.__key && prevSelection.focus.offset === prevTextContentSize && !node.canInsertTextAfter() && !isComposing))) ) { node.markDirty(); return; } const selection = $getSelection(); if ( !$isRangeSelection(selection) || anchorOffset === null || focusOffset === null ) { $setTextContentWithSelection(node, normalizedTextContent, selection); return; } selection.setTextNodeRange(node, anchorOffset, node, focusOffset); if (node.isSegmented()) { const originalTextContent = node.getTextContent(); const replacement = $createTextNode(originalTextContent); node.replace(replacement); node = replacement; } $setTextContentWithSelection(node, normalizedTextContent, selection); } } } function $setTextContentWithSelection( node: TextNode, textContent: string, selection: BaseSelection | null, ) { node.setTextContent(textContent); if ($isRangeSelection(selection)) { const key = node.getKey(); let pointMutated = false; for (const k of ['anchor', 'focus'] as const) { const pt = selection[k]; if (pt.type === 'text' && pt.key === key) { pt.offset = $getTextNodeOffset(node, pt.offset, 'clamp'); pointMutated = true; } } if (pointMutated) { selection._cachedNodes = null; selection._cachedIsBackward = null; } } } function $previousSiblingDoesNotAcceptText(node: TextNode): boolean { const previousSibling = node.getPreviousSibling(); return ( ($isTextNode(previousSibling) || ($isElementNode(previousSibling) && previousSibling.isInline())) && !previousSibling.canInsertTextAfter() ); } // This function is connected to $shouldPreventDefaultAndInsertText and determines whether the // TextNode boundaries are writable or we should use the previous/next sibling instead. For example, // in the case of a LinkNode, boundaries are not writable. export function $shouldInsertTextAfterOrBeforeTextNode( selection: RangeSelection, node: TextNode, ): boolean { if (node.isSegmented()) { return true; } if (!selection.isCollapsed()) { return false; } const offset = selection.anchor.offset; const parent = node.getParentOrThrow(); const isToken = $isTokenOrTab(node); if (offset === 0) { return ( !node.canInsertTextBefore() || (!parent.canInsertTextBefore() && !node.isComposing()) || isToken || $previousSiblingDoesNotAcceptText(node) ); } else if (offset === node.getTextContentSize()) { return ( !node.canInsertTextAfter() || (!parent.canInsertTextAfter() && !node.isComposing()) || isToken ); } else { return false; } } /** * A KeyboardEvent or structurally similar object with a string `key` as well * as `altKey`, `ctrlKey`, `metaKey`, and `shiftKey` boolean properties. */ export type KeyboardEventModifiers = Pick< KeyboardEvent, 'key' | 'code' | 'metaKey' | 'ctrlKey' | 'shiftKey' | 'altKey' >; /** * A record of keyboard modifiers that must be enabled. * If the value is `'any'` then the modifier key's state is ignored. * If the value is `true` then the modifier key must be pressed. * If the value is `false` or the property is omitted then the modifier key must * not be pressed. */ export type KeyboardEventModifierMask = { [K in Exclude<keyof KeyboardEventModifiers, 'key'>]?: | boolean | undefined | 'any'; }; function matchModifier( event: KeyboardEventModifiers, mask: KeyboardEventModifierMask, prop: keyof KeyboardEventModifierMask, ): boolean { const expected = mask[prop] || false; return expected === 'any' || expected === event[prop]; } /** * Match a KeyboardEvent with its expected modifier state * * @param event A KeyboardEvent, or structurally similar object * @param mask An object specifying the expected state of the modifiers * @returns true if the event matches */ export function isModifierMatch( event: KeyboardEventModifiers, mask: KeyboardEventModifierMask, ): boolean { return ( matchModifier(event, mask, 'altKey') && matchModifier(event, mask, 'ctrlKey') && matchModifier(event, mask, 'shiftKey') && matchModifier(event, mask, 'metaKey') ); } /** * Match a KeyboardEvent with its expected state * * @param event A KeyboardEvent, or structurally similar object * @param expectedKey The string to compare with event.key (case insensitive) * @param mask An object specifying the expected state of the modifiers * @returns true if the event matches */ export function isExactShortcutMatch( event: KeyboardEventModifiers, expectedKey: string, mask: KeyboardEventModifierMask, ): boolean { if (!isModifierMatch(event, mask)) { return false; } if (event.key.toLowerCase() === expectedKey.toLowerCase()) { // For special keys like Enter, Tab, ArrowUp, etc. // For default keys with English-based keyboard layout. return true; } if (expectedKey.length > 1) { // For non English-based keyboard layout but the key is a special key, we must not match it by `event.code`. return false; } if (event.key.length === 1 && event.key.charCodeAt(0) <= 127) { // For ASCII keys we must not match it by `event.code` because it would break remapped layouts (English (US) Dvorak, etc.). return false; } // Fallback for number keys if (event.code.startsWith('Digit') && /^\d$/.test(expectedKey)) { return event.code === `Digit${expectedKey}`; } const expectedCode = 'Key' + expectedKey.toUpperCase(); // For default keys with not English-based keyboard layouts where `event.key` is non-ASCII, match by `event.code`. return event.code === expectedCode; } const CONTROL_OR_META = {ctrlKey: !IS_APPLE, metaKey: IS_APPLE}; const CONTROL_OR_ALT = {altKey: IS_APPLE, ctrlKey: !IS_APPLE}; export function isTab(event: KeyboardEventModifiers): boolean { return isExactShortcutMatch(event, 'Tab', { shiftKey: 'any', }); } export function isBold(event: KeyboardEventModifiers): boolean { return isExactShortcutMatch(event, 'b', CONTROL_OR_META); } export function isItalic(event: KeyboardEventModifiers): boolean { return isExactShortcutMatch(event, 'i', CONTROL_OR_META); } export function isUnderline(event: KeyboardEventModifiers): boolean { return isExactShortcutMatch(event, 'u', CONTROL_OR_META); } export function isParagraph(event: KeyboardEventModifiers): boolean { return isExactShortcutMatch(event, 'Enter', { altKey: 'any', ctrlKey: 'any', metaKey: 'any', }); } export function isLineBreak(event: KeyboardEventModifiers): boolean { return isExactShortcutMatch(event, 'Enter', { altKey: 'any', ctrlKey: 'any', metaKey: 'any', shiftKey: true, }); } // Inserts a new line after the selection export function isOpenLineBreak(event: KeyboardEventModifiers): boolean { // 79 = KeyO return IS_APPLE && isExactShortcutMatch(event, 'o', {ctrlKey: true}); } export function isDeleteWordBackward(event: KeyboardEventModifiers): boolean { return isExactShortcutMatch(event, 'Backspace', CONTROL_OR_ALT); } export function isDeleteWordForward(event: KeyboardEventModifiers): boolean { return isExactShortcutMatch(event, 'Delete', CONTROL_OR_ALT); } export function isDeleteLineBackward(event: KeyboardEventModifiers): boolean { return IS_APPLE && isExactShortcutMatch(event, 'Backspace', {metaKey: true}); } export function isDeleteLineForward(event: KeyboardEventModifiers): boolean { return ( IS_APPLE && (isExactShortcutMatch(event, 'Delete', {metaKey: true}) || isExactShortcutMatch(event, 'k', {ctrlKey: true})) ); } export function isDeleteBackward(event: KeyboardEventModifiers): boolean { return ( isExactShortcutMatch(event, 'Backspace', {shiftKey: 'any'}) || (IS_APPLE && isExactShortcutMatch(event, 'h', {ctrlKey: true})) ); } export function isDeleteForward(event: KeyboardEventModifiers): boolean { return ( isExactShortcutMatch(event, 'Delete', {}) || (IS_APPLE && isExactShortcutMatch(event, 'd', {ctrlKey: true})) ); } export function isUndo(event: KeyboardEventModifiers): boolean { return isExactShortcutMatch(event, 'z', CONTROL_OR_META); } export function isRedo(event: KeyboardEventModifiers): boolean { if (IS_APPLE) { return isExactShortcutMatch(event, 'z', {metaKey: true, shiftKey: true}); } return ( isExactShortcutMatch(event, 'y', {ctrlKey: true}) || isExactShortcutMatch(event, 'z', {ctrlKey: true, shiftKey: true}) ); } export function isCopy(event: KeyboardEventModifiers): boolean { return isExactShortcutMatch(event, 'c', CONTROL_OR_META); } export function isCut(event: KeyboardEventModifiers): boolean { return isExactShortcutMatch(event, 'x', CONTROL_OR_META); } export function isMoveBackward(event: KeyboardEventModifiers): boolean { return isExactShortcutMatch(event, 'ArrowLeft', { shiftKey: 'any', }); } export function isMoveToStart(event: KeyboardEventModifiers): boolean { return isExactShortcutMatch(event, 'ArrowLeft', { ...CONTROL_OR_META, shiftKey: 'any', }); } export function isMoveForward(event: KeyboardEventModifiers): boolean { return isExactShortcutMatch(event, 'ArrowRight', { shiftKey: 'any', }); } export function isMoveToEnd(event: KeyboardEventModifiers): boolean { return isExactShortcutMatch(event, 'ArrowRight', { ...CONTROL_OR_META, shiftKey: 'any', }); } export function isMoveUp(event: KeyboardEventModifiers): boolean { return isExactShortcutMatch(event, 'ArrowUp', { altKey: 'any', shiftKey: 'any', }); } export function isMoveDown(event: KeyboardEventModifiers): boolean { return isExactShortcutMatch(event, 'ArrowDown', { altKey: 'any', shiftKey: 'any', }); } export function isModifier(event: KeyboardEventModifiers): boolean { return event.ctrlKey || event.shiftKey || event.altKey || event.metaKey; } export function isSpace(event: KeyboardEventModifiers): boolean { return event.key === ' '; } export function controlOrMeta(metaKey: boolean, ctrlKey: boolean): boolean { if (IS_APPLE) { return metaKey; } return ctrlKey; } export function isBackspace(event: KeyboardEventModifiers): boolean { return event.key === 'Backspace'; } export function isEscape(event: KeyboardEventModifiers): boolean { return event.key === 'Escape'; } export function isDelete(event: KeyboardEventModifiers): boolean { return event.key === 'Delete'; } export function isSelectAll(event: KeyboardEventModifiers): boolean { return isExactShortcutMatch(event, 'a', CONTROL_OR_META); } export function $selectAll(selection?: RangeSelection | null): RangeSelection { const root = $getRoot(); if ($isRangeSelection(selection)) { const anchor = selection.anchor; const focus = selection.focus; const anchorNode = anchor.getNode(); const topParent = anchorNode.getTopLevelElementOrThrow(); const rootNode = topParent.getParentOrThrow(); anchor.set(rootNode.getKey(), 0, 'element'); focus.set(rootNode.getKey(), rootNode.getChildrenSize(), 'element'); $normalizeSelection(selection); return selection; } else { // Create a new RangeSelection const newSelection = root.select(0, root.getChildrenSize()); $setSelection($normalizeSelection(newSelection)); return newSelection; } } export function getCachedClassNameArray( classNamesTheme: EditorThemeClasses, classNameThemeType: string, ): Array<string> { if (classNamesTheme.__lexicalClassNameCache === undefined) { classNamesTheme.__lexicalClassNameCache = {}; } const classNamesCache = classNamesTheme.__lexicalClassNameCache; const cachedClassNames = classNamesCache[classNameThemeType]; if (cachedClassNames !== undefined) { return cachedClassNames; } const classNames = classNamesTheme[classNameThemeType]; // As we're using classList, we need // to handle className tokens that have spaces. // The easiest way to do this to convert the // className tokens to an array that can be // applied to classList.add()/remove(). if (typeof classNames === 'string') { const classNamesArr = normalizeClassNames(classNames); classNamesCache[classNameThemeType] = classNamesArr; return classNamesArr; } return classNames; } export function setMutatedNode( mutatedNodes: MutatedNodes, registeredNodes: RegisteredNodes, mutationListeners: MutationListeners, node: LexicalNode, mutation: NodeMutation, ) { if (mutationListeners.size === 0) { return; } const nodeType = node.__type; const nodeKey = node.__key; const registeredNode = registeredNodes.get(nodeType); if (registeredNode === undefined) { invariant(false, 'Type %s not in registeredNodes', nodeType); } const klass = registeredNode.klass; let mutatedNodesByType = mutatedNodes.get(klass); if (mutatedNodesByType === undefined) { mutatedNodesByType = new Map(); mutatedNodes.set(klass, mutatedNodesByType); } const prevMutation = mutatedNodesByType.get(nodeKey); // If the node has already been "destroyed", yet we are // re-making it, then this means a move likely happened. // We should change the mutation to be that of "updated" // instead. const isMove = prevMutation === 'destroyed' && mutation === 'created'; if (prevMutation === undefined || isMove) { mutatedNodesByType.set(nodeKey, isMove ? 'updated' : mutation); } } /** * @deprecated Use {@link LexicalEditor.registerMutationListener} with `skipInitialization: false` instead. */ export function $nodesOfType<T extends LexicalNode>(klass: Klass<T>): Array<T> { const klassType = klass.getType(); const editorState = getActiveEditorState(); if (editorState._readOnly) { const nodes = getCachedTypeToNodeMap(editorState).get(klassType) as | undefined | Map<string, T>; return nodes ? Array.from(nodes.values()) : []; } const nodes = editorState._nodeMap; const nodesOfType: Array<T> = []; for (const [, node] of nodes) { if ( node instanceof klass && node.__type === klassType && node.isAttached() ) { nodesOfType.push(node as T); } } return nodesOfType; } function resolveElement( element: ElementNode, isBackward: boolean, focusOffset: number, ): LexicalNode | null { const parent = element.getParent(); let offset = focusOffset; let block = element; if (parent !== null) { if (isBackward && focusOffset === 0) { offset = block.getIndexWithinParent(); block = parent; } else if (!isBackward && focusOffset === block.getChildrenSize()) { offset = block.getIndexWithinParent() + 1; block = parent; } } return block.getChildAtIndex(isBackward ? offset - 1 : offset); } export function $getAdjacentNode( focus: PointType, isBackward: boolean, ): null | LexicalNode { const focusOffset = focus.offset; if (focus.type === 'element') { const block = focus.getNode(); return resolveElement(block, isBackward, focusOffset); } else { const focusNode = focus.getNode(); if ( (isBackward && focusOffset === 0) || (!isBackward && focusOffset === focusNode.getTextContentSize()) ) { const possibleNode = isBackward ? focusNode.getPreviousSibling() : focusNode.getNextSibling(); if (possibleNode === null) { return resolveElement( focusNode.getParentOrThrow(), isBackward, focusNode.getIndexWithinParent() + (isBackward ? 0 : 1), ); } return possibleNode; } } return null; } export function isFirefoxClipboardEvents(editor: LexicalEditor): boolean { const event = getWindow(editor).event; const inputType = event && (event as InputEvent).inputType; return ( inputType === 'insertFromPaste' || inputType === 'insertFromPasteAsQuotation' ); } export function dispatchCommand<TCommand extends LexicalCommand<unknown>>( editor: LexicalEditor, command: TCommand, payload: CommandPayloadType<TCommand>, ): boolean { return triggerCommandListeners(editor, command, payload, editor); } export function getElementByKeyOrThrow( editor: LexicalEditor, key: NodeKey, ): HTMLElement { const element = editor._keyToDOMMap.get(key); if (element === undefined) { invariant( false, 'Reconciliation: could not find DOM element for node key %s', key, ); } return element; } export function getParentElement(node: Node): HTMLElement | null { const parentElement = (node as HTMLSlotElement).assignedSlot || node.parentElement; return isDocumentFragment(parentElement) ? ((parentElement as unknown as ShadowRoot).host as HTMLElement) : parentElement; } export function getDOMOwnerDocument( target: EventTarget | null, ): Document | null { return isDOMDocumentNode(target) ? target : isHTMLElement(target) ? target.ownerDocument : null; } export function scrollIntoViewIfNeeded( editor: LexicalEditor, selectionRect: DOMRect, rootElement: HTMLElement, ): void { const doc = getDOMOwnerDocument(rootElement); const defaultView = getDefaultView(doc); if (doc === null || defaultView === null) { return; } let {top: currentTop, bottom: currentBottom} = selectionRect; let targetTop = 0; let targetBottom = 0; let element: HTMLElement | null = rootElement; while (element !== null) { const isBodyElement = element === doc.body; if (isBodyElement) { // On mobile, the on-screen keyboard shrinks the visual viewport but // not the layout viewport (innerHeight). // selectionRect comes from getBoundingClientRect in layout-viewport coords, // so we must compare against visualViewport bounds, // or the caret stays behind the keyboard. const visualViewport = defaultView.visualViewport; if (visualViewport) { const offsetTop = visualViewport.offsetTop; targetTop = offsetTop; targetBottom = offsetTop + visualViewport.height; } else { targetTop = 0; targetBottom = getWindow(editor).innerHeight; } // Account for CSS scroll-padding on the document element const computedStyle = defaultView.getComputedStyle(doc.documentElement); const scrollPaddingTop = parseFloat(computedStyle.scrollPaddingTop); const scrollPaddingBottom = parseFloat(computedStyle.scrollPaddingBottom); if (isFinite(scrollPaddingTop)) { targetTop += scrollPaddingTop; } if (isFinite(scrollPaddingBottom)) { targetBottom -= scrollPaddingBottom; } } else { const targetRect = element.getBoundingClientRect(); targetTop = targetRect.top; targetBottom = targetRect.bottom; } let diff = 0; if (currentTop < targetTop) { diff = -(targetTop - currentTop); } else if (currentBottom > targetBottom) { diff = currentBottom - targetBottom; } if (diff !== 0) { if (isBodyElement) { // Only handles scrolling of Y axis defaultView.scrollBy(0, diff); } else { const scrollTop = element.scrollTop; element.scrollTop += diff; const yOffset = element.scrollTop - scrollTop; currentTop -= yOffset; currentBottom -= yOffset; } } if (isBodyElement) { break; } element = getParentElement(element); } } export function $hasUpdateTag(tag: UpdateTag): boolean { const editor = getActiveEditor(); return editor._updateTags.has(tag); } export function $addUpdateTag(tag: UpdateTag): void { errorOnReadOnly(); const editor = getActiveEditor(); editor._updateTags.add(tag); } /** * Add a function to run after the current update. This will run after any * `onUpdate` function already supplied to `editor.update()`, as well as any * functions added with previous calls to `$onUpdate`. * * @param updateFn The function to run after the current update. */ export function $onUpdate(updateFn: () => void): void { errorOnReadOnly(); const editor = getActiveEditor(); editor._deferred.push(updateFn); } export function $maybeMoveChildrenSelectionToParent( parentNode: LexicalNode, ): BaseSelection | null { const selection = $getSelection(); if (!$isRangeSelection(selection) || !$isElementNode(parentNode)) { return selection; } const {anchor, focus} = selection; const anchorNode = anchor.getNode(); const focusNode = focus.getNode(); if ($hasAncestor(anchorNode, parentNode)) { anchor.set(parentNode.__key, 0, 'element'); } if ($hasAncestor(focusNode, parentNode)) { focus.set(parentNode.__key, 0, 'element'); } return selection; } export function $hasAncestor( child: LexicalNode, targetNode: LexicalNode, ): boolean { let parent = child.getParent(); while (parent !== null) { if (parent.is(targetNode)) { return true; } parent = parent.getParent(); } return false; } export function getDefaultView(domElem: EventTarget | null): Window | null { const ownerDoc = getDOMOwnerDocument(domElem); return ownerDoc ? ownerDoc.defaultView : null; } export function getWindow(editor: LexicalEditor): Window { const windowObj = editor._window; if (windowObj === null) { invariant(false, 'window object not found'); } return windowObj; } const InlineNodeBrand: unique symbol = Symbol.for('@lexical/InlineNodeBrand'); export function $isInlineElementOrDecoratorNode<T>(node: LexicalNode): node is ( | ElementNode | DecoratorNode<T> ) & { isInline(): true; [InlineNodeBrand]: never; } { return ( ($isElementNode(node) && node.isInline()) || ($isDecoratorNode(node) && node.isInline()) ); } export function $getNearestRootOrShadowRoot( node: LexicalNode, ): RootNode | ElementNode { let parent = node.getParentOrThrow(); while (parent !== null) { if ($isRootOrShadowRoot(parent)) { return parent; } parent = parent.getParentOrThrow(); } return parent; } const ShadowRootNodeBrand: unique symbol = Symbol.for( '@lexical/ShadowRootNodeBrand', ); type ShadowRootNode = Spread< {isShadowRoot(): true; [ShadowRootNodeBrand]: never}, ElementNode >; export function $isRootOrShadowRoot( node: null | LexicalNode, ): node is RootNode | ShadowRootNode { return $isRootNode(node) || ($isElementNode(node) && node.isShadowRoot()); } /** * Returns a shallow clone of node with a new key. All properties of the node * will be copied to the new node (by `clone` and then `afterCloneFrom`), * except those related to parent/sibling/child * relationships in the `EditorState`. This means that the copy must be * separately added to the document, and it will not have any children. * * @param node - The node to be copied. * @param skipReset - If true (default false) skip the call to resetOnCopyNodeFrom * @returns The copy of the node. */ export function $copyNode<T extends LexicalNode>( node: T, skipReset = false, ): T { const copy = node.constructor.clone(node) as T; $setNodeKey(copy, null); copy.afterCloneFrom(node); if (!skipReset) { copy.resetOnCopyNodeFrom(node); } return copy; } export function $applyNodeReplacement<N extends LexicalNode>(node: N): N { const editor = getActiveEditor(); const nodeType = node.getType(); const registeredNode = getRegisteredNode(editor, nodeType); invariant( registeredNode !== undefined, '$applyNodeReplacement node %s with type %s must be registered to the editor. You can do this by passing the node class via the "nodes" array in the editor config.', node.constructor.name, nodeType, ); const {replace, replaceWithKlass} = registeredNode; if (replace !== null) { const replacementNode = replace(node); const replacementNodeKlass = replacementNode.constructor; if (replaceWithKlass !== null) { invariant( replacementNode instanceof replaceWithKlass, '$applyNodeReplacement failed. Expected replacement node to be an instance of %s with type %s but returned %s with type %s from original node %s with type %s', replaceWithKlass.name, replaceWithKlass.getType(), replacementNodeKlass.name, replacementNodeKlass.getType(), node.constructor.name, nodeType, ); } else { invariant( replacementNode instanceof node.constructor && replacementNodeKlass !== node.constructor, '$applyNodeReplacement failed. Ensure replacement node %s with type %s is a subclass of the original node %s with type %s.', replacementNodeKlass.name, replacementNodeKlass.getType(), node.constructor.name, nodeType, ); } invariant( replacementNode.__key !== node.__key, '$applyNodeReplacement failed. Ensure that the key argument is *not* used in your replace function (from node %s with type %s to node %s with type %s), Node keys must never be re-used except by the static clone method.', node.constructor.name, nodeType, replacementNodeKlass.name, replacementNodeKlass.getType(), ); return replacementNode as N; } return node; } export function errorOnInsertTextNodeOnRoot( node: LexicalNode, insertNode: Lexi