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> { + const dir = flipDirection(this.direction); + return ( + $getSiblingCaret(this.getNodeAtCaret(), dir) || + $getChildCaret(this.origin, dir) + ); + } + getParentAtCaret(): T { + return this.origin; + } + getChildCaret(): this { + return this; + } + isSameNodeCaret( + other: null | undefined | PointCaret, + ): other is ChildCaret { + return ( + other instanceof AbstractChildCaret && + this.direction === other.direction && + this.origin.is(other.origin) + ); + } + isSamePointCaret( + other: null | undefined | PointCaret, + ): other is ChildCaret { + return this.isSameNodeCaret(other); + } +} + +class ChildCaretFirst extends AbstractChildCaret< + T, + 'next' +> { + readonly direction = 'next'; + getNodeAtCaret(): null | LexicalNode { + return this.origin.getFirstChild(); + } + insert(node: LexicalNode): this { + this.origin.splice(0, 0, [node]); + return this; + } +} + +class ChildCaretLast extends AbstractChildCaret< + T, + 'previous' +> { + readonly direction = 'previous'; + getNodeAtCaret(): null | LexicalNode { + return this.origin.getLastChild(); + } + insert(node: LexicalNode): this { + this.origin.splice(this.origin.getChildrenSize(), 0, [node]); + return this; + } +} + +const MODE_PREDICATE = { + root: $isRootNode, + shadowRoot: $isRootOrShadowRoot, +} as const; + +/** + * Flip a direction ('next' -> 'previous'; 'previous' -> 'next'). + * + * Note that TypeScript can't prove that FlipDirection is its own + * inverse (but if you have a concrete 'next' or 'previous' it will + * simplify accordingly). + * + * @param direction A direction + * @returns The opposite direction + */ +export function flipDirection( + direction: D, +): FlipDirection { + return FLIP_DIRECTION[direction]; +} + +function $filterByMode( + node: T | null, + mode: RootMode = 'root', +): T | null { + return MODE_PREDICATE[mode](node) ? null : node; +} + +abstract class AbstractSiblingCaret< + T extends LexicalNode, + D extends CaretDirection, + > + extends AbstractCaret + implements SiblingCaret +{ + readonly type = 'sibling'; + getLatest(): SiblingCaret { + const origin = this.origin.getLatest(); + return origin === this.origin + ? this + : $getSiblingCaret(origin, this.direction); + } + getSiblingCaret(): this { + return this; + } + getParentAtCaret(): null | ElementNode { + return this.origin.getParent(); + } + getChildCaret(): ChildCaret | null { + return $isElementNode(this.origin) + ? $getChildCaret(this.origin, this.direction) + : null; + } + getParentCaret(mode: RootMode = 'root'): SiblingCaret | null { + return $getSiblingCaret( + $filterByMode(this.getParentAtCaret(), mode), + this.direction, + ); + } + getFlipped(): NodeCaret> { + const dir = flipDirection(this.direction); + return ( + $getSiblingCaret(this.getNodeAtCaret(), dir) || + $getChildCaret(this.origin.getParentOrThrow(), dir) + ); + } + isSamePointCaret( + other: null | undefined | PointCaret, + ): other is SiblingCaret { + return ( + other instanceof AbstractSiblingCaret && + this.direction === other.direction && + this.origin.is(other.origin) + ); + } + isSameNodeCaret( + other: null | undefined | PointCaret, + ): other is T | SiblingCaret extends TextNode + ? TextPointCaret + : never { + return ( + (other instanceof AbstractSiblingCaret || + other instanceof AbstractTextPointCaret) && + this.direction === other.direction && + this.origin.is(other.origin) + ); + } +} + +abstract class AbstractTextPointCaret< + T extends TextNode, + D extends CaretDirection, + > + extends AbstractCaret + implements TextPointCaret +{ + readonly type = 'text'; + readonly offset: number; + abstract readonly direction: D; + constructor(origin: T, offset: number) { + super(origin); + this.offset = offset; + } + getLatest(): TextPointCaret { + const origin = this.origin.getLatest(); + return origin === this.origin + ? this + : $getTextPointCaret(origin, this.direction, this.offset); + } + getParentAtCaret(): null | ElementNode { + return this.origin.getParent(); + } + getChildCaret(): null { + return null; + } + getParentCaret(mode: RootMode = 'root'): SiblingCaret | null { + return $getSiblingCaret( + $filterByMode(this.getParentAtCaret(), mode), + this.direction, + ); + } + getFlipped(): TextPointCaret> { + return $getTextPointCaret( + this.origin, + flipDirection(this.direction), + this.offset, + ); + } + isSamePointCaret( + other: null | undefined | PointCaret, + ): other is TextPointCaret { + return ( + other instanceof AbstractTextPointCaret && + this.direction === other.direction && + this.origin.is(other.origin) && + this.offset === other.offset + ); + } + isSameNodeCaret( + other: null | undefined | PointCaret, + ): other is SiblingCaret | TextPointCaret { + return ( + (other instanceof AbstractSiblingCaret || + other instanceof AbstractTextPointCaret) && + this.direction === other.direction && + this.origin.is(other.origin) + ); + } + getSiblingCaret(): SiblingCaret { + return $getSiblingCaret(this.origin, this.direction); + } +} +/** + * Guard to check if the given caret is specifically a TextPointCaret + * + * @param caret Any caret + * @returns true if it is a TextPointCaret + */ +export function $isTextPointCaret( + caret: null | undefined | PointCaret, +): caret is TextPointCaret { + return caret instanceof AbstractTextPointCaret; +} + +/** + * Guard to check if the given argument is any type of caret + * + * @param caret + * @returns true if caret is any type of caret + */ +export function $isNodeCaret( + caret: null | undefined | PointCaret, +): caret is PointCaret { + return caret instanceof AbstractCaret; +} + +/** + * Guard to check if the given argument is specifically a SiblingCaret (or TextPointCaret) + * + * @param caret + * @returns true if caret is a SiblingCaret + */ +export function $isSiblingCaret( + caret: null | undefined | PointCaret, +): caret is SiblingCaret { + return caret instanceof AbstractSiblingCaret; +} + +/** + * Guard to check if the given argument is specifically a ChildCaret + + * @param caret + * @returns true if caret is a ChildCaret + */ +export function $isChildCaret( + caret: null | undefined | PointCaret, +): caret is ChildCaret { + return caret instanceof AbstractChildCaret; +} + +class SiblingCaretNext extends AbstractSiblingCaret< + T, + 'next' +> { + readonly direction = 'next'; + getNodeAtCaret(): null | LexicalNode { + return this.origin.getNextSibling(); + } + insert(node: LexicalNode): this { + this.origin.insertAfter(node); + return this; + } +} + +class SiblingCaretPrevious extends AbstractSiblingCaret< + T, + 'previous' +> { + readonly direction = 'previous'; + getNodeAtCaret(): null | LexicalNode { + return this.origin.getPreviousSibling(); + } + insert(node: LexicalNode): this { + this.origin.insertBefore(node); + return this; + } +} + +class TextPointCaretNext extends AbstractTextPointCaret< + T, + 'next' +> { + readonly direction = 'next'; + getNodeAtCaret(): null | LexicalNode { + return this.origin.getNextSibling(); + } + insert(node: LexicalNode): this { + this.origin.insertAfter(node); + return this; + } +} + +class TextPointCaretPrevious extends AbstractTextPointCaret< + T, + 'previous' +> { + readonly direction = 'previous'; + getNodeAtCaret(): null | LexicalNode { + return this.origin.getPreviousSibling(); + } + insert(node: LexicalNode): this { + this.origin.insertBefore(node); + return this; + } +} + +const TEXT_CTOR = { + next: TextPointCaretNext, + previous: TextPointCaretPrevious, +} as const; + +const SIBLING_CTOR = { + next: SiblingCaretNext, + previous: SiblingCaretPrevious, +} as const; + +const CHILD_CTOR = { + next: ChildCaretFirst, + previous: ChildCaretLast, +}; + +/** + * Get a caret that points at the next or previous sibling of the given origin node. + * + * @param origin The origin node + * @param direction 'next' or 'previous' + * @returns null if origin is null, otherwise a SiblingCaret for this origin and direction + */ +export function $getSiblingCaret< + T extends LexicalNode, + D extends CaretDirection, +>(origin: T, direction: D): SiblingCaret; +export function $getSiblingCaret< + T extends LexicalNode, + D extends CaretDirection, +>(origin: null | T, direction: D): null | SiblingCaret; +export function $getSiblingCaret( + origin: null | LexicalNode, + direction: CaretDirection, +): null | SiblingCaret { + return origin ? new SIBLING_CTOR[direction](origin) : null; +} + +/** + * Construct a TextPointCaret + * + * @param origin The TextNode + * @param direction The direction (next points to the end of the text, previous points to the beginning) + * @param offset The offset into the text in absolute positive string coordinates (0 is the start) + * @returns a TextPointCaret + */ +export function $getTextPointCaret< + T extends TextNode, + D extends CaretDirection, +>( + origin: T, + direction: D, + offset: number | CaretDirection, +): TextPointCaret; +export function $getTextPointCaret< + T extends TextNode, + D extends CaretDirection, +>( + origin: null | T, + direction: D, + offset: number | CaretDirection, +): null | TextPointCaret; +export function $getTextPointCaret( + origin: TextNode | null, + direction: CaretDirection, + offset: number | CaretDirection, +): null | TextPointCaret { + return origin + ? new TEXT_CTOR[direction](origin, $getTextNodeOffset(origin, offset)) + : null; +} + +/** + * Get a normalized offset into a TextNode given a numeric offset or a + * direction for which end of the string to use. Throws if the offset + * is not in the bounds of the text content size. + * + * @param origin a TextNode + * @param offset An absolute offset into the TextNode string, or a direction for which end to use as the offset + * @returns An absolute offset into the TextNode string + */ +export function $getTextNodeOffset( + origin: TextNode, + offset: number | CaretDirection, +): number { + const size = origin.getTextContentSize(); + const numericOffset = + offset === 'next' ? size : offset === 'previous' ? 0 : offset; + invariant( + numericOffset >= 0 && numericOffset <= size, + '$getTextPointCaret: invalid offset %s for size %s', + String(offset), + String(size), + ); + return numericOffset; +} + +/** + * Construct a TextPointCaretSlice given a TextPointCaret and a signed distance. The + * distance should be negative to slice text before the caret's offset, and positive + * to slice text after the offset. The direction of the caret itself is not + * relevant to the string coordinates when working with a TextPointCaretSlice + * but mutation operations will preserve the direction. + * + * @param caret + * @param distance + * @returns TextPointCaretSlice + */ +export function $getTextPointCaretSlice< + T extends TextNode, + D extends CaretDirection, +>(caret: TextPointCaret, distance: number): TextPointCaretSlice { + return new TextPointCaretSliceImpl(caret, distance); +} + +/** + * Get a caret that points at the first or last child of the given origin node, + * which must be an ElementNode. + * + * @param origin The origin ElementNode + * @param direction 'next' for first child or 'previous' for last child + * @returns null if origin is null or not an ElementNode, otherwise a ChildCaret for this origin and direction + */ +export function $getChildCaret( + origin: T, + direction: D, +): ChildCaret; +export function $getChildCaret( + origin: null | LexicalNode, + direction: CaretDirection, +): null | ChildCaret { + return $isElementNode(origin) ? new CHILD_CTOR[direction](origin) : null; +} + +/** + * Gets the ChildCaret if one is possible at this caret origin, otherwise return the caret + */ +export function $getChildCaretOrSelf( + caret: Caret, +): Caret | ChildCaret['direction']> { + return (caret && caret.getChildCaret()) || caret; +} + +/** + * Gets the adjacent caret, if not-null and if the origin of the adjacent caret is an ElementNode, then return + * the ChildCaret. This can be used along with the getParentAdjacentCaret method to perform a full DFS + * style traversal of the tree. + * + * @param caret The caret to start at + */ +export function $getAdjacentChildCaret( + caret: null | NodeCaret, +): null | NodeCaret { + return caret && $getChildCaretOrSelf(caret.getAdjacentCaret()); +} + +class CaretRangeImpl implements CaretRange { + readonly type = 'node-caret-range'; + readonly direction: D; + anchor: PointCaret; + focus: PointCaret; + constructor(anchor: PointCaret, focus: PointCaret, direction: D) { + this.anchor = anchor; + this.focus = focus; + this.direction = direction; + } + getLatest(): CaretRange { + const anchor = this.anchor.getLatest(); + const focus = this.focus.getLatest(); + return anchor === this.anchor && focus === this.focus + ? this + : new CaretRangeImpl(anchor, focus, this.direction); + } + isCollapsed(): boolean { + return this.anchor.isSamePointCaret(this.focus); + } + getTextSlices(): TextPointCaretSliceTuple { + const getSlice = (k: 'anchor' | 'focus') => { + const caret = this[k]; + return $isTextPointCaret(caret) + ? $getSliceFromTextPointCaret(caret, k) + : null; + }; + const anchorSlice = getSlice('anchor'); + const focusSlice = getSlice('focus'); + if (anchorSlice && focusSlice) { + const {caret: anchorCaret} = anchorSlice; + const {caret: focusCaret} = focusSlice; + if (anchorCaret.isSameNodeCaret(focusCaret)) { + return [ + $getTextPointCaretSlice( + anchorCaret, + focusCaret.offset - anchorCaret.offset, + ), + null, + ]; + } + } + return [anchorSlice, focusSlice]; + } + iterNodeCarets(rootMode: RootMode = 'root'): IterableIterator> { + const anchor = $isTextPointCaret(this.anchor) + ? this.anchor.getSiblingCaret() + : this.anchor; + const {focus} = this; + const isTextFocus = $isTextPointCaret(focus); + const step = (state: NodeCaret) => + state.isSameNodeCaret(focus) + ? null + : $getAdjacentChildCaret(state) || state.getParentCaret(rootMode); + return makeStepwiseIterator({ + hasNext: (state: null | NodeCaret): state is NodeCaret => + state !== null && !(isTextFocus && focus.isSameNodeCaret(state)), + initial: anchor.isSameNodeCaret(focus) ? null : step(anchor), + map: (state) => state, + step, + }); + } + [Symbol.iterator](): IterableIterator> { + return this.iterNodeCarets('root'); + } +} + +class TextPointCaretSliceImpl + implements TextPointCaretSlice +{ + readonly type = 'slice'; + readonly caret: TextPointCaret; + readonly distance: number; + constructor(caret: TextPointCaret, distance: number) { + this.caret = caret; + this.distance = distance; + } + getSliceIndices(): [startIndex: number, endIndex: number] { + const { + distance, + caret: {offset}, + } = this; + const offsetB = offset + distance; + return offsetB < offset ? [offsetB, offset] : [offset, offsetB]; + } + + getTextContent(): string { + const [startIndex, endIndex] = this.getSliceIndices(); + return this.caret.origin.getTextContent().slice(startIndex, endIndex); + } + + getTextContentSize(): number { + return Math.abs(this.distance); + } + + removeTextSlice(): TextPointCaret { + const { + caret: {origin, direction}, + } = this; + const [indexStart, indexEnd] = this.getSliceIndices(); + const text = origin.getTextContent(); + return $getTextPointCaret( + origin.setTextContent(text.slice(0, indexStart) + text.slice(indexEnd)), + direction, + indexStart, + ); + } +} + +function $getSliceFromTextPointCaret< + T extends TextNode, + D extends CaretDirection, +>( + caret: TextPointCaret, + anchorOrFocus: 'anchor' | 'focus', +): TextPointCaretSlice { + const {direction, origin} = caret; + const offsetB = $getTextNodeOffset( + origin, + anchorOrFocus === 'focus' ? flipDirection(direction) : direction, + ); + return $getTextPointCaretSlice(caret, offsetB - caret.offset); +} + +/** + * Guard to check for a TextPointCaretSlice + * + * @param caretOrSlice A caret or slice + * @returns true if caretOrSlice is a TextPointCaretSlice + */ +export function $isTextPointCaretSlice( + caretOrSlice: + | null + | undefined + | PointCaret + | TextPointCaretSlice, +): caretOrSlice is TextPointCaretSlice { + return caretOrSlice instanceof TextPointCaretSliceImpl; +} + +/** + * Construct a CaretRange from anchor and focus carets pointing in the + * same direction. In order to get the expected behavior, + * the anchor must point towards the focus or be the same point. + * + * In the 'next' direction the anchor should be at or before the + * focus in the document. In the 'previous' direction the anchor + * should be at or after the focus in the document + * (similar to a backwards RangeSelection). + * + * @param anchor + * @param focus + * @returns a CaretRange + */ +export function $getCaretRange( + anchor: PointCaret, + focus: PointCaret, +): CaretRange { + invariant( + anchor.direction === focus.direction, + '$getCaretRange: anchor and focus must be in the same direction', + ); + return new CaretRangeImpl(anchor, focus, anchor.direction); +} + +/** + * A generalized utility for creating a stepwise iterator + * based on: + * + * - an initial state + * - a stop guard that returns true if the iteration is over, this + * is typically used to detect a sentinel value such as null or + * undefined from the state but may return true for other conditions + * as well + * - a step function that advances the state (this will be called + * after map each time next() is called to prepare the next state) + * - a map function that will be called that may transform the state + * before returning it. It will only be called once for each next() + * call when stop(state) === false + * + * @param config + * @returns An IterableIterator + */ +export function makeStepwiseIterator( + config: StepwiseIteratorConfig, +): IterableIterator { + const {initial, hasNext, step, map} = config; + let state = initial; + return { + [Symbol.iterator]() { + return this; + }, + next(): IteratorResult { + if (!hasNext(state)) { + return {done: true, value: undefined}; + } + const rval = {done: false, value: map(state)}; + state = step(state); + return rval; + }, + }; +} diff --git a/packages/lexical/src/caret/LexicalCaretUtils.ts b/packages/lexical/src/caret/LexicalCaretUtils.ts new file mode 100644 index 00000000000..7412819908c --- /dev/null +++ b/packages/lexical/src/caret/LexicalCaretUtils.ts @@ -0,0 +1,504 @@ +/** + * 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 type { + CaretDirection, + CaretRange, + ChildCaret, + NodeCaret, + PointCaret, + RootMode, + SiblingCaret, + TextPointCaret, +} from './LexicalCaret'; + +import invariant from 'shared/invariant'; + +import { + $createRangeSelection, + $getSelection, + $isRangeSelection, + type PointType, + type RangeSelection, +} from '../LexicalSelection'; +import { + $getAncestor, + $getNodeByKeyOrThrow, + $setSelection, + INTERNAL_$isBlock, +} from '../LexicalUtils'; +import {$isElementNode, type ElementNode} from '../nodes/LexicalElementNode'; +import { + $createTextNode, + $isTextNode, + type TextNode, +} from '../nodes/LexicalTextNode'; +import { + $getAdjacentChildCaret, + $getCaretRange, + $getChildCaret, + $getSiblingCaret, + $getTextNodeOffset, + $getTextPointCaret, + $isChildCaret, + $isSiblingCaret, + $isTextPointCaret, + flipDirection, +} from './LexicalCaret'; + +/** + * @param point + * @returns a PointCaret for the point + */ +export function $caretFromPoint( + point: PointType, + direction: D, +): PointCaret { + const {type, key, offset} = point; + const node = $getNodeByKeyOrThrow(point.key); + if (type === 'text') { + invariant( + $isTextNode(node), + '$caretFromPoint: Node with type %s and key %s that does not inherit from TextNode encountered for text point', + node.getType(), + key, + ); + return $getTextPointCaret(node, direction, offset); + } + invariant( + $isElementNode(node), + '$caretFromPoint: Node with type %s and key %s that does not inherit from ElementNode encountered for element point', + node.getType(), + key, + ); + return $getChildCaretAtIndex(node, point.offset, direction); +} + +/** + * Update the given point in-place from the PointCaret + * + * @param point the point to set + * @param caret the caret to set the point from + */ +export function $setPointFromCaret( + point: PointType, + caret: PointCaret, +): void { + const {origin, direction} = caret; + const isNext = direction === 'next'; + if ($isTextPointCaret(caret)) { + point.set(origin.getKey(), caret.offset, 'text'); + } else if ($isSiblingCaret(caret)) { + if ($isTextNode(origin)) { + point.set(origin.getKey(), $getTextNodeOffset(origin, direction), 'text'); + } else { + point.set( + origin.getParentOrThrow().getKey(), + origin.getIndexWithinParent() + (isNext ? 1 : 0), + 'element', + ); + } + } else { + invariant( + $isChildCaret(caret) && $isElementNode(origin), + '$setPointFromCaret: exhaustiveness check', + ); + point.set( + origin.getKey(), + isNext ? 0 : origin.getChildrenSize(), + 'element', + ); + } +} + +/** + * Set a RangeSelection on the editor from the given CaretRange + * + * @returns The new RangeSelection + */ +export function $setSelectionFromCaretRange( + caretRange: CaretRange, +): RangeSelection { + const currentSelection = $getSelection(); + const selection = $isRangeSelection(currentSelection) + ? currentSelection + : $createRangeSelection(); + $updateRangeSelectionFromCaretRange(selection, caretRange); + $setSelection(selection); + return selection; +} + +/** + * Update the points of a RangeSelection based on the given PointCaret. + */ +export function $updateRangeSelectionFromCaretRange( + selection: RangeSelection, + caretRange: CaretRange, +): void { + $setPointFromCaret(selection.anchor, caretRange.anchor); + $setPointFromCaret(selection.focus, caretRange.focus); +} + +/** + * Get a pair of carets for a RangeSelection. + * + * If the focus is before the anchor, then the direction will be + * 'previous', otherwise the direction will be 'next'. + */ +export function $caretRangeFromSelection( + selection: RangeSelection, +): CaretRange { + const {anchor, focus} = selection; + const direction = focus.isBefore(anchor) ? 'previous' : 'next'; + return $getCaretRange( + $caretFromPoint(anchor, direction), + $caretFromPoint(focus, direction), + ); +} + +/** + * Given a SiblingCaret we can always compute a caret that points to the + * origin of that caret in the same direction. The adjacent caret of the + * returned caret will be equivalent to the given caret. + * + * @example + * ```ts + * siblingCaret.is($rewindSiblingCaret(siblingCaret).getAdjacentCaret()) + * ``` + * + * @param caret The caret to "rewind" + * @returns A new caret (ChildCaret or SiblingCaret) with the same direction + */ +export function $rewindSiblingCaret< + T extends LexicalNode, + D extends CaretDirection, +>(caret: SiblingCaret): NodeCaret { + const {direction, origin} = caret; + // Rotate the direction around the origin and get the adjacent node + const rewindOrigin = $getSiblingCaret( + origin, + flipDirection(direction), + ).getNodeAtCaret(); + return rewindOrigin + ? $getSiblingCaret(rewindOrigin, direction) + : $getChildCaret(origin.getParentOrThrow(), direction); +} + +function $getAnchorCandidates( + anchor: PointCaret, + rootMode: RootMode = 'root', +): [PointCaret, ...NodeCaret[]] { + // These candidates will be the anchor itself, the pointer to the anchor (if different), and then any parents of that + const carets: [PointCaret, ...NodeCaret[]] = [anchor]; + for ( + let parent = $isChildCaret(anchor) + ? anchor.getParentCaret(rootMode) + : anchor.getSiblingCaret(); + parent !== null; + parent = parent.getParentCaret(rootMode) + ) { + carets.push($rewindSiblingCaret(parent)); + } + return carets; +} + +/** + * Remove all text and nodes in the given range. If the range spans multiple + * blocks then the remaining contents of the later block will be merged with + * the earlier block. + * + * @param range The range to remove text and nodes from + * @param sliceMode If 'preserveEmptyTextPointCaret' it will leave an empty TextPointCaret at the anchor for insert if one exists, otherwise empty slices will be removed + * @returns The new collapsed range (biased towards the earlier node) + */ +export function $removeTextFromCaretRange( + initialRange: CaretRange, + sliceMode: + | 'removeEmptySlices' + | 'preserveEmptyTextSliceCaret' = 'removeEmptySlices', +): CaretRange { + if (initialRange.isCollapsed()) { + return initialRange; + } + // Always process removals in document order + const rootMode = 'root'; + const nextDirection = 'next'; + let sliceState = sliceMode; + const range = $getCaretRangeInDirection(initialRange, nextDirection); + + const anchorCandidates = $getAnchorCandidates(range.anchor, rootMode); + const focusCandidates = $getAnchorCandidates( + range.focus.getFlipped(), + rootMode, + ); + + // Mark the start of each ElementNode + const seenStart = new Set(); + // Queue removals since removing the only child can cascade to having + // a parent remove itself which will affect iteration + const removedNodes: LexicalNode[] = []; + for (const caret of range.iterNodeCarets(rootMode)) { + if ($isChildCaret(caret)) { + seenStart.add(caret.origin.getKey()); + } else if ($isSiblingCaret(caret)) { + const {origin} = caret; + if (!$isElementNode(origin) || seenStart.has(origin.getKey())) { + removedNodes.push(origin); + } + } + } + for (const node of removedNodes) { + node.remove(); + } + + // Splice text at the anchor and/or origin. + // If the text is entirely selected then it is removed (unless it is the first slice and sliceMode is preserveEmptyTextSliceCaret). + // If it's a token with a non-empty selection then it is removed. + // Segmented nodes will be copied to a plain text node with the same format + // and style and set to normal mode. + for (const slice of range.getTextSlices()) { + if (!slice) { + continue; + } + const {origin} = slice.caret; + const contentSize = origin.getTextContentSize(); + const caretBefore = $rewindSiblingCaret( + $getSiblingCaret(origin, nextDirection), + ); + const mode = origin.getMode(); + if ( + (Math.abs(slice.distance) === contentSize && + sliceState === 'removeEmptySlices') || + (mode === 'token' && slice.distance !== 0) + ) { + // anchorCandidates[1] should still be valid, it is caretBefore + caretBefore.remove(); + } else if (slice.distance !== 0) { + sliceState = 'removeEmptySlices'; + let nextCaret = slice.removeTextSlice(); + if (mode === 'segmented') { + const src = nextCaret.origin; + const plainTextNode = $createTextNode(src.getTextContent()) + .setStyle(src.getStyle()) + .setFormat(src.getFormat()); + caretBefore.replaceOrInsert(plainTextNode); + nextCaret = $getTextPointCaret( + plainTextNode, + nextDirection, + nextCaret.offset, + ); + } + if (anchorCandidates[0].isSameNodeCaret(slice.caret)) { + anchorCandidates[0] = nextCaret; + } + } + } + + for (const candidates of [anchorCandidates, focusCandidates]) { + const deleteCount = candidates.findIndex((caret) => + caret.origin.isAttached(), + ); + candidates.splice(0, deleteCount); + } + + const anchorCandidate = anchorCandidates.find((v) => v.origin.isAttached()); + const focusCandidate = focusCandidates.find((v) => v.origin.isAttached()); + + // Merge blocks if necessary + const anchorBlock = + anchorCandidate && $getAncestor(anchorCandidate.origin, INTERNAL_$isBlock); + const focusBlock = + focusCandidate && $getAncestor(focusCandidate.origin, INTERNAL_$isBlock); + if ( + $isElementNode(focusBlock) && + seenStart.has(focusBlock.getKey()) && + $isElementNode(anchorBlock) + ) { + // always merge blocks later in the document with + // blocks earlier in the document + $getChildCaret(anchorBlock, 'previous').splice(0, focusBlock.getChildren()); + focusBlock.remove(); + } + + for (const caret of [anchorCandidate, focusCandidate]) { + if (caret && caret.origin.isAttached()) { + const anchor = $getCaretInDirection( + $normalizeCaret(caret), + initialRange.direction, + ); + return $getCaretRange(anchor, anchor); + } + } + invariant( + false, + '$removeTextFromCaretRange: selection was lost, could not find a new anchor given candidates with keys: %s', + JSON.stringify(anchorCandidates.map((n) => n.origin.__key)), + ); +} + +/** + * Return the deepest ChildCaret that has initialCaret's origin + * as an ancestor, or initialCaret if the origin is not an ElementNode + * or is already the deepest ChildCaret. + * + * This is generally used when normalizing because there is + * "zero distance" between these locations. + * + * @param initialCaret + * @returns Either a deeper ChildCaret or the given initialCaret + */ +function $getDeepestChildOrSelf< + Caret extends null | PointCaret, +>( + initialCaret: Caret, +): ChildCaret['direction']> | Caret { + let caret: ChildCaret['direction']> | Caret = + initialCaret; + while ($isChildCaret(caret)) { + const adjacent = $getAdjacentChildCaret(caret); + if (!$isChildCaret(adjacent)) { + break; + } + caret = adjacent; + } + return caret; +} + +/** + * Normalize a caret to the deepest equivalent PointCaret. + * This will return a TextPointCaret with the offset set according + * to the direction if given a caret with a TextNode origin + * or a caret with an ElementNode origin with the deepest ChildCaret + * having an adjacent TextNode. + * + * If given a TextPointCaret, it will be returned, as no normalization + * is required when an offset is already present. + * + * @param initialCaret + * @returns The normalized PointCaret + */ +export function $normalizeCaret( + initialCaret: PointCaret, +): PointCaret { + const caret = $getDeepestChildOrSelf(initialCaret.getLatest()); + const {direction} = caret; + if ($isTextNode(caret.origin)) { + return $isTextPointCaret(caret) + ? caret + : $getTextPointCaret(caret.origin, direction, direction); + } + const adj = caret.getAdjacentCaret(); + return $isSiblingCaret(adj) && $isTextNode(adj.origin) + ? $getTextPointCaret(adj.origin, direction, flipDirection(direction)) + : caret; +} + +/** + * Return the caret if it's in the given direction, otherwise return + * caret.getFlipped(). + * + * @param caret Any PointCaret + * @param direction The desired direction + * @returns A PointCaret in direction + */ +export function $getCaretInDirection< + Caret extends PointCaret, + D extends CaretDirection, +>( + caret: Caret, + direction: D, +): + | NodeCaret + | (Caret extends TextPointCaret + ? TextPointCaret + : never) { + return (caret.direction === direction ? caret : caret.getFlipped()) as + | NodeCaret + | (Caret extends TextPointCaret + ? TextPointCaret + : never); +} + +/** + * Return the range if it's in the given direction, otherwise + * construct a new range using a flipped focus as the anchor + * and a flipped anchor as the focus. This transformation + * preserves the section of the document that it's working + * with, but reverses the order of iteration. + * + * @param range Any CaretRange + * @param direction The desired direction + * @returns A CaretRange in direction + */ +export function $getCaretRangeInDirection( + range: CaretRange, + direction: D, +): CaretRange { + if (range.direction === direction) { + return range as CaretRange; + } + return $getCaretRange( + // focus and anchor get flipped here + $getCaretInDirection(range.focus, direction), + $getCaretInDirection(range.anchor, direction), + ); +} + +/** + * Get a caret pointing at the child at the given index, or the last + * caret in that node if out of bounds. + * + * @param parent An ElementNode + * @param index The index of the origin for the caret + * @returns A caret pointing towards the node at that index + */ +export function $getChildCaretAtIndex( + parent: ElementNode, + index: number, + direction: D, +): NodeCaret { + let caret: NodeCaret<'next'> = $getChildCaret(parent, 'next'); + for (let i = 0; i < index; i++) { + const nextCaret: null | SiblingCaret = + caret.getAdjacentCaret(); + if (nextCaret === null) { + break; + } + caret = nextCaret; + } + return $getCaretInDirection(caret, direction); +} + +/** + * 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); + } + return nextCaret && [nextCaret, depthDiff]; +} diff --git a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts new file mode 100644 index 00000000000..b5d01bf6107 --- /dev/null +++ b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts @@ -0,0 +1,1681 @@ +/** + * 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 {$createLinkNode} from '@lexical/link'; +import {$createListItemNode, $createListNode} from '@lexical/list'; +import {$createHeadingNode, $isHeadingNode} from '@lexical/rich-text'; +import { + $createTableCellNode, + $createTableNode, + $createTableRowNode, +} from '@lexical/table'; +import { + $caretRangeFromSelection, + $createParagraphNode, + $createRangeSelection, + $createTextNode, + $getCaretRange, + $getChildCaret, + $getRoot, + $getSelection, + $getSiblingCaret, + $getTextPointCaret, + $isTextNode, + $isTextPointCaret, + $isTextPointCaretSlice, + $removeTextFromCaretRange, + $rewindSiblingCaret, + $selectAll, + $setPointFromCaret, + $setSelection, + $setSelectionFromCaretRange, + ChildCaret, + LexicalNode, + RootNode, + SiblingCaret, + TextNode, +} from 'lexical'; + +import { + $assertRangeSelection, + $createTestDecoratorNode, + initializeUnitTest, + invariant, +} from '../../../__tests__/utils'; + +const DIRECTIONS = ['next', 'previous'] as const; +const BIASES = ['inside', 'outside'] as const; + +function combinations(as: Iterable, bs: Iterable): [A, B][] { + const rval: [A, B][] = []; + for (const a of as) { + for (const b of bs) { + rval.push([a, b]); + } + } + return rval; +} + +function startOfNode(size: number) { + return 0; +} +function endOfNode(size: number) { + return size; +} +function insideNode(size: number) { + return 1; +} + +describe('LexicalCaret', () => { + initializeUnitTest((testEnv) => { + describe('$getChildCaret', () => { + for (const direction of DIRECTIONS) { + test(`direction ${direction}`, async () => { + await testEnv.editor.update( + () => { + const paragraph = $createParagraphNode(); + const root = $getRoot(); + root.clear().append(paragraph); + // Note that the type declarations here would normally be inferred, these are + // used just to demonstrate that inference is working as expected + const caret: ChildCaret = + $getChildCaret(root, direction); + expect(root.is(caret.origin)).toBe(true); + expect(caret.direction).toBe(direction); + expect(caret.type).toBe('child'); + expect(paragraph.is(caret.getNodeAtCaret())).toBe(true); + expect(root.is(caret.getParentAtCaret())).toBe(true); + + const flipped = caret.getFlipped(); + expect(flipped).not.toBe(caret); + expect(flipped.getFlipped().isSameNodeCaret(caret)).toBe(true); + expect(flipped.direction).not.toBe(direction); + expect(flipped.type).toBe('sibling'); + expect(flipped.getNodeAtCaret()).toBe(null); + expect(flipped.getAdjacentCaret()).toBe(null); + for (const mode of ['root', 'shadowRoot'] as const) { + expect(caret.getParentCaret(mode)).toBe(null); + expect(flipped.getParentCaret(mode)).toBe(null); + } + const adjacent: SiblingCaret< + LexicalNode, + typeof direction + > | null = caret.getAdjacentCaret(); + invariant( + adjacent !== null, + 'depth caret of a non-empty element must always have an adjacent caret', + ); + expect(paragraph.is(adjacent.origin)).toBe(true); + expect(adjacent.type).toBe('sibling'); + expect(adjacent.getAdjacentCaret()).toBe(null); + + expect(root.getChildrenSize()).toBe(1); + caret.remove(); + expect(root.isEmpty()).toBe(true); + caret.replaceOrInsert(paragraph); + expect(root.getChildrenSize()).toBe(1); + caret.remove(); + caret.insert(paragraph); + expect(root.getChildrenSize()).toBe(1); + + // When direction === 'next' we are prepending the second node, otherwise we are appending it + const secondParagraph = $createParagraphNode(); + caret.insert(secondParagraph); + expect(root.getChildrenSize()).toBe(2); + const paragraphKeys = [ + paragraph.getKey(), + secondParagraph.getKey(), + ]; + expect(root.getChildrenKeys()).toEqual( + direction === 'next' + ? [...paragraphKeys].reverse() + : paragraphKeys, + ); + + caret.splice(2, []); + expect(root.getChildrenSize()).toBe(0); + caret.splice(0, [paragraph, secondParagraph]); + expect(root.getChildrenKeys()).toEqual(paragraphKeys); + caret.splice(0, [secondParagraph, paragraph]); + expect(root.getChildrenKeys()).toEqual( + [...paragraphKeys].reverse(), + ); + caret.splice(0, [paragraph, secondParagraph]); + expect(root.getChildrenKeys()).toEqual(paragraphKeys); + caret.splice(2, [secondParagraph, paragraph]); + expect(root.getChildrenKeys()).toEqual( + [...paragraphKeys].reverse(), + ); + caret.splice(2, [paragraph, secondParagraph]); + expect(root.getChildrenKeys()).toEqual(paragraphKeys); + caret.splice(20, [paragraph]); + expect(root.getChildrenKeys()).toEqual([paragraph.getKey()]); + caret.splice(-1, [secondParagraph]); + expect(root.getChildrenKeys()).toEqual( + direction === 'next' + ? [...paragraphKeys].reverse() + : paragraphKeys, + ); + caret.splice(Infinity, [paragraph, secondParagraph], direction); + expect(root.getChildrenKeys()).toEqual( + direction === 'next' + ? paragraphKeys + : [...paragraphKeys].reverse(), + ); + + expect( + Array.from(caret, (nextCaret) => nextCaret.origin.getKey()), + ).toEqual( + direction === 'next' + ? root.getChildrenKeys() + : [...root.getChildrenKeys()].reverse(), + ); + }, + {discrete: true}, + ); + }); + } + }); + describe('$getSiblingCaret', () => { + for (const direction of DIRECTIONS) { + test(`direction ${direction}`, async () => { + await testEnv.editor.update( + () => { + const paragraph = $createParagraphNode(); + const tokens = ['-2', '-1', '0', '1', '2'].map((text) => + $createTextNode(text).setMode('token'), + ); + const root = $getRoot(); + root.clear().append(paragraph.append(...tokens)); + const ZERO_INDEX = 2; + const zToken = tokens[ZERO_INDEX]; + const nextToken = + direction === 'next' + ? zToken.getNextSibling() + : zToken.getPreviousSibling(); + invariant(nextToken !== null, 'nextToken must exist'); + // Note that the type declarations here would normally be inferred, these are + // used just to demonstrate that inference is working as expected + const caret: SiblingCaret = + $getSiblingCaret(zToken, direction); + expect(zToken.is(caret.origin)).toBe(true); + expect(caret.direction).toBe(direction); + expect(caret.type).toBe('sibling'); + expect(nextToken.is(caret.getNodeAtCaret())).toBe(true); + expect(paragraph.is(caret.getParentAtCaret())).toBe(true); + + expect( + Array.from( + caret, + (nextCaret) => + (direction === 'next' ? 1 : -1) * + +nextCaret.origin.getTextContent(), + ), + ).toEqual([1, 2]); + + const flipped = caret.getFlipped(); + expect(flipped).not.toBe(caret); + expect(flipped.getFlipped().isSameNodeCaret(caret)); + expect(flipped.origin.is(caret.getNodeAtCaret())).toBe(true); + expect(flipped.direction).not.toBe(direction); + expect(flipped.type).toBe('sibling'); + expect(zToken.is(flipped.getNodeAtCaret())).toBe(true); + const flippedAdjacent = flipped.getAdjacentCaret(); + invariant( + flippedAdjacent !== null, + 'A flipped SiblingCaret always has an adjacent caret because it points back to the origin', + ); + expect(flippedAdjacent.origin.is(caret.origin)).toBe(true); + + for (const mode of ['root', 'shadowRoot'] as const) { + expect( + $getSiblingCaret(paragraph, caret.direction).isSameNodeCaret( + caret.getParentCaret(mode), + ), + ).toBe(true); + expect( + $getSiblingCaret( + paragraph, + flipped.direction, + ).isSameNodeCaret(flipped.getParentCaret(mode)), + ).toBe(true); + } + + const adjacent: SiblingCaret< + LexicalNode, + typeof direction + > | null = caret.getAdjacentCaret(); + invariant(adjacent !== null, 'expecting adjacent caret'); + const offset = direction === 'next' ? 1 : -1; + expect(tokens[ZERO_INDEX + offset].is(adjacent.origin)).toBe( + true, + ); + expect(adjacent.type).toBe('sibling'); + expect(adjacent.origin.getTextContent()).toBe(String(offset)); + + expect(tokens[ZERO_INDEX + offset].isAttached()).toBe(true); + expect( + tokens[ZERO_INDEX + offset].is(caret.getNodeAtCaret()), + ).toBe(true); + expect( + tokens[ZERO_INDEX + offset].is(caret.getNodeAtCaret()), + ).toBe(true); + expect(paragraph.getChildrenSize()).toBe(tokens.length); + caret.remove(); + expect(paragraph.getChildrenSize()).toBe(tokens.length - 1); + expect(tokens[ZERO_INDEX + offset].isAttached()).toBe(false); + expect( + tokens[ZERO_INDEX + 2 * offset].is(caret.getNodeAtCaret()), + ).toBe(true); + expect( + Array.from( + caret, + (nextCaret) => + (direction === 'next' ? 1 : -1) * + +nextCaret.origin.getTextContent(), + ), + ).toEqual([2]); + expect( + paragraph + .getLatest() + .getChildren() + .map((node) => node.getTextContent()), + ).toEqual( + tokens + .map((n) => n.getTextContent()) + .filter((t) => t !== String(offset)), + ); + caret.insert(tokens[ZERO_INDEX + offset]); + expect( + paragraph + .getLatest() + .getChildren() + .map((node) => node.getTextContent()), + ).toEqual(tokens.map((n) => n.getTextContent())); + caret.replaceOrInsert(tokens[ZERO_INDEX + offset]); + expect( + paragraph + .getLatest() + .getChildren() + .map((node) => node.getTextContent()), + ).toEqual(tokens.map((n) => n.getTextContent())); + + caret.replaceOrInsert($createTextNode('replaced!')); + expect( + paragraph + .getLatest() + .getChildren() + .map((node) => node.getTextContent()), + ).toEqual( + tokens.map((n, i) => + i === ZERO_INDEX + offset ? 'replaced!' : n.getTextContent(), + ), + ); + caret.replaceOrInsert(tokens[ZERO_INDEX + offset]); + + const abNodes = ['a', 'b'].map((t) => $createTextNode(t)); + caret.splice(0, abNodes); + expect( + paragraph + .getLatest() + .getChildren() + .map((node) => node.getTextContent()), + ).toEqual( + tokens.flatMap((n, i) => { + if (i !== ZERO_INDEX) { + return [n.getTextContent()]; + } else if (direction === 'next') { + return ['0', 'a', 'b']; + } else { + return ['a', 'b', '0']; + } + }), + ); + abNodes.forEach((n) => n.remove()); + + caret.splice(0, abNodes, 'previous'); + expect( + paragraph + .getLatest() + .getChildren() + .map((node) => node.getTextContent()), + ).toEqual( + tokens.flatMap((n, i) => { + if (i !== ZERO_INDEX) { + return [n.getTextContent()]; + } else if (direction === 'next') { + return ['0', 'b', 'a']; + } else { + return ['b', 'a', '0']; + } + }), + ); + abNodes.forEach((n) => n.remove()); + + caret.splice(1, abNodes); + expect( + paragraph + .getLatest() + .getChildren() + .map((node) => node.getTextContent()), + ).toEqual( + tokens.flatMap((n, i) => { + if (i === ZERO_INDEX + offset) { + return []; + } else if (i !== ZERO_INDEX) { + return [n.getTextContent()]; + } else if (direction === 'next') { + return ['0', 'a', 'b']; + } else { + return ['a', 'b', '0']; + } + }), + ); + paragraph.clear().append(...tokens); + + caret.splice(1, abNodes.slice(0, 1)); + expect( + paragraph + .getLatest() + .getChildren() + .map((node) => node.getTextContent()), + ).toEqual( + tokens.map((n, i) => + i === ZERO_INDEX + offset ? 'a' : n.getTextContent(), + ), + ); + paragraph.clear().append(...tokens); + + caret.splice(2, abNodes.slice(0, 1)); + expect( + paragraph + .getLatest() + .getChildren() + .map((node) => node.getTextContent()), + ).toEqual( + direction === 'next' + ? ['-2', '-1', '0', 'a'] + : ['a', '0', '1', '2'], + ); + paragraph.clear().append(...tokens); + + caret.splice(Infinity, abNodes); + expect( + paragraph + .getLatest() + .getChildren() + .map((node) => node.getTextContent()), + ).toEqual( + direction === 'next' + ? ['-2', '-1', '0', 'a', 'b'] + : ['a', 'b', '0', '1', '2'], + ); + paragraph.clear().append(...tokens); + }, + {discrete: true}, + ); + }); + } + }); + describe('$caretRangeFromSelection', () => { + test('collapsed text point selection', async () => { + await testEnv.editor.update(() => { + const textNodes = ['first', 'second', 'third'].map((text) => + $createTextNode(text).setMode('token'), + ); + $getRoot() + .clear() + .append($createParagraphNode().append(...textNodes)); + const node = textNodes[1]; + const cases = [ + [() => node.selectStart(), 0], + [() => node.selectEnd(), node.getTextContentSize()], + [() => node.select(3, 3), 3], + ] as const; + for (const [$makeSelection, offset] of cases) { + const key = node.getKey(); + const selection = $makeSelection(); + expect(selection).toMatchObject({ + anchor: {key, offset, type: 'text'}, + focus: {key, offset, type: 'text'}, + }); + const range = $caretRangeFromSelection(selection); + expect(range.isCollapsed()).toBe(true); + invariant( + $isTextPointCaret(range.anchor), + '$isTextPointCaret(range.anchor)', + ); + invariant( + $isTextPointCaret(range.focus), + '$isTextPointCaret(range.anchor)', + ); + expect(range).toMatchObject({ + anchor: { + direction: 'next', + offset, + }, + focus: { + direction: 'next', + offset, + }, + }); + expect( + range.getTextSlices().filter($isTextPointCaretSlice), + ).toMatchObject([ + { + caret: { + direction: 'next', + offset, + origin: node, + type: 'text', + }, + distance: 0, + }, + ]); + expect([...range.iterNodeCarets('root')]).toEqual([]); + } + }); + }); + for (const direction of DIRECTIONS) { + test(`full text node selection (${direction})`, async () => { + await testEnv.editor.update(() => { + const textNodes = ['first', 'second', 'third'].map((text) => + $createTextNode(text).setMode('token'), + ); + $getRoot() + .clear() + .append($createParagraphNode().append(...textNodes)); + for (const node of textNodes) { + const key = node.getKey(); + const textSize = node.getTextContentSize(); + const [anchorOffset, focusOffset] = + direction === 'next' ? [0, textSize] : [textSize, 0]; + const selection = node.select(anchorOffset, focusOffset); + expect(selection).toMatchObject({ + anchor: {key, offset: anchorOffset, type: 'text'}, + focus: {key, offset: focusOffset, type: 'text'}, + }); + const range = $caretRangeFromSelection(selection); + invariant( + $isTextPointCaret(range.anchor), + '$isTextPointCaret(range.anchor)', + ); + invariant( + $isTextPointCaret(range.focus), + '$isTextPointCaret(range.anchor)', + ); + expect(range).toMatchObject({ + anchor: {direction, offset: anchorOffset, origin: node}, + direction, + focus: {direction, offset: focusOffset, origin: node}, + }); + expect( + range.getTextSlices().filter($isTextPointCaretSlice), + ).toMatchObject([ + { + caret: { + direction, + offset: anchorOffset, + origin: node, + }, + distance: focusOffset - anchorOffset, + }, + ]); + expect([...range.iterNodeCarets('root')]).toEqual([]); + expect(range.isCollapsed()).toBe(false); + } + }); + }); + } + test('single text node non-empty selection', async () => { + await testEnv.editor.update(() => { + const textNodes = ['first', 'second', 'third'].map((text) => + $createTextNode(text).setMode('token'), + ); + $getRoot() + .clear() + .append($createParagraphNode().append(...textNodes)); + for (const node of textNodes) { + // Test all non-empty selections + const textSize = node.getTextContentSize(); + for (let indexStart = 0; indexStart < textSize; indexStart++) { + for ( + let indexEnd = indexStart + 1; + indexEnd <= textSize; + indexEnd++ + ) { + for (const direction of DIRECTIONS) { + const [offset, size] = + direction === 'next' + ? [indexStart, indexEnd - indexStart] + : [indexEnd, indexStart - indexEnd]; + const selection = + direction === 'next' + ? node.select(indexStart, indexEnd) + : node.select(indexEnd, indexStart); + const range = $caretRangeFromSelection(selection); + invariant( + $isTextPointCaret(range.anchor), + '$isTextPointCaret(range.anchor)', + ); + invariant( + $isTextPointCaret(range.focus), + '$isTextPointCaret(range.anchor)', + ); + expect(range.direction).toBe(direction); + expect( + range.getTextSlices().filter($isTextPointCaretSlice), + ).toMatchObject([ + {caret: {direction, offset, origin: node}, distance: size}, + ]); + expect([...range.iterNodeCarets('root')]).toMatchObject([]); + } + } + } + } + }); + }); + for (const direction of DIRECTIONS) { + test(`multiple text node non-empty selection (${direction})`, async () => { + await testEnv.editor.update(() => { + const textNodes = ['first', 'second', 'third'].map((text) => + $createTextNode(text).setMode('token'), + ); + $getRoot() + .clear() + .append($createParagraphNode().append(...textNodes)); + const selection = $getRoot().select(); + + // test all start and end nodes (where different) + const nodeCount = textNodes.length; + for ( + let indexNodeStart = 0; + indexNodeStart < nodeCount; + indexNodeStart++ + ) { + for ( + let indexNodeEnd = indexNodeStart + 1; + indexNodeEnd < nodeCount; + indexNodeEnd++ + ) { + const startNode = textNodes[indexNodeStart]!; + const endNode = textNodes[indexNodeEnd]!; + for (const indexStart of [ + 0, + 1, + startNode.getTextContentSize(), + ]) { + for (const indexEnd of [0, 1, endNode.getTextContentSize()]) { + const [anchorNode, anchorOffset, focusNode, focusOffset] = + direction === 'next' + ? [startNode, indexStart, endNode, indexEnd] + : [endNode, indexEnd, startNode, indexStart]; + selection.setTextNodeRange( + anchorNode, + anchorOffset, + focusNode, + focusOffset, + ); + const range = $caretRangeFromSelection(selection); + invariant( + $isTextPointCaret(range.anchor), + '$isTextPointCaret(range.anchor)', + ); + invariant( + $isTextPointCaret(range.focus), + '$isTextPointCaret(range.anchor)', + ); + expect(range.direction).toBe(direction); + const textSliceCarets = range + .getTextSlices() + .filter($isTextPointCaretSlice); + expect(textSliceCarets).toHaveLength(2); + const [anchorSlice, focusSlice] = textSliceCarets; + expect(anchorSlice).toMatchObject({ + caret: { + direction, + offset: anchorOffset, + origin: anchorNode, + type: 'text', + }, + distance: + direction === 'next' + ? anchorNode.getTextContentSize() - anchorOffset + : 0 - anchorOffset, + }); + expect(focusSlice).toMatchObject({ + caret: { + direction, + offset: focusOffset, + origin: focusNode, + type: 'text', + }, + distance: + direction === 'next' + ? 0 - focusOffset + : focusNode.getTextContentSize() - focusOffset, + }); + expect([...range.iterNodeCarets('root')]).toMatchObject( + textNodes + .slice(indexNodeStart + 1, indexNodeEnd) + .map((origin) => ({ + direction, + origin, + type: 'sibling', + })), + ); + } + } + } + } + }); + }); + } + }); + describe('$removeTextFromCaretRange', () => { + const texts = ['first', 'second', 'third'] as const; + describe('ported Headings e2e tests', () => { + test('Pressing return in the middle of a heading creates a new heading below', () => { + testEnv.editor.update( + () => { + const initialTextNode = $createTextNode('[before][after]'); + const headingNode = $createHeadingNode().append(initialTextNode); + $getRoot().clear().append(headingNode); + const newHeadingNode = initialTextNode + .select('[before]'.length, '[before]'.length) + .insertParagraph(); + expect( + $getRoot() + .getAllTextNodes() + .map((n) => n.getTextContent()), + ).toEqual(['[before]', '[after]']); + invariant($isHeadingNode(newHeadingNode), 'paragraph inserted'); + expect($getRoot().getChildren()).toEqual([ + headingNode, + newHeadingNode, + ]); + expect(initialTextNode.getTextContent()).toBe('[before]'); + expect(initialTextNode.getParent()).toBe(headingNode); + const newTextNodes = newHeadingNode.getAllTextNodes(); + expect(newTextNodes).toHaveLength(1); + invariant($isTextNode(newTextNodes[0]), 'new text node created'); + expect(newTextNodes[0].getTextContent()).toBe('[after]'); + }, + {discrete: true}, + ); + }); + }); + describe('ported File e2e tests', () => { + test('$selectAll() with nesting and a trailing decorator', () => { + testEnv.editor.update( + () => { + const paragraphNode = $createParagraphNode().append( + $createTextNode('Hello').setFormat('bold'), + $createTextNode('World'), + ); + const listNode = $createListNode('number').append( + $createListItemNode().append($createTextNode('one')), + $createListItemNode().append($createTextNode('two')), + $createListItemNode().append($createTestDecoratorNode()), + ); + $getRoot().clear().append(paragraphNode, listNode); + expect($getRoot().getChildrenSize()).toBe(2); + const range = $caretRangeFromSelection($selectAll()); + const resultRange = $removeTextFromCaretRange(range); + expect($getRoot().getAllTextNodes()).toEqual([]); + expect($getRoot().getChildren()).toEqual([paragraphNode]); + expect(resultRange).toMatchObject({ + anchor: { + direction: 'next', + origin: paragraphNode, + type: 'child', + }, + }); + }, + {discrete: true}, + ); + }); + }); + describe('ported Table e2e tests', () => { + test('Can delete all with range selection anchored in table', () => { + testEnv.editor.update( + () => { + const tableNode = $createTableNode().append( + $createTableRowNode().append( + $createTableCellNode().append( + $createParagraphNode().append($createTextNode('cell 1')), + ), + $createTableCellNode().append( + $createParagraphNode().append($createTextNode('cell 2')), + ), + ), + ); + const paragraphNode = $createParagraphNode().append( + $createTextNode('paragraph 2'), + ); + $getRoot().clear().append(tableNode, paragraphNode); + const selection = $selectAll(); + // The table plug-in would normally do this normalization + selection.anchor.set('root', 0, 'element'); + const range = $caretRangeFromSelection(selection); + const resultRange = $removeTextFromCaretRange(range); + expect($getRoot().getAllTextNodes()).toEqual([]); + expect($getRoot().getChildren()).toEqual([paragraphNode]); + expect(resultRange).toMatchObject({ + anchor: { + direction: 'next', + origin: paragraphNode, + type: 'child', + }, + }); + }, + {discrete: true}, + ); + }); + }); + describe('ported LexicalSelection tests', () => { + test('remove partial initial TextNode and partial segmented TextNode', () => { + let leadingText: TextNode; + let trailingSegmentedText: TextNode; + testEnv.editor.update( + () => { + const sel = $createRangeSelection(); + leadingText = $createTextNode('leading text'); + trailingSegmentedText = + $createTextNode('segmented text').setMode('segmented'); + $getRoot() + .clear() + .append( + $createParagraphNode().append( + leadingText, + trailingSegmentedText, + ), + ); + sel.anchor.set(leadingText.getKey(), 'lead'.length, 'text'); + sel.focus.set( + trailingSegmentedText.getKey(), + 'segmented '.length, + 'text', + ); + $setSelection(sel); + const resultRange = $removeTextFromCaretRange( + $caretRangeFromSelection(sel), + ); + $setSelectionFromCaretRange(resultRange); + expect(resultRange).toMatchObject({ + anchor: { + offset: 'lead'.length, + origin: leadingText.getLatest(), + }, + direction: 'next', + }); + expect(leadingText.isAttached()).toBe(true); + expect(trailingSegmentedText.isAttached()).toBe(false); + const allTextNodes = $getRoot().getAllTextNodes(); + // These should get merged in reconciliation + expect(allTextNodes.map((node) => node.getTextContent())).toEqual( + ['lead', 'text'], + ); + const selection = $assertRangeSelection($getSelection()); + expect(selection.isCollapsed()).toBe(true); + expect(selection.anchor.key).toBe(leadingText.getKey()); + expect(selection.anchor.offset).toBe('lead'.length); + }, + {discrete: true}, + ); + // Reconciliation has happened + testEnv.editor.getEditorState().read(() => { + const allTextNodes = $getRoot().getAllTextNodes(); + // These should get merged in reconciliation + expect(allTextNodes.map((node) => node.getTextContent())).toEqual([ + 'leadtext', + ]); + expect(leadingText.isAttached()).toBe(true); + expect(trailingSegmentedText.isAttached()).toBe(false); + }); + }); + }); + describe('single block', () => { + beforeEach(() => { + testEnv.editor.update(() => { + // Ensure that the separate texts don't get merged + const textNodes = texts.map((text) => + $createTextNode(text).setStyle(`color: --color-${text}`), + ); + $getRoot() + .clear() + .append($createParagraphNode().append(...textNodes)); + }); + }); + test('remove second TextNode when wrapped in a LinkNode that will become empty', () => { + testEnv.editor.update( + () => { + const sel = $createRangeSelection(); + const originalNodes = $getRoot().getAllTextNodes(); + const [leadingText, trailingLinkText] = originalNodes; + const linkWrapper = $createLinkNode('https://lexical.dev'); + trailingLinkText.replace(linkWrapper); + linkWrapper.append(trailingLinkText); + sel.anchor.set(trailingLinkText.getKey(), 0, 'text'); + sel.focus.set( + trailingLinkText.getKey(), + trailingLinkText.getTextContentSize(), + 'text', + ); + const direction = 'next'; + const range = $caretRangeFromSelection(sel); + $setSelection(sel); + expect(range).toMatchObject({ + anchor: { + direction, + offset: 0, + origin: trailingLinkText.getLatest(), + }, + focus: { + direction, + offset: trailingLinkText.getTextContentSize(), + origin: trailingLinkText.getLatest(), + }, + }); + const resultRange = $removeTextFromCaretRange(range); + $setSelectionFromCaretRange(resultRange); + expect(leadingText.isAttached()).toBe(true); + expect(trailingLinkText.isAttached()).toBe(false); + expect($getRoot().getAllTextNodes()).toHaveLength(2); + expect(resultRange.isCollapsed()).toBe(true); + expect(sel.isCollapsed()).toBe(true); + expect(sel.anchor.getNode()).toBe(leadingText.getLatest()); + expect(sel.anchor.key).toBe(leadingText.getKey()); + expect(sel.anchor.offset).toBe(leadingText.getTextContentSize()); + expect(resultRange.anchor).toMatchObject({ + direction, + offset: leadingText.getTextContentSize(), + origin: leadingText.getLatest(), + }); + }, + {discrete: true}, + ); + }); + test('remove first TextNode with second in token mode', () => { + testEnv.editor.update( + () => { + const sel = $createRangeSelection(); + const originalNodes = $getRoot().getAllTextNodes(); + const [leadingText, trailingTokenText] = originalNodes; + trailingTokenText.setMode('token'); + sel.anchor.set(leadingText.getKey(), 0, 'text'); + sel.focus.set( + leadingText.getKey(), + leadingText.getTextContentSize(), + 'text', + ); + const direction = 'next'; + const range = $caretRangeFromSelection(sel); + $setSelection(sel); + expect(range).toMatchObject({ + anchor: {direction, offset: 0, origin: leadingText}, + focus: { + direction, + offset: leadingText.getTextContentSize(), + origin: leadingText, + }, + }); + const resultRange = $removeTextFromCaretRange(range); + $setSelectionFromCaretRange(resultRange); + expect(leadingText.isAttached()).toBe(false); + expect(trailingTokenText.isAttached()).toBe(true); + expect($getRoot().getAllTextNodes()).toHaveLength(2); + expect(resultRange.isCollapsed()).toBe(true); + expect(sel.isCollapsed()).toBe(true); + expect(sel.anchor.key).toBe(trailingTokenText.getKey()); + expect(sel.anchor.offset).toBe(0); + expect(resultRange.anchor).toMatchObject({ + direction, + offset: 0, + origin: trailingTokenText.getLatest(), + }); + }, + {discrete: true}, + ); + }); + test('collapsed text point selection', async () => { + await testEnv.editor.update(() => { + const textNodes = $getRoot().getAllTextNodes(); + const originalText = $getRoot().getTextContent(); + const node = textNodes[1]; + const cases = [ + [() => node.selectStart(), 0], + [() => node.selectEnd(), node.getTextContentSize()], + [() => node.select(3, 3), 3], + ] as const; + for (const [$makeSelection, offset] of cases) { + const key = node.getKey(); + const selection = $makeSelection(); + expect(selection).toMatchObject({ + anchor: {key, offset, type: 'text'}, + focus: {key, offset, type: 'text'}, + }); + const range = $caretRangeFromSelection(selection); + expect(range.isCollapsed()).toBe(true); + invariant( + $isTextPointCaret(range.anchor), + '$isTextPointCaret(range.anchor)', + ); + invariant( + $isTextPointCaret(range.focus), + '$isTextPointCaret(range.anchor)', + ); + const originalRangeMatch = { + anchor: { + direction: 'next', + offset, + }, + focus: { + direction: 'next', + offset, + }, + } as const; + expect(range).toMatchObject(originalRangeMatch); + expect( + range.getTextSlices().filter($isTextPointCaretSlice), + ).toMatchObject([ + { + caret: { + direction: 'next', + offset, + origin: node, + type: 'text', + }, + distance: 0, + }, + ]); + expect([...range.iterNodeCarets('root')]).toEqual([]); + expect($removeTextFromCaretRange(range)).toMatchObject( + originalRangeMatch, + ); + expect($getRoot().getTextContent()).toEqual(originalText); + $setSelection(null); + } + }); + }); + describe('full text node internal selection', () => { + for (const direction of DIRECTIONS) { + texts.forEach((text, i) => { + test(`${text} node (${direction})`, async () => { + await testEnv.editor.update(() => { + const originalNodes = $getRoot().getAllTextNodes(); + const [node] = originalNodes.splice(i, 1); + invariant($isTextNode(node), `Missing TextNode ${i}`); + const key = node.getKey(); + const size = node.getTextContentSize(); + const [anchorOffset, focusOffset] = + direction === 'next' ? [0, size] : [size, 0]; + const selection = node.select(anchorOffset, focusOffset); + expect(selection).toMatchObject({ + anchor: {key, offset: anchorOffset, type: 'text'}, + focus: {key, offset: focusOffset, type: 'text'}, + }); + const range = $caretRangeFromSelection(selection); + invariant( + $isTextPointCaret(range.anchor), + '$isTextPointCaret(range.anchor)', + ); + invariant( + $isTextPointCaret(range.focus), + '$isTextPointCaret(range.anchor)', + ); + expect(range).toMatchObject({ + anchor: {offset: anchorOffset, origin: node}, + direction, + focus: {offset: focusOffset, origin: node}, + }); + expect( + range.getTextSlices().filter($isTextPointCaretSlice), + ).toMatchObject([ + { + caret: { + offset: + direction === 'next' ? 0 : node.getTextContentSize(), + origin: node, + }, + distance: + (direction === 'next' ? 1 : -1) * + node.getTextContentSize(), + }, + ]); + expect([...range.iterNodeCarets('root')]).toEqual([]); + expect(range.isCollapsed()).toBe(false); + const resultRange = $removeTextFromCaretRange(range); + $setSelection(null); + const remainingNodes = $getRoot().getAllTextNodes(); + expect(remainingNodes).toEqual( + originalNodes.map((n) => n.getLatest()), + ); + expect(remainingNodes.map((n) => n.getTextContent())).toEqual( + texts.filter((_v, j) => j !== i), + ); + expect(resultRange.isCollapsed()).toBe(true); + // bias towards the start + const adjacentIndex = Math.max(0, i - 1); + const newOrigin = remainingNodes[adjacentIndex]; + const offset = i === 0 ? 0 : newOrigin.getTextContentSize(); + const pt = { + direction, + offset, + origin: newOrigin, + type: 'text', + }; + expect(resultRange).toMatchObject({ + anchor: pt, + direction, + focus: pt, + type: 'node-caret-range', + }); + }); + }); + }); + } + }); + describe('full text node biased selection', () => { + for (const [direction, [anchorBias, focusBias]] of combinations( + DIRECTIONS, + combinations(BIASES, BIASES), + )) { + if (anchorBias === 'inside' && focusBias === 'inside') { + // These cases are tested above + continue; + } + texts.forEach((text, i) => { + test(`${text} node (${direction} ${anchorBias} ${focusBias})`, async () => { + await testEnv.editor.update(() => { + const originalNodes = $getRoot().getAllTextNodes(); + const [node] = originalNodes.splice(i, 1); + invariant($isTextNode(node), `Missing TextNode ${i}`); + const size = node.getTextContentSize(); + const [anchorOffset, focusOffset] = + direction === 'next' ? [0, size] : [size, 0]; + // Create the inside selection, will mutate for outside + const selection = node.select(anchorOffset, focusOffset); + const nodeCaret = $getSiblingCaret(node, direction); + if (anchorBias === 'outside') { + $setPointFromCaret( + selection.anchor, + $rewindSiblingCaret(nodeCaret), + ); + if (direction === 'next') { + if (i === 0) { + expect(selection.anchor).toMatchObject({ + key: node.getParentOrThrow().getKey(), + offset: 0, + type: 'element', + }); + } else { + const adj = originalNodes[i - 1]!; + expect(selection.anchor).toMatchObject({ + key: adj.getKey(), + offset: adj.getTextContentSize(), + type: 'text', + }); + } + } else { + if (i === texts.length - 1) { + const parent = node.getParentOrThrow(); + expect(selection.anchor).toMatchObject({ + key: parent.getKey(), + offset: parent.getChildrenSize(), + type: 'element', + }); + } else { + const adj = originalNodes[i]!; + expect(selection.anchor).toMatchObject({ + key: adj.getKey(), + offset: 0, + type: 'text', + }); + } + } + } + if (focusBias === 'outside') { + $setPointFromCaret( + selection.focus, + $getSiblingCaret(node, direction).getFlipped(), + ); + if (direction === 'next') { + if (i === texts.length - 1) { + const parent = node.getParentOrThrow(); + expect(selection.focus).toMatchObject({ + key: parent.getKey(), + offset: parent.getChildrenSize(), + type: 'element', + }); + } else { + const adj = originalNodes[i]!; + expect(selection.focus).toMatchObject({ + key: adj.getKey(), + offset: 0, + type: 'text', + }); + } + } else { + if (i === 0) { + const parent = node.getParentOrThrow(); + expect(selection.focus).toMatchObject({ + key: parent.getKey(), + offset: 0, + type: 'element', + }); + } else { + const adj = originalNodes[i - 1]!; + expect(selection.focus).toMatchObject({ + key: adj.getKey(), + offset: adj.getTextContentSize(), + type: 'text', + }); + } + } + } + const range = $caretRangeFromSelection(selection); + expect(range.isCollapsed()).toBe(false); + expect([...range.iterNodeCarets('root')].length).toBe( + anchorBias === 'outside' && focusBias === 'outside' ? 1 : 0, + ); + expect( + range + .getTextSlices() + .filter((slice) => slice && slice.distance !== 0), + ).toMatchObject( + anchorBias === 'outside' && focusBias === 'outside' + ? [] + : (anchorBias === 'inside') === (direction === 'next') + ? [{caret: {offset: 0}, distance: size}] + : [{caret: {offset: size}, distance: -size}], + ); + const resultRange = $removeTextFromCaretRange(range); + $setSelection(null); + const remainingNodes = $getRoot().getAllTextNodes(); + expect(remainingNodes.map((n) => n.getTextContent())).toEqual( + texts.filter((_v, j) => j !== i), + ); + expect(remainingNodes).toEqual( + originalNodes.map((n) => n.getLatest()), + ); + expect(resultRange.isCollapsed()).toBe(true); + // bias towards the start + const adjacentIndex = Math.max(0, i - 1); + const newOrigin = remainingNodes[adjacentIndex]; + const offset = i === 0 ? 0 : newOrigin.getTextContentSize(); + expect(resultRange).toMatchObject({ + anchor: { + direction, + offset, + origin: newOrigin, + type: 'text', + }, + direction, + focus: { + direction, + offset, + origin: newOrigin, + type: 'text', + }, + type: 'node-caret-range', + }); + }); + }); + }); + } + }); + + const EDGE_OFFSETS = [ + [0, 1], + [1, 1], + [1, 0], + ] as const; + describe('single text node non-empty partial selection', () => { + for (const [ + direction, + [anchorEdgeOffset, focusEdgeOffset], + ] of combinations(DIRECTIONS, EDGE_OFFSETS)) { + test(`${direction} ${anchorEdgeOffset}:${-focusEdgeOffset}`, async () => { + await testEnv.editor.update(() => { + const originalNodes = $getRoot().getAllTextNodes(); + const i = 0; + const text = texts[i]; + const node = originalNodes[i]; + invariant($isTextNode(node), `Missing TextNode 0`); + const size = node.getTextContentSize(); + const anchor = $getTextPointCaret( + node, + direction, + direction === 'next' + ? anchorEdgeOffset + : size - anchorEdgeOffset, + ); + const focus = $getTextPointCaret( + node, + direction, + direction === 'next' + ? size - focusEdgeOffset + : focusEdgeOffset, + ); + const [offsetStart, offsetEnd] = [ + anchor.offset, + focus.offset, + ].sort((a, b) => a - b); + const range = $getCaretRange(anchor, focus); + const slices = range + .getTextSlices() + .filter($isTextPointCaretSlice) + .filter((slice) => slice.distance !== 0); + expect([...range.iterNodeCarets('root')]).toEqual([]); + expect(slices.length).toBe(1); + const [slice] = slices; + expect(slice.distance).toBe( + (direction === 'next' ? 1 : -1) * + (size - anchorEdgeOffset - focusEdgeOffset), + ); + expect(slice.getTextContent()).toBe( + text.slice(offsetStart, offsetEnd), + ); + const resultRange = $removeTextFromCaretRange(range); + $setSelection(null); + expect(resultRange.isCollapsed()).toBe(true); + expect(resultRange.anchor).toMatchObject({ + direction, + offset: offsetStart, + origin: node.getLatest(), + }); + const remainingNodes = $getRoot().getAllTextNodes(); + expect(remainingNodes).toHaveLength(texts.length); + expect(remainingNodes.map((n) => n.getTextContent())).toEqual( + texts.map((v, j) => + i === j ? v.slice(0, offsetStart) + v.slice(offsetEnd) : v, + ), + ); + }); + }); + } + }); + + describe('multiple text node selection', () => { + const OFFSETS = [startOfNode, insideNode, endOfNode]; + const NODE_PAIRS = [ + [0, 1], + [0, 2], + [1, 2], + ] as const; + for (const [ + direction, + [[nodeIndexStart, nodeIndexEnd], [startFn, endFn]], + ] of combinations( + DIRECTIONS, + combinations(NODE_PAIRS, combinations(OFFSETS, OFFSETS)), + )) { + test(`${direction} ${texts[nodeIndexStart]} ${startFn.name} ${texts[nodeIndexEnd]} ${endFn.name}`, async () => { + await testEnv.editor.update(() => { + const originalNodes = $getRoot().getAllTextNodes(); + const startNode = originalNodes[nodeIndexStart]; + const endNode = originalNodes[nodeIndexEnd]; + expect(startNode !== endNode).toBe(true); + invariant($isTextNode(startNode), 'text node'); + invariant($isTextNode(endNode), 'text node'); + expect(startNode.isBefore(endNode)).toBe(true); + const startCaret = $getTextPointCaret( + startNode, + direction, + startFn(startNode.getTextContentSize()), + ); + const endCaret = $getTextPointCaret( + endNode, + direction, + endFn(endNode.getTextContentSize()), + ); + const [anchor, focus] = + direction === 'next' + ? [startCaret, endCaret] + : [endCaret, startCaret]; + const range = $getCaretRange(anchor, focus); + expect([...range.iterNodeCarets('root')]).toHaveLength( + Math.max(0, nodeIndexEnd - nodeIndexStart - 1), + ); + const slices = range + .getTextSlices() + .filter($isTextPointCaretSlice); + expect(slices).toHaveLength(2); + expect(slices.map((slice) => slice.getTextContent())).toEqual( + direction === 'next' + ? [ + startCaret.origin + .getTextContent() + .slice(startCaret.offset), + endCaret.origin + .getTextContent() + .slice(0, endCaret.offset), + ] + : [ + endCaret.origin + .getTextContent() + .slice(0, endCaret.offset), + startCaret.origin + .getTextContent() + .slice(startCaret.offset), + ], + ); + const resultRange = $removeTextFromCaretRange(range); + expect(resultRange).toMatchObject({ + anchor: {direction}, + direction, + focus: {direction}, + }); + if (startCaret.offset !== 0) { + // Part of the start remains + expect(resultRange).toMatchObject({ + anchor: { + offset: startCaret.offset, + origin: startCaret.origin.getLatest(), + }, + }); + } else if (nodeIndexStart > 0) { + // The anchor was removed so bias towards the previous node + const prevNode = + originalNodes[nodeIndexStart - 1].getLatest(); + expect(resultRange).toMatchObject({ + anchor: { + offset: prevNode.getTextContentSize(), + origin: prevNode, + }, + }); + } else if (endCaret.offset !== texts[nodeIndexEnd].length) { + // The focus was not deleted and there is no previous node + // so the new anchor will be set to the focus origin + expect(resultRange).toMatchObject({ + anchor: { + offset: 0, + origin: originalNodes[nodeIndexEnd].getLatest(), + }, + }); + } else if (nodeIndexEnd !== texts.length - 1) { + // The anchor was at the start and the focus was removed + // but there is another text node to use as the anchor caret + expect(resultRange).toMatchObject({ + anchor: { + offset: 0, + origin: originalNodes[nodeIndexEnd + 1].getLatest(), + }, + }); + } else { + // All text has been removed so we have to use a depth caret + expect(resultRange).toMatchObject({ + anchor: { + origin: $getRoot().getFirstChild(), + type: 'child', + }, + }); + } + const remainingNodes = $getRoot().getAllTextNodes(); + let newIndex = 0; + for ( + let originalIndex = 0; + originalIndex < originalNodes.length; + originalIndex++ + ) { + const originalText = texts[originalIndex]; + const originalNode = originalNodes[originalIndex]; + let deleted: boolean; + if (originalIndex === nodeIndexStart) { + deleted = startCaret.offset === 0; + if (!deleted) { + expect(originalNode.getTextContent()).toBe( + originalText.slice(0, startCaret.offset), + ); + } + } else if ( + originalIndex > nodeIndexStart && + originalIndex < nodeIndexEnd + ) { + deleted = true; + } else if (originalIndex === nodeIndexEnd) { + deleted = endCaret.offset === originalText.length; + if (!deleted) { + expect(originalNode.getTextContent()).toBe( + originalText.slice(endCaret.offset), + ); + } + } else { + deleted = false; + expect(originalNode.getTextContent()).toBe(originalText); + } + expect(originalNode.isAttached()).toBe(!deleted); + if (!deleted) { + expect(originalNode.is(remainingNodes[newIndex])).toBe( + true, + ); + } + newIndex += deleted ? 0 : 1; + } + expect(remainingNodes).toHaveLength(newIndex); + }); + }); + } + }); + }); + describe('multiple blocks', () => { + beforeEach(async () => { + await testEnv.editor.update(() => { + // Ensure that the separate texts don't get merged + const textNodes = texts.map((text) => + $createTextNode(text).setStyle(`color: --color-${text}`), + ); + $getRoot() + .clear() + .append( + ...textNodes.map((node) => $createParagraphNode().append(node)), + ); + }); + }); + describe('multiple text node selection', () => { + const OFFSETS = [startOfNode, insideNode, endOfNode]; + const NODE_PAIRS = [ + [0, 1], + [0, 2], + [1, 2], + ] as const; + for (const [ + direction, + [[nodeIndexStart, nodeIndexEnd], [startFn, endFn]], + ] of combinations( + DIRECTIONS, + combinations(NODE_PAIRS, combinations(OFFSETS, OFFSETS)), + )) { + test(`${direction} ${texts[nodeIndexStart]} ${startFn.name} ${texts[nodeIndexEnd]} ${endFn.name}`, async () => { + await testEnv.editor.update(() => { + const originalNodes = $getRoot().getAllTextNodes(); + const startNode = originalNodes[nodeIndexStart]; + const endNode = originalNodes[nodeIndexEnd]; + expect(startNode !== endNode).toBe(true); + invariant($isTextNode(startNode), 'text node'); + invariant($isTextNode(endNode), 'text node'); + expect(startNode.isBefore(endNode)).toBe(true); + const startCaret = $getTextPointCaret( + startNode, + direction, + startFn(startNode.getTextContentSize()), + ); + const endCaret = $getTextPointCaret( + endNode, + direction, + endFn(endNode.getTextContentSize()), + ); + const [anchor, focus] = + direction === 'next' + ? [startCaret, endCaret] + : [endCaret, startCaret]; + const range = $getCaretRange(anchor, focus); + // TODO check [...range] carets + const slices = range + .getTextSlices() + .filter($isTextPointCaretSlice); + expect(slices).toHaveLength(2); + expect(slices.map((slice) => slice.getTextContent())).toEqual( + direction === 'next' + ? [ + startCaret.origin + .getTextContent() + .slice(startCaret.offset), + endCaret.origin + .getTextContent() + .slice(0, endCaret.offset), + ] + : [ + endCaret.origin + .getTextContent() + .slice(0, endCaret.offset), + startCaret.origin + .getTextContent() + .slice(startCaret.offset), + ], + ); + const originalStartParent = startCaret.getParentAtCaret()!; + const originalEndParent = endCaret.getParentAtCaret()!; + const resultRange = $removeTextFromCaretRange(range); + if (direction === 'next') { + if (anchor.offset !== 0) { + // Part of the anchor remains + expect(resultRange).toMatchObject({ + anchor: { + direction, + offset: anchor.offset, + origin: anchor.origin.getLatest(), + }, + direction, + }); + } else if (focus.offset !== texts[nodeIndexEnd].length) { + // The focus was not deleted and there is no previous node + // so the new anchor will be set to the focus origin + expect(resultRange).toMatchObject({ + anchor: { + direction, + offset: 0, + origin: originalNodes[nodeIndexEnd].getLatest(), + }, + direction, + }); + } else { + // The anchor and focus were removed + // so we have an empty paragraph at the anchor + expect(resultRange).toMatchObject({ + anchor: { + direction, + origin: originalStartParent.getLatest(), + type: 'child', + }, + direction, + }); + } + } + // Check that the containing block is always that of the anchor + expect(resultRange.anchor.getParentAtCaret()!.getLatest()).toBe( + originalStartParent.getLatest(), + ); + // Check that the focus parent has always been removed + expect(originalEndParent.isAttached()).toBe(false); + // Check that the focus has been removed or moved to the anchor parent + expect( + !focus.origin.isAttached() || + originalStartParent.is(focus.origin.getParent()), + ).toBe(true); + const remainingNodes = $getRoot().getAllTextNodes(); + let newIndex = 0; + for ( + let originalIndex = 0; + originalIndex < originalNodes.length; + originalIndex++ + ) { + const originalText = texts[originalIndex]; + const originalNode = originalNodes[originalIndex]; + let deleted: boolean; + if (originalIndex === nodeIndexStart) { + deleted = startCaret.offset === 0; + if (!deleted) { + expect(originalNode.getTextContent()).toBe( + originalText.slice(0, startCaret.offset), + ); + } + } else if ( + originalIndex > nodeIndexStart && + originalIndex < nodeIndexEnd + ) { + deleted = true; + } else if (originalIndex === nodeIndexEnd) { + deleted = endCaret.offset === originalText.length; + if (!deleted) { + expect(originalNode.getTextContent()).toBe( + originalText.slice(endCaret.offset), + ); + } + } else { + deleted = false; + expect(originalNode.getTextContent()).toBe(originalText); + } + expect(originalNode.isAttached()).toBe(!deleted); + if (!deleted) { + expect(originalNode.is(remainingNodes[newIndex])).toBe( + true, + ); + } + newIndex += deleted ? 0 : 1; + } + expect(remainingNodes).toHaveLength(newIndex); + }); + }); + } + }); + }); + }); + }); +}); + +describe('LexicalSelectionHelpers', () => { + initializeUnitTest((testEnv) => { + describe('with a fully-selected text node preceded by an inline element', () => { + test('a single text node', async () => { + await testEnv.editor.update(() => { + const root = $getRoot(); + + const paragraph = $createParagraphNode(); + root.append(paragraph); + + const link = $createLinkNode('https://'); + link.append($createTextNode('link')); + paragraph.append(link); + + const text = $createTextNode('Existing text...'); + paragraph.append(text); + + const range = $getCaretRange( + $getTextPointCaret(text, 'next', 0), + $getTextPointCaret(text, 'next', 'next'), + ); + const newRange = $removeTextFromCaretRange(range); + expect(newRange).toMatchObject({ + anchor: { + direction: 'next', + origin: link.getLatest(), + type: 'sibling', + }, + }); + newRange.focus.insert($createTextNode('foo')); + }); + + expect(testEnv.innerHTML).toBe( + '

linkfoo

', + ); + }); + }); + }); +}); diff --git a/packages/lexical/src/caret/__tests__/unit/docs-traversals.test.ts b/packages/lexical/src/caret/__tests__/unit/docs-traversals.test.ts new file mode 100644 index 00000000000..513b5998dc0 --- /dev/null +++ b/packages/lexical/src/caret/__tests__/unit/docs-traversals.test.ts @@ -0,0 +1,330 @@ +/** + * 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 {$createLinkNode, LinkNode} from '@lexical/link'; +import { + $createParagraphNode, + $createTextNode, + $getCaretRange, + $getChildCaret, + $getRoot, + $getSiblingCaret, + $isChildCaret, + $isElementNode, + CaretDirection, + LexicalNode, + NodeCaret, + NodeKey, + ParagraphNode, + SiblingCaret, + TextNode, +} from 'lexical'; + +import {initializeUnitTest} from '../../../__tests__/utils'; + +// The tests below here are intended to be basically copied from packages/lexical-website/docs/concepts/traversals.md +describe('traversals.md', () => { + initializeUnitTest((testEnv) => { + describe('Traversal Strategies', () => { + let paragraphA: ParagraphNode; + let textA1: TextNode; + let linkA2: LinkNode; + let textA3: TextNode; + let textA4: TextNode; + let paragraphB: ParagraphNode; + let textB1: TextNode; + let paragraphC: ParagraphNode; + beforeEach(() => { + testEnv.editor.update(() => { + paragraphA = $createParagraphNode(); + textA1 = $createTextNode('Text A1'); + linkA2 = $createLinkNode( + 'https://lexical.dev/docs/concepts/traversals', + ); + textA3 = $createTextNode('Text A3'); + textA4 = $createTextNode('Text A4'); + paragraphB = $createParagraphNode(); + textB1 = $createTextNode('Text B1'); + paragraphC = $createParagraphNode(); + // Root + // * Paragraph A + // * Text A1 + // * Link A2 + // * Text A3 + // * Text A4 + // * Paragraph B + // * Text B1 + // * Paragraph C + $getRoot() + .clear() + .append( + paragraphA.append(textA1, linkA2.append(textA3), textA4), + paragraphB.append(textB1), + paragraphC, + ); + }); + }); + describe('Adjacent Caret Traversals', () => { + test('$iterSiblings', () => { + // 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; + } + } + testEnv.editor.update( + () => { + expect([ + ...$iterSiblings($getChildCaret($getRoot(), 'next')), + ]).toEqual([ + $getSiblingCaret(paragraphA, 'next'), + $getSiblingCaret(paragraphB, 'next'), + $getSiblingCaret(paragraphC, 'next'), + ]); + // iterSiblings is the same as iterating the caret + expect([ + ...$iterSiblings($getChildCaret($getRoot(), 'next')), + ]).toEqual([...$getChildCaret($getRoot(), 'next')]); + }, + {discrete: true}, + ); + }); + test('root has no siblings', () => { + testEnv.editor.update( + () => { + // The root does not have sibling nodes + const carets = [...$getSiblingCaret($getRoot(), 'next')]; + expect(carets).toEqual([]); + }, + {discrete: true}, + ); + }); + test('root has paragraph children', () => { + testEnv.editor.update( + () => { + // 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'), + ]); + }, + {discrete: true}, + ); + }); + test('iteration does not include the origin', () => { + testEnv.editor.update( + () => { + // 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'), + ]); + }, + {discrete: true}, + ); + }); + }); + describe('Depth First Caret Traversals', () => { + describe('$iterCaretsDepthFirst', () => { + test('via generator', () => { + 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; + } + } + testEnv.editor.update( + () => { + expect([ + ...$iterCaretsDepthFirst($getChildCaret($getRoot(), 'next')), + ]).toEqual([ + $getChildCaret(paragraphA, 'next'), + $getSiblingCaret(textA1, 'next'), + $getChildCaret(linkA2, 'next'), + $getSiblingCaret(textA3, 'next'), + $getSiblingCaret(linkA2, 'next'), + $getSiblingCaret(textA4, 'next'), + $getSiblingCaret(paragraphA, 'next'), + $getChildCaret(paragraphB, 'next'), + $getSiblingCaret(textB1, 'next'), + $getSiblingCaret(paragraphB, 'next'), + $getChildCaret(paragraphC, 'next'), + $getSiblingCaret(paragraphC, 'next'), + ]); + }, + {discrete: true}, + ); + }); + test('via CaretRange', () => { + 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), + ); + } + testEnv.editor.update( + () => { + expect([ + ...$iterCaretsDepthFirst($getChildCaret($getRoot(), 'next')), + ]).toEqual([ + $getChildCaret(paragraphA, 'next'), + $getSiblingCaret(textA1, 'next'), + $getChildCaret(linkA2, 'next'), + $getSiblingCaret(textA3, 'next'), + $getSiblingCaret(linkA2, 'next'), + $getSiblingCaret(textA4, 'next'), + $getSiblingCaret(paragraphA, 'next'), + $getChildCaret(paragraphB, 'next'), + $getSiblingCaret(textB1, 'next'), + $getSiblingCaret(paragraphB, 'next'), + $getChildCaret(paragraphC, 'next'), + $getSiblingCaret(paragraphC, 'next'), + ]); + }, + {discrete: true}, + ); + }); + }); + describe('$iterNodesDepthFirst', () => { + 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; + } + } + } + test('includes only wholly included nodes', () => { + testEnv.editor.update( + () => { + expect([ + ...$iterNodesDepthFirst( + $getChildCaret(paragraphA, 'next'), + $getChildCaret(paragraphC, 'next'), + ), + ]).toEqual([ + // already starting inside paragraphA + textA1, + // linkA2 is entered here + textA3, + // linkA2 is exited and included + linkA2, + textA4, + // paragraphA is exited but not included because it was never entered + // paragraphB is entered here + textB1, + // paragraphB is exited and included + paragraphB, + // paragraphC is entered but never exited so not included + ]); + }, + {discrete: true}, + ); + }); + }); + test('full traversal', () => { + testEnv.editor.update( + () => { + // 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 + ]); + }, + {discrete: true}, + ); + }); + }); + }); + }); +}); diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index 7d89f3e2daf..c72e969a5ae 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -6,72 +6,54 @@ * */ -export type {PasteCommandType} from './LexicalCommands'; export type { - CommandListener, - CommandListenerPriority, - CommandPayloadType, - CreateEditorArgs, - EditableListener, - EditorConfig, - EditorSetOptions, - EditorThemeClasses, - EditorThemeClassName, - EditorUpdateOptions, - HTMLConfig, - Klass, - KlassConstructor, - LexicalCommand, - LexicalEditor, - LexicalNodeReplacement, - MutationListener, - NodeMutation, - SerializedEditor, - Spread, - Transform, - UpdateListener, -} from './LexicalEditor'; -export type { - EditorState, - EditorStateReadOptions, - SerializedEditorState, -} from './LexicalEditorState'; -export type { - DOMChildConversion, - DOMConversion, - DOMConversionFn, - DOMConversionMap, - DOMConversionOutput, - DOMExportOutput, - DOMExportOutputMap, - LexicalNode, - LexicalUpdateJSON, - NodeKey, - NodeMap, - SerializedLexicalNode, -} from './LexicalNode'; -export type { - BaseSelection, - ElementPointType as ElementPoint, - NodeSelection, - Point, - PointType, - RangeSelection, - TextPointType as TextPoint, -} from './LexicalSelection'; -export type { - ElementDOMSlot, - ElementFormatType, - SerializedElementNode, -} from './nodes/LexicalElementNode'; -export type {SerializedRootNode} from './nodes/LexicalRootNode'; -export type { - SerializedTextNode, - TextFormatType, - TextModeType, -} from './nodes/LexicalTextNode'; - -// TODO Move this somewhere else and/or recheck if we still need this + BaseCaret, + CaretDirection, + CaretRange, + CaretType, + ChildCaret, + FlipDirection, + NodeCaret, + PointCaret, + RootMode, + SiblingCaret, + StepwiseIteratorConfig, + TextPointCaret, + TextPointCaretSlice, + TextPointCaretSliceTuple, +} from './caret/LexicalCaret'; +export { + $getAdjacentChildCaret, + $getCaretRange, + $getChildCaret, + $getChildCaretOrSelf, + $getSiblingCaret, + $getTextNodeOffset, + $getTextPointCaret, + $getTextPointCaretSlice, + $isChildCaret, + $isNodeCaret, + $isSiblingCaret, + $isTextPointCaret, + $isTextPointCaretSlice, + flipDirection, + makeStepwiseIterator, +} from './caret/LexicalCaret'; +export { + $caretFromPoint, + $caretRangeFromSelection, + $getAdjacentSiblingOrParentSiblingCaret, + $getCaretInDirection, + $getCaretRangeInDirection, + $getChildCaretAtIndex, + $normalizeCaret, + $removeTextFromCaretRange, + $rewindSiblingCaret, + $setPointFromCaret, + $setSelectionFromCaretRange, + $updateRangeSelectionFromCaretRange, +} from './caret/LexicalCaretUtils'; +export type {PasteCommandType} from './LexicalCommands'; export { BLUR_COMMAND, CAN_REDO_COMMAND, @@ -132,6 +114,30 @@ export { IS_UNDERLINE, TEXT_TYPE_TO_FORMAT, } from './LexicalConstants'; +export type { + CommandListener, + CommandListenerPriority, + CommandPayloadType, + CreateEditorArgs, + EditableListener, + EditorConfig, + EditorSetOptions, + EditorThemeClasses, + EditorThemeClassName, + EditorUpdateOptions, + HTMLConfig, + Klass, + KlassConstructor, + LexicalCommand, + LexicalEditor, + LexicalNodeReplacement, + MutationListener, + NodeMutation, + SerializedEditor, + Spread, + Transform, + UpdateListener, +} from './LexicalEditor'; export { COMMAND_PRIORITY_CRITICAL, COMMAND_PRIORITY_EDITOR, @@ -140,8 +146,36 @@ export { COMMAND_PRIORITY_NORMAL, createEditor, } from './LexicalEditor'; +export type { + EditorState, + EditorStateReadOptions, + SerializedEditorState, +} from './LexicalEditorState'; export type {EventHandler} from './LexicalEvents'; +export type { + DOMChildConversion, + DOMConversion, + DOMConversionFn, + DOMConversionMap, + DOMConversionOutput, + DOMExportOutput, + DOMExportOutputMap, + LexicalNode, + LexicalUpdateJSON, + NodeKey, + NodeMap, + SerializedLexicalNode, +} from './LexicalNode'; export {$normalizeSelection as $normalizeSelection__EXPERIMENTAL} from './LexicalNormalization'; +export type { + BaseSelection, + ElementPointType as ElementPoint, + NodeSelection, + Point, + PointType, + RangeSelection, + TextPointType as TextPoint, +} from './LexicalSelection'; export { $createNodeSelection, $createPoint, @@ -206,6 +240,11 @@ export { } from './LexicalUtils'; export {ArtificialNode__DO_NOT_USE} from './nodes/ArtificialNode'; export {$isDecoratorNode, DecoratorNode} from './nodes/LexicalDecoratorNode'; +export type { + ElementDOMSlot, + ElementFormatType, + SerializedElementNode, +} from './nodes/LexicalElementNode'; export {$isElementNode, ElementNode} from './nodes/LexicalElementNode'; export type {SerializedLineBreakNode} from './nodes/LexicalLineBreakNode'; export { @@ -219,7 +258,13 @@ export { $isParagraphNode, ParagraphNode, } from './nodes/LexicalParagraphNode'; +export type {SerializedRootNode} from './nodes/LexicalRootNode'; export {$isRootNode, RootNode} from './nodes/LexicalRootNode'; export type {SerializedTabNode} from './nodes/LexicalTabNode'; export {$createTabNode, $isTabNode, TabNode} from './nodes/LexicalTabNode'; +export type { + SerializedTextNode, + TextFormatType, + TextModeType, +} from './nodes/LexicalTextNode'; export {$createTextNode, $isTextNode, TextNode} from './nodes/LexicalTextNode'; diff --git a/packages/lexical/src/nodes/LexicalTextNode.ts b/packages/lexical/src/nodes/LexicalTextNode.ts index 15d6e4afb6e..6f7f2312140 100644 --- a/packages/lexical/src/nodes/LexicalTextNode.ts +++ b/packages/lexical/src/nodes/LexicalTextNode.ts @@ -21,7 +21,11 @@ import type { NodeKey, SerializedLexicalNode, } from '../LexicalNode'; -import type {BaseSelection, RangeSelection} from '../LexicalSelection'; +import type { + BaseSelection, + RangeSelection, + TextPointType, +} from '../LexicalSelection'; import type {ElementNode} from './LexicalElementNode'; import {IS_FIREFOX} from 'shared/environment'; @@ -927,26 +931,29 @@ export class TextNode extends LexicalNode { errorOnReadOnly(); const self = this.getLatest(); const textContent = self.getTextContent(); + if (textContent === '') { + return []; + } const key = self.__key; const compositionKey = $getCompositionKey(); - const offsetsSet = new Set(splitOffsets); - const parts = []; const textLength = textContent.length; - let string = ''; - for (let i = 0; i < textLength; i++) { - if (string !== '' && offsetsSet.has(i)) { - parts.push(string); - string = ''; + splitOffsets.sort((a, b) => a - b); + splitOffsets.push(textLength); + const parts = []; + const splitOffsetsLength = splitOffsets.length; + for ( + let start = 0, offsetIndex = 0; + start < textLength && offsetIndex <= splitOffsetsLength; + offsetIndex++ + ) { + const end = splitOffsets[offsetIndex]; + if (end > start) { + parts.push(textContent.slice(start, end)); + start = end; } - string += textContent[i]; - } - if (string !== '') { - parts.push(string); } const partsLength = parts.length; - if (partsLength === 0) { - return []; - } else if (parts[0] === textContent) { + if (partsLength === 1) { return [self]; } const firstPart = parts[0]; @@ -957,6 +964,22 @@ export class TextNode extends LexicalNode { const detail = self.__detail; let hasReplacedSelf = false; + // Prepare to handle selection + let startTextPoint: TextPointType | null = null; + let endTextPoint: TextPointType | null = null; + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + const [startPoint, endPoint] = selection.isBackward() + ? [selection.focus, selection.anchor] + : [selection.anchor, selection.focus]; + if (startPoint.type === 'text' && startPoint.key === key) { + startTextPoint = startPoint; + } + if (endPoint.type === 'text' && endPoint.key === key) { + endTextPoint = endPoint; + } + } + if (self.isSegmented()) { // Create a new TextNode writableNode = $createTextNode(firstPart); @@ -970,9 +993,6 @@ export class TextNode extends LexicalNode { writableNode.__text = firstPart; } - // Handle selection - const selection = $getSelection(); - // Then handle all other parts const splitNodes: TextNode[] = [writableNode]; let textSize = firstPart.length; @@ -980,38 +1000,12 @@ export class TextNode extends LexicalNode { for (let i = 1; i < partsLength; i++) { const part = parts[i]; const partSize = part.length; - const sibling = $createTextNode(part).getWritable(); + const sibling = $createTextNode(part); sibling.__format = format; sibling.__style = style; sibling.__detail = detail; const siblingKey = sibling.__key; const nextTextSize = textSize + partSize; - - if ($isRangeSelection(selection)) { - const anchor = selection.anchor; - const focus = selection.focus; - - if ( - anchor.key === key && - anchor.type === 'text' && - anchor.offset > textSize && - anchor.offset <= nextTextSize - ) { - anchor.key = siblingKey; - anchor.offset -= textSize; - selection.dirty = true; - } - if ( - focus.key === key && - focus.type === 'text' && - focus.offset > textSize && - focus.offset <= nextTextSize - ) { - focus.key = siblingKey; - focus.offset -= textSize; - selection.dirty = true; - } - } if (compositionKey === key) { $setCompositionKey(siblingKey); } @@ -1019,6 +1013,52 @@ export class TextNode extends LexicalNode { splitNodes.push(sibling); } + // Move the selection to the best location in the split string. + // The end point is always left-biased, and the start point is + // generally left biased unless the end point would land on a + // later node in the split in which case it will prefer the start + // of that node so they will tend to be on the same node. + const originalStartOffset = startTextPoint ? startTextPoint.offset : null; + const originalEndOffset = endTextPoint ? endTextPoint.offset : null; + let startOffset = 0; + for (const node of splitNodes) { + if (!(startTextPoint || endTextPoint)) { + break; + } + const endOffset = startOffset + node.getTextContentSize(); + if ( + startTextPoint !== null && + originalStartOffset !== null && + originalStartOffset <= endOffset && + originalStartOffset >= startOffset + ) { + // Set the start point to the first valid node + startTextPoint.set( + node.getKey(), + originalStartOffset - startOffset, + 'text', + ); + if (originalStartOffset < endOffset) { + // The start isn't on a border so we can stop checking + startTextPoint = null; + } + } + if ( + endTextPoint !== null && + originalEndOffset !== null && + originalEndOffset <= endOffset && + originalEndOffset >= startOffset + ) { + endTextPoint.set( + node.getKey(), + originalEndOffset - startOffset, + 'text', + ); + break; + } + startOffset = endOffset; + } + // Insert the nodes into the parent's children if (parent !== null) { internalMarkSiblingsAsDirty(this); diff --git a/scripts/build.js b/scripts/build.js index e92f9376e99..59a979cfe7c 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -135,7 +135,7 @@ async function build( if ( typeof modulePkgName === 'string' && !( - modulePkgName in pkg.packageJson.dependencies || + modulePkgName in (pkg.packageJson.dependencies || {}) || modulePkgName === pkg.getNpmName() ) ) {