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
text/typescript
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import type {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 <=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