Skip to content

Commit 62c51a9

Browse files
authored
Iterable $dfs (#6664)
1 parent 3f7dca7 commit 62c51a9

File tree

3 files changed

+150
-47
lines changed

3 files changed

+150
-47
lines changed

packages/lexical-utils/flow/LexicalUtils.js.flow

+18-7
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,6 @@ import type {
1212
LexicalNode,
1313
ElementNode,
1414
} from 'lexical';
15-
export type DFSNode = $ReadOnly<{
16-
depth: number,
17-
node: LexicalNode,
18-
}>;
1915
declare export function addClassNamesToElement(
2016
element: HTMLElement,
2117
...classNames: Array<typeof undefined | boolean | null | string>
@@ -32,11 +28,26 @@ declare export function mediaFileReader(
3228
files: Array<File>,
3329
acceptableMimeTypes: Array<string>,
3430
): Promise<Array<$ReadOnly<{file: File, result: string}>>>;
31+
export type DFSNode = $ReadOnly<{
32+
depth: number,
33+
node: LexicalNode,
34+
}>;
3535
declare export function $dfs(
36-
startingNode?: LexicalNode,
37-
endingNode?: LexicalNode,
36+
startNode?: LexicalNode,
37+
endNode?: LexicalNode,
3838
): Array<DFSNode>;
39-
declare function $getDepth(node: LexicalNode): number;
39+
type DFSIterator = {
40+
next: () => IteratorResult<DFSNode, void>;
41+
@@iterator: () => DFSIterator;
42+
};
43+
declare export function $dfsIterator(
44+
startNode?: LexicalNode,
45+
endNode?: LexicalNode,
46+
): DFSIterator;
47+
declare export function $getNextSiblingOrParentSibling(
48+
node: LexicalNode,
49+
): null | [LexicalNode, number];
50+
declare export function $getDepth(node: LexicalNode): number;
4051
declare export function $getNearestNodeOfType<T: LexicalNode>(
4152
node: LexicalNode,
4253
klass: Class<T>,

packages/lexical-utils/src/__tests__/unit/LexicalNodeHelpers.test.ts

+28-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
invariant,
2222
} from 'lexical/src/__tests__/utils';
2323

24-
import {$dfs} from '../..';
24+
import {$dfs, $getNextSiblingOrParentSibling} from '../..';
2525

2626
describe('LexicalNodeHelpers tests', () => {
2727
initializeUnitTest((testEnv) => {
@@ -232,5 +232,32 @@ describe('LexicalNodeHelpers tests', () => {
232232
]);
233233
});
234234
});
235+
236+
test('$getNextSiblingOrParentSibling', async () => {
237+
const editor: LexicalEditor = testEnv.editor;
238+
239+
await editor.update(() => {
240+
const root = $getRoot();
241+
const paragraph = $createParagraphNode();
242+
const paragraph2 = $createParagraphNode();
243+
const text1 = $createTextNode('text1');
244+
const text2 = $createTextNode('text2').toggleUnmergeable();
245+
paragraph.append(text1, text2);
246+
root.append(paragraph, paragraph2);
247+
248+
// Sibling
249+
expect($getNextSiblingOrParentSibling(paragraph)).toEqual([
250+
paragraph2,
251+
0,
252+
]);
253+
expect($getNextSiblingOrParentSibling(text1)).toEqual([text2, 0]);
254+
255+
// Parent
256+
expect($getNextSiblingOrParentSibling(text2)).toEqual([paragraph2, -1]);
257+
258+
// Null (end of the tree)
259+
expect($getNextSiblingOrParentSibling(paragraph2)).toBe(null);
260+
});
261+
});
235262
});
236263
});

packages/lexical-utils/src/index.ts

+104-39
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,6 @@ export const IS_FIREFOX: boolean = IS_FIREFOX_;
6363
export const IS_IOS: boolean = IS_IOS_;
6464
export const IS_SAFARI: boolean = IS_SAFARI_;
6565

