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'
|
'use client'
|
||||||
|
|
||||||
import type { DecoratorNode } from 'lexical'
|
import type { DecoratorNode, ElementNode, LexicalNode } from 'lexical'
|
||||||
|
|
||||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||||
import { mergeRegister } from '@lexical/utils'
|
import { $findMatchingParent, mergeRegister } from '@lexical/utils'
|
||||||
import {
|
import {
|
||||||
$createNodeSelection,
|
$createNodeSelection,
|
||||||
|
$getEditor,
|
||||||
$getNearestNodeFromDOMNode,
|
$getNearestNodeFromDOMNode,
|
||||||
$getSelection,
|
$getSelection,
|
||||||
$isDecoratorNode,
|
$isDecoratorNode,
|
||||||
|
$isElementNode,
|
||||||
|
$isLineBreakNode,
|
||||||
$isNodeSelection,
|
$isNodeSelection,
|
||||||
|
$isRangeSelection,
|
||||||
|
$isRootOrShadowRoot,
|
||||||
|
$isTextNode,
|
||||||
$setSelection,
|
$setSelection,
|
||||||
CLICK_COMMAND,
|
CLICK_COMMAND,
|
||||||
COMMAND_PRIORITY_LOW,
|
COMMAND_PRIORITY_LOW,
|
||||||
|
KEY_ARROW_DOWN_COMMAND,
|
||||||
|
KEY_ARROW_UP_COMMAND,
|
||||||
KEY_BACKSPACE_COMMAND,
|
KEY_BACKSPACE_COMMAND,
|
||||||
KEY_DELETE_COMMAND,
|
KEY_DELETE_COMMAND,
|
||||||
|
SELECTION_CHANGE_COMMAND,
|
||||||
} from 'lexical'
|
} from 'lexical'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
@@ -43,11 +52,10 @@ export function DecoratorPlugin() {
|
|||||||
CLICK_COMMAND,
|
CLICK_COMMAND,
|
||||||
(event) => {
|
(event) => {
|
||||||
document.querySelector('.decorator-selected')?.classList.remove('decorator-selected')
|
document.querySelector('.decorator-selected')?.classList.remove('decorator-selected')
|
||||||
const decorator = $getDecorator(event)
|
const decorator = $getDecoratorByMouseEvent(event)
|
||||||
if (!decorator) {
|
if (!decorator) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
const { decoratorElement, decoratorNode } = decorator
|
|
||||||
const { target } = event
|
const { target } = event
|
||||||
const isInteractive =
|
const isInteractive =
|
||||||
!(target instanceof HTMLElement) ||
|
!(target instanceof HTMLElement) ||
|
||||||
@@ -58,10 +66,7 @@ export function DecoratorPlugin() {
|
|||||||
if (isInteractive) {
|
if (isInteractive) {
|
||||||
$setSelection(null)
|
$setSelection(null)
|
||||||
} else {
|
} else {
|
||||||
const selection = $createNodeSelection()
|
$selectDecorator(decorator)
|
||||||
selection.add(decoratorNode.getKey())
|
|
||||||
$setSelection(selection)
|
|
||||||
decoratorElement.classList.add('decorator-selected')
|
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
@@ -69,22 +74,231 @@ export function DecoratorPlugin() {
|
|||||||
),
|
),
|
||||||
editor.registerCommand(KEY_DELETE_COMMAND, $onDelete, COMMAND_PRIORITY_LOW),
|
editor.registerCommand(KEY_DELETE_COMMAND, $onDelete, COMMAND_PRIORITY_LOW),
|
||||||
editor.registerCommand(KEY_BACKSPACE_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])
|
}, [editor])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function $getDecorator(
|
function $getDecoratorByMouseEvent(
|
||||||
event: MouseEvent,
|
event: MouseEvent,
|
||||||
): { decoratorElement: Element; decoratorNode: DecoratorNode<unknown> } | undefined {
|
): { element: HTMLElement; node: DecoratorNode<unknown> } | undefined {
|
||||||
if (!(event.target instanceof Element)) {
|
if (!(event.target instanceof HTMLElement)) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
const decoratorElement = event.target.closest('[data-lexical-decorator="true"]')
|
const element = event.target.closest('[data-lexical-decorator="true"]')
|
||||||
if (!decoratorElement) {
|
if (!(element instanceof HTMLElement)) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
const node = $getNearestNodeFromDOMNode(decoratorElement)
|
const node = $getNearestNodeFromDOMNode(element)
|
||||||
return $isDecoratorNode(node) ? { decoratorElement, decoratorNode: node } : undefined
|
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 monacoCode.click()
|
||||||
await expect(decoratorLocator).toBeHidden()
|
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