Skip to content

Commit

Permalink
feat(richtext-lexical): make decoratorNodes and blocks selectable. Ce…
Browse files Browse the repository at this point in the history
…ntralize 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
  • Loading branch information
GermanJablo authored Jan 22, 2025
1 parent f181f97 commit 4aaef5e
Show file tree
Hide file tree
Showing 17 changed files with 231 additions and 323 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand All @@ -38,11 +38,6 @@
overflow: hidden;
}

&--selected {
background: var(--theme-success-100);
outline: 1px solid var(--theme-success-400);
}

&__editButton.btn {
margin: 0;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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'

Expand Down Expand Up @@ -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}`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -362,20 +301,15 @@ 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}
>
{children}
</div>
),
[formData.blockType, isSelected],
[formData.blockType],
)

const Label = useMemo(() => {
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

@layer payload-default {
.LexicalEditorTheme__hr {
width: auto !important;
padding: 2px 2px;
border: none;
margin: 1rem 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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', {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,6 @@
overflow: hidden;
}

&--selected {
box-shadow: $focus-box-shadow;
outline: none;
}

&__doc-drawer-toggler {
text-decoration: underline;
pointer-events: all;
Expand Down
Loading

0 comments on commit 4aaef5e

Please sign in to comment.