Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 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
2 changes: 1 addition & 1 deletion packages/lexical-playground/src/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ export default function Editor(): JSX.Element {
hasFitNestedTables={hasFitNestedTables}
hasNestedTables={hasNestedTables}
/>
<TableCellResizer />
<TableCellResizer hasFitNestedTables={hasFitNestedTables} />
<TableScrollShadowPlugin />
<ImagesPlugin />
<LinkPlugin hasLinkAttributes={hasLinkAttributes} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@ import {
$getTableNodeFromLexicalNodeOrThrow,
$getTableRowIndexFromTableCellNode,
$isTableCellNode,
$isTableNode,
$isTableRowNode,
getDOMCellFromTarget,
getTableElement,
TableNode,
} from '@lexical/table';
import {calculateZoomLevel, mergeRegister} from '@lexical/utils';
import {$dfs, calculateZoomLevel, mergeRegister} from '@lexical/utils';
import {
$getNearestNodeFromDOMNode,
isHTMLElement,
Expand Down Expand Up @@ -53,7 +54,13 @@ const MIN_ROW_HEIGHT = 33;
const MIN_COLUMN_WIDTH = 92;
const ACTIVE_RESIZER_COLOR = '#76b6ff';

function TableCellResizer({editor}: {editor: LexicalEditor}): JSX.Element {
function TableCellResizer({
editor,
hasFitNestedTables,
}: {
editor: LexicalEditor;
hasFitNestedTables: boolean;
}): JSX.Element {
const targetRef = useRef<HTMLElement | null>(null);
const resizerRef = useRef<HTMLDivElement | null>(null);
const tableRectRef = useRef<ClientRect | null>(null);
Expand Down Expand Up @@ -310,11 +317,19 @@ function TableCellResizer({editor}: {editor: LexicalEditor}): JSX.Element {
const newWidth = Math.max(width + widthChange, MIN_COLUMN_WIDTH);
newColWidths[columnIndex] = newWidth;
tableNode.setColWidths(newColWidths);
if (hasFitNestedTables) {
// Marking all child tables as dirty forces them to recalculate their widths.
$dfs(tableNode)
.filter((n) => $isTableNode(n.node))
.forEach((n) => {
n.node.markDirty();
});
}
},
{tag: SKIP_SCROLL_INTO_VIEW_TAG},
);
},
[activeCell, editor],
[activeCell, editor, hasFitNestedTables],
);

const pointerUpHandler = useCallback(
Expand Down Expand Up @@ -491,15 +506,25 @@ function TableCellResizer({editor}: {editor: LexicalEditor}): JSX.Element {
);
}

export default function TableCellResizerPlugin(): null | ReactPortal {
export default function TableCellResizerPlugin({
hasFitNestedTables,
}: {
hasFitNestedTables: boolean;
}): null | ReactPortal {
const [editor] = useLexicalComposerContext();
const isEditable = useLexicalEditable();

return useMemo(
() =>
isEditable
? createPortal(<TableCellResizer editor={editor} />, document.body)
? createPortal(
<TableCellResizer
editor={editor}
hasFitNestedTables={hasFitNestedTables}
/>,
document.body,
)
: null,
[editor, isEditable],
[editor, isEditable, hasFitNestedTables],
);
}
93 changes: 41 additions & 52 deletions packages/lexical-table/src/LexicalTablePluginHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ import {
ElementNode,
isDOMNode,
LexicalEditor,
LexicalNode,
NodeKey,
RangeSelection,
SELECT_ALL_COMMAND,
Expand Down Expand Up @@ -426,7 +425,6 @@ export function registerTablePlugin(
return $tableSelectionInsertClipboardNodesCommand(
payload,
hasNestedTables,
hasFitNestedTables,
);
},
COMMAND_PRIORITY_EDITOR,
Expand All @@ -444,6 +442,15 @@ export function registerTablePlugin(
editor.registerNodeTransform(TableNode, $tableTransform),
editor.registerNodeTransform(TableRowNode, $tableRowTransform),
editor.registerNodeTransform(TableCellNode, $tableCellTransform),
editor.registerNodeTransform(TableNode, (node) => {
if (!hasFitNestedTables.peek()) {
return;
}
const parentCell = $findMatchingParent(node, $isTableCellNode);
if (parentCell) {
$fitNestedTableIntoCell(parentCell, node);
}
}),
);
}

Expand All @@ -452,7 +459,6 @@ function $tableSelectionInsertClipboardNodesCommand(
typeof SELECTION_INSERT_CLIPBOARD_NODES_COMMAND
>,
hasNestedTables: Signal<boolean>,
hasFitNestedTables: Signal<boolean>,
) {
const {nodes, selection} = selectionPayload;

Expand Down Expand Up @@ -486,13 +492,13 @@ function $tableSelectionInsertClipboardNodesCommand(
return $insertTableIntoGrid(nodes[0], selection);
}

// When pasting multiple nodes (including tables) into a cell, update the table to fit.
if (isRangeSelection && hasNestedTables.peek()) {
return $insertTableNodesIntoCells(
nodes,
selection,
hasFitNestedTables.peek(),
);
// If nested tables are enabled, allow pasting a table into a single cell.
if (
isRangeSelection &&
hasNestedTables.peek() &&
!$isMultiCellTableSelection(selection)
) {
return false;
}

// If we reached this point, there's a table in the selection and nested tables are not allowed - reject the paste.
Expand Down Expand Up @@ -676,50 +682,44 @@ function $insertTableIntoGrid(
return true;
}

// Inserts the given nodes (which will include TableNodes) into the table at the given selection.
function $insertTableNodesIntoCells(
nodes: LexicalNode[],
function $isMultiCellTableSelection(
selection: TableSelection | RangeSelection,
hasFitNestedTables: boolean,
) {
// Currently only support pasting into a single cell. In other cases we reject the insertion.
const isMultiCellTableSelection =
if (
$isTableSelection(selection) &&
!selection.focus.getNode().is(selection.anchor.getNode());
const isMultiCellRangeSelection =
!selection.focus.getNode().is(selection.anchor.getNode())
) {
return true;
}
if (
$isRangeSelection(selection) &&
$isTableCellNode(selection.anchor.getNode()) &&
!selection.anchor.getNode().is(selection.focus.getNode());
if (isMultiCellTableSelection || isMultiCellRangeSelection) {
!selection.anchor.getNode().is(selection.focus.getNode())
) {
return true;
}
return false;
}

if (!hasFitNestedTables) {
return false;
}

const focusNode = selection.focus.getNode();
const parentCell = $findMatchingParent(focusNode, $isTableCellNode);
if (!parentCell) {
return false;
}

function $fitNestedTableIntoCell(
parentCell: TableCellNode,
tableNode: TableNode,
) {
const cellWidth = $getCellWidth(parentCell);
if (cellWidth === undefined) {
return false;
}
const borderBoxInsets = $calculateCellInsets(parentCell);
const tables = nodes.filter($isTableNode);
for (const table of tables) {
// Note: here we assume the inset is consistent for cells at all nesting levels.
$resizeTableToFitCell(table, cellWidth, borderBoxInsets);
}
const borderBoxInsets = $calculateCellHorizontalInsets(parentCell);
$resizeTableToFitCell(tableNode, cellWidth, borderBoxInsets);

return false;
}

/**
* Return the width of a specific cell, using the table-level colWidths.
* Return the total width of a specific cell, using the table-level colWidths. Accounts for column spans.
*
* @param cell - The cell to get the width of.
* @returns The total width of the cell, in pixels.
*/
function $getCellWidth(cell: TableCellNode) {
const destinationTableNode = $getTableNodeFromLexicalNodeOrThrow(cell);
Expand All @@ -739,8 +739,11 @@ function $getCellWidth(cell: TableCellNode) {

/**
* Returns horizontal insets of the given cell (padding + border).
*
* @param cell - The cell to calculate the horizontal insets for.
* @returns The horizontal insets of the cell, in pixels.
*/
function $calculateCellInsets(cell: TableCellNode) {
function $calculateCellHorizontalInsets(cell: TableCellNode) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking of exporting this, and $getCellWidth, so that a similar pattern could be used for things like ImageNodes (basically, any node that can be resized)?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding exports is fine as long as the functions are suitable for being maintained as public API, or marked as @internal

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated to expose $getCellWidth. This specific function ($calculateCellHorizontalInsets) is now a user-specified callback and no longer needs to be exported.

const cellDOM = $getEditor().getElementByKey(cell.getKey());
if (cellDOM === null) {
return 0;
Expand Down Expand Up @@ -800,18 +803,4 @@ function $resizeTableToFitCell(

const proportionalWidth = usableWidth / tableWidth;
node.setColWidths(oldColWidths.map((width) => width * proportionalWidth));

const rowChildren = node.getChildren().filter($isTableRowNode);
for (const rowChild of rowChildren) {
const cellChildren = rowChild.getChildren().filter($isTableCellNode);
for (const cellChild of cellChildren) {
const cellWidth = $getCellWidth(cellChild);
if (cellWidth === undefined) {
continue;
}
for (const table of cellChild.getChildren().filter($isTableNode)) {
$resizeTableToFitCell(table, cellWidth, borderBoxInsets);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,76 @@ describe('TableExtension', () => {
expect(innerTableNode.getColWidths()).toEqual([375, 125]);
});
});

it('resizes inner table when expanding larger than parent table cell (with hasNestedTables, hasFitNestedTables)', () => {
const extension = getExtensionDependencyFromEditor(
editor,
TableExtension,
);
extension.output.hasNestedTables.value = true;
extension.output.hasFitNestedTables.value = true;

editor.update(
() => {
const root = $getRoot().clear();

// Outer table is a single 200-wide cell. Inner table is initially two 100-wide cells.
const outerTable = $createTableNode();
outerTable.setColWidths([200]);
const outerRow = $createTableRowNode();
const outerCell = $createTableCellNode();

const innerTable = $createTableNode();
innerTable.setColWidths([100, 100]);
const innerRow = $createTableRowNode();
const innerCell1 = $createTableCellNode();
const innerCell2 = $createTableCellNode();

innerCell1.append($createParagraphNode());
innerCell2.append($createParagraphNode());
innerRow.append(innerCell1, innerCell2);
innerTable.append(innerRow);

outerCell.append(innerTable);
outerRow.append(outerCell);
outerTable.append(outerRow);
root.append(outerTable);
},
{discrete: true},
);

editor.update(
() => {
// Resize the inner table cell to 900 (9x the width of its sibling)
const root = $getRoot();
const outerTable = root.getFirstChild();
assert($isTableNode(outerTable), 'Expected table node');
const outerRow = outerTable.getFirstChild();
assert($isTableRowNode(outerRow), 'Expected table row');
const outerCell = outerRow.getFirstChild();
assert($isTableCellNode(outerCell), 'Expected outer table cell');
const innerTable = outerCell.getFirstChild();
assert($isTableNode(innerTable), 'Expected nested inner table');
innerTable.setColWidths([900, 100]);
},
{discrete: true},
);

editor.getEditorState().read(() => {
const root = $getRoot();
const outerTable = root.getFirstChild();
assert($isTableNode(outerTable), 'Expected table node');
const outerRow = outerTable.getFirstChild();
assert($isTableRowNode(outerRow), 'Expected table row');
const outerCell = outerRow.getFirstChild();
assert($isTableCellNode(outerCell), 'Expected outer table cell');
const innerTable = outerCell.getFirstChild();
assert($isTableNode(innerTable), 'Expected nested inner table');

// Verify the inner cell was rescaled (still 9x the width, but total size still fits in 200)
expect(innerTable.getColWidths()).toEqual([180, 20]);
});
});
});

describe('SELECT_ALL_COMMAND', () => {
Expand Down