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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user