66-
export type DFSNode = Readonly<{
67-
depth: number;
68-
node: LexicalNode;
69-
}>;
70-
7166
/**
7267
* Takes an HTML element and adds the classNames passed within an array,
7368
* ignoring any non-string types. A space can be used to add multiple classes
@@ -166,59 +161,129 @@ export function mediaFileReader(
166161
});
167162
}
168163

164+
export type DFSNode = Readonly<{
165+
depth: number;
166+
node: LexicalNode;
167+
}>;
168+
169169
/**
170170
* "Depth-First Search" starts at the root/top node of a tree and goes as far as it can down a branch end
171171
* before backtracking and finding a new path. Consider solving a maze by hugging either wall, moving down a
172172
* branch until you hit a dead-end (leaf) and backtracking to find the nearest branching path and repeat.
173173
* It will then return all the nodes found in the search in an array of objects.
174-
* @param startingNode - The node to start the search, if ommitted, it will start at the root node.
175-
* @param endingNode - The node to end the search, if ommitted, it will find all descendants of the startingNode.
174+
* @param startNode - The node to start the search, if omitted, it will start at the root node.
175+
* @param endNode - The node to end the search, if omitted, it will find all descendants of the startingNode.
176176
* @returns An array of objects of all the nodes found by the search, including their depth into the tree.
177-
* \\{depth: number, node: LexicalNode\\} It will always return at least 1 node (the ending node) so long as it exists
177+
* \\{depth: number, node: LexicalNode\\} It will always return at least 1 node (the start node).
178178
*/
179179
export function $dfs(
180-
startingNode?: LexicalNode,
181-
endingNode?: LexicalNode,
180+
startNode?: LexicalNode,
181+
endNode?: LexicalNode,
182182
): Array<DFSNode> {
183-
const nodes = [];
184-
const start = (startingNode || $getRoot()).getLatest();
185-
const end =
186-
endingNode ||
187-
($isElementNode(start) ? start.getLastDescendant() || start : start);
188-
let node: LexicalNode | null = start;
189-
let depth = $getDepth(node);
190-
191-
while (node !== null && !node.is(end)) {
192-
nodes.push({depth, node});
193-
194-
if ($isElementNode(node) && node.getChildrenSize() > 0) {
195-
node = node.getFirstChild();
196-
depth++;
197-
} else {
198-
// Find immediate sibling or nearest parent sibling
199-
let sibling = null;
183+
return Array.from($dfsIterator(startNode, endNode));
184+
}
185+
186+
type DFSIterator = {
187+
next: () => IteratorResult<DFSNode, void>;
188+
[Symbol.iterator]: () => DFSIterator;
189+
};
200190

201-
while (sibling === null && node !== null) {
202-
sibling = node.getNextSibling();
191+
const iteratorDone: Readonly<{done: true; value: void}> = {
192+
done: true,
193+
value: undefined,
194+
};
195+
const iteratorNotDone: <T>(value: T) => Readonly<{done: false; value: T}> = <T>(
196+
value: T,
197+
) => ({done: false, value});
203198

204-
if (sibling === null) {
205-
node = node.getParent();
206-
depth--;
207-
} else {
208-
node = sibling;
199+
/**
200+
* $dfs iterator. Tree traversal is done on the fly as new values are requested with O(1) memory.
201+
* @param startNode - The node to start the search, if omitted, it will start at the root node.
202+
* @param endNode - The node to end the search, if omitted, it will find all descendants of the startingNode.
203+
* @returns An iterator, each yielded value is a DFSNode. It will always return at least 1 node (the start node).
204+
*/
205+
export function $dfsIterator(
206+
startNode?: LexicalNode,
207+
endNode?: LexicalNode,
208+
): DFSIterator {
209+
const start = (startNode || $getRoot()).getLatest();
210+
const startDepth = $getDepth(start);
211+
const end = endNode;
212+
let node: null | LexicalNode = start;
213+
let depth = startDepth;
214+
let isFirstNext = true;
215+
216+
const iterator: DFSIterator = {
217+
next(): IteratorResult<DFSNode, void> {
218+
if (node === null) {
219+
return iteratorDone;
220+
}
221+
if (isFirstNext) {
222+
isFirstNext = false;
223+
return iteratorNotDone({depth, node});
224+
}
225+
if (node === end) {
226+
return iteratorDone;
227+
}
228+
229+
if ($isElementNode(node) && node.getChildrenSize() > 0) {
230+
node = node.getFirstChild();
231+
depth++;
232+
} else {
233+
let depthDiff;
234+
[node, depthDiff] = $getNextSiblingOrParentSibling(node) || [null, 0];
235+
depth += depthDiff;
236+
if (end == null && depth <= startDepth) {
237+
node = null;
209238
}
210239
}
240+
241+
if (node === null) {
242+
return iteratorDone;
243+
}
244+
return iteratorNotDone({depth, node});
245+
},
246+
[Symbol.iterator](): DFSIterator {
247+
return iterator;
248+
},
249+
};
250+
return iterator;
251+
}
252+
253+
/**
254+
* Returns the Node sibling when this exists, otherwise the closest parent sibling. For example
255+
* R -> P -> T1, T2
256+
* -> P2
257+
* returns T2 for node T1, P2 for node T2, and null for node P2.
258+
* @param node LexicalNode.
259+
* @returns An array (tuple) containing the found Lexical node and the depth difference, or null, if this node doesn't exist.
260+
*/
261+
export function $getNextSiblingOrParentSibling(
262+
node: LexicalNode,
263+
): null | [LexicalNode, number] {
264+
let node_: null | LexicalNode = node;
265+
// Find immediate sibling or nearest parent sibling
266+
let sibling = null;
267+
let depthDiff = 0;
268+
269+
while (sibling === null && node_ !== null) {
270+
sibling = node_.getNextSibling();
271+
272+
if (sibling === null) {
273+
node_ = node_.getParent();
274+
depthDiff--;
275+
} else {
276+
node_ = sibling;
211277
}
212278
}
213279

214-
if (node !== null && node.is(end)) {
215-
nodes.push({depth, node});
280+
if (node_ === null) {
281+
return null;
216282
}
217-
218-
return nodes;
283+
return [node_, depthDiff];
219284
}
220285

221-
function $getDepth(node: LexicalNode): number {
286+
export function $getDepth(node: LexicalNode): number {
222287
let innerNode: LexicalNode | null = node;
223288
let depth = 0;
224289

0 commit comments

Comments
 (0)