Skip to content

Commit

Permalink
feat(richtext-lexical): upgrade lexical from 0.20.0 to 0.21.0. Fixes …
Browse files Browse the repository at this point in the history
…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.
  • Loading branch information
AlessioGr authored Jan 10, 2025
1 parent a49f782 commit 04733f0
Show file tree
Hide file tree
Showing 13 changed files with 441 additions and 376 deletions.
44 changes: 22 additions & 22 deletions packages/richtext-lexical/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -346,15 +346,15 @@
]
},
"dependencies": {
"@lexical/headless": "0.20.0",
"@lexical/html": "0.20.0",
"@lexical/link": "0.20.0",
"@lexical/list": "0.20.0",
"@lexical/mark": "0.20.0",
"@lexical/react": "0.20.0",
"@lexical/rich-text": "0.20.0",
"@lexical/selection": "0.20.0",
"@lexical/utils": "0.20.0",
"@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/utils": "0.21.0",
"@payloadcms/translations": "workspace:*",
"@payloadcms/ui": "workspace:*",
"@types/uuid": "10.0.0",
Expand All @@ -363,7 +363,7 @@
"dequal": "2.0.3",
"escape-html": "1.0.3",
"jsox": "1.2.121",
"lexical": "0.20.0",
"lexical": "0.21.0",
"mdast-util-from-markdown": "2.0.2",
"mdast-util-mdx-jsx": "3.1.3",
"micromark-extension-mdx-jsx": "3.0.1",
Expand All @@ -377,7 +377,7 @@
"@babel/preset-env": "7.26.0",
"@babel/preset-react": "7.25.9",
"@babel/preset-typescript": "7.26.0",
"@lexical/eslint-plugin": "0.20.0",
"@lexical/eslint-plugin": "0.21.0",
"@payloadcms/eslint-config": "workspace:*",
"@types/escape-html": "1.0.4",
"@types/json-schema": "7.0.15",
Expand All @@ -395,18 +395,18 @@
"peerDependencies": {
"@faceless-ui/modal": "3.0.0-beta.2",
"@faceless-ui/scroll-info": "2.0.0-beta.0",
"@lexical/headless": "0.20.0",
"@lexical/html": "0.20.0",
"@lexical/link": "0.20.0",
"@lexical/list": "0.20.0",
"@lexical/mark": "0.20.0",
"@lexical/react": "0.20.0",
"@lexical/rich-text": "0.20.0",
"@lexical/selection": "0.20.0",
"@lexical/table": "0.20.0",
"@lexical/utils": "0.20.0",
"@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",
"@payloadcms/next": "workspace:*",
"lexical": "0.20.0",
"lexical": "0.21.0",
"payload": "workspace:*",
"react": "^19.0.0 || ^19.0.0-rc-65a56d0e-20241020",
"react-dom": "^19.0.0 || ^19.0.0-rc-65a56d0e-20241020"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import type { BaseSelection, LexicalEditor } from 'lexical'

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

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

const generateTestContent = useCallback(() => {
const rootElement = editor.getRootElement()
const browserSelection = window.getSelection()
const browserSelection = getDOMSelection(editor._window)

if (
rootElement == null ||
Expand Down Expand Up @@ -290,7 +290,7 @@ ${steps.map(formatStep).join(`\n`)}
const skipNextSelectionChange = skipNextSelectionChangeRef.current
if (previousSelection !== currentSelection) {
if (dirtyLeaves.size === 0 && dirtyElements.size === 0 && !skipNextSelectionChange) {
const browserSelection = window.getSelection()
const browserSelection = getDOMSelection(editor._window)
if (
browserSelection &&
(browserSelection.anchorNode == null || browserSelection.focusNode == null)
Expand Down Expand Up @@ -346,7 +346,7 @@ ${steps.map(formatStep).join(`\n`)}
if (!isRecording) {
return
}
const browserSelection = window.getSelection()
const browserSelection = getDOMSelection(editor._window)
if (
browserSelection === null ||
browserSelection.anchorNode == null ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,22 @@
will-change: transform;
}

.table-cell-action-button-container.table-cell-action-button-container--active {
pointer-events: auto;
opacity: 1;
}
.table-cell-action-button-container.table-cell-action-button-container--inactive {
pointer-events: none;
opacity: 0;
}

.table-cell-action-button {
background-color: var(--theme-elevation-200);
//background-color: var(--theme-elevation-200);
border: 0;
padding: 2px;
position: relative;
position: absolute;
top: 10px;
right: 10px;
border-radius: $style-radius-m;
color: var(--theme-elevation-800);
display: inline-block;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
'use client'

import type {
HTMLTableElementWithWithTableSelectionState,
TableRowNode,
TableSelection,
} from '@lexical/table'
import type { TableObserver, TableRowNode, TableSelection } from '@lexical/table'
import type { ElementNode } from 'lexical'
import type { JSX } from 'react'

Expand All @@ -24,10 +20,12 @@ import {
$isTableRowNode,
$isTableSelection,
$unmergeCell,
getTableElement,
getTableObserverFromTableElement,
TableCellHeaderStates,
TableCellNode,
} from '@lexical/table'
import { mergeRegister } from '@lexical/utils'
import { useScrollInfo } from '@payloadcms/ui'
import {
$createParagraphNode,
Expand All @@ -37,15 +35,18 @@ import {
$isParagraphNode,
$isRangeSelection,
$isTextNode,
COMMAND_PRIORITY_CRITICAL,
getDOMSelection,
SELECTION_CHANGE_COMMAND,
} 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 './index.scss'
import { MeatballsIcon } from '../../../../../lexical/ui/icons/Meatballs/index.js'

function computeSelectionCount(selection: TableSelection): {
columns: number
Expand Down Expand Up @@ -201,17 +202,15 @@ function TableActionMenu({
editor.update(() => {
if (tableCellNode.isAttached()) {
const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode)
const tableElement = editor.getElementByKey(
tableNode.getKey(),
) as HTMLTableElementWithWithTableSelectionState
const tableElement = getTableElement(tableNode, editor.getElementByKey(tableNode.getKey()))

if (!tableElement) {
if (tableElement === null) {
throw new Error('Expected to find tableElement in DOM')
}

const tableObserver = getTableObserverFromTableElement(tableElement)
if (tableObserver !== null) {
tableObserver.clearHighlight()
tableObserver.$clearHighlight()
}

tableNode.markDirty()
Expand Down Expand Up @@ -409,7 +408,7 @@ function TableActionMenu({
onClick={() => mergeTableCellsAtSelection()}
type="button"
>
Merge cells
<span className="text">Merge cells</span>
</button>
)
} else if (canUnmergeCell) {
Expand All @@ -420,7 +419,7 @@ function TableActionMenu({
onClick={() => unmergeTableCellsAtSelection()}
type="button"
>
Unmerge cells
<span className="text">Unmerge cells</span>
</button>
)
}
Expand Down Expand Up @@ -555,24 +554,32 @@ function TableCellActionMenuContainer({
}): JSX.Element {
const [editor] = useLexicalComposerContext()

const menuButtonRef = useRef(null)
const menuRootRef = useRef(null)
const menuButtonRef = useRef<HTMLDivElement | null>(null)
const menuRootRef = useRef<HTMLButtonElement | null>(null)
const [isMenuOpen, setIsMenuOpen] = useState(false)

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

const $moveMenu = useCallback(() => {
const menu = menuButtonRef.current
const selection = $getSelection()
const nativeSelection = window.getSelection()
const nativeSelection = getDOMSelection(editor._window)
const activeElement = document.activeElement
function disable() {
if (menu) {
menu.classList.remove('table-cell-action-button-container--active')
menu.classList.add('table-cell-action-button-container--inactive')
}
setTableMenuCellNode(null)
}

if (selection == null || menu == null) {
setTableMenuCellNode(null)
return
return disable()
}

const rootElement = editor.getRootElement()
let tableObserver: null | TableObserver = null
let tableCellParentNodeDOM: HTMLElement | null = null

if (
$isRangeSelection(selection) &&
Expand All @@ -585,53 +592,85 @@ function TableCellActionMenuContainer({
)

if (tableCellNodeFromSelection == null) {
setTableMenuCellNode(null)
return
return disable()
}

const tableCellParentNodeDOM = editor.getElementByKey(tableCellNodeFromSelection.getKey())
tableCellParentNodeDOM = editor.getElementByKey(tableCellNodeFromSelection.getKey())

if (tableCellParentNodeDOM == null) {
setTableMenuCellNode(null)
return
if (tableCellParentNodeDOM == null || !tableCellNodeFromSelection.isAttached()) {
return disable()
}

setTableMenuCellNode(tableCellNodeFromSelection)
} else if (!activeElement) {
setTableMenuCellNode(null)
}
}, [editor])

useEffect(() => {
return editor.registerUpdateListener(() => {
editor.getEditorState().read(() => {
$moveMenu()
})
})
})
const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNodeFromSelection)
const tableElement = getTableElement(tableNode, editor.getElementByKey(tableNode.getKey()))

useEffect(() => {
const menuButtonDOM = menuButtonRef.current as HTMLButtonElement | null
if (tableElement === null) {
throw new Error('TableActionMenu: Expected to find tableElement in DOM')
}

if (menuButtonDOM != null && tableCellNode != null) {
const tableCellNodeDOM = editor.getElementByKey(tableCellNode.getKey())
tableObserver = getTableObserverFromTableElement(tableElement)
setTableMenuCellNode(tableCellNodeFromSelection)
} else if ($isTableSelection(selection)) {
const anchorNode = $getTableCellNodeFromLexicalNode(selection.anchor.getNode())
if (!$isTableCellNode(anchorNode)) {
throw new Error('TableSelection anchorNode must be a TableCellNode')
}
const tableNode = $getTableNodeFromLexicalNodeOrThrow(anchorNode)
const tableElement = getTableElement(tableNode, editor.getElementByKey(tableNode.getKey()))

if (tableCellNodeDOM != null) {
const tableCellRect = tableCellNodeDOM.getBoundingClientRect()
const menuRect = menuButtonDOM.getBoundingClientRect()
const anchorRect = anchorElem.getBoundingClientRect()
if (tableElement === null) {
throw new Error('TableActionMenu: Expected to find tableElement in DOM')
}

const top = tableCellRect.top - anchorRect.top + 4
const left = tableCellRect.right - menuRect.width - 10 - anchorRect.left
tableObserver = getTableObserverFromTableElement(tableElement)
tableCellParentNodeDOM = editor.getElementByKey(anchorNode.getKey())
} else if (!activeElement) {
return disable()
}
if (tableObserver === null || tableCellParentNodeDOM === null) {
return disable()
}
const enabled = !tableObserver || !tableObserver.isSelecting
menu.classList.toggle('table-cell-action-button-container--active', enabled)
menu.classList.toggle('table-cell-action-button-container--inactive', !enabled)
if (enabled) {
const tableCellRect = tableCellParentNodeDOM.getBoundingClientRect()
const anchorRect = anchorElem.getBoundingClientRect()
const top = tableCellRect.top - anchorRect.top
const left = tableCellRect.right - anchorRect.left
menu.style.transform = `translate(${left}px, ${top}px)`
}
}, [editor, anchorElem])

menuButtonDOM.style.opacity = '1'
menuButtonDOM.style.transform = `translate(${left}px, ${top}px)`
} else {
menuButtonDOM.style.opacity = '0'
menuButtonDOM.style.transform = 'translate(-10000px, -10000px)'
useEffect(() => {
// We call the $moveMenu callback every time the selection changes,
// once up front, and once after each mouseUp
let timeoutId: ReturnType<typeof setTimeout> | undefined = undefined
const callback = () => {
timeoutId = undefined
editor.getEditorState().read($moveMenu)
}
const delayedCallback = () => {
if (timeoutId === undefined) {
timeoutId = setTimeout(callback, 0)
}
return false
}
}, [menuButtonRef, tableCellNode, editor, anchorElem])
return mergeRegister(
editor.registerUpdateListener(delayedCallback),
editor.registerCommand(SELECTION_CHANGE_COMMAND, delayedCallback, COMMAND_PRIORITY_CRITICAL),
editor.registerRootListener((rootElement, prevRootElement) => {
if (prevRootElement) {
prevRootElement.removeEventListener('mouseup', delayedCallback)
}
if (rootElement) {
rootElement.addEventListener('mouseup', delayedCallback)
delayedCallback()
}
}),
() => clearTimeout(timeoutId),
)
})

const prevTableCellDOM = useRef(tableCellNode)

Expand Down
Loading

0 comments on commit 04733f0

Please sign in to comment.