mail-editor-pancake 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -286,7 +286,7 @@ export class Canvas {
286
286
 
287
287
  const { doc } = this.opts.store;
288
288
  this.inner.style.maxWidth = docContentWidthCss(doc.meta.width);
289
- this.inner.style.background = doc.styles.contentBackgroundColor;
289
+ this.inner.style.background = doc.styles.backgroundColor;
290
290
  this.inner.style.fontFamily = doc.styles.fontFamily;
291
291
  this.inner.style.fontSize = doc.styles.fontSize;
292
292
  this.inner.style.fontWeight = normalizeFontWeightStep(doc.styles.fontWeight);
@@ -849,6 +849,35 @@ export class Canvas {
849
849
  return Object.keys(rest).length ? rest : undefined;
850
850
  }
851
851
 
852
+ /** 组合 palette 可一次创建原生多列 Section,并将 Block 放入指定列。 */
853
+ private _createSectionFromPaletteDrop(drop: PaletteDropResult): Section {
854
+ const section = createSection(drop.sectionLayout ?? '1');
855
+ this._applySectionAttrs(section, this._paletteSectionAttrsForDrop(drop.sectionAttrs));
856
+ if (drop.columnBlocks?.length) {
857
+ drop.columnBlocks.forEach((blocks, columnIndex) => {
858
+ section.columns[columnIndex]?.blocks.push(...blocks);
859
+ });
860
+ } else {
861
+ section.columns[0]?.blocks.push(...drop.blocks);
862
+ }
863
+ return section;
864
+ }
865
+
866
+ private _paletteDropNeedsDedicatedSection(drop: PaletteDropResult): boolean {
867
+ if (drop.sectionLayout && drop.sectionLayout !== '1') return true;
868
+ if ((drop.columnBlocks?.length ?? 0) > 1) return true;
869
+ const attrs = this._paletteSectionAttrsForDrop(drop.sectionAttrs);
870
+ return !!attrs?.dynamicVariantKey;
871
+ }
872
+
873
+ private _firstBlockInSection(section: Section): { block: Block; columnIndex: number } | undefined {
874
+ for (let columnIndex = 0; columnIndex < section.columns.length; columnIndex += 1) {
875
+ const block = section.columns[columnIndex]?.blocks[0];
876
+ if (block) return { block, columnIndex };
877
+ }
878
+ return undefined;
879
+ }
880
+
852
881
  private _handleSectionAdd(e: Sortable.SortableEvent) {
853
882
  this._commitEditingBeforeStructureChange();
854
883
  const item = e.item;
@@ -870,20 +899,18 @@ export class Canvas {
870
899
  // 路径 B:左栏内容/自定义卡片拖到 sections 之间 → 自动裹一列 Section
871
900
  if (sourceGroup === 'blocks' && blockType) {
872
901
  item.parentElement?.removeChild(item);
873
- const { blocks, sectionAttrs } = this._resolvePaletteDrop(blockType);
874
- const newSection = createSection('1');
875
- this._applySectionAttrs(newSection, this._paletteSectionAttrsForDrop(sectionAttrs));
876
- newSection.columns[0].blocks.splice(newIndex, 0, ...blocks);
902
+ const drop = this._resolvePaletteDrop(blockType);
903
+ const newSection = this._createSectionFromPaletteDrop(drop);
877
904
  this.opts.store.update((d) => {
878
905
  d.sections.splice(newIndex, 0, newSection);
879
906
  });
880
- const head = blocks[0];
907
+ const head = this._firstBlockInSection(newSection);
881
908
  if (head) {
882
909
  this.opts.store.setSelection({
883
910
  kind: 'block',
884
911
  sectionId: newSection.id,
885
- columnIndex: 0,
886
- blockId: head.id,
912
+ columnIndex: head.columnIndex,
913
+ blockId: head.block.id,
887
914
  });
888
915
  }
889
916
  return;
@@ -938,23 +965,20 @@ export class Canvas {
938
965
  if (sourceGroup === 'blocks' && blockType) {
939
966
  item.parentElement?.removeChild(item);
940
967
  const drop = this._resolvePaletteDrop(blockType);
941
- const paletteSectionAttrs = this._paletteSectionAttrsForDrop(drop.sectionAttrs);
942
- if (paletteSectionAttrs?.dynamicVariantKey) {
943
- const newSection = createSection('1');
944
- this._applySectionAttrs(newSection, paletteSectionAttrs);
945
- newSection.columns[0].blocks.push(...drop.blocks);
968
+ if (this._paletteDropNeedsDedicatedSection(drop)) {
969
+ const newSection = this._createSectionFromPaletteDrop(drop);
946
970
  this.opts.store.update((d) => {
947
971
  const secIdx = d.sections.findIndex((s) => s.id === sectionId);
948
972
  const insertAt = secIdx >= 0 ? secIdx + 1 : d.sections.length;
949
973
  d.sections.splice(insertAt, 0, newSection);
950
974
  });
951
- const head = drop.blocks[0];
975
+ const head = this._firstBlockInSection(newSection);
952
976
  if (head) {
953
977
  this.opts.store.setSelection({
954
978
  kind: 'block',
955
979
  sectionId: newSection.id,
956
- columnIndex: 0,
957
- blockId: head.id,
980
+ columnIndex: head.columnIndex,
981
+ blockId: head.block.id,
958
982
  });
959
983
  }
960
984
  return;
@@ -6,6 +6,7 @@ import type {
6
6
  BlockDefinition,
7
7
  EditorUiOptions,
8
8
  EmailDoc,
9
+ GlobalStyles,
9
10
  RenderEngine,
10
11
  Selection,
11
12
  Variable,
@@ -273,7 +274,11 @@ export class MailEditor {
273
274
 
274
275
  setValue(doc: EmailDoc) {
275
276
  this._blurRightPanelIfFocused();
276
- this.store.replace(doc);
277
+ // 兼容旧文档:剥离/合并已废弃的 styles.contentBackgroundColor
278
+ this.store.replace({
279
+ ...doc,
280
+ styles: { ...doc.styles, ...migrateLegacyStyles(doc.styles) },
281
+ });
277
282
  this._applyConfiguredVariables();
278
283
  }
279
284
 
@@ -1224,17 +1229,31 @@ function applyLocalizedDefaultMeta(
1224
1229
  if (source?.meta?.subject == null) doc.meta.subject = t('doc.defaultSubject');
1225
1230
  }
1226
1231
 
1232
+ /**
1233
+ * 迁移旧字段 `styles.contentBackgroundColor`(内容背景)→ `backgroundColor`。
1234
+ * 导出产物历史上只认 `backgroundColor`(内容背景从未写进 MJML),因此优先保留它,
1235
+ * 保证已导出/已发送邮件的背景不变;仅在其为空时用内容背景兜底,并丢弃废弃字段,
1236
+ * 避免它随文档回写到宿主持久化存储。
1237
+ */
1238
+ function migrateLegacyStyles(styles: Partial<GlobalStyles>): Partial<GlobalStyles> {
1239
+ const legacy = styles as Partial<GlobalStyles> & { contentBackgroundColor?: string };
1240
+ const legacyBg = legacy.contentBackgroundColor;
1241
+ if (legacyBg === undefined) return styles;
1242
+ const { contentBackgroundColor, ...rest } = legacy;
1243
+ const bg = rest.backgroundColor || contentBackgroundColor;
1244
+ return bg ? { ...rest, backgroundColor: bg } : rest;
1245
+ }
1246
+
1227
1247
  function createDefaultDoc(partial?: Partial<EmailDoc>): EmailDoc {
1228
1248
  const mergedStyles = {
1229
1249
  backgroundColor: '#ffffff',
1230
- contentBackgroundColor: '#ffffff',
1231
1250
  fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif',
1232
1251
  fontSize: '16px',
1233
1252
  fontWeight: '400',
1234
1253
  color: '#433f3f',
1235
1254
  linkColor: '#ff5a00',
1236
1255
  lineHeight: '1.25',
1237
- ...(partial?.styles ?? {}),
1256
+ ...migrateLegacyStyles(partial?.styles ?? {}),
1238
1257
  };
1239
1258
  return {
1240
1259
  version: '1',
@@ -229,6 +229,8 @@ export class InlineEditor {
229
229
  richTextExecCommand('styleWithCSS', false, isListCmd ? 'false' : 'true');
230
230
  richTextExecCommand(command, false, value);
231
231
  }
232
+ // 链接内改文字色时同步到 <a> 自身:下划线由 <a> 绘制,不同步会出现「线与字异色」
233
+ if (command === 'foreColor' && value) this._syncAnchorColor(value);
232
234
  if (command === 'hiliteColor') {
233
235
  this.pendingHiliteColor = value ?? null;
234
236
  this.pendingHiliteAnchor = hiliteAnchor;
@@ -306,9 +308,15 @@ export class InlineEditor {
306
308
  this._restoreSelection();
307
309
  }
308
310
 
309
- /** 字号:走原生 fontSize 命令(styleWithCSS 下浏览器会写 span),与 foreColor 同类 */
311
+ /**
312
+ * 字号:直接写精确 px。
313
+ * 不能用 `execCommand('fontSize')`——它只接受 1–7 的 legacy 档位,
314
+ * 浏览器按自己的档位表渲染(如 5→24px、6→32px),回读时 20px 会变成 24px、28px 会变成 32px。
315
+ */
310
316
  applyFontSize(px: string) {
311
- this.exec('fontSize', pxToLegacyFontSize(px));
317
+ const css = normalizeFontSizeCss(px);
318
+ if (!css) return;
319
+ this._wrapInlineStyle(`font-size:${css};`);
312
320
  }
313
321
 
314
322
  /**
@@ -316,6 +324,14 @@ export class InlineEditor {
316
324
  * 有选区包裹内容;折叠光标插入 typing span,后续输入沿用。
317
325
  */
318
326
  applyFontWeight(weight: string) {
327
+ this._wrapInlineStyle(`font-weight:${weight};`);
328
+ }
329
+
330
+ /**
331
+ * 用 `<span style="...">` 包裹选区写入内联样式。
332
+ * 有选区则包裹内容并重新选中;折叠光标则插入零宽占位 span,后续输入沿用。
333
+ */
334
+ private _wrapInlineStyle(style: string) {
319
335
  this.edited = true;
320
336
  this._ensureFocus();
321
337
  this._restoreSelection();
@@ -324,35 +340,84 @@ export class InlineEditor {
324
340
  const range = sel.getRangeAt(0);
325
341
  if (!this.el.contains(range.startContainer) || !this.el.contains(range.endContainer)) return;
326
342
 
327
- if (!range.collapsed) {
328
- const span = document.createElement('span');
329
- span.setAttribute('style', `font-weight:${weight};`);
330
- try {
343
+ const span = document.createElement('span');
344
+ span.setAttribute('style', style);
345
+ try {
346
+ if (!range.collapsed) {
331
347
  span.appendChild(range.extractContents());
332
348
  range.insertNode(span);
333
349
  const newRange = document.createRange();
334
350
  newRange.selectNodeContents(span);
335
351
  sel.removeAllRanges();
336
352
  sel.addRange(newRange);
337
- } catch {
338
- return;
353
+ } else {
354
+ // 标记 typing 占位:只设了样式却没输入内容时,提交前由 sanitizeRichHtml 拆掉空壳
355
+ span.setAttribute('data-sm-typing', '1');
356
+ span.appendChild(document.createTextNode('\u200b'));
357
+ range.insertNode(span);
358
+ const caret = document.createRange();
359
+ caret.setStart(span.firstChild!, 1);
360
+ caret.collapse(true);
361
+ sel.removeAllRanges();
362
+ sel.addRange(caret);
339
363
  }
340
- } else {
341
- const span = document.createElement('span');
342
- span.setAttribute('style', `font-weight:${weight};`);
343
- span.appendChild(document.createTextNode('\u200b'));
344
- range.insertNode(span);
345
- const caret = document.createRange();
346
- caret.setStart(span.firstChild!, 1);
347
- caret.collapse(true);
348
- sel.removeAllRanges();
349
- sel.addRange(caret);
364
+ } catch {
365
+ return;
366
+ }
367
+
368
+ this.saveSelection();
369
+ this._emitSelection();
370
+ }
371
+
372
+ /**
373
+ * 下划线:链接内的下划线来自 <a> 自身的(UA 默认)样式。
374
+ * CSS 里 text-decoration 由祖先向后代传播,**后代无法用 `text-decoration:none` 取消**,
375
+ * 而 execCommand('underline') 只能写后代 → 链接文字的下划线永远点不掉。
376
+ * 所以选区在链接内时直接改写 <a> 自己的 text-decoration。
377
+ */
378
+ toggleUnderline() {
379
+ this.edited = true;
380
+ this._ensureFocus();
381
+ this._restoreSelection();
382
+ const sel = window.getSelection();
383
+ if (!sel || sel.rangeCount === 0) return;
384
+ const range = sel.getRangeAt(0);
385
+ if (!this.el.contains(range.startContainer) || !this.el.contains(range.endContainer)) return;
386
+
387
+ const anchors = collectAnchorsInRange(range, this.el);
388
+ if (!anchors.length) {
389
+ this.exec('underline');
390
+ return;
391
+ }
392
+ // 全部已有下划线则整体关闭,否则整体打开
393
+ const turnOn = !anchors.every(hasComputedUnderline);
394
+ for (const a of anchors) {
395
+ setStyleDecl(a, TEXT_DECORATION_RE, `text-decoration:${turnOn ? 'underline' : 'none'}`);
350
396
  }
351
397
 
352
398
  this.saveSelection();
353
399
  this._emitSelection();
354
400
  }
355
401
 
402
+ /** 选区内(或折叠光标所在)的 <a> 元素 */
403
+ private _anchorsAtSelection(): HTMLAnchorElement[] {
404
+ const sel = window.getSelection();
405
+ if (!sel || sel.rangeCount === 0) return [];
406
+ const range = sel.getRangeAt(0);
407
+ if (!this.el.contains(range.startContainer) || !this.el.contains(range.endContainer)) return [];
408
+ return collectAnchorsInRange(range, this.el);
409
+ }
410
+
411
+ /**
412
+ * 把文字色同步到选区所在的 <a>:下划线颜色取 <a> 的 color,
413
+ * 若内嵌 span 另设 color,会出现「字是 A 色、线是 B 色」且 B 无法通过工具条修改。
414
+ */
415
+ private _syncAnchorColor(color: string): void {
416
+ for (const a of this._anchorsAtSelection()) {
417
+ setStyleDecl(a, COLOR_RE, `color:${color}`);
418
+ }
419
+ }
420
+
356
421
  /** 内联编辑根节点(如 `.sm-text-content`)的盒模型矩形,供富文本条贴在正文区域外沿定位 */
357
422
  getAnchorRect(): DOMRect {
358
423
  return this.el.getBoundingClientRect();
@@ -675,7 +740,7 @@ export class InlineEditor {
675
740
  formats: {
676
741
  bold: richTextQueryCommandState('bold'),
677
742
  italic: richTextQueryCommandState('italic'),
678
- underline: richTextQueryCommandState('underline'),
743
+ underline: resolveUnderlineAtSelection(this.el, sel),
679
744
  strikethrough: richTextQueryCommandState('strikeThrough'),
680
745
  unorderedList:
681
746
  listFormats.unorderedList || richTextQueryCommandState('insertUnorderedList'),
@@ -794,6 +859,53 @@ function resolveInlineFormatsAtSelection(
794
859
  return { fontSize, fontWeight, foreColor, backColor };
795
860
  }
796
861
 
862
+ const TEXT_DECORATION_RE = /^text-decoration(-line)?\s*:/i;
863
+ const COLOR_RE = /^color\s*:/i;
864
+
865
+ /** 选区内(折叠光标则取其所在)的 <a> 元素 */
866
+ function collectAnchorsInRange(range: Range, root: HTMLElement): HTMLAnchorElement[] {
867
+ const found = new Set<HTMLAnchorElement>();
868
+ if (range.collapsed) {
869
+ const node = range.startContainer;
870
+ const el = node.nodeType === Node.TEXT_NODE ? node.parentElement : (node as HTMLElement);
871
+ const a = el?.closest('a') ?? null;
872
+ if (a && root.contains(a)) found.add(a);
873
+ } else {
874
+ for (const a of Array.from(root.querySelectorAll('a'))) {
875
+ if (range.intersectsNode(a)) found.add(a);
876
+ }
877
+ }
878
+ return [...found];
879
+ }
880
+
881
+ /** 元素当前是否真的有下划线(含 UA 默认、CSS 规则、inline style) */
882
+ function hasComputedUnderline(el: HTMLElement): boolean {
883
+ const line = window.getComputedStyle(el).textDecorationLine || '';
884
+ return /(^|\s)underline(\s|$)/.test(line);
885
+ }
886
+
887
+ /** 替换(无则追加)style 中匹配 propRe 的那条声明 */
888
+ function setStyleDecl(el: HTMLElement, propRe: RegExp, decl: string): void {
889
+ const raw = (el.getAttribute('style') ?? '').trim();
890
+ const kept = raw
891
+ .split(';')
892
+ .map((s) => s.trim())
893
+ .filter((s) => s && !propRe.test(s))
894
+ .join(';');
895
+ el.setAttribute('style', kept ? `${kept};${decl}` : decl);
896
+ }
897
+
898
+ /**
899
+ * 下划线状态:链接内的下划线由 <a> 决定,execCommand 的状态查询读不到它,
900
+ * 会出现「看着有线、按钮却是未激活」→ 点了没反应。故链接内以 <a> 的 computed 为准。
901
+ */
902
+ function resolveUnderlineAtSelection(root: HTMLElement, sel: Selection): boolean {
903
+ const host = selectionHostElement(sel, root);
904
+ const a = host?.closest('a') ?? null;
905
+ if (a && root.contains(a)) return hasComputedUnderline(a);
906
+ return richTextQueryCommandState('underline');
907
+ }
908
+
797
909
  /** 光标处文字色(foreColor / 链接局部 color;queryCommandValue 在 <a> 内常不准) */
798
910
  function resolveForeColorAtSelection(root: HTMLElement, host: HTMLElement): string | null {
799
911
  for (let el: HTMLElement | null = host; el && root.contains(el) && el !== root; el = el.parentElement) {
@@ -851,44 +963,13 @@ function selectionAnchorKey(range: Range): string {
851
963
  return `${range.startContainer.nodeType}:${range.startOffset}:${range.endContainer.nodeType}:${range.endOffset}`;
852
964
  }
853
965
 
854
- /** execCommand fontSize 只认 1–7,与工具条 px 互转 */
855
- function pxToLegacyFontSize(px: string): string {
856
- const map: Record<string, string> = {
857
- '10px': '1',
858
- '12px': '2',
859
- '13px': '3',
860
- '14px': '3',
861
- '15px': '4',
862
- '16px': '4',
863
- '18px': '4',
864
- '20px': '5',
865
- '24px': '6',
866
- '28px': '6',
867
- '32px': '7',
868
- };
869
- const key = px.trim().toLowerCase();
870
- if (map[key]) return map[key];
871
- const num = parseFloat(key);
872
- if (Number.isNaN(num)) return '4';
873
- const steps: [number, string][] = [
874
- [10, '1'],
875
- [12, '2'],
876
- [14, '3'],
877
- [16, '4'],
878
- [20, '5'],
879
- [24, '6'],
880
- [32, '7'],
881
- ];
882
- let best = '4';
883
- let bestD = Infinity;
884
- for (const [size, legacy] of steps) {
885
- const d = Math.abs(size - num);
886
- if (d < bestD) {
887
- bestD = d;
888
- best = legacy;
889
- }
890
- }
891
- return best;
966
+ /** 工具条传入的 `28px` / `28` 规范成 CSS 值;非法返回 ''(调用方忽略) */
967
+ function normalizeFontSizeCss(px: string): string {
968
+ const m = /^(\d+(?:\.\d+)?)(px)?$/i.exec((px ?? '').trim());
969
+ if (!m) return '';
970
+ const n = Number.parseFloat(m[1]);
971
+ if (!Number.isFinite(n) || n <= 0) return '';
972
+ return `${Math.min(96, Math.max(8, n))}px`;
892
973
  }
893
974
 
894
975
  function isTransparentColor(c: string): boolean {
@@ -926,6 +1007,7 @@ export function sanitizeRichHtml(html: string): string {
926
1007
  const tpl = document.createElement('template');
927
1008
  tpl.innerHTML = html;
928
1009
  walk(tpl.content);
1010
+ mergeRedundantSpans(tpl.content);
929
1011
  return tpl.innerHTML.replace(/\u200b/g, '');
930
1012
 
931
1013
  function walk(root: Node) {
@@ -938,6 +1020,15 @@ export function sanitizeRichHtml(html: string): string {
938
1020
  el.remove();
939
1021
  return;
940
1022
  }
1023
+ // 只改了字号/字重却没输入内容的 typing 占位 span:零宽字符剥掉后是空壳,拆掉避免进邮件
1024
+ if (tag === 'span' && el.getAttribute('data-sm-typing') === '1') {
1025
+ const text = (el.textContent ?? '').replace(/\u200b/g, '');
1026
+ if (text.length === 0 && !el.querySelector('br, img')) {
1027
+ while (el.firstChild) el.parentNode?.insertBefore(el.firstChild, el);
1028
+ el.remove();
1029
+ return;
1030
+ }
1031
+ }
941
1032
  // <font color="..." size="..." face="..."> → 转成 span style
942
1033
  if (tag === 'font') {
943
1034
  const span = document.createElement('span');
@@ -1009,6 +1100,35 @@ export function sanitizeRichHtml(html: string): string {
1009
1100
  }
1010
1101
  }
1011
1102
 
1103
+ /**
1104
+ * 合并 style/class 完全相同的父子 span:反复改字号/字重会套出多层同义 span,
1105
+ * 渲染结果一样但 HTML 变胖(逼近 Gmail 102KB 截断)。合并后内层子节点上提。
1106
+ */
1107
+ function mergeRedundantSpans(root: ParentNode): void {
1108
+ for (let pass = 0; pass < 5; pass++) {
1109
+ let changed = false;
1110
+ for (const el of Array.from(root.querySelectorAll('span'))) {
1111
+ const parent = el.parentElement;
1112
+ if (!parent || parent.tagName.toLowerCase() !== 'span') continue;
1113
+ if (spanIdentity(parent) !== spanIdentity(el)) continue;
1114
+ while (el.firstChild) parent.insertBefore(el.firstChild, el);
1115
+ el.remove();
1116
+ changed = true;
1117
+ }
1118
+ if (!changed) break;
1119
+ }
1120
+ }
1121
+
1122
+ /** span 的样式身份:class + 规范化后的 style,用于判断是否可合并 */
1123
+ function spanIdentity(el: Element): string {
1124
+ const style = (el.getAttribute('style') ?? '')
1125
+ .replace(/\s+/g, ' ')
1126
+ .trim()
1127
+ .toLowerCase()
1128
+ .replace(/;+$/, '');
1129
+ return `${(el.getAttribute('class') ?? '').trim()} ${style}`;
1130
+ }
1131
+
1012
1132
  function hasMarginStyle(el: Element): boolean {
1013
1133
  const style = el.getAttribute('style') ?? '';
1014
1134
  return /(?:^|;)\s*margin(?:-(?:top|right|bottom|left))?\s*:/i.test(style);
@@ -1020,15 +1140,17 @@ function appendStyle(el: Element, style: string) {
1020
1140
  }
1021
1141
 
1022
1142
  function fontSizeFromLegacy(size: string): string {
1023
- // 旧 <font size="1..7"> → px 近似
1143
+ // 旧 <font size="1..7"> → px。必须用浏览器实际渲染 legacy 档位的那张表
1144
+ // (与 HTML 规范建议值一致:1=10, 2=13, 3=16, 4=18, 5=24, 6=32, 7=48),
1145
+ // 否则编辑器里看到的字号与提交进邮件的字号不一致(如 size=5 显示 24px 却存成 20px)。
1024
1146
  const map: Record<string, string> = {
1025
1147
  '1': '10px',
1026
- '2': '12px',
1027
- '3': '14px',
1028
- '4': '16px',
1029
- '5': '20px',
1030
- '6': '24px',
1031
- '7': '32px',
1148
+ '2': '13px',
1149
+ '3': '16px',
1150
+ '4': '18px',
1151
+ '5': '24px',
1152
+ '6': '32px',
1153
+ '7': '48px',
1032
1154
  };
1033
1155
  return map[size] ?? size;
1034
1156
  }
@@ -40,7 +40,20 @@ const FONT_FAMILIES = [
40
40
  { label: 'Courier', value: '"Courier New", monospace' },
41
41
  ];
42
42
 
43
- const FONT_SIZES = ['12px', '13px', '14px', '15px', '16px', '18px', '20px', '24px', '28px', '32px'];
43
+ /** 末位 48px 对应遗留 `<font size="7">`,见 InlineEditor.fontSizeFromLegacy */
44
+ const FONT_SIZES = [
45
+ '12px',
46
+ '13px',
47
+ '14px',
48
+ '15px',
49
+ '16px',
50
+ '18px',
51
+ '20px',
52
+ '24px',
53
+ '28px',
54
+ '32px',
55
+ '48px',
56
+ ];
44
57
 
45
58
  export class RichTextToolbar {
46
59
  el: HTMLElement;
@@ -159,7 +172,8 @@ export class RichTextToolbar {
159
172
 
160
173
  this.btns.bold = this._btn('B', t('toolbar.bold'), exec('bold'), { fontWeight: '700' });
161
174
  this.btns.italic = this._btn('I', t('toolbar.italic'), exec('italic'), { fontStyle: 'italic' });
162
- this.btns.underline = this._btn('U', t('toolbar.underline'), exec('underline'), {
175
+ // 链接内的下划线归 <a> 管,走专用实现;非链接文本回落到 execCommand
176
+ this.btns.underline = this._btn('U', t('toolbar.underline'), () => this.editor?.toggleUnderline(), {
163
177
  textDecoration: 'underline',
164
178
  });
165
179
  this.btns.strikethrough = this._btn('S', t('toolbar.strikethrough'), exec('strikeThrough'), {
@@ -395,15 +395,6 @@ export class RightPanel {
395
395
  }),
396
396
  'doc:styles.backgroundColor',
397
397
  ),
398
- this._colorField(
399
- this.opts.t('rightPanel.doc.contentBackground'),
400
- doc.styles.contentBackgroundColor,
401
- (v) =>
402
- this.opts.store.update((d) => {
403
- d.styles.contentBackgroundColor = v;
404
- }),
405
- 'doc:styles.contentBackgroundColor',
406
- ),
407
398
  this._textField(
408
399
  this.opts.t('rightPanel.doc.fontFamily'),
409
400
  doc.styles.fontFamily,
@@ -562,6 +553,24 @@ export class RightPanel {
562
553
  `section:${section.id}:attrs.columnGap`,
563
554
  )
564
555
  : null,
556
+ section.layout !== '1' && !a.preserveColumnsOnMobile
557
+ ? this._numberField(
558
+ this.opts.t('rightPanel.section.stackedGap'),
559
+ a.columnStackedGap ?? 0,
560
+ 0,
561
+ 64,
562
+ (v) => {
563
+ const g = Math.max(0, Math.min(64, Math.round(v)));
564
+ this.opts.store.update((d) => {
565
+ const s = findSection(d, section.id);
566
+ if (s) s.attrs.columnStackedGap = g > 0 ? g : undefined;
567
+ });
568
+ },
569
+ 1,
570
+ `section:${section.id}:attrs.stackedGap`,
571
+ this.opts.t('rightPanel.section.stackedGapHelp'),
572
+ )
573
+ : null,
565
574
  section.layout !== '1'
566
575
  ? this._switchField(
567
576
  this.opts.t('rightPanel.section.preserveMobile'),
@@ -1137,6 +1146,24 @@ export class RightPanel {
1137
1146
  onChange: (v: number) => void,
1138
1147
  step = 1,
1139
1148
  focusToken?: string,
1149
+ help?: string,
1150
+ ): HTMLElement {
1151
+ const field = this._buildNumberField(label, value, min, max, onChange, step, focusToken);
1152
+ if (!help) return field;
1153
+ return h('div', { class: 'sm-field', style: 'flex-direction:column;align-items:flex-start;gap:6px;' }, [
1154
+ field,
1155
+ h('div', { class: 'sm-field__help' }, [help]),
1156
+ ]);
1157
+ }
1158
+
1159
+ private _buildNumberField(
1160
+ label: string,
1161
+ value: number,
1162
+ min: number,
1163
+ max: number,
1164
+ onChange: (v: number) => void,
1165
+ step = 1,
1166
+ focusToken?: string,
1140
1167
  ) {
1141
1168
  const snap = (n: number) => {
1142
1169
  const c = Math.min(max, Math.max(min, n));
@@ -108,7 +108,6 @@ export const zhCNMessages: SimpleMailMessages = {
108
108
  'rightPanel.doc.contentWidthPlaceholder': '如 600、600px、100%',
109
109
  'rightPanel.doc.globalStyles': '全局样式',
110
110
  'rightPanel.doc.pageBackground': '页面背景',
111
- 'rightPanel.doc.contentBackground': '内容背景',
112
111
  'rightPanel.doc.fontFamily': '字体',
113
112
  'rightPanel.doc.lineHeight': '行高',
114
113
  'rightPanel.doc.lineHeightPlaceholder': '如 1.25 或 24px',
@@ -140,6 +139,8 @@ export const zhCNMessages: SimpleMailMessages = {
140
139
  'rightPanel.section.widthHelp': '留空=与邮件同宽;自适应等价于清空宽度',
141
140
  'rightPanel.section.padding': '内边距',
142
141
  'rightPanel.section.columnGap': '列间距 (px)',
142
+ 'rightPanel.section.stackedGap': '堆叠纵向间距 (px)',
143
+ 'rightPanel.section.stackedGapHelp': '小屏堆叠为单列后,相邻列之间的额外纵向间距;0 表示沿用块自身内边距。',
143
144
  'rightPanel.section.preserveMobile': '小屏仍并排显示多列(可能字很窄)',
144
145
  'rightPanel.section.preserveMobileHelp': '开启后 MJML 会生成 mj-group,移动端预览/导出与默认「小屏堆叠列」行为不同。',
145
146
  'rightPanel.section.columnLayout': '列布局',
@@ -415,7 +416,6 @@ export const enUSMessages: SimpleMailMessages = {
415
416
  'rightPanel.doc.contentWidthPlaceholder': 'e.g. 600, 600px, or 100%',
416
417
  'rightPanel.doc.globalStyles': 'Global styles',
417
418
  'rightPanel.doc.pageBackground': 'Page background',
418
- 'rightPanel.doc.contentBackground': 'Content background',
419
419
  'rightPanel.doc.fontFamily': 'Font family',
420
420
  'rightPanel.doc.lineHeight': 'Line height',
421
421
  'rightPanel.doc.lineHeightPlaceholder': 'e.g. 1.25 or 24px',
@@ -447,6 +447,8 @@ export const enUSMessages: SimpleMailMessages = {
447
447
  'rightPanel.section.widthHelp': 'Empty = email width. Auto is equivalent to clearing the width.',
448
448
  'rightPanel.section.padding': 'Padding',
449
449
  'rightPanel.section.columnGap': 'Column gap (px)',
450
+ 'rightPanel.section.stackedGap': 'Stacked vertical gap (px)',
451
+ 'rightPanel.section.stackedGapHelp': 'Extra vertical space between columns once they stack on small screens. 0 keeps each block own padding.',
450
452
  'rightPanel.section.preserveMobile': 'Keep columns side by side on small screens',
451
453
  'rightPanel.section.preserveMobileHelp': 'When enabled, MJML outputs mj-group. Mobile preview/export differs from the default stacked-column behavior.',
452
454
  'rightPanel.section.columnLayout': 'Column layout',