diff --git a/packages/editor/src/core/serializer/compose-react-email.spec.tsx b/packages/editor/src/core/serializer/compose-react-email.spec.tsx index 93df6eeb96..87f8da5026 100644 --- a/packages/editor/src/core/serializer/compose-react-email.spec.tsx +++ b/packages/editor/src/core/serializer/compose-react-email.spec.tsx @@ -358,6 +358,64 @@ describe('StarterKit node wrappers', () => { }); }); +describe('Trailing empty paragraph stripping', () => { + it('strips trailing empty paragraph from container ending with button', async () => { + const content: JSONContent = { + type: 'doc', + content: [ + { + type: 'container', + content: [ + { + type: 'button', + attrs: { href: 'https://example.com' }, + content: [{ type: 'text', text: 'Click me' }], + }, + { type: 'paragraph' }, + ], + }, + ], + }; + + const editor = createEditorWithContent(content); + const result = await composeReactEmail({ editor, preview: '' }); + + const buttonIndex = result.html.indexOf('Click me'); + const lastParagraph = result.html.lastIndexOf(' { + const content: JSONContent = { + type: 'doc', + content: [ + { + type: 'container', + content: [ + { + type: 'button', + attrs: { href: 'https://example.com' }, + content: [{ type: 'text', text: 'Click me' }], + }, + { + type: 'paragraph', + content: [{ type: 'text', text: 'Footer text' }], + }, + ], + }, + ], + }; + + const editor = createEditorWithContent(content); + const result = await composeReactEmail({ editor, preview: '' }); + + expect(result.html).toContain('Click me'); + expect(result.html).toContain('Footer text'); + }); +}); + describe('Button and image reset styles', () => { it('should include display:inline-block on buttons with the basic theme', async () => { const content = docWithGlobalContent( diff --git a/packages/editor/src/core/serializer/compose-react-email.tsx b/packages/editor/src/core/serializer/compose-react-email.tsx index e9dbd447fa..4e172a37e1 100644 --- a/packages/editor/src/core/serializer/compose-react-email.tsx +++ b/packages/editor/src/core/serializer/compose-react-email.tsx @@ -2,6 +2,7 @@ import { pretty, render, toPlainText } from '@react-email/components'; import type { Editor, JSONContent } from '@tiptap/core'; import type { MarkType, Schema } from '@tiptap/pm/model'; import { inlineCssToJs } from '../../utils/styles'; +import { stripTrailingEmptyParagraphs } from '../../utils/strip-trailing-empty-paragraphs'; import { DefaultBaseTemplate } from './default-base-template'; import { EmailMark } from './email-mark'; import { EmailNode } from './email-node'; @@ -45,7 +46,7 @@ export const composeReactEmail = async ({ editor: Editor; preview?: string; }): Promise => { - const data = editor.getJSON(); + const data = stripTrailingEmptyParagraphs(editor.getJSON()); const extensions = editor.extensionManager.extensions; const serializerPlugin = extensions diff --git a/packages/editor/src/utils/strip-trailing-empty-paragraphs.spec.ts b/packages/editor/src/utils/strip-trailing-empty-paragraphs.spec.ts new file mode 100644 index 0000000000..eca26f2a16 --- /dev/null +++ b/packages/editor/src/utils/strip-trailing-empty-paragraphs.spec.ts @@ -0,0 +1,180 @@ +import type { JSONContent } from '@tiptap/core'; +import { describe, expect, it } from 'vitest'; +import { stripTrailingEmptyParagraphs } from './strip-trailing-empty-paragraphs'; + +describe('stripTrailingEmptyParagraphs', () => { + it('removes trailing empty paragraph from container', () => { + const input: JSONContent = { + type: 'doc', + content: [ + { + type: 'container', + content: [ + { + type: 'button', + attrs: { href: 'https://example.com' }, + content: [{ type: 'text', text: 'Click' }], + }, + { type: 'paragraph' }, + ], + }, + ], + }; + + const result = stripTrailingEmptyParagraphs(input); + const container = result.content![0]!; + expect(container.content).toHaveLength(1); + expect(container.content![0]!.type).toBe('button'); + }); + + it('removes trailing empty paragraph with empty content array', () => { + const input: JSONContent = { + type: 'doc', + content: [ + { + type: 'container', + content: [ + { + type: 'heading', + content: [{ type: 'text', text: 'Title' }], + }, + { type: 'paragraph', content: [] }, + ], + }, + ], + }; + + const result = stripTrailingEmptyParagraphs(input); + const container = result.content![0]!; + expect(container.content).toHaveLength(1); + expect(container.content![0]!.type).toBe('heading'); + }); + + it('preserves trailing paragraph with content', () => { + const input: JSONContent = { + type: 'doc', + content: [ + { + type: 'container', + content: [ + { + type: 'button', + attrs: { href: 'https://example.com' }, + content: [{ type: 'text', text: 'Click' }], + }, + { + type: 'paragraph', + content: [{ type: 'text', text: 'Some text' }], + }, + ], + }, + ], + }; + + const result = stripTrailingEmptyParagraphs(input); + const container = result.content![0]!; + expect(container.content).toHaveLength(2); + expect(container.content![1]!.type).toBe('paragraph'); + }); + + it('keeps single empty paragraph in container to avoid empty content', () => { + const input: JSONContent = { + type: 'doc', + content: [ + { + type: 'container', + content: [{ type: 'paragraph' }], + }, + ], + }; + + const result = stripTrailingEmptyParagraphs(input); + const container = result.content![0]!; + expect(container.content).toHaveLength(1); + expect(container.content![0]!.type).toBe('paragraph'); + }); + + it('does not strip trailing paragraph from non-container nodes', () => { + const input: JSONContent = { + type: 'doc', + content: [ + { + type: 'section', + content: [ + { + type: 'button', + attrs: { href: 'https://example.com' }, + content: [{ type: 'text', text: 'Click' }], + }, + { type: 'paragraph' }, + ], + }, + ], + }; + + const result = stripTrailingEmptyParagraphs(input); + const section = result.content![0]!; + expect(section.content).toHaveLength(2); + }); + + it('handles nested containers', () => { + const input: JSONContent = { + type: 'doc', + content: [ + { + type: 'container', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Hello' }], + }, + { type: 'paragraph' }, + ], + }, + ], + }; + + const result = stripTrailingEmptyParagraphs(input); + const container = result.content![0]!; + expect(container.content).toHaveLength(1); + expect(container.content![0]!.type).toBe('paragraph'); + expect(container.content![0]!.content![0]!.text).toBe('Hello'); + }); + + it('handles doc with no content', () => { + const input: JSONContent = { type: 'doc' }; + const result = stripTrailingEmptyParagraphs(input); + expect(result).toEqual({ type: 'doc' }); + }); + + it('handles doc with empty content array', () => { + const input: JSONContent = { type: 'doc', content: [] }; + const result = stripTrailingEmptyParagraphs(input); + expect(result).toEqual({ type: 'doc', content: [] }); + }); + + it('does not strip non-trailing empty paragraphs from container', () => { + const input: JSONContent = { + type: 'doc', + content: [ + { + type: 'container', + content: [ + { type: 'paragraph' }, + { + type: 'button', + attrs: { href: 'https://example.com' }, + content: [{ type: 'text', text: 'Click' }], + }, + ], + }, + ], + }; + + const result = stripTrailingEmptyParagraphs(input); + const container = result.content![0]!; + expect(container.content).toHaveLength(2); + expect(container.content![0]!.type).toBe('paragraph'); + expect(container.content![1]!.type).toBe('button'); + }); +}); diff --git a/packages/editor/src/utils/strip-trailing-empty-paragraphs.ts b/packages/editor/src/utils/strip-trailing-empty-paragraphs.ts new file mode 100644 index 0000000000..c0da1d2eb1 --- /dev/null +++ b/packages/editor/src/utils/strip-trailing-empty-paragraphs.ts @@ -0,0 +1,32 @@ +import type { JSONContent } from '@tiptap/core'; + +const isEmptyParagraph = (node: JSONContent): boolean => + node.type === 'paragraph' && (!node.content || node.content.length === 0); + +export const stripTrailingEmptyParagraphs = ( + content: JSONContent, +): JSONContent => { + if (!content.content || content.content.length === 0) { + return content; + } + + const processedChildren = content.content.map((child) => + stripTrailingEmptyParagraphs(child), + ); + + if (content.type !== 'container') { + return { ...content, content: processedChildren }; + } + + const lastChild = processedChildren[processedChildren.length - 1]; + if (!lastChild || !isEmptyParagraph(lastChild)) { + return { ...content, content: processedChildren }; + } + + const trimmed = processedChildren.slice(0, -1); + + return { + ...content, + content: trimmed.length > 0 ? trimmed : processedChildren, + }; +};