From 97481c9d1035c8759f2b94897847e7d0e2b0c12a Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Fri, 6 Dec 2024 06:56:51 -0800 Subject: [PATCH 1/4] [lexical-table][lexical-utils][lexical-react]: Bug Fix: Enforce table integrity with transforms and move non-React plugin code to @lexical/table (#6914) --- .../__tests__/e2e/Tables.spec.mjs | 12 +- .../src/plugins/TablePlugin.tsx | 46 +-- .../lexical-react/src/LexicalTablePlugin.ts | 211 +----------- .../src/shared/useCharacterLimit.ts | 14 +- .../lexical-table/flow/LexicalTable.js.flow | 14 +- .../lexical-table/src/LexicalTableCellNode.ts | 2 +- .../lexical-table/src/LexicalTableNode.ts | 9 +- .../src/LexicalTablePluginHelpers.ts | 275 +++++++++++++++ .../lexical-table/src/LexicalTableRowNode.ts | 8 +- .../__tests__/unit/LexicalTableNode.test.tsx | 312 ++++++++++++++++++ packages/lexical-table/src/index.ts | 5 + .../lexical-utils/flow/LexicalUtils.js.flow | 11 + .../unit/descendantsMatching.test.tsx | 84 +++++ .../src/__tests__/unit/iterators.test.tsx | 216 ++++++++++++ .../unit/unwrapAndFilterDescendants.test.tsx | 101 ++++++ packages/lexical-utils/src/index.ts | 155 +++++++++ .../src/__tests__/unit/LexicalEditor.test.tsx | 4 +- 17 files changed, 1217 insertions(+), 262 deletions(-) create mode 100644 packages/lexical-table/src/LexicalTablePluginHelpers.ts create mode 100644 packages/lexical-utils/src/__tests__/unit/descendantsMatching.test.tsx create mode 100644 packages/lexical-utils/src/__tests__/unit/iterators.test.tsx create mode 100644 packages/lexical-utils/src/__tests__/unit/unwrapAndFilterDescendants.test.tsx diff --git a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs index 4fd7ca25b30..1cf4e25a7b1 100644 --- a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs @@ -3737,7 +3737,9 @@ test.describe.parallel('Tables', () => { Hello world

-
+ +


+

{ -
-
+ +


+ + +


+

; -export const INSERT_NEW_TABLE_COMMAND: LexicalCommand = - createCommand('INSERT_NEW_TABLE_COMMAND'); - export const CellContext = createContext({ cellEditorConfig: null, cellEditorPlugins: null, @@ -155,28 +143,16 @@ export function TablePlugin({ }): JSX.Element | null { const [editor] = useLexicalComposerContext(); const cellContext = useContext(CellContext); - useEffect(() => { - if (!editor.hasNodes([TableNode])) { - invariant(false, 'TablePlugin: TableNode is not registered on editor'); + if (!editor.hasNodes([TableNode, TableRowNode, TableCellNode])) { + invariant( + false, + 'TablePlugin: TableNode, TableRowNode, or TableCellNode is not registered on editor', + ); } - + }, [editor]); + useEffect(() => { cellContext.set(cellEditorConfig, children); - - return editor.registerCommand( - INSERT_NEW_TABLE_COMMAND, - ({columns, rows, includeHeaders}) => { - const tableNode = $createTableNodeWithDimensions( - Number(rows), - Number(columns), - includeHeaders, - ); - $insertNodes([tableNode]); - return true; - }, - COMMAND_PRIORITY_EDITOR, - ); - }, [cellContext, cellEditorConfig, children, editor]); - + }, [cellContext, cellEditorConfig, children]); return null; } 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/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-table/flow/LexicalTable.js.flow b/packages/lexical-table/flow/LexicalTable.js.flow index 2674a125f50..0d3af559ed3 100644 --- a/packages/lexical-table/flow/LexicalTable.js.flow +++ b/packages/lexical-table/flow/LexicalTable.js.flow @@ -75,7 +75,7 @@ declare export class TableCellNode extends ElementNode { canBeEmpty(): false; } declare export function $createTableCellNode( - headerState: TableCellHeaderState, + headerState?: TableCellHeaderState, colSpan?: number, width?: ?number, ): TableCellNode; @@ -350,4 +350,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..795779c4990 100644 --- a/packages/lexical-table/src/LexicalTableCellNode.ts +++ b/packages/lexical-table/src/LexicalTableCellNode.ts @@ -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..636613346b3 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, @@ -498,7 +498,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/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..9a7d5c99c88 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< { @@ -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/__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` + + + + + + + + + + + + + + + + + + + + + +
+

+ Items +

+
+

+ Expenditure +

+
+

+ Donuts +

+
+

+ 3,000 +

+
+

+ Stationery +

+
+

+ 18,000 +

+
+

+ Totals +

+
+

+ 21,000 +

+
+ `, + ); + }); + + test('Copy table with caption 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/caption + html` + + + + + + + + + + + + + + + + + + + + + + + + + +
+ He-Man and Skeletor facts +
+ He-Man + + Skeletor +
+ Role + + Hero + + Villain +
+ Weapon + + Power Sword + + Havoc Staff +
+ Dark secret + + Expert florist + + Cries at romcoms +
+ `, + ); + 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(``), + ]; +} + +function textContentForDepth(i: number): string { + return i > 0 ? `<${i}>${textContentForDepth(i - 1)}` : `<${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(``), + ]; +} + +function textContentForDepth(i: number): string { + return i > 0 ? `<${i}>${textContentForDepth(i - 1)}` : `<${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/src/__tests__/unit/LexicalEditor.test.tsx b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx index cf33a568d3f..3986f27806f 100644 --- a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx +++ b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx @@ -2210,7 +2210,7 @@ describe('LexicalEditor tests', () => { await editor.update(() => { const root = $getRoot(); - const tableCell = $createTableCellNode(0); + const tableCell = $createTableCellNode(); const tableRow = $createTableRowNode(); const table = $createTableNode(); @@ -2225,7 +2225,7 @@ describe('LexicalEditor tests', () => { await editor.update(() => { const tableRow = $getNodeByKey(tableRowKey) as TableRowNode; - const tableCell = $createTableCellNode(0); + const tableCell = $createTableCellNode(); tableRow.append(tableCell); }); From 55ef7cad49c303a343575745d311fb1a3d05c15b Mon Sep 17 00:00:00 2001 From: Hadi Elghoul <113323394+elgh0ul@users.noreply.github.com> Date: Fri, 6 Dec 2024 14:25:05 -0500 Subject: [PATCH 2/4] [lexical][@lexical/selection] Feature: Unify $selectAll Implementations (#6902) Co-authored-by: Hadi Elghoul --- packages/lexical-selection/src/index.ts | 4 +-- .../lexical-selection/src/range-selection.ts | 35 ------------------- packages/lexical/src/LexicalUtils.ts | 21 +++++++++-- 3 files changed, 20 insertions(+), 40 deletions(-) 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/src/LexicalUtils.ts b/packages/lexical/src/LexicalUtils.ts index f4cc88ac86a..d4bc2d1ea46 100644 --- a/packages/lexical/src/LexicalUtils.ts +++ b/packages/lexical/src/LexicalUtils.ts @@ -1084,10 +1084,25 @@ export function isSelectAll( return key.toLowerCase() === 'a' && controlOrMeta(metaKey, ctrlKey); } -export function $selectAll(): void { +export function $selectAll(selection?: RangeSelection | null): RangeSelection { const root = $getRoot(); - const selection = root.select(0, root.getChildrenSize()); - $setSelection($normalizeSelection(selection)); + + if ($isRangeSelection(selection)) { + const anchor = selection.anchor; + const focus = selection.focus; + const anchorNode = anchor.getNode(); + const topParent = anchorNode.getTopLevelElementOrThrow(); + const rootNode = topParent.getParentOrThrow(); + anchor.set(rootNode.getKey(), 0, 'element'); + focus.set(rootNode.getKey(), rootNode.getChildrenSize(), 'element'); + $normalizeSelection(selection); + return selection; + } else { + // Create a new RangeSelection + const newSelection = root.select(0, root.getChildrenSize()); + $setSelection($normalizeSelection(newSelection)); + return newSelection; + } } export function getCachedClassNameArray( From 7776cea0f1862cb57b3c2d2118da629e098b784c Mon Sep 17 00:00:00 2001 From: Basile Savouret <47100280+basile-savouret@users.noreply.github.com> Date: Fri, 6 Dec 2024 20:26:53 +0100 Subject: [PATCH 3/4] [lexical-playground]: Fix empty layout item causes 100% CPU usage (#6906) Co-authored-by: Ivaylo Pavlov --- .../src/plugins/LayoutPlugin/LayoutPlugin.tsx | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/packages/lexical-playground/src/plugins/LayoutPlugin/LayoutPlugin.tsx b/packages/lexical-playground/src/plugins/LayoutPlugin/LayoutPlugin.tsx index cbdeee1fe23..dd226849e62 100644 --- a/packages/lexical-playground/src/plugins/LayoutPlugin/LayoutPlugin.tsx +++ b/packages/lexical-playground/src/plugins/LayoutPlugin/LayoutPlugin.tsx @@ -97,6 +97,25 @@ export function LayoutPlugin(): null { return false; }; + const $fillLayoutItemIfEmpty = (node: LayoutItemNode) => { + if (node.isEmpty()) { + node.append($createParagraphNode()); + } + }; + + const $removeIsolatedLayoutItem = (node: LayoutItemNode): boolean => { + const parent = node.getParent(); + if (!$isLayoutContainerNode(parent)) { + const children = node.getChildren(); + for (const child of children) { + node.insertBefore(child); + } + node.remove(); + return true; + } + return false; + }; + return mergeRegister( // When layout is the last child pressing down/right arrow will insert paragraph // below it to allow adding more content. It's similar what $insertBlockNode @@ -186,17 +205,17 @@ export function LayoutPlugin(): null { }, COMMAND_PRIORITY_EDITOR, ), - // Structure enforcing transformers for each node type. In case nesting structure is not - // "Container > Item" it'll unwrap nodes and convert it back - // to regular content. + editor.registerNodeTransform(LayoutItemNode, (node) => { - const parent = node.getParent(); - if (!$isLayoutContainerNode(parent)) { - const children = node.getChildren(); - for (const child of children) { - node.insertBefore(child); - } - node.remove(); + // Structure enforcing transformers for each node type. In case nesting structure is not + // "Container > Item" it'll unwrap nodes and convert it back + // to regular content. + const isRemoved = $removeIsolatedLayoutItem(node); + + if (!isRemoved) { + // Layout item should always have a child. this function will listen + // for any empty layout item and fill it with a paragraph node + $fillLayoutItemIfEmpty(node); } }), editor.registerNodeTransform(LayoutContainerNode, (node) => { From 05fa244bd0f6043114ffb8feab2922d8e4de7e6f Mon Sep 17 00:00:00 2001 From: daichan132 <71433925+daichan132@users.noreply.github.com> Date: Mon, 9 Dec 2024 08:42:29 +0900 Subject: [PATCH 4/4] [lexical-playground] Chore: Update Prettier to v3 (#6920) --- package-lock.json | 30 ++++++- .../__tests__/utils/index.mjs | 26 +++--- packages/lexical-playground/package.json | 2 +- .../components/PrettierButton/index.tsx | 85 ++++++++++--------- 4 files changed, 86 insertions(+), 57 deletions(-) diff --git a/package-lock.json b/package-lock.json index e5387c0ffdc..4488fab50c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30584,6 +30584,7 @@ "version": "2.7.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz", "integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==", + "dev": true, "bin": { "prettier": "bin-prettier.js" }, @@ -39153,7 +39154,7 @@ "katex": "^0.16.10", "lexical": "0.21.0", "lodash-es": "^4.17.21", - "prettier": "^2.3.2", + "prettier": "^3.4.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-error-boundary": "^3.1.4", @@ -39173,6 +39174,21 @@ "vite-plugin-static-copy": "^2.1.0" } }, + "packages/lexical-playground/node_modules/prettier": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", + "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "packages/lexical-react": { "name": "@lexical/react", "version": "0.21.0", @@ -56265,7 +56281,7 @@ "katex": "^0.16.10", "lexical": "0.21.0", "lodash-es": "^4.17.21", - "prettier": "^2.3.2", + "prettier": "^3.4.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-error-boundary": "^3.1.4", @@ -56275,6 +56291,13 @@ "vite-plugin-static-copy": "^2.1.0", "y-websocket": "^1.5.4", "yjs": ">=13.5.42" + }, + "dependencies": { + "prettier": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", + "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==" + } } }, "lib0": { @@ -60208,7 +60231,8 @@ "prettier": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz", - "integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==" + "integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==", + "dev": true }, "prettier-plugin-hermes-parser": { "version": "0.20.1", diff --git a/packages/lexical-playground/__tests__/utils/index.mjs b/packages/lexical-playground/__tests__/utils/index.mjs index ed81a581866..a9e13d587ec 100644 --- a/packages/lexical-playground/__tests__/utils/index.mjs +++ b/packages/lexical-playground/__tests__/utils/index.mjs @@ -225,7 +225,7 @@ async function assertHTMLOnPageOrFrame( frameName, actualHtmlModificationsCallback = (actualHtml) => actualHtml, ) { - const expected = prettifyHTML(expectedHtml.replace(/\n/gm, ''), { + const expected = await prettifyHTML(expectedHtml.replace(/\n/gm, ''), { ignoreClasses, ignoreInlineStyles, }); @@ -236,7 +236,7 @@ async function assertHTMLOnPageOrFrame( .first() .innerHTML(), ); - let actual = prettifyHTML(actualHtml.replace(/\n/gm, ''), { + let actual = await prettifyHTML(actualHtml.replace(/\n/gm, ''), { ignoreClasses, ignoreInlineStyles, }); @@ -780,7 +780,10 @@ export async function dragImage( ); } -export function prettifyHTML(string, {ignoreClasses, ignoreInlineStyles} = {}) { +export async function prettifyHTML( + string, + {ignoreClasses, ignoreInlineStyles} = {}, +) { let output = string; if (ignoreClasses) { @@ -793,15 +796,14 @@ export function prettifyHTML(string, {ignoreClasses, ignoreInlineStyles} = {}) { output = output.replace(/\s__playwright_target__="[^"]+"/, ''); - return prettier - .format(output, { - attributeGroups: ['$DEFAULT', '^data-'], - attributeSort: 'ASC', - bracketSameLine: true, - htmlWhitespaceSensitivity: 'ignore', - parser: 'html', - }) - .trim(); + return await prettier.format(output, { + attributeGroups: ['$DEFAULT', '^data-'], + attributeSort: 'asc', + bracketSameLine: true, + htmlWhitespaceSensitivity: 'ignore', + parser: 'html', + plugins: ['prettier-plugin-organize-attributes'], + }); } // This function does not suppose to do anything, it's only used as a trigger diff --git a/packages/lexical-playground/package.json b/packages/lexical-playground/package.json index cd7ced6303f..5a2751577cf 100644 --- a/packages/lexical-playground/package.json +++ b/packages/lexical-playground/package.json @@ -29,7 +29,7 @@ "katex": "^0.16.10", "lexical": "0.21.0", "lodash-es": "^4.17.21", - "prettier": "^2.3.2", + "prettier": "^3.4.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-error-boundary": "^3.1.4", diff --git a/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/components/PrettierButton/index.tsx b/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/components/PrettierButton/index.tsx index 203e63702b9..c1e47f23ec4 100644 --- a/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/components/PrettierButton/index.tsx +++ b/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/components/PrettierButton/index.tsx @@ -10,7 +10,6 @@ import './index.css'; import {$isCodeNode} from '@lexical/code'; import {$getNearestNodeFromDOMNode, LexicalEditor} from 'lexical'; import {Options} from 'prettier'; -import * as React from 'react'; import {useState} from 'react'; interface Props { @@ -20,17 +19,27 @@ interface Props { } const PRETTIER_PARSER_MODULES = { - css: () => import('prettier/parser-postcss'), - html: () => import('prettier/parser-html'), - js: () => import('prettier/parser-babel'), - markdown: () => import('prettier/parser-markdown'), + css: [() => import('prettier/parser-postcss')], + html: [() => import('prettier/parser-html')], + js: [ + () => import('prettier/parser-babel'), + () => import('prettier/plugins/estree'), + ], + markdown: [() => import('prettier/parser-markdown')], + typescript: [ + () => import('prettier/parser-typescript'), + () => import('prettier/plugins/estree'), + ], } as const; type LanguagesType = keyof typeof PRETTIER_PARSER_MODULES; async function loadPrettierParserByLang(lang: string) { - const dynamicImport = PRETTIER_PARSER_MODULES[lang as LanguagesType]; - return await dynamicImport(); + const dynamicImports = PRETTIER_PARSER_MODULES[lang as LanguagesType]; + const modules = await Promise.all( + dynamicImports.map((dynamicImport) => dynamicImport()), + ); + return modules; } async function loadPrettierFormat() { @@ -39,18 +48,11 @@ async function loadPrettierFormat() { } const PRETTIER_OPTIONS_BY_LANG: Record = { - css: { - parser: 'css', - }, - html: { - parser: 'html', - }, - js: { - parser: 'babel', - }, - markdown: { - parser: 'markdown', - }, + css: {parser: 'css'}, + html: {parser: 'html'}, + js: {parser: 'babel'}, + markdown: {parser: 'markdown'}, + typescript: {parser: 'typescript'}, }; const LANG_CAN_BE_PRETTIER = Object.keys(PRETTIER_OPTIONS_BY_LANG); @@ -76,36 +78,37 @@ export function PrettierButton({lang, editor, getCodeDOMNode}: Props) { async function handleClick(): Promise { const codeDOMNode = getCodeDOMNode(); + if (!codeDOMNode) { + return; + } + + let content = ''; + editor.update(() => { + const codeNode = $getNearestNodeFromDOMNode(codeDOMNode); + if ($isCodeNode(codeNode)) { + content = codeNode.getTextContent(); + } + }); + if (content === '') { + return; + } try { const format = await loadPrettierFormat(); const options = getPrettierOptions(lang); - options.plugins = [await loadPrettierParserByLang(lang)]; - - if (!codeDOMNode) { - return; - } + const prettierParsers = await loadPrettierParserByLang(lang); + options.plugins = prettierParsers.map( + (parser) => parser.default || parser, + ); + const formattedCode = await format(content, options); editor.update(() => { const codeNode = $getNearestNodeFromDOMNode(codeDOMNode); - if ($isCodeNode(codeNode)) { - const content = codeNode.getTextContent(); - - let parsed = ''; - - try { - parsed = format(content, options); - } catch (error: unknown) { - setError(error); - } - - if (parsed !== '') { - const selection = codeNode.select(0); - selection.insertText(parsed); - setSyntaxError(''); - setTipsVisible(false); - } + const selection = codeNode.select(0); + selection.insertText(formattedCode); + setSyntaxError(''); + setTipsVisible(false); } }); } catch (error: unknown) {