Files
ncd-fe/app/soapbox/features/compose/editor/plugins/table-action-menu-plugin.tsx
marcin mikołajczak 700e7af19d Lexical: WIP port tables support
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
2023-08-07 21:55:17 +02:00

702 lines
20 KiB
TypeScript

/**
* 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<number> = [];
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<HTMLDivElement | null>(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 = (
<button
className='item'
onClick={() => mergeTableCellsAtSelection()}
data-test-id='table-merge-cells'
>
Merge cells
</button>
);
} else if (canUnmergeCell) {
mergeCellButton = (
<button
className='item'
onClick={() => unmergeTableCellsAtSelection()}
data-test-id='table-unmerge-cells'
>
Unmerge cells
</button>
);
}
}
return createPortal(
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
className='dropdown'
ref={dropDownRef}
onClick={(e) => {
e.stopPropagation();
}}
>
{mergeCellButton}
<hr />
<button
className='item'
onClick={() => insertTableRowAtSelection(false)}
data-test-id='table-insert-row-above'
>
<span className='text'>
Insert{' '}
{selectionCounts.rows === 1 ? 'row' : `${selectionCounts.rows} rows`}{' '}
above
</span>
</button>
<button
className='item'
onClick={() => insertTableRowAtSelection(true)}
data-test-id='table-insert-row-below'
>
<span className='text'>
Insert{' '}
{selectionCounts.rows === 1 ? 'row' : `${selectionCounts.rows} rows`}{' '}
below
</span>
</button>
<hr />
<button
className='item'
onClick={() => insertTableColumnAtSelection(false)}
data-test-id='table-insert-column-before'
>
<span className='text'>
Insert{' '}
{selectionCounts.columns === 1
? 'column'
: `${selectionCounts.columns} columns`}{' '}
left
</span>
</button>
<button
className='item'
onClick={() => insertTableColumnAtSelection(true)}
data-test-id='table-insert-column-after'
>
<span className='text'>
Insert{' '}
{selectionCounts.columns === 1
? 'column'
: `${selectionCounts.columns} columns`}{' '}
right
</span>
</button>
<hr />
<button
className='item'
onClick={() => deleteTableColumnAtSelection()}
data-test-id='table-delete-columns'
>
<span className='text'>Delete column</span>
</button>
<button
className='item'
onClick={() => deleteTableRowAtSelection()}
data-test-id='table-delete-rows'
>
<span className='text'>Delete row</span>
</button>
<button
className='item'
onClick={() => deleteTableAtSelection()}
data-test-id='table-delete'
>
<span className='text'>Delete table</span>
</button>
<hr />
<button className='item' onClick={() => toggleTableRowIsHeader()}>
<span className='text'>
{(tableCellNode.__headerState & TableCellHeaderStates.ROW) ===
TableCellHeaderStates.ROW
? 'Remove'
: 'Add'}{' '}
row header
</span>
</button>
<button className='item' onClick={() => toggleTableColumnIsHeader()}>
<span className='text'>
{(tableCellNode.__headerState & TableCellHeaderStates.COLUMN) ===
TableCellHeaderStates.COLUMN
? 'Remove'
: 'Add'}{' '}
column header
</span>
</button>
</div>,
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<TableCellNode | null>(
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 (
<div className='table-cell-action-button-container' ref={menuButtonRef}>
{tableCellNode && (
<>
<button
className='table-cell-action-button chevron-down'
onClick={(e) => {
e.stopPropagation();
setIsMenuOpen(!isMenuOpen);
}}
ref={menuRootRef}
>
<i className='chevron-down' />
</button>
{isMenuOpen && (
<TableActionMenu
contextRef={menuRootRef}
setIsMenuOpen={setIsMenuOpen}
onClose={() => setIsMenuOpen(false)}
tableCellNode={tableCellNode}
cellMerge={cellMerge}
/>
)}
</>
)}
</div>
);
}
export default function TableActionMenuPlugin({
anchorElem,
cellMerge = false,
}: {
anchorElem?: HTMLElement | null
cellMerge?: boolean
}): null | ReactPortal {
const isEditable = useLexicalEditable();
return createPortal(
isEditable ? (
<TableCellActionMenuContainer
anchorElem={anchorElem || document.body}
cellMerge={cellMerge}
/>
) : null,
anchorElem || document.body,
);
}