fix(richtext-lexical): unify indent between different converters and make paragraphs and lists match without CSS (#13274)

Previously, the Lexical editor was using px, and the JSX converter was
using rem. #12848 fixed the inconsistency by changing the editor to rem,
but it should have been the other way around, changing the JSX converter
to px.

You can see the latest explanation about why it should be 40px
[here](https://github.com/payloadcms/payload/issues/13130#issuecomment-3058348085).
In short, that's the default indentation all browsers use for lists.

This time I'm making sure to leave clear comments everywhere and a test
to avoid another regression.

Here is an image of what the e2e test looks like:

<img width="321" height="678" alt="image"
src="https://github.com/user-attachments/assets/8880c7cb-a954-4487-8377-aee17c06754c"
/>

The first part is the Lexical editor, the second is the JSX converter.

As you can see, the checkbox in JSX looks a little odd because it uses
an input checkbox (as opposed to a pseudo-element in the Lexical
editor). I thought about adding an inline style to move it slightly to
the left, but I found that browsers don't have a standard size for the
checkbox; it varies by browser and device.
That requires a little more thought; I'll address that in a future PR.

Fixes #13130
This commit is contained in:
German Jablonski
2025-07-25 22:58:49 +01:00
committed by GitHub
parent bc802846c5
commit 7cd4a8a602
15 changed files with 248 additions and 14 deletions

View File

@@ -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(' ')

View File

@@ -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`
}
}

View File

@@ -31,7 +31,11 @@ export const ParagraphHTMLConverter: HTMLConverter<SerializedParagraphNode> = {
})
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(' ')

View File

@@ -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`
}
}

View File

@@ -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 <RichText converters={defaultJSXConverters} data={editorState} />
return (
<div className="debug-jsx-converter">
<RichText converters={defaultJSXConverters} data={editorState} />
</div>
)
}

View File

@@ -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;
}
}

View File

@@ -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(' ')

View File

@@ -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<Config> = {
collections: [
LexicalFullyFeatured,
LexicalLinkFeature,
LexicalJSXConverter,
getLexicalFieldsCollection({
blocks: lexicalBlocks,
inlineBlocks: lexicalInlineBlocks,

View File

@@ -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) =>
`<p style='text-align: center;'>paragraph centered</p><h1 style='text-align: right;'>Heading right</h1><p>paragraph without indent</p><p style='padding-inline-start: ${indentToSize(1)};'>paragraph indent 1</p><h2 style='padding-inline-start: ${indentToSize(2)};'>heading indent 2</h2><blockquote style='padding-inline-start: ${indentToSize(3)};'>quote indent 3</blockquote>`
const htmlContent = `<p style='text-align: center;'>paragraph centered</p><h1 style='text-align: right;'>Heading right</h1><p>paragraph without indent</p><p style='padding-inline-start: 40px;'>paragraph indent 1</p><h2 style='padding-inline-start: 80px;'>heading indent 2</h2><blockquote style='padding-inline-start: 120px;'>quote indent 3</blockquote>`
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()
})

View File

@@ -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(
`<ol class="list-number"><li class="" value="1">HelloA0</li></ol><p style="padding-inline-start: 40px;">HelloA1</p><ul class="list-bullet"><li class="" value="1">HelloA2</li></ul><h1 style="padding-inline-start: 40px;">HelloA3</h1><ol class="list-number"><li class="" value="1">HelloA4</li><li class="nestedListItem" value="2" style="list-style-type: none;"><ol class="list-number"><li class="" value="1">HelloB0</li></ol></li></ol><p style="padding-inline-start: 80px;">HelloB1</p><ul class="list-bullet"><li class="nestedListItem" value="1" style="list-style-type: none;"><ul class="list-bullet"><li class="" value="1">HelloB2</li></ul></li></ul><h1 style="padding-inline-start: 80px;">HelloB3</h1><ul class="list-check"><li aria-checked="false" class="list-item-checkbox list-item-checkbox-unchecked nestedListItem" role="checkbox" tabindex="-1" value="1" style="list-style-type: none;"><ul class="list-check"><li aria-checked="false" class="list-item-checkbox list-item-checkbox-unchecked" role="checkbox" tabindex="-1" value="1" style="list-style-type: none;"><input id="DETERMINISTIC_UUID" readonly="" type="checkbox"><label for="DETERMINISTIC_UUID">HelloB4</label><br></li></ul></li></ul>`,
)
}).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"')
}

View File

@@ -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()],
}),
},
],
}

View File

@@ -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, '../../')

View File

@@ -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(`/`)

View File

@@ -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<false> | LexicalFullyFeaturedSelect<true>;
'lexical-link-feature': LexicalLinkFeatureSelect<false> | LexicalLinkFeatureSelect<true>;
'lexical-jsx-converter': LexicalJsxConverterSelect<false> | LexicalJsxConverterSelect<true>;
'lexical-fields': LexicalFieldsSelect<false> | LexicalFieldsSelect<true>;
'lexical-migrate-fields': LexicalMigrateFieldsSelect<false> | LexicalMigrateFieldsSelect<true>;
'lexical-localized-fields': LexicalLocalizedFieldsSelect<false> | LexicalLocalizedFieldsSelect<true>;
@@ -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<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "lexical-jsx-converter_select".
*/
export interface LexicalJsxConverterSelect<T extends boolean = true> {
richText?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "lexical-fields_select".

View File

@@ -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'