fix(richtext-lexical): improve keyboard navigation on DecoratorNodes (#11022)

Fixes #8506



https://github.com/user-attachments/assets/a5e26f18-2557-4f19-bd89-73f246200fa5
This commit is contained in:
Germán Jabloñski
2025-02-10 16:22:25 -03:00
committed by GitHub
parent 91a0f90649
commit fa18923317
2 changed files with 309 additions and 15 deletions

View File

@@ -1,20 +1,29 @@
'use client'
import type { DecoratorNode } from 'lexical'
import type { DecoratorNode, ElementNode, LexicalNode } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import { $findMatchingParent, mergeRegister } from '@lexical/utils'
import {
$createNodeSelection,
$getEditor,
$getNearestNodeFromDOMNode,
$getSelection,
$isDecoratorNode,
$isElementNode,
$isLineBreakNode,
$isNodeSelection,
$isRangeSelection,
$isRootOrShadowRoot,
$isTextNode,
$setSelection,
CLICK_COMMAND,
COMMAND_PRIORITY_LOW,
KEY_ARROW_DOWN_COMMAND,
KEY_ARROW_UP_COMMAND,
KEY_BACKSPACE_COMMAND,
KEY_DELETE_COMMAND,
SELECTION_CHANGE_COMMAND,
} from 'lexical'
import { useEffect } from 'react'
@@ -43,11 +52,10 @@ export function DecoratorPlugin() {
CLICK_COMMAND,
(event) => {
document.querySelector('.decorator-selected')?.classList.remove('decorator-selected')
const decorator = $getDecorator(event)
const decorator = $getDecoratorByMouseEvent(event)
if (!decorator) {
return true
}
const { decoratorElement, decoratorNode } = decorator
const { target } = event
const isInteractive =
!(target instanceof HTMLElement) ||
@@ -58,10 +66,7 @@ export function DecoratorPlugin() {
if (isInteractive) {
$setSelection(null)
} else {
const selection = $createNodeSelection()
selection.add(decoratorNode.getKey())
$setSelection(selection)
decoratorElement.classList.add('decorator-selected')
$selectDecorator(decorator)
}
return true
},
@@ -69,22 +74,231 @@ export function DecoratorPlugin() {
),
editor.registerCommand(KEY_DELETE_COMMAND, $onDelete, COMMAND_PRIORITY_LOW),
editor.registerCommand(KEY_BACKSPACE_COMMAND, $onDelete, COMMAND_PRIORITY_LOW),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
const decorator = $getSelectedDecorator()
document.querySelector('.decorator-selected')?.classList.remove('decorator-selected')
if (decorator) {
decorator.element?.classList.add('decorator-selected')
return true
}
return false
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
KEY_ARROW_UP_COMMAND,
(event) => {
// CASE 1: Node selection
const selection = $getSelection()
if ($isNodeSelection(selection)) {
const prevSibling = selection.getNodes()[0]?.getPreviousSibling()
if ($isDecoratorNode(prevSibling)) {
const element = $getEditor().getElementByKey(prevSibling.getKey())
if (element) {
$selectDecorator({ element, node: prevSibling })
event.preventDefault()
return true
}
return false
}
if (!$isElementNode(prevSibling)) {
return false
}
const lastDescendant = prevSibling.getLastDescendant() ?? prevSibling
if (!lastDescendant) {
return false
}
const block = $findMatchingParent(lastDescendant, INTERNAL_$isBlock)
block?.selectStart()
event.preventDefault()
return true
}
if (!$isRangeSelection(selection)) {
return false
}
// CASE 2: Range selection
// Get first selected block
const firstPoint = selection.isBackward() ? selection.anchor : selection.focus
const firstNode = firstPoint.getNode()
const firstSelectedBlock = $findMatchingParent(firstNode, (node) => {
return findFirstSiblingBlock(node) !== null
})
const prevBlock = firstSelectedBlock?.getPreviousSibling()
if (!firstSelectedBlock || prevBlock !== findFirstSiblingBlock(firstSelectedBlock)) {
return false
}
if ($isDecoratorNode(prevBlock)) {
const prevBlockElement = $getEditor().getElementByKey(prevBlock.getKey())
if (prevBlockElement) {
$selectDecorator({ element: prevBlockElement, node: prevBlock })
event.preventDefault()
return true
}
}
return false
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
KEY_ARROW_DOWN_COMMAND,
(event) => {
// CASE 1: Node selection
const selection = $getSelection()
if ($isNodeSelection(selection)) {
event.preventDefault()
const nextSibling = selection.getNodes()[0]?.getNextSibling()
if ($isDecoratorNode(nextSibling)) {
const element = $getEditor().getElementByKey(nextSibling.getKey())
if (element) {
$selectDecorator({ element, node: nextSibling })
}
return true
}
if (!$isElementNode(nextSibling)) {
return true
}
const firstDescendant = nextSibling.getFirstDescendant() ?? nextSibling
if (!firstDescendant) {
return true
}
const block = $findMatchingParent(firstDescendant, INTERNAL_$isBlock)
block?.selectEnd()
event.preventDefault()
return true
}
if (!$isRangeSelection(selection)) {
return false
}
// CASE 2: Range selection
// Get last selected block
const lastPoint = selection.isBackward() ? selection.anchor : selection.focus
const lastNode = lastPoint.getNode()
const lastSelectedBlock = $findMatchingParent(lastNode, (node) => {
return findLaterSiblingBlock(node) !== null
})
const nextBlock = lastSelectedBlock?.getNextSibling()
if (!lastSelectedBlock || nextBlock !== findLaterSiblingBlock(lastSelectedBlock)) {
return false
}
if ($isDecoratorNode(nextBlock)) {
const nextBlockElement = $getEditor().getElementByKey(nextBlock.getKey())
if (nextBlockElement) {
$selectDecorator({ element: nextBlockElement, node: nextBlock })
event.preventDefault()
return true
}
}
return false
},
COMMAND_PRIORITY_LOW,
),
)
}, [editor])
return null
}
function $getDecorator(
function $getDecoratorByMouseEvent(
event: MouseEvent,
): { decoratorElement: Element; decoratorNode: DecoratorNode<unknown> } | undefined {
if (!(event.target instanceof Element)) {
): { element: HTMLElement; node: DecoratorNode<unknown> } | undefined {
if (!(event.target instanceof HTMLElement)) {
return undefined
}
const decoratorElement = event.target.closest('[data-lexical-decorator="true"]')
if (!decoratorElement) {
const element = event.target.closest('[data-lexical-decorator="true"]')
if (!(element instanceof HTMLElement)) {
return undefined
}
const node = $getNearestNodeFromDOMNode(decoratorElement)
return $isDecoratorNode(node) ? { decoratorElement, decoratorNode: node } : undefined
const node = $getNearestNodeFromDOMNode(element)
return $isDecoratorNode(node) ? { element, node } : undefined
}
function $getSelectedDecorator() {
const selection = $getSelection()
if (!$isNodeSelection(selection)) {
return undefined
}
const nodes = selection.getNodes()
if (nodes.length !== 1) {
return undefined
}
const node = nodes[0]
return $isDecoratorNode(node)
? {
decorator: node,
element: $getEditor().getElementByKey(node.getKey()),
}
: undefined
}
function $selectDecorator({
element,
node,
}: {
element: HTMLElement
node: DecoratorNode<unknown>
}) {
document.querySelector('.decorator-selected')?.classList.remove('decorator-selected')
const selection = $createNodeSelection()
selection.add(node.getKey())
$setSelection(selection)
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
element.classList.add('decorator-selected')
}
/**
* Copied from https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalUtils.ts
*
* This function returns true for a DecoratorNode that is not inline OR
* an ElementNode that is:
* - not a root or shadow root
* - not inline
* - can't be empty
* - has no children or an inline first child
*/
export function INTERNAL_$isBlock(node: LexicalNode): node is DecoratorNode<unknown> | ElementNode {
if ($isDecoratorNode(node) && !node.isInline()) {
return true
}
if (!$isElementNode(node) || $isRootOrShadowRoot(node)) {
return false
}
const firstChild = node.getFirstChild()
const isLeafElement =
firstChild === null ||
$isLineBreakNode(firstChild) ||
$isTextNode(firstChild) ||
firstChild.isInline()
return !node.isInline() && node.canBeEmpty() !== false && isLeafElement
}
function findLaterSiblingBlock(node: LexicalNode): LexicalNode | null {
let current = node.getNextSibling()
while (current !== null) {
if (INTERNAL_$isBlock(current)) {
return current
}
current = current.getNextSibling()
}
return null
}
function findFirstSiblingBlock(node: LexicalNode): LexicalNode | null {
let current = node.getPreviousSibling()
while (current !== null) {
if (INTERNAL_$isBlock(current)) {
return current
}
current = current.getPreviousSibling()
}
return null
}

View File

@@ -1519,4 +1519,84 @@ describe('lexicalMain', () => {
await monacoCode.click()
await expect(decoratorLocator).toBeHidden()
})
test('arrow keys', async () => {
// utils
const selectedDecorator = page.locator('.decorator-selected')
const topLevelDecorator = page.locator(
'[data-lexical-decorator="true"]:not([data-lexical-decorator="true"] [data-lexical-decorator="true"])',
)
const selectedNthDecorator = async (nth: number) => {
await expect(selectedDecorator).toBeVisible()
const areSame = await selectedDecorator.evaluateHandle(
(el1, el2) => el1 === el2,
await topLevelDecorator.nth(nth).elementHandle(),
)
await expect.poll(async () => await areSame.jsonValue()).toBe(true)
}
// test
await navigateToLexicalFields()
const textNode = page.getByText('Upload Node:', { exact: true })
await textNode.click()
await expect(selectedDecorator).toBeHidden()
await page.keyboard.press('ArrowDown')
await selectedNthDecorator(0)
await page.keyboard.press('ArrowDown')
await selectedNthDecorator(1)
await page.keyboard.press('ArrowDown')
await selectedNthDecorator(2)
await page.keyboard.press('ArrowDown')
await selectedNthDecorator(3)
await page.keyboard.press('ArrowDown')
await selectedNthDecorator(4)
await page.keyboard.press('ArrowDown')
await selectedNthDecorator(5)
await page.keyboard.press('ArrowDown')
await page.keyboard.press('ArrowDown')
await selectedNthDecorator(6)
await page.keyboard.press('ArrowDown')
await selectedNthDecorator(7)
await page.keyboard.press('ArrowDown')
await page.keyboard.press('ArrowDown')
await selectedNthDecorator(8)
await page.keyboard.press('ArrowDown')
await page.keyboard.press('ArrowDown')
await selectedNthDecorator(9)
await page.keyboard.press('ArrowDown')
await selectedNthDecorator(10)
await page.keyboard.press('ArrowDown')
await selectedNthDecorator(10)
await page.keyboard.press('ArrowUp')
await selectedNthDecorator(9)
await page.keyboard.press('ArrowUp')
await page.keyboard.press('ArrowUp')
await selectedNthDecorator(8)
await page.keyboard.press('ArrowUp')
await page.keyboard.press('ArrowUp')
await selectedNthDecorator(7)
await page.keyboard.press('ArrowUp')
await selectedNthDecorator(6)
await page.keyboard.press('ArrowUp')
await page.keyboard.press('ArrowUp')
await selectedNthDecorator(5)
await page.keyboard.press('ArrowUp')
await selectedNthDecorator(4)
await page.keyboard.press('ArrowUp')
await selectedNthDecorator(3)
await page.keyboard.press('ArrowUp')
await selectedNthDecorator(2)
await page.keyboard.press('ArrowUp')
await selectedNthDecorator(1)
await page.keyboard.press('ArrowUp')
await selectedNthDecorator(0)
await page.keyboard.press('ArrowUp')
await selectedNthDecorator(0)
// TODO: It would be nice to add tests with lists and nested lists
// before and after decoratorNodes and paragraphs. Tested manually,
// but these are complex cases.
})
})