diff --git a/packages/richtext-lexical/src/features/blockquote/server/index.ts b/packages/richtext-lexical/src/features/blockquote/server/index.ts index 2248ef3c9b5..70aef051729 100644 --- a/packages/richtext-lexical/src/features/blockquote/server/index.ts +++ b/packages/richtext-lexical/src/features/blockquote/server/index.ts @@ -5,6 +5,7 @@ import { QuoteNode } from '@lexical/rich-text' import { createServerFeature } from '../../../utilities/createServerFeature.js' import { convertLexicalNodesToHTML } from '../../converters/html/converter/index.js' +import { getElementNodeDefaultStyle } from '../../shared/defaultStyle/getElementNodeDefaultStyle.js' import { createNode } from '../../typeUtilities.js' import { MarkdownTransformer } from '../markdownTransformer.js' import { i18n } from './i18n.js' @@ -51,8 +52,12 @@ export const BlockquoteFeature = createServerFeature({ req, showHiddenFields, }) + const defaultStyle = getElementNodeDefaultStyle({ + node, + }) + const style = defaultStyle ? ` style="${defaultStyle}"` : '' - return `
${childrenText}
` + return `${childrenText}` }, nodeTypes: [QuoteNode.getType()], }, diff --git a/packages/richtext-lexical/src/features/converters/html/converter/converters/paragraph.ts b/packages/richtext-lexical/src/features/converters/html/converter/converters/paragraph.ts index 1f8ba4631e8..6a7234cd273 100644 --- a/packages/richtext-lexical/src/features/converters/html/converter/converters/paragraph.ts +++ b/packages/richtext-lexical/src/features/converters/html/converter/converters/paragraph.ts @@ -2,6 +2,7 @@ import type { SerializedParagraphNode } from 'lexical' import type { HTMLConverter } from '../types.js' +import { getElementNodeDefaultStyle } from '../../../../shared/defaultStyle/getElementNodeDefaultStyle.js' import { convertLexicalNodesToHTML } from '../index.js' export const ParagraphHTMLConverter: HTMLConverter = { @@ -30,7 +31,12 @@ export const ParagraphHTMLConverter: HTMLConverter = { req, showHiddenFields, }) - return `

${childrenText}

` + const defaultStyle = getElementNodeDefaultStyle({ + node, + }) + const style = defaultStyle ? ` style="${defaultStyle}"` : '' + + return `${childrenText}

` }, nodeTypes: ['paragraph'], } diff --git a/packages/richtext-lexical/src/features/heading/server/index.ts b/packages/richtext-lexical/src/features/heading/server/index.ts index 30e5cc4ff09..0ea83f6d24f 100644 --- a/packages/richtext-lexical/src/features/heading/server/index.ts +++ b/packages/richtext-lexical/src/features/heading/server/index.ts @@ -8,6 +8,7 @@ import { HeadingNode } from '@lexical/rich-text' import { createServerFeature } from '../../../utilities/createServerFeature.js' import { convertLexicalNodesToHTML } from '../../converters/html/converter/index.js' +import { getElementNodeDefaultStyle } from '../../shared/defaultStyle/getElementNodeDefaultStyle.js' import { createNode } from '../../typeUtilities.js' import { MarkdownTransformer } from '../markdownTransformer.js' import { i18n } from './i18n.js' @@ -69,8 +70,12 @@ export const HeadingFeature = createServerFeature< req, showHiddenFields, }) + const defaultStyle = getElementNodeDefaultStyle({ + node, + }) + const style = defaultStyle ? ` style="${defaultStyle}"` : '' - return '<' + node?.tag + '>' + childrenText + '' + return `<${node?.tag}${style}>${childrenText}` }, nodeTypes: [HeadingNode.getType()], }, diff --git a/packages/richtext-lexical/src/features/lists/htmlConverter.ts b/packages/richtext-lexical/src/features/lists/htmlConverter.ts index 882410fb5c0..2a693073a11 100644 --- a/packages/richtext-lexical/src/features/lists/htmlConverter.ts +++ b/packages/richtext-lexical/src/features/lists/htmlConverter.ts @@ -5,6 +5,10 @@ import type { HTMLConverter } from '../converters/html/converter/types.js' import type { SerializedListItemNode, SerializedListNode } from './plugin/index.js' import { convertLexicalNodesToHTML } from '../converters/html/converter/index.js' +import { + getAlignStyle, + getElementNodeDefaultStyle, +} from '../shared/defaultStyle/getElementNodeDefaultStyle.js' export const ListHTMLConverter: HTMLConverter = { converter: async ({ @@ -66,6 +70,11 @@ export const ListItemHTMLConverter: HTMLConverter = { req, showHiddenFields, }) + const defaultStyle = getElementNodeDefaultStyle({ + node, + styleConverters: [getAlignStyle], + }) + const style = defaultStyle ? ` style="${defaultStyle}"` : '' if ('listType' in parent && parent?.listType === 'check') { const uuid = uuidv4() @@ -78,6 +87,7 @@ export const ListItemHTMLConverter: HTMLConverter = { role="checkbox" tabIndex=${-1} value=${node?.value} + ${style} > ${ hasSubLists @@ -87,11 +97,9 @@ export const ListItemHTMLConverter: HTMLConverter = {
` } - - - ` + ` } else { - return `
  • ${childrenText}
  • ` + return `
  • ${childrenText}
  • ` } }, nodeTypes: [ListItemNode.getType()], diff --git a/packages/richtext-lexical/src/features/shared/defaultStyle/getElementNodeDefaultStyle.ts b/packages/richtext-lexical/src/features/shared/defaultStyle/getElementNodeDefaultStyle.ts new file mode 100644 index 00000000000..d2744743f8e --- /dev/null +++ b/packages/richtext-lexical/src/features/shared/defaultStyle/getElementNodeDefaultStyle.ts @@ -0,0 +1,55 @@ +import type { SerializedElementNode } from 'lexical' + +const convertStyleObjectToString = (style: Record): string => { + let styleString = '' + const styleEntries = Object.entries(style) + for (const styleEntry of styleEntries) { + const [styleKey, styleValue] = styleEntry + if ( + styleValue === null || + styleValue === undefined || + (typeof styleValue === 'string' && styleValue === '') + ) { + continue + } + styleString += `${styleKey}: ${String(styleValue)};` + } + return styleString +} + +export const getAlignStyle = (node: SerializedElementNode) => { + return { 'text-align': node.format } +} + +export const getIndentStyle = (node: SerializedElementNode) => { + if (!node.indent) { + return {} + } + /** + * https://github.com/facebook/lexical/blob/da405bba0511ba26191e56ec8d7c7770b36c59f0/packages/lexical/src/nodes/LexicalParagraphNode.ts#L156-L157 + * Lexical renders indent with padding-inline-start, but it use text-indent for RTL supports and widely supports. + * */ + return { 'text-indent': `${node.indent * 20}px` } +} + +interface GetElementNodeDefaultStyleProps { + node: SerializedElementNode + styleConverters?: ((node: SerializedElementNode) => Record)[] +} + +export const getElementNodeDefaultStyle = ({ + node, + styleConverters = [getAlignStyle, getIndentStyle], +}: GetElementNodeDefaultStyleProps) => { + const convertedStyleObject = styleConverters.reduce( + (prevObject, styleConverter) => { + return { + ...prevObject, + ...styleConverter(node), + } + }, + {} as Record, + ) + + return convertStyleObjectToString(convertedStyleObject) +} diff --git a/test/fields/collections/LexicalMigrate/data.ts b/test/fields/collections/LexicalMigrate/data.ts index f579aba2e90..a5f890fdb24 100644 --- a/test/fields/collections/LexicalMigrate/data.ts +++ b/test/fields/collections/LexicalMigrate/data.ts @@ -56,3 +56,63 @@ export function getSimpleLexicalData(textContent: string) { }, } } + +export function getAlignIndentLexicalData(textContent: string) { + return { + root: { + children: [ + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: textContent, + type: 'text', + version: 1, + }, + ], + direction: 'ltr', + format: 'center', + indent: 0, + type: 'heading', + version: 1, + tag: 'h2', + }, + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: textContent, + type: 'text', + version: 1, + }, + ], + direction: 'ltr', + format: 'left', + indent: 2, + type: 'paragraph', + version: 1, + textFormat: 0, + textStyle: '', + }, + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'root', + version: 1, + }, + } +} + +/** + * If the HTML Conversion structure of the heading and paragraph changes, must edit this. + */ +export function getAlignIndentHTMLData(textContent: string) { + return `

    ${textContent}

    ${textContent}

    ` +} diff --git a/test/fields/collections/LexicalMigrate/e2e/converter/e2e.spec.ts b/test/fields/collections/LexicalMigrate/e2e/converter/e2e.spec.ts new file mode 100644 index 00000000000..9a44dd5f4eb --- /dev/null +++ b/test/fields/collections/LexicalMigrate/e2e/converter/e2e.spec.ts @@ -0,0 +1,173 @@ +import type { BrowserContext, Page } from '@playwright/test' +import type { SerializedEditorState } from 'lexical' + +import { expect, test } from '@playwright/test' +import os from 'os' +import path from 'path' +import { fileURLToPath } from 'url' + +import type { PayloadTestSDK } from '../../../../../helpers/sdk/index.js' +import type { Config, LexicalMigrateField } from '../../../../payload-types.js' + +import { + ensureCompilationIsDone, + initPageConsoleErrorCatch, + saveDocAndAssert, +} from '../../../../../helpers.js' +import { AdminUrlUtil } from '../../../../../helpers/adminUrlUtil.js' +import { initPayloadE2ENoConfig } from '../../../../../helpers/initPayloadE2ENoConfig.js' +import { reInitializeDB } from '../../../../../helpers/reInitializeDB.js' +import { RESTClient } from '../../../../../helpers/rest.js' +import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../../../../../playwright.config.js' +import { lexicalMigrateFieldsSlug } from '../../../../slugs.js' +import { + getAlignIndentHTMLData, + getAlignIndentLexicalData, + lexicalMigrateDocData, +} from '../../data.js' + +const filename = fileURLToPath(import.meta.url) +const currentFolder = path.dirname(filename) +const dirname = path.resolve(currentFolder, '../../../../') + +const { beforeAll, beforeEach, describe } = test + +let payload: PayloadTestSDK +let client: RESTClient +let page: Page +let context: BrowserContext +let serverURL: string + +/** + * Client-side navigation to the lexical editor from list view + */ +async function navigateToLexicalFields(navigateToListView: boolean = true) { + if (navigateToListView) { + const url: AdminUrlUtil = new AdminUrlUtil(serverURL, lexicalMigrateFieldsSlug) + await page.goto(url.list) + } + + const linkToDoc = page.locator('tbody tr:first-child .cell-title a').first() + await expect(() => expect(linkToDoc).toBeTruthy()).toPass({ timeout: POLL_TOPASS_TIMEOUT }) + const linkDocHref = await linkToDoc.getAttribute('href') + + await linkToDoc.click() + + await page.waitForURL(`**${linkDocHref}`) +} +async function initClipboard( + { + context, + page, + }: { + context: BrowserContext + page: Page + }, + // example: [['text/html', '

    simple

    '], ['text/plain', 'simple']] + initialClipboardData?: Array<[string, string]>, +) { + await context.grantPermissions(['clipboard-read', 'clipboard-write']) + await page.evaluate((initialClipboardData) => { + const initClipboardData = (event: ClipboardEvent) => { + event.preventDefault() + for (const [type, value] of initialClipboardData) { + event.clipboardData.setData(type, value) + } + document.removeEventListener('copy', initClipboardData) + } + document.addEventListener('copy', initClipboardData) + }, initialClipboardData) + const isMac = os.platform() === 'darwin' + // For Mac, paste command is with Meta. + const modifier = isMac ? 'Meta' : 'Control' + await page.keyboard.press(`${modifier}+KeyC`) +} + +describe('lexicalMigrateConverter', () => { + 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 + ;({ payload, serverURL } = await initPayloadE2ENoConfig({ dirname })) + + context = await browser.newContext() + page = await context.newPage() + + initPageConsoleErrorCatch(page) + await reInitializeDB({ + serverURL, + snapshotKey: 'fieldsLexicalMainTest', + uploadsDir: path.resolve(dirname, './collections/Upload/uploads'), + }) + await ensureCompilationIsDone({ page, serverURL }) + }) + beforeEach(async () => { + /*await throttleTest({ + page, + context, + delay: 'Slow 4G', + })*/ + await reInitializeDB({ + serverURL, + snapshotKey: 'fieldsLexicalMigrateConverterTest', + uploadsDir: [ + path.resolve(dirname, './collections/Upload/uploads'), + path.resolve(dirname, './collections/Upload2/uploads2'), + ], + }) + + if (client) { + await client.logout() + } + client = new RESTClient(null, { defaultSlug: 'rich-text-fields', serverURL }) + await client.login() + }) + + test('should be replaced indent and align styles in text/html with lexical data', async ({ + context, + }) => { + await initClipboard( + { + context, + page, + }, + [['text/html', getAlignIndentHTMLData('styled')]], + ) + await navigateToLexicalFields() + // Fix lexicalWithSlateData data because of a LinkNode validate issue. + const richTextField = page.locator('.rich-text-lexical').nth(1) + await richTextField.scrollIntoViewIfNeeded() + await expect(richTextField).toBeVisible() + + const editor = richTextField.locator('.editor') + await editor.click() + const isMac = os.platform() === 'darwin' + // For Mac, paste command is with Meta. + const modifier = isMac ? 'Meta' : 'Control' + // Delete all lexical data for HTML paste test. + await page.keyboard.press(`${modifier}+KeyA`) + await page.keyboard.press('Backspace') + await page.keyboard.press(`${modifier}+KeyV`) + + await saveDocAndAssert(page) + await expect(async () => { + const lexicalDoc: LexicalMigrateField = ( + await payload.find({ + collection: lexicalMigrateFieldsSlug, + depth: 0, + overrideAccess: true, + where: { + title: { + equals: lexicalMigrateDocData.title, + }, + }, + }) + ).docs[0] as never + + const lexicalField: SerializedEditorState = lexicalDoc.lexicalWithSlateData + + expect(lexicalField).toMatchObject(getAlignIndentLexicalData('styled')) + }).toPass({ + timeout: POLL_TOPASS_TIMEOUT, + }) + }) +}) diff --git a/test/fields/collections/LexicalMigrate/index.ts b/test/fields/collections/LexicalMigrate/index.ts index 19403825f47..577c50c744a 100644 --- a/test/fields/collections/LexicalMigrate/index.ts +++ b/test/fields/collections/LexicalMigrate/index.ts @@ -14,7 +14,7 @@ import { } from '@payloadcms/richtext-lexical/migrate' import { lexicalMigrateFieldsSlug } from '../../slugs.js' -import { getSimpleLexicalData } from './data.js' +import { getAlignIndentLexicalData, getSimpleLexicalData } from './data.js' export const LexicalMigrateFields: CollectionConfig = { slug: lexicalMigrateFieldsSlug, @@ -122,6 +122,15 @@ export const LexicalMigrateFields: CollectionConfig = { defaultValue: getSimpleLexicalData('simple'), }, lexicalHTML('lexicalSimple', { name: 'lexicalSimple_html' }), + { + name: 'lexicalStyledIndentAlign', + type: 'richText', + editor: lexicalEditor({ + features: ({ defaultFeatures }) => [...defaultFeatures, HTMLConverterFeature()], + }), + defaultValue: getAlignIndentLexicalData('styled'), + }, + lexicalHTML('lexicalStyledIndentAlign', { name: 'lexicalStyledIndentAlign_html' }), { name: 'groupWithLexicalField', type: 'group', diff --git a/test/fields/lexical.int.spec.ts b/test/fields/lexical.int.spec.ts index 71203c6fbec..92e572ad6be 100644 --- a/test/fields/lexical.int.spec.ts +++ b/test/fields/lexical.int.spec.ts @@ -18,7 +18,7 @@ import { NextRESTClient } from '../helpers/NextRESTClient.js' import { lexicalDocData } from './collections/Lexical/data.js' import { generateLexicalLocalizedRichText } from './collections/LexicalLocalized/generateLexicalRichText.js' import { textToLexicalJSON } from './collections/LexicalLocalized/textToLexicalJSON.js' -import { lexicalMigrateDocData } from './collections/LexicalMigrate/data.js' +import { getAlignIndentHTMLData, lexicalMigrateDocData } from './collections/LexicalMigrate/data.js' import { richTextDocData } from './collections/RichText/data.js' import { generateLexicalRichText } from './collections/RichText/generateLexicalRichText.js' import { textDoc } from './collections/Text/shared.js' @@ -304,6 +304,22 @@ describe('Lexical', () => { const htmlField: string = lexicalDoc?.lexicalSimple_html expect(htmlField).toStrictEqual('

    simple

    ') }) + it('htmlConverter: should output correct HTML for lexical data with indent and align', async () => { + const lexicalDoc: LexicalMigrateField = ( + await payload.find({ + collection: lexicalMigrateFieldsSlug, + depth: 0, + where: { + title: { + equals: lexicalMigrateDocData.title, + }, + }, + }) + ).docs[0] as never + + const htmlField: string = lexicalDoc?.lexicalStyledIndentAlign_html + expect(htmlField).toStrictEqual(getAlignIndentHTMLData('styled')) + }) it('htmlConverter: should output correct HTML for lexical field nested in group', async () => { const lexicalDoc: LexicalMigrateField = ( await payload.find({