diff --git a/packages/richtext-lexical/src/features/heading/client/index.tsx b/packages/richtext-lexical/src/features/heading/client/index.tsx index 3036e2f18..3bf2bba5e 100644 --- a/packages/richtext-lexical/src/features/heading/client/index.tsx +++ b/packages/richtext-lexical/src/features/heading/client/index.tsx @@ -2,11 +2,14 @@ import type { HeadingTagType } from '@lexical/rich-text' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { $createHeadingNode, $isHeadingNode, HeadingNode } from '@lexical/rich-text' import { $setBlocksType } from '@lexical/selection' import { $getSelection, $isRangeSelection } from 'lexical' +import { useEffect } from 'react' import type { ToolbarGroup } from '../../toolbars/types.js' +import type { PluginComponent } from '../../typesClient.js' import type { HeadingFeatureProps } from '../server/index.js' import { H1Icon } from '../../../lexical/ui/icons/H1/index.js' @@ -78,6 +81,12 @@ export const HeadingFeatureClient = createClientFeature(({ return { markdownTransformers: [MarkdownTransformer(enabledHeadingSizes)], nodes: [HeadingNode], + plugins: [ + { + Component: HeadingPlugin, + position: 'normal', + }, + ], sanitizedClientFeatureProps: props, slashMenu: { groups: enabledHeadingSizes?.length @@ -112,3 +121,22 @@ export const HeadingFeatureClient = createClientFeature(({ }, } }) + +const HeadingPlugin: PluginComponent = ({ clientProps }) => { + const { enabledHeadingSizes = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] } = clientProps + const lowestAllowed = enabledHeadingSizes.at(-1) + const [editor] = useLexicalComposerContext() + + useEffect(() => { + if (!lowestAllowed || enabledHeadingSizes.length === 6) { + return + } + return editor.registerNodeTransform(HeadingNode, (node) => { + if (!enabledHeadingSizes.includes(node.getTag())) { + node.setTag(lowestAllowed) + } + }) + }, [editor, enabledHeadingSizes, lowestAllowed]) + + return null +} diff --git a/packages/richtext-lexical/src/features/heading/server/index.ts b/packages/richtext-lexical/src/features/heading/server/index.ts index b7c80a5ae..0b9acb5cb 100644 --- a/packages/richtext-lexical/src/features/heading/server/index.ts +++ b/packages/richtext-lexical/src/features/heading/server/index.ts @@ -35,6 +35,8 @@ export const HeadingFeature = createServerFeature< const { enabledHeadingSizes = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] } = props + enabledHeadingSizes.sort() + return { ClientFeature: '@payloadcms/richtext-lexical/client#HeadingFeatureClient', clientFeatureProps: props, diff --git a/test/lexical/baseConfig.ts b/test/lexical/baseConfig.ts index cffae565f..f9c28c027 100644 --- a/test/lexical/baseConfig.ts +++ b/test/lexical/baseConfig.ts @@ -10,6 +10,7 @@ import { lexicalInlineBlocks, } from './collections/Lexical/index.js' import { LexicalAccessControl } from './collections/LexicalAccessControl/index.js' +import { LexicalHeadingFeature } from './collections/LexicalHeadingFeature/index.js' import { LexicalInBlock } from './collections/LexicalInBlock/index.js' import { LexicalJSXConverter } from './collections/LexicalJSXConverter/index.js' import { LexicalLinkFeature } from './collections/LexicalLinkFeature/index.js' @@ -33,6 +34,7 @@ export const baseConfig: Partial = { collections: [ LexicalFullyFeatured, LexicalLinkFeature, + LexicalHeadingFeature, LexicalJSXConverter, getLexicalFieldsCollection({ blocks: lexicalBlocks, diff --git a/test/lexical/collections/LexicalHeadingFeature/e2e.spec.ts b/test/lexical/collections/LexicalHeadingFeature/e2e.spec.ts new file mode 100644 index 000000000..185c0839f --- /dev/null +++ b/test/lexical/collections/LexicalHeadingFeature/e2e.spec.ts @@ -0,0 +1,55 @@ +import { expect, test } from '@playwright/test' +import { AdminUrlUtil } from 'helpers/adminUrlUtil.js' +import { reInitializeDB } from 'helpers/reInitializeDB.js' +import { lexicalHeadingFeatureSlug } 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 +// PLEASE do not reset the database or perform any operations that modify it in this file. + +test.describe.configure({ mode: 'parallel' }) + +const { serverURL } = await initPayloadE2ENoConfig({ + dirname, +}) + +describe('Lexical Heading Feature', () => { + let lexical: LexicalHelpers + 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 }) => { + const url = new AdminUrlUtil(serverURL, lexicalHeadingFeatureSlug) + lexical = new LexicalHelpers(page) + await page.goto(url.create) + await lexical.editor.first().focus() + }) + + test('unallowed headings should be converted when pasting', async () => { + await lexical.paste( + 'html', + '

Hello1

Hello2

Hello3

Hello4

Hello5
Hello6
', + ) + await expect(lexical.editor.locator('h1')).toHaveCount(0) + await expect(lexical.editor.locator('h2')).toHaveCount(1) + await expect(lexical.editor.locator('h3')).toHaveCount(0) + await expect(lexical.editor.locator('h4')).toHaveCount(5) + await expect(lexical.editor.locator('h5')).toHaveCount(0) + await expect(lexical.editor.locator('h6')).toHaveCount(0) + }) +}) diff --git a/test/lexical/collections/LexicalHeadingFeature/index.ts b/test/lexical/collections/LexicalHeadingFeature/index.ts new file mode 100644 index 000000000..04e80d8a9 --- /dev/null +++ b/test/lexical/collections/LexicalHeadingFeature/index.ts @@ -0,0 +1,26 @@ +import type { CollectionConfig } from 'payload' + +import { FixedToolbarFeature, HeadingFeature, lexicalEditor } from '@payloadcms/richtext-lexical' + +import { lexicalHeadingFeatureSlug } from '../../slugs.js' + +export const LexicalHeadingFeature: CollectionConfig = { + slug: lexicalHeadingFeatureSlug, + labels: { + singular: 'Lexical Heading Feature', + plural: 'Lexical Heading Feature', + }, + fields: [ + { + name: 'richText', + type: 'richText', + editor: lexicalEditor({ + features: ({ defaultFeatures }) => [ + ...defaultFeatures, + FixedToolbarFeature(), + HeadingFeature({ enabledHeadingSizes: ['h4', 'h2'] }), + ], + }), + }, + ], +} diff --git a/test/lexical/collections/LexicalJSXConverter/e2e.spec.ts b/test/lexical/collections/LexicalJSXConverter/e2e.spec.ts index 8a52a7c24..27d0f53d6 100644 --- a/test/lexical/collections/LexicalJSXConverter/e2e.spec.ts +++ b/test/lexical/collections/LexicalJSXConverter/e2e.spec.ts @@ -18,6 +18,7 @@ const dirname = path.resolve(currentFolder, '../../') const { beforeAll, beforeEach, describe } = test // Unlike other suites, this one runs in parallel, as they run on the `/create` URL and are "pure" tests +// PLEASE do not reset the database or perform any operations that modify it in this file. test.describe.configure({ mode: 'parallel' }) const { serverURL } = await initPayloadE2ENoConfig({ @@ -33,11 +34,6 @@ describe('Lexical JSX Converter', () => { await page.close() }) beforeEach(async ({ page }) => { - await reInitializeDB({ - serverURL, - snapshotKey: 'lexicalTest', - uploadsDir: [path.resolve(dirname, './collections/Upload/uploads')], - }) const url = new AdminUrlUtil(serverURL, lexicalJSXConverterSlug) const lexical = new LexicalHelpers(page) await page.goto(url.create) diff --git a/test/lexical/collections/LexicalLinkFeature/e2e.spec.ts b/test/lexical/collections/LexicalLinkFeature/e2e.spec.ts index 31f4ac277..4e2fe90bb 100644 --- a/test/lexical/collections/LexicalLinkFeature/e2e.spec.ts +++ b/test/lexical/collections/LexicalLinkFeature/e2e.spec.ts @@ -8,7 +8,7 @@ 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' +import { LexicalHelpers } from '../utils.js' const filename = fileURLToPath(import.meta.url) const currentFolder = path.dirname(filename) const dirname = path.resolve(currentFolder, '../../') @@ -16,6 +16,7 @@ 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 +// PLEASE do not reset the database or perform any operations that modify it in this file. test.describe.configure({ mode: 'parallel' }) const { serverURL } = await initPayloadE2ENoConfig({ @@ -31,11 +32,6 @@ describe('Lexical Link Feature', () => { await page.close() }) beforeEach(async ({ page }) => { - await reInitializeDB({ - serverURL, - snapshotKey: 'lexicalTest', - uploadsDir: [path.resolve(dirname, './collections/Upload/uploads')], - }) const url = new AdminUrlUtil(serverURL, lexicalLinkFeatureSlug) const lexical = new LexicalHelpers(page) await page.goto(url.create) diff --git a/test/lexical/collections/LexicalLinkFeature/utils.ts b/test/lexical/collections/LexicalLinkFeature/utils.ts deleted file mode 100644 index 95030f7fe..000000000 --- a/test/lexical/collections/LexicalLinkFeature/utils.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { Page } from 'playwright' - -import { expect } from '@playwright/test' - -export class LexicalHelpers { - page: Page - constructor(page: Page) { - this.page = page - } - - async save(container: 'document' | 'drawer') { - if (container === 'drawer') { - await this.drawer.getByText('Save').click() - } else { - throw new Error('Not implemented') - } - await this.page.waitForTimeout(1000) - } - - async slashCommand( - // prettier-ignore - command: 'block' | 'check' | 'code' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' |'h6' | 'inline' - | 'link' | 'ordered' | 'paragraph' | 'quote' | 'relationship' | 'unordered' | 'upload', - ) { - await this.page.keyboard.press(`/`) - - const slashMenuPopover = this.page.locator('#slash-menu .slash-menu-popup') - await expect(slashMenuPopover).toBeVisible() - await this.page.keyboard.type(command) - await this.page.keyboard.press(`Enter`) - await expect(slashMenuPopover).toBeHidden() - } - - get decorator() { - return this.editor.locator('[data-lexical-decorator="true"]') - } - - get drawer() { - return this.page.locator('.drawer__content') - } - - get editor() { - return this.page.locator('[data-lexical-editor="true"]') - } - - get paragraph() { - return this.editor.locator('p') - } -} diff --git a/test/lexical/collections/_LexicalFullyFeatured/e2e.spec.ts b/test/lexical/collections/_LexicalFullyFeatured/e2e.spec.ts index 19fdf13b9..eaf14e201 100644 --- a/test/lexical/collections/_LexicalFullyFeatured/e2e.spec.ts +++ b/test/lexical/collections/_LexicalFullyFeatured/e2e.spec.ts @@ -16,7 +16,8 @@ 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' }) +// PLEASE do not reset the database or perform any operations that modify it in this file. +test.describe.configure({ mode: 'parallel' }) const { serverURL } = await initPayloadE2ENoConfig({ dirname, @@ -32,19 +33,12 @@ describe('Lexical Fully Featured', () => { await page.close() }) beforeEach(async ({ page }) => { - await reInitializeDB({ - serverURL, - snapshotKey: 'lexicalTest', - uploadsDir: [path.resolve(dirname, './collections/Upload/uploads')], - }) const url = new AdminUrlUtil(serverURL, lexicalFullyFeaturedSlug) 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, - }) => { + test('prevent extra paragraph when inserting decorator blocks like blocks or upload node', async () => { await lexical.slashCommand('block') await expect(lexical.editor.locator('.lexical-block')).toBeVisible() await lexical.slashCommand('relationship') diff --git a/test/lexical/collections/utils.ts b/test/lexical/collections/utils.ts index 29d16f55e..03075143c 100644 --- a/test/lexical/collections/utils.ts +++ b/test/lexical/collections/utils.ts @@ -88,6 +88,18 @@ export class LexicalHelpers { return {} } + async paste(type: 'html' | 'markdown', text: string) { + await this.page.evaluate( + async ([text, type]) => { + const blob = new Blob([text!], { type: type === 'html' ? 'text/html' : 'text/markdown' }) + const clipboardItem = new ClipboardItem({ 'text/html': blob }) + await navigator.clipboard.write([clipboardItem]) + }, + [text, type], + ) + await this.page.keyboard.press(`ControlOrMeta+v`) + } + async save(container: 'document' | 'drawer') { if (container === 'drawer') { await this.drawer.getByText('Save').click() diff --git a/test/lexical/config.ts b/test/lexical/config.ts index 22c3b1d82..80338891b 100644 --- a/test/lexical/config.ts +++ b/test/lexical/config.ts @@ -1,4 +1,3 @@ -/* eslint-disable no-restricted-exports */ import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' import { baseConfig } from './baseConfig.js' diff --git a/test/lexical/payload-types.ts b/test/lexical/payload-types.ts index aba744f61..97e7ebce1 100644 --- a/test/lexical/payload-types.ts +++ b/test/lexical/payload-types.ts @@ -85,6 +85,7 @@ export interface Config { collections: { 'lexical-fully-featured': LexicalFullyFeatured; 'lexical-link-feature': LexicalLinkFeature; + 'lexical-heading-feature': LexicalHeadingFeature; 'lexical-jsx-converter': LexicalJsxConverter; 'lexical-fields': LexicalField; 'lexical-migrate-fields': LexicalMigrateField; @@ -108,6 +109,7 @@ export interface Config { collectionsSelect: { 'lexical-fully-featured': LexicalFullyFeaturedSelect | LexicalFullyFeaturedSelect; 'lexical-link-feature': LexicalLinkFeatureSelect | LexicalLinkFeatureSelect; + 'lexical-heading-feature': LexicalHeadingFeatureSelect | LexicalHeadingFeatureSelect; 'lexical-jsx-converter': LexicalJsxConverterSelect | LexicalJsxConverterSelect; 'lexical-fields': LexicalFieldsSelect | LexicalFieldsSelect; 'lexical-migrate-fields': LexicalMigrateFieldsSelect | LexicalMigrateFieldsSelect; @@ -211,6 +213,30 @@ export interface LexicalLinkFeature { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "lexical-heading-feature". + */ +export interface LexicalHeadingFeature { + id: string; + 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; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "lexical-jsx-converter". @@ -907,6 +933,10 @@ export interface PayloadLockedDocument { relationTo: 'lexical-link-feature'; value: string | LexicalLinkFeature; } | null) + | ({ + relationTo: 'lexical-heading-feature'; + value: string | LexicalHeadingFeature; + } | null) | ({ relationTo: 'lexical-jsx-converter'; value: string | LexicalJsxConverter; @@ -1027,6 +1057,15 @@ export interface LexicalLinkFeatureSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "lexical-heading-feature_select". + */ +export interface LexicalHeadingFeatureSelect { + richText?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "lexical-jsx-converter_select". diff --git a/test/lexical/slugs.ts b/test/lexical/slugs.ts index 4ac585943..a2fb62064 100644 --- a/test/lexical/slugs.ts +++ b/test/lexical/slugs.ts @@ -3,6 +3,7 @@ export const usersSlug = 'users' export const lexicalFullyFeaturedSlug = 'lexical-fully-featured' export const lexicalFieldsSlug = 'lexical-fields' export const lexicalJSXConverterSlug = 'lexical-jsx-converter' +export const lexicalHeadingFeatureSlug = 'lexical-heading-feature' export const lexicalLinkFeatureSlug = 'lexical-link-feature' export const lexicalLocalizedFieldsSlug = 'lexical-localized-fields'