Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/lexical-playground/src/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -246,9 +247,9 @@ export default function Editor(): JSX.Element {
hasCellMerge={tableCellMerge}
hasCellBackgroundColor={tableCellBackgroundColor}
hasHorizontalScroll={tableHorizontalScroll && !hasFitNestedTables}
hasFitNestedTables={hasFitNestedTables}
hasNestedTables={hasNestedTables}
/>
{hasFitNestedTables ? <TableFitNestedTablePlugin /> : null}
<TableCellResizer />
<TableScrollShadowPlugin />
<ImagesPlugin />
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TableNode>,
): ReadonlyArray<TableNode> {
const inputTables: ReadonlySet<LexicalNode> = 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<TableNode>();
for (const [nodeKey, mutation] of nodeMutations) {
if (mutation === 'created' || mutation === 'updated') {
const tableNode = $getNodeByKey<TableNode>(nodeKey);
if (tableNode) {
modifiedTables.add(tableNode);
}
}
}
const resizeRoots = $calculateResizeRootTables(modifiedTables);
resizeRoots.forEach((root) => {
$resizeDOMColWidthsToFit(editor, root);
});
});
});
}, [editor]);

return null;
}
18 changes: 2 additions & 16 deletions packages/lexical-react/src/LexicalTablePlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -67,7 +59,6 @@ export function TablePlugin({
hasTabHandler = true,
hasHorizontalScroll = false,
hasNestedTables = false,
hasFitNestedTables = false,
}: TablePluginProps): JSX.Element | null {
const [editor] = useLexicalComposerContext();

Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
$getRoot,
$getSelection,
$isElementNode,
$isLineBreakNode,
$isRangeSelection,
$isTextNode,
$setSelection,
Expand Down Expand Up @@ -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 <br> 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(' ')],
Expand Down
9 changes: 0 additions & 9 deletions packages/lexical-table/src/LexicalTableExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -68,7 +60,6 @@ export const TableExtension = defineExtension({
config: safeCast<TableConfig>({
hasCellBackgroundColor: true,
hasCellMerge: true,
hasFitNestedTables: false,
hasHorizontalScroll: true,
hasNestedTables: false,
hasTabHandler: true,
Expand Down
Loading
Loading