feat(richtext-lexical): make decoratorNodes and blocks selectable. Centralize selection and deletion logic (#10735)

- Blocks can now be selected (only inline blocks were possible before).
- Any DecoratorNode that users create will have the necessary logic out
of the box so that they are selected with a click and deleted with
backspace/delete.
- By having the code for selecting and deleting centralized, a lot of
repetitive code was eliminated
- More performant code due to the use of event delegation. There is only
one listener, previously there was one for each decoratorNode.
- Heuristics to exclude scenarios where you don't want to select the
node: if it is inside the DecoratorNode, but is also inside a button,
input, textarea, contentEditable, .react-select, .code-editor or
.no-select-decorator. That last one was added as a means of opt-out.
- Fix #10634

Note: arrow navigation will be introduced in a later PR.



https://github.com/user-attachments/assets/92f91cad-4f70-4f72-a36f-c68afbe33c0d
This commit is contained in:
Germán Jabloñski
2025-01-22 19:28:48 -03:00
committed by GitHub
parent f181f97d4e
commit 4aaef5e63e
17 changed files with 231 additions and 323 deletions

View File

@@ -4,7 +4,7 @@ import type {
SerializedParagraphNode,
SerializedTextNode,
} from '@payloadcms/richtext-lexical/lexical'
import type { BrowserContext, Page } from '@playwright/test'
import type { BrowserContext, Locator, Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import path from 'path'
@@ -28,6 +28,7 @@ import { RESTClient } from '../../../../../helpers/rest.js'
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../../../../../playwright.config.js'
import { lexicalFieldsSlug } from '../../../../slugs.js'
import { lexicalDocData } from '../../data.js'
import { except } from 'drizzle-orm/mysql-core'
const filename = fileURLToPath(import.meta.url)
const currentFolder = path.dirname(filename)
@@ -1294,4 +1295,59 @@ describe('lexicalMain', () => {
await navigateToLexicalFields(true, true)
})
})
test('select decoratorNodes', async () => {
// utils
const decoratorLocator = page.locator('.decorator-selected') // [data-lexical-decorator="true"]
const expectInsideSelectedDecorator = async (innerLocator: Locator) => {
await expect(decoratorLocator).toBeVisible()
await expect(decoratorLocator.locator(innerLocator)).toBeVisible()
}
// test
await navigateToLexicalFields()
const bottomOfUploadNode = page
.locator('div')
.filter({ hasText: /^payload\.jpg$/ })
.first()
await bottomOfUploadNode.click()
await expectInsideSelectedDecorator(bottomOfUploadNode)
const textNode = page.getByText('Upload Node:', { exact: true })
await textNode.click()
await expect(decoratorLocator).not.toBeVisible()
const closeTagInMultiSelect = page
.getByRole('button', { name: 'payload.jpg Edit payload.jpg' })
.getByLabel('Remove')
await closeTagInMultiSelect.click()
await expect(decoratorLocator).not.toBeVisible()
const labelInsideCollapsableBody = page.locator('label').getByText('Sub Blocks')
await labelInsideCollapsableBody.click()
await expectInsideSelectedDecorator(labelInsideCollapsableBody)
const textNodeInNestedEditor = page.getByText('Some text below relationship node 1')
await textNodeInNestedEditor.click()
await expect(decoratorLocator).not.toBeVisible()
await page.getByRole('button', { name: 'Tab2' }).click()
await expect(decoratorLocator).not.toBeVisible()
const labelInsideCollapsableBody2 = page.getByText('Text2')
await labelInsideCollapsableBody2.click()
await expectInsideSelectedDecorator(labelInsideCollapsableBody2)
// TEST DELETE!
await page.keyboard.press('Backspace')
await expect(labelInsideCollapsableBody2).not.toBeVisible()
const monacoLabel = page.locator('label').getByText('Code')
await monacoLabel.click()
await expectInsideSelectedDecorator(monacoLabel)
const monacoCode = page.getByText('Some code')
await monacoCode.click()
await expect(decoratorLocator).not.toBeVisible()
})
})