diff --git a/packages/lexical/src/LexicalEditor.js b/packages/lexical/src/LexicalEditor.js index 9e3dbd43f..837934839 100644 --- a/packages/lexical/src/LexicalEditor.js +++ b/packages/lexical/src/LexicalEditor.js @@ -135,6 +135,10 @@ export type RegisteredNode = { }; export type Transform = (node: T) => void; +export type MutationListeners = Map>; +export type MutatedNodes = Map, Map>; +export type NodeMutation = 'attached' | 'detached'; + export type ErrorListener = (error: Error) => void; export type UpdateListener = ({ dirtyElements: Map, @@ -150,6 +154,7 @@ export type RootListener = ( prevRootElement: null | HTMLElement, ) => void; export type TextContentListener = (text: string) => void; +export type MutationListener = (nodes: Map) => void; export type CommandListener = ( type: string, payload: CommandPayload, @@ -176,6 +181,7 @@ type Listeners = { command: Array>, decorator: Set, error: Set, + mutation: MutationListeners, root: Set, textcontent: Set, update: Set, @@ -187,6 +193,7 @@ export type ListenerType = | 'root' | 'decorator' | 'textcontent' + | 'mutation' | 'command'; export type TransformerType = 'text' | 'decorator' | 'element' | 'root'; @@ -338,6 +345,7 @@ class BaseLexicalEditor { command: [new Set(), new Set(), new Set(), new Set(), new Set()], decorator: new Set(), error: new Set(), + mutation: new Map(), root: new Set(), textcontent: new Set(), update: new Set(), @@ -366,31 +374,60 @@ class BaseLexicalEditor { } addListener( type: ListenerType, - listener: + arg1: | ErrorListener | UpdateListener | DecoratorListener | RootListener | TextContentListener - | CommandListener, - priority: CommandListenerPriority, + | CommandListener + | Class, + arg2: MutationListener | CommandListenerPriority, ): () => void { const listenerSetOrMap = this._listeners[type]; if (type === 'command') { + // $FlowFixMe: TODO refine + const listener: CommandListener = arg1; + // $FlowFixMe: TODO refine + const priority = (arg2: CommandListenerPriority); if (priority === undefined) { invariant(false, 'Listener for type "command" requires a "priority".'); } - // $FlowFixMe: unsure how to csae this + // $FlowFixMe: unsure how to cast this const commands: Array> = listenerSetOrMap; const commandSet = commands[priority]; - // $FlowFixMe: cast commandSet.add(listener); return () => { - // $FlowFixMe: cast commandSet.delete(listener); }; + } else if (type === 'mutation') { + // $FlowFixMe: refine + const klass = (arg1: Class); + // $FlowFixMe: refine + const mutationListener = (arg2: MutationListener); + 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, + ); + } + const mutations = this._listeners.mutation; + mutations.set(mutationListener, klass); + return () => { + mutations.delete(mutationListener); + }; } else { + const listener: + | ErrorListener + | UpdateListener + | DecoratorListener + | RootListener + | TextContentListener + // $FlowFixMe: TODO refine + | CommandListener = arg1; // $FlowFixMe: TODO refine this from the above types listenerSetOrMap.add(listener); @@ -601,6 +638,11 @@ declare export class LexicalEditor { addListener(type: 'root', listener: RootListener): () => void; addListener(type: 'decorator', listener: DecoratorListener): () => void; addListener(type: 'textcontent', listener: TextContentListener): () => void; + addListener( + type: 'mutation', + klass: Class, + listener: MutationListener, + ): () => void; addListener( type: 'command', listener: CommandListener, diff --git a/packages/lexical/src/LexicalNode.js b/packages/lexical/src/LexicalNode.js index f7974f820..547ab5f4f 100644 --- a/packages/lexical/src/LexicalNode.js +++ b/packages/lexical/src/LexicalNode.js @@ -31,10 +31,10 @@ import { getActiveEditorState, } from './LexicalUpdates'; import { - $generateKey, $getCompositionKey, $getNodeByKey, $setCompositionKey, + $setNodeKey, internalMarkNodeAsDirty, internalMarkSiblingsAsDirty, } from './LexicalUtils'; @@ -131,8 +131,8 @@ export class LexicalNode { constructor(key?: NodeKey): void { this.__type = this.constructor.getType(); - this.__key = key || $generateKey(this); this.__parent = null; + $setNodeKey(this, key); // Ensure custom nodes implement required methods. if (__DEV__) { diff --git a/packages/lexical/src/LexicalReconciler.js b/packages/lexical/src/LexicalReconciler.js index ade25429d..c8cae3813 100644 --- a/packages/lexical/src/LexicalReconciler.js +++ b/packages/lexical/src/LexicalReconciler.js @@ -11,6 +11,8 @@ import type { EditorConfig, IntentionallyMarkedAsDirtyElement, LexicalEditor, + MutatedNodes, + RegisteredNodes, } from './LexicalEditor'; import type {NodeKey, NodeMap} from './LexicalNode'; import type {RangeSelection} from './LexicalSelection'; @@ -40,6 +42,7 @@ import { getDOMTextNode, getTextDirection, isSelectionWithinEditor, + setMutatedNode, } from './LexicalUtils'; let subTreeTextContent = ''; @@ -47,6 +50,7 @@ let subTreeDirectionedTextContent = ''; let editorTextContent = ''; let activeEditorConfig: EditorConfig<{...}>; let activeEditor: LexicalEditor; +let activeEditorNodes: RegisteredNodes; let treatAllNodesAsDirty: boolean = false; let activeEditorStateReadOnly: boolean = false; let activeTextDirection = null; @@ -55,6 +59,7 @@ 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); @@ -72,6 +77,9 @@ function destroyNode(key: NodeKey, parentDOM: null | HTMLElement): void { const children = node.__children; destroyChildren(children, 0, children.length - 1, null); } + if (node !== undefined) { + setMutatedNode(mutatedNodes, activeEditorNodes, node, 'detached'); + } } function destroyChildren( @@ -199,6 +207,7 @@ function createNode( // Freeze the node in DEV to prevent accidental mutations Object.freeze(node); } + setMutatedNode(mutatedNodes, activeEditorNodes, node, 'attached'); return dom; } @@ -612,7 +621,7 @@ function reconcileRoot( dirtyType: 0 | 1 | 2, dirtyElements: Map, dirtyLeaves: Set, -): void { +): MutatedNodes { subTreeTextContent = ''; editorTextContent = ''; subTreeDirectionedTextContent = ''; @@ -622,12 +631,15 @@ function reconcileRoot( activeTextDirection = null; activeEditor = editor; activeEditorConfig = editor._config; + activeEditorNodes = editor._nodes; activeDirtyElements = dirtyElements; activeDirtyLeaves = dirtyLeaves; activePrevNodeMap = prevEditorState._nodeMap; activeNextNodeMap = nextEditorState._nodeMap; activeEditorStateReadOnly = nextEditorState._readOnly; activePrevKeyToDOMMap = new Map(editor._keyToDOMMap); + const currentMutatedNodes = new Map(); + mutatedNodes = currentMutatedNodes; reconcileNode('root', null); // We don't want a bunch of void checks throughout the scope @@ -637,6 +649,8 @@ function reconcileRoot( // $FlowFixMe activeEditor = undefined; // $FlowFixMe + activeEditorNodes = undefined; + // $FlowFixMe activeDirtyElements = undefined; // $FlowFixMe activeDirtyLeaves = undefined; @@ -648,6 +662,10 @@ function reconcileRoot( activeEditorConfig = undefined; // $FlowFixMe activePrevKeyToDOMMap = undefined; + // $FlowFixMe + mutatedNodes = undefined; + + return currentMutatedNodes; } export function updateEditorState( @@ -658,8 +676,9 @@ export function updateEditorState( pendingSelection: RangeSelection | null, needsUpdate: boolean, editor: LexicalEditor, -): void { +): null | MutatedNodes { const observer = editor._observer; + let reconcileMutatedNodes = null; if (needsUpdate && observer !== null) { const dirtyType = editor._dirtyType; @@ -668,7 +687,7 @@ export function updateEditorState( observer.disconnect(); try { - reconcileRoot( + reconcileMutatedNodes = reconcileRoot( currentEditorState, pendingEditorState, editor, @@ -698,6 +717,8 @@ export function updateEditorState( domSelection, ); } + + return reconcileMutatedNodes; } function scrollIntoViewIfNeeded(node: Node, rootElement: ?HTMLElement): void { diff --git a/packages/lexical/src/LexicalUpdates.js b/packages/lexical/src/LexicalUpdates.js index 6a196efe4..d0ed12ad3 100644 --- a/packages/lexical/src/LexicalUpdates.js +++ b/packages/lexical/src/LexicalUpdates.js @@ -11,6 +11,7 @@ import type { CommandPayload, EditorUpdateOptions, LexicalEditor, + MutatedNodes, Transform, } from './LexicalEditor'; import type {ParsedEditorState} from './LexicalEditorState'; @@ -346,7 +347,7 @@ export function commitPendingUpdates(editor: LexicalEditor): void { editor._updating = true; try { - updateEditorState( + const mutatedNodes = updateEditorState( rootElement, currentEditorState, pendingEditorState, @@ -355,6 +356,14 @@ export function commitPendingUpdates(editor: LexicalEditor): void { needsUpdate, editor, ); + if (mutatedNodes !== null) { + triggerMutationListeners( + editor, + currentEditorState, + pendingEditorState, + mutatedNodes, + ); + } } catch (error) { // Report errors triggerListeners('error', editor, false, error); @@ -423,6 +432,22 @@ function triggerTextContentListeners( } } +function triggerMutationListeners( + editor: LexicalEditor, + currentEditorState: EditorState, + pendingEditorState: EditorState, + mutatedNodes: MutatedNodes, +): void { + const listeners = editor._listeners.mutation; + listeners.forEach((klass, listener) => { + const mutatedNodesByType = mutatedNodes.get(klass); + if (mutatedNodesByType === undefined) { + return; + } + listener(mutatedNodesByType); + }); +} + export function triggerListeners( type: 'update' | 'error' | 'root' | 'decorator' | 'textcontent', diff --git a/packages/lexical/src/LexicalUtils.js b/packages/lexical/src/LexicalUtils.js index dc1a5f0b7..3dfba06a4 100644 --- a/packages/lexical/src/LexicalUtils.js +++ b/packages/lexical/src/LexicalUtils.js @@ -10,7 +10,10 @@ import type { IntentionallyMarkedAsDirtyElement, LexicalEditor, + MutatedNodes, + NodeMutation, RegisteredNode, + RegisteredNodes, } from './LexicalEditor'; import type {EditorState} from './LexicalEditorState'; import type {LexicalNode, NodeKey, NodeMap} from './LexicalNode'; @@ -157,7 +160,11 @@ export function $isLeafNode(node: ?LexicalNode): boolean %checks { return $isTextNode(node) || $isLineBreakNode(node) || $isDecoratorNode(node); } -export function $generateKey(node: LexicalNode): NodeKey { +export function $setNodeKey(node: LexicalNode, existingKey: ?NodeKey): void { + if (existingKey != null) { + node.__key = existingKey; + return; + } errorOnReadOnly(); errorOnInfiniteTransforms(); const editor = getActiveEditor(); @@ -172,7 +179,7 @@ export function $generateKey(node: LexicalNode): NodeKey { } editor._cloneNotNeeded.add(key); editor._dirtyType = HAS_DIRTY_NODES; - return key; + node.__key = key; } function internalMarkParentElementsAsDirty( @@ -779,3 +786,22 @@ export function getCachedClassNameArray( } return classNames; } + +export function setMutatedNode( + mutatedNodes: MutatedNodes, + registeredNodes: RegisteredNodes, + node: LexicalNode, + mutation: NodeMutation, +) { + const registeredNode = registeredNodes.get(node.__type); + if (registeredNode === undefined) { + invariant(false, 'Type %s not in registeredNodes', node.__type); + } + const klass = registeredNode.klass; + let mutatedNodesByType = mutatedNodes.get(klass); + if (mutatedNodesByType === undefined) { + mutatedNodesByType = new Map(); + mutatedNodes.set(klass, mutatedNodesByType); + } + mutatedNodesByType.set(node.__key, mutation); +} diff --git a/packages/lexical/src/__tests__/unit/LexicalEditor.test.js b/packages/lexical/src/__tests__/unit/LexicalEditor.test.js index 0d1a1c660..6c3711350 100644 --- a/packages/lexical/src/__tests__/unit/LexicalEditor.test.js +++ b/packages/lexical/src/__tests__/unit/LexicalEditor.test.js @@ -1337,4 +1337,167 @@ describe('LexicalEditor tests', () => { expect(fn).toHaveBeenCalledTimes(1); expect(fn).toHaveBeenCalledWith('foobar'); }); + + it('mutation listener', async () => { + init(); + const paragraphMutations = jest.fn(); + const textNodeMutations = jest.fn(); + editor.addListener('mutation', ParagraphNode, paragraphMutations); + editor.addListener('mutation', TextNode, textNodeMutations); + + const paragraphKeys = []; + const textNodeKeys = []; + // No await intentional (batch with next) + editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const textNode = $createTextNode('foo'); + root.append(paragraph); + paragraph.append(textNode); + paragraphKeys.push(paragraph.getKey()); + textNodeKeys.push(textNode.getKey()); + }); + await editor.update(() => { + const textNode = $getNodeByKey(textNodeKeys[0]); + const textNode2 = $createTextNode('bar').toggleFormat('bold'); + const textNode3 = $createTextNode('xyz').toggleFormat('italic'); + textNode.insertAfter(textNode2); + textNode2.insertAfter(textNode3); + textNodeKeys.push(textNode2.getKey()); + textNodeKeys.push(textNode3.getKey()); + }); + await editor.update(() => { + $getRoot().clear(); + }); + await editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + root.append(paragraph); + paragraphKeys.push(paragraph.getKey()); + // Created and deleted in the same update + textNodeKeys.push($createTextNode('zzz').getKey()); + }); + expect(paragraphMutations.mock.calls.length).toBe(3); + expect(textNodeMutations.mock.calls.length).toBe(2); + + const [paragraphMutation1, paragraphMutation2, paragraphMutation3] = + paragraphMutations.mock.calls; + const [textNodeMutation1, textNodeMutation2] = textNodeMutations.mock.calls; + expect(paragraphMutation1[0].size).toBe(1); + expect(paragraphMutation1[0].get(paragraphKeys[0])).toBe('attached'); + expect(paragraphMutation1[0].size).toBe(1); + expect(paragraphMutation2[0].get(paragraphKeys[0])).toBe('detached'); + expect(paragraphMutation3[0].size).toBe(1); + expect(paragraphMutation3[0].get(paragraphKeys[1])).toBe('attached'); + expect(textNodeMutation1[0].size).toBe(3); + expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('attached'); + expect(textNodeMutation1[0].get(textNodeKeys[1])).toBe('attached'); + expect(textNodeMutation1[0].get(textNodeKeys[2])).toBe('attached'); + expect(textNodeMutation2[0].size).toBe(3); + expect(textNodeMutation2[0].get(textNodeKeys[0])).toBe('detached'); + expect(textNodeMutation2[0].get(textNodeKeys[1])).toBe('detached'); + expect(textNodeMutation2[0].get(textNodeKeys[2])).toBe('detached'); + }); + + it('mutation listener with setEditorState', async () => { + init(); + await editor.update(() => { + $getRoot().append($createParagraphNode()); + }); + const initialEditorState = editor.getEditorState(); + const textNodeMutations = jest.fn(); + editor.addListener('mutation', TextNode, textNodeMutations); + + const textNodeKeys = []; + await editor.update(() => { + const paragraph = $getRoot().getFirstChild(); + const textNode1 = $createTextNode('foo'); + paragraph.append(textNode1); + textNodeKeys.push(textNode1.getKey()); + }); + const fooEditorState = editor.getEditorState(); + await editor.setEditorState(initialEditorState); + // This line should have no effect on the mutation listeners + const parsedFooEditorState = editor.parseEditorState( + JSON.stringify(fooEditorState), + ); + await editor.update(() => { + const paragraph = $getRoot().getFirstChild(); + const textNode2 = $createTextNode('bar').toggleFormat('bold'); + const textNode3 = $createTextNode('xyz').toggleFormat('italic'); + paragraph.append(textNode2, textNode3); + textNodeKeys.push(textNode2.getKey(), textNode3.getKey()); + }); + await editor.setEditorState(parsedFooEditorState); + + expect(textNodeMutations.mock.calls.length).toBe(4); + const [ + textNodeMutation1, + textNodeMutation2, + textNodeMutation3, + textNodeMutation4, + ] = textNodeMutations.mock.calls; + expect(textNodeMutation1[0].size).toBe(1); + expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('attached'); + expect(textNodeMutation2[0].size).toBe(1); + expect(textNodeMutation2[0].get(textNodeKeys[0])).toBe('detached'); + expect(textNodeMutation3[0].size).toBe(2); + expect(textNodeMutation3[0].get(textNodeKeys[1])).toBe('attached'); + expect(textNodeMutation3[0].get(textNodeKeys[2])).toBe('attached'); + expect(textNodeMutation4[0].size).toBe(3); // +1 newly generated key by parseEditorState + expect(textNodeMutation4[0].get(textNodeKeys[1])).toBe('detached'); + expect(textNodeMutation4[0].get(textNodeKeys[2])).toBe('detached'); + }); + + it('mutation listeners does not trigger when other node types are mutated', async () => { + init(); + const paragraphMutations = jest.fn(); + const textNodeMutations = jest.fn(); + editor.addListener('mutation', ParagraphNode, paragraphMutations); + editor.addListener('mutation', TextNode, textNodeMutations); + + await editor.update(() => { + $getRoot().append($createParagraphNode()); + }); + + expect(paragraphMutations.mock.calls.length).toBe(1); + expect(textNodeMutations.mock.calls.length).toBe(0); + }); + + it('mutation listeners with normalization', async () => { + init(); + const textNodeMutations = jest.fn(); + editor.addListener('mutation', TextNode, textNodeMutations); + + const textNodeKeys = []; + await editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const textNode1 = $createTextNode('foo'); + const textNode2 = $createTextNode('bar'); + textNodeKeys.push(textNode1.getKey(), textNode2.getKey()); + root.append(paragraph); + paragraph.append(textNode1, textNode2); + }); + await editor.update(() => { + const paragraph = $getRoot().getFirstChild(); + const textNode3 = $createTextNode('xyz').toggleFormat('bold'); + paragraph.append(textNode3); + textNodeKeys.push(textNode3.getKey()); + }); + await editor.update(() => { + const textNode3 = $getNodeByKey(textNodeKeys[2]); + textNode3.toggleFormat('bold'); // Normalize with foobar + }); + + expect(textNodeMutations.mock.calls.length).toBe(3); + const [textNodeMutation1, textNodeMutation2, textNodeMutation3] = + textNodeMutations.mock.calls; + expect(textNodeMutation1[0].size).toBe(1); + expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('attached'); + expect(textNodeMutation2[0].size).toBe(1); + expect(textNodeMutation2[0].get(textNodeKeys[2])).toBe('attached'); + expect(textNodeMutation3[0].size).toBe(1); + expect(textNodeMutation3[0].get(textNodeKeys[2])).toBe('detached'); + }); }); diff --git a/packages/lexical/src/__tests__/unit/LexicalEditorState.test.js b/packages/lexical/src/__tests__/unit/LexicalEditorState.test.js index 07dcef455..bfb7d6279 100644 --- a/packages/lexical/src/__tests__/unit/LexicalEditorState.test.js +++ b/packages/lexical/src/__tests__/unit/LexicalEditorState.test.js @@ -82,7 +82,7 @@ describe('LexicalEditorState tests', () => { $getRoot().append(paragraph); }); expect(JSON.stringify(editor.getEditorState().toJSON())).toEqual( - `{\"_nodeMap\":[[\"root\",{\"__type\":\"root\",\"__key\":\"root\",\"__parent\":null,\"__children\":[\"1\"],\"__format\":0,\"__indent\":0,\"__dir\":\"ltr\",\"__cachedText\":\"Hello world\"}],[\"1\",{\"__type\":\"paragraph\",\"__key\":\"1\",\"__parent\":\"root\",\"__children\":[\"2\"],\"__format\":0,\"__indent\":0,\"__dir\":\"ltr\"}],[\"2\",{\"__type\":\"text\",\"__key\":\"2\",\"__parent\":\"1\",\"__text\":\"Hello world\",\"__format\":0,\"__style\":\"\",\"__mode\":0,\"__detail\":0}]],\"_selection\":{\"anchor\":{\"key\":\"2\",\"offset\":6,\"type\":\"text\"},\"focus\":{\"key\":\"2\",\"offset\":11,\"type\":\"text\"}}}`, + `{\"_nodeMap\":[[\"root\",{\"__type\":\"root\",\"__parent\":null,\"__key\":\"root\",\"__children\":[\"1\"],\"__format\":0,\"__indent\":0,\"__dir\":\"ltr\",\"__cachedText\":\"Hello world\"}],[\"1\",{\"__type\":\"paragraph\",\"__parent\":\"root\",\"__key\":\"1\",\"__children\":[\"2\"],\"__format\":0,\"__indent\":0,\"__dir\":\"ltr\"}],[\"2\",{\"__type\":\"text\",\"__parent\":\"1\",\"__key\":\"2\",\"__text\":\"Hello world\",\"__format\":0,\"__style\":\"\",\"__mode\":0,\"__detail\":0}]],\"_selection\":{\"anchor\":{\"key\":\"2\",\"offset\":6,\"type\":\"text\"},\"focus\":{\"key\":\"2\",\"offset\":11,\"type\":\"text\"}}}`, ); }); diff --git a/packages/lexical/src/index.js b/packages/lexical/src/index.js index 1c805a1a1..a39111dac 100644 --- a/packages/lexical/src/index.js +++ b/packages/lexical/src/index.js @@ -62,6 +62,7 @@ export type { EditorThemeClasses, IntentionallyMarkedAsDirtyElement, LexicalEditor, + NodeMutation, } from './LexicalEditor'; export type {EditorState, ParsedEditorState} from './LexicalEditorState'; export type {LexicalNode, NodeKey, NodeMap} from './LexicalNode'; diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index ece6eb3e8..d3d9c1a0a 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -49,5 +49,20 @@ "49": "append: attemtping to append self", "50": "Node %s and selection point do not match.", "51": "setIndent is not implemented on ListItemNode", - "52": "Expected node %s to to be a ElementNode." + "52": "Expected node %s to to be a ElementNode.", + "53": "LexicalAutoLinkPlugin: AutoLinkNode, TableCellNode or TableRowNode not registered on editor", + "54": "TablePlugin: TableNode, TableCellNode or TableRowNode not registered on editor", + "55": "Grid not found.", + "56": "Listener for type \"command\" requires a \"priority\".", + "57": "Node %s has not been registered. Ensure node has been passed to createEditor.", + "58": "useCharacterLimit: OverflowNode not registered on editor", + "59": "Attached node not in nextNodeMap", + "60": "Mutation listener on an unregistered node %s", + "61": "Create node: Attempted to create node %s that was not previously registered on the editor. You can use register your custom nodes.", + "62": "joinedText was not calculated", + "63": "Bad regEx pattern found for %s", + "64": "Expected anchor and focus offsets to have ascending character order.", + "65": "The capture group count in the RegEx does match the actual capture group count.", + "66": "Type %s not in registeredNodes", + "67": "The text content string length does not correlate with insertions/deletions of new text." }