Skip to content

Commit 4aaef5e

Browse files
authoredJan 22, 2025··
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
1 parent f181f97 commit 4aaef5e

File tree

17 files changed

+231
-323
lines changed

17 files changed

+231
-323
lines changed
 

‎packages/richtext-lexical/src/features/blocks/client/component/index.scss

+4
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
z-index: 1;
77
}
88

9+
[data-lexical-decorator='true']:has(.lexical-block) {
10+
width: auto;
11+
}
12+
913
.lexical-block-not-found {
1014
color: var(--theme-error-500);
1115
font-size: 1.1rem;

‎packages/richtext-lexical/src/features/blocks/client/componentInline/index.scss

+2-7
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
@layer payload-default {
44
.inline-block-container {
55
display: inline-block;
6+
margin-right: base(0.2);
7+
margin-left: base(0.2);
68
}
79

810
.inline-block.inline-block-not-found {
@@ -22,8 +24,6 @@
2224
border-radius: $style-radius-s;
2325
max-width: calc(var(--base) * 15);
2426
font-family: var(--font-body);
25-
margin-right: base(0.2);
26-
margin-left: base(0.2);
2727

2828
&::selection {
2929
background: transparent;
@@ -38,11 +38,6 @@
3838
overflow: hidden;
3939
}
4040

41-
&--selected {
42-
background: var(--theme-success-100);
43-
outline: 1px solid var(--theme-success-400);
44-
}
45-
4641
&__editButton.btn {
4742
margin: 0;
4843
}

‎packages/richtext-lexical/src/features/blocks/client/componentInline/index.tsx

+3-69
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@ const baseClass = 'inline-block'
66
import type { BlocksFieldClient, Data, FormState } from 'payload'
77

88
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
9-
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection'
10-
import { mergeRegister } from '@lexical/utils'
119
import { getTranslation } from '@payloadcms/translations'
1210
import {
1311
Button,
@@ -24,15 +22,7 @@ import {
2422
useTranslation,
2523
} from '@payloadcms/ui'
2624
import { abortAndIgnore } from '@payloadcms/ui/shared'
27-
import {
28-
$getNodeByKey,
29-
$getSelection,
30-
$isNodeSelection,
31-
CLICK_COMMAND,
32-
COMMAND_PRIORITY_LOW,
33-
KEY_BACKSPACE_COMMAND,
34-
KEY_DELETE_COMMAND,
35-
} from 'lexical'
25+
import { $getNodeByKey } from 'lexical'
3626

3727
import './index.scss'
3828

@@ -116,7 +106,6 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
116106
const { toggleDrawer } = useLexicalDrawer(drawerSlug, true)
117107

118108
const inlineBlockElemElemRef = useRef<HTMLDivElement | null>(null)
119-
const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey)
120109
const { id, collectionSlug, getDocPreferences, globalSlug } = useDocumentInfo()
121110

122111
const componentMapRenderedBlockPath = `${schemaPath}.lexical_internal_feature.blocks.lexical_inline_blocks.${formData.blockType}`
@@ -153,56 +142,6 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
153142
})
154143
}, [editor, nodeKey])
155144

