From 9440d21069045235c579f3f1b65a3885cfd03266 Mon Sep 17 00:00:00 2001 From: Nigel Gutzmann Date: Tue, 28 Jan 2025 10:57:53 -0800 Subject: [PATCH 1/2] add reverse dfs iterator --- .../__tests__/unit/LexicalNodeHelpers.test.ts | 325 ++++++++++++------ packages/lexical-utils/src/index.ts | 97 +++++- 2 files changed, 322 insertions(+), 100 deletions(-) diff --git a/packages/lexical-utils/src/__tests__/unit/LexicalNodeHelpers.test.ts b/packages/lexical-utils/src/__tests__/unit/LexicalNodeHelpers.test.ts index 40fab2f26bc..734f2636dfa 100644 --- a/packages/lexical-utils/src/__tests__/unit/LexicalNodeHelpers.test.ts +++ b/packages/lexical-utils/src/__tests__/unit/LexicalNodeHelpers.test.ts @@ -21,132 +21,220 @@ import { invariant, } from 'lexical/src/__tests__/utils'; -import {$dfs, $getNextSiblingOrParentSibling} from '../..'; +import {$dfs, $getNextSiblingOrParentSibling, $reverseDfs} from '../..'; describe('LexicalNodeHelpers tests', () => { initializeUnitTest((testEnv) => { - /** - * R - * P1 P2 - * B1 B2 T4 T5 B3 - * T1 T2 T3 T6 - * - * DFS: R, P1, B1, T1, B2, T2, T3, P2, T4, T5, B3, T6 - */ - test('DFS node order', async () => { - const editor: LexicalEditor = testEnv.editor; - + describe('dfs order', () => { let expectedKeys: Array<{ depth: number; node: NodeKey; }> = []; + /** + * R + * P1 P2 + * B1 B2 T4 T5 B3 + * T1 T2 T3 T6 + * + * DFS: R, P1, B1, T1, B2, T2, T3, P2, T4, T5, B3, T6 + */ + beforeEach(async () => { + const editor: LexicalEditor = testEnv.editor; + await editor.update(() => { + const root = $getRoot(); + + const paragraph1 = $createParagraphNode(); + const paragraph2 = $createParagraphNode(); + + const block1 = $createTestElementNode(); + const block2 = $createTestElementNode(); + const block3 = $createTestElementNode(); + + const text1 = $createTextNode('text1'); + const text2 = $createTextNode('text2'); + const text3 = $createTextNode('text3'); + const text4 = $createTextNode('text4'); + const text5 = $createTextNode('text5'); + const text6 = $createTextNode('text6'); + + root.append(paragraph1, paragraph2); + paragraph1.append(block1, block2); + paragraph2.append(text4, text5); + + text5.toggleFormat('bold'); // Prevent from merging with text 4 + + paragraph2.append(block3); + block1.append(text1); + block2.append(text2, text3); + + text3.toggleFormat('bold'); // Prevent from merging with text2 + + block3.append(text6); + + expectedKeys = [ + { + depth: 0, + node: root.getKey(), + }, + { + depth: 1, + node: paragraph1.getKey(), + }, + { + depth: 2, + node: block1.getKey(), + }, + { + depth: 3, + node: text1.getKey(), + }, + { + depth: 2, + node: block2.getKey(), + }, + { + depth: 3, + node: text2.getKey(), + }, + { + depth: 3, + node: text3.getKey(), + }, + { + depth: 1, + node: paragraph2.getKey(), + }, + { + depth: 2, + node: text4.getKey(), + }, + { + depth: 2, + node: text5.getKey(), + }, + { + depth: 2, + node: block3.getKey(), + }, + { + depth: 3, + node: text6.getKey(), + }, + ]; + }); + }); + + test('DFS node order', async () => { + const editor: LexicalEditor = testEnv.editor; + editor.getEditorState().read(() => { + const expectedNodes = expectedKeys.map(({depth, node: nodeKey}) => ({ + depth, + node: $getNodeByKey(nodeKey)!.getLatest(), + })); + + const first = expectedNodes[0]; + const second = expectedNodes[1]; + const last = expectedNodes[expectedNodes.length - 1]; + const secondToLast = expectedNodes[expectedNodes.length - 2]; + + expect($dfs(first.node, last.node)).toEqual(expectedNodes); + expect($dfs(second.node, secondToLast.node)).toEqual( + expectedNodes.slice(1, expectedNodes.length - 1), + ); + expect($dfs()).toEqual(expectedNodes); + expect($dfs($getRoot())).toEqual(expectedNodes); + }); + }); + + test('Reverse DFS node order', async () => { + const editor: LexicalEditor = testEnv.editor; + editor.getEditorState().read(() => { + const expectedNodes = expectedKeys + .map(({depth, node: nodeKey}) => ({ + depth, + node: $getNodeByKey(nodeKey)!.getLatest(), + })) + .reverse(); + + const first = expectedNodes[0]; + const second = expectedNodes[1]; + const last = expectedNodes[expectedNodes.length - 1]; + const secondToLast = expectedNodes[expectedNodes.length - 2]; + + expect($reverseDfs(first.node, last.node)).toEqual(expectedNodes); + expect($reverseDfs(second.node, secondToLast.node)).toEqual( + expectedNodes.slice(1, expectedNodes.length - 1), + ); + expect($reverseDfs()).toEqual(expectedNodes); + expect($reverseDfs($getRoot().getLastDescendant()!)).toEqual( + expectedNodes, + ); + }); + }); + }); + + test('DFS triggers getLatest()', async () => { + const editor: LexicalEditor = testEnv.editor; + let rootKey: string; + let paragraphKey: string; + let block1Key: string; + let block2Key: string; + await editor.update(() => { const root = $getRoot(); - const paragraph1 = $createParagraphNode(); - const paragraph2 = $createParagraphNode(); - + const paragraph = $createParagraphNode(); const block1 = $createTestElementNode(); const block2 = $createTestElementNode(); - const block3 = $createTestElementNode(); - const text1 = $createTextNode('text1'); - const text2 = $createTextNode('text2'); - const text3 = $createTextNode('text3'); - const text4 = $createTextNode('text4'); - const text5 = $createTextNode('text5'); - const text6 = $createTextNode('text6'); - - root.append(paragraph1, paragraph2); - paragraph1.append(block1, block2); - paragraph2.append(text4, text5); + rootKey = root.getKey(); + paragraphKey = paragraph.getKey(); + block1Key = block1.getKey(); + block2Key = block2.getKey(); - text5.toggleFormat('bold'); // Prevent from merging with text 4 + root.append(paragraph); + paragraph.append(block1, block2); + }); - paragraph2.append(block3); - block1.append(text1); - block2.append(text2, text3); + await editor.update(() => { + const root = $getNodeByKey(rootKey); + const paragraph = $getNodeByKey(paragraphKey); + const block1 = $getNodeByKey(block1Key); + const block2 = $getNodeByKey(block2Key); - text3.toggleFormat('bold'); // Prevent from merging with text2 + const block3 = $createTestElementNode(); + invariant($isElementNode(block1)); - block3.append(text6); + block1.append(block3); - expectedKeys = [ + expect($dfs(root!)).toEqual([ { depth: 0, - node: root.getKey(), + node: root!.getLatest(), }, { depth: 1, - node: paragraph1.getKey(), - }, - { - depth: 2, - node: block1.getKey(), - }, - { - depth: 3, - node: text1.getKey(), + node: paragraph!.getLatest(), }, { depth: 2, - node: block2.getKey(), - }, - { - depth: 3, - node: text2.getKey(), + node: block1.getLatest(), }, { depth: 3, - node: text3.getKey(), - }, - { - depth: 1, - node: paragraph2.getKey(), - }, - { - depth: 2, - node: text4.getKey(), - }, - { - depth: 2, - node: text5.getKey(), + node: block3.getLatest(), }, { depth: 2, - node: block3.getKey(), - }, - { - depth: 3, - node: text6.getKey(), + node: block2!.getLatest(), }, - ]; - }); - - editor.getEditorState().read(() => { - const expectedNodes = expectedKeys.map(({depth, node: nodeKey}) => ({ - depth, - node: $getNodeByKey(nodeKey)!.getLatest(), - })); - - const first = expectedNodes[0]; - const second = expectedNodes[1]; - const last = expectedNodes[expectedNodes.length - 1]; - const secondToLast = expectedNodes[expectedNodes.length - 2]; - - expect($dfs(first.node, last.node)).toEqual(expectedNodes); - expect($dfs(second.node, secondToLast.node)).toEqual( - expectedNodes.slice(1, expectedNodes.length - 1), - ); - expect($dfs()).toEqual(expectedNodes); - expect($dfs($getRoot())).toEqual(expectedNodes); + ]); }); }); - test('DFS triggers getLatest()', async () => { + test('reverse DFS triggers getLatest()', async () => { const editor: LexicalEditor = testEnv.editor; - let rootKey: string; let paragraphKey: string; let block1Key: string; @@ -179,26 +267,26 @@ describe('LexicalNodeHelpers tests', () => { block1.append(block3); - expect($dfs(root!)).toEqual([ + expect($reverseDfs()).toEqual([ { - depth: 0, - node: root!.getLatest(), + depth: 2, + node: block2!.getLatest(), }, { - depth: 1, - node: paragraph!.getLatest(), + depth: 3, + node: block3.getLatest(), }, { depth: 2, node: block1.getLatest(), }, { - depth: 3, - node: block3.getLatest(), + depth: 1, + node: paragraph!.getLatest(), }, { - depth: 2, - node: block2!.getLatest(), + depth: 0, + node: root!.getLatest(), }, ]); }); @@ -206,7 +294,6 @@ describe('LexicalNodeHelpers tests', () => { test('DFS of empty ParagraphNode returns only itself', async () => { const editor: LexicalEditor = testEnv.editor; - let paragraphKey: string; await editor.update(() => { @@ -233,9 +320,49 @@ describe('LexicalNodeHelpers tests', () => { }); }); - test('$getNextSiblingOrParentSibling', async () => { + test('reverse DFS of empty ParagraphNode returns only itself', async () => { + const editor: LexicalEditor = testEnv.editor; + let paragraphKey: string; + + await editor.update(() => { + const root = $getRoot(); + + const paragraph = $createParagraphNode(); + const paragraph2 = $createParagraphNode(); + const text = $createTextNode('test'); + + paragraphKey = paragraph.getKey(); + + paragraph2.append(text); + root.append(paragraph, paragraph2); + }); + await editor.update(() => { + const paragraph = $getNodeByKey(paragraphKey)!; + + expect($reverseDfs(paragraph, paragraph)).toEqual([ + { + depth: 1, + node: paragraph?.getLatest(), + }, + ]); + }); + }); + + test('reverse DFS of empty RootNode returns only itself', async () => { const editor: LexicalEditor = testEnv.editor; + await editor.update(() => { + expect($reverseDfs()).toEqual([ + { + depth: 0, + node: $getRoot().getLatest(), + }, + ]); + }); + }); + + test('$getNextSiblingOrParentSibling', async () => { + const editor: LexicalEditor = testEnv.editor; await editor.update(() => { const root = $getRoot(); const paragraph = $createParagraphNode(); diff --git a/packages/lexical-utils/src/index.ts b/packages/lexical-utils/src/index.ts index 0a758f40f2f..91ceee27e5a 100644 --- a/packages/lexical-utils/src/index.ts +++ b/packages/lexical-utils/src/index.ts @@ -185,6 +185,21 @@ export function $dfs( return Array.from($dfsIterator(startNode, endNode)); } +/** + * A function which will return exactly the reversed order of $dfs. That means that the tree is traversed + * from left to right, starting at the leaf and working towards the root. + * @param startNode - The node to start the search. If omitted, it will start at the last leaf node in the tree. + * @param endNode - The node to end the search. If omitted, it will work backwards all the way to the root node + * @returns An array of objects of all the nodes found by the search, including their depth into the tree. + * \\{depth: number, node: LexicalNode\\} It will always return at least 1 node (the start node). + */ +export function $reverseDfs( + startNode?: LexicalNode, + endNode?: LexicalNode, +): Array { + return Array.from($reverseDfsIterator(startNode, endNode)); +} + type DFSIterator = { next: () => IteratorResult; [Symbol.iterator]: () => DFSIterator; @@ -327,6 +342,85 @@ export function $getNextRightPreorderNode( return node; } +/** + * An iterator which will traverse the tree in exactly the reversed order of $dfsIterator. Tree traversal is done + * on the fly as new values are requested with O(1) memory. + * @param startNode - The node to start the search. If omitted, it will start at the last leaf node in the tree. + * @param endNode - The node to end the search. If omitted, it will work backwards all the way to the root node + * @returns An iterator, each yielded value is a DFSNode. It will always return at least 1 node (the start node). + */ +export function $reverseDfsIterator( + startNode?: LexicalNode, + endNode?: LexicalNode, +): DFSIterator { + const start = ( + startNode || + $getRoot().getLastDescendant() || + $getRoot() + ).getLatest(); + const startDepth = $getDepth(start); + const end = endNode || $getRoot(); + let node: null | LexicalNode = start; + let depth = startDepth; + let isFirstNext = true; + + const iterator: DFSIterator = { + next(): IteratorResult { + if (node === null) { + return iteratorDone; + } + if (isFirstNext) { + isFirstNext = false; + return iteratorNotDone({depth, node}); + } + if (node === end) { + return iteratorDone; + } + if (node.getPreviousSibling()) { + let depthDiff; + [node, depthDiff] = $getPreviousSiblingsLastDescendantOrPreviousSibling( + node, + ) || [null, 0]; + depth += depthDiff; + } else { + node = node.getParent(); + depth--; + } + if (node === null) { + return iteratorDone; + } + return iteratorNotDone({depth, node}); + }, + [Symbol.iterator](): DFSIterator { + return iterator; + }, + }; + return iterator; +} + +/** + * Returns the previous sibling's last descendant (when it exists) or the previous sibling. + * R -> P -> T1 + * -> T2 + * -> P2 + * returns T1 for node T2, T2 for node P2, and null for node T1 or P. + * @param node LexicalNode. + * @returns an array (tuple) coontaining the found Lexical node and the depth difference, or null, if this node doesn't exist. + */ +function $getPreviousSiblingsLastDescendantOrPreviousSibling( + node: LexicalNode, +): null | [LexicalNode, number] { + let _node: LexicalNode | null = node.getPreviousSibling(); + let depthDiff = 0; + while ($isElementNode(_node) && _node.getChildrenSize() > 0) { + _node = _node.getLastChild(); + depthDiff++; + } + if (_node === null) { + return null; + } + return [_node, depthDiff]; +} /** * Takes a node and traverses up its ancestors (toward the root node) * in order to find a specific type of node. @@ -746,7 +840,8 @@ function $unwrapAndFilterDescendantsImpl( * * This function is read-only and performs no mutation operations, which makes * it suitable for import and export purposes but likely not for any in-place - * mutation. You should use {@link $unwrapAndFilterDescendants} for in-place + * mutation. You shimport { $getRoot } from 'lexical'; +ould use {@link $unwrapAndFilterDescendants} for in-place * mutations such as node transforms. * * @param children The children to traverse From 03f5aad737e8149fb7aacc35d1b7199ed89aac39 Mon Sep 17 00:00:00 2001 From: Nigel Gutzmann Date: Tue, 28 Jan 2025 11:02:07 -0800 Subject: [PATCH 2/2] fixing comment - vscode seems to auto inject an import? --- packages/lexical-utils/src/index.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/lexical-utils/src/index.ts b/packages/lexical-utils/src/index.ts index 91ceee27e5a..e98cd572887 100644 --- a/packages/lexical-utils/src/index.ts +++ b/packages/lexical-utils/src/index.ts @@ -187,7 +187,7 @@ export function $dfs( /** * A function which will return exactly the reversed order of $dfs. That means that the tree is traversed - * from left to right, starting at the leaf and working towards the root. + * from right to left, starting at the leaf and working towards the root. * @param startNode - The node to start the search. If omitted, it will start at the last leaf node in the tree. * @param endNode - The node to end the search. If omitted, it will work backwards all the way to the root node * @returns An array of objects of all the nodes found by the search, including their depth into the tree. @@ -840,8 +840,7 @@ function $unwrapAndFilterDescendantsImpl( * * This function is read-only and performs no mutation operations, which makes * it suitable for import and export purposes but likely not for any in-place - * mutation. You shimport { $getRoot } from 'lexical'; -ould use {@link $unwrapAndFilterDescendants} for in-place + * mutation. You should use {@link $unwrapAndFilterDescendants} for in-place * mutations such as node transforms. * * @param children The children to traverse