diff --git a/packages/richtext-lexical/src/features/blockquote/server/index.ts b/packages/richtext-lexical/src/features/blockquote/server/index.ts index 96ab9e791..041da2318 100644 --- a/packages/richtext-lexical/src/features/blockquote/server/index.ts +++ b/packages/richtext-lexical/src/features/blockquote/server/index.ts @@ -53,7 +53,11 @@ export const BlockquoteFeature = createServerFeature({ }) const style = [ node.format ? `text-align: ${node.format};` : '', - node.indent > 0 ? `padding-inline-start: ${Number(node.indent) * 2}rem;` : '', + // the unit should be px. Do not change it to rem, em, or something else. + // The quantity should be 40px. Do not change it either. + // See rationale in + // https://github.com/payloadcms/payload/issues/13130#issuecomment-3058348085 + node.indent > 0 ? `padding-inline-start: ${node.indent * 40}px;` : '', ] .filter(Boolean) .join(' ') diff --git a/packages/richtext-lexical/src/features/converters/lexicalToHtml/shared/findConverterForNode.ts b/packages/richtext-lexical/src/features/converters/lexicalToHtml/shared/findConverterForNode.ts index 83bde9163..ebc1e793c 100644 --- a/packages/richtext-lexical/src/features/converters/lexicalToHtml/shared/findConverterForNode.ts +++ b/packages/richtext-lexical/src/features/converters/lexicalToHtml/shared/findConverterForNode.ts @@ -83,7 +83,11 @@ export function findConverterForNode< if (!disableIndent && (!Array.isArray(disableIndent) || !disableIndent?.includes(node.type))) { if ('indent' in node && node.indent && node.type !== 'listitem') { - style['padding-inline-start'] = `${Number(node.indent) * 2}rem` + // the unit should be px. Do not change it to rem, em, or something else. + // The quantity should be 40px. Do not change it either. + // See rationale in + // https://github.com/payloadcms/payload/issues/13130#issuecomment-3058348085 + style['padding-inline-start'] = `${Number(node.indent) * 40}px` } } diff --git a/packages/richtext-lexical/src/features/converters/lexicalToHtml_deprecated/converter/converters/paragraph.ts b/packages/richtext-lexical/src/features/converters/lexicalToHtml_deprecated/converter/converters/paragraph.ts index cb737b94c..5ce159e65 100644 --- a/packages/richtext-lexical/src/features/converters/lexicalToHtml_deprecated/converter/converters/paragraph.ts +++ b/packages/richtext-lexical/src/features/converters/lexicalToHtml_deprecated/converter/converters/paragraph.ts @@ -31,7 +31,11 @@ export const ParagraphHTMLConverter: HTMLConverter = { }) const style = [ node.format ? `text-align: ${node.format};` : '', - node.indent > 0 ? `padding-inline-start: ${Number(node.indent) * 2}rem;` : '', + // the unit should be px. Do not change it to rem, em, or something else. + // The quantity should be 40px. Do not change it either. + // See rationale in + // https://github.com/payloadcms/payload/issues/13130#issuecomment-3058348085 + node.indent > 0 ? `padding-inline-start: ${node.indent * 40}px;` : '', ] .filter(Boolean) .join(' ') diff --git a/packages/richtext-lexical/src/features/converters/lexicalToJSX/converter/index.tsx b/packages/richtext-lexical/src/features/converters/lexicalToJSX/converter/index.tsx index 15361de29..772292795 100644 --- a/packages/richtext-lexical/src/features/converters/lexicalToJSX/converter/index.tsx +++ b/packages/richtext-lexical/src/features/converters/lexicalToJSX/converter/index.tsx @@ -139,7 +139,11 @@ export function convertLexicalNodesToJSX({ (!Array.isArray(disableIndent) || !disableIndent?.includes(node.type)) ) { if ('indent' in node && node.indent && node.type !== 'listitem') { - style.paddingInlineStart = `${Number(node.indent) * 2}em` + // the unit should be px. Do not change it to rem, em, or something else. + // The quantity should be 40px. Do not change it either. + // See rationale in + // https://github.com/payloadcms/payload/issues/13130#issuecomment-3058348085 + style.paddingInlineStart = `${Number(node.indent) * 40}px` } } diff --git a/packages/richtext-lexical/src/features/debug/jsxConverter/client/plugin/index.tsx b/packages/richtext-lexical/src/features/debug/jsxConverter/client/plugin/index.tsx index 1553d9fb1..21cadb619 100644 --- a/packages/richtext-lexical/src/features/debug/jsxConverter/client/plugin/index.tsx +++ b/packages/richtext-lexical/src/features/debug/jsxConverter/client/plugin/index.tsx @@ -5,6 +5,7 @@ import { useEffect, useState } from 'react' // eslint-disable-next-line payload/no-imports-from-exports-dir import { defaultJSXConverters, RichText } from '../../../../../exports/react/index.js' +import './style.scss' export function RichTextPlugin() { const [editor] = useLexicalComposerContext() @@ -16,5 +17,9 @@ export function RichTextPlugin() { }) }, [editor]) - return + return ( +
+ +
+ ) } diff --git a/packages/richtext-lexical/src/features/debug/jsxConverter/client/plugin/style.scss b/packages/richtext-lexical/src/features/debug/jsxConverter/client/plugin/style.scss new file mode 100644 index 000000000..6f579ce35 --- /dev/null +++ b/packages/richtext-lexical/src/features/debug/jsxConverter/client/plugin/style.scss @@ -0,0 +1,12 @@ +.debug-jsx-converter { + // this is to match the editor component, and be able to compare aligned styles + padding-left: 36px; + + // We revert to the browser defaults (user-agent), because we want to see + // the indentations look good without the need for CSS. + ul, + ol { + padding-left: revert; + margin: revert; + } +} diff --git a/packages/richtext-lexical/src/features/heading/server/index.ts b/packages/richtext-lexical/src/features/heading/server/index.ts index 1da8b1f99..b7c80a5ae 100644 --- a/packages/richtext-lexical/src/features/heading/server/index.ts +++ b/packages/richtext-lexical/src/features/heading/server/index.ts @@ -71,7 +71,11 @@ export const HeadingFeature = createServerFeature< }) const style = [ node.format ? `text-align: ${node.format};` : '', - node.indent > 0 ? `padding-inline-start: ${Number(node.indent) * 2}rem;` : '', + // the unit should be px. Do not change it to rem, em, or something else. + // The quantity should be 40px. Do not change it either. + // See rationale in + // https://github.com/payloadcms/payload/issues/13130#issuecomment-3058348085 + node.indent > 0 ? `padding-inline-start: ${node.indent * 40}px;` : '', ] .filter(Boolean) .join(' ') diff --git a/test/lexical/baseConfig.ts b/test/lexical/baseConfig.ts index efc7ce833..fcb2c040e 100644 --- a/test/lexical/baseConfig.ts +++ b/test/lexical/baseConfig.ts @@ -11,6 +11,7 @@ import { } from './collections/Lexical/index.js' import { LexicalAccessControl } from './collections/LexicalAccessControl/index.js' import { LexicalInBlock } from './collections/LexicalInBlock/index.js' +import { LexicalJSXConverter } from './collections/LexicalJSXConverter/index.js' import { LexicalLinkFeature } from './collections/LexicalLinkFeature/index.js' import { LexicalLocalizedFields } from './collections/LexicalLocalized/index.js' import { LexicalMigrateFields } from './collections/LexicalMigrate/index.js' @@ -30,6 +31,7 @@ export const baseConfig: Partial = { collections: [ LexicalFullyFeatured, LexicalLinkFeature, + LexicalJSXConverter, getLexicalFieldsCollection({ blocks: lexicalBlocks, inlineBlocks: lexicalInlineBlocks, diff --git a/test/lexical/collections/Lexical/e2e/main/e2e.spec.ts b/test/lexical/collections/Lexical/e2e/main/e2e.spec.ts index c85513cc7..5451d54f4 100644 --- a/test/lexical/collections/Lexical/e2e/main/e2e.spec.ts +++ b/test/lexical/collections/Lexical/e2e/main/e2e.spec.ts @@ -1296,24 +1296,23 @@ describe('lexicalMain', () => { await page.getByLabel('Title*').fill('Indent and Text-align') await page.getByRole('paragraph').nth(1).click() await context.grantPermissions(['clipboard-read', 'clipboard-write']) - const getHTMLContent: (indentToSize: (indent: number) => string) => string = (indentToSize) => - `

paragraph centered

Heading right

paragraph without indent

paragraph indent 1

heading indent 2

quote indent 3
` + const htmlContent = `

paragraph centered

Heading right

paragraph without indent

paragraph indent 1

heading indent 2

quote indent 3
` await page.evaluate( async ([htmlContent]) => { - const blob = new Blob([htmlContent as string], { type: 'text/html' }) + const blob = new Blob([htmlContent], { type: 'text/html' }) const clipboardItem = new ClipboardItem({ 'text/html': blob }) await navigator.clipboard.write([clipboardItem]) }, - [getHTMLContent((indent: number) => `${indent * 40}px`)], + [htmlContent], ) // eslint-disable-next-line playwright/no-conditional-in-test const pasteKey = process.platform === 'darwin' ? 'Meta' : 'Control' await page.keyboard.press(`${pasteKey}+v`) await page.locator('#field-richText').click() await page.locator('#field-richText').fill('asd') - await saveDocAndAssert(page) + await page.getByRole('button', { name: 'Save' }).click() await page.getByRole('link', { name: 'API' }).click() - const htmlOutput = page.getByText(getHTMLContent((indent: number) => `${indent * 2}rem`)) + const htmlOutput = page.getByText(htmlContent) await expect(htmlOutput).toBeVisible() }) diff --git a/test/lexical/collections/LexicalJSXConverter/e2e.spec.ts b/test/lexical/collections/LexicalJSXConverter/e2e.spec.ts new file mode 100644 index 000000000..8a52a7c24 --- /dev/null +++ b/test/lexical/collections/LexicalJSXConverter/e2e.spec.ts @@ -0,0 +1,117 @@ +import type { Locator, Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' +import { AdminUrlUtil } from 'helpers/adminUrlUtil.js' +import { reInitializeDB } from 'helpers/reInitializeDB.js' +import { lexicalJSXConverterSlug } 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 other suites, this one runs in parallel, as they run on the `/create` URL and are "pure" tests +test.describe.configure({ mode: 'parallel' }) + +const { serverURL } = await initPayloadE2ENoConfig({ + dirname, +}) + +describe('Lexical JSX Converter', () => { + 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: 'lexicalTest', + uploadsDir: [path.resolve(dirname, './collections/Upload/uploads')], + }) + const url = new AdminUrlUtil(serverURL, lexicalJSXConverterSlug) + const lexical = new LexicalHelpers(page) + await page.goto(url.create) + await lexical.editor.first().focus() + }) + + // See rationale in https://github.com/payloadcms/payload/issues/13130#issuecomment-3058348085 + test('indents should be 40px in the editor and in the jsx converter', async ({ page }) => { + const lexical = new LexicalHelpers(page) + // 40px + await lexical.addLine('ordered', 'HelloA0', 1, false) + await lexical.addLine('paragraph', 'HelloA1', 1) + await lexical.addLine('unordered', 'HelloA2', 1) + await lexical.addLine('h1', 'HelloA3', 1) + await lexical.addLine('check', 'HelloA4', 1) + + // 80px + await lexical.addLine('ordered', 'HelloB0', 2) + await lexical.addLine('paragraph', 'HelloB1', 2) + await lexical.addLine('unordered', 'HelloB2', 2) + await lexical.addLine('h1', 'HelloB3', 2) + await lexical.addLine('check', 'HelloB4', 2) + + const [offsetA0_ed, offsetA0_jsx] = await getIndentOffset(page, 'HelloA0') + const [offsetA1_ed, offsetA1_jsx] = await getIndentOffset(page, 'HelloA1') + const [offsetA2_ed, offsetA2_jsx] = await getIndentOffset(page, 'HelloA2') + const [offsetA3_ed, offsetA3_jsx] = await getIndentOffset(page, 'HelloA3') + const [offsetA4_ed, offsetA4_jsx] = await getIndentOffset(page, 'HelloA4') + + const [offsetB0_ed, offsetB0_jsx] = await getIndentOffset(page, 'HelloB0') + const [offsetB1_ed, offsetB1_jsx] = await getIndentOffset(page, 'HelloB1') + const [offsetB2_ed, offsetB2_jsx] = await getIndentOffset(page, 'HelloB2') + const [offsetB3_ed, offsetB3_jsx] = await getIndentOffset(page, 'HelloB3') + const [offsetB4_ed, offsetB4_jsx] = await getIndentOffset(page, 'HelloB4') + + await expect(() => { + expect(offsetA0_ed).toBe(offsetB0_ed - 40) + expect(offsetA1_ed).toBe(offsetB1_ed - 40) + expect(offsetA2_ed).toBe(offsetB2_ed - 40) + expect(offsetA3_ed).toBe(offsetB3_ed - 40) + expect(offsetA4_ed).toBe(offsetB4_ed - 40) + expect(offsetA0_jsx).toBe(offsetB0_jsx - 40) + expect(offsetA1_jsx).toBe(offsetB1_jsx - 40) + expect(offsetA2_jsx).toBe(offsetB2_jsx - 40) + expect(offsetA3_jsx).toBe(offsetB3_jsx - 40) + // TODO: Checklist item in JSX needs more thought + // expect(offsetA4_jsx).toBe(offsetB4_jsx - 40) + }).toPass() + + // HTML in JSX converter should contain as few inline styles as possible + await expect(async () => { + const innerHTML = await page.locator('.payload-richtext').innerHTML() + const normalized = normalizeCheckboxUUIDs(innerHTML) + expect(normalized).toBe( + `
  1. HelloA0

HelloA1

  • HelloA2

HelloA3

  1. HelloA4
    1. HelloB0

HelloB1

    • HelloB2

HelloB3

`, + ) + }).toPass() + }) +}) + +async function getIndentOffset(page: Page, text: string): Promise<[number, number]> { + const textElement = page.getByText(text) + await expect(textElement).toHaveCount(2) + const startLeft = (locator: Locator) => + locator.evaluate((el) => { + const leftRect = el.getBoundingClientRect().left + const paddingLeft = getComputedStyle(el).paddingLeft + return leftRect + parseFloat(paddingLeft) + }) + return [await startLeft(textElement.first()), await startLeft(textElement.last())] +} + +function normalizeCheckboxUUIDs(html: string): string { + return html + .replace(/id="[a-f0-9-]{36}"/g, 'id="DETERMINISTIC_UUID"') + .replace(/for="[a-f0-9-]{36}"/g, 'for="DETERMINISTIC_UUID"') +} diff --git a/test/lexical/collections/LexicalJSXConverter/index.ts b/test/lexical/collections/LexicalJSXConverter/index.ts new file mode 100644 index 000000000..60e37ba35 --- /dev/null +++ b/test/lexical/collections/LexicalJSXConverter/index.ts @@ -0,0 +1,18 @@ +import type { CollectionConfig } from 'payload' + +import { DebugJsxConverterFeature, lexicalEditor } from '@payloadcms/richtext-lexical' + +import { lexicalJSXConverterSlug } from '../../slugs.js' + +export const LexicalJSXConverter: CollectionConfig = { + slug: lexicalJSXConverterSlug, + fields: [ + { + name: 'richText', + type: 'richText', + editor: lexicalEditor({ + features: ({ defaultFeatures }) => [...defaultFeatures, DebugJsxConverterFeature()], + }), + }, + ], +} diff --git a/test/lexical/collections/_LexicalFullyFeatured/e2e.spec.ts b/test/lexical/collections/_LexicalFullyFeatured/e2e.spec.ts index ed63640fb..fa9115438 100644 --- a/test/lexical/collections/_LexicalFullyFeatured/e2e.spec.ts +++ b/test/lexical/collections/_LexicalFullyFeatured/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, '../../') diff --git a/test/lexical/collections/_LexicalFullyFeatured/utils.ts b/test/lexical/collections/utils.ts similarity index 63% rename from test/lexical/collections/_LexicalFullyFeatured/utils.ts rename to test/lexical/collections/utils.ts index 95030f7fe..560c21379 100644 --- a/test/lexical/collections/_LexicalFullyFeatured/utils.ts +++ b/test/lexical/collections/utils.ts @@ -8,6 +8,27 @@ export class LexicalHelpers { this.page = page } + async addLine( + type: 'check' | 'h1' | 'h2' | 'ordered' | 'paragraph' | 'unordered', + text: string, + indent: number, + startWithEnter = true, + ) { + if (startWithEnter) { + await this.page.keyboard.press('Enter') + } + await this.slashCommand(type) + // Outdent 10 times to be sure we are at the beginning of the line + for (let i = 0; i < 10; i++) { + await this.page.keyboard.press('Shift+Tab') + } + const adjustedIndent = ['check', 'ordered', 'unordered'].includes(type) ? indent - 1 : indent + for (let i = 0; i < adjustedIndent; i++) { + await this.page.keyboard.press('Tab') + } + await this.page.keyboard.type(text) + } + async save(container: 'document' | 'drawer') { if (container === 'drawer') { await this.drawer.getByText('Save').click() @@ -19,7 +40,7 @@ export class LexicalHelpers { async slashCommand( // prettier-ignore - command: 'block' | 'check' | 'code' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' |'h6' | 'inline' + command: 'block' | 'check' | 'code' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' |'h6' | 'inline' | 'link' | 'ordered' | 'paragraph' | 'quote' | 'relationship' | 'unordered' | 'upload', ) { await this.page.keyboard.press(`/`) diff --git a/test/lexical/payload-types.ts b/test/lexical/payload-types.ts index f6ed02d76..63f9ecc24 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-jsx-converter': LexicalJsxConverter; 'lexical-fields': LexicalField; 'lexical-migrate-fields': LexicalMigrateField; 'lexical-localized-fields': LexicalLocalizedField; @@ -105,6 +106,7 @@ export interface Config { collectionsSelect: { 'lexical-fully-featured': LexicalFullyFeaturedSelect | LexicalFullyFeaturedSelect; 'lexical-link-feature': LexicalLinkFeatureSelect | LexicalLinkFeatureSelect; + 'lexical-jsx-converter': LexicalJsxConverterSelect | LexicalJsxConverterSelect; 'lexical-fields': LexicalFieldsSelect | LexicalFieldsSelect; 'lexical-migrate-fields': LexicalMigrateFieldsSelect | LexicalMigrateFieldsSelect; 'lexical-localized-fields': LexicalLocalizedFieldsSelect | LexicalLocalizedFieldsSelect; @@ -205,6 +207,30 @@ export interface LexicalLinkFeature { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "lexical-jsx-converter". + */ +export interface LexicalJsxConverter { + 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-fields". @@ -841,6 +867,10 @@ export interface PayloadLockedDocument { relationTo: 'lexical-link-feature'; value: string | LexicalLinkFeature; } | null) + | ({ + relationTo: 'lexical-jsx-converter'; + value: string | LexicalJsxConverter; + } | null) | ({ relationTo: 'lexical-fields'; value: string | LexicalField; @@ -949,6 +979,15 @@ export interface LexicalLinkFeatureSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "lexical-jsx-converter_select". + */ +export interface LexicalJsxConverterSelect { + richText?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "lexical-fields_select". diff --git a/test/lexical/slugs.ts b/test/lexical/slugs.ts index 502cec89c..4ac585943 100644 --- a/test/lexical/slugs.ts +++ b/test/lexical/slugs.ts @@ -2,6 +2,7 @@ export const usersSlug = 'users' export const lexicalFullyFeaturedSlug = 'lexical-fully-featured' export const lexicalFieldsSlug = 'lexical-fields' +export const lexicalJSXConverterSlug = 'lexical-jsx-converter' export const lexicalLinkFeatureSlug = 'lexical-link-feature' export const lexicalLocalizedFieldsSlug = 'lexical-localized-fields'