;
+ prevEditorState: EditorState;
+ },
+) => void;
+
+export type CommandListener = (payload: P, editor: LexicalEditor) => boolean;
+
+export type EditableListener = (editable: boolean) => void;
+
+export type CommandListenerPriority = 0 | 1 | 2 | 3 | 4;
+
+export const COMMAND_PRIORITY_EDITOR = 0;
+export const COMMAND_PRIORITY_LOW = 1;
+export const COMMAND_PRIORITY_NORMAL = 2;
+export const COMMAND_PRIORITY_HIGH = 3;
+export const COMMAND_PRIORITY_CRITICAL = 4;
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export type LexicalCommand = {
+ type?: string;
+};
+
+/**
+ * Type helper for extracting the payload type from a command.
+ *
+ * @example
+ * ```ts
+ * const MY_COMMAND = createCommand();
+ *
+ * // ...
+ *
+ * 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) {
+ * // `payload` is of type `SomeType`, extracted from the command.
+ * }
+ * ```
+ */
+export type CommandPayloadType> =
+ TCommand extends LexicalCommand ? TPayload : never;
+
+type Commands = Map<
+ LexicalCommand,
+ Array>>
+>;
+type Listeners = {
+ decorator: Set;
+ mutation: MutationListeners;
+ editable: Set;
+ root: Set;
+ textcontent: Set;
+ update: Set;
+};
+
+export type Listener =
+ | DecoratorListener
+ | EditableListener
+ | MutationListener
+ | RootListener
+ | TextContentListener
+ | UpdateListener;
+
+export type ListenerType =
+ | 'update'
+ | 'root'
+ | 'decorator'
+ | 'textcontent'
+ | 'mutation'
+ | 'editable';
+
+export type TransformerType = 'text' | 'decorator' | 'element' | 'root';
+
+type IntentionallyMarkedAsDirtyElement = boolean;
+
+type DOMConversionCache = Map<
+ string,
+ Array<(node: Node) => DOMConversion | null>
+>;
+
+export type SerializedEditor = {
+ editorState: SerializedEditorState;
+};
+
+export function resetEditor(
+ editor: LexicalEditor,
+ prevRootElement: null | HTMLElement,
+ nextRootElement: null | HTMLElement,
+ pendingEditorState: EditorState,
+): 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();
+ editor._updateTags = new Set();
+ editor._updates = [];
+ 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;
+}
+
+/**
+ * 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: Map;
+
+ 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;
+ }
+ // 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 nodeType =
+ Object.prototype.hasOwnProperty.call(klass, 'getType') &&
+ klass.getType();
+ const name = klass.name;
+
+ if (replaceWithKlass) {
+ invariant(
+ replaceWithKlass.prototype instanceof klass,
+ "%s doesn't extend the %s",
+ replaceWithKlass.name,
+ name,
+ );
+ }
+
+ if (
+ name !== 'RootNode' &&
+ nodeType !== 'root' &&
+ nodeType !== 'artificial'
+ ) {
+ const proto = klass.prototype;
+ ['getType', 'clone'].forEach((method) => {
+ // eslint-disable-next-line no-prototype-builtins
+ if (!klass.hasOwnProperty(method)) {
+ console.warn(`${name} must implement static "${method}" method`);
+ }
+ });
+ if (
+ // eslint-disable-next-line no-prototype-builtins
+ !klass.hasOwnProperty('importDOM') &&
+ // eslint-disable-next-line no-prototype-builtins
+ klass.hasOwnProperty('exportDOM')
+ ) {
+ console.warn(
+ `${name} should implement "importDOM" if using a custom "exportDOM" method to ensure HTML serialization (important for copy & paste) works as expected`,
+ );
+ }
+ if (proto instanceof DecoratorNode) {
+ // eslint-disable-next-line no-prototype-builtins
+ if (!proto.hasOwnProperty('decorate')) {
+ console.warn(
+ `${proto.constructor.name} must implement "decorate" method`,
+ );
+ }
+ }
+ if (
+ // eslint-disable-next-line no-prototype-builtins
+ !klass.hasOwnProperty('importJSON')
+ ) {
+ console.warn(
+ `${name} should implement "importJSON" method to ensure JSON and default HTML serialization works as expected`,
+ );
+ }
+ if (
+ // eslint-disable-next-line no-prototype-builtins
+ !proto.hasOwnProperty('exportJSON')
+ ) {
+ console.warn(
+ `${name} should implement "exportJSON" method to ensure JSON and default HTML serialization works as expected`,
+ );
+ }
+ }
+ }
+ const type = klass.getType();
+ const transform = klass.transform();
+ const transforms = new Set>();
+ if (transform !== null) {
+ transforms.add(transform);
+ }
+ registeredNodes.set(type, {
+ exportDOM: html && html.export ? html.export.get(klass) : undefined,
+ klass,
+ replace,
+ replaceWithKlass,
+ transforms,
+ });
+ }
+ }
+ const editor = new LexicalEditor(
+ editorState,
+ parentEditor,
+ registeredNodes,
+ {
+ disableEvents,
+ namespace,
+ theme,
+ },
+ onError ? onError : console.error,
+ initializeConversionCache(registeredNodes, html ? html.import : undefined),
+ isEditable,
+ );
+
+ if (initialEditorState !== undefined) {
+ editor._pendingEditorState = initialEditorState;
+ editor._dirtyType = FULL_RECONCILE;
+ }
+
+ return editor;
+}
+export class LexicalEditor {
+ ['constructor']!: KlassConstructor;
+
+ /** 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;
+ /** @internal */
+ _updates: Array<[() => void, EditorUpdateOptions | undefined]>;
+ /** @internal */
+ _updating: boolean;
+ /** @internal */
+ _listeners: Listeners;
+ /** @internal */
+ _commands: Commands;
+ /** @internal */
+ _nodes: RegisteredNodes;
+ /** @internal */
+ _decorators: Record;
+ /** @internal */
+ _pendingDecorators: null | Record;
+ /** @internal */
+ _config: EditorConfig;
+ /** @internal */
+ _dirtyType: 0 | 1 | 2;
+ /** @internal */
+ _cloneNotNeeded: Set;
+ /** @internal */
+ _dirtyLeaves: Set;
+ /** @internal */
+ _dirtyElements: Map;
+ /** @internal */
+ _normalizedNodes: Set;
+ /** @internal */
+ _updateTags: Set;
+ /** @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 */
+ constructor(
+ editorState: EditorState,
+ parentEditor: null | LexicalEditor,
+ nodes: RegisteredNodes,
+ config: EditorConfig,
+ onError: ErrorHandler,
+ htmlConversions: DOMConversionCache,
+ editable: boolean,
+ ) {
+ 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 Map();
+ this._updates = [];
+ this._updating = false;
+ // Listeners
+ this._listeners = {
+ decorator: new Set(),
+ editable: new Set(),
+ mutation: new Map(),
+ root: new Set(),
+ textcontent: new Set(),
+ update: new Set(),
+ };
+ // 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 {
+ const listenerSetOrMap = this._listeners.update;
+ listenerSetOrMap.add(listener);
+ return () => {
+ listenerSetOrMap.delete(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.
+ *
+ * @returns a teardown function that can be used to cleanup the listener.
+ */
+ registerEditableListener(listener: EditableListener): () => void {
+ const listenerSetOrMap = this._listeners.editable;
+ listenerSetOrMap.add(listener);
+ return () => {
+ listenerSetOrMap.delete(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(listener: DecoratorListener): () => void {
+ const listenerSetOrMap = this._listeners.decorator;
+ listenerSetOrMap.add(listener);
+ return () => {
+ listenerSetOrMap.delete(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 {
+ const listenerSetOrMap = this._listeners.textcontent;
+ listenerSetOrMap.add(listener);
+ return () => {
+ listenerSetOrMap.delete(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.
+ *
+ * @returns a teardown function that can be used to cleanup the listener.
+ */
+ registerRootListener(listener: RootListener): () => void {
+ const listenerSetOrMap = this._listeners.root;
+ listener(this._rootElement, null);
+ listenerSetOrMap.add(listener);
+ return () => {
+ listener(null, this._rootElement);
+ listenerSetOrMap.delete(listener);
+ };
+ }
+ /**
+ * Registers a listener that will trigger anytime the provided command
+ * is dispatched, 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 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
+ * @returns a teardown function that can be used to cleanup the listener.
+ */
+ registerCommand(
+ command: LexicalCommand
,
+ listener: CommandListener
,
+ priority: CommandListenerPriority,
+ ): () => 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 Set(),
+ new Set(),
+ new Set(),
+ new Set(),
+ new Set(),
+ ]);
+ }
+
+ const listenersInPriorityOrder = commandsMap.get(command);
+
+ if (listenersInPriorityOrder === undefined) {
+ invariant(
+ false,
+ 'registerCommand: Command %s not found in command map',
+ String(command),
+ );
+ }
+
+ const listeners = listenersInPriorityOrder[priority];
+ listeners.add(listener as CommandListener);
+ return () => {
+ listeners.delete(listener as CommandListener);
+
+ 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
+ * (default is currently true for backwards compatibility in 0.16.x but will change to false in 0.17.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,
+ listener: MutationListener,
+ options?: MutationListenerOptions,
+ ): () => void {
+ const klassToMutate = this.resolveRegisteredNodeAfterReplacements(
+ this.getRegisteredNode(klass),
+ ).klass;
+ const mutations = this._listeners.mutation;
+ mutations.set(listener, klassToMutate);
+ const skipInitialization = options && options.skipInitialization;
+ if (
+ !(skipInitialization === undefined
+ ? DEFAULT_SKIP_INITIALIZATION
+ : skipInitialization)
+ ) {
+ this.initializeMutationListener(listener, klassToMutate);
+ }
+
+ return () => {
+ mutations.delete(listener);
+ };
+ }
+
+ /** @internal */
+ private getRegisteredNode(klass: Klass): 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 */
+ private resolveRegisteredNodeAfterReplacements(
+ registeredNode: RegisteredNode,
+ ): RegisteredNode {
+ while (registeredNode.replaceWithKlass) {
+ registeredNode = this.getRegisteredNode(registeredNode.replaceWithKlass);
+ }
+ return registeredNode;
+ }
+
+ /** @internal */
+ private initializeMutationListener(
+ listener: MutationListener,
+ klass: Klass,
+ ): void {
+ const prevEditorState = this._editorState;
+ const nodeMap = getCachedTypeToNodeMap(prevEditorState).get(
+ klass.getType(),
+ );
+ if (!nodeMap) {
+ return;
+ }
+ const nodeMutationMap = new Map();
+ 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(
+ klass: Klass,
+ listener: Transform,
+ ): RegisteredNode {
+ const registeredNode = this.getRegisteredNode(klass);
+ registeredNode.transforms.add(listener as Transform);
+
+ 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(
+ klass: Klass,
+ listener: Transform,
+ ): () => 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,
+ );
+ registeredNodes.push(registeredReplaceWithNode);
+ }
+
+ markAllNodesAsDirty(this, klass.getType());
+ return () => {
+ registeredNodes.forEach((node) =>
+ node.transforms.delete(listener as Transform),
+ );
+ };
+ }
+
+ /**
+ * 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>(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>(nodes: Array): 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.
+ * @param type - the type of command listeners to trigger.
+ * @param payload - the data to pass as an argument to the command listeners.
+ */
+ dispatchCommand>(
+ type: TCommand,
+ payload: CommandPayloadType,
+ ): 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(): Record {
+ return this._decorators as Record;
+ }
+
+ /**
+ *
+ * @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);
+
+ 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');
+
+ $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);
+ }
+ } else {
+ // If content editable is unmounted we'll reset editor state back to original
+ // (or pending) editor state since there will be no reconciliation
+ this._editorState = pendingEditorState;
+ this._pendingEditorState = null;
+ this._window = null;
+ }
+
+ triggerListeners('root', this, false, nextRootElement, prevRootElement);
+ }
+ }
+
+ /**
+ * Gets the underlying HTMLElement associated with the LexicalNode for the given key.
+ * @returns the HTMLElement rendered by the LexicalNode associated with the key.
+ * @param key - the key of the LexicalNode.
+ */
+ getElementByKey(key: NodeKey): HTMLElement | null {
+ return this._keyToDOMMap.get(key) || null;
+ }
+
+ /**
+ * Gets the active editor state.
+ * @returns The editor state
+ */
+ getEditorState(): EditorState {
+ return this._editorState;
+ }
+
+ /**
+ * Imperatively set the EditorState. Triggers reconciliation like an update.
+ * @param editorState - the state to set the editor
+ * @param options - options for the update.
+ */
+ setEditorState(editorState: EditorState, options?: EditorSetOptions): void {
+ if (editorState.isEmpty()) {
+ invariant(
+ false,
+ "setEditorState: the editor state is empty. Ensure the editor state's root node never becomes empty.",
+ );
+ }
+
+ $flushRootMutations(this);
+ const pendingEditorState = this._pendingEditorState;
+ const tags = this._updateTags;
+ const tag = options !== undefined ? options.tag : null;
+
+ if (pendingEditorState !== null && !pendingEditorState.isEmpty()) {
+ if (tag != null) {
+ tags.add(tag);
+ }
+
+ $commitPendingUpdates(this);
+ }
+
+ this._pendingEditorState = editorState;
+ this._dirtyType = FULL_RECONCILE;
+ this._dirtyElements.set('root', false);
+ this._compositionKey = null;
+
+ if (tag != null) {
+ tags.add(tag);
+ }
+
+ $commitPendingUpdates(this);
+ }
+
+ /**
+ * Parses a SerializedEditorState (usually produced by {@link EditorState.toJSON}) and returns
+ * and EditorState object that can be, for example, passed to {@link LexicalEditor.setEditorState}. Typically,
+ * deserialization from JSON stored in a database uses this method.
+ * @param maybeStringifiedEditorState
+ * @param updateFn
+ * @returns
+ */
+ parseEditorState(
+ maybeStringifiedEditorState: string | SerializedEditorState,
+ updateFn?: () => void,
+ ): EditorState {
+ const serializedEditorState =
+ typeof maybeStringifiedEditorState === 'string'
+ ? JSON.parse(maybeStringifiedEditorState)
+ : maybeStringifiedEditorState;
+ return parseEditorState(serializedEditorState, this, updateFn);
+ }
+
+ /**
+ * Executes a read of the editor's state, with the
+ * editor context available (useful for exporting and read-only DOM
+ * operations). Much like update, but prevents any mutation of the
+ * editor's state. Any pending updates will be flushed immediately before
+ * the read.
+ * @param callbackFn - A function that has access to read-only editor state.
+ */
+ read(callbackFn: () => T): T {
+ $commitPendingUpdates(this);
+ return this.getEditorState().read(callbackFn, {editor: this});
+ }
+
+ /**
+ * Executes an update to the editor state. The updateFn callback is the ONLY place
+ * where Lexical editor state can be safely mutated.
+ * @param updateFn - A function that has access to writable editor state.
+ * @param options - A bag of options to control the behavior of the update.
+ * @param options.onUpdate - A function to run once the update is complete.
+ * Useful for synchronizing updates in some cases.
+ * @param options.skipTransforms - Setting this to true will suppress all node
+ * transforms for this update cycle.
+ * @param options.tag - A tag to identify this update, in an update listener, for instance.
+ * Some tags are reserved by the core and control update behavior in different ways.
+ * @param options.discrete - If true, prevents this update from being batched, forcing it to
+ * run synchronously.
+ */
+ update(updateFn: () => void, options?: EditorUpdateOptions): void {
+ updateEditor(this, updateFn, options);
+ }
+
+ /**
+ * Focuses the editor
+ * @param callbackFn - A function to run after the editor is focused.
+ * @param options - A bag of options
+ * @param options.defaultSelection - Where to move selection when the editor is
+ * focused. Can be rootStart, rootEnd, or undefined. Defaults to rootEnd.
+ */
+ focus(callbackFn?: () => void, options: EditorFocusOptions = {}): void {
+ const rootElement = this._rootElement;
+
+ if (rootElement !== null) {
+ // This ensures that iOS does not trigger caps lock upon focus
+ rootElement.setAttribute('autocapitalize', 'off');
+ updateEditor(
+ this,
+ () => {
+ const selection = $getSelection();
+ const root = $getRoot();
+
+ if (selection !== null) {
+ // Marking the selection dirty will force the selection back to it
+ selection.dirty = true;
+ } else if (root.getChildrenSize() !== 0) {
+ if (options.defaultSelection === 'rootStart') {
+ root.selectStart();
+ } else {
+ root.selectEnd();
+ }
+ }
+ },
+ {
+ onUpdate: () => {
+ rootElement.removeAttribute('autocapitalize');
+ if (callbackFn) {
+ callbackFn();
+ }
+ },
+ tag: 'focus',
+ },
+ );
+ // In the case where onUpdate doesn't fire (due to the focus update not
+ // occuring).
+ if (this._pendingEditorState === null) {
+ rootElement.removeAttribute('autocapitalize');
+ }
+ }
+ }
+
+ /**
+ * Commits any currently pending updates scheduled for the editor.
+ */
+ commitUpdates(): void {
+ $commitPendingUpdates(this);
+ }
+
+ /**
+ * Removes focus from the editor.
+ */
+ blur(): void {
+ const rootElement = this._rootElement;
+
+ if (rootElement !== null) {
+ rootElement.blur();
+ }
+
+ const domSelection = getDOMSelection(this._window);
+
+ if (domSelection !== null) {
+ domSelection.removeAllRanges();
+ }
+ }
+ /**
+ * Returns true if the editor is editable, false otherwise.
+ * @returns True if the editor is editable, false otherwise.
+ */
+ isEditable(): boolean {
+ return this._editable;
+ }
+ /**
+ * Sets the editable property of the editor. When false, the
+ * editor will not listen for user events on the underling contenteditable.
+ * @param editable - the value to set the editable mode to.
+ */
+ setEditable(editable: boolean): void {
+ if (this._editable !== editable) {
+ this._editable = editable;
+ triggerListeners('editable', this, true, editable);
+ }
+ }
+ /**
+ * Returns a JSON-serializable javascript object NOT a JSON string.
+ * You still must call JSON.stringify (or something else) to turn the
+ * state into a string you can transfer over the wire and store in a database.
+ *
+ * See {@link LexicalNode.exportJSON}
+ *
+ * @returns A JSON-serializable javascript object
+ */
+ toJSON(): SerializedEditor {
+ return {
+ editorState: this._editorState.toJSON(),
+ };
+ }
+}
+
+LexicalEditor.version = '0.17.1';
diff --git a/resources/js/wysiwyg/lexical/core/LexicalEditorState.ts b/resources/js/wysiwyg/lexical/core/LexicalEditorState.ts
new file mode 100644
index 000000000..f84d2e40a
--- /dev/null
+++ b/resources/js/wysiwyg/lexical/core/LexicalEditorState.ts
@@ -0,0 +1,137 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type {LexicalEditor} from './LexicalEditor';
+import type {LexicalNode, NodeMap, SerializedLexicalNode} from './LexicalNode';
+import type {BaseSelection} from './LexicalSelection';
+import type {SerializedElementNode} from './nodes/LexicalElementNode';
+import type {SerializedRootNode} from './nodes/LexicalRootNode';
+
+import invariant from 'lexical/shared/invariant';
+
+import {readEditorState} from './LexicalUpdates';
+import {$getRoot} from './LexicalUtils';
+import {$isElementNode} from './nodes/LexicalElementNode';
+import {$createRootNode} from './nodes/LexicalRootNode';
+
+export interface SerializedEditorState<
+ T extends SerializedLexicalNode = SerializedLexicalNode,
+> {
+ root: SerializedRootNode;
+}
+
+export function editorStateHasDirtySelection(
+ editorState: EditorState,
+ editor: LexicalEditor,
+): boolean {
+ const currentSelection = editor.getEditorState()._selection;
+
+ const pendingSelection = editorState._selection;
+
+ // Check if we need to update because of changes in selection
+ if (pendingSelection !== null) {
+ if (pendingSelection.dirty || !pendingSelection.is(currentSelection)) {
+ return true;
+ }
+ } else if (currentSelection !== null) {
+ return true;
+ }
+
+ return false;
+}
+
+export function cloneEditorState(current: EditorState): EditorState {
+ return new EditorState(new Map(current._nodeMap));
+}
+
+export function createEmptyEditorState(): EditorState {
+ return new EditorState(new Map([['root', $createRootNode()]]));
+}
+
+function exportNodeToJSON(
+ node: LexicalNode,
+): SerializedNode {
+ const serializedNode = node.exportJSON();
+ const nodeClass = node.constructor;
+
+ if (serializedNode.type !== nodeClass.getType()) {
+ invariant(
+ false,
+ 'LexicalNode: Node %s does not match the serialized type. Check if .exportJSON() is implemented and it is returning the correct type.',
+ nodeClass.name,
+ );
+ }
+
+ if ($isElementNode(node)) {
+ const serializedChildren = (serializedNode as SerializedElementNode)
+ .children;
+ if (!Array.isArray(serializedChildren)) {
+ invariant(
+ false,
+ 'LexicalNode: Node %s is an element but .exportJSON() does not have a children array.',
+ nodeClass.name,
+ );
+ }
+
+ const children = node.getChildren();
+
+ for (let i = 0; i < children.length; i++) {
+ const child = children[i];
+ const serializedChildNode = exportNodeToJSON(child);
+ serializedChildren.push(serializedChildNode);
+ }
+ }
+
+ // @ts-expect-error
+ return serializedNode;
+}
+
+export interface EditorStateReadOptions {
+ editor?: LexicalEditor | null;
+}
+
+export class EditorState {
+ _nodeMap: NodeMap;
+ _selection: null | BaseSelection;
+ _flushSync: boolean;
+ _readOnly: boolean;
+
+ constructor(nodeMap: NodeMap, selection?: null | BaseSelection) {
+ this._nodeMap = nodeMap;
+ this._selection = selection || null;
+ this._flushSync = false;
+ this._readOnly = false;
+ }
+
+ isEmpty(): boolean {
+ return this._nodeMap.size === 1 && this._selection === null;
+ }
+
+ read(callbackFn: () => V, options?: EditorStateReadOptions): V {
+ return readEditorState(
+ (options && options.editor) || null,
+ this,
+ callbackFn,
+ );
+ }
+
+ clone(selection?: null | BaseSelection): EditorState {
+ const editorState = new EditorState(
+ this._nodeMap,
+ selection === undefined ? this._selection : selection,
+ );
+ editorState._readOnly = true;
+
+ return editorState;
+ }
+ toJSON(): SerializedEditorState {
+ return readEditorState(null, this, () => ({
+ root: exportNodeToJSON($getRoot()),
+ }));
+ }
+}
diff --git a/resources/js/wysiwyg/lexical/core/LexicalEvents.ts b/resources/js/wysiwyg/lexical/core/LexicalEvents.ts
new file mode 100644
index 000000000..5fd671a76
--- /dev/null
+++ b/resources/js/wysiwyg/lexical/core/LexicalEvents.ts
@@ -0,0 +1,1385 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type {LexicalEditor} from './LexicalEditor';
+import type {NodeKey} from './LexicalNode';
+import type {ElementNode} from './nodes/LexicalElementNode';
+import type {TextNode} from './nodes/LexicalTextNode';
+
+import {
+ CAN_USE_BEFORE_INPUT,
+ IS_ANDROID_CHROME,
+ IS_APPLE_WEBKIT,
+ IS_FIREFOX,
+ IS_IOS,
+ IS_SAFARI,
+} from 'lexical/shared/environment';
+import invariant from 'lexical/shared/invariant';
+
+import {
+ $getPreviousSelection,
+ $getRoot,
+ $getSelection,
+ $isElementNode,
+ $isNodeSelection,
+ $isRangeSelection,
+ $isRootNode,
+ $isTextNode,
+ $setCompositionKey,
+ BLUR_COMMAND,
+ CLICK_COMMAND,
+ CONTROLLED_TEXT_INSERTION_COMMAND,
+ COPY_COMMAND,
+ CUT_COMMAND,
+ DELETE_CHARACTER_COMMAND,
+ DELETE_LINE_COMMAND,
+ DELETE_WORD_COMMAND,
+ DRAGEND_COMMAND,
+ DRAGOVER_COMMAND,
+ DRAGSTART_COMMAND,
+ DROP_COMMAND,
+ FOCUS_COMMAND,
+ FORMAT_TEXT_COMMAND,
+ INSERT_LINE_BREAK_COMMAND,
+ INSERT_PARAGRAPH_COMMAND,
+ KEY_ARROW_DOWN_COMMAND,
+ KEY_ARROW_LEFT_COMMAND,
+ KEY_ARROW_RIGHT_COMMAND,
+ KEY_ARROW_UP_COMMAND,
+ KEY_BACKSPACE_COMMAND,
+ KEY_DELETE_COMMAND,
+ KEY_DOWN_COMMAND,
+ KEY_ENTER_COMMAND,
+ KEY_ESCAPE_COMMAND,
+ KEY_SPACE_COMMAND,
+ KEY_TAB_COMMAND,
+ MOVE_TO_END,
+ MOVE_TO_START,
+ ParagraphNode,
+ PASTE_COMMAND,
+ REDO_COMMAND,
+ REMOVE_TEXT_COMMAND,
+ SELECTION_CHANGE_COMMAND,
+ UNDO_COMMAND,
+} from '.';
+import {KEY_MODIFIER_COMMAND, SELECT_ALL_COMMAND} from './LexicalCommands';
+import {
+ COMPOSITION_START_CHAR,
+ DOM_ELEMENT_TYPE,
+ DOM_TEXT_TYPE,
+ DOUBLE_LINE_BREAK,
+ IS_ALL_FORMATTING,
+} from './LexicalConstants';
+import {
+ $internalCreateRangeSelection,
+ RangeSelection,
+} from './LexicalSelection';
+import {getActiveEditor, updateEditor} from './LexicalUpdates';
+import {
+ $flushMutations,
+ $getNodeByKey,
+ $isSelectionCapturedInDecorator,
+ $isTokenOrSegmented,
+ $setSelection,
+ $shouldInsertTextAfterOrBeforeTextNode,
+ $updateSelectedTextFromDOM,
+ $updateTextNodeFromDOMContent,
+ dispatchCommand,
+ doesContainGrapheme,
+ getAnchorTextFromDOM,
+ getDOMSelection,
+ getDOMTextNode,
+ getEditorPropertyFromDOMNode,
+ getEditorsToPropagate,
+ getNearestEditorFromDOMNode,
+ getWindow,
+ isBackspace,
+ isBold,
+ isCopy,
+ isCut,
+ isDelete,
+ isDeleteBackward,
+ isDeleteForward,
+ isDeleteLineBackward,
+ isDeleteLineForward,
+ isDeleteWordBackward,
+ isDeleteWordForward,
+ isEscape,
+ isFirefoxClipboardEvents,
+ isItalic,
+ isLexicalEditor,
+ isLineBreak,
+ isModifier,
+ isMoveBackward,
+ isMoveDown,
+ isMoveForward,
+ isMoveToEnd,
+ isMoveToStart,
+ isMoveUp,
+ isOpenLineBreak,
+ isParagraph,
+ isRedo,
+ isSelectAll,
+ isSelectionWithinEditor,
+ isSpace,
+ isTab,
+ isUnderline,
+ isUndo,
+} from './LexicalUtils';
+
+type RootElementRemoveHandles = Array<() => void>;
+type RootElementEvents = Array<
+ [
+ string,
+ Record | ((event: Event, editor: LexicalEditor) => void),
+ ]
+>;
+const PASS_THROUGH_COMMAND = Object.freeze({});
+const ANDROID_COMPOSITION_LATENCY = 30;
+const rootElementEvents: RootElementEvents = [
+ ['keydown', onKeyDown],
+ ['pointerdown', onPointerDown],
+ ['compositionstart', onCompositionStart],
+ ['compositionend', onCompositionEnd],
+ ['input', onInput],
+ ['click', onClick],
+ ['cut', PASS_THROUGH_COMMAND],
+ ['copy', PASS_THROUGH_COMMAND],
+ ['dragstart', PASS_THROUGH_COMMAND],
+ ['dragover', PASS_THROUGH_COMMAND],
+ ['dragend', PASS_THROUGH_COMMAND],
+ ['paste', PASS_THROUGH_COMMAND],
+ ['focus', PASS_THROUGH_COMMAND],
+ ['blur', PASS_THROUGH_COMMAND],
+ ['drop', PASS_THROUGH_COMMAND],
+];
+
+if (CAN_USE_BEFORE_INPUT) {
+ rootElementEvents.push([
+ 'beforeinput',
+ (event, editor) => onBeforeInput(event as InputEvent, editor),
+ ]);
+}
+
+let lastKeyDownTimeStamp = 0;
+let lastKeyCode: null | string = null;
+let lastBeforeInputInsertTextTimeStamp = 0;
+let unprocessedBeforeInputData: null | string = null;
+const rootElementsRegistered = new WeakMap();
+let isSelectionChangeFromDOMUpdate = false;
+let isSelectionChangeFromMouseDown = false;
+let isInsertLineBreak = false;
+let isFirefoxEndingComposition = false;
+let collapsedSelectionFormat: [number, string, number, NodeKey, number] = [
+ 0,
+ '',
+ 0,
+ 'root',
+ 0,
+];
+
+// This function is used to determine if Lexical should attempt to override
+// the default browser behavior for insertion of text and use its own internal
+// heuristics. This is an extremely important function, and makes much of Lexical
+// work as intended between different browsers and across word, line and character
+// boundary/formats. It also is important for text replacement, node schemas and
+// composition mechanics.
+
+function $shouldPreventDefaultAndInsertText(
+ selection: RangeSelection,
+ domTargetRange: null | StaticRange,
+ text: string,
+ timeStamp: number,
+ isBeforeInput: boolean,
+): boolean {
+ const anchor = selection.anchor;
+ const focus = selection.focus;
+ const anchorNode = anchor.getNode();
+ const editor = getActiveEditor();
+ const domSelection = getDOMSelection(editor._window);
+ const domAnchorNode = domSelection !== null ? domSelection.anchorNode : null;
+ const anchorKey = anchor.key;
+ const backingAnchorElement = editor.getElementByKey(anchorKey);
+ const textLength = text.length;
+
+ return (
+ anchorKey !== focus.key ||
+ // If we're working with a non-text node.
+ !$isTextNode(anchorNode) ||
+ // If we are replacing a range with a single character or grapheme, and not composing.
+ (((!isBeforeInput &&
+ (!CAN_USE_BEFORE_INPUT ||
+ // We check to see if there has been
+ // a recent beforeinput event for "textInput". If there has been one in the last
+ // 50ms then we proceed as normal. However, if there is not, then this is likely
+ // a dangling `input` event caused by execCommand('insertText').
+ lastBeforeInputInsertTextTimeStamp < timeStamp + 50)) ||
+ (anchorNode.isDirty() && textLength < 2) ||
+ doesContainGrapheme(text)) &&
+ anchor.offset !== focus.offset &&
+ !anchorNode.isComposing()) ||
+ // Any non standard text node.
+ $isTokenOrSegmented(anchorNode) ||
+ // If the text length is more than a single character and we're either
+ // dealing with this in "beforeinput" or where the node has already recently
+ // been changed (thus is dirty).
+ (anchorNode.isDirty() && textLength > 1) ||
+ // If the DOM selection element is not the same as the backing node during beforeinput.
+ ((isBeforeInput || !CAN_USE_BEFORE_INPUT) &&
+ backingAnchorElement !== null &&
+ !anchorNode.isComposing() &&
+ domAnchorNode !== getDOMTextNode(backingAnchorElement)) ||
+ // If TargetRange is not the same as the DOM selection; browser trying to edit random parts
+ // of the editor.
+ (domSelection !== null &&
+ domTargetRange !== null &&
+ (!domTargetRange.collapsed ||
+ domTargetRange.startContainer !== domSelection.anchorNode ||
+ domTargetRange.startOffset !== domSelection.anchorOffset)) ||
+ // Check if we're changing from bold to italics, or some other format.
+ anchorNode.getFormat() !== selection.format ||
+ anchorNode.getStyle() !== selection.style ||
+ // One last set of heuristics to check against.
+ $shouldInsertTextAfterOrBeforeTextNode(selection, anchorNode)
+ );
+}
+
+function shouldSkipSelectionChange(
+ domNode: null | Node,
+ offset: number,
+): boolean {
+ return (
+ domNode !== null &&
+ domNode.nodeValue !== null &&
+ domNode.nodeType === DOM_TEXT_TYPE &&
+ offset !== 0 &&
+ offset !== domNode.nodeValue.length
+ );
+}
+
+function onSelectionChange(
+ domSelection: Selection,
+ editor: LexicalEditor,
+ isActive: boolean,
+): void {
+ const {
+ anchorNode: anchorDOM,
+ anchorOffset,
+ focusNode: focusDOM,
+ focusOffset,
+ } = domSelection;
+ if (isSelectionChangeFromDOMUpdate) {
+ isSelectionChangeFromDOMUpdate = false;
+
+ // If native DOM selection is on a DOM element, then
+ // we should continue as usual, as Lexical's selection
+ // may have normalized to a better child. If the DOM
+ // element is a text node, we can safely apply this
+ // optimization and skip the selection change entirely.
+ // We also need to check if the offset is at the boundary,
+ // because in this case, we might need to normalize to a
+ // sibling instead.
+ if (
+ shouldSkipSelectionChange(anchorDOM, anchorOffset) &&
+ shouldSkipSelectionChange(focusDOM, focusOffset)
+ ) {
+ return;
+ }
+ }
+ updateEditor(editor, () => {
+ // Non-active editor don't need any extra logic for selection, it only needs update
+ // to reconcile selection (set it to null) to ensure that only one editor has non-null selection.
+ if (!isActive) {
+ $setSelection(null);
+ return;
+ }
+
+ if (!isSelectionWithinEditor(editor, anchorDOM, focusDOM)) {
+ return;
+ }
+
+ const selection = $getSelection();
+
+ // Update the selection format
+ if ($isRangeSelection(selection)) {
+ const anchor = selection.anchor;
+ const anchorNode = anchor.getNode();
+
+ if (selection.isCollapsed()) {
+ // Badly interpreted range selection when collapsed - #1482
+ if (
+ domSelection.type === 'Range' &&
+ domSelection.anchorNode === domSelection.focusNode
+ ) {
+ selection.dirty = true;
+ }
+
+ // If we have marked a collapsed selection format, and we're
+ // within the given time range – then attempt to use that format
+ // instead of getting the format from the anchor node.
+ const windowEvent = getWindow(editor).event;
+ const currentTimeStamp = windowEvent
+ ? windowEvent.timeStamp
+ : performance.now();
+ const [lastFormat, lastStyle, lastOffset, lastKey, timeStamp] =
+ collapsedSelectionFormat;
+
+ const root = $getRoot();
+ const isRootTextContentEmpty =
+ editor.isComposing() === false && root.getTextContent() === '';
+
+ if (
+ currentTimeStamp < timeStamp + 200 &&
+ anchor.offset === lastOffset &&
+ anchor.key === lastKey
+ ) {
+ selection.format = lastFormat;
+ selection.style = lastStyle;
+ } else {
+ if (anchor.type === 'text') {
+ invariant(
+ $isTextNode(anchorNode),
+ 'Point.getNode() must return TextNode when type is text',
+ );
+ selection.format = anchorNode.getFormat();
+ selection.style = anchorNode.getStyle();
+ } else if (anchor.type === 'element' && !isRootTextContentEmpty) {
+ const lastNode = anchor.getNode();
+ selection.style = '';
+ if (
+ lastNode instanceof ParagraphNode &&
+ lastNode.getChildrenSize() === 0
+ ) {
+ selection.format = lastNode.getTextFormat();
+ selection.style = lastNode.getTextStyle();
+ } else {
+ selection.format = 0;
+ }
+ }
+ }
+ } else {
+ const anchorKey = anchor.key;
+ const focus = selection.focus;
+ const focusKey = focus.key;
+ const nodes = selection.getNodes();
+ const nodesLength = nodes.length;
+ const isBackward = selection.isBackward();
+ const startOffset = isBackward ? focusOffset : anchorOffset;
+ const endOffset = isBackward ? anchorOffset : focusOffset;
+ const startKey = isBackward ? focusKey : anchorKey;
+ const endKey = isBackward ? anchorKey : focusKey;
+ let combinedFormat = IS_ALL_FORMATTING;
+ let hasTextNodes = false;
+ for (let i = 0; i < nodesLength; i++) {
+ const node = nodes[i];
+ const textContentSize = node.getTextContentSize();
+ if (
+ $isTextNode(node) &&
+ textContentSize !== 0 &&
+ // Exclude empty text nodes at boundaries resulting from user's selection
+ !(
+ (i === 0 &&
+ node.__key === startKey &&
+ startOffset === textContentSize) ||
+ (i === nodesLength - 1 &&
+ node.__key === endKey &&
+ endOffset === 0)
+ )
+ ) {
+ // TODO: what about style?
+ hasTextNodes = true;
+ combinedFormat &= node.getFormat();
+ if (combinedFormat === 0) {
+ break;
+ }
+ }
+ }
+
+ selection.format = hasTextNodes ? combinedFormat : 0;
+ }
+ }
+
+ dispatchCommand(editor, SELECTION_CHANGE_COMMAND, undefined);
+ });
+}
+
+// This is a work-around is mainly Chrome specific bug where if you select
+// the contents of an empty block, you cannot easily unselect anything.
+// This results in a tiny selection box that looks buggy/broken. This can
+// also help other browsers when selection might "appear" lost, when it
+// really isn't.
+function onClick(event: PointerEvent, editor: LexicalEditor): void {
+ updateEditor(editor, () => {
+ const selection = $getSelection();
+ const domSelection = getDOMSelection(editor._window);
+ const lastSelection = $getPreviousSelection();
+
+ if (domSelection) {
+ if ($isRangeSelection(selection)) {
+ const anchor = selection.anchor;
+ const anchorNode = anchor.getNode();
+
+ if (
+ anchor.type === 'element' &&
+ anchor.offset === 0 &&
+ selection.isCollapsed() &&
+ !$isRootNode(anchorNode) &&
+ $getRoot().getChildrenSize() === 1 &&
+ anchorNode.getTopLevelElementOrThrow().isEmpty() &&
+ lastSelection !== null &&
+ selection.is(lastSelection)
+ ) {
+ domSelection.removeAllRanges();
+ selection.dirty = true;
+ } else if (event.detail === 3 && !selection.isCollapsed()) {
+ // Tripple click causing selection to overflow into the nearest element. In that
+ // case visually it looks like a single element content is selected, focus node
+ // is actually at the beginning of the next element (if present) and any manipulations
+ // with selection (formatting) are affecting second element as well
+ const focus = selection.focus;
+ const focusNode = focus.getNode();
+ if (anchorNode !== focusNode) {
+ if ($isElementNode(anchorNode)) {
+ anchorNode.select(0);
+ } else {
+ anchorNode.getParentOrThrow().select(0);
+ }
+ }
+ }
+ } else if (event.pointerType === 'touch') {
+ // This is used to update the selection on touch devices when the user clicks on text after a
+ // node selection. See isSelectionChangeFromMouseDown for the inverse
+ const domAnchorNode = domSelection.anchorNode;
+ if (domAnchorNode !== null) {
+ const nodeType = domAnchorNode.nodeType;
+ // If the user is attempting to click selection back onto text, then
+ // we should attempt create a range selection.
+ // When we click on an empty paragraph node or the end of a paragraph that ends
+ // with an image/poll, the nodeType will be ELEMENT_NODE
+ if (nodeType === DOM_ELEMENT_TYPE || nodeType === DOM_TEXT_TYPE) {
+ const newSelection = $internalCreateRangeSelection(
+ lastSelection,
+ domSelection,
+ editor,
+ event,
+ );
+ $setSelection(newSelection);
+ }
+ }
+ }
+ }
+
+ dispatchCommand(editor, CLICK_COMMAND, event);
+ });
+}
+
+function onPointerDown(event: PointerEvent, editor: LexicalEditor) {
+ // TODO implement text drag & drop
+ const target = event.target;
+ const pointerType = event.pointerType;
+ if (target instanceof Node && pointerType !== 'touch') {
+ updateEditor(editor, () => {
+ // Drag & drop should not recompute selection until mouse up; otherwise the initially
+ // selected content is lost.
+ if (!$isSelectionCapturedInDecorator(target)) {
+ isSelectionChangeFromMouseDown = true;
+ }
+ });
+ }
+}
+
+function getTargetRange(event: InputEvent): null | StaticRange {
+ if (!event.getTargetRanges) {
+ return null;
+ }
+ const targetRanges = event.getTargetRanges();
+ if (targetRanges.length === 0) {
+ return null;
+ }
+ return targetRanges[0];
+}
+
+function $canRemoveText(
+ anchorNode: TextNode | ElementNode,
+ focusNode: TextNode | ElementNode,
+): boolean {
+ return (
+ anchorNode !== focusNode ||
+ $isElementNode(anchorNode) ||
+ $isElementNode(focusNode) ||
+ !anchorNode.isToken() ||
+ !focusNode.isToken()
+ );
+}
+
+function isPossiblyAndroidKeyPress(timeStamp: number): boolean {
+ return (
+ lastKeyCode === 'MediaLast' &&
+ timeStamp < lastKeyDownTimeStamp + ANDROID_COMPOSITION_LATENCY
+ );
+}
+
+function onBeforeInput(event: InputEvent, editor: LexicalEditor): void {
+ const inputType = event.inputType;
+ const targetRange = getTargetRange(event);
+
+ // We let the browser do its own thing for composition.
+ if (
+ inputType === 'deleteCompositionText' ||
+ // If we're pasting in FF, we shouldn't get this event
+ // as the `paste` event should have triggered, unless the
+ // user has dom.event.clipboardevents.enabled disabled in
+ // about:config. In that case, we need to process the
+ // pasted content in the DOM mutation phase.
+ (IS_FIREFOX && isFirefoxClipboardEvents(editor))
+ ) {
+ return;
+ } else if (inputType === 'insertCompositionText') {
+ return;
+ }
+
+ updateEditor(editor, () => {
+ const selection = $getSelection();
+
+ if (inputType === 'deleteContentBackward') {
+ if (selection === null) {
+ // Use previous selection
+ const prevSelection = $getPreviousSelection();
+
+ if (!$isRangeSelection(prevSelection)) {
+ return;
+ }
+
+ $setSelection(prevSelection.clone());
+ }
+
+ if ($isRangeSelection(selection)) {
+ const isSelectionAnchorSameAsFocus =
+ selection.anchor.key === selection.focus.key;
+
+ if (
+ isPossiblyAndroidKeyPress(event.timeStamp) &&
+ editor.isComposing() &&
+ isSelectionAnchorSameAsFocus
+ ) {
+ $setCompositionKey(null);
+ lastKeyDownTimeStamp = 0;
+ // Fixes an Android bug where selection flickers when backspacing
+ setTimeout(() => {
+ updateEditor(editor, () => {
+ $setCompositionKey(null);
+ });
+ }, ANDROID_COMPOSITION_LATENCY);
+ if ($isRangeSelection(selection)) {
+ const anchorNode = selection.anchor.getNode();
+ anchorNode.markDirty();
+ selection.format = anchorNode.getFormat();
+ invariant(
+ $isTextNode(anchorNode),
+ 'Anchor node must be a TextNode',
+ );
+ selection.style = anchorNode.getStyle();
+ }
+ } else {
+ $setCompositionKey(null);
+ event.preventDefault();
+ // Chromium Android at the moment seems to ignore the preventDefault
+ // on 'deleteContentBackward' and still deletes the content. Which leads
+ // to multiple deletions. So we let the browser handle the deletion in this case.
+ const selectedNodeText = selection.anchor.getNode().getTextContent();
+ const hasSelectedAllTextInNode =
+ selection.anchor.offset === 0 &&
+ selection.focus.offset === selectedNodeText.length;
+ const shouldLetBrowserHandleDelete =
+ IS_ANDROID_CHROME &&
+ isSelectionAnchorSameAsFocus &&
+ !hasSelectedAllTextInNode;
+ if (!shouldLetBrowserHandleDelete) {
+ dispatchCommand(editor, DELETE_CHARACTER_COMMAND, true);
+ }
+ }
+ return;
+ }
+ }
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+
+ const data = event.data;
+
+ // This represents the case when two beforeinput events are triggered at the same time (without a
+ // full event loop ending at input). This happens with MacOS with the default keyboard settings,
+ // a combination of autocorrection + autocapitalization.
+ // Having Lexical run everything in controlled mode would fix the issue without additional code
+ // but this would kill the massive performance win from the most common typing event.
+ // Alternatively, when this happens we can prematurely update our EditorState based on the DOM
+ // content, a job that would usually be the input event's responsibility.
+ if (unprocessedBeforeInputData !== null) {
+ $updateSelectedTextFromDOM(false, editor, unprocessedBeforeInputData);
+ }
+
+ if (
+ (!selection.dirty || unprocessedBeforeInputData !== null) &&
+ selection.isCollapsed() &&
+ !$isRootNode(selection.anchor.getNode()) &&
+ targetRange !== null
+ ) {
+ selection.applyDOMRange(targetRange);
+ }
+
+ unprocessedBeforeInputData = null;
+
+ const anchor = selection.anchor;
+ const focus = selection.focus;
+ const anchorNode = anchor.getNode();
+ const focusNode = focus.getNode();
+
+ if (inputType === 'insertText' || inputType === 'insertTranspose') {
+ if (data === '\n') {
+ event.preventDefault();
+ dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, false);
+ } else if (data === DOUBLE_LINE_BREAK) {
+ event.preventDefault();
+ dispatchCommand(editor, INSERT_PARAGRAPH_COMMAND, undefined);
+ } else if (data == null && event.dataTransfer) {
+ // Gets around a Safari text replacement bug.
+ const text = event.dataTransfer.getData('text/plain');
+ event.preventDefault();
+ selection.insertRawText(text);
+ } else if (
+ data != null &&
+ $shouldPreventDefaultAndInsertText(
+ selection,
+ targetRange,
+ data,
+ event.timeStamp,
+ true,
+ )
+ ) {
+ event.preventDefault();
+ dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, data);
+ } else {
+ unprocessedBeforeInputData = data;
+ }
+ lastBeforeInputInsertTextTimeStamp = event.timeStamp;
+ return;
+ }
+
+ // Prevent the browser from carrying out
+ // the input event, so we can control the
+ // output.
+ event.preventDefault();
+
+ switch (inputType) {
+ case 'insertFromYank':
+ case 'insertFromDrop':
+ case 'insertReplacementText': {
+ dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, event);
+ break;
+ }
+
+ case 'insertFromComposition': {
+ // This is the end of composition
+ $setCompositionKey(null);
+ dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, event);
+ break;
+ }
+
+ case 'insertLineBreak': {
+ // Used for Android
+ $setCompositionKey(null);
+ dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, false);
+ break;
+ }
+
+ case 'insertParagraph': {
+ // Used for Android
+ $setCompositionKey(null);
+
+ // Safari does not provide the type "insertLineBreak".
+ // So instead, we need to infer it from the keyboard event.
+ // We do not apply this logic to iOS to allow newline auto-capitalization
+ // work without creating linebreaks when pressing Enter
+ if (isInsertLineBreak && !IS_IOS) {
+ isInsertLineBreak = false;
+ dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, false);
+ } else {
+ dispatchCommand(editor, INSERT_PARAGRAPH_COMMAND, undefined);
+ }
+
+ break;
+ }
+
+ case 'insertFromPaste':
+ case 'insertFromPasteAsQuotation': {
+ dispatchCommand(editor, PASTE_COMMAND, event);
+ break;
+ }
+
+ case 'deleteByComposition': {
+ if ($canRemoveText(anchorNode, focusNode)) {
+ dispatchCommand(editor, REMOVE_TEXT_COMMAND, event);
+ }
+
+ break;
+ }
+
+ case 'deleteByDrag':
+ case 'deleteByCut': {
+ dispatchCommand(editor, REMOVE_TEXT_COMMAND, event);
+ break;
+ }
+
+ case 'deleteContent': {
+ dispatchCommand(editor, DELETE_CHARACTER_COMMAND, false);
+ break;
+ }
+
+ case 'deleteWordBackward': {
+ dispatchCommand(editor, DELETE_WORD_COMMAND, true);
+ break;
+ }
+
+ case 'deleteWordForward': {
+ dispatchCommand(editor, DELETE_WORD_COMMAND, false);
+ break;
+ }
+
+ case 'deleteHardLineBackward':
+ case 'deleteSoftLineBackward': {
+ dispatchCommand(editor, DELETE_LINE_COMMAND, true);
+ break;
+ }
+
+ case 'deleteContentForward':
+ case 'deleteHardLineForward':
+ case 'deleteSoftLineForward': {
+ dispatchCommand(editor, DELETE_LINE_COMMAND, false);
+ break;
+ }
+
+ case 'formatStrikeThrough': {
+ dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'strikethrough');
+ break;
+ }
+
+ case 'formatBold': {
+ dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'bold');
+ break;
+ }
+
+ case 'formatItalic': {
+ dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'italic');
+ break;
+ }
+
+ case 'formatUnderline': {
+ dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'underline');
+ break;
+ }
+
+ case 'historyUndo': {
+ dispatchCommand(editor, UNDO_COMMAND, undefined);
+ break;
+ }
+
+ case 'historyRedo': {
+ dispatchCommand(editor, REDO_COMMAND, undefined);
+ break;
+ }
+
+ default:
+ // NO-OP
+ }
+ });
+}
+
+function onInput(event: InputEvent, editor: LexicalEditor): void {
+ // We don't want the onInput to bubble, in the case of nested editors.
+ event.stopPropagation();
+ updateEditor(editor, () => {
+ const selection = $getSelection();
+ const data = event.data;
+ const targetRange = getTargetRange(event);
+
+ if (
+ data != null &&
+ $isRangeSelection(selection) &&
+ $shouldPreventDefaultAndInsertText(
+ selection,
+ targetRange,
+ data,
+ event.timeStamp,
+ false,
+ )
+ ) {
+ // Given we're over-riding the default behavior, we will need
+ // to ensure to disable composition before dispatching the
+ // insertText command for when changing the sequence for FF.
+ if (isFirefoxEndingComposition) {
+ $onCompositionEndImpl(editor, data);
+ isFirefoxEndingComposition = false;
+ }
+ const anchor = selection.anchor;
+ const anchorNode = anchor.getNode();
+ const domSelection = getDOMSelection(editor._window);
+ if (domSelection === null) {
+ return;
+ }
+ const isBackward = selection.isBackward();
+ const startOffset = isBackward
+ ? selection.anchor.offset
+ : selection.focus.offset;
+ const endOffset = isBackward
+ ? selection.focus.offset
+ : selection.anchor.offset;
+ // If the content is the same as inserted, then don't dispatch an insertion.
+ // Given onInput doesn't take the current selection (it uses the previous)
+ // we can compare that against what the DOM currently says.
+ if (
+ !CAN_USE_BEFORE_INPUT ||
+ selection.isCollapsed() ||
+ !$isTextNode(anchorNode) ||
+ domSelection.anchorNode === null ||
+ anchorNode.getTextContent().slice(0, startOffset) +
+ data +
+ anchorNode.getTextContent().slice(startOffset + endOffset) !==
+ getAnchorTextFromDOM(domSelection.anchorNode)
+ ) {
+ dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, data);
+ }
+
+ const textLength = data.length;
+
+ // Another hack for FF, as it's possible that the IME is still
+ // open, even though compositionend has already fired (sigh).
+ if (
+ IS_FIREFOX &&
+ textLength > 1 &&
+ event.inputType === 'insertCompositionText' &&
+ !editor.isComposing()
+ ) {
+ selection.anchor.offset -= textLength;
+ }
+
+ // This ensures consistency on Android.
+ if (!IS_SAFARI && !IS_IOS && !IS_APPLE_WEBKIT && editor.isComposing()) {
+ lastKeyDownTimeStamp = 0;
+ $setCompositionKey(null);
+ }
+ } else {
+ const characterData = data !== null ? data : undefined;
+ $updateSelectedTextFromDOM(false, editor, characterData);
+
+ // onInput always fires after onCompositionEnd for FF.
+ if (isFirefoxEndingComposition) {
+ $onCompositionEndImpl(editor, data || undefined);
+ isFirefoxEndingComposition = false;
+ }
+ }
+
+ // Also flush any other mutations that might have occurred
+ // since the change.
+ $flushMutations();
+ });
+ unprocessedBeforeInputData = null;
+}
+
+function onCompositionStart(
+ event: CompositionEvent,
+ editor: LexicalEditor,
+): void {
+ updateEditor(editor, () => {
+ const selection = $getSelection();
+
+ if ($isRangeSelection(selection) && !editor.isComposing()) {
+ const anchor = selection.anchor;
+ const node = selection.anchor.getNode();
+ $setCompositionKey(anchor.key);
+
+ if (
+ // If it has been 30ms since the last keydown, then we should
+ // apply the empty space heuristic. We can't do this for Safari,
+ // as the keydown fires after composition start.
+ event.timeStamp < lastKeyDownTimeStamp + ANDROID_COMPOSITION_LATENCY ||
+ // FF has issues around composing multibyte characters, so we also
+ // need to invoke the empty space heuristic below.
+ anchor.type === 'element' ||
+ !selection.isCollapsed() ||
+ node.getFormat() !== selection.format ||
+ ($isTextNode(node) && node.getStyle() !== selection.style)
+ ) {
+ // We insert a zero width character, ready for the composition
+ // to get inserted into the new node we create. If
+ // we don't do this, Safari will fail on us because
+ // there is no text node matching the selection.
+ dispatchCommand(
+ editor,
+ CONTROLLED_TEXT_INSERTION_COMMAND,
+ COMPOSITION_START_CHAR,
+ );
+ }
+ }
+ });
+}
+
+function $onCompositionEndImpl(editor: LexicalEditor, data?: string): void {
+ const compositionKey = editor._compositionKey;
+ $setCompositionKey(null);
+
+ // Handle termination of composition.
+ if (compositionKey !== null && data != null) {
+ // Composition can sometimes move to an adjacent DOM node when backspacing.
+ // So check for the empty case.
+ if (data === '') {
+ const node = $getNodeByKey(compositionKey);
+ const textNode = getDOMTextNode(editor.getElementByKey(compositionKey));
+
+ if (
+ textNode !== null &&
+ textNode.nodeValue !== null &&
+ $isTextNode(node)
+ ) {
+ $updateTextNodeFromDOMContent(
+ node,
+ textNode.nodeValue,
+ null,
+ null,
+ true,
+ );
+ }
+
+ return;
+ }
+
+ // Composition can sometimes be that of a new line. In which case, we need to
+ // handle that accordingly.
+ if (data[data.length - 1] === '\n') {
+ const selection = $getSelection();
+
+ if ($isRangeSelection(selection)) {
+ // If the last character is a line break, we also need to insert
+ // a line break.
+ const focus = selection.focus;
+ selection.anchor.set(focus.key, focus.offset, focus.type);
+ dispatchCommand(editor, KEY_ENTER_COMMAND, null);
+ return;
+ }
+ }
+ }
+
+ $updateSelectedTextFromDOM(true, editor, data);
+}
+
+function onCompositionEnd(
+ event: CompositionEvent,
+ editor: LexicalEditor,
+): void {
+ // Firefox fires onCompositionEnd before onInput, but Chrome/Webkit,
+ // fire onInput before onCompositionEnd. To ensure the sequence works
+ // like Chrome/Webkit we use the isFirefoxEndingComposition flag to
+ // defer handling of onCompositionEnd in Firefox till we have processed
+ // the logic in onInput.
+ if (IS_FIREFOX) {
+ isFirefoxEndingComposition = true;
+ } else {
+ updateEditor(editor, () => {
+ $onCompositionEndImpl(editor, event.data);
+ });
+ }
+}
+
+function onKeyDown(event: KeyboardEvent, editor: LexicalEditor): void {
+ lastKeyDownTimeStamp = event.timeStamp;
+ lastKeyCode = event.key;
+ if (editor.isComposing()) {
+ return;
+ }
+
+ const {key, shiftKey, ctrlKey, metaKey, altKey} = event;
+
+ if (dispatchCommand(editor, KEY_DOWN_COMMAND, event)) {
+ return;
+ }
+
+ if (key == null) {
+ return;
+ }
+
+ if (isMoveForward(key, ctrlKey, altKey, metaKey)) {
+ dispatchCommand(editor, KEY_ARROW_RIGHT_COMMAND, event);
+ } else if (isMoveToEnd(key, ctrlKey, shiftKey, altKey, metaKey)) {
+ dispatchCommand(editor, MOVE_TO_END, event);
+ } else if (isMoveBackward(key, ctrlKey, altKey, metaKey)) {
+ dispatchCommand(editor, KEY_ARROW_LEFT_COMMAND, event);
+ } else if (isMoveToStart(key, ctrlKey, shiftKey, altKey, metaKey)) {
+ dispatchCommand(editor, MOVE_TO_START, event);
+ } else if (isMoveUp(key, ctrlKey, metaKey)) {
+ dispatchCommand(editor, KEY_ARROW_UP_COMMAND, event);
+ } else if (isMoveDown(key, ctrlKey, metaKey)) {
+ dispatchCommand(editor, KEY_ARROW_DOWN_COMMAND, event);
+ } else if (isLineBreak(key, shiftKey)) {
+ isInsertLineBreak = true;
+ dispatchCommand(editor, KEY_ENTER_COMMAND, event);
+ } else if (isSpace(key)) {
+ dispatchCommand(editor, KEY_SPACE_COMMAND, event);
+ } else if (isOpenLineBreak(key, ctrlKey)) {
+ event.preventDefault();
+ isInsertLineBreak = true;
+ dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, true);
+ } else if (isParagraph(key, shiftKey)) {
+ isInsertLineBreak = false;
+ dispatchCommand(editor, KEY_ENTER_COMMAND, event);
+ } else if (isDeleteBackward(key, altKey, metaKey, ctrlKey)) {
+ if (isBackspace(key)) {
+ dispatchCommand(editor, KEY_BACKSPACE_COMMAND, event);
+ } else {
+ event.preventDefault();
+ dispatchCommand(editor, DELETE_CHARACTER_COMMAND, true);
+ }
+ } else if (isEscape(key)) {
+ dispatchCommand(editor, KEY_ESCAPE_COMMAND, event);
+ } else if (isDeleteForward(key, ctrlKey, shiftKey, altKey, metaKey)) {
+ if (isDelete(key)) {
+ dispatchCommand(editor, KEY_DELETE_COMMAND, event);
+ } else {
+ event.preventDefault();
+ dispatchCommand(editor, DELETE_CHARACTER_COMMAND, false);
+ }
+ } else if (isDeleteWordBackward(key, altKey, ctrlKey)) {
+ event.preventDefault();
+ dispatchCommand(editor, DELETE_WORD_COMMAND, true);
+ } else if (isDeleteWordForward(key, altKey, ctrlKey)) {
+ event.preventDefault();
+ dispatchCommand(editor, DELETE_WORD_COMMAND, false);
+ } else if (isDeleteLineBackward(key, metaKey)) {
+ event.preventDefault();
+ dispatchCommand(editor, DELETE_LINE_COMMAND, true);
+ } else if (isDeleteLineForward(key, metaKey)) {
+ event.preventDefault();
+ dispatchCommand(editor, DELETE_LINE_COMMAND, false);
+ } else if (isBold(key, altKey, metaKey, ctrlKey)) {
+ event.preventDefault();
+ dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'bold');
+ } else if (isUnderline(key, altKey, metaKey, ctrlKey)) {
+ event.preventDefault();
+ dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'underline');
+ } else if (isItalic(key, altKey, metaKey, ctrlKey)) {
+ event.preventDefault();
+ dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'italic');
+ } else if (isTab(key, altKey, ctrlKey, metaKey)) {
+ dispatchCommand(editor, KEY_TAB_COMMAND, event);
+ } else if (isUndo(key, shiftKey, metaKey, ctrlKey)) {
+ event.preventDefault();
+ dispatchCommand(editor, UNDO_COMMAND, undefined);
+ } else if (isRedo(key, shiftKey, metaKey, ctrlKey)) {
+ event.preventDefault();
+ dispatchCommand(editor, REDO_COMMAND, undefined);
+ } else {
+ const prevSelection = editor._editorState._selection;
+ if ($isNodeSelection(prevSelection)) {
+ if (isCopy(key, shiftKey, metaKey, ctrlKey)) {
+ event.preventDefault();
+ dispatchCommand(editor, COPY_COMMAND, event);
+ } else if (isCut(key, shiftKey, metaKey, ctrlKey)) {
+ event.preventDefault();
+ dispatchCommand(editor, CUT_COMMAND, event);
+ } else if (isSelectAll(key, metaKey, ctrlKey)) {
+ event.preventDefault();
+ dispatchCommand(editor, SELECT_ALL_COMMAND, event);
+ }
+ // FF does it well (no need to override behavior)
+ } else if (!IS_FIREFOX && isSelectAll(key, metaKey, ctrlKey)) {
+ event.preventDefault();
+ dispatchCommand(editor, SELECT_ALL_COMMAND, event);
+ }
+ }
+
+ if (isModifier(ctrlKey, shiftKey, altKey, metaKey)) {
+ dispatchCommand(editor, KEY_MODIFIER_COMMAND, event);
+ }
+}
+
+function getRootElementRemoveHandles(
+ rootElement: HTMLElement,
+): RootElementRemoveHandles {
+ // @ts-expect-error: internal field
+ let eventHandles = rootElement.__lexicalEventHandles;
+
+ if (eventHandles === undefined) {
+ eventHandles = [];
+ // @ts-expect-error: internal field
+ rootElement.__lexicalEventHandles = eventHandles;
+ }
+
+ return eventHandles;
+}
+
+// Mapping root editors to their active nested editors, contains nested editors
+// mapping only, so if root editor is selected map will have no reference to free up memory
+const activeNestedEditorsMap: Map = new Map();
+
+function onDocumentSelectionChange(event: Event): void {
+ const target = event.target as null | Element | Document;
+ const targetWindow =
+ target == null
+ ? null
+ : target.nodeType === 9
+ ? (target as Document).defaultView
+ : (target as Element).ownerDocument.defaultView;
+ const domSelection = getDOMSelection(targetWindow);
+ if (domSelection === null) {
+ return;
+ }
+ const nextActiveEditor = getNearestEditorFromDOMNode(domSelection.anchorNode);
+ if (nextActiveEditor === null) {
+ return;
+ }
+
+ if (isSelectionChangeFromMouseDown) {
+ isSelectionChangeFromMouseDown = false;
+ updateEditor(nextActiveEditor, () => {
+ const lastSelection = $getPreviousSelection();
+ const domAnchorNode = domSelection.anchorNode;
+ if (domAnchorNode === null) {
+ return;
+ }
+ const nodeType = domAnchorNode.nodeType;
+ // If the user is attempting to click selection back onto text, then
+ // we should attempt create a range selection.
+ // When we click on an empty paragraph node or the end of a paragraph that ends
+ // with an image/poll, the nodeType will be ELEMENT_NODE
+ if (nodeType !== DOM_ELEMENT_TYPE && nodeType !== DOM_TEXT_TYPE) {
+ return;
+ }
+ const newSelection = $internalCreateRangeSelection(
+ lastSelection,
+ domSelection,
+ nextActiveEditor,
+ event,
+ );
+ $setSelection(newSelection);
+ });
+ }
+
+ // When editor receives selection change event, we're checking if
+ // it has any sibling editors (within same parent editor) that were active
+ // before, and trigger selection change on it to nullify selection.
+ const editors = getEditorsToPropagate(nextActiveEditor);
+ const rootEditor = editors[editors.length - 1];
+ const rootEditorKey = rootEditor._key;
+ const activeNestedEditor = activeNestedEditorsMap.get(rootEditorKey);
+ const prevActiveEditor = activeNestedEditor || rootEditor;
+
+ if (prevActiveEditor !== nextActiveEditor) {
+ onSelectionChange(domSelection, prevActiveEditor, false);
+ }
+
+ onSelectionChange(domSelection, nextActiveEditor, true);
+
+ // If newly selected editor is nested, then add it to the map, clean map otherwise
+ if (nextActiveEditor !== rootEditor) {
+ activeNestedEditorsMap.set(rootEditorKey, nextActiveEditor);
+ } else if (activeNestedEditor) {
+ activeNestedEditorsMap.delete(rootEditorKey);
+ }
+}
+
+function stopLexicalPropagation(event: Event): void {
+ // We attach a special property to ensure the same event doesn't re-fire
+ // for parent editors.
+ // @ts-ignore
+ event._lexicalHandled = true;
+}
+
+function hasStoppedLexicalPropagation(event: Event): boolean {
+ // @ts-ignore
+ const stopped = event._lexicalHandled === true;
+ return stopped;
+}
+
+export type EventHandler = (event: Event, editor: LexicalEditor) => void;
+
+export function addRootElementEvents(
+ rootElement: HTMLElement,
+ editor: LexicalEditor,
+): void {
+ // We only want to have a single global selectionchange event handler, shared
+ // between all editor instances.
+ const doc = rootElement.ownerDocument;
+ const documentRootElementsCount = rootElementsRegistered.get(doc);
+ if (
+ documentRootElementsCount === undefined ||
+ documentRootElementsCount < 1
+ ) {
+ doc.addEventListener('selectionchange', onDocumentSelectionChange);
+ }
+ rootElementsRegistered.set(doc, (documentRootElementsCount || 0) + 1);
+
+ // @ts-expect-error: internal field
+ rootElement.__lexicalEditor = editor;
+ const removeHandles = getRootElementRemoveHandles(rootElement);
+
+ for (let i = 0; i < rootElementEvents.length; i++) {
+ const [eventName, onEvent] = rootElementEvents[i];
+ const eventHandler =
+ typeof onEvent === 'function'
+ ? (event: Event) => {
+ if (hasStoppedLexicalPropagation(event)) {
+ return;
+ }
+ stopLexicalPropagation(event);
+ if (editor.isEditable() || eventName === 'click') {
+ onEvent(event, editor);
+ }
+ }
+ : (event: Event) => {
+ if (hasStoppedLexicalPropagation(event)) {
+ return;
+ }
+ stopLexicalPropagation(event);
+ const isEditable = editor.isEditable();
+ switch (eventName) {
+ case 'cut':
+ return (
+ isEditable &&
+ dispatchCommand(editor, CUT_COMMAND, event as ClipboardEvent)
+ );
+
+ case 'copy':
+ return dispatchCommand(
+ editor,
+ COPY_COMMAND,
+ event as ClipboardEvent,
+ );
+
+ case 'paste':
+ return (
+ isEditable &&
+ dispatchCommand(
+ editor,
+ PASTE_COMMAND,
+ event as ClipboardEvent,
+ )
+ );
+
+ case 'dragstart':
+ return (
+ isEditable &&
+ dispatchCommand(editor, DRAGSTART_COMMAND, event as DragEvent)
+ );
+
+ case 'dragover':
+ return (
+ isEditable &&
+ dispatchCommand(editor, DRAGOVER_COMMAND, event as DragEvent)
+ );
+
+ case 'dragend':
+ return (
+ isEditable &&
+ dispatchCommand(editor, DRAGEND_COMMAND, event as DragEvent)
+ );
+
+ case 'focus':
+ return (
+ isEditable &&
+ dispatchCommand(editor, FOCUS_COMMAND, event as FocusEvent)
+ );
+
+ case 'blur': {
+ return (
+ isEditable &&
+ dispatchCommand(editor, BLUR_COMMAND, event as FocusEvent)
+ );
+ }
+
+ case 'drop':
+ return (
+ isEditable &&
+ dispatchCommand(editor, DROP_COMMAND, event as DragEvent)
+ );
+ }
+ };
+ rootElement.addEventListener(eventName, eventHandler);
+ removeHandles.push(() => {
+ rootElement.removeEventListener(eventName, eventHandler);
+ });
+ }
+}
+
+export function removeRootElementEvents(rootElement: HTMLElement): void {
+ const doc = rootElement.ownerDocument;
+ const documentRootElementsCount = rootElementsRegistered.get(doc);
+ invariant(
+ documentRootElementsCount !== undefined,
+ 'Root element not registered',
+ );
+
+ // We only want to have a single global selectionchange event handler, shared
+ // between all editor instances.
+ const newCount = documentRootElementsCount - 1;
+ invariant(newCount >= 0, 'Root element count less than 0');
+ rootElementsRegistered.set(doc, newCount);
+ if (newCount === 0) {
+ doc.removeEventListener('selectionchange', onDocumentSelectionChange);
+ }
+
+ const editor = getEditorPropertyFromDOMNode(rootElement);
+
+ if (isLexicalEditor(editor)) {
+ cleanActiveNestedEditorsMap(editor);
+ // @ts-expect-error: internal field
+ rootElement.__lexicalEditor = null;
+ } else if (editor) {
+ invariant(
+ false,
+ 'Attempted to remove event handlers from a node that does not belong to this build of Lexical',
+ );
+ }
+
+ const removeHandles = getRootElementRemoveHandles(rootElement);
+
+ for (let i = 0; i < removeHandles.length; i++) {
+ removeHandles[i]();
+ }
+
+ // @ts-expect-error: internal field
+ rootElement.__lexicalEventHandles = [];
+}
+
+function cleanActiveNestedEditorsMap(editor: LexicalEditor) {
+ if (editor._parentEditor !== null) {
+ // For nested editor cleanup map if this editor was marked as active
+ const editors = getEditorsToPropagate(editor);
+ const rootEditor = editors[editors.length - 1];
+ const rootEditorKey = rootEditor._key;
+
+ if (activeNestedEditorsMap.get(rootEditorKey) === editor) {
+ activeNestedEditorsMap.delete(rootEditorKey);
+ }
+ } else {
+ // For top-level editors cleanup map
+ activeNestedEditorsMap.delete(editor._key);
+ }
+}
+
+export function markSelectionChangeFromDOMUpdate(): void {
+ isSelectionChangeFromDOMUpdate = true;
+}
+
+export function markCollapsedSelectionFormat(
+ format: number,
+ style: string,
+ offset: number,
+ key: NodeKey,
+ timeStamp: number,
+): void {
+ collapsedSelectionFormat = [format, style, offset, key, timeStamp];
+}
diff --git a/resources/js/wysiwyg/lexical/core/LexicalGC.ts b/resources/js/wysiwyg/lexical/core/LexicalGC.ts
new file mode 100644
index 000000000..9405ae6cf
--- /dev/null
+++ b/resources/js/wysiwyg/lexical/core/LexicalGC.ts
@@ -0,0 +1,125 @@
+/**
+ * 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 {ElementNode} from '.';
+import type {LexicalEditor} from './LexicalEditor';
+import type {EditorState} from './LexicalEditorState';
+import type {NodeKey, NodeMap} from './LexicalNode';
+
+import {$isElementNode} from '.';
+import {cloneDecorators} from './LexicalUtils';
+
+export function $garbageCollectDetachedDecorators(
+ editor: LexicalEditor,
+ pendingEditorState: EditorState,
+): void {
+ const currentDecorators = editor._decorators;
+ const pendingDecorators = editor._pendingDecorators;
+ let decorators = pendingDecorators || currentDecorators;
+ const nodeMap = pendingEditorState._nodeMap;
+ let key;
+
+ for (key in decorators) {
+ if (!nodeMap.has(key)) {
+ if (decorators === currentDecorators) {
+ decorators = cloneDecorators(editor);
+ }
+
+ delete decorators[key];
+ }
+ }
+}
+
+type IntentionallyMarkedAsDirtyElement = boolean;
+
+function $garbageCollectDetachedDeepChildNodes(
+ node: ElementNode,
+ parentKey: NodeKey,
+ prevNodeMap: NodeMap,
+ nodeMap: NodeMap,
+ nodeMapDelete: Array,
+ dirtyNodes: Map,
+): void {
+ let child = node.getFirstChild();
+
+ while (child !== null) {
+ const childKey = child.__key;
+ // TODO Revise condition below, redundant? LexicalNode already cleans up children when moving Nodes
+ if (child.__parent === parentKey) {
+ if ($isElementNode(child)) {
+ $garbageCollectDetachedDeepChildNodes(
+ child,
+ childKey,
+ prevNodeMap,
+ nodeMap,
+ nodeMapDelete,
+ dirtyNodes,
+ );
+ }
+
+ // If we have created a node and it was dereferenced, then also
+ // remove it from out dirty nodes Set.
+ if (!prevNodeMap.has(childKey)) {
+ dirtyNodes.delete(childKey);
+ }
+ nodeMapDelete.push(childKey);
+ }
+ child = child.getNextSibling();
+ }
+}
+
+export function $garbageCollectDetachedNodes(
+ prevEditorState: EditorState,
+ editorState: EditorState,
+ dirtyLeaves: Set,
+ dirtyElements: Map,
+): void {
+ const prevNodeMap = prevEditorState._nodeMap;
+ const nodeMap = editorState._nodeMap;
+ // Store dirtyElements in a queue for later deletion; deleting dirty subtrees too early will
+ // hinder accessing .__next on child nodes
+ const nodeMapDelete: Array = [];
+
+ for (const [nodeKey] of dirtyElements) {
+ const node = nodeMap.get(nodeKey);
+ if (node !== undefined) {
+ // Garbage collect node and its children if they exist
+ if (!node.isAttached()) {
+ if ($isElementNode(node)) {
+ $garbageCollectDetachedDeepChildNodes(
+ node,
+ nodeKey,
+ prevNodeMap,
+ nodeMap,
+ nodeMapDelete,
+ dirtyElements,
+ );
+ }
+ // If we have created a node and it was dereferenced, then also
+ // remove it from out dirty nodes Set.
+ if (!prevNodeMap.has(nodeKey)) {
+ dirtyElements.delete(nodeKey);
+ }
+ nodeMapDelete.push(nodeKey);
+ }
+ }
+ }
+ for (const nodeKey of nodeMapDelete) {
+ nodeMap.delete(nodeKey);
+ }
+
+ for (const nodeKey of dirtyLeaves) {
+ const node = nodeMap.get(nodeKey);
+ if (node !== undefined && !node.isAttached()) {
+ if (!prevNodeMap.has(nodeKey)) {
+ dirtyLeaves.delete(nodeKey);
+ }
+ nodeMap.delete(nodeKey);
+ }
+ }
+}
diff --git a/resources/js/wysiwyg/lexical/core/LexicalMutations.ts b/resources/js/wysiwyg/lexical/core/LexicalMutations.ts
new file mode 100644
index 000000000..56f364501
--- /dev/null
+++ b/resources/js/wysiwyg/lexical/core/LexicalMutations.ts
@@ -0,0 +1,322 @@
+/**
+ * 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 {TextNode} from '.';
+import type {LexicalEditor} from './LexicalEditor';
+import type {BaseSelection} from './LexicalSelection';
+
+import {IS_FIREFOX} from 'lexical/shared/environment';
+
+import {
+ $getSelection,
+ $isDecoratorNode,
+ $isElementNode,
+ $isRangeSelection,
+ $isTextNode,
+ $setSelection,
+} from '.';
+import {DOM_TEXT_TYPE} from './LexicalConstants';
+import {updateEditor} from './LexicalUpdates';
+import {
+ $getNearestNodeFromDOMNode,
+ $getNodeFromDOMNode,
+ $updateTextNodeFromDOMContent,
+ getDOMSelection,
+ getWindow,
+ internalGetRoot,
+ isFirefoxClipboardEvents,
+} from './LexicalUtils';
+// The time between a text entry event and the mutation observer firing.
+const TEXT_MUTATION_VARIANCE = 100;
+
+let isProcessingMutations = false;
+let lastTextEntryTimeStamp = 0;
+
+export function getIsProcessingMutations(): boolean {
+ return isProcessingMutations;
+}
+
+function updateTimeStamp(event: Event) {
+ lastTextEntryTimeStamp = event.timeStamp;
+}
+
+function initTextEntryListener(editor: LexicalEditor): void {
+ if (lastTextEntryTimeStamp === 0) {
+ getWindow(editor).addEventListener('textInput', updateTimeStamp, true);
+ }
+}
+
+function isManagedLineBreak(
+ dom: Node,
+ target: Node,
+ editor: LexicalEditor,
+): boolean {
+ return (
+ // @ts-expect-error: internal field
+ target.__lexicalLineBreak === dom ||
+ // @ts-ignore We intentionally add this to the Node.
+ dom[`__lexicalKey_${editor._key}`] !== undefined
+ );
+}
+
+function getLastSelection(editor: LexicalEditor): null | BaseSelection {
+ return editor.getEditorState().read(() => {
+ const selection = $getSelection();
+ return selection !== null ? selection.clone() : null;
+ });
+}
+
+function $handleTextMutation(
+ target: Text,
+ node: TextNode,
+ editor: LexicalEditor,
+): void {
+ const domSelection = getDOMSelection(editor._window);
+ let anchorOffset = null;
+ let focusOffset = null;
+
+ if (domSelection !== null && domSelection.anchorNode === target) {
+ anchorOffset = domSelection.anchorOffset;
+ focusOffset = domSelection.focusOffset;
+ }
+
+ const text = target.nodeValue;
+ if (text !== null) {
+ $updateTextNodeFromDOMContent(node, text, anchorOffset, focusOffset, false);
+ }
+}
+
+function shouldUpdateTextNodeFromMutation(
+ selection: null | BaseSelection,
+ targetDOM: Node,
+ targetNode: TextNode,
+): boolean {
+ if ($isRangeSelection(selection)) {
+ const anchorNode = selection.anchor.getNode();
+ if (
+ anchorNode.is(targetNode) &&
+ selection.format !== anchorNode.getFormat()
+ ) {
+ return false;
+ }
+ }
+ return targetDOM.nodeType === DOM_TEXT_TYPE && targetNode.isAttached();
+}
+
+export function $flushMutations(
+ editor: LexicalEditor,
+ mutations: Array,
+ observer: MutationObserver,
+): void {
+ isProcessingMutations = true;
+ const shouldFlushTextMutations =
+ performance.now() - lastTextEntryTimeStamp > TEXT_MUTATION_VARIANCE;
+
+ try {
+ updateEditor(editor, () => {
+ const selection = $getSelection() || getLastSelection(editor);
+ const badDOMTargets = new Map();
+ const rootElement = editor.getRootElement();
+ // We use the current editor state, as that reflects what is
+ // actually "on screen".
+ const currentEditorState = editor._editorState;
+ const blockCursorElement = editor._blockCursorElement;
+ let shouldRevertSelection = false;
+ let possibleTextForFirefoxPaste = '';
+
+ for (let i = 0; i < mutations.length; i++) {
+ const mutation = mutations[i];
+ const type = mutation.type;
+ const targetDOM = mutation.target;
+ let targetNode = $getNearestNodeFromDOMNode(
+ targetDOM,
+ currentEditorState,
+ );
+
+ if (
+ (targetNode === null && targetDOM !== rootElement) ||
+ $isDecoratorNode(targetNode)
+ ) {
+ continue;
+ }
+
+ if (type === 'characterData') {
+ // Text mutations are deferred and passed to mutation listeners to be
+ // processed outside of the Lexical engine.
+ if (
+ shouldFlushTextMutations &&
+ $isTextNode(targetNode) &&
+ shouldUpdateTextNodeFromMutation(selection, targetDOM, targetNode)
+ ) {
+ $handleTextMutation(
+ // nodeType === DOM_TEXT_TYPE is a Text DOM node
+ targetDOM as Text,
+ targetNode,
+ editor,
+ );
+ }
+ } else if (type === 'childList') {
+ shouldRevertSelection = true;
+ // We attempt to "undo" any changes that have occurred outside
+ // of Lexical. We want Lexical's editor state to be source of truth.
+ // To the user, these will look like no-ops.
+ const addedDOMs = mutation.addedNodes;
+
+ for (let s = 0; s < addedDOMs.length; s++) {
+ const addedDOM = addedDOMs[s];
+ const node = $getNodeFromDOMNode(addedDOM);
+ const parentDOM = addedDOM.parentNode;
+
+ if (
+ parentDOM != null &&
+ addedDOM !== blockCursorElement &&
+ node === null &&
+ (addedDOM.nodeName !== 'BR' ||
+ !isManagedLineBreak(addedDOM, parentDOM, editor))
+ ) {
+ if (IS_FIREFOX) {
+ const possibleText =
+ (addedDOM as HTMLElement).innerText || addedDOM.nodeValue;
+
+ if (possibleText) {
+ possibleTextForFirefoxPaste += possibleText;
+ }
+ }
+
+ parentDOM.removeChild(addedDOM);
+ }
+ }
+
+ const removedDOMs = mutation.removedNodes;
+ const removedDOMsLength = removedDOMs.length;
+
+ if (removedDOMsLength > 0) {
+ let unremovedBRs = 0;
+
+ for (let s = 0; s < removedDOMsLength; s++) {
+ const removedDOM = removedDOMs[s];
+
+ if (
+ (removedDOM.nodeName === 'BR' &&
+ isManagedLineBreak(removedDOM, targetDOM, editor)) ||
+ blockCursorElement === removedDOM
+ ) {
+ targetDOM.appendChild(removedDOM);
+ unremovedBRs++;
+ }
+ }
+
+ if (removedDOMsLength !== unremovedBRs) {
+ if (targetDOM === rootElement) {
+ targetNode = internalGetRoot(currentEditorState);
+ }
+
+ badDOMTargets.set(targetDOM, targetNode);
+ }
+ }
+ }
+ }
+
+ // Now we process each of the unique target nodes, attempting
+ // to restore their contents back to the source of truth, which
+ // is Lexical's "current" editor state. This is basically like
+ // an internal revert on the DOM.
+ if (badDOMTargets.size > 0) {
+ for (const [targetDOM, targetNode] of badDOMTargets) {
+ if ($isElementNode(targetNode)) {
+ const childKeys = targetNode.getChildrenKeys();
+ let currentDOM = targetDOM.firstChild;
+
+ for (let s = 0; s < childKeys.length; s++) {
+ const key = childKeys[s];
+ const correctDOM = editor.getElementByKey(key);
+
+ if (correctDOM === null) {
+ continue;
+ }
+
+ if (currentDOM == null) {
+ targetDOM.appendChild(correctDOM);
+ currentDOM = correctDOM;
+ } else if (currentDOM !== correctDOM) {
+ targetDOM.replaceChild(correctDOM, currentDOM);
+ }
+
+ currentDOM = currentDOM.nextSibling;
+ }
+ } else if ($isTextNode(targetNode)) {
+ targetNode.markDirty();
+ }
+ }
+ }
+
+ // Capture all the mutations made during this function. This
+ // also prevents us having to process them on the next cycle
+ // of onMutation, as these mutations were made by us.
+ const records = observer.takeRecords();
+
+ // Check for any random auto-added
elements, and remove them.
+ // These get added by the browser when we undo the above mutations
+ // and this can lead to a broken UI.
+ if (records.length > 0) {
+ for (let i = 0; i < records.length; i++) {
+ const record = records[i];
+ const addedNodes = record.addedNodes;
+ const target = record.target;
+
+ for (let s = 0; s < addedNodes.length; s++) {
+ const addedDOM = addedNodes[s];
+ const parentDOM = addedDOM.parentNode;
+
+ if (
+ parentDOM != null &&
+ addedDOM.nodeName === 'BR' &&
+ !isManagedLineBreak(addedDOM, target, editor)
+ ) {
+ parentDOM.removeChild(addedDOM);
+ }
+ }
+ }
+
+ // Clear any of those removal mutations
+ observer.takeRecords();
+ }
+
+ if (selection !== null) {
+ if (shouldRevertSelection) {
+ selection.dirty = true;
+ $setSelection(selection);
+ }
+
+ if (IS_FIREFOX && isFirefoxClipboardEvents(editor)) {
+ selection.insertRawText(possibleTextForFirefoxPaste);
+ }
+ }
+ });
+ } finally {
+ isProcessingMutations = false;
+ }
+}
+
+export function $flushRootMutations(editor: LexicalEditor): void {
+ const observer = editor._observer;
+
+ if (observer !== null) {
+ const mutations = observer.takeRecords();
+ $flushMutations(editor, mutations, observer);
+ }
+}
+
+export function initMutationObserver(editor: LexicalEditor): void {
+ initTextEntryListener(editor);
+ editor._observer = new MutationObserver(
+ (mutations: Array, observer: MutationObserver) => {
+ $flushMutations(editor, mutations, observer);
+ },
+ );
+}
diff --git a/resources/js/wysiwyg/lexical/core/LexicalNode.ts b/resources/js/wysiwyg/lexical/core/LexicalNode.ts
new file mode 100644
index 000000000..c6bc2e642
--- /dev/null
+++ b/resources/js/wysiwyg/lexical/core/LexicalNode.ts
@@ -0,0 +1,1221 @@
+/**
+ * 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.
+ *
+ */
+
+/* eslint-disable no-constant-condition */
+import type {EditorConfig, LexicalEditor} from './LexicalEditor';
+import type {BaseSelection, RangeSelection} from './LexicalSelection';
+import type {Klass, KlassConstructor} from 'lexical';
+
+import invariant from 'lexical/shared/invariant';
+
+import {
+ $createParagraphNode,
+ $isDecoratorNode,
+ $isElementNode,
+ $isRootNode,
+ $isTextNode,
+ type DecoratorNode,
+ ElementNode,
+} from '.';
+import {
+ $getSelection,
+ $isNodeSelection,
+ $isRangeSelection,
+ $moveSelectionPointToEnd,
+ $updateElementSelectionOnCreateDeleteNode,
+ moveSelectionPointToSibling,
+} from './LexicalSelection';
+import {
+ errorOnReadOnly,
+ getActiveEditor,
+ getActiveEditorState,
+} from './LexicalUpdates';
+import {
+ $cloneWithProperties,
+ $getCompositionKey,
+ $getNodeByKey,
+ $isRootOrShadowRoot,
+ $maybeMoveChildrenSelectionToParent,
+ $setCompositionKey,
+ $setNodeKey,
+ $setSelection,
+ errorOnInsertTextNodeOnRoot,
+ internalMarkNodeAsDirty,
+ removeFromParent,
+} from './LexicalUtils';
+
+export type NodeMap = Map;
+
+export type SerializedLexicalNode = {
+ type: string;
+ version: number;
+};
+
+export function $removeNode(
+ nodeToRemove: LexicalNode,
+ restoreSelection: boolean,
+ preserveEmptyParent?: boolean,
+): void {
+ errorOnReadOnly();
+ const key = nodeToRemove.__key;
+ const parent = nodeToRemove.getParent();
+ if (parent === null) {
+ return;
+ }
+ const selection = $maybeMoveChildrenSelectionToParent(nodeToRemove);
+ let selectionMoved = false;
+ if ($isRangeSelection(selection) && restoreSelection) {
+ const anchor = selection.anchor;
+ const focus = selection.focus;
+ if (anchor.key === key) {
+ moveSelectionPointToSibling(
+ anchor,
+ nodeToRemove,
+ parent,
+ nodeToRemove.getPreviousSibling(),
+ nodeToRemove.getNextSibling(),
+ );
+ selectionMoved = true;
+ }
+ if (focus.key === key) {
+ moveSelectionPointToSibling(
+ focus,
+ nodeToRemove,
+ parent,
+ nodeToRemove.getPreviousSibling(),
+ nodeToRemove.getNextSibling(),
+ );
+ selectionMoved = true;
+ }
+ } else if (
+ $isNodeSelection(selection) &&
+ restoreSelection &&
+ nodeToRemove.isSelected()
+ ) {
+ nodeToRemove.selectPrevious();
+ }
+
+ if ($isRangeSelection(selection) && restoreSelection && !selectionMoved) {
+ // Doing this is O(n) so lets avoid it unless we need to do it
+ const index = nodeToRemove.getIndexWithinParent();
+ removeFromParent(nodeToRemove);
+ $updateElementSelectionOnCreateDeleteNode(selection, parent, index, -1);
+ } else {
+ removeFromParent(nodeToRemove);
+ }
+
+ if (
+ !preserveEmptyParent &&
+ !$isRootOrShadowRoot(parent) &&
+ !parent.canBeEmpty() &&
+ parent.isEmpty()
+ ) {
+ $removeNode(parent, restoreSelection);
+ }
+ if (restoreSelection && $isRootNode(parent) && parent.isEmpty()) {
+ parent.selectEnd();
+ }
+}
+
+export type DOMConversion = {
+ conversion: DOMConversionFn;
+ priority?: 0 | 1 | 2 | 3 | 4;
+};
+
+export type DOMConversionFn = (
+ element: T,
+) => DOMConversionOutput | null;
+
+export type DOMChildConversion = (
+ lexicalNode: LexicalNode,
+ parentLexicalNode: LexicalNode | null | undefined,
+) => LexicalNode | null | undefined;
+
+export type DOMConversionMap = Record<
+ NodeName,
+ (node: T) => DOMConversion | null
+>;
+type NodeName = string;
+
+export type DOMConversionOutput = {
+ after?: (childLexicalNodes: Array) => Array;
+ forChild?: DOMChildConversion;
+ node: null | LexicalNode | Array;
+};
+
+export type DOMExportOutputMap = Map<
+ Klass,
+ (editor: LexicalEditor, target: LexicalNode) => DOMExportOutput
+>;
+
+export type DOMExportOutput = {
+ after?: (
+ generatedElement: HTMLElement | Text | null | undefined,
+ ) => HTMLElement | Text | null | undefined;
+ element: HTMLElement | Text | null;
+};
+
+export type NodeKey = string;
+
+export class LexicalNode {
+ // Allow us to look up the type including static props
+ ['constructor']!: KlassConstructor;
+ /** @internal */
+ __type: string;
+ /** @internal */
+ //@ts-ignore We set the key in the constructor.
+ __key: string;
+ /** @internal */
+ __parent: null | NodeKey;
+ /** @internal */
+ __prev: null | NodeKey;
+ /** @internal */
+ __next: null | NodeKey;
+
+ // Flow doesn't support abstract classes unfortunately, so we can't _force_
+ // subclasses of Node to implement statics. All subclasses of Node should have
+ // a static getType and clone method though. We define getType and clone here so we can call it
+ // on any Node, and we throw this error by default since the subclass should provide
+ // their own implementation.
+ /**
+ * Returns the string type of this node. Every node must
+ * implement this and it MUST BE UNIQUE amongst nodes registered
+ * on the editor.
+ *
+ */
+ static getType(): string {
+ invariant(
+ false,
+ 'LexicalNode: Node %s does not implement .getType().',
+ this.name,
+ );
+ }
+
+ /**
+ * Clones this node, creating a new node with a different key
+ * and adding it to the EditorState (but not attaching it anywhere!). All nodes must
+ * implement this method.
+ *
+ */
+ static clone(_data: unknown): LexicalNode {
+ invariant(
+ false,
+ 'LexicalNode: Node %s does not implement .clone().',
+ this.name,
+ );
+ }
+
+ /**
+ * Perform any state updates on the clone of prevNode that are not already
+ * handled by the constructor call in the static clone method. If you have
+ * state to update in your clone that is not handled directly by the
+ * constructor, it is advisable to override this method but it is required
+ * to include a call to `super.afterCloneFrom(prevNode)` in your
+ * implementation. This is only intended to be called by
+ * {@link $cloneWithProperties} function or via a super call.
+ *
+ * @example
+ * ```ts
+ * class ClassesTextNode extends TextNode {
+ * // Not shown: static getType, static importJSON, exportJSON, createDOM, updateDOM
+ * __classes = new Set();
+ * static clone(node: ClassesTextNode): ClassesTextNode {
+ * // The inherited TextNode constructor is used here, so
+ * // classes is not set by this method.
+ * return new ClassesTextNode(node.__text, node.__key);
+ * }
+ * afterCloneFrom(node: this): void {
+ * // This calls TextNode.afterCloneFrom and LexicalNode.afterCloneFrom
+ * // for necessary state updates
+ * super.afterCloneFrom(node);
+ * this.__addClasses(node.__classes);
+ * }
+ * // This method is a private implementation detail, it is not
+ * // suitable for the public API because it does not call getWritable
+ * __addClasses(classNames: Iterable): this {
+ * for (const className of classNames) {
+ * this.__classes.add(className);
+ * }
+ * return this;
+ * }
+ * addClass(...classNames: string[]): this {
+ * return this.getWritable().__addClasses(classNames);
+ * }
+ * removeClass(...classNames: string[]): this {
+ * const node = this.getWritable();
+ * for (const className of classNames) {
+ * this.__classes.delete(className);
+ * }
+ * return this;
+ * }
+ * getClasses(): Set {
+ * return this.getLatest().__classes;
+ * }
+ * }
+ * ```
+ *
+ */
+ afterCloneFrom(prevNode: this) {
+ this.__parent = prevNode.__parent;
+ this.__next = prevNode.__next;
+ this.__prev = prevNode.__prev;
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ static importDOM?: () => DOMConversionMap | null;
+
+ constructor(key?: NodeKey) {
+ this.__type = this.constructor.getType();
+ this.__parent = null;
+ this.__prev = null;
+ this.__next = null;
+ $setNodeKey(this, key);
+
+ if (__DEV__) {
+ if (this.__type !== 'root') {
+ errorOnReadOnly();
+ errorOnTypeKlassMismatch(this.__type, this.constructor);
+ }
+ }
+ }
+ // Getters and Traversers
+
+ /**
+ * Returns the string type of this node.
+ */
+ getType(): string {
+ return this.__type;
+ }
+
+ isInline(): boolean {
+ invariant(
+ false,
+ 'LexicalNode: Node %s does not implement .isInline().',
+ this.constructor.name,
+ );
+ }
+
+ /**
+ * Returns true if there is a path between this node and the RootNode, false otherwise.
+ * This is a way of determining if the node is "attached" EditorState. Unattached nodes
+ * won't be reconciled and will ultimatelt be cleaned up by the Lexical GC.
+ */
+ isAttached(): boolean {
+ let nodeKey: string | null = this.__key;
+ while (nodeKey !== null) {
+ if (nodeKey === 'root') {
+ return true;
+ }
+
+ const node: LexicalNode | null = $getNodeByKey(nodeKey);
+
+ if (node === null) {
+ break;
+ }
+ nodeKey = node.__parent;
+ }
+ return false;
+ }
+
+ /**
+ * Returns true if this node is contained within the provided Selection., false otherwise.
+ * Relies on the algorithms implemented in {@link BaseSelection.getNodes} to determine
+ * what's included.
+ *
+ * @param selection - The selection that we want to determine if the node is in.
+ */
+ isSelected(selection?: null | BaseSelection): boolean {
+ const targetSelection = selection || $getSelection();
+ if (targetSelection == null) {
+ return false;
+ }
+
+ const isSelected = targetSelection
+ .getNodes()
+ .some((n) => n.__key === this.__key);
+
+ if ($isTextNode(this)) {
+ return isSelected;
+ }
+ // For inline images inside of element nodes.
+ // Without this change the image will be selected if the cursor is before or after it.
+ const isElementRangeSelection =
+ $isRangeSelection(targetSelection) &&
+ targetSelection.anchor.type === 'element' &&
+ targetSelection.focus.type === 'element';
+
+ if (isElementRangeSelection) {
+ if (targetSelection.isCollapsed()) {
+ return false;
+ }
+
+ const parentNode = this.getParent();
+ if ($isDecoratorNode(this) && this.isInline() && parentNode) {
+ const firstPoint = targetSelection.isBackward()
+ ? targetSelection.focus
+ : targetSelection.anchor;
+ const firstElement = firstPoint.getNode() as ElementNode;
+ if (
+ firstPoint.offset === firstElement.getChildrenSize() &&
+ firstElement.is(parentNode) &&
+ firstElement.getLastChildOrThrow().is(this)
+ ) {
+ return false;
+ }
+ }
+ }
+ return isSelected;
+ }
+
+ /**
+ * Returns this nodes key.
+ */
+ getKey(): NodeKey {
+ // Key is stable between copies
+ return this.__key;
+ }
+
+ /**
+ * Returns the zero-based index of this node within the parent.
+ */
+ getIndexWithinParent(): number {
+ const parent = this.getParent();
+ if (parent === null) {
+ return -1;
+ }
+ let node = parent.getFirstChild();
+ let index = 0;
+ while (node !== null) {
+ if (this.is(node)) {
+ return index;
+ }
+ index++;
+ node = node.getNextSibling();
+ }
+ return -1;
+ }
+
+ /**
+ * Returns the parent of this node, or null if none is found.
+ */
+ getParent(): T | null {
+ const parent = this.getLatest().__parent;
+ if (parent === null) {
+ return null;
+ }
+ return $getNodeByKey(parent);
+ }
+
+ /**
+ * Returns the parent of this node, or throws if none is found.
+ */
+ getParentOrThrow(): T {
+ const parent = this.getParent();
+ if (parent === null) {
+ invariant(false, 'Expected node %s to have a parent.', this.__key);
+ }
+ return parent;
+ }
+
+ /**
+ * Returns the highest (in the EditorState tree)
+ * non-root ancestor of this node, or null if none is found. See {@link lexical!$isRootOrShadowRoot}
+ * for more information on which Elements comprise "roots".
+ */
+ getTopLevelElement(): ElementNode | DecoratorNode | null {
+ let node: ElementNode | this | null = this;
+ while (node !== null) {
+ const parent: ElementNode | null = node.getParent();
+ if ($isRootOrShadowRoot(parent)) {
+ invariant(
+ $isElementNode(node) || (node === this && $isDecoratorNode(node)),
+ 'Children of root nodes must be elements or decorators',
+ );
+ return node;
+ }
+ node = parent;
+ }
+ return null;
+ }
+
+ /**
+ * Returns the highest (in the EditorState tree)
+ * non-root ancestor of this node, or throws if none is found. See {@link lexical!$isRootOrShadowRoot}
+ * for more information on which Elements comprise "roots".
+ */
+ getTopLevelElementOrThrow(): ElementNode | DecoratorNode {
+ const parent = this.getTopLevelElement();
+ if (parent === null) {
+ invariant(
+ false,
+ 'Expected node %s to have a top parent element.',
+ this.__key,
+ );
+ }
+ return parent;
+ }
+
+ /**
+ * Returns a list of the every ancestor of this node,
+ * all the way up to the RootNode.
+ *
+ */
+ getParents(): Array {
+ const parents: Array = [];
+ let node = this.getParent();
+ while (node !== null) {
+ parents.push(node);
+ node = node.getParent();
+ }
+ return parents;
+ }
+
+ /**
+ * Returns a list of the keys of every ancestor of this node,
+ * all the way up to the RootNode.
+ *
+ */
+ getParentKeys(): Array {
+ const parents = [];
+ let node = this.getParent();
+ while (node !== null) {
+ parents.push(node.__key);
+ node = node.getParent();
+ }
+ return parents;
+ }
+
+ /**
+ * Returns the "previous" siblings - that is, the node that comes
+ * before this one in the same parent.
+ *
+ */
+ getPreviousSibling(): T | null {
+ const self = this.getLatest();
+ const prevKey = self.__prev;
+ return prevKey === null ? null : $getNodeByKey(prevKey);
+ }
+
+ /**
+ * Returns the "previous" siblings - that is, the nodes that come between
+ * this one and the first child of it's parent, inclusive.
+ *
+ */
+ getPreviousSiblings(): Array {
+ const siblings: Array = [];
+ const parent = this.getParent();
+ if (parent === null) {
+ return siblings;
+ }
+ let node: null | T = parent.getFirstChild();
+ while (node !== null) {
+ if (node.is(this)) {
+ break;
+ }
+ siblings.push(node);
+ node = node.getNextSibling();
+ }
+ return siblings;
+ }
+
+ /**
+ * Returns the "next" siblings - that is, the node that comes
+ * after this one in the same parent
+ *
+ */
+ getNextSibling(): T | null {
+ const self = this.getLatest();
+ const nextKey = self.__next;
+ return nextKey === null ? null : $getNodeByKey(nextKey);
+ }
+
+ /**
+ * Returns all "next" siblings - that is, the nodes that come between this
+ * one and the last child of it's parent, inclusive.
+ *
+ */
+ getNextSiblings(): Array {
+ const siblings: Array = [];
+ let node: null | T = this.getNextSibling();
+ while (node !== null) {
+ siblings.push(node);
+ node = node.getNextSibling();
+ }
+ return siblings;
+ }
+
+ /**
+ * Returns the closest common ancestor of this node and the provided one or null
+ * if one cannot be found.
+ *
+ * @param node - the other node to find the common ancestor of.
+ */
+ getCommonAncestor(
+ node: LexicalNode,
+ ): T | null {
+ const a = this.getParents();
+ const b = node.getParents();
+ if ($isElementNode(this)) {
+ a.unshift(this);
+ }
+ if ($isElementNode(node)) {
+ b.unshift(node);
+ }
+ const aLength = a.length;
+ const bLength = b.length;
+ if (aLength === 0 || bLength === 0 || a[aLength - 1] !== b[bLength - 1]) {
+ return null;
+ }
+ const bSet = new Set(b);
+ for (let i = 0; i < aLength; i++) {
+ const ancestor = a[i] as T;
+ if (bSet.has(ancestor)) {
+ return ancestor;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Returns true if the provided node is the exact same one as this node, from Lexical's perspective.
+ * Always use this instead of referential equality.
+ *
+ * @param object - the node to perform the equality comparison on.
+ */
+ is(object: LexicalNode | null | undefined): boolean {
+ if (object == null) {
+ return false;
+ }
+ return this.__key === object.__key;
+ }
+
+ /**
+ * Returns true if this node logical precedes the target node in the editor state.
+ *
+ * @param targetNode - the node we're testing to see if it's after this one.
+ */
+ isBefore(targetNode: LexicalNode): boolean {
+ if (this === targetNode) {
+ return false;
+ }
+ if (targetNode.isParentOf(this)) {
+ return true;
+ }
+ if (this.isParentOf(targetNode)) {
+ return false;
+ }
+ const commonAncestor = this.getCommonAncestor(targetNode);
+ let indexA = 0;
+ let indexB = 0;
+ let node: this | ElementNode | LexicalNode = this;
+ while (true) {
+ const parent: ElementNode = node.getParentOrThrow();
+ if (parent === commonAncestor) {
+ indexA = node.getIndexWithinParent();
+ break;
+ }
+ node = parent;
+ }
+ node = targetNode;
+ while (true) {
+ const parent: ElementNode = node.getParentOrThrow();
+ if (parent === commonAncestor) {
+ indexB = node.getIndexWithinParent();
+ break;
+ }
+ node = parent;
+ }
+ return indexA < indexB;
+ }
+
+ /**
+ * Returns true if this node is the parent of the target node, false otherwise.
+ *
+ * @param targetNode - the would-be child node.
+ */
+ isParentOf(targetNode: LexicalNode): boolean {
+ const key = this.__key;
+ if (key === targetNode.__key) {
+ return false;
+ }
+ let node: ElementNode | LexicalNode | null = targetNode;
+ while (node !== null) {
+ if (node.__key === key) {
+ return true;
+ }
+ node = node.getParent();
+ }
+ return false;
+ }
+
+ // TO-DO: this function can be simplified a lot
+ /**
+ * Returns a list of nodes that are between this node and
+ * the target node in the EditorState.
+ *
+ * @param targetNode - the node that marks the other end of the range of nodes to be returned.
+ */
+ getNodesBetween(targetNode: LexicalNode): Array {
+ const isBefore = this.isBefore(targetNode);
+ const nodes = [];
+ const visited = new Set();
+ let node: LexicalNode | this | null = this;
+ while (true) {
+ if (node === null) {
+ break;
+ }
+ const key = node.__key;
+ if (!visited.has(key)) {
+ visited.add(key);
+ nodes.push(node);
+ }
+ if (node === targetNode) {
+ break;
+ }
+ const child: LexicalNode | null = $isElementNode(node)
+ ? isBefore
+ ? node.getFirstChild()
+ : node.getLastChild()
+ : null;
+ if (child !== null) {
+ node = child;
+ continue;
+ }
+ const nextSibling: LexicalNode | null = isBefore
+ ? node.getNextSibling()
+ : node.getPreviousSibling();
+ if (nextSibling !== null) {
+ node = nextSibling;
+ continue;
+ }
+ const parent: LexicalNode | null = node.getParentOrThrow();
+ if (!visited.has(parent.__key)) {
+ nodes.push(parent);
+ }
+ if (parent === targetNode) {
+ break;
+ }
+ let parentSibling = null;
+ let ancestor: LexicalNode | null = parent;
+ do {
+ if (ancestor === null) {
+ invariant(false, 'getNodesBetween: ancestor is null');
+ }
+ parentSibling = isBefore
+ ? ancestor.getNextSibling()
+ : ancestor.getPreviousSibling();
+ ancestor = ancestor.getParent();
+ if (ancestor !== null) {
+ if (parentSibling === null && !visited.has(ancestor.__key)) {
+ nodes.push(ancestor);
+ }
+ } else {
+ break;
+ }
+ } while (parentSibling === null);
+ node = parentSibling;
+ }
+ if (!isBefore) {
+ nodes.reverse();
+ }
+ return nodes;
+ }
+
+ /**
+ * Returns true if this node has been marked dirty during this update cycle.
+ *
+ */
+ isDirty(): boolean {
+ const editor = getActiveEditor();
+ const dirtyLeaves = editor._dirtyLeaves;
+ return dirtyLeaves !== null && dirtyLeaves.has(this.__key);
+ }
+
+ /**
+ * Returns the latest version of the node from the active EditorState.
+ * This is used to avoid getting values from stale node references.
+ *
+ */
+ getLatest(): this {
+ const latest = $getNodeByKey(this.__key);
+ if (latest === null) {
+ invariant(
+ false,
+ 'Lexical node does not exist in active editor state. Avoid using the same node references between nested closures from editorState.read/editor.update.',
+ );
+ }
+ return latest;
+ }
+
+ /**
+ * Returns a mutable version of the node using {@link $cloneWithProperties}
+ * if necessary. Will throw an error if called outside of a Lexical Editor
+ * {@link LexicalEditor.update} callback.
+ *
+ */
+ getWritable(): this {
+ errorOnReadOnly();
+ const editorState = getActiveEditorState();
+ const editor = getActiveEditor();
+ const nodeMap = editorState._nodeMap;
+ const key = this.__key;
+ // Ensure we get the latest node from pending state
+ const latestNode = this.getLatest();
+ const cloneNotNeeded = editor._cloneNotNeeded;
+ const selection = $getSelection();
+ if (selection !== null) {
+ selection.setCachedNodes(null);
+ }
+ if (cloneNotNeeded.has(key)) {
+ // Transforms clear the dirty node set on each iteration to keep track on newly dirty nodes
+ internalMarkNodeAsDirty(latestNode);
+ return latestNode;
+ }
+ const mutableNode = $cloneWithProperties(latestNode);
+ cloneNotNeeded.add(key);
+ internalMarkNodeAsDirty(mutableNode);
+ // Update reference in node map
+ nodeMap.set(key, mutableNode);
+
+ return mutableNode;
+ }
+
+ /**
+ * Returns the text content of the node. Override this for
+ * custom nodes that should have a representation in plain text
+ * format (for copy + paste, for example)
+ *
+ */
+ getTextContent(): string {
+ return '';
+ }
+
+ /**
+ * Returns the length of the string produced by calling getTextContent on this node.
+ *
+ */
+ getTextContentSize(): number {
+ return this.getTextContent().length;
+ }
+
+ // View
+
+ /**
+ * Called during the reconciliation process to determine which nodes
+ * to insert into the DOM for this Lexical Node.
+ *
+ * This method must return exactly one HTMLElement. Nested elements are not supported.
+ *
+ * Do not attempt to update the Lexical EditorState during this phase of the update lifecyle.
+ *
+ * @param _config - allows access to things like the EditorTheme (to apply classes) during reconciliation.
+ * @param _editor - allows access to the editor for context during reconciliation.
+ *
+ * */
+ createDOM(_config: EditorConfig, _editor: LexicalEditor): HTMLElement {
+ invariant(false, 'createDOM: base method not extended');
+ }
+
+ /**
+ * Called when a node changes and should update the DOM
+ * in whatever way is necessary to make it align with any changes that might
+ * have happened during the update.
+ *
+ * Returning "true" here will cause lexical to unmount and recreate the DOM node
+ * (by calling createDOM). You would need to do this if the element tag changes,
+ * for instance.
+ *
+ * */
+ updateDOM(
+ _prevNode: unknown,
+ _dom: HTMLElement,
+ _config: EditorConfig,
+ ): boolean {
+ invariant(false, 'updateDOM: base method not extended');
+ }
+
+ /**
+ * Controls how the this node is serialized to HTML. This is important for
+ * copy and paste between Lexical and non-Lexical editors, or Lexical editors with different namespaces,
+ * in which case the primary transfer format is HTML. It's also important if you're serializing
+ * to HTML for any other reason via {@link @lexical/html!$generateHtmlFromNodes}. You could
+ * also use this method to build your own HTML renderer.
+ *
+ * */
+ exportDOM(editor: LexicalEditor): DOMExportOutput {
+ const element = this.createDOM(editor._config, editor);
+ return {element};
+ }
+
+ /**
+ * Controls how the this node is serialized to JSON. This is important for
+ * copy and paste between Lexical editors sharing the same namespace. It's also important
+ * if you're serializing to JSON for persistent storage somewhere.
+ * See [Serialization & Deserialization](https://lexical.dev/docs/concepts/serialization#lexical---html).
+ *
+ * */
+ exportJSON(): SerializedLexicalNode {
+ invariant(false, 'exportJSON: base method not extended');
+ }
+
+ /**
+ * Controls how the this node is deserialized from JSON. This is usually boilerplate,
+ * but provides an abstraction between the node implementation and serialized interface that can
+ * be important if you ever make breaking changes to a node schema (by adding or removing properties).
+ * See [Serialization & Deserialization](https://lexical.dev/docs/concepts/serialization#lexical---html).
+ *
+ * */
+ static importJSON(_serializedNode: SerializedLexicalNode): LexicalNode {
+ invariant(
+ false,
+ 'LexicalNode: Node %s does not implement .importJSON().',
+ this.name,
+ );
+ }
+ /**
+ * @experimental
+ *
+ * Registers the returned function as a transform on the node during
+ * Editor initialization. Most such use cases should be addressed via
+ * the {@link LexicalEditor.registerNodeTransform} API.
+ *
+ * Experimental - use at your own risk.
+ */
+ static transform(): ((node: LexicalNode) => void) | null {
+ return null;
+ }
+
+ // Setters and mutators
+
+ /**
+ * Removes this LexicalNode from the EditorState. If the node isn't re-inserted
+ * somewhere, the Lexical garbage collector will eventually clean it up.
+ *
+ * @param preserveEmptyParent - If falsy, the node's parent will be removed if
+ * it's empty after the removal operation. This is the default behavior, subject to
+ * other node heuristics such as {@link ElementNode#canBeEmpty}
+ * */
+ remove(preserveEmptyParent?: boolean): void {
+ $removeNode(this, true, preserveEmptyParent);
+ }
+
+ /**
+ * Replaces this LexicalNode with the provided node, optionally transferring the children
+ * of the replaced node to the replacing node.
+ *
+ * @param replaceWith - The node to replace this one with.
+ * @param includeChildren - Whether or not to transfer the children of this node to the replacing node.
+ * */
+ replace(replaceWith: N, includeChildren?: boolean): N {
+ errorOnReadOnly();
+ let selection = $getSelection();
+ if (selection !== null) {
+ selection = selection.clone();
+ }
+ errorOnInsertTextNodeOnRoot(this, replaceWith);
+ const self = this.getLatest();
+ const toReplaceKey = this.__key;
+ const key = replaceWith.__key;
+ const writableReplaceWith = replaceWith.getWritable();
+ const writableParent = this.getParentOrThrow().getWritable();
+ const size = writableParent.__size;
+ removeFromParent(writableReplaceWith);
+ const prevSibling = self.getPreviousSibling();
+ const nextSibling = self.getNextSibling();
+ const prevKey = self.__prev;
+ const nextKey = self.__next;
+ const parentKey = self.__parent;
+ $removeNode(self, false, true);
+
+ if (prevSibling === null) {
+ writableParent.__first = key;
+ } else {
+ const writablePrevSibling = prevSibling.getWritable();
+ writablePrevSibling.__next = key;
+ }
+ writableReplaceWith.__prev = prevKey;
+ if (nextSibling === null) {
+ writableParent.__last = key;
+ } else {
+ const writableNextSibling = nextSibling.getWritable();
+ writableNextSibling.__prev = key;
+ }
+ writableReplaceWith.__next = nextKey;
+ writableReplaceWith.__parent = parentKey;
+ writableParent.__size = size;
+ if (includeChildren) {
+ invariant(
+ $isElementNode(this) && $isElementNode(writableReplaceWith),
+ 'includeChildren should only be true for ElementNodes',
+ );
+ this.getChildren().forEach((child: LexicalNode) => {
+ writableReplaceWith.append(child);
+ });
+ }
+ if ($isRangeSelection(selection)) {
+ $setSelection(selection);
+ const anchor = selection.anchor;
+ const focus = selection.focus;
+ if (anchor.key === toReplaceKey) {
+ $moveSelectionPointToEnd(anchor, writableReplaceWith);
+ }
+ if (focus.key === toReplaceKey) {
+ $moveSelectionPointToEnd(focus, writableReplaceWith);
+ }
+ }
+ if ($getCompositionKey() === toReplaceKey) {
+ $setCompositionKey(key);
+ }
+ return writableReplaceWith;
+ }
+
+ /**
+ * Inserts a node after this LexicalNode (as the next sibling).
+ *
+ * @param nodeToInsert - The node to insert after this one.
+ * @param restoreSelection - Whether or not to attempt to resolve the
+ * selection to the appropriate place after the operation is complete.
+ * */
+ insertAfter(nodeToInsert: LexicalNode, restoreSelection = true): LexicalNode {
+ errorOnReadOnly();
+ errorOnInsertTextNodeOnRoot(this, nodeToInsert);
+ const writableSelf = this.getWritable();
+ const writableNodeToInsert = nodeToInsert.getWritable();
+ const oldParent = writableNodeToInsert.getParent();
+ const selection = $getSelection();
+ let elementAnchorSelectionOnNode = false;
+ let elementFocusSelectionOnNode = false;
+ if (oldParent !== null) {
+ // TODO: this is O(n), can we improve?
+ const oldIndex = nodeToInsert.getIndexWithinParent();
+ removeFromParent(writableNodeToInsert);
+ if ($isRangeSelection(selection)) {
+ const oldParentKey = oldParent.__key;
+ const anchor = selection.anchor;
+ const focus = selection.focus;
+ elementAnchorSelectionOnNode =
+ anchor.type === 'element' &&
+ anchor.key === oldParentKey &&
+ anchor.offset === oldIndex + 1;
+ elementFocusSelectionOnNode =
+ focus.type === 'element' &&
+ focus.key === oldParentKey &&
+ focus.offset === oldIndex + 1;
+ }
+ }
+ const nextSibling = this.getNextSibling();
+ const writableParent = this.getParentOrThrow().getWritable();
+ const insertKey = writableNodeToInsert.__key;
+ const nextKey = writableSelf.__next;
+ if (nextSibling === null) {
+ writableParent.__last = insertKey;
+ } else {
+ const writableNextSibling = nextSibling.getWritable();
+ writableNextSibling.__prev = insertKey;
+ }
+ writableParent.__size++;
+ writableSelf.__next = insertKey;
+ writableNodeToInsert.__next = nextKey;
+ writableNodeToInsert.__prev = writableSelf.__key;
+ writableNodeToInsert.__parent = writableSelf.__parent;
+ if (restoreSelection && $isRangeSelection(selection)) {
+ const index = this.getIndexWithinParent();
+ $updateElementSelectionOnCreateDeleteNode(
+ selection,
+ writableParent,
+ index + 1,
+ );
+ const writableParentKey = writableParent.__key;
+ if (elementAnchorSelectionOnNode) {
+ selection.anchor.set(writableParentKey, index + 2, 'element');
+ }
+ if (elementFocusSelectionOnNode) {
+ selection.focus.set(writableParentKey, index + 2, 'element');
+ }
+ }
+ return nodeToInsert;
+ }
+
+ /**
+ * Inserts a node before this LexicalNode (as the previous sibling).
+ *
+ * @param nodeToInsert - The node to insert before this one.
+ * @param restoreSelection - Whether or not to attempt to resolve the
+ * selection to the appropriate place after the operation is complete.
+ * */
+ insertBefore(
+ nodeToInsert: LexicalNode,
+ restoreSelection = true,
+ ): LexicalNode {
+ errorOnReadOnly();
+ errorOnInsertTextNodeOnRoot(this, nodeToInsert);
+ const writableSelf = this.getWritable();
+ const writableNodeToInsert = nodeToInsert.getWritable();
+ const insertKey = writableNodeToInsert.__key;
+ removeFromParent(writableNodeToInsert);
+ const prevSibling = this.getPreviousSibling();
+ const writableParent = this.getParentOrThrow().getWritable();
+ const prevKey = writableSelf.__prev;
+ // TODO: this is O(n), can we improve?
+ const index = this.getIndexWithinParent();
+ if (prevSibling === null) {
+ writableParent.__first = insertKey;
+ } else {
+ const writablePrevSibling = prevSibling.getWritable();
+ writablePrevSibling.__next = insertKey;
+ }
+ writableParent.__size++;
+ writableSelf.__prev = insertKey;
+ writableNodeToInsert.__prev = prevKey;
+ writableNodeToInsert.__next = writableSelf.__key;
+ writableNodeToInsert.__parent = writableSelf.__parent;
+ const selection = $getSelection();
+ if (restoreSelection && $isRangeSelection(selection)) {
+ const parent = this.getParentOrThrow();
+ $updateElementSelectionOnCreateDeleteNode(selection, parent, index);
+ }
+ return nodeToInsert;
+ }
+
+ /**
+ * Whether or not this node has a required parent. Used during copy + paste operations
+ * to normalize nodes that would otherwise be orphaned. For example, ListItemNodes without
+ * a ListNode parent or TextNodes with a ParagraphNode parent.
+ *
+ * */
+ isParentRequired(): boolean {
+ return false;
+ }
+
+ /**
+ * The creation logic for any required parent. Should be implemented if {@link isParentRequired} returns true.
+ *
+ * */
+ createParentElementNode(): ElementNode {
+ return $createParagraphNode();
+ }
+
+ selectStart(): RangeSelection {
+ return this.selectPrevious();
+ }
+
+ selectEnd(): RangeSelection {
+ return this.selectNext(0, 0);
+ }
+
+ /**
+ * Moves selection to the previous sibling of this node, at the specified offsets.
+ *
+ * @param anchorOffset - The anchor offset for selection.
+ * @param focusOffset - The focus offset for selection
+ * */
+ selectPrevious(anchorOffset?: number, focusOffset?: number): RangeSelection {
+ errorOnReadOnly();
+ const prevSibling = this.getPreviousSibling();
+ const parent = this.getParentOrThrow();
+ if (prevSibling === null) {
+ return parent.select(0, 0);
+ }
+ if ($isElementNode(prevSibling)) {
+ return prevSibling.select();
+ } else if (!$isTextNode(prevSibling)) {
+ const index = prevSibling.getIndexWithinParent() + 1;
+ return parent.select(index, index);
+ }
+ return prevSibling.select(anchorOffset, focusOffset);
+ }
+
+ /**
+ * Moves selection to the next sibling of this node, at the specified offsets.
+ *
+ * @param anchorOffset - The anchor offset for selection.
+ * @param focusOffset - The focus offset for selection
+ * */
+ selectNext(anchorOffset?: number, focusOffset?: number): RangeSelection {
+ errorOnReadOnly();
+ const nextSibling = this.getNextSibling();
+ const parent = this.getParentOrThrow();
+ if (nextSibling === null) {
+ return parent.select();
+ }
+ if ($isElementNode(nextSibling)) {
+ return nextSibling.select(0, 0);
+ } else if (!$isTextNode(nextSibling)) {
+ const index = nextSibling.getIndexWithinParent();
+ return parent.select(index, index);
+ }
+ return nextSibling.select(anchorOffset, focusOffset);
+ }
+
+ /**
+ * Marks a node dirty, triggering transforms and
+ * forcing it to be reconciled during the update cycle.
+ *
+ * */
+ markDirty(): void {
+ this.getWritable();
+ }
+}
+
+function errorOnTypeKlassMismatch(
+ type: string,
+ klass: Klass,
+): void {
+ const registeredNode = getActiveEditor()._nodes.get(type);
+ // Common error - split in its own invariant
+ if (registeredNode === undefined) {
+ invariant(
+ false,
+ 'Create node: Attempted to create node %s that was not configured to be used on the editor.',
+ klass.name,
+ );
+ }
+ const editorKlass = registeredNode.klass;
+ if (editorKlass !== klass) {
+ invariant(
+ false,
+ 'Create node: Type %s in node %s does not match registered node %s with the same type',
+ type,
+ klass.name,
+ editorKlass.name,
+ );
+ }
+}
+
+/**
+ * Insert a series of nodes after this LexicalNode (as next siblings)
+ *
+ * @param firstToInsert - The first node to insert after this one.
+ * @param lastToInsert - The last node to insert after this one. Must be a
+ * later sibling of FirstNode. If not provided, it will be its last sibling.
+ */
+export function insertRangeAfter(
+ node: LexicalNode,
+ firstToInsert: LexicalNode,
+ lastToInsert?: LexicalNode,
+) {
+ const lastToInsert2 =
+ lastToInsert || firstToInsert.getParentOrThrow().getLastChild()!;
+ let current = firstToInsert;
+ const nodesToInsert = [firstToInsert];
+ while (current !== lastToInsert2) {
+ if (!current.getNextSibling()) {
+ invariant(
+ false,
+ 'insertRangeAfter: lastToInsert must be a later sibling of firstToInsert',
+ );
+ }
+ current = current.getNextSibling()!;
+ nodesToInsert.push(current);
+ }
+
+ let currentNode: LexicalNode = node;
+ for (const nodeToInsert of nodesToInsert) {
+ currentNode = currentNode.insertAfter(nodeToInsert);
+ }
+}
diff --git a/resources/js/wysiwyg/lexical/core/LexicalNormalization.ts b/resources/js/wysiwyg/lexical/core/LexicalNormalization.ts
new file mode 100644
index 000000000..59a7be644
--- /dev/null
+++ b/resources/js/wysiwyg/lexical/core/LexicalNormalization.ts
@@ -0,0 +1,124 @@
+/**
+ * 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 {RangeSelection, TextNode} from '.';
+import type {PointType} from './LexicalSelection';
+
+import {$isElementNode, $isTextNode} from '.';
+import {getActiveEditor} from './LexicalUpdates';
+
+function $canSimpleTextNodesBeMerged(
+ node1: TextNode,
+ node2: TextNode,
+): boolean {
+ const node1Mode = node1.__mode;
+ const node1Format = node1.__format;
+ const node1Style = node1.__style;
+ const node2Mode = node2.__mode;
+ const node2Format = node2.__format;
+ const node2Style = node2.__style;
+ return (
+ (node1Mode === null || node1Mode === node2Mode) &&
+ (node1Format === null || node1Format === node2Format) &&
+ (node1Style === null || node1Style === node2Style)
+ );
+}
+
+function $mergeTextNodes(node1: TextNode, node2: TextNode): TextNode {
+ const writableNode1 = node1.mergeWithSibling(node2);
+
+ const normalizedNodes = getActiveEditor()._normalizedNodes;
+
+ normalizedNodes.add(node1.__key);
+ normalizedNodes.add(node2.__key);
+ return writableNode1;
+}
+
+export function $normalizeTextNode(textNode: TextNode): void {
+ let node = textNode;
+
+ if (node.__text === '' && node.isSimpleText() && !node.isUnmergeable()) {
+ node.remove();
+ return;
+ }
+
+ // Backward
+ let previousNode;
+
+ while (
+ (previousNode = node.getPreviousSibling()) !== null &&
+ $isTextNode(previousNode) &&
+ previousNode.isSimpleText() &&
+ !previousNode.isUnmergeable()
+ ) {
+ if (previousNode.__text === '') {
+ previousNode.remove();
+ } else if ($canSimpleTextNodesBeMerged(previousNode, node)) {
+ node = $mergeTextNodes(previousNode, node);
+ break;
+ } else {
+ break;
+ }
+ }
+
+ // Forward
+ let nextNode;
+
+ while (
+ (nextNode = node.getNextSibling()) !== null &&
+ $isTextNode(nextNode) &&
+ nextNode.isSimpleText() &&
+ !nextNode.isUnmergeable()
+ ) {
+ if (nextNode.__text === '') {
+ nextNode.remove();
+ } else if ($canSimpleTextNodesBeMerged(node, nextNode)) {
+ node = $mergeTextNodes(node, nextNode);
+ break;
+ } else {
+ break;
+ }
+ }
+}
+
+export function $normalizeSelection(selection: RangeSelection): RangeSelection {
+ $normalizePoint(selection.anchor);
+ $normalizePoint(selection.focus);
+ return selection;
+}
+
+function $normalizePoint(point: PointType): void {
+ while (point.type === 'element') {
+ const node = point.getNode();
+ const offset = point.offset;
+ let nextNode;
+ let nextOffsetAtEnd;
+ if (offset === node.getChildrenSize()) {
+ nextNode = node.getChildAtIndex(offset - 1);
+ nextOffsetAtEnd = true;
+ } else {
+ nextNode = node.getChildAtIndex(offset);
+ nextOffsetAtEnd = false;
+ }
+ if ($isTextNode(nextNode)) {
+ point.set(
+ nextNode.__key,
+ nextOffsetAtEnd ? nextNode.getTextContentSize() : 0,
+ 'text',
+ );
+ break;
+ } else if (!$isElementNode(nextNode)) {
+ break;
+ }
+ point.set(
+ nextNode.__key,
+ nextOffsetAtEnd ? nextNode.getChildrenSize() : 0,
+ 'element',
+ );
+ }
+}
diff --git a/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts b/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts
new file mode 100644
index 000000000..09d01bffd
--- /dev/null
+++ b/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts
@@ -0,0 +1,830 @@
+/**
+ * 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 {
+ EditorConfig,
+ LexicalEditor,
+ MutatedNodes,
+ MutationListeners,
+ RegisteredNodes,
+} from './LexicalEditor';
+import type {NodeKey, NodeMap} from './LexicalNode';
+import type {ElementNode} from './nodes/LexicalElementNode';
+
+import invariant from 'lexical/shared/invariant';
+import normalizeClassNames from 'lexical/shared/normalizeClassNames';
+
+import {
+ $isDecoratorNode,
+ $isElementNode,
+ $isLineBreakNode,
+ $isParagraphNode,
+ $isRootNode,
+ $isTextNode,
+} from '.';
+import {
+ DOUBLE_LINE_BREAK,
+ FULL_RECONCILE,
+ IS_ALIGN_CENTER,
+ IS_ALIGN_END,
+ IS_ALIGN_JUSTIFY,
+ IS_ALIGN_LEFT,
+ IS_ALIGN_RIGHT,
+ IS_ALIGN_START,
+} from './LexicalConstants';
+import {EditorState} from './LexicalEditorState';
+import {
+ $textContentRequiresDoubleLinebreakAtEnd,
+ cloneDecorators,
+ getElementByKeyOrThrow,
+ setMutatedNode,
+} from './LexicalUtils';
+
+type IntentionallyMarkedAsDirtyElement = boolean;
+
+let subTreeTextContent = '';
+let subTreeTextFormat: number | null = null;
+let subTreeTextStyle: string = '';
+let editorTextContent = '';
+let activeEditorConfig: EditorConfig;
+let activeEditor: LexicalEditor;
+let activeEditorNodes: RegisteredNodes;
+let treatAllNodesAsDirty = false;
+let activeEditorStateReadOnly = false;
+let activeMutationListeners: MutationListeners;
+let activeDirtyElements: Map;
+let activeDirtyLeaves: Set;
+let activePrevNodeMap: NodeMap;
+let activeNextNodeMap: NodeMap;
+let activePrevKeyToDOMMap: Map;
+let mutatedNodes: MutatedNodes;
+
+function destroyNode(key: NodeKey, parentDOM: null | HTMLElement): void {
+ const node = activePrevNodeMap.get(key);
+
+ if (parentDOM !== null) {
+ const dom = getPrevElementByKeyOrThrow(key);
+ if (dom.parentNode === parentDOM) {
+ parentDOM.removeChild(dom);
+ }
+ }
+
+ // This logic is really important, otherwise we will leak DOM nodes
+ // when their corresponding LexicalNodes are removed from the editor state.
+ if (!activeNextNodeMap.has(key)) {
+ activeEditor._keyToDOMMap.delete(key);
+ }
+
+ if ($isElementNode(node)) {
+ const children = createChildrenArray(node, activePrevNodeMap);
+ destroyChildren(children, 0, children.length - 1, null);
+ }
+
+ if (node !== undefined) {
+ setMutatedNode(
+ mutatedNodes,
+ activeEditorNodes,
+ activeMutationListeners,
+ node,
+ 'destroyed',
+ );
+ }
+}
+
+function destroyChildren(
+ children: Array