Skip to content

Commit 207caa5

Browse files
authored
fix(richtext-lexical): prevent disallowed headings to be pasted (#13765)
Fixes #13705 With this PR, if the editor detects a disallowed heading in `HeadingFeature`, it automatically converts it to the lowest allowed heading. I've also verified that disallowed headings aren't introduced when pasting from the clipboard if the HeadingFeature isn't registered at all. The reason this works is because the LexicalEditor doesn't have the HeadingNode in that case.
1 parent 77cac30 commit 207caa5

File tree

13 files changed

+171
-70
lines changed

13 files changed

+171
-70
lines changed

packages/richtext-lexical/src/features/heading/client/index.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@
22

33
import type { HeadingTagType } from '@lexical/rich-text'
44

5+
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
56
import { $createHeadingNode, $isHeadingNode, HeadingNode } from '@lexical/rich-text'
67
import { $setBlocksType } from '@lexical/selection'
78
import { $getSelection, $isRangeSelection } from 'lexical'
9+
import { useEffect } from 'react'
810

911
import type { ToolbarGroup } from '../../toolbars/types.js'
12+
import type { PluginComponent } from '../../typesClient.js'
1013
import type { HeadingFeatureProps } from '../server/index.js'
1114

1215
import { H1Icon } from '../../../lexical/ui/icons/H1/index.js'
@@ -78,6 +81,12 @@ export const HeadingFeatureClient = createClientFeature<HeadingFeatureProps>(({
7881
return {
7982
markdownTransformers: [MarkdownTransformer(enabledHeadingSizes)],
8083
nodes: [HeadingNode],
84+
plugins: [
85+
{
86+
Component: HeadingPlugin,
87+
position: 'normal',
88+
},
89+
],
8190
sanitizedClientFeatureProps: props,
8291
slashMenu: {
8392
groups: enabledHeadingSizes?.length
@@ -112,3 +121,22 @@ export const HeadingFeatureClient = createClientFeature<HeadingFeatureProps>(({
112121
},
113122
}
114123
})
124+
125+
const HeadingPlugin: PluginComponent<HeadingFeatureProps> = ({ clientProps }) => {
126+
const { enabledHeadingSizes = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] } = clientProps
127+
const lowestAllowed = enabledHeadingSizes.at(-1)
128+
const [editor] = useLexicalComposerContext()
129+
130+
useEffect(() => {
131+
if (!lowestAllowed || enabledHeadingSizes.length === 6) {
132+
return
133+
}
134+
return editor.registerNodeTransform(HeadingNode, (node) => {
135+
if (!enabledHeadingSizes.includes(node.getTag())) {
136+
node.setTag(lowestAllowed)
137+
}
138+
})
139+
}, [editor, enabledHeadingSizes, lowestAllowed])
140+
141+
return null
142+
}

packages/richtext-lexical/src/features/heading/server/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ export const HeadingFeature = createServerFeature<
3535

3636
const { enabledHeadingSizes = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] } = props
3737

38+
enabledHeadingSizes.sort()
39+
3840
return {
3941
ClientFeature: '@payloadcms/richtext-lexical/client#HeadingFeatureClient',
4042
clientFeatureProps: props,

test/lexical/baseConfig.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
lexicalInlineBlocks,
1111
} from './collections/Lexical/index.js'
1212
import { LexicalAccessControl } from './collections/LexicalAccessControl/index.js'
13+
import { LexicalHeadingFeature } from './collections/LexicalHeadingFeature/index.js'
1314
import { LexicalInBlock } from './collections/LexicalInBlock/index.js'
1415
import { LexicalJSXConverter } from './collections/LexicalJSXConverter/index.js'
1516
import { LexicalLinkFeature } from './collections/LexicalLinkFeature/index.js'
@@ -33,6 +34,7 @@ export const baseConfig: Partial<Config> = {
3334
collections: [
3435
LexicalFullyFeatured,
3536
LexicalLinkFeature,
37+
LexicalHeadingFeature,
3638
LexicalJSXConverter,
3739
getLexicalFieldsCollection({
3840
blocks: lexicalBlocks,
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { expect, test } from '@playwright/test'
2+
import { AdminUrlUtil } from 'helpers/adminUrlUtil.js'
3+
import { reInitializeDB } from 'helpers/reInitializeDB.js'
4+
import { lexicalHeadingFeatureSlug } from 'lexical/slugs.js'
5+
import path from 'path'
6+
import { fileURLToPath } from 'url'
7+
8+
import { ensureCompilationIsDone } from '../../../helpers.js'
9+
import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js'
10+
import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js'
11+
import { LexicalHelpers } from '../utils.js'
12+
const filename = fileURLToPath(import.meta.url)
13+
const currentFolder = path.dirname(filename)
14+
const dirname = path.resolve(currentFolder, '../../')
15+
16+
const { beforeAll, beforeEach, describe } = test
17+
18+
// Unlike the other suites, this one runs in parallel, as they run on the `lexical-fully-featured/create` URL and are "pure" tests
19+
// PLEASE do not reset the database or perform any operations that modify it in this file.
20+
21+
test.describe.configure({ mode: 'parallel' })
22+
23+
const { serverURL } = await initPayloadE2ENoConfig({
24+
dirname,
25+
})
26+
27+
describe('Lexical Heading Feature', () => {
28+
let lexical: LexicalHelpers
29+
beforeAll(async ({ browser }, testInfo) => {
30+
testInfo.setTimeout(TEST_TIMEOUT_LONG)
31+
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
32+
const page = await browser.newPage()
33+
await ensureCompilationIsDone({ page, serverURL })
34+
await page.close()
35+
})
36+
beforeEach(async ({ page }) => {
37+
const url = new AdminUrlUtil(serverURL, lexicalHeadingFeatureSlug)
38+
lexical = new LexicalHelpers(page)
39+
await page.goto(url.create)
40+
await lexical.editor.first().focus()
41+
})
42+
43+
test('unallowed headings should be converted when pasting', async () => {
44+
await lexical.paste(
45+
'html',
46+
'<h1>Hello1</h1><h2>Hello2</h2><h3>Hello3</h3><h4>Hello4</h4><h5>Hello5</h5><h6>Hello6</h6>',
47+
)
48+
await expect(lexical.editor.locator('h1')).toHaveCount(0)
49+
await expect(lexical.editor.locator('h2')).toHaveCount(1)
50+
await expect(lexical.editor.locator('h3')).toHaveCount(0)
51+
await expect(lexical.editor.locator('h4')).toHaveCount(5)
52+
await expect(lexical.editor.locator('h5')).toHaveCount(0)
53+
await expect(lexical.editor.locator('h6')).toHaveCount(0)
54+
})
55+
})
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { CollectionConfig } from 'payload'
2+
3+
import { FixedToolbarFeature, HeadingFeature, lexicalEditor } from '@payloadcms/richtext-lexical'
4+
5+
import { lexicalHeadingFeatureSlug } from '../../slugs.js'
6+
7+
export const LexicalHeadingFeature: CollectionConfig = {
8+
slug: lexicalHeadingFeatureSlug,
9+
labels: {
10+
singular: 'Lexical Heading Feature',
11+
plural: 'Lexical Heading Feature',
12+
},
13+
fields: [
14+
{
15+
name: 'richText',
16+
type: 'richText',
17+
editor: lexicalEditor({
18+
features: ({ defaultFeatures }) => [
19+
...defaultFeatures,
20+
FixedToolbarFeature(),
21+
HeadingFeature({ enabledHeadingSizes: ['h4', 'h2'] }),
22+
],
23+
}),
24+
},
25+
],
26+
}

test/lexical/collections/LexicalJSXConverter/e2e.spec.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const dirname = path.resolve(currentFolder, '../../')
1818
const { beforeAll, beforeEach, describe } = test
1919

2020
// Unlike other suites, this one runs in parallel, as they run on the `/create` URL and are "pure" tests
21+
// PLEASE do not reset the database or perform any operations that modify it in this file.
2122
test.describe.configure({ mode: 'parallel' })
2223

2324
const { serverURL } = await initPayloadE2ENoConfig({
@@ -33,11 +34,6 @@ describe('Lexical JSX Converter', () => {
3334
await page.close()
3435
})
3536
beforeEach(async ({ page }) => {
36-
await reInitializeDB({
37-
serverURL,
38-
snapshotKey: 'lexicalTest',
39-
uploadsDir: [path.resolve(dirname, './collections/Upload/uploads')],
40-
})
4137
const url = new AdminUrlUtil(serverURL, lexicalJSXConverterSlug)
4238
const lexical = new LexicalHelpers(page)
4339
await page.goto(url.create)

test/lexical/collections/LexicalLinkFeature/e2e.spec.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@ import { fileURLToPath } from 'url'
88
import { ensureCompilationIsDone } from '../../../helpers.js'
99
import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js'
1010
import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js'
11-
import { LexicalHelpers } from './utils.js'
11+
import { LexicalHelpers } from '../utils.js'
1212
const filename = fileURLToPath(import.meta.url)
1313
const currentFolder = path.dirname(filename)
1414
const dirname = path.resolve(currentFolder, '../../')
1515

1616
const { beforeAll, beforeEach, describe } = test
1717

1818
// Unlike the other suites, this one runs in parallel, as they run on the `lexical-fully-featured/create` URL and are "pure" tests
19+
// PLEASE do not reset the database or perform any operations that modify it in this file.
1920
test.describe.configure({ mode: 'parallel' })
2021

2122
const { serverURL } = await initPayloadE2ENoConfig({
@@ -31,11 +32,6 @@ describe('Lexical Link Feature', () => {
3132
await page.close()
3233
})
3334
beforeEach(async ({ page }) => {
34-
await reInitializeDB({
35-
serverURL,
36-
snapshotKey: 'lexicalTest',
37-
uploadsDir: [path.resolve(dirname, './collections/Upload/uploads')],
38-
})
3935
const url = new AdminUrlUtil(serverURL, lexicalLinkFeatureSlug)
4036
const lexical = new LexicalHelpers(page)
4137
await page.goto(url.create)

test/lexical/collections/LexicalLinkFeature/utils.ts

Lines changed: 0 additions & 49 deletions
This file was deleted.

test/lexical/collections/_LexicalFullyFeatured/e2e.spec.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ const dirname = path.resolve(currentFolder, '../../')
1616
const { beforeAll, beforeEach, describe } = test
1717

1818
// Unlike the other suites, this one runs in parallel, as they run on the `lexical-fully-featured/create` URL and are "pure" tests
19-
//test.describe.configure({ mode: 'parallel' })
19+
// PLEASE do not reset the database or perform any operations that modify it in this file.
20+
test.describe.configure({ mode: 'parallel' })
2021

2122
const { serverURL } = await initPayloadE2ENoConfig({
2223
dirname,
@@ -32,19 +33,12 @@ describe('Lexical Fully Featured', () => {
3233
await page.close()
3334
})
3435
beforeEach(async ({ page }) => {
35-
await reInitializeDB({
36-
serverURL,
37-
snapshotKey: 'lexicalTest',
38-
uploadsDir: [path.resolve(dirname, './collections/Upload/uploads')],
39-
})
4036
const url = new AdminUrlUtil(serverURL, lexicalFullyFeaturedSlug)
4137
lexical = new LexicalHelpers(page)
4238
await page.goto(url.create)
4339
await lexical.editor.first().focus()
4440
})
45-
test('prevent extra paragraph when inserting decorator blocks like blocks or upload node', async ({
46-
page,
47-
}) => {
41+
test('prevent extra paragraph when inserting decorator blocks like blocks or upload node', async () => {
4842
await lexical.slashCommand('block')
4943
await expect(lexical.editor.locator('.lexical-block')).toBeVisible()
5044
await lexical.slashCommand('relationship')

test/lexical/collections/utils.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,18 @@ export class LexicalHelpers {
8888
return {}
8989
}
9090

91+
async paste(type: 'html' | 'markdown', text: string) {
92+
await this.page.evaluate(
93+
async ([text, type]) => {
94+
const blob = new Blob([text!], { type: type === 'html' ? 'text/html' : 'text/markdown' })
95+
const clipboardItem = new ClipboardItem({ 'text/html': blob })
96+
await navigator.clipboard.write([clipboardItem])
97+
},
98+
[text, type],
99+
)
100+
await this.page.keyboard.press(`ControlOrMeta+v`)
101+
}
102+
91103
async save(container: 'document' | 'drawer') {
92104
if (container === 'drawer') {
93105
await this.drawer.getByText('Save').click()

0 commit comments

Comments
 (0)