156-
const $onDelete = useCallback(
157-
(event: KeyboardEvent) => {
158-
const deleteSelection = $getSelection()
159-
if (isSelected && $isNodeSelection(deleteSelection)) {
160-
event.preventDefault()
161-
editor.update(() => {
162-
deleteSelection.getNodes().forEach((node) => {
163-
if ($isInlineBlockNode(node)) {
164-
node.remove()
165-
}
166-
})
167-
})
168-
}
169-
return false
170-
},
171-
[editor, isSelected],
172-
)
173-
const onClick = useCallback(
174-
(payload: MouseEvent) => {
175-
const event = payload
176-
// Check if inlineBlockElemElemRef.target or anything WITHIN inlineBlockElemElemRef.target was clicked
177-
if (
178-
event.target === inlineBlockElemElemRef.current ||
179-
inlineBlockElemElemRef.current?.contains(event.target as Node)
180-
) {
181-
if (event.shiftKey) {
182-
setSelected(!isSelected)
183-
} else {
184-
if (!isSelected) {
185-
clearSelection()
186-
setSelected(true)
187-
}
188-
}
189-
return true
190-
}
191-
192-
return false
193-
},
194-
[isSelected, setSelected, clearSelection],
195-
)
196-
197-
useEffect(() => {
198-
return mergeRegister(
199-
editor.registerCommand<MouseEvent>(CLICK_COMMAND, onClick, COMMAND_PRIORITY_LOW),
200-
201-
editor.registerCommand(KEY_DELETE_COMMAND, $onDelete, COMMAND_PRIORITY_LOW),
202-
editor.registerCommand(KEY_BACKSPACE_COMMAND, $onDelete, COMMAND_PRIORITY_LOW),
203-
)
204-
}, [clearSelection, editor, isSelected, nodeKey, $onDelete, setSelected, onClick])
205-
206145
const blockDisplayName = clientBlock?.labels?.singular
207146
? getTranslation(clientBlock?.labels.singular, i18n)
208147
: clientBlock?.slug
@@ -362,20 +301,15 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
362301
() =>
363302
({ children, className }: { children: React.ReactNode; className?: string }) => (
364303
<div
365-
className={[
366-
baseClass,
367-
baseClass + '-' + formData.blockType,
368-
isSelected && `${baseClass}--selected`,
369-
className,
370-
]
304+
className={[baseClass, baseClass + '-' + formData.blockType, className]
371305
.filter(Boolean)
372306
.join(' ')}
373307
ref={inlineBlockElemElemRef}
374308
>
375309
{children}
376310
</div>
377311
),
378-
[formData.blockType, isSelected],
312+
[formData.blockType],
379313
)
380314

