/** * This source code is derived from code from Meta Platforms, Inc. * and affiliates, licensed under the MIT license located in the * LICENSE file in the /app/soapbox/features/compose/editor directory. */ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import useLexicalEditable from '@lexical/react/useLexicalEditable'; import { $deleteTableColumn__EXPERIMENTAL, $deleteTableRow__EXPERIMENTAL, $getTableCellNodeFromLexicalNode, $getTableColumnIndexFromTableCellNode, $getTableNodeFromLexicalNodeOrThrow, $getTableRowIndexFromTableCellNode, $insertTableColumn__EXPERIMENTAL, $insertTableRow__EXPERIMENTAL, $isTableCellNode, $isTableRowNode, $unmergeCell, getTableSelectionFromTableElement, HTMLTableElementWithWithTableSelectionState, TableCellHeaderStates, TableCellNode, type TableRowNode, } from '@lexical/table'; import { $createParagraphNode, $getRoot, $getSelection, $isElementNode, $isParagraphNode, $isRangeSelection, $isTextNode, DEPRECATED_$getNodeTriplet, DEPRECATED_$isGridCellNode, DEPRECATED_$isGridSelection, GridSelection, } from 'lexical'; import * as React from 'react'; import { ReactPortal, useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import type { DEPRECATED_GridCellNode, ElementNode } from 'lexical'; function computeSelectionCount(selection: GridSelection): { columns: number rows: number } { const selectionShape = selection.getShape(); return { columns: selectionShape.toX - selectionShape.fromX + 1, rows: selectionShape.toY - selectionShape.fromY + 1, }; } // This is important when merging cells as there is no good way to re-merge weird shapes (a result // of selecting merged cells and non-merged) function isGridSelectionRectangular(selection: GridSelection): boolean { const nodes = selection.getNodes(); const currentRows: Array = []; let currentRow: TableRowNode | null = null; let expectedColumns: number | null = null; let currentColumns = 0; for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; if ($isTableCellNode(node)) { const row = node.getParentOrThrow(); if (!$isTableRowNode(row)) throw new Error('Expected CellNode to have a RowNode parent'); if (currentRow !== row) { if (expectedColumns !== null && currentColumns !== expectedColumns) { return false; } if (currentRow !== null) { expectedColumns = currentColumns; } currentRow = row; currentColumns = 0; } const colSpan = node.__colSpan; for (let j = 0; j < colSpan; j++) { if (currentRows[currentColumns + j] === undefined) { currentRows[currentColumns + j] = 0; } currentRows[currentColumns + j] += node.__rowSpan; } currentColumns += colSpan; } } return ( (expectedColumns === null || currentColumns === expectedColumns) && currentRows.every((v) => v === currentRows[0]) ); } function $canUnmerge(): boolean { const selection = $getSelection(); if ( ($isRangeSelection(selection) && !selection.isCollapsed()) || (DEPRECATED_$isGridSelection(selection) && !selection.anchor.is(selection.focus)) || (!$isRangeSelection(selection) && !DEPRECATED_$isGridSelection(selection)) ) { return false; } const [cell] = DEPRECATED_$getNodeTriplet(selection.anchor); return cell.__colSpan > 1 || cell.__rowSpan > 1; } function $cellContainsEmptyParagraph(cell: DEPRECATED_GridCellNode): boolean { if (cell.getChildrenSize() !== 1) { return false; } const firstChild = cell.getFirstChildOrThrow(); if (!$isParagraphNode(firstChild) || !firstChild.isEmpty()) { return false; } return true; } function $selectLastDescendant(node: ElementNode): void { const lastDescendant = node.getLastDescendant(); if ($isTextNode(lastDescendant)) { lastDescendant.select(); } else if ($isElementNode(lastDescendant)) { lastDescendant.selectEnd(); } else if (lastDescendant !== null) { lastDescendant.selectNext(); } } type TableCellActionMenuProps = Readonly<{ contextRef: {current: null | HTMLElement} onClose: () => void setIsMenuOpen: (isOpen: boolean) => void tableCellNode: TableCellNode cellMerge: boolean }>; function TableActionMenu({ onClose, tableCellNode: _tableCellNode, setIsMenuOpen, contextRef, cellMerge, }: TableCellActionMenuProps) { const [editor] = useLexicalComposerContext(); const dropDownRef = useRef(null); const [tableCellNode, updateTableCellNode] = useState(_tableCellNode); const [selectionCounts, updateSelectionCounts] = useState({ columns: 1, rows: 1, }); const [canMergeCells, setCanMergeCells] = useState(false); const [canUnmergeCell, setCanUnmergeCell] = useState(false); useEffect(() => { return editor.registerMutationListener(TableCellNode, (nodeMutations) => { const nodeUpdated = nodeMutations.get(tableCellNode.getKey()) === 'updated'; if (nodeUpdated) { editor.getEditorState().read(() => { updateTableCellNode(tableCellNode.getLatest()); }); } }); }, [editor, tableCellNode]); useEffect(() => { editor.getEditorState().read(() => { const selection = $getSelection(); // Merge cells if (DEPRECATED_$isGridSelection(selection)) { const currentSelectionCounts = computeSelectionCount(selection); updateSelectionCounts(computeSelectionCount(selection)); setCanMergeCells( isGridSelectionRectangular(selection) && (currentSelectionCounts.columns > 1 || currentSelectionCounts.rows > 1), ); } // Unmerge cell setCanUnmergeCell($canUnmerge()); }); }, [editor]); useEffect(() => { const menuButtonElement = contextRef.current; const dropDownElement = dropDownRef.current; const rootElement = editor.getRootElement(); if (menuButtonElement && dropDownElement && rootElement) { const rootEleRect = rootElement.getBoundingClientRect(); const menuButtonRect = menuButtonElement.getBoundingClientRect(); dropDownElement.style.opacity = '1'; const dropDownElementRect = dropDownElement.getBoundingClientRect(); const margin = 5; let leftPosition = menuButtonRect.right + margin; if ( leftPosition + dropDownElementRect.width > window.innerWidth || leftPosition + dropDownElementRect.width > rootEleRect.right ) { const position = menuButtonRect.left - dropDownElementRect.width - margin; leftPosition = (position < 0 ? margin : position) + window.pageXOffset; } dropDownElement.style.left = `${leftPosition + window.pageXOffset}px`; let topPosition = menuButtonRect.top; if (topPosition + dropDownElementRect.height > window.innerHeight) { const position = menuButtonRect.bottom - dropDownElementRect.height; topPosition = (position < 0 ? margin : position) + window.pageYOffset; } dropDownElement.style.top = `${topPosition + +window.pageYOffset}px`; } }, [contextRef, dropDownRef, editor]); useEffect(() => { function handleClickOutside(event: MouseEvent) { if ( dropDownRef.current && contextRef.current && !dropDownRef.current.contains(event.target as Node) && !contextRef.current.contains(event.target as Node) ) { setIsMenuOpen(false); } } window.addEventListener('click', handleClickOutside); return () => window.removeEventListener('click', handleClickOutside); }, [setIsMenuOpen, contextRef]); const clearTableSelection = useCallback(() => { editor.update(() => { if (tableCellNode.isAttached()) { const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode); const tableElement = editor.getElementByKey( tableNode.getKey(), ) as HTMLTableElementWithWithTableSelectionState; if (!tableElement) { throw new Error('Expected to find tableElement in DOM'); } const tableSelection = getTableSelectionFromTableElement(tableElement); if (tableSelection !== null) { tableSelection.clearHighlight(); } tableNode.markDirty(); updateTableCellNode(tableCellNode.getLatest()); } const rootNode = $getRoot(); rootNode.selectStart(); }); }, [editor, tableCellNode]); const mergeTableCellsAtSelection = () => { editor.update(() => { const selection = $getSelection(); if (DEPRECATED_$isGridSelection(selection)) { const { columns, rows } = computeSelectionCount(selection); const nodes = selection.getNodes(); let firstCell: null | DEPRECATED_GridCellNode = null; for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; if (DEPRECATED_$isGridCellNode(node)) { if (firstCell === null) { node.setColSpan(columns).setRowSpan(rows); firstCell = node; const isEmpty = $cellContainsEmptyParagraph(node); let firstChild; if ( isEmpty && $isParagraphNode((firstChild = node.getFirstChild())) ) { firstChild.remove(); } } else if (DEPRECATED_$isGridCellNode(firstCell)) { const isEmpty = $cellContainsEmptyParagraph(node); if (!isEmpty) { firstCell.append(...node.getChildren()); } node.remove(); } } } if (firstCell !== null) { if (firstCell.getChildrenSize() === 0) { firstCell.append($createParagraphNode()); } $selectLastDescendant(firstCell); } onClose(); } }); }; const unmergeTableCellsAtSelection = () => { editor.update(() => { $unmergeCell(); }); }; const insertTableRowAtSelection = useCallback( (shouldInsertAfter: boolean) => { editor.update(() => { $insertTableRow__EXPERIMENTAL(shouldInsertAfter); onClose(); }); }, [editor, onClose], ); const insertTableColumnAtSelection = useCallback( (shouldInsertAfter: boolean) => { editor.update(() => { for (let i = 0; i < selectionCounts.columns; i++) { $insertTableColumn__EXPERIMENTAL(shouldInsertAfter); } onClose(); }); }, [editor, onClose, selectionCounts.columns], ); const deleteTableRowAtSelection = useCallback(() => { editor.update(() => { $deleteTableRow__EXPERIMENTAL(); onClose(); }); }, [editor, onClose]); const deleteTableAtSelection = useCallback(() => { editor.update(() => { const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode); tableNode.remove(); clearTableSelection(); onClose(); }); }, [editor, tableCellNode, clearTableSelection, onClose]); const deleteTableColumnAtSelection = useCallback(() => { editor.update(() => { $deleteTableColumn__EXPERIMENTAL(); onClose(); }); }, [editor, onClose]); const toggleTableRowIsHeader = useCallback(() => { editor.update(() => { const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode); const tableRowIndex = $getTableRowIndexFromTableCellNode(tableCellNode); const tableRows = tableNode.getChildren(); if (tableRowIndex >= tableRows.length || tableRowIndex < 0) { throw new Error('Expected table cell to be inside of table row.'); } const tableRow = tableRows[tableRowIndex]; if (!$isTableRowNode(tableRow)) { throw new Error('Expected table row'); } tableRow.getChildren().forEach((tableCell) => { if (!$isTableCellNode(tableCell)) { throw new Error('Expected table cell'); } tableCell.toggleHeaderStyle(TableCellHeaderStates.ROW); }); clearTableSelection(); onClose(); }); }, [editor, tableCellNode, clearTableSelection, onClose]); const toggleTableColumnIsHeader = useCallback(() => { editor.update(() => { const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode); const tableColumnIndex = $getTableColumnIndexFromTableCellNode(tableCellNode); const tableRows = tableNode.getChildren(); for (let r = 0; r < tableRows.length; r++) { const tableRow = tableRows[r]; if (!$isTableRowNode(tableRow)) { throw new Error('Expected table row'); } const tableCells = tableRow.getChildren(); if (tableColumnIndex >= tableCells.length || tableColumnIndex < 0) { throw new Error('Expected table cell to be inside of table row.'); } const tableCell = tableCells[tableColumnIndex]; if (!$isTableCellNode(tableCell)) { throw new Error('Expected table cell'); } tableCell.toggleHeaderStyle(TableCellHeaderStates.COLUMN); } clearTableSelection(); onClose(); }); }, [editor, tableCellNode, clearTableSelection, onClose]); let mergeCellButton: null | JSX.Element = null; if (cellMerge) { if (canMergeCells) { mergeCellButton = ( ); } else if (canUnmergeCell) { mergeCellButton = ( ); } } return createPortal( // eslint-disable-next-line jsx-a11y/no-static-element-interactions
{ e.stopPropagation(); }} > {mergeCellButton}



