`,
);
@@ -118,7 +123,12 @@ test.describe('Autocomplete', () => {
data-lexical-text="true">
Test
-
+
+ imonials (TAB)
+
`,
);
@@ -204,7 +214,12 @@ test.describe('Autocomplete', () => {
data-lexical-text="true">
Test
-
+
+ imonials (TAB)
+
`,
);
@@ -241,7 +256,12 @@ test.describe('Autocomplete', () => {
data-lexical-text="true">
Test
-
+
+ imonials
+
`,
);
@@ -278,7 +298,12 @@ test.describe('Autocomplete', () => {
data-lexical-text="true">
Test
-
+
+ imonials (TAB)
+
`,
);
diff --git a/packages/lexical-playground/__tests__/e2e/CodeBlock.spec.mjs b/packages/lexical-playground/__tests__/e2e/CodeBlock.spec.mjs
index 34dd9972778..dab99d16c5d 100644
--- a/packages/lexical-playground/__tests__/e2e/CodeBlock.spec.mjs
+++ b/packages/lexical-playground/__tests__/e2e/CodeBlock.spec.mjs
@@ -366,7 +366,9 @@ test.describe('CodeBlock', () => {
;
-
+
@@ -393,7 +395,9 @@ test.describe('CodeBlock', () => {
;
-
+
@@ -453,7 +457,9 @@ test.describe('CodeBlock', () => {
{
-
+
@@ -501,8 +507,12 @@ test.describe('CodeBlock', () => {
data-gutter="123"
data-highlight-language="javascript"
data-language="javascript">
-
-
+
+
@@ -527,9 +537,15 @@ test.describe('CodeBlock', () => {
{
-
-
-
+
+
+
@@ -551,8 +567,12 @@ test.describe('CodeBlock', () => {
;
-
-
+
+
@@ -575,7 +595,9 @@ test.describe('CodeBlock', () => {
data-gutter="123"
data-highlight-language="javascript"
data-language="javascript">
-
+
@@ -600,8 +622,12 @@ test.describe('CodeBlock', () => {
{
-
-
+
+
@@ -623,7 +649,9 @@ test.describe('CodeBlock', () => {
;
-
+
@@ -913,10 +941,10 @@ test.describe('CodeBlock', () => {
data-gutter="12"
data-language="javascript"
data-highlight-language="javascript">
-
+
a b
-
+
c d
`,
diff --git a/packages/lexical-playground/__tests__/e2e/KeyboardShortcuts.spec.mjs b/packages/lexical-playground/__tests__/e2e/KeyboardShortcuts.spec.mjs
index 989a4bbbeb8..60997995ceb 100644
--- a/packages/lexical-playground/__tests__/e2e/KeyboardShortcuts.spec.mjs
+++ b/packages/lexical-playground/__tests__/e2e/KeyboardShortcuts.spec.mjs
@@ -23,14 +23,17 @@ import {
selectCharacters,
toggleBold,
toggleBulletList,
+ toggleCapitalize,
toggleChecklist,
toggleInsertCodeBlock,
toggleItalic,
+ toggleLowercase,
toggleNumberedList,
toggleStrikethrough,
toggleSubscript,
toggleSuperscript,
toggleUnderline,
+ toggleUppercase,
} from '../keyboardShortcuts/index.mjs';
import {
assertHTML,
@@ -112,6 +115,18 @@ const alignmentTestCases = [
];
const additionalStylesTestCases = [
+ {
+ applyShortcut: (page) => toggleLowercase(page),
+ style: 'Lowercase',
+ },
+ {
+ applyShortcut: (page) => toggleUppercase(page),
+ style: 'Uppercase',
+ },
+ {
+ applyShortcut: (page) => toggleCapitalize(page),
+ style: 'Capitalize',
+ },
{
applyShortcut: (page) => toggleStrikethrough(page),
style: 'Strikethrough',
diff --git a/packages/lexical-playground/__tests__/e2e/List.spec.mjs b/packages/lexical-playground/__tests__/e2e/List.spec.mjs
index 8c1703c15c9..df81baee7a2 100644
--- a/packages/lexical-playground/__tests__/e2e/List.spec.mjs
+++ b/packages/lexical-playground/__tests__/e2e/List.spec.mjs
@@ -17,6 +17,7 @@ import {
redo,
selectAll,
selectCharacters,
+ toggleBold,
undo,
} from '../keyboardShortcuts/index.mjs';
import {
@@ -28,10 +29,10 @@ import {
focusEditor,
html,
initialize,
- IS_LINUX,
pasteFromClipboard,
repeat,
selectFromAlignDropdown,
+ selectFromColorPicker,
selectFromFormatDropdown,
test,
waitForSelector,
@@ -72,60 +73,62 @@ test.beforeEach(({isPlainText}) => {
test.describe.parallel('Nested List', () => {
test.beforeEach(({isCollab, page}) => initialize({isCollab, page}));
- test(`Can create a list and partially copy some content out of it`, async ({
- page,
- isCollab,
- }) => {
- test.fixme(isCollab && IS_LINUX, 'Flaky on Linux + Collab');
- await focusEditor(page);
- await page.keyboard.type(
- 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam venenatis risus ac cursus efficitur. Cras efficitur magna odio, lacinia posuere mauris placerat in. Etiam eu congue nisl. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nulla vulputate justo id eros convallis, vel pellentesque orci hendrerit. Pellentesque accumsan molestie eros, vitae tempor nisl semper sit amet. Sed vulputate leo dolor, et bibendum quam feugiat eget. Praesent vestibulum libero sed enim ornare, in consequat dui posuere. Maecenas ornare vestibulum felis, non elementum urna imperdiet sit amet.',
- );
- await toggleBulletList(page);
- await moveToEditorBeginning(page);
- await moveRight(page, 6);
- await selectCharacters(page, 'right', 11);
+ test(
+ `Can create a list and partially copy some content out of it`,
+ {
+ tag: '@flaky',
+ },
+ async ({page, isCollab}) => {
+ await focusEditor(page);
+ await page.keyboard.type(
+ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam venenatis risus ac cursus efficitur. Cras efficitur magna odio, lacinia posuere mauris placerat in. Etiam eu congue nisl. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nulla vulputate justo id eros convallis, vel pellentesque orci hendrerit. Pellentesque accumsan molestie eros, vitae tempor nisl semper sit amet. Sed vulputate leo dolor, et bibendum quam feugiat eget. Praesent vestibulum libero sed enim ornare, in consequat dui posuere. Maecenas ornare vestibulum felis, non elementum urna imperdiet sit amet.',
+ );
+ await toggleBulletList(page);
+ await moveToEditorBeginning(page);
+ await moveRight(page, 6);
+ await selectCharacters(page, 'right', 11);
- await withExclusiveClipboardAccess(async () => {
- const clipboard = await copyToClipboard(page);
+ await withExclusiveClipboardAccess(async () => {
+ const clipboard = await copyToClipboard(page);
- await moveToEditorEnd(page);
- await page.keyboard.press('Enter');
- await page.keyboard.press('Enter');
+ await moveToEditorEnd(page);
+ await page.keyboard.press('Enter');
+ await page.keyboard.press('Enter');
- await pasteFromClipboard(page, clipboard);
- });
+ await pasteFromClipboard(page, clipboard);
+ });
- await assertHTML(
- page,
- html`
-
-
-
- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam
- venenatis risus ac cursus efficitur. Cras efficitur magna odio,
- lacinia posuere mauris placerat in. Etiam eu congue nisl.
- Vestibulum ante ipsum primis in faucibus orci luctus et ultrices
- posuere cubilia curae; Nulla vulputate justo id eros convallis,
- vel pellentesque orci hendrerit. Pellentesque accumsan molestie
- eros, vitae tempor nisl semper sit amet. Sed vulputate leo dolor,
- et bibendum quam feugiat eget. Praesent vestibulum libero sed enim
- ornare, in consequat dui posuere. Maecenas ornare vestibulum
- felis, non elementum urna imperdiet sit amet.
-
-
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam
+ venenatis risus ac cursus efficitur. Cras efficitur magna odio,
+ lacinia posuere mauris placerat in. Etiam eu congue nisl.
+ Vestibulum ante ipsum primis in faucibus orci luctus et ultrices
+ posuere cubilia curae; Nulla vulputate justo id eros convallis,
+ vel pellentesque orci hendrerit. Pellentesque accumsan molestie
+ eros, vitae tempor nisl semper sit amet. Sed vulputate leo
+ dolor, et bibendum quam feugiat eget. Praesent vestibulum libero
+ sed enim ornare, in consequat dui posuere. Maecenas ornare
+ vestibulum felis, non elementum urna imperdiet sit amet.
+
+
+ {SHORTCUTS.CAPITALIZE}
+ {
activeEditor.dispatchCommand(
diff --git a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css
index 527085b7539..b5306f0b691 100644
--- a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css
+++ b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css
@@ -57,12 +57,56 @@
.PlaygroundEditorTheme__textUnderline {
text-decoration: underline;
}
+
.PlaygroundEditorTheme__textStrikethrough {
text-decoration: line-through;
}
+
.PlaygroundEditorTheme__textUnderlineStrikethrough {
text-decoration: underline line-through;
}
+
+.PlaygroundEditorTheme__tabNode {
+ position: relative;
+ text-decoration: none;
+}
+
+.PlaygroundEditorTheme__tabNode.PlaygroundEditorTheme__textUnderline::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0.15em;
+ border-bottom: 0.1em solid currentColor;
+}
+
+.PlaygroundEditorTheme__tabNode.PlaygroundEditorTheme__textStrikethrough::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 0.69em;
+ border-top: 0.1em solid currentColor;
+}
+
+.PlaygroundEditorTheme__tabNode.PlaygroundEditorTheme__textUnderlineStrikethrough::before,
+.PlaygroundEditorTheme__tabNode.PlaygroundEditorTheme__textUnderlineStrikethrough::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ right: 0;
+}
+
+.PlaygroundEditorTheme__tabNode.PlaygroundEditorTheme__textUnderlineStrikethrough::before {
+ top: 0.69em;
+ border-top: 0.1em solid currentColor;
+}
+
+.PlaygroundEditorTheme__tabNode.PlaygroundEditorTheme__textUnderlineStrikethrough::after {
+ bottom: 0.05em;
+ border-bottom: 0.1em solid currentColor;
+}
+
.PlaygroundEditorTheme__textSubscript {
font-size: 0.8em;
vertical-align: sub !important;
@@ -77,6 +121,15 @@
font-family: Menlo, Consolas, Monaco, monospace;
font-size: 94%;
}
+.PlaygroundEditorTheme__textLowercase {
+ text-transform: lowercase;
+}
+.PlaygroundEditorTheme__textUppercase {
+ text-transform: uppercase;
+}
+.PlaygroundEditorTheme__textCapitalize {
+ text-transform: capitalize;
+}
.PlaygroundEditorTheme__hashtag {
background-color: rgba(88, 144, 255, 0.15);
border-bottom: 1px solid rgba(88, 144, 255, 0.3);
@@ -89,6 +142,25 @@
text-decoration: underline;
cursor: pointer;
}
+.PlaygroundEditorTheme__blockCursor {
+ display: block;
+ pointer-events: none;
+ position: absolute;
+}
+.PlaygroundEditorTheme__blockCursor:after {
+ content: '';
+ display: block;
+ position: absolute;
+ top: -2px;
+ width: 20px;
+ border-top: 1px solid black;
+ animation: CursorBlink 1.1s steps(2, start) infinite;
+}
+@keyframes CursorBlink {
+ to {
+ visibility: hidden;
+ }
+}
.PlaygroundEditorTheme__code {
background-color: rgb(240, 242, 245);
font-family: Menlo, Consolas, Monaco, monospace;
@@ -150,6 +222,14 @@
padding: 6px 8px;
position: relative;
outline: none;
+ overflow: auto;
+}
+/*
+ A firefox workaround to allow scrolling of overflowing table cell
+ ref: https://bugzilla.mozilla.org/show_bug.cgi?id=1904159
+*/
+.PlaygroundEditorTheme__tableCell > * {
+ overflow: inherit;
}
.PlaygroundEditorTheme__tableCellResizer {
position: absolute;
diff --git a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.ts b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.ts
index 0b45916782b..e7c6a4aab7e 100644
--- a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.ts
+++ b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.ts
@@ -91,6 +91,7 @@ const theme: EditorThemeClasses = {
quote: 'PlaygroundEditorTheme__quote',
rtl: 'PlaygroundEditorTheme__rtl',
specialText: 'PlaygroundEditorTheme__specialText',
+ tab: 'PlaygroundEditorTheme__tabNode',
table: 'PlaygroundEditorTheme__table',
tableCell: 'PlaygroundEditorTheme__tableCell',
tableCellActionButton: 'PlaygroundEditorTheme__tableCellActionButton',
@@ -105,13 +106,16 @@ const theme: EditorThemeClasses = {
tableSelection: 'PlaygroundEditorTheme__tableSelection',
text: {
bold: 'PlaygroundEditorTheme__textBold',
+ capitalize: 'PlaygroundEditorTheme__textCapitalize',
code: 'PlaygroundEditorTheme__textCode',
italic: 'PlaygroundEditorTheme__textItalic',
+ lowercase: 'PlaygroundEditorTheme__textLowercase',
strikethrough: 'PlaygroundEditorTheme__textStrikethrough',
subscript: 'PlaygroundEditorTheme__textSubscript',
superscript: 'PlaygroundEditorTheme__textSuperscript',
underline: 'PlaygroundEditorTheme__textUnderline',
underlineStrikethrough: 'PlaygroundEditorTheme__textUnderlineStrikethrough',
+ uppercase: 'PlaygroundEditorTheme__textUppercase',
},
};
diff --git a/packages/lexical-playground/src/ui/DropDown.tsx b/packages/lexical-playground/src/ui/DropDown.tsx
index 012d4674b9e..19d3065df0e 100644
--- a/packages/lexical-playground/src/ui/DropDown.tsx
+++ b/packages/lexical-playground/src/ui/DropDown.tsx
@@ -6,6 +6,7 @@
*
*/
+import {isDOMNode} from 'lexical';
import * as React from 'react';
import {
ReactNode,
@@ -189,15 +190,15 @@ export default function DropDown({
if (button !== null && showDropDown) {
const handle = (event: MouseEvent) => {
const target = event.target;
+ if (!isDOMNode(target)) {
+ return;
+ }
if (stopCloseOnClickSelf) {
- if (
- dropDownRef.current &&
- dropDownRef.current.contains(target as Node)
- ) {
+ if (dropDownRef.current && dropDownRef.current.contains(target)) {
return;
}
}
- if (!button.contains(target as Node)) {
+ if (!button.contains(target)) {
setShowDropDown(false);
}
};
diff --git a/packages/lexical-playground/src/ui/EquationEditor.tsx b/packages/lexical-playground/src/ui/EquationEditor.tsx
index d8f9aec527c..ce512aed010 100644
--- a/packages/lexical-playground/src/ui/EquationEditor.tsx
+++ b/packages/lexical-playground/src/ui/EquationEditor.tsx
@@ -10,7 +10,7 @@ import type {Ref, RefObject} from 'react';
import './EquationEditor.css';
-import * as React from 'react';
+import {isHTMLElement} from 'lexical';
import {ChangeEvent, forwardRef} from 'react';
type BaseEquationEditorProps = {
@@ -27,7 +27,7 @@ function EquationEditor(
setEquation((event.target as HTMLInputElement).value);
};
- return inline && forwardedRef instanceof HTMLInputElement ? (
+ return inline && isHTMLElement(forwardedRef) ? (
$
void) {
const target = event.target;
- if (target === null || !isHTMLElement(target)) {
+ if (!isHTMLElement(target)) {
return;
}
@@ -173,7 +173,6 @@ function handleCheckItemEvent(event: PointerEvent, callback: () => void) {
const firstChild = target.firstChild;
if (
- firstChild != null &&
isHTMLElement(firstChild) &&
(firstChild.tagName === 'UL' || firstChild.tagName === 'OL')
) {
@@ -200,7 +199,7 @@ function handleCheckItemEvent(event: PointerEvent, callback: () => void) {
function handleClick(event: Event) {
handleCheckItemEvent(event as PointerEvent, () => {
- if (event.target instanceof HTMLElement) {
+ if (isHTMLElement(event.target)) {
const domNode = event.target;
const editor = getNearestEditorFromDOMNode(domNode);
@@ -226,9 +225,9 @@ function handlePointerDown(event: PointerEvent) {
}
function getActiveCheckListItem(): HTMLElement | null {
- const activeElement = document.activeElement as HTMLElement;
+ const activeElement = document.activeElement;
- return activeElement != null &&
+ return isHTMLElement(activeElement) &&
activeElement.tagName === 'LI' &&
activeElement.parentNode != null &&
// @ts-ignore internal field
diff --git a/packages/lexical-react/src/LexicalClickableLinkPlugin.tsx b/packages/lexical-react/src/LexicalClickableLinkPlugin.tsx
index 3c5e56c36e2..dd76a0b30a5 100644
--- a/packages/lexical-react/src/LexicalClickableLinkPlugin.tsx
+++ b/packages/lexical-react/src/LexicalClickableLinkPlugin.tsx
@@ -15,6 +15,7 @@ import {
$isElementNode,
$isRangeSelection,
getNearestEditorFromDOMNode,
+ isDOMNode,
} from 'lexical';
import {useEffect} from 'react';
@@ -44,7 +45,7 @@ export function ClickableLinkPlugin({
useEffect(() => {
const onClick = (event: MouseEvent) => {
const target = event.target;
- if (!(target instanceof Node)) {
+ if (!isDOMNode(target)) {
return;
}
const nearestEditor = getNearestEditorFromDOMNode(target);
diff --git a/packages/lexical-react/src/LexicalContextMenuPlugin.tsx b/packages/lexical-react/src/LexicalContextMenuPlugin.tsx
index c105d48b8f1..7f1676f3cf9 100644
--- a/packages/lexical-react/src/LexicalContextMenuPlugin.tsx
+++ b/packages/lexical-react/src/LexicalContextMenuPlugin.tsx
@@ -12,6 +12,7 @@ import {calculateZoomLevel} from '@lexical/utils';
import {
COMMAND_PRIORITY_LOW,
CommandListenerPriority,
+ isDOMNode,
LexicalNode,
} from 'lexical';
import {
@@ -122,7 +123,8 @@ export function LexicalContextMenuPlugin({
resolution !== null &&
menuRef.current != null &&
event.target != null &&
- !menuRef.current.contains(event.target as Node)
+ isDOMNode(event.target) &&
+ !menuRef.current.contains(event.target)
) {
closeNodeMenu();
}
diff --git a/packages/lexical-react/src/LexicalDraggableBlockPlugin.tsx b/packages/lexical-react/src/LexicalDraggableBlockPlugin.tsx
index d37bd69f3c9..be695b76b23 100644
--- a/packages/lexical-react/src/LexicalDraggableBlockPlugin.tsx
+++ b/packages/lexical-react/src/LexicalDraggableBlockPlugin.tsx
@@ -191,9 +191,15 @@ function setMenuPosition(
const floatingElemRect = floatingElem.getBoundingClientRect();
const anchorElementRect = anchorElem.getBoundingClientRect();
+ // top left
+ let targetCalculateHeight: number = parseInt(targetStyle.lineHeight, 10);
+ if (isNaN(targetCalculateHeight)) {
+ // middle
+ targetCalculateHeight = targetRect.bottom - targetRect.top;
+ }
const top =
targetRect.top +
- (parseInt(targetStyle.lineHeight, 10) - floatingElemRect.height) / 2 -
+ (targetCalculateHeight - floatingElemRect.height) / 2 -
anchorElementRect.top;
const left = SPACE;
@@ -271,12 +277,12 @@ function useDraggableBlockMenu(
useEffect(() => {
function onMouseMove(event: MouseEvent) {
const target = event.target;
- if (target != null && !isHTMLElement(target)) {
+ if (!isHTMLElement(target)) {
setDraggableBlockElem(null);
return;
}
- if (target != null && isOnMenu(target as HTMLElement)) {
+ if (isOnMenu(target as HTMLElement)) {
return;
}
@@ -318,7 +324,7 @@ function useDraggableBlockMenu(
return false;
}
const {pageY, target} = event;
- if (target != null && !isHTMLElement(target)) {
+ if (!isHTMLElement(target)) {
return false;
}
const targetBlockElem = getBlockElement(anchorElem, editor, event, true);
@@ -352,7 +358,7 @@ function useDraggableBlockMenu(
if (!draggedNode) {
return false;
}
- if (target != null && !isHTMLElement(target)) {
+ if (!isHTMLElement(target)) {
return false;
}
const targetBlockElem = getBlockElement(anchorElem, editor, event, true);
diff --git a/packages/lexical-react/src/LexicalTablePlugin.ts b/packages/lexical-react/src/LexicalTablePlugin.ts
index a5c43d17c65..7d91b0b576c 100644
--- a/packages/lexical-react/src/LexicalTablePlugin.ts
+++ b/packages/lexical-react/src/LexicalTablePlugin.ts
@@ -6,43 +6,15 @@
*
*/
-import type {
- HTMLTableElementWithWithTableSelectionState,
- InsertTableCommandPayload,
- TableObserver,
-} from '@lexical/table';
-import type {NodeKey} from 'lexical';
-
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {
- $computeTableMap,
- $computeTableMapSkipCellCheck,
- $createTableCellNode,
- $createTableNodeWithDimensions,
- $getNodeTriplet,
- $getTableAndElementByKey,
- $isTableCellNode,
- $isTableRowNode,
- applyTableHandlers,
- getTableElement,
- INSERT_TABLE_COMMAND,
+ registerTableCellUnmergeTransform,
+ registerTablePlugin,
+ registerTableSelectionObserver,
setScrollableTablesActive,
TableCellNode,
- TableNode,
- TableRowNode,
} from '@lexical/table';
-import {
- $insertFirst,
- $insertNodeToNearestRoot,
- mergeRegister,
-} from '@lexical/utils';
-import {
- $createParagraphNode,
- $isTextNode,
- COMMAND_PRIORITY_EDITOR,
-} from 'lexical';
import {useEffect} from 'react';
-import invariant from 'shared/invariant';
export interface TablePluginProps {
/**
@@ -82,181 +54,18 @@ export function TablePlugin({
setScrollableTablesActive(editor, hasHorizontalScroll);
}, [editor, hasHorizontalScroll]);
- useEffect(() => {
- if (!editor.hasNodes([TableNode, TableCellNode, TableRowNode])) {
- invariant(
- false,
- 'TablePlugin: TableNode, TableCellNode or TableRowNode not registered on editor',
- );
- }
-
- return mergeRegister(
- editor.registerCommand(
- INSERT_TABLE_COMMAND,
- ({columns, rows, includeHeaders}) => {
- const tableNode = $createTableNodeWithDimensions(
- Number(rows),
- Number(columns),
- includeHeaders,
- );
- $insertNodeToNearestRoot(tableNode);
-
- const firstDescendant = tableNode.getFirstDescendant();
- if ($isTextNode(firstDescendant)) {
- firstDescendant.select();
- }
-
- return true;
- },
- COMMAND_PRIORITY_EDITOR,
- ),
- editor.registerNodeTransform(TableNode, (node) => {
- const [gridMap] = $computeTableMapSkipCellCheck(node, null, null);
- const maxRowLength = gridMap.reduce((curLength, row) => {
- return Math.max(curLength, row.length);
- }, 0);
- const rowNodes = node.getChildren();
- for (let i = 0; i < gridMap.length; ++i) {
- const rowNode = rowNodes[i];
- if (!rowNode) {
- continue;
- }
- const rowLength = gridMap[i].reduce(
- (acc, cell) => (cell ? 1 + acc : acc),
- 0,
- );
- if (rowLength === maxRowLength) {
- continue;
- }
- for (let j = rowLength; j < maxRowLength; ++j) {
- // TODO: inherit header state from another header or body
- const newCell = $createTableCellNode(0);
- newCell.append($createParagraphNode());
- (rowNode as TableRowNode).append(newCell);
- }
- }
- }),
- );
- }, [editor]);
+ useEffect(() => registerTablePlugin(editor), [editor]);
- useEffect(() => {
- const tableSelections = new Map<
- NodeKey,
- [TableObserver, HTMLTableElementWithWithTableSelectionState]
- >();
-
- const initializeTableNode = (
- tableNode: TableNode,
- nodeKey: NodeKey,
- dom: HTMLElement,
- ) => {
- const tableElement = getTableElement(tableNode, dom);
- const tableSelection = applyTableHandlers(
- tableNode,
- tableElement,
- editor,
- hasTabHandler,
- );
- tableSelections.set(nodeKey, [tableSelection, tableElement]);
- };
-
- const unregisterMutationListener = editor.registerMutationListener(
- TableNode,
- (nodeMutations) => {
- editor.getEditorState().read(
- () => {
- for (const [nodeKey, mutation] of nodeMutations) {
- const tableSelection = tableSelections.get(nodeKey);
- if (mutation === 'created' || mutation === 'updated') {
- const {tableNode, tableElement} =
- $getTableAndElementByKey(nodeKey);
- if (tableSelection === undefined) {
- initializeTableNode(tableNode, nodeKey, tableElement);
- } else if (tableElement !== tableSelection[1]) {
- // The update created a new DOM node, destroy the existing TableObserver
- tableSelection[0].removeListeners();
- tableSelections.delete(nodeKey);
- initializeTableNode(tableNode, nodeKey, tableElement);
- }
- } else if (mutation === 'destroyed') {
- if (tableSelection !== undefined) {
- tableSelection[0].removeListeners();
- tableSelections.delete(nodeKey);
- }
- }
- }
- },
- {editor},
- );
- },
- {skipInitialization: false},
- );
-
- return () => {
- unregisterMutationListener();
- // Hook might be called multiple times so cleaning up tables listeners as well,
- // as it'll be reinitialized during recurring call
- for (const [, [tableSelection]] of tableSelections) {
- tableSelection.removeListeners();
- }
- };
- }, [editor, hasTabHandler]);
+ useEffect(
+ () => registerTableSelectionObserver(editor, hasTabHandler),
+ [editor, hasTabHandler],
+ );
// Unmerge cells when the feature isn't enabled
useEffect(() => {
- if (hasCellMerge) {
- return;
+ if (!hasCellMerge) {
+ return registerTableCellUnmergeTransform(editor);
}
- return editor.registerNodeTransform(TableCellNode, (node) => {
- if (node.getColSpan() > 1 || node.getRowSpan() > 1) {
- // When we have rowSpan we have to map the entire Table to understand where the new Cells
- // fit best; let's analyze all Cells at once to save us from further transform iterations
- const [, , gridNode] = $getNodeTriplet(node);
- const [gridMap] = $computeTableMap(gridNode, node, node);
- // TODO this function expects Tables to be normalized. Look into this once it exists
- const rowsCount = gridMap.length;
- const columnsCount = gridMap[0].length;
- let row = gridNode.getFirstChild();
- invariant(
- $isTableRowNode(row),
- 'Expected TableNode first child to be a RowNode',
- );
- const unmerged = [];
- for (let i = 0; i < rowsCount; i++) {
- if (i !== 0) {
- row = row.getNextSibling();
- invariant(
- $isTableRowNode(row),
- 'Expected TableNode first child to be a RowNode',
- );
- }
- let lastRowCell: null | TableCellNode = null;
- for (let j = 0; j < columnsCount; j++) {
- const cellMap = gridMap[i][j];
- const cell = cellMap.cell;
- if (cellMap.startRow === i && cellMap.startColumn === j) {
- lastRowCell = cell;
- unmerged.push(cell);
- } else if (cell.getColSpan() > 1 || cell.getRowSpan() > 1) {
- invariant(
- $isTableCellNode(cell),
- 'Expected TableNode cell to be a TableCellNode',
- );
- const newCell = $createTableCellNode(cell.__headerState);
- if (lastRowCell !== null) {
- lastRowCell.insertAfter(newCell);
- } else {
- $insertFirst(row, newCell);
- }
- }
- }
- }
- for (const cell of unmerged) {
- cell.setColSpan(1);
- cell.setRowSpan(1);
- }
- }
- });
}, [editor, hasCellMerge]);
// Remove cell background color when feature is disabled
diff --git a/packages/lexical-react/src/LexicalTreeView.tsx b/packages/lexical-react/src/LexicalTreeView.tsx
index db536de589b..1b7057b27ef 100644
--- a/packages/lexical-react/src/LexicalTreeView.tsx
+++ b/packages/lexical-react/src/LexicalTreeView.tsx
@@ -18,13 +18,31 @@ import {mergeRegister} from '@lexical/utils';
import * as React from 'react';
import {useEffect, useState} from 'react';
+/**
+ * TreeView is a React component that provides a visual representation of
+ * the Lexical editor's state and enables debugging features like time travel
+ * and custom tree node rendering.
+ *
+ * @param {Object} props - The properties passed to the TreeView component.
+ * @param {LexicalEditor} props.editor - The Lexical editor instance to be visualized and debugged.
+ * @param {string} [props.treeTypeButtonClassName] - Custom class name for the tree type toggle button.
+ * @param {string} [props.timeTravelButtonClassName] - Custom class name for the time travel toggle button.
+ * @param {string} [props.timeTravelPanelButtonClassName] - Custom class name for buttons inside the time travel panel.
+ * @param {string} [props.timeTravelPanelClassName] - Custom class name for the overall time travel panel container.
+ * @param {string} [props.timeTravelPanelSliderClassName] - Custom class name for the time travel slider in the panel.
+ * @param {string} [props.viewClassName] - Custom class name for the tree view container.
+ * @param {CustomPrintNodeFn} [props.customPrintNode] - A function for customizing the display of nodes in the tree.
+ *
+ * @returns {JSX.Element} - A React element that visualizes the editor's state and supports debugging interactions.
+ */
+
export function TreeView({
treeTypeButtonClassName,
timeTravelButtonClassName,
timeTravelPanelSliderClassName,
timeTravelPanelButtonClassName,
- viewClassName,
timeTravelPanelClassName,
+ viewClassName,
editor,
customPrintNode,
}: {
@@ -38,6 +56,7 @@ export function TreeView({
customPrintNode?: CustomPrintNodeFn;
}): JSX.Element {
const treeElementRef = React.createRef();
+
const [editorCurrentState, setEditorCurrentState] = useState(
editor.getEditorState(),
);
@@ -45,6 +64,7 @@ export function TreeView({
const commandsLog = useLexicalCommandsLog(editor);
useEffect(() => {
+ // Registers listeners to update the tree view when the editor state changes
return mergeRegister(
editor.registerUpdateListener(({editorState}) => {
setEditorCurrentState(editorState);
@@ -59,16 +79,23 @@ export function TreeView({
const element = treeElementRef.current;
if (element !== null) {
- // @ts-ignore Internal field
+ // Assigns the editor instance to the tree view DOM element for internal tracking
+ // @ts-ignore Internal field used by Lexical
element.__lexicalEditor = editor;
return () => {
- // @ts-ignore Internal field
+ // Cleans up the reference when the component is unmounted
+ // @ts-ignore Internal field used by Lexical
element.__lexicalEditor = null;
};
}
}, [editor, treeElementRef]);
+ /**
+ * Handles toggling the readonly state of the editor.
+ *
+ * @param {boolean} isReadonly - Whether the editor should be set to readonly.
+ */
const handleEditorReadOnly = (isReadonly: boolean) => {
const rootElement = editor.getRootElement();
if (rootElement == null) {
@@ -90,6 +117,7 @@ export function TreeView({
editorState={editorCurrentState}
setEditorState={(state) => editor.setEditorState(state)}
generateContent={async function (exportDOM) {
+ // Generates the content for the tree view, allowing customization with exportDOM and customPrintNode
return generateContent(editor, commandsLog, exportDOM, customPrintNode);
}}
ref={treeElementRef}
diff --git a/packages/lexical-react/src/shared/useCharacterLimit.ts b/packages/lexical-react/src/shared/useCharacterLimit.ts
index 8e1e4f813c0..75d8e58040b 100644
--- a/packages/lexical-react/src/shared/useCharacterLimit.ts
+++ b/packages/lexical-react/src/shared/useCharacterLimit.ts
@@ -14,7 +14,7 @@ import {
OverflowNode,
} from '@lexical/overflow';
import {$rootTextContent} from '@lexical/text';
-import {$dfs, mergeRegister} from '@lexical/utils';
+import {$dfs, $unwrapNode, mergeRegister} from '@lexical/utils';
import {
$getSelection,
$isElementNode,
@@ -254,18 +254,6 @@ function $wrapNode(node: LexicalNode): OverflowNode {
return overflowNode;
}
-function $unwrapNode(node: OverflowNode): LexicalNode | null {
- const children = node.getChildren();
- const childrenLength = children.length;
-
- for (let i = 0; i < childrenLength; i++) {
- node.insertBefore(children[i]);
- }
-
- node.remove();
- return childrenLength > 0 ? children[childrenLength - 1] : null;
-}
-
export function $mergePrevious(overflowNode: OverflowNode): void {
const previousNode = overflowNode.getPreviousSibling();
diff --git a/packages/lexical-rich-text/flow/LexicalRichText.js.flow b/packages/lexical-rich-text/flow/LexicalRichText.js.flow
index 0751c17fc5f..9b7bbb0c803 100644
--- a/packages/lexical-rich-text/flow/LexicalRichText.js.flow
+++ b/packages/lexical-rich-text/flow/LexicalRichText.js.flow
@@ -25,7 +25,6 @@ declare export class QuoteNode extends ElementNode {
static clone(node: QuoteNode): QuoteNode;
constructor(key?: NodeKey): void;
createDOM(config: EditorConfig): HTMLElement;
- updateDOM(prevNode: QuoteNode, dom: HTMLElement): boolean;
insertNewAfter(
selection: RangeSelection,
restoreSelection?: boolean,
@@ -44,7 +43,6 @@ declare export class HeadingNode extends ElementNode {
constructor(tag: HeadingTagType, key?: NodeKey): void;
getTag(): HeadingTagType;
createDOM(config: EditorConfig): HTMLElement;
- updateDOM(prevNode: HeadingNode, dom: HTMLElement): boolean;
static importDOM(): DOMConversionMap | null;
insertNewAfter(
selection: RangeSelection,
diff --git a/packages/lexical-rich-text/src/index.ts b/packages/lexical-rich-text/src/index.ts
index cec5da17fa7..7e39f5631dd 100644
--- a/packages/lexical-rich-text/src/index.ts
+++ b/packages/lexical-rich-text/src/index.ts
@@ -79,6 +79,7 @@ import {
INSERT_LINE_BREAK_COMMAND,
INSERT_PARAGRAPH_COMMAND,
INSERT_TAB_COMMAND,
+ isDOMNode,
isSelectionCapturedInDecoratorInput,
KEY_ARROW_DOWN_COMMAND,
KEY_ARROW_LEFT_COMMAND,
@@ -88,6 +89,8 @@ import {
KEY_DELETE_COMMAND,
KEY_ENTER_COMMAND,
KEY_ESCAPE_COMMAND,
+ KEY_SPACE_COMMAND,
+ KEY_TAB_COMMAND,
OUTDENT_CONTENT_COMMAND,
PASTE_COMMAND,
REMOVE_TEXT_COMMAND,
@@ -136,7 +139,7 @@ export class QuoteNode extends ElementNode {
addClassNamesToElement(element, config.theme.quote);
return element;
}
- updateDOM(prevNode: QuoteNode, dom: HTMLElement): boolean {
+ updateDOM(prevNode: this, dom: HTMLElement): boolean {
return false;
}
@@ -152,7 +155,7 @@ export class QuoteNode extends ElementNode {
exportDOM(editor: LexicalEditor): DOMExportOutput {
const {element} = super.exportDOM(editor);
- if (element && isHTMLElement(element)) {
+ if (isHTMLElement(element)) {
if (this.isEmpty()) {
element.append(document.createElement('br'));
}
@@ -257,7 +260,7 @@ export class HeadingNode extends ElementNode {
return element;
}
- updateDOM(prevNode: HeadingNode, dom: HTMLElement): boolean {
+ updateDOM(prevNode: this, dom: HTMLElement): boolean {
return false;
}
@@ -318,7 +321,7 @@ export class HeadingNode extends ElementNode {
exportDOM(editor: LexicalEditor): DOMExportOutput {
const {element} = super.exportDOM(editor);
- if (element && isHTMLElement(element)) {
+ if (isHTMLElement(element)) {
if (this.isEmpty()) {
element.append(document.createElement('br'));
}
@@ -549,6 +552,19 @@ function $isSelectionAtEndOfRoot(selection: RangeSelection) {
return focus.key === 'root' && focus.offset === $getRoot().getChildrenSize();
}
+/**
+ * Resets the capitalization of the selection to default.
+ * Called when the user presses space, tab, or enter key.
+ * @param selection The selection to reset the capitalization of.
+ */
+function $resetCapitalization(selection: RangeSelection): void {
+ for (const format of ['lowercase', 'uppercase', 'capitalize'] as const) {
+ if (selection.hasFormat(format)) {
+ selection.toggleFormat(format);
+ }
+ }
+}
+
export function registerRichText(editor: LexicalEditor): () => void {
const removeListener = mergeRegister(
editor.registerCommand(
@@ -909,6 +925,9 @@ export function registerRichText(editor: LexicalEditor): () => void {
if (!$isRangeSelection(selection)) {
return false;
}
+
+ $resetCapitalization(selection);
+
if (event !== null) {
// If we have beforeinput, then we can avoid blocking
// the default behavior. This ensures that the iOS can
@@ -1060,7 +1079,10 @@ export function registerRichText(editor: LexicalEditor): () => void {
}
// if inputs then paste within the input ignore creating a new node on paste event
- if (isSelectionCapturedInDecoratorInput(event.target as Node)) {
+ if (
+ isDOMNode(event.target) &&
+ isSelectionCapturedInDecoratorInput(event.target)
+ ) {
return false;
}
@@ -1074,6 +1096,32 @@ export function registerRichText(editor: LexicalEditor): () => void {
},
COMMAND_PRIORITY_EDITOR,
),
+ editor.registerCommand(
+ KEY_SPACE_COMMAND,
+ (_) => {
+ const selection = $getSelection();
+
+ if ($isRangeSelection(selection)) {
+ $resetCapitalization(selection);
+ }
+
+ return false;
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerCommand(
+ KEY_TAB_COMMAND,
+ (_) => {
+ const selection = $getSelection();
+
+ if ($isRangeSelection(selection)) {
+ $resetCapitalization(selection);
+ }
+
+ return false;
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
);
return removeListener;
}
diff --git a/packages/lexical-selection/src/index.ts b/packages/lexical-selection/src/index.ts
index d901ab4d4d9..8d9d47ce635 100644
--- a/packages/lexical-selection/src/index.ts
+++ b/packages/lexical-selection/src/index.ts
@@ -18,7 +18,6 @@ import {
$isParentElementRTL,
$moveCaretSelection,
$moveCharacter,
- $selectAll,
$setBlocksType,
$shouldOverrideDefaultCharacterSelection,
$wrapNodes,
@@ -32,7 +31,9 @@ import {
export {
/** @deprecated moved to the lexical package */ $cloneWithProperties,
+ /** @deprecated moved to the lexical package */ $selectAll,
} from 'lexical';
+
export {
$addNodeStyle,
$isAtNodeEnd,
@@ -48,7 +49,6 @@ export {
$isParentElementRTL,
$moveCaretSelection,
$moveCharacter,
- $selectAll,
$setBlocksType,
$shouldOverrideDefaultCharacterSelection,
$wrapNodes,
diff --git a/packages/lexical-selection/src/range-selection.ts b/packages/lexical-selection/src/range-selection.ts
index e92a81b6188..15b4e66c5c7 100644
--- a/packages/lexical-selection/src/range-selection.ts
+++ b/packages/lexical-selection/src/range-selection.ts
@@ -452,41 +452,6 @@ export function $moveCharacter(
);
}
-/**
- * Expands the current Selection to cover all of the content in the editor.
- * @param selection - The current selection.
- */
-export function $selectAll(selection: RangeSelection): void {
- const anchor = selection.anchor;
- const focus = selection.focus;
- const anchorNode = anchor.getNode();
- const topParent = anchorNode.getTopLevelElementOrThrow();
- const root = topParent.getParentOrThrow();
- let firstNode = root.getFirstDescendant();
- let lastNode = root.getLastDescendant();
- let firstType: 'element' | 'text' = 'element';
- let lastType: 'element' | 'text' = 'element';
- let lastOffset = 0;
-
- if ($isTextNode(firstNode)) {
- firstType = 'text';
- } else if (!$isElementNode(firstNode) && firstNode !== null) {
- firstNode = firstNode.getParentOrThrow();
- }
-
- if ($isTextNode(lastNode)) {
- lastType = 'text';
- lastOffset = lastNode.getTextContentSize();
- } else if (!$isElementNode(lastNode) && lastNode !== null) {
- lastNode = lastNode.getParentOrThrow();
- }
-
- if (firstNode && lastNode) {
- anchor.set(firstNode.getKey(), 0, firstType);
- focus.set(lastNode.getKey(), lastOffset, lastType);
- }
-}
-
/**
* Returns the current value of a CSS property for Nodes, if set. If not set, it returns the defaultValue.
* @param node - The node whose style value to get.
diff --git a/packages/lexical-table/flow/LexicalTable.js.flow b/packages/lexical-table/flow/LexicalTable.js.flow
index 2674a125f50..075314baf93 100644
--- a/packages/lexical-table/flow/LexicalTable.js.flow
+++ b/packages/lexical-table/flow/LexicalTable.js.flow
@@ -52,7 +52,6 @@ declare export class TableCellNode extends ElementNode {
key?: NodeKey,
): void;
createDOM(config: EditorConfig): HTMLElement;
- updateDOM(prevNode: TableCellNode, dom: HTMLElement): boolean;
insertNewAfter(
selection: RangeSelection,
): null | ParagraphNode | TableCellNode;
@@ -70,12 +69,11 @@ declare export class TableCellNode extends ElementNode {
setBackgroundColor(newBackgroundColor: null | string): TableCellNode;
toggleHeaderStyle(headerState: TableCellHeaderState): TableCellNode;
hasHeader(): boolean;
- updateDOM(prevNode: TableCellNode): boolean;
collapseAtStart(): true;
canBeEmpty(): false;
}
declare export function $createTableCellNode(
- headerState: TableCellHeaderState,
+ headerState?: TableCellHeaderState,
colSpan?: number,
width?: ?number,
): TableCellNode;
@@ -99,7 +97,6 @@ declare export class TableNode extends ElementNode {
static clone(node: TableNode): TableNode;
constructor(key?: NodeKey): void;
createDOM(config: EditorConfig): HTMLElement;
- updateDOM(prevNode: TableNode, dom: HTMLElement): boolean;
insertNewAfter(selection: RangeSelection): null | ParagraphNode | TableNode;
collapseAtStart(): true;
getCordsFromCellNode(
@@ -126,7 +123,6 @@ declare export class TableRowNode extends ElementNode {
static clone(node: TableRowNode): TableRowNode;
constructor(height?: ?number, key?: NodeKey): void;
createDOM(config: EditorConfig): HTMLElement;
- updateDOM(prevNode: TableRowNode, dom: HTMLElement): boolean;
setHeight(height: number): ?number;
getHeight(): ?number;
insertNewAfter(
@@ -350,4 +346,14 @@ export type InsertTableCommandPayload = $ReadOnly<{
includeHeaders?: InsertTableCommandPayloadHeaders;
}>;
-declare export var INSERT_TABLE_COMMAND: LexicalCommand;
\ No newline at end of file
+declare export var INSERT_TABLE_COMMAND: LexicalCommand;
+
+/**
+ * LexicalTablePluginHelpers
+ */
+
+declare export function registerTableCellUnmergeTransform(editor: LexicalEditor): () => void;
+
+declare export function registerTablePlugin(editor: LexicalEditor): () => void;
+
+declare export function registerTableSelectionObserver(editor: LexicalEditor, hasTabHandler?: boolean): () => void;
diff --git a/packages/lexical-table/src/LexicalTableCellNode.ts b/packages/lexical-table/src/LexicalTableCellNode.ts
index 2b00b8dfb6e..396fc6564d7 100644
--- a/packages/lexical-table/src/LexicalTableCellNode.ts
+++ b/packages/lexical-table/src/LexicalTableCellNode.ts
@@ -151,7 +151,7 @@ export class TableCellNode extends ElementNode {
exportDOM(editor: LexicalEditor): DOMExportOutput {
const output = super.exportDOM(editor);
- if (output.element && isHTMLElement(output.element)) {
+ if (isHTMLElement(output.element)) {
const element = output.element as HTMLTableCellElement;
element.setAttribute(
'data-temporary-table-cell-lexical-key',
@@ -265,7 +265,7 @@ export class TableCellNode extends ElementNode {
return this.getLatest().__headerState !== TableCellHeaderStates.NO_STATUS;
}
- updateDOM(prevNode: TableCellNode): boolean {
+ updateDOM(prevNode: this): boolean {
return (
prevNode.__headerState !== this.__headerState ||
prevNode.__width !== this.__width ||
@@ -366,7 +366,7 @@ export function $convertTableCellNodeElement(
}
export function $createTableCellNode(
- headerState: TableCellHeaderState,
+ headerState: TableCellHeaderState = TableCellHeaderStates.NO_STATUS,
colSpan = 1,
width?: number,
): TableCellNode {
diff --git a/packages/lexical-table/src/LexicalTableNode.ts b/packages/lexical-table/src/LexicalTableNode.ts
index 4a4a2c970fa..c404e822f32 100644
--- a/packages/lexical-table/src/LexicalTableNode.ts
+++ b/packages/lexical-table/src/LexicalTableNode.ts
@@ -6,9 +6,8 @@
*
*/
-import type {TableRowNode} from './LexicalTableRowNode';
-
import {
+ $descendantsMatching,
addClassNamesToElement,
isHTMLElement,
removeClassNamesFromElement,
@@ -36,6 +35,7 @@ import invariant from 'shared/invariant';
import {PIXEL_VALUE_REG_EXP} from './constants';
import {$isTableCellNode, type TableCellNode} from './LexicalTableCellNode';
import {TableDOMCell, TableDOMTable} from './LexicalTableObserver';
+import {$isTableRowNode, type TableRowNode} from './LexicalTableRowNode';
import {
$getNearestTableCellInTableFromDOMNode,
getTable,
@@ -225,11 +225,7 @@ export class TableNode extends ElementNode {
return tableElement;
}
- updateDOM(
- prevNode: TableNode,
- dom: HTMLElement,
- config: EditorConfig,
- ): boolean {
+ updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean {
if (prevNode.__rowStriping !== this.__rowStriping) {
setRowStriping(dom, config, this.__rowStriping);
}
@@ -245,14 +241,10 @@ export class TableNode extends ElementNode {
if (superExport.after) {
tableElement = superExport.after(tableElement);
}
- if (
- tableElement &&
- isHTMLElement(tableElement) &&
- tableElement.nodeName !== 'TABLE'
- ) {
+ if (isHTMLElement(tableElement) && tableElement.nodeName !== 'TABLE') {
tableElement = tableElement.querySelector('table');
}
- if (!tableElement || !isHTMLElement(tableElement)) {
+ if (!isHTMLElement(tableElement)) {
return null;
}
@@ -316,7 +308,7 @@ export class TableNode extends ElementNode {
return tableElement;
},
element:
- element && isHTMLElement(element) && element.nodeName !== 'TABLE'
+ isHTMLElement(element) && element.nodeName !== 'TABLE'
? element.querySelector('table')
: element,
};
@@ -498,7 +490,10 @@ export function $convertTableElement(
tableNode.setColWidths(columns);
}
}
- return {node: tableNode};
+ return {
+ after: (children) => $descendantsMatching(children, $isTableRowNode),
+ node: tableNode,
+ };
}
export function $createTableNode(): TableNode {
diff --git a/packages/lexical-table/src/LexicalTableObserver.ts b/packages/lexical-table/src/LexicalTableObserver.ts
index 059c471b96d..2f76ce0712a 100644
--- a/packages/lexical-table/src/LexicalTableObserver.ts
+++ b/packages/lexical-table/src/LexicalTableObserver.ts
@@ -462,9 +462,7 @@ export class TableObserver {
const selection = $getSelection();
- if (!$isTableSelection(selection)) {
- invariant(false, 'Expected grid selection');
- }
+ invariant($isTableSelection(selection), 'Expected TableSelection');
const selectedNodes = selection.getNodes().filter($isTableCellNode);
diff --git a/packages/lexical-table/src/LexicalTablePluginHelpers.ts b/packages/lexical-table/src/LexicalTablePluginHelpers.ts
new file mode 100644
index 00000000000..ae7ee4547e2
--- /dev/null
+++ b/packages/lexical-table/src/LexicalTablePluginHelpers.ts
@@ -0,0 +1,275 @@
+/**
+ * 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 {
+ $insertFirst,
+ $insertNodeToNearestRoot,
+ $unwrapAndFilterDescendants,
+ mergeRegister,
+} from '@lexical/utils';
+import {
+ $createParagraphNode,
+ $isTextNode,
+ COMMAND_PRIORITY_EDITOR,
+ LexicalEditor,
+ NodeKey,
+} from 'lexical';
+import invariant from 'shared/invariant';
+
+import {
+ $createTableCellNode,
+ $isTableCellNode,
+ TableCellNode,
+} from './LexicalTableCellNode';
+import {
+ INSERT_TABLE_COMMAND,
+ InsertTableCommandPayload,
+} from './LexicalTableCommands';
+import {$isTableNode, TableNode} from './LexicalTableNode';
+import {$getTableAndElementByKey, TableObserver} from './LexicalTableObserver';
+import {$isTableRowNode, TableRowNode} from './LexicalTableRowNode';
+import {
+ applyTableHandlers,
+ getTableElement,
+ HTMLTableElementWithWithTableSelectionState,
+} from './LexicalTableSelectionHelpers';
+import {
+ $computeTableMap,
+ $computeTableMapSkipCellCheck,
+ $createTableNodeWithDimensions,
+ $getNodeTriplet,
+} from './LexicalTableUtils';
+
+function $insertTableCommandListener({
+ rows,
+ columns,
+ includeHeaders,
+}: InsertTableCommandPayload): boolean {
+ const tableNode = $createTableNodeWithDimensions(
+ Number(rows),
+ Number(columns),
+ includeHeaders,
+ );
+ $insertNodeToNearestRoot(tableNode);
+
+ const firstDescendant = tableNode.getFirstDescendant();
+ if ($isTextNode(firstDescendant)) {
+ firstDescendant.select();
+ }
+
+ return true;
+}
+
+function $tableCellTransform(node: TableCellNode) {
+ if (!$isTableRowNode(node.getParent())) {
+ // TableCellNode must be a child of TableRowNode.
+ node.remove();
+ } else if (node.isEmpty()) {
+ // TableCellNode should never be empty
+ node.append($createParagraphNode());
+ }
+}
+
+function $tableRowTransform(node: TableRowNode) {
+ if (!$isTableNode(node.getParent())) {
+ // TableRowNode must be a child of TableNode.
+ // TODO: Future support of tbody/thead/tfoot may change this
+ node.remove();
+ } else {
+ $unwrapAndFilterDescendants(node, $isTableCellNode);
+ }
+}
+
+function $tableTransform(node: TableNode) {
+ // TableRowNode is the only valid child for TableNode
+ // TODO: Future support of tbody/thead/tfoot/caption may change this
+ $unwrapAndFilterDescendants(node, $isTableRowNode);
+
+ const [gridMap] = $computeTableMapSkipCellCheck(node, null, null);
+ const maxRowLength = gridMap.reduce((curLength, row) => {
+ return Math.max(curLength, row.length);
+ }, 0);
+ const rowNodes = node.getChildren();
+ for (let i = 0; i < gridMap.length; ++i) {
+ const rowNode = rowNodes[i];
+ if (!rowNode) {
+ continue;
+ }
+ invariant(
+ $isTableRowNode(rowNode),
+ 'TablePlugin: Expecting all children of TableNode to be TableRowNode, found %s (type %s)',
+ rowNode.constructor.name,
+ rowNode.getType(),
+ );
+ const rowLength = gridMap[i].reduce(
+ (acc, cell) => (cell ? 1 + acc : acc),
+ 0,
+ );
+ if (rowLength === maxRowLength) {
+ continue;
+ }
+ for (let j = rowLength; j < maxRowLength; ++j) {
+ // TODO: inherit header state from another header or body
+ const newCell = $createTableCellNode();
+ newCell.append($createParagraphNode());
+ rowNode.append(newCell);
+ }
+ }
+}
+
+/**
+ * Register a transform to ensure that all TableCellNode have a colSpan and rowSpan of 1.
+ * This should only be registered when you do not want to support merged cells.
+ *
+ * @param editor The editor
+ * @returns An unregister callback
+ */
+export function registerTableCellUnmergeTransform(
+ editor: LexicalEditor,
+): () => void {
+ return editor.registerNodeTransform(TableCellNode, (node) => {
+ if (node.getColSpan() > 1 || node.getRowSpan() > 1) {
+ // When we have rowSpan we have to map the entire Table to understand where the new Cells
+ // fit best; let's analyze all Cells at once to save us from further transform iterations
+ const [, , gridNode] = $getNodeTriplet(node);
+ const [gridMap] = $computeTableMap(gridNode, node, node);
+ // TODO this function expects Tables to be normalized. Look into this once it exists
+ const rowsCount = gridMap.length;
+ const columnsCount = gridMap[0].length;
+ let row = gridNode.getFirstChild();
+ invariant(
+ $isTableRowNode(row),
+ 'Expected TableNode first child to be a RowNode',
+ );
+ const unmerged = [];
+ for (let i = 0; i < rowsCount; i++) {
+ if (i !== 0) {
+ row = row.getNextSibling();
+ invariant(
+ $isTableRowNode(row),
+ 'Expected TableNode first child to be a RowNode',
+ );
+ }
+ let lastRowCell: null | TableCellNode = null;
+ for (let j = 0; j < columnsCount; j++) {
+ const cellMap = gridMap[i][j];
+ const cell = cellMap.cell;
+ if (cellMap.startRow === i && cellMap.startColumn === j) {
+ lastRowCell = cell;
+ unmerged.push(cell);
+ } else if (cell.getColSpan() > 1 || cell.getRowSpan() > 1) {
+ invariant(
+ $isTableCellNode(cell),
+ 'Expected TableNode cell to be a TableCellNode',
+ );
+ const newCell = $createTableCellNode(cell.__headerState);
+ if (lastRowCell !== null) {
+ lastRowCell.insertAfter(newCell);
+ } else {
+ $insertFirst(row, newCell);
+ }
+ }
+ }
+ }
+ for (const cell of unmerged) {
+ cell.setColSpan(1);
+ cell.setRowSpan(1);
+ }
+ }
+ });
+}
+
+export function registerTableSelectionObserver(
+ editor: LexicalEditor,
+ hasTabHandler: boolean = true,
+): () => void {
+ const tableSelections = new Map<
+ NodeKey,
+ [TableObserver, HTMLTableElementWithWithTableSelectionState]
+ >();
+
+ const initializeTableNode = (
+ tableNode: TableNode,
+ nodeKey: NodeKey,
+ dom: HTMLElement,
+ ) => {
+ const tableElement = getTableElement(tableNode, dom);
+ const tableSelection = applyTableHandlers(
+ tableNode,
+ tableElement,
+ editor,
+ hasTabHandler,
+ );
+ tableSelections.set(nodeKey, [tableSelection, tableElement]);
+ };
+
+ const unregisterMutationListener = editor.registerMutationListener(
+ TableNode,
+ (nodeMutations) => {
+ editor.getEditorState().read(
+ () => {
+ for (const [nodeKey, mutation] of nodeMutations) {
+ const tableSelection = tableSelections.get(nodeKey);
+ if (mutation === 'created' || mutation === 'updated') {
+ const {tableNode, tableElement} =
+ $getTableAndElementByKey(nodeKey);
+ if (tableSelection === undefined) {
+ initializeTableNode(tableNode, nodeKey, tableElement);
+ } else if (tableElement !== tableSelection[1]) {
+ // The update created a new DOM node, destroy the existing TableObserver
+ tableSelection[0].removeListeners();
+ tableSelections.delete(nodeKey);
+ initializeTableNode(tableNode, nodeKey, tableElement);
+ }
+ } else if (mutation === 'destroyed') {
+ if (tableSelection !== undefined) {
+ tableSelection[0].removeListeners();
+ tableSelections.delete(nodeKey);
+ }
+ }
+ }
+ },
+ {editor},
+ );
+ },
+ {skipInitialization: false},
+ );
+
+ return () => {
+ unregisterMutationListener();
+ // Hook might be called multiple times so cleaning up tables listeners as well,
+ // as it'll be reinitialized during recurring call
+ for (const [, [tableSelection]] of tableSelections) {
+ tableSelection.removeListeners();
+ }
+ };
+}
+
+/**
+ * Register the INSERT_TABLE_COMMAND listener and the table integrity transforms. The
+ * table selection observer should be registered separately after this with
+ * {@link registerTableSelectionObserver}.
+ *
+ * @param editor The editor
+ * @returns An unregister callback
+ */
+export function registerTablePlugin(editor: LexicalEditor): () => void {
+ if (!editor.hasNodes([TableNode])) {
+ invariant(false, 'TablePlugin: TableNode is not registered on editor');
+ }
+ return mergeRegister(
+ editor.registerCommand(
+ INSERT_TABLE_COMMAND,
+ $insertTableCommandListener,
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerNodeTransform(TableNode, $tableTransform),
+ editor.registerNodeTransform(TableRowNode, $tableRowTransform),
+ editor.registerNodeTransform(TableCellNode, $tableCellTransform),
+ );
+}
diff --git a/packages/lexical-table/src/LexicalTableRowNode.ts b/packages/lexical-table/src/LexicalTableRowNode.ts
index fd8bcb8fa0a..4e216b865df 100644
--- a/packages/lexical-table/src/LexicalTableRowNode.ts
+++ b/packages/lexical-table/src/LexicalTableRowNode.ts
@@ -8,7 +8,7 @@
import type {BaseSelection, Spread} from 'lexical';
-import {addClassNamesToElement} from '@lexical/utils';
+import {$descendantsMatching, addClassNamesToElement} from '@lexical/utils';
import {
$applyNodeReplacement,
DOMConversionMap,
@@ -21,6 +21,7 @@ import {
} from 'lexical';
import {PIXEL_VALUE_REG_EXP} from './constants';
+import {$isTableCellNode} from './LexicalTableCellNode';
export type SerializedTableRowNode = Spread<
{
@@ -103,7 +104,7 @@ export class TableRowNode extends ElementNode {
return this.getLatest().__height;
}
- updateDOM(prevNode: TableRowNode): boolean {
+ updateDOM(prevNode: this): boolean {
return prevNode.__height !== this.__height;
}
@@ -124,7 +125,10 @@ export function $convertTableRowElement(domNode: Node): DOMConversionOutput {
height = parseFloat(domNode_.style.height);
}
- return {node: $createTableRowNode(height)};
+ return {
+ after: (children) => $descendantsMatching(children, $isTableCellNode),
+ node: $createTableRowNode(height),
+ };
}
export function $createTableRowNode(height?: number): TableRowNode {
diff --git a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts
index 7959f1cfd75..5d010cc0bc2 100644
--- a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts
+++ b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts
@@ -60,6 +60,7 @@ import {
FORMAT_TEXT_COMMAND,
getDOMSelection,
INSERT_PARAGRAPH_COMMAND,
+ isDOMNode,
KEY_ARROW_DOWN_COMMAND,
KEY_ARROW_LEFT_COMMAND,
KEY_ARROW_RIGHT_COMMAND,
@@ -185,16 +186,19 @@ export function applyTableHandlers(
};
const onMouseMove = (moveEvent: MouseEvent) => {
+ if (!isDOMNode(moveEvent.target)) {
+ return;
+ }
if (!isMouseDownOnEvent(moveEvent) && tableObserver.isSelecting) {
tableObserver.isSelecting = false;
editorWindow.removeEventListener('mouseup', onMouseUp);
editorWindow.removeEventListener('mousemove', onMouseMove);
return;
}
- const override = !tableElement.contains(moveEvent.target as Node);
+ const override = !tableElement.contains(moveEvent.target);
let focusCell: null | TableDOMCell = null;
if (!override) {
- focusCell = getDOMCellFromTarget(moveEvent.target as Node);
+ focusCell = getDOMCellFromTarget(moveEvent.target);
} else {
for (const el of document.elementsFromPoint(
moveEvent.clientX,
@@ -231,15 +235,11 @@ export function applyTableHandlers(
};
const onMouseDown = (event: MouseEvent) => {
- if (event.button !== 0) {
- return;
- }
-
- if (!editorWindow) {
+ if (event.button !== 0 || !isDOMNode(event.target) || !editorWindow) {
return;
}
- const targetCell = getDOMCellFromTarget(event.target as Node);
+ const targetCell = getDOMCellFromTarget(event.target);
if (targetCell !== null) {
editor.update(() => {
const prevSelection = $getPreviousSelection();
@@ -292,13 +292,13 @@ export function applyTableHandlers(
// Clear selection when clicking outside of dom.
const mouseDownCallback = (event: MouseEvent) => {
- if (event.button !== 0) {
+ const target = event.target;
+ if (event.button !== 0 || !isDOMNode(target)) {
return;
}
editor.update(() => {
const selection = $getSelection();
- const target = event.target as Node;
if (
$isTableSelection(selection) &&
selection.tableKey === tableObserver.tableNodeKey &&
diff --git a/packages/lexical-table/src/LexicalTableUtils.ts b/packages/lexical-table/src/LexicalTableUtils.ts
index e1c0c0884cd..84e4ab3f5a7 100644
--- a/packages/lexical-table/src/LexicalTableUtils.ts
+++ b/packages/lexical-table/src/LexicalTableUtils.ts
@@ -258,20 +258,31 @@ export function $insertTableRow__EXPERIMENTAL(
$isRangeSelection(selection) || $isTableSelection(selection),
'Expected a RangeSelection or TableSelection',
);
+ const anchor = selection.anchor.getNode();
const focus = selection.focus.getNode();
+ const [anchorCell] = $getNodeTriplet(anchor);
const [focusCell, , grid] = $getNodeTriplet(focus);
- const [gridMap, focusCellMap] = $computeTableMap(grid, focusCell, focusCell);
+ const [gridMap, focusCellMap, anchorCellMap] = $computeTableMap(
+ grid,
+ focusCell,
+ anchorCell,
+ );
const columnCount = gridMap[0].length;
+ const {startRow: anchorStartRow} = anchorCellMap;
const {startRow: focusStartRow} = focusCellMap;
let insertedRow: TableRowNode | null = null;
if (insertAfter) {
- const focusEndRow = focusStartRow + focusCell.__rowSpan - 1;
- const focusEndRowMap = gridMap[focusEndRow];
+ const insertAfterEndRow =
+ Math.max(
+ focusStartRow + focusCell.__rowSpan,
+ anchorStartRow + anchorCell.__rowSpan,
+ ) - 1;
+ const insertAfterEndRowMap = gridMap[insertAfterEndRow];
const newRow = $createTableRowNode();
for (let i = 0; i < columnCount; i++) {
- const {cell, startRow} = focusEndRowMap[i];
- if (startRow + cell.__rowSpan - 1 <= focusEndRow) {
- const currentCell = focusEndRowMap[i].cell as TableCellNode;
+ const {cell, startRow} = insertAfterEndRowMap[i];
+ if (startRow + cell.__rowSpan - 1 <= insertAfterEndRow) {
+ const currentCell = insertAfterEndRowMap[i].cell as TableCellNode;
const currentCellHeaderState = currentCell.__headerState;
const headerState = getHeaderState(
@@ -286,20 +297,21 @@ export function $insertTableRow__EXPERIMENTAL(
cell.setRowSpan(cell.__rowSpan + 1);
}
}
- const focusEndRowNode = grid.getChildAtIndex(focusEndRow);
+ const insertAfterEndRowNode = grid.getChildAtIndex(insertAfterEndRow);
invariant(
- $isTableRowNode(focusEndRowNode),
- 'focusEndRow is not a TableRowNode',
+ $isTableRowNode(insertAfterEndRowNode),
+ 'insertAfterEndRow is not a TableRowNode',
);
- focusEndRowNode.insertAfter(newRow);
+ insertAfterEndRowNode.insertAfter(newRow);
insertedRow = newRow;
} else {
- const focusStartRowMap = gridMap[focusStartRow];
+ const insertBeforeStartRow = Math.min(focusStartRow, anchorStartRow);
+ const insertBeforeStartRowMap = gridMap[insertBeforeStartRow];
const newRow = $createTableRowNode();
for (let i = 0; i < columnCount; i++) {
- const {cell, startRow} = focusStartRowMap[i];
- if (startRow === focusStartRow) {
- const currentCell = focusStartRowMap[i].cell as TableCellNode;
+ const {cell, startRow} = insertBeforeStartRowMap[i];
+ if (startRow === insertBeforeStartRow) {
+ const currentCell = insertBeforeStartRowMap[i].cell as TableCellNode;
const currentCellHeaderState = currentCell.__headerState;
const headerState = getHeaderState(
@@ -314,12 +326,12 @@ export function $insertTableRow__EXPERIMENTAL(
cell.setRowSpan(cell.__rowSpan + 1);
}
}
- const focusStartRowNode = grid.getChildAtIndex(focusStartRow);
+ const insertBeforeStartRowNode = grid.getChildAtIndex(insertBeforeStartRow);
invariant(
- $isTableRowNode(focusStartRowNode),
- 'focusEndRow is not a TableRowNode',
+ $isTableRowNode(insertBeforeStartRowNode),
+ 'insertBeforeStartRow is not a TableRowNode',
);
- focusStartRowNode.insertBefore(newRow);
+ insertBeforeStartRowNode.insertBefore(newRow);
insertedRow = newRow;
}
return insertedRow;
@@ -548,6 +560,7 @@ export function $deleteTableRow__EXPERIMENTAL(): void {
return;
}
const columnCount = gridMap[0].length;
+ const selectedRowCount = anchorCell.__rowSpan;
const nextRow = gridMap[focusEndRow + 1];
const nextRowNode: null | TableRowNode = grid.getChildAtIndex(
focusEndRow + 1,
@@ -565,7 +578,11 @@ export function $deleteTableRow__EXPERIMENTAL(): void {
}
// Rows overflowing top have to be trimmed
if (row === anchorStartRow && cellStartRow < anchorStartRow) {
- cell.setRowSpan(cell.__rowSpan - (cellStartRow - anchorStartRow));
+ const overflowTop = anchorStartRow - cellStartRow;
+ cell.setRowSpan(
+ cell.__rowSpan -
+ Math.min(selectedRowCount, cell.__rowSpan - overflowTop),
+ );
}
// Rows overflowing bottom have to be trimmed and moved to the next row
if (
@@ -574,11 +591,22 @@ export function $deleteTableRow__EXPERIMENTAL(): void {
) {
cell.setRowSpan(cell.__rowSpan - (focusEndRow - cellStartRow + 1));
invariant(nextRowNode !== null, 'Expected nextRowNode not to be null');
- if (column === 0) {
+ let insertAfterCell: null | TableCellNode = null;
+ for (let columnIndex = 0; columnIndex < column; columnIndex++) {
+ const currentCellMap = nextRow[columnIndex];
+ const currentCell = currentCellMap.cell;
+ // Checking the cell having startRow as same as nextRow
+ if (currentCellMap.startRow === row + 1) {
+ insertAfterCell = currentCell;
+ }
+ if (currentCell.__colSpan > 1) {
+ columnIndex += currentCell.__colSpan - 1;
+ }
+ }
+ if (insertAfterCell === null) {
$insertFirst(nextRowNode, cell);
} else {
- const {cell: previousCell} = nextRow[column - 1];
- previousCell.insertAfter(cell);
+ insertAfterCell.insertAfter(cell);
}
}
}
diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx b/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx
index 96ca3c7e426..38755a841e2 100644
--- a/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx
+++ b/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx
@@ -317,6 +317,318 @@ describe('LexicalTableNode tests', () => {
);
});
+ test('Copy table with caption/tbody/thead/tfoot from an external source', async () => {
+ const {editor} = testEnv;
+
+ const dataTransfer = new DataTransferMock();
+ dataTransfer.setData(
+ 'text/html',
+ // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/thead
+ html`
+
+
+
+ Council budget (in £) 2018
+
+
+
+
+ Items
+
+
+ Expenditure
+
+
+
+
+
+
+ Donuts
+
+
+ 3,000
+
+
+
+
+ Stationery
+
+
+ 18,000
+
+
+
+
+
+
+ Totals
+
+
+ 21,000
+
+
+
+
+ `,
+ );
+ await editor.update(() => {
+ const selection = $getSelection();
+ invariant(
+ $isRangeSelection(selection),
+ 'isRangeSelection(selection)',
+ );
+ $insertDataTransferForRichText(dataTransfer, selection, editor);
+ });
+ // Here we are testing the createDOM, not the exportDOM, so the tbody is not there
+ expectTableHtmlToBeEqual(
+ testEnv.innerHTML,
+ html`
+
+ `,
+ );
+ await editor.update(() => {
+ const selection = $getSelection();
+ invariant(
+ $isRangeSelection(selection),
+ 'isRangeSelection(selection)',
+ );
+ $insertDataTransferForRichText(dataTransfer, selection, editor);
+ });
+ // Here we are testing the createDOM, not the exportDOM, so the tbody is not there
+ expectTableHtmlToBeEqual(
+ testEnv.innerHTML,
+ html`
+
+
+
+
+
+
+
+
+
+
+
+
+ He-Man
+
+
+
+
+ Skeletor
+
+
+
+
+
+
+ Role
+
+
+
+
+ Hero
+
+
+
+
+ Villain
+
+
+
+
+
+
+ Weapon
+
+
+
+
+ Power Sword
+
+
+
+
+ Havoc Staff
+
+
+
+
+
+
+ Dark secret
+
+
+
+
+ Expert florist
+
+
+
+
+ Cries at romcoms
+
+
+
+
+ `,
+ );
+ });
+
test('Copy table from an external source like gdoc with formatting', async () => {
const {editor} = testEnv;
diff --git a/packages/lexical-table/src/index.ts b/packages/lexical-table/src/index.ts
index be452681b98..c4fe6ace096 100644
--- a/packages/lexical-table/src/index.ts
+++ b/packages/lexical-table/src/index.ts
@@ -29,6 +29,11 @@ export {
} from './LexicalTableNode';
export type {TableDOMCell} from './LexicalTableObserver';
export {$getTableAndElementByKey, TableObserver} from './LexicalTableObserver';
+export {
+ registerTableCellUnmergeTransform,
+ registerTablePlugin,
+ registerTableSelectionObserver,
+} from './LexicalTablePluginHelpers';
export type {SerializedTableRowNode} from './LexicalTableRowNode';
export {
$createTableRowNode,
diff --git a/packages/lexical-utils/flow/LexicalUtils.js.flow b/packages/lexical-utils/flow/LexicalUtils.js.flow
index 11524eee950..958dd8acfa7 100644
--- a/packages/lexical-utils/flow/LexicalUtils.js.flow
+++ b/packages/lexical-utils/flow/LexicalUtils.js.flow
@@ -125,3 +125,14 @@ declare export function $splitNode(
declare export function calculateZoomLevel(element: Element | null): number;
declare export function $isEditorIsNestedEditor(editor: LexicalEditor): boolean;
+
+declare export function $unwrapAndFilterDescendants(
+ root: ElementNode,
+ $predicate: (node: LexicalNode) => boolean,
+): boolean;
+
+declare export function $firstToLastIterator(node: ElementNode): Iterable;
+
+declare export function $lastToFirstIterator(node: ElementNode): Iterable;
+
+declare export function $unwrapNode(node: ElementNode): void;
diff --git a/packages/lexical-utils/src/__tests__/unit/descendantsMatching.test.tsx b/packages/lexical-utils/src/__tests__/unit/descendantsMatching.test.tsx
new file mode 100644
index 00000000000..3a4e596626f
--- /dev/null
+++ b/packages/lexical-utils/src/__tests__/unit/descendantsMatching.test.tsx
@@ -0,0 +1,84 @@
+/**
+ * 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 {Klass, LexicalEditor, LexicalNode} from 'lexical';
+
+import {$descendantsMatching} from '@lexical/utils';
+import {
+ $createParagraphNode,
+ $createTextNode,
+ $getRoot,
+ $isTextNode,
+ ParagraphNode,
+} from 'lexical';
+import {createTestEditor} from 'lexical/src/__tests__/utils';
+
+function assertClass(v: unknown, klass: Klass): T {
+ if (v instanceof klass) {
+ return v as T;
+ }
+ throw new Error(`Value does not extend ${klass.name}`);
+}
+
+function $createTextAndParagraphWithDepth(depth: number): LexicalNode[] {
+ if (depth <= 0) {
+ return [$createTextNode(`<${depth} />`)];
+ }
+ return [
+ $createTextNode(`<${depth}>`),
+ $createParagraphNode().append(
+ ...$createTextAndParagraphWithDepth(depth - 1),
+ ),
+ $createTextNode(`${depth}>`),
+ ];
+}
+
+function textContentForDepth(i: number): string {
+ return i > 0 ? `<${i}>${textContentForDepth(i - 1)}${i}>` : `<${i} />`;
+}
+
+describe('$descendantsMatching', () => {
+ let editor: LexicalEditor;
+
+ beforeEach(async () => {
+ editor = createTestEditor();
+ editor._headless = true;
+ });
+
+ [0, 1, 2].forEach((depth) =>
+ it(`Can un-nest children at depth ${depth}`, () => {
+ editor.update(
+ () => {
+ const firstNode = $createParagraphNode();
+ $getRoot()
+ .clear()
+ .append(
+ firstNode.append(...$createTextAndParagraphWithDepth(depth)),
+ );
+ },
+ {discrete: true},
+ );
+ editor.update(
+ () => {
+ const firstNode = assertClass(
+ $getRoot().getFirstChildOrThrow(),
+ ParagraphNode,
+ );
+ expect(firstNode.getChildren().every($isTextNode)).toBe(depth === 0);
+ firstNode.splice(
+ 0,
+ firstNode.getChildrenSize(),
+ $descendantsMatching(firstNode.getChildren(), $isTextNode),
+ );
+ expect(firstNode.getChildren().every($isTextNode)).toBe(true);
+ expect(firstNode.getTextContent()).toBe(textContentForDepth(depth));
+ },
+ {discrete: true},
+ );
+ }),
+ );
+});
diff --git a/packages/lexical-utils/src/__tests__/unit/iterators.test.tsx b/packages/lexical-utils/src/__tests__/unit/iterators.test.tsx
new file mode 100644
index 00000000000..f7042e14e0e
--- /dev/null
+++ b/packages/lexical-utils/src/__tests__/unit/iterators.test.tsx
@@ -0,0 +1,216 @@
+/**
+ * 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 {Klass, LexicalEditor, LexicalNode} from 'lexical';
+
+import {$firstToLastIterator, $lastToFirstIterator} from '@lexical/utils';
+import {$createParagraphNode, $createTextNode, TextNode} from 'lexical';
+import {createTestEditor} from 'lexical/src/__tests__/utils';
+
+function assertClass(v: unknown, klass: Klass): T {
+ if (v instanceof klass) {
+ return v as T;
+ }
+ throw new Error(`Value does not extend ${klass.name}`);
+}
+
+describe('$firstToLastIterator', () => {
+ let editor: LexicalEditor;
+
+ beforeEach(async () => {
+ editor = createTestEditor();
+ editor._headless = true;
+ });
+
+ it(`Iterates from first to last`, () => {
+ editor.update(
+ () => {
+ const parent = $createParagraphNode().splice(
+ 0,
+ 0,
+ Array.from({length: 5}, (_v, i) => $createTextNode(`${i}`)),
+ );
+ // Check initial state
+ expect(
+ parent.getAllTextNodes().map((node) => node.getTextContent()),
+ ).toEqual(['0', '1', '2', '3', '4']);
+ expect(
+ Array.from($firstToLastIterator(parent), (node) => {
+ return assertClass(node, TextNode).getTextContent();
+ }),
+ ).toEqual(['0', '1', '2', '3', '4']);
+ // Parent was not affected
+ expect(
+ parent.getAllTextNodes().map((node) => node.getTextContent()),
+ ).toEqual(['0', '1', '2', '3', '4']);
+ },
+ {discrete: true},
+ );
+ });
+ it(`Can handle node removal`, () => {
+ editor.update(
+ () => {
+ const parent = $createParagraphNode().splice(
+ 0,
+ 0,
+ Array.from({length: 5}, (_v, i) => $createTextNode(`${i}`)),
+ );
+ // Check initial state
+ expect(
+ parent.getAllTextNodes().map((node) => node.getTextContent()),
+ ).toEqual(['0', '1', '2', '3', '4']);
+ expect(
+ Array.from($firstToLastIterator(parent), (node) => {
+ const rval = assertClass(node, TextNode).getTextContent();
+ node.remove();
+ return rval;
+ }),
+ ).toEqual(['0', '1', '2', '3', '4']);
+ expect(parent.getChildren()).toEqual([]);
+ },
+ {discrete: true},
+ );
+ });
+ it(`Detects cycles when nodes move incorrectly`, () => {
+ editor.update(
+ () => {
+ const parent = $createParagraphNode().splice(
+ 0,
+ 0,
+ Array.from({length: 5}, (_v, i) => $createTextNode(`${i}`)),
+ );
+ // Check initial state
+ expect(
+ parent.getAllTextNodes().map((node) => node.getTextContent()),
+ ).toEqual(['0', '1', '2', '3', '4']);
+ expect(() =>
+ Array.from($firstToLastIterator(parent), (node) => {
+ const rval = assertClass(node, TextNode).getTextContent();
+ parent.append(node);
+ return rval;
+ }),
+ ).toThrow(/\$childIterator: Cycle detected/);
+ },
+ {discrete: true},
+ );
+ });
+ it(`Can handle nodes moving in the other direction`, () => {
+ editor.update(
+ () => {
+ const parent = $createParagraphNode().splice(
+ 0,
+ 0,
+ Array.from({length: 5}, (_v, i) => $createTextNode(`${i}`)),
+ );
+ // Check initial state
+ expect(
+ parent.getAllTextNodes().map((node) => node.getTextContent()),
+ ).toEqual(['0', '1', '2', '3', '4']);
+ expect(
+ Array.from($firstToLastIterator(parent), (node) => {
+ const rval = assertClass(node, TextNode).getTextContent();
+ if (node.getPreviousSibling() !== null) {
+ parent.splice(0, 0, [node]);
+ }
+ return rval;
+ }),
+ ).toEqual(['0', '1', '2', '3', '4']);
+ // This mutation reversed the nodes while traversing
+ expect(
+ parent.getAllTextNodes().map((node) => node.getTextContent()),
+ ).toEqual(['4', '3', '2', '1', '0']);
+ },
+ {discrete: true},
+ );
+ });
+});
+
+describe('$lastToFirstIterator', () => {
+ let editor: LexicalEditor;
+
+ beforeEach(async () => {
+ editor = createTestEditor();
+ editor._headless = true;
+ });
+
+ it(`Iterates from last to first`, () => {
+ editor.update(
+ () => {
+ const parent = $createParagraphNode().splice(
+ 0,
+ 0,
+ Array.from({length: 5}, (_v, i) => $createTextNode(`${i}`)),
+ );
+ // Check initial state
+ expect(
+ parent.getAllTextNodes().map((node) => node.getTextContent()),
+ ).toEqual(['0', '1', '2', '3', '4']);
+ expect(
+ Array.from($lastToFirstIterator(parent), (node) => {
+ return assertClass(node, TextNode).getTextContent();
+ }),
+ ).toEqual(['4', '3', '2', '1', '0']);
+ // Parent was not affected
+ expect(
+ parent.getAllTextNodes().map((node) => node.getTextContent()),
+ ).toEqual(['0', '1', '2', '3', '4']);
+ },
+ {discrete: true},
+ );
+ });
+ it(`Can handle node removal`, () => {
+ editor.update(
+ () => {
+ const parent = $createParagraphNode().splice(
+ 0,
+ 0,
+ Array.from({length: 5}, (_v, i) => $createTextNode(`${i}`)),
+ );
+ // Check initial state
+ expect(
+ parent.getAllTextNodes().map((node) => node.getTextContent()),
+ ).toEqual(['0', '1', '2', '3', '4']);
+ expect(
+ Array.from($lastToFirstIterator(parent), (node) => {
+ const rval = assertClass(node, TextNode).getTextContent();
+ node.remove();
+ return rval;
+ }),
+ ).toEqual(['4', '3', '2', '1', '0']);
+ expect(parent.getChildren()).toEqual([]);
+ },
+ {discrete: true},
+ );
+ });
+ it(`Can handle nodes moving in the other direction`, () => {
+ editor.update(
+ () => {
+ const parent = $createParagraphNode().splice(
+ 0,
+ 0,
+ Array.from({length: 5}, (_v, i) => $createTextNode(`${i}`)),
+ );
+ // Check initial state
+ expect(
+ parent.getAllTextNodes().map((node) => node.getTextContent()),
+ ).toEqual(['0', '1', '2', '3', '4']);
+ expect(
+ Array.from($lastToFirstIterator(parent), (node) => {
+ const rval = assertClass(node, TextNode).getTextContent();
+ parent.append(node);
+ return rval;
+ }),
+ ).toEqual(['4', '3', '2', '1', '0']);
+ // This mutation reversed the nodes while traversing
+ expect(
+ parent.getAllTextNodes().map((node) => node.getTextContent()),
+ ).toEqual(['4', '3', '2', '1', '0']);
+ },
+ {discrete: true},
+ );
+ });
+});
diff --git a/packages/lexical-utils/src/__tests__/unit/unwrapAndFilterDescendants.test.tsx b/packages/lexical-utils/src/__tests__/unit/unwrapAndFilterDescendants.test.tsx
new file mode 100644
index 00000000000..cb42d0fe643
--- /dev/null
+++ b/packages/lexical-utils/src/__tests__/unit/unwrapAndFilterDescendants.test.tsx
@@ -0,0 +1,101 @@
+/**
+ * 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 {Klass, LexicalEditor, LexicalNode} from 'lexical';
+
+import {$unwrapAndFilterDescendants} from '@lexical/utils';
+import {
+ $createParagraphNode,
+ $createTextNode,
+ $getRoot,
+ $isParagraphNode,
+ $isTextNode,
+ ParagraphNode,
+} from 'lexical';
+import {createTestEditor} from 'lexical/src/__tests__/utils';
+
+function assertClass(v: unknown, klass: Klass): T {
+ if (v instanceof klass) {
+ return v as T;
+ }
+ throw new Error(`Value does not extend ${klass.name}`);
+}
+
+function $createTextAndParagraphWithDepth(depth: number): LexicalNode[] {
+ if (depth <= 0) {
+ return [$createTextNode(`<${depth} />`)];
+ }
+ return [
+ $createTextNode(`<${depth}>`),
+ $createParagraphNode().append(
+ ...$createTextAndParagraphWithDepth(depth - 1),
+ ),
+ $createTextNode(`${depth}>`),
+ ];
+}
+
+function textContentForDepth(i: number): string {
+ return i > 0 ? `<${i}>${textContentForDepth(i - 1)}${i}>` : `<${i} />`;
+}
+
+describe('$unwrapAndFilterDescendants', () => {
+ let editor: LexicalEditor;
+
+ beforeEach(async () => {
+ editor = createTestEditor();
+ editor._headless = true;
+ });
+
+ it('Is a no-op with valid children', () => {
+ editor.update(
+ () => {
+ $getRoot().clear().append($createParagraphNode());
+ },
+ {discrete: true},
+ );
+ editor.update(
+ () => {
+ expect($unwrapAndFilterDescendants($getRoot(), $isParagraphNode)).toBe(
+ false,
+ );
+ expect($getRoot().getChildrenSize()).toBe(1);
+ expect($isParagraphNode($getRoot().getFirstChild())).toBe(true);
+ },
+ {discrete: true},
+ );
+ });
+ [0, 1, 2].forEach((depth) =>
+ it(`Can un-nest children at depth ${depth}`, () => {
+ editor.update(
+ () => {
+ const firstNode = $createParagraphNode();
+ $getRoot()
+ .clear()
+ .append(
+ firstNode.append(...$createTextAndParagraphWithDepth(depth)),
+ );
+ },
+ {discrete: true},
+ );
+ editor.update(
+ () => {
+ const firstNode = assertClass(
+ $getRoot().getFirstChildOrThrow(),
+ ParagraphNode,
+ );
+ expect(firstNode.getChildren().every($isTextNode)).toBe(depth === 0);
+ expect($unwrapAndFilterDescendants(firstNode, $isTextNode)).toBe(
+ depth > 0,
+ );
+ expect(firstNode.getChildren().every($isTextNode)).toBe(true);
+ expect(firstNode.getTextContent()).toBe(textContentForDepth(depth));
+ },
+ {discrete: true},
+ );
+ }),
+ );
+});
diff --git a/packages/lexical-utils/src/index.ts b/packages/lexical-utils/src/index.ts
index 8994e3dad65..0a758f40f2f 100644
--- a/packages/lexical-utils/src/index.ts
+++ b/packages/lexical-utils/src/index.ts
@@ -23,6 +23,7 @@ import {
Klass,
LexicalEditor,
LexicalNode,
+ NodeKey,
} from 'lexical';
// This underscore postfixing is used as a hotfix so we do not
// export shared types from this module #5918
@@ -690,3 +691,157 @@ export function calculateZoomLevel(element: Element | null): number {
export function $isEditorIsNestedEditor(editor: LexicalEditor): boolean {
return editor._parentEditor !== null;
}
+
+/**
+ * A depth first last-to-first traversal of root that stops at each node that matches
+ * $predicate and ensures that its parent is root. This is typically used to discard
+ * invalid or unsupported wrapping nodes. For example, a TableNode must only have
+ * TableRowNode as children, but an importer might add invalid nodes based on
+ * caption, tbody, thead, etc. and this will unwrap and discard those.
+ *
+ * @param root The root to start the traversal
+ * @param $predicate Should return true for nodes that are permitted to be children of root
+ * @returns true if this unwrapped or removed any nodes
+ */
+export function $unwrapAndFilterDescendants(
+ root: ElementNode,
+ $predicate: (node: LexicalNode) => boolean,
+): boolean {
+ return $unwrapAndFilterDescendantsImpl(root, $predicate, null);
+}
+
+function $unwrapAndFilterDescendantsImpl(
+ root: ElementNode,
+ $predicate: (node: LexicalNode) => boolean,
+ $onSuccess: null | ((node: LexicalNode) => void),
+): boolean {
+ let didMutate = false;
+ for (const node of $lastToFirstIterator(root)) {
+ if ($predicate(node)) {
+ if ($onSuccess !== null) {
+ $onSuccess(node);
+ }
+ continue;
+ }
+ didMutate = true;
+ if ($isElementNode(node)) {
+ $unwrapAndFilterDescendantsImpl(
+ node,
+ $predicate,
+ $onSuccess ? $onSuccess : (child) => node.insertAfter(child),
+ );
+ }
+ node.remove();
+ }
+ return didMutate;
+}
+
+/**
+ * A depth first traversal of the children array that stops at and collects
+ * each node that `$predicate` matches. This is typically used to discard
+ * invalid or unsupported wrapping nodes on a children array in the `after`
+ * of an {@link lexical!DOMConversionOutput}. For example, a TableNode must only have
+ * TableRowNode as children, but an importer might add invalid nodes based on
+ * caption, tbody, thead, etc. and this will unwrap and discard those.
+ *
+ * This function is read-only and performs no mutation operations, which makes
+ * it suitable for import and export purposes but likely not for any in-place
+ * mutation. You should use {@link $unwrapAndFilterDescendants} for in-place
+ * mutations such as node transforms.
+ *
+ * @param children The children to traverse
+ * @param $predicate Should return true for nodes that are permitted to be children of root
+ * @returns The children or their descendants that match $predicate
+ */
+export function $descendantsMatching(
+ children: LexicalNode[],
+ $predicate: (node: LexicalNode) => node is T,
+): T[];
+export function $descendantsMatching(
+ children: LexicalNode[],
+ $predicate: (node: LexicalNode) => boolean,
+): LexicalNode[] {
+ const result: LexicalNode[] = [];
+ const stack = [...children].reverse();
+ for (let child = stack.pop(); child !== undefined; child = stack.pop()) {
+ if ($predicate(child)) {
+ result.push(child);
+ } else if ($isElementNode(child)) {
+ for (const grandchild of $lastToFirstIterator(child)) {
+ stack.push(grandchild);
+ }
+ }
+ }
+ return result;
+}
+
+/**
+ * Return an iterator that yields each child of node from first to last, taking
+ * care to preserve the next sibling before yielding the value in case the caller
+ * removes the yielded node.
+ *
+ * @param node The node whose children to iterate
+ * @returns An iterator of the node's children
+ */
+export function $firstToLastIterator(node: ElementNode): Iterable {
+ return {
+ [Symbol.iterator]: () =>
+ $childIterator(node.getFirstChild(), (child) => child.getNextSibling()),
+ };
+}
+
+/**
+ * Return an iterator that yields each child of node from last to first, taking
+ * care to preserve the previous sibling before yielding the value in case the caller
+ * removes the yielded node.
+ *
+ * @param node The node whose children to iterate
+ * @returns An iterator of the node's children
+ */
+export function $lastToFirstIterator(node: ElementNode): Iterable {
+ return {
+ [Symbol.iterator]: () =>
+ $childIterator(node.getLastChild(), (child) =>
+ child.getPreviousSibling(),
+ ),
+ };
+}
+
+function $childIterator(
+ initialNode: LexicalNode | null,
+ nextNode: (node: LexicalNode) => LexicalNode | null,
+): Iterator {
+ let state = initialNode;
+ const seen = __DEV__ ? new Set() : null;
+ return {
+ next() {
+ if (state === null) {
+ return iteratorDone;
+ }
+ const rval = iteratorNotDone(state);
+ if (__DEV__ && seen !== null) {
+ const key = state.getKey();
+ invariant(
+ !seen.has(key),
+ '$childIterator: Cycle detected, node with key %s has already been traversed',
+ String(key),
+ );
+ seen.add(key);
+ }
+ state = nextNode(state);
+ return rval;
+ },
+ };
+}
+
+/**
+ * Insert all children before this node, and then remove it.
+ *
+ * @param node The ElementNode to unwrap and remove
+ */
+export function $unwrapNode(node: ElementNode): void {
+ for (const child of $firstToLastIterator(node)) {
+ node.insertBefore(child);
+ }
+ node.remove();
+}
diff --git a/packages/lexical-utils/src/markSelection.ts b/packages/lexical-utils/src/markSelection.ts
index 382b57b4bb7..458aa230fae 100644
--- a/packages/lexical-utils/src/markSelection.ts
+++ b/packages/lexical-utils/src/markSelection.ts
@@ -8,12 +8,13 @@
import {
$getSelection,
+ $isElementNode,
$isRangeSelection,
- $isTextNode,
type EditorState,
ElementNode,
getDOMTextNode,
type LexicalEditor,
+ Point,
TextNode,
} from 'lexical';
@@ -21,6 +22,40 @@ import mergeRegister from './mergeRegister';
import positionNodeOnRange from './positionNodeOnRange';
import px from './px';
+function rangeTargetFromPoint(
+ point: Point,
+ node: ElementNode | TextNode,
+ dom: HTMLElement,
+): [HTMLElement | Text, number] {
+ if (point.type === 'text' || !$isElementNode(node)) {
+ const textDOM = getDOMTextNode(dom) || dom;
+ return [textDOM, point.offset];
+ } else {
+ const slot = node.getDOMSlot(dom);
+ return [slot.element, slot.getFirstChildOffset() + point.offset];
+ }
+}
+
+function rangeFromPoints(
+ editor: LexicalEditor,
+ anchor: Point,
+ anchorNode: ElementNode | TextNode,
+ anchorDOM: HTMLElement,
+ focus: Point,
+ focusNode: ElementNode | TextNode,
+ focusDOM: HTMLElement,
+): Range {
+ const editorDocument = editor._window ? editor._window.document : document;
+ const range = editorDocument.createRange();
+ if (focusNode.isBefore(anchorNode)) {
+ range.setStart(...rangeTargetFromPoint(focus, focusNode, focusDOM));
+ range.setEnd(...rangeTargetFromPoint(anchor, anchorNode, anchorDOM));
+ } else {
+ range.setStart(...rangeTargetFromPoint(anchor, anchorNode, anchorDOM));
+ range.setEnd(...rangeTargetFromPoint(focus, focusNode, focusDOM));
+ }
+ return range;
+}
/**
* Place one or multiple newly created Nodes at the current selection. Multiple
* nodes will only be created when the selection spans multiple lines (aka
@@ -34,8 +69,10 @@ export default function markSelection(
onReposition?: (node: Array) => void,
): () => void {
let previousAnchorNode: null | TextNode | ElementNode = null;
+ let previousAnchorNodeDOM: null | HTMLElement = null;
let previousAnchorOffset: null | number = null;
let previousFocusNode: null | TextNode | ElementNode = null;
+ let previousFocusNodeDOM: null | HTMLElement = null;
let previousFocusOffset: null | number = null;
let removeRangeListener: () => void = () => {};
function compute(editorState: EditorState) {
@@ -62,101 +99,66 @@ export default function markSelection(
const currentFocusNodeDOM = editor.getElementByKey(currentFocusNodeKey);
const differentAnchorDOM =
previousAnchorNode === null ||
- currentAnchorNodeDOM === null ||
+ currentAnchorNodeDOM !== previousAnchorNodeDOM ||
currentAnchorOffset !== previousAnchorOffset ||
- currentAnchorNodeKey !== previousAnchorNode.getKey() ||
- (currentAnchorNode !== previousAnchorNode &&
- (!$isTextNode(previousAnchorNode) ||
- currentAnchorNode.updateDOM(
- previousAnchorNode,
- currentAnchorNodeDOM,
- editor._config,
- )));
+ currentAnchorNodeKey !== previousAnchorNode.getKey();
const differentFocusDOM =
previousFocusNode === null ||
- currentFocusNodeDOM === null ||
+ currentFocusNodeDOM !== previousFocusNodeDOM ||
currentFocusOffset !== previousFocusOffset ||
- currentFocusNodeKey !== previousFocusNode.getKey() ||
- (currentFocusNode !== previousFocusNode &&
- (!$isTextNode(previousFocusNode) ||
- currentFocusNode.updateDOM(
- previousFocusNode,
- currentFocusNodeDOM,
- editor._config,
- )));
- if (differentAnchorDOM || differentFocusDOM) {
- const anchorHTMLElement = editor.getElementByKey(
- anchor.getNode().getKey(),
+ currentFocusNodeKey !== previousFocusNode.getKey();
+ if (
+ (differentAnchorDOM || differentFocusDOM) &&
+ currentAnchorNodeDOM !== null &&
+ currentFocusNodeDOM !== null
+ ) {
+ const range = rangeFromPoints(
+ editor,
+ anchor,
+ currentAnchorNode,
+ currentAnchorNodeDOM,
+ focus,
+ currentFocusNode,
+ currentFocusNodeDOM,
);
- const focusHTMLElement = editor.getElementByKey(
- focus.getNode().getKey(),
- );
- if (anchorHTMLElement !== null && focusHTMLElement !== null) {
- const range = document.createRange();
- let firstHTMLElement;
- let firstOffset;
- let lastHTMLElement;
- let lastOffset;
- if (focus.isBefore(anchor)) {
- firstHTMLElement = focusHTMLElement;
- firstOffset = focus.offset;
- lastHTMLElement = anchorHTMLElement;
- lastOffset = anchor.offset;
- } else {
- firstHTMLElement = anchorHTMLElement;
- firstOffset = anchor.offset;
- lastHTMLElement = focusHTMLElement;
- lastOffset = focus.offset;
- }
- const firstHTMLElementTextChild = getDOMTextNode(firstHTMLElement);
- const lastHTMLElementtextChild = getDOMTextNode(lastHTMLElement);
- range.setStart(
- firstHTMLElementTextChild || firstHTMLElement,
- firstOffset,
- );
- range.setEnd(lastHTMLElementtextChild || lastHTMLElement, lastOffset);
- removeRangeListener();
- removeRangeListener = positionNodeOnRange(
- editor,
- range,
- (domNodes) => {
- if (onReposition === undefined) {
- for (const domNode of domNodes) {
- const domNodeStyle = domNode.style;
+ removeRangeListener();
+ removeRangeListener = positionNodeOnRange(editor, range, (domNodes) => {
+ if (onReposition === undefined) {
+ for (const domNode of domNodes) {
+ const domNodeStyle = domNode.style;
- if (domNodeStyle.background !== 'Highlight') {
- domNodeStyle.background = 'Highlight';
- }
- if (domNodeStyle.color !== 'HighlightText') {
- domNodeStyle.color = 'HighlightText';
- }
- if (domNodeStyle.marginTop !== px(-1.5)) {
- domNodeStyle.marginTop = px(-1.5);
- }
- if (domNodeStyle.paddingTop !== px(4)) {
- domNodeStyle.paddingTop = px(4);
- }
- if (domNodeStyle.paddingBottom !== px(0)) {
- domNodeStyle.paddingBottom = px(0);
- }
- }
- } else {
- onReposition(domNodes);
+ if (domNodeStyle.background !== 'Highlight') {
+ domNodeStyle.background = 'Highlight';
+ }
+ if (domNodeStyle.color !== 'HighlightText') {
+ domNodeStyle.color = 'HighlightText';
+ }
+ if (domNodeStyle.marginTop !== px(-1.5)) {
+ domNodeStyle.marginTop = px(-1.5);
}
- },
- );
- }
+ if (domNodeStyle.paddingTop !== px(4)) {
+ domNodeStyle.paddingTop = px(4);
+ }
+ if (domNodeStyle.paddingBottom !== px(0)) {
+ domNodeStyle.paddingBottom = px(0);
+ }
+ }
+ } else {
+ onReposition(domNodes);
+ }
+ });
}
previousAnchorNode = currentAnchorNode;
+ previousAnchorNodeDOM = currentAnchorNodeDOM;
previousAnchorOffset = currentAnchorOffset;
previousFocusNode = currentFocusNode;
+ previousFocusNodeDOM = currentFocusNodeDOM;
previousFocusOffset = currentFocusOffset;
});
}
compute(editor.getEditorState());
return mergeRegister(
editor.registerUpdateListener(({editorState}) => compute(editorState)),
- removeRangeListener,
() => {
removeRangeListener();
},
diff --git a/packages/lexical-utils/src/positionNodeOnRange.ts b/packages/lexical-utils/src/positionNodeOnRange.ts
index 2ae7df5e35e..4202aa51a03 100644
--- a/packages/lexical-utils/src/positionNodeOnRange.ts
+++ b/packages/lexical-utils/src/positionNodeOnRange.ts
@@ -121,7 +121,7 @@ export default function mlcPositionNodeOnRange(
return stop();
}
const currentParentDOMNode = currentRootDOMNode.parentElement;
- if (currentParentDOMNode === null || !isHTMLElement(currentParentDOMNode)) {
+ if (!isHTMLElement(currentParentDOMNode)) {
return stop();
}
stop();
diff --git a/packages/lexical-website/docs/concepts/commands.md b/packages/lexical-website/docs/concepts/commands.md
index a773b640186..5456058c3cf 100644
--- a/packages/lexical-website/docs/concepts/commands.md
+++ b/packages/lexical-website/docs/concepts/commands.md
@@ -31,6 +31,8 @@ editor.registerCommand(
Commands can be dispatched from anywhere you have access to the `editor` such as a Toolbar Button, an event listener, or a Plugin, but most of the core commands are dispatched from [`LexicalEvents.ts`](https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalEvents.ts).
+Calling `dispatchCommand` will implicitly call `editor.update` to trigger its command listeners if it was not called from inside `editor.update`.
+
```js
editor.dispatchCommand(command, payload);
```
@@ -70,6 +72,10 @@ editor.registerCommand(
You can register a command from anywhere you have access to the `editor` object, but it's important that you remember to clean up the listener with its remove listener callback when it's no longer needed.
+The command listener will always be called from an `editor.update`, so you may use dollar functions. You should not use
+`editor.update` (and *never* call `editor.read`) synchronously from within a command listener. It is safe to call
+`editor.getEditorState().read` if you need to read the previous state after updates have already been made.
+
```js
const removeListener = editor.registerCommand(
COMMAND,
diff --git a/packages/lexical-website/docs/concepts/nodes.md b/packages/lexical-website/docs/concepts/nodes.md
index baa60eb92c5..99526c6d082 100644
--- a/packages/lexical-website/docs/concepts/nodes.md
+++ b/packages/lexical-website/docs/concepts/nodes.md
@@ -183,7 +183,7 @@ export class CustomParagraph extends ElementNode {
return dom;
}
- updateDOM(prevNode: CustomParagraph, dom: HTMLElement): boolean {
+ updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean {
// Returning false tells Lexical that this node does not need its
// DOM element replacing with a new copy from createDOM.
return false;
@@ -231,7 +231,7 @@ export class ColoredNode extends TextNode {
}
updateDOM(
- prevNode: ColoredNode,
+ prevNode: this,
dom: HTMLElement,
config: EditorConfig,
): boolean {
diff --git a/packages/lexical-website/docs/concepts/selection.md b/packages/lexical-website/docs/concepts/selection.md
index e1c1a152fd9..0923b3befa1 100644
--- a/packages/lexical-website/docs/concepts/selection.md
+++ b/packages/lexical-website/docs/concepts/selection.md
@@ -116,4 +116,54 @@ editor.update(() => {
// You can also clear selection by setting it to `null`.
$setSelection(null);
});
-```
\ No newline at end of file
+```
+
+## Focus
+
+You may notice that when you issue an `editor.update` or
+`editor.dispatchCommand` then the editor can "steal focus" if there is
+a selection and the editor is editable. This is because the Lexical
+selection is reconciled to the DOM selection during reconciliation,
+and the browser's focus follows its DOM selection.
+
+If you want to make updates or dispatch commands to the editor without
+changing the selection, can use the `'skip-dom-selection'` update tag
+(added in v0.22.0):
+
+```js
+// Call this from an editor.update or command listener
+$addUpdateTag('skip-dom-selection');
+```
+
+If you want to add this tag during processing of a `dispatchCommand`,
+you can wrap it in an `editor.update`:
+
+```js
+// NOTE: If you are already in a command listener or editor.update,
+// do *not* nest a second editor.update! Nested updates have
+// confusing semantics (dispatchCommand will re-use the
+// current update without nesting)
+editor.update(() => {
+ $addUpdateTag('skip-dom-selection');
+ editor.dispatchCommand(/* … */);
+});
+```
+
+If you have to support older versions of Lexical, you can mark the editor
+as not editable during the update or dispatch.
+
+```js
+// NOTE: This code should be *outside* of your update or command listener, e.g.
+// directly in the DOM event listener
+const prevEditable = editor.isEditable();
+editor.setEditable(false);
+editor.update(
+ () => {
+ // run your update code or editor.dispatchCommand in here
+ }, {
+ onUpdate: () => {
+ editor.setEditable(prevEditable);
+ },
+ },
+);
+```
diff --git a/packages/lexical-website/docs/react/create_plugin.md b/packages/lexical-website/docs/react/create_plugin.md
index 310d964672c..6ebf19263fe 100644
--- a/packages/lexical-website/docs/react/create_plugin.md
+++ b/packages/lexical-website/docs/react/create_plugin.md
@@ -1,7 +1,3 @@
----
-sidebar_position: 2
----
-
# Creating a React Plugin
In addition to using the Lexical React plugins offered by the core library, you can make your own plugins to extend or alter Lexical's functionality to suit your own use cases.
@@ -18,7 +14,7 @@ If the Plugin introduces new nodes, they have to be registered in `initialConfig
```js
const initialConfig = {
- namespace: "MyEditor",
+ namespace: 'MyEditor',
nodes: [MyLexicalNode],
};
```
diff --git a/packages/lexical-website/docs/react/faq.md b/packages/lexical-website/docs/react/faq.md
index c98dce9be91..b63a2295e5a 100644
--- a/packages/lexical-website/docs/react/faq.md
+++ b/packages/lexical-website/docs/react/faq.md
@@ -1,6 +1,3 @@
----
----
-
# React FAQ
## My app does not work in dev when using StrictMode, help!?
@@ -14,18 +11,18 @@ conventions and guidelines. This is a great place to start:
Some Lexical-specific concerns (which are consequences of React's
concurrent and StrictMode semantics, not due to anything unusual in Lexical):
-* In React 19, `useMemo` calls are cached across StrictMode re-renders, so
+- In React 19, `useMemo` calls are cached across StrictMode re-renders, so
only one editor will be used for both renders. If you have a `useEffect`
call with side-effects (such as updating the document when a plug-in
initializes), then you should first check to make sure that this effect
has not already occurred (e.g. by checking the state of the document or
undoing the change as a cleanup function returned by the effect)
-* `LexicalComposer`'s initialConfig prop is only considered once during
+- `LexicalComposer`'s initialConfig prop is only considered once during
the first render (`useMemo` is used to create the `LexicalComposerContext`
which includes the editor and theme)
-* If you are using an `editorState` argument in the config when creating the
+- If you are using an `editorState` argument in the config when creating the
editor, it will only be called once when the editor is created.
-* You should generally prefer to use hooks that return state such as
+- You should generally prefer to use hooks that return state such as
`useLexicalEditable` (`useLexicalSubscription` is a generalization of this
style) rather than manually registering the listeners and expecting a
particular sequence of triggers to be called, especially
@@ -45,10 +42,10 @@ build of Lexical that the hook was imported from.
The most common root causes of this issue are:
-* You are trying to use `useLexicalComposerContext()` in a component that is
+- You are trying to use `useLexicalComposerContext()` in a component that is
not a child of the `LexicalComposer`. If you need to do that, you need to
pass the context or editor up the tree with something like `EditorRefPlugin`.
-* You have multiple builds of Lexical in your project. This could be because
+- You have multiple builds of Lexical in your project. This could be because
you have a dependency that has a direct dependency on some other version
of Lexical (these packages should have Lexical as `peerDependencies`, but
not all do), or because your project mixes import and require statements
diff --git a/packages/lexical-website/docs/react/index.md b/packages/lexical-website/docs/react/index.md
index b0150b10116..1b811e5b517 100644
--- a/packages/lexical-website/docs/react/index.md
+++ b/packages/lexical-website/docs/react/index.md
@@ -1,9 +1,5 @@
---
-id: "index"
-title: "Lexical API"
-sidebar_label: "Introduction"
-sidebar_position: 0
-custom_edit_url: null
+sidebar_label: 'Introduction'
---
# Lexical + React
@@ -13,7 +9,7 @@ To make it easier for React users to implement rich-text editors, Lexical expose
- {`Getting Started Guide`}
+{`Getting Started Guide`}
## Supported Versions
diff --git a/packages/lexical-website/docs/react/plugins.md b/packages/lexical-website/docs/react/plugins.md
index c40f8c22251..ca97f7935ca 100644
--- a/packages/lexical-website/docs/react/plugins.md
+++ b/packages/lexical-website/docs/react/plugins.md
@@ -1,7 +1,3 @@
----
-sidebar_position: 1
----
-
# Lexical Plugins
React-based plugins are using Lexical editor instance from `` context:
@@ -29,7 +25,7 @@ const initialConfig = {
...
-
+;
```
> Note: Many plugins might require you to register the one or many Lexical nodes in order for the plugin to work. You can do this by passing a reference to the node to the `nodes` array in your initial editor configuration.
@@ -45,7 +41,7 @@ const initialConfig = {
### `LexicalPlainTextPlugin`
-React wrapper for `@lexical/plain-text` that adds major features for plain text editing, including typing, deletion and copy/pasting
+React wrapper for `@lexical/plain-text` that adds major features for plain text editing, including typing, deletion and copy/pasting.
```jsx
@@ -77,7 +73,7 @@ Plugin that calls `onChange` whenever Lexical state is updated. Using `ignoreHis
### `LexicalHistoryPlugin`
-React wrapper for `@lexical/history` that adds support for history stack management and `undo` / `redo` commands
+React wrapper for `@lexical/history` that adds support for history stack management and `undo` / `redo` commands.
```jsx
@@ -85,7 +81,7 @@ React wrapper for `@lexical/history` that adds support for history stack managem
### `LexicalLinkPlugin`
-React wrapper for `@lexical/link` that adds support for links, including `$toggleLink` command support that toggles link for selected text
+React wrapper for `@lexical/link` that adds support for links, including `$toggleLink` command support that toggles link for selected text.
```jsx
@@ -111,7 +107,7 @@ React wrapper for `@lexical/list` that adds support for check lists. Note that i
[![See API Documentation](/img/see-api-documentation.svg)](/docs/api/modules/lexical_react_LexicalTablePlugin)
-React wrapper for `@lexical/table` that adds support for tables
+React wrapper for `@lexical/table` that adds support for tables.
```jsx
@@ -157,7 +153,7 @@ const MATCHERS = [
### `LexicalClearEditorPlugin`
-Adds `clearEditor` command support to clear editor's content
+Adds `clearEditor` command support to clear editor's content.
```jsx
@@ -165,7 +161,7 @@ Adds `clearEditor` command support to clear editor's content
### `LexicalMarkdownShortcutPlugin`
-Adds markdown shortcut support: headings, lists, code blocks, quotes, links and inline styles (bold, italic, strikethrough)
+Adds markdown shortcut support: headings, lists, code blocks, quotes, links and inline styles (bold, italic, strikethrough).
```jsx
@@ -184,7 +180,9 @@ In order to use `TableOfContentsPlugin`, you need to pass a callback function in
```jsx
{(tableOfContentsArray) => {
- return ;
+ return (
+
+ );
}}
```
@@ -195,8 +193,8 @@ Allows you to get a ref to the underlying editor instance outside of LexicalComp
from a separate part of your application.
```jsx
- const editorRef = useRef(null);
-
+const editorRef = useRef(null);
+;
```
### `LexicalSelectionAlwaysOnDisplay`
@@ -204,5 +202,5 @@ from a separate part of your application.
By default, browser text selection becomes invisible when clicking away from the editor. This plugin ensures the selection remains visible.
```jsx
-
-```
\ No newline at end of file
+
+```
diff --git a/packages/lexical-website/docusaurus.config.js b/packages/lexical-website/docusaurus.config.js
index 4559ce4ca2c..33c47913249 100644
--- a/packages/lexical-website/docusaurus.config.js
+++ b/packages/lexical-website/docusaurus.config.js
@@ -335,6 +335,10 @@ const config = {
},
{
items: [
+ {
+ href: 'https://discord.gg/KmG4wQnnD9',
+ label: 'Discord',
+ },
{
href: 'https://stackoverflow.com/questions/tagged/lexicaljs',
label: 'Stack Overflow',
diff --git a/packages/lexical-website/sidebars.js b/packages/lexical-website/sidebars.js
index d1f61b1374a..523933de4c2 100644
--- a/packages/lexical-website/sidebars.js
+++ b/packages/lexical-website/sidebars.js
@@ -71,12 +71,13 @@ const sidebars = {
type: 'category',
},
{
- items: [{dirName: 'react', type: 'autogenerated'}],
+ items: [
+ 'react/index',
+ 'react/plugins',
+ 'react/create_plugin',
+ 'react/faq',
+ ],
label: 'React',
- link: {
- id: 'react/index',
- type: 'doc',
- },
type: 'category',
},
{
diff --git a/packages/lexical/flow/Lexical.js.flow b/packages/lexical/flow/Lexical.js.flow
index dccc5987079..1779c2f272a 100644
--- a/packages/lexical/flow/Lexical.js.flow
+++ b/packages/lexical/flow/Lexical.js.flow
@@ -80,7 +80,10 @@ declare export var IS_ITALIC: number;
declare export var IS_STRIKETHROUGH: number;
declare export var IS_SUBSCRIPT: number;
declare export var IS_SUPERSCRIPT: number;
-declare export var IS_UNDERLIN: number;
+declare export var IS_UNDERLINE: number;
+declare export var IS_UPPERCASE: number;
+declare export var IS_LOWERCASE: number;
+declare export var IS_CAPITALIZE: number;
declare export var TEXT_TYPE_TO_FORMAT: Record;
/**
@@ -243,6 +246,9 @@ type TextNodeThemeClasses = {
code?: EditorThemeClassName,
subscript?: EditorThemeClassName,
superscript?: EditorThemeClassName,
+ lowercase?: EditorThemeClassName,
+ uppercase?: EditorThemeClassName,
+ capitalize?: EditorThemeClassName,
};
export type EditorThemeClasses = {
characterLimit?: EditorThemeClassName,
@@ -415,8 +421,7 @@ declare export class LexicalNode {
getTextContentSize(includeDirectionless?: boolean): number;
createDOM(config: EditorConfig, editor: LexicalEditor): HTMLElement;
updateDOM(
- // $FlowFixMe
- prevNode: any,
+ prevNode: this,
dom: HTMLElement,
config: EditorConfig,
): boolean;
@@ -585,7 +590,11 @@ export type TextFormatType =
| 'highlight'
| 'code'
| 'subscript'
- | 'superscript';
+ | 'superscript'
+ | 'lowercase'
+ | 'uppercase'
+ | 'capitalize';
+
type TextModeType = 'normal' | 'token' | 'segmented';
declare export class TextNode extends LexicalNode {
@@ -611,11 +620,6 @@ declare export class TextNode extends LexicalNode {
getTextContent(): string;
getFormatFlags(type: TextFormatType, alignWithFormat: null | number): number;
createDOM(config: EditorConfig): HTMLElement;
- updateDOM(
- prevNode: TextNode,
- dom: HTMLElement,
- config: EditorConfig,
- ): boolean;
selectionTransform(
prevSelection: null | BaseSelection,
nextSelection: RangeSelection,
@@ -707,7 +711,6 @@ declare export class RootNode extends ElementNode {
replace(node: N): N;
insertBefore(nodeToInsert: T): T;
insertAfter(nodeToInsert: T): T;
- updateDOM(prevNode: RootNode, dom: HTMLElement): false;
append(...nodesToAppend: Array): this;
canBeEmpty(): false;
}
@@ -850,7 +853,6 @@ declare export class ParagraphNode extends ElementNode {
static clone(node: ParagraphNode): ParagraphNode;
constructor(key?: NodeKey): void;
createDOM(config: EditorConfig): HTMLElement;
- updateDOM(prevNode: ParagraphNode, dom: HTMLElement): boolean;
insertNewAfter(
selection: RangeSelection,
restoreSelection?: boolean,
diff --git a/packages/lexical/src/LexicalConstants.ts b/packages/lexical/src/LexicalConstants.ts
index 81b86a372ef..aead3dbddff 100644
--- a/packages/lexical/src/LexicalConstants.ts
+++ b/packages/lexical/src/LexicalConstants.ts
@@ -44,6 +44,9 @@ export const IS_CODE = 1 << 4;
export const IS_SUBSCRIPT = 1 << 5;
export const IS_SUPERSCRIPT = 1 << 6;
export const IS_HIGHLIGHT = 1 << 7;
+export const IS_LOWERCASE = 1 << 8;
+export const IS_UPPERCASE = 1 << 9;
+export const IS_CAPITALIZE = 1 << 10;
export const IS_ALL_FORMATTING =
IS_BOLD |
@@ -53,7 +56,10 @@ export const IS_ALL_FORMATTING =
IS_CODE |
IS_SUBSCRIPT |
IS_SUPERSCRIPT |
- IS_HIGHLIGHT;
+ IS_HIGHLIGHT |
+ IS_LOWERCASE |
+ IS_UPPERCASE |
+ IS_CAPITALIZE;
// Text node details
export const IS_DIRECTIONLESS = 1;
@@ -97,13 +103,16 @@ export const LTR_REGEX = new RegExp('^[^' + RTL + ']*[' + LTR + ']');
export const TEXT_TYPE_TO_FORMAT: Record = {
bold: IS_BOLD,
+ capitalize: IS_CAPITALIZE,
code: IS_CODE,
highlight: IS_HIGHLIGHT,
italic: IS_ITALIC,
+ lowercase: IS_LOWERCASE,
strikethrough: IS_STRIKETHROUGH,
subscript: IS_SUBSCRIPT,
superscript: IS_SUPERSCRIPT,
underline: IS_UNDERLINE,
+ uppercase: IS_UPPERCASE,
};
export const DETAIL_TYPE_TO_DETAIL: Record = {
diff --git a/packages/lexical/src/LexicalEditor.ts b/packages/lexical/src/LexicalEditor.ts
index 02be308ba84..1961c8f4f34 100644
--- a/packages/lexical/src/LexicalEditor.ts
+++ b/packages/lexical/src/LexicalEditor.ts
@@ -37,7 +37,7 @@ import {
getCachedTypeToNodeMap,
getDefaultView,
getDOMSelection,
- markAllNodesAsDirty,
+ markNodesWithTypesAsDirty,
} from './LexicalUtils';
import {ArtificialNode__DO_NOT_USE} from './nodes/ArtificialNode';
import {DecoratorNode} from './nodes/LexicalDecoratorNode';
@@ -69,6 +69,9 @@ export type TextNodeThemeClasses = {
code?: EditorThemeClassName;
highlight?: EditorThemeClassName;
italic?: EditorThemeClassName;
+ lowercase?: EditorThemeClassName;
+ uppercase?: EditorThemeClassName;
+ capitalize?: EditorThemeClassName;
strikethrough?: EditorThemeClassName;
subscript?: EditorThemeClassName;
superscript?: EditorThemeClassName;
@@ -131,6 +134,7 @@ export type EditorThemeClasses = {
quote?: EditorThemeClassName;
root?: EditorThemeClassName;
rtl?: EditorThemeClassName;
+ tab?: EditorThemeClassName;
table?: EditorThemeClassName;
tableAddColumns?: EditorThemeClassName;
tableAddRows?: EditorThemeClassName;
@@ -278,11 +282,11 @@ export type LexicalCommand = {
*
* editor.registerCommand(MY_COMMAND, payload => {
* // Type of `payload` is inferred here. But lets say we want to extract a function to delegate to
- * handleMyCommand(editor, payload);
+ * $handleMyCommand(editor, payload);
* return true;
* });
*
- * function handleMyCommand(editor: LexicalEditor, payload: CommandPayloadType) {
+ * function $handleMyCommand(editor: LexicalEditor, payload: CommandPayloadType) {
* // `payload` is of type `SomeType`, extracted from the command.
* }
* ```
@@ -774,14 +778,24 @@ export class LexicalEditor {
}
/**
* Registers a listener that will trigger anytime the provided command
- * is dispatched, subject to priority. Listeners that run at a higher priority can "intercept"
- * commands and prevent them from propagating to other handlers by returning true.
+ * is dispatched with {@link LexicalEditor.dispatch}, subject to priority.
+ * Listeners that run at a higher priority can "intercept" commands and
+ * prevent them from propagating to other handlers by returning true.
*
- * Listeners registered at the same priority level will run deterministically in the order of registration.
+ * Listeners are always invoked in an {@link LexicalEditor.update} and can
+ * call dollar functions.
+ *
+ * Listeners registered at the same priority level will run
+ * deterministically in the order of registration.
*
* @param command - the command that will trigger the callback.
* @param listener - the function that will execute when the command is dispatched.
* @param priority - the relative priority of the listener. 0 | 1 | 2 | 3 | 4
+ * (or {@link COMMAND_PRIORITY_EDITOR} |
+ * {@link COMMAND_PRIORITY_LOW} |
+ * {@link COMMAND_PRIORITY_NORMAL} |
+ * {@link COMMAND_PRIORITY_HIGH} |
+ * {@link COMMAND_PRIORITY_CRITICAL})
* @returns a teardown function that can be used to cleanup the listener.
*/
registerCommand