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, ''); } } }