diff --git a/packages/web/src/javascripts/Components/Button/RoundIconButton.tsx b/packages/web/src/javascripts/Components/Button/RoundIconButton.tsx index d19dd001f..c77f7ae93 100644 --- a/packages/web/src/javascripts/Components/Button/RoundIconButton.tsx +++ b/packages/web/src/javascripts/Components/Button/RoundIconButton.tsx @@ -5,22 +5,23 @@ import { classNames } from '@standardnotes/utils' import StyledTooltip from '../StyledTooltip/StyledTooltip' type Props = { - onClick: () => void + onClick: MouseEventHandler className?: string icon: IconType iconClassName?: string + iconProps?: Partial[0]> label: string id?: string } & ComponentPropsWithoutRef<'button'> const RoundIconButton = forwardRef( ( - { onClick, className, icon: iconType, iconClassName, id, label, ...props }: Props, + { onClick, className, icon: iconType, iconClassName, iconProps, id, label, ...props }: Props, ref: ForwardedRef, ) => { const click: MouseEventHandler = (e) => { e.preventDefault() - onClick() + onClick(e) } return ( @@ -37,7 +38,7 @@ const RoundIconButton = forwardRef( aria-label={label} {...props} > - + ) diff --git a/packages/web/src/javascripts/Components/SuperEditor/BlocksEditor.tsx b/packages/web/src/javascripts/Components/SuperEditor/BlocksEditor.tsx index c95662dca..3a9e6a3ec 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/BlocksEditor.tsx +++ b/packages/web/src/javascripts/Components/SuperEditor/BlocksEditor.tsx @@ -26,6 +26,7 @@ import { SuperEditorContentId } from './Constants' import { classNames } from '@standardnotes/utils' import { MarkdownTransformers } from './MarkdownTransformers' import { RemoveBrokenTablesPlugin } from './Plugins/TablePlugin' +import TableActionMenuPlugin from './Plugins/TableCellActionMenuPlugin' type BlocksEditorProps = { onChange?: (value: string, preview: string) => void @@ -92,7 +93,7 @@ export const BlocksEditor: FunctionComponent = ({ /> - + @@ -111,6 +112,7 @@ export const BlocksEditor: FunctionComponent = ({ <> + )} {children} diff --git a/packages/web/src/javascripts/Components/SuperEditor/Lexical/Theme/base.scss b/packages/web/src/javascripts/Components/SuperEditor/Lexical/Theme/base.scss index b0f06ab1e..d8ade84a1 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/Lexical/Theme/base.scss +++ b/packages/web/src/javascripts/Components/SuperEditor/Lexical/Theme/base.scss @@ -291,7 +291,9 @@ body { z-index: 10; display: block; position: fixed; - box-shadow: 0 12px 28px 0 rgba(0, 0, 0, 0.2), 0 2px 4px 0 rgba(0, 0, 0, 0.1), + box-shadow: + 0 12px 28px 0 rgba(0, 0, 0, 0.2), + 0 2px 4px 0 rgba(0, 0, 0, 0.1), inset 0 0 0 1px rgba(255, 255, 255, 0.5); border-radius: 8px; min-height: 40px; @@ -657,15 +659,6 @@ body { cursor: pointer; } - i.chevron-down { - background-color: transparent; - background-size: contain; - display: inline-block; - height: 8px; - width: 8px; - background-image: url(#{$blocks-editor-icons-path}/chevron-down.svg); - } - .action-button { background-color: #eee; border: 0; @@ -860,24 +853,6 @@ body { background-size: contain; } - .toolbar i.chevron-down { - margin-top: 3px; - width: 16px; - height: 16px; - display: flex; - user-select: none; - } - - .toolbar i.chevron-down.inside { - width: 16px; - height: 16px; - display: flex; - margin-left: -25px; - margin-top: 11px; - margin-right: 10px; - pointer-events: none; - } - .toolbar .divider { width: 1px; background-color: #eee; diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/TableCellActionMenuPlugin/index.tsx b/packages/web/src/javascripts/Components/SuperEditor/Plugins/TableCellActionMenuPlugin/index.tsx new file mode 100644 index 000000000..ac87ff995 --- /dev/null +++ b/packages/web/src/javascripts/Components/SuperEditor/Plugins/TableCellActionMenuPlugin/index.tsx @@ -0,0 +1,600 @@ +/** + * 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 { + DEPRECATED_GridCellNode, + ElementNode, + $createParagraphNode, + $getRoot, + $getSelection, + $isElementNode, + $isParagraphNode, + $isRangeSelection, + $isTextNode, + DEPRECATED_$getNodeTriplet, + DEPRECATED_$isGridCellNode, + DEPRECATED_$isGridSelection, + GridSelection, +} from 'lexical' + +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, +} from '@lexical/table' +import { ReactPortal, useCallback, useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import invariant from '../../Lexical/Shared/invariant' +import Popover from '@/Components/Popover/Popover' +import RoundIconButton from '@/Components/Button/RoundIconButton' +import Menu from '@/Components/Menu/Menu' +import MenuItem from '@/Components/Menu/MenuItem' +import MenuItemSeparator from '@/Components/Menu/MenuItemSeparator' + +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 = null + let expectedColumns = null + let currentColumns = 0 + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i] + if ($isTableCellNode(node)) { + const row = node.getParentOrThrow() + invariant($isTableRowNode(row), '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<{ + onClose: () => void + tableCellNode: TableCellNode + cellMerge: boolean +}> + +function TableActionMenu({ onClose, tableCellNode: _tableCellNode, 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]) + + 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( + (headerState?: number) => { + 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') + } + + if (headerState === undefined) { + tableCell.toggleHeaderStyle(TableCellHeaderStates.ROW) + return + } + + tableCell.setHeaderStyles(headerState) + }) + + clearTableSelection() + onClose() + }) + }, + [editor, tableCellNode, clearTableSelection, onClose], + ) + + const toggleTableColumnIsHeader = useCallback( + (headerState?: number) => { + 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') + } + + if (headerState === undefined) { + tableCell.toggleHeaderStyle(TableCellHeaderStates.COLUMN) + continue + } + + tableCell.setHeaderStyles(headerState) + } + + clearTableSelection() + onClose() + }) + }, + [editor, tableCellNode, clearTableSelection, onClose], + ) + + let mergeCellButton: null | JSX.Element = null + if (cellMerge) { + if (canMergeCells) { + mergeCellButton = Merge cells + } else if (canUnmergeCell) { + mergeCellButton = Unmerge cells + } + } + + const isCurrentCellRowHeader = (tableCellNode.__headerState & TableCellHeaderStates.ROW) === TableCellHeaderStates.ROW + const isCurrentCellColumnHeader = + (tableCellNode.__headerState & TableCellHeaderStates.COLUMN) === TableCellHeaderStates.COLUMN + + return ( + + {mergeCellButton} + {!!mergeCellButton && } + insertTableRowAtSelection(false)}> + Insert {selectionCounts.rows === 1 ? 'row' : `${selectionCounts.rows} rows`} above + + insertTableRowAtSelection(true)}> + Insert {selectionCounts.rows === 1 ? 'row' : `${selectionCounts.rows} rows`} below + + + insertTableColumnAtSelection(false)}> + Insert {selectionCounts.columns === 1 ? 'column' : `${selectionCounts.columns} columns`} left + + insertTableColumnAtSelection(true)}> + Insert {selectionCounts.columns === 1 ? 'column' : `${selectionCounts.columns} columns`} right + + + Delete column + Delete row + Delete table + + { + toggleTableRowIsHeader(isCurrentCellRowHeader ? TableCellHeaderStates.NO_STATUS : TableCellHeaderStates.ROW) + }} + > + {isCurrentCellRowHeader ? 'Remove' : 'Add'} row header + + { + toggleTableColumnIsHeader( + isCurrentCellColumnHeader ? TableCellHeaderStates.NO_STATUS : TableCellHeaderStates.COLUMN, + ) + }} + > + {isCurrentCellColumnHeader ? 'Remove' : 'Add'} column header + + + ) +} + +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 == null || menu == null) { + 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 == null) { + setTableMenuCellNode(null) + return + } + + const tableCellParentNodeDOM = editor.getElementByKey(tableCellNodeFromSelection.getKey()) + + if (tableCellParentNodeDOM == null) { + setTableMenuCellNode(null) + return + } + + setTableMenuCellNode(tableCellNodeFromSelection) + } else if (!activeElement) { + setTableMenuCellNode(null) + } + }, [editor]) + + useEffect(() => { + return editor.registerUpdateListener(() => { + editor.getEditorState().read(() => { + moveMenu() + }) + }) + }) + + const setMenuButtonPosition = useCallback(() => { + const menuButtonDOM = menuButtonRef.current as HTMLButtonElement | null + + if (menuButtonDOM != null && tableCellNode != null) { + const tableCellNodeDOM = editor.getElementByKey(tableCellNode.getKey()) + + if (tableCellNodeDOM != null) { + const tableCellRect = tableCellNodeDOM.getBoundingClientRect() + const menuButtonRect = menuButtonDOM.getBoundingClientRect() + const anchorRect = anchorElem.getBoundingClientRect() + + const top = tableCellRect.top - anchorRect.top + menuButtonRect.height / 2 - 2 + const left = tableCellRect.right - menuButtonRect.width - 8 - 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]) + + useEffect(() => { + setMenuButtonPosition() + }, [setMenuButtonPosition]) + + useEffect(() => { + const scrollerElem = editor.getRootElement() + + const update = () => { + editor.getEditorState().read(() => { + setMenuButtonPosition() + }) + setIsMenuOpen(false) + } + + window.addEventListener('resize', update) + if (scrollerElem) { + scrollerElem.addEventListener('scroll', update) + } + + return () => { + window.removeEventListener('resize', update) + if (scrollerElem) { + scrollerElem.removeEventListener('scroll', update) + } + } + }, [editor, anchorElem, setMenuButtonPosition]) + + const prevTableCellDOM = useRef(tableCellNode) + + useEffect(() => { + if (prevTableCellDOM.current !== tableCellNode) { + setIsMenuOpen(false) + } + + prevTableCellDOM.current = tableCellNode + }, [prevTableCellDOM, tableCellNode]) + + return ( +
+ {tableCellNode != null && ( + <> + { + e.stopPropagation() + setIsMenuOpen(!isMenuOpen) + }} + ref={menuRootRef} + /> + + setIsMenuOpen(false)} tableCellNode={tableCellNode} cellMerge={cellMerge} /> + + + )} +
+ ) +} + +export default function TableActionMenuPlugin({ + anchorElem = document.body, + cellMerge = false, +}: { + anchorElem?: HTMLElement + cellMerge?: boolean +}): null | ReactPortal { + const isEditable = useLexicalEditable() + return createPortal( + isEditable ? : null, + anchorElem, + ) +}