feat: Added table cell menu for adding/removing rows and columns to Super note tables and merging cells (#2508)
This commit is contained in:
@@ -5,22 +5,23 @@ import { classNames } from '@standardnotes/utils'
|
|||||||
import StyledTooltip from '../StyledTooltip/StyledTooltip'
|
import StyledTooltip from '../StyledTooltip/StyledTooltip'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onClick: () => void
|
onClick: MouseEventHandler
|
||||||
className?: string
|
className?: string
|
||||||
icon: IconType
|
icon: IconType
|
||||||
iconClassName?: string
|
iconClassName?: string
|
||||||
|
iconProps?: Partial<Parameters<typeof Icon>[0]>
|
||||||
label: string
|
label: string
|
||||||
id?: string
|
id?: string
|
||||||
} & ComponentPropsWithoutRef<'button'>
|
} & ComponentPropsWithoutRef<'button'>
|
||||||
|
|
||||||
const RoundIconButton = forwardRef(
|
const RoundIconButton = forwardRef(
|
||||||
(
|
(
|
||||||
{ onClick, className, icon: iconType, iconClassName, id, label, ...props }: Props,
|
{ onClick, className, icon: iconType, iconClassName, iconProps, id, label, ...props }: Props,
|
||||||
ref: ForwardedRef<HTMLButtonElement>,
|
ref: ForwardedRef<HTMLButtonElement>,
|
||||||
) => {
|
) => {
|
||||||
const click: MouseEventHandler = (e) => {
|
const click: MouseEventHandler = (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
onClick()
|
onClick(e)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<StyledTooltip label={label}>
|
<StyledTooltip label={label}>
|
||||||
@@ -37,7 +38,7 @@ const RoundIconButton = forwardRef(
|
|||||||
aria-label={label}
|
aria-label={label}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<Icon type={iconType} className={iconClassName} />
|
<Icon {...iconProps} type={iconType} className={iconClassName} />
|
||||||
</button>
|
</button>
|
||||||
</StyledTooltip>
|
</StyledTooltip>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { SuperEditorContentId } from './Constants'
|
|||||||
import { classNames } from '@standardnotes/utils'
|
import { classNames } from '@standardnotes/utils'
|
||||||
import { MarkdownTransformers } from './MarkdownTransformers'
|
import { MarkdownTransformers } from './MarkdownTransformers'
|
||||||
import { RemoveBrokenTablesPlugin } from './Plugins/TablePlugin'
|
import { RemoveBrokenTablesPlugin } from './Plugins/TablePlugin'
|
||||||
|
import TableActionMenuPlugin from './Plugins/TableCellActionMenuPlugin'
|
||||||
|
|
||||||
type BlocksEditorProps = {
|
type BlocksEditorProps = {
|
||||||
onChange?: (value: string, preview: string) => void
|
onChange?: (value: string, preview: string) => void
|
||||||
@@ -92,7 +93,7 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
|
|||||||
/>
|
/>
|
||||||
<ListPlugin />
|
<ListPlugin />
|
||||||
<MarkdownShortcutPlugin transformers={MarkdownTransformers} />
|
<MarkdownShortcutPlugin transformers={MarkdownTransformers} />
|
||||||
<TablePlugin />
|
<TablePlugin hasCellMerge />
|
||||||
<OnChangePlugin onChange={handleChange} ignoreSelectionChange={true} />
|
<OnChangePlugin onChange={handleChange} ignoreSelectionChange={true} />
|
||||||
<HistoryPlugin />
|
<HistoryPlugin />
|
||||||
<HorizontalRulePlugin />
|
<HorizontalRulePlugin />
|
||||||
@@ -111,6 +112,7 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
|
|||||||
<>
|
<>
|
||||||
<FloatingTextFormatToolbarPlugin anchorElem={floatingAnchorElem} />
|
<FloatingTextFormatToolbarPlugin anchorElem={floatingAnchorElem} />
|
||||||
<DraggableBlockPlugin anchorElem={floatingAnchorElem} />
|
<DraggableBlockPlugin anchorElem={floatingAnchorElem} />
|
||||||
|
<TableActionMenuPlugin anchorElem={floatingAnchorElem} cellMerge />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -291,7 +291,9 @@ body {
|
|||||||
z-index: 10;
|
z-index: 10;
|
||||||
display: block;
|
display: block;
|
||||||
position: fixed;
|
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);
|
inset 0 0 0 1px rgba(255, 255, 255, 0.5);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
@@ -657,15 +659,6 @@ body {
|
|||||||
cursor: pointer;
|
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 {
|
.action-button {
|
||||||
background-color: #eee;
|
background-color: #eee;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -860,24 +853,6 @@ body {
|
|||||||
background-size: contain;
|
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 {
|
.toolbar .divider {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
background-color: #eee;
|
background-color: #eee;
|
||||||
|
|||||||
@@ -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<number> = []
|
||||||
|
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<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])
|
||||||
|
|
||||||
|
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 = <MenuItem onClick={mergeTableCellsAtSelection}>Merge cells</MenuItem>
|
||||||
|
} else if (canUnmergeCell) {
|
||||||
|
mergeCellButton = <MenuItem onClick={unmergeTableCellsAtSelection}>Unmerge cells</MenuItem>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCurrentCellRowHeader = (tableCellNode.__headerState & TableCellHeaderStates.ROW) === TableCellHeaderStates.ROW
|
||||||
|
const isCurrentCellColumnHeader =
|
||||||
|
(tableCellNode.__headerState & TableCellHeaderStates.COLUMN) === TableCellHeaderStates.COLUMN
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu className="dropdown" ref={dropDownRef} a11yLabel="Table actions menu" isOpen>
|
||||||
|
{mergeCellButton}
|
||||||
|
{!!mergeCellButton && <MenuItemSeparator />}
|
||||||
|
<MenuItem onClick={() => insertTableRowAtSelection(false)}>
|
||||||
|
Insert {selectionCounts.rows === 1 ? 'row' : `${selectionCounts.rows} rows`} above
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={() => insertTableRowAtSelection(true)}>
|
||||||
|
Insert {selectionCounts.rows === 1 ? 'row' : `${selectionCounts.rows} rows`} below
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItemSeparator />
|
||||||
|
<MenuItem onClick={() => insertTableColumnAtSelection(false)}>
|
||||||
|
Insert {selectionCounts.columns === 1 ? 'column' : `${selectionCounts.columns} columns`} left
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={() => insertTableColumnAtSelection(true)}>
|
||||||
|
Insert {selectionCounts.columns === 1 ? 'column' : `${selectionCounts.columns} columns`} right
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItemSeparator />
|
||||||
|
<MenuItem onClick={deleteTableColumnAtSelection}>Delete column</MenuItem>
|
||||||
|
<MenuItem onClick={deleteTableRowAtSelection}>Delete row</MenuItem>
|
||||||
|
<MenuItem onClick={deleteTableAtSelection}>Delete table</MenuItem>
|
||||||
|
<MenuItemSeparator />
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
toggleTableRowIsHeader(isCurrentCellRowHeader ? TableCellHeaderStates.NO_STATUS : TableCellHeaderStates.ROW)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isCurrentCellRowHeader ? 'Remove' : 'Add'} row header
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
toggleTableColumnIsHeader(
|
||||||
|
isCurrentCellColumnHeader ? TableCellHeaderStates.NO_STATUS : TableCellHeaderStates.COLUMN,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isCurrentCellColumnHeader ? 'Remove' : 'Add'} column header
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 == 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 (
|
||||||
|
<div className="table-cell-action-button-container" ref={menuButtonRef}>
|
||||||
|
{tableCellNode != null && (
|
||||||
|
<>
|
||||||
|
<RoundIconButton
|
||||||
|
label="Open table actions menu"
|
||||||
|
icon="chevron-down"
|
||||||
|
iconProps={{
|
||||||
|
size: 'small',
|
||||||
|
}}
|
||||||
|
className="!h-6 !min-w-6 bg-default md:!h-5 md:!min-w-5"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsMenuOpen(!isMenuOpen)
|
||||||
|
}}
|
||||||
|
ref={menuRootRef}
|
||||||
|
/>
|
||||||
|
<Popover
|
||||||
|
open={isMenuOpen}
|
||||||
|
title="Table actions"
|
||||||
|
className="py-1"
|
||||||
|
anchorElement={menuRootRef}
|
||||||
|
disableMobileFullscreenTakeover
|
||||||
|
>
|
||||||
|
<TableActionMenu onClose={() => setIsMenuOpen(false)} tableCellNode={tableCellNode} cellMerge={cellMerge} />
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TableActionMenuPlugin({
|
||||||
|
anchorElem = document.body,
|
||||||
|
cellMerge = false,
|
||||||
|
}: {
|
||||||
|
anchorElem?: HTMLElement
|
||||||
|
cellMerge?: boolean
|
||||||
|
}): null | ReactPortal {
|
||||||
|
const isEditable = useLexicalEditable()
|
||||||
|
return createPortal(
|
||||||
|
isEditable ? <TableCellActionMenuContainer anchorElem={anchorElem} cellMerge={cellMerge} /> : null,
|
||||||
|
anchorElem,
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user