Skip to content

Commit

Permalink
Refactor mutation observer
Browse files Browse the repository at this point in the history
  • Loading branch information
etrepum committed Oct 29, 2024
1 parent 6a291cf commit b6f65bb
Show file tree
Hide file tree
Showing 5 changed files with 72 additions and 46 deletions.
66 changes: 21 additions & 45 deletions packages/lexical/src/LexicalMutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import {IS_FIREFOX} from 'shared/environment';
import {
$getSelection,
$isDecoratorNode,
$isElementNode,
$isRangeSelection,
$isTextNode,
$setSelection,
Expand All @@ -33,6 +32,7 @@ import {
getParentElement,
getWindow,
internalGetRoot,
isDOMUnmanaged,
isFirefoxClipboardEvents,
} from './LexicalUtils';
// The time between a text entry event and the mutation observer firing.
Expand Down Expand Up @@ -110,21 +110,28 @@ function shouldUpdateTextNodeFromMutation(
return targetDOM.nodeType === DOM_TEXT_TYPE && targetNode.isAttached();
}

export function $getNearestNodePairFromDOMNode(
function $getNearestManagedNodePairFromDOMNode(
startingDOM: Node,
editor: LexicalEditor,
editorState: EditorState,
): [Node, LexicalNode] | [null, null] {
for (let dom: Node | null = startingDOM; dom; dom = getParentElement(dom)) {
rootElement: HTMLElement | null,
): [HTMLElement, LexicalNode] | undefined {
for (
let dom: Node | null = startingDOM;
dom && !isDOMUnmanaged(dom);
dom = getParentElement(dom)
) {
const key = getNodeKeyFromDOMNode(dom, editor);
if (key !== undefined) {
const node = $getNodeByKey(key, editorState);
if (node) {
return [dom, node];
// All decorator nodes are unmanaged
return $isDecoratorNode(node) ? undefined : [dom as HTMLElement, node];
}
} else if (dom === rootElement) {
return [rootElement, internalGetRoot(editorState)];
}
}
return [null, null];
}

export function $flushMutations(
Expand All @@ -139,7 +146,7 @@ export function $flushMutations(
try {
updateEditor(editor, () => {
const selection = $getSelection() || getLastSelection(editor);
const badDOMTargets = new Map<Node, LexicalNode | null>();
const badDOMTargets = new Map<HTMLElement, LexicalNode>();
const rootElement = editor.getRootElement();
// We use the current editor state, as that reflects what is
// actually "on screen".
Expand All @@ -152,19 +159,16 @@ export function $flushMutations(
const mutation = mutations[i];
const type = mutation.type;
const targetDOM = mutation.target;
const pair = $getNearestNodePairFromDOMNode(
const pair = $getNearestManagedNodePairFromDOMNode(
targetDOM,
editor,
currentEditorState,
rootElement,
);
let targetNode = pair[1];

if (
(targetNode === null && targetDOM !== rootElement) ||
$isDecoratorNode(targetNode)
) {
if (!pair) {
continue;
}
const [nodeDOM, targetNode] = pair;

if (type === 'characterData') {
// Text mutations are deferred and passed to mutation listeners to be
Expand Down Expand Up @@ -233,11 +237,7 @@ export function $flushMutations(
}

if (removedDOMsLength !== unremovedBRs) {
if (targetDOM === rootElement) {
targetNode = internalGetRoot(currentEditorState);
}

badDOMTargets.set(pair[0] || targetDOM, targetNode);
badDOMTargets.set(nodeDOM, targetNode);
}
}
}
Expand All @@ -248,32 +248,8 @@ export function $flushMutations(
// 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();
const slot = targetNode.getDOMSlot(targetDOM as HTMLElement);
let currentDOM = slot.getFirstChild();

for (let s = 0; s < childKeys.length; s++) {
const key = childKeys[s];
const correctDOM = editor.getElementByKey(key);

if (correctDOM === null) {
continue;
}

if (currentDOM == null) {
slot.insertChild(correctDOM);
currentDOM = correctDOM;
} else if (currentDOM !== correctDOM) {
slot.replaceChild(correctDOM, currentDOM);
}

currentDOM = currentDOM.nextSibling;
}
} else if ($isTextNode(targetNode)) {
targetNode.markDirty();
}
for (const [nodeDOM, targetNode] of badDOMTargets) {
targetNode.reconcileObservedMutation(nodeDOM, editor);
}
}

Expand Down
6 changes: 6 additions & 0 deletions packages/lexical/src/LexicalNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export interface LexicalPrivateDOM {
__lexicalLineBreak?: HTMLBRElement | undefined | null;
__lexicalDirTextContent?: string | undefined | null;
__lexicalDir?: 'ltr' | 'rtl' | null | undefined;
__lexicalUnmanaged?: boolean | undefined;
}

export function $removeNode(
Expand Down Expand Up @@ -1168,6 +1169,11 @@ export class LexicalNode {
markDirty(): void {
this.getWritable();
}

/** @internal */
reconcileObservedMutation(dom: HTMLElement, editor: LexicalEditor): void {
this.markDirty();
}
}

function errorOnTypeKlassMismatch(
Expand Down
18 changes: 17 additions & 1 deletion packages/lexical/src/LexicalUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ import type {
Spread,
} from './LexicalEditor';
import type {EditorState} from './LexicalEditorState';
import type {LexicalNode, NodeKey, NodeMap} from './LexicalNode';
import type {
LexicalNode,
LexicalPrivateDOM,
NodeKey,
NodeMap,
} from './LexicalNode';
import type {
BaseSelection,
PointType,
Expand Down Expand Up @@ -1857,3 +1862,14 @@ export function setNodeIndentFromDOM(
const indent = indentSize / 40;
elementNode.setIndent(indent);
}

/** @internal */
export function setDOMUnmanaged(elementDom: HTMLElement): void {
const el: HTMLElement & LexicalPrivateDOM = elementDom;
el.__lexicalUnmanaged = true;
}

export function isDOMUnmanaged(elementDom: Node): boolean {
const el: Node & LexicalPrivateDOM = elementDom;
return el.__lexicalUnmanaged === true;
}
2 changes: 2 additions & 0 deletions packages/lexical/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,13 +182,15 @@ export {
getNearestEditorFromDOMNode,
isBlockDomNode,
isDocumentFragment,
isDOMUnmanaged,
isHTMLAnchorElement,
isHTMLElement,
isInlineDomNode,
isLexicalEditor,
isSelectionCapturedInDecoratorInput,
isSelectionWithinEditor,
resetRandomKey,
setDOMUnmanaged,
setNodeIndentFromDOM,
} from './LexicalUtils';
export {ArtificialNode__DO_NOT_USE} from './nodes/ArtificialNode';
Expand Down
26 changes: 26 additions & 0 deletions packages/lexical/src/nodes/LexicalElementNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,32 @@ export class ElementNode extends LexicalNode {
canMergeWhenEmpty(): boolean {
return false;
}

/** @internal */
reconcileObservedMutation(dom: HTMLElement, editor: LexicalEditor): void {
const slot = this.getDOMSlot(dom);
let currentDOM = slot.getFirstChild();
for (
let currentNode = this.getFirstChild();
currentNode;
currentNode = currentNode.getNextSibling()
) {
const correctDOM = editor.getElementByKey(currentNode.getKey());

if (correctDOM === null) {
continue;
}

if (currentDOM == null) {
slot.insertChild(correctDOM);
currentDOM = correctDOM;
} else if (currentDOM !== correctDOM) {
slot.replaceChild(correctDOM, currentDOM);
}

currentDOM = currentDOM.nextSibling;
}
}
}

export function $isElementNode(
Expand Down

0 comments on commit b6f65bb

Please sign in to comment.