diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 00000000000..c8949b400c3
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,30 @@
+
+
+## Description
+
+*Describe the changes in this pull request*
+
+**Closes:** #
+
+## Test plan
+
+### Before
+
+*Insert relevant screenshots/recordings/automated-tests*
+
+
+### After
+
+*Insert relevant screenshots/recordings/automated-tests*
\ No newline at end of file
diff --git a/packages/lexical-code/src/CodeNode.ts b/packages/lexical-code/src/CodeNode.ts
index ef2588aa598..361accf2cc1 100644
--- a/packages/lexical-code/src/CodeNode.ts
+++ b/packages/lexical-code/src/CodeNode.ts
@@ -168,13 +168,7 @@ export class CodeNode extends ElementNode {
const td = node as HTMLTableCellElement;
const table: HTMLTableElement | null = td.closest('table');
- if (isGitHubCodeCell(td)) {
- return {
- conversion: convertTableCellElement,
- priority: 3,
- };
- }
- if (table && isGitHubCodeTable(table)) {
+ if (isGitHubCodeCell(td) || (table && isGitHubCodeTable(table))) {
// Return a no-op if it's a table cell in a code table, but not a code line.
// Otherwise it'll fall back to the T
return {
@@ -348,13 +342,6 @@ function convertDivElement(domNode: Node): DOMConversionOutput {
};
}
return {
- after: (childLexicalNodes) => {
- const domParent = domNode.parentNode;
- if (domParent != null && domNode !== domParent.lastChild) {
- childLexicalNodes.push($createLineBreakNode());
- }
- return childLexicalNodes;
- },
node: isCode ? $createCodeNode() : null,
};
}
@@ -367,22 +354,6 @@ function convertCodeNoop(): DOMConversionOutput {
return {node: null};
}
-function convertTableCellElement(domNode: Node): DOMConversionOutput {
- // domNode is a
since we matched it by nodeName
- const cell = domNode as HTMLTableCellElement;
-
- return {
- after: (childLexicalNodes) => {
- if (cell.parentNode && cell.parentNode.nextSibling) {
- // Append newline between code lines
- childLexicalNodes.push($createLineBreakNode());
- }
- return childLexicalNodes;
- },
- node: null,
- };
-}
-
function isCodeElement(div: HTMLElement): boolean {
return div.style.fontFamily.match('monospace') !== null;
}
diff --git a/packages/lexical-code/src/__tests__/unit/LexicalCodeNode.test.ts b/packages/lexical-code/src/__tests__/unit/LexicalCodeNode.test.ts
index b44bd3ad606..406c8c757a8 100644
--- a/packages/lexical-code/src/__tests__/unit/LexicalCodeNode.test.ts
+++ b/packages/lexical-code/src/__tests__/unit/LexicalCodeNode.test.ts
@@ -178,7 +178,7 @@ describe('LexicalCodeNode tests', () => {
const code = $createCodeNode();
root.append(code);
code.selectStart();
- $getSelection().insertText('function');
+ $getSelection()!.insertText('function');
});
await editor.dispatchCommand(KEY_TAB_COMMAND, tabKeyboardEvent());
expect(testEnv.innerHTML).toBe(
@@ -187,12 +187,12 @@ describe('LexicalCodeNode tests', () => {
// CodeNode should only render diffs, make sure that the TabNode is not cloned when
// appending more text
- let tabKey;
+ let tabKey: string;
await editor.update(() => {
tabKey = $dfs()
- .find(({node}) => $isTabNode(node))
+ .find(({node}) => $isTabNode(node))!
.node.getKey();
- $getSelection().insertText('foo');
+ $getSelection()!.insertText('foo');
});
expect(
editor.getEditorState().read(() => {
@@ -214,7 +214,7 @@ describe('LexicalCodeNode tests', () => {
const code = $createCodeNode();
root.append(code);
code.selectStart();
- $getSelection().insertText('function');
+ $getSelection()!.insertText('function');
});
// TODO consolidate editor.update - there's some bad logic in updateAndRetainSelection
await editor.update(() => {
@@ -238,7 +238,7 @@ describe('LexicalCodeNode tests', () => {
const code = $createCodeNode();
root.append(code);
code.selectStart();
- $getSelection().insertText('function');
+ $getSelection()!.insertText('function');
});
// TODO consolidate editor.update - there's some bad logic in updateAndRetainSelection
await editor.update(() => {
@@ -253,8 +253,8 @@ describe('LexicalCodeNode tests', () => {
await editor.update(() => {
const root = $getRoot();
- const codeTab = root.getFirstDescendant();
- const codeText = root.getLastDescendant();
+ const codeTab = root.getFirstDescendant()!;
+ const codeText = root.getLastDescendant()!;
const selection = $createRangeSelection();
selection.anchor.set(codeTab.getKey(), 0, 'text');
selection.focus.set(codeText.getKey(), 'function'.length, 'text');
@@ -275,7 +275,7 @@ describe('LexicalCodeNode tests', () => {
const code = $createCodeNode();
root.append(code);
code.selectStart();
- $getSelection().insertText('function');
+ $getSelection()!.insertText('function');
});
// TODO consolidate editor.update - there's some bad logic in updateAndRetainSelection
await editor.update(() => {
@@ -290,8 +290,8 @@ describe('LexicalCodeNode tests', () => {
await editor.update(() => {
const root = $getRoot();
- const codeTab = root.getFirstDescendant();
- const codeText = root.getLastDescendant();
+ const codeTab = root.getFirstDescendant()!;
+ const codeText = root.getLastDescendant()!;
const selection = $createRangeSelection();
selection.anchor.set(codeTab.getKey(), 0, 'text');
selection.focus.set(codeText.getKey(), 0, 'text');
@@ -313,12 +313,12 @@ describe('LexicalCodeNode tests', () => {
const code = $createCodeNode();
root.append(code);
code.selectStart();
- $getSelection().insertRawText('hello\tworld\nhello\tworld');
+ $getSelection()!.insertRawText('hello\tworld\nhello\tworld');
});
// TODO consolidate editor.update - there's some bad logic in updateAndRetainSelection
await editor.update(() => {
- const firstCodeText = $getRoot().getFirstDescendant();
- const lastCodeText = $getRoot().getLastDescendant();
+ const firstCodeText = $getRoot().getFirstDescendant()!;
+ const lastCodeText = $getRoot().getLastDescendant()!;
const selection = $createRangeSelection();
selection.anchor.set(firstCodeText.getKey(), 1, 'text');
selection.focus.set(lastCodeText.getKey(), 1, 'text');
@@ -347,7 +347,7 @@ describe('LexicalCodeNode tests', () => {
const code = $createCodeNode();
root.append(code);
code.selectStart();
- $getSelection().insertRawText('hello\n');
+ $getSelection()!.insertRawText('hello\n');
});
await editor.dispatchCommand(KEY_TAB_COMMAND, tabKeyboardEvent());
expect(testEnv.innerHTML)
@@ -365,7 +365,7 @@ describe('LexicalCodeNode tests', () => {
const code = $createCodeNode();
root.append(code);
code.selectStart();
- $getSelection().insertRawText('\thello');
+ $getSelection()!.insertRawText('\thello');
});
// TODO consolidate editor.update - there's some bad logic in updateAndRetainSelection
await editor.update(() => {
@@ -389,7 +389,7 @@ describe('LexicalCodeNode tests', () => {
const code = $createCodeNode();
root.append(code);
code.selectStart();
- $getSelection().insertRawText('abc\tdef\nghi\tjkl');
+ $getSelection()!.insertRawText('abc\tdef\nghi\tjkl');
});
const keyEvent = new KeyboardEventMock();
keyEvent.altKey = true;
@@ -409,16 +409,16 @@ describe('LexicalCodeNode tests', () => {
const code = $createCodeNode();
root.append(code);
code.selectStart();
- $getSelection().insertRawText('abc\tdef\nghi\tjkl\nmno\tpqr');
+ $getSelection()!.insertRawText('abc\tdef\nghi\tjkl\nmno\tpqr');
});
// TODO consolidate editor.update - there's some bad logic in updateAndRetainSelection
await editor.update(() => {
- const firstCodeText = $getRoot().getFirstDescendant();
+ const firstCodeText = $getRoot().getFirstDescendant()!;
const secondCodeText = firstCodeText
- .getNextSibling() // tab
- .getNextSibling() // def
- .getNextSibling() // linebreak
- .getNextSibling(); // ghi;
+ .getNextSibling()! // tab
+ .getNextSibling()! // def
+ .getNextSibling()! // linebreak
+ .getNextSibling()!; // ghi;
const selection = $createRangeSelection();
selection.anchor.set(firstCodeText.getKey(), 1, 'text');
selection.focus.set(secondCodeText.getKey(), 1, 'text');
@@ -455,7 +455,7 @@ describe('LexicalCodeNode tests', () => {
const code = $createCodeNode();
root.append(code);
code.selectStart();
- const selection = $getSelection();
+ const selection = $getSelection()!;
if (tabOrSpaces === 'tab') {
selection.insertRawText('\t\tfunction foo\n\t\tfunction bar');
} else {
@@ -570,7 +570,7 @@ describe('LexicalCodeNode tests', () => {
const firstChild = code.getFirstChild();
invariant($isTextNode(firstChild));
if (tabOrSpaces === 'tab') {
- firstChild.getNextSibling().selectNext(0, 0);
+ firstChild.getNextSibling()!.selectNext(0, 0);
} else {
firstChild.select(4, 4);
}
@@ -605,7 +605,7 @@ describe('LexicalCodeNode tests', () => {
$isLineBreakNode(dfsNode.node),
)[0].node;
if (tabOrSpaces === 'tab') {
- const firstTab = linebreak.getNextSibling();
+ const firstTab = linebreak.getNextSibling()!;
firstTab.selectNext();
} else {
linebreak.selectNext(4, 4);
@@ -687,7 +687,7 @@ describe('LexicalCodeNode tests', () => {
$isLineBreakNode(dfsNode.node),
)[0].node;
if (tabOrSpaces === 'tab') {
- const firstTab = linebreak.getNextSibling();
+ const firstTab = linebreak.getNextSibling()!;
firstTab.selectNext(0, 0);
} else {
linebreak.selectNext(2, 2);
diff --git a/packages/lexical-devtools-core/flow/LexicalDevtoolsCore.js.flow b/packages/lexical-devtools-core/flow/LexicalDevtoolsCore.js.flow
index e124beb2302..11f5fdbc79e 100644
--- a/packages/lexical-devtools-core/flow/LexicalDevtoolsCore.js.flow
+++ b/packages/lexical-devtools-core/flow/LexicalDevtoolsCore.js.flow
@@ -6,3 +6,7 @@
*
* @flow strict
*/
+
+/**
+ * LexicalDevtoolsCore
+ */
diff --git a/packages/lexical-devtools-core/src/generateContent.ts b/packages/lexical-devtools-core/src/generateContent.ts
index db702c6ddd0..37309ccfeb1 100644
--- a/packages/lexical-devtools-core/src/generateContent.ts
+++ b/packages/lexical-devtools-core/src/generateContent.ts
@@ -88,6 +88,7 @@ export function generateContent(
editor: LexicalEditor,
commandsLog: ReadonlyArray & {payload: unknown}>,
exportDOM: boolean,
+ obfuscateText: boolean = false,
): string {
const editorState = editor.getEditorState();
const editorConfig = editor._config;
@@ -118,7 +119,10 @@ export function generateContent(
res += `${isSelected ? SYMBOLS.selectedLine : ' '} ${indent.join(
' ',
- )} ${nodeKeyDisplay} ${typeDisplay} ${idsDisplay} ${printNode(node)}\n`;
+ )} ${nodeKeyDisplay} ${typeDisplay} ${idsDisplay} ${printNode(
+ node,
+ obfuscateText,
+ )}\n`;
res += printSelectedCharsLine({
indent,
@@ -230,18 +234,23 @@ function visitTree(
});
}
-function normalize(text: string) {
- return Object.entries(NON_SINGLE_WIDTH_CHARS_REPLACEMENT).reduce(
+function normalize(text: string, obfuscateText: boolean = false) {
+ const textToPrint = Object.entries(NON_SINGLE_WIDTH_CHARS_REPLACEMENT).reduce(
(acc, [key, value]) => acc.replace(new RegExp(key, 'g'), String(value)),
text,
);
+ if (obfuscateText) {
+ return textToPrint.replace(/[^\s]/g, '*');
+ }
+ return textToPrint;
}
// TODO Pass via props to allow customizability
-function printNode(node: LexicalNode) {
+function printNode(node: LexicalNode, obfuscateText: boolean = false) {
if ($isTextNode(node)) {
const text = node.getTextContent();
- const title = text.length === 0 ? '(empty)' : `"${normalize(text)}"`;
+ const title =
+ text.length === 0 ? '(empty)' : `"${normalize(text, obfuscateText)}"`;
const properties = printAllTextNodeProperties(node);
return [title, properties.length !== 0 ? `{ ${properties} }` : null]
.filter(Boolean)
@@ -249,7 +258,8 @@ function printNode(node: LexicalNode) {
.trim();
} else if ($isLinkNode(node)) {
const link = node.getURL();
- const title = link.length === 0 ? '(empty)' : `"${normalize(link)}"`;
+ const title =
+ link.length === 0 ? '(empty)' : `"${normalize(link, obfuscateText)}"`;
const properties = printAllLinkNodeProperties(node);
return [title, properties.length !== 0 ? `{ ${properties} }` : null]
.filter(Boolean)
diff --git a/packages/lexical-headless/src/__tests__/unit/LexicalHeadlessEditor.test.ts b/packages/lexical-headless/src/__tests__/unit/LexicalHeadlessEditor.test.ts
index 9f695c1f316..afa65708d4b 100644
--- a/packages/lexical-headless/src/__tests__/unit/LexicalHeadlessEditor.test.ts
+++ b/packages/lexical-headless/src/__tests__/unit/LexicalHeadlessEditor.test.ts
@@ -16,7 +16,7 @@
*
*/
-import type {RangeSelection} from 'lexical';
+import type {EditorState, LexicalEditor, RangeSelection} from 'lexical';
import {$generateHtmlFromNodes} from '@lexical/html';
import {JSDOM} from 'jsdom';
@@ -33,14 +33,17 @@ import {
import {createHeadlessEditor} from '../..';
describe('LexicalHeadlessEditor', () => {
- let editor;
+ let editor: LexicalEditor;
- async function update(updateFn) {
+ async function update(updateFn: () => void) {
editor.update(updateFn);
await Promise.resolve();
}
- function assertEditorState(editorState, nodes) {
+ function assertEditorState(
+ editorState: EditorState,
+ nodes: Record[],
+ ) {
const nodesFromState = Array.from(editorState._nodeMap.values());
expect(nodesFromState).toEqual(
nodes.map((node) => expect.objectContaining(node)),
diff --git a/packages/lexical-history/src/__tests__/unit/LexicalHistory.test.tsx b/packages/lexical-history/src/__tests__/unit/LexicalHistory.test.tsx
index c1017b18684..4b13bb94fa2 100644
--- a/packages/lexical-history/src/__tests__/unit/LexicalHistory.test.tsx
+++ b/packages/lexical-history/src/__tests__/unit/LexicalHistory.test.tsx
@@ -34,12 +34,12 @@ import {
} from 'lexical/src';
import {createTestEditor, TestComposer} from 'lexical/src/__tests__/utils';
import React from 'react';
-import {createRoot} from 'react-dom/client';
+import {createRoot, Root} from 'react-dom/client';
import * as ReactTestUtils from 'react-dom/test-utils';
describe('LexicalHistory tests', () => {
let container: HTMLDivElement | null = null;
- let reactRoot;
+ let reactRoot: Root;
beforeEach(() => {
container = document.createElement('div');
diff --git a/packages/lexical-html/src/index.ts b/packages/lexical-html/src/index.ts
index 52f946f8057..415e0217cf7 100644
--- a/packages/lexical-html/src/index.ts
+++ b/packages/lexical-html/src/index.ts
@@ -19,8 +19,18 @@ import {
$cloneWithProperties,
$sliceSelectedTextNodeContent,
} from '@lexical/selection';
-import {isHTMLElement} from '@lexical/utils';
-import {$getRoot, $isElementNode, $isTextNode} from 'lexical';
+import {isBlockDomNode, isHTMLElement} from '@lexical/utils';
+import {
+ $createLineBreakNode,
+ $createParagraphNode,
+ $getRoot,
+ $isBlockElementNode,
+ $isElementNode,
+ $isRootOrShadowRoot,
+ $isTextNode,
+ ArtificialNode__DO_NOT_USE,
+ ElementNode,
+} from 'lexical';
/**
* How you parse your html string to get a document is left up to you. In the browser you can use the native
@@ -33,15 +43,22 @@ export function $generateNodesFromDOM(
): Array {
const elements = dom.body ? dom.body.childNodes : [];
let lexicalNodes: Array = [];
+ const allArtificialNodes: Array = [];
for (let i = 0; i < elements.length; i++) {
const element = elements[i];
if (!IGNORE_TAGS.has(element.nodeName)) {
- const lexicalNode = $createNodesFromDOM(element, editor);
+ const lexicalNode = $createNodesFromDOM(
+ element,
+ editor,
+ allArtificialNodes,
+ false,
+ );
if (lexicalNode !== null) {
lexicalNodes = lexicalNodes.concat(lexicalNode);
}
}
}
+ unwrapArtificalNodes(allArtificialNodes);
return lexicalNodes;
}
@@ -161,7 +178,6 @@ function getConversionFunction(
if (cachedConversions !== undefined) {
for (const cachedConversion of cachedConversions) {
const domConversion = cachedConversion(domNode);
-
if (
domConversion !== null &&
(currentConversion === null ||
@@ -180,6 +196,8 @@ const IGNORE_TAGS = new Set(['STYLE', 'SCRIPT']);
function $createNodesFromDOM(
node: Node,
editor: LexicalEditor,
+ allArtificialNodes: Array,
+ hasBlockAncestorLexicalNode: boolean,
forChildMap: Map = new Map(),
parentLexicalNode?: LexicalNode | null | undefined,
): Array {
@@ -234,11 +252,20 @@ function $createNodesFromDOM(
const children = node.childNodes;
let childLexicalNodes = [];
+ const hasBlockAncestorLexicalNodeForChildren =
+ currentLexicalNode != null && $isRootOrShadowRoot(currentLexicalNode)
+ ? false
+ : (currentLexicalNode != null &&
+ $isBlockElementNode(currentLexicalNode)) ||
+ hasBlockAncestorLexicalNode;
+
for (let i = 0; i < children.length; i++) {
childLexicalNodes.push(
...$createNodesFromDOM(
children[i],
editor,
+ allArtificialNodes,
+ hasBlockAncestorLexicalNodeForChildren,
new Map(forChildMap),
currentLexicalNode,
),
@@ -249,6 +276,22 @@ function $createNodesFromDOM(
childLexicalNodes = postTransform(childLexicalNodes);
}
+ if (isBlockDomNode(node)) {
+ if (!hasBlockAncestorLexicalNodeForChildren) {
+ childLexicalNodes = wrapContinuousInlines(
+ node,
+ childLexicalNodes,
+ $createParagraphNode,
+ );
+ } else {
+ childLexicalNodes = wrapContinuousInlines(node, childLexicalNodes, () => {
+ const artificialNode = new ArtificialNode__DO_NOT_USE();
+ allArtificialNodes.push(artificialNode);
+ return artificialNode;
+ });
+ }
+ }
+
if (currentLexicalNode == null) {
// If it hasn't been converted to a LexicalNode, we hoist its children
// up to the same level as it.
@@ -263,3 +306,49 @@ function $createNodesFromDOM(
return lexicalNodes;
}
+
+function wrapContinuousInlines(
+ domNode: Node,
+ nodes: Array,
+ createWrapperFn: () => ElementNode,
+): Array {
+ const out: Array = [];
+ let continuousInlines: Array = [];
+ // wrap contiguous inline child nodes in para
+ for (let i = 0; i < nodes.length; i++) {
+ const node = nodes[i];
+ if ($isBlockElementNode(node)) {
+ out.push(node);
+ } else {
+ continuousInlines.push(node);
+ if (
+ i === nodes.length - 1 ||
+ (i < nodes.length - 1 && $isBlockElementNode(nodes[i + 1]))
+ ) {
+ const wrapper = createWrapperFn();
+ wrapper.append(...continuousInlines);
+ out.push(wrapper);
+ continuousInlines = [];
+ }
+ }
+ }
+ return out;
+}
+
+function unwrapArtificalNodes(
+ allArtificialNodes: Array,
+) {
+ for (const node of allArtificialNodes) {
+ if (node.getNextSibling() instanceof ArtificialNode__DO_NOT_USE) {
+ node.insertAfter($createLineBreakNode());
+ }
+ }
+ // Replace artificial node with it's children
+ for (const node of allArtificialNodes) {
+ const children = node.getChildren();
+ for (const child of children) {
+ node.insertBefore(child);
+ }
+ node.remove();
+ }
+}
diff --git a/packages/lexical-list/src/__tests__/unit/LexicalListItemNode.test.ts b/packages/lexical-list/src/__tests__/unit/LexicalListItemNode.test.ts
index 5abc144ae2c..a739c5fa7de 100644
--- a/packages/lexical-list/src/__tests__/unit/LexicalListItemNode.test.ts
+++ b/packages/lexical-list/src/__tests__/unit/LexicalListItemNode.test.ts
@@ -6,7 +6,12 @@
*
*/
-import {$createParagraphNode, $getRoot, TextNode} from 'lexical';
+import {
+ $createParagraphNode,
+ $createRangeSelection,
+ $getRoot,
+ TextNode,
+} from 'lexical';
import {
expectHtmlToBeEqual,
html,
@@ -147,10 +152,10 @@ describe('LexicalListItemNode tests', () => {
});
describe('ListItemNode.replace()', () => {
- let listNode;
- let listItemNode1;
- let listItemNode2;
- let listItemNode3;
+ let listNode: ListNode;
+ let listItemNode1: ListItemNode;
+ let listItemNode2: ListItemNode;
+ let listItemNode3: ListItemNode;
beforeEach(async () => {
const {editor} = testEnv;
@@ -391,7 +396,7 @@ describe('LexicalListItemNode tests', () => {
// - B
test('siblings are not nested', async () => {
const {editor} = testEnv;
- let x;
+ let x: ListItemNode;
await editor.update(() => {
const root = $getRoot();
@@ -459,7 +464,7 @@ describe('LexicalListItemNode tests', () => {
// - B
test('the previous sibling is nested', async () => {
const {editor} = testEnv;
- let x;
+ let x: ListItemNode;
await editor.update(() => {
const root = $getRoot();
@@ -539,7 +544,7 @@ describe('LexicalListItemNode tests', () => {
// - B
test('the next sibling is nested', async () => {
const {editor} = testEnv;
- let x;
+ let x: ListItemNode;
await editor.update(() => {
const root = $getRoot();
@@ -619,7 +624,7 @@ describe('LexicalListItemNode tests', () => {
// - B
test('both siblings are nested', async () => {
const {editor} = testEnv;
- let x;
+ let x: ListItemNode;
await editor.update(() => {
const root = $getRoot();
@@ -708,7 +713,7 @@ describe('LexicalListItemNode tests', () => {
// - B
test('the previous sibling is nested deeper than the next sibling', async () => {
const {editor} = testEnv;
- let x;
+ let x: ListItemNode;
await editor.update(() => {
const root = $getRoot();
@@ -818,7 +823,7 @@ describe('LexicalListItemNode tests', () => {
// - B2
test('the next sibling is nested deeper than the previous sibling', async () => {
const {editor} = testEnv;
- let x;
+ let x: ListItemNode;
await editor.update(() => {
const root = $getRoot();
@@ -929,7 +934,7 @@ describe('LexicalListItemNode tests', () => {
// - B2
test('both siblings are deeply nested', async () => {
const {editor} = testEnv;
- let x;
+ let x: ListItemNode;
await editor.update(() => {
const root = $getRoot();
@@ -1052,10 +1057,10 @@ describe('LexicalListItemNode tests', () => {
});
describe('ListItemNode.insertNewAfter(): non-empty list items', () => {
- let listNode;
- let listItemNode1;
- let listItemNode2;
- let listItemNode3;
+ let listNode: ListNode;
+ let listItemNode1: ListItemNode;
+ let listItemNode2: ListItemNode;
+ let listItemNode3: ListItemNode;
beforeEach(async () => {
const {editor} = testEnv;
@@ -1103,7 +1108,7 @@ describe('LexicalListItemNode tests', () => {
const {editor} = testEnv;
await editor.update(() => {
- listItemNode1.insertNewAfter();
+ listItemNode1.insertNewAfter($createRangeSelection());
});
expectHtmlToBeEqual(
@@ -1134,7 +1139,7 @@ describe('LexicalListItemNode tests', () => {
const {editor} = testEnv;
await editor.update(() => {
- listItemNode3.insertNewAfter();
+ listItemNode3.insertNewAfter($createRangeSelection());
});
expectHtmlToBeEqual(
@@ -1165,7 +1170,7 @@ describe('LexicalListItemNode tests', () => {
const {editor} = testEnv;
await editor.update(() => {
- listItemNode3.insertNewAfter();
+ listItemNode3.insertNewAfter($createRangeSelection());
});
expectHtmlToBeEqual(
@@ -1217,7 +1222,7 @@ describe('LexicalListItemNode tests', () => {
);
await editor.update(() => {
- listItemNode1.insertNewAfter();
+ listItemNode1.insertNewAfter($createRangeSelection());
});
expectHtmlToBeEqual(
@@ -1264,9 +1269,9 @@ describe('LexicalListItemNode tests', () => {
});
describe('ListItemNode.setIndent()', () => {
- let listNode;
- let listItemNode1;
- let listItemNode2;
+ let listNode: ListNode;
+ let listItemNode1: ListItemNode;
+ let listItemNode2: ListItemNode;
beforeEach(async () => {
const {editor} = testEnv;
@@ -1296,7 +1301,7 @@ describe('LexicalListItemNode tests', () => {
});
expectHtmlToBeEqual(
- editor.getRootElement().innerHTML,
+ editor.getRootElement()!.innerHTML,
html`
-
@@ -1330,7 +1335,7 @@ describe('LexicalListItemNode tests', () => {
});
expectHtmlToBeEqual(
- editor.getRootElement().innerHTML,
+ editor.getRootElement()!.innerHTML,
html`
-
diff --git a/packages/lexical-list/src/__tests__/unit/LexicalListNode.test.ts b/packages/lexical-list/src/__tests__/unit/LexicalListNode.test.ts
index 2a3e5e05497..6abcbbd4cb7 100644
--- a/packages/lexical-list/src/__tests__/unit/LexicalListNode.test.ts
+++ b/packages/lexical-list/src/__tests__/unit/LexicalListNode.test.ts
@@ -238,7 +238,7 @@ describe('LexicalListNode tests', () => {
expect(listNode.append(...nodesToAppend)).toBe(listNode);
expect($isListItemNode(listNode.getFirstChild())).toBe(true);
- expect(listNode.getFirstChild().getFirstChild()).toBe(
+ expect(listNode.getFirstChild()!.getFirstChild()).toBe(
nestedListNode,
);
});
diff --git a/packages/lexical-playground/__tests__/e2e/CodeBlock.spec.mjs b/packages/lexical-playground/__tests__/e2e/CodeBlock.spec.mjs
index 5d6a72fb4a6..4ec9ee67b67 100644
--- a/packages/lexical-playground/__tests__/e2e/CodeBlock.spec.mjs
+++ b/packages/lexical-playground/__tests__/e2e/CodeBlock.spec.mjs
@@ -1027,7 +1027,11 @@ test.describe('CodeBlock', () => {
- XDS_RICH_TEXT_AREA
+
+ XDS_RICH_TEXT_AREA
+
|
@@ -1120,7 +1124,7 @@ test.describe('CodeBlock', () => {
{
expectedHTML: EXPECTED_HTML_GOOGLE_SPREADSHEET,
name: 'Google Spreadsheet',
- pastedHTML: `Surface | MWP_WORK_LS_COMPOSER | 77349 | Lexical | XDS_RICH_TEXT_AREA | sdvd sdfvsfs | `,
+ pastedHTML: `Surface | MWP_WORK_LS_COMPOSER | 77349 | Lexical | XDS_RICH_TEXT_AREA | sdvd sdfvsfs | `,
},
];
diff --git a/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/HTMLCopyAndPaste.spec.mjs b/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/HTMLCopyAndPaste.spec.mjs
index 3b832edde30..cb0a9bed93a 100644
--- a/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/HTMLCopyAndPaste.spec.mjs
+++ b/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/HTMLCopyAndPaste.spec.mjs
@@ -275,4 +275,229 @@ test.describe('HTML CopyAndPaste', () => {
`,
);
});
+
+ test('Copy + paste single div', async ({page, isPlainText}) => {
+ test.skip(isPlainText);
+
+ await focusEditor(page);
+
+ const clipboard = {
+ 'text/html': `
+ 123
+
+ 456
+ `,
+ };
+
+ await pasteFromClipboard(page, clipboard);
+
+ await assertHTML(
+ page,
+ html`
+
+ 123
+
+
+ 456
+
+ `,
+ );
+ await assertSelection(page, {
+ anchorOffset: 3,
+ anchorPath: [1, 0, 0],
+ focusOffset: 3,
+ focusPath: [1, 0, 0],
+ });
+ });
+
+ test('Copy + paste nested divs', async ({page, isPlainText}) => {
+ test.skip(isPlainText);
+
+ await focusEditor(page);
+
+ const clipboard = {
+ 'text/html': html`
+
+ `,
+ };
+
+ await pasteFromClipboard(page, clipboard);
+
+ await assertHTML(
+ page,
+ html`
+
+ a
+
+
+ b b
+
+
+ c
+
+
+ z
+
+
+ d e
+
+
+ fg
+
+ `,
+ );
+ await assertSelection(page, {
+ anchorOffset: 2,
+ anchorPath: [5, 0, 0],
+ focusOffset: 2,
+ focusPath: [5, 0, 0],
+ });
+ });
+
+ test('Copy + paste nested div in a span', async ({page, isPlainText}) => {
+ test.skip(isPlainText);
+
+ await focusEditor(page);
+
+ const clipboard = {
+ 'text/html': html`
+
+ 123
+ 456
+
+ `,
+ };
+
+ await pasteFromClipboard(page, clipboard);
+
+ await assertHTML(
+ page,
+ html`
+
+ 123
+
+
+ 456
+
+ `,
+ );
+ await assertSelection(page, {
+ anchorOffset: 3,
+ anchorPath: [1, 0, 0],
+ focusOffset: 3,
+ focusPath: [1, 0, 0],
+ });
+ });
+
+ test('Copy + paste nested span in a div', async ({page, isPlainText}) => {
+ test.skip(isPlainText);
+
+ await focusEditor(page);
+
+ const clipboard = {
+ 'text/html': html`
+
+ `,
+ };
+
+ await pasteFromClipboard(page, clipboard);
+
+ await assertHTML(
+ page,
+ html`
+
+ 123
+
+
+ 456
+
+ `,
+ );
+ await assertSelection(page, {
+ anchorOffset: 3,
+ anchorPath: [1, 0, 0],
+ focusOffset: 3,
+ focusPath: [1, 0, 0],
+ });
+ });
+
+ test('Copy + paste multiple nested spans and divs', async ({
+ page,
+ isPlainText,
+ }) => {
+ test.skip(isPlainText);
+
+ await focusEditor(page);
+
+ const clipboard = {
+ 'text/html': html`
+
+ a b
+
+ c d
+ e
+
+
+ f
+ g h
+
+
+ `,
+ };
+
+ await pasteFromClipboard(page, clipboard);
+
+ await assertHTML(
+ page,
+ html`
+
+ a b c d e
+
+
+ f g h
+
+ `,
+ );
+ await assertSelection(page, {
+ anchorOffset: 5,
+ anchorPath: [1, 0, 0],
+ focusOffset: 5,
+ focusPath: [1, 0, 0],
+ });
+ });
});
diff --git a/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/ListsHTMLCopyAndPaste.spec.mjs b/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/ListsHTMLCopyAndPaste.spec.mjs
index 33bf100adad..dbb58e82d3c 100644
--- a/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/ListsHTMLCopyAndPaste.spec.mjs
+++ b/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/ListsHTMLCopyAndPaste.spec.mjs
@@ -416,4 +416,61 @@ test.describe('HTML Lists CopyAndPaste', () => {
`,
);
});
+
+ test('Copy + paste a nested divs in a list', async ({page, isPlainText}) => {
+ test.skip(isPlainText);
+
+ await focusEditor(page);
+
+ const clipboard = {
+ 'text/html': html`
+
+ -
+ 1
+
2
+ 3
+
+ -
+ A
+
B
+ C
+
+
+ `,
+ };
+
+ await pasteFromClipboard(page, clipboard);
+
+ await assertHTML(
+ page,
+ html`
+
+ -
+ 1
+
+ 2
+
+ 3
+
+ -
+ A
+
+ B
+
+ C
+
+
+ `,
+ );
+
+ await assertSelection(page, {
+ anchorOffset: 1,
+ anchorPath: [0, 1, 4, 0],
+ focusOffset: 1,
+ focusPath: [0, 1, 4, 0],
+ });
+ });
});
diff --git a/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/TablesHTMLCopyAndPaste.spec.mjs b/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/TablesHTMLCopyAndPaste.spec.mjs
index d697742b6bc..3b1c2693400 100644
--- a/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/TablesHTMLCopyAndPaste.spec.mjs
+++ b/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/TablesHTMLCopyAndPaste.spec.mjs
@@ -471,4 +471,104 @@ test.describe('HTML Tables CopyAndPaste', () => {
`,
);
});
+
+ test('Copy + paste nested block and inline html in a table', async ({
+ page,
+ isPlainText,
+ isCollab,
+ }) => {
+ test.skip(isPlainText);
+
+ test.fixme(
+ isCollab,
+ 'Table selection styles are not properly synced to the right hand frame',
+ );
+
+ await focusEditor(page);
+
+ const clipboard = {
+ 'text/html': html`
+ 123
+
+
+
+
+ 456
+ |
+
+ 789
+
+ 000
+
+ |
+
+
+
+ ABC
+
+ |
+
+ DEF
+ |
+
+
+
+ `,
+ };
+
+ await pasteFromClipboard(page, clipboard);
+
+ await assertHTML(
+ page,
+ html`
+
+ 123
+
+
+
+
+
+ 456
+
+ |
+
+
+ 789
+
+
+ 000
+
+ |
+
+
+
+
+ ABC
+
+
+ 000
+
+
+ 000
+
+ |
+
+
+ DEF
+
+ |
+
+
+ `,
+ );
+ });
});
diff --git a/packages/lexical-playground/src/plugins/TableCellResizer/index.tsx b/packages/lexical-playground/src/plugins/TableCellResizer/index.tsx
index 484338f8919..70d588a833e 100644
--- a/packages/lexical-playground/src/plugins/TableCellResizer/index.tsx
+++ b/packages/lexical-playground/src/plugins/TableCellResizer/index.tsx
@@ -154,30 +154,34 @@ function TableCellResizer({editor}: {editor: LexicalEditor}): JSX.Element {
throw new Error('TableCellResizer: Expected active cell.');
}
- editor.update(() => {
- const tableCellNode = $getNearestNodeFromDOMNode(activeCell.elem);
- if (!$isTableCellNode(tableCellNode)) {
- throw new Error('TableCellResizer: Table cell node not found.');
- }
+ editor.update(
+ () => {
+ const tableCellNode = $getNearestNodeFromDOMNode(activeCell.elem);
+ if (!$isTableCellNode(tableCellNode)) {
+ throw new Error('TableCellResizer: Table cell node not found.');
+ }
- const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode);
+ const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode);
- const tableRowIndex = $getTableRowIndexFromTableCellNode(tableCellNode);
+ const tableRowIndex =
+ $getTableRowIndexFromTableCellNode(tableCellNode);
- const tableRows = tableNode.getChildren();
+ const tableRows = tableNode.getChildren();
- if (tableRowIndex >= tableRows.length || tableRowIndex < 0) {
- throw new Error('Expected table cell to be inside of table row.');
- }
+ if (tableRowIndex >= tableRows.length || tableRowIndex < 0) {
+ throw new Error('Expected table cell to be inside of table row.');
+ }
- const tableRow = tableRows[tableRowIndex];
+ const tableRow = tableRows[tableRowIndex];
- if (!$isTableRowNode(tableRow)) {
- throw new Error('Expected table row');
- }
+ if (!$isTableRowNode(tableRow)) {
+ throw new Error('Expected table row');
+ }
- tableRow.setHeight(newHeight);
- });
+ tableRow.setHeight(newHeight);
+ },
+ {tag: 'skip-scroll-into-view'},
+ );
},
[activeCell, editor],
);
@@ -187,57 +191,60 @@ function TableCellResizer({editor}: {editor: LexicalEditor}): JSX.Element {
if (!activeCell) {
throw new Error('TableCellResizer: Expected active cell.');
}
- editor.update(() => {
- const tableCellNode = $getNearestNodeFromDOMNode(activeCell.elem);
- if (!$isTableCellNode(tableCellNode)) {
- throw new Error('TableCellResizer: Table cell node not found.');
- }
+ editor.update(
+ () => {
+ const tableCellNode = $getNearestNodeFromDOMNode(activeCell.elem);
+ if (!$isTableCellNode(tableCellNode)) {
+ throw new Error('TableCellResizer: Table cell node not found.');
+ }
- const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode);
+ const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode);
- const tableColumnIndex =
- $getTableColumnIndexFromTableCellNode(tableCellNode);
+ const tableColumnIndex =
+ $getTableColumnIndexFromTableCellNode(tableCellNode);
- const tableRows = tableNode.getChildren();
+ const tableRows = tableNode.getChildren();
- for (let r = 0; r < tableRows.length; r++) {
- const tableRow = tableRows[r];
+ for (let r = 0; r < tableRows.length; r++) {
+ const tableRow = tableRows[r];
- if (!$isTableRowNode(tableRow)) {
- throw new Error('Expected table row');
- }
+ if (!$isTableRowNode(tableRow)) {
+ throw new Error('Expected table row');
+ }
- const rowCells = tableRow.getChildren();
- const rowCellsSpan = rowCells.map((cell) => cell.getColSpan());
-
- const aggregatedRowSpans = rowCellsSpan.reduce(
- (rowSpans: number[], cellSpan) => {
- const previousCell = rowSpans[rowSpans.length - 1] ?? 0;
- rowSpans.push(previousCell + cellSpan);
- return rowSpans;
- },
- [],
- );
- const rowColumnIndexWithSpan = aggregatedRowSpans.findIndex(
- (cellSpan: number) => cellSpan > tableColumnIndex,
- );
-
- if (
- rowColumnIndexWithSpan >= rowCells.length ||
- rowColumnIndexWithSpan < 0
- ) {
- throw new Error('Expected table cell to be inside of table row.');
- }
+ const rowCells = tableRow.getChildren();
+ const rowCellsSpan = rowCells.map((cell) => cell.getColSpan());
+
+ const aggregatedRowSpans = rowCellsSpan.reduce(
+ (rowSpans: number[], cellSpan) => {
+ const previousCell = rowSpans[rowSpans.length - 1] ?? 0;
+ rowSpans.push(previousCell + cellSpan);
+ return rowSpans;
+ },
+ [],
+ );
+ const rowColumnIndexWithSpan = aggregatedRowSpans.findIndex(
+ (cellSpan: number) => cellSpan > tableColumnIndex,
+ );
- const tableCell = rowCells[rowColumnIndexWithSpan];
+ if (
+ rowColumnIndexWithSpan >= rowCells.length ||
+ rowColumnIndexWithSpan < 0
+ ) {
+ throw new Error('Expected table cell to be inside of table row.');
+ }
- if (!$isTableCellNode(tableCell)) {
- throw new Error('Expected table cell');
- }
+ const tableCell = rowCells[rowColumnIndexWithSpan];
- tableCell.setWidth(newWidth);
- }
- });
+ if (!$isTableCellNode(tableCell)) {
+ throw new Error('Expected table cell');
+ }
+
+ tableCell.setWidth(newWidth);
+ }
+ },
+ {tag: 'skip-scroll-into-view'},
+ );
},
[activeCell, editor],
);
diff --git a/packages/lexical-react/flow/LexicalContextMenuPlugin.js.flow b/packages/lexical-react/flow/LexicalContextMenuPlugin.js.flow
index 27b66459534..4fe8d867aae 100644
--- a/packages/lexical-react/flow/LexicalContextMenuPlugin.js.flow
+++ b/packages/lexical-react/flow/LexicalContextMenuPlugin.js.flow
@@ -4,6 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
+ * @flow strict
*/
/**
diff --git a/packages/lexical-react/flow/LexicalNodeEventPlugin.js.flow b/packages/lexical-react/flow/LexicalNodeEventPlugin.js.flow
index a7fed0f44a0..904a6d285fe 100644
--- a/packages/lexical-react/flow/LexicalNodeEventPlugin.js.flow
+++ b/packages/lexical-react/flow/LexicalNodeEventPlugin.js.flow
@@ -4,6 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
+ * @flow strict
*/
/**
diff --git a/packages/lexical-react/src/LexicalTableOfContents.tsx b/packages/lexical-react/src/LexicalTableOfContents.tsx
index 602b196af2c..97e8920094e 100644
--- a/packages/lexical-react/src/LexicalTableOfContents.tsx
+++ b/packages/lexical-react/src/LexicalTableOfContents.tsx
@@ -6,11 +6,19 @@
*
*/
-import type {LexicalEditor, NodeKey, NodeMutation} from 'lexical';
-
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {$isHeadingNode, HeadingNode, HeadingTagType} from '@lexical/rich-text';
-import {$getNodeByKey, $getRoot, TextNode} from 'lexical';
+import {$getNextRightPreorderNode} from '@lexical/utils';
+import {
+ $getNodeByKey,
+ $getRoot,
+ $isElementNode,
+ ElementNode,
+ LexicalEditor,
+ NodeKey,
+ NodeMutation,
+ TextNode,
+} from 'lexical';
import {useEffect, useState} from 'react';
export type TableOfContentsEntry = [
@@ -34,12 +42,26 @@ function $insertHeadingIntoTableOfContents(
const newEntry: TableOfContentsEntry = toEntry(newHeading);
let newTableOfContents: Array = [];
if (prevHeading === null) {
+ // check if key already exists
+ if (
+ currentTableOfContents.length > 0 &&
+ currentTableOfContents[0][0] === newHeading.__key
+ ) {
+ return currentTableOfContents;
+ }
newTableOfContents = [newEntry, ...currentTableOfContents];
} else {
for (let i = 0; i < currentTableOfContents.length; i++) {
const key = currentTableOfContents[i][0];
newTableOfContents.push(currentTableOfContents[i]);
if (key === prevHeading.getKey() && key !== newHeading.getKey()) {
+ // check if key already exists
+ if (
+ i + 1 < currentTableOfContents.length &&
+ currentTableOfContents[i + 1][0] === newHeading.__key
+ ) {
+ return currentTableOfContents;
+ }
newTableOfContents.push(newEntry);
}
}
@@ -103,6 +125,14 @@ function $updateHeadingPosition(
return newTableOfContents;
}
+function getPreviousHeading(node: HeadingNode): HeadingNode | null {
+ let prevHeading = $getNextRightPreorderNode(node);
+ while (prevHeading !== null && !$isHeadingNode(prevHeading)) {
+ prevHeading = $getNextRightPreorderNode(prevHeading);
+ }
+ return prevHeading;
+}
+
type Props = {
children: (
values: Array,
@@ -135,6 +165,37 @@ export default function LexicalTableOfContentsPlugin({
setTableOfContents(currentTableOfContents);
});
+ const removeRootUpdateListener = editor.registerUpdateListener(
+ ({editorState, dirtyElements}) => {
+ editorState.read(() => {
+ const updateChildHeadings = (node: ElementNode) => {
+ for (const child of node.getChildren()) {
+ if ($isHeadingNode(child)) {
+ const prevHeading = getPreviousHeading(child);
+ currentTableOfContents = $updateHeadingPosition(
+ prevHeading,
+ child,
+ currentTableOfContents,
+ );
+ setTableOfContents(currentTableOfContents);
+ } else if ($isElementNode(child)) {
+ updateChildHeadings(child);
+ }
+ }
+ };
+
+ // If a node is changes, all child heading positions need to be updated
+ $getRoot()
+ .getChildren()
+ .forEach((node) => {
+ if ($isElementNode(node) && dirtyElements.get(node.__key)) {
+ updateChildHeadings(node);
+ }
+ });
+ });
+ },
+ );
+
// Listen to updates to heading mutations and update state
const removeHeaderMutationListener = editor.registerMutationListener(
HeadingNode,
@@ -144,10 +205,7 @@ export default function LexicalTableOfContentsPlugin({
if (mutation === 'created') {
const newHeading = $getNodeByKey(nodeKey);
if (newHeading !== null) {
- let prevHeading = newHeading.getPreviousSibling();
- while (prevHeading !== null && !$isHeadingNode(prevHeading)) {
- prevHeading = prevHeading.getPreviousSibling();
- }
+ const prevHeading = getPreviousHeading(newHeading);
currentTableOfContents = $insertHeadingIntoTableOfContents(
prevHeading,
newHeading,
@@ -162,10 +220,7 @@ export default function LexicalTableOfContentsPlugin({
} else if (mutation === 'updated') {
const newHeading = $getNodeByKey(nodeKey);
if (newHeading !== null) {
- let prevHeading = newHeading.getPreviousSibling();
- while (prevHeading !== null && !$isHeadingNode(prevHeading)) {
- prevHeading = prevHeading.getPreviousSibling();
- }
+ const prevHeading = getPreviousHeading(newHeading);
currentTableOfContents = $updateHeadingPosition(
prevHeading,
newHeading,
@@ -206,6 +261,7 @@ export default function LexicalTableOfContentsPlugin({
return () => {
removeHeaderMutationListener();
removeTextNodeMutationListener();
+ removeRootUpdateListener();
};
}, [editor]);
diff --git a/packages/lexical-react/src/__tests__/unit/Collaboration.test.ts b/packages/lexical-react/src/__tests__/unit/Collaboration.test.ts
index 72a706f5948..98402f8a93e 100644
--- a/packages/lexical-react/src/__tests__/unit/Collaboration.test.ts
+++ b/packages/lexical-react/src/__tests__/unit/Collaboration.test.ts
@@ -8,10 +8,10 @@
import {$createTextNode, $getRoot, ParagraphNode, TextNode} from 'lexical';
-import {createTestConnection, waitForReact} from './utils';
+import {Client, createTestConnection, waitForReact} from './utils';
describe('Collaboration', () => {
- let container = null;
+ let container: null | HTMLDivElement = null;
beforeEach(() => {
container = document.createElement('div');
@@ -19,11 +19,11 @@ describe('Collaboration', () => {
});
afterEach(() => {
- document.body.removeChild(container);
+ document.body.removeChild(container!);
container = null;
});
- async function expectCorrectInitialContent(client1, client2) {
+ async function expectCorrectInitialContent(client1: Client, client2: Client) {
// Should be empty, as client has not yet updated
expect(client1.getHTML()).toEqual('');
expect(client1.getHTML()).toEqual(client2.getHTML());
@@ -42,8 +42,8 @@ describe('Collaboration', () => {
const client1 = connector.createClient('1');
const client2 = connector.createClient('2');
- client1.start(container);
- client2.start(container);
+ client1.start(container!);
+ client2.start(container!);
await expectCorrectInitialContent(client1, client2);
@@ -56,7 +56,7 @@ describe('Collaboration', () => {
const text = $createTextNode('Hello world');
- paragraph.append(text);
+ paragraph!.append(text);
});
});
@@ -71,8 +71,8 @@ describe('Collaboration', () => {
client2.update(() => {
const root = $getRoot();
- const paragraph = root.getFirstChild();
- const text = paragraph.getFirstChild();
+ const paragraph = root.getFirstChild()!;
+ const text = paragraph.getFirstChild()!;
text.spliceText(6, 5, 'metaverse');
});
@@ -97,8 +97,8 @@ describe('Collaboration', () => {
const client1 = connector.createClient('1');
const client2 = connector.createClient('2');
- client1.start(container);
- client2.start(container);
+ client1.start(container!);
+ client2.start(container!);
await expectCorrectInitialContent(client1, client2);
@@ -109,7 +109,7 @@ describe('Collaboration', () => {
client1.update(() => {
const root = $getRoot();
- const paragraph = root.getFirstChild();
+ const paragraph = root.getFirstChild()!;
const text = $createTextNode('Hello world');
paragraph.append(text);
@@ -125,7 +125,7 @@ describe('Collaboration', () => {
client2.update(() => {
const root = $getRoot();
- const paragraph = root.getFirstChild();
+ const paragraph = root.getFirstChild()!;
const text = $createTextNode('Hello world');
paragraph.append(text);
@@ -157,8 +157,8 @@ describe('Collaboration', () => {
client1.update(() => {
const root = $getRoot();
- const paragraph = root.getFirstChild();
- const text = paragraph.getFirstChild();
+ const paragraph = root.getFirstChild()!;
+ const text = paragraph.getFirstChild()!;
text.spliceText(11, 11, '');
});
@@ -175,8 +175,8 @@ describe('Collaboration', () => {
client2.update(() => {
const root = $getRoot();
- const paragraph = root.getFirstChild();
- const text = paragraph.getFirstChild();
+ const paragraph = root.getFirstChild()!;
+ const text = paragraph.getFirstChild()!;
text.spliceText(11, 11, '!');
});
@@ -203,8 +203,8 @@ describe('Collaboration', () => {
const connector = createTestConnection();
const client1 = connector.createClient('1');
const client2 = connector.createClient('2');
- client1.start(container);
- client2.start(container);
+ client1.start(container!);
+ client2.start(container!);
await expectCorrectInitialContent(client1, client2);
@@ -213,7 +213,7 @@ describe('Collaboration', () => {
client1.update(() => {
const root = $getRoot();
- const paragraph = root.getFirstChild();
+ const paragraph = root.getFirstChild()!;
const text = $createTextNode('Hello world');
paragraph.append(text);
});
@@ -235,8 +235,8 @@ describe('Collaboration', () => {
client1.update(() => {
const root = $getRoot();
- const paragraph = root.getFirstChild();
- paragraph.getFirstChild().remove();
+ const paragraph = root.getFirstChild()!;
+ paragraph.getFirstChild()!.remove();
});
});
@@ -250,9 +250,9 @@ describe('Collaboration', () => {
client2.update(() => {
const root = $getRoot();
- const paragraph = root.getFirstChild();
+ const paragraph = root.getFirstChild()!;
- paragraph.getFirstChild().spliceText(11, 0, 'Hello world');
+ paragraph.getFirstChild()!.spliceText(11, 0, 'Hello world');
});
});
@@ -297,15 +297,15 @@ describe('Collaboration', () => {
uuid: Math.floor(Math.random() * 10000),
};
- client1.start(container, awarenessData1);
- client2.start(container, awarenessData2);
+ client1.start(container!, awarenessData1);
+ client2.start(container!, awarenessData2);
await expectCorrectInitialContent(client1, client2);
- expect(client1.awareness.getLocalState().awarenessData).toEqual(
+ expect(client1.awareness.getLocalState()!.awarenessData).toEqual(
awarenessData1,
);
- expect(client2.awareness.getLocalState().awarenessData).toEqual(
+ expect(client2.awareness.getLocalState()!.awarenessData).toEqual(
awarenessData2,
);
diff --git a/packages/lexical-react/src/__tests__/unit/CollaborationWithCollisions.test.ts b/packages/lexical-react/src/__tests__/unit/CollaborationWithCollisions.test.ts
index 6372656496b..6ffb1895089 100644
--- a/packages/lexical-react/src/__tests__/unit/CollaborationWithCollisions.test.ts
+++ b/packages/lexical-react/src/__tests__/unit/CollaborationWithCollisions.test.ts
@@ -52,11 +52,11 @@ const $createSelectionByPath = ({
const root = $getRoot();
const anchorNode = anchorPath.reduce(
- (node, index) => node.getChildAtIndex(index),
+ (node, index) => node.getChildAtIndex(index)!,
root,
);
const focusNode = focusPath.reduce(
- (node, index) => node.getChildAtIndex(index),
+ (node, index) => node.getChildAtIndex(index)!,
root,
);
@@ -95,11 +95,11 @@ const $replaceTextByPath = ({
focusOffset,
focusPath,
});
- selection.insertText(text);
+ selection.insertText(text!);
};
describe('CollaborationWithCollisions', () => {
- let container = null;
+ let container: HTMLDivElement | null = null;
beforeEach(() => {
container = document.createElement('div');
@@ -107,7 +107,7 @@ describe('CollaborationWithCollisions', () => {
});
afterEach(() => {
- document.body.removeChild(container);
+ document.body.removeChild(container!);
container = null;
});
@@ -131,7 +131,7 @@ describe('CollaborationWithCollisions', () => {
},
() => {
// Second client deletes first paragraph
- $getRoot().getFirstChild().remove();
+ $getRoot().getFirstChild()!.remove();
},
],
expectedHTML: null,
@@ -152,7 +152,7 @@ describe('CollaborationWithCollisions', () => {
},
() => {
// Second client deletes first paragraph
- $getRoot().getFirstChild().remove();
+ $getRoot().getFirstChild()!.remove();
},
],
expectedHTML: null,
@@ -199,7 +199,7 @@ describe('CollaborationWithCollisions', () => {
const connection = createTestConnection();
const clients = createAndStartClients(
connection,
- container,
+ container!,
testCase.clients.length,
);
diff --git a/packages/lexical-react/src/__tests__/unit/LexicalComposer.test.tsx b/packages/lexical-react/src/__tests__/unit/LexicalComposer.test.tsx
index afb35f9fea5..2a79c9bc636 100644
--- a/packages/lexical-react/src/__tests__/unit/LexicalComposer.test.tsx
+++ b/packages/lexical-react/src/__tests__/unit/LexicalComposer.test.tsx
@@ -8,14 +8,14 @@
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import * as React from 'react';
-import {createRoot} from 'react-dom/client';
+import {createRoot, Root} from 'react-dom/client';
import * as ReactTestUtils from 'react-dom/test-utils';
import {LexicalComposer} from '../../LexicalComposer';
describe('LexicalNodeHelpers tests', () => {
- let container = null;
- let reactRoot;
+ let container: HTMLDivElement | null = null;
+ let reactRoot: Root;
beforeEach(() => {
container = document.createElement('div');
@@ -24,7 +24,7 @@ describe('LexicalNodeHelpers tests', () => {
});
afterEach(() => {
- document.body.removeChild(container);
+ document.body.removeChild(container!);
container = null;
jest.restoreAllMocks();
diff --git a/packages/lexical-react/src/__tests__/unit/PlainRichTextPlugin.test.tsx b/packages/lexical-react/src/__tests__/unit/PlainRichTextPlugin.test.tsx
index b1878b14921..42bfea40230 100644
--- a/packages/lexical-react/src/__tests__/unit/PlainRichTextPlugin.test.tsx
+++ b/packages/lexical-react/src/__tests__/unit/PlainRichTextPlugin.test.tsx
@@ -22,9 +22,10 @@ import {
$getRoot,
$getSelection,
$isRangeSelection,
+ LexicalEditor,
} from 'lexical';
import * as React from 'react';
-import {createRoot} from 'react-dom/client';
+import {createRoot, Root} from 'react-dom/client';
import * as ReactTestUtils from 'react-dom/test-utils';
import {LexicalComposer} from '../../LexicalComposer';
@@ -49,8 +50,8 @@ const RICH_TEXT_NODES = [
];
describe('LexicalNodeHelpers tests', () => {
- let container = null;
- let reactRoot;
+ let container: HTMLDivElement | null = null;
+ let reactRoot: Root;
beforeEach(() => {
container = document.createElement('div');
@@ -59,7 +60,7 @@ describe('LexicalNodeHelpers tests', () => {
});
afterEach(() => {
- document.body.removeChild(container);
+ document.body.removeChild(container!);
container = null;
jest.restoreAllMocks();
@@ -114,7 +115,7 @@ describe('LexicalNodeHelpers tests', () => {
reactRoot.render();
});
- const text = editor.getEditorState().read($rootTextContent);
+ const text = editor!.getEditorState().read($rootTextContent);
expect(text).toBe('foo');
});
}
@@ -165,9 +166,9 @@ describe('LexicalNodeHelpers tests', () => {
reactRoot.render();
});
- await editor.focus();
+ await editor!.focus();
- await editor.getEditorState().read(() => {
+ await editor!.getEditorState().read(() => {
expect($rootTextContent()).toBe('foo');
const selection = $getSelection();
@@ -184,7 +185,7 @@ describe('LexicalNodeHelpers tests', () => {
for (const plugin of ['PlainTextPlugin', 'RichTextPlugin']) {
it(`${plugin} can hide placeholder when non-editable`, async () => {
- let editor;
+ let editor: LexicalEditor;
function GrabEditor() {
[editor] = useLexicalComposerContext();
@@ -233,7 +234,7 @@ describe('LexicalNodeHelpers tests', () => {
});
function placeholderText() {
- const placeholderContainer = container.querySelector('.placeholder');
+ const placeholderContainer = container!.querySelector('.placeholder');
return placeholderContainer && placeholderContainer.textContent;
}
diff --git a/packages/lexical-react/src/__tests__/unit/useLexicalCharacterLimit.test.ts b/packages/lexical-react/src/__tests__/unit/useLexicalCharacterLimit.test.ts
index e47d55f6929..0351c93ca52 100644
--- a/packages/lexical-react/src/__tests__/unit/useLexicalCharacterLimit.test.ts
+++ b/packages/lexical-react/src/__tests__/unit/useLexicalCharacterLimit.test.ts
@@ -54,7 +54,7 @@ describe('LexicalNodeHelpers tests', () => {
paragraph.append(overflowRight);
});
- return [overflowLeftKey, overflowRightKey];
+ return [overflowLeftKey!, overflowRightKey!];
}
it('merges an empty overflow node (left overflow selected)', async () => {
@@ -63,8 +63,9 @@ describe('LexicalNodeHelpers tests', () => {
await initializeEditorWithLeftRightOverflowNodes();
await editor.update(() => {
- const overflowLeft = $getNodeByKey(overflowLeftKey);
- const overflowRight = $getNodeByKey(overflowRightKey);
+ const overflowLeft = $getNodeByKey(overflowLeftKey)!;
+ const overflowRight =
+ $getNodeByKey(overflowRightKey)!;
const text1 = $createTextNode('1');
const text2 = $createTextNode('2');
@@ -77,8 +78,9 @@ describe('LexicalNodeHelpers tests', () => {
});
await editor.update(() => {
- const paragraph = $getRoot().getFirstChild();
- const overflowRight = $getNodeByKey(overflowRightKey);
+ const paragraph = $getRoot().getFirstChild()!;
+ const overflowRight =
+ $getNodeByKey(overflowRightKey)!;
mergePrevious(overflowRight);
@@ -110,8 +112,9 @@ describe('LexicalNodeHelpers tests', () => {
let text1Key: NodeKey;
await editor.update(() => {
- const overflowLeft = $getNodeByKey(overflowLeftKey);
- const overflowRight = $getNodeByKey(overflowRightKey);
+ const overflowLeft = $getNodeByKey(overflowLeftKey)!;
+ const overflowRight =
+ $getNodeByKey(overflowRightKey)!;
const text1 = $createTextNode('1');
const text2 = $createTextNode('2');
@@ -133,9 +136,10 @@ describe('LexicalNodeHelpers tests', () => {
});
await editor.update(() => {
- const paragraph = $getRoot().getFirstChild();
+ const paragraph = $getRoot().getFirstChild()!;
- const overflowRight = $getNodeByKey(overflowRightKey);
+ const overflowRight =
+ $getNodeByKey(overflowRightKey)!;
mergePrevious(overflowRight);
@@ -168,8 +172,9 @@ describe('LexicalNodeHelpers tests', () => {
let text3Key: NodeKey;
await editor.update(() => {
- const overflowLeft = $getNodeByKey(overflowLeftKey);
- const overflowRight = $getNodeByKey(overflowRightKey);
+ const overflowLeft = $getNodeByKey(overflowLeftKey)!;
+ const overflowRight =
+ $getNodeByKey(overflowRightKey)!;
const text1 = $createTextNode('1');
const text2 = $createTextNode('2');
@@ -202,8 +207,9 @@ describe('LexicalNodeHelpers tests', () => {
});
await editor.update(() => {
- const paragraph = $getRoot().getFirstChild();
- const overflowRight = $getNodeByKey(overflowRightKey);
+ const paragraph = $getRoot().getFirstChild()!;
+ const overflowRight =
+ $getNodeByKey(overflowRightKey)!;
mergePrevious(overflowRight);
diff --git a/packages/lexical-react/src/__tests__/unit/useLexicalIsTextContentEmpty.test.tsx b/packages/lexical-react/src/__tests__/unit/useLexicalIsTextContentEmpty.test.tsx
index 40d240a91cd..652946f168b 100644
--- a/packages/lexical-react/src/__tests__/unit/useLexicalIsTextContentEmpty.test.tsx
+++ b/packages/lexical-react/src/__tests__/unit/useLexicalIsTextContentEmpty.test.tsx
@@ -11,17 +11,19 @@ import {
$createTextNode,
$getRoot,
createEditor,
+ LexicalEditor,
ParagraphNode,
} from 'lexical';
import * as React from 'react';
-import {createRoot} from 'react-dom/client';
+import {createRef} from 'react';
+import {createRoot, Root} from 'react-dom/client';
import * as ReactTestUtils from 'react-dom/test-utils';
import {useLexicalIsTextContentEmpty} from '../../useLexicalIsTextContentEmpty';
describe('useLexicalIsTextContentEmpty', () => {
- let container = null;
- let reactRoot;
+ let container: HTMLDivElement | null = null;
+ let reactRoot: Root;
beforeEach(() => {
container = document.createElement('div');
@@ -30,13 +32,13 @@ describe('useLexicalIsTextContentEmpty', () => {
});
afterEach(() => {
- document.body.removeChild(container);
+ document.body.removeChild(container!);
container = null;
jest.restoreAllMocks();
});
- function useLexicalEditor(rootElementRef) {
+ function useLexicalEditor(rootElementRef: React.RefObject) {
const editor = React.useMemo(
() =>
createEditor({
@@ -58,8 +60,8 @@ describe('useLexicalIsTextContentEmpty', () => {
}
test('hook works', async () => {
- const ref = React.createRef();
- let editor;
+ const ref = createRef();
+ let editor: LexicalEditor;
let hasText = false;
function TestBase() {
diff --git a/packages/lexical-react/src/__tests__/unit/utils.tsx b/packages/lexical-react/src/__tests__/unit/utils.tsx
index da06ddd35de..c8c4beeba72 100644
--- a/packages/lexical-react/src/__tests__/unit/utils.tsx
+++ b/packages/lexical-react/src/__tests__/unit/utils.tsx
@@ -6,9 +6,10 @@
*
*/
-import {UserState} from '@lexical/yjs';
+import {Provider, UserState} from '@lexical/yjs';
import {LexicalEditor} from 'lexical';
import * as React from 'react';
+import {Container} from 'react-dom';
import {createRoot, Root} from 'react-dom/client';
import * as ReactTestUtils from 'react-dom/test-utils';
import * as Y from 'yjs';
@@ -21,7 +22,17 @@ import {ContentEditable} from '../../LexicalContentEditable';
import LexicalErrorBoundary from '../../LexicalErrorBoundary';
import {RichTextPlugin} from '../../LexicalRichTextPlugin';
-function Editor({doc, provider, setEditor, awarenessData}) {
+function Editor({
+ doc,
+ provider,
+ setEditor,
+ awarenessData,
+}: {
+ doc: Y.Doc;
+ provider: Provider;
+ setEditor: (editor: LexicalEditor) => void;
+ awarenessData?: object | undefined;
+}) {
const context = useCollaborationContext();
const [editor] = useLexicalComposerContext();
@@ -49,20 +60,20 @@ function Editor({doc, provider, setEditor, awarenessData}) {
);
}
-class Client {
+export class Client implements Provider {
_id: string;
- _reactRoot: Root;
- _container: HTMLDivElement;
- _editor: LexicalEditor;
+ _reactRoot: Root | null = null;
+ _container: HTMLDivElement | null = null;
+ _editor: LexicalEditor | null = null;
_connection: {
- _clients: Client[];
+ _clients: Map;
};
- _connected: boolean;
- _doc: Y.Doc;
- _awarenessState: unknown;
+ _connected: boolean = false;
+ _doc: Y.Doc = new Y.Doc();
- _listeners: Map void>>;
- _updates: Uint8Array[];
+ _listeners = new Map void>>();
+ _updates: Uint8Array[] = [];
+ _awarenessState: UserState | null = null;
awareness: {
getLocalState: () => UserState | null;
getStates: () => Map;
@@ -71,54 +82,37 @@ class Client {
setLocalState: (state: UserState) => void;
};
- constructor(id, connection) {
+ constructor(id: Client['_id'], connection: Client['_connection']) {
this._id = id;
- this._reactRoot = null;
- this._container = null;
this._connection = connection;
- this._connected = false;
- this._doc = new Y.Doc();
- this._awarenessState = {};
this._onUpdate = this._onUpdate.bind(this);
this._doc.on('update', this._onUpdate);
- this._listeners = new Map();
- this._updates = [];
- this._editor = null;
-
this.awareness = {
- getLocalState() {
- return this._awarenessState;
- },
-
- getStates() {
- const states: Map = new Map();
- states[0] = this._awarenessState as UserState;
- return states;
- },
-
- off() {
+ getLocalState: () => this._awarenessState,
+ getStates: () => new Map([[0, this._awarenessState!]]),
+ off: () => {
// TODO
},
- on() {
+ on: () => {
// TODO
},
- setLocalState(state) {
+ setLocalState: (state) => {
this._awarenessState = state;
},
};
}
- _onUpdate(update, origin, transaction) {
+ _onUpdate(update: Uint8Array, origin: unknown, transaction: unknown) {
if (origin !== this._connection && this._connected) {
this._broadcastUpdate(update);
}
}
- _broadcastUpdate(update) {
+ _broadcastUpdate(update: Uint8Array) {
this._connection._clients.forEach((client) => {
if (client !== this) {
if (client._connected) {
@@ -154,7 +148,7 @@ class Client {
this._connected = false;
}
- start(rootContainer, awarenessData?) {
+ start(rootContainer: Container, awarenessData?: object) {
const container = document.createElement('div');
const reactRoot = createRoot(container);
this._container = container;
@@ -185,15 +179,16 @@ class Client {
stop() {
ReactTestUtils.act(() => {
- this._reactRoot.render(null);
+ this._reactRoot!.render(null);
});
- this._container.parentNode.removeChild(this._container);
+ this.getContainer().parentNode!.removeChild(this.getContainer());
this._container = null;
}
- on(type, callback) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ on(type: string, callback: (arg: any) => void) {
let listenerSet = this._listeners.get(type);
if (listenerSet === undefined) {
@@ -205,7 +200,8 @@ class Client {
listenerSet.add(callback);
}
- off(type, callback) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ off(type: string, callback: (arg: any) => void) {
const listenerSet = this._listeners.get(type);
if (listenerSet !== undefined) {
@@ -213,7 +209,7 @@ class Client {
}
}
- _dispatch(type, data) {
+ _dispatch(type: string, data: unknown) {
const listenerSet = this._listeners.get(type);
if (listenerSet !== undefined) {
@@ -222,7 +218,7 @@ class Client {
}
getHTML() {
- return (this._container.firstChild as HTMLElement).innerHTML;
+ return (this.getContainer().firstChild as HTMLElement).innerHTML;
}
getDocJSON() {
@@ -230,36 +226,32 @@ class Client {
}
getEditorState() {
- return this._editor.getEditorState();
+ return this.getEditor().getEditorState();
}
getEditor() {
- return this._editor;
+ return this._editor!;
}
getContainer() {
- return this._container;
+ return this._container!;
}
async focus() {
- this._container.focus();
+ this.getContainer().focus();
await Promise.resolve().then();
}
- update(cb) {
- this._editor.update(cb);
+ update(cb: () => void) {
+ this.getEditor().update(cb);
}
}
class TestConnection {
- _clients: Map;
-
- constructor() {
- this._clients = new Map();
- }
+ _clients = new Map();
- createClient(id) {
+ createClient(id: string) {
const client = new Client(id, this);
this._clients.set(id, client);
@@ -272,7 +264,7 @@ export function createTestConnection() {
return new TestConnection();
}
-export async function waitForReact(cb) {
+export async function waitForReact(cb: () => void) {
await ReactTestUtils.act(async () => {
cb();
await Promise.resolve().then();
diff --git a/packages/lexical-rich-text/src/__tests__/unit/LexicalHeadingNode.test.ts b/packages/lexical-rich-text/src/__tests__/unit/LexicalHeadingNode.test.ts
index 7b54b8ab80e..a58f759e0ea 100644
--- a/packages/lexical-rich-text/src/__tests__/unit/LexicalHeadingNode.test.ts
+++ b/packages/lexical-rich-text/src/__tests__/unit/LexicalHeadingNode.test.ts
@@ -11,7 +11,13 @@ import {
$isHeadingNode,
HeadingNode,
} from '@lexical/rich-text';
-import {$createTextNode, $getRoot, $getSelection, ParagraphNode} from 'lexical';
+import {
+ $createTextNode,
+ $getRoot,
+ $getSelection,
+ ParagraphNode,
+ RangeSelection,
+} from 'lexical';
import {initializeUnitTest} from 'lexical/src/__tests__/utils';
const editorConfig = Object.freeze({
@@ -81,7 +87,7 @@ describe('LexicalHeadingNode tests', () => {
test('HeadingNode.insertNewAfter()', async () => {
const {editor} = testEnv;
- let headingNode;
+ let headingNode: HeadingNode;
await editor.update(() => {
const root = $getRoot();
headingNode = new HeadingNode('h1');
@@ -91,7 +97,7 @@ describe('LexicalHeadingNode tests', () => {
'
',
);
await editor.update(() => {
- const selection = $getSelection();
+ const selection = $getSelection() as RangeSelection;
const result = headingNode.insertNewAfter(selection);
expect(result).toBeInstanceOf(ParagraphNode);
expect(result.getDirection()).toEqual(headingNode.getDirection());
@@ -122,7 +128,7 @@ describe('LexicalHeadingNode tests', () => {
test('creates a h2 with text and can insert a new paragraph after', async () => {
const {editor} = testEnv;
- let headingNode;
+ let headingNode: HeadingNode;
const text = 'hello world';
await editor.update(() => {
const root = $getRoot();
diff --git a/packages/lexical-rich-text/src/__tests__/unit/LexicalQuoteNode.test.ts b/packages/lexical-rich-text/src/__tests__/unit/LexicalQuoteNode.test.ts
index 15469fc12a3..e64c418803e 100644
--- a/packages/lexical-rich-text/src/__tests__/unit/LexicalQuoteNode.test.ts
+++ b/packages/lexical-rich-text/src/__tests__/unit/LexicalQuoteNode.test.ts
@@ -6,8 +6,8 @@
*
*/
-import {$createQuoteNode} from '@lexical/rich-text';
-import {$getRoot, ParagraphNode} from 'lexical';
+import {$createQuoteNode, QuoteNode} from '@lexical/rich-text';
+import {$createRangeSelection, $getRoot, ParagraphNode} from 'lexical';
import {initializeUnitTest} from 'lexical/src/__tests__/utils';
const editorConfig = Object.freeze({
@@ -64,7 +64,7 @@ describe('LexicalQuoteNode tests', () => {
test('QuoteNode.insertNewAfter()', async () => {
const {editor} = testEnv;
- let quoteNode;
+ let quoteNode: QuoteNode;
await editor.update(() => {
const root = $getRoot();
quoteNode = $createQuoteNode();
@@ -74,7 +74,7 @@ describe('LexicalQuoteNode tests', () => {
'',
);
await editor.update(() => {
- const result = quoteNode.insertNewAfter();
+ const result = quoteNode.insertNewAfter($createRangeSelection());
expect(result).toBeInstanceOf(ParagraphNode);
expect(result.getDirection()).toEqual(quoteNode.getDirection());
});
diff --git a/packages/lexical-selection/src/__tests__/unit/LexicalSelection.test.tsx b/packages/lexical-selection/src/__tests__/unit/LexicalSelection.test.tsx
index d6a7b9a588a..f7bb508d6cd 100644
--- a/packages/lexical-selection/src/__tests__/unit/LexicalSelection.test.tsx
+++ b/packages/lexical-selection/src/__tests__/unit/LexicalSelection.test.tsx
@@ -30,7 +30,13 @@ import {
$isElementNode,
$isRangeSelection,
$setSelection,
+ DecoratorNode,
+ ElementNode,
+ LexicalEditor,
+ LexicalNode,
ParagraphNode,
+ PointType,
+ TextNode,
} from 'lexical';
import {
$assertRangeSelection,
@@ -41,7 +47,6 @@ import {
invariant,
TestComposer,
} from 'lexical/src/__tests__/utils';
-import * as React from 'react';
import {createRoot} from 'react-dom/client';
import * as ReactTestUtils from 'react-dom/test-utils';
@@ -73,6 +78,13 @@ import {
undo,
} from '../utils';
+interface ExpectedSelection {
+ anchorPath: number[];
+ anchorOffset: number;
+ focusPath: number[];
+ focusOffset: number;
+}
+
initializeClipboard();
jest.mock('shared/environment', () => {
@@ -117,7 +129,7 @@ describe('LexicalSelection tests', () => {
container = null;
});
- let editor = null;
+ let editor: LexicalEditor | null = null;
async function init() {
function TestBase() {
@@ -163,8 +175,8 @@ describe('LexicalSelection tests', () => {
}}>
+ // eslint-disable-next-line jsx-a11y/aria-role, @typescript-eslint/no-explicit-any
+
}
placeholder={null}
ErrorBoundary={LexicalErrorBoundary}
@@ -176,32 +188,41 @@ describe('LexicalSelection tests', () => {
}
ReactTestUtils.act(() => {
- createRoot(container).render();
+ createRoot(container!).render();
});
- editor.getRootElement().focus();
+ editor!.getRootElement()!.focus();
await Promise.resolve().then();
// Focus first element
- setNativeSelectionWithPaths(editor.getRootElement(), [0, 0], 0, [0, 0], 0);
+ setNativeSelectionWithPaths(
+ editor!.getRootElement()!,
+ [0, 0],
+ 0,
+ [0, 0],
+ 0,
+ );
}
- async function update(fn) {
+ async function update(fn: () => void) {
await ReactTestUtils.act(async () => {
- await editor.update(fn);
+ await editor!.update(fn);
});
return Promise.resolve().then();
}
test('Expect initial output to be a block with no text.', () => {
- expect(container.innerHTML).toBe(
+ expect(container!.innerHTML).toBe(
'',
);
});
- function assertSelection(rootElement, expectedSelection) {
- const actualSelection = window.getSelection();
+ function assertSelection(
+ rootElement: HTMLElement,
+ expectedSelection: ExpectedSelection,
+ ) {
+ const actualSelection = window.getSelection()!;
expect(actualSelection.anchorNode).toBe(
getNodeFromPath(expectedSelection.anchorPath, rootElement),
@@ -1108,13 +1129,13 @@ describe('LexicalSelection tests', () => {
const name = testUnit.name || 'Test case';
test(name + ` (#${i + 1})`, async () => {
- await applySelectionInputs(testUnit.inputs, update, editor);
+ await applySelectionInputs(testUnit.inputs, update, editor!);
// Validate HTML matches
- expect(container.innerHTML).toBe(testUnit.expectedHTML);
+ expect(container!.innerHTML).toBe(testUnit.expectedHTML);
// Validate selection matches
- const rootElement = editor.getRootElement();
+ const rootElement = editor!.getRootElement()!;
const expectedSelection = testUnit.expectedSelection;
assertSelection(rootElement, expectedSelection);
@@ -1123,10 +1144,10 @@ describe('LexicalSelection tests', () => {
test('insert text one selected node element selection', async () => {
await ReactTestUtils.act(async () => {
- await editor.update(() => {
+ await editor!.update(() => {
const root = $getRoot();
- const paragraph = root.getFirstChild();
+ const paragraph = root.getFirstChild()!;
const elementNode = $createTestElementNode();
const text = $createTextNode('foo');
@@ -1147,10 +1168,10 @@ describe('LexicalSelection tests', () => {
test('getNodes resolves nested block nodes', async () => {
await ReactTestUtils.act(async () => {
- await editor.update(() => {
+ await editor!.update(() => {
const root = $getRoot();
- const paragraph = root.getFirstChild();
+ const paragraph = root.getFirstChild()!;
const elementNode = $createTestElementNode();
const text = $createTextNode();
@@ -1158,7 +1179,7 @@ describe('LexicalSelection tests', () => {
paragraph.append(elementNode);
elementNode.append(text);
- const selectedNodes = $getSelection().getNodes();
+ const selectedNodes = $getSelection()!.getNodes();
expect(selectedNodes.length).toBe(1);
expect(selectedNodes[0].getKey()).toBe(text.getKey());
@@ -1167,7 +1188,23 @@ describe('LexicalSelection tests', () => {
});
describe('Block selection moves when new nodes are inserted', () => {
- [
+ const baseCases: {
+ name: string;
+ anchorOffset: number;
+ focusOffset: number;
+ fn: (
+ paragraph: ElementNode,
+ text: TextNode,
+ ) => {
+ expectedAnchor: LexicalNode;
+ expectedAnchorOffset: number;
+ expectedFocus: LexicalNode;
+ expectedFocusOffset: number;
+ };
+ fnBefore?: (paragraph: ElementNode, text: TextNode) => void;
+ invertSelection?: true;
+ only?: true;
+ }[] = [
// Collapsed selection on end; add/remove/replace beginning
{
anchorOffset: 2,
@@ -1314,8 +1351,8 @@ describe('LexicalSelection tests', () => {
{
anchorOffset: 0,
fn: (paragraph, originalText1) => {
- const originalText2 = originalText1.getPreviousSibling();
- const lastChild = paragraph.getLastChild();
+ const originalText2 = originalText1.getPreviousSibling()!;
+ const lastChild = paragraph.getLastChild()!;
const newText = $createTextNode('2');
lastChild.insertBefore(newText);
@@ -1336,7 +1373,7 @@ describe('LexicalSelection tests', () => {
{
anchorOffset: 0,
fn: (paragraph, text) => {
- const lastChild = paragraph.getLastChild();
+ const lastChild = paragraph.getLastChild()!;
const newText = $createTextNode('2');
lastChild.insertAfter(newText);
@@ -1353,7 +1390,7 @@ describe('LexicalSelection tests', () => {
{
anchorOffset: 0,
fn: (paragraph, originalText1) => {
- const originalText2 = originalText1.getPreviousSibling();
+ const originalText2 = originalText1.getPreviousSibling()!;
const [, text] = originalText1.splitText(1);
return {
@@ -1373,7 +1410,7 @@ describe('LexicalSelection tests', () => {
{
anchorOffset: 0,
fn: (paragraph, text) => {
- const lastChild = paragraph.getLastChild();
+ const lastChild = paragraph.getLastChild()!;
lastChild.remove();
return {
@@ -1390,7 +1427,7 @@ describe('LexicalSelection tests', () => {
anchorOffset: 0,
fn: (paragraph, text) => {
const newText = $createTextNode('replacement');
- const lastChild = paragraph.getLastChild();
+ const lastChild = paragraph.getLastChild()!;
lastChild.replace(newText);
return {
@@ -1407,7 +1444,7 @@ describe('LexicalSelection tests', () => {
{
anchorOffset: 0,
fn: (paragraph, text) => {
- const lastChild = paragraph.getLastChild();
+ const lastChild = paragraph.getLastChild()!;
const newText = $createTextNode('2');
lastChild.insertBefore(newText);
@@ -1440,7 +1477,7 @@ describe('LexicalSelection tests', () => {
{
anchorOffset: 0,
fn: (paragraph, originalText1) => {
- const originalText2 = originalText1.getPreviousSibling();
+ const originalText2 = originalText1.getPreviousSibling()!;
const [, text] = originalText1.splitText(1);
return {
@@ -1460,7 +1497,7 @@ describe('LexicalSelection tests', () => {
{
anchorOffset: 1,
fn: (paragraph, originalText1) => {
- const lastChild = paragraph.getLastChild();
+ const lastChild = paragraph.getLastChild()!;
lastChild.remove();
return {
@@ -1481,7 +1518,7 @@ describe('LexicalSelection tests', () => {
anchorOffset: 1,
fn: (paragraph, originalText1) => {
const newText = $createTextNode('replacement');
- const lastChild = paragraph.getLastChild();
+ const lastChild = paragraph.getLastChild()!;
lastChild.replace(newText);
return {
@@ -1502,8 +1539,8 @@ describe('LexicalSelection tests', () => {
{
anchorOffset: 0,
fn: (paragraph, originalText1) => {
- const originalText2 = originalText1.getPreviousSibling();
- const lastChild = paragraph.getLastChild();
+ const originalText2 = originalText1.getPreviousSibling()!;
+ const lastChild = paragraph.getLastChild()!;
const newText = $createTextNode('2');
lastChild.insertBefore(newText);
@@ -1524,7 +1561,7 @@ describe('LexicalSelection tests', () => {
{
anchorOffset: 0,
fn: (paragraph, originalText1) => {
- const originalText2 = originalText1.getPreviousSibling();
+ const originalText2 = originalText1.getPreviousSibling()!;
const newText = $createTextNode('2');
originalText1.insertAfter(newText);
@@ -1545,7 +1582,7 @@ describe('LexicalSelection tests', () => {
{
anchorOffset: 0,
fn: (paragraph, originalText1) => {
- const originalText2 = originalText1.getPreviousSibling();
+ const originalText2 = originalText1.getPreviousSibling()!;
originalText1.splitText(1);
return {
@@ -1565,7 +1602,7 @@ describe('LexicalSelection tests', () => {
{
anchorOffset: 0,
fn: (paragraph, originalText1) => {
- const originalText2 = originalText1.getPreviousSibling();
+ const originalText2 = originalText1.getPreviousSibling()!;
originalText1.remove();
return {
@@ -1606,7 +1643,7 @@ describe('LexicalSelection tests', () => {
{
anchorOffset: 3,
fn: (paragraph, originalText1) => {
- const originalText2 = paragraph.getLastChild();
+ const originalText2 = paragraph.getLastChild()!;
const newText = $createTextNode('new');
originalText1.insertBefore(newText);
@@ -1627,7 +1664,7 @@ describe('LexicalSelection tests', () => {
{
anchorOffset: 0,
fn: (paragraph, originalText1) => {
- const originalText2 = paragraph.getLastChild();
+ const originalText2 = paragraph.getLastChild()!;
const newText = $createTextNode('new');
originalText1.insertBefore(newText);
@@ -1648,7 +1685,7 @@ describe('LexicalSelection tests', () => {
{
anchorOffset: 1,
fn: (paragraph, originalText1) => {
- originalText1.getNextSibling().remove();
+ originalText1.getNextSibling()!.remove();
return {
expectedAnchor: originalText1,
@@ -1663,7 +1700,7 @@ describe('LexicalSelection tests', () => {
{
anchorOffset: 0,
fn: (paragraph, originalText1) => {
- originalText1.getNextSibling().remove();
+ originalText1.getNextSibling()!.remove();
return {
expectedAnchor: originalText1,
@@ -1675,8 +1712,9 @@ describe('LexicalSelection tests', () => {
focusOffset: 1,
name: 'remove - Remove with non-collapsed selection at offset',
},
- ]
- .reduce((testSuite, testCase) => {
+ ];
+ baseCases
+ .flatMap((testCase) => {
// Test inverse selection
const inverse = {
...testCase,
@@ -1685,9 +1723,8 @@ describe('LexicalSelection tests', () => {
invertSelection: true,
name: testCase.name + ' (inverse selection)',
};
-
- return testSuite.concat(testCase, inverse);
- }, [])
+ return [testCase, inverse];
+ })
.forEach(
({
name,
@@ -1704,10 +1741,10 @@ describe('LexicalSelection tests', () => {
const test_ = only === true ? test.only : test;
test_(name, async () => {
await ReactTestUtils.act(async () => {
- await editor.update(() => {
+ await editor!.update(() => {
const root = $getRoot();
- const paragraph = root.getFirstChild();
+ const paragraph = root.getFirstChild()!;
const textNode = $createTextNode('foo');
// Note: line break can't be selected by the DOM
const linebreak = $createLineBreakNode();
@@ -1756,7 +1793,7 @@ describe('LexicalSelection tests', () => {
describe('Selection correctly resolves to a sibling ElementNode when a node is removed', () => {
test('', async () => {
await ReactTestUtils.act(async () => {
- await editor.update(() => {
+ await editor!.update(() => {
const root = $getRoot();
const listNode = $createListNode('bullet');
@@ -1786,8 +1823,8 @@ describe('LexicalSelection tests', () => {
describe('Selection correctly resolves to a sibling ElementNode when a selected node child is removed', () => {
test('', async () => {
await ReactTestUtils.act(async () => {
- let paragraphNodeKey;
- await editor.update(() => {
+ let paragraphNodeKey: string;
+ await editor!.update(() => {
const root = $getRoot();
const paragraphNode = $createParagraphNode();
@@ -1809,7 +1846,7 @@ describe('LexicalSelection tests', () => {
listNode.remove();
});
- await editor.getEditorState().read(() => {
+ await editor!.getEditorState().read(() => {
const selection = $assertRangeSelection($getSelection());
expect(selection.anchor.key).toBe(paragraphNodeKey);
expect(selection.focus.key).toBe(paragraphNodeKey);
@@ -1821,7 +1858,7 @@ describe('LexicalSelection tests', () => {
describe('Selection correctly resolves to a sibling ElementNode that has multiple children with the correct offset when a node is removed', () => {
test('', async () => {
await ReactTestUtils.act(async () => {
- await editor.update(() => {
+ await editor!.update(() => {
// Arrange
// Root
// |- Paragraph
@@ -1872,10 +1909,10 @@ describe('LexicalSelection tests', () => {
test('isBackward', async () => {
await ReactTestUtils.act(async () => {
- await editor.update(() => {
+ await editor!.update(() => {
const root = $getRoot();
- const paragraph = root.getFirstChild();
+ const paragraph = root.getFirstChild()!;
const paragraphKey = paragraph.getKey();
const textNode = $createTextNode('foo');
const textNodeKey = textNode.getKey();
@@ -1915,7 +1952,18 @@ describe('LexicalSelection tests', () => {
});
describe('Decorator text content for selection', () => {
- [
+ const baseCases: {
+ name: string;
+ fn: (opts: {
+ textNode1: TextNode;
+ textNode2: TextNode;
+ decorator: DecoratorNode;
+ paragraph: ParagraphNode;
+ anchor: PointType;
+ focus: PointType;
+ }) => string;
+ invertSelection?: true;
+ }[] = [
{
fn: ({textNode1, anchor, focus}) => {
anchor.set(textNode1.getKey(), 1, 'text');
@@ -1972,23 +2020,24 @@ describe('LexicalSelection tests', () => {
},
name: 'Included if decorator is selected as the only node',
},
- ]
- .reduce((testSuite, testCase) => {
+ ];
+ baseCases
+ .flatMap((testCase) => {
const inverse = {
...testCase,
invertSelection: true,
name: testCase.name + ' (inverse selection)',
};
- return testSuite.concat(testCase, inverse);
- }, [])
+ return [testCase, inverse];
+ })
.forEach(({name, fn, invertSelection}) => {
it(name, async () => {
await ReactTestUtils.act(async () => {
- await editor.update(() => {
+ await editor!.update(() => {
const root = $getRoot();
- const paragraph = root.getFirstChild();
+ const paragraph = root.getFirstChild()!;
const textNode1 = $createTextNode('1');
const textNode2 = $createTextNode('2');
const decorator = $createTestDecoratorNode();
@@ -2114,7 +2163,7 @@ describe('LexicalSelection tests', () => {
it('adjust offset for inline elements text formatting', async () => {
init();
- await editor.update(() => {
+ await editor!.update(() => {
const root = $getRoot();
const text1 = $createTextNode('--');
@@ -2155,7 +2204,11 @@ describe('LexicalSelection tests', () => {
});
describe('Node.replace', () => {
- let text1, text2, text3, paragraph, testEditor;
+ let text1: TextNode,
+ text2: TextNode,
+ text3: TextNode,
+ paragraph: ParagraphNode,
+ testEditor: LexicalEditor;
beforeEach(async () => {
testEditor = createTestEditor();
@@ -2482,7 +2535,7 @@ describe('LexicalSelection tests', () => {
expect(rootChildren[0].__type).toBe('heading');
expect(rootChildren[1].__type).toBe('heading');
expect(rootChildren.length).toBe(2);
- const sel = $getSelection();
+ const sel = $getSelection()!;
expect(sel.getNodes().length).toBe(2);
});
});
@@ -2541,7 +2594,7 @@ describe('LexicalSelection tests', () => {
const paragraph = column.getFirstChild();
invariant($isElementNode(paragraph));
if (paragraph.getFirstChild()) {
- paragraph.getFirstChild().remove();
+ paragraph.getFirstChild()!.remove();
}
root.append(table);
diff --git a/packages/lexical-selection/src/__tests__/unit/LexicalSelectionHelpers.test.ts b/packages/lexical-selection/src/__tests__/unit/LexicalSelectionHelpers.test.ts
index 0cda5e66128..36453f7972b 100644
--- a/packages/lexical-selection/src/__tests__/unit/LexicalSelectionHelpers.test.ts
+++ b/packages/lexical-selection/src/__tests__/unit/LexicalSelectionHelpers.test.ts
@@ -25,6 +25,9 @@ import {
$isParagraphNode,
$isRangeSelection,
$setSelection,
+ ElementNode,
+ LexicalEditor,
+ ParagraphNode,
RangeSelection,
TextNode,
} from 'lexical';
@@ -59,9 +62,12 @@ Range.prototype.getBoundingClientRect = function (): DOMRect {
};
};
-function createParagraphWithNodes(editor, nodes) {
+function createParagraphWithNodes(
+ editor: LexicalEditor,
+ nodes: {text: string; key: string; mergeable?: boolean}[],
+) {
const paragraph = $createParagraphNode();
- const nodeMap = editor._pendingEditorState._nodeMap;
+ const nodeMap = editor._pendingEditorState!._nodeMap;
for (let i = 0; i < nodes.length; i++) {
const {text, key, mergeable} = nodes[i];
@@ -81,7 +87,9 @@ function createParagraphWithNodes(editor, nodes) {
describe('LexicalSelectionHelpers tests', () => {
describe('Collapsed', () => {
test('Can handle a text point', () => {
- const setupTestCase = (cb) => {
+ const setupTestCase = (
+ cb: (selection: RangeSelection, node: ElementNode) => void,
+ ) => {
const editor = createTestEditor();
editor.update(() => {
@@ -119,7 +127,7 @@ describe('LexicalSelectionHelpers tests', () => {
type: 'text',
});
const selection = $getSelection();
- cb(selection, element);
+ cb(selection as RangeSelection, element);
});
};
@@ -137,7 +145,7 @@ describe('LexicalSelectionHelpers tests', () => {
setupTestCase((selection, state) => {
selection.insertText('Test');
- expect($getNodeByKey('a').getTextContent()).toBe('Testa');
+ expect($getNodeByKey('a')!.getTextContent()).toBe('Testa');
expect(selection.anchor).toEqual(
expect.objectContaining({
@@ -162,7 +170,7 @@ describe('LexicalSelectionHelpers tests', () => {
expect(selection.anchor).toEqual(
expect.objectContaining({
- key: element.getFirstChild().getKey(),
+ key: element.getFirstChild()!.getKey(),
offset: 3,
type: 'text',
}),
@@ -170,7 +178,7 @@ describe('LexicalSelectionHelpers tests', () => {
expect(selection.focus).toEqual(
expect.objectContaining({
- key: element.getFirstChild().getKey(),
+ key: element.getFirstChild()!.getKey(),
offset: 3,
type: 'text',
}),
@@ -224,11 +232,11 @@ describe('LexicalSelectionHelpers tests', () => {
selection.formatText('bold');
selection.insertText('Test');
- expect(element.getFirstChild().getTextContent()).toBe('Test');
+ expect(element.getFirstChild()!.getTextContent()).toBe('Test');
expect(selection.anchor).toEqual(
expect.objectContaining({
- key: element.getFirstChild().getKey(),
+ key: element.getFirstChild()!.getKey(),
offset: 4,
type: 'text',
}),
@@ -236,15 +244,15 @@ describe('LexicalSelectionHelpers tests', () => {
expect(selection.focus).toEqual(
expect.objectContaining({
- key: element.getFirstChild().getKey(),
+ key: element.getFirstChild()!.getKey(),
offset: 4,
type: 'text',
}),
);
- expect(element.getFirstChild().getNextSibling().getTextContent()).toBe(
- 'a',
- );
+ expect(
+ element.getFirstChild()!.getNextSibling()!.getTextContent(),
+ ).toBe('a');
});
// Extract selection
@@ -414,7 +422,7 @@ describe('LexicalSelectionHelpers tests', () => {
const editor = createTestEditor();
const domElement = document.createElement('div');
- let element;
+ let element: ParagraphNode;
editor.setRootElement(domElement);
@@ -690,7 +698,9 @@ describe('LexicalSelectionHelpers tests', () => {
});
test('Can handle an element point on empty element', () => {
- const setupTestCase = (cb) => {
+ const setupTestCase = (
+ cb: (selection: RangeSelection, el: ElementNode) => void,
+ ) => {
const editor = createTestEditor();
editor.update(() => {
@@ -712,7 +722,7 @@ describe('LexicalSelectionHelpers tests', () => {
type: 'element',
});
const selection = $getSelection();
- cb(selection, element);
+ cb(selection as RangeSelection, element);
});
};
@@ -729,7 +739,7 @@ describe('LexicalSelectionHelpers tests', () => {
// insertText
setupTestCase((selection, element) => {
selection.insertText('Test');
- const firstChild = element.getFirstChild();
+ const firstChild = element.getFirstChild()!;
expect(firstChild.getTextContent()).toBe('Test');
@@ -753,7 +763,7 @@ describe('LexicalSelectionHelpers tests', () => {
// insertParagraph
setupTestCase((selection, element) => {
selection.insertParagraph();
- const nextElement = element.getNextSibling();
+ const nextElement = element.getNextSibling()!;
expect(selection.anchor).toEqual(
expect.objectContaining({
@@ -797,7 +807,7 @@ describe('LexicalSelectionHelpers tests', () => {
setupTestCase((selection, element) => {
selection.formatText('bold');
selection.insertText('Test');
- const firstChild = element.getFirstChild();
+ const firstChild = element.getFirstChild()!;
expect(firstChild.getTextContent()).toBe('Test');
@@ -825,7 +835,9 @@ describe('LexicalSelectionHelpers tests', () => {
});
test('Can handle a start element point', () => {
- const setupTestCase = (cb) => {
+ const setupTestCase = (
+ cb: (selection: RangeSelection, el: ElementNode) => void,
+ ) => {
const editor = createTestEditor();
editor.update(() => {
@@ -863,7 +875,7 @@ describe('LexicalSelectionHelpers tests', () => {
type: 'element',
});
const selection = $getSelection();
- cb(selection, element);
+ cb(selection as RangeSelection, element);
});
};
@@ -880,7 +892,7 @@ describe('LexicalSelectionHelpers tests', () => {
// insertText
setupTestCase((selection, element) => {
selection.insertText('Test');
- const firstChild = element.getFirstChild();
+ const firstChild = element.getFirstChild()!;
expect(firstChild.getTextContent()).toBe('Test');
@@ -948,7 +960,7 @@ describe('LexicalSelectionHelpers tests', () => {
selection.formatText('bold');
selection.insertText('Test');
- const firstChild = element.getFirstChild();
+ const firstChild = element.getFirstChild()!;
expect(firstChild.getTextContent()).toBe('Test');
@@ -976,7 +988,9 @@ describe('LexicalSelectionHelpers tests', () => {
});
test('Can handle an end element point', () => {
- const setupTestCase = (cb) => {
+ const setupTestCase = (
+ cb: (selection: RangeSelection, el: ElementNode) => void,
+ ) => {
const editor = createTestEditor();
editor.update(() => {
@@ -1014,7 +1028,7 @@ describe('LexicalSelectionHelpers tests', () => {
type: 'element',
});
const selection = $getSelection();
- cb(selection, element);
+ cb(selection as RangeSelection, element);
});
};
@@ -1031,7 +1045,7 @@ describe('LexicalSelectionHelpers tests', () => {
// insertText
setupTestCase((selection, element) => {
selection.insertText('Test');
- const lastChild = element.getLastChild();
+ const lastChild = element.getLastChild()!;
expect(lastChild.getTextContent()).toBe('Test');
@@ -1055,7 +1069,7 @@ describe('LexicalSelectionHelpers tests', () => {
// insertParagraph
setupTestCase((selection, element) => {
selection.insertParagraph();
- const nextSibling = element.getNextSibling();
+ const nextSibling = element.getNextSibling()!;
expect(selection.anchor).toEqual(
expect.objectContaining({
@@ -1099,7 +1113,7 @@ describe('LexicalSelectionHelpers tests', () => {
setupTestCase((selection, element) => {
selection.formatText('bold');
selection.insertText('Test');
- const lastChild = element.getLastChild();
+ const lastChild = element.getLastChild()!;
expect(lastChild.getTextContent()).toBe('Test');
@@ -1271,7 +1285,9 @@ describe('LexicalSelectionHelpers tests', () => {
describe('Simple range', () => {
test('Can handle multiple text points', () => {
- const setupTestCase = (cb) => {
+ const setupTestCase = (
+ cb: (selection: RangeSelection, el: ElementNode) => void,
+ ) => {
const editor = createTestEditor();
editor.update(() => {
@@ -1333,7 +1349,7 @@ describe('LexicalSelectionHelpers tests', () => {
setupTestCase((selection, state) => {
selection.insertText('Test');
- expect($getNodeByKey('a').getTextContent()).toBe('Test');
+ expect($getNodeByKey('a')!.getTextContent()).toBe('Test');
expect(selection.anchor).toEqual(
expect.objectContaining({
@@ -1358,7 +1374,7 @@ describe('LexicalSelectionHelpers tests', () => {
expect(selection.anchor).toEqual(
expect.objectContaining({
- key: element.getFirstChild().getKey(),
+ key: element.getFirstChild()!.getKey(),
offset: 3,
type: 'text',
}),
@@ -1366,7 +1382,7 @@ describe('LexicalSelectionHelpers tests', () => {
expect(selection.focus).toEqual(
expect.objectContaining({
- key: element.getFirstChild().getKey(),
+ key: element.getFirstChild()!.getKey(),
offset: 3,
type: 'text',
}),
@@ -1420,11 +1436,11 @@ describe('LexicalSelectionHelpers tests', () => {
selection.formatText('bold');
selection.insertText('Test');
- expect(element.getFirstChild().getTextContent()).toBe('Test');
+ expect(element.getFirstChild()!.getTextContent()).toBe('Test');
expect(selection.anchor).toEqual(
expect.objectContaining({
- key: element.getFirstChild().getKey(),
+ key: element.getFirstChild()!.getKey(),
offset: 4,
type: 'text',
}),
@@ -1432,7 +1448,7 @@ describe('LexicalSelectionHelpers tests', () => {
expect(selection.focus).toEqual(
expect.objectContaining({
- key: element.getFirstChild().getKey(),
+ key: element.getFirstChild()!.getKey(),
offset: 4,
type: 'text',
}),
@@ -1446,7 +1462,9 @@ describe('LexicalSelectionHelpers tests', () => {
});
test('Can handle multiple element points', () => {
- const setupTestCase = (cb) => {
+ const setupTestCase = (
+ cb: (selection: RangeSelection, el: ElementNode) => void,
+ ) => {
const editor = createTestEditor();
editor.update(() => {
@@ -1504,7 +1522,7 @@ describe('LexicalSelectionHelpers tests', () => {
// insertText
setupTestCase((selection, element) => {
selection.insertText('Test');
- const firstChild = element.getFirstChild();
+ const firstChild = element.getFirstChild()!;
expect(firstChild.getTextContent()).toBe('Test');
@@ -1571,7 +1589,7 @@ describe('LexicalSelectionHelpers tests', () => {
setupTestCase((selection, element) => {
selection.formatText('bold');
selection.insertText('Test');
- const firstChild = element.getFirstChild();
+ const firstChild = element.getFirstChild()!;
expect(firstChild.getTextContent()).toBe('Test');
@@ -1601,7 +1619,9 @@ describe('LexicalSelectionHelpers tests', () => {
});
test('Can handle a mix of text and element points', () => {
- const setupTestCase = (cb) => {
+ const setupTestCase = (
+ cb: (selection: RangeSelection, el: ElementNode) => void,
+ ) => {
const editor = createTestEditor();
editor.update(() => {
@@ -1668,7 +1688,7 @@ describe('LexicalSelectionHelpers tests', () => {
// insertText
setupTestCase((selection, element) => {
selection.insertText('Test');
- const firstChild = element.getFirstChild();
+ const firstChild = element.getFirstChild()!;
expect(firstChild.getTextContent()).toBe('Test');
@@ -1692,7 +1712,7 @@ describe('LexicalSelectionHelpers tests', () => {
// insertParagraph
setupTestCase((selection, element) => {
selection.insertParagraph();
- const nextElement = element.getNextSibling();
+ const nextElement = element.getNextSibling()!;
expect(selection.anchor).toEqual(
expect.objectContaining({
@@ -1736,7 +1756,7 @@ describe('LexicalSelectionHelpers tests', () => {
setupTestCase((selection, element) => {
selection.formatText('bold');
selection.insertText('Test');
- const firstChild = element.getFirstChild();
+ const firstChild = element.getFirstChild()!;
expect(firstChild.getTextContent()).toBe('Test');
@@ -2121,7 +2141,7 @@ describe('LexicalSelectionHelpers tests', () => {
expect(selection.anchor).toEqual(
expect.objectContaining({
key: paragraph
- .getChildAtIndex(paragraph.getChildrenSize() - 2)
+ .getChildAtIndex(paragraph.getChildrenSize() - 2)!
.getKey(),
offset: 1,
type: 'text',
@@ -2131,7 +2151,7 @@ describe('LexicalSelectionHelpers tests', () => {
expect(selection.focus).toEqual(
expect.objectContaining({
key: paragraph
- .getChildAtIndex(paragraph.getChildrenSize() - 2)
+ .getChildAtIndex(paragraph.getChildrenSize() - 2)!
.getKey(),
offset: 1,
type: 'text',
@@ -2547,7 +2567,7 @@ describe('extract', () => {
const selection = $getSelection();
expect($isRangeSelection(selection)).toBeTruthy();
- expect(selection.extract()).toEqual([text]);
+ expect(selection!.extract()).toEqual([text]);
});
});
});
@@ -2605,7 +2625,7 @@ describe('insertNodes', () => {
});
await editor.update(() => {
const selection = $createRangeSelection();
- const text = $getRoot().getLastDescendant();
+ const text = $getRoot().getLastDescendant()!;
selection.anchor.set(text.getKey(), 0, 'text');
selection.focus.set(text.getKey(), 0, 'text');
@@ -2629,7 +2649,7 @@ describe('insertNodes', () => {
$createParagraphNode().append(emptyTextNode, $createTextNode('text')),
);
emptyTextNode.select(0, 0);
- const selection = $getSelection();
+ const selection = $getSelection()!;
expect($isRangeSelection(selection)).toBeTruthy();
selection.insertNodes([$createTextNode('foo')]);
@@ -2692,7 +2712,7 @@ describe('$patchStyleText', () => {
const link = $createLinkNode('https://');
link.append($createTextNode('link'));
- const a = $getNodeByKey('a');
+ const a = $getNodeByKey('a')!;
a.insertAfter(link);
setAnchorPoint({
@@ -2793,7 +2813,7 @@ describe('$patchStyleText', () => {
const link = $createLinkNode('https://');
link.append($createTextNode('link'));
- const a = $getNodeByKey('a');
+ const a = $getNodeByKey('a')!;
a.insertAfter(link);
setAnchorPoint({
@@ -2846,7 +2866,7 @@ describe('$patchStyleText', () => {
const link = $createLinkNode('https://');
link.append($createTextNode('link'));
- const a = $getNodeByKey('a');
+ const a = $getNodeByKey('a')!;
a.insertAfter(link);
// Select from the end of the link _element_
diff --git a/packages/lexical-selection/src/__tests__/utils/index.ts b/packages/lexical-selection/src/__tests__/utils/index.ts
index 1d35b6e12b7..ca9d87e27e4 100644
--- a/packages/lexical-selection/src/__tests__/utils/index.ts
+++ b/packages/lexical-selection/src/__tests__/utils/index.ts
@@ -12,6 +12,8 @@ import {
$isNodeSelection,
$isRangeSelection,
$isTextNode,
+ LexicalEditor,
+ PointType,
} from 'lexical';
Object.defineProperty(HTMLElement.prototype, 'contentEditable', {
@@ -49,7 +51,7 @@ if (!Selection.prototype.modify) {
};
const getWordsFromString = function (string: string): Array {
- const segments = [];
+ const segments: Segment[] = [];
let wordString = '';
let nonWordString = '';
let i;
@@ -90,7 +92,8 @@ if (!Selection.prototype.modify) {
// This is not a thorough implementation, it was more to get tests working
// given the refactor to use this selection method.
const symbol = Object.getOwnPropertySymbols(this)[0];
- const impl = this[symbol];
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const impl = (this as any)[symbol];
const focus = impl._focus;
const anchor = impl._anchor;
@@ -164,11 +167,11 @@ if (!Selection.prototype.modify) {
}
}
} else if (granularity === 'word') {
- const anchorNode = this.anchorNode;
+ const anchorNode = this.anchorNode!;
const targetTextContent =
direction === 'backward'
- ? anchorNode.textContent.slice(0, this.anchorOffset)
- : anchorNode.textContent.slice(this.anchorOffset);
+ ? anchorNode.textContent!.slice(0, this.anchorOffset)
+ : anchorNode.textContent!.slice(this.anchorOffset);
const segments = getWordsFromString(targetTextContent);
const segmentsLength = segments.length;
let index = anchor.offset;
@@ -218,27 +221,27 @@ if (!Selection.prototype.modify) {
};
}
-export function printWhitespace(whitespaceCharacter) {
+export function printWhitespace(whitespaceCharacter: string) {
return whitespaceCharacter.charCodeAt(0) === 160
? ' '
: whitespaceCharacter;
}
-export function insertText(text) {
+export function insertText(text: string) {
return {
text,
type: 'insert_text',
};
}
-export function insertTokenNode(text) {
+export function insertTokenNode(text: string) {
return {
text,
type: 'insert_token_node',
};
}
-export function insertSegmentedNode(text) {
+export function insertSegmentedNode(text: string) {
return {
text,
type: 'insert_segmented_node',
@@ -385,10 +388,10 @@ export function pasteHTML(text: string) {
}
export function moveNativeSelection(
- anchorPath,
- anchorOffset,
- focusPath,
- focusOffset,
+ anchorPath: number[],
+ anchorOffset: number,
+ focusPath: number[],
+ focusOffset: number,
) {
return {
anchorOffset,
@@ -399,7 +402,7 @@ export function moveNativeSelection(
};
}
-export function getNodeFromPath(path, rootElement) {
+export function getNodeFromPath(path: number[], rootElement: Node) {
let node = rootElement;
for (let i = 0; i < path.length; i++) {
@@ -410,12 +413,12 @@ export function getNodeFromPath(path, rootElement) {
}
export function setNativeSelection(
- anchorNode,
- anchorOffset,
- focusNode,
- focusOffset,
+ anchorNode: Node,
+ anchorOffset: number,
+ focusNode: Node,
+ focusOffset: number,
) {
- const domSelection = window.getSelection();
+ const domSelection = window.getSelection()!;
const range = document.createRange();
range.setStart(anchorNode, anchorOffset);
range.setEnd(focusNode, focusOffset);
@@ -427,18 +430,18 @@ export function setNativeSelection(
}
export function setNativeSelectionWithPaths(
- rootElement,
- anchorPath,
- anchorOffset,
- focusPath,
- focusOffset,
+ rootElement: Node,
+ anchorPath: number[],
+ anchorOffset: number,
+ focusPath: number[],
+ focusOffset: number,
) {
const anchorNode = getNodeFromPath(anchorPath, rootElement);
const focusNode = getNodeFromPath(focusPath, rootElement);
setNativeSelection(anchorNode, anchorOffset, focusNode, focusOffset);
}
-function getLastTextNode(startingNode) {
+function getLastTextNode(startingNode: Node) {
let node = startingNode;
mainLoop: while (node !== null) {
@@ -477,7 +480,7 @@ function getLastTextNode(startingNode) {
return null;
}
-function getNextTextNode(startingNode) {
+function getNextTextNode(startingNode: Node) {
let node = startingNode;
mainLoop: while (node !== null) {
@@ -517,12 +520,14 @@ function getNextTextNode(startingNode) {
}
function moveNativeSelectionBackward() {
- const domSelection = window.getSelection();
- let {anchorNode, anchorOffset} = domSelection;
+ const domSelection = window.getSelection()!;
+ let anchorNode = domSelection.anchorNode!;
+ let anchorOffset = domSelection.anchorOffset!;
if (domSelection.isCollapsed) {
- const target =
- anchorNode.nodeType === 1 ? anchorNode : anchorNode.parentNode;
+ const target = (
+ anchorNode.nodeType === 1 ? anchorNode : anchorNode.parentNode
+ )!;
const keyDownEvent = new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
@@ -539,7 +544,7 @@ function moveNativeSelectionBackward() {
if (lastTextNode === null) {
throw new Error('moveNativeSelectionBackward: TODO');
} else {
- const textLength = lastTextNode.nodeValue.length;
+ const textLength = lastTextNode.nodeValue!.length;
setNativeSelection(
lastTextNode,
textLength,
@@ -557,7 +562,7 @@ function moveNativeSelectionBackward() {
}
} else if (anchorNode.nodeType === 1) {
if (anchorNode.nodeName === 'BR') {
- const parentNode = anchorNode.parentNode;
+ const parentNode = anchorNode.parentNode!;
const childNodes = Array.from(parentNode.childNodes);
anchorOffset = childNodes.indexOf(anchorNode as ChildNode);
anchorNode = parentNode;
@@ -584,12 +589,14 @@ function moveNativeSelectionBackward() {
}
function moveNativeSelectionForward() {
- const domSelection = window.getSelection();
- const {anchorNode, anchorOffset} = domSelection;
+ const domSelection = window.getSelection()!;
+ const anchorNode = domSelection.anchorNode!;
+ const anchorOffset = domSelection.anchorOffset!;
if (domSelection.isCollapsed) {
- const target =
- anchorNode.nodeType === 1 ? anchorNode : anchorNode.parentNode;
+ const target = (
+ anchorNode.nodeType === 1 ? anchorNode : anchorNode.parentNode
+ )!;
const keyDownEvent = new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
@@ -600,7 +607,7 @@ function moveNativeSelectionForward() {
if (!keyDownEvent.defaultPrevented) {
if (anchorNode.nodeType === 3) {
- const text = anchorNode.nodeValue;
+ const text = anchorNode.nodeValue!;
if (text.length === anchorOffset) {
const nextTextNode = getNextTextNode(anchorNode);
@@ -635,8 +642,13 @@ function moveNativeSelectionForward() {
}
}
-export async function applySelectionInputs(inputs, update, editor) {
- const rootElement = editor.getRootElement();
+export async function applySelectionInputs(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ inputs: Record[],
+ update: (fn: () => void) => Promise,
+ editor: LexicalEditor,
+) {
+ const rootElement = editor.getRootElement()!;
for (let i = 0; i < inputs.length; i++) {
const input = inputs[i];
@@ -644,7 +656,7 @@ export async function applySelectionInputs(inputs, update, editor) {
for (let j = 0; j < times; j++) {
await update(() => {
- const selection = $getSelection();
+ const selection = $getSelection()!;
switch (input.type) {
case 'insert_text': {
@@ -800,7 +812,7 @@ export async function applySelectionInputs(inputs, update, editor) {
}),
{
clipboardData: {
- getData: (type) => {
+ getData: (type: string) => {
if (type === 'text/plain') {
return input.text;
}
@@ -823,7 +835,7 @@ export async function applySelectionInputs(inputs, update, editor) {
}),
{
clipboardData: {
- getData: (type) => {
+ getData: (type: string) => {
if (type === 'application/x-lexical-editor') {
return input.text;
}
@@ -846,7 +858,7 @@ export async function applySelectionInputs(inputs, update, editor) {
}),
{
clipboardData: {
- getData: (type) => {
+ getData: (type: string) => {
if (type === 'text/html') {
return input.text;
}
@@ -865,7 +877,9 @@ export async function applySelectionInputs(inputs, update, editor) {
}
}
-export function setAnchorPoint(point) {
+export function setAnchorPoint(
+ point: Pick,
+) {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
@@ -884,7 +898,9 @@ export function setAnchorPoint(point) {
anchor.key = point.key;
}
-export function setFocusPoint(point) {
+export function setFocusPoint(
+ point: Pick,
+) {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableSelection.test.tsx b/packages/lexical-table/src/__tests__/unit/LexicalTableSelection.test.tsx
index 4d5f9f3ae95..ee962723709 100644
--- a/packages/lexical-table/src/__tests__/unit/LexicalTableSelection.test.tsx
+++ b/packages/lexical-table/src/__tests__/unit/LexicalTableSelection.test.tsx
@@ -13,24 +13,27 @@ import {
$createTextNode,
$getRoot,
$setSelection,
+ EditorState,
+ ParagraphNode,
+ RootNode,
+ TextNode,
} from 'lexical';
import {createTestEditor} from 'lexical/src/__tests__/utils';
-import * as React from 'react';
import {createRef, useEffect, useMemo} from 'react';
-import {createRoot} from 'react-dom/client';
+import {createRoot, Root} from 'react-dom/client';
import * as ReactTestUtils from 'react-dom/test-utils';
describe('table selection', () => {
- let originalText;
- let parsedParagraph;
- let parsedRoot;
- let parsedText;
- let paragraphKey;
- let textKey;
- let parsedEditorState;
- let reactRoot;
+ 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;
+ let editor: LexicalEditor | null = null;
beforeEach(() => {
container = document.createElement('div');
@@ -38,7 +41,10 @@ describe('table selection', () => {
document.body.appendChild(container);
});
- function useLexicalEditor(rootElementRef, onError) {
+ function useLexicalEditor(
+ rootElementRef: React.RefObject,
+ onError?: () => void,
+ ) {
const editorInHook = useMemo(
() =>
createTestEditor({
@@ -78,8 +84,8 @@ describe('table selection', () => {
});
}
- async function update(fn) {
- editor.update(fn);
+ async function update(fn: () => void) {
+ editor!.update(fn);
return Promise.resolve().then();
}
@@ -102,15 +108,15 @@ describe('table selection', () => {
});
const stringifiedEditorState = JSON.stringify(
- editor.getEditorState().toJSON(),
+ editor!.getEditorState().toJSON(),
);
- parsedEditorState = editor.parseEditorState(stringifiedEditorState);
+ parsedEditorState = editor!.parseEditorState(stringifiedEditorState);
parsedEditorState.read(() => {
parsedRoot = $getRoot();
- parsedParagraph = parsedRoot.getFirstChild();
+ parsedParagraph = parsedRoot.getFirstChild()!;
paragraphKey = parsedParagraph.getKey();
- parsedText = parsedParagraph.getFirstChild();
+ parsedText = parsedParagraph.getFirstChild()!;
textKey = parsedText.getKey();
});
});
diff --git a/packages/lexical-utils/src/__tests__/unit/LexicalEventHelpers.test.tsx b/packages/lexical-utils/src/__tests__/unit/LexicalEventHelpers.test.tsx
index 7434d0a0937..089dd086ad1 100644
--- a/packages/lexical-utils/src/__tests__/unit/LexicalEventHelpers.test.tsx
+++ b/packages/lexical-utils/src/__tests__/unit/LexicalEventHelpers.test.tsx
@@ -22,8 +22,8 @@ import {
pasteHTML,
} from '@lexical/selection/src/__tests__/utils';
import {TableCellNode, TableNode, TableRowNode} from '@lexical/table';
+import {LexicalEditor} from 'lexical';
import {initializeClipboard, TestComposer} from 'lexical/src/__tests__/utils';
-import * as React from 'react';
import {createRoot} from 'react-dom/client';
import * as ReactTestUtils from 'react-dom/test-utils';
@@ -73,7 +73,7 @@ Range.prototype.getBoundingClientRect = function (): DOMRect {
};
describe('LexicalEventHelpers', () => {
- let container = null;
+ let container: HTMLDivElement | null = null;
beforeEach(async () => {
container = document.createElement('div');
@@ -82,15 +82,15 @@ describe('LexicalEventHelpers', () => {
});
afterEach(() => {
- document.body.removeChild(container);
+ document.body.removeChild(container!);
container = null;
});
- let editor = null;
+ let editor: LexicalEditor | null = null;
async function init() {
function TestBase() {
- function TestPlugin(): JSX.Element {
+ function TestPlugin(): null {
[editor] = useLexicalComposerContext();
return null;
@@ -147,8 +147,8 @@ describe('LexicalEventHelpers', () => {
}}>
+ // eslint-disable-next-line jsx-a11y/aria-role, @typescript-eslint/no-explicit-any
+
}
placeholder={null}
ErrorBoundary={LexicalErrorBoundary}
@@ -160,20 +160,20 @@ describe('LexicalEventHelpers', () => {
}
ReactTestUtils.act(() => {
- createRoot(container).render();
+ createRoot(container!).render();
});
}
- async function update(fn) {
+ async function update(fn: () => void) {
await ReactTestUtils.act(async () => {
- await editor.update(fn);
+ await editor!.update(fn);
});
return Promise.resolve().then();
}
test('Expect initial output to be a block with no text', () => {
- expect(container.innerHTML).toBe(
+ expect(container!.innerHTML).toBe(
'',
);
});
@@ -345,10 +345,10 @@ describe('LexicalEventHelpers', () => {
const name = testUnit.name || 'Test case';
test(name + ` (#${i + 1})`, async () => {
- await applySelectionInputs(testUnit.inputs, update, editor);
+ await applySelectionInputs(testUnit.inputs, update, editor!);
// Validate HTML matches
- expect(container.innerHTML).toBe(testUnit.expectedHTML);
+ expect(container!.innerHTML).toBe(testUnit.expectedHTML);
});
});
});
@@ -401,10 +401,10 @@ describe('LexicalEventHelpers', () => {
const name = testUnit.name || 'Test case';
test(name + ` (#${i + 1})`, async () => {
- await applySelectionInputs(testUnit.inputs, update, editor);
+ await applySelectionInputs(testUnit.inputs, update, editor!);
// Validate HTML matches
- expect(container.innerHTML).toBe(testUnit.expectedHTML);
+ expect(container!.innerHTML).toBe(testUnit.expectedHTML);
});
});
});
@@ -674,12 +674,14 @@ describe('LexicalEventHelpers', () => {
const name = testUnit.name || 'Test case';
// eslint-disable-next-line no-only-tests/no-only-tests, dot-notation
- const test_ = testUnit['only'] ? test.only : test;
+ const test_ = 'only' in testUnit && testUnit['only'] ? test.only : test;
test_(name + ` (#${i + 1})`, async () => {
- await applySelectionInputs(testUnit.inputs, update, editor);
+ await applySelectionInputs(testUnit.inputs, update, editor!);
// Validate HTML matches
- expect(container.firstChild.innerHTML).toBe(testUnit.expectedHTML);
+ expect((container!.firstChild as HTMLElement).innerHTML).toBe(
+ testUnit.expectedHTML,
+ );
});
});
});
diff --git a/packages/lexical-utils/src/__tests__/unit/LexicalNodeHelpers.test.ts b/packages/lexical-utils/src/__tests__/unit/LexicalNodeHelpers.test.ts
index 28783003cdd..b86fdc01ebe 100644
--- a/packages/lexical-utils/src/__tests__/unit/LexicalNodeHelpers.test.ts
+++ b/packages/lexical-utils/src/__tests__/unit/LexicalNodeHelpers.test.ts
@@ -127,7 +127,7 @@ describe('LexicalNodeHelpers tests', () => {
editor.getEditorState().read(() => {
const expectedNodes = expectedKeys.map(({depth, node: nodeKey}) => ({
depth,
- node: $getNodeByKey(nodeKey).getLatest(),
+ node: $getNodeByKey(nodeKey)!.getLatest(),
}));
const first = expectedNodes[0];
@@ -147,10 +147,10 @@ describe('LexicalNodeHelpers tests', () => {
test('DFS triggers getLatest()', async () => {
const editor: LexicalEditor = testEnv.editor;
- let rootKey;
- let paragraphKey;
- let block1Key;
- let block2Key;
+ let rootKey: string;
+ let paragraphKey: string;
+ let block1Key: string;
+ let block2Key: string;
await editor.update(() => {
const root = $getRoot();
@@ -179,14 +179,14 @@ describe('LexicalNodeHelpers tests', () => {
block1.append(block3);
- expect($dfs(root)).toEqual([
+ expect($dfs(root!)).toEqual([
{
depth: 0,
- node: root.getLatest(),
+ node: root!.getLatest(),
},
{
depth: 1,
- node: paragraph.getLatest(),
+ node: paragraph!.getLatest(),
},
{
depth: 2,
@@ -198,7 +198,36 @@ describe('LexicalNodeHelpers tests', () => {
},
{
depth: 2,
- node: block2.getLatest(),
+ node: block2!.getLatest(),
+ },
+ ]);
+ });
+ });
+
+ test('DFS of empty ParagraphNode returns only itself', async () => {
+ const editor: LexicalEditor = testEnv.editor;
+
+ let paragraphKey: string;
+
+ await editor.update(() => {
+ const root = $getRoot();
+
+ const paragraph = $createParagraphNode();
+ const paragraph2 = $createParagraphNode();
+ const text = $createTextNode('test');
+
+ paragraphKey = paragraph.getKey();
+
+ paragraph2.append(text);
+ root.append(paragraph, paragraph2);
+ });
+ await editor.update(() => {
+ const paragraph = $getNodeByKey(paragraphKey);
+
+ expect($dfs(paragraph ?? undefined)).toEqual([
+ {
+ depth: 1,
+ node: paragraph?.getLatest(),
},
]);
});
diff --git a/packages/lexical-utils/src/__tests__/unit/LexicalUtilsSplitNode.test.tsx b/packages/lexical-utils/src/__tests__/unit/LexicalUtilsSplitNode.test.tsx
index 475203fbbfd..f3db39390d6 100644
--- a/packages/lexical-utils/src/__tests__/unit/LexicalUtilsSplitNode.test.tsx
+++ b/packages/lexical-utils/src/__tests__/unit/LexicalUtilsSplitNode.test.tsx
@@ -17,7 +17,7 @@ import {$splitNode} from '../../index';
describe('LexicalUtils#splitNode', () => {
let editor: LexicalEditor;
- const update = async (updateFn) => {
+ const update = async (updateFn: () => void) => {
editor.update(updateFn);
await Promise.resolve();
};
@@ -115,7 +115,7 @@ describe('LexicalUtils#splitNode', () => {
let nodeToSplit: ElementNode = $getRoot();
for (const index of testCase.splitPath) {
- nodeToSplit = nodeToSplit.getChildAtIndex(index);
+ nodeToSplit = nodeToSplit.getChildAtIndex(index)!;
if (!$isElementNode(nodeToSplit)) {
throw new Error('Expected node to be element');
}
diff --git a/packages/lexical-utils/src/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.tsx b/packages/lexical-utils/src/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.tsx
index 0ff05228ea1..0e46573e7f6 100644
--- a/packages/lexical-utils/src/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.tsx
+++ b/packages/lexical-utils/src/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.tsx
@@ -25,7 +25,7 @@ import {$insertNodeToNearestRoot} from '../..';
describe('LexicalUtils#insertNodeToNearestRoot', () => {
let editor: LexicalEditor;
- const update = async (updateFn) => {
+ const update = async (updateFn: () => void) => {
editor.update(updateFn);
await Promise.resolve();
};
@@ -155,7 +155,7 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => {
'Expected node to be element (to traverse the tree)',
);
}
- selectionNode = selectionNode.getChildAtIndex(index);
+ selectionNode = selectionNode.getChildAtIndex(index)!;
}
// Calling selectionNode.select() would "normalize" selection and move it
diff --git a/packages/lexical-utils/src/index.ts b/packages/lexical-utils/src/index.ts
index 1d4bd037de0..3d2346cc009 100644
--- a/packages/lexical-utils/src/index.ts
+++ b/packages/lexical-utils/src/index.ts
@@ -44,7 +44,13 @@ import normalizeClassNames from 'shared/normalizeClassNames';
export {default as markSelection} from './markSelection';
export {default as mergeRegister} from './mergeRegister';
export {default as positionNodeOnRange} from './positionNodeOnRange';
-export {$splitNode, isHTMLAnchorElement, isHTMLElement} from 'lexical';
+export {
+ $splitNode,
+ isBlockDomNode,
+ isHTMLAnchorElement,
+ isHTMLElement,
+ isInlineDomNode,
+} from 'lexical';
// Hotfix to export these with inlined types #5918
export const CAN_USE_BEFORE_INPUT: boolean = CAN_USE_BEFORE_INPUT_;
export const CAN_USE_DOM: boolean = CAN_USE_DOM_;
@@ -177,7 +183,8 @@ export function $dfs(
const nodes = [];
const start = (startingNode || $getRoot()).getLatest();
const end =
- endingNode || ($isElementNode(start) ? start.getLastDescendant() : start);
+ endingNode ||
+ ($isElementNode(start) ? start.getLastDescendant() || start : start);
let node: LexicalNode | null = start;
let depth = $getDepth(node);
@@ -222,6 +229,37 @@ function $getDepth(node: LexicalNode): number {
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.
+ * 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.
+ * @returns The next node in pre-order right to left traversal sequence or `null`, if the node does not exist
+ */
+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;
+}
+
/**
* Takes a node and traverses up its ancestors (toward the root node)
* in order to find a specific type of node.
diff --git a/packages/lexical-website/docs/collaboration/faq.md b/packages/lexical-website/docs/collaboration/faq.md
new file mode 100644
index 00000000000..eaa36ff23cd
--- /dev/null
+++ b/packages/lexical-website/docs/collaboration/faq.md
@@ -0,0 +1,158 @@
+---
+sidebar_position: 2
+---
+
+# Collaboration FAQ
+
+## Source of truth: Lexical State, Yjs and App's DB
+
+It's recommended to treat the Yjs model as the source of truth. You can store the document to a database for indexing.
+But, if possible, you should never forget the Yjs model, as this is the only way clients without internet access can reliably join and sync with the server.
+
+You can also treat the database as the source of truth. This is how it could be achieved:
+
+- Clients receive a `sessionId` when they connect to the server
+- When a client connects without an existing `sessionId`, get the content from the database and create a `sessionId`
+- When all clients disconnect, forget the room content and `sessionId` on the server after some timeout (e.g. 1 hour)
+- When a client reconnects, use that content on the server. Furthermore, get the `sessionId` from the client
+- When two clients with different `sessionId` reconnect, one of the clients should forget the room content. _In this case the client will lose content_ - although it is very unlikely if you set the forget timeout (see point 2) very high.
+
+Or, there is an ever simpler approach:
+
+- When a client connects to the server, the server populates the room content if empty
+- When all clients disconnect, the server forgets the room content after some timeout (e.g. 1 hour)
+- When a client was not able to reconnect for 40 minutes, the client must forget its local updates and start fresh (this should be enforced by the server)
+
+When the database is the source of truth, and if you want to be able to forget the Yjs model, you will always run into cases where clients are not able to commit changes. That's not too bad in most projects. It somehow limits you, because you can't cache the document on the client using y-indexeddb. On the other hand, it is much easier to maintain, and do Yjs upgrades. Furthermore, most people would say that SQL is a bit more reliable than Yjs.
+
+_* Based on the advice of the Yjs author - [Kevin Jahns](https://github.com/yjs/yjs/issues/82#issuecomment-328365015)_
+
+
+## Initializing `EditorState` from Yjs Document
+
+It's achievable by leveraging headless Lexical and no-op provider for Yjs:
+
+
+ createHeadlessCollaborativeEditor.ts
+
+ ```typescript
+ import type {Binding, Provider} from '@lexical/yjs';
+ import type {
+ Klass,
+ LexicalEditor,
+ LexicalNode,
+ LexicalNodeReplacement,
+ SerializedEditorState,
+ SerializedLexicalNode,
+ } from 'lexical';
+
+ import {createHeadlessEditor} from '@lexical/headless';
+ import {
+ createBinding,
+ syncLexicalUpdateToYjs,
+ syncYjsChangesToLexical,
+ } from '@lexical/yjs';
+ import {type YEvent, applyUpdate, Doc, Transaction} from 'yjs';
+
+ export default function headlessConvertYDocStateToLexicalJSON(
+ nodes: ReadonlyArray | LexicalNodeReplacement>,
+ yDocState: Uint8Array,
+ ): SerializedEditorState {
+ return withHeadlessCollaborationEditor(nodes, (editor, binding) => {
+ applyUpdate(binding.doc, yDocState, {isUpdateRemote: true});
+ editor.update(() => {}, {discrete: true});
+
+ return editor.getEditorState().toJSON();
+ });
+ }
+
+ /**
+ * Creates headless collaboration editor with no-op provider (since it won't
+ * connect to message distribution infra) and binding. It also sets up
+ * bi-directional synchronization between yDoc and editor
+ */
+ function withHeadlessCollaborationEditor(
+ nodes: ReadonlyArray | LexicalNodeReplacement>,
+ callback: (editor: LexicalEditor, binding: Binding, provider: Provider) => T,
+ ): T {
+ const editor = createHeadlessEditor({
+ nodes,
+ });
+
+ const id = 'main';
+ const doc = new Doc();
+ const docMap = new Map([[id, doc]]);
+ const provider = createNoOpProvider();
+ const binding = createBinding(editor, provider, id, doc, docMap);
+
+ const unsubscribe = registerCollaborationListeners(editor, provider, binding);
+
+ const res = callback(editor, binding, provider);
+
+ unsubscribe();
+
+ return res;
+ }
+
+ function registerCollaborationListeners(
+ editor: LexicalEditor,
+ provider: Provider,
+ binding: Binding,
+ ): () => void {
+ const unsubscribeUpdateListener = editor.registerUpdateListener(
+ ({
+ dirtyElements,
+ dirtyLeaves,
+ editorState,
+ normalizedNodes,
+ prevEditorState,
+ tags,
+ }) => {
+ if (tags.has('skip-collab') === false) {
+ syncLexicalUpdateToYjs(
+ binding,
+ provider,
+ prevEditorState,
+ editorState,
+ dirtyElements,
+ dirtyLeaves,
+ normalizedNodes,
+ tags,
+ );
+ }
+ },
+ );
+
+ const observer = (events: Array>, transaction: Transaction) => {
+ if (transaction.origin !== binding) {
+ syncYjsChangesToLexical(binding, provider, events, false);
+ }
+ };
+
+ binding.root.getSharedType().observeDeep(observer);
+
+ return () => {
+ unsubscribeUpdateListener();
+ binding.root.getSharedType().unobserveDeep(observer);
+ };
+ }
+
+ function createNoOpProvider(): Provider {
+ const emptyFunction = () => {};
+
+ return {
+ awareness: {
+ getLocalState: () => null,
+ getStates: () => new Map(),
+ off: emptyFunction,
+ on: emptyFunction,
+ setLocalState: emptyFunction,
+ },
+ connect: emptyFunction,
+ disconnect: emptyFunction,
+ off: emptyFunction,
+ on: emptyFunction,
+ };
+ }
+ ```
+
diff --git a/packages/lexical-website/docs/concepts/node-replacement.md b/packages/lexical-website/docs/concepts/node-replacement.md
index 10d3f06906a..06435667189 100644
--- a/packages/lexical-website/docs/concepts/node-replacement.md
+++ b/packages/lexical-website/docs/concepts/node-replacement.md
@@ -28,5 +28,5 @@ Once this is done, Lexical will replace all ParagraphNode instances with CustomP
style="width:100%; height:700px; border:0; border-radius:4px; overflow:hidden;"
title="lexical-collapsible-container-plugin-example"
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
- sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
+ sandbox="allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox allow-presentation allow-same-origin allow-scripts"
>
diff --git a/packages/lexical-website/docs/faq.md b/packages/lexical-website/docs/faq.md
index 64c90b67ece..2a0428d1f9b 100644
--- a/packages/lexical-website/docs/faq.md
+++ b/packages/lexical-website/docs/faq.md
@@ -37,6 +37,55 @@ If you've used React Hooks before, you can think of `$` functions as being somet
Internally, we've found this scales really well and developers get to grips with it in almost no time at all.
+## When does reconciliation happen?
+
+Reconciliation is scheduled with
+[queueMicrotask](https://developer.mozilla.org/en-US/docs/Web/API/queueMicrotask),
+which means that it will happen very soon, but asynchronously. This is similar
+to something like `setTimeout(reconcile, 0)` with a bit more immediacy or
+`Promise.resolve().then(reconcile)` with less overhead. This is done so
+that all of the updates that occur as a result of a single logical event will
+be batched into one reconciliation.
+
+You can force a reconciliation to take place synchronously with the discrete
+option to `editor.update` (demonstrated below).
+
+## Why do tests use `await editor.update(…)`
+
+You may notice that many tests look like this:
+
+```js
+await editor.update(updateA);
+await editor.update(updateB);
+```
+
+An astute observer would notice that this seems very strange, since
+`editor.update()` returns `void` and not `Promise`. However,
+it does happen to work as you would want it to because
+the implementation of Promise uses the same microtask queue.
+
+It's not recommended to rely on this in browser code as it could depend on
+implementation details of the compilers, bundlers, and VM. It's best to stick
+to using the `discrete` or the `onUpdate` callback options to be sure that
+the reconciliation has taken place.
+
+Ignoring any other microtasks that were scheduled elsewhere,
+it is roughly equivalent to this synchronous code:
+
+```js
+editor.update(updateA, {discrete: true});
+editor.update(updateB, {discrete: true});
+```
+
+At a high level, very roughly, the order of operations looks like this:
+
+1. `editor.update()` is called
+2. `updateA()` is called and updates the editor state
+3. `editor.update()` schedules a reconciliation microtask and returns
+4. `await` schedules a resume microtask and yields control to the task executor
+5. the reconciliation microtask runs, reconciling the editor state with the DOM
+6. the resume microtask runs
+
## How do I listen for user text insertions?
Listening to text insertion events is problematic with content editables in general. It's a common source of bugs due to how
diff --git a/packages/lexical-website/docs/getting-started/creating-plugin.md b/packages/lexical-website/docs/getting-started/creating-plugin.md
index 1e76287ba81..32791754217 100644
--- a/packages/lexical-website/docs/getting-started/creating-plugin.md
+++ b/packages/lexical-website/docs/getting-started/creating-plugin.md
@@ -183,4 +183,4 @@ mergeRegister(
);
```
-
+
diff --git a/packages/lexical-website/docs/getting-started/quick-start.md b/packages/lexical-website/docs/getting-started/quick-start.md
index 3275560dfc2..6c31823aa1e 100644
--- a/packages/lexical-website/docs/getting-started/quick-start.md
+++ b/packages/lexical-website/docs/getting-started/quick-start.md
@@ -128,4 +128,4 @@ editor.registerUpdateListener(({editorState}) => {
Here we have simplest Lexical setup in rich text configuration (`@lexical/rich-text`) with history (`@lexical/history`) and accessibility (`@lexical/dragon`) features enabled.
-
+
diff --git a/packages/lexical-website/docs/getting-started/react.md b/packages/lexical-website/docs/getting-started/react.md
index 9c6b12f447d..b5ec59a2086 100644
--- a/packages/lexical-website/docs/getting-started/react.md
+++ b/packages/lexical-website/docs/getting-started/react.md
@@ -81,7 +81,7 @@ Below you can find an example of the integration from the previous chapter that
However no UI can be created w/o CSS and Lexical is not an exception here. Pay attention to `ExampleTheme.ts` and how it's used in this example, with corresponding styles defined in `styles.css`.
-
+
## Saving Lexical State
diff --git a/packages/lexical-website/sidebars.js b/packages/lexical-website/sidebars.js
index cb284eda073..08a7c9c36f3 100644
--- a/packages/lexical-website/sidebars.js
+++ b/packages/lexical-website/sidebars.js
@@ -72,7 +72,7 @@ const sidebars = {
type: 'category',
},
{
- items: ['collaboration/react'],
+ items: ['collaboration/react', 'collaboration/faq'],
label: 'Collaboration',
type: 'category',
},
diff --git a/packages/lexical-website/src/components/HomepageExamples/index.js b/packages/lexical-website/src/components/HomepageExamples/index.js
index 38436d3960d..f47cacafa88 100644
--- a/packages/lexical-website/src/components/HomepageExamples/index.js
+++ b/packages/lexical-website/src/components/HomepageExamples/index.js
@@ -79,7 +79,7 @@ export default function HomepageExamples() {
- {EXAMPLES.map(({id, content, src}) => (
+ {EXAMPLES.map(({id, content, src, label}) => (
@@ -98,8 +98,8 @@ export default function HomepageExamples() {
diff --git a/packages/lexical/src/LexicalEditor.ts b/packages/lexical/src/LexicalEditor.ts
index 79b3bc0f3e3..468cfbd413a 100644
--- a/packages/lexical/src/LexicalEditor.ts
+++ b/packages/lexical/src/LexicalEditor.ts
@@ -37,6 +37,7 @@ import {
getDOMSelection,
markAllNodesAsDirty,
} from './LexicalUtils';
+import {ArtificialNode__DO_NOT_USE} from './nodes/ArtificialNode';
import {DecoratorNode} from './nodes/LexicalDecoratorNode';
import {LineBreakNode} from './nodes/LexicalLineBreakNode';
import {ParagraphNode} from './nodes/LexicalParagraphNode';
@@ -421,6 +422,7 @@ export function createEditor(editorConfig?: CreateEditorArgs): LexicalEditor {
LineBreakNode,
TabNode,
ParagraphNode,
+ ArtificialNode__DO_NOT_USE,
...(config.nodes || []),
];
const {onError, html} = config;
diff --git a/packages/lexical/src/LexicalEvents.ts b/packages/lexical/src/LexicalEvents.ts
index 96cf9ad7c86..6b3c2973901 100644
--- a/packages/lexical/src/LexicalEvents.ts
+++ b/packages/lexical/src/LexicalEvents.ts
@@ -1210,7 +1210,7 @@ export function addRootElementEvents(
) {
doc.addEventListener('selectionchange', onDocumentSelectionChange);
}
- rootElementsRegistered.set(doc, documentRootElementsCount || 0 + 1);
+ rootElementsRegistered.set(doc, (documentRootElementsCount || 0) + 1);
// @ts-expect-error: internal field
rootElement.__lexicalEditor = editor;
@@ -1319,8 +1319,10 @@ export function removeRootElementEvents(rootElement: HTMLElement): void {
// We only want to have a single global selectionchange event handler, shared
// between all editor instances.
- rootElementsRegistered.set(doc, documentRootElementsCount - 1);
- if (rootElementsRegistered.get(doc) === 0) {
+ const newCount = documentRootElementsCount - 1;
+ invariant(newCount >= 0, 'Root element count less than 0');
+ rootElementsRegistered.set(doc, newCount);
+ if (newCount === 0) {
doc.removeEventListener('selectionchange', onDocumentSelectionChange);
}
diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts
index 12067bad8e3..8bd4f1467d9 100644
--- a/packages/lexical/src/LexicalSelection.ts
+++ b/packages/lexical/src/LexicalSelection.ts
@@ -1912,6 +1912,7 @@ function internalResolveSelectionPoint(
// We use the anchor to find which child node to select
const childNodes = dom.childNodes;
const childNodesLength = childNodes.length;
+ const blockCursorElement = editor._blockCursorElement;
// If the anchor is the same as length, then this means we
// need to select the very last text node.
if (resolvedOffset === childNodesLength) {
@@ -1920,11 +1921,20 @@ function internalResolveSelectionPoint(
}
let childDOM = childNodes[resolvedOffset];
let hasBlockCursor = false;
- if (childDOM === editor._blockCursorElement) {
+ if (childDOM === blockCursorElement) {
childDOM = childNodes[resolvedOffset + 1];
hasBlockCursor = true;
- } else if (editor._blockCursorElement !== null) {
- resolvedOffset--;
+ } else if (blockCursorElement !== null) {
+ const blockCursorElementParent = blockCursorElement.parentNode;
+ if (dom === blockCursorElementParent) {
+ const blockCursorOffset = Array.prototype.indexOf.call(
+ blockCursorElementParent.children,
+ blockCursorElement,
+ );
+ if (offset > blockCursorOffset) {
+ resolvedOffset--;
+ }
+ }
}
resolvedNode = getNodeFromDOM(childDOM);
diff --git a/packages/lexical/src/LexicalUtils.ts b/packages/lexical/src/LexicalUtils.ts
index f9af189d991..52d78f07f36 100644
--- a/packages/lexical/src/LexicalUtils.ts
+++ b/packages/lexical/src/LexicalUtils.ts
@@ -1584,6 +1584,32 @@ export function isHTMLElement(x: Node | EventTarget): x is HTMLElement {
return x.nodeType === 1;
}
+/**
+ *
+ * @param node - the Dom Node to check
+ * @returns if the Dom Node is an inline node
+ */
+export function isInlineDomNode(node: Node) {
+ const inlineNodes = new RegExp(
+ /^(a|abbr|acronym|b|cite|code|del|em|i|ins|kbd|label|output|q|ruby|s|samp|span|strong|sub|sup|time|u|tt|var|#text)$/,
+ 'i',
+ );
+ return node.nodeName.match(inlineNodes) !== null;
+}
+
+/**
+ *
+ * @param node - the Dom Node to check
+ * @returns if the Dom Node is a block node
+ */
+export function isBlockDomNode(node: Node) {
+ const blockNodes = new RegExp(
+ /^(address|article|aside|blockquote|canvas|dd|div|dl|dt|fieldset|figcaption|figure|footer|form|h1|h2|h3|h4|h5|h6|header|hr|li|main|nav|noscript|ol|p|pre|section|table|td|tfoot|ul|video)$/,
+ 'i',
+ );
+ return node.nodeName.match(blockNodes) !== null;
+}
+
/**
* This function is for internal use of the library.
* Please do not use it as it may change in the future.
diff --git a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx
index 8e5d86c9f4f..0b9ab667f63 100644
--- a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx
+++ b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx
@@ -2215,4 +2215,20 @@ describe('LexicalEditor tests', () => {
expect(ParagraphNode.importDOM).toHaveBeenCalledTimes(1);
});
+
+ it('root element count is always positive', () => {
+ const newEditor1 = createTestEditor();
+ const newEditor2 = createTestEditor();
+
+ const container1 = document.createElement('div');
+ const container2 = document.createElement('div');
+
+ newEditor1.setRootElement(container1);
+ newEditor1.setRootElement(null);
+
+ newEditor1.setRootElement(container1);
+ newEditor2.setRootElement(container2);
+ newEditor1.setRootElement(null);
+ newEditor2.setRootElement(null);
+ });
});
diff --git a/packages/lexical/src/__tests__/unit/LexicalListPlugin.test.tsx b/packages/lexical/src/__tests__/unit/LexicalListPlugin.test.tsx
index 37c0aa4de3a..f9e6544fe95 100644
--- a/packages/lexical/src/__tests__/unit/LexicalListPlugin.test.tsx
+++ b/packages/lexical/src/__tests__/unit/LexicalListPlugin.test.tsx
@@ -21,7 +21,6 @@ import {
html,
TestComposer,
} from 'lexical/src/__tests__/utils';
-import * as React from 'react';
import {createRoot, Root} from 'react-dom/client';
import * as ReactTestUtils from 'react-dom/test-utils';
diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts
index 01c14c34ada..d46e369b288 100644
--- a/packages/lexical/src/index.ts
+++ b/packages/lexical/src/index.ts
@@ -157,11 +157,14 @@ export {
$setSelection,
$splitNode,
getNearestEditorFromDOMNode,
+ isBlockDomNode,
isHTMLAnchorElement,
isHTMLElement,
+ isInlineDomNode,
isSelectionCapturedInDecoratorInput,
isSelectionWithinEditor,
} from './LexicalUtils';
+export {ArtificialNode__DO_NOT_USE} from './nodes/ArtificialNode';
export {$isDecoratorNode, DecoratorNode} from './nodes/LexicalDecoratorNode';
export {$isElementNode, ElementNode} from './nodes/LexicalElementNode';
export type {SerializedLineBreakNode} from './nodes/LexicalLineBreakNode';
diff --git a/packages/lexical/src/nodes/ArtificialNode.ts b/packages/lexical/src/nodes/ArtificialNode.ts
new file mode 100644
index 00000000000..0f01d2c3493
--- /dev/null
+++ b/packages/lexical/src/nodes/ArtificialNode.ts
@@ -0,0 +1,23 @@
+/**
+ * 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 {EditorConfig} from 'lexical';
+
+import {ElementNode} from './LexicalElementNode';
+
+// TODO: Cleanup ArtificialNode__DO_NOT_USE #5966
+export class ArtificialNode__DO_NOT_USE extends ElementNode {
+ static getType(): string {
+ return 'artificial';
+ }
+
+ createDOM(config: EditorConfig): HTMLElement {
+ // this isnt supposed to be used and is not used anywhere but defining it to appease the API
+ const dom = document.createElement('div');
+ return dom;
+ }
+}
diff --git a/packages/lexical/src/nodes/LexicalTextNode.ts b/packages/lexical/src/nodes/LexicalTextNode.ts
index 26bc0f63849..269fa116af8 100644
--- a/packages/lexical/src/nodes/LexicalTextNode.ts
+++ b/packages/lexical/src/nodes/LexicalTextNode.ts
@@ -62,6 +62,7 @@ import {
getCachedClassNameArray,
internalMarkSiblingsAsDirty,
isHTMLElement,
+ isInlineDomNode,
toggleTextFormatType,
} from '../LexicalUtils';
import {$createLineBreakNode} from './LexicalLineBreakNode';
@@ -1261,11 +1262,6 @@ function convertTextDOMNode(domNode: Node): DOMConversionOutput {
return {node: $createTextNode(textContent)};
}
-const inlineParents = new RegExp(
- /^(a|abbr|acronym|b|cite|code|del|em|i|ins|kbd|label|output|q|ruby|s|samp|span|strong|sub|sup|time|u|tt|var)$/,
- 'i',
-);
-
function findTextInLine(text: Text, forward: boolean): null | Text {
let node: Node = text;
// eslint-disable-next-line no-constant-condition
@@ -1284,7 +1280,7 @@ function findTextInLine(text: Text, forward: boolean): null | Text {
if (node.nodeType === DOM_ELEMENT_TYPE) {
const display = (node as HTMLElement).style.display;
if (
- (display === '' && node.nodeName.match(inlineParents) === null) ||
+ (display === '' && !isInlineDomNode(node)) ||
(display !== '' && !display.startsWith('inline'))
) {
return null;
diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json
index 8869d17b2a5..bdb4a97372d 100644
--- a/scripts/error-codes/codes.json
+++ b/scripts/error-codes/codes.json
@@ -162,5 +162,6 @@
"160": "Expected TableCellNode parent to be a TableRowNode",
"161": "Unexpected dirty selection to be null",
"162": "Root element not registered",
- "163": "node is not a ListNode"
+ "163": "node is not a ListNode",
+ "164": "Root element count less than 0"
}
diff --git a/scripts/www/__tests__/unit/transformFlowFileContents.test.js b/scripts/www/__tests__/unit/transformFlowFileContents.test.js
new file mode 100644
index 00000000000..af3b931fede
--- /dev/null
+++ b/scripts/www/__tests__/unit/transformFlowFileContents.test.js
@@ -0,0 +1,107 @@
+/**
+ * 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.
+ *
+ */
+// @ts-check
+'use strict';
+
+const transformFlowFileContents = require('../../transformFlowFileContents');
+
+const HEADER_BEFORE =
+ `
+/**
+ * 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.
+ *
+ * @flow strict
+ */
+`.trim() + '\n';
+
+const HEADER_AFTER =
+ `
+/**
+ * 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.
+ *
+ * @flow strict
+ * @generated
+ * @oncall lexical_web_text_editor
+ */
+`.trim() + '\n';
+
+const IMPORTS_BEFORE =
+ `
+import type {Doc, RelativePosition, UndoManager, XmlText} from 'yjs';
+import type {
+ DecoratorNode,
+ EditorState,
+ ElementNode,
+ LexicalCommand,
+ LexicalEditor,
+ LexicalNode,
+ LineBreakNode,
+ NodeMap,
+ NodeKey,
+ TextNode,
+} from 'lexical';
+`.trim() + '\n';
+
+const IMPORTS_AFTER =
+ `
+import type {Doc, RelativePosition, UndoManager, XmlText} from 'yjs';
+import type {
+ DecoratorNode,
+ EditorState,
+ ElementNode,
+ LexicalCommand,
+ LexicalEditor,
+ LexicalNode,
+ LineBreakNode,
+ NodeMap,
+ NodeKey,
+ TextNode,
+} from 'Lexical';
+`.trim() + '\n';
+
+const EXTRA_BLOCK_COMMENT =
+ `
+/**
+ * LexicalDevToolsCore
+ */
+`.trim() + '\n';
+
+describe('transformFlowFileContents', () => {
+ [
+ {
+ input: [HEADER_BEFORE, IMPORTS_BEFORE, EXTRA_BLOCK_COMMENT].join('\n'),
+ output: [HEADER_AFTER, IMPORTS_AFTER, EXTRA_BLOCK_COMMENT].join('\n'),
+ title: 'header-imports-comment',
+ },
+ {
+ input: [HEADER_BEFORE, EXTRA_BLOCK_COMMENT].join('\n'),
+ output: [HEADER_AFTER, EXTRA_BLOCK_COMMENT].join('\n'),
+ title: 'header-comment',
+ },
+ {
+ input: [HEADER_BEFORE, IMPORTS_BEFORE].join('\n'),
+ output: [HEADER_AFTER, IMPORTS_AFTER].join('\n'),
+ title: 'header-imports',
+ },
+ {
+ input: [HEADER_BEFORE].join('\n'),
+ output: [HEADER_AFTER].join('\n'),
+ title: 'header',
+ },
+ ].forEach(({input, output, title}) => {
+ it(`transforms ${title}`, async () => {
+ expect(await transformFlowFileContents(input)).toBe(output);
+ });
+ });
+});
diff --git a/scripts/www/rewriteImports.js b/scripts/www/rewriteImports.js
index b27a1e35f8b..6635300f5ad 100644
--- a/scripts/www/rewriteImports.js
+++ b/scripts/www/rewriteImports.js
@@ -12,55 +12,7 @@ const fs = require('fs-extra');
const glob = require('glob');
const path = require('node:path');
const {packagesManager} = require('../shared/packagesManager');
-const npmToWwwName = require('./npmToWwwName');
-const {t, transform} = require('hermes-transform');
-
-const wwwMappings = Object.fromEntries(
- packagesManager
- .getPublicPackages()
- .flatMap((pkg) =>
- pkg.getExportedNpmModuleNames().map((npm) => [npm, npmToWwwName(npm)]),
- ),
-);
-
-/**
- * It would be nice to use jscodeshift for this but the flow sources are using
- * ast features that are not supported in ast-types (as of 2024-04-11) so it's
- * not possible to traverse the tree and replace the imports & comments.
- *
- * It might be possible going straight to flow-parser, but it was a slew of
- * hardcoded regexps before and now it's at least automated based on the
- * exports.
- *
- * @param {string} source
- * @returns {Promise} transformed source
- */
-async function transformFlowFileContents(source) {
- return await transform(
- source,
- (context) => ({
- ImportDeclaration(node) {
- const value = wwwMappings[node.source.value];
- if (value) {
- context.replaceNode(node.source, t.StringLiteral({value}));
- }
- },
- Program(node) {
- if (
- node.docblock &&
- node.docblock.comment &&
- node.docblock.comment.value.includes('@flow strict')
- ) {
- node.docblock.comment.value = node.docblock.comment.value.replace(
- / \* @flow strict/g,
- ' * @flow strict\n * @generated\n * @oncall lexical_web_text_editor',
- );
- }
- },
- }),
- {},
- );
-}
+const transformFlowFileContents = require('./transformFlowFileContents');
// This script attempts to find all Flow definition modules, and makes
// them compatible with www. Specifically, it finds any imports that
diff --git a/scripts/www/transformFlowFileContents.js b/scripts/www/transformFlowFileContents.js
new file mode 100644
index 00000000000..4dc505c3d36
--- /dev/null
+++ b/scripts/www/transformFlowFileContents.js
@@ -0,0 +1,90 @@
+/**
+ * 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.
+ *
+ */
+
+'use strict';
+
+const {packagesManager} = require('../shared/packagesManager');
+const npmToWwwName = require('./npmToWwwName');
+const {t, transform} = require('hermes-transform');
+const prettier = require('prettier');
+
+const wwwMappings = Object.fromEntries(
+ packagesManager
+ .getPublicPackages()
+ .flatMap((pkg) =>
+ pkg.getExportedNpmModuleNames().map((npm) => [npm, npmToWwwName(npm)]),
+ ),
+);
+
+const prettierConfig = prettier.resolveConfig('./').then((cfg) => cfg || {});
+
+/**
+ * Add a statement to the end of the code so the comments don't
+ * disappear. This is a workaround for a hermes transform issue.
+ *
+ * @param {string} code
+ */
+function wrapCode(code) {
+ return [code, 'export {};\n'].join('\n');
+}
+
+/**
+ * The inverse transform of wrapCode, removes the added statement.
+ *
+ * @param {string} code
+ */
+function unwrapCode(code) {
+ return code.replace(/\n+export {};\n?$/, '\n');
+}
+
+/**
+ * It would be nice to use jscodeshift for this but the flow sources are using
+ * ast features that are not supported in ast-types (as of 2024-04-11) so it's
+ * not possible to traverse the tree and replace the imports & comments.
+ *
+ * It might be possible going straight to flow-parser, but it was a slew of
+ * hardcoded regexps before and now it's at least automated based on the
+ * exports.
+ *
+ * @param {string} source
+ * @returns {Promise} transformed source
+ */
+module.exports = async function transformFlowFileContents(source) {
+ return unwrapCode(
+ await transform(
+ wrapCode(source),
+ (context) => ({
+ ImportDeclaration(node) {
+ const value = wwwMappings[node.source.value];
+ if (value) {
+ context.replaceNode(node.source, t.StringLiteral({value}));
+ }
+ },
+ Program(node) {
+ if (
+ node.docblock &&
+ node.docblock.comment &&
+ node.docblock.comment.value.includes('@flow strict')
+ ) {
+ // This is mutated in-place because I couldn't find a mutation that
+ // did not fail for replacing the Program node.
+ node.docblock.comment.value = node.docblock.comment.value.replace(
+ / \* @flow strict/g,
+ ' * @flow strict\n * @generated\n * @oncall lexical_web_text_editor',
+ );
+ // We need the mutations array to be non-empty, so remove something
+ // that is not there. The AST traversals use object identity in a
+ // Set so we don't have to worry about some other line changing.
+ context.removeComments(t.LineComment({value: ''}));
+ }
+ },
+ }),
+ await prettierConfig,
+ ),
+ );
+};
diff --git a/tsconfig.json b/tsconfig.json
index 41ed3fdc8a0..e106a0d58b1 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -211,7 +211,6 @@
"include": ["./libdefs", "./packages"],
"exclude": [
"./libdefs/*.js",
- "**/lexical-*/src/__tests__/**",
"**/dist/**",
"**/npm/**",
"**/node_modules/**",
diff --git a/tsconfig.test.json b/tsconfig.test.json
index dd6c48b0ab1..d2e2efc9391 100644
--- a/tsconfig.test.json
+++ b/tsconfig.test.json
@@ -10,7 +10,6 @@
"**/dist/**",
"**/npm/**",
"**/node_modules/**",
- "./packages/playwright-core/**",
"./packages/lexical-devtools/**"
],
"extends": "./tsconfig.json"
|