diff --git a/package-lock.json b/package-lock.json
index 1f127759b34..115454a238b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -69,8 +69,8 @@
"glob": "^10.4.1",
"google-closure-compiler": "^20220202.0.0",
"gzip-size": "^6.0.0",
- "hermes-parser": "^0.20.1",
- "hermes-transform": "^0.20.1",
+ "hermes-parser": "^0.26.0",
+ "hermes-transform": "^0.26.0",
"husky": "^7.0.1",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
@@ -79,7 +79,7 @@
"minimist": "^1.2.5",
"npm-run-all": "^4.1.5",
"prettier": "^2.3.2",
- "prettier-plugin-hermes-parser": "^0.20.1",
+ "prettier-plugin-hermes-parser": "^0.26.0",
"prettier-plugin-organize-attributes": "^0.0.5",
"prettier-plugin-tailwindcss": "^0.4.1",
"proper-lockfile": "^4.1.2",
@@ -19813,47 +19813,48 @@
}
},
"node_modules/hermes-eslint": {
- "version": "0.20.1",
- "resolved": "https://registry.npmjs.org/hermes-eslint/-/hermes-eslint-0.20.1.tgz",
- "integrity": "sha512-EhdvFV6RkPIJvbqN8oqFZO1oF4NlPWMjhMjCWkUJX1YL1MZMfkF7nSdx6RKTq6xK17yo+Bgv88L21xuH9GtRpw==",
+ "version": "0.26.0",
+ "resolved": "https://registry.npmjs.org/hermes-eslint/-/hermes-eslint-0.26.0.tgz",
+ "integrity": "sha512-SLMolASQQThPQ372LkA1z0GOSUtJ8LOsLolQnvskRiVfnoU+pVlR69cD75q3aEAncVGoAw+ZX+fFpMsBmVj0Gg==",
"dev": true,
"dependencies": {
"esrecurse": "^4.3.0",
- "hermes-estree": "0.20.1",
- "hermes-parser": "0.20.1"
+ "hermes-estree": "0.26.0",
+ "hermes-parser": "0.26.0"
}
},
"node_modules/hermes-estree": {
- "version": "0.20.1",
- "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.20.1.tgz",
- "integrity": "sha512-SQpZK4BzR48kuOg0v4pb3EAGNclzIlqMj3Opu/mu7bbAoFw6oig6cEt/RAi0zTFW/iW6Iz9X9ggGuZTAZ/yZHg==",
+ "version": "0.26.0",
+ "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.26.0.tgz",
+ "integrity": "sha512-If1T7lhfXnGlVLbnsmwerNB5cyJm2oIE8TN1UKEq6/OUX1nOGUhjXMpqAwZ1wkkn9Brda0VRyJEWOGT2GgVcAQ==",
"dev": true
},
"node_modules/hermes-parser": {
- "version": "0.20.1",
- "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.20.1.tgz",
- "integrity": "sha512-BL5P83cwCogI8D7rrDCgsFY0tdYUtmFP9XaXtl2IQjC+2Xo+4okjfXintlTxcIwl4qeGddEl28Z11kbVIw0aNA==",
+ "version": "0.26.0",
+ "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.26.0.tgz",
+ "integrity": "sha512-fWT40jJ/BtlzuyiiQS7lzNIlB5h6flVZoN8Jn8v5l987HL5dK9s+/4+py0FaBmeIEROC2zxt5qMLwXFTPLQ7BA==",
"dev": true,
"dependencies": {
- "hermes-estree": "0.20.1"
+ "hermes-estree": "0.26.0"
}
},
"node_modules/hermes-transform": {
- "version": "0.20.1",
- "resolved": "https://registry.npmjs.org/hermes-transform/-/hermes-transform-0.20.1.tgz",
- "integrity": "sha512-gpetyzAQvuLXVWIk8/I2A/ei/5+o8eT+QuSGd8FcWpXoYxVkYjVKLVNE9xKLsEkk2wQ1tXODY5OeOZoaz9jL7Q==",
+ "version": "0.26.0",
+ "resolved": "https://registry.npmjs.org/hermes-transform/-/hermes-transform-0.26.0.tgz",
+ "integrity": "sha512-R1YFKP/7KuU3e5orhgNZO9vTWzt3KQxK0qxz5majto8RPUNtCC2SFQ9m2lPk4Jwc4lHeAtoKp+2z/UPVN88fRQ==",
"dev": true,
"dependencies": {
"@babel/code-frame": "^7.16.0",
"esquery": "^1.4.0",
"flow-enums-runtime": "^0.0.6",
- "hermes-eslint": "0.20.1",
- "hermes-estree": "0.20.1",
- "hermes-parser": "0.20.1"
+ "hermes-eslint": "0.26.0",
+ "hermes-estree": "0.26.0",
+ "hermes-parser": "0.26.0",
+ "string-width": "4.2.3"
},
"peerDependencies": {
"prettier": "^3.0.0 || ^2.7.1",
- "prettier-plugin-hermes-parser": "0.20.1"
+ "prettier-plugin-hermes-parser": "0.26.0"
}
},
"node_modules/highlight.js": {
@@ -29930,14 +29931,14 @@
}
},
"node_modules/prettier-plugin-hermes-parser": {
- "version": "0.20.1",
- "resolved": "https://registry.npmjs.org/prettier-plugin-hermes-parser/-/prettier-plugin-hermes-parser-0.20.1.tgz",
- "integrity": "sha512-T6dfa1++ckTxd3MbLxS6sTv1T3yvTu1drahNt3g34hyCzSwYTKTByocLyhd1A9j9uCUlIPD+ogum7+i1h3+CEw==",
+ "version": "0.26.0",
+ "resolved": "https://registry.npmjs.org/prettier-plugin-hermes-parser/-/prettier-plugin-hermes-parser-0.26.0.tgz",
+ "integrity": "sha512-ajjlx/0OQ+lcZQEnKEUDU7Srr9vw1OoMO6qZDIYmck1u7j9STiCStqb3RG1vE7FripXYAhquuI+oYG8BCTNC4g==",
"dev": true,
"dependencies": {
- "hermes-estree": "0.20.1",
- "hermes-parser": "0.20.1",
- "prettier-plugin-hermes-parser": "0.20.1"
+ "hermes-estree": "0.26.0",
+ "hermes-parser": "0.26.0",
+ "prettier-plugin-hermes-parser": "0.26.0"
},
"peerDependencies": {
"prettier": "^3.0.0 || ^2.7.1"
@@ -52083,43 +52084,44 @@
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="
},
"hermes-eslint": {
- "version": "0.20.1",
- "resolved": "https://registry.npmjs.org/hermes-eslint/-/hermes-eslint-0.20.1.tgz",
- "integrity": "sha512-EhdvFV6RkPIJvbqN8oqFZO1oF4NlPWMjhMjCWkUJX1YL1MZMfkF7nSdx6RKTq6xK17yo+Bgv88L21xuH9GtRpw==",
+ "version": "0.26.0",
+ "resolved": "https://registry.npmjs.org/hermes-eslint/-/hermes-eslint-0.26.0.tgz",
+ "integrity": "sha512-SLMolASQQThPQ372LkA1z0GOSUtJ8LOsLolQnvskRiVfnoU+pVlR69cD75q3aEAncVGoAw+ZX+fFpMsBmVj0Gg==",
"dev": true,
"requires": {
"esrecurse": "^4.3.0",
- "hermes-estree": "0.20.1",
- "hermes-parser": "0.20.1"
+ "hermes-estree": "0.26.0",
+ "hermes-parser": "0.26.0"
}
},
"hermes-estree": {
- "version": "0.20.1",
- "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.20.1.tgz",
- "integrity": "sha512-SQpZK4BzR48kuOg0v4pb3EAGNclzIlqMj3Opu/mu7bbAoFw6oig6cEt/RAi0zTFW/iW6Iz9X9ggGuZTAZ/yZHg==",
+ "version": "0.26.0",
+ "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.26.0.tgz",
+ "integrity": "sha512-If1T7lhfXnGlVLbnsmwerNB5cyJm2oIE8TN1UKEq6/OUX1nOGUhjXMpqAwZ1wkkn9Brda0VRyJEWOGT2GgVcAQ==",
"dev": true
},
"hermes-parser": {
- "version": "0.20.1",
- "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.20.1.tgz",
- "integrity": "sha512-BL5P83cwCogI8D7rrDCgsFY0tdYUtmFP9XaXtl2IQjC+2Xo+4okjfXintlTxcIwl4qeGddEl28Z11kbVIw0aNA==",
+ "version": "0.26.0",
+ "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.26.0.tgz",
+ "integrity": "sha512-fWT40jJ/BtlzuyiiQS7lzNIlB5h6flVZoN8Jn8v5l987HL5dK9s+/4+py0FaBmeIEROC2zxt5qMLwXFTPLQ7BA==",
"dev": true,
"requires": {
- "hermes-estree": "0.20.1"
+ "hermes-estree": "0.26.0"
}
},
"hermes-transform": {
- "version": "0.20.1",
- "resolved": "https://registry.npmjs.org/hermes-transform/-/hermes-transform-0.20.1.tgz",
- "integrity": "sha512-gpetyzAQvuLXVWIk8/I2A/ei/5+o8eT+QuSGd8FcWpXoYxVkYjVKLVNE9xKLsEkk2wQ1tXODY5OeOZoaz9jL7Q==",
+ "version": "0.26.0",
+ "resolved": "https://registry.npmjs.org/hermes-transform/-/hermes-transform-0.26.0.tgz",
+ "integrity": "sha512-R1YFKP/7KuU3e5orhgNZO9vTWzt3KQxK0qxz5majto8RPUNtCC2SFQ9m2lPk4Jwc4lHeAtoKp+2z/UPVN88fRQ==",
"dev": true,
"requires": {
"@babel/code-frame": "^7.16.0",
"esquery": "^1.4.0",
"flow-enums-runtime": "^0.0.6",
- "hermes-eslint": "0.20.1",
- "hermes-estree": "0.20.1",
- "hermes-parser": "0.20.1"
+ "hermes-eslint": "0.26.0",
+ "hermes-estree": "0.26.0",
+ "hermes-parser": "0.26.0",
+ "string-width": "4.2.3"
}
},
"highlight.js": {
@@ -58706,14 +58708,14 @@
"dev": true
},
"prettier-plugin-hermes-parser": {
- "version": "0.20.1",
- "resolved": "https://registry.npmjs.org/prettier-plugin-hermes-parser/-/prettier-plugin-hermes-parser-0.20.1.tgz",
- "integrity": "sha512-T6dfa1++ckTxd3MbLxS6sTv1T3yvTu1drahNt3g34hyCzSwYTKTByocLyhd1A9j9uCUlIPD+ogum7+i1h3+CEw==",
+ "version": "0.26.0",
+ "resolved": "https://registry.npmjs.org/prettier-plugin-hermes-parser/-/prettier-plugin-hermes-parser-0.26.0.tgz",
+ "integrity": "sha512-ajjlx/0OQ+lcZQEnKEUDU7Srr9vw1OoMO6qZDIYmck1u7j9STiCStqb3RG1vE7FripXYAhquuI+oYG8BCTNC4g==",
"dev": true,
"requires": {
- "hermes-estree": "0.20.1",
- "hermes-parser": "0.20.1",
- "prettier-plugin-hermes-parser": "0.20.1"
+ "hermes-estree": "0.26.0",
+ "hermes-parser": "0.26.0",
+ "prettier-plugin-hermes-parser": "0.26.0"
}
},
"prettier-plugin-organize-attributes": {
diff --git a/package.json b/package.json
index 535b3e9f0b5..ed690ba2cbb 100644
--- a/package.json
+++ b/package.json
@@ -163,8 +163,8 @@
"glob": "^10.4.1",
"google-closure-compiler": "^20220202.0.0",
"gzip-size": "^6.0.0",
- "hermes-parser": "^0.20.1",
- "hermes-transform": "^0.20.1",
+ "hermes-parser": "^0.26.0",
+ "hermes-transform": "^0.26.0",
"husky": "^7.0.1",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
@@ -173,7 +173,7 @@
"minimist": "^1.2.5",
"npm-run-all": "^4.1.5",
"prettier": "^2.3.2",
- "prettier-plugin-hermes-parser": "^0.20.1",
+ "prettier-plugin-hermes-parser": "^0.26.0",
"prettier-plugin-organize-attributes": "^0.0.5",
"prettier-plugin-tailwindcss": "^0.4.1",
"proper-lockfile": "^4.1.2",
diff --git a/packages/lexical-playground/__tests__/e2e/Links.spec.mjs b/packages/lexical-playground/__tests__/e2e/Links.spec.mjs
index ece9a617486..98fa7139d48 100644
--- a/packages/lexical-playground/__tests__/e2e/Links.spec.mjs
+++ b/packages/lexical-playground/__tests__/e2e/Links.spec.mjs
@@ -1714,7 +1714,9 @@ test.describe.parallel('Links', () => {
);
await assertSelection(page, {
anchorOffset: 0,
- anchorPath: [0, 1],
+ // Previous to #7046 NodeCaret the selection anchor would've been
+ // inside the tag but now it's normalized to the text
+ anchorPath: [0, 1, 0, 0],
focusOffset: 5,
focusPath: [0, 1, 0, 0],
});
@@ -1739,21 +1741,14 @@ test.describe.parallel('Links', () => {
`,
);
- if (browserName === 'webkit') {
- await assertSelection(page, {
- anchorOffset: 0,
- anchorPath: [0, 1, 0, 0],
- focusOffset: 5,
- focusPath: [0, 1, 0, 0],
- });
- } else {
- await assertSelection(page, {
- anchorOffset: 0,
- anchorPath: [0, 1, 0, 0],
- focusOffset: 5,
- focusPath: [0, 1, 0, 0],
- });
- }
+ // Previous to #7046 NodeCaret the selection anchor would've been
+ // inside the tag but now it's normalized to the text
+ await assertSelection(page, {
+ anchorOffset: 0,
+ anchorPath: [0, 1, 0, 0],
+ focusOffset: 5,
+ focusPath: [0, 1, 0, 0],
+ });
// unlink
await click(page, '.link');
@@ -1819,21 +1814,12 @@ test.describe.parallel('Links', () => {
`,
);
- if (browserName === 'chromium') {
- await assertSelection(page, {
- anchorOffset: 5,
- anchorPath: [0, 1, 0, 0],
- focusOffset: 0,
- focusPath: [0, 1],
- });
- } else {
- await assertSelection(page, {
- anchorOffset: 5,
- anchorPath: [0, 1, 0, 0],
- focusOffset: 0,
- focusPath: [0, 1],
- });
- }
+ await assertSelection(page, {
+ anchorOffset: 5,
+ anchorPath: [0, 1, 0, 0],
+ focusOffset: 0,
+ focusPath: [0, 1, 0, 0],
+ });
await setURL(page, 'facebook.com');
diff --git a/packages/lexical-selection/src/__tests__/unit/LexicalSelection.test.tsx b/packages/lexical-selection/src/__tests__/unit/LexicalSelection.test.tsx
index 121d13fe3ec..b65d09f0a10 100644
--- a/packages/lexical-selection/src/__tests__/unit/LexicalSelection.test.tsx
+++ b/packages/lexical-selection/src/__tests__/unit/LexicalSelection.test.tsx
@@ -1290,7 +1290,8 @@ describe('LexicalSelection tests', () => {
paragraph.append(elementNode);
elementNode.append(text);
- const selectedNodes = $getSelection()!.getNodes();
+ const selection = $getSelection()!;
+ const selectedNodes = selection.getNodes();
expect(selectedNodes.length).toBe(1);
expect(selectedNodes[0].getKey()).toBe(text.getKey());
@@ -2756,7 +2757,11 @@ describe('LexicalSelection tests', () => {
expect(rootChildren[1].__type).toBe('heading');
expect(rootChildren.length).toBe(2);
const sel = $getSelection()!;
- expect(sel.getNodes().length).toBe(2);
+ expect(sel).toMatchObject({
+ anchor: {key: rootChildren[0].__key, offset: 0, type: 'element'},
+ focus: {key: rootChildren[1].__key, offset: 0, type: 'element'},
+ });
+ expect(sel.getNodes()).toEqual(rootChildren);
});
});
@@ -3008,15 +3013,17 @@ describe('LexicalSelection tests', () => {
const root = $getRoot();
const ul1 = $createListNode('bullet');
const text1 = $createTextNode('1');
- const li1 = $createListItemNode().append(text1);
+ const li1 = $createListItemNode();
const li1_wrapper = $createListItemNode();
const ul2 = $createListNode('bullet');
const text1_1 = $createTextNode('1.1');
- const li1_1 = $createListItemNode().append(text1_1);
- ul1.append(li1, li1_wrapper);
- li1_wrapper.append(ul2);
- ul2.append(li1_1);
- root.append(ul1);
+ const li1_1 = $createListItemNode();
+ root.append(
+ ul1.append(
+ li1.append(text1),
+ li1_wrapper.append(ul2.append(li1_1.append(text1_1))),
+ ),
+ );
const selection = $createRangeSelection();
$setSelection(selection);
@@ -3040,7 +3047,7 @@ describe('LexicalSelection tests', () => {
);
});
- test('Nested list with listItem twice indented from his father', async () => {
+ test('Nested list with listItem twice indented from its parent', async () => {
const testEditor = createTestEditor();
const element = document.createElement('div');
testEditor.setRootElement(element);
@@ -3051,11 +3058,10 @@ describe('LexicalSelection tests', () => {
const li1_wrapper = $createListItemNode();
const ul2 = $createListNode('bullet');
const text1_1 = $createTextNode('1.1');
- const li1_1 = $createListItemNode().append(text1_1);
- ul1.append(li1_wrapper);
- li1_wrapper.append(ul2);
- ul2.append(li1_1);
- root.append(ul1);
+ const li1_1 = $createListItemNode();
+ root.append(
+ ul1.append(li1_wrapper.append(ul2.append(li1_1.append(text1_1)))),
+ );
const selection = $createRangeSelection();
$setSelection(selection);
diff --git a/packages/lexical-selection/src/__tests__/unit/LexicalSelectionHelpers.test.ts b/packages/lexical-selection/src/__tests__/unit/LexicalSelectionHelpers.test.ts
index 01390ed7180..1f5c2f16568 100644
--- a/packages/lexical-selection/src/__tests__/unit/LexicalSelectionHelpers.test.ts
+++ b/packages/lexical-selection/src/__tests__/unit/LexicalSelectionHelpers.test.ts
@@ -3155,11 +3155,11 @@ describe('$patchStyleText', () => {
type: 'text',
});
- const selection = $getSelection();
+ const selection = $getSelection()!;
- $patchStyleText(selection!, {'font-size': '11px'});
+ $patchStyleText(selection, {'font-size': '11px'});
- const [newAnchor, newFocus] = selection!.getStartEndPoints()!;
+ const [newAnchor, newFocus] = selection.getStartEndPoints()!;
const newAnchorNode: LexicalNode = newAnchor.getNode();
expect(newAnchorNode.getTextContent()).toBe('sec');
diff --git a/packages/lexical-selection/src/index.ts b/packages/lexical-selection/src/index.ts
index 2fd6becf09b..e5bad689aba 100644
--- a/packages/lexical-selection/src/index.ts
+++ b/packages/lexical-selection/src/index.ts
@@ -6,15 +6,19 @@
*
*/
-import {
+import {$trimTextContentFromAnchor} from './lexical-node';
+
+export {
$addNodeStyle,
+ $ensureForwardRangeSelection,
$forEachSelectedTextNode,
$isAtNodeEnd,
$patchStyleText,
$sliceSelectedTextNodeContent,
$trimTextContentFromAnchor,
} from './lexical-node';
-import {
+export {
+ $copyBlockFormatIndent,
$getSelectionStyleValueForProperty,
$isParentElementRTL,
$moveCaretSelection,
@@ -23,42 +27,15 @@ import {
$shouldOverrideDefaultCharacterSelection,
$wrapNodes,
} from './range-selection';
-import {
+export {
createDOMRange,
createRectsFromDOMRange,
getCSSFromStyleObject,
getStyleObjectFromCSS,
} from './utils';
-
+/** @deprecated renamed to {@link $trimTextContentFromAnchor} by @lexical/eslint-plugin rules-of-lexical */
+export const trimTextContentFromAnchor = $trimTextContentFromAnchor;
export {
/** @deprecated moved to the lexical package */ $cloneWithProperties,
/** @deprecated moved to the lexical package */ $selectAll,
} from 'lexical';
-
-export {
- $addNodeStyle,
- $forEachSelectedTextNode,
- $isAtNodeEnd,
- $patchStyleText,
- $sliceSelectedTextNodeContent,
- $trimTextContentFromAnchor,
-};
-/** @deprecated renamed to {@link $trimTextContentFromAnchor} by @lexical/eslint-plugin rules-of-lexical */
-export const trimTextContentFromAnchor = $trimTextContentFromAnchor;
-
-export {
- $getSelectionStyleValueForProperty,
- $isParentElementRTL,
- $moveCaretSelection,
- $moveCharacter,
- $setBlocksType,
- $shouldOverrideDefaultCharacterSelection,
- $wrapNodes,
-};
-
-export {
- createDOMRange,
- createRectsFromDOMRange,
- getCSSFromStyleObject,
- getStyleObjectFromCSS,
-};
diff --git a/packages/lexical-selection/src/lexical-node.ts b/packages/lexical-selection/src/lexical-node.ts
index 22074d1fd6e..81d3c90ca93 100644
--- a/packages/lexical-selection/src/lexical-node.ts
+++ b/packages/lexical-selection/src/lexical-node.ts
@@ -6,6 +6,7 @@
*
*/
import {
+ $caretRangeFromSelection,
$createTextNode,
$getCharacterOffsets,
$getNodeByKey,
@@ -19,6 +20,7 @@ import {
BaseSelection,
LexicalEditor,
LexicalNode,
+ NodeKey,
Point,
RangeSelection,
TextNode,
@@ -240,7 +242,15 @@ export function $addNodeStyle(node: TextNode): void {
CSS_TO_STYLES.set(CSSText, styles);
}
-function $patchStyle(
+/**
+ * Applies the provided styles to the given TextNodes or collapsed RangeSelection.
+ * Will update partially selected TextNodes by splitting the TextNode and applying
+ * the styles to the appropriate one.
+ *
+ * @param target - The TextNode or collapsed RangeSelection to apply the styles to
+ * @param patch - The patch to apply, which can include multiple styles. \\{CSSProperty: value\\} . Can also accept a function that returns the new property value.
+ */
+export function $patchStyle(
target: TextNode | RangeSelection,
patch: Record<
string,
@@ -249,8 +259,12 @@ function $patchStyle(
| ((currentStyleValue: string | null, _target: typeof target) => string)
>,
): void {
+ invariant(
+ target instanceof TextNode || target.isCollapsed(),
+ '$patchStyle must only be called with a TextNode or collapsed RangeSelection',
+ );
const prevStyles = getStyleObjectFromCSS(
- 'getStyle' in target ? target.getStyle() : target.style,
+ target instanceof TextNode ? target.getStyle() : target.style,
);
const newStyles = Object.entries(patch).reduce>(
(styles, [key, value]) => {
@@ -263,7 +277,7 @@ function $patchStyle(
}
return styles;
},
- {...prevStyles} || {},
+ {...prevStyles},
);
const newCSSText = getCSSFromStyleObject(newStyles);
target.setStyle(newCSSText);
@@ -289,147 +303,95 @@ export function $patchStyleText(
) => string)
>,
): void {
- if (selection.isCollapsed() && $isRangeSelection(selection)) {
- $patchStyle(selection, patch);
- } else {
- $forEachSelectedTextNode((textNode) => {
- $patchStyle(textNode, patch);
- });
+ if ($isRangeSelection(selection) && selection.isCollapsed()) {
+ return $patchStyle(selection, patch);
}
+ $forEachSelectedTextNode((textNode) => {
+ $patchStyle(textNode, patch);
+ });
}
export function $forEachSelectedTextNode(
fn: (textNode: TextNode) => void,
): void {
const selection = $getSelection();
- if (!$isRangeSelection(selection)) {
+ if (!selection) {
return;
}
- const selectedNodes = selection.getNodes();
- const selectedNodesLength = selectedNodes.length;
- const {anchor, focus} = selection;
- const lastIndex = selectedNodesLength - 1;
- let firstNode = selectedNodes[0];
- let lastNode = selectedNodes[lastIndex];
+ const slicedTextNodes = new Map<
+ NodeKey,
+ [startIndex: number, endIndex: number]
+ >();
+ const getSliceIndices = (
+ node: TextNode,
+ ): [startIndex: number, endIndex: number] =>
+ slicedTextNodes.get(node.getKey()) || [0, node.getTextContentSize()];
- const firstNodeText = firstNode.getTextContent();
- const firstNodeTextLength = firstNodeText.length;
- const focusOffset = focus.offset;
- let anchorOffset = anchor.offset;
- const isBefore = anchor.isBefore(focus);
- let startOffset = isBefore ? anchorOffset : focusOffset;
- let endOffset = isBefore ? focusOffset : anchorOffset;
- const startType = isBefore ? anchor.type : focus.type;
- const endType = isBefore ? focus.type : anchor.type;
- const endKey = isBefore ? focus.key : anchor.key;
-
- // This is the case where the user only selected the very end of the
- // first node so we don't want to include it in the formatting change.
- if ($isTextNode(firstNode) && startOffset === firstNodeTextLength) {
- const nextSibling = firstNode.getNextSibling();
-
- if ($isTextNode(nextSibling)) {
- // we basically make the second node the firstNode, changing offsets accordingly
- anchorOffset = 0;
- startOffset = 0;
- firstNode = nextSibling;
+ if ($isRangeSelection(selection)) {
+ for (const slice of $caretRangeFromSelection(selection).getTextSlices()) {
+ if (slice) {
+ slicedTextNodes.set(
+ slice.caret.origin.getKey(),
+ slice.getSliceIndices(),
+ );
+ }
}
}
- // This is the case where we only selected a single node
- if (selectedNodes.length === 1) {
- if ($isTextNode(firstNode) && firstNode.canHaveFormat()) {
- startOffset =
- startType === 'element'
- ? 0
- : anchorOffset > focusOffset
- ? focusOffset
- : anchorOffset;
- endOffset =
- endType === 'element'
- ? firstNodeTextLength
- : anchorOffset > focusOffset
- ? anchorOffset
- : focusOffset;
-
- // No actual text is selected, so do nothing.
- if (startOffset === endOffset) {
- return;
- }
+ const selectedNodes = selection.getNodes();
+ for (const selectedNode of selectedNodes) {
+ if (!($isTextNode(selectedNode) && selectedNode.canHaveFormat())) {
+ continue;
+ }
+ const [startOffset, endOffset] = getSliceIndices(selectedNode);
+ // No actual text is selected, so do nothing.
+ if (endOffset === startOffset) {
+ continue;
+ }
- // The entire node is selected or a token/segment, so just format it
- if (
- $isTokenOrSegmented(firstNode) ||
- (startOffset === 0 && endOffset === firstNodeTextLength)
- ) {
- fn(firstNode);
- firstNode.select(startOffset, endOffset);
- } else {
- // The node is partially selected, so split it into two nodes
- // and style the selected one.
- const splitNodes = firstNode.splitText(startOffset, endOffset);
- const replacement = startOffset === 0 ? splitNodes[0] : splitNodes[1];
- fn(replacement);
- replacement.select(0, endOffset - startOffset);
- }
- } // multiple nodes selected.
- } else {
+ // The entire node is selected or a token/segment, so just format it
if (
- $isTextNode(firstNode) &&
- startOffset < firstNode.getTextContentSize() &&
- firstNode.canHaveFormat()
+ $isTokenOrSegmented(selectedNode) ||
+ (startOffset === 0 && endOffset === selectedNode.getTextContentSize())
) {
- if (startOffset !== 0 && !$isTokenOrSegmented(firstNode)) {
- // the entire first node isn't selected and it isn't a token or segmented, so split it
- firstNode = firstNode.splitText(startOffset)[1];
- startOffset = 0;
- if (isBefore) {
- anchor.set(firstNode.getKey(), startOffset, 'text');
- } else {
- focus.set(firstNode.getKey(), startOffset, 'text');
- }
- }
-
- fn(firstNode as TextNode);
- }
-
- if ($isTextNode(lastNode) && lastNode.canHaveFormat()) {
- const lastNodeText = lastNode.getTextContent();
- const lastNodeTextLength = lastNodeText.length;
-
- // The last node might not actually be the end node
- //
- // If not, assume the last node is fully-selected unless the end offset is
- // zero.
- if (lastNode.__key !== endKey && endOffset !== 0) {
- endOffset = lastNodeTextLength;
- }
-
- // if the entire last node isn't selected and it isn't a token or segmented, split it
- if (endOffset !== lastNodeTextLength && !$isTokenOrSegmented(lastNode)) {
- [lastNode] = lastNode.splitText(endOffset);
- }
-
- if (endOffset !== 0 || endType === 'element') {
- fn(lastNode as TextNode);
- }
+ fn(selectedNode);
+ } else {
+ // The node is partially selected, so split it into two or three nodes
+ // and style the selected one.
+ const splitNodes = selectedNode.splitText(startOffset, endOffset);
+ const replacement = splitNodes[startOffset === 0 ? 0 : 1];
+ fn(replacement);
}
+ }
+ // Prior to NodeCaret #7046 this would have been a side-effect
+ // so we do this for test compatibility.
+ // TODO: we may want to consider simplifying by removing this
+ if (
+ $isRangeSelection(selection) &&
+ selection.anchor.type === 'text' &&
+ selection.focus.type === 'text' &&
+ selection.anchor.key === selection.focus.key
+ ) {
+ $ensureForwardRangeSelection(selection);
+ }
+}
- // style all the text nodes in between
- for (let i = 1; i < lastIndex; i++) {
- const selectedNode = selectedNodes[i];
- const selectedNodeKey = selectedNode.getKey();
-
- if (
- $isTextNode(selectedNode) &&
- selectedNode.canHaveFormat() &&
- selectedNodeKey !== firstNode.getKey() &&
- selectedNodeKey !== lastNode.getKey() &&
- !selectedNode.isToken()
- ) {
- fn(selectedNode as TextNode);
- }
- }
+/**
+ * Ensure that the given RangeSelection is not backwards. If it
+ * is backwards, then the anchor and focus points will be swapped
+ * in-place. Ensuring that the selection is a writable RangeSelection
+ * is the responsibility of the caller (e.g. in a read-only context
+ * you will want to clone $getSelection() before using this).
+ *
+ * @param selection a writable RangeSelection
+ */
+export function $ensureForwardRangeSelection(selection: RangeSelection): void {
+ if (selection.isBackward()) {
+ const {anchor, focus} = selection;
+ // stash for the in-place swap
+ const {key, offset, type} = anchor;
+ anchor.set(focus.key, focus.offset, focus.type);
+ focus.set(key, offset, type);
}
}
diff --git a/packages/lexical-selection/src/range-selection.ts b/packages/lexical-selection/src/range-selection.ts
index 6dc33ca750a..9d6189872e3 100644
--- a/packages/lexical-selection/src/range-selection.ts
+++ b/packages/lexical-selection/src/range-selection.ts
@@ -18,9 +18,9 @@ import type {
import {TableSelection} from '@lexical/table';
import {
+ $createRangeSelection,
$getAdjacentNode,
$getPreviousSelection,
- $getRoot,
$getSelection,
$hasAncestor,
$isDecoratorNode,
@@ -37,61 +37,77 @@ import invariant from 'shared/invariant';
import {getStyleObjectFromCSS} from './utils';
+export function $copyBlockFormatIndent(
+ srcNode: ElementNode,
+ destNode: ElementNode,
+): void {
+ const format = srcNode.getFormatType();
+ const indent = srcNode.getIndent();
+ if (format !== destNode.getFormatType()) {
+ destNode.setFormat(format);
+ }
+ if (indent !== destNode.getIndent()) {
+ destNode.setIndent(indent);
+ }
+}
+
/**
* Converts all nodes in the selection that are of one block type to another.
* @param selection - The selected blocks to be converted.
- * @param createElement - The function that creates the node. eg. $createParagraphNode.
+ * @param $createElement - The function that creates the node. eg. $createParagraphNode.
+ * @param $afterCreateElement - The function that updates the new node based on the previous one ($copyBlockFormatIndent by default)
*/
-export function $setBlocksType(
+export function $setBlocksType(
selection: BaseSelection | null,
- createElement: () => ElementNode,
+ $createElement: () => T,
+ $afterCreateElement: (
+ prevNodeSrc: ElementNode,
+ newNodeDest: T,
+ ) => void = $copyBlockFormatIndent,
): void {
if (selection === null) {
return;
}
+ // Selections tend to not include their containing blocks so we effectively
+ // expand it here
const anchorAndFocus = selection.getStartEndPoints();
- const anchor = anchorAndFocus ? anchorAndFocus[0] : null;
- const isCollapsedSelection =
- selection.is($getSelection()) && selection.isCollapsed();
-
- if (anchor !== null && anchor.key === 'root') {
- const element = createElement();
- const root = $getRoot();
- const firstChild = root.getFirstChild();
-
- if (firstChild) {
- firstChild.replace(element, true);
- } else {
- root.append(element);
+ const blockMap = new Map();
+ let newSelection: RangeSelection | null = null;
+ if (anchorAndFocus) {
+ const [anchor, focus] = anchorAndFocus;
+ newSelection = $createRangeSelection();
+ newSelection.anchor.set(anchor.key, anchor.offset, anchor.type);
+ newSelection.focus.set(focus.key, focus.offset, focus.type);
+ const anchorBlock = $getAncestor(anchor.getNode(), INTERNAL_$isBlock);
+ const focusBlock = $getAncestor(focus.getNode(), INTERNAL_$isBlock);
+ if ($isElementNode(anchorBlock)) {
+ blockMap.set(anchorBlock.getKey(), anchorBlock);
}
- if (isCollapsedSelection) {
- element.select();
+ if ($isElementNode(focusBlock)) {
+ blockMap.set(focusBlock.getKey(), focusBlock);
}
- return;
}
-
- const nodes = selection
- .getNodes()
- .filter(INTERNAL_$isBlock)
- .filter($isElementNode);
- const firstSelectedBlock = anchor
- ? $getAncestor(anchor.getNode(), INTERNAL_$isBlock)
- : null;
- if (
- $isElementNode(firstSelectedBlock) &&
- !nodes.find((node) => node.is(firstSelectedBlock))
- ) {
- nodes.push(firstSelectedBlock);
+ for (const node of selection.getNodes()) {
+ if ($isElementNode(node) && INTERNAL_$isBlock(node)) {
+ blockMap.set(node.getKey(), node);
+ }
}
- for (const node of nodes) {
- const targetElement = createElement();
- targetElement.setFormat(node.getFormatType());
- targetElement.setIndent(node.getIndent());
- node.replace(targetElement, true);
- if (node.is(firstSelectedBlock) && isCollapsedSelection) {
- targetElement.select();
+ for (const [key, prevNode] of blockMap) {
+ const element = $createElement();
+ $afterCreateElement(prevNode, element);
+ prevNode.replace(element, true);
+ if (newSelection) {
+ if (key === newSelection.anchor.key) {
+ newSelection.anchor.key = element.getKey();
+ }
+ if (key === newSelection.focus.key) {
+ newSelection.focus.key = element.getKey();
+ }
}
}
+ if (newSelection && selection.is($getSelection())) {
+ $setSelection(newSelection);
+ }
}
function isPointAttached(point: Point): boolean {
diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableSelection.test.tsx b/packages/lexical-table/src/__tests__/unit/LexicalTableSelection.test.tsx
index dace11e3e0a..ec0f7f0557f 100644
--- a/packages/lexical-table/src/__tests__/unit/LexicalTableSelection.test.tsx
+++ b/packages/lexical-table/src/__tests__/unit/LexicalTableSelection.test.tsx
@@ -6,173 +6,85 @@
*
*/
-import {$createTableSelection} from '@lexical/table';
+import {$patchStyleText} from '@lexical/selection';
+import {
+ $computeTableMapSkipCellCheck,
+ $createTableCellNode,
+ $createTableNode,
+ $createTableRowNode,
+ $createTableSelectionFrom,
+ $isTableSelection,
+ TableMapType,
+ TableNode,
+ TableSelection,
+} from '@lexical/table';
import {
$createParagraphNode,
$createTextNode,
$getRoot,
+ $getSelection,
$setSelection,
- EditorState,
- type LexicalEditor,
- ParagraphNode,
- RootNode,
- TextNode,
} from 'lexical';
-import {createTestEditor} from 'lexical/src/__tests__/utils';
-import {createRef, useEffect, useMemo} from 'react';
-import {createRoot, Root} from 'react-dom/client';
-import * as ReactTestUtils from 'shared/react-test-utils';
+import {initializeUnitTest} from 'lexical/src/__tests__/utils';
describe('table selection', () => {
- let originalText: TextNode;
- let parsedParagraph: ParagraphNode;
- let parsedRoot: RootNode;
- let parsedText: TextNode;
- let paragraphKey: string;
- let textKey: string;
- let parsedEditorState: EditorState;
- let reactRoot: Root;
- let container: HTMLDivElement | null = null;
- let editor: LexicalEditor | null = null;
-
- beforeEach(() => {
- container = document.createElement('div');
- reactRoot = createRoot(container);
- document.body.appendChild(container);
- });
-
- function useLexicalEditor(
- rootElementRef: React.RefObject,
- onError?: () => void,
- ) {
- const editorInHook = useMemo(
- () =>
- createTestEditor({
- nodes: [],
- onError: onError || jest.fn(),
- theme: {
- text: {
- bold: 'editor-text-bold',
- italic: 'editor-text-italic',
- underline: 'editor-text-underline',
- },
- },
- }),
- [onError],
- );
-
- useEffect(() => {
- const rootElement = rootElementRef.current;
-
- editorInHook.setRootElement(rootElement);
- }, [rootElementRef, editorInHook]);
-
- return editorInHook;
- }
-
- function init(onError?: () => void) {
- const ref = createRef();
-
- function TestBase() {
- editor = useLexicalEditor(ref, onError);
-
- return ;
- }
-
- ReactTestUtils.act(() => {
- reactRoot.render();
- });
- }
-
- async function update(fn: () => void) {
- editor!.update(fn);
-
- return Promise.resolve().then();
- }
-
- beforeEach(async () => {
- init();
-
- await update(() => {
- const paragraph = $createParagraphNode();
- originalText = $createTextNode('Hello world');
- const selection = $createTableSelection();
- selection.set(
- originalText.getKey(),
- originalText.getKey(),
- originalText.getKey(),
- );
- $setSelection(selection);
- paragraph.append(originalText);
- $getRoot().append(paragraph);
+ initializeUnitTest((testEnv) => {
+ let tableNode: TableNode;
+ let tableMap: TableMapType;
+ let tableSelection: TableSelection;
+
+ beforeEach(() => {
+ testEnv.editor.update(() => {
+ tableNode = $createTableNode();
+ $getRoot()
+ .clear()
+ .append(
+ tableNode.append(
+ ...Array.from({length: 2}, (_0, row) =>
+ $createTableRowNode().append(
+ ...Array.from({length: 2}, (_1, col) =>
+ $createTableCellNode().append(
+ $createParagraphNode().append(
+ $createTextNode(`${col},${row}`),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ tableMap = $computeTableMapSkipCellCheck(tableNode, null, null)[0];
+ tableSelection = $createTableSelectionFrom(
+ tableNode,
+ tableMap.at(0)!.at(0)!.cell,
+ tableMap.at(-1)!.at(-1)!.cell,
+ );
+ $setSelection(tableSelection);
+ });
});
- const stringifiedEditorState = JSON.stringify(
- editor!.getEditorState().toJSON(),
- );
-
- parsedEditorState = editor!.parseEditorState(stringifiedEditorState);
- parsedEditorState.read(() => {
- parsedRoot = $getRoot();
- parsedParagraph = parsedRoot.getFirstChild()!;
- paragraphKey = parsedParagraph.getKey();
- parsedText = parsedParagraph.getFirstChild()!;
- textKey = parsedText.getKey();
- });
- });
-
- it('Parses the nodes of a stringified editor state', async () => {
- expect(parsedRoot).toEqual({
- __cachedText: null,
- __dir: 'ltr',
- __first: paragraphKey,
- __format: 0,
- __indent: 0,
- __key: 'root',
- __last: paragraphKey,
- __next: null,
- __parent: null,
- __prev: null,
- __size: 1,
- __style: '',
- __textFormat: 0,
- __textStyle: '',
- __type: 'root',
- });
- expect(parsedParagraph).toEqual({
- __dir: 'ltr',
- __first: textKey,
- __format: 0,
- __indent: 0,
- __key: paragraphKey,
- __last: textKey,
- __next: null,
- __parent: 'root',
- __prev: null,
- __size: 1,
- __style: '',
- __textFormat: 0,
- __textStyle: '',
- __type: 'paragraph',
- });
- expect(parsedText).toEqual({
- __detail: 0,
- __format: 0,
- __key: textKey,
- __mode: 0,
- __next: null,
- __parent: paragraphKey,
- __prev: null,
- __style: '',
- __text: 'Hello world',
- __type: 'text',
+ describe('regression #7076', () => {
+ test('$patchStyleText works on a TableSelection', () => {
+ testEnv.editor.update(
+ () => {
+ const length = 4;
+ expect(
+ $getRoot()
+ .getAllTextNodes()
+ .map((node) => node.getStyle()),
+ ).toEqual(Array.from({length}, () => ''));
+ expect($isTableSelection($getSelection())).toBe(true);
+ $patchStyleText($getSelection()!, {color: 'red'});
+ expect($isTableSelection($getSelection())).toBe(true);
+ expect(
+ $getRoot()
+ .getAllTextNodes()
+ .map((node) => node.getStyle()),
+ ).toEqual(Array.from({length}, () => 'color: red;'));
+ },
+ {discrete: true},
+ );
+ });
});
});
-
- it('Parses the text content of the editor state', async () => {
- expect(parsedEditorState.read(() => $getRoot().__cachedText)).toBe(null);
- expect(parsedEditorState.read(() => $getRoot().getTextContent())).toBe(
- 'Hello world',
- );
- });
});
diff --git a/packages/lexical-table/src/index.ts b/packages/lexical-table/src/index.ts
index c4fe6ace096..83d249d5400 100644
--- a/packages/lexical-table/src/index.ts
+++ b/packages/lexical-table/src/index.ts
@@ -48,6 +48,7 @@ export type {
} from './LexicalTableSelection';
export {
$createTableSelection,
+ $createTableSelectionFrom,
$isTableSelection,
} from './LexicalTableSelection';
export type {HTMLTableElementWithWithTableSelectionState} from './LexicalTableSelectionHelpers';
diff --git a/packages/lexical-utils/flow/LexicalUtils.js.flow b/packages/lexical-utils/flow/LexicalUtils.js.flow
index 958dd8acfa7..c6b0dd95a36 100644
--- a/packages/lexical-utils/flow/LexicalUtils.js.flow
+++ b/packages/lexical-utils/flow/LexicalUtils.js.flow
@@ -47,7 +47,7 @@ declare export function $dfsIterator(
declare export function $getNextSiblingOrParentSibling(
node: LexicalNode,
): null | [LexicalNode, number];
-declare export function $getDepth(node: LexicalNode): number;
+declare export function $getDepth(node: null | LexicalNode): number;
declare export function $getNextRightPreorderNode(
startingNode: LexicalNode,
): LexicalNode | null;
diff --git a/packages/lexical-utils/src/index.ts b/packages/lexical-utils/src/index.ts
index b850a6052d5..0ffc944d9cc 100644
--- a/packages/lexical-utils/src/index.ts
+++ b/packages/lexical-utils/src/index.ts
@@ -9,21 +9,34 @@
import {
$cloneWithProperties,
$createParagraphNode,
+ $getAdjacentChildCaret,
+ $getChildCaret,
+ $getChildCaretAtIndex,
+ $getChildCaretOrSelf,
$getPreviousSelection,
$getRoot,
$getSelection,
+ $getSiblingCaret,
+ $isChildCaret,
$isElementNode,
$isRangeSelection,
$isRootOrShadowRoot,
+ $isSiblingCaret,
$isTextNode,
+ $rewindSiblingCaret,
$setSelection,
$splitNode,
- EditorState,
+ type CaretDirection,
+ type EditorState,
ElementNode,
- Klass,
- LexicalEditor,
- LexicalNode,
- NodeKey,
+ type Klass,
+ type LexicalEditor,
+ type LexicalNode,
+ makeStepwiseIterator,
+ type NodeCaret,
+ type NodeKey,
+ RootMode,
+ type SiblingCaret,
} from 'lexical';
// This underscore postfixing is used as a hotfix so we do not
// export shared types from this module #5918
@@ -163,10 +176,10 @@ export function mediaFileReader(
});
}
-export type DFSNode = Readonly<{
- depth: number;
- node: LexicalNode;
-}>;
+export interface DFSNode {
+ readonly depth: number;
+ readonly node: LexicalNode;
+}
/**
* "Depth-First Search" starts at the root/top node of a tree and goes as far as it can down a branch end
@@ -186,12 +199,22 @@ export function $dfs(
}
/**
- * A function which will return exactly the reversed order of $dfs. That means that the tree is traversed
- * 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.
- * \\{depth: number, node: LexicalNode\\} It will always return at least 1 node (the start node).
+ * Get the adjacent caret in the same direction
+ *
+ * @param caret A caret or null
+ * @returns `caret.getAdjacentCaret()` or `null`
+ */
+export function $getAdjacentCaret(
+ caret: null | NodeCaret,
+): null | SiblingCaret {
+ return caret ? caret.getAdjacentCaret() : null;
+}
+
+/**
+ * $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 $reverseDfs(
startNode?: LexicalNode,
@@ -200,19 +223,6 @@ export function $reverseDfs(
return Array.from($reverseDfsIterator(startNode, endNode));
}
-type DFSIterator = {
- next: () => IteratorResult;
- [Symbol.iterator]: () => DFSIterator;
-};
-
-const iteratorDone: Readonly<{done: true; value: void}> = {
- done: true,
- value: undefined,
-};
-const iteratorNotDone: (value: T) => Readonly<{done: false; value: T}> = (
- value: T,
-) => ({done: false, value});
-
/**
* $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.
@@ -222,49 +232,47 @@ const iteratorNotDone: (value: T) => Readonly<{done: false; value: T}> = (
export function $dfsIterator(
startNode?: LexicalNode,
endNode?: LexicalNode,
-): DFSIterator {
- const start = (startNode || $getRoot()).getLatest();
- const startDepth = $getDepth(start);
- const end = endNode;
- let node: null | LexicalNode = start;
- let depth = startDepth;
- let isFirstNext = true;
+): IterableIterator {
+ return $dfsCaretIterator('next', startNode, endNode);
+}
- const iterator: DFSIterator = {
- next(): IteratorResult {
- if (node === null) {
- return iteratorDone;
- }
- if (isFirstNext) {
- isFirstNext = false;
- return iteratorNotDone({depth, node});
- }
- if (node === end) {
- return iteratorDone;
+function $dfsCaretIterator(
+ direction: D,
+ startNode?: LexicalNode,
+ endNode?: LexicalNode,
+): IterableIterator {
+ const rootMode = 'root';
+ const root = $getRoot();
+ const start = startNode || root;
+ const startCaret = $isElementNode(start)
+ ? $getChildCaret(start, direction)
+ : $rewindSiblingCaret($getSiblingCaret(start, direction));
+ const startDepth = $getDepth(startCaret.getParentAtCaret());
+ const endCaret = $getAdjacentChildCaret(
+ endNode
+ ? $getChildCaretOrSelf($getSiblingCaret(endNode, direction))
+ : startCaret.getParentCaret(rootMode),
+ );
+ let depth = startDepth;
+ return makeStepwiseIterator({
+ hasNext: (state): state is NodeCaret<'next'> => state !== null,
+ initial: startCaret,
+ map: (state) => ({depth, node: state.origin}),
+ step: (state: NodeCaret<'next'>) => {
+ if (state.isSameNodeCaret(endCaret)) {
+ return null;
}
-
- if ($isElementNode(node) && node.getChildrenSize() > 0) {
- node = node.getFirstChild();
+ if ($isChildCaret(state)) {
depth++;
- } else {
- let depthDiff;
- [node, depthDiff] = $getNextSiblingOrParentSibling(node) || [null, 0];
- depth += depthDiff;
- if (end == null && depth <= startDepth) {
- node = null;
- }
}
-
- if (node === null) {
- return iteratorDone;
+ const rval = $getAdjacentSiblingOrParentSiblingCaret(state);
+ if (!rval || rval[0].isSameNodeCaret(endCaret)) {
+ return null;
}
- return iteratorNotDone({depth, node});
+ depth += rval[1];
+ return rval[0];
},
- [Symbol.iterator](): DFSIterator {
- return iterator;
- },
- };
- return iterator;
+ });
}
/**
@@ -278,75 +286,27 @@ export function $dfsIterator(
export function $getNextSiblingOrParentSibling(
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_.getNextSibling();
-
- if (sibling === null) {
- node_ = node_.getParent();
- depthDiff--;
- } else {
- node_ = sibling;
- }
- }
-
- if (node_ === null) {
- return null;
- }
- 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];
+ const rval = $getAdjacentSiblingOrParentSiblingCaret(
+ $getSiblingCaret(node, 'next'),
+ );
+ return rval && [rval[0].origin, rval[1]];
}
-export function $getDepth(node: LexicalNode): number {
- let innerNode: LexicalNode | null = node;
- let depth = 0;
-
- while ((innerNode = innerNode.getParent()) !== null) {
+export function $getDepth(node: null | LexicalNode): number {
+ let depth = -1;
+ for (
+ let innerNode = node;
+ innerNode !== null;
+ innerNode = innerNode.getParent()
+ ) {
depth++;
}
-
return depth;
}
/**
* Performs a right-to-left preorder tree traversal.
- * From the starting node it goes to the rightmost child, than backtracks to paret and finds new rightmost path.
+ * From the starting node it goes to the rightmost child, than backtracks to parent and finds new rightmost path.
* It will return the next node in traversal sequence after the startingNode.
* The traversal is similar to $dfs functions above, but the nodes are visited right-to-left, not left-to-right.
* @param startingNode - The node to start the search.
@@ -355,24 +315,11 @@ export function $getDepth(node: LexicalNode): number {
export function $getNextRightPreorderNode(
startingNode: LexicalNode,
): LexicalNode | null {
- let node: LexicalNode | null = startingNode;
-
- if ($isElementNode(node) && node.getChildrenSize() > 0) {
- node = node.getLastChild();
- } else {
- let sibling = null;
-
- while (sibling === null && node !== null) {
- sibling = node.getPreviousSibling();
-
- if (sibling === null) {
- node = node.getParent();
- } else {
- node = sibling;
- }
- }
- }
- return node;
+ const startCaret = $getChildCaretOrSelf(
+ $getSiblingCaret(startingNode, 'previous'),
+ );
+ const next = $getAdjacentSiblingOrParentSiblingCaret(startCaret, 'root');
+ return next && next[0].origin;
}
/**
@@ -384,52 +331,8 @@ export function $getNextRightPreorderNode(
export function $reverseDfsIterator(
startNode?: LexicalNode,
endNode?: LexicalNode,
-): DFSIterator {
- const start = (startNode || $getRoot()).getLatest();
- const startDepth = $getDepth(start);
- const end = endNode;
- 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 ($isElementNode(node) && node.getChildrenSize() > 0) {
- node = node.getLastChild();
- depth++;
- } else {
- let depthDiff;
- [node, depthDiff] = $getPreviousSiblingOrParentSibling(node) || [
- null,
- 0,
- ];
- depth += depthDiff;
- if (end == null && depth <= startDepth) {
- node = null;
- }
- }
-
- if (node === null) {
- return iteratorDone;
- }
- return iteratorNotDone({depth, node});
- },
- [Symbol.iterator](): DFSIterator {
- return iterator;
- },
- };
- return iterator;
+): IterableIterator {
+ return $dfsCaretIterator('previous', startNode, endNode);
}
/**
@@ -647,12 +550,7 @@ export function $insertNodeToNearestRoot(node: T): T {
const focusOffset = focus.offset;
if ($isRootOrShadowRoot(focusNode)) {
- const focusChild = focusNode.getChildAtIndex(focusOffset);
- if (focusChild == null) {
- focusNode.append(node);
- } else {
- focusChild.insertBefore(node);
- }
+ $getChildCaretAtIndex(focusNode, focusOffset, 'next').insert(node);
node.selectNext();
} else {
let splitNode: ElementNode;
@@ -677,8 +575,7 @@ export function $insertNodeToNearestRoot(node: T): T {
const nodes = selection.getNodes();
nodes[nodes.length - 1].getTopLevelElementOrThrow().insertAfter(node);
} else {
- const root = $getRoot();
- root.append(node);
+ $getRoot().append(node);
}
const paragraphNode = $createParagraphNode();
node.insertAfter(paragraphNode);
@@ -704,7 +601,7 @@ export function $wrapNodeInElement(
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
-type ObjectKlass = new (...args: any[]) => T;
+export type ObjectKlass = new (...args: any[]) => T;
/**
* @param object = The instance of the type
@@ -746,12 +643,7 @@ export function $filter(
* @param node Node that needs to be appended
*/
export function $insertFirst(parent: ElementNode, node: LexicalNode): void {
- const firstChild = parent.getFirstChild();
- if (firstChild !== null) {
- firstChild.insertBefore(node);
- } else {
- parent.append(node);
- }
+ $getChildCaret(parent, 'next').insert(node);
}
let NEEDS_MANUAL_ZOOM = IS_FIREFOX || !CAN_USE_DOM ? false : undefined;
@@ -833,7 +725,7 @@ function $unwrapAndFilterDescendantsImpl(
$unwrapAndFilterDescendantsImpl(
node,
$predicate,
- $onSuccess ? $onSuccess : (child) => node.insertAfter(child),
+ $onSuccess || ((child) => node.insertAfter(child)),
);
}
node.remove();
@@ -867,7 +759,7 @@ export function $descendantsMatching(
$predicate: (node: LexicalNode) => boolean,
): LexicalNode[] {
const result: LexicalNode[] = [];
- const stack = [...children].reverse();
+ const stack = Array.from(children).reverse();
for (let child = stack.pop(); child !== undefined; child = stack.pop()) {
if ($predicate(child)) {
result.push(child);
@@ -889,10 +781,7 @@ export function $descendantsMatching(
* @returns An iterator of the node's children
*/
export function $firstToLastIterator(node: ElementNode): Iterable {
- return {
- [Symbol.iterator]: () =>
- $childIterator(node.getFirstChild(), (child) => child.getNextSibling()),
- };
+ return $childIterator($getChildCaret(node, 'next'));
}
/**
@@ -904,28 +793,20 @@ export function $firstToLastIterator(node: ElementNode): Iterable {
* @returns An iterator of the node's children
*/
export function $lastToFirstIterator(node: ElementNode): Iterable {
- return {
- [Symbol.iterator]: () =>
- $childIterator(node.getLastChild(), (child) =>
- child.getPreviousSibling(),
- ),
- };
+ return $childIterator($getChildCaret(node, 'previous'));
}
-function $childIterator(
- initialNode: LexicalNode | null,
- nextNode: (node: LexicalNode) => LexicalNode | null,
-): Iterator {
- let state = initialNode;
+function $childIterator(
+ startCaret: NodeCaret,
+): IterableIterator {
const seen = __DEV__ ? new Set() : null;
- return {
- next() {
- if (state === null) {
- return iteratorDone;
- }
- const rval = iteratorNotDone(state);
+ return makeStepwiseIterator({
+ hasNext: $isSiblingCaret,
+ initial: startCaret.getAdjacentCaret(),
+ map: (caret) => {
+ const origin = caret.origin.getLatest();
if (__DEV__ && seen !== null) {
- const key = state.getKey();
+ const key = origin.getKey();
invariant(
!seen.has(key),
'$childIterator: Cycle detected, node with key %s has already been traversed',
@@ -933,20 +814,49 @@ function $childIterator(
);
seen.add(key);
}
- state = nextNode(state);
- return rval;
+ return origin;
},
- };
+ step: (caret: SiblingCaret) => caret.getAdjacentCaret(),
+ });
}
/**
- * Insert all children before this node, and then remove it.
+ * Replace this node with its children
*
* @param node The ElementNode to unwrap and remove
*/
export function $unwrapNode(node: ElementNode): void {
- for (const child of $firstToLastIterator(node)) {
- node.insertBefore(child);
+ $rewindSiblingCaret($getSiblingCaret(node, 'next')).splice(
+ 1,
+ node.getChildren(),
+ );
+}
+
+/**
+ * Returns the Node sibling when this exists, otherwise the closest parent sibling. For example
+ * R -> P -> T1, T2
+ * -> P2
+ * returns T2 for node T1, P2 for node T2, and null for node P2.
+ * @param node LexicalNode.
+ * @returns An array (tuple) containing the found Lexical node and the depth difference, or null, if this node doesn't exist.
+ */
+export function $getAdjacentSiblingOrParentSiblingCaret<
+ D extends CaretDirection,
+>(
+ startCaret: NodeCaret,
+ rootMode: RootMode = 'root',
+): null | [NodeCaret, number] {
+ let depthDiff = 0;
+ let caret = startCaret;
+ let nextCaret = $getAdjacentChildCaret(caret);
+ while (nextCaret === null) {
+ depthDiff--;
+ nextCaret = caret.getParentCaret(rootMode);
+ if (!nextCaret) {
+ return null;
+ }
+ caret = nextCaret;
+ nextCaret = $getAdjacentChildCaret(caret);
}
- node.remove();
+ return nextCaret && [nextCaret, depthDiff];
}
diff --git a/packages/lexical-website/docs/concepts/traversals.md b/packages/lexical-website/docs/concepts/traversals.md
new file mode 100644
index 00000000000..6440b0cbf0f
--- /dev/null
+++ b/packages/lexical-website/docs/concepts/traversals.md
@@ -0,0 +1,501 @@
+# Node Traversals with NodeCaret
+
+NodeCaret offers a unified and efficient way for traversing the document
+tree, making it much easier to correctly implement traversals and avoid
+edge cases around empty nodes and collapsed selections.
+
+These new low-level functions were all designed to work together as a
+fully featured relatively lightweight API to use in the core to
+allow us to gradually address some edge cases and then simplify and shrink
+the code. We expect higher-level utilities to be developed and shipped
+in @lexical/utils or another module at a later date. The current overhead
+should be less than 3kB in a production environment.
+
+The NodeCaret API was introduced in lexical v0.25.0.
+
+## Concepts
+
+The core concept with `NodeCaret` is that you can represent any specific
+point in the document by using an `origin` node, a `direction` that
+points towards an adjacent node (`next` or `previous`), and a `type`
+to specify whether the arrow points towards a sibling (`breadth`) or
+towards a child (`child`).
+
+All of these types have a `D` type parameter that must be a `CaretDirection`, so you
+can not accidentally mix up `next` and `previous` carets. Many of them
+also have a `T` type parameter that encodes the type of the `origin` node.
+
+:::tip
+
+The methods of a caret are designed to operate on nodes attached to the `origin`
+in the designated direction, not the `origin` itself. For example, this code is
+a no-op because it will attach a node to the `origin`, and then remove the node
+that was just attached.
+
+```ts
+// The origin is unaffected (other than being marked dirty)
+caret.insert($createTextNode('no-op')).remove();
+```
+
+:::
+
+:::warning
+
+Carets are immutable, and designed for low-level usage. There is no attempt
+for carets to automatically update based on changes to the document
+(this is a common source of bugs when working with `RangeSelection`).
+Functions and methods that work with carets and are expected to change the
+structure of the document will always return a possibly new caret.
+
+The `origin` of a caret is the exact version of the object that it was
+constructed with, all accessor methods on that origin will generally call
+`origin.getLatest()` so the operations will see the latest version.
+
+:::
+
+### NodeCaret
+
+`NodeCaret` is any `SiblingCaret` or any `ChildCaret`
+* Typically constructed with `$getChildCaretOrSelf($getSiblingCaret(origin, direction))`
+ which returns a `ChildCaret` when the origin is an `ElementNode`
+
+:::tip
+
+This type does not include `TextPointCaret` or `TextPointCaretSlice`,
+so you will not have to consider those edge cases when you see this
+more specific type.
+
+:::
+
+### SiblingCaret
+
+`SiblingCaret` is a caret that points towards a sibling of the origin
+
+* Constructed with `$getSiblingCaret(origin: LexicalNode, direction: CaretDirection)`
+* The `next` direction points towards the right
+* The `previous` direction points towards the left
+
+| | → direction: `'next'` | ← direction: `'previous'` |
+|------------------------|---------------------------|-------------------------------|
+| `getParentAtCaret()` | `origin.getParent()` | `origin.getParent()` |
+| `getNodeAtCaret()` | `origin.getNextSibling()` | `origin.getPreviousSibling()` |
+| `insert(node)` | `origin.insertAfter(node)`| `origin.insertBefore(node)` |
+
+### ChildCaret
+
+`ChildCaret` is a caret that points towards the first or last child of the origin
+
+* Constructed with `$getChildCaret(origin: ElementNode, direction: CaretDirection)`
+* The `next` direction points towards the first child
+* The `previous` direction points towards the last child
+
+| | ↘ direction: `'next'` | ↙ direction: `'previous'` |
+|------------------------|----------------------------|-------------------------------|
+| `getParentAtCaret()` | `origin` | `origin` |
+| `getNodeAtCaret()` | `origin.getFirstChild()` | `origin.getLastChild()` |
+| `insert(node)` | `origin.splice(0, 0, node)`| `origin.append(node)` |
+
+### PointCaret
+
+`PointCaret` is any `TextPointCaret`, `SiblingCaret` or `ChildCaret`. This
+type can be used to represent any point in the document that `PointType` can represent.
+
+### TextPointCaret
+
+`TextPointCaret` is basically a `SiblingCaret` with a `TextNode` origin and an `offset` property
+* Constructed with `$getTextPointCaret(origin, direction, offset)`
+* The `offset` property is an absolute index into the string
+* The `next` direction implies all text content after `offset`
+* The `previous` direction implies all text content before `offset`
+* All methods that are also present on `SiblingCaret` behave in the same way
+
+### TextPointCaretSlice
+
+`TextPointCaretSlice` is a wrapper for `TextPointCaret` that provides a signed `distance`.
+
+* Constructed with `$getTextPointCaretSlice(caret, distance)`
+* There are convenience methods like `removeTextSlice()` and `getTextContent()`,
+ so it's not generally necessary to know the implementation details here
+* `Math.min(caret.offset, caret.offset + distance)` refers to the start offset of the slice
+* `Math.max(caret.offset, caret.offset + distance)` refers to the end offset of the slice
+* The `direction` of the caret is generally ignored when working with a
+ `TextPointCaretSlice`, the slice is in absolute string coordinates
+
+:::info
+
+The property name `distance` was chosen because `length` and `size` are
+commonly used on other data structures in JavaScript and Lexical, and they
+are overwhelmingly non-negative. While most uses of `distance` are also
+non-negative, in some contexts such as computer graphics it is not uncommon
+to use
+[Signed distance functions](https://en.wikipedia.org/wiki/Signed_distance_function)
+where the distance metric is signed.
+
+In SDF terms, the subset of the space is `[offset, ∞)`. Any coordinate less
+than the `offset` boundary is a negative distance; otherise the distance is
+non-negative.
+
+:::
+
+### CaretRange
+
+`CaretRange` contains a pair of `PointCaret` that are in the same direction. It
+is equivalent in purpose to a `RangeSelection`, and is what you would generally
+use for depth first traversals.
+
+* Constructed with `$getCaretRange(anchor, focus)` or `$caretRangeFromSelection(selection)`
+* The `anchor` is the start of the range, generally where the selection originated,
+ and it is "anchored" in place because when a selection grows or shrinks only the
+ `focus` will be moved
+* The `focus` is the end of the range, where the blinking cursor is, it's the current
+ focus of the user
+* Anchor and focus must point in the same direction. The `anchor` points towards the first
+ node *in the range* and the focus points towards the first node *not in the range*
+* The `getTextSlices()` method is essential to handle the literal edge cases where
+ the anchor and/or focus are a `TextPointCaret`. These edges are *not* included
+ in the default caret iteration of the `CaretRange`.
+
+:::warning
+
+If you are iterating a `CaretRange` you must consider the `getTextSlices()`
+separately, they are not included in the iteration. This is so you don't have
+to consider `TextPointCaretSlice` at every step. They are literal edge cases
+that can only be at the start and/or end and typically have special
+treatment (splitting instead of removing, for example).
+
+:::
+
+## Traversal Strategies
+
+
+
+### Adjacent Caret Traversals
+
+The lowest level building block for traversals with NodeCaret is the adjacent caret
+traversal, which is supported directly by methods of NodeCaret.
+
+`getAdjacentCaret()` - Gets a `SiblingCaret` for the node attached to
+ `origin` in direction. If there is no attached node, it will return `null`
+
+`getParentCaret(rootMode)` - Gets a `SiblingCaret` for the parent node
+ of `origin` in the same direction. If there is no parent node, or the parent
+ is a root according to `rootMode`, then it will return `null`. `rootMode`
+ may be `'root'` to only return `null` for `RootNode` or `'shadowRoot'` to
+ return `null` for `RootNode` or any `ElementNode` parent where
+ `isShadowRoot()` returns true
+
+`getChildCaret()` - Gets a `ChildCaret` for this origin, or `null` if the
+ origin is not an `ElementNode`. Will return `this` if the caret is already
+ a `ChildCaret`
+
+For example, iterating all siblings:
+
+```ts
+// Note that NodeCaret already implements Iterable> in this
+// way, so this function is not very useful. You can just use startCaret as
+// the iterable.
+function *$iterSiblings(
+ startCaret: NodeCaret
+): Iterable> {
+ // Note that we start at the adjacent caret. The start caret
+ // points away from the origin node, so we do not want to
+ // trick ourselves into thinking that that origin is included.
+ for (
+ let caret = startCaret.getAdjacentCaret();
+ caret !== null;
+ caret = caret.getAdjacentCaret()
+ ) {
+ yield caret;
+ }
+}
+```
+
+### Examples
+
+Given the following document tree, here are some examples of using the
+adjacent node traversal:
+
+Root
+* Paragraph A
+ * Text A1
+ * Link A2
+ * Text A3
+ * Text A4
+* Paragraph B
+ * Text B1
+* Paragraph C
+
+```ts
+// The root does not have sibling nodes
+const carets = [...$getSiblingCaret($getRoot(), 'next')];
+expect(carets).toEqual([]);
+```
+
+```ts
+// The adjacent node to a ChildNode is its first or last child
+// and is always a SiblingNode. It does not traverse deeper.
+const carets = [...$getChildCaret($getRoot(), 'next')];
+
+// next starts at the first child
+expect(carets).toEqual([
+ $getSiblingCaret(paragraphA, 'next'),
+ $getSiblingCaret(paragraphB, 'next'),
+ $getSiblingCaret(paragraphC, 'next'),
+]);
+
+// previous starts at the last child
+const prevCarets = [...$getChildCaret($getRoot(), 'previous')];
+expect(prevCarets).toEqual([
+ $getSiblingCaret(paragraphC, 'previous'),
+ $getSiblingCaret(paragraphB, 'previous'),
+ $getSiblingCaret(paragraphA, 'previous'),
+]);
+```
+
+```ts
+// The iteration starts at the node where the head of the "arrow"
+// is pointing, which is away from the origin (the tail of the "arrow").
+const carets = [...$getSiblingCaret(paragraphB, 'next')];
+expect(carets).toEqual([
+ $getSiblingCaret(paragraphC, 'next'),
+]);
+
+const prevCarets = [...$getSiblingCaret(paragraphB, 'previous')];
+expect(prevCarets).toEqual([
+ $getSiblingCaret(paragraphA, 'previous'),
+]);
+```
+
+### Depth First Caret Traversals
+
+The strategy to do a depth-first caret traversal is to use an adjacent caret
+traversal and immediately use a `ChildCaret` any time that an `ElementNode`
+origin is encountered. This strategy yields all possible carets, but each
+ElementNode in the traversal may be yielded once or twice (a `ChildCaret` on
+enter, and a `SiblingCaret` on leave). Allowing you to see whether an
+`ElementNode` is partially included in the range or not is one of the
+reasons that this abstraction exists.
+
+```ts
+function *$iterCaretsDepthFirst(
+ startCaret: NodeCaret
+): Iterable> {
+ function step(prevCaret: NodeCaret): null | NodeCaret {
+ // Get the adjacent SiblingCaret
+ const nextCaret = prevCaret.getAdjacentCaret();
+ return (
+ // If there is a sibling, try and get a ChildCaret from it
+ (nextCaret && nextCaret.getChildCaret()) ||
+ // Return the sibling if there is one
+ nextCaret ||
+ // Return a SiblingCaret of the parent, if there is one
+ prevCaret.getParentCaret('root')
+ );
+ }
+ // You may add an additional check here, usually some specific
+ // caret to terminate the iteration with (such as the parent caret
+ // of startCaret):
+ //
+ // `caret !== null || caret.is(endCaret)`
+ //
+ for (
+ let caret = step(startCaret);
+ caret !== null;
+ caret = step(caret)
+ ) {
+ yield caret;
+ }
+}
+```
+
+Normally this type of iteration would be done from a `CaretRange`, where you
+would specify a precise end caret (focus).
+
+```ts
+function $iterCaretsDepthFirst(
+ startCaret: NodeCaret,
+ endCaret?: NodeCaret,
+): Iterable> {
+ return $getCaretRange(
+ startCaret,
+ // Use the root as the default end caret, but you might choose
+ // to use startCaret.getParentCaret('root') for example
+ endCaret || $getSiblingCaret($getRoot(), startCaret.direction)
+ );
+}
+```
+
+To get all nodes that are entirely selected between two carets:
+
+```ts
+function *$iterNodesDepthFirst(
+ startCaret: NodeCaret,
+ endCaret: NodeCaret = $getChildCaret($getRoot(), startCaret.direction),
+): Iterable {
+ const seen = new Set();
+ for (const caret of $getCaretRange(startCaret, endCaret)) {
+ const {origin} = caret;
+ if ($isChildCaret(caret)) {
+ seen.add(origin.getKey());
+ } else if (!$isElementNode(origin) || seen.has(origin.getKey())) {
+ // If the origin is an element and we have not seen it as a ChildCaret
+ // then it was not entirely in the CaretRange
+ yield origin;
+ }
+ }
+}
+```
+
+### Examples
+
+Given the following document tree, here are some examples of using the
+depth-first node traversal (with a `CaretRange`):
+
+Root
+* Paragraph A
+ * Text A1
+ * Link A2
+ * Text A3
+ * Text A4
+* Paragraph B
+ * Text B1
+* Paragraph C
+
+```ts
+// A full traversal of the document from root
+const carets = [...$getCaretRange(
+ // Start with the arrow pointing towards the first child of root
+ $getChildCaret($getRoot(), 'next'),
+ // End when the arrow points away from root
+ $getSiblingCaret($getRoot(), 'next'),
+)];
+expect(carets).toEqual([
+ $getChildCaret(paragraphA, 'next'), // enter Paragraph A
+ $getSiblingCaret(textA1, 'next'),
+ $getChildCaret(linkA2, 'next'), // enter Link A2
+ $getSiblingCaret(textA3, 'next'),
+ $getSiblingCaret(linkA2, 'next'), // leave Link A2
+ $getSiblingCaret(textA4, 'next'),
+ $getSiblingCaret(paragraphA, 'next'), // leave Paragraph A
+ $getChildCaret(paragraphB, 'next'), // enter Paragraph B
+ $getSiblingCaret(textB1, 'next'),
+ $getSiblingCaret(paragraphB, 'next'), // leave Paragraph B
+ $getChildCaret(paragraphC, 'next'), // enter Paragraph C
+ $getSiblingCaret(paragraphC, 'next'), // leave Paragraph C
+]);
+```
+
+## Future Direction
+
+It's expected that higher-level abstractions will be built on top of this
+outside of the core, either in @lexical/utils or a separate companion package.
+This is just designed to be the lowest-level layer with a consistent and
+type-safe interface. That sort of abstraction will probably look a little bit
+like cheerio or jQuery, but for working with Lexical documents. It is not
+expected that more abstractions will be added to the core.
+
+In order to reduce code size and eliminate bugs, more of the core will be
+refactored to use NodeCaret internally.
+
+Once this happens, it's possible that the internal structure of PointType
+and/or RangeSelection may change to accommodate NodeCaret, as it is more
+resilient to document changes (only changes that directly affect the
+orgin node will "break" the point). A simple version of this would be to
+create a caret any time that the point changes, and use that caret
+as a fallback if the selection would otherwise be lost.
+
+It may be the case that NodeCaret will become the lowest level API, working
+directly with private LexicalNode/ElementNode internals. When/if that happens,
+the methods on LexicalNode will remain for backwards compatibility,
+but overriding them will not be supported. It isn't particularly safe to
+override them as-is anyway, and these overrides are frequently the
+root cause of bugs (e.g. parents that remove themselves after an operation
+on a child, causing the point to be lost unless the caller was sophisticated
+enough to store the array of parents).
+
+## History
+
+Before NodeCaret, Lexical's core API offered a relatively low-level DOM-like
+interface for working with nodes and traversing them. It has accumulated
+many functions over time for performing various kinds of traversals around
+the tree (finding ancestors, children, depth, siblings, etc.), but most of
+them are not implemented in a way that makes them easy to combine
+efficiently, and many of them have edge cases that are difficult to avoid
+and can't really be addressed without breaking compatibility.
+
+Many of these functions also have a lot of edge cases, particularly around
+assuming the reference nodes are inclusive. Many are also left-to-right
+biased, don't offer an iterative version that can be aborted early or
+consumed on the fly, etc.
+
+Refactoring many of these to use something like `PointType` would almost
+be sufficient for many of these use cases, but the representation of
+that type is inefficient and error-prone as any mutation to the tree
+requires that each point be manually recomputed. `PointType` is also
+directionless, forcing a specific left-to-right bias into most APIs.
+`RangeSelection` can be used in many cases because a direction can
+be inferred from any two different points, but that collapses with
+a single point. It's also impractical to use `RangeSelection`
+concurrently with mutations due to the problems with `PointType`.
+
+NodeCaret was born out of frustration with these APIs and a desire
+to unify it all in a coherent way to simplify and reduce errors in
+the core.
+
+## Terminology
+
+### Caret
+
+The term Caret was chosen because it is concise and specific term
+specific to a point in a text document. Lexical is "an extensible text editor
+framework" so it makes sense that navigation in the document would use
+terms relevant to text. Most other terms such as Cursor or Point
+already have meanings in Lexical and/or are less specific.
+
+See also:
+- [Caret](https://developer.mozilla.org/en-US/docs/Glossary/Caret)
+- [Caret navigation](https://en.wikipedia.org/wiki/Caret_navigation)
+
+### Origin
+
+The origin is the reference node for a NodeCaret. Absolute coordinates
+are determined by combining this origin node and an "arrow" that points
+towards to where the adjacent node is (or could be). The "arrow" is
+determined by the `direction` and `type` of the caret.
+
+In a way this "arrow" is considered to be something like a unit vector
+to indicate the direction, and adding it to an origin allows you to specify
+an absolute location relative to that origin. Unlike the other coordinate
+systems available in Lexical, it does not need recomputing whenever
+siblings or a parent changes, so long as the origin node is still attached.
+
+See also:
+- [Origin](https://en.wikipedia.org/wiki/Origin_(mathematics))
+- [Unit Vector](https://en.wikipedia.org/wiki/Unit_vector)
+
+### ChildCaret / SiblingCaret
+
+These were chosen because they match the existing methods on `ElementNode`
+and `LexicalNode` (`getFirstChild`, `getNextSibling`, etc.)
+
+### Direction
+
+`'next'` and `'previous'` were chosen for direction mostly to match the
+existing methods such as `getNextSibling()` that exist in DOM and in Lexical.
+Using other words such as `'left'` and `'right'` would be ambiguous since
+text direction can be bidirectional and already uses the terms left-to-right
+and right-to-left.
+
+### Distance
+
+The property name `distance` was chosen for `TextPointCaretSlice` because
+`length` and `size` are commonly used on other data structures in JavaScript
+and Lexical, and they are overwhelmingly non-negative. While most uses of
+`distance` are also non-negative, in some contexts such as computer graphics
+it is not uncommon to use
+[Signed distance functions](https://en.wikipedia.org/wiki/Signed_distance_function)
+where the distance metric is signed.
+
+In SDF terms, the subset of the space is `[offset, ∞)`. Any coordinate less
+than the `offset` boundary is a negative distance; otherise the distance is
+non-negative.
diff --git a/packages/lexical-website/sidebars.js b/packages/lexical-website/sidebars.js
index 523933de4c2..6a47818203a 100644
--- a/packages/lexical-website/sidebars.js
+++ b/packages/lexical-website/sidebars.js
@@ -50,6 +50,7 @@ const sidebars = {
'concepts/history',
'concepts/serialization',
'concepts/dom-events',
+ 'concepts/traversals',
],
label: 'Concepts',
type: 'category',
diff --git a/packages/lexical/flow/Lexical.js.flow b/packages/lexical/flow/Lexical.js.flow
index 32fce6535da..be1ff4225c6 100644
--- a/packages/lexical/flow/Lexical.js.flow
+++ b/packages/lexical/flow/Lexical.js.flow
@@ -1011,3 +1011,168 @@ export interface SerializedEditorState {
export type SerializedEditor = {
editorState: SerializedEditorState,
};
+
+/**
+ * LexicalCaret
+ */
+export interface BaseCaret extends Iterable> {
+ +origin: T;
+ +type: Type;
+ +direction: D;
+ getParentAtCaret(): null | ElementNode;
+ getNodeAtCaret(): null | LexicalNode;
+ getAdjacentCaret(): null | SiblingCaret;
+ getSiblingCaret(): SiblingCaret;
+ remove(): BaseCaret; // this
+ insert(node: LexicalNode): BaseCaret; // this
+ replaceOrInsert(node: LexicalNode, includeChildren?: boolean): BaseCaret; // this
+ splice(deleteCount: number, nodes: Iterable, nodesDirection?: CaretDirection): BaseCaret; // this
+}
+export type CaretDirection = 'next' | 'previous';
+type FLIP_DIRECTION = {'next' : 'previous', 'previous': 'next'};
+export interface CaretRange extends Iterable> {
+ +type: 'node-caret-range';
+ +direction: D;
+ anchor: PointCaret;
+ focus: PointCaret;
+ isCollapsed(): boolean;
+ iterNodeCarets(rootMode?: RootMode): Iterable>;
+ getTextSlices(): TextPointCaretSliceTuple;
+}
+export type CaretType = 'sibling' | 'child';
+export interface ChildCaret extends BaseCaret {
+ getLatest(): ChildCaret;
+ getParentCaret(mode?: RootMode): null | SiblingCaret;
+ getParentAtCaret(): T;
+ getChildCaret(): ChildCaret;
+ isSameNodeCaret(other: null | void | PointCaret): boolean; // other is ChildCaret;
+ isSamePointCaret(other: null | void | PointCaret): boolean; // other is ChildCaret;
+ getFlipped(): NodeCaret>;
+ // Refine chained types
+ remove(): ChildCaret;
+ insert(node: LexicalNode): ChildCaret;
+ replaceOrInsert(node: LexicalNode, includeChildren?: boolean): ChildCaret;
+ splice(deleteCount: number, nodes: Iterable, nodesDirection?: CaretDirection): ChildCaret;
+}
+export type FlipDirection = FLIP_DIRECTION[D];
+export type NodeCaret = ChildCaret | SiblingCaret;
+export type PointCaret = ChildCaret | SiblingCaret | TextPointCaret;
+export type RootMode = 'root' | 'shadowRoot';
+export interface SiblingCaret extends BaseCaret {
+ getLatest(): SiblingCaret;
+ getChildCaret(): null | ChildCaret;
+ getParentCaret(mode?: RootMode): null | SiblingCaret;
+ isSameNodeCaret(other: null | void | PointCaret): boolean; // other is SiblingCaret | T extends TextNode ? TextPointCaret : empty;
+ isSamePointCaret(other: null | void | PointCaret): boolean; // other is SiblingCaret;
+ getFlipped(): NodeCaret>;
+ // Refine chained types
+ remove(): SiblingCaret;
+ insert(node: LexicalNode): SiblingCaret;
+ replaceOrInsert(node: LexicalNode, includeChildren?: boolean): SiblingCaret;
+ splice(deleteCount: number, nodes: Iterable, nodesDirection?: CaretDirection): SiblingCaret;
+}
+export interface StepwiseIteratorConfig {
+ +initial: State | Stop;
+ +hasNext: (value: State | Stop) => implies value is State;
+ +step: (value: State) => State | Stop;
+ +map: (value: State) => Value;
+}
+export interface TextPointCaret extends BaseCaret {
+ +offset: number;
+ getLatest(): TextPointCaret;
+ getChildCaret(): null;
+ getParentCaret(): null | SiblingCaret;
+ isSameNodeCaret(other: null | void | PointCaret): boolean; // other is TextPointCaret | SiblingCaret;
+ isSamePointCaret(other: null | void | PointCaret): boolean; // other is TextPointCaret;
+ getFlipped(): TextPointCaret>;
+}
+export interface TextPointCaretSlice {
+ +type: 'slice';
+ +caret: TextPointCaret;
+ +distance: number;
+ getSliceIndices(): [startIndex: number, endIndex: number];
+ getTextContent(): string;
+ getTextContentSize(): number;
+ removeTextSlice(): TextPointCaret;
+}
+export type TextPointCaretSliceTuple = [
+ +anchorSlice: null | TextPointCaretSlice,
+ +focusSlice: null | TextPointCaretSlice,
+];
+declare export function $getAdjacentChildCaret(caret: null | NodeCaret): null | NodeCaret;
+declare export function $getCaretRange(anchor: PointCaret, focus: PointCaret): CaretRange;
+declare export function $getChildCaret(origin: T, direction: D): ChildCaret, D> | Extract;
+declare export function $getChildCaretOrSelf>(caret: Caret): Caret | ChildCaret['direction']>;
+declare export function $getSiblingCaret(origin: T, direction: D): SiblingCaret, D> | Extract;
+declare export function $getTextNodeOffset(origin: TextNode, offset: number | CaretDirection): number;
+declare export function $getTextPointCaret(origin: T, direction: D, offset: number | CaretDirection): TextPointCaret, D> | Extract;
+declare export function $getTextPointCaretSlice(caret: TextPointCaret, distance: number): TextPointCaretSlice;
+declare export function $isChildCaret(caret: null | void | PointCaret): caret is ChildCaret;
+declare export function $isNodeCaret(caret: null | void | PointCaret): caret is NodeCaret;
+declare export function $isSiblingCaret(caret: null | void | PointCaret): caret is SiblingCaret;
+declare export function $isTextPointCaret(caret: null | void | PointCaret): caret is TextPointCaret;
+declare export function $isTextPointCaretSlice(caret: null | void | PointCaret | TextPointCaretSlice): caret is TextPointCaretSlice;
+declare export function flipDirection(direction: D): FlipDirection;
+declare export function makeStepwiseIterator(
+ config: StepwiseIteratorConfig,
+): Iterator;
+/**
+ * LexicalCaretUtils
+ */
+declare export function $caretFromPoint(
+ point: PointType,
+ direction: D,
+): PointCaret;
+declare export function $caretRangeFromSelection(
+ selection: RangeSelection,
+): CaretRange;
+declare export function $getAdjacentSiblingOrParentSiblingCaret<
+ D: CaretDirection,
+>(
+ startCaret: NodeCaret,
+ rootMode?: RootMode
+): null | [NodeCaret, number]
+declare export function $getCaretInDirection<
+ Caret: PointCaret,
+ D: CaretDirection,
+>(
+ caret: Caret,
+ direction: D,
+):
+ | NodeCaret
+ | (Caret extends TextPointCaret
+ ? TextPointCaret
+ : empty);
+declare export function $getCaretRangeInDirection(
+ range: CaretRange,
+ direction: D,
+): CaretRange;
+declare export function $getChildCaretAtIndex(
+ parent: ElementNode,
+ index: number,
+ direction: D,
+): NodeCaret;
+declare export function $normalizeCaret(
+ initialCaret: PointCaret,
+): PointCaret;
+declare export function $removeTextFromCaretRange(
+ initialRange: CaretRange,
+ sliceMode?:
+ | 'removeEmptySlices'
+ | 'preserveEmptyTextSliceCaret'
+): CaretRange;
+declare export function $rewindSiblingCaret<
+ T: LexicalNode,
+ D: CaretDirection,
+>(caret: SiblingCaret): NodeCaret;
+declare export function $setPointFromCaret(
+ point: PointType,
+ caret: PointCaret,
+): void;
+declare export function $setSelectionFromCaretRange(
+ caretRange: CaretRange,
+): RangeSelection;
+declare export function $updateRangeSelectionFromCaretRange(
+ selection: RangeSelection,
+ caretRange: CaretRange,
+): void;
diff --git a/packages/lexical/src/LexicalNode.ts b/packages/lexical/src/LexicalNode.ts
index 105b1487ec1..4bf603f15b0 100644
--- a/packages/lexical/src/LexicalNode.ts
+++ b/packages/lexical/src/LexicalNode.ts
@@ -381,11 +381,10 @@ export class LexicalNode {
const firstPoint = targetSelection.isBackward()
? targetSelection.focus
: targetSelection.anchor;
- const firstElement = firstPoint.getNode() as ElementNode;
if (
- firstPoint.offset === firstElement.getChildrenSize() &&
- firstElement.is(parentNode) &&
- firstElement.getLastChildOrThrow().is(this)
+ parentNode.is(firstPoint.getNode()) &&
+ firstPoint.offset === parentNode.getChildrenSize() &&
+ this.is(parentNode.getLastChild())
) {
return false;
}
diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts
index d18c170fc3d..08ed6571418 100644
--- a/packages/lexical/src/LexicalSelection.ts
+++ b/packages/lexical/src/LexicalSelection.ts
@@ -16,6 +16,8 @@ import {IS_ANDROID_CHROME} from 'shared/environment';
import invariant from 'shared/invariant';
import {
+ $caretFromPoint,
+ $caretRangeFromSelection,
$createLineBreakNode,
$createParagraphNode,
$createTextNode,
@@ -24,7 +26,11 @@ import {
$isLineBreakNode,
$isRootNode,
$isTextNode,
+ $normalizeCaret,
+ $removeTextFromCaretRange,
+ $setPointFromCaret,
$setSelection,
+ $updateRangeSelectionFromCaretRange,
SELECTION_CHANGE_COMMAND,
TextNode,
} from '.';
@@ -504,46 +510,83 @@ export class RangeSelection implements BaseSelection {
const isBefore = anchor.isBefore(focus);
const firstPoint = isBefore ? anchor : focus;
const lastPoint = isBefore ? focus : anchor;
- let firstNode = firstPoint.getNode();
- let lastNode = lastPoint.getNode();
- const overselectedFirstNode =
- $isElementNode(firstNode) &&
- firstPoint.offset > 0 &&
- firstPoint.offset >= firstNode.getChildrenSize();
- const startOffset = firstPoint.offset;
- const endOffset = lastPoint.offset;
-
- if ($isElementNode(firstNode)) {
- const firstNodeDescendant =
- firstNode.getDescendantByIndex(startOffset);
- firstNode = firstNodeDescendant != null ? firstNodeDescendant : firstNode;
- }
- if ($isElementNode(lastNode)) {
- let lastNodeDescendant =
- lastNode.getDescendantByIndex(endOffset);
- // We don't want to over-select, as node selection infers the child before
- // the last descendant, not including that descendant.
- if (
- lastNodeDescendant !== null &&
- lastNodeDescendant !== firstNode &&
- lastNode.getChildAtIndex(endOffset) === lastNodeDescendant
- ) {
- lastNodeDescendant = lastNodeDescendant.getPreviousSibling();
+ const firstPointNode = firstPoint.getNode();
+ const lastPointNode = lastPoint.getNode();
+ let firstNode: LexicalNode = firstPointNode;
+ let lastNode: LexicalNode = lastPointNode;
+ let overselectedFirstNode = false;
+ const overselectedLastNodes = new Set();
+
+ if ($isElementNode(firstPointNode)) {
+ overselectedFirstNode =
+ firstPoint.offset > 0 &&
+ firstPoint.offset >= firstPointNode.getChildrenSize();
+ firstNode =
+ firstPointNode.getDescendantByIndex(firstPoint.offset) ||
+ firstPointNode;
+ }
+ if ($isElementNode(lastPointNode)) {
+ const lastPointChild = lastPointNode.getChildAtIndex(lastPoint.offset);
+ if (lastPointChild) {
+ overselectedLastNodes.add(lastPointChild.getKey());
+ lastNode =
+ ($isElementNode(lastPointChild) &&
+ lastPointChild.getFirstDescendant()) ||
+ lastPointChild;
+ for (
+ let overselected: LexicalNode | null = lastNode;
+ overselected && !overselected.is(lastPointChild);
+ overselected = overselected.getParent()
+ ) {
+ overselectedLastNodes.add(overselected.getKey());
+ }
+ } else {
+ const beforeChild =
+ lastPoint.offset > 0 &&
+ lastPointNode.getChildAtIndex(lastPoint.offset - 1);
+ if (beforeChild) {
+ // This case is not an overselection
+ lastNode =
+ ($isElementNode(beforeChild) && beforeChild.getLastDescendant()) ||
+ beforeChild;
+ } else {
+ // It's the last node and we have to find something at or after lastNode
+ // and mark all of the ancestors inbetween as overselected
+ let parent = lastPointNode.getParent();
+ for (; parent !== null; parent = parent.getParent()) {
+ overselectedLastNodes.add(parent.getKey());
+ const parentSibling = parent.getNextSibling();
+ if (parentSibling) {
+ lastNode = parentSibling;
+ break;
+ }
+ }
+ if (!(lastPointNode.isEmpty() && lastPointNode.is(lastNode))) {
+ overselectedLastNodes.add(lastNode.getKey());
+ }
+ }
}
- lastNode = lastNodeDescendant != null ? lastNodeDescendant : lastNode;
}
let nodes: Array;
- if (firstNode.is(lastNode)) {
+ if (firstNode.is(lastNode) || this.isCollapsed()) {
if ($isElementNode(firstNode) && firstNode.getChildrenSize() > 0) {
nodes = [];
} else {
nodes = [firstNode];
}
} else {
- nodes = firstNode.getNodesBetween(lastNode);
// Prevent over-selection due to the edge case of getDescendantByIndex always returning something #6974
+ nodes = firstNode.getNodesBetween(lastNode);
+ if (overselectedLastNodes.size > 0) {
+ while (
+ nodes.length > 0 &&
+ overselectedLastNodes.has(nodes[nodes.length - 1].getKey())
+ ) {
+ nodes.pop();
+ }
+ }
if (overselectedFirstNode) {
const deleteCount = nodes.findIndex(
(node) => !node.is(firstNode) && !node.isBefore(firstNode),
@@ -551,6 +594,15 @@ export class RangeSelection implements BaseSelection {
nodes.splice(0, deleteCount);
}
}
+ if (__DEV__) {
+ if (this.isCollapsed() && nodes.length > 1) {
+ invariant(
+ false,
+ 'RangeSelection.getNodes() returned %s > 1 nodes in a collapsed selection',
+ String(nodes.length),
+ );
+ }
+ }
if (!isCurrentlyReadOnlyMode()) {
this._cachedNodes = nodes;
}
@@ -817,6 +869,12 @@ export class RangeSelection implements BaseSelection {
style,
);
}
+ if (endPoint.type === 'element') {
+ $setPointFromCaret(
+ endPoint,
+ $normalizeCaret($caretFromPoint(endPoint, 'next')),
+ );
+ }
const startOffset = firstPoint.offset;
let endOffset = endPoint.offset;
const selectedNodes = this.getNodes();
@@ -1145,77 +1203,11 @@ export class RangeSelection implements BaseSelection {
* Removes the text in the Selection, adjusting the EditorState accordingly.
*/
removeText(): void {
- if (this.isCollapsed()) {
- return;
- }
- const {anchor, focus} = this;
- const selectedNodes = this.getNodes();
- const firstPoint = this.isBackward() ? focus : anchor;
- const lastPoint = this.isBackward() ? anchor : focus;
- let firstNode = firstPoint.getNode();
- let lastNode = lastPoint.getNode();
- const firstBlock = $getAncestor(firstNode, INTERNAL_$isBlock);
- const lastBlock = $getAncestor(lastNode, INTERNAL_$isBlock);
- // If a token is partially selected then move the selection to cover the whole selection
- if (
- $isTextNode(firstNode) &&
- firstNode.isToken() &&
- firstPoint.offset < firstNode.getTextContentSize()
- ) {
- firstPoint.set(firstNode.getKey(), 0, 'text');
- }
- if (lastPoint.offset > 0 && $isTextNode(lastNode) && lastNode.isToken()) {
- lastPoint.set(lastNode.getKey(), lastNode.getTextContentSize(), 'text');
- }
-
- for (const node of selectedNodes) {
- if (
- !$hasAncestor(firstNode, node) &&
- !$hasAncestor(lastNode, node) &&
- node.getKey() !== firstNode.getKey() &&
- node.getKey() !== lastNode.getKey()
- ) {
- node.remove();
- }
- }
-
- const fixText = (node: TextNode, del: number) => {
- if (node.getTextContent() === '') {
- node.remove();
- } else if (del !== 0 && $isTokenOrSegmented(node)) {
- const textNode = $createTextNode(node.getTextContent());
- textNode.setFormat(node.getFormat());
- textNode.setStyle(node.getStyle());
- return node.replace(textNode);
- }
- };
- if (firstNode === lastNode && $isTextNode(firstNode)) {
- const del = Math.abs(focus.offset - anchor.offset);
- firstNode.spliceText(firstPoint.offset, del, '', true);
- fixText(firstNode, del);
- return;
- }
- if ($isTextNode(firstNode)) {
- const del = firstNode.getTextContentSize() - firstPoint.offset;
- firstNode.spliceText(firstPoint.offset, del, '');
- firstNode = fixText(firstNode, del) || firstNode;
- }
- if ($isTextNode(lastNode)) {
- lastNode.spliceText(0, lastPoint.offset, '');
- lastNode = fixText(lastNode, lastPoint.offset) || lastNode;
- }
- if (firstNode.isAttached() && $isTextNode(firstNode)) {
- firstNode.selectEnd();
- } else if (lastNode.isAttached() && $isTextNode(lastNode)) {
- lastNode.selectStart();
- }
-
- // Merge blocks
- const bothElem = $isElementNode(firstBlock) && $isElementNode(lastBlock);
- if (bothElem && firstBlock !== lastBlock) {
- firstBlock.append(...lastBlock.getChildren());
- lastBlock.remove();
- lastPoint.set(firstPoint.key, firstPoint.offset, firstPoint.type);
+ const isCurrentSelection = $getSelection() === this;
+ const newRange = $removeTextFromCaretRange($caretRangeFromSelection(this));
+ $updateRangeSelectionFromCaretRange(this, newRange);
+ if (isCurrentSelection && $getSelection() !== this) {
+ $setSelection(this);
}
}
diff --git a/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts b/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts
index f0116549f43..0c6416f7956 100644
--- a/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts
+++ b/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts
@@ -6,8 +6,15 @@
*
*/
-import {$createLinkNode, $isLinkNode} from '@lexical/link';
+import {$createLinkNode, $isLinkNode, LinkNode} from '@lexical/link';
import {
+ $createListItemNode,
+ $createListNode,
+ ListItemNode,
+ ListNode,
+} from '@lexical/list';
+import {
+ $createLineBreakNode,
$createParagraphNode,
$createRangeSelection,
$createTextNode,
@@ -15,6 +22,7 @@ import {
$getSelection,
$isParagraphNode,
$isTextNode,
+ $selectAll,
$setSelection,
createEditor,
ElementNode,
@@ -764,7 +772,9 @@ describe('LexicalSelection tests', () => {
'text',
);
$setSelection(sel);
+ expect($getSelection()).toBe(sel);
sel.removeText();
+ expect($getSelection()).toBe(sel);
expect(leadingText.isAttached()).toBe(true);
expect(trailingSegmentedText.isAttached()).toBe(false);
const allTextNodes = $getRoot().getAllTextNodes();
@@ -835,3 +845,249 @@ describe('Regression tests for #6701', () => {
);
});
});
+
+describe('getNodes()', () => {
+ initializeUnitTest((testEnv) => {
+ let paragraphNode: ParagraphNode;
+ let paragraphText: TextNode;
+ let linkNode: LinkNode;
+ let linkText: TextNode;
+ let listNode: ListNode;
+ let listItemText1: TextNode;
+ let listItemText2: TextNode;
+ let listItem1: ListItemNode;
+ let listItem2: ListItemNode;
+ let emptyParagraph: ParagraphNode;
+
+ beforeEach(() => {
+ testEnv.editor.update(() => {
+ paragraphText = $createTextNode('paragraph text');
+ linkText = $createTextNode('link text');
+ linkNode = $createLinkNode().append(linkText);
+ paragraphNode = $createParagraphNode().append(paragraphText, linkNode);
+ listItemText1 = $createTextNode('item 1');
+ listItemText2 = $createTextNode('item 2');
+ listItem1 = $createListItemNode().append(listItemText1);
+ listItem2 = $createListItemNode().append(listItemText2);
+ listNode = $createListNode('bullet').append(listItem1, listItem2);
+ emptyParagraph = $createParagraphNode();
+ $getRoot().clear().append(paragraphNode, listNode, emptyParagraph);
+ });
+ });
+ test('$selectAll()', () => {
+ testEnv.editor.update(
+ () => {
+ const selection = $selectAll();
+ // Normalized to the text nodes
+ expect(selection).toMatchObject({
+ anchor: {key: paragraphText.getKey(), offset: 0, type: 'text'},
+ focus: {key: emptyParagraph.getKey(), offset: 0, type: 'element'},
+ });
+ expect(selection.getNodes()).toEqual([
+ paragraphText,
+ linkNode,
+ linkText,
+ // The parent paragraphNode comes after its children because the
+ // selection started inside of it at paragraphText
+ paragraphNode,
+ listNode,
+ listItem1,
+ listItemText1,
+ listItem2,
+ listItemText2,
+ emptyParagraph,
+ ]);
+ },
+ {discrete: true},
+ );
+ });
+ test('$selectAll() after removing empty paragraph', () => {
+ testEnv.editor.update(
+ () => {
+ emptyParagraph.remove();
+ const selection = $selectAll();
+ // Normalized to the text nodes
+ expect(selection).toMatchObject({
+ anchor: {key: paragraphText.getKey(), offset: 0, type: 'text'},
+ focus: {
+ key: listItemText2.getKey(),
+ offset: listItemText2.getTextContentSize(),
+ type: 'text',
+ },
+ });
+ expect(selection.getNodes()).toEqual(
+ [
+ paragraphText,
+ linkNode,
+ linkText,
+ // The parent paragraphNode comes after its children because the
+ // selection started inside of it at paragraphText
+ paragraphNode,
+ listNode,
+ listItem1,
+ listItemText1,
+ listItem2,
+ listItemText2,
+ ].map((n) => n.getLatest()),
+ );
+ },
+ {discrete: true},
+ );
+ });
+ test('Manual select all without normalization', () => {
+ testEnv.editor.update(
+ () => {
+ const selection = $createRangeSelection();
+ selection.anchor.set('root', 0, 'element');
+ selection.focus.set('root', $getRoot().getChildrenSize(), 'element');
+ expect(selection.getNodes()).toEqual([
+ paragraphText,
+ linkNode,
+ linkText,
+ // The parent paragraphNode comes later because there is
+ // an implicit normalization in the beginning of getNodes
+ // to work around… something? See the getDescendantByIndex usage.
+ paragraphNode,
+ listNode,
+ listItem1,
+ listItemText1,
+ listItem2,
+ listItemText2,
+ emptyParagraph,
+ ]);
+ },
+ {discrete: true},
+ );
+ });
+ test('Manual select all from first text to last empty paragraph', () => {
+ testEnv.editor.update(
+ () => {
+ const selection = $createRangeSelection();
+ selection.anchor.set(paragraphText.getKey(), 0, 'text');
+ selection.focus.set(emptyParagraph.getKey(), 0, 'element');
+ expect(selection.getNodes()).toEqual([
+ paragraphText,
+ linkNode,
+ linkText,
+ // The parent paragraphNode comes later because there is
+ // an implicit normalization in the beginning of getNodes
+ // to work around… something? See the getDescendantByIndex usage.
+ paragraphNode,
+ listNode,
+ listItem1,
+ listItemText1,
+ listItem2,
+ listItemText2,
+ emptyParagraph,
+ ]);
+ },
+ {discrete: true},
+ );
+ });
+ test('select only the paragraph (not normalized)', () => {
+ testEnv.editor.update(
+ () => {
+ const selection = paragraphNode.select(
+ 0,
+ paragraphNode.getChildrenSize(),
+ );
+ expect(selection).toMatchObject({
+ anchor: {key: paragraphNode.getKey(), offset: 0, type: 'element'},
+ focus: {
+ key: paragraphNode.getKey(),
+ offset: paragraphNode.getChildrenSize(),
+ type: 'element',
+ },
+ });
+ // The selection doesn't visit outside of the paragraph
+ expect(selection.getNodes()).toEqual([
+ paragraphText,
+ linkNode,
+ linkText,
+ ]);
+ },
+ {discrete: true},
+ );
+ });
+ test('select around the paragraph (not normalized)', () => {
+ testEnv.editor.update(
+ () => {
+ const selection = $createRangeSelection();
+ selection.anchor.set(
+ 'root',
+ paragraphNode.getIndexWithinParent(),
+ 'element',
+ );
+ selection.focus.set(
+ 'root',
+ paragraphNode.getIndexWithinParent() + 1,
+ 'element',
+ );
+ expect(selection).toMatchObject({
+ anchor: {key: 'root', offset: 0, type: 'element'},
+ focus: {key: 'root', offset: 1, type: 'element'},
+ });
+ // The selection shouldn't visit outside of the paragraph
+ expect(selection.getNodes()).toEqual([
+ paragraphText,
+ linkNode,
+ linkText,
+ paragraphNode,
+ ]);
+ },
+ {discrete: true},
+ );
+ });
+ test('select an empty ListItemNode (collapsed)', () => {
+ testEnv.editor.update(
+ () => {
+ const emptyListItem = $createListItemNode();
+ listItem2.insertBefore(emptyListItem);
+ const selection = $createRangeSelection();
+ selection.anchor.set(emptyListItem.getKey(), 0, 'element');
+ selection.focus.set(emptyListItem.getKey(), 0, 'element');
+ expect(selection).toMatchObject({
+ anchor: {key: emptyListItem.getKey(), offset: 0, type: 'element'},
+ focus: {key: emptyListItem.getKey(), offset: 0, type: 'element'},
+ });
+ expect(selection.getNodes()).toEqual([emptyListItem]);
+ },
+ {discrete: true},
+ );
+ });
+ });
+});
+
+describe('Regression #7081', () => {
+ initializeUnitTest((testEnv) => {
+ test('Firefox selection & paste before linebreak', () => {
+ testEnv.editor.update(
+ () => {
+ const textNode =
+ $createTextNode('XXXX').setStyle(`color: --color-test`);
+ const paragraphNode = $createParagraphNode();
+ $getRoot()
+ .clear()
+ .append(
+ paragraphNode.append(
+ $createTextNode('ID: '),
+ textNode,
+ $createLineBreakNode(),
+ $createTextNode('aa'),
+ ),
+ );
+ const selection = textNode.select(0);
+ selection.focus.set(
+ paragraphNode.getKey(),
+ 1 + textNode.getIndexWithinParent(),
+ 'element',
+ );
+ selection.insertText('123');
+ expect(textNode.isAttached()).toBe(true);
+ expect(textNode.getTextContent()).toBe('123');
+ },
+ {discrete: true},
+ );
+ });
+ });
+});
diff --git a/packages/lexical/src/caret/LexicalCaret.ts b/packages/lexical/src/caret/LexicalCaret.ts
new file mode 100644
index 00000000000..4f35d87ad61
--- /dev/null
+++ b/packages/lexical/src/caret/LexicalCaret.ts
@@ -0,0 +1,1175 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+import type {LexicalNode, NodeKey} from '../LexicalNode';
+
+import invariant from 'shared/invariant';
+
+import {$isRootOrShadowRoot} from '../LexicalUtils';
+import {$isElementNode, type ElementNode} from '../nodes/LexicalElementNode';
+import {$isRootNode} from '../nodes/LexicalRootNode';
+import {TextNode} from '../nodes/LexicalTextNode';
+
+/**
+ * The direction of a caret, 'next' points towards the end of the document
+ * and 'previous' points towards the beginning
+ */
+export type CaretDirection = 'next' | 'previous';
+/**
+ * A type utility to flip next and previous
+ */
+export type FlipDirection = typeof FLIP_DIRECTION[D];
+/**
+ * A sibling caret type points from a LexicalNode origin to its next or previous sibling,
+ * and a child caret type points from an ElementNode origin to its first or last child.
+ */
+export type CaretType = 'sibling' | 'child';
+/**
+ * The RootMode is specified in all caret traversals where the traversal can go up
+ * towards the root. 'root' means that it will stop at the document root,
+ * and 'shadowRoot' will stop at the document root or any shadow root
+ * (per {@link $isRootOrShadowRoot}).
+ */
+export type RootMode = 'root' | 'shadowRoot';
+
+const FLIP_DIRECTION = {
+ next: 'previous',
+ previous: 'next',
+} as const;
+
+/** @noInheritDoc */
+export interface BaseCaret<
+ T extends LexicalNode,
+ D extends CaretDirection,
+ Type,
+> extends Iterable> {
+ /** The origin node of this caret, typically this is what you will use in traversals */
+ readonly origin: T;
+ /** sibling for a SiblingCaret (pointing at the next or previous sibling) or child for a ChildCaret (pointing at the first or last child) */
+ readonly type: Type;
+ /** next if pointing at the next sibling or first child, previous if pointing at the previous sibling or last child */
+ readonly direction: D;
+ /** Get the ElementNode that is the logical parent (`origin` for `ChildCaret`, `origin.getParent()` for `SiblingCaret`) */
+ getParentAtCaret: () => null | ElementNode;
+ /** Get the node connected to the origin in the caret's direction, or null if there is no node */
+ getNodeAtCaret: () => null | LexicalNode;
+ /** Get a new SiblingCaret from getNodeAtCaret() in the same direction. */
+ getAdjacentCaret: () => null | SiblingCaret;
+ /**
+ * Get a new SiblingCaret with this same node
+ */
+ getSiblingCaret: () => SiblingCaret;
+ /** Remove the getNodeAtCaret() node that this caret is pointing towards, if it exists */
+ remove: () => this;
+ /**
+ * Insert a node connected to origin in this direction (before the node that this caret is pointing towards, if any existed).
+ * For a `SiblingCaret` this is `origin.insertAfter(node)` for next, or `origin.insertBefore(node)` for previous.
+ * For a `ChildCaret` this is `origin.splice(0, 0, [node])` for next or `origin.append(node)` for previous.
+ */
+ insert: (node: LexicalNode) => this;
+ /** If getNodeAtCaret() is not null then replace it with node, otherwise insert node */
+ replaceOrInsert: (node: LexicalNode, includeChildren?: boolean) => this;
+ /**
+ * Splice an iterable (typically an Array) of nodes into this location.
+ *
+ * @param deleteCount The number of existing nodes to replace or delete
+ * @param nodes An iterable of nodes that will be inserted in this location, using replace instead of insert for the first deleteCount nodes
+ * @param nodesDirection The direction of the nodes iterable, defaults to 'next'
+ */
+ splice: (
+ deleteCount: number,
+ nodes: Iterable,
+ nodesDirection?: CaretDirection,
+ ) => this;
+}
+
+/**
+ * A RangeSelection expressed as a pair of Carets
+ */
+export interface CaretRange
+ extends Iterable> {
+ readonly type: 'node-caret-range';
+ readonly direction: D;
+ anchor: PointCaret;
+ focus: PointCaret;
+ /** Return true if anchor and focus are the same caret */
+ isCollapsed: () => boolean;
+ /**
+ * Iterate the carets between anchor and focus in a pre-order fashion, note
+ * that this does not include any text slices represented by the anchor and/or
+ * focus. Those are accessed separately from getTextSlices.
+ *
+ * An ElementNode origin will be yielded as a ChildCaret on enter,
+ * and a SiblingCaret on leave.
+ */
+ iterNodeCarets: (rootMode?: RootMode) => IterableIterator>;
+ /**
+ * There are between zero and two non-null TextSliceCarets for a CaretRange.
+ * Note that when anchor and focus share an origin node the second element
+ * will be null becaues the slice is entirely represented by the first element.
+ *
+ * `[slice, slice]`: anchor and focus are TextPointCaret with distinct origin nodes
+ * `[slice, null]`: anchor is a TextPointCaret
+ * `[null, slice]`: focus is a TextPointCaret
+ * `[null, null]`: Neither anchor nor focus are TextPointCarets
+ */
+ getTextSlices: () => TextPointCaretSliceTuple;
+}
+
+export interface StepwiseIteratorConfig {
+ readonly initial: State | Stop;
+ readonly hasNext: (value: State | Stop) => value is State;
+ readonly step: (value: State) => State | Stop;
+ readonly map: (value: State) => Value;
+}
+
+/**
+ * A NodeCaret is the combination of an origin node and a direction
+ * that points towards where a connected node will be fetched, inserted,
+ * or replaced. A SiblingCaret points from a node to its next or previous
+ * sibling, and a ChildCaret points to its first or last child
+ * (using next or previous as direction, for symmetry with SiblingCaret).
+ *
+ * The differences between NodeCaret and PointType are:
+ * - NodeCaret can only be used to refer to an entire node (PointCaret is used when a full analog is needed). A PointType of text type can be used to refer to a specific location inside of a TextNode.
+ * - NodeCaret stores an origin node, type (sibling or child), and direction (next or previous). A PointType stores a type (text or element), the key of a node, and a text or child offset within that node.
+ * - NodeCaret is directional and always refers to a very specific node, eliminating all ambiguity. PointType can refer to the location before or at a node depending on context.
+ * - NodeCaret is more robust to nearby mutations, as it relies only on a node's direct connections. An element Any change to the count of previous siblings in an element PointType will invalidate it.
+ * - NodeCaret is designed to work more directly with the internal representation of the document tree, making it suitable for use in traversals without performing any redundant work.
+ *
+ * The caret does *not* update in response to any mutations, you should
+ * not persist it across editor updates, and using a caret after its origin
+ * node has been removed or replaced may result in runtime errors.
+ */
+export type NodeCaret =
+ | SiblingCaret
+ | ChildCaret;
+
+/**
+ * A PointCaret is a NodeCaret that also includes a
+ * TextPointCaret type which refers to a specific offset of a TextNode.
+ * This type is separate because it is not relevant to general node traversal
+ * so it doesn't make sense to have it show up except when defining
+ * a CaretRange and in those cases there will be at most two of them only
+ * at the boundaries.
+ *
+ * The addition of TextPointCaret allows this type to represent any location
+ * that is representable by PointType, as the TextPointCaret refers to a
+ * specific offset within a TextNode.
+ */
+export type PointCaret =
+ | TextPointCaret
+ | SiblingCaret
+ | ChildCaret;
+
+/**
+ * A SiblingCaret points from an origin LexicalNode towards its next or previous sibling.
+ */
+export interface SiblingCaret<
+ T extends LexicalNode = LexicalNode,
+ D extends CaretDirection = CaretDirection,
+> extends BaseCaret {
+ /** Get a new caret with the latest origin pointer */
+ getLatest: () => SiblingCaret;
+ /**
+ * If the origin of this node is an ElementNode, return the ChildCaret of this origin in the same direction.
+ * If the origin is not an ElementNode, this will return null.
+ */
+ getChildCaret: () => null | ChildCaret;
+ /**
+ * Get the caret in the same direction from the parent of this origin.
+ *
+ * @param mode 'root' to return null at the root, 'shadowRoot' to return null at the root or any shadow root
+ * @returns A SiblingCaret with the parent of this origin, or null if the parent is a root according to mode.
+ */
+ getParentCaret: (mode?: RootMode) => null | SiblingCaret;
+ /**
+ * Retun true if other is a SiblingCaret or TextPointCaret with the same
+ * origin (by node key comparion) and direction.
+ */
+ isSameNodeCaret: (
+ other: null | undefined | PointCaret,
+ ) => other is SiblingCaret | T extends TextNode
+ ? TextPointCaret
+ : never;
+ /**
+ * Retun true if other is a SiblingCaret with the same
+ * origin (by node key comparion) and direction.
+ */
+ isSamePointCaret: (
+ other: null | undefined | PointCaret,
+ ) => other is SiblingCaret;
+ /**
+ * Get a new NodeCaret with the head and tail of its directional arrow flipped, such that flipping twice is the identity.
+ * For example, given a non-empty parent with a firstChild and lastChild, and a second emptyParent node with no children:
+ *
+ * @example
+ * ```
+ * caret.getFlipped().getFlipped().is(caret) === true;
+ * $getChildCaret(parent, 'next').getFlipped().is($getSiblingCaret(firstChild, 'previous')) === true;
+ * $getSiblingCaret(lastChild, 'next').getFlipped().is($getChildCaret(parent, 'previous')) === true;
+ * $getSiblingCaret(firstChild, 'next).getFlipped().is($getSiblingCaret(lastChild, 'previous')) === true;
+ * $getChildCaret(emptyParent, 'next').getFlipped().is($getChildCaret(emptyParent, 'previous')) === true;
+ * ```
+ */
+ getFlipped: () => NodeCaret>;
+}
+
+/**
+ * A ChildCaret points from an origin ElementNode towards its first or last child.
+ */
+export interface ChildCaret<
+ T extends ElementNode = ElementNode,
+ D extends CaretDirection = CaretDirection,
+> extends BaseCaret {
+ /** Get a new caret with the latest origin pointer */
+ getLatest: () => ChildCaret;
+ getParentCaret: (mode?: RootMode) => null | SiblingCaret;
+ getParentAtCaret: () => T;
+ /** Return this, the ChildCaret is already a child caret of its origin */
+ getChildCaret: () => this;
+ /**
+ * Retun true if other is a ChildCaret with the same
+ * origin (by node key comparion) and direction.
+ */
+ isSameNodeCaret: (
+ other: null | undefined | PointCaret,
+ ) => other is ChildCaret;
+ /**
+ * Retun true if other is a ChildCaret with the same
+ * origin (by node key comparion) and direction.
+ */
+ isSamePointCaret: (
+ other: null | undefined | PointCaret,
+ ) => other is ChildCaret;
+ /**
+ * Get a new NodeCaret with the head and tail of its directional arrow flipped, such that flipping twice is the identity.
+ * For example, given a non-empty parent with a firstChild and lastChild, and a second emptyParent node with no children:
+ *
+ * @example
+ * ```
+ * caret.getFlipped().getFlipped().is(caret) === true;
+ * $getChildCaret(parent, 'next').getFlipped().is($getSiblingCaret(firstChild, 'previous')) === true;
+ * $getSiblingCaret(lastChild, 'next').getFlipped().is($getChildCaret(parent, 'previous')) === true;
+ * $getSiblingCaret(firstChild, 'next).getFlipped().is($getSiblingCaret(lastChild, 'previous')) === true;
+ * $getChildCaret(emptyParent, 'next').getFlipped().is($getChildCaret(emptyParent, 'previous')) === true;
+ * ```
+ */
+ getFlipped: () => NodeCaret>;
+}
+
+/**
+ * A TextPointCaret is a special case of a SiblingCaret that also carries
+ * an offset used for representing partially selected TextNode at the edges
+ * of a CaretRange.
+ *
+ * The direction determines which part of the text is adjacent to the caret,
+ * if next it's all of the text after offset. If previous, it's all of the
+ * text before offset.
+ *
+ * While this can be used in place of any SiblingCaret of a TextNode,
+ * the offset into the text will be ignored except in contexts that
+ * specifically use the TextPointCaret or PointCaret types.
+ */
+export interface TextPointCaret<
+ T extends TextNode = TextNode,
+ D extends CaretDirection = CaretDirection,
+> extends BaseCaret {
+ /** The offset into the string */
+ readonly offset: number;
+ /** Get a new caret with the latest origin pointer */
+ getLatest: () => TextPointCaret;
+ /**
+ * A TextPointCaret can not have a ChildCaret.
+ */
+ getChildCaret: () => null;
+ /**
+ * Get the caret in the same direction from the parent of this origin.
+ *
+ * @param mode 'root' to return null at the root, 'shadowRoot' to return null at the root or any shadow root
+ * @returns A SiblingCaret with the parent of this origin, or null if the parent is a root according to mode.
+ */
+ getParentCaret: (mode?: RootMode) => null | SiblingCaret;
+ /**
+ * Retun true if other is a TextPointCaret or SiblingCaret with the same
+ * origin (by node key comparion) and direction.
+ */
+ isSameNodeCaret: (
+ other: null | undefined | PointCaret,
+ ) => other is TextPointCaret | SiblingCaret;
+ /**
+ * Retun true if other is a ChildCaret with the same
+ * origin (by node key comparion) and direction.
+ */
+ isSamePointCaret: (
+ other: null | undefined | PointCaret,
+ ) => other is TextPointCaret;
+ /**
+ * Get a new TextPointCaret with the head and tail of its directional arrow flipped, such that flipping twice is the identity.
+ * For a TextPointCaret this merely flips the direction because the arrow is internal to the node.
+ *
+ * @example
+ * ```
+ * caret.getFlipped().getFlipped().is(caret) === true;
+ * ```
+ */
+ getFlipped: () => TextPointCaret>;
+}
+
+/**
+ * A TextPointCaretSlice is a wrapper for a TextPointCaret that carries a signed
+ * distance representing the direction and amount of text selected from the given
+ * caret. A negative distance means that text before offset is selected, a
+ * positive distance means that text after offset is selected. The offset+distance
+ * pair is not affected in any way by the direction of the caret.
+ */
+export interface TextPointCaretSlice<
+ T extends TextNode = TextNode,
+ D extends CaretDirection = CaretDirection,
+> {
+ readonly type: 'slice';
+ readonly caret: TextPointCaret;
+ readonly distance: number;
+ /**
+ * @returns absolute coordinates into the text (for use with `text.slice(...)`)
+ */
+ getSliceIndices: () => [startIndex: number, endIndex: number];
+ /**
+ * @returns The text represented by the slice
+ */
+ getTextContent: () => string;
+ /**
+ * @returns The size of the text represented by the slice
+ */
+ getTextContentSize: () => number;
+ /**
+ * Remove the slice of text from the contained caret, returning a new
+ * TextPointCaret without the wrapper (since the size would be zero).
+ *
+ * Note that this is a lower-level utility that does not have any specific
+ * behavior for 'segmented' or 'token' modes and it will not remove
+ * an empty TextNode.
+ *
+ * @returns The inner TextPointCaret with the same offset and direction
+ * and the latest TextNode origin after mutation
+ */
+ removeTextSlice(): TextPointCaret;
+}
+
+/**
+ * A utility type to specify that a CaretRange may have zero,
+ * one, or two associated TextPointCaretSlice. If the anchor
+ * and focus are on the same node, the anchorSlice will contain
+ * the slice and focusSlie will be null.
+ */
+export type TextPointCaretSliceTuple = readonly [
+ anchorSlice: null | TextPointCaretSlice,
+ focusSlice: null | TextPointCaretSlice,
+];
+
+abstract class AbstractCaret<
+ T extends LexicalNode,
+ D extends CaretDirection,
+ Type,
+> implements BaseCaret
+{
+ abstract readonly type: Type;
+ abstract readonly direction: D;
+ readonly origin: T;
+ abstract getNodeAtCaret(): null | LexicalNode;
+ abstract insert(node: LexicalNode): this;
+ abstract getParentAtCaret(): null | ElementNode;
+ constructor(origin: T) {
+ this.origin = origin;
+ }
+ [Symbol.iterator](): IterableIterator> {
+ return makeStepwiseIterator({
+ hasNext: $isSiblingCaret,
+ initial: this.getAdjacentCaret(),
+ map: (caret) => caret,
+ step: (caret: SiblingCaret) => caret.getAdjacentCaret(),
+ });
+ }
+ getAdjacentCaret(): null | SiblingCaret {
+ return $getSiblingCaret(this.getNodeAtCaret(), this.direction);
+ }
+ getSiblingCaret(): SiblingCaret {
+ return $getSiblingCaret(this.origin, this.direction);
+ }
+ remove(): this {
+ const node = this.getNodeAtCaret();
+ if (node) {
+ node.remove();
+ }
+ return this;
+ }
+ replaceOrInsert(node: LexicalNode, includeChildren?: boolean): this {
+ const target = this.getNodeAtCaret();
+ if (node.is(this.origin) || node.is(target)) {
+ // do nothing
+ } else if (target === null) {
+ this.insert(node);
+ } else {
+ target.replace(node, includeChildren);
+ }
+ return this;
+ }
+ splice(
+ deleteCount: number,
+ nodes: Iterable,
+ nodesDirection: CaretDirection = 'next',
+ ): this {
+ const nodeIter =
+ nodesDirection === this.direction ? nodes : Array.from(nodes).reverse();
+ let caret: SiblingCaret | this = this;
+ const parent = this.getParentAtCaret();
+ const nodesToRemove = new Map();
+ // Find all of the nodes we expect to remove first, so
+ // we don't have to worry about the cases where there is
+ // overlap between the nodes to insert and the nodes to
+ // remove
+ for (
+ let removeCaret = caret.getAdjacentCaret();
+ removeCaret !== null && nodesToRemove.size < deleteCount;
+ removeCaret = removeCaret.getAdjacentCaret()
+ ) {
+ const writableNode = removeCaret.origin.getWritable();
+ nodesToRemove.set(writableNode.getKey(), writableNode);
+ }
+ // TODO: Optimize this to work directly with node internals
+ for (const node of nodeIter) {
+ if (nodesToRemove.size > 0) {
+ // For some reason `npm run tsc-extension` needs this annotation?
+ const target: null | LexicalNode = caret.getNodeAtCaret();
+ if (target) {
+ nodesToRemove.delete(target.getKey());
+ nodesToRemove.delete(node.getKey());
+ if (target.is(node) || caret.origin.is(node)) {
+ // do nothing, it's already in the right place
+ } else {
+ const nodeParent = node.getParent();
+ if (nodeParent && nodeParent.is(parent)) {
+ // It's a sibling somewhere else in this node, so unparent it first
+ node.remove();
+ }
+ target.replace(node);
+ }
+ } else {
+ invariant(
+ target !== null,
+ 'NodeCaret.splice: Underflow of expected nodesToRemove during splice (keys: %s)',
+ Array.from(nodesToRemove).join(' '),
+ );
+ }
+ } else {
+ caret.insert(node);
+ }
+ caret = $getSiblingCaret(node, this.direction);
+ }
+ for (const node of nodesToRemove.values()) {
+ node.remove();
+ }
+ return this;
+ }
+}
+
+abstract class AbstractChildCaret<
+ T extends ElementNode,
+ D extends CaretDirection,
+ >
+ extends AbstractCaret
+ implements ChildCaret
+{
+ readonly type = 'child';
+ getLatest(): ChildCaret {
+ const origin = this.origin.getLatest();
+ return origin === this.origin
+ ? this
+ : $getChildCaret(origin, this.direction);
+ }
+ /**
+ * Get the SiblingCaret from this origin in the same direction.
+ *
+ * @param mode 'root' to return null at the root, 'shadowRoot' to return null at the root or any shadow root
+ * @returns A SiblingCaret with this origin, or null if origin is a root according to mode.
+ */
+ getParentCaret(mode: RootMode = 'root'): null | SiblingCaret {
+ return $getSiblingCaret(
+ $filterByMode(this.getParentAtCaret(), mode),
+ this.direction,
+ );
+ }
+ getFlipped(): NodeCaret