From fa189233179fd8d34361ba989b3829ec812ef56b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Jablo=C3=B1ski?= <43938777+GermanJablo@users.noreply.github.com> Date: Mon, 10 Feb 2025 16:22:25 -0300 Subject: [PATCH] fix(richtext-lexical): improve keyboard navigation on DecoratorNodes (#11022) Fixes #8506 https://github.com/user-attachments/assets/a5e26f18-2557-4f19-bd89-73f246200fa5 --- .../lexical/plugins/DecoratorPlugin/index.tsx | 244 ++++++++++++++++-- .../collections/Lexical/e2e/main/e2e.spec.ts | 80 ++++++ 2 files changed, 309 insertions(+), 15 deletions(-) diff --git a/packages/richtext-lexical/src/lexical/plugins/DecoratorPlugin/index.tsx b/packages/richtext-lexical/src/lexical/plugins/DecoratorPlugin/index.tsx index 7217a41cb8..10ddc8ace8 100644 --- a/packages/richtext-lexical/src/lexical/plugins/DecoratorPlugin/index.tsx +++ b/packages/richtext-lexical/src/lexical/plugins/DecoratorPlugin/index.tsx @@ -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 } | undefined { - if (!(event.target instanceof Element)) { +): { element: HTMLElement; node: DecoratorNode } | 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 +}) { + 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 | 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 } diff --git a/test/fields/collections/Lexical/e2e/main/e2e.spec.ts b/test/fields/collections/Lexical/e2e/main/e2e.spec.ts index f53c04ed49..abd2fcee58 100644 --- a/test/fields/collections/Lexical/e2e/main/e2e.spec.ts +++ b/test/fields/collections/Lexical/e2e/main/e2e.spec.ts @@ -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. + }) })