Skip to content

Commit 04733f0

Browse files
authored
feat(richtext-lexical): upgrade lexical from 0.20.0 to 0.21.0. Fixes table selection & scrollable table bugs (#10501)
Fixes #8036 This PR upgrades lexical from 0.20.0 to 0.21.0. As stated in the docs, please ensure you're using our re-exported lexical packages instead of installing lexical directly. E.g., import from `@payloadcms/richtext-lexical/lexical` instead of `lexical`. Direct lexical imports are not supported and may break. This PR ports over all relevant PRs from the lexical playground that have been pushed between 0.20.0 and 0.21.0. This includes a lot of bug fixes related to tables, specifically scrollable tables and table selection.
1 parent a49f782 commit 04733f0

File tree

13 files changed

+441
-376
lines changed

13 files changed

+441
-376
lines changed

packages/richtext-lexical/package.json

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -346,15 +346,15 @@
346346
]
347347
},
348348
"dependencies": {
349-
"@lexical/headless": "0.20.0",
350-
"@lexical/html": "0.20.0",
351-
"@lexical/link": "0.20.0",
352-
"@lexical/list": "0.20.0",
353-
"@lexical/mark": "0.20.0",
354-
"@lexical/react": "0.20.0",
355-
"@lexical/rich-text": "0.20.0",
356-
"@lexical/selection": "0.20.0",
357-
"@lexical/utils": "0.20.0",
349+
"@lexical/headless": "0.21.0",
350+
"@lexical/html": "0.21.0",
351+
"@lexical/link": "0.21.0",
352+
"@lexical/list": "0.21.0",
353+
"@lexical/mark": "0.21.0",
354+
"@lexical/react": "0.21.0",
355+
"@lexical/rich-text": "0.21.0",
356+
"@lexical/selection": "0.21.0",
357+
"@lexical/utils": "0.21.0",
358358
"@payloadcms/translations": "workspace:*",
359359
"@payloadcms/ui": "workspace:*",
360360
"@types/uuid": "10.0.0",
@@ -363,7 +363,7 @@
363363
"dequal": "2.0.3",
364364
"escape-html": "1.0.3",
365365
"jsox": "1.2.121",
366-
"lexical": "0.20.0",
366+
"lexical": "0.21.0",
367367
"mdast-util-from-markdown": "2.0.2",
368368
"mdast-util-mdx-jsx": "3.1.3",
369369
"micromark-extension-mdx-jsx": "3.0.1",
@@ -377,7 +377,7 @@
377377
"@babel/preset-env": "7.26.0",
378378
"@babel/preset-react": "7.25.9",
379379
"@babel/preset-typescript": "7.26.0",
380-
"@lexical/eslint-plugin": "0.20.0",
380+
"@lexical/eslint-plugin": "0.21.0",
381381
"@payloadcms/eslint-config": "workspace:*",
382382
"@types/escape-html": "1.0.4",
383383
"@types/json-schema": "7.0.15",
@@ -395,18 +395,18 @@
395395
"peerDependencies": {
396396
"@faceless-ui/modal": "3.0.0-beta.2",
397397
"@faceless-ui/scroll-info": "2.0.0-beta.0",
398-
"@lexical/headless": "0.20.0",
399-
"@lexical/html": "0.20.0",
400-
"@lexical/link": "0.20.0",
401-
"@lexical/list": "0.20.0",
402-
"@lexical/mark": "0.20.0",
403-
"@lexical/react": "0.20.0",
404-
"@lexical/rich-text": "0.20.0",
405-
"@lexical/selection": "0.20.0",
406-
"@lexical/table": "0.20.0",
407-
"@lexical/utils": "0.20.0",
398+
"@lexical/headless": "0.21.0",
399+
"@lexical/html": "0.21.0",
400+
"@lexical/link": "0.21.0",
401+
"@lexical/list": "0.21.0",
402+
"@lexical/mark": "0.21.0",
403+
"@lexical/react": "0.21.0",
404+
"@lexical/rich-text": "0.21.0",
405+
"@lexical/selection": "0.21.0",
406+
"@lexical/table": "0.21.0",
407+
"@lexical/utils": "0.21.0",
408408
"@payloadcms/next": "workspace:*",
409-
"lexical": "0.20.0",
409+
"lexical": "0.21.0",
410410
"payload": "workspace:*",
411411
"react": "^19.0.0 || ^19.0.0-rc-65a56d0e-20241020",
412412
"react-dom": "^19.0.0 || ^19.0.0-rc-65a56d0e-20241020"

packages/richtext-lexical/src/features/debug/testRecorder/client/plugin/index.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import type { BaseSelection, LexicalEditor } from 'lexical'
33

44
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
5-
import { $createParagraphNode, $createTextNode, $getRoot } from 'lexical'
5+
import { $createParagraphNode, $createTextNode, $getRoot, getDOMSelection } from 'lexical'
66
import * as React from 'react'
77
import { type JSX, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
88

@@ -153,7 +153,7 @@ function useTestRecorder(editor: LexicalEditor): [JSX.Element, JSX.Element | nul
153153

154154
const generateTestContent = useCallback(() => {
155155
const rootElement = editor.getRootElement()
156-
const browserSelection = window.getSelection()
156+
const browserSelection = getDOMSelection(editor._window)
157157

158158
if (
159159
rootElement == null ||
@@ -290,7 +290,7 @@ ${steps.map(formatStep).join(`\n`)}
290290
const skipNextSelectionChange = skipNextSelectionChangeRef.current
291291
if (previousSelection !== currentSelection) {
292292
if (dirtyLeaves.size === 0 && dirtyElements.size === 0 && !skipNextSelectionChange) {
293-
const browserSelection = window.getSelection()
293+
const browserSelection = getDOMSelection(editor._window)
294294
if (
295295
browserSelection &&
296296
(browserSelection.anchorNode == null || browserSelection.focusNode == null)
@@ -346,7 +346,7 @@ ${steps.map(formatStep).join(`\n`)}
346346
if (!isRecording) {
347347
return
348348
}
349-
const browserSelection = window.getSelection()
349+
const browserSelection = getDOMSelection(editor._window)
350350
if (
351351
browserSelection === null ||
352352
browserSelection.anchorNode == null ||

packages/richtext-lexical/src/features/experimental_table/client/plugins/TableActionMenuPlugin/index.scss

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,22 @@
88
will-change: transform;
99
}
1010

11+
.table-cell-action-button-container.table-cell-action-button-container--active {
12+
pointer-events: auto;
13+
opacity: 1;
14+
}
15+
.table-cell-action-button-container.table-cell-action-button-container--inactive {
16+
pointer-events: none;
17+
opacity: 0;
18+
}
19+
1120
.table-cell-action-button {
12-
background-color: var(--theme-elevation-200);
21+
//background-color: var(--theme-elevation-200);
1322
border: 0;
1423
padding: 2px;
15-
position: relative;
24+
position: absolute;
25+
top: 10px;
26+
right: 10px;
1627
border-radius: $style-radius-m;
1728
color: var(--theme-elevation-800);
1829
display: inline-block;

packages/richtext-lexical/src/features/experimental_table/client/plugins/TableActionMenuPlugin/index.tsx

Lines changed: 92 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
'use client'
22

3-
import type {
4-
HTMLTableElementWithWithTableSelectionState,
5-
TableRowNode,
6-
TableSelection,
7-
} from '@lexical/table'
3+
import type { TableObserver, TableRowNode, TableSelection } from '@lexical/table'
84
import type { ElementNode } from 'lexical'
95
import type { JSX } from 'react'
106

@@ -24,10 +20,12 @@ import {
2420
$isTableRowNode,
2521
$isTableSelection,
2622
$unmergeCell,
23+
getTableElement,
2724
getTableObserverFromTableElement,
2825
TableCellHeaderStates,
2926
TableCellNode,
3027
} from '@lexical/table'
28+
import { mergeRegister } from '@lexical/utils'
3129
import { useScrollInfo } from '@payloadcms/ui'
3230
import {
3331
$createParagraphNode,
@@ -37,15 +35,18 @@ import {
3735
$isParagraphNode,
3836
$isRangeSelection,
3937
$isTextNode,
38+
COMMAND_PRIORITY_CRITICAL,
39+
getDOMSelection,
40+
SELECTION_CHANGE_COMMAND,
4041
} from 'lexical'
4142
import * as React from 'react'
4243
import { useCallback, useEffect, useRef, useState } from 'react'
4344
import { createPortal } from 'react-dom'
4445

4546
import type { PluginComponentWithAnchor } from '../../../../typesClient.js'
4647

47-
import { MeatballsIcon } from '../../../../../lexical/ui/icons/Meatballs/index.js'
4848
import './index.scss'
49+
import { MeatballsIcon } from '../../../../../lexical/ui/icons/Meatballs/index.js'
4950

5051
function computeSelectionCount(selection: TableSelection): {
5152
columns: number
@@ -201,17 +202,15 @@ function TableActionMenu({
201202
editor.update(() => {
202203
if (tableCellNode.isAttached()) {
203204
const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode)
204-
const tableElement = editor.getElementByKey(
205-
tableNode.getKey(),
206-
) as HTMLTableElementWithWithTableSelectionState
205+
const tableElement = getTableElement(tableNode, editor.getElementByKey(tableNode.getKey()))
207206

208-
if (!tableElement) {
207+
if (tableElement === null) {
209208
throw new Error('Expected to find tableElement in DOM')
210209
}
211210

212211
const tableObserver = getTableObserverFromTableElement(tableElement)
213212
if (tableObserver !== null) {
214-
tableObserver.clearHighlight()
213+
tableObserver.$clearHighlight()
215214
}
216215

217216
tableNode.markDirty()
@@ -409,7 +408,7 @@ function TableActionMenu({
409408
onClick={() => mergeTableCellsAtSelection()}
410409
type="button"
411410
>
412-
Merge cells
411+
<span className="text">Merge cells</span>
413412
</button>
414413
)
415414
} else if (canUnmergeCell) {
@@ -420,7 +419,7 @@ function TableActionMenu({
420419
onClick={() => unmergeTableCellsAtSelection()}
421420
type="button"
422421
>
423-
Unmerge cells
422+
<span className="text">Unmerge cells</span>
424423
</button>
425424
)
426425
}
@@ -555,24 +554,32 @@ function TableCellActionMenuContainer({
555554
}): JSX.Element {
556555
const [editor] = useLexicalComposerContext()
557556

558-
const menuButtonRef = useRef(null)
559-
const menuRootRef = useRef(null)
557+
const menuButtonRef = useRef<HTMLDivElement | null>(null)
558+
const menuRootRef = useRef<HTMLButtonElement | null>(null)
560559
const [isMenuOpen, setIsMenuOpen] = useState(false)
561560

562561
const [tableCellNode, setTableMenuCellNode] = useState<null | TableCellNode>(null)
563562

564563
const $moveMenu = useCallback(() => {
565564
const menu = menuButtonRef.current
566565
const selection = $getSelection()
567-
const nativeSelection = window.getSelection()
566+
const nativeSelection = getDOMSelection(editor._window)
568567
const activeElement = document.activeElement
568+
function disable() {
569+
if (menu) {
570+
menu.classList.remove('table-cell-action-button-container--active')
571+
menu.classList.add('table-cell-action-button-container--inactive')
572+
}
573+
setTableMenuCellNode(null)
574+
}
569575

570576
if (selection == null || menu == null) {
571-
setTableMenuCellNode(null)
572-
return
577+
return disable()
573578
}
574579

575580
const rootElement = editor.getRootElement()
581+
let tableObserver: null | TableObserver = null
582+
let tableCellParentNodeDOM: HTMLElement | null = null
576583

577584
if (
578585
$isRangeSelection(selection) &&
@@ -585,53 +592,85 @@ function TableCellActionMenuContainer({
585592
)
586593

587594
if (tableCellNodeFromSelection == null) {
588-
setTableMenuCellNode(null)
589-
return
595+
return disable()
590596
}
591597

592-
const tableCellParentNodeDOM = editor.getElementByKey(tableCellNodeFromSelection.getKey())
598+
tableCellParentNodeDOM = editor.getElementByKey(tableCellNodeFromSelection.getKey())
593599

594-
if (tableCellParentNodeDOM == null) {
595-
setTableMenuCellNode(null)
596-
return
600+
if (tableCellParentNodeDOM == null || !tableCellNodeFromSelection.isAttached()) {
601+
return disable()
597602
}
598603

599-
setTableMenuCellNode(tableCellNodeFromSelection)
600-
} else if (!activeElement) {
601-
setTableMenuCellNode(null)
602-
}
603-
}, [editor])
604-
605-
useEffect(() => {
606-
return editor.registerUpdateListener(() => {
607-
editor.getEditorState().read(() => {
608-
$moveMenu()
609-
})
610-
})
611-
})
604+
const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNodeFromSelection)
605+
const tableElement = getTableElement(tableNode, editor.getElementByKey(tableNode.getKey()))
612606

613-
useEffect(() => {
614-
const menuButtonDOM = menuButtonRef.current as HTMLButtonElement | null
607+
if (tableElement === null) {
608+
throw new Error('TableActionMenu: Expected to find tableElement in DOM')
609+
}
615610

616-
if (menuButtonDOM != null && tableCellNode != null) {
617-
const tableCellNodeDOM = editor.getElementByKey(tableCellNode.getKey())
611+
tableObserver = getTableObserverFromTableElement(tableElement)
612+
setTableMenuCellNode(tableCellNodeFromSelection)
613+
} else if ($isTableSelection(selection)) {
614+
const anchorNode = $getTableCellNodeFromLexicalNode(selection.anchor.getNode())
615+
if (!$isTableCellNode(anchorNode)) {
616+
throw new Error('TableSelection anchorNode must be a TableCellNode')
617+
}
618+
const tableNode = $getTableNodeFromLexicalNodeOrThrow(anchorNode)
619+
const tableElement = getTableElement(tableNode, editor.getElementByKey(tableNode.getKey()))
618620

619-
if (tableCellNodeDOM != null) {
620-
const tableCellRect = tableCellNodeDOM.getBoundingClientRect()
621-
const menuRect = menuButtonDOM.getBoundingClientRect()
622-
const anchorRect = anchorElem.getBoundingClientRect()
621+
if (tableElement === null) {
622+
throw new Error('TableActionMenu: Expected to find tableElement in DOM')
623+
}
623624

624-
const top = tableCellRect.top - anchorRect.top + 4
625-
const left = tableCellRect.right - menuRect.width - 10 - anchorRect.left
625+
tableObserver = getTableObserverFromTableElement(tableElement)
626+
tableCellParentNodeDOM = editor.getElementByKey(anchorNode.getKey())
627+
} else if (!activeElement) {
628+
return disable()
629+
}
630+
if (tableObserver === null || tableCellParentNodeDOM === null) {
631+
return disable()
632+
}
633+
const enabled = !tableObserver || !tableObserver.isSelecting
634+
menu.classList.toggle('table-cell-action-button-container--active', enabled)
635+
menu.classList.toggle('table-cell-action-button-container--inactive', !enabled)
636+
if (enabled) {
637+
const tableCellRect = tableCellParentNodeDOM.getBoundingClientRect()
638+
const anchorRect = anchorElem.getBoundingClientRect()
639+
const top = tableCellRect.top - anchorRect.top
640+
const left = tableCellRect.right - anchorRect.left
641+
menu.style.transform = `translate(${left}px, ${top}px)`
642+
}
643+
}, [editor, anchorElem])
626644

627-
menuButtonDOM.style.opacity = '1'
628-
menuButtonDOM.style.transform = `translate(${left}px, ${top}px)`
629-
} else {
630-
menuButtonDOM.style.opacity = '0'
631-
menuButtonDOM.style.transform = 'translate(-10000px, -10000px)'
645+
useEffect(() => {
646+
// We call the $moveMenu callback every time the selection changes,
647+
// once up front, and once after each mouseUp
648+
let timeoutId: ReturnType<typeof setTimeout> | undefined = undefined
649+
const callback = () => {
650+
timeoutId = undefined
651+
editor.getEditorState().read($moveMenu)
652+
}
653+
const delayedCallback = () => {
654+
if (timeoutId === undefined) {
655+
timeoutId = setTimeout(callback, 0)
632656
}
657+
return false
633658
}
634-
}, [menuButtonRef, tableCellNode, editor, anchorElem])
659+
return mergeRegister(
660+
editor.registerUpdateListener(delayedCallback),
661+
editor.registerCommand(SELECTION_CHANGE_COMMAND, delayedCallback, COMMAND_PRIORITY_CRITICAL),
662+
editor.registerRootListener((rootElement, prevRootElement) => {
663+
if (prevRootElement) {
664+
prevRootElement.removeEventListener('mouseup', delayedCallback)
665+
}
666+
if (rootElement) {
667+
rootElement.addEventListener('mouseup', delayedCallback)
668+
delayedCallback()
669+
}
670+
}),
671+
() => clearTimeout(timeoutId),
672+
)
673+
})
635674

636675
const prevTableCellDOM = useRef(tableCellNode)
637676

0 commit comments

Comments
 (0)