UNPKG

lexical

Version:

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

1,504 lines (1,389 loc) 57.2 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 {DOMSlot, ElementDOMSlot} from './LexicalDOMSlot'; import type {EditorState, SerializedEditorState} from './LexicalEditorState'; import type { DOMConversion, DOMConversionMap, DOMExportOutput, DOMExportOutputMap, LexicalPrivateDOM, NodeKey, } from './LexicalNode'; import type {ElementNode} from './nodes/LexicalElementNode'; import invariant from '@lexical/internal/invariant'; import {LEXICAL_VERSION} from '@lexical/internal/version'; import { $getRoot, $getSelection, $isElementNode, BaseSelection, mergeRegister, TextNode, } from '.'; import {FULL_RECONCILE, NO_DIRTY_NODES} from './LexicalConstants'; import {DequeSet} from './LexicalDequeSet'; import {cloneEditorState, createEmptyEditorState} from './LexicalEditorState'; import { addRootElementEvents, registerDefaultCommandHandlers, removeRootElementEvents, } from './LexicalEvents'; import {GenMap} from './LexicalGenMap'; import {flushRootMutations, initMutationObserver} from './LexicalMutations'; import {LexicalNode} from './LexicalNode'; import {createSharedNodeState, SharedNodeState} from './LexicalNodeState'; import { $commitPendingUpdates, internalGetActiveEditor, parseEditorState, triggerListeners, updateEditor, updateEditorSync, } from './LexicalUpdates'; import {FOCUS_TAG, HISTORY_MERGE_TAG, UpdateTag} from './LexicalUpdateTags'; import { $addUpdateTag, $onUpdate, $setSelection, createUID, dispatchCommand, getCachedClassNameArray, getCachedTypeToNodeMap, getDefaultView, getDOMSelection, getRegisteredNode, getStaticNodeConfig, hasOwnExportDOM, hasOwnStaticMethod, markNodesWithTypesAsDirty, } from './LexicalUtils'; import {ArtificialNode__DO_NOT_USE} from './nodes/ArtificialNode'; import {LineBreakNode} from './nodes/LexicalLineBreakNode'; import {ParagraphNode} from './nodes/LexicalParagraphNode'; import {RootNode} from './nodes/LexicalRootNode'; import {TabNode} from './nodes/LexicalTabNode'; const __DEV__ = process.env.NODE_ENV !== 'production'; export type Spread<T1, T2> = Omit<T2, keyof T1> & T1; // https://github.com/microsoft/TypeScript/issues/3841 // eslint-disable-next-line @typescript-eslint/no-explicit-any export type KlassConstructor<Cls extends GenericConstructor<any>> = GenericConstructor<InstanceType<Cls>> & {[k in keyof Cls]: Cls[k]}; // eslint-disable-next-line @typescript-eslint/no-explicit-any type GenericConstructor<T> = new (...args: any[]) => T; export type Klass<T extends LexicalNode> = InstanceType<T['constructor']> extends T ? T['constructor'] : GenericConstructor<T> & T['constructor']; export type EditorThemeClassName = string; export interface TextNodeThemeClasses { base?: EditorThemeClassName; bold?: EditorThemeClassName; code?: EditorThemeClassName; highlight?: EditorThemeClassName; italic?: EditorThemeClassName; lowercase?: EditorThemeClassName; uppercase?: EditorThemeClassName; capitalize?: EditorThemeClassName; strikethrough?: EditorThemeClassName; subscript?: EditorThemeClassName; superscript?: EditorThemeClassName; underline?: EditorThemeClassName; underlineStrikethrough?: EditorThemeClassName; [key: string]: EditorThemeClassName | undefined; } export type EditorUpdateOptions = { /** * A function to run once the update is complete. See also {@link $onUpdate}. */ onUpdate?: () => void; /** * Setting this to true will suppress all node * transforms for this update cycle. * Useful for synchronizing updates in some cases. */ skipTransforms?: true; /** * A tag to identify this update, in an update listener, for instance. * See also {@link $addUpdateTag}. */ tag?: UpdateTag | UpdateTag[]; /** * If true, prevents this update from being batched, forcing it to * run synchronously. */ discrete?: true; /** @internal */ event?: undefined | UIEvent | Event | null; }; export type EditorSetOptions = { tag?: string; }; export interface EditorFocusOptions { /** * Where to move selection when the editor is * focused. Can be rootStart, rootEnd, or undefined. Defaults to rootEnd. */ defaultSelection?: 'rootStart' | 'rootEnd'; } export interface EditorThemeClasses { blockCursor?: EditorThemeClassName; characterLimit?: EditorThemeClassName; code?: EditorThemeClassName; codeHighlight?: Record<string, EditorThemeClassName>; hashtag?: EditorThemeClassName; specialText?: EditorThemeClassName; heading?: { h1?: EditorThemeClassName; h2?: EditorThemeClassName; h3?: EditorThemeClassName; h4?: EditorThemeClassName; h5?: EditorThemeClassName; h6?: EditorThemeClassName; }; hr?: EditorThemeClassName; hrSelected?: EditorThemeClassName; image?: EditorThemeClassName; link?: EditorThemeClassName; list?: { ul?: EditorThemeClassName; ulDepth?: Array<EditorThemeClassName>; ol?: EditorThemeClassName; olDepth?: Array<EditorThemeClassName>; checklist?: EditorThemeClassName; listitem?: EditorThemeClassName; listitemChecked?: EditorThemeClassName; listitemUnchecked?: EditorThemeClassName; nested?: { list?: EditorThemeClassName; listitem?: EditorThemeClassName; }; }; ltr?: EditorThemeClassName; mark?: EditorThemeClassName; markOverlap?: EditorThemeClassName; paragraph?: EditorThemeClassName; quote?: EditorThemeClassName; root?: EditorThemeClassName; rtl?: EditorThemeClassName; tab?: EditorThemeClassName; table?: EditorThemeClassName; tableAddColumns?: EditorThemeClassName; tableAddRows?: EditorThemeClassName; tableCellActionButton?: EditorThemeClassName; tableCellActionButtonContainer?: EditorThemeClassName; tableCellSelected?: EditorThemeClassName; tableCell?: EditorThemeClassName; tableCellHeader?: EditorThemeClassName; tableCellResizer?: EditorThemeClassName; tableRow?: EditorThemeClassName; tableScrollableWrapper?: EditorThemeClassName; tableSelected?: EditorThemeClassName; tableSelection?: EditorThemeClassName; text?: TextNodeThemeClasses; collaboration?: { cursor?: EditorThemeClassName; cursorName?: EditorThemeClassName; selection?: EditorThemeClassName; selectionBg?: EditorThemeClassName; }; embedBlock?: { base?: EditorThemeClassName; focus?: EditorThemeClassName; }; indent?: EditorThemeClassName; // eslint-disable-next-line @typescript-eslint/no-explicit-any [key: string]: any; } export interface EditorConfig { dom?: EditorDOMRenderConfig; disableEvents?: boolean; namespace: string; theme: EditorThemeClasses; } /** * Configuration entry passed in {@link CreateEditorArgs.nodes} to substitute * a core node class with a custom subclass. The replacement class itself * must also appear in `nodes`. * * See [Node Replacement](https://lexical.dev/docs/concepts/node-replacement). */ export type LexicalNodeReplacement = { /** * The core node class whose instances should be replaced. */ replace: Klass<LexicalNode>; /** * Called by the `$create*` factories for `replace` with the * freshly-constructed original. Returns the substitute node, which must be * an instance of `withKlass` when set. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any with: <T extends {new (...args: any): any}>( node: InstanceType<T>, ) => LexicalNode; /** * The replacement class returned by `with`. Must extend `replace`. When * set, {@link LexicalEditor.registerNodeTransform} and * {@link LexicalEditor.registerMutationListener} subscriptions registered * against `replace` also fire for the replacement. Will be required in a * future version. */ withKlass?: Klass<LexicalNode>; }; export type HTMLConfig = { export?: DOMExportOutputMap; import?: DOMConversionMap; }; /** * A LexicalNode class or LexicalNodeReplacement configuration */ export type LexicalNodeConfig = Klass<LexicalNode> | LexicalNodeReplacement; /** * @experimental * * The slot type produced by `$getDOMSlot` for a given node, narrowed via * the node's static class: `ElementNode` resolves to {@link ElementDOMSlot} * (with children-management methods), other nodes to the base * {@link DOMSlot}. Callers passing a known node type get the narrowed slot * without manual `instanceof` checks. */ export type DOMSlotForNode<N extends LexicalNode> = N extends ElementNode ? ElementDOMSlot<HTMLElement> : DOMSlot<HTMLElement>; /** @internal @experimental */ export interface EditorDOMRenderConfig { /** @internal @experimental */ $createDOM: <T extends LexicalNode>( node: T, editor: LexicalEditor, ) => HTMLElement; /** * @internal @experimental * * The default impl dispatches to `node.getDOMSlot(dom)`. The return type is * narrowed via {@link DOMSlotForNode}: callers passing an `ElementNode` get * an {@link ElementDOMSlot} with children-management methods, callers * passing a non-Element node get the base {@link DOMSlot}. */ $getDOMSlot: <N extends LexicalNode>( node: N, dom: HTMLElement, editor: LexicalEditor, ) => DOMSlotForNode<N>; /** @internal @experimental */ $exportDOM: <T extends LexicalNode>( node: T, editor: LexicalEditor, ) => DOMExportOutput; /** @internal @experimental */ $extractWithChild: <T extends LexicalNode>( node: T, childNode: LexicalNode, selection: null | BaseSelection, destination: 'clone' | 'html', editor: LexicalEditor, ) => boolean; /** @internal @experimental */ $decorateDOM: <T extends LexicalNode>( node: T, prevNode: null | T, dom: HTMLElement, editor: LexicalEditor, ) => void; /** @internal @experimental */ $updateDOM: <T extends LexicalNode>( nextNode: T, prevNode: T, dom: HTMLElement, editor: LexicalEditor, ) => boolean; /** @internal @experimental */ $shouldInclude: <T extends LexicalNode>( node: T, selection: null | BaseSelection, editor: LexicalEditor, ) => boolean; /** @internal @experimental */ $shouldExclude: <T extends LexicalNode>( node: T, selection: null | BaseSelection, editor: LexicalEditor, ) => boolean; } export interface CreateEditorArgs { disableEvents?: boolean; editorState?: EditorState; namespace?: string; nodes?: ReadonlyArray<LexicalNodeConfig>; onError?: ErrorHandler; parentEditor?: LexicalEditor; editable?: boolean; theme?: EditorThemeClasses; html?: HTMLConfig; dom?: Partial<EditorDOMRenderConfig>; } export type RegisteredNodes = Map<string, RegisteredNode>; export type RegisteredNode = { klass: Klass<LexicalNode>; transforms: Set<Transform<LexicalNode>>; replace: null | ((node: LexicalNode) => LexicalNode); replaceWithKlass: null | Klass<LexicalNode>; exportDOM?: ( editor: LexicalEditor, targetNode: LexicalNode, ) => DOMExportOutput; sharedNodeState: SharedNodeState; }; export type Transform<T extends LexicalNode> = (node: T) => void; export type ErrorHandler = (error: Error) => void; export type MutationListeners = Map<MutationListener, Set<Klass<LexicalNode>>>; export type MutatedNodes = Map<Klass<LexicalNode>, Map<NodeKey, NodeMutation>>; export type NodeMutation = 'created' | 'updated' | 'destroyed'; export interface MutationListenerOptions { /** * Skip the initial call of the listener with pre-existing DOM nodes. * * The default was previously true for backwards compatibility with <= 0.16.1 * but this default has been changed to false as of 0.21.0. */ skipInitialization?: boolean; } const DEFAULT_SKIP_INITIALIZATION = false; /** * The payload passed to an UpdateListener */ export interface UpdateListenerPayload { /** * A Map of NodeKeys of ElementNodes to a boolean that is true * if the node was intentionally mutated ('unintentional' mutations * are triggered when an indirect descendant is marked dirty) */ dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>; /** * A Set of NodeKeys of all nodes that were marked dirty that * do not inherit from ElementNode. */ dirtyLeaves: Set<NodeKey>; /** * The new EditorState after all updates have been processed, * equivalent to `editor.getEditorState()` */ editorState: EditorState; /** * The Map of LexicalNode constructors to a `Map<NodeKey, NodeMutation>`, * this is useful when you have a mutation listener type use cases that * should apply to all or most nodes. Will be null if no DOM was mutated, * such as when only the selection changed. Note that this will be empty * unless at least one MutationListener is explicitly registered * (any MutationListener is sufficient to compute the mutatedNodes Map * for all nodes). * * Added in v0.28.0 */ mutatedNodes: null | MutatedNodes; /** * For advanced use cases only. * * Tracks the keys of TextNode descendants that have been merged * with their siblings by normalization. Note that these keys may * not exist in either editorState or prevEditorState and generally * this is only used for conflict resolution edge cases in collab. */ normalizedNodes: Set<NodeKey>; /** * The previous EditorState that is being discarded */ prevEditorState: EditorState; /** * The set of tags added with update options or {@link $addUpdateTag}, * node that this includes all tags that were processed in this * reconciliation which may have been added by separate updates. */ tags: Set<string>; } /** * A listener that gets called after the editor is updated */ export type UpdateListener = (payload: UpdateListenerPayload) => void; export type DecoratorListener<T = never> = ( decorator: Record<NodeKey, T>, ) => void; /** * A listener that is called when {@link LexicalEditor.setRootElement} changes the * element that the editor is attached to. If this callback returns a function, * that function will be called before the next value update or unregister. */ export type RootListener = ( rootElement: null | HTMLElement, prevRootElement: null | HTMLElement, ) => void | (() => void); export type TextContentListener = (text: string) => void; export type MutationListener = ( nodes: Map<NodeKey, NodeMutation>, payload: { updateTags: Set<string>; dirtyLeaves: Set<string>; prevEditorState: EditorState; }, ) => void; export type CommandListener<P> = (payload: P, editor: LexicalEditor) => boolean; /** * A listener that is called when {@link LexicalEditor.setEditable} changes the * editable state of the editor. If this callback returns a function, * that function will be called before the next value update or unregister. */ export type EditableListener = (editable: boolean) => void | (() => void); export type CommandListenerPriority = 0 | 1 | 2 | 3 | 4; export type CommandListenerPriorityBefore = | typeof COMMAND_PRIORITY_BEFORE_CRITICAL | typeof COMMAND_PRIORITY_BEFORE_EDITOR | typeof COMMAND_PRIORITY_BEFORE_HIGH | typeof COMMAND_PRIORITY_BEFORE_LOW | typeof COMMAND_PRIORITY_BEFORE_NORMAL; /** * {@link LexicalEditor.registerCommand} listener added to the end of the editor priority queue (after critical, high, normal, low) */ export const COMMAND_PRIORITY_EDITOR = 0; /** * {@link LexicalEditor.registerCommand} listener added to the end of the low priority queue (after critical, high, normal; before editor) */ export const COMMAND_PRIORITY_LOW = 1; /** * {@link LexicalEditor.registerCommand} listener added to the end of the normal priority queue (after critical, high; before low, editor) */ export const COMMAND_PRIORITY_NORMAL = 2; /** * {@link LexicalEditor.registerCommand} listener added to the end of the high priority queue (after critical; before normal, low, editor) */ export const COMMAND_PRIORITY_HIGH = 3; /** * {@link LexicalEditor.registerCommand} listener added to the end of the critical priority queue (before high, normal, low, editor) */ export const COMMAND_PRIORITY_CRITICAL = 4; /** * {@link LexicalEditor.registerCommand} listener added to the beginning of the editor priority queue (after critical, high, normal, low) */ export const COMMAND_PRIORITY_BEFORE_EDITOR = -8; /** * {@link LexicalEditor.registerCommand} listener added to the beginning of the low priority queue (after critical, high, normal; before editor) */ export const COMMAND_PRIORITY_BEFORE_LOW = -7; /** * {@link LexicalEditor.registerCommand} listener added to the beginning of the normal priority queue (after critical, high; before low, editor) */ export const COMMAND_PRIORITY_BEFORE_NORMAL = -6; /** * {@link LexicalEditor.registerCommand} listener added to the beginning of the high priority queue (after critical; before normal, low, editor) */ export const COMMAND_PRIORITY_BEFORE_HIGH = -5; /** * {@link LexicalEditor.registerCommand} listener added to the beginning of the critical priority queue (before high, normal, low, editor) */ export const COMMAND_PRIORITY_BEFORE_CRITICAL = -4; type Tuple5<T> = readonly [T, T, T, T, T]; function normalizePriority( priority: CommandListenerPriority | CommandListenerPriorityBefore, ): CommandListenerPriority { return (priority & 7) as CommandListenerPriority; } // eslint-disable-next-line @typescript-eslint/no-unused-vars export type LexicalCommand<TPayload> = { type?: string; }; /** * Type helper for extracting the payload type from a command. * * @example * ```ts * const MY_COMMAND = createCommand<SomeType>(); * * // ... * * editor.registerCommand(MY_COMMAND, payload => { * // Type of `payload` is inferred here. But lets say we want to extract a function to delegate to * $handleMyCommand(editor, payload); * return true; * }); * * function $handleMyCommand(editor: LexicalEditor, payload: CommandPayloadType<typeof MY_COMMAND>) { * // `payload` is of type `SomeType`, extracted from the command. * } * ``` */ export type CommandPayloadType<TCommand extends LexicalCommand<unknown>> = TCommand extends LexicalCommand<infer TPayload> ? TPayload : never; // eslint-disable-next-line @typescript-eslint/no-explicit-any type AnyCommandListener = CommandListener<any>; type Commands = Map< LexicalCommand<unknown>, Tuple5<DequeSet<AnyCommandListener>> >; export type ListenerMap<T> = Map<T, undefined | (() => void)>; export interface Listeners { // eslint-disable-next-line @typescript-eslint/no-explicit-any decorator: ListenerMap<DecoratorListener<any>>; mutation: MutationListeners; editable: ListenerMap<EditableListener>; root: ListenerMap<RootListener>; textcontent: ListenerMap<TextContentListener>; update: ListenerMap<UpdateListener>; } export type MapListeners = { [K in keyof Listeners as Listeners[K] extends Map< // eslint-disable-next-line @typescript-eslint/no-explicit-any (...args: any[]) => void | undefined | (() => void), undefined | (() => void) > ? K : never]: Listeners[K] extends Map< (...args: infer Args) => void | undefined | (() => void), undefined | (() => void) > ? Args : never; }; export type TransformerType = 'text' | 'decorator' | 'element' | 'root'; type IntentionallyMarkedAsDirtyElement = boolean; type DOMConversionCache = Map< string, Array<(node: Node) => DOMConversion | null> >; export type SerializedEditor = { editorState: SerializedEditorState; }; /** @internal */ export type ResetEditorOptions = { /** * When `true`, `_updates` and `_updateTags` are kept intact across the * reset. Used by callers that preserve `pendingEditorState` and intend * the queued updates to commit against it (notably `setRootElement`). * Without this, queued callbacks tagged for the upcoming commit would * be silently dropped despite the state being kept. */ preserveUpdateQueue?: boolean; }; /** * @internal * * Resets the editor's transient state — DOM mappings, dirty tracking, * composition, and (by default) the queued updates and tags — while * applying the given pendingEditorState. Used during root element * transitions and reconciler error recovery. */ export function resetEditor( editor: LexicalEditor, prevRootElement: null | HTMLElement, nextRootElement: null | HTMLElement, pendingEditorState: EditorState, options?: ResetEditorOptions, ): void { const keyNodeMap = editor._keyToDOMMap; keyNodeMap.clear(); editor._editorState = createEmptyEditorState(); editor._pendingEditorState = pendingEditorState; editor._compositionKey = null; editor._dirtyType = NO_DIRTY_NODES; editor._cloneNotNeeded.clear(); editor._dirtyLeaves = new Set(); editor._dirtyElements.clear(); editor._normalizedNodes = new Set(); if (!options || !options.preserveUpdateQueue) { editor._updateTags = new Set(); editor._updates = []; editor._cascadeCount = 0; } editor._blockCursorElement = null; const observer = editor._observer; if (observer !== null) { observer.disconnect(); editor._observer = null; } // Remove all the DOM nodes from the root element if (prevRootElement !== null) { prevRootElement.textContent = ''; } if (nextRootElement !== null) { nextRootElement.textContent = ''; keyNodeMap.set('root', nextRootElement); } } function initializeConversionCache( nodes: RegisteredNodes, additionalConversions?: DOMConversionMap, ): DOMConversionCache { const conversionCache = new Map(); const handledConversions = new Set(); const addConversionsToCache = (map: DOMConversionMap) => { Object.keys(map).forEach(key => { let currentCache = conversionCache.get(key); if (currentCache === undefined) { currentCache = []; conversionCache.set(key, currentCache); } currentCache.push(map[key]); }); }; nodes.forEach(node => { const importDOM = node.klass.importDOM; if (importDOM == null || handledConversions.has(importDOM)) { return; } handledConversions.add(importDOM); const map = importDOM.call(node.klass); if (map !== null) { addConversionsToCache(map); } }); if (additionalConversions) { addConversionsToCache(additionalConversions); } return conversionCache; } /** @internal */ export function getTransformSetFromKlass( klass: KlassConstructor<typeof LexicalNode>, ): Set<Transform<LexicalNode>> { const transforms = new Set<Transform<LexicalNode>>(); const staticTransforms = new Set<(typeof klass)['transform']>(); let currentKlass: undefined | typeof klass = klass; while (currentKlass) { const {ownNodeConfig} = getStaticNodeConfig(currentKlass); const staticTransform = currentKlass.transform; if (!staticTransforms.has(staticTransform)) { staticTransforms.add(staticTransform); const transform = currentKlass.transform(); if (transform) { transforms.add(transform); } } if (ownNodeConfig) { const $transform = ownNodeConfig.$transform; if ($transform) { transforms.add($transform); } currentKlass = ownNodeConfig.extends; } else { const parent = Object.getPrototypeOf(currentKlass); currentKlass = parent.prototype instanceof LexicalNode && parent !== LexicalNode ? parent : undefined; } } return transforms; } /** @internal @experimental */ export const DEFAULT_EDITOR_DOM_CONFIG: EditorDOMRenderConfig = { $createDOM: (node, editor) => node.createDOM(editor._config, editor), $decorateDOM: (_node, _prevNode, _dom, _editor) => {}, $exportDOM: (node, editor) => { const registeredNode = getRegisteredNode(editor, node.getType()); // Use HTMLConfig overrides, if available. return registeredNode && registeredNode.exportDOM !== undefined ? registeredNode.exportDOM(editor, node) : node.exportDOM(editor); }, $extractWithChild: (node, childNode, selection, destination, _editor) => $isElementNode(node) && node.extractWithChild(childNode, selection, destination), $getDOMSlot: <N extends LexicalNode>( node: N, dom: HTMLElement, _editor: LexicalEditor, ): DOMSlotForNode<N> => node.getDOMSlot(dom) as DOMSlotForNode<N>, $shouldExclude: (node, _selection, _editor) => $isElementNode(node) && node.excludeFromCopy('html'), $shouldInclude: (node, selection, _editor) => selection ? node.isSelected(selection) : true, $updateDOM: (nextNode, prevNode, dom, editor) => nextNode.updateDOM(prevNode, dom, editor._config), }; /** * Creates a new LexicalEditor attached to a single contentEditable (provided in the config). This is * the lowest-level initialization API for a LexicalEditor. If you're using React or another framework, * consider using the appropriate abstractions, such as LexicalComposer * @param editorConfig - the editor configuration. * @returns a LexicalEditor instance */ export function createEditor(editorConfig?: CreateEditorArgs): LexicalEditor { const config = editorConfig || {}; const activeEditor = internalGetActiveEditor(); const theme = config.theme || {}; const parentEditor = editorConfig === undefined ? activeEditor : config.parentEditor || null; const disableEvents = config.disableEvents || false; const editorState = createEmptyEditorState(); const namespace = config.namespace || (parentEditor !== null ? parentEditor._config.namespace : createUID()); const initialEditorState = config.editorState; const nodes = [ RootNode, TextNode, LineBreakNode, TabNode, ParagraphNode, ArtificialNode__DO_NOT_USE, ...(config.nodes || []), ]; const {onError, html} = config; const isEditable = config.editable !== undefined ? config.editable : true; let registeredNodes: RegisteredNodes; if (editorConfig === undefined && activeEditor !== null) { registeredNodes = activeEditor._nodes; } else { registeredNodes = new Map(); for (let i = 0; i < nodes.length; i++) { let klass = nodes[i]; let replace: RegisteredNode['replace'] = null; let replaceWithKlass: RegisteredNode['replaceWithKlass'] = null; if (typeof klass !== 'function') { const options = klass; klass = options.replace; replace = options.with; replaceWithKlass = options.withKlass || null; } // For the side-effect of filling in the static methods void getStaticNodeConfig(klass); // Ensure custom nodes implement required methods and replaceWithKlass is instance of base klass. if (__DEV__) { // ArtificialNode__DO_NOT_USE can get renamed, so we use the type const name = klass.name; const nodeType = hasOwnStaticMethod(klass, 'getType') && klass.getType(); if (replaceWithKlass) { invariant( replaceWithKlass.prototype instanceof klass, "%s doesn't extend the %s", replaceWithKlass.name, name, ); } else if (replace) { console.warn( `Override for ${name} specifies 'replace' without 'withKlass'. 'withKlass' will be required in a future version.`, ); } if ( name !== 'RootNode' && nodeType !== 'root' && nodeType !== 'artificial' && // This is mostly for the unit test suite which // uses LexicalNode in an otherwise incorrect way // by mocking its static getType klass !== LexicalNode ) { (['getType', 'clone'] as const).forEach(method => { if (!hasOwnStaticMethod(klass, method)) { console.warn(`${name} must implement static "${method}" method`); } }); if ( !hasOwnStaticMethod(klass, 'importDOM') && hasOwnExportDOM(klass) ) { console.warn( `${name} should implement "importDOM" if using a custom "exportDOM" method to ensure HTML serialization (important for copy & paste) works as expected`, ); } if (!hasOwnStaticMethod(klass, 'importJSON')) { console.warn( `${name} should implement "importJSON" method to ensure JSON and default HTML serialization works as expected`, ); } } } const type = klass.getType(); const transforms = getTransformSetFromKlass(klass); registeredNodes.set(type, { exportDOM: html && html.export ? html.export.get(klass) : undefined, klass, replace, replaceWithKlass, sharedNodeState: createSharedNodeState(nodes[i]), transforms, }); } } const editor = new LexicalEditor( editorState, parentEditor, registeredNodes, { disableEvents, dom: { ...DEFAULT_EDITOR_DOM_CONFIG, ...(editorConfig && editorConfig.dom), }, namespace, theme, }, onError ? onError : console.error, initializeConversionCache(registeredNodes, html ? html.import : undefined), isEditable, editorConfig, ); if (initialEditorState !== undefined) { editor._pendingEditorState = initialEditorState; editor._dirtyType = FULL_RECONCILE; } registerDefaultCommandHandlers(editor); return editor; } function triggerListener< T extends (...args_: Args) => void | undefined | (() => void), // eslint-disable-next-line @typescript-eslint/no-explicit-any Args extends any[], >(listenerMap: ListenerMap<T>, listener: T, args: Args) { const unregister = listenerMap.get(listener); if (unregister) { unregister(); } listenerMap.set(listener, listener(...args) || undefined); } function unregisterListener<T>(listenerMap: ListenerMap<T>, listener: T): void { const unregister = listenerMap.get(listener); listenerMap.delete(listener); if (unregister) { unregister(); } } function registerListener<T>( listenerMap: ListenerMap<T>, listener: T, unregister?: undefined | (() => void), ): () => void { listenerMap.set(listener, unregister); return unregisterListener.bind(null, listenerMap, listener); } export class LexicalEditor { /** @internal */ declare ['constructor']: KlassConstructor<typeof LexicalEditor>; /** The version with build identifiers for this editor (since 0.17.1) */ static version: string | undefined; /** @internal */ _headless: boolean; /** @internal */ _parentEditor: null | LexicalEditor; /** @internal */ _rootElement: null | HTMLElement; /** @internal */ _editorState: EditorState; /** @internal */ _pendingEditorState: null | EditorState; /** @internal */ _compositionKey: null | NodeKey; /** @internal */ _deferred: Array<() => void>; /** @internal */ _keyToDOMMap: Map<NodeKey, HTMLElement & LexicalPrivateDOM>; /** @internal */ _updates: Array<[() => void, EditorUpdateOptions | undefined]>; /** @internal */ _updating: boolean; /** @internal */ _cascadeCount: number; /** @internal */ _listeners: Listeners; /** @internal */ _commands: Commands; /** @internal */ _nodes: RegisteredNodes; /** @internal */ _decorators: Record<NodeKey, unknown>; /** @internal */ _pendingDecorators: null | Record<NodeKey, unknown>; /** @internal */ _config: EditorConfig; /** @internal */ _dirtyType: 0 | 1 | 2; /** @internal */ _cloneNotNeeded: Set<NodeKey>; /** @internal */ _dirtyLeaves: Set<NodeKey>; /** @internal */ _dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>; /** @internal */ _normalizedNodes: Set<NodeKey>; /** @internal */ _updateTags: Set<UpdateTag>; /** @internal */ _observer: null | MutationObserver; /** @internal */ _key: string; /** @internal */ _onError: ErrorHandler; /** @internal */ _htmlConversions: DOMConversionCache; /** @internal */ _window: null | Window; /** @internal */ _editable: boolean; /** @internal */ _blockCursorElement: null | HTMLDivElement; /** @internal */ _createEditorArgs?: undefined | CreateEditorArgs; /** @internal */ constructor( editorState: EditorState, parentEditor: null | LexicalEditor, nodes: RegisteredNodes, config: EditorConfig, onError: ErrorHandler, htmlConversions: DOMConversionCache, editable: boolean, createEditorArgs?: CreateEditorArgs, ) { this._createEditorArgs = createEditorArgs; this._parentEditor = parentEditor; // The root element associated with this editor this._rootElement = null; // The current editor state this._editorState = editorState; // Handling of drafts and updates this._pendingEditorState = null; // Used to help co-ordinate selection and events this._compositionKey = null; this._deferred = []; // Used during reconciliation this._keyToDOMMap = new GenMap(); this._updates = []; this._updating = false; this._cascadeCount = 0; // Listeners this._listeners = { decorator: new Map(), editable: new Map(), mutation: new Map(), root: new Map(), textcontent: new Map(), update: new Map(), }; // Commands this._commands = new Map(); // Editor configuration for theme/context. this._config = config; // Mapping of types to their nodes this._nodes = nodes; // React node decorators for portals this._decorators = {}; this._pendingDecorators = null; // Used to optimize reconciliation this._dirtyType = NO_DIRTY_NODES; this._cloneNotNeeded = new Set(); this._dirtyLeaves = new Set(); this._dirtyElements = new Map(); this._normalizedNodes = new Set(); this._updateTags = new Set(); // Handling of DOM mutations this._observer = null; // Used for identifying owning editors this._key = createUID(); this._onError = onError; this._htmlConversions = htmlConversions; this._editable = editable; this._headless = parentEditor !== null && parentEditor._headless; this._window = null; this._blockCursorElement = null; } /** * * @returns true if the editor is currently in "composition" mode due to receiving input * through an IME, or 3P extension, for example. Returns false otherwise. */ isComposing(): boolean { return this._compositionKey != null; } /** * Registers a listener for Editor update event. Will trigger the provided callback * each time the editor goes through an update (via {@link LexicalEditor.update}) until the * teardown function is called. * * @returns a teardown function that can be used to cleanup the listener. */ registerUpdateListener(listener: UpdateListener): () => void { return registerListener(this._listeners.update, listener); } /** * Registers a listener for for when the editor changes between editable and non-editable states. * Will trigger the provided callback each time the editor transitions between these states until the * teardown function is called. * * If the listener returns a function, that function will be called before the next transition or * teardown. * * @returns a teardown function that can be used to cleanup the listener. */ registerEditableListener(listener: EditableListener): () => void { return registerListener(this._listeners.editable, listener); } /** * Registers a listener for when the editor's decorator object changes. The decorator object contains * all DecoratorNode keys -> their decorated value. This is primarily used with external UI frameworks. * * Will trigger the provided callback each time the editor transitions between these states until the * teardown function is called. * * @returns a teardown function that can be used to cleanup the listener. */ registerDecoratorListener<T>(listener: DecoratorListener<T>): () => void { return registerListener(this._listeners.decorator, listener); } /** * Registers a listener for when Lexical commits an update to the DOM and the text content of * the editor changes from the previous state of the editor. If the text content is the * same between updates, no notifications to the listeners will happen. * * Will trigger the provided callback each time the editor transitions between these states until the * teardown function is called. * * @returns a teardown function that can be used to cleanup the listener. */ registerTextContentListener(listener: TextContentListener): () => void { return registerListener(this._listeners.textcontent, listener); } /** * Registers a listener for when the editor's root DOM element (the content editable * Lexical attaches to) changes. This is primarily used to attach event listeners to the root * element. The root listener function is executed directly upon registration and then on * any subsequent update. * * Will trigger the provided callback each time the editor transitions between these states until the * teardown function is called. * * If the listener returns a function, that function will be called before the next transition or * teardown. * * @returns a teardown function that can be used to cleanup the listener. */ registerRootListener(listener: RootListener): () => void { const listenerMap = this._listeners.root; return mergeRegister( registerListener( listenerMap, listener, listener(this._rootElement, null) || undefined, ), () => triggerListener(listenerMap, listener, [null, this._rootElement]), ); } /** * Registers a listener that will trigger anytime the provided command * is dispatched with {@link LexicalEditor.dispatch}, subject to priority. * Listeners that run at a higher priority can "intercept" commands and * prevent them from propagating to other handlers by returning true. * * Listeners are always invoked in an {@link LexicalEditor.update} and can * call dollar functions. * * Listeners registered at the same priority level will run * deterministically in the order of registration. * * @param command - the command that will trigger the callback. * @param listener - the function that will execute when the command is dispatched. * @param priority - the relative priority of the listener. 0 | 1 | 2 | 3 | 4 * (or {@link COMMAND_PRIORITY_EDITOR} | * {@link COMMAND_PRIORITY_LOW} | * {@link COMMAND_PRIORITY_NORMAL} | * {@link COMMAND_PRIORITY_HIGH} | * {@link COMMAND_PRIORITY_CRITICAL}) * @returns a teardown function that can be used to cleanup the listener. */ registerCommand<P>( command: LexicalCommand<P>, listener: CommandListener<P>, priority: CommandListenerPriority | CommandListenerPriorityBefore, ): () => void { if (priority === undefined) { invariant(false, 'Listener for type "command" requires a "priority".'); } const commandsMap = this._commands; if (!commandsMap.has(command)) { commandsMap.set(command, [ new DequeSet(), new DequeSet(), new DequeSet(), new DequeSet(), new DequeSet(), ]); } const listenersInPriorityOrder = commandsMap.get(command); if (listenersInPriorityOrder === undefined) { invariant( false, 'registerCommand: Command %s not found in command map', String(command), ); } const normalizedPriority = normalizePriority(priority); const listeners = listenersInPriorityOrder[normalizedPriority]; if (normalizedPriority !== priority) { listeners.addFront(listener); } else { listeners.addBack(listener); } return () => { listeners.delete(listener); if ( listenersInPriorityOrder.every(listenersSet => listenersSet.size === 0) ) { commandsMap.delete(command); } }; } /** * Registers a listener that will run when a Lexical node of the provided class is * mutated. The listener will receive a list of nodes along with the type of mutation * that was performed on each: created, destroyed, or updated. * * One common use case for this is to attach DOM event listeners to the underlying DOM nodes as Lexical nodes are created. * {@link LexicalEditor.getElementByKey} can be used for this. * * If any existing nodes are in the DOM, and skipInitialization is not true, the listener * will be called immediately with an updateTag of 'registerMutationListener' where all * nodes have the 'created' NodeMutation. This can be controlled with the skipInitialization option * (whose default was previously true for backwards compatibility with &lt;=0.16.1 but has been changed to false as of 0.21.0). * * @param klass - The class of the node that you want to listen to mutations on. * @param listener - The logic you want to run when the node is mutated. * @param options - see {@link MutationListenerOptions} * @returns a teardown function that can be used to cleanup the listener. */ registerMutationListener( klass: Klass<LexicalNode>, listener: MutationListener, options?: MutationListenerOptions, ): () => void { const klassToMutate = this.resolveRegisteredNodeAfterReplacements( this.getRegisteredNode(klass), ).klass; const mutations = this._listeners.mutation; let klassSet = mutations.get(listener); if (klassSet === undefined) { klassSet = new Set(); mutations.set(listener, klassSet); } klassSet.add(klassToMutate); const skipInitialization = options && options.skipInitialization; if ( !(skipInitialization === undefined ? DEFAULT_SKIP_INITIALIZATION : skipInitialization) ) { this.initializeMutationListener(listener, klassToMutate); } return () => { klassSet.delete(klassToMutate); if (klassSet.size === 0) { mutations.delete(listener); } }; } /** @internal */ getRegisteredNode(klass: Klass<LexicalNode>): RegisteredNode { const registeredNode = this._nodes.get(klass.getType()); if (registeredNode === undefined) { invariant( false, 'Node %s has not been registered. Ensure node has been passed to createEditor.', klass.name, ); } return registeredNode; } /** @internal */ resolveRegisteredNodeAfterReplacements( registeredNode: RegisteredNode, ): RegisteredNode { while (registeredNode.replaceWithKlass) { registeredNode = this.getRegisteredNode(registeredNode.replaceWithKlass); } return registeredNode; } /** @internal */ private initializeMutationListener( listener: MutationListener, klass: Klass<LexicalNode>, ): void { const prevEditorState = this._editorState; const nodeMap = getCachedTypeToNodeMap(prevEditorState).get( klass.getType(), ); if (!nodeMap) { return; } const nodeMutationMap = new Map<string, NodeMutation>(); for (const k of nodeMap.keys()) { nodeMutationMap.set(k, 'created'); } if (nodeMutationMap.size > 0) { listener(nodeMutationMap, { dirtyLeaves: new Set(), prevEditorState, updateTags: new Set(['registerMutationListener']), }); } } /** @internal */ private registerNodeTransformToKlass<T extends LexicalNode>( klass: Klass<T>, listener: Transform<T>, ): RegisteredNode { const registeredNode = this.getRegisteredNode(klass); registeredNode.transforms.add(listener as Transform<LexicalNode>); return registeredNode; } /** * Registers a listener that will run when a Lexical node of the provided class is * marked dirty during an update. The listener will continue to run as long as the node * is marked dirty. There are no guarantees around the order of transform execution! * * Watch out for infinite loops. See [Node Transforms](https://lexical.dev/docs/concepts/transforms) * @param klass - The class of the node that you want to run transforms on. * @param listener - The logic you want to run when the node is updated. * @returns a teardown function that can be used to cleanup the listener. */ registerNodeTransform<T extends LexicalNode>( klass: Klass<T>, listener: Transform<T>, ): () => void { const registeredNode = this.registerNodeTransformToKlass(klass, listener); const registeredNodes = [registeredNode]; const replaceWithKlass = registeredNode.replaceWithKlass; if (replaceWithKlass != null) { const registeredReplaceWithNode = this.registerNodeTransformToKlass( replaceWithKlass, listener as Transform<LexicalNode>, ); registeredNodes.push(registeredReplaceWithNode); } markNodesWithTypesAsDirty( this, registeredNodes.map(node => node.klass.getType()), ); return () => { registeredNodes.forEach(node => node.transforms.delete(listener as Transform<LexicalNode>), ); }; } /** * Used to assert that a certain node is registered, usually by plugins to ensure nodes that they * depend on have been registered. * @returns True if the editor has registered the provided node type, false otherwise. */ hasNode<T extends Klass<LexicalNode>>(node: T): boolean { return this._nodes.has(node.getType()); } /** * Used to assert that certain nodes are registered, usually by plugins to ensure nodes that they * depend on have been registered. * @returns True if the editor has registered all of the provided node types, false otherwise. */ hasNodes<T extends Klass<LexicalNode>>(nodes: Array<T>): boolean { return nodes.every(this.hasNode.bind(this)); } /** * Dispatches a command of the specified type with the specified payload. * This triggers all command listeners (set by {@link LexicalEditor.registerCommand}) * for this type, passing them the provided payload. The command listeners * will be triggered in an implicit {@link LexicalEditor.update}, unless * this was invoked from inside an update in which case that update context * will be re-used (as if this was a dollar function itself). * @param type - the type of command listeners to trigger. * @param payload - the data to pass as an argument to the command listeners. */ dispatchCommand<TCommand extends LexicalCommand<unknown>>( type: TCommand, payload: CommandPayloadType<TCommand>, ): boolean { return dispatchCommand(this, type, payload); } /** * Gets a map of all decorators in the editor. * @returns A mapping of call decorator keys to their decorated content */ getDecorators<T>(): Record<NodeKey, T> { return this._decorators as Record<NodeKey, T>; } /** * * @returns the current root element of the editor. If you want to register * an event listener, do it via {@link LexicalEditor.registerRootListener}, since * this reference may not be stable. */ getRootElement(): null | HTMLElement { return this._rootElement; } /** * Gets the key of the editor * @returns The editor key */ getKey(): string { return this._key; } /** * Imperatively set the root contenteditable element that Lexical listens * for events on. */ setRootElement(nextRootElement: null | HTMLElement): void { const prevRootElement = this._rootElement; if (nextRootElement !== prevRootElement) { const classNames = getCachedClassNameArray(this._config.theme, 'root'); const pendingEditorState = this._pendingEditorState || this._editorState; this._rootElement = nextRootElement; resetEditor(this, prevRootElement, nextRootElement, pendingEditorState, { preserveUpdateQueue: true, }); if (prevRootElement !== null) { // TODO: remove this flag once we no longer use UEv2 internally if (!this._config.disableEvents) { removeRootElementEvents(prevRootElement); } if (classNames != null) { prevRootElement.classList.remove(...classNames); } } if (nextRootElement !== null) { const windowObj = getDefaultView(nextRootElement); const style = nextRootElement.style; style.userSelect = 'text'; style.whiteSpace = 'pre-wrap'; style.wordBreak = 'break-word'; nextRootElement.setAttribute('data-lexical-editor', 'true'); this._window = windowObj; this._dirtyType = FULL_RECONCILE; initMutationObserver(this); this._updateTags.add(HISTORY_MERGE_TAG); $commitPendingUpdates(this); // TODO: remove this flag once we no longer use UEv2 internally if (!this._config.disableEvents) { addRootElementEvents(nextRootElement, this); } if (classNames != null) { nextRootElement.classList.add(...classNames); } if (__DEV__) { const nextRootElementParent = nextRootElement.parentElement; if ( nextRootElementParent != null && ['flex', 'inline-flex'].includes( getComputedStyle(nextRootElementParent).display, ) ) { console.warn( `When using "display: flex" or "display: inline-flex" on an element containing content editable, Chrome may have unwanted focusing behavior when clicking outside of it. Consider wrapping the content editable within a non-flex element.`, ); } } } else { // When the content editable is unmounted we will still trigger a // reconciliation so that any pending updates are flushed, // to match the previous state change when // `_editorState = pendingEditorState` was used, but by // using a commit we