, document.body, ); } function TableCellActionMenuContainer({ anchorElem, cellMerge, }: { anchorElem: HTMLElement cellMerge: boolean }): JSX.Element { const [editor] = useLexicalComposerContext(); const menuButtonRef = useRef(null); const menuRootRef = useRef(null); const [isMenuOpen, setIsMenuOpen] = useState(false); const [tableCellNode, setTableMenuCellNode] = useState( null, ); const moveMenu = useCallback(() => { const menu = menuButtonRef.current; const selection = $getSelection(); const nativeSelection = window.getSelection(); const activeElement = document.activeElement; if (!selection || !menu) { setTableMenuCellNode(null); return; } const rootElement = editor.getRootElement(); if ( $isRangeSelection(selection) && rootElement !== null && nativeSelection !== null && rootElement.contains(nativeSelection.anchorNode) ) { const tableCellNodeFromSelection = $getTableCellNodeFromLexicalNode( selection.anchor.getNode(), ); if (!tableCellNodeFromSelection) { setTableMenuCellNode(null); return; } const tableCellParentNodeDOM = editor.getElementByKey( tableCellNodeFromSelection.getKey(), ); if (!tableCellParentNodeDOM) { setTableMenuCellNode(null); return; } setTableMenuCellNode(tableCellNodeFromSelection); } else if (!activeElement) { setTableMenuCellNode(null); } }, [editor]); useEffect(() => { return editor.registerUpdateListener(() => { editor.getEditorState().read(() => { moveMenu(); }); }); }); useEffect(() => { const menuButtonDOM = menuButtonRef.current as HTMLButtonElement | null; if (menuButtonDOM && tableCellNode) { const tableCellNodeDOM = editor.getElementByKey(tableCellNode.getKey()); if (tableCellNodeDOM) { const tableCellRect = tableCellNodeDOM.getBoundingClientRect(); const menuRect = menuButtonDOM.getBoundingClientRect(); const anchorRect = anchorElem.getBoundingClientRect(); const top = tableCellRect.top - anchorRect.top + 4; const left = tableCellRect.right - menuRect.width - 10 - anchorRect.left; menuButtonDOM.style.opacity = '1'; menuButtonDOM.style.transform = `translate(${left}px, ${top}px)`; } else { menuButtonDOM.style.opacity = '0'; menuButtonDOM.style.transform = 'translate(-10000px, -10000px)'; } } }, [menuButtonRef, tableCellNode, editor, anchorElem]); const prevTableCellDOM = useRef(tableCellNode); useEffect(() => { if (prevTableCellDOM.current !== tableCellNode) { setIsMenuOpen(false); } prevTableCellDOM.current = tableCellNode; }, [prevTableCellDOM, tableCellNode]); return (
{tableCellNode && ( <> {isMenuOpen && ( setIsMenuOpen(false)} tableCellNode={tableCellNode} cellMerge={cellMerge} /> )} )}
); } export default function TableActionMenuPlugin({ anchorElem, cellMerge = false, }: { anchorElem?: HTMLElement | null cellMerge?: boolean }): null | ReactPortal { const isEditable = useLexicalEditable(); return createPortal( isEditable ? ( ) : null, anchorElem || document.body, ); }