feat(richtext-lexical): add EXPERIMENTAL_TableFeature, allow Client Features to register providers (#7010)

This commit is contained in:
Alessio Gravili
2024-07-02 17:06:21 -04:00
committed by GitHub
parent 4a8d3a0b73
commit 93bdc0e98d
22 changed files with 1719 additions and 184 deletions

View File

@@ -745,6 +745,21 @@ export const MyFeature = createClientFeature({
In this example, a new `MyNode` will be inserted into the editor when `+++ ` is typed.
### Providers
You can add providers to your client feature, which will be nested below the `EditorConfigProvider`. This can be useful if you want to provide some context to your nodes or other parts of your feature.
```ts
'use client'
import { createClientFeature } from '@payloadcms/richtext-lexical/client';
import { TableContext } from './context';
export const MyClientFeature = createClientFeature({
providers: [TableContext],
})
```
## Props
To accept props in your feature, type them as a generic.

View File

@@ -147,31 +147,32 @@ import { CallToAction } from '../blocks/CallToAction'
Here's an overview of all the included features:
| Feature Name | Included by default | Description |
|--------------------------------|---------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **`BoldTextFeature`** | Yes | Handles the bold text format |
| **`ItalicTextFeature`** | Yes | Handles the italic text format |
| **`UnderlineTextFeature`** | Yes | Handles the underline text format |
| **`StrikethroughTextFeature`** | Yes | Handles the strikethrough text format |
| **`SubscriptTextFeature`** | Yes | Handles the subscript text format |
| **`SuperscriptTextFeature`** | Yes | Handles the superscript text format |
| **`InlineCodeTextFeature`** | Yes | Handles the inline-code text format |
| **`ParagraphFeature`** | Yes | Handles paragraphs. Since they are already a key feature of lexical itself, this Feature mainly handles the Slash and Add-Block menu entries for paragraphs |
| **`HeadingFeature`** | Yes | Adds Heading Nodes (by default, H1 - H6, but that can be customized) |
| **`AlignFeature`** | Yes | Allows you to align text left, centered and right |
| **`IndentFeature`** | Yes | Allows you to indent text with the tab key |
| **`UnorderedListFeature`** | Yes | Adds unordered lists (ul) |
| **`OrderedListFeature`** | Yes | Adds ordered lists (ol) |
| **`CheckListFeature`** | Yes | Adds checklists |
| **`LinkFeature`** | Yes | Allows you to create internal and external links |
| **`RelationshipFeature`** | Yes | Allows you to create block-level (not inline) relationships to other documents |
| **`BlockQuoteFeature`** | Yes | Allows you to create block-level quotes |
| **`UploadFeature`** | Yes | Allows you to create block-level upload nodes - this supports all kinds of uploads, not just images |
| **`HorizontalRuleFeature`** | Yes | Horizontal rules / separators. Basically displays an `<hr>` element |
| **`InlineToolbarFeature`** | Yes | The inline toolbar is the floating toolbar which appears when you select text. This toolbar only contains actions relevant for selected text |
| **`FixedToolbarFeature`** | No | This classic toolbar is pinned to the top and always visible. Both inline and fixed toolbars can be enabled at the same time. |
| **`BlocksFeature`** | No | Allows you to use Payload's [Blocks Field](/docs/fields/blocks) directly inside your editor. In the feature props, you can specify the allowed blocks - just like in the Blocks field. |
| **`TreeViewFeature`** | No | Adds a debug box under the editor, which allows you to see the current editor state live, the dom, as well as time travel. Very useful for debugging |
| Feature Name | Included by default | Description |
|---------------------------------|---------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **`BoldTextFeature`** | Yes | Handles the bold text format |
| **`ItalicTextFeature`** | Yes | Handles the italic text format |
| **`UnderlineTextFeature`** | Yes | Handles the underline text format |
| **`StrikethroughTextFeature`** | Yes | Handles the strikethrough text format |
| **`SubscriptTextFeature`** | Yes | Handles the subscript text format |
| **`SuperscriptTextFeature`** | Yes | Handles the superscript text format |
| **`InlineCodeTextFeature`** | Yes | Handles the inline-code text format |
| **`ParagraphFeature`** | Yes | Handles paragraphs. Since they are already a key feature of lexical itself, this Feature mainly handles the Slash and Add-Block menu entries for paragraphs |
| **`HeadingFeature`** | Yes | Adds Heading Nodes (by default, H1 - H6, but that can be customized) |
| **`AlignFeature`** | Yes | Allows you to align text left, centered and right |
| **`IndentFeature`** | Yes | Allows you to indent text with the tab key |
| **`UnorderedListFeature`** | Yes | Adds unordered lists (ul) |
| **`OrderedListFeature`** | Yes | Adds ordered lists (ol) |
| **`CheckListFeature`** | Yes | Adds checklists |
| **`LinkFeature`** | Yes | Allows you to create internal and external links |
| **`RelationshipFeature`** | Yes | Allows you to create block-level (not inline) relationships to other documents |
| **`BlockQuoteFeature`** | Yes | Allows you to create block-level quotes |
| **`UploadFeature`** | Yes | Allows you to create block-level upload nodes - this supports all kinds of uploads, not just images |
| **`HorizontalRuleFeature`** | Yes | Horizontal rules / separators. Basically displays an `<hr>` element |
| **`InlineToolbarFeature`** | Yes | The inline toolbar is the floating toolbar which appears when you select text. This toolbar only contains actions relevant for selected text |
| **`FixedToolbarFeature`** | No | This classic toolbar is pinned to the top and always visible. Both inline and fixed toolbars can be enabled at the same time. |
| **`BlocksFeature`** | No | Allows you to use Payload's [Blocks Field](/docs/fields/blocks) directly inside your editor. In the feature props, you can specify the allowed blocks - just like in the Blocks field. |
| **`TreeViewFeature`** | No | Adds a debug box under the editor, which allows you to see the current editor state live, the dom, as well as time travel. Very useful for debugging |
| **`EXPERIMENTAL_TableFeature`** | No | Adds support for tables. This feature may be removed or receive breaking changes in the future - even within a stable lexical release, without needing a major release. |
Notice how even the toolbars are features? That's how extensible our lexical editor is - you could theoretically create your own toolbar if you wanted to!

View File

@@ -83,6 +83,7 @@
"@lexical/react": "0.16.1",
"@lexical/rich-text": "0.16.1",
"@lexical/selection": "0.16.1",
"@lexical/table": "0.16.1",
"@lexical/utils": "0.16.1",
"@payloadcms/next": "workspace:*",
"@payloadcms/translations": "workspace:*",

View File

@@ -37,6 +37,7 @@ export { toolbarTextDropdownGroupWithItems } from '../../features/shared/toolbar
export { FixedToolbarFeatureClient } from '../../features/toolbars/fixed/feature.client.js'
export { InlineToolbarFeatureClient } from '../../features/toolbars/inline/feature.client.js'
export { ToolbarButton } from '../../features/toolbars/shared/ToolbarButton/index.js'
export { TableFeatureClient } from '../../features/experimental_table/feature.client.js'
export { ToolbarDropdown } from '../../features/toolbars/shared/ToolbarDropdown/index.js'
export { UploadFeatureClient } from '../../features/upload/feature.client.js'

View File

@@ -0,0 +1,63 @@
'use client'
import { TableCellNode, TableNode, TableRowNode } from '@lexical/table'
import { TableIcon } from '../../lexical/ui/icons/Table/index.js'
import { createClientFeature } from '../../utilities/createClientFeature.js'
import { slashMenuBasicGroupWithItems } from '../shared/slashMenu/basicGroup.js'
import { toolbarAddDropdownGroupWithItems } from '../shared/toolbar/addDropdownGroup.js'
import { TableActionMenuPlugin } from './plugins/TableActionMenuPlugin/index.js'
import { TableCellResizerPlugin } from './plugins/TableCellResizerPlugin/index.js'
import {
OPEN_TABLE_DRAWER_COMMAND,
TableContext,
TablePlugin,
} from './plugins/TablePlugin/index.js'
export const TableFeatureClient = createClientFeature({
nodes: [TableNode, TableCellNode, TableRowNode],
plugins: [
{
Component: TablePlugin,
position: 'normal',
},
{
Component: TableCellResizerPlugin,
position: 'normal',
},
{
Component: TableActionMenuPlugin,
position: 'floatingAnchorElem',
},
],
providers: [TableContext],
slashMenu: {
groups: [
slashMenuBasicGroupWithItems([
{
Icon: TableIcon,
key: 'table',
keywords: ['table'],
label: 'Table',
onSelect: ({ editor }) => {
editor.dispatchCommand(OPEN_TABLE_DRAWER_COMMAND, {})
},
},
]),
],
},
toolbarFixed: {
groups: [
toolbarAddDropdownGroupWithItems([
{
ChildComponent: TableIcon,
key: 'table',
label: 'Table',
onSelect: ({ editor }) => {
editor.dispatchCommand(OPEN_TABLE_DRAWER_COMMAND, {})
},
},
]),
],
},
})

View File

@@ -0,0 +1,57 @@
import type { Config, Field } from 'payload'
import { TableCellNode, TableNode, TableRowNode } from '@lexical/table'
import { sanitizeFields } from 'payload'
// eslint-disable-next-line payload/no-imports-from-exports-dir
import { TableFeatureClient } from '../../exports/client/index.js'
import { createServerFeature } from '../../utilities/createServerFeature.js'
const fields: Field[] = [
{
name: 'rows',
type: 'number',
defaultValue: 5,
required: true,
},
{
name: 'columns',
type: 'number',
defaultValue: 5,
required: true,
},
]
export const EXPERIMENTAL_TableFeature = createServerFeature({
feature: async ({ config, isRoot }) => {
const validRelationships = config.collections.map((c) => c.slug) || []
const sanitizedFields = await sanitizeFields({
config: config as unknown as Config,
fields,
requireFieldLevelRichTextEditor: isRoot,
validRelationships,
})
return {
ClientFeature: TableFeatureClient,
generateSchemaMap: () => {
const schemaMap = new Map<string, Field[]>()
schemaMap.set('fields', sanitizedFields)
return schemaMap
},
nodes: [
{
node: TableNode,
},
{
node: TableCellNode,
},
{
node: TableRowNode,
},
],
}
},
key: 'experimental_table',
})

View File

@@ -0,0 +1,66 @@
@import '../../../../scss/styles.scss';
.table-cell-action-button-container {
position: absolute;
top: 0;
left: 0;
will-change: transform;
}
.table-cell-action-button {
background-color: var(--theme-elevation-200);
display: flex;
justify-content: center;
align-items: center;
border: 0;
padding: 2px;
position: relative;
border-radius: 4px;
color: var(--theme-elevation-800);
display: inline-block;
cursor: pointer;
&:hover {
background-color: var(--theme-elevation-300);
}
}
html[data-theme='light'] {
.table-action-menu-dropdown {
@include shadow-m;
}
}
.table-action-menu-dropdown {
z-index: 100;
display: block;
position: fixed;
background: var(--color-base-0);
min-width: 160px;
color: var(--color-base-800);
border-radius: 4px;
min-height: 40px;
overflow-y: auto;
.item {
padding: 8px;
color: var(--color-base-900);
background: var(--color-base-0);
cursor: pointer;
font-size: 13px;
font-family: var(--font-body);
display: flex;
align-content: center;
flex-direction: row;
flex-shrink: 0;
justify-content: space-between;
border: 0;
height: 30px;
width: 100%;
&:hover {
background: var(--color-base-100);
}
}
}

View File

@@ -0,0 +1,699 @@
'use client'
import type {
HTMLTableElementWithWithTableSelectionState,
TableRowNode,
TableSelection,
} from '@lexical/table'
import type { ElementNode } from 'lexical'
import type { JSX } from 'react'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { useLexicalEditable } from '@lexical/react/useLexicalEditable'
import {
$deleteTableColumn__EXPERIMENTAL,
$deleteTableRow__EXPERIMENTAL,
$getNodeTriplet,
$getTableCellNodeFromLexicalNode,
$getTableColumnIndexFromTableCellNode,
$getTableNodeFromLexicalNodeOrThrow,
$getTableRowIndexFromTableCellNode,
$insertTableColumn__EXPERIMENTAL,
$insertTableRow__EXPERIMENTAL,
$isTableCellNode,
$isTableRowNode,
$isTableSelection,
$unmergeCell,
TableCellHeaderStates,
TableCellNode,
getTableObserverFromTableElement,
} from '@lexical/table'
import { useScrollInfo } from '@payloadcms/ui'
import {
$createParagraphNode,
$getRoot,
$getSelection,
$isElementNode,
$isParagraphNode,
$isRangeSelection,
$isTextNode,
} from 'lexical'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import type { PluginComponentWithAnchor } from '../../../typesClient.js'
import { MeatballsIcon } from '../../../../lexical/ui/icons/Meatballs/index.js'
import { invariant } from '../../../../lexical/utils/invariant.js'
import './index.scss'
function computeSelectionCount(selection: TableSelection): {
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 isTableSelectionRectangular(selection: TableSelection): 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()) ||
($isTableSelection(selection) && !selection.anchor.is(selection.focus)) ||
(!$isRangeSelection(selection) && !$isTableSelection(selection))
) {
return false
}
const [cell] = $getNodeTriplet(selection.anchor)
return cell.__colSpan > 1 || cell.__rowSpan > 1
}
function $cellContainsEmptyParagraph(cell: TableCellNode): 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<{
cellMerge: boolean
contextRef: { current: HTMLElement | null }
onClose: () => void
setIsMenuOpen: (isOpen: boolean) => void
tableCellNode: TableCellNode
}>
function TableActionMenu({
cellMerge,
contextRef,
onClose,
setIsMenuOpen,
tableCellNode: _tableCellNode,
}: 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)
const { y } = useScrollInfo()
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 ($isTableSelection(selection)) {
const currentSelectionCounts = computeSelectionCount(selection)
updateSelectionCounts(computeSelectionCount(selection))
setCanMergeCells(
isTableSelectionRectangular(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 != null && dropDownElement != null && rootElement != null) {
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}px`
}
}, [contextRef, dropDownRef, editor, y])
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
dropDownRef.current != null &&
contextRef.current != null &&
!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 = getTableObserverFromTableElement(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 ($isTableSelection(selection)) {
const { columns, rows } = computeSelectionCount(selection)
const nodes = selection.getNodes()
let firstCell: TableCellNode | null = null
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]
if ($isTableCellNode(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 ($isTableCellNode(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<TableRowNode>()
const maxRowsLength = Math.max(...tableRows.map((row) => row.getChildren().length))
if (tableColumnIndex >= maxRowsLength || tableColumnIndex < 0) {
throw new Error('Expected table cell to be inside of table row.')
}
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) {
// if cell is outside of bounds for the current row (for example various merge cell cases) we shouldn't highlight it
continue
}
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: JSX.Element | null = null
if (cellMerge) {
if (canMergeCells) {
mergeCellButton = (
<button
className="item"
data-test-id="table-merge-cells"
onClick={() => mergeTableCellsAtSelection()}
type="button"
>
Merge cells
</button>
)
} else if (canUnmergeCell) {
mergeCellButton = (
<button
className="item"
data-test-id="table-unmerge-cells"
onClick={() => unmergeTableCellsAtSelection()}
type="button"
>
Unmerge cells
</button>
)
}
}
return createPortal(
// eslint-disable-next-line jsx-a11y/no-static-element-interactions,jsx-a11y/click-events-have-key-events
<div
className="table-action-menu-dropdown"
onClick={(e) => {
e.stopPropagation()
}}
ref={dropDownRef}
>
{mergeCellButton ? (
<React.Fragment>
{mergeCellButton}
<hr />
</React.Fragment>
) : null}
<button
className="item"
data-test-id="table-insert-row-above"
onClick={() => insertTableRowAtSelection(false)}
type="button"
>
<span className="text">
Insert {selectionCounts.rows === 1 ? 'row' : `${selectionCounts.rows} rows`} above
</span>
</button>
<button
className="item"
data-test-id="table-insert-row-below"
onClick={() => insertTableRowAtSelection(true)}
type="button"
>
<span className="text">
Insert {selectionCounts.rows === 1 ? 'row' : `${selectionCounts.rows} rows`} below
</span>
</button>
<hr />
<button
className="item"
data-test-id="table-insert-column-before"
onClick={() => insertTableColumnAtSelection(false)}
type="button"
>
<span className="text">
Insert {selectionCounts.columns === 1 ? 'column' : `${selectionCounts.columns} columns`}{' '}
left
</span>
</button>
<button
className="item"
data-test-id="table-insert-column-after"
onClick={() => insertTableColumnAtSelection(true)}
type="button"
>
<span className="text">
Insert {selectionCounts.columns === 1 ? 'column' : `${selectionCounts.columns} columns`}{' '}
right
</span>
</button>
<hr />
<button
className="item"
data-test-id="table-delete-columns"
onClick={() => deleteTableColumnAtSelection()}
type="button"
>
<span className="text">Delete column</span>
</button>
<button
className="item"
data-test-id="table-delete-rows"
onClick={() => deleteTableRowAtSelection()}
type="button"
>
<span className="text">Delete row</span>
</button>
<button
className="item"
data-test-id="table-delete"
onClick={() => deleteTableAtSelection()}
type="button"
>
<span className="text">Delete table</span>
</button>
<hr />
<button className="item" onClick={() => toggleTableRowIsHeader()} type="button">
<span className="text">
{(tableCellNode.__headerState & TableCellHeaderStates.ROW) === TableCellHeaderStates.ROW
? 'Remove'
: 'Add'}{' '}
row header
</span>
</button>
<button
className="item"
data-test-id="table-column-header"
onClick={() => toggleTableColumnIsHeader()}
type="button"
>
<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 == 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()
})
})
})
useEffect(() => {
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 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 != null && (
<React.Fragment>
<button
className="table-cell-action-button"
onClick={(e) => {
e.stopPropagation()
setIsMenuOpen(!isMenuOpen)
}}
ref={menuRootRef}
type="button"
>
<MeatballsIcon />
</button>
{isMenuOpen && (
<TableActionMenu
cellMerge={cellMerge}
contextRef={menuRootRef}
onClose={() => setIsMenuOpen(false)}
setIsMenuOpen={setIsMenuOpen}
tableCellNode={tableCellNode}
/>
)}
</React.Fragment>
)}
</div>
)
}
export const TableActionMenuPlugin: PluginComponentWithAnchor = ({ anchorElem }) => {
const isEditable = useLexicalEditable()
return createPortal(
isEditable ? (
<TableCellActionMenuContainer anchorElem={anchorElem ?? document.body} cellMerge />
) : null,
anchorElem ?? document.body,
)
}

View File

@@ -0,0 +1,3 @@
.TableCellResizer__resizer {
position: absolute;
}

View File

@@ -0,0 +1,404 @@
'use client'
import type { TableCellNode, TableDOMCell, TableMapType, TableMapValueType } from '@lexical/table'
import type { LexicalEditor } from 'lexical'
import type { JSX, MouseEventHandler } from 'react'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { useLexicalEditable } from '@lexical/react/useLexicalEditable'
import {
$computeTableMapSkipCellCheck,
$getTableNodeFromLexicalNodeOrThrow,
$getTableRowIndexFromTableCellNode,
$isTableCellNode,
$isTableRowNode,
getDOMCellFromTarget,
} from '@lexical/table'
import { calculateZoomLevel } from '@lexical/utils'
import { $getNearestNodeFromDOMNode } from 'lexical'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import type { PluginComponent, PluginComponentWithAnchor } from '../../../typesClient.js'
import './index.scss'
type MousePosition = {
x: number
y: number
}
type MouseDraggingDirection = 'bottom' | 'right'
const MIN_ROW_HEIGHT = 33
const MIN_COLUMN_WIDTH = 50
function TableCellResizer({ editor }: { editor: LexicalEditor }): JSX.Element {
const targetRef = useRef<HTMLElement | null>(null)
const resizerRef = useRef<HTMLDivElement | null>(null)
const tableRectRef = useRef<ClientRect | null>(null)
const mouseStartPosRef = useRef<MousePosition | null>(null)
const [mouseCurrentPos, updateMouseCurrentPos] = useState<MousePosition | null>(null)
const [activeCell, updateActiveCell] = useState<TableDOMCell | null>(null)
const [isMouseDown, updateIsMouseDown] = useState<boolean>(false)
const [draggingDirection, updateDraggingDirection] = useState<MouseDraggingDirection | null>(null)
const resetState = useCallback(() => {
updateActiveCell(null)
targetRef.current = null
updateDraggingDirection(null)
mouseStartPosRef.current = null
tableRectRef.current = null
}, [])
const isMouseDownOnEvent = (event: MouseEvent) => {
return (event.buttons & 1) === 1
}
useEffect(() => {
const onMouseMove = (event: MouseEvent) => {
setTimeout(() => {
const target = event.target
if (draggingDirection) {
updateMouseCurrentPos({
x: event.clientX,
y: event.clientY,
})
return
}
updateIsMouseDown(isMouseDownOnEvent(event))
if (resizerRef.current && resizerRef.current.contains(target as Node)) {
return
}
if (targetRef.current !== target) {
targetRef.current = target as HTMLElement
const cell = getDOMCellFromTarget(target as HTMLElement)
if (cell && activeCell !== cell) {
editor.update(() => {
const tableCellNode = $getNearestNodeFromDOMNode(cell.elem)
if (!tableCellNode) {
throw new Error('TableCellResizer: Table cell node not found.')
}
const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode)
const tableElement = editor.getElementByKey(tableNode.getKey())
if (!tableElement) {
throw new Error('TableCellResizer: Table element not found.')
}
targetRef.current = target as HTMLElement
tableRectRef.current = tableElement.getBoundingClientRect()
updateActiveCell(cell)
})
} else if (cell == null) {
resetState()
}
}
}, 0)
}
const onMouseDown = (event: MouseEvent) => {
setTimeout(() => {
updateIsMouseDown(true)
}, 0)
}
const onMouseUp = (event: MouseEvent) => {
setTimeout(() => {
updateIsMouseDown(false)
}, 0)
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mousedown', onMouseDown)
document.addEventListener('mouseup', onMouseUp)
return () => {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mousedown', onMouseDown)
document.removeEventListener('mouseup', onMouseUp)
}
}, [activeCell, draggingDirection, editor, resetState])
const isHeightChanging = (direction: MouseDraggingDirection) => {
if (direction === 'bottom') {
return true
}
return false
}
const updateRowHeight = useCallback(
(heightChange: number) => {
if (!activeCell) {
throw new Error('TableCellResizer: Expected active cell.')
}
editor.update(
() => {
const tableCellNode = $getNearestNodeFromDOMNode(activeCell.elem)
if (!$isTableCellNode(tableCellNode)) {
throw new Error('TableCellResizer: Table cell node not found.')
}
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')
}
let height = tableRow.getHeight()
if (height === undefined) {
const rowCells = tableRow.getChildren<TableCellNode>()
height = Math.min(
...rowCells.map((cell) => getCellNodeHeight(cell, editor) ?? Infinity),
)
}
const newHeight = Math.max(height + heightChange, MIN_ROW_HEIGHT)
tableRow.setHeight(newHeight)
},
{ tag: 'skip-scroll-into-view' },
)
},
[activeCell, editor],
)
const getCellNodeWidth = (
cell: TableCellNode,
activeEditor: LexicalEditor,
): number | undefined => {
const width = cell.getWidth()
if (width !== undefined) {
return width
}
const domCellNode = activeEditor.getElementByKey(cell.getKey())
if (domCellNode == null) {
return undefined
}
const computedStyle = getComputedStyle(domCellNode)
return (
domCellNode.clientWidth -
parseFloat(computedStyle.paddingLeft) -
parseFloat(computedStyle.paddingRight)
)
}
const getCellNodeHeight = (
cell: TableCellNode,
activeEditor: LexicalEditor,
): number | undefined => {
const domCellNode = activeEditor.getElementByKey(cell.getKey())
return domCellNode?.clientHeight
}
const getCellColumnIndex = (tableCellNode: TableCellNode, tableMap: TableMapType) => {
for (let row = 0; row < tableMap.length; row++) {
for (let column = 0; column < tableMap[row].length; column++) {
if (tableMap[row][column].cell === tableCellNode) {
return column
}
}
}
}
const updateColumnWidth = useCallback(
(widthChange: number) => {
if (!activeCell) {
throw new Error('TableCellResizer: Expected active cell.')
}
editor.update(
() => {
const tableCellNode = $getNearestNodeFromDOMNode(activeCell.elem)
if (!$isTableCellNode(tableCellNode)) {
throw new Error('TableCellResizer: Table cell node not found.')
}
const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode)
const [tableMap] = $computeTableMapSkipCellCheck(tableNode, null, null)
const columnIndex = getCellColumnIndex(tableCellNode, tableMap)
if (columnIndex === undefined) {
throw new Error('TableCellResizer: Table column not found.')
}
for (let row = 0; row < tableMap.length; row++) {
const cell: TableMapValueType = tableMap[row][columnIndex]
if (
cell.startRow === row &&
(columnIndex === tableMap[row].length - 1 ||
tableMap[row][columnIndex].cell !== tableMap[row][columnIndex + 1].cell)
) {
const width = getCellNodeWidth(cell.cell, editor)
if (width === undefined) {
continue
}
const newWidth = Math.max(width + widthChange, MIN_COLUMN_WIDTH)
cell.cell.setWidth(newWidth)
}
}
},
{ tag: 'skip-scroll-into-view' },
)
},
[activeCell, editor],
)
const mouseUpHandler = useCallback(
(direction: MouseDraggingDirection) => {
const handler = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!activeCell) {
throw new Error('TableCellResizer: Expected active cell.')
}
if (mouseStartPosRef.current) {
const { x, y } = mouseStartPosRef.current
if (activeCell === null) {
return
}
const zoom = calculateZoomLevel(event.target as Element)
if (isHeightChanging(direction)) {
const heightChange = (event.clientY - y) / zoom
updateRowHeight(heightChange)
} else {
const widthChange = (event.clientX - x) / zoom
updateColumnWidth(widthChange)
}
resetState()
document.removeEventListener('mouseup', handler)
}
}
return handler
},
[activeCell, resetState, updateColumnWidth, updateRowHeight],
)
const toggleResize = useCallback(
(direction: MouseDraggingDirection): MouseEventHandler<HTMLDivElement> =>
(event) => {
event.preventDefault()
event.stopPropagation()
if (!activeCell) {
throw new Error('TableCellResizer: Expected active cell.')
}
mouseStartPosRef.current = {
x: event.clientX,
y: event.clientY,
}
updateMouseCurrentPos(mouseStartPosRef.current)
updateDraggingDirection(direction)
document.addEventListener('mouseup', mouseUpHandler(direction))
},
[activeCell, mouseUpHandler],
)
const getResizers = useCallback(() => {
if (activeCell) {
const { height, left, top, width } = activeCell.elem.getBoundingClientRect()
const zoom = calculateZoomLevel(activeCell.elem)
const zoneWidth = 10 // Pixel width of the zone where you can drag the edge
const styles = {
bottom: {
backgroundColor: 'none',
cursor: 'row-resize',
height: `${zoneWidth}px`,
left: `${window.pageXOffset + left}px`,
top: `${window.pageYOffset + top + height - zoneWidth / 2}px`,
width: `${width}px`,
},
right: {
backgroundColor: 'none',
cursor: 'col-resize',
height: `${height}px`,
left: `${window.pageXOffset + left + width - zoneWidth / 2}px`,
top: `${window.pageYOffset + top}px`,
width: `${zoneWidth}px`,
},
}
const tableRect = tableRectRef.current
if (draggingDirection && mouseCurrentPos && tableRect) {
if (isHeightChanging(draggingDirection)) {
styles[draggingDirection].left = `${window.pageXOffset + tableRect.left}px`
styles[draggingDirection].top = `${window.pageYOffset + mouseCurrentPos.y / zoom}px`
styles[draggingDirection].height = '3px'
styles[draggingDirection].width = `${tableRect.width}px`
} else {
styles[draggingDirection].top = `${window.pageYOffset + tableRect.top}px`
styles[draggingDirection].left = `${window.pageXOffset + mouseCurrentPos.x / zoom}px`
styles[draggingDirection].width = '3px'
styles[draggingDirection].height = `${tableRect.height}px`
}
styles[draggingDirection].backgroundColor = '#adf'
}
return styles
}
return {
bottom: null,
left: null,
right: null,
top: null,
}
}, [activeCell, draggingDirection, mouseCurrentPos])
const resizerStyles = getResizers()
return (
<div ref={resizerRef}>
{activeCell != null && !isMouseDown && (
<React.Fragment>
<div
className="TableCellResizer__resizer TableCellResizer__ui"
onMouseDown={toggleResize('right')}
style={resizerStyles.right || undefined}
/>
<div
className="TableCellResizer__resizer TableCellResizer__ui"
onMouseDown={toggleResize('bottom')}
style={resizerStyles.bottom || undefined}
/>
</React.Fragment>
)}
</div>
)
}
export const TableCellResizerPlugin: PluginComponent = () => {
const [editor] = useLexicalComposerContext()
const isEditable = useLexicalEditable()
return useMemo(
() => (isEditable ? createPortal(<TableCellResizer editor={editor} />, document.body) : null),
[editor, isEditable],
)
}

View File

@@ -0,0 +1,169 @@
@import '../../../../scss/styles.scss';
.LexicalEditorTheme {
&__table {
border-collapse: collapse;
border-spacing: 0;
max-width: 100%;
overflow-y: scroll;
table-layout: fixed;
width: calc(100% - 25px);
margin: 30px 0;
::selection {
background: rgba(172,206,247);
}
br::selection {
background: unset;
}
}
&__tableSelected {
outline: 2px solid rgb(60, 132, 244);
}
&__tableCell {
border: 1px solid var(--theme-elevation-200);
min-width: 75px;
vertical-align: top;
text-align: start;
padding: 6px 8px;
position: relative;
cursor: default;
outline: none;
}
&__tableCellSortedIndicator {
display: block;
opacity: 0.5;
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 4px;
background-color: #999;
}
&__tableCellResizer {
position: absolute;
right: -4px;
height: 100%;
width: 8px;
cursor: ew-resize;
z-index: 10;
top: 0;
}
&__tableCellHeader {
background-color: #f2f3f5;
text-align: start;
}
&__tableCellSelected {
background-color: #c9dbf0;
}
&__tableCellPrimarySelected {
border: 2px solid rgb(60, 132, 244);
display: block;
height: calc(100% - 2px);
position: absolute;
width: calc(100% - 2px);
left: -1px;
top: -1px;
z-index: 2;
}
&__tableCellEditing {
box-shadow: 0 0 5px rgba(0, 0, 0, 0.4);
border-radius: 3px;
}
&__tableAddColumns {
position: absolute;
top: 0;
width: 20px;
background-color: #eee;
height: 100%;
right: 0;
animation: table-controls 0.2s ease;
border: 0;
cursor: pointer;
}
&__tableAddColumns:hover {
background-color: #c9dbf0;
}
&__tableAddRows {
position: absolute;
bottom: -25px;
width: calc(100% - 25px);
background-color: #eee;
height: 20px;
left: 0;
animation: table-controls 0.2s ease;
border: 0;
cursor: pointer;
}
&__tableAddRows:hover {
background-color: #c9dbf0;
}
@keyframes table-controls {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
&__tableCellResizeRuler {
display: block;
position: absolute;
width: 1px;
background-color: rgb(60, 132, 244);
height: 100%;
top: 0;
}
&__tableCellActionButtonContainer {
display: block;
right: 5px;
top: 6px;
position: absolute;
z-index: 4;
width: 20px;
height: 20px;
}
&__tableCellActionButton {
background-color: #eee;
display: block;
border: 0;
border-radius: 20px;
width: 20px;
height: 20px;
color: #222;
cursor: pointer;
}
&__tableCellActionButton:hover {
background-color: #ddd;
}
}
html[data-theme='dark'] {
.LexicalEditorTheme {
&__tableCellHeader {
background-color: var(--theme-elevation-50);
}
}
}

View File

@@ -0,0 +1,141 @@
'use client'
import type {
EditorThemeClasses,
Klass,
LexicalCommand,
LexicalEditor,
LexicalNode,
RangeSelection,
} from 'lexical'
import type { JSX } from 'react'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { TablePlugin as LexicalReactTablePlugin } from '@lexical/react/LexicalTablePlugin'
import { INSERT_TABLE_COMMAND, TableNode } from '@lexical/table'
import { mergeRegister } from '@lexical/utils'
import { useModal } from '@payloadcms/ui'
import { $getSelection, $isRangeSelection, COMMAND_PRIORITY_EDITOR, createCommand } from 'lexical'
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
import * as React from 'react'
import type { PluginComponent } from '../../../typesClient.js'
import { invariant } from '../../../../lexical/utils/invariant.js'
import { FieldsDrawer } from '../../../../utilities/fieldsDrawer/Drawer.js'
import './index.scss'
export type CellContextShape = {
cellEditorConfig: CellEditorConfig | null
cellEditorPlugins: Array<JSX.Element> | JSX.Element | null
set: (
cellEditorConfig: CellEditorConfig | null,
cellEditorPlugins: Array<JSX.Element> | JSX.Element | null,
) => void
}
export type CellEditorConfig = Readonly<{
namespace: string
nodes?: ReadonlyArray<Klass<LexicalNode>>
onError: (error: Error, editor: LexicalEditor) => void
readOnly?: boolean
theme?: EditorThemeClasses
}>
export const OPEN_TABLE_DRAWER_COMMAND: LexicalCommand<{}> = createCommand(
'OPEN_EMBED_DRAWER_COMMAND',
)
export const CellContext = createContext<CellContextShape>({
cellEditorConfig: null,
cellEditorPlugins: null,
set: () => {
// Empty
},
})
const drawerSlug = 'lexical-table-create'
export function TableContext({ children }: { children: JSX.Element }) {
const [contextValue, setContextValue] = useState<{
cellEditorConfig: CellEditorConfig | null
cellEditorPlugins: Array<JSX.Element> | JSX.Element | null
}>({
cellEditorConfig: null,
cellEditorPlugins: null,
})
return (
<CellContext.Provider
value={useMemo(
() => ({
cellEditorConfig: contextValue.cellEditorConfig,
cellEditorPlugins: contextValue.cellEditorPlugins,
set: (cellEditorConfig, cellEditorPlugins) => {
setContextValue({ cellEditorConfig, cellEditorPlugins })
},
}),
[contextValue.cellEditorConfig, contextValue.cellEditorPlugins],
)}
>
{children}
</CellContext.Provider>
)
}
export const TablePlugin: PluginComponent = () => {
const [editor] = useLexicalComposerContext()
const cellContext = useContext(CellContext)
const { closeModal, toggleModal } = useModal()
useEffect(() => {
if (!editor.hasNodes([TableNode])) {
invariant(false, 'TablePlugin: TableNode is not registered on editor')
}
return mergeRegister(
editor.registerCommand(
OPEN_TABLE_DRAWER_COMMAND,
() => {
let rangeSelection: RangeSelection | null = null
editor.getEditorState().read(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
rangeSelection = selection
}
})
if (rangeSelection) {
toggleModal(drawerSlug)
}
return true
},
COMMAND_PRIORITY_EDITOR,
),
)
}, [cellContext, editor, toggleModal])
return (
<React.Fragment>
<FieldsDrawer
data={{}}
drawerSlug={drawerSlug}
drawerTitle="Create Table"
featureKey="experimental_table"
handleDrawerSubmit={(_fields, data) => {
closeModal(drawerSlug)
if (!data.columns || !data.rows) {
return
}
editor.dispatchCommand(INSERT_TABLE_COMMAND, {
columns: String(data.columns),
rows: String(data.rows),
})
}}
schemaPathSuffix="fields"
/>
<LexicalReactTablePlugin hasCellBackgroundColor={false} hasCellMerge />
</React.Fragment>
)
}

View File

@@ -66,6 +66,7 @@ html[data-theme='dark'] {
&__group {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px;
z-index: 1;

View File

@@ -98,6 +98,10 @@ export type ClientFeature<ClientFeatureProps> = {
position: 'belowContainer' // Determines at which position the Component will be added.
}
>
/**
* Client Features can register their own providers, which will be nested below the EditorConfigProvider
*/
providers?: Array<React.FC>
/**
* Return props, to make it easy to retrieve passed in props to this Feature for the client if anyone wants to
*/
@@ -207,7 +211,7 @@ export type SanitizedPlugin =
export type SanitizedClientFeatures = Required<
Pick<
ResolvedClientFeature<unknown>,
'markdownTransformers' | 'nodes' | 'toolbarFixed' | 'toolbarInline'
'markdownTransformers' | 'nodes' | 'providers' | 'toolbarFixed' | 'toolbarInline'
>
> & {
/** The keys of all enabled features */

View File

@@ -812,12 +812,12 @@ export { AlignFeature } from './features/align/feature.server.js'
export { BlockquoteFeature } from './features/blockquote/feature.server.js'
export { BlocksFeature, type BlocksFeatureProps } from './features/blocks/feature.server.js'
export { type BlockFields, BlockNode } from './features/blocks/nodes/BlocksNode.js'
export { LinebreakHTMLConverter } from './features/converters/html/converter/converters/linebreak.js'
export { ParagraphHTMLConverter } from './features/converters/html/converter/converters/paragraph.js'
export { TextHTMLConverter } from './features/converters/html/converter/converters/text.js'
export { defaultHTMLConverters } from './features/converters/html/converter/defaultConverters.js'
export {
convertLexicalNodesToHTML,
convertLexicalToHTML,
@@ -830,6 +830,7 @@ export {
export { consolidateHTMLConverters, lexicalHTML } from './features/converters/html/field/index.js'
export { TestRecorderFeature } from './features/debug/testRecorder/feature.server.js'
export { TreeViewFeature } from './features/debug/treeView/feature.server.js'
export { EXPERIMENTAL_TableFeature } from './features/experimental_table/feature.server.js'
export { BoldFeature } from './features/format/bold/feature.server.js'
export { InlineCodeFeature } from './features/format/inlineCode/feature.server.js'

View File

@@ -28,6 +28,22 @@ export type LexicalProviderProps = {
readOnly: boolean
value: SerializedEditorState
}
const NestProviders = ({ children, providers }) => {
if (!providers?.length) {
return children
}
const Component = providers[0]
if (providers.length > 1) {
return (
<Component>
<NestProviders providers={providers.slice(1)}>{children}</NestProviders>
</Component>
)
}
return <Component>{children}</Component>
}
export const LexicalProvider: React.FC<LexicalProviderProps> = (props) => {
const { editorConfig, fieldProps, onChange, path, readOnly, value } = props
@@ -90,11 +106,13 @@ export const LexicalProvider: React.FC<LexicalProviderProps> = (props) => {
fieldProps={fieldProps}
parentContext={parentContext}
>
<LexicalEditorComponent
editorConfig={editorConfig}
editorContainerRef={editorContainerRef}
onChange={onChange}
/>
<NestProviders providers={editorConfig.features.providers}>
<LexicalEditorComponent
editorConfig={editorConfig}
editorContainerRef={editorContainerRef}
onChange={onChange}
/>
</NestProviders>
</EditorConfigProvider>
</LexicalComposer>
)

View File

@@ -21,6 +21,7 @@ export const sanitizeClientFeatures = (
markdownTransformers: [],
nodes: [],
plugins: [],
providers: [],
slashMenu: {
dynamicGroups: [],
groups: [],
@@ -47,6 +48,10 @@ export const sanitizeClientFeatures = (
}
}
if (feature.providers?.length) {
sanitized.providers = sanitized.providers.concat(feature.providers)
}
if (feature.nodes?.length) {
sanitized.nodes = sanitized.nodes.concat(feature.nodes)
}

View File

@@ -139,153 +139,6 @@
min-width: 25px;
}
&__table {
border-collapse: collapse;
border-spacing: 0;
max-width: 100%;
overflow-y: scroll;
table-layout: fixed;
width: calc(100% - 25px);
margin: 30px 0;
}
&__tableSelected {
outline: 2px solid rgb(60, 132, 244);
}
&__tableCell {
border: 1px solid #bbb;
min-width: 75px;
vertical-align: top;
text-align: start;
padding: 6px 8px;
position: relative;
cursor: default;
outline: none;
}
&__tableCellSortedIndicator {
display: block;
opacity: 0.5;
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 4px;
background-color: #999;
}
&__tableCellResizer {
position: absolute;
right: -4px;
height: 100%;
width: 8px;
cursor: ew-resize;
z-index: 10;
top: 0;
}
&__tableCellHeader {
background-color: #f2f3f5;
text-align: start;
}
&__tableCellSelected {
background-color: #c9dbf0;
}
&__tableCellPrimarySelected {
border: 2px solid rgb(60, 132, 244);
display: block;
height: calc(100% - 2px);
position: absolute;
width: calc(100% - 2px);
left: -1px;
top: -1px;
z-index: 2;
}
&__tableCellEditing {
box-shadow: 0 0 5px rgba(0, 0, 0, 0.4);
border-radius: 3px;
}
&__tableAddColumns {
position: absolute;
top: 0;
width: 20px;
background-color: #eee;
height: 100%;
right: 0;
animation: table-controls 0.2s ease;
border: 0;
cursor: pointer;
}
&__tableAddColumns:hover {
background-color: #c9dbf0;
}
&__tableAddRows {
position: absolute;
bottom: -25px;
width: calc(100% - 25px);
background-color: #eee;
height: 20px;
left: 0;
animation: table-controls 0.2s ease;
border: 0;
cursor: pointer;
}
&__tableAddRows:hover {
background-color: #c9dbf0;
}
@keyframes table-controls {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
&__tableCellResizeRuler {
display: block;
position: absolute;
width: 1px;
background-color: rgb(60, 132, 244);
height: 100%;
top: 0;
}
&__tableCellActionButtonContainer {
display: block;
right: 5px;
top: 6px;
position: absolute;
z-index: 4;
width: 20px;
height: 20px;
}
&__tableCellActionButton {
background-color: #eee;
display: block;
border: 0;
border-radius: 20px;
width: 20px;
height: 20px;
color: #222;
cursor: pointer;
}
&__tableCellActionButton:hover {
background-color: #ddd;
}
&__characterLimit {
display: inline;
background-color: #ffbbbb !important;
@@ -493,10 +346,6 @@ html[data-theme='dark'] {
color: var(--color-blue-600);
}
&__tableCellHeader {
background-color: var(--theme-elevation-50);
}
&__quote {
color: rgb(193, 198, 206);
border-left-color: var(--theme-elevation-150);

View File

@@ -0,0 +1,18 @@
import React from 'react'
export const MeatballsIcon: React.FC = () => (
<svg fill="none" height="18" viewBox="0 0 20 20" width="18" xmlns="http://www.w3.org/2000/svg">
<path
d="M5 11C5.55228 11 6 10.5523 6 10C6 9.44772 5.55228 9 5 9C4.44772 9 4 9.44772 4 10C4 10.5523 4.44772 11 5 11Z"
fill="currentColor"
/>
<path
d="M10 11C10.5523 11 11 10.5523 11 10C11 9.44772 10.5523 9 10 9C9.44772 9 9 9.44772 9 10C9 10.5523 9.44772 11 10 11Z"
fill="currentColor"
/>
<path
d="M15 11C15.5523 11 16 10.5523 16 10C16 9.44772 15.5523 9 15 9C14.4477 9 14 9.44772 14 10C14 10.5523 14.4477 11 15 11Z"
fill="currentColor"
/>
</svg>
)

View File

@@ -0,0 +1,14 @@
import React from 'react'
export const TableIcon: React.FC = () => {
return (
<svg fill="none" height="20" viewBox="0 0 20 20" width="20" xmlns="http://www.w3.org/2000/svg">
<path
clipRule="evenodd"
d="M5.33333 4.5C4.8731 4.5 4.5 4.8731 4.5 5.33333V7.5H9.5V4.5H5.33333ZM5.33333 3.5C4.32081 3.5 3.5 4.32081 3.5 5.33333V14.6667C3.5 15.6792 4.32081 16.5 5.33333 16.5H14.6667C15.6792 16.5 16.5 15.6792 16.5 14.6667V5.33333C16.5 4.32081 15.6792 3.5 14.6667 3.5H5.33333ZM10.5 4.5V7.5H15.5V5.33333C15.5 4.8731 15.1269 4.5 14.6667 4.5H10.5ZM15.5 8.5H10.5V11.5H15.5V8.5ZM15.5 12.5H10.5V15.5H14.6667C15.1269 15.5 15.5 15.1269 15.5 14.6667V12.5ZM9.5 15.5V12.5H4.5V14.6667C4.5 15.1269 4.8731 15.5 5.33333 15.5H9.5ZM4.5 11.5H9.5V8.5H4.5V11.5Z"
fill="currentColor"
fillRule="evenodd"
/>
</svg>
)
}

3
pnpm-lock.yaml generated
View File

@@ -1177,6 +1177,9 @@ importers:
'@lexical/selection':
specifier: 0.16.1
version: 0.16.1
'@lexical/table':
specifier: 0.16.1
version: 0.16.1
'@lexical/utils':
specifier: 0.16.1
version: 0.16.1

View File

@@ -6,6 +6,7 @@ import { createHeadlessEditor } from '@lexical/headless'
import { $convertToMarkdownString } from '@lexical/markdown'
import {
BlocksFeature,
EXPERIMENTAL_TableFeature,
FixedToolbarFeature,
HeadingFeature,
LinkFeature,
@@ -81,6 +82,7 @@ const editorConfig: ServerEditorConfig = {
TabBlock,
],
}),
EXPERIMENTAL_TableFeature(),
],
}