Skip to content

Commit

Permalink
[lexical-utils] Fix: Modify $reverseDfs to be a right-to-left variant…
Browse files Browse the repository at this point in the history
… of $dfs (#7112)
  • Loading branch information
etrepum authored Jan 30, 2025
1 parent ce2bb45 commit 881c7fe
Show file tree
Hide file tree
Showing 2 changed files with 138 additions and 112 deletions.
157 changes: 86 additions & 71 deletions packages/lexical-utils/src/__tests__/unit/LexicalNodeHelpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import {
$getNodeByKey,
$getRoot,
$isElementNode,
ElementNode,
LexicalEditor,
LexicalNode,
NodeKey,
} from 'lexical';
import {
Expand All @@ -21,15 +23,24 @@ import {
invariant,
} from 'lexical/src/__tests__/utils';

import {$dfs, $getNextSiblingOrParentSibling, $reverseDfs} from '../..';
import {
$dfs,
$firstToLastIterator,
$getNextSiblingOrParentSibling,
$lastToFirstIterator,
$reverseDfs,
} from '../..';

interface DFSKeyPair {
depth: number;
node: NodeKey;
}

describe('LexicalNodeHelpers tests', () => {
initializeUnitTest((testEnv) => {
describe('dfs order', () => {
let expectedKeys: Array<{
depth: number;
node: NodeKey;
}> = [];
let expectedKeys: DFSKeyPair[];
let reverseExpectedKeys: DFSKeyPair[];

/**
* R
Expand All @@ -38,6 +49,8 @@ describe('LexicalNodeHelpers tests', () => {
* T1 T2 T3 T6
*
* DFS: R, P1, B1, T1, B2, T2, T3, P2, T4, T5, B3, T6
*
* Reverse DFS: R, P2, B3, T6, T5, T4, P1, B2, T3, T2, B1, T1
*/
beforeEach(async () => {
const editor: LexicalEditor = testEnv.editor;
Expand Down Expand Up @@ -72,56 +85,56 @@ describe('LexicalNodeHelpers tests', () => {

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(),
},
];
function* keysForNode(
depth: number,
node: LexicalNode,
$getChildren: (element: ElementNode) => Iterable<LexicalNode>,
): Iterable<DFSKeyPair> {
yield {depth, node: node.getKey()};
if ($isElementNode(node)) {
const childDepth = depth + 1;
for (const child of $getChildren(node)) {
yield* keysForNode(childDepth, child, $getChildren);
}
}
}

expectedKeys = [...keysForNode(0, root, $firstToLastIterator)];
reverseExpectedKeys = [...keysForNode(0, root, $lastToFirstIterator)];
// R, P1, B1, T1, B2, T2, T3, P2, T4, T5, B3, T6
expect(expectedKeys).toEqual(
[
root,
paragraph1,
block1,
text1,
block2,
text2,
text3,
paragraph2,
text4,
text5,
block3,
text6,
].map((n) => ({depth: n.getParentKeys().length, node: n.getKey()})),
);
// R, P2, B3, T6, T5, T4, P1, B2, T3, T2, B1, T1
expect(reverseExpectedKeys).toEqual(
[
root,
paragraph2,
block3,
text6,
text5,
text4,
paragraph1,
block2,
text3,
text2,
block1,
text1,
].map((n) => ({depth: n.getParentKeys().length, node: n.getKey()})),
);
});
});

Expand Down Expand Up @@ -150,12 +163,12 @@ describe('LexicalNodeHelpers tests', () => {
test('Reverse DFS node order', async () => {
const editor: LexicalEditor = testEnv.editor;
editor.getEditorState().read(() => {
const expectedNodes = expectedKeys
.map(({depth, node: nodeKey}) => ({
const expectedNodes = reverseExpectedKeys.map(
({depth, node: nodeKey}) => ({
depth,
node: $getNodeByKey(nodeKey)!.getLatest(),
}))
.reverse();
}),
);

const first = expectedNodes[0];
const second = expectedNodes[1];
Expand All @@ -167,9 +180,7 @@ describe('LexicalNodeHelpers tests', () => {
expectedNodes.slice(1, expectedNodes.length - 1),
);
expect($reverseDfs()).toEqual(expectedNodes);
expect($reverseDfs($getRoot().getLastDescendant()!)).toEqual(
expectedNodes,
);
expect($reverseDfs($getRoot())).toEqual(expectedNodes);
});
});
});
Expand Down Expand Up @@ -206,6 +217,8 @@ describe('LexicalNodeHelpers tests', () => {
const block3 = $createTestElementNode();
invariant($isElementNode(block1));

// this will (only) change the latest state of block1
// all other nodes will be the same version
block1.append(block3);

expect($dfs(root!)).toEqual([
Expand Down Expand Up @@ -265,28 +278,30 @@ describe('LexicalNodeHelpers tests', () => {
const block3 = $createTestElementNode();
invariant($isElementNode(block1));

// this will (only) change the latest state of block1
// all other nodes will be the same version
block1.append(block3);

expect($reverseDfs()).toEqual([
{
depth: 2,
node: block2!.getLatest(),
depth: 0,
node: root!.getLatest(),
},
{
depth: 3,
node: block3.getLatest(),
depth: 1,
node: paragraph!.getLatest(),
},
{
depth: 2,
node: block1.getLatest(),
node: block2!.getLatest(),
},
{
depth: 1,
node: paragraph!.getLatest(),
depth: 2,
node: block1.getLatest(),
},
{
depth: 0,
node: root!.getLatest(),
depth: 3,
node: block3.getLatest(),
},
]);
});
Expand Down
93 changes: 52 additions & 41 deletions packages/lexical-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ const iteratorNotDone: <T>(value: T) => Readonly<{done: false; value: T}> = <T>(
) => ({done: false, value});

/**
* $dfs iterator. Tree traversal is done on the fly as new values are requested with O(1) memory.
* $dfs iterator (left to right). 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 root node.
* @param endNode - The node to end the search, if omitted, it will find all descendants of the startingNode.
* @returns An iterator, each yielded value is a DFSNode. It will always return at least 1 node (the start node).
Expand Down Expand Up @@ -300,6 +300,39 @@ export function $getNextSiblingOrParentSibling(
return [node_, depthDiff];
}

/**
* Returns the Node's previous sibling when this exists, otherwise the closest parent previous sibling. For example
* R -> P -> T1, T2
* -> P2
* returns T1 for node T2, P for node P2, and null for node P
* @param node LexicalNode.
* @returns An array (tuple) containing the found Lexical node and the depth difference, or null, if this node doesn't exist.
*/
function $getPreviousSiblingOrParentSibling(
node: LexicalNode,
): null | [LexicalNode, number] {
let node_: null | LexicalNode = node;
// Find immediate sibling or nearest parent sibling
let sibling = null;
let depthDiff = 0;

while (sibling === null && node_ !== null) {
sibling = node_.getPreviousSibling();

if (sibling === null) {
node_ = node_.getParent();
depthDiff--;
} else {
node_ = sibling;
}
}

if (node_ === null) {
return null;
}
return [node_, depthDiff];
}

export function $getDepth(node: LexicalNode): number {
let innerNode: LexicalNode | null = node;
let depth = 0;
Expand Down Expand Up @@ -343,23 +376,18 @@ export function $getNextRightPreorderNode(
}

/**
* 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
* $dfs iterator (right to left). 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 root node.
* @param endNode - The node to end the search, if omitted, it will find all descendants of the startingNode.
* @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 start = (startNode || $getRoot()).getLatest();
const startDepth = $getDepth(start);
const end = endNode || $getRoot();
const end = endNode;
let node: null | LexicalNode = start;
let depth = startDepth;
let isFirstNext = true;
Expand All @@ -376,16 +404,22 @@ export function $reverseDfsIterator(
if (node === end) {
return iteratorDone;
}
if (node.getPreviousSibling()) {

if ($isElementNode(node) && node.getChildrenSize() > 0) {
node = node.getLastChild();
depth++;
} else {
let depthDiff;
[node, depthDiff] = $getPreviousSiblingsLastDescendantOrPreviousSibling(
node,
) || [null, 0];
[node, depthDiff] = $getPreviousSiblingOrParentSibling(node) || [
null,
0,
];
depth += depthDiff;
} else {
node = node.getParent();
depth--;
if (end == null && depth <= startDepth) {
node = null;
}
}

if (node === null) {
return iteratorDone;
}
Expand All @@ -398,29 +432,6 @@ export function $reverseDfsIterator(
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.
Expand Down

0 comments on commit 881c7fe

Please sign in to comment.