diff --git a/packages/lexical-list/src/LexicalListNode.ts b/packages/lexical-list/src/LexicalListNode.ts index 680e27aa899..45a87f30d2a 100644 --- a/packages/lexical-list/src/LexicalListNode.ts +++ b/packages/lexical-list/src/LexicalListNode.ts @@ -155,7 +155,7 @@ export class ListNode extends ElementNode { } exportDOM(editor: LexicalEditor): DOMExportOutput { - const {element} = super.exportDOM(editor); + const element = this.createDOM(editor._config, editor); if (element && isHTMLElement(element)) { if (this.__start !== 1) { element.setAttribute('start', String(this.__start)); diff --git a/packages/lexical-playground/__tests__/unit/docSerialization.test.ts b/packages/lexical-playground/__tests__/unit/docSerialization.test.ts index 41e697d30ce..8b9e73a6de1 100644 --- a/packages/lexical-playground/__tests__/unit/docSerialization.test.ts +++ b/packages/lexical-playground/__tests__/unit/docSerialization.test.ts @@ -15,7 +15,13 @@ */ import {serializedDocumentFromEditorState} from '@lexical/file'; -import {$createParagraphNode, $createTextNode, $getRoot} from 'lexical'; +import {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $insertNodes, +} from 'lexical'; import {initializeUnitTest} from 'lexical/src/__tests__/utils'; import {docFromHash, docToHash} from '../../src/utils/docSerialization'; @@ -48,5 +54,172 @@ describe('docSerialization', () => { expect(await docFromHash(await docToHash(doc))).toEqual(doc); }); }); + + describe('Preserve indent serializing HTML <-> Lexical', () => { + it('preserves indentation', async () => { + const {editor} = testEnv; + const parser = new DOMParser(); + const htmlString = `

+ paragraph +

+

+ heading +

+
+ quote +
+

+ paragraph +

+

+ heading +

+
+ quote +
`; + const dom = parser.parseFromString(htmlString, 'text/html'); + await editor.update(() => { + const nodes = $generateNodesFromDOM(editor, dom); + $getRoot().select(); + $insertNodes(nodes); + }); + + const expectedEditorState = { + root: { + children: [ + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'paragraph', + type: 'text', + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 0, + textFormat: 0, + textStyle: '', + type: 'paragraph', + version: 1, + }, + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'heading', + type: 'text', + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 0, + tag: 'h1', + type: 'heading', + version: 1, + }, + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'quote', + type: 'text', + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'quote', + version: 1, + }, + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'paragraph', + type: 'text', + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 2, + textFormat: 0, + textStyle: '', + type: 'paragraph', + version: 1, + }, + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'heading', + type: 'text', + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 2, + tag: 'h1', + type: 'heading', + version: 1, + }, + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'quote', + type: 'text', + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 2, + type: 'quote', + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'root', + version: 1, + }, + }; + + const editorState = editor.getEditorState().toJSON(); + expect(editorState).toEqual(expectedEditorState); + let htmlString2; + await editor.update(() => { + htmlString2 = $generateHtmlFromNodes(editor); + }); + expect(htmlString2).toBe( + '

paragraph

heading

quote

paragraph

heading

quote
', + ); + }); + }); }); }); diff --git a/packages/lexical-rich-text/src/index.ts b/packages/lexical-rich-text/src/index.ts index fbf9f53b0ec..bf53a8acdd4 100644 --- a/packages/lexical-rich-text/src/index.ts +++ b/packages/lexical-rich-text/src/index.ts @@ -92,6 +92,7 @@ import { PASTE_COMMAND, REMOVE_TEXT_COMMAND, SELECT_ALL_COMMAND, + setNodeIndentFromDOM, } from 'lexical'; import caretFromPoint from 'shared/caretFromPoint'; import { @@ -415,6 +416,7 @@ function $convertHeadingElement(element: HTMLElement): DOMConversionOutput { ) { node = $createHeadingNode(nodeName); if (element.style !== null) { + setNodeIndentFromDOM(element, node); node.setFormat(element.style.textAlign as ElementFormatType); } } @@ -425,6 +427,7 @@ function $convertBlockquoteElement(element: HTMLElement): DOMConversionOutput { const node = $createQuoteNode(); if (element.style !== null) { node.setFormat(element.style.textAlign as ElementFormatType); + setNodeIndentFromDOM(element, node); } return {node}; } diff --git a/packages/lexical/src/LexicalUtils.ts b/packages/lexical/src/LexicalUtils.ts index 472703d63d5..9039a186c50 100644 --- a/packages/lexical/src/LexicalUtils.ts +++ b/packages/lexical/src/LexicalUtils.ts @@ -1820,3 +1820,12 @@ export function $cloneWithProperties(latestNode: T): T { } return mutableNode; } + +export function setNodeIndentFromDOM( + elementDom: HTMLElement, + elementNode: ElementNode, +) { + const indentSize = parseInt(elementDom.style.paddingInlineStart, 10) || 0; + const indent = indentSize / 40; + elementNode.setIndent(indent); +} diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index b3f5013cdd7..538440b8195 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -187,6 +187,7 @@ export { isSelectionCapturedInDecoratorInput, isSelectionWithinEditor, resetRandomKey, + setNodeIndentFromDOM, } from './LexicalUtils'; export {ArtificialNode__DO_NOT_USE} from './nodes/ArtificialNode'; export {$isDecoratorNode, DecoratorNode} from './nodes/LexicalDecoratorNode'; diff --git a/packages/lexical/src/nodes/LexicalElementNode.ts b/packages/lexical/src/nodes/LexicalElementNode.ts index 474d0b405ba..65bc77aec5a 100644 --- a/packages/lexical/src/nodes/LexicalElementNode.ts +++ b/packages/lexical/src/nodes/LexicalElementNode.ts @@ -6,13 +6,17 @@ * */ -import type {NodeKey, SerializedLexicalNode} from '../LexicalNode'; +import type { + DOMExportOutput, + NodeKey, + SerializedLexicalNode, +} from '../LexicalNode'; import type { BaseSelection, PointType, RangeSelection, } from '../LexicalSelection'; -import type {KlassConstructor, Spread} from 'lexical'; +import type {KlassConstructor, LexicalEditor, Spread} from 'lexical'; import invariant from 'shared/invariant'; @@ -33,6 +37,7 @@ import {errorOnReadOnly, getActiveEditor} from '../LexicalUpdates'; import { $getNodeByKey, $isRootOrShadowRoot, + isHTMLElement, removeFromParent, } from '../LexicalUtils'; @@ -523,6 +528,24 @@ export class ElementNode extends LexicalNode { return writableSelf; } + exportDOM(editor: LexicalEditor): DOMExportOutput { + const {element} = super.exportDOM(editor); + if (element && isHTMLElement(element)) { + const indent = this.getIndent(); + if (indent > 0) { + // padding-inline-start is not widely supported in email HTML + // (see https://www.caniemail.com/features/css-padding-inline-start-end/), + // If you want to use HTML output for email, consider overriding the serialization + // to use `padding-right` in RTL languages, `padding-left` in `LTR` languages, or + // `text-indent` if you are ok with first-line indents. + // We recommend keeping multiples of 40px to maintain consistency with list-items + // (see https://github.com/facebook/lexical/pull/4025) + element.style.paddingInlineStart = `${indent * 40}px`; + } + } + + return {element}; + } // JSON serialization exportJSON(): SerializedElementNode { return { diff --git a/packages/lexical/src/nodes/LexicalParagraphNode.ts b/packages/lexical/src/nodes/LexicalParagraphNode.ts index ebbf9814581..c1250aeae16 100644 --- a/packages/lexical/src/nodes/LexicalParagraphNode.ts +++ b/packages/lexical/src/nodes/LexicalParagraphNode.ts @@ -30,6 +30,7 @@ import { $applyNodeReplacement, getCachedClassNameArray, isHTMLElement, + setNodeIndentFromDOM, toggleTextFormatType, } from '../LexicalUtils'; import {ElementNode} from './LexicalElementNode'; @@ -151,12 +152,6 @@ export class ParagraphNode extends ElementNode { if (direction) { element.dir = direction; } - const indent = this.getIndent(); - if (indent > 0) { - // padding-inline-start is not widely supported in email HTML, but - // Lexical Reconciler uses padding-inline-start. Using text-indent instead. - element.style.textIndent = `${indent * 20}px`; - } } return { @@ -229,10 +224,7 @@ function $convertParagraphElement(element: HTMLElement): DOMConversionOutput { const node = $createParagraphNode(); if (element.style) { node.setFormat(element.style.textAlign as ElementFormatType); - const indent = parseInt(element.style.textIndent, 10) / 20; - if (indent > 0) { - node.setIndent(indent); - } + setNodeIndentFromDOM(element, node); } return {node}; }