import { Fragment, Node as PMNode } from 'prosemirror-model'; /** email-fidelity: writes the editor's data-table rendering into the saved HTML; layout tables keep their authored output. */ /** Theme-resolved colours the editor draws data tables with; null = not drawn. */ export interface EmailRenderDefaults { borderColor: string | null; headerBackground: string | null; } /** Kept in sync by hand with `.ProseMirror td, .ProseMirror th { padding: 6px 8px }`. */ const CELL_PADDING: Record = { paddingTop: '6px', paddingRight: '8px', paddingBottom: '6px', paddingLeft: '8px', }; const CELL_BORDERS = ['borderTop', 'borderRight', 'borderBottom', 'borderLeft']; /** `.ProseMirror td > *` zeroes block margins inside cells. */ const CELL_CHILD_MARGIN = 'margin-top: 0px; margin-bottom: 0px'; type Context = 'data-table' | 'data-cell' | null; /** Returns a copy of `doc` with the editor's data-table rendering filled into node attrs. */ export function applyEmailRenderDefaults(doc: PMNode, defaults: EmailRenderDefaults): PMNode { return fill(doc, null, defaults); } function fill(node: PMNode, context: Context, defaults: EmailRenderDefaults): PMNode { if (node.isLeaf) return node; const name = node.type.name; let attrs = node.attrs; let childContext: Context = null; if (name === 'table') { if (!attrs.layout) { childContext = 'data-table'; if (!attrs.width) attrs = { ...attrs, width: '100%' }; } } else if (name === 'table_row') { childContext = context; } else if ((name === 'table_cell' || name === 'table_header') && context === 'data-table') { attrs = cellDefaults(attrs, name === 'table_header', defaults); childContext = 'data-cell'; } else if (context === 'data-cell' && 'margin' in attrs && !attrs.margin) { attrs = { ...attrs, margin: CELL_CHILD_MARGIN }; } const children: PMNode[] = []; node.forEach(child => children.push(fill(child, childContext, defaults))); return node.type.create(attrs, Fragment.fromArray(children), node.marks); } function cellDefaults( attrs: Record, header: boolean, defaults: EmailRenderDefaults ): Record { const next = { ...attrs }; for (const [key, value] of Object.entries(CELL_PADDING)) if (!next[key]) next[key] = value; if (!next.valign) next.valign = 'top'; if (defaults.borderColor) { const border = `1px solid ${defaults.borderColor}`; for (const key of CELL_BORDERS) if (!next[key]) next[key] = border; } // A resized column keeps its width in data-colwidth, which mail clients ignore. const colwidth = next.colwidth as number[] | null; if (!next.width && Array.isArray(colwidth) && colwidth.length && colwidth.every(w => w > 0)) { next.width = `${colwidth.reduce((sum, w) => sum + w, 0)}px`; } if (header) { if (!next.background && defaults.headerBackground) next.background = defaults.headerBackground; if (!next.textAlign) next.textAlign = 'left'; const textStyle = (next.textStyle as string | null) ?? ''; if (!/font-weight/i.test(textStyle)) { next.textStyle = textStyle ? `${textStyle}; font-weight: 600` : 'font-weight: 600'; } } return next; } /** Collapsed borders and no cell spacing on every table, fixed columns on data tables. */ export function finishEmailTables(container: HTMLElement): void { for (const table of Array.from(container.querySelectorAll('table'))) { if (!table.hasAttribute('cellpadding')) table.setAttribute('cellpadding', '0'); if (!table.hasAttribute('cellspacing')) table.setAttribute('cellspacing', '0'); const style = table.getAttribute('style') ?? ''; const add: string[] = []; if (!/(^|;)\s*border-collapse\s*:/i.test(style)) add.push('border-collapse: collapse'); const layout = table.getAttribute('role') === 'presentation'; if (!layout && !/(^|;)\s*table-layout\s*:/i.test(style)) add.push('table-layout: fixed'); if (add.length) { const base = style.trim().replace(/;\s*$/, ''); table.setAttribute('style', base ? `${base}; ${add.join('; ')}` : add.join('; ')); } } }