diff --git a/packages/lexical-playground/src/Editor.tsx b/packages/lexical-playground/src/Editor.tsx
index e76320569a0..542630b0ddb 100644
--- a/packages/lexical-playground/src/Editor.tsx
+++ b/packages/lexical-playground/src/Editor.tsx
@@ -75,6 +75,7 @@ import SpeechToTextPlugin from './plugins/SpeechToTextPlugin';
import TabFocusPlugin from './plugins/TabFocusPlugin';
import TableCellActionMenuPlugin from './plugins/TableActionMenuPlugin';
import TableCellResizer from './plugins/TableCellResizer';
+import TableFitNestedTablePlugin from './plugins/TableFitNestedTablePlugin';
import TableHoverActionsV2Plugin from './plugins/TableHoverActionsV2Plugin';
import TableOfContentsPlugin from './plugins/TableOfContentsPlugin';
import TableScrollShadowPlugin from './plugins/TableScrollShadowPlugin';
@@ -246,9 +247,9 @@ export default function Editor(): JSX.Element {
hasCellMerge={tableCellMerge}
hasCellBackgroundColor={tableCellBackgroundColor}
hasHorizontalScroll={tableHorizontalScroll && !hasFitNestedTables}
- hasFitNestedTables={hasFitNestedTables}
hasNestedTables={hasNestedTables}
/>
+ {hasFitNestedTables ? : null}
diff --git a/packages/lexical-playground/src/plugins/TableFitNestedTablePlugin/index.tsx b/packages/lexical-playground/src/plugins/TableFitNestedTablePlugin/index.tsx
new file mode 100644
index 00000000000..6eb52e9b100
--- /dev/null
+++ b/packages/lexical-playground/src/plugins/TableFitNestedTablePlugin/index.tsx
@@ -0,0 +1,148 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type {LexicalNode} from 'lexical';
+
+import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
+import {$isTableNode, TableNode} from '@lexical/table';
+import {$dfs, $findMatchingParent} from '@lexical/utils';
+import {$getNodeByKey, $isRootOrShadowRoot, LexicalEditor} from 'lexical';
+import {useEffect} from 'react';
+
+const PIXEL_VALUE_REG_EXP = /^(\d+(?:\.\d+)?)px$/;
+
+function calculateHorizontalInsets(
+ dom: HTMLElement,
+ editorWindow: Window,
+): number {
+ const computedStyle = editorWindow.getComputedStyle(dom);
+ const paddingLeft = computedStyle.getPropertyValue('padding-left') || '0px';
+ const paddingRight = computedStyle.getPropertyValue('padding-right') || '0px';
+ const borderLeftWidth =
+ computedStyle.getPropertyValue('border-left-width') || '0px';
+ const borderRightWidth =
+ computedStyle.getPropertyValue('border-right-width') || '0px';
+
+ if (
+ !PIXEL_VALUE_REG_EXP.test(paddingLeft) ||
+ !PIXEL_VALUE_REG_EXP.test(paddingRight) ||
+ !PIXEL_VALUE_REG_EXP.test(borderLeftWidth) ||
+ !PIXEL_VALUE_REG_EXP.test(borderRightWidth)
+ ) {
+ return 0;
+ }
+ const paddingLeftPx = parseFloat(paddingLeft);
+ const paddingRightPx = parseFloat(paddingRight);
+ const borderLeftWidthPx = parseFloat(borderLeftWidth);
+ const borderRightWidthPx = parseFloat(borderRightWidth);
+
+ return (
+ paddingLeftPx + paddingRightPx + borderLeftWidthPx + borderRightWidthPx
+ );
+}
+
+function getTotalTableWidth(colWidths: readonly number[]): number {
+ return colWidths.reduce((curWidth, width) => curWidth + width, 0);
+}
+
+function $calculateResizeRootTables(
+ tables: ReadonlySet,
+): ReadonlyArray {
+ const inputTables: ReadonlySet = tables;
+ const roots: TableNode[] = [];
+ for (const table of tables) {
+ if (
+ $findMatchingParent(table, (n) => n !== table && inputTables.has(n)) ===
+ null
+ ) {
+ roots.push(table);
+ }
+ }
+ return roots;
+}
+
+function $resizeDOMColWidthsToFit(
+ editor: LexicalEditor,
+ node: TableNode,
+): void {
+ const editorWindow = editor._window;
+ if (!editorWindow) {
+ return;
+ }
+ const allNestedTables = $dfs(node)
+ .map((n) => n.node)
+ .filter($isTableNode);
+ for (const table of allNestedTables) {
+ const element = editor.getElementByKey(table.getKey());
+ if (!element) {
+ continue;
+ }
+ const tableParent = table.getParent();
+ if (!tableParent) {
+ continue;
+ }
+ const parentShadowRoot = $findMatchingParent(
+ tableParent,
+ $isRootOrShadowRoot,
+ );
+ const fitContainer = parentShadowRoot
+ ? editor.getElementByKey(parentShadowRoot.getKey())
+ : editor.getRootElement();
+ if (!fitContainer) {
+ continue;
+ }
+
+ const oldColWidths = table.getColWidths();
+ if (!oldColWidths) {
+ continue;
+ }
+
+ const availableWidth = fitContainer.getBoundingClientRect().width;
+ const horizontalInsets = calculateHorizontalInsets(
+ fitContainer,
+ editorWindow,
+ );
+ const usableWidth = availableWidth - horizontalInsets;
+ const tableWidth = getTotalTableWidth(oldColWidths);
+
+ const proportionalWidth = Math.min(1, usableWidth / tableWidth);
+
+ table.scaleDOMColWidths(element, proportionalWidth);
+ }
+}
+
+/**
+ * When mounted, listens for table mutations and resizes nested tables so they
+ * fit the width of their container (nearest root or shadow root). Only affects
+ * DOM column widths; underlying column widths are not modified.
+ */
+export default function TableFitNestedTablePlugin(): null {
+ const [editor] = useLexicalComposerContext();
+
+ useEffect(() => {
+ return editor.registerMutationListener(TableNode, (nodeMutations) => {
+ editor.getEditorState().read(() => {
+ const modifiedTables = new Set();
+ for (const [nodeKey, mutation] of nodeMutations) {
+ if (mutation === 'created' || mutation === 'updated') {
+ const tableNode = $getNodeByKey(nodeKey);
+ if (tableNode) {
+ modifiedTables.add(tableNode);
+ }
+ }
+ }
+ const resizeRoots = $calculateResizeRootTables(modifiedTables);
+ resizeRoots.forEach((root) => {
+ $resizeDOMColWidthsToFit(editor, root);
+ });
+ });
+ });
+ }, [editor]);
+
+ return null;
+}
diff --git a/packages/lexical-react/src/LexicalTablePlugin.ts b/packages/lexical-react/src/LexicalTablePlugin.ts
index 2fed828f829..d651f77a59c 100644
--- a/packages/lexical-react/src/LexicalTablePlugin.ts
+++ b/packages/lexical-react/src/LexicalTablePlugin.ts
@@ -45,14 +45,6 @@ export interface TablePluginProps {
* @experimental Nested tables are not officially supported.
*/
hasNestedTables?: boolean;
- /**
- * When `true` (default `false`), nested tables will be visually resized to fit the width of the nearest
- * root or shadow root (including table cells). This only affects the rendered table, underlying column widths
- * are not modified.
- *
- * @experimental Nested tables are not officially supported.
- */
- hasFitNestedTables?: boolean;
}
/**
@@ -67,7 +59,6 @@ export function TablePlugin({
hasTabHandler = true,
hasHorizontalScroll = false,
hasNestedTables = false,
- hasFitNestedTables = false,
}: TablePluginProps): JSX.Element | null {
const [editor] = useLexicalComposerContext();
@@ -82,15 +73,10 @@ export function TablePlugin({
}, [editor, hasHorizontalScroll]);
const hasNestedTablesSignal = usePropSignal(hasNestedTables);
- const hasFitNestedTablesSignal = usePropSignal(hasFitNestedTables);
useEffect(
- () =>
- registerTablePlugin(editor, {
- hasFitNestedTables: hasFitNestedTablesSignal,
- hasNestedTables: hasNestedTablesSignal,
- }),
- [editor, hasNestedTablesSignal, hasFitNestedTablesSignal],
+ () => registerTablePlugin(editor, {hasNestedTables: hasNestedTablesSignal}),
+ [editor, hasNestedTablesSignal],
);
useEffect(
diff --git a/packages/lexical-selection/src/__tests__/unit/LexicalSelection.test.tsx b/packages/lexical-selection/src/__tests__/unit/LexicalSelection.test.tsx
index b427beeba94..ad3b013f006 100644
--- a/packages/lexical-selection/src/__tests__/unit/LexicalSelection.test.tsx
+++ b/packages/lexical-selection/src/__tests__/unit/LexicalSelection.test.tsx
@@ -38,6 +38,7 @@ import {
$getRoot,
$getSelection,
$isElementNode,
+ $isLineBreakNode,
$isRangeSelection,
$isTextNode,
$setSelection,
@@ -230,6 +231,66 @@ describe('LexicalSelection tests', () => {
);
});
+ test('Bold format preserved when typing between consecutive line breaks', async () => {
+ await applySelectionInputs(
+ [formatBold(), insertText('hello')],
+ update,
+ editor!,
+ );
+
+ // Move cursor between "he" and "llo"
+ await update(() => {
+ const paragraph = $getRoot().getFirstChildOrThrow();
+ invariant($isElementNode(paragraph));
+ const textNode = paragraph.getFirstChildOrThrow();
+ invariant($isTextNode(textNode));
+ textNode.select(2, 2);
+ });
+
+ // Shift+Enter twice (logical line breaks)
+ await update(() => {
+ const selection = $getSelection();
+ if ($isRangeSelection(selection)) {
+ selection.insertLineBreak();
+ selection.insertLineBreak();
+ }
+ });
+
+ // Move cursor between the two
nodes in the paragraph
+ await update(() => {
+ const paragraph = $getRoot().getFirstChildOrThrow();
+ invariant($isElementNode(paragraph));
+ const children = paragraph.getChildren();
+ let betweenOffset: null | number = null;
+
+ for (let i = 0; i < children.length - 1; i++) {
+ if (
+ $isLineBreakNode(children[i]) &&
+ $isLineBreakNode(children[i + 1])
+ ) {
+ betweenOffset = i + 1;
+ break;
+ }
+ }
+
+ if (betweenOffset === null) {
+ throw new Error('Expected to find consecutive line breaks');
+ }
+
+ paragraph.select(betweenOffset, betweenOffset);
+ });
+
+ await applySelectionInputs([insertText('x')], update, editor!);
+
+ editor!.getEditorState().read(() => {
+ const xNode = $getRoot()
+ .getAllTextNodes()
+ .find((node) => node.getTextContent() === 'x');
+ expect(xNode).toBeDefined();
+ expect(xNode!.hasFormat('bold')).toBe(true);
+ });
+ });
+
test('Ctrl+Backspace deletes list created by typing "- "', async () => {
await applySelectionInputs(
[insertText('-'), insertText(' ')],
diff --git a/packages/lexical-table/src/LexicalTableExtension.ts b/packages/lexical-table/src/LexicalTableExtension.ts
index b51321da9f3..6bfc30e6de9 100644
--- a/packages/lexical-table/src/LexicalTableExtension.ts
+++ b/packages/lexical-table/src/LexicalTableExtension.ts
@@ -47,14 +47,6 @@ export interface TableConfig {
* @experimental Nested tables are not officially supported.
*/
hasNestedTables: boolean;
- /**
- * When `true` (default `false`), nested tables will be visually resized to fit the width of the nearest
- * root or shadow root (including table cells). This only affects the rendered table, underlying column widths
- * are not modified.
- *
- * @experimental Nested tables are not officially supported.
- */
- hasFitNestedTables: boolean;
}
/**
@@ -68,7 +60,6 @@ export const TableExtension = defineExtension({
config: safeCast({
hasCellBackgroundColor: true,
hasCellMerge: true,
- hasFitNestedTables: false,
hasHorizontalScroll: true,
hasNestedTables: false,
hasTabHandler: true,
diff --git a/packages/lexical-table/src/LexicalTablePluginHelpers.ts b/packages/lexical-table/src/LexicalTablePluginHelpers.ts
index 3f809d8f43d..93ba342cc9a 100644
--- a/packages/lexical-table/src/LexicalTablePluginHelpers.ts
+++ b/packages/lexical-table/src/LexicalTablePluginHelpers.ts
@@ -18,13 +18,11 @@ import {
import {
$createParagraphNode,
$getNearestNodeFromDOMNode,
- $getNodeByKey,
$getPreviousSelection,
$getRoot,
$getSelection,
$isElementNode,
$isRangeSelection,
- $isRootOrShadowRoot,
$isTextNode,
$setSelection,
CLICK_COMMAND,
@@ -35,7 +33,6 @@ import {
ElementNode,
isDOMNode,
LexicalEditor,
- LexicalNode,
NodeKey,
RangeSelection,
SELECT_ALL_COMMAND,
@@ -44,7 +41,6 @@ import {
} from 'lexical';
import invariant from 'shared/invariant';
-import {PIXEL_VALUE_REG_EXP} from './constants';
import {
$createTableCellNode,
$isTableCellNode,
@@ -405,17 +401,13 @@ export function registerTableSelectionObserver(
*/
export function registerTablePlugin(
editor: LexicalEditor,
- options?: Pick<
- NamedSignalsOutput,
- 'hasNestedTables' | 'hasFitNestedTables'
- >,
+ options?: Pick, 'hasNestedTables'>,
): () => void {
if (!editor.hasNodes([TableNode])) {
invariant(false, 'TablePlugin: TableNode is not registered on editor');
}
- const {hasNestedTables = signal(false), hasFitNestedTables = signal(false)} =
- options ?? {};
+ const {hasNestedTables = signal(false)} = options ?? {};
return mergeRegister(
editor.registerCommand(
@@ -451,27 +443,6 @@ export function registerTablePlugin(
editor.registerNodeTransform(TableNode, $tableTransform),
editor.registerNodeTransform(TableRowNode, $tableRowTransform),
editor.registerNodeTransform(TableCellNode, $tableCellTransform),
- editor.registerMutationListener(TableNode, (nodeMutations) => {
- if (!hasFitNestedTables.peek()) {
- return;
- }
-
- editor.getEditorState().read(() => {
- const modifiedTables = new Set();
- for (const [nodeKey, mutation] of nodeMutations) {
- if (mutation === 'created' || mutation === 'updated') {
- const tableNode = $getNodeByKey(nodeKey);
- if (tableNode) {
- modifiedTables.add(tableNode);
- }
- }
- }
- const resizeRoots = $calculateResizeRootTables(modifiedTables);
- resizeRoots.forEach((root) => {
- $resizeDOMColWidthsToFit(editor, root);
- });
- });
- }),
);
}
@@ -721,114 +692,3 @@ function $isMultiCellTableSelection(
}
return false;
}
-
-/**
- * Returns horizontal insets of the given node (padding + border).
- *
- * @param dom - The DOM element to calculate the horizontal insets for.
- * @param editorWindow - The window object of the editor.
- * @returns The horizontal insets of the node, in pixels.
- */
-export function $calculateHorizontalInsets(
- dom: HTMLElement,
- editorWindow: Window,
-) {
- const computedStyle = editorWindow.getComputedStyle(dom);
- const paddingLeft = computedStyle.getPropertyValue('padding-left') || '0px';
- const paddingRight = computedStyle.getPropertyValue('padding-right') || '0px';
- const borderLeftWidth =
- computedStyle.getPropertyValue('border-left-width') || '0px';
- const borderRightWidth =
- computedStyle.getPropertyValue('padding-right-width') || '0px';
-
- if (
- !PIXEL_VALUE_REG_EXP.test(paddingLeft) ||
- !PIXEL_VALUE_REG_EXP.test(paddingRight) ||
- !PIXEL_VALUE_REG_EXP.test(borderLeftWidth) ||
- !PIXEL_VALUE_REG_EXP.test(borderRightWidth)
- ) {
- return 0;
- }
- const paddingLeftPx = parseFloat(paddingLeft);
- const paddingRightPx = parseFloat(paddingRight);
- const borderLeftWidthPx = parseFloat(borderLeftWidth);
- const borderRightWidthPx = parseFloat(borderRightWidth);
-
- return (
- paddingLeftPx + paddingRightPx + borderLeftWidthPx + borderRightWidthPx
- );
-}
-
-function getTotalTableWidth(colWidths: readonly number[]) {
- return colWidths.reduce((curWidth, width) => curWidth + width, 0);
-}
-
-// Returns the subset of tables that are not contained by any of the other tables in
-// the input.
-function $calculateResizeRootTables(tables: ReadonlySet) {
- const inputTables: ReadonlySet = tables;
- const roots: TableNode[] = [];
- for (const table of tables) {
- if (
- $findMatchingParent(table, (n) => n !== table && inputTables.has(n)) ===
- null
- ) {
- roots.push(table);
- }
- }
- return roots;
-}
-
-/**
- * Recursively scales the DOM colWidths of all tables starting from the given node.
- * Each table will be scaled to fit the nearest root or shadow root.
- *
- * @param editor the editor instance
- * @param node the table node to resize. The table must have colWidths to be resized
- */
-function $resizeDOMColWidthsToFit(editor: LexicalEditor, node: TableNode) {
- const editorWindow = editor._window;
- if (!editorWindow) {
- return;
- }
- const allNestedTables = $dfs(node)
- .map((n) => n.node)
- .filter($isTableNode);
- for (const table of allNestedTables) {
- const element = editor.getElementByKey(table.getKey());
- if (!element) {
- continue;
- }
- const tableParent = table.getParent();
- if (!tableParent) {
- continue;
- }
- const parentShadowRoot = $findMatchingParent(
- tableParent,
- $isRootOrShadowRoot,
- );
- const fitContainer = parentShadowRoot
- ? editor.getElementByKey(parentShadowRoot.getKey())
- : editor.getRootElement();
- if (!fitContainer) {
- continue;
- }
-
- const oldColWidths = table.getColWidths();
- if (!oldColWidths) {
- continue;
- }
-
- const availableWidth = fitContainer.getBoundingClientRect().width;
- const horizontalInsets = $calculateHorizontalInsets(
- fitContainer,
- editorWindow,
- );
- const usableWidth = availableWidth - horizontalInsets;
- const tableWidth = getTotalTableWidth(oldColWidths);
-
- const proportionalWidth = Math.min(1, usableWidth / tableWidth);
-
- table.scaleDOMColWidths(element, proportionalWidth);
- }
-}
diff --git a/packages/lexical/src/LexicalEvents.ts b/packages/lexical/src/LexicalEvents.ts
index e8a8f0efffa..29ef506b9fd 100644
--- a/packages/lexical/src/LexicalEvents.ts
+++ b/packages/lexical/src/LexicalEvents.ts
@@ -406,7 +406,7 @@ function onSelectionChange(
) {
$updateSelectionFormatStyleFromElementNode(selection, lastNode);
} else {
- $updateSelectionFormatStyle(selection, 0, '');
+ $updateSelectionFormatStyle(selection, selection.format, '');
}
}
}