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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
@layer payload-default {
|
||||
.LexicalEditorTheme__hr {
|
||||
width: auto !important;
|
||||
padding: 2px 2px;
|
||||
border: none;
|
||||
margin: 1rem 0;
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -43,11 +43,6 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
box-shadow: $focus-box-shadow;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&__doc-drawer-toggler {
|
||||
text-decoration: underline;
|
||||
pointer-events: all;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user