381315
const Label = useMemo(() => {

‎packages/richtext-lexical/src/features/horizontalRule/client/component/index.tsx

-85
This file was deleted.

‎packages/richtext-lexical/src/features/horizontalRule/client/nodes/HorizontalRuleNode.tsx

+2-8
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,6 @@ import type { SerializedHorizontalRuleNode } from '../../server/nodes/Horizontal
88

99
import { HorizontalRuleServerNode } from '../../server/nodes/HorizontalRuleNode.js'
1010

11-
const HorizontalRuleComponent = React.lazy(() =>
12-
import('../../client/component/index.js').then((module) => ({
13-
default: module.HorizontalRuleComponent,
14-
})),
15-
)
16-
1711
export class HorizontalRuleNode extends HorizontalRuleServerNode {
1812
static override clone(node: HorizontalRuleServerNode): HorizontalRuleServerNode {
1913
return super.clone(node)
@@ -33,8 +27,8 @@ export class HorizontalRuleNode extends HorizontalRuleServerNode {
3327
/**
3428
* Allows you to render a React component within whatever createDOM returns.
3529
*/
36-
override decorate(): React.ReactElement {
37-
return <HorizontalRuleComponent nodeKey={this.__key} />
30+
override decorate() {
31+
return null
3832
}
3933

4034
override exportJSON(): SerializedLexicalNode {

‎packages/richtext-lexical/src/features/horizontalRule/client/plugin/index.scss

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
@layer payload-default {
44
.LexicalEditorTheme__hr {
5+
width: auto !important;
56
padding: 2px 2px;
67
border: none;
78
margin: 1rem 0;

‎packages/richtext-lexical/src/features/relationship/client/components/RelationshipComponent.tsx

+4-73
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,16 @@
22
import type { ElementFormatType } from 'lexical'
33

44
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
5-
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection.js'
6-
import { mergeRegister } from '@lexical/utils'
75
import { getTranslation } from '@payloadcms/translations'
86
import { Button, useConfig, usePayloadAPI, useTranslation } from '@payloadcms/ui'
9-
import {
10-
$getNodeByKey,
11-
$getSelection,
12-
$isNodeSelection,
13-
CLICK_COMMAND,
14-
COMMAND_PRIORITY_LOW,
15-
KEY_BACKSPACE_COMMAND,
16-
KEY_DELETE_COMMAND,
17-
} from 'lexical'
18-
import React, { useCallback, useEffect, useReducer, useRef, useState } from 'react'
7+
import { $getNodeByKey } from 'lexical'
8+
import React, { useCallback, useReducer, useRef, useState } from 'react'
199

2010
import type { RelationshipData } from '../../server/nodes/RelationshipNode.js'
2111

2212
import { useEditorConfigContext } from '../../../../lexical/config/client/EditorConfigProvider.js'
2313
import { useLexicalDocumentDrawer } from '../../../../utilities/fieldsDrawer/useLexicalDocumentDrawer.js'
2414
import { INSERT_RELATIONSHIP_WITH_DRAWER_COMMAND } from '../drawer/commands.js'
25-
import { $isRelationshipNode } from '../nodes/RelationshipNode.js'
2615
import './index.scss'
2716

2817
const baseClass = 'lexical-relationship'
@@ -53,7 +42,6 @@ const Component: React.FC<Props> = (props) => {
5342
const relationshipElemRef = useRef<HTMLDivElement | null>(null)
5443

5544
const [editor] = useLexicalComposerContext()
56-
const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey!)
5745
const {
5846
fieldProps: { readOnly },
5947
} = useEditorConfigContext()
@@ -65,9 +53,7 @@ const Component: React.FC<Props> = (props) => {
6553
getEntityConfig,
6654
} = useConfig()
6755

68-
const [relatedCollection, setRelatedCollection] = useState(() =>
69-
getEntityConfig({ collectionSlug: relationTo }),
70-
)
56+
const [relatedCollection] = useState(() => getEntityConfig({ collectionSlug: relationTo }))
7157

7258
const { i18n, t } = useTranslation()
7359
const [cacheBust, dispatchCacheBust] = useReducer((state) => state + 1, 0)
@@ -97,63 +83,8 @@ const Component: React.FC<Props> = (props) => {
9783
dispatchCacheBust()
9884
}, [cacheBust, setParams, closeDocumentDrawer])
9985

100-
const $onDelete = useCallback(
101-
(payload: KeyboardEvent) => {
102-
const deleteSelection = $getSelection()
103-
if (isSelected && $isNodeSelection(deleteSelection)) {
104-
const event: KeyboardEvent = payload
105-
event.preventDefault()
106-
editor.update(() => {
107-
deleteSelection.getNodes().forEach((node) => {
108-
if ($isRelationshipNode(node)) {
109-
node.remove()
110-
}
111-
})
112-
})
113-
}
114-
return false
115-
},
116-
[editor, isSelected],
117-
)
118-
const onClick = useCallback(
119-
(payload: MouseEvent) => {
120-
const event = payload
121-
// Check if relationshipElemRef.target or anything WITHIN relationshipElemRef.target was clicked
122-
if (
123-
event.target === relationshipElemRef.current ||
124-
relationshipElemRef.current?.contains(event.target as Node)
125-
) {
126-
if (event.shiftKey) {
127-
setSelected(!isSelected)
128-
} else {
129-
if (!isSelected) {
130-
clearSelection()
131-
setSelected(true)
132-
}
133-
}
134-
return true
135-
}
136-
137-
return false
138-
},
139-
[isSelected, setSelected, clearSelection],
140-
)
141-
142-
useEffect(() => {
143-
return mergeRegister(
144-
editor.registerCommand<MouseEvent>(CLICK_COMMAND, onClick, COMMAND_PRIORITY_LOW),
145-
146-
editor.registerCommand(KEY_DELETE_COMMAND, $onDelete, COMMAND_PRIORITY_LOW),
147-
editor.registerCommand(KEY_BACKSPACE_COMMAND, $onDelete, COMMAND_PRIORITY_LOW),
148-
)
149-
}, [clearSelection, editor, isSelected, nodeKey, $onDelete, setSelected, onClick])
150-
15186
return (
152-
<div
153-
className={[baseClass, isSelected && `${baseClass}--selected`].filter(Boolean).join(' ')}
154-
contentEditable={false}
155-
ref={relationshipElemRef}
156-
>
87+
<div className={baseClass} contentEditable={false} ref={relationshipElemRef}>
15788
<div className={`${baseClass}__wrap`}>
15889
<p className={`${baseClass}__label`}>
15990
{t('fields:labelRelationship', {

‎packages/richtext-lexical/src/features/relationship/client/components/index.scss

-5
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,6 @@
4343
overflow: hidden;
4444
}
4545

46-
&--selected {
47-
box-shadow: $focus-box-shadow;
48-
outline: none;
49-
}
50-
5146
&__doc-drawer-toggler {
5247
text-decoration: underline;
5348
pointer-events: all;

‎packages/richtext-lexical/src/features/upload/client/component/index.scss

-5
Original file line numberDiff line numberDiff line change
@@ -138,11 +138,6 @@
138138
text-overflow: ellipsis;
139139
}
140140

141-
&--selected {
142-
box-shadow: $focus-box-shadow;
143-
outline: none;
144-
}
145-
146141
@include small-break {
147142
&__topRowRightPanel {
148143
padding: calc(var(--base) * 0.75) calc(var(--base) * 0.5);

‎packages/richtext-lexical/src/features/upload/client/component/index.tsx

+3-68
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22
import type { ClientCollectionConfig, Data, FormState, JsonObject } from 'payload'
33

44
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
5-
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection.js'
6-
import { mergeRegister } from '@lexical/utils'
75
import { getTranslation } from '@payloadcms/translations'
86
import {
97
Button,
@@ -14,16 +12,8 @@ import {
1412
usePayloadAPI,
1513
useTranslation,
1614
} from '@payloadcms/ui'
17-
import {
18-
$getNodeByKey,
19-
$getSelection,
20-
$isNodeSelection,
21-
CLICK_COMMAND,
22-
COMMAND_PRIORITY_LOW,
23-
KEY_BACKSPACE_COMMAND,
24-
KEY_DELETE_COMMAND,
25-
} from 'lexical'
26-
import React, { useCallback, useEffect, useId, useReducer, useRef, useState } from 'react'
15+
import { $getNodeByKey } from 'lexical'
16+
import React, { useCallback, useId, useReducer, useRef, useState } from 'react'
2717

2818
import type { BaseClientFeatureProps } from '../../../typesClient.js'
2919
import type { UploadData } from '../../server/nodes/UploadNode.js'
@@ -36,7 +26,6 @@ import { useLexicalDocumentDrawer } from '../../../../utilities/fieldsDrawer/use
3626
import { useLexicalDrawer } from '../../../../utilities/fieldsDrawer/useLexicalDrawer.js'
3727
import { EnabledRelationshipsCondition } from '../../../relationship/client/utils/EnabledRelationshipsCondition.js'
3828
import { INSERT_UPLOAD_WITH_DRAWER_COMMAND } from '../drawer/commands.js'
39-
import { $isUploadNode } from '../nodes/UploadNode.js'
4029
import './index.scss'
4130

4231
const baseClass = 'lexical-upload'
@@ -73,7 +62,6 @@ const Component: React.FC<ElementProps> = (props) => {
7362
const { uuid } = useEditorConfigContext()
7463
const editDepth = useEditDepth()
7564
const [editor] = useLexicalComposerContext()
76-
const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey)
7765

7866
const {
7967
editorConfig,
@@ -128,55 +116,6 @@ const Component: React.FC<ElementProps> = (props) => {
128116
[setParams, cacheBust, closeDocumentDrawer],
129117
)
130118

131-
const $onDelete = useCallback(
132-
(event: KeyboardEvent) => {
133-
const deleteSelection = $getSelection()
134-
if (isSelected && $isNodeSelection(deleteSelection)) {
135-
event.preventDefault()
136-
editor.update(() => {
137-
deleteSelection.getNodes().forEach((node) => {
138-
if ($isUploadNode(node)) {
139-
node.remove()
140-
}
141-
})
142-
})
143-
}
144-
return false
145-
},
146-
[editor, isSelected],
147-
)
148-
149-
useEffect(() => {
150-
return mergeRegister(
151-
editor.registerCommand<MouseEvent>(
152-
CLICK_COMMAND,
153-
(event: MouseEvent) => {
154-
// Check if uploadRef.target or anything WITHIN uploadRef.target was clicked
155-
if (
156-
event.target === uploadRef.current ||
157-
uploadRef.current?.contains(event.target as Node)
158-
) {
159-
if (event.shiftKey) {
160-
setSelected(!isSelected)
161-
} else {
162-
if (!isSelected) {
163-
clearSelection()
164-
setSelected(true)
165-
}
166-
}
167-
return true
168-
}
169-
170-
return false
171-
},
172-
COMMAND_PRIORITY_LOW,
173-
),
174-
175-
editor.registerCommand(KEY_DELETE_COMMAND, $onDelete, COMMAND_PRIORITY_LOW),
176-
editor.registerCommand(KEY_BACKSPACE_COMMAND, $onDelete, COMMAND_PRIORITY_LOW),
177-
)
178-
}, [clearSelection, editor, isSelected, nodeKey, $onDelete, setSelected])
179-
180119
const hasExtraFields = (
181120
editorConfig?.resolvedFeatureMap?.get('upload')
182121
?.sanitizedClientFeatureProps as BaseClientFeatureProps<UploadFeaturePropsClient>
@@ -200,11 +139,7 @@ const Component: React.FC<ElementProps> = (props) => {
200139
)
201140

202141
return (
203-
<div
204-
className={[baseClass, isSelected && `${baseClass}--selected`].filter(Boolean).join(' ')}
205-
contentEditable={false}
206-
ref={uploadRef}
207-
>
142+
<div className={baseClass} contentEditable={false} ref={uploadRef}>
208143
<div className={`${baseClass}__card`}>
209144
<div className={`${baseClass}__topRow`}>
210145
{/* TODO: migrate to use @payloadcms/ui/elements/Thumbnail component */}

‎packages/richtext-lexical/src/lexical/LexicalEditor.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type { LexicalProviderProps } from './LexicalProvider.js'
1919
import { useEditorConfigContext } from './config/client/EditorConfigProvider.js'
2020
import { EditorPlugin } from './EditorPlugin.js'
2121
import './LexicalEditor.scss'
22+
import { DecoratorPlugin } from './plugins/DecoratorPlugin/index.js'
2223
import { AddBlockHandlePlugin } from './plugins/handles/AddBlockHandlePlugin/index.js'
2324
import { DraggableBlockPlugin } from './plugins/handles/DraggableBlockPlugin/index.js'
2425
import { InsertParagraphAtEndPlugin } from './plugins/InsertParagraphAtEnd/index.js'
@@ -112,6 +113,7 @@ export const LexicalEditor: React.FC<
112113
ErrorBoundary={LexicalErrorBoundary}
113114
/>
114115
<InsertParagraphAtEndPlugin />
116+
<DecoratorPlugin />
115117
<TextPlugin features={editorConfig.features} />
116118
<OnChangePlugin
117119
// Selection changes can be ignored here, reducing the
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
@import '../../../scss/styles';
2+
3+
@layer payload-default {
4+
[data-lexical-decorator='true'] {
5+
width: fit-content;
6+
border-radius: $style-radius-m;
7+
}
8+
9+
.decorator-selected {
10+
box-shadow: $focus-box-shadow;
11+
outline: none;
12+
}
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
'use client'
2+
3+
import type { DecoratorNode } from 'lexical'
4+
5+
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
6+
import { mergeRegister } from '@lexical/utils'
7+
import {
8+
$createNodeSelection,
9+
$getNearestNodeFromDOMNode,
10+
$getSelection,
11+
$isDecoratorNode,
12+
$isNodeSelection,
13+
$setSelection,
14+
CLICK_COMMAND,
15+
COMMAND_PRIORITY_LOW,
16+
KEY_BACKSPACE_COMMAND,
17+
KEY_DELETE_COMMAND,
18+
} from 'lexical'
19+
import { useEffect } from 'react'
20+
21+
import './index.scss'
22+
23+
// TODO: This should ideally be fixed in Lexical. See
24+
// https://github.com/facebook/lexical/pull/7072
25+
export function DecoratorPlugin() {
26+
const [editor] = useLexicalComposerContext()
27+
28+
const $onDelete = (event: KeyboardEvent) => {
29+
const selection = $getSelection()
30+
if (!$isNodeSelection(selection)) {
31+
return false
32+
}
33+
event.preventDefault()
34+
selection.getNodes().forEach((node) => {
35+
node.remove()
36+
})
37+
return true
38+
}
39+
40+
useEffect(() => {
41+
return mergeRegister(
42+
editor.registerCommand(
43+
CLICK_COMMAND,
44+
(event) => {
45+
document.querySelector('.decorator-selected')?.classList.remove('decorator-selected')
46+
const decorator = $getDecorator(event)
47+
if (!decorator) {
48+
return true
49+
}
50+
const { decoratorElement, decoratorNode } = decorator
51+
const { target } = event
52+
const isInteractive =
53+
!(target instanceof HTMLElement) ||
54+
target.isContentEditable ||
55+
target.closest(
56+
'button, textarea, input, .react-select, .code-editor, .no-select-decorator, [role="button"]',
57+
)
58+
if (isInteractive) {
59+
$setSelection(null)
60+
} else {
61+
const selection = $createNodeSelection()
62+
selection.add(decoratorNode.getKey())
63+
$setSelection(selection)
64+
decoratorElement.classList.add('decorator-selected')
65+
}
66+
return true
67+
},
68+
COMMAND_PRIORITY_LOW,
69+
),
70+
editor.registerCommand(KEY_DELETE_COMMAND, $onDelete, COMMAND_PRIORITY_LOW),
71+
editor.registerCommand(KEY_BACKSPACE_COMMAND, $onDelete, COMMAND_PRIORITY_LOW),
72+
)
73+
}, [editor])
74+
75+
return null
76+
}
77+
78+
function $getDecorator(
79+
event: MouseEvent,
80+
): { decoratorElement: Element; decoratorNode: DecoratorNode<unknown> } | undefined {
81+
if (!(event.target instanceof Element)) {
82+
return undefined
83+
}
84+
const decoratorElement = event.target.closest('[data-lexical-decorator="true"]')
85+
if (!decoratorElement) {
86+
return undefined
87+
}
88+
const node = $getNearestNodeFromDOMNode(decoratorElement)
89+
return $isDecoratorNode(node) ? { decoratorElement, decoratorNode: node } : undefined
90+
}

‎packages/richtext-lexical/src/lexical/plugins/InsertParagraphAtEnd/index.tsx

+8-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
/* eslint-disable jsx-a11y/no-static-element-interactions */
21
/* eslint-disable jsx-a11y/click-events-have-key-events */
32
'use client'
43

@@ -27,7 +26,14 @@ export const InsertParagraphAtEndPlugin: React.FC = () => {
2726
}
2827

2928
return (
30-
<div aria-label="Insert Paragraph" className={baseClass} onClick={onClick}>
29+
// TODO: convert to button
30+
<div
31+
aria-label="Insert Paragraph"
32+
className={baseClass}
33+
onClick={onClick}
34+
role="button"
35+
tabIndex={0}
36+
>
3137
<div className={`${baseClass}-inside`}>
3238
<span>+</span>
3339
</div>

‎test/_community/collections/Posts/index.ts

+9
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { CollectionConfig } from 'payload'
22

3+
import { lexicalEditor } from '@payloadcms/richtext-lexical'
4+
35
export const postsSlug = 'posts'
46

57
export const PostsCollection: CollectionConfig = {
@@ -12,6 +14,13 @@ export const PostsCollection: CollectionConfig = {
1214
name: 'title',
1315
type: 'text',
1416
},
17+
{
18+
name: 'content',
19+
type: 'richText',
20+
editor: lexicalEditor({
21+
features: ({ defaultFeatures }) => [...defaultFeatures],
22+
}),
23+
},
1524
],
1625
versions: {
1726
drafts: true,

‎test/_community/payload-types.ts

+33
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,21 @@ export interface UserAuthOperations {
7070
export interface Post {
7171
id: string;
7272
title?: string | null;
73+
richText?: {
74+
root: {
75+
type: string;
76+
children: {
77+
type: string;
78+
version: number;
79+
[k: string]: unknown;
80+
}[];
81+
direction: ('ltr' | 'rtl') | null;
82+
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
83+
indent: number;
84+
version: number;
85+
};
86+
[k: string]: unknown;
87+
} | null;
7388
updatedAt: string;
7489
createdAt: string;
7590
_status?: ('draft' | 'published') | null;
@@ -202,6 +217,7 @@ export interface PayloadMigration {
202217
*/
203218
export interface PostsSelect<T extends boolean = true> {
204219
title?: T;
220+
richText?: T;
205221
updatedAt?: T;
206222
createdAt?: T;
207223
_status?: T;
@@ -324,6 +340,23 @@ export interface MenuSelect<T extends boolean = true> {
324340
createdAt?: T;
325341
globalType?: T;
326342
}
343+
/**
344+
* This interface was referenced by `Config`'s JSON-Schema
345+
* via the `definition` "ContactBlock".
346+
*/
347+
export interface ContactBlock {
348+
/**
349+
* ...
350+
*/
351+
first: string;
352+
/**
353+
* ...
354+
*/
355+
two: string;
356+
id?: string | null;
357+
blockName?: string | null;
358+
blockType: 'contact';
359+
}
327360
/**
328361
* This interface was referenced by `Config`'s JSON-Schema
329362
* via the `definition` "auth".

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

+57-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type {
44
SerializedParagraphNode,
55
SerializedTextNode,
66
} from '@payloadcms/richtext-lexical/lexical'
7-
import type { BrowserContext, Page } from '@playwright/test'
7+
import type { BrowserContext, Locator, Page } from '@playwright/test'
88

99
import { expect, test } from '@playwright/test'
1010
import path from 'path'
@@ -28,6 +28,7 @@ import { RESTClient } from '../../../../../helpers/rest.js'
2828
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../../../../../playwright.config.js'
2929
import { lexicalFieldsSlug } from '../../../../slugs.js'
3030
import { lexicalDocData } from '../../data.js'
31+
import { except } from 'drizzle-orm/mysql-core'
3132

3233
const filename = fileURLToPath(import.meta.url)
3334
const currentFolder = path.dirname(filename)
@@ -1294,4 +1295,59 @@ describe('lexicalMain', () => {
12941295
await navigateToLexicalFields(true, true)
12951296
})
12961297
})
1298+
1299+
test('select decoratorNodes', async () => {
1300+
// utils
1301+
const decoratorLocator = page.locator('.decorator-selected') // [data-lexical-decorator="true"]
1302+
const expectInsideSelectedDecorator = async (innerLocator: Locator) => {
1303+
await expect(decoratorLocator).toBeVisible()
1304+
await expect(decoratorLocator.locator(innerLocator)).toBeVisible()
1305+
}
1306+
1307+
// test
1308+
await navigateToLexicalFields()
1309+
const bottomOfUploadNode = page
1310+
.locator('div')
1311+
.filter({ hasText: /^payload\.jpg$/ })
1312+
.first()
1313+
await bottomOfUploadNode.click()
1314+
await expectInsideSelectedDecorator(bottomOfUploadNode)
1315+
1316+
const textNode = page.getByText('Upload Node:', { exact: true })
1317+
await textNode.click()
1318+
await expect(decoratorLocator).not.toBeVisible()
1319+
1320+
const closeTagInMultiSelect = page
1321+
.getByRole('button', { name: 'payload.jpg Edit payload.jpg' })
1322+
.getByLabel('Remove')
1323+
await closeTagInMultiSelect.click()
1324+
await expect(decoratorLocator).not.toBeVisible()
1325+
1326+
const labelInsideCollapsableBody = page.locator('label').getByText('Sub Blocks')
1327+
await labelInsideCollapsableBody.click()
1328+
await expectInsideSelectedDecorator(labelInsideCollapsableBody)
1329+
1330+
const textNodeInNestedEditor = page.getByText('Some text below relationship node 1')
1331+
await textNodeInNestedEditor.click()
1332+
await expect(decoratorLocator).not.toBeVisible()
1333+
1334+
await page.getByRole('button', { name: 'Tab2' }).click()
1335+
await expect(decoratorLocator).not.toBeVisible()
1336+
1337+
const labelInsideCollapsableBody2 = page.getByText('Text2')
1338+
await labelInsideCollapsableBody2.click()
1339+
await expectInsideSelectedDecorator(labelInsideCollapsableBody2)
1340+
1341+
// TEST DELETE!
1342+
await page.keyboard.press('Backspace')
1343+
await expect(labelInsideCollapsableBody2).not.toBeVisible()
1344+
1345+
const monacoLabel = page.locator('label').getByText('Code')
1346+
await monacoLabel.click()
1347+
await expectInsideSelectedDecorator(monacoLabel)
1348+
1349+
const monacoCode = page.getByText('Some code')
1350+
await monacoCode.click()
1351+
await expect(decoratorLocator).not.toBeVisible()
1352+
})
12971353
})

0 commit comments

Comments
 (0)
Please sign in to comment.