feat(richtext-lexical): upgrade lexical from 0.21.0 to 0.27.1 (#11564)

Fixes https://github.com/payloadcms/payload/issues/10628

This upgrades lexical from 0.21.0 to 0.27.1. This will allow us to use the new node state API to implement custom text formats (e.g. text colors), [thanks to Germán](https://x.com/GermanJablo/status/1897345631821222292).

## Notable changes ported over from lexical playground:

### Table column freezing

https://github.com/user-attachments/assets/febdd7dd-6fa0-40d7-811c-9a38de04bfa7

### Block cursors

We now render a block cursor, which is a custom cursor that gets rendered when the browser doesn't render the native one. An example would be this this horizontal cursor above block nodes, if there is no space above:

![CleanShot 2025-03-05 at 18 48 08@2x](https://github.com/user-attachments/assets/f61ce280-599c-4123-bdf7-25507078fcd7)

Previously, those cursors were unstyled and not visible

### Table Alignment

Tables can now be aligned

![CleanShot 2025-03-05 at 19 48 32@2x](https://github.com/user-attachments/assets/3fe263db-a98e-4a5d-92fd-a0388e547e5b)
This commit is contained in:
Alessio Gravili
2025-03-06 10:06:39 -07:00
committed by GitHub
parent 9f7e8f47d2
commit 557ac9931a
28 changed files with 752 additions and 978 deletions

View File

@@ -265,11 +265,6 @@
"types": "./src/lexical-proxy/@lexical-react/LexicalTabIndentationPlugin.ts",
"default": "./src/lexical-proxy/@lexical-react/LexicalTabIndentationPlugin.ts"
},
"./lexical/react/LexicalTableOfContents": {
"import": "./src/lexical-proxy/@lexical-react/LexicalTableOfContents.ts",
"types": "./src/lexical-proxy/@lexical-react/LexicalTableOfContents.ts",
"default": "./src/lexical-proxy/@lexical-react/LexicalTableOfContents.ts"
},
"./lexical/react/LexicalTableOfContentsPlugin": {
"import": "./src/lexical-proxy/@lexical-react/LexicalTableOfContentsPlugin.ts",
"types": "./src/lexical-proxy/@lexical-react/LexicalTableOfContentsPlugin.ts",
@@ -356,16 +351,16 @@
]
},
"dependencies": {
"@lexical/headless": "0.21.0",
"@lexical/html": "0.21.0",
"@lexical/link": "0.21.0",
"@lexical/list": "0.21.0",
"@lexical/mark": "0.21.0",
"@lexical/react": "0.21.0",
"@lexical/rich-text": "0.21.0",
"@lexical/selection": "0.21.0",
"@lexical/table": "0.21.0",
"@lexical/utils": "0.21.0",
"@lexical/headless": "0.27.1",
"@lexical/html": "0.27.1",
"@lexical/link": "0.27.1",
"@lexical/list": "0.27.1",
"@lexical/mark": "0.27.1",
"@lexical/react": "0.27.1",
"@lexical/rich-text": "0.27.1",
"@lexical/selection": "0.27.1",
"@lexical/table": "0.27.1",
"@lexical/utils": "0.27.1",
"@payloadcms/translations": "workspace:*",
"@payloadcms/ui": "workspace:*",
"@types/uuid": "10.0.0",
@@ -374,7 +369,7 @@
"dequal": "2.0.3",
"escape-html": "1.0.3",
"jsox": "1.2.121",
"lexical": "0.21.0",
"lexical": "0.27.1",
"mdast-util-from-markdown": "2.0.2",
"mdast-util-mdx-jsx": "3.1.3",
"micromark-extension-mdx-jsx": "3.0.1",
@@ -389,7 +384,7 @@
"@babel/preset-env": "7.26.7",
"@babel/preset-react": "7.26.3",
"@babel/preset-typescript": "7.26.0",
"@lexical/eslint-plugin": "0.21.0",
"@lexical/eslint-plugin": "0.27.1",
"@payloadcms/eslint-config": "workspace:*",
"@types/escape-html": "1.0.4",
"@types/json-schema": "7.0.15",
@@ -662,11 +657,6 @@
"types": "./dist/lexical-proxy/@lexical-react/LexicalTabIndentationPlugin.d.ts",
"default": "./dist/lexical-proxy/@lexical-react/LexicalTabIndentationPlugin.js"
},
"./lexical/react/LexicalTableOfContents": {
"import": "./dist/lexical-proxy/@lexical-react/LexicalTableOfContents.js",
"types": "./dist/lexical-proxy/@lexical-react/LexicalTableOfContents.d.ts",
"default": "./dist/lexical-proxy/@lexical-react/LexicalTableOfContents.js"
},
"./lexical/react/LexicalTableOfContentsPlugin": {
"import": "./dist/lexical-proxy/@lexical-react/LexicalTableOfContentsPlugin.js",
"types": "./dist/lexical-proxy/@lexical-react/LexicalTableOfContentsPlugin.d.ts",

View File

@@ -1,5 +1,4 @@
import { $createQuoteNode, $isQuoteNode, QuoteNode } from '@lexical/rich-text'
import { $createLineBreakNode } from 'lexical'
import type { ElementTransformer } from '../../packages/@lexical/markdown/index.js'
@@ -23,10 +22,7 @@ export const MarkdownTransformer: ElementTransformer = {
if (isImport) {
const previousNode = parentNode.getPreviousSibling()
if ($isQuoteNode(previousNode)) {
previousNode.splice(previousNode.getChildrenSize(), 0, [
$createLineBreakNode(),
...children,
])
previousNode.splice(previousNode.getChildrenSize(), 0, [...children])
previousNode.select(0, 0)
parentNode.remove()
return

View File

@@ -95,8 +95,8 @@ export class ServerBlockNode extends DecoratorBlockNode {
return false
}
override decorate(editor: LexicalEditor, config: EditorConfig): JSX.Element | null {
return null
override decorate(editor: LexicalEditor, config: EditorConfig): JSX.Element {
return null as unknown as JSX.Element
}
override exportDOM(): DOMExportOutput {

View File

@@ -3,6 +3,7 @@
@layer payload-default {
.table-cell-action-button-container {
position: absolute;
z-index: 3;
top: 0;
left: 0;
will-change: transform;

View File

@@ -1,12 +1,13 @@
'use client'
import type { TableObserver, TableRowNode, TableSelection } from '@lexical/table'
import type { TableObserver, 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 {
$computeTableMapSkipCellCheck,
$deleteTableColumn__EXPERIMENTAL,
$deleteTableRow__EXPERIMENTAL,
$getNodeTriplet,
@@ -17,7 +18,6 @@ import {
$insertTableColumn__EXPERIMENTAL,
$insertTableRow__EXPERIMENTAL,
$isTableCellNode,
$isTableRowNode,
$isTableSelection,
$unmergeCell,
getTableElement,
@@ -37,6 +37,7 @@ import {
$isTextNode,
COMMAND_PRIORITY_CRITICAL,
getDOMSelection,
isDOMNode,
SELECTION_CHANGE_COMMAND,
} from 'lexical'
import * as React from 'react'
@@ -186,8 +187,9 @@ function TableActionMenu({
if (
dropDownRef.current != null &&
contextRef.current != null &&
!dropDownRef.current.contains(event.target as Node) &&
!contextRef.current.contains(event.target as Node)
isDOMNode(event.target) &&
!dropDownRef.current.contains(event.target) &&
!contextRef.current.contains(event.target)
) {
setIsMenuOpen(false)
}
@@ -226,35 +228,105 @@ function TableActionMenu({
editor.update(() => {
const selection = $getSelection()
if ($isTableSelection(selection)) {
const { columns, rows } = computeSelectionCount(selection)
// Get all selected cells and compute the total area
const nodes = selection.getNodes()
let firstCell: null | TableCellNode = 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()
const tableCells = nodes.filter($isTableCellNode)
if (tableCells.length === 0) {
return
}
// Find the table node
const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCells[0] as TableCellNode)
const [gridMap] = $computeTableMapSkipCellCheck(tableNode, null, null)
// Find the boundaries of the selection including merged cells
let minRow = Infinity
let maxRow = -Infinity
let minCol = Infinity
let maxCol = -Infinity
// First pass: find the actual boundaries considering merged cells
const processedCells = new Set()
for (const row of gridMap) {
for (const mapCell of row) {
if (!mapCell || !mapCell.cell) {
continue
}
const cellKey = mapCell.cell.getKey()
if (processedCells.has(cellKey)) {
continue
}
if (tableCells.some((cell) => cell.is(mapCell.cell))) {
processedCells.add(cellKey)
// Get the actual position of this cell in the grid
const cellStartRow = mapCell.startRow
const cellStartCol = mapCell.startColumn
const cellRowSpan = mapCell.cell.__rowSpan || 1
const cellColSpan = mapCell.cell.__colSpan || 1
// Update boundaries considering the cell's actual position and span
minRow = Math.min(minRow, cellStartRow)
maxRow = Math.max(maxRow, cellStartRow + cellRowSpan - 1)
minCol = Math.min(minCol, cellStartCol)
maxCol = Math.max(maxCol, cellStartCol + cellColSpan - 1)
}
}
}
if (firstCell !== null) {
if (firstCell.getChildrenSize() === 0) {
firstCell.append($createParagraphNode())
}
$selectLastDescendant(firstCell)
// Validate boundaries
if (minRow === Infinity || minCol === Infinity) {
return
}
// The total span of the merged cell
const totalRowSpan = maxRow - minRow + 1
const totalColSpan = maxCol - minCol + 1
// Use the top-left cell as the target cell
const targetCellMap = gridMap?.[minRow]?.[minCol]
if (!targetCellMap?.cell) {
return
}
const targetCell = targetCellMap.cell
// Set the spans for the target cell
targetCell.setColSpan(totalColSpan)
targetCell.setRowSpan(totalRowSpan)
// Move content from other cells to the target cell
const seenCells = new Set([targetCell.getKey()])
// Second pass: merge content and remove other cells
for (let row = minRow; row <= maxRow; row++) {
for (let col = minCol; col <= maxCol; col++) {
const mapCell = gridMap?.[row]?.[col]
if (!mapCell?.cell) {
continue
}
const currentCell = mapCell.cell
const key = currentCell.getKey()
if (!seenCells.has(key)) {
seenCells.add(key)
const isEmpty = $cellContainsEmptyParagraph(currentCell)
if (!isEmpty) {
targetCell.append(...currentCell.getChildren())
}
currentCell.remove()
}
}
}
// Ensure target cell has content
if (targetCell.getChildrenSize() === 0) {
targetCell.append($createParagraphNode())
}
$selectLastDescendant(targetCell)
onClose()
}
})
@@ -269,11 +341,13 @@ function TableActionMenu({
const insertTableRowAtSelection = useCallback(
(shouldInsertAfter: boolean) => {
editor.update(() => {
$insertTableRow__EXPERIMENTAL(shouldInsertAfter)
for (let i = 0; i < selectionCounts.rows; i++) {
$insertTableRow__EXPERIMENTAL(shouldInsertAfter)
}
onClose()
})
},
[editor, onClose],
[editor, onClose, selectionCounts.rows],
)
const insertTableColumnAtSelection = useCallback(
@@ -318,26 +392,25 @@ function TableActionMenu({
const tableRowIndex = $getTableRowIndexFromTableCellNode(tableCellNode)
const tableRows = tableNode.getChildren()
const [gridMap] = $computeTableMapSkipCellCheck(tableNode, null, null)
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')
}
const rowCells = new Set<TableCellNode>()
const newStyle = tableCellNode.getHeaderStyles() ^ TableCellHeaderStates.ROW
tableRow.getChildren().forEach((tableCell) => {
if (!$isTableCellNode(tableCell)) {
throw new Error('Expected table cell')
}
if (gridMap[tableRowIndex]) {
for (let col = 0; col < gridMap[tableRowIndex].length; col++) {
const mapCell = gridMap[tableRowIndex][col]
tableCell.setHeaderStyles(newStyle, TableCellHeaderStates.ROW)
})
if (!mapCell?.cell) {
continue
}
if (!rowCells.has(mapCell.cell)) {
rowCells.add(mapCell.cell)
mapCell.cell.setHeaderStyles(newStyle, TableCellHeaderStates.ROW)
}
}
}
clearTableSelection()
onClose()
@@ -350,35 +423,26 @@ function TableActionMenu({
const tableColumnIndex = $getTableColumnIndexFromTableCellNode(tableCellNode)
const tableRows = tableNode.getChildren<TableRowNode>()
const maxRowsLength = Math.max(...tableRows.map((row) => row.getChildren().length))
const [gridMap] = $computeTableMapSkipCellCheck(tableNode, null, null)
if (tableColumnIndex >= maxRowsLength || tableColumnIndex < 0) {
throw new Error('Expected table cell to be inside of table row.')
}
const columnCells = new Set<TableCellNode>()
const newStyle = tableCellNode.getHeaderStyles() ^ TableCellHeaderStates.COLUMN
for (let r = 0; r < tableRows.length; r++) {
const tableRow = tableRows[r]
if (gridMap) {
for (let row = 0; row < gridMap.length; row++) {
const mapCell = gridMap?.[row]?.[tableColumnIndex]
if (!$isTableRowNode(tableRow)) {
throw new Error('Expected table row')
if (!mapCell?.cell) {
continue
}
if (!columnCells.has(mapCell.cell)) {
columnCells.add(mapCell.cell)
mapCell.cell.setHeaderStyles(newStyle, TableCellHeaderStates.COLUMN)
}
}
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.setHeaderStyles(newStyle, TableCellHeaderStates.COLUMN)
}
clearTableSelection()
onClose()
})
@@ -398,6 +462,19 @@ function TableActionMenu({
})
}, [editor, tableCellNode, clearTableSelection, onClose])
const toggleFirstColumnFreeze = useCallback(() => {
editor.update(() => {
if (tableCellNode.isAttached()) {
const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode)
if (tableNode) {
tableNode.setFrozenColumns(tableNode.getFrozenColumns() === 0 ? 1 : 0)
}
}
clearTableSelection()
onClose()
})
}, [editor, tableCellNode, clearTableSelection, onClose])
let mergeCellButton: JSX.Element | null = null
if (cellMerge) {
if (canMergeCells) {
@@ -449,6 +526,14 @@ function TableActionMenu({
>
<span className="text">Toggle Row Striping</span>
</button>
<button
className="item"
data-test-id="table-freeze-first-column"
onClick={() => toggleFirstColumnFreeze()}
type="button"
>
<span className="text">Toggle First Column Freeze</span>
</button>
<button
className="item"
data-test-id="table-insert-row-above"
@@ -518,7 +603,12 @@ function TableActionMenu({
<span className="text">Delete table</span>
</button>
<hr />
<button className="item" onClick={() => toggleTableRowIsHeader()} type="button">
<button
className="item"
data-test-id="table-row-header"
onClick={() => toggleTableRowIsHeader()}
type="button"
>
<span className="text">
{(tableCellNode.__headerState & TableCellHeaderStates.ROW) === TableCellHeaderStates.ROW
? 'Remove'

View File

@@ -1,7 +1,7 @@
'use client'
import type { TableCellNode, TableDOMCell, TableMapType } from '@lexical/table'
import type { LexicalEditor } from 'lexical'
import type { LexicalEditor, NodeKey } from 'lexical'
import type { JSX, MouseEventHandler } from 'react'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
@@ -16,8 +16,8 @@ import {
getTableElement,
TableNode,
} from '@lexical/table'
import { calculateZoomLevel } from '@lexical/utils'
import { $getNearestNodeFromDOMNode } from 'lexical'
import { calculateZoomLevel, mergeRegister } from '@lexical/utils'
import { $getNearestNodeFromDOMNode, isHTMLElement } from 'lexical'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
@@ -40,6 +40,7 @@ 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 [hasTable, setHasTable] = useState(false)
const editorConfig = useEditorConfigContext()
const mouseStartPosRef = useRef<MousePosition | null>(null)
@@ -62,22 +63,42 @@ function TableCellResizer({ editor }: { editor: LexicalEditor }): JSX.Element {
}
useEffect(() => {
return editor.registerNodeTransform(TableNode, (tableNode) => {
if (tableNode.getColWidths()) {
const tableKeys = new Set<NodeKey>()
return mergeRegister(
editor.registerMutationListener(TableNode, (nodeMutations) => {
for (const [nodeKey, mutation] of nodeMutations) {
if (mutation === 'destroyed') {
tableKeys.delete(nodeKey)
} else {
tableKeys.add(nodeKey)
}
}
setHasTable(tableKeys.size > 0)
}),
editor.registerNodeTransform(TableNode, (tableNode) => {
if (tableNode.getColWidths()) {
return tableNode
}
const numColumns = tableNode.getColumnCount()
const columnWidth = MIN_COLUMN_WIDTH
tableNode.setColWidths(Array(numColumns).fill(columnWidth))
return tableNode
}
const numColumns = tableNode.getColumnCount()
const columnWidth = MIN_COLUMN_WIDTH
tableNode.setColWidths(Array(numColumns).fill(columnWidth))
return tableNode
})
}),
)
}, [editor])
useEffect(() => {
if (!hasTable) {
return
}
const onMouseMove = (event: MouseEvent) => {
const target = event.target
if (!isHTMLElement(target)) {
return
}
if (draggingDirection) {
updateMouseCurrentPos({
@@ -87,13 +108,13 @@ function TableCellResizer({ editor }: { editor: LexicalEditor }): JSX.Element {
return
}
updateIsMouseDown(isMouseDownOnEvent(event))
if (resizerRef.current && resizerRef.current.contains(target as Node)) {
if (resizerRef.current && resizerRef.current.contains(target)) {
return
}
if (targetRef.current !== target) {
targetRef.current = target as HTMLElement
const cell = getDOMCellFromTarget(target as HTMLElement)
targetRef.current = target
const cell = getDOMCellFromTarget(target)
if (cell && activeCell !== cell) {
editor.getEditorState().read(
@@ -113,7 +134,7 @@ function TableCellResizer({ editor }: { editor: LexicalEditor }): JSX.Element {
throw new Error('TableCellResizer: Table element not found.')
}
targetRef.current = target as HTMLElement
targetRef.current = target
tableRectRef.current = tableElement.getBoundingClientRect()
updateActiveCell(cell)
},
@@ -145,7 +166,7 @@ function TableCellResizer({ editor }: { editor: LexicalEditor }): JSX.Element {
return () => {
removeRootListener()
}
}, [activeCell, draggingDirection, editor, resetState])
}, [activeCell, draggingDirection, editor, hasTable, resetState])
const isHeightChanging = (direction: MouseDraggingDirection) => {
if (direction === 'bottom') {

View File

@@ -17,7 +17,7 @@ import {
TableNode,
} from '@lexical/table'
import { $findMatchingParent, mergeRegister } from '@lexical/utils'
import { $getNearestNodeFromDOMNode } from 'lexical'
import { $getNearestNodeFromDOMNode, isHTMLElement } from 'lexical'
import { useEffect, useMemo, useRef, useState } from 'react'
import * as React from 'react'
import { createPortal } from 'react-dom'
@@ -109,7 +109,15 @@ function TableHoverActionsContainer({
right: tableElemRight,
width: tableElemWidth,
y: tableElemY,
} = tableContainerElement.getBoundingClientRect()
} = (tableDOMElement as HTMLTableElement).getBoundingClientRect()
let tableHasScroll = false
if (
tableContainerElement &&
tableContainerElement.classList.contains('LexicalEditorTheme__tableScrollableWrapper')
) {
tableHasScroll = tableContainerElement.scrollWidth > tableContainerElement.clientWidth
}
const { left: editorElemLeft, y: editorElemY } = anchorElem.getBoundingClientRect()
@@ -118,9 +126,15 @@ function TableHoverActionsContainer({
setShownRow(true)
setPosition({
height: BUTTON_WIDTH_PX,
left: tableElemLeft - editorElemLeft,
left:
tableHasScroll && tableContainerElement
? tableContainerElement.offsetLeft
: tableElemLeft - editorElemLeft,
top: tableElemBottom - editorElemY + 5,
width: tableElemWidth,
width:
tableHasScroll && tableContainerElement
? tableContainerElement.offsetWidth
: tableElemWidth,
})
} else if (hoveredColumnNode) {
setShownColumn(true)
@@ -256,7 +270,7 @@ function getMouseInfo(
} {
const target = event.target
if (target && target instanceof HTMLElement) {
if (isHTMLElement(target)) {
const tableDOMNode = target.closest<HTMLElement>(
`td.${editorConfig.theme.tableCell}, th.${editorConfig.theme.tableCell}`,
)

View File

@@ -7,8 +7,17 @@
margin: 0px 25px 30px 0px;
}
&__tableScrollableWrapper > .LexicalEditorTheme__table {
/* Remove the table's margin and put it on the wrapper */
margin: 0;
/* Remove the table's vertical margin and put it on the wrapper */
margin-top: 0;
margin-bottom: 0;
}
&__tableAlignmentCenter {
margin-left: auto;
margin-right: auto;
}
&__tableAlignmentRight {
margin-left: auto;
}
&__tableSelection *::selection {
@@ -23,7 +32,8 @@
overflow-x: scroll;
table-layout: fixed;
width: fit-content;
margin: 0 25px 30px 0;
margin-top: 25px;
margin-bottom: 30px;
::selection {
background: rgba(172, 206, 247);
@@ -34,6 +44,28 @@
}
}
&__tableFrozenColumn tr > td:first-child {
background-color: var(--theme-bg);
position: sticky;
z-index: 2;
left: 0;
}
&__tableFrozenColumn tr > th:first-child {
background-color: var(--theme-elevation-50);
position: sticky;
z-index: 2;
left: 0;
}
&__tableFrozenColumn tr > :first-child::after {
content: '';
position: absolute;
left: 0;
top: 0;
right: 0;
height: 100%;
border-right: 1px solid var(--theme-elevation-400);
}
&__tableRowStriping tr:nth-child(even) {
background-color: var(--theme-elevation-100);
}
@@ -52,6 +84,14 @@
outline: none;
}
/*
* A firefox workaround to allow scrolling of overflowing table cell
* ref: https://bugzilla.mozilla.org/show_bug.cgi?id=1904159
*/
&__tableCell > * {
overflow: inherit;
}
&__tableCellResizer {
position: absolute;
right: -4px;

View File

@@ -12,7 +12,7 @@ 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 { INSERT_TABLE_COMMAND, TableCellNode, TableNode, TableRowNode } from '@lexical/table'
import { mergeRegister } from '@lexical/utils'
import { formatDrawerSlug, useEditDepth } from '@payloadcms/ui'
import { $getSelection, $isRangeSelection, COMMAND_PRIORITY_EDITOR, createCommand } from 'lexical'
@@ -97,8 +97,10 @@ export const TablePlugin: PluginComponent = () => {
const { toggleDrawer } = useLexicalDrawer(drawerSlug, true)
useEffect(() => {
if (!editor.hasNodes([TableNode])) {
throw new Error('TablePlugin: TableNode is not registered on editor')
if (!editor.hasNodes([TableNode, TableRowNode, TableCellNode])) {
throw new Error(
'TablePlugin: TableNode, TableRowNode, or TableCellNode is not registered on editor',
)
}
return mergeRegister(

View File

@@ -48,7 +48,10 @@ export const TableMarkdownTransformer: (props: {
for (const cell of row.getChildren()) {
// It's TableCellNode, so it's just to make flow happy
if ($isTableCellNode(cell)) {
rowOutput.push($convertToMarkdownString(allTransformers, cell).replace(/\n/g, '\\n'))
rowOutput.push(
$convertToMarkdownString(allTransformers, cell).replace(/\n/g, '\\n').trim(),
)
if (cell.__headerState === TableCellHeaderStates.ROW) {
isHeaderRow = true
}

View File

@@ -16,7 +16,7 @@
background-color: var(--theme-elevation-250);
}
.LexicalEditorTheme__hr.selected {
.LexicalEditorTheme__hr.LexicalEditorTheme__hrSelected {
outline: 2px solid var(--theme-success-250);
user-select: none;
}

View File

@@ -12,6 +12,7 @@ import {
} from 'lexical'
import type { ToolbarGroup } from '../../toolbars/types.js'
import type { PluginComponent } from '../../typesClient.js'
import { IndentDecreaseIcon } from '../../../lexical/ui/icons/IndentDecrease/index.js'
import { IndentIncreaseIcon } from '../../../lexical/ui/icons/IndentIncrease/index.js'
@@ -83,7 +84,7 @@ const toolbarGroups: ToolbarGroup[] = [
export const IndentFeatureClient = createClientFeature({
plugins: [
{
Component: TabIndentationPlugin,
Component: TabIndentationPlugin as PluginComponent,
position: 'normal',
},
],

View File

@@ -1,4 +1,4 @@
import type { ElementNode, LexicalNode, RangeSelection } from 'lexical'
import type { ElementNode, LexicalNode, LexicalUpdateJSON, RangeSelection } from 'lexical'
import { $applyNodeReplacement, $isElementNode } from 'lexical'
@@ -24,6 +24,11 @@ export class AutoLinkNode extends LinkNode {
}
static override importJSON(serializedNode: SerializedAutoLinkNode): AutoLinkNode {
const node = $createAutoLinkNode({}).updateFromJSON(serializedNode)
/**
* @todo remove in 4.0
*/
if (
serializedNode.version === 1 &&
typeof serializedNode.fields?.doc?.value === 'object' &&
@@ -33,11 +38,6 @@ export class AutoLinkNode extends LinkNode {
serializedNode.version = 2
}
const node = $createAutoLinkNode({ fields: serializedNode.fields })
node.setFormat(serializedNode.format)
node.setIndent(serializedNode.indent)
node.setDirection(serializedNode.direction)
return node
}
@@ -64,9 +64,13 @@ export class AutoLinkNode extends LinkNode {
}
return null
}
override updateFromJSON(serializedNode: LexicalUpdateJSON<SerializedAutoLinkNode>): this {
return super.updateFromJSON(serializedNode).setFields(serializedNode.fields)
}
}
export function $createAutoLinkNode({ fields }: { fields: LinkFields }): AutoLinkNode {
export function $createAutoLinkNode({ fields }: { fields?: LinkFields }): AutoLinkNode {
return $applyNodeReplacement(new AutoLinkNode({ id: '', fields }))
}
export function $isAutoLinkNode(node: LexicalNode | null | undefined): node is AutoLinkNode {

View File

@@ -6,6 +6,7 @@ import type {
ElementNode as ElementNodeType,
LexicalCommand,
LexicalNode,
LexicalUpdateJSON,
NodeKey,
RangeSelection,
} from 'lexical'
@@ -40,7 +41,7 @@ export class LinkNode extends ElementNode {
},
key,
}: {
fields: LinkFields
fields?: LinkFields
id: string
key?: NodeKey
}) {
@@ -71,6 +72,11 @@ export class LinkNode extends ElementNode {
}
static override importJSON(serializedNode: SerializedLinkNode): LinkNode {
const node = $createLinkNode({}).updateFromJSON(serializedNode)
/**
* @todo remove this in 4.0
*/
if (
serializedNode.version === 1 &&
typeof serializedNode.fields?.doc?.value === 'object' &&
@@ -84,14 +90,6 @@ export class LinkNode extends ElementNode {
serializedNode.id = new ObjectID.default().toHexString()
serializedNode.version = 3
}
const node = $createLinkNode({
id: serializedNode.id,
fields: serializedNode.fields,
})
node.setFormat(serializedNode.format)
node.setIndent(serializedNode.indent)
node.setDirection(serializedNode.direction)
return node
}
@@ -203,12 +201,19 @@ export class LinkNode extends ElementNode {
return url
}
setFields(fields: LinkFields): void {
setFields(fields: LinkFields): this {
const writable = this.getWritable()
writable.__fields = fields
return writable
}
override updateDOM(prevNode: LinkNode, anchor: HTMLAnchorElement, config: EditorConfig): boolean {
setID(id: string): this {
const writable = this.getWritable()
writable.__id = id
return writable
}
override updateDOM(prevNode: this, anchor: HTMLAnchorElement, config: EditorConfig): boolean {
const url = this.__fields?.url
const newTab = this.__fields?.newTab
if (url != null && url !== prevNode.__fields?.url && this.__fields?.linkType === 'custom') {
@@ -238,6 +243,13 @@ export class LinkNode extends ElementNode {
return false
}
override updateFromJSON(serializedNode: LexicalUpdateJSON<SerializedLinkNode>): this {
return super
.updateFromJSON(serializedNode)
.setFields(serializedNode.fields)
.setID(serializedNode.id as string)
}
}
function $convertAnchorElement(domNode: Node): DOMConversionOutput {
@@ -259,7 +271,7 @@ function $convertAnchorElement(domNode: Node): DOMConversionOutput {
return { node }
}
export function $createLinkNode({ id, fields }: { fields: LinkFields; id?: string }): LinkNode {
export function $createLinkNode({ id, fields }: { fields?: LinkFields; id?: string }): LinkNode {
return $applyNodeReplacement(
new LinkNode({
id: id ?? new ObjectID.default().toHexString(),

View File

@@ -21,6 +21,9 @@ export type LinkFields = {
export type SerializedLinkNode<T extends SerializedLexicalNode = SerializedLexicalNode> = Spread<
{
fields: LinkFields
/**
* @todo make required in 4.0 and type AutoLinkNode differently
*/
id?: string // optional if AutoLinkNode
type: 'link'
},

View File

@@ -80,7 +80,7 @@ export class UnknownConvertedNode extends DecoratorNode<JSX.Element> {
return true
}
override updateDOM(prevNode: UnknownConvertedNode, dom: HTMLElement): boolean {
override updateDOM(prevNode: this, dom: HTMLElement): boolean {
return false
}
}

View File

@@ -80,7 +80,7 @@ export class UnknownConvertedNode extends DecoratorNode<JSX.Element> {
return true
}
override updateDOM(prevNode: UnknownConvertedNode, dom: HTMLElement): boolean {
override updateDOM(prevNode: this, dom: HTMLElement): boolean {
return false
}
}

View File

@@ -104,8 +104,8 @@ export class RelationshipServerNode extends DecoratorBlockNode {
return false
}
override decorate(_editor: LexicalEditor, _config: EditorConfig): JSX.Element | null {
return null
override decorate(_editor: LexicalEditor, _config: EditorConfig): JSX.Element {
return null as unknown as JSX.Element
}
override exportDOM(): DOMExportOutput {

View File

@@ -1,6 +1,6 @@
'use client'
import { Button } from '@payloadcms/ui'
import { $addUpdateTag, type LexicalEditor } from 'lexical'
import { $addUpdateTag, isDOMNode, type LexicalEditor } from 'lexical'
import React, { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
@@ -229,13 +229,16 @@ export function DropDown({
if (button !== null && showDropDown) {
const handle = (event: MouseEvent): void => {
const { target } = event
if (stopCloseOnClickSelf != null) {
if (dropDownRef.current != null && dropDownRef.current.contains(target as Node)) {
const target = event.target
if (!isDOMNode(target)) {
return
}
if (stopCloseOnClickSelf) {
if (dropDownRef.current && dropDownRef.current.contains(target)) {
return
}
}
if (!button.contains(target as Node)) {
if (!button.contains(target)) {
setShowDropDown(false)
}
}

View File

@@ -26,7 +26,7 @@ import { richTextValidateHOC } from './validate/index.js'
let checkedDependencies = false
export const lexicalTargetVersion = '0.21.0'
export const lexicalTargetVersion = '0.27.1'
export function lexicalEditor(args?: LexicalEditorProps): LexicalRichTextAdapterProvider {
if (

View File

@@ -1 +0,0 @@
export * from '@lexical/react/LexicalTableOfContents'

View File

@@ -451,6 +451,16 @@ export function LexicalMenu({
)
}
function setContainerDivAttributes(containerDiv: HTMLElement, className?: string) {
if (className != null) {
containerDiv.className = className
}
containerDiv.setAttribute('aria-label', 'Slash menu')
containerDiv.setAttribute('role', 'listbox')
containerDiv.style.display = 'block'
containerDiv.style.position = 'absolute'
}
export function useMenuAnchorRef(
anchorElem: HTMLElement,
resolution: MenuResolution | null,
@@ -508,16 +518,10 @@ export function useMenuAnchorRef(
}
if (!containerDiv.isConnected) {
if (className != null) {
containerDiv.className = className
}
containerDiv.setAttribute('aria-label', 'Slash menu')
containerDiv.setAttribute('id', 'slash-menu')
containerDiv.setAttribute('role', 'listbox')
containerDiv.style.display = 'block'
containerDiv.style.position = 'absolute'
setContainerDivAttributes(containerDiv, className)
anchorElem.append(containerDiv)
}
containerDiv.setAttribute('id', 'slash-menu')
anchorElementRef.current = containerDiv
rootElement.setAttribute('aria-controls', 'slash-menu')
}
@@ -535,6 +539,7 @@ export function useMenuAnchorRef(
const containerDiv = anchorElementRef.current
if (containerDiv !== null && containerDiv.isConnected) {
containerDiv.remove()
containerDiv.removeAttribute('id')
}
}
}

View File

@@ -2,13 +2,12 @@
import type { LexicalEditor, LexicalNode, ParagraphNode } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
import { $createParagraphNode } from 'lexical'
import { $createParagraphNode, isHTMLElement } from 'lexical'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useEditorConfigContext } from '../../../config/client/EditorConfigProvider.js'
import { isHTMLElement } from '../../../utils/guard.js'
import { Point } from '../../../utils/point.js'
import { ENABLE_SLASH_MENU_COMMAND } from '../../SlashMenu/LexicalTypeaheadMenuPlugin/index.js'
import { calculateDistanceFromScrollerElem } from '../utils/calculateDistanceFromScrollerElem.js'

View File

@@ -4,13 +4,12 @@ import type { DragEvent as ReactDragEvent } from 'react'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
import { eventFiles } from '@lexical/rich-text'
import { $getNearestNodeFromDOMNode, $getNodeByKey } from 'lexical'
import { $getNearestNodeFromDOMNode, $getNodeByKey, isHTMLElement } from 'lexical'
import * as React from 'react'
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useEditorConfigContext } from '../../../config/client/EditorConfigProvider.js'
import { isHTMLElement } from '../../../utils/guard.js'
import { Point } from '../../../utils/point.js'
import { calculateDistanceFromScrollerElem } from '../utils/calculateDistanceFromScrollerElem.js'
import { getNodeCloseToPoint } from '../utils/getNodeCloseToPoint.js'

View File

@@ -106,6 +106,47 @@
text-decoration: underline line-through;
}
&__tabNode {
position: relative;
text-decoration: none;
}
&__tabNode.LexicalEditorTheme__textUnderline::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0.15em;
border-bottom: 0.1em solid currentColor;
}
&__tabNode.LexicalEditorTheme__textStrikethrough::before {
content: '';
position: absolute;
left: 0;
right: 0;
top: 0.69em;
border-top: 0.1em solid currentColor;
}
&__tabNode.LexicalEditorTheme__textUnderlineStrikethrough::before,
&__tabNode.LexicalEditorTheme__textUnderlineStrikethrough::after {
content: '';
position: absolute;
left: 0;
right: 0;
}
&__tabNode.LexicalEditorTheme__textUnderlineStrikethrough::before {
top: 0.69em;
border-top: 0.1em solid currentColor;
}
&__tabNode.LexicalEditorTheme__textUnderlineStrikethrough::after {
bottom: 0.05em;
border-bottom: 0.1em solid currentColor;
}
&__textSubscript {
font-size: 0.8em;
vertical-align: sub !important;
@@ -143,6 +184,27 @@
border-bottom: 1px dotted;
}
// Renders cursor when native browser does not. See https://github.com/facebook/lexical/issues/3417
&__blockCursor {
display: block;
pointer-events: none;
position: absolute;
}
&__blockCursor:after {
content: '';
display: block;
position: absolute;
top: -2px;
width: 20px;
border-top: 1px solid var(--theme-text);
animation: CursorBlink 1.1s steps(2, start) infinite;
}
@keyframes CursorBlink {
to {
visibility: hidden;
}
}
&__code {
/*background-color: rgb(240, 242, 245);*/
font-family: Menlo, Consolas, Monaco, monospace;

View File

@@ -51,6 +51,7 @@ export const LexicalEditorTheme: EditorThemeClasses = {
h6: 'LexicalEditorTheme__h6',
},
hr: 'LexicalEditorTheme__hr',
hrSelected: 'LexicalEditorTheme__hrSelected',
indent: 'LexicalEditorTheme__indent',
inlineImage: 'LexicalEditor__inline-image',
link: 'LexicalEditorTheme__link',
@@ -78,15 +79,21 @@ export const LexicalEditorTheme: EditorThemeClasses = {
quote: 'LexicalEditorTheme__quote',
relationship: 'LexicalEditorTheme__relationship',
rtl: 'LexicalEditorTheme__rtl',
tab: 'LexicalEditorTheme__tabNode',
table: 'LexicalEditorTheme__table',
tableAddColumns: 'LexicalEditorTheme__tableAddColumns',
tableAddRows: 'LexicalEditorTheme__tableAddRows',
tableAlignment: {
center: 'LexicalEditorTheme__tableAlignmentCenter',
right: 'LexicalEditorTheme__tableAlignmentRight',
},
tableCell: 'LexicalEditorTheme__tableCell',
tableCellActionButton: 'LexicalEditorTheme__tableCellActionButton',
tableCellActionButtonContainer: 'LexicalEditorTheme__tableCellActionButtonContainer',
tableCellHeader: 'LexicalEditorTheme__tableCellHeader',
tableCellResizer: 'LexicalEditorTheme__tableCellResizer',
tableCellSelected: 'LexicalEditorTheme__tableCellSelected',
tableFrozenColumn: 'LexicalEditorTheme__tableFrozenColumn',
tableRowStriping: 'LexicalEditorTheme__tableRowStriping',
tableScrollableWrapper: 'LexicalEditorTheme__tableScrollableWrapper',
tableSelected: 'LexicalEditorTheme__tableSelected',

View File

@@ -1,4 +1,8 @@
'use client'
/**
* @deprecated - remove in 4.0. lexical already exports an isHTMLElement utility
*/
export function isHTMLElement(x: unknown): x is HTMLElement {
return x instanceof HTMLElement
}

1129
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff