Skip to content

Commit fa18923

Browse files
authored
fix(richtext-lexical): improve keyboard navigation on DecoratorNodes (#11022)
Fixes #8506 https://github.com/user-attachments/assets/a5e26f18-2557-4f19-bd89-73f246200fa5
1 parent 91a0f90 commit fa18923

File tree

2 files changed

+309
-15
lines changed
  • packages/richtext-lexical/src/lexical/plugins/DecoratorPlugin
  • test/fields/collections/Lexical/e2e/main

2 files changed

+309
-15
lines changed

packages/richtext-lexical/src/lexical/plugins/DecoratorPlugin/index.tsx

Lines changed: 229 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,29 @@
11
'use client'
22

3-
import type { DecoratorNode } from 'lexical'
3+
import type { DecoratorNode, ElementNode, LexicalNode } from 'lexical'
44

55
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
6-
import { mergeRegister } from '@lexical/utils'
6+
import { $findMatchingParent, mergeRegister } from '@lexical/utils'
77
import {
88
$createNodeSelection,
9+
$getEditor,
910
$getNearestNodeFromDOMNode,
1011
$getSelection,
1112
$isDecoratorNode,
13+
$isElementNode,
14+
$isLineBreakNode,
1215
$isNodeSelection,
16+
$isRangeSelection,
17+
$isRootOrShadowRoot,
18+
$isTextNode,
1319
$setSelection,
1420
CLICK_COMMAND,
1521
COMMAND_PRIORITY_LOW,
22+
KEY_ARROW_DOWN_COMMAND,
23+
KEY_ARROW_UP_COMMAND,
1624
KEY_BACKSPACE_COMMAND,
1725
KEY_DELETE_COMMAND,
26+
SELECTION_CHANGE_COMMAND,
1827
} from 'lexical'
1928
import { useEffect } from 'react'
2029

@@ -43,11 +52,10 @@ export function DecoratorPlugin() {
4352
CLICK_COMMAND,
4453
(event) => {
4554
document.querySelector('.decorator-selected')?.classList.remove('decorator-selected')
46-
const decorator = $getDecorator(event)
55+
const decorator = $getDecoratorByMouseEvent(event)
4756
if (!decorator) {
4857
return true
4958
}
50-
const { decoratorElement, decoratorNode } = decorator
5159
const { target } = event
5260
const isInteractive =
5361
!(target instanceof HTMLElement) ||
@@ -58,33 +66,239 @@ export function DecoratorPlugin() {
5866
if (isInteractive) {
5967
$setSelection(null)
6068
} else {
61-
const selection = $createNodeSelection()
62-
selection.add(decoratorNode.getKey())
63-
$setSelection(selection)
64-
decoratorElement.classList.add('decorator-selected')
69+
$selectDecorator(decorator)
6570
}
6671
return true
6772
},
6873
COMMAND_PRIORITY_LOW,
6974
),
7075
editor.registerCommand(KEY_DELETE_COMMAND, $onDelete, COMMAND_PRIORITY_LOW),
7176
editor.registerCommand(KEY_BACKSPACE_COMMAND, $onDelete, COMMAND_PRIORITY_LOW),
77+
editor.registerCommand(
78+
SELECTION_CHANGE_COMMAND,
79+
() => {
80+
const decorator = $getSelectedDecorator()
81+
document.querySelector('.decorator-selected')?.classList.remove('decorator-selected')
82+
if (decorator) {
83+
decorator.element?.classList.add('decorator-selected')
84+
return true
85+
}
86+
return false
87+
},
88+
COMMAND_PRIORITY_LOW,
89+
),
90+
editor.registerCommand(
91+
KEY_ARROW_UP_COMMAND,
92+
(event) => {
93+
// CASE 1: Node selection
94+
const selection = $getSelection()
95+
if ($isNodeSelection(selection)) {
96+
const prevSibling = selection.getNodes()[0]?.getPreviousSibling()
97+
if ($isDecoratorNode(prevSibling)) {
98+
const element = $getEditor().getElementByKey(prevSibling.getKey())
99+
if (element) {
100+
$selectDecorator({ element, node: prevSibling })
101+
event.preventDefault()
102+
return true
103+
}
104+
return false
105+
}
106+
if (!$isElementNode(prevSibling)) {
107+
return false
108+
}
109+
const lastDescendant = prevSibling.getLastDescendant() ?? prevSibling
110+
if (!lastDescendant) {
111+
return false
112+
}
113+
const block = $findMatchingParent(lastDescendant, INTERNAL_$isBlock)
114+
block?.selectStart()
115+
event.preventDefault()
116+
return true
117+
}
118+
if (!$isRangeSelection(selection)) {
119+
return false
120+
}
121+
122+
// CASE 2: Range selection
123+
// Get first selected block
124+
const firstPoint = selection.isBackward() ? selection.anchor : selection.focus
125+
const firstNode = firstPoint.getNode()
126+
const firstSelectedBlock = $findMatchingParent(firstNode, (node) => {
127+
return findFirstSiblingBlock(node) !== null
128+
})
129+
const prevBlock = firstSelectedBlock?.getPreviousSibling()
130+
if (!firstSelectedBlock || prevBlock !== findFirstSiblingBlock(firstSelectedBlock)) {
131+
return false
132+
}
133+
134+
if ($isDecoratorNode(prevBlock)) {
135+
const prevBlockElement = $getEditor().getElementByKey(prevBlock.getKey())
136+
if (prevBlockElement) {
137+
$selectDecorator({ element: prevBlockElement, node: prevBlock })
138+
event.preventDefault()
139+
return true
140+
}
141+
}
142+
143+
return false
144+
},
145+
COMMAND_PRIORITY_LOW,
146+
),
147+
editor.registerCommand(
148+
KEY_ARROW_DOWN_COMMAND,
149+
(event) => {
150+
// CASE 1: Node selection
151+
const selection = $getSelection()
152+
if ($isNodeSelection(selection)) {
153+
event.preventDefault()
154+
const nextSibling = selection.getNodes()[0]?.getNextSibling()
155+
if ($isDecoratorNode(nextSibling)) {
156+
const element = $getEditor().getElementByKey(nextSibling.getKey())
157+
if (element) {
158+
$selectDecorator({ element, node: nextSibling })
159+
}
160+
return true
161+
}
162+
if (!$isElementNode(nextSibling)) {
163+
return true
164+
}
165+
const firstDescendant = nextSibling.getFirstDescendant() ?? nextSibling
166+
if (!firstDescendant) {
167+
return true
168+
}
169+
const block = $findMatchingParent(firstDescendant, INTERNAL_$isBlock)
170+
block?.selectEnd()
171+
event.preventDefault()
172+
return true
173+
}
174+
if (!$isRangeSelection(selection)) {
175+
return false
176+
}
177+
178+
// CASE 2: Range selection
179+
// Get last selected block
180+
const lastPoint = selection.isBackward() ? selection.anchor : selection.focus
181+
const lastNode = lastPoint.getNode()
182+
const lastSelectedBlock = $findMatchingParent(lastNode, (node) => {
183+
return findLaterSiblingBlock(node) !== null
184+
})
185+
const nextBlock = lastSelectedBlock?.getNextSibling()
186+
if (!lastSelectedBlock || nextBlock !== findLaterSiblingBlock(lastSelectedBlock)) {
187+
return false
188+
}
189+
190+
if ($isDecoratorNode(nextBlock)) {
191+
const nextBlockElement = $getEditor().getElementByKey(nextBlock.getKey())
192+
if (nextBlockElement) {
193+
$selectDecorator({ element: nextBlockElement, node: nextBlock })
194+
event.preventDefault()
195+
return true
196+
}
197+
}
198+
199+
return false
200+
},
201+
COMMAND_PRIORITY_LOW,
202+
),
72203
)
73204
}, [editor])
74205

75206
return null
76207
}
77208

78-
function $getDecorator(
209+
function $getDecoratorByMouseEvent(
79210
event: MouseEvent,
80-
): { decoratorElement: Element; decoratorNode: DecoratorNode<unknown> } | undefined {
81-
if (!(event.target instanceof Element)) {
211+
): { element: HTMLElement; node: DecoratorNode<unknown> } | undefined {
212+
if (!(event.target instanceof HTMLElement)) {
82213
return undefined
83214
}
84-
const decoratorElement = event.target.closest('[data-lexical-decorator="true"]')
85-
if (!decoratorElement) {
215+
const element = event.target.closest('[data-lexical-decorator="true"]')
216+
if (!(element instanceof HTMLElement)) {
217+
return undefined
218+
}
219+
const node = $getNearestNodeFromDOMNode(element)
220+
return $isDecoratorNode(node) ? { element, node } : undefined
221+
}
222+
223+
function $getSelectedDecorator() {
224+
const selection = $getSelection()
225+
if (!$isNodeSelection(selection)) {
86226
return undefined
87227
}
88-
const node = $getNearestNodeFromDOMNode(decoratorElement)
89-
return $isDecoratorNode(node) ? { decoratorElement, decoratorNode: node } : undefined
228+
const nodes = selection.getNodes()
229+
if (nodes.length !== 1) {
230+
return undefined
231+
}
232+
const node = nodes[0]
233+
return $isDecoratorNode(node)
234+
? {
235+
decorator: node,
236+
element: $getEditor().getElementByKey(node.getKey()),
237+
}
238+
: undefined
239+
}
240+
241+
function $selectDecorator({
242+
element,
243+
node,
244+
}: {
245+
element: HTMLElement
246+
node: DecoratorNode<unknown>
247+
}) {
248+
document.querySelector('.decorator-selected')?.classList.remove('decorator-selected')
249+
const selection = $createNodeSelection()
250+
selection.add(node.getKey())
251+
$setSelection(selection)
252+
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
253+
element.classList.add('decorator-selected')
254+
}
255+
256+
/**
257+
* Copied from https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalUtils.ts
258+
*
259+
* This function returns true for a DecoratorNode that is not inline OR
260+
* an ElementNode that is:
261+
* - not a root or shadow root
262+
* - not inline
263+
* - can't be empty
264+
* - has no children or an inline first child
265+
*/
266+
export function INTERNAL_$isBlock(node: LexicalNode): node is DecoratorNode<unknown> | ElementNode {
267+
if ($isDecoratorNode(node) && !node.isInline()) {
268+
return true
269+
}
270+
if (!$isElementNode(node) || $isRootOrShadowRoot(node)) {
271+
return false
272+
}
273+
274+
const firstChild = node.getFirstChild()
275+
const isLeafElement =
276+
firstChild === null ||
277+
$isLineBreakNode(firstChild) ||
278+
$isTextNode(firstChild) ||
279+
firstChild.isInline()
280+
281+
return !node.isInline() && node.canBeEmpty() !== false && isLeafElement
282+
}
283+
284+
function findLaterSiblingBlock(node: LexicalNode): LexicalNode | null {
285+
let current = node.getNextSibling()
286+
while (current !== null) {
287+
if (INTERNAL_$isBlock(current)) {
288+
return current
289+
}
290+
current = current.getNextSibling()
291+
}
292+
return null
293+
}
294+
295+
function findFirstSiblingBlock(node: LexicalNode): LexicalNode | null {
296+
let current = node.getPreviousSibling()
297+
while (current !== null) {
298+
if (INTERNAL_$isBlock(current)) {
299+
return current
300+
}
301+
current = current.getPreviousSibling()
302+
}
303+
return null
90304
}

test/fields/collections/Lexical/e2e/main/e2e.spec.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1519,4 +1519,84 @@ describe('lexicalMain', () => {
15191519
await monacoCode.click()
15201520
await expect(decoratorLocator).toBeHidden()
15211521
})
1522+
1523+
test('arrow keys', async () => {
1524+
// utils
1525+
const selectedDecorator = page.locator('.decorator-selected')
1526+
const topLevelDecorator = page.locator(
1527+
'[data-lexical-decorator="true"]:not([data-lexical-decorator="true"] [data-lexical-decorator="true"])',
1528+
)
1529+
const selectedNthDecorator = async (nth: number) => {
1530+
await expect(selectedDecorator).toBeVisible()
1531+
const areSame = await selectedDecorator.evaluateHandle(
1532+
(el1, el2) => el1 === el2,
1533+
await topLevelDecorator.nth(nth).elementHandle(),
1534+
)
1535+
await expect.poll(async () => await areSame.jsonValue()).toBe(true)
1536+
}
1537+
1538+
// test
1539+
await navigateToLexicalFields()
1540+
1541+
const textNode = page.getByText('Upload Node:', { exact: true })
1542+
await textNode.click()
1543+
await expect(selectedDecorator).toBeHidden()
1544+
await page.keyboard.press('ArrowDown')
1545+
await selectedNthDecorator(0)
1546+
await page.keyboard.press('ArrowDown')
1547+
await selectedNthDecorator(1)
1548+
await page.keyboard.press('ArrowDown')
1549+
await selectedNthDecorator(2)
1550+
await page.keyboard.press('ArrowDown')
1551+
await selectedNthDecorator(3)
1552+
await page.keyboard.press('ArrowDown')
1553+
await selectedNthDecorator(4)
1554+
await page.keyboard.press('ArrowDown')
1555+
await selectedNthDecorator(5)
1556+
await page.keyboard.press('ArrowDown')
1557+
await page.keyboard.press('ArrowDown')
1558+
await selectedNthDecorator(6)
1559+
await page.keyboard.press('ArrowDown')
1560+
await selectedNthDecorator(7)
1561+
await page.keyboard.press('ArrowDown')
1562+
await page.keyboard.press('ArrowDown')
1563+
await selectedNthDecorator(8)
1564+
await page.keyboard.press('ArrowDown')
1565+
await page.keyboard.press('ArrowDown')
1566+
await selectedNthDecorator(9)
1567+
await page.keyboard.press('ArrowDown')
1568+
await selectedNthDecorator(10)
1569+
await page.keyboard.press('ArrowDown')
1570+
await selectedNthDecorator(10)
1571+
1572+
await page.keyboard.press('ArrowUp')
1573+
await selectedNthDecorator(9)
1574+
await page.keyboard.press('ArrowUp')
1575+
await page.keyboard.press('ArrowUp')
1576+
await selectedNthDecorator(8)
1577+
await page.keyboard.press('ArrowUp')
1578+
await page.keyboard.press('ArrowUp')
1579+
await selectedNthDecorator(7)
1580+
await page.keyboard.press('ArrowUp')
1581+
await selectedNthDecorator(6)
1582+
await page.keyboard.press('ArrowUp')
1583+
await page.keyboard.press('ArrowUp')
1584+
await selectedNthDecorator(5)
1585+
await page.keyboard.press('ArrowUp')
1586+
await selectedNthDecorator(4)
1587+
await page.keyboard.press('ArrowUp')
1588+
await selectedNthDecorator(3)
1589+
await page.keyboard.press('ArrowUp')
1590+
await selectedNthDecorator(2)
1591+
await page.keyboard.press('ArrowUp')
1592+
await selectedNthDecorator(1)
1593+
await page.keyboard.press('ArrowUp')
1594+
await selectedNthDecorator(0)
1595+
await page.keyboard.press('ArrowUp')
1596+
await selectedNthDecorator(0)
1597+
1598+
// TODO: It would be nice to add tests with lists and nested lists
1599+
// before and after decoratorNodes and paragraphs. Tested manually,
1600+
// but these are complex cases.
1601+
})
15221602
})

0 commit comments

Comments
 (0)