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

@@ -6,6 +6,10 @@
z-index: 1;
}
[data-lexical-decorator='true']:has(.lexical-block) {
width: auto;
}
.lexical-block-not-found {
color: var(--theme-error-500);
font-size: 1.1rem;

View File

@@ -3,6 +3,8 @@
@layer payload-default {
.inline-block-container {
display: inline-block;
margin-right: base(0.2);
margin-left: base(0.2);
}
.inline-block.inline-block-not-found {
@@ -22,8 +24,6 @@
border-radius: $style-radius-s;
max-width: calc(var(--base) * 15);
font-family: var(--font-body);
margin-right: base(0.2);
margin-left: base(0.2);
&::selection {
background: transparent;
@@ -38,11 +38,6 @@
overflow: hidden;
}
&--selected {
background: var(--theme-success-100);
outline: 1px solid var(--theme-success-400);
}
&__editButton.btn {
margin: 0;
}

View File

@@ -6,8 +6,6 @@ const baseClass = 'inline-block'
import type { BlocksFieldClient, Data, FormState } from 'payload'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection'
import { mergeRegister } from '@lexical/utils'
import { getTranslation } from '@payloadcms/translations'
import {
Button,
@@ -24,15 +22,7 @@ import {
useTranslation,
} from '@payloadcms/ui'
import { abortAndIgnore } from '@payloadcms/ui/shared'
import {
$getNodeByKey,
$getSelection,
$isNodeSelection,
CLICK_COMMAND,
COMMAND_PRIORITY_LOW,
KEY_BACKSPACE_COMMAND,
KEY_DELETE_COMMAND,
} from 'lexical'
import { $getNodeByKey } from 'lexical'
import './index.scss'
@@ -116,7 +106,6 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
const { toggleDrawer } = useLexicalDrawer(drawerSlug, true)
const inlineBlockElemElemRef = useRef<HTMLDivElement | null>(null)
const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey)
const { id, collectionSlug, getDocPreferences, globalSlug } = useDocumentInfo()
const componentMapRenderedBlockPath = `${schemaPath}.lexical_internal_feature.blocks.lexical_inline_blocks.${formData.blockType}`
@@ -153,56 +142,6 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
})
}, [editor, nodeKey])
const $onDelete = useCallback(
(event: KeyboardEvent) => {
const deleteSelection = $getSelection()
if (isSelected && $isNodeSelection(deleteSelection)) {
event.preventDefault()
editor.update(() => {
deleteSelection.getNodes().forEach((node) => {
if ($isInlineBlockNode(node)) {
node.remove()
}
})
})
}
return false
},
[editor, isSelected],
)
const onClick = useCallback(
(payload: MouseEvent) => {
const event = payload
// Check if inlineBlockElemElemRef.target or anything WITHIN inlineBlockElemElemRef.target was clicked
if (
event.target === inlineBlockElemElemRef.current ||
inlineBlockElemElemRef.current?.contains(event.target as Node)
) {
if (event.shiftKey) {
setSelected(!isSelected)
} else {
if (!isSelected) {
clearSelection()
setSelected(true)
}
}
return true
}
return false
},
[isSelected, setSelected, clearSelection],
)
useEffect(() => {
return mergeRegister(
editor.registerCommand<MouseEvent>(CLICK_COMMAND, onClick, COMMAND_PRIORITY_LOW),
editor.registerCommand(KEY_DELETE_COMMAND, $onDelete, COMMAND_PRIORITY_LOW),
editor.registerCommand(KEY_BACKSPACE_COMMAND, $onDelete, COMMAND_PRIORITY_LOW),
)
}, [clearSelection, editor, isSelected, nodeKey, $onDelete, setSelected, onClick])
const blockDisplayName = clientBlock?.labels?.singular
? getTranslation(clientBlock?.labels.singular, i18n)
: clientBlock?.slug
@@ -362,12 +301,7 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
() =>
({ children, className }: { children: React.ReactNode; className?: string }) => (
<div
className={[
baseClass,
baseClass + '-' + formData.blockType,
isSelected && `${baseClass}--selected`,
className,
]
className={[baseClass, baseClass + '-' + formData.blockType, className]
.filter(Boolean)
.join(' ')}
ref={inlineBlockElemElemRef}
@@ -375,7 +309,7 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
{children}
</div>
),
[formData.blockType, isSelected],
[formData.blockType],
)
const Label = useMemo(() => {

View File

@@ -1,85 +0,0 @@
'use client'
import type { NodeKey } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection.js'
import { addClassNamesToElement, mergeRegister, removeClassNamesFromElement } from '@lexical/utils'
import {
$getSelection,
$isNodeSelection,
CLICK_COMMAND,
COMMAND_PRIORITY_LOW,
KEY_BACKSPACE_COMMAND,
KEY_DELETE_COMMAND,
} from 'lexical'
import { useCallback, useEffect } from 'react'
import { $isHorizontalRuleNode } from '../nodes/HorizontalRuleNode.js'
const isSelectedClassName = 'selected'
/**
* React component rendered in the lexical editor, WITHIN the hr element created by createDOM of the HorizontalRuleNode.
*
* @param nodeKey every node has a unique key (this key is not saved to the database and thus may differ between sessions). It's useful for working with the CURRENT lexical editor state
*/
export function HorizontalRuleComponent({ nodeKey }: { nodeKey: NodeKey }) {
const [editor] = useLexicalComposerContext()
const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey)
const $onDelete = useCallback(
(event: KeyboardEvent) => {
const deleteSelection = $getSelection()
if (isSelected && $isNodeSelection(deleteSelection)) {
event.preventDefault()
editor.update(() => {
deleteSelection.getNodes().forEach((node) => {
if ($isHorizontalRuleNode(node)) {
node.remove()
}
})
})
}
return false
},
[editor, isSelected],
)
useEffect(() => {
return mergeRegister(
editor.registerCommand(
CLICK_COMMAND,
(event: MouseEvent) => {
const hrElem = editor.getElementByKey(nodeKey)
if (event.target === hrElem) {
if (!event.shiftKey) {
clearSelection()
}
setSelected(!isSelected)
return true
}
return false
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(KEY_DELETE_COMMAND, $onDelete, COMMAND_PRIORITY_LOW),
editor.registerCommand(KEY_BACKSPACE_COMMAND, $onDelete, COMMAND_PRIORITY_LOW),
)
}, [clearSelection, editor, isSelected, nodeKey, $onDelete, setSelected])
useEffect(() => {
const hrElem = editor.getElementByKey(nodeKey)
if (hrElem !== null) {
if (isSelected) {
addClassNamesToElement(hrElem, isSelectedClassName)
} else {
removeClassNamesFromElement(hrElem, isSelectedClassName)
}
}
}, [editor, isSelected, nodeKey])
return null
}

View File

@@ -8,12 +8,6 @@ import type { SerializedHorizontalRuleNode } from '../../server/nodes/Horizontal
import { HorizontalRuleServerNode } from '../../server/nodes/HorizontalRuleNode.js'
const HorizontalRuleComponent = React.lazy(() =>
import('../../client/component/index.js').then((module) => ({
default: module.HorizontalRuleComponent,
})),
)
export class HorizontalRuleNode extends HorizontalRuleServerNode {
static override clone(node: HorizontalRuleServerNode): HorizontalRuleServerNode {
return super.clone(node)
@@ -33,8 +27,8 @@ export class HorizontalRuleNode extends HorizontalRuleServerNode {
/**
* Allows you to render a React component within whatever createDOM returns.
*/
override decorate(): React.ReactElement {
return <HorizontalRuleComponent nodeKey={this.__key} />
override decorate() {
return null
}
override exportJSON(): SerializedLexicalNode {

View File

@@ -2,6 +2,7 @@
@layer payload-default {
.LexicalEditorTheme__hr {
width: auto !important;
padding: 2px 2px;
border: none;
margin: 1rem 0;

View File

@@ -2,27 +2,16 @@
import type { ElementFormatType } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection.js'
import { mergeRegister } from '@lexical/utils'
import { getTranslation } from '@payloadcms/translations'
import { Button, useConfig, usePayloadAPI, useTranslation } from '@payloadcms/ui'
import {
$getNodeByKey,
$getSelection,
$isNodeSelection,
CLICK_COMMAND,
COMMAND_PRIORITY_LOW,
KEY_BACKSPACE_COMMAND,
KEY_DELETE_COMMAND,
} from 'lexical'
import React, { useCallback, useEffect, useReducer, useRef, useState } from 'react'
import { $getNodeByKey } from 'lexical'
import React, { useCallback, useReducer, useRef, useState } from 'react'
import type { RelationshipData } from '../../server/nodes/RelationshipNode.js'
import { useEditorConfigContext } from '../../../../lexical/config/client/EditorConfigProvider.js'
import { useLexicalDocumentDrawer } from '../../../../utilities/fieldsDrawer/useLexicalDocumentDrawer.js'
import { INSERT_RELATIONSHIP_WITH_DRAWER_COMMAND } from '../drawer/commands.js'
import { $isRelationshipNode } from '../nodes/RelationshipNode.js'
import './index.scss'
const baseClass = 'lexical-relationship'
@@ -53,7 +42,6 @@ const Component: React.FC<Props> = (props) => {
const relationshipElemRef = useRef<HTMLDivElement | null>(null)
const [editor] = useLexicalComposerContext()
const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey!)
const {
fieldProps: { readOnly },
} = useEditorConfigContext()
@@ -65,9 +53,7 @@ const Component: React.FC<Props> = (props) => {
getEntityConfig,
} = useConfig()
const [relatedCollection, setRelatedCollection] = useState(() =>
getEntityConfig({ collectionSlug: relationTo }),
)
const [relatedCollection] = useState(() => getEntityConfig({ collectionSlug: relationTo }))
const { i18n, t } = useTranslation()
const [cacheBust, dispatchCacheBust] = useReducer((state) => state + 1, 0)
@@ -97,63 +83,8 @@ const Component: React.FC<Props> = (props) => {
dispatchCacheBust()
}, [cacheBust, setParams, closeDocumentDrawer])
const $onDelete = useCallback(
(payload: KeyboardEvent) => {
const deleteSelection = $getSelection()
if (isSelected && $isNodeSelection(deleteSelection)) {
const event: KeyboardEvent = payload
event.preventDefault()
editor.update(() => {
deleteSelection.getNodes().forEach((node) => {
if ($isRelationshipNode(node)) {
node.remove()
}
})
})
}
return false
},
[editor, isSelected],
)
const onClick = useCallback(
(payload: MouseEvent) => {
const event = payload
// Check if relationshipElemRef.target or anything WITHIN relationshipElemRef.target was clicked
if (
event.target === relationshipElemRef.current ||
relationshipElemRef.current?.contains(event.target as Node)
) {
if (event.shiftKey) {
setSelected(!isSelected)
} else {
if (!isSelected) {
clearSelection()
setSelected(true)
}
}
return true
}
return false
},
[isSelected, setSelected, clearSelection],
)
useEffect(() => {
return mergeRegister(
editor.registerCommand<MouseEvent>(CLICK_COMMAND, onClick, COMMAND_PRIORITY_LOW),
editor.registerCommand(KEY_DELETE_COMMAND, $onDelete, COMMAND_PRIORITY_LOW),
editor.registerCommand(KEY_BACKSPACE_COMMAND, $onDelete, COMMAND_PRIORITY_LOW),
)
}, [clearSelection, editor, isSelected, nodeKey, $onDelete, setSelected, onClick])
return (
<div
className={[baseClass, isSelected && `${baseClass}--selected`].filter(Boolean).join(' ')}
contentEditable={false}
ref={relationshipElemRef}
>
<div className={baseClass} contentEditable={false} ref={relationshipElemRef}>
<div className={`${baseClass}__wrap`}>
<p className={`${baseClass}__label`}>
{t('fields:labelRelationship', {

View File

@@ -43,11 +43,6 @@
overflow: hidden;
}
&--selected {
box-shadow: $focus-box-shadow;
outline: none;
}
&__doc-drawer-toggler {
text-decoration: underline;
pointer-events: all;

View File

@@ -138,11 +138,6 @@
text-overflow: ellipsis;
}
&--selected {
box-shadow: $focus-box-shadow;
outline: none;
}
@include small-break {
&__topRowRightPanel {
padding: calc(var(--base) * 0.75) calc(var(--base) * 0.5);

View File

@@ -2,8 +2,6 @@
import type { ClientCollectionConfig, Data, FormState, JsonObject } from 'payload'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection.js'
import { mergeRegister } from '@lexical/utils'
import { getTranslation } from '@payloadcms/translations'
import {
Button,
@@ -14,16 +12,8 @@ import {
usePayloadAPI,
useTranslation,
} from '@payloadcms/ui'
import {
$getNodeByKey,
$getSelection,
$isNodeSelection,
CLICK_COMMAND,
COMMAND_PRIORITY_LOW,
KEY_BACKSPACE_COMMAND,
KEY_DELETE_COMMAND,
} from 'lexical'
import React, { useCallback, useEffect, useId, useReducer, useRef, useState } from 'react'
import { $getNodeByKey } from 'lexical'
import React, { useCallback, useId, useReducer, useRef, useState } from 'react'
import type { BaseClientFeatureProps } from '../../../typesClient.js'
import type { UploadData } from '../../server/nodes/UploadNode.js'
@@ -36,7 +26,6 @@ import { useLexicalDocumentDrawer } from '../../../../utilities/fieldsDrawer/use
import { useLexicalDrawer } from '../../../../utilities/fieldsDrawer/useLexicalDrawer.js'
import { EnabledRelationshipsCondition } from '../../../relationship/client/utils/EnabledRelationshipsCondition.js'
import { INSERT_UPLOAD_WITH_DRAWER_COMMAND } from '../drawer/commands.js'
import { $isUploadNode } from '../nodes/UploadNode.js'
import './index.scss'
const baseClass = 'lexical-upload'
@@ -73,7 +62,6 @@ const Component: React.FC<ElementProps> = (props) => {
const { uuid } = useEditorConfigContext()
const editDepth = useEditDepth()
const [editor] = useLexicalComposerContext()
const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey)
const {
editorConfig,
@@ -128,55 +116,6 @@ const Component: React.FC<ElementProps> = (props) => {
[setParams, cacheBust, closeDocumentDrawer],
)
const $onDelete = useCallback(
(event: KeyboardEvent) => {
const deleteSelection = $getSelection()
if (isSelected && $isNodeSelection(deleteSelection)) {
event.preventDefault()
editor.update(() => {
deleteSelection.getNodes().forEach((node) => {
if ($isUploadNode(node)) {
node.remove()
}
})
})
}
return false
},
[editor, isSelected],
)
useEffect(() => {
return mergeRegister(
editor.registerCommand<MouseEvent>(
CLICK_COMMAND,
(event: MouseEvent) => {
// Check if uploadRef.target or anything WITHIN uploadRef.target was clicked
if (
event.target === uploadRef.current ||
uploadRef.current?.contains(event.target as Node)
) {
if (event.shiftKey) {
setSelected(!isSelected)
} else {
if (!isSelected) {
clearSelection()
setSelected(true)
}
}
return true
}
return false
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(KEY_DELETE_COMMAND, $onDelete, COMMAND_PRIORITY_LOW),
editor.registerCommand(KEY_BACKSPACE_COMMAND, $onDelete, COMMAND_PRIORITY_LOW),
)
}, [clearSelection, editor, isSelected, nodeKey, $onDelete, setSelected])
const hasExtraFields = (
editorConfig?.resolvedFeatureMap?.get('upload')
?.sanitizedClientFeatureProps as BaseClientFeatureProps<UploadFeaturePropsClient>
@@ -200,11 +139,7 @@ const Component: React.FC<ElementProps> = (props) => {
)
return (
<div
className={[baseClass, isSelected && `${baseClass}--selected`].filter(Boolean).join(' ')}
contentEditable={false}
ref={uploadRef}
>
<div className={baseClass} contentEditable={false} ref={uploadRef}>
<div className={`${baseClass}__card`}>
<div className={`${baseClass}__topRow`}>
{/* TODO: migrate to use @payloadcms/ui/elements/Thumbnail component */}

View File

@@ -19,6 +19,7 @@ import type { LexicalProviderProps } from './LexicalProvider.js'
import { useEditorConfigContext } from './config/client/EditorConfigProvider.js'
import { EditorPlugin } from './EditorPlugin.js'
import './LexicalEditor.scss'
import { DecoratorPlugin } from './plugins/DecoratorPlugin/index.js'
import { AddBlockHandlePlugin } from './plugins/handles/AddBlockHandlePlugin/index.js'
import { DraggableBlockPlugin } from './plugins/handles/DraggableBlockPlugin/index.js'
import { InsertParagraphAtEndPlugin } from './plugins/InsertParagraphAtEnd/index.js'
@@ -112,6 +113,7 @@ export const LexicalEditor: React.FC<
ErrorBoundary={LexicalErrorBoundary}
/>
<InsertParagraphAtEndPlugin />
<DecoratorPlugin />
<TextPlugin features={editorConfig.features} />
<OnChangePlugin
// Selection changes can be ignored here, reducing the

View File

@@ -0,0 +1,13 @@
@import '../../../scss/styles';
@layer payload-default {
[data-lexical-decorator='true'] {
width: fit-content;
border-radius: $style-radius-m;
}
.decorator-selected {
box-shadow: $focus-box-shadow;
outline: none;
}
}

View File

@@ -0,0 +1,90 @@
'use client'
import type { DecoratorNode } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import {
$createNodeSelection,
$getNearestNodeFromDOMNode,
$getSelection,
$isDecoratorNode,
$isNodeSelection,
$setSelection,
CLICK_COMMAND,
COMMAND_PRIORITY_LOW,
KEY_BACKSPACE_COMMAND,
KEY_DELETE_COMMAND,
} from 'lexical'
import { useEffect } from 'react'
import './index.scss'
// TODO: This should ideally be fixed in Lexical. See
// https://github.com/facebook/lexical/pull/7072
export function DecoratorPlugin() {
const [editor] = useLexicalComposerContext()
const $onDelete = (event: KeyboardEvent) => {
const selection = $getSelection()
if (!$isNodeSelection(selection)) {
return false
}
event.preventDefault()
selection.getNodes().forEach((node) => {
node.remove()
})
return true
}
useEffect(() => {
return mergeRegister(
editor.registerCommand(
CLICK_COMMAND,
(event) => {
document.querySelector('.decorator-selected')?.classList.remove('decorator-selected')
const decorator = $getDecorator(event)
if (!decorator) {
return true
}
const { decoratorElement, decoratorNode } = decorator
const { target } = event
const isInteractive =
!(target instanceof HTMLElement) ||
target.isContentEditable ||
target.closest(
'button, textarea, input, .react-select, .code-editor, .no-select-decorator, [role="button"]',
)
if (isInteractive) {
$setSelection(null)
} else {
const selection = $createNodeSelection()
selection.add(decoratorNode.getKey())
$setSelection(selection)
decoratorElement.classList.add('decorator-selected')
}
return true
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(KEY_DELETE_COMMAND, $onDelete, COMMAND_PRIORITY_LOW),
editor.registerCommand(KEY_BACKSPACE_COMMAND, $onDelete, COMMAND_PRIORITY_LOW),
)
}, [editor])
return null
}
function $getDecorator(
event: MouseEvent,
): { decoratorElement: Element; decoratorNode: DecoratorNode<unknown> } | undefined {
if (!(event.target instanceof Element)) {
return undefined
}
const decoratorElement = event.target.closest('[data-lexical-decorator="true"]')
if (!decoratorElement) {
return undefined
}
const node = $getNearestNodeFromDOMNode(decoratorElement)
return $isDecoratorNode(node) ? { decoratorElement, decoratorNode: node } : undefined
}

View File

@@ -1,4 +1,3 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/click-events-have-key-events */
'use client'
@@ -27,7 +26,14 @@ export const InsertParagraphAtEndPlugin: React.FC = () => {
}
return (
<div aria-label="Insert Paragraph" className={baseClass} onClick={onClick}>
// TODO: convert to button
<div
aria-label="Insert Paragraph"
className={baseClass}
onClick={onClick}
role="button"
tabIndex={0}
>
<div className={`${baseClass}-inside`}>
<span>+</span>
</div>

View File

@@ -1,5 +1,7 @@
import type { CollectionConfig } from 'payload'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
export const postsSlug = 'posts'
export const PostsCollection: CollectionConfig = {
@@ -12,6 +14,13 @@ export const PostsCollection: CollectionConfig = {
name: 'title',
type: 'text',
},
{
name: 'content',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [...defaultFeatures],
}),
},
],
versions: {
drafts: true,

View File

@@ -70,6 +70,21 @@ export interface UserAuthOperations {
export interface Post {
id: string;
title?: string | null;
richText?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
@@ -202,6 +217,7 @@ export interface PayloadMigration {
*/
export interface PostsSelect<T extends boolean = true> {
title?: T;
richText?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;
@@ -324,6 +340,23 @@ export interface MenuSelect<T extends boolean = true> {
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "ContactBlock".
*/
export interface ContactBlock {
/**
* ...
*/
first: string;
/**
* ...
*/
two: string;
id?: string | null;
blockName?: string | null;
blockType: 'contact';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".

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()
})
})