From 8e99f774e1fb3bfbcd7b46be773847b6c53066cc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 26 Mar 2026 12:54:06 +0000 Subject: [PATCH 1/2] refactor(email-theming): remove classReference, use sectionId as CssJs key, rename normalization to backwards-compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove classReference from PanelGroup and PanelInputProperty types - Use group id (PanelSectionId) as CssJs key instead of per-input classReference - Merge typography section into body (fontSize) and container (lineHeight) - Rename section IDs to camelCase: code-block → codeBlock, inline-code → inlineCode - Remove typography from PanelSectionId - Rename normalization module to backwards-compatibility - normalizeThemePanelStyles → applyPanelStylesBackwardsCompat - normalizePanelInputs → backfillPanelInputs - Add PersistedPanelGroup type for backward-compatible legacy data handling - Strip classReference from inputs during normalization - Filter out unresolvable groups instead of passing them through - Update all tests to reflect new structure Syncs changes from resend/resend PR #9330 (PRs #3113 and #3114 equivalent). Co-authored-by: Gabriel Miranda --- .../backwards-compatibility.spec.ts | 95 +++++++++ .../email-theming/backwards-compatibility.ts | 185 ++++++++++++++++++ .../email-theming/css-transforms.spec.ts | 109 +++++++---- .../plugins/email-theming/css-transforms.ts | 21 +- .../src/plugins/email-theming/extension.tsx | 27 +-- .../email-theming/normalization.spec.ts | 66 ------- .../plugins/email-theming/normalization.ts | 164 ---------------- .../src/plugins/email-theming/themes.ts | 80 ++------ .../editor/src/plugins/email-theming/types.ts | 7 +- packages/editor/src/utils/types.ts | 2 - 10 files changed, 398 insertions(+), 358 deletions(-) create mode 100644 packages/editor/src/plugins/email-theming/backwards-compatibility.spec.ts create mode 100644 packages/editor/src/plugins/email-theming/backwards-compatibility.ts delete mode 100644 packages/editor/src/plugins/email-theming/normalization.spec.ts delete mode 100644 packages/editor/src/plugins/email-theming/normalization.ts diff --git a/packages/editor/src/plugins/email-theming/backwards-compatibility.spec.ts b/packages/editor/src/plugins/email-theming/backwards-compatibility.spec.ts new file mode 100644 index 0000000000..66ccf15f52 --- /dev/null +++ b/packages/editor/src/plugins/email-theming/backwards-compatibility.spec.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import { + type PersistedPanelGroup, + applyPanelStylesBackwardsCompat, + inferThemeFromPanelStyles, +} from './backwards-compatibility'; + +describe('applyPanelStylesBackwardsCompat', () => { + it('maps legacy global panel groups to the current section ids', () => { + const legacy: PersistedPanelGroup[] = [ + { title: 'Body', classReference: 'body', inputs: [] }, + { title: 'Container', classReference: 'container', inputs: [] }, + { title: 'Typography', classReference: 'body', inputs: [] }, + { title: 'Code Block', classReference: 'codeBlock', inputs: [] }, + ]; + + const result = applyPanelStylesBackwardsCompat('minimal', legacy); + + expect(result).toEqual([ + expect.objectContaining({ + id: 'body', + title: 'Background', + }), + expect.objectContaining({ + id: 'container', + title: 'Body', + }), + // Legacy 'typography' with classReference 'body' maps to 'body' + expect.objectContaining({ + id: 'body', + title: 'Background', + }), + expect.objectContaining({ + id: 'codeBlock', + title: 'Code Block', + }), + ]); + + // classReference should be stripped from all groups + for (const group of result!) { + expect(group).not.toHaveProperty('classReference'); + } + }); + + it('maps legacy kebab-case section ids to camelCase', () => { + const legacy: PersistedPanelGroup[] = [ + { id: 'code-block', title: 'Code Block', inputs: [] }, + { id: 'inline-code', title: 'Inline Code', inputs: [] }, + ]; + + const result = applyPanelStylesBackwardsCompat('minimal', legacy); + + expect(result).toEqual([ + expect.objectContaining({ id: 'codeBlock' }), + expect.objectContaining({ id: 'inlineCode' }), + ]); + }); + + it('strips classReference from inputs during normalization', () => { + const legacy: PersistedPanelGroup[] = [ + { + id: 'button', + title: 'Button', + inputs: [ + { + label: 'Background', + type: 'color', + value: '#000000', + prop: 'backgroundColor', + classReference: 'button', + }, + ], + }, + ]; + + const result = applyPanelStylesBackwardsCompat('basic', legacy); + + expect(result![0].inputs[0]).not.toHaveProperty('classReference'); + expect(result![0].inputs[0]).toMatchObject({ + prop: 'backgroundColor', + value: '#000000', + }); + }); +}); + +describe('inferThemeFromPanelStyles', () => { + it('infers the minimal theme from legacy empty panel groups', () => { + expect( + inferThemeFromPanelStyles([ + { title: 'Body', inputs: [] }, + { title: 'Container', inputs: [] }, + ]), + ).toBe('minimal'); + }); +}); diff --git a/packages/editor/src/plugins/email-theming/backwards-compatibility.ts b/packages/editor/src/plugins/email-theming/backwards-compatibility.ts new file mode 100644 index 0000000000..8abcc3428b --- /dev/null +++ b/packages/editor/src/plugins/email-theming/backwards-compatibility.ts @@ -0,0 +1,185 @@ +import { EDITOR_THEMES } from './themes'; +import type { + EditorTheme, + KnownThemeComponents, + PanelGroup, + PanelSectionId, +} from './types'; + +/** + * Represents the shape of a PanelGroup as it may exist in persisted data. + * Legacy data can include `classReference` and old-style section IDs + * (e.g. `'code-block'`, `'typography'`) that no longer exist in the + * current `PanelSectionId` type. + */ +export interface PersistedPanelGroup { + id?: string; + title: string; + classReference?: KnownThemeComponents; + inputs: Array< + PanelGroup['inputs'][number] & { classReference?: KnownThemeComponents } + >; +} + +const PANEL_SECTION_IDS = new Set([ + 'body', + 'container', + 'link', + 'image', + 'button', + 'codeBlock', + 'inlineCode', +]); + +const PANEL_SECTION_IDS_BY_TITLE: Record = { + background: 'body', + body: 'body', + content: 'container', + container: 'container', + link: 'link', + image: 'image', + button: 'button', + 'code block': 'codeBlock', + 'inline code': 'inlineCode', +}; + +/** + * Maps legacy section IDs and classReference values to current PanelSectionId. + * Handles backward compatibility for persisted data. + */ +const LEGACY_ID_MAP: Record = { + 'code-block': 'codeBlock', + 'inline-code': 'inlineCode', +}; + +/** + * Maps legacy classReference values to PanelSectionId for data + * that predates the id field. + */ +const LEGACY_CLASS_REFERENCE_MAP: Record = { + body: 'body', + container: 'container', + link: 'link', + image: 'image', + button: 'button', + codeBlock: 'codeBlock', + inlineCode: 'inlineCode', +}; + +function isPanelSectionId(value: unknown): value is PanelSectionId { + return ( + typeof value === 'string' && PANEL_SECTION_IDS.has(value as PanelSectionId) + ); +} + +function normalizeTitle(title: string | undefined): string { + return title?.trim().toLowerCase().replace(/\s+/g, ' ') ?? ''; +} + +function resolvePanelSectionId( + group: PersistedPanelGroup, +): PanelSectionId | null { + if (isPanelSectionId(group.id)) { + return group.id; + } + + // Handle legacy IDs like 'code-block' → 'codeBlock' + if (group.id && LEGACY_ID_MAP[group.id]) { + return LEGACY_ID_MAP[group.id]!; + } + + // Handle legacy classReference-based resolution + if (group.classReference && LEGACY_CLASS_REFERENCE_MAP[group.classReference]) { + const normalizedTitle = normalizeTitle(group.title); + // Special case: legacy 'typography' section with classReference 'body' + // should be merged into 'body' (its inputs have been redistributed) + if (group.classReference === 'body' && normalizedTitle === 'typography') { + return 'body'; + } + return LEGACY_CLASS_REFERENCE_MAP[group.classReference]!; + } + + return PANEL_SECTION_IDS_BY_TITLE[normalizeTitle(group.title)] ?? null; +} + +function backfillPanelInputs( + inputs: PersistedPanelGroup['inputs'], + defaultInputs: PanelGroup['inputs'], +): PanelGroup['inputs'] { + if (!Array.isArray(inputs)) { + return []; + } + + return inputs.map((input) => { + const defaultInput = defaultInputs.find( + (candidate) => candidate.prop === input.prop, + ); + + const { classReference: _, ...inputWithoutClassRef } = input; + + return { + ...defaultInput, + ...inputWithoutClassRef, + }; + }); +} + +export function inferThemeFromPanelStyles( + panelStyles: PersistedPanelGroup[] | null | undefined, +): EditorTheme | null { + if (!Array.isArray(panelStyles) || panelStyles.length === 0) { + return null; + } + + let finalTheme: EditorTheme | null = null; + for (const group of panelStyles) { + if (!Array.isArray(group?.inputs)) { + finalTheme = null; + break; + } + + if (group.inputs.length !== 0) { + finalTheme = 'basic'; + break; + } + + finalTheme = 'minimal'; + } + + return finalTheme; +} + +export function applyPanelStylesBackwardsCompat( + theme: EditorTheme, + panelStyles: PersistedPanelGroup[] | null | undefined, +): PanelGroup[] | null { + if (!Array.isArray(panelStyles)) { + return null; + } + + return panelStyles + .map((group) => { + const panelId = resolvePanelSectionId(group); + + if (!panelId) { + return null; + } + + const defaultGroup = EDITOR_THEMES[theme].find( + (candidate) => candidate.id === panelId, + ); + + if (!defaultGroup) { + return null; + } + + const normalized: PanelGroup = { + id: panelId, + title: defaultGroup.title, + inputs: backfillPanelInputs(group.inputs, defaultGroup.inputs), + }; + + return normalized; + }) + .filter((group): group is PanelGroup => group !== null); +} diff --git a/packages/editor/src/plugins/email-theming/css-transforms.spec.ts b/packages/editor/src/plugins/email-theming/css-transforms.spec.ts index 6d24a13053..c43e241dbb 100644 --- a/packages/editor/src/plugins/email-theming/css-transforms.spec.ts +++ b/packages/editor/src/plugins/email-theming/css-transforms.spec.ts @@ -27,21 +27,26 @@ it('returns empty object for empty array', () => { it('transforms basic styles without units', () => { const styles: PanelGroup[] = [ { - title: 'Basic', + id: 'body', + title: 'Body', inputs: [ { label: 'Color', type: 'color', value: '#FF0000', prop: 'color', - classReference: 'body', }, + ], + }, + { + id: 'container', + title: 'Container', + inputs: [ { label: 'Background', type: 'color', value: '#FFFFFF', prop: 'backgroundColor', - classReference: 'container', }, ], }, @@ -57,6 +62,7 @@ it('transforms basic styles without units', () => { it('appends units to numeric values', () => { const styles: PanelGroup[] = [ { + id: 'container', title: 'With Units', inputs: [ { @@ -65,7 +71,6 @@ it('appends units to numeric values', () => { value: 100, unit: 'px', prop: 'width', - classReference: 'container', }, { label: 'Height', @@ -73,7 +78,6 @@ it('appends units to numeric values', () => { value: 200, unit: 'px', prop: 'height', - classReference: 'container', }, ], }, @@ -88,6 +92,7 @@ it('appends units to numeric values', () => { it('converts fontSize from px to em for mobile adjustment', () => { const styles: PanelGroup[] = [ { + id: 'body', title: 'Font Size', inputs: [ { @@ -96,7 +101,6 @@ it('converts fontSize from px to em for mobile adjustment', () => { value: 26, unit: 'px', prop: 'fontSize', - classReference: 'body', }, ], }, @@ -111,6 +115,7 @@ it('converts fontSize from px to em for mobile adjustment', () => { it('includes container background when body is not white', () => { const styles: PanelGroup[] = [ { + id: 'body', title: 'Body', inputs: [ { @@ -118,11 +123,11 @@ it('includes container background when body is not white', () => { type: 'color', value: '#F0F0F0', prop: 'backgroundColor', - classReference: 'body', }, ], }, { + id: 'container', title: 'Container', inputs: [ { @@ -130,7 +135,6 @@ it('includes container background when body is not white', () => { type: 'color', value: '#FFFFFF', prop: 'backgroundColor', - classReference: 'container', }, ], }, @@ -146,6 +150,7 @@ it('includes container background when body is not white', () => { it('includes non-black text colors for body', () => { const styles: PanelGroup[] = [ { + id: 'body', title: 'Body', inputs: [ { @@ -153,7 +158,6 @@ it('includes non-black text colors for body', () => { type: 'color', value: '#333333', prop: 'color', - classReference: 'body', }, ], }, @@ -168,6 +172,7 @@ it('includes non-black text colors for body', () => { it('handles mixed input types and properties', () => { const styles: PanelGroup[] = [ { + id: 'container', title: 'Mixed Properties', inputs: [ { @@ -176,21 +181,18 @@ it('handles mixed input types and properties', () => { value: 600, unit: 'px', prop: 'width', - classReference: 'container', }, { label: 'Align', type: 'select', value: 'center', prop: 'align', - classReference: 'container', }, { label: 'Background', type: 'color', value: '#F5F5F5', prop: 'backgroundColor', - classReference: 'container', }, ], }, @@ -206,31 +208,41 @@ it('handles mixed input types and properties', () => { }); }); -it('handles multiple class references', () => { +it('handles multiple sections with different ids', () => { const styles: PanelGroup[] = [ { - title: 'Multiple Classes', + id: 'body', + title: 'Body', inputs: [ { label: 'Body Color', type: 'color', value: '#333333', prop: 'color', - classReference: 'body', }, + ], + }, + { + id: 'link', + title: 'Link', + inputs: [ { label: 'Link Color', type: 'color', value: '#0066CC', prop: 'color', - classReference: 'link', }, + ], + }, + { + id: 'button', + title: 'Button', + inputs: [ { label: 'Button Background', type: 'color', value: '#000000', prop: 'backgroundColor', - classReference: 'button', }, ], }, @@ -247,6 +259,7 @@ it('handles multiple class references', () => { it('handles string values with units', () => { const styles: PanelGroup[] = [ { + id: 'button', title: 'String Values', inputs: [ { @@ -255,7 +268,6 @@ it('handles string values with units', () => { value: 'auto', unit: 'px', prop: 'width', - classReference: 'button', }, ], }, @@ -270,21 +282,39 @@ it('handles string values with units', () => { it('handles complex nested structure', () => { const styles: PanelGroup[] = [ { - title: 'Complex Theme', + id: 'body', + title: 'Body', inputs: [ { label: 'Body Background', type: 'color', value: '#FFFFFF', prop: 'backgroundColor', - classReference: 'body', }, + { + label: 'Body Font Size', + type: 'number', + value: 16, + unit: 'px', + prop: 'fontSize', + }, + { + label: 'Body Color', + type: 'color', + value: '#000000', + prop: 'color', + }, + ], + }, + { + id: 'container', + title: 'Container', + inputs: [ { label: 'Container Background', type: 'color', value: '#FFFFFF', prop: 'backgroundColor', - classReference: 'container', }, { label: 'Container Width', @@ -292,7 +322,6 @@ it('handles complex nested structure', () => { value: 600, unit: 'px', prop: 'width', - classReference: 'container', }, { label: 'Padding Left', @@ -300,7 +329,6 @@ it('handles complex nested structure', () => { value: 20, unit: 'px', prop: 'paddingLeft', - classReference: 'container', }, { label: 'Padding Right', @@ -308,22 +336,6 @@ it('handles complex nested structure', () => { value: 20, unit: 'px', prop: 'paddingRight', - classReference: 'container', - }, - { - label: 'Body Font Size', - type: 'number', - value: 16, - unit: 'px', - prop: 'fontSize', - classReference: 'body', - }, - { - label: 'Body Color', - type: 'color', - value: '#000000', - prop: 'color', - classReference: 'body', }, ], }, @@ -344,3 +356,22 @@ it('handles complex nested structure', () => { }, }); }); + +it('skips groups without an id', () => { + const styles: PanelGroup[] = [ + { + title: 'No ID', + inputs: [ + { + label: 'Color', + type: 'color', + value: '#FF0000', + prop: 'color', + }, + ], + }, + ]; + + const result = transformToCssJs(styles); + expect(result).toEqual({}); +}); diff --git a/packages/editor/src/plugins/email-theming/css-transforms.ts b/packages/editor/src/plugins/email-theming/css-transforms.ts index d708c54bd1..b60f31982c 100644 --- a/packages/editor/src/plugins/email-theming/css-transforms.ts +++ b/packages/editor/src/plugins/email-theming/css-transforms.ts @@ -1,5 +1,5 @@ import { ensureBorderStyleFallback } from '../../utils/styles'; -import type { CssJs, PanelGroup } from './types'; +import type { CssJs, KnownThemeComponents, PanelGroup } from './types'; export function transformToCssJs(styleArray: PanelGroup[]): CssJs { const cssJS = {} as CssJs; @@ -9,6 +9,11 @@ export function transformToCssJs(styleArray: PanelGroup[]): CssJs { } for (const style of styleArray) { + const key = style.id as KnownThemeComponents | undefined; + if (!key) { + continue; + } + for (const input of style.inputs) { let value = input.value; @@ -22,24 +27,20 @@ export function transformToCssJs(styleArray: PanelGroup[]): CssJs { } } - if (!input.classReference) { - continue; - } - - if (!cssJS[input.classReference]) { - cssJS[input.classReference] = {}; + if (!cssJS[key]) { + cssJS[key] = {}; } // @ts-expect-error -- backward compatibility: 'h-padding' is a legacy prop not in KnownCssProperties if (input.prop === 'h-padding') { - cssJS[input.classReference].paddingLeft = value; - cssJS[input.classReference].paddingRight = value; + cssJS[key].paddingLeft = value; + cssJS[key].paddingRight = value; continue; } // @ts-expect-error -- input.prop is KnownCssProperties but CssJs values are React.CSSProperties; dynamic assignment is intentional - cssJS[input.classReference][input.prop] = value; + cssJS[key][input.prop] = value; } } diff --git a/packages/editor/src/plugins/email-theming/extension.tsx b/packages/editor/src/plugins/email-theming/extension.tsx index 0b43a34f36..d1610a5bd9 100644 --- a/packages/editor/src/plugins/email-theming/extension.tsx +++ b/packages/editor/src/plugins/email-theming/extension.tsx @@ -6,16 +6,17 @@ import { useEditorState } from '@tiptap/react'; import type * as React from 'react'; import type { SerializerPlugin } from '../../core/serializer/serializer-plugin'; import { getGlobalContent } from '../../extensions/global-content'; +import { + applyPanelStylesBackwardsCompat, + inferThemeFromPanelStyles, +} from './backwards-compatibility'; +import type { PersistedPanelGroup } from './backwards-compatibility'; import { injectGlobalPlainCss, injectThemeCss, mergeCssJs, transformToCssJs, } from './css-transforms'; -import { - inferThemeFromPanelStyles, - normalizeThemePanelStyles, -} from './normalization'; import { EDITOR_THEMES, RESET_THEMES } from './themes'; import type { CssJs, @@ -83,10 +84,10 @@ export function getThemeComponentKey( */ export function getMergedCssJs( theme: EditorTheme, - panelStyles: PanelGroup[] | undefined, + panelStyles: PersistedPanelGroup[] | PanelGroup[] | undefined, ): CssJs { const panels: PanelGroup[] = - normalizeThemePanelStyles(theme, panelStyles) ?? EDITOR_THEMES[theme]; + applyPanelStylesBackwardsCompat(theme, panelStyles) ?? EDITOR_THEMES[theme]; const parsed = transformToCssJs(panels); return mergeCssJs(RESET_THEMES[theme], parsed); } @@ -145,19 +146,19 @@ export function stylesToCss( theme: EditorTheme, ): Record { const parsed = transformToCssJs( - normalizeThemePanelStyles(theme, styles) ?? EDITOR_THEMES[theme], + applyPanelStylesBackwardsCompat(theme, styles) ?? EDITOR_THEMES[theme], ); return mergeCssJs(RESET_THEMES[theme], parsed); } function getEmailTheming(editor: Editor) { const theme = getEmailTheme(editor); - const normalizedStyles = - normalizeThemePanelStyles(theme, getEmailStyles(editor)) ?? + const compatStyles = + applyPanelStylesBackwardsCompat(theme, getEmailStyles(editor)) ?? EDITOR_THEMES[theme]; return { - styles: normalizedStyles, + styles: compatStyles, theme, css: getEmailCss(editor), }; @@ -176,7 +177,7 @@ export function useEmailTheming(editor: Editor | null) { } function getEmailStyles(editor: Editor) { - return getGlobalContent('styles', editor) as PanelGroup[] | null; + return getGlobalContent('styles', editor) as PersistedPanelGroup[] | null; } function getEmailTheme(editor: Editor) { @@ -292,7 +293,7 @@ export const EmailTheming = Extension.create<{ new Plugin({ key: new PluginKey('themingStyleInjector'), view(view) { - let prevStyles: PanelGroup[] | null = null; + let prevStyles: PersistedPanelGroup[] | null = null; let prevTheme: EditorTheme | null = null; let prevCss: string | null = null; @@ -305,7 +306,7 @@ export const EmailTheming = Extension.create<{ const css = getEmailCss(editor); if (styles !== prevStyles || theme !== prevTheme) { - prevStyles = styles as PanelGroup[] | null; + prevStyles = styles; prevTheme = theme; injectThemeCss(getMergedCssJs(theme, resolvedStyles), { scopeSelector, diff --git a/packages/editor/src/plugins/email-theming/normalization.spec.ts b/packages/editor/src/plugins/email-theming/normalization.spec.ts deleted file mode 100644 index 0e62454fcc..0000000000 --- a/packages/editor/src/plugins/email-theming/normalization.spec.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - inferThemeFromPanelStyles, - normalizeThemePanelStyles, -} from './normalization'; - -describe('normalizeThemePanelStyles', () => { - it('maps legacy global panel groups to the current section ids', () => { - const result = normalizeThemePanelStyles('minimal', [ - { - title: 'Body', - classReference: 'body', - inputs: [], - }, - { - title: 'Container', - classReference: 'container', - inputs: [], - }, - { - title: 'Typography', - classReference: 'body', - inputs: [], - }, - { - title: 'Code Block', - classReference: 'codeBlock', - inputs: [], - }, - ]); - - expect(result).toEqual([ - expect.objectContaining({ - id: 'body', - title: 'Background', - classReference: 'body', - }), - expect.objectContaining({ - id: 'container', - title: 'Content', - classReference: 'container', - }), - expect.objectContaining({ - id: 'typography', - title: 'Typography', - classReference: 'body', - }), - expect.objectContaining({ - id: 'code-block', - title: 'Code Block', - classReference: 'codeBlock', - }), - ]); - }); -}); - -describe('inferThemeFromPanelStyles', () => { - it('infers the minimal theme from legacy empty panel groups', () => { - expect( - inferThemeFromPanelStyles([ - { title: 'Body', classReference: 'body', inputs: [] }, - { title: 'Container', classReference: 'container', inputs: [] }, - ]), - ).toBe('minimal'); - }); -}); diff --git a/packages/editor/src/plugins/email-theming/normalization.ts b/packages/editor/src/plugins/email-theming/normalization.ts deleted file mode 100644 index 0d564b7c0b..0000000000 --- a/packages/editor/src/plugins/email-theming/normalization.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { EDITOR_THEMES } from './themes'; -import type { - EditorTheme, - KnownThemeComponents, - PanelGroup, - PanelSectionId, -} from './types'; - -const PANEL_SECTION_IDS = new Set([ - 'body', - 'container', - 'typography', - 'link', - 'image', - 'button', - 'code-block', - 'inline-code', -]); - -const PANEL_SECTION_IDS_BY_TITLE: Record = { - background: 'body', - body: 'body', - content: 'container', - container: 'container', - typography: 'typography', - link: 'link', - image: 'image', - button: 'button', - 'code block': 'code-block', - 'inline code': 'inline-code', -}; - -const PANEL_SECTION_IDS_BY_CLASS_REFERENCE: Partial< - Record -> = { - container: 'container', - link: 'link', - image: 'image', - button: 'button', - codeBlock: 'code-block', - inlineCode: 'inline-code', -}; - -function isPanelSectionId(value: unknown): value is PanelSectionId { - return ( - typeof value === 'string' && PANEL_SECTION_IDS.has(value as PanelSectionId) - ); -} - -function normalizeTitle(title: string | undefined): string { - return title?.trim().toLowerCase().replace(/\s+/g, ' ') ?? ''; -} - -function resolvePanelSectionId(group: PanelGroup): PanelSectionId | null { - if (isPanelSectionId(group.id)) { - return group.id; - } - - const normalizedTitle = normalizeTitle(group.title); - - if (group.classReference === 'body') { - if (normalizedTitle === 'typography') { - return 'typography'; - } - - return 'body'; - } - - if ( - group.classReference && - PANEL_SECTION_IDS_BY_CLASS_REFERENCE[group.classReference] - ) { - return PANEL_SECTION_IDS_BY_CLASS_REFERENCE[group.classReference] ?? null; - } - - return PANEL_SECTION_IDS_BY_TITLE[normalizedTitle] ?? null; -} - -function normalizePanelInputs( - inputs: PanelGroup['inputs'], - defaultInputs: PanelGroup['inputs'], - fallbackClassReference?: KnownThemeComponents, -): PanelGroup['inputs'] { - if (!Array.isArray(inputs)) { - return []; - } - - return inputs.map((input) => { - const defaultInput = defaultInputs.find( - (candidate) => candidate.prop === input.prop, - ); - - return { - ...defaultInput, - ...input, - classReference: - input.classReference ?? - defaultInput?.classReference ?? - fallbackClassReference, - }; - }); -} - -export function inferThemeFromPanelStyles( - panelStyles: PanelGroup[] | null | undefined, -): EditorTheme | null { - if (!Array.isArray(panelStyles) || panelStyles.length === 0) { - return null; - } - - let finalTheme: EditorTheme | null = null; - for (const group of panelStyles) { - if (!Array.isArray(group?.inputs)) { - finalTheme = null; - break; - } - - if (group.inputs.length !== 0) { - finalTheme = 'basic'; - break; - } - - finalTheme = 'minimal'; - } - - return finalTheme; -} - -export function normalizeThemePanelStyles( - theme: EditorTheme, - panelStyles: PanelGroup[] | null | undefined, -): PanelGroup[] | null { - if (!Array.isArray(panelStyles)) { - return null; - } - - return panelStyles.map((group) => { - const panelId = resolvePanelSectionId(group); - - if (!panelId) { - return group; - } - - const defaultGroup = EDITOR_THEMES[theme].find( - (candidate) => candidate.id === panelId, - ); - - if (!defaultGroup) { - return group; - } - - return { - ...group, - id: panelId, - title: defaultGroup.title, - classReference: defaultGroup.classReference, - inputs: normalizePanelInputs( - group.inputs, - defaultGroup.inputs, - defaultGroup.classReference, - ), - }; - }); -} diff --git a/packages/editor/src/plugins/email-theming/themes.ts b/packages/editor/src/plugins/email-theming/themes.ts index 6655656077..48a7b7c804 100644 --- a/packages/editor/src/plugins/email-theming/themes.ts +++ b/packages/editor/src/plugins/email-theming/themes.ts @@ -14,12 +14,11 @@ import type { const PANEL_SECTION_TITLES: Record = { body: 'Background', container: 'Body', - typography: 'Typography', link: 'Link', image: 'Image', button: 'Button', - 'code-block': 'Code Block', - 'inline-code': 'Inline Code', + codeBlock: 'Code Block', + inlineCode: 'Inline Code', }; /** @@ -38,13 +37,19 @@ const THEME_BASIC: PanelGroup[] = [ { id: 'body', title: 'Background', - classReference: 'body', - inputs: [], + inputs: [ + { + label: 'Font size', + type: 'number', + value: 14, + unit: 'px', + prop: 'fontSize', + }, + ], }, { id: 'container', - title: 'Content', - classReference: 'container', + title: 'Body', inputs: [ { label: 'Align', @@ -56,7 +61,6 @@ const THEME_BASIC: PanelGroup[] = [ right: 'Right', }, prop: 'align', - classReference: 'container', }, { label: 'Width', @@ -64,7 +68,13 @@ const THEME_BASIC: PanelGroup[] = [ value: 600, unit: 'px', prop: 'width', - classReference: 'container', + }, + { + label: 'Line Height', + type: 'number', + value: 155, + unit: '%', + prop: 'lineHeight', }, { label: 'Padding Left', @@ -72,7 +82,6 @@ const THEME_BASIC: PanelGroup[] = [ value: 0, unit: 'px', prop: 'paddingLeft', - classReference: 'container', }, { label: 'Padding Right', @@ -80,44 +89,18 @@ const THEME_BASIC: PanelGroup[] = [ value: 0, unit: 'px', prop: 'paddingRight', - classReference: 'container', - }, - ], - }, - { - id: 'typography', - title: 'Typography', - classReference: 'body', - inputs: [ - { - label: 'Font size', - type: 'number', - value: 14, - unit: 'px', - prop: 'fontSize', - classReference: 'body', - }, - { - label: 'Line Height', - type: 'number', - value: 155, - unit: '%', - prop: 'lineHeight', - classReference: 'container', }, ], }, { id: 'link', title: 'Link', - classReference: 'link', inputs: [ { label: 'Color', type: 'color', value: '#0670DB', prop: 'color', - classReference: 'link', }, { label: 'Decoration', @@ -128,14 +111,12 @@ const THEME_BASIC: PanelGroup[] = [ underline: 'Underline', none: 'None', }, - classReference: 'link', }, ], }, { id: 'image', title: 'Image', - classReference: 'image', inputs: [ { label: 'Border radius', @@ -143,28 +124,24 @@ const THEME_BASIC: PanelGroup[] = [ value: 8, unit: 'px', prop: 'borderRadius', - classReference: 'image', }, ], }, { id: 'button', title: 'Button', - classReference: 'button', inputs: [ { label: 'Background', type: 'color', value: '#000000', prop: 'backgroundColor', - classReference: 'button', }, { label: 'Text color', type: 'color', value: '#ffffff', prop: 'color', - classReference: 'button', }, { label: 'Radius', @@ -172,7 +149,6 @@ const THEME_BASIC: PanelGroup[] = [ value: 4, unit: 'px', prop: 'borderRadius', - classReference: 'button', }, { label: 'Padding Top', @@ -180,7 +156,6 @@ const THEME_BASIC: PanelGroup[] = [ value: 7, unit: 'px', prop: 'paddingTop', - classReference: 'button', }, { label: 'Padding Right', @@ -188,7 +163,6 @@ const THEME_BASIC: PanelGroup[] = [ value: 12, unit: 'px', prop: 'paddingRight', - classReference: 'button', }, { label: 'Padding Bottom', @@ -196,7 +170,6 @@ const THEME_BASIC: PanelGroup[] = [ value: 7, unit: 'px', prop: 'paddingBottom', - classReference: 'button', }, { label: 'Padding Left', @@ -204,14 +177,12 @@ const THEME_BASIC: PanelGroup[] = [ value: 12, unit: 'px', prop: 'paddingLeft', - classReference: 'button', }, ], }, { - id: 'code-block', + id: 'codeBlock', title: 'Code Block', - classReference: 'codeBlock', inputs: [ { label: 'Border Radius', @@ -219,7 +190,6 @@ const THEME_BASIC: PanelGroup[] = [ value: 4, unit: 'px', prop: 'borderRadius', - classReference: 'codeBlock', }, { label: 'Padding Top', @@ -227,7 +197,6 @@ const THEME_BASIC: PanelGroup[] = [ value: 12, unit: 'px', prop: 'paddingTop', - classReference: 'codeBlock', }, { label: 'Padding Bottom', @@ -235,7 +204,6 @@ const THEME_BASIC: PanelGroup[] = [ value: 12, unit: 'px', prop: 'paddingBottom', - classReference: 'codeBlock', }, { label: 'Padding Left', @@ -243,7 +211,6 @@ const THEME_BASIC: PanelGroup[] = [ value: 16, unit: 'px', prop: 'paddingLeft', - classReference: 'codeBlock', }, { label: 'Padding Right', @@ -251,28 +218,24 @@ const THEME_BASIC: PanelGroup[] = [ value: 16, unit: 'px', prop: 'paddingRight', - classReference: 'codeBlock', }, ], }, { - id: 'inline-code', + id: 'inlineCode', title: 'Inline Code', - classReference: 'inlineCode', inputs: [ { label: 'Background', type: 'color', value: '#e5e7eb', prop: 'backgroundColor', - classReference: 'inlineCode', }, { label: 'Text color', type: 'color', value: '#1e293b', prop: 'color', - classReference: 'inlineCode', }, { label: 'Radius', @@ -280,7 +243,6 @@ const THEME_BASIC: PanelGroup[] = [ value: 4, unit: 'px', prop: 'borderRadius', - classReference: 'inlineCode', }, ], }, diff --git a/packages/editor/src/plugins/email-theming/types.ts b/packages/editor/src/plugins/email-theming/types.ts index 825ad1143c..d8408cd9e9 100644 --- a/packages/editor/src/plugins/email-theming/types.ts +++ b/packages/editor/src/plugins/email-theming/types.ts @@ -8,12 +8,11 @@ export type EditorTheme = 'basic' | 'minimal'; export type PanelSectionId = | 'body' | 'container' - | 'typography' | 'link' | 'image' | 'button' - | 'code-block' - | 'inline-code'; + | 'codeBlock' + | 'inlineCode'; export type KnownThemeComponents = | 'reset' | 'body' @@ -103,7 +102,6 @@ export interface PanelInputProperty { type: InputType; value: string | number; prop: KnownCssProperties; - classReference?: KnownThemeComponents; unit?: InputUnit; options?: Options; placeholder?: string; @@ -114,6 +112,5 @@ export interface PanelGroup { id?: PanelSectionId; title: string; headerSlot?: React.ReactNode; - classReference?: KnownThemeComponents; inputs: Omit[]; } diff --git a/packages/editor/src/utils/types.ts b/packages/editor/src/utils/types.ts index ec97ce9de6..97ec641efe 100644 --- a/packages/editor/src/utils/types.ts +++ b/packages/editor/src/utils/types.ts @@ -90,7 +90,6 @@ export interface PanelInputProperty { type: InputType; value: string | number; prop: KnownCssProperties; - classReference?: KnownThemeComponents; unit?: InputUnit; options?: Options; placeholder?: string; @@ -100,7 +99,6 @@ export interface PanelInputProperty { export interface PanelGroup { title: string; headerSlot?: React.ReactNode; - classReference?: KnownThemeComponents; inputs: Omit[]; } From cd4b1498d21c317b8e968746784cf9fe9593d2ee Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 26 Mar 2026 12:57:13 +0000 Subject: [PATCH 2/2] Revert "refactor(email-theming): remove classReference, use sectionId as CssJs key, rename normalization to backwards-compatibility" This reverts commit 8e99f774e1fb3bfbcd7b46be773847b6c53066cc. --- .../backwards-compatibility.spec.ts | 95 --------- .../email-theming/backwards-compatibility.ts | 185 ------------------ .../email-theming/css-transforms.spec.ts | 109 ++++------- .../plugins/email-theming/css-transforms.ts | 21 +- .../src/plugins/email-theming/extension.tsx | 27 ++- .../email-theming/normalization.spec.ts | 66 +++++++ .../plugins/email-theming/normalization.ts | 164 ++++++++++++++++ .../src/plugins/email-theming/themes.ts | 80 ++++++-- .../editor/src/plugins/email-theming/types.ts | 7 +- packages/editor/src/utils/types.ts | 2 + 10 files changed, 358 insertions(+), 398 deletions(-) delete mode 100644 packages/editor/src/plugins/email-theming/backwards-compatibility.spec.ts delete mode 100644 packages/editor/src/plugins/email-theming/backwards-compatibility.ts create mode 100644 packages/editor/src/plugins/email-theming/normalization.spec.ts create mode 100644 packages/editor/src/plugins/email-theming/normalization.ts diff --git a/packages/editor/src/plugins/email-theming/backwards-compatibility.spec.ts b/packages/editor/src/plugins/email-theming/backwards-compatibility.spec.ts deleted file mode 100644 index 66ccf15f52..0000000000 --- a/packages/editor/src/plugins/email-theming/backwards-compatibility.spec.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - type PersistedPanelGroup, - applyPanelStylesBackwardsCompat, - inferThemeFromPanelStyles, -} from './backwards-compatibility'; - -describe('applyPanelStylesBackwardsCompat', () => { - it('maps legacy global panel groups to the current section ids', () => { - const legacy: PersistedPanelGroup[] = [ - { title: 'Body', classReference: 'body', inputs: [] }, - { title: 'Container', classReference: 'container', inputs: [] }, - { title: 'Typography', classReference: 'body', inputs: [] }, - { title: 'Code Block', classReference: 'codeBlock', inputs: [] }, - ]; - - const result = applyPanelStylesBackwardsCompat('minimal', legacy); - - expect(result).toEqual([ - expect.objectContaining({ - id: 'body', - title: 'Background', - }), - expect.objectContaining({ - id: 'container', - title: 'Body', - }), - // Legacy 'typography' with classReference 'body' maps to 'body' - expect.objectContaining({ - id: 'body', - title: 'Background', - }), - expect.objectContaining({ - id: 'codeBlock', - title: 'Code Block', - }), - ]); - - // classReference should be stripped from all groups - for (const group of result!) { - expect(group).not.toHaveProperty('classReference'); - } - }); - - it('maps legacy kebab-case section ids to camelCase', () => { - const legacy: PersistedPanelGroup[] = [ - { id: 'code-block', title: 'Code Block', inputs: [] }, - { id: 'inline-code', title: 'Inline Code', inputs: [] }, - ]; - - const result = applyPanelStylesBackwardsCompat('minimal', legacy); - - expect(result).toEqual([ - expect.objectContaining({ id: 'codeBlock' }), - expect.objectContaining({ id: 'inlineCode' }), - ]); - }); - - it('strips classReference from inputs during normalization', () => { - const legacy: PersistedPanelGroup[] = [ - { - id: 'button', - title: 'Button', - inputs: [ - { - label: 'Background', - type: 'color', - value: '#000000', - prop: 'backgroundColor', - classReference: 'button', - }, - ], - }, - ]; - - const result = applyPanelStylesBackwardsCompat('basic', legacy); - - expect(result![0].inputs[0]).not.toHaveProperty('classReference'); - expect(result![0].inputs[0]).toMatchObject({ - prop: 'backgroundColor', - value: '#000000', - }); - }); -}); - -describe('inferThemeFromPanelStyles', () => { - it('infers the minimal theme from legacy empty panel groups', () => { - expect( - inferThemeFromPanelStyles([ - { title: 'Body', inputs: [] }, - { title: 'Container', inputs: [] }, - ]), - ).toBe('minimal'); - }); -}); diff --git a/packages/editor/src/plugins/email-theming/backwards-compatibility.ts b/packages/editor/src/plugins/email-theming/backwards-compatibility.ts deleted file mode 100644 index 8abcc3428b..0000000000 --- a/packages/editor/src/plugins/email-theming/backwards-compatibility.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { EDITOR_THEMES } from './themes'; -import type { - EditorTheme, - KnownThemeComponents, - PanelGroup, - PanelSectionId, -} from './types'; - -/** - * Represents the shape of a PanelGroup as it may exist in persisted data. - * Legacy data can include `classReference` and old-style section IDs - * (e.g. `'code-block'`, `'typography'`) that no longer exist in the - * current `PanelSectionId` type. - */ -export interface PersistedPanelGroup { - id?: string; - title: string; - classReference?: KnownThemeComponents; - inputs: Array< - PanelGroup['inputs'][number] & { classReference?: KnownThemeComponents } - >; -} - -const PANEL_SECTION_IDS = new Set([ - 'body', - 'container', - 'link', - 'image', - 'button', - 'codeBlock', - 'inlineCode', -]); - -const PANEL_SECTION_IDS_BY_TITLE: Record = { - background: 'body', - body: 'body', - content: 'container', - container: 'container', - link: 'link', - image: 'image', - button: 'button', - 'code block': 'codeBlock', - 'inline code': 'inlineCode', -}; - -/** - * Maps legacy section IDs and classReference values to current PanelSectionId. - * Handles backward compatibility for persisted data. - */ -const LEGACY_ID_MAP: Record = { - 'code-block': 'codeBlock', - 'inline-code': 'inlineCode', -}; - -/** - * Maps legacy classReference values to PanelSectionId for data - * that predates the id field. - */ -const LEGACY_CLASS_REFERENCE_MAP: Record = { - body: 'body', - container: 'container', - link: 'link', - image: 'image', - button: 'button', - codeBlock: 'codeBlock', - inlineCode: 'inlineCode', -}; - -function isPanelSectionId(value: unknown): value is PanelSectionId { - return ( - typeof value === 'string' && PANEL_SECTION_IDS.has(value as PanelSectionId) - ); -} - -function normalizeTitle(title: string | undefined): string { - return title?.trim().toLowerCase().replace(/\s+/g, ' ') ?? ''; -} - -function resolvePanelSectionId( - group: PersistedPanelGroup, -): PanelSectionId | null { - if (isPanelSectionId(group.id)) { - return group.id; - } - - // Handle legacy IDs like 'code-block' → 'codeBlock' - if (group.id && LEGACY_ID_MAP[group.id]) { - return LEGACY_ID_MAP[group.id]!; - } - - // Handle legacy classReference-based resolution - if (group.classReference && LEGACY_CLASS_REFERENCE_MAP[group.classReference]) { - const normalizedTitle = normalizeTitle(group.title); - // Special case: legacy 'typography' section with classReference 'body' - // should be merged into 'body' (its inputs have been redistributed) - if (group.classReference === 'body' && normalizedTitle === 'typography') { - return 'body'; - } - return LEGACY_CLASS_REFERENCE_MAP[group.classReference]!; - } - - return PANEL_SECTION_IDS_BY_TITLE[normalizeTitle(group.title)] ?? null; -} - -function backfillPanelInputs( - inputs: PersistedPanelGroup['inputs'], - defaultInputs: PanelGroup['inputs'], -): PanelGroup['inputs'] { - if (!Array.isArray(inputs)) { - return []; - } - - return inputs.map((input) => { - const defaultInput = defaultInputs.find( - (candidate) => candidate.prop === input.prop, - ); - - const { classReference: _, ...inputWithoutClassRef } = input; - - return { - ...defaultInput, - ...inputWithoutClassRef, - }; - }); -} - -export function inferThemeFromPanelStyles( - panelStyles: PersistedPanelGroup[] | null | undefined, -): EditorTheme | null { - if (!Array.isArray(panelStyles) || panelStyles.length === 0) { - return null; - } - - let finalTheme: EditorTheme | null = null; - for (const group of panelStyles) { - if (!Array.isArray(group?.inputs)) { - finalTheme = null; - break; - } - - if (group.inputs.length !== 0) { - finalTheme = 'basic'; - break; - } - - finalTheme = 'minimal'; - } - - return finalTheme; -} - -export function applyPanelStylesBackwardsCompat( - theme: EditorTheme, - panelStyles: PersistedPanelGroup[] | null | undefined, -): PanelGroup[] | null { - if (!Array.isArray(panelStyles)) { - return null; - } - - return panelStyles - .map((group) => { - const panelId = resolvePanelSectionId(group); - - if (!panelId) { - return null; - } - - const defaultGroup = EDITOR_THEMES[theme].find( - (candidate) => candidate.id === panelId, - ); - - if (!defaultGroup) { - return null; - } - - const normalized: PanelGroup = { - id: panelId, - title: defaultGroup.title, - inputs: backfillPanelInputs(group.inputs, defaultGroup.inputs), - }; - - return normalized; - }) - .filter((group): group is PanelGroup => group !== null); -} diff --git a/packages/editor/src/plugins/email-theming/css-transforms.spec.ts b/packages/editor/src/plugins/email-theming/css-transforms.spec.ts index c43e241dbb..6d24a13053 100644 --- a/packages/editor/src/plugins/email-theming/css-transforms.spec.ts +++ b/packages/editor/src/plugins/email-theming/css-transforms.spec.ts @@ -27,26 +27,21 @@ it('returns empty object for empty array', () => { it('transforms basic styles without units', () => { const styles: PanelGroup[] = [ { - id: 'body', - title: 'Body', + title: 'Basic', inputs: [ { label: 'Color', type: 'color', value: '#FF0000', prop: 'color', + classReference: 'body', }, - ], - }, - { - id: 'container', - title: 'Container', - inputs: [ { label: 'Background', type: 'color', value: '#FFFFFF', prop: 'backgroundColor', + classReference: 'container', }, ], }, @@ -62,7 +57,6 @@ it('transforms basic styles without units', () => { it('appends units to numeric values', () => { const styles: PanelGroup[] = [ { - id: 'container', title: 'With Units', inputs: [ { @@ -71,6 +65,7 @@ it('appends units to numeric values', () => { value: 100, unit: 'px', prop: 'width', + classReference: 'container', }, { label: 'Height', @@ -78,6 +73,7 @@ it('appends units to numeric values', () => { value: 200, unit: 'px', prop: 'height', + classReference: 'container', }, ], }, @@ -92,7 +88,6 @@ it('appends units to numeric values', () => { it('converts fontSize from px to em for mobile adjustment', () => { const styles: PanelGroup[] = [ { - id: 'body', title: 'Font Size', inputs: [ { @@ -101,6 +96,7 @@ it('converts fontSize from px to em for mobile adjustment', () => { value: 26, unit: 'px', prop: 'fontSize', + classReference: 'body', }, ], }, @@ -115,7 +111,6 @@ it('converts fontSize from px to em for mobile adjustment', () => { it('includes container background when body is not white', () => { const styles: PanelGroup[] = [ { - id: 'body', title: 'Body', inputs: [ { @@ -123,11 +118,11 @@ it('includes container background when body is not white', () => { type: 'color', value: '#F0F0F0', prop: 'backgroundColor', + classReference: 'body', }, ], }, { - id: 'container', title: 'Container', inputs: [ { @@ -135,6 +130,7 @@ it('includes container background when body is not white', () => { type: 'color', value: '#FFFFFF', prop: 'backgroundColor', + classReference: 'container', }, ], }, @@ -150,7 +146,6 @@ it('includes container background when body is not white', () => { it('includes non-black text colors for body', () => { const styles: PanelGroup[] = [ { - id: 'body', title: 'Body', inputs: [ { @@ -158,6 +153,7 @@ it('includes non-black text colors for body', () => { type: 'color', value: '#333333', prop: 'color', + classReference: 'body', }, ], }, @@ -172,7 +168,6 @@ it('includes non-black text colors for body', () => { it('handles mixed input types and properties', () => { const styles: PanelGroup[] = [ { - id: 'container', title: 'Mixed Properties', inputs: [ { @@ -181,18 +176,21 @@ it('handles mixed input types and properties', () => { value: 600, unit: 'px', prop: 'width', + classReference: 'container', }, { label: 'Align', type: 'select', value: 'center', prop: 'align', + classReference: 'container', }, { label: 'Background', type: 'color', value: '#F5F5F5', prop: 'backgroundColor', + classReference: 'container', }, ], }, @@ -208,41 +206,31 @@ it('handles mixed input types and properties', () => { }); }); -it('handles multiple sections with different ids', () => { +it('handles multiple class references', () => { const styles: PanelGroup[] = [ { - id: 'body', - title: 'Body', + title: 'Multiple Classes', inputs: [ { label: 'Body Color', type: 'color', value: '#333333', prop: 'color', + classReference: 'body', }, - ], - }, - { - id: 'link', - title: 'Link', - inputs: [ { label: 'Link Color', type: 'color', value: '#0066CC', prop: 'color', + classReference: 'link', }, - ], - }, - { - id: 'button', - title: 'Button', - inputs: [ { label: 'Button Background', type: 'color', value: '#000000', prop: 'backgroundColor', + classReference: 'button', }, ], }, @@ -259,7 +247,6 @@ it('handles multiple sections with different ids', () => { it('handles string values with units', () => { const styles: PanelGroup[] = [ { - id: 'button', title: 'String Values', inputs: [ { @@ -268,6 +255,7 @@ it('handles string values with units', () => { value: 'auto', unit: 'px', prop: 'width', + classReference: 'button', }, ], }, @@ -282,39 +270,21 @@ it('handles string values with units', () => { it('handles complex nested structure', () => { const styles: PanelGroup[] = [ { - id: 'body', - title: 'Body', + title: 'Complex Theme', inputs: [ { label: 'Body Background', type: 'color', value: '#FFFFFF', prop: 'backgroundColor', + classReference: 'body', }, - { - label: 'Body Font Size', - type: 'number', - value: 16, - unit: 'px', - prop: 'fontSize', - }, - { - label: 'Body Color', - type: 'color', - value: '#000000', - prop: 'color', - }, - ], - }, - { - id: 'container', - title: 'Container', - inputs: [ { label: 'Container Background', type: 'color', value: '#FFFFFF', prop: 'backgroundColor', + classReference: 'container', }, { label: 'Container Width', @@ -322,6 +292,7 @@ it('handles complex nested structure', () => { value: 600, unit: 'px', prop: 'width', + classReference: 'container', }, { label: 'Padding Left', @@ -329,6 +300,7 @@ it('handles complex nested structure', () => { value: 20, unit: 'px', prop: 'paddingLeft', + classReference: 'container', }, { label: 'Padding Right', @@ -336,6 +308,22 @@ it('handles complex nested structure', () => { value: 20, unit: 'px', prop: 'paddingRight', + classReference: 'container', + }, + { + label: 'Body Font Size', + type: 'number', + value: 16, + unit: 'px', + prop: 'fontSize', + classReference: 'body', + }, + { + label: 'Body Color', + type: 'color', + value: '#000000', + prop: 'color', + classReference: 'body', }, ], }, @@ -356,22 +344,3 @@ it('handles complex nested structure', () => { }, }); }); - -it('skips groups without an id', () => { - const styles: PanelGroup[] = [ - { - title: 'No ID', - inputs: [ - { - label: 'Color', - type: 'color', - value: '#FF0000', - prop: 'color', - }, - ], - }, - ]; - - const result = transformToCssJs(styles); - expect(result).toEqual({}); -}); diff --git a/packages/editor/src/plugins/email-theming/css-transforms.ts b/packages/editor/src/plugins/email-theming/css-transforms.ts index b60f31982c..d708c54bd1 100644 --- a/packages/editor/src/plugins/email-theming/css-transforms.ts +++ b/packages/editor/src/plugins/email-theming/css-transforms.ts @@ -1,5 +1,5 @@ import { ensureBorderStyleFallback } from '../../utils/styles'; -import type { CssJs, KnownThemeComponents, PanelGroup } from './types'; +import type { CssJs, PanelGroup } from './types'; export function transformToCssJs(styleArray: PanelGroup[]): CssJs { const cssJS = {} as CssJs; @@ -9,11 +9,6 @@ export function transformToCssJs(styleArray: PanelGroup[]): CssJs { } for (const style of styleArray) { - const key = style.id as KnownThemeComponents | undefined; - if (!key) { - continue; - } - for (const input of style.inputs) { let value = input.value; @@ -27,20 +22,24 @@ export function transformToCssJs(styleArray: PanelGroup[]): CssJs { } } - if (!cssJS[key]) { - cssJS[key] = {}; + if (!input.classReference) { + continue; + } + + if (!cssJS[input.classReference]) { + cssJS[input.classReference] = {}; } // @ts-expect-error -- backward compatibility: 'h-padding' is a legacy prop not in KnownCssProperties if (input.prop === 'h-padding') { - cssJS[key].paddingLeft = value; - cssJS[key].paddingRight = value; + cssJS[input.classReference].paddingLeft = value; + cssJS[input.classReference].paddingRight = value; continue; } // @ts-expect-error -- input.prop is KnownCssProperties but CssJs values are React.CSSProperties; dynamic assignment is intentional - cssJS[key][input.prop] = value; + cssJS[input.classReference][input.prop] = value; } } diff --git a/packages/editor/src/plugins/email-theming/extension.tsx b/packages/editor/src/plugins/email-theming/extension.tsx index d1610a5bd9..0b43a34f36 100644 --- a/packages/editor/src/plugins/email-theming/extension.tsx +++ b/packages/editor/src/plugins/email-theming/extension.tsx @@ -6,17 +6,16 @@ import { useEditorState } from '@tiptap/react'; import type * as React from 'react'; import type { SerializerPlugin } from '../../core/serializer/serializer-plugin'; import { getGlobalContent } from '../../extensions/global-content'; -import { - applyPanelStylesBackwardsCompat, - inferThemeFromPanelStyles, -} from './backwards-compatibility'; -import type { PersistedPanelGroup } from './backwards-compatibility'; import { injectGlobalPlainCss, injectThemeCss, mergeCssJs, transformToCssJs, } from './css-transforms'; +import { + inferThemeFromPanelStyles, + normalizeThemePanelStyles, +} from './normalization'; import { EDITOR_THEMES, RESET_THEMES } from './themes'; import type { CssJs, @@ -84,10 +83,10 @@ export function getThemeComponentKey( */ export function getMergedCssJs( theme: EditorTheme, - panelStyles: PersistedPanelGroup[] | PanelGroup[] | undefined, + panelStyles: PanelGroup[] | undefined, ): CssJs { const panels: PanelGroup[] = - applyPanelStylesBackwardsCompat(theme, panelStyles) ?? EDITOR_THEMES[theme]; + normalizeThemePanelStyles(theme, panelStyles) ?? EDITOR_THEMES[theme]; const parsed = transformToCssJs(panels); return mergeCssJs(RESET_THEMES[theme], parsed); } @@ -146,19 +145,19 @@ export function stylesToCss( theme: EditorTheme, ): Record { const parsed = transformToCssJs( - applyPanelStylesBackwardsCompat(theme, styles) ?? EDITOR_THEMES[theme], + normalizeThemePanelStyles(theme, styles) ?? EDITOR_THEMES[theme], ); return mergeCssJs(RESET_THEMES[theme], parsed); } function getEmailTheming(editor: Editor) { const theme = getEmailTheme(editor); - const compatStyles = - applyPanelStylesBackwardsCompat(theme, getEmailStyles(editor)) ?? + const normalizedStyles = + normalizeThemePanelStyles(theme, getEmailStyles(editor)) ?? EDITOR_THEMES[theme]; return { - styles: compatStyles, + styles: normalizedStyles, theme, css: getEmailCss(editor), }; @@ -177,7 +176,7 @@ export function useEmailTheming(editor: Editor | null) { } function getEmailStyles(editor: Editor) { - return getGlobalContent('styles', editor) as PersistedPanelGroup[] | null; + return getGlobalContent('styles', editor) as PanelGroup[] | null; } function getEmailTheme(editor: Editor) { @@ -293,7 +292,7 @@ export const EmailTheming = Extension.create<{ new Plugin({ key: new PluginKey('themingStyleInjector'), view(view) { - let prevStyles: PersistedPanelGroup[] | null = null; + let prevStyles: PanelGroup[] | null = null; let prevTheme: EditorTheme | null = null; let prevCss: string | null = null; @@ -306,7 +305,7 @@ export const EmailTheming = Extension.create<{ const css = getEmailCss(editor); if (styles !== prevStyles || theme !== prevTheme) { - prevStyles = styles; + prevStyles = styles as PanelGroup[] | null; prevTheme = theme; injectThemeCss(getMergedCssJs(theme, resolvedStyles), { scopeSelector, diff --git a/packages/editor/src/plugins/email-theming/normalization.spec.ts b/packages/editor/src/plugins/email-theming/normalization.spec.ts new file mode 100644 index 0000000000..0e62454fcc --- /dev/null +++ b/packages/editor/src/plugins/email-theming/normalization.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { + inferThemeFromPanelStyles, + normalizeThemePanelStyles, +} from './normalization'; + +describe('normalizeThemePanelStyles', () => { + it('maps legacy global panel groups to the current section ids', () => { + const result = normalizeThemePanelStyles('minimal', [ + { + title: 'Body', + classReference: 'body', + inputs: [], + }, + { + title: 'Container', + classReference: 'container', + inputs: [], + }, + { + title: 'Typography', + classReference: 'body', + inputs: [], + }, + { + title: 'Code Block', + classReference: 'codeBlock', + inputs: [], + }, + ]); + + expect(result).toEqual([ + expect.objectContaining({ + id: 'body', + title: 'Background', + classReference: 'body', + }), + expect.objectContaining({ + id: 'container', + title: 'Content', + classReference: 'container', + }), + expect.objectContaining({ + id: 'typography', + title: 'Typography', + classReference: 'body', + }), + expect.objectContaining({ + id: 'code-block', + title: 'Code Block', + classReference: 'codeBlock', + }), + ]); + }); +}); + +describe('inferThemeFromPanelStyles', () => { + it('infers the minimal theme from legacy empty panel groups', () => { + expect( + inferThemeFromPanelStyles([ + { title: 'Body', classReference: 'body', inputs: [] }, + { title: 'Container', classReference: 'container', inputs: [] }, + ]), + ).toBe('minimal'); + }); +}); diff --git a/packages/editor/src/plugins/email-theming/normalization.ts b/packages/editor/src/plugins/email-theming/normalization.ts new file mode 100644 index 0000000000..0d564b7c0b --- /dev/null +++ b/packages/editor/src/plugins/email-theming/normalization.ts @@ -0,0 +1,164 @@ +import { EDITOR_THEMES } from './themes'; +import type { + EditorTheme, + KnownThemeComponents, + PanelGroup, + PanelSectionId, +} from './types'; + +const PANEL_SECTION_IDS = new Set([ + 'body', + 'container', + 'typography', + 'link', + 'image', + 'button', + 'code-block', + 'inline-code', +]); + +const PANEL_SECTION_IDS_BY_TITLE: Record = { + background: 'body', + body: 'body', + content: 'container', + container: 'container', + typography: 'typography', + link: 'link', + image: 'image', + button: 'button', + 'code block': 'code-block', + 'inline code': 'inline-code', +}; + +const PANEL_SECTION_IDS_BY_CLASS_REFERENCE: Partial< + Record +> = { + container: 'container', + link: 'link', + image: 'image', + button: 'button', + codeBlock: 'code-block', + inlineCode: 'inline-code', +}; + +function isPanelSectionId(value: unknown): value is PanelSectionId { + return ( + typeof value === 'string' && PANEL_SECTION_IDS.has(value as PanelSectionId) + ); +} + +function normalizeTitle(title: string | undefined): string { + return title?.trim().toLowerCase().replace(/\s+/g, ' ') ?? ''; +} + +function resolvePanelSectionId(group: PanelGroup): PanelSectionId | null { + if (isPanelSectionId(group.id)) { + return group.id; + } + + const normalizedTitle = normalizeTitle(group.title); + + if (group.classReference === 'body') { + if (normalizedTitle === 'typography') { + return 'typography'; + } + + return 'body'; + } + + if ( + group.classReference && + PANEL_SECTION_IDS_BY_CLASS_REFERENCE[group.classReference] + ) { + return PANEL_SECTION_IDS_BY_CLASS_REFERENCE[group.classReference] ?? null; + } + + return PANEL_SECTION_IDS_BY_TITLE[normalizedTitle] ?? null; +} + +function normalizePanelInputs( + inputs: PanelGroup['inputs'], + defaultInputs: PanelGroup['inputs'], + fallbackClassReference?: KnownThemeComponents, +): PanelGroup['inputs'] { + if (!Array.isArray(inputs)) { + return []; + } + + return inputs.map((input) => { + const defaultInput = defaultInputs.find( + (candidate) => candidate.prop === input.prop, + ); + + return { + ...defaultInput, + ...input, + classReference: + input.classReference ?? + defaultInput?.classReference ?? + fallbackClassReference, + }; + }); +} + +export function inferThemeFromPanelStyles( + panelStyles: PanelGroup[] | null | undefined, +): EditorTheme | null { + if (!Array.isArray(panelStyles) || panelStyles.length === 0) { + return null; + } + + let finalTheme: EditorTheme | null = null; + for (const group of panelStyles) { + if (!Array.isArray(group?.inputs)) { + finalTheme = null; + break; + } + + if (group.inputs.length !== 0) { + finalTheme = 'basic'; + break; + } + + finalTheme = 'minimal'; + } + + return finalTheme; +} + +export function normalizeThemePanelStyles( + theme: EditorTheme, + panelStyles: PanelGroup[] | null | undefined, +): PanelGroup[] | null { + if (!Array.isArray(panelStyles)) { + return null; + } + + return panelStyles.map((group) => { + const panelId = resolvePanelSectionId(group); + + if (!panelId) { + return group; + } + + const defaultGroup = EDITOR_THEMES[theme].find( + (candidate) => candidate.id === panelId, + ); + + if (!defaultGroup) { + return group; + } + + return { + ...group, + id: panelId, + title: defaultGroup.title, + classReference: defaultGroup.classReference, + inputs: normalizePanelInputs( + group.inputs, + defaultGroup.inputs, + defaultGroup.classReference, + ), + }; + }); +} diff --git a/packages/editor/src/plugins/email-theming/themes.ts b/packages/editor/src/plugins/email-theming/themes.ts index 48a7b7c804..6655656077 100644 --- a/packages/editor/src/plugins/email-theming/themes.ts +++ b/packages/editor/src/plugins/email-theming/themes.ts @@ -14,11 +14,12 @@ import type { const PANEL_SECTION_TITLES: Record = { body: 'Background', container: 'Body', + typography: 'Typography', link: 'Link', image: 'Image', button: 'Button', - codeBlock: 'Code Block', - inlineCode: 'Inline Code', + 'code-block': 'Code Block', + 'inline-code': 'Inline Code', }; /** @@ -37,19 +38,13 @@ const THEME_BASIC: PanelGroup[] = [ { id: 'body', title: 'Background', - inputs: [ - { - label: 'Font size', - type: 'number', - value: 14, - unit: 'px', - prop: 'fontSize', - }, - ], + classReference: 'body', + inputs: [], }, { id: 'container', - title: 'Body', + title: 'Content', + classReference: 'container', inputs: [ { label: 'Align', @@ -61,6 +56,7 @@ const THEME_BASIC: PanelGroup[] = [ right: 'Right', }, prop: 'align', + classReference: 'container', }, { label: 'Width', @@ -68,13 +64,7 @@ const THEME_BASIC: PanelGroup[] = [ value: 600, unit: 'px', prop: 'width', - }, - { - label: 'Line Height', - type: 'number', - value: 155, - unit: '%', - prop: 'lineHeight', + classReference: 'container', }, { label: 'Padding Left', @@ -82,6 +72,7 @@ const THEME_BASIC: PanelGroup[] = [ value: 0, unit: 'px', prop: 'paddingLeft', + classReference: 'container', }, { label: 'Padding Right', @@ -89,18 +80,44 @@ const THEME_BASIC: PanelGroup[] = [ value: 0, unit: 'px', prop: 'paddingRight', + classReference: 'container', + }, + ], + }, + { + id: 'typography', + title: 'Typography', + classReference: 'body', + inputs: [ + { + label: 'Font size', + type: 'number', + value: 14, + unit: 'px', + prop: 'fontSize', + classReference: 'body', + }, + { + label: 'Line Height', + type: 'number', + value: 155, + unit: '%', + prop: 'lineHeight', + classReference: 'container', }, ], }, { id: 'link', title: 'Link', + classReference: 'link', inputs: [ { label: 'Color', type: 'color', value: '#0670DB', prop: 'color', + classReference: 'link', }, { label: 'Decoration', @@ -111,12 +128,14 @@ const THEME_BASIC: PanelGroup[] = [ underline: 'Underline', none: 'None', }, + classReference: 'link', }, ], }, { id: 'image', title: 'Image', + classReference: 'image', inputs: [ { label: 'Border radius', @@ -124,24 +143,28 @@ const THEME_BASIC: PanelGroup[] = [ value: 8, unit: 'px', prop: 'borderRadius', + classReference: 'image', }, ], }, { id: 'button', title: 'Button', + classReference: 'button', inputs: [ { label: 'Background', type: 'color', value: '#000000', prop: 'backgroundColor', + classReference: 'button', }, { label: 'Text color', type: 'color', value: '#ffffff', prop: 'color', + classReference: 'button', }, { label: 'Radius', @@ -149,6 +172,7 @@ const THEME_BASIC: PanelGroup[] = [ value: 4, unit: 'px', prop: 'borderRadius', + classReference: 'button', }, { label: 'Padding Top', @@ -156,6 +180,7 @@ const THEME_BASIC: PanelGroup[] = [ value: 7, unit: 'px', prop: 'paddingTop', + classReference: 'button', }, { label: 'Padding Right', @@ -163,6 +188,7 @@ const THEME_BASIC: PanelGroup[] = [ value: 12, unit: 'px', prop: 'paddingRight', + classReference: 'button', }, { label: 'Padding Bottom', @@ -170,6 +196,7 @@ const THEME_BASIC: PanelGroup[] = [ value: 7, unit: 'px', prop: 'paddingBottom', + classReference: 'button', }, { label: 'Padding Left', @@ -177,12 +204,14 @@ const THEME_BASIC: PanelGroup[] = [ value: 12, unit: 'px', prop: 'paddingLeft', + classReference: 'button', }, ], }, { - id: 'codeBlock', + id: 'code-block', title: 'Code Block', + classReference: 'codeBlock', inputs: [ { label: 'Border Radius', @@ -190,6 +219,7 @@ const THEME_BASIC: PanelGroup[] = [ value: 4, unit: 'px', prop: 'borderRadius', + classReference: 'codeBlock', }, { label: 'Padding Top', @@ -197,6 +227,7 @@ const THEME_BASIC: PanelGroup[] = [ value: 12, unit: 'px', prop: 'paddingTop', + classReference: 'codeBlock', }, { label: 'Padding Bottom', @@ -204,6 +235,7 @@ const THEME_BASIC: PanelGroup[] = [ value: 12, unit: 'px', prop: 'paddingBottom', + classReference: 'codeBlock', }, { label: 'Padding Left', @@ -211,6 +243,7 @@ const THEME_BASIC: PanelGroup[] = [ value: 16, unit: 'px', prop: 'paddingLeft', + classReference: 'codeBlock', }, { label: 'Padding Right', @@ -218,24 +251,28 @@ const THEME_BASIC: PanelGroup[] = [ value: 16, unit: 'px', prop: 'paddingRight', + classReference: 'codeBlock', }, ], }, { - id: 'inlineCode', + id: 'inline-code', title: 'Inline Code', + classReference: 'inlineCode', inputs: [ { label: 'Background', type: 'color', value: '#e5e7eb', prop: 'backgroundColor', + classReference: 'inlineCode', }, { label: 'Text color', type: 'color', value: '#1e293b', prop: 'color', + classReference: 'inlineCode', }, { label: 'Radius', @@ -243,6 +280,7 @@ const THEME_BASIC: PanelGroup[] = [ value: 4, unit: 'px', prop: 'borderRadius', + classReference: 'inlineCode', }, ], }, diff --git a/packages/editor/src/plugins/email-theming/types.ts b/packages/editor/src/plugins/email-theming/types.ts index d8408cd9e9..825ad1143c 100644 --- a/packages/editor/src/plugins/email-theming/types.ts +++ b/packages/editor/src/plugins/email-theming/types.ts @@ -8,11 +8,12 @@ export type EditorTheme = 'basic' | 'minimal'; export type PanelSectionId = | 'body' | 'container' + | 'typography' | 'link' | 'image' | 'button' - | 'codeBlock' - | 'inlineCode'; + | 'code-block' + | 'inline-code'; export type KnownThemeComponents = | 'reset' | 'body' @@ -102,6 +103,7 @@ export interface PanelInputProperty { type: InputType; value: string | number; prop: KnownCssProperties; + classReference?: KnownThemeComponents; unit?: InputUnit; options?: Options; placeholder?: string; @@ -112,5 +114,6 @@ export interface PanelGroup { id?: PanelSectionId; title: string; headerSlot?: React.ReactNode; + classReference?: KnownThemeComponents; inputs: Omit[]; } diff --git a/packages/editor/src/utils/types.ts b/packages/editor/src/utils/types.ts index 97ec641efe..ec97ce9de6 100644 --- a/packages/editor/src/utils/types.ts +++ b/packages/editor/src/utils/types.ts @@ -90,6 +90,7 @@ export interface PanelInputProperty { type: InputType; value: string | number; prop: KnownCssProperties; + classReference?: KnownThemeComponents; unit?: InputUnit; options?: Options; placeholder?: string; @@ -99,6 +100,7 @@ export interface PanelInputProperty { export interface PanelGroup { title: string; headerSlot?: React.ReactNode; + classReference?: KnownThemeComponents; inputs: Omit[]; }