mail-editor-pancake 0.2.3 → 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);
@@ -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,
@@ -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',
@@ -417,7 +416,6 @@ export const enUSMessages: SimpleMailMessages = {
417
416
  'rightPanel.doc.contentWidthPlaceholder': 'e.g. 600, 600px, or 100%',
418
417
  'rightPanel.doc.globalStyles': 'Global styles',
419
418
  'rightPanel.doc.pageBackground': 'Page background',
420
- 'rightPanel.doc.contentBackground': 'Content background',
421
419
  'rightPanel.doc.fontFamily': 'Font family',
422
420
  'rightPanel.doc.lineHeight': 'Line height',
423
421
  'rightPanel.doc.lineHeightPlaceholder': 'e.g. 1.25 or 24px',
@@ -24,8 +24,12 @@ export interface EmailMeta {
24
24
  }
25
25
 
26
26
  export interface GlobalStyles {
27
+ /**
28
+ * 邮件背景色:导出时写入 `<mj-body background-color>`,画布内容纸取同一值。
29
+ * 旧字段 `contentBackgroundColor`(内容背景)已废弃并合并到此处,
30
+ * 由 `createDefaultDoc` 在读入旧文档时迁移。
31
+ */
27
32
  backgroundColor: string;
28
- contentBackgroundColor: string;
29
33
  fontFamily: string;
30
34
  fontSize: string;
31
35
  /** 全局字重:数值档 300 | 400 | 500 | 600 | 700(兼容读写 normal / bold 等别名) */