Attached/detached nodes listener (#1292)

This commit is contained in:
Gerard Rovira
2022-02-18 08:25:03 +00:00
committed by acywatson
parent 2c7775f940
commit 96e9e310be
9 changed files with 309 additions and 16 deletions

View File

@ -135,6 +135,10 @@ export type RegisteredNode = {
};
export type Transform<T> = (node: T) => void;
export type MutationListeners = Map<MutationListener, Class<LexicalNode>>;
export type MutatedNodes = Map<Class<LexicalNode>, Map<NodeKey, NodeMutation>>;
export type NodeMutation = 'attached' | 'detached';
export type ErrorListener = (error: Error) => void;
export type UpdateListener = ({
dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
@ -150,6 +154,7 @@ export type RootListener = (
prevRootElement: null | HTMLElement,
) => void;
export type TextContentListener = (text: string) => void;
export type MutationListener = (nodes: Map<NodeKey, NodeMutation>) => void;
export type CommandListener = (
type: string,
payload: CommandPayload,
@ -176,6 +181,7 @@ type Listeners = {
command: Array<Set<CommandListener>>,
decorator: Set<DecoratorListener>,
error: Set<ErrorListener>,
mutation: MutationListeners,
root: Set<RootListener>,
textcontent: Set<TextContentListener>,
update: Set<UpdateListener>,
@ -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<LexicalNode>,
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<Set<CommandListener>> = 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<LexicalNode>);
// $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<LexicalNode>,
listener: MutationListener,
): () => void;
addListener(
type: 'command',
listener: CommandListener,

View File

@ -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__) {

View File

@ -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<NodeKey>;
let activePrevNodeMap: NodeMap;
let activeNextNodeMap: NodeMap;
let activePrevKeyToDOMMap: Map<NodeKey, HTMLElement>;
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<NodeKey, IntentionallyMarkedAsDirtyElement>,
dirtyLeaves: Set<NodeKey>,
): 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 {

View File

@ -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',

View File

@ -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<Theme: {...}>(
}
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);
}

View File

@ -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');
});
});

View File

@ -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\"}}}`,
);
});

View File

@ -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';

View File

@ -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."
}