Skip to content

fix(richtext-lexical): prevent extra paragraph when inserting blocks or uploadNodes. Add preemptive selection normalization #12077

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Apr 29, 2025
Original file line number Diff line number Diff line change
Expand Up @@ -56,22 +56,15 @@ export const BlocksPlugin: PluginComponent = () => {

if ($isRangeSelection(selection)) {
const blockNode = $createBlockNode(payload)
// Insert blocks node BEFORE potentially removing focusNode, as $insertNodeToNearestRoot errors if the focusNode doesn't exist
$insertNodeToNearestRoot(blockNode)

// we need to get the focus node before inserting the block node, as $insertNodeToNearestRoot can change the focus node
const { focus } = selection
const focusNode = focus.getNode()
// Insert blocks node BEFORE potentially removing focusNode, as $insertNodeToNearestRoot errors if the focusNode doesn't exist
$insertNodeToNearestRoot(blockNode)
Comment on lines +63 to +64
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the key point of the fix. We still insert before removing focusNode, but after getting focusNode, since $insertNodeToNearestRoot can change the selection.


// First, delete currently selected node if it's an empty paragraph and if there are sufficient
// paragraph nodes (more than 1) left in the parent node, so that we don't "trap" the user
if (
$isParagraphNode(focusNode) &&
focusNode.getTextContentSize() === 0 &&
focusNode
.getParentOrThrow()
.getChildren()
.filter((node) => $isParagraphNode(node)).length > 1
) {
// Delete the node it it's an empty paragraph
if ($isParagraphNode(focusNode) && !focusNode.__first) {
focusNode.remove()
Comment on lines -65 to 68
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since PR #10530 this is no longer necessary

}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,22 +53,14 @@ export const RelationshipPlugin: PluginComponent<RelationshipFeatureProps> = ({

if ($isRangeSelection(selection)) {
const relationshipNode = $createRelationshipNode(payload)
// Insert relationship node BEFORE potentially removing focusNode, as $insertNodeToNearestRoot errors if the focusNode doesn't exist
$insertNodeToNearestRoot(relationshipNode)

// we need to get the focus node before inserting the block node, as $insertNodeToNearestRoot can change the focus node
const { focus } = selection
const focusNode = focus.getNode()
// Insert relationship node BEFORE potentially removing focusNode, as $insertNodeToNearestRoot errors if the focusNode doesn't exist
$insertNodeToNearestRoot(relationshipNode)

// First, delete currently selected node if it's an empty paragraph and if there are sufficient
// paragraph nodes (more than 1) left in the parent node, so that we don't "trap" the user
if (
$isParagraphNode(focusNode) &&
focusNode.getTextContentSize() === 0 &&
focusNode
.getParentOrThrow()
.getChildren()
.filter((node) => $isParagraphNode(node)).length > 1
) {
// Delete the node it it's an empty paragraph
if ($isParagraphNode(focusNode) && !focusNode.__first) {
focusNode.remove()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,18 +53,14 @@ export const UploadPlugin: PluginComponent<UploadFeaturePropsClient> = ({ client
value: payload.value,
},
})
// Insert upload node BEFORE potentially removing focusNode, as $insertNodeToNearestRoot errors if the focusNode doesn't exist
$insertNodeToNearestRoot(uploadNode)

// we need to get the focus node before inserting the block node, as $insertNodeToNearestRoot can change the focus node
const { focus } = selection
const focusNode = focus.getNode()
// Insert upload node BEFORE potentially removing focusNode, as $insertNodeToNearestRoot errors if the focusNode doesn't exist
$insertNodeToNearestRoot(uploadNode)

// Delete the node it it's an empty paragraph and it has at least one sibling, so that we don't "trap" the user
if (
$isParagraphNode(focusNode) &&
!focusNode.__first &&
(focusNode.__prev || focusNode.__next)
) {
// Delete the node it it's an empty paragraph
if ($isParagraphNode(focusNode) && !focusNode.__first) {
focusNode.remove()
}
}
Expand Down
10 changes: 3 additions & 7 deletions packages/richtext-lexical/src/lexical/LexicalEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,7 @@ import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary.js'
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin.js'
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin.js'
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin.js'
import {
$createParagraphNode,
$getRoot,
BLUR_COMMAND,
COMMAND_PRIORITY_LOW,
FOCUS_COMMAND,
} from 'lexical'
import { BLUR_COMMAND, COMMAND_PRIORITY_LOW, FOCUS_COMMAND } from 'lexical'
import * as React from 'react'
import { useEffect, useState } from 'react'

Expand All @@ -24,6 +18,7 @@ import { AddBlockHandlePlugin } from './plugins/handles/AddBlockHandlePlugin/ind
import { DraggableBlockPlugin } from './plugins/handles/DraggableBlockPlugin/index.js'
import { InsertParagraphAtEndPlugin } from './plugins/InsertParagraphAtEnd/index.js'
import { MarkdownShortcutPlugin } from './plugins/MarkdownShortcut/index.js'
import { NormalizeSelectionPlugin } from './plugins/NormalizeSelection/index.js'
import { SlashMenuPlugin } from './plugins/SlashMenu/index.js'
import { TextPlugin } from './plugins/TextPlugin/index.js'
import { LexicalContentEditable } from './ui/ContentEditable.js'
Expand Down Expand Up @@ -112,6 +107,7 @@ export const LexicalEditor: React.FC<
}
ErrorBoundary={LexicalErrorBoundary}
/>
<NormalizeSelectionPlugin />
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue was resolved with the changes above, so this isn't strictly necessary, but I decided to add it to make the editor more robust and prevent errors in the future. Additionally, I believe this will fix a bug in the email builder plugin.

<InsertParagraphAtEndPlugin />
<DecoratorPlugin />
<TextPlugin features={editorConfig.features} />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $getSelection, $isRangeSelection, RootNode } from 'lexical'
import { useEffect } from 'react'

/**
* By default, Lexical throws an error if the selection ends in deleted nodes.
* This is very aggressive considering there are reasons why this can happen
* outside of Payload's control (custom features or conflicting features, for example).
* In the case of selections on nonexistent nodes, this plugin moves the selection to
* the end of the editor and displays a warning instead of an error.
*/
export function NormalizeSelectionPlugin() {
const [editor] = useLexicalComposerContext()

useEffect(() => {
return editor.registerNodeTransform(RootNode, (root) => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
const anchorNode = selection.anchor.getNode()
const focusNode = selection.focus.getNode()
if (!anchorNode.isAttached() || !focusNode.isAttached()) {
root.selectEnd()
// eslint-disable-next-line no-console
console.warn(
'updateEditor: selection has been moved to the end of the editor because the previously selected nodes have been removed and ' +
"selection wasn't moved to another node. Ensure selection changes after removing/replacing a selected node.",
)
}
}
return false
})
}, [editor])

return null
}
10 changes: 10 additions & 0 deletions test/lexical/baseConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { fileURLToPath } from 'node:url'
import path from 'path'
import { type Config } from 'payload'

import { LexicalFullyFeatured } from './collections/_LexicalFullyFeatured/index.js'
import ArrayFields from './collections/Array/index.js'
import {
getLexicalFieldsCollection,
Expand All @@ -26,6 +27,7 @@ const dirname = path.dirname(filename)
export const baseConfig: Partial<Config> = {
// ...extend config here
collections: [
LexicalFullyFeatured,
getLexicalFieldsCollection({
blocks: lexicalBlocks,
inlineBlocks: lexicalInlineBlocks,
Expand All @@ -42,10 +44,18 @@ export const baseConfig: Partial<Config> = {
ArrayFields,
],
globals: [TabsWithRichText],

admin: {
importMap: {
baseDir: path.resolve(dirname),
},
components: {
beforeDashboard: [
{
path: './components/CollectionsExplained.tsx#CollectionsExplained',
},
],
},
},
onInit: async (payload) => {
if (process.env.SEED_IN_CONFIG_ONINIT !== 'false') {
Expand Down
10 changes: 5 additions & 5 deletions test/lexical/collections/Lexical/e2e/blocks/e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ describe('lexicalBlocks', () => {
await assertLexicalDoc({
fn: ({ lexicalWithBlocks }) => {
const rscBlock: SerializedBlockNode = lexicalWithBlocks.root
.children[14] as SerializedBlockNode
.children[13] as SerializedBlockNode
const paragraphNode: SerializedParagraphNode = lexicalWithBlocks.root
.children[12] as SerializedParagraphNode

Expand Down Expand Up @@ -1133,9 +1133,9 @@ describe('lexicalBlocks', () => {
).docs[0] as never

const richTextBlock: SerializedBlockNode = lexicalWithBlocks.root
.children[13] as SerializedBlockNode
.children[12] as SerializedBlockNode
const subRichTextBlock: SerializedBlockNode = richTextBlock.fields.richTextField.root
.children[1] as SerializedBlockNode // index 0 and 2 are paragraphs created by default around the block node when a new block is added via slash command
.children[0] as SerializedBlockNode // index 0 and 2 are paragraphs created by default around the block node when a new block is added via slash command

const subSubRichTextField = subRichTextBlock.fields.subRichTextField
const subSubUploadField = subRichTextBlock.fields.subUploadField
Expand Down Expand Up @@ -1163,9 +1163,9 @@ describe('lexicalBlocks', () => {
).docs[0] as never

const richTextBlock2: SerializedBlockNode = lexicalWithBlocks.root
.children[13] as SerializedBlockNode
.children[12] as SerializedBlockNode
const subRichTextBlock2: SerializedBlockNode = richTextBlock2.fields.richTextField.root
.children[1] as SerializedBlockNode // index 0 and 2 are paragraphs created by default around the block node when a new block is added via slash command
.children[0] as SerializedBlockNode // index 0 and 2 are paragraphs created by default around the block node when a new block is added via slash command

const subSubRichTextField2 = subRichTextBlock2.fields.subRichTextField
const subSubUploadField2 = subRichTextBlock2.fields.subUploadField
Expand Down
14 changes: 8 additions & 6 deletions test/lexical/collections/Lexical/e2e/main/e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -728,7 +728,8 @@ describe('lexicalMain', () => {
await expect(relationshipListDrawer).toBeVisible()
await wait(500)

await expect(relationshipListDrawer.locator('.rs__single-value')).toHaveText('Lexical Field')
await relationshipListDrawer.locator('.rs__input').first().click()
await relationshipListDrawer.locator('.rs__menu').getByText('Lexical Field').click()

await relationshipListDrawer.locator('button').getByText('Rich Text').first().click()
await expect(relationshipListDrawer).toBeHidden()
Expand Down Expand Up @@ -1203,10 +1204,11 @@ describe('lexicalMain', () => {
await expect(newUploadNode.locator('.lexical-upload__bottomRow')).toContainText('payload.png')

await page.keyboard.press('Enter') // floating toolbar needs to appear with enough distance to the upload node, otherwise clicking may fail
await page.keyboard.press('Enter')
await page.keyboard.press('ArrowLeft')
await page.keyboard.press('ArrowLeft')
// Select "there" by pressing shift + arrow left
for (let i = 0; i < 4; i++) {
for (let i = 0; i < 5; i++) {
await page.keyboard.press('Shift+ArrowLeft')
}

Expand Down Expand Up @@ -1258,10 +1260,10 @@ describe('lexicalMain', () => {
const firstParagraph: SerializedParagraphNode = lexicalField.root
.children[0] as SerializedParagraphNode
const secondParagraph: SerializedParagraphNode = lexicalField.root
.children[1] as SerializedParagraphNode
const thirdParagraph: SerializedParagraphNode = lexicalField.root
.children[2] as SerializedParagraphNode
const uploadNode: SerializedUploadNode = lexicalField.root.children[3] as SerializedUploadNode
const thirdParagraph: SerializedParagraphNode = lexicalField.root
.children[3] as SerializedParagraphNode
const uploadNode: SerializedUploadNode = lexicalField.root.children[1] as SerializedUploadNode

expect(firstParagraph.children).toHaveLength(2)
expect((firstParagraph.children[0] as SerializedTextNode).text).toBe('Some ')
Expand Down Expand Up @@ -1391,7 +1393,7 @@ describe('lexicalMain', () => {
const lexicalField: SerializedEditorState = lexicalDoc.lexicalRootEditor

// @ts-expect-error no need to type this
expect(lexicalField?.root?.children[1].fields.someTextRequired).toEqual('test')
expect(lexicalField?.root?.children[0].fields.someTextRequired).toEqual('test')
}).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
Expand Down
68 changes: 68 additions & 0 deletions test/lexical/collections/_LexicalFullyFeatured/e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { expect, test } from '@playwright/test'
import { AdminUrlUtil } from 'helpers/adminUrlUtil.js'
import { reInitializeDB } from 'helpers/reInitializeDB.js'
import { lexicalFullyFeaturedSlug } from 'lexical/slugs.js'
import path from 'path'
import { fileURLToPath } from 'url'

import { ensureCompilationIsDone } from '../../../helpers.js'
import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js'
import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js'
import { LexicalHelpers } from './utils.js'
const filename = fileURLToPath(import.meta.url)
const currentFolder = path.dirname(filename)
const dirname = path.resolve(currentFolder, '../../')

const { beforeAll, beforeEach, describe } = test

// Unlike the other suites, this one runs in parallel, as they run on the `lexical-fully-featured/create` URL and are "pure" tests
test.describe.configure({ mode: 'parallel' })

const { serverURL } = await initPayloadE2ENoConfig({
dirname,
})

describe('Lexical Fully Featured', () => {
beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
const page = await browser.newPage()
await ensureCompilationIsDone({ page, serverURL })
await page.close()
})
beforeEach(async ({ page }) => {
await reInitializeDB({
serverURL,
snapshotKey: 'fieldsTest',
uploadsDir: [
path.resolve(dirname, './collections/Upload/uploads'),
path.resolve(dirname, './collections/Upload2/uploads2'),
],
})
const url = new AdminUrlUtil(serverURL, lexicalFullyFeaturedSlug)
const lexical = new LexicalHelpers(page)
await page.goto(url.create)
await lexical.editor.first().focus()
})
test('prevent extra paragraph when inserting decorator blocks like blocks or upload node', async ({
page,
}) => {
const lexical = new LexicalHelpers(page)
await lexical.slashCommand('block')
await lexical.slashCommand('relationship')
await lexical.drawer.locator('.list-drawer__header').getByText('Create New').click()
await lexical.save('drawer')
await expect(lexical.decorator).toHaveCount(2)
await lexical.slashCommand('upload')
await lexical.drawer.locator('.list-drawer__header').getByText('Create New').click()
await lexical.drawer.getByText('Paste URL').click()
await lexical.drawer
.locator('.file-field__remote-file')
.fill('https://payloadcms.com/images/universal-truth.jpg')
await lexical.drawer.getByText('Add file').click()
await lexical.save('drawer')
await expect(lexical.decorator).toHaveCount(3)
const paragraph = lexical.editor.locator('> p')
await expect(paragraph).toHaveText('')
})
})
57 changes: 57 additions & 0 deletions test/lexical/collections/_LexicalFullyFeatured/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { CollectionConfig } from 'payload'

import {
BlocksFeature,
EXPERIMENTAL_TableFeature,
FixedToolbarFeature,
lexicalEditor,
TreeViewFeature,
} from '@payloadcms/richtext-lexical'

import { lexicalFullyFeaturedSlug } from '../../slugs.js'

export const LexicalFullyFeatured: CollectionConfig = {
slug: lexicalFullyFeaturedSlug,
labels: {
singular: 'Lexical Fully Featured',
plural: 'Lexical Fully Featured',
},
fields: [
{
name: 'richText',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
TreeViewFeature(),
FixedToolbarFeature(),
EXPERIMENTAL_TableFeature(),
BlocksFeature({
blocks: [
{
slug: 'myBlock',
fields: [
{
name: 'someText',
type: 'text',
},
],
},
],
inlineBlocks: [
{
slug: 'myInlineBlock',
fields: [
{
name: 'someText',
type: 'text',
},
],
},
],
}),
],
}),
},
],
}
Loading
Loading