feat(richtext-lexical): add EXPERIMENTAL_TableFeature, allow Client Features to register providers (#7010)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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!
|
||||
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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, {})
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.TableCellResizer__resizer {
|
||||
position: absolute;
|
||||
}
|
||||
@@ -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],
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -66,6 +66,7 @@ html[data-theme='dark'] {
|
||||
|
||||
&__group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
z-index: 1;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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
3
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user