mail-editor-pancake 0.2.6 → 0.2.8

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.
@@ -292,7 +292,7 @@ export class Canvas {
292
292
  this.inner.style.fontSize = doc.styles.fontSize;
293
293
  this.inner.style.fontWeight = normalizeFontWeightStep(doc.styles.fontWeight);
294
294
  this.inner.style.color = doc.styles.color;
295
- this.inner.style.lineHeight = doc.styles.lineHeight ?? '1.25';
295
+ this.inner.style.lineHeight = doc.styles.lineHeight ?? '1.5';
296
296
  this.inner.style.setProperty('--sm-editor-link-color', doc.styles.linkColor);
297
297
  this.inner.style.setProperty(
298
298
  '--sm-list-indent-default',
@@ -1253,7 +1253,7 @@ function createDefaultDoc(partial?: Partial<EmailDoc>): EmailDoc {
1253
1253
  fontWeight: '400',
1254
1254
  color: '#433f3f',
1255
1255
  linkColor: '#ff5a00',
1256
- lineHeight: '1.25',
1256
+ lineHeight: '1.5',
1257
1257
  ...migrateLegacyStyles(partial?.styles ?? {}),
1258
1258
  };
1259
1259
  return {
@@ -1268,7 +1268,7 @@ function createDefaultDoc(partial?: Partial<EmailDoc>): EmailDoc {
1268
1268
  styles: {
1269
1269
  ...mergedStyles,
1270
1270
  fontWeight: mergedStyles.fontWeight ?? '400',
1271
- lineHeight: mergedStyles.lineHeight ?? '1.25',
1271
+ lineHeight: mergedStyles.lineHeight ?? '1.5',
1272
1272
  },
1273
1273
  sections: partial?.sections ?? [],
1274
1274
  };
@@ -45,6 +45,14 @@ import {
45
45
  hasRichHtmlLineBreak,
46
46
  getRichHtmlPlainText,
47
47
  } from '../utils/richHtmlEmpty';
48
+ import {
49
+ convertFontTags,
50
+ firstTextNode,
51
+ lastTextNode,
52
+ mergeRedundantSpans,
53
+ purgeInlineStyleDecl,
54
+ unwrapEmptyInlineShells,
55
+ } from '../utils/richTextStyle';
48
56
  import { isColorPickerOpen } from './ColorPickerPopover';
49
57
 
50
58
  export interface InlineEditorOptions {
@@ -225,10 +233,33 @@ export class InlineEditor {
225
233
  this.saveSelection();
226
234
  this._emitSelection();
227
235
  return;
228
- } else {
229
- richTextExecCommand('styleWithCSS', false, isListCmd ? 'false' : 'true');
230
- richTextExecCommand(command, false, value);
231
236
  }
237
+ // 文字色 / 背景色:单块内联选区走 purge 流程,化简嵌套 span
238
+ // (execCommand 只会再包一层 span,不清旧声明,导致内层旧色盖过新色)。
239
+ // 跨块时 extractContents 会把块级内容塞进一个 <span>,产生 <span>包<p> 非法嵌套,
240
+ // 此时退回 execCommand(浏览器按文本节点逐段上色,跨块反而正确)。
241
+ if (
242
+ sel?.rangeCount &&
243
+ (command === 'foreColor' || command === 'hiliteColor') &&
244
+ value &&
245
+ !sel.getRangeAt(0).collapsed &&
246
+ isRangeInlineOnly(sel.getRangeAt(0), this.el)
247
+ ) {
248
+ const prop = command === 'foreColor' ? 'color' : 'background-color';
249
+ this._applyInlineStyleDecl(prop, value);
250
+ // 链接内改文字色时同步到 <a> 自身:下划线由 <a> 绘制,不同步会出现「线与字异色」
251
+ if (command === 'foreColor') this._syncAnchorColor(value);
252
+ if (command === 'hiliteColor') {
253
+ this.pendingHiliteColor = value ?? null;
254
+ this.pendingHiliteAnchor = hiliteAnchor;
255
+ }
256
+ this.saveSelection();
257
+ this._emitSelection();
258
+ return;
259
+ }
260
+ // 跨块 / 折叠光标 / 其它命令:走 execCommand
261
+ richTextExecCommand('styleWithCSS', false, isListCmd ? 'false' : 'true');
262
+ richTextExecCommand(command, false, value);
232
263
  // 链接内改文字色时同步到 <a> 自身:下划线由 <a> 绘制,不同步会出现「线与字异色」
233
264
  if (command === 'foreColor' && value) this._syncAnchorColor(value);
234
265
  if (command === 'hiliteColor') {
@@ -316,7 +347,7 @@ export class InlineEditor {
316
347
  applyFontSize(px: string) {
317
348
  const css = normalizeFontSizeCss(px);
318
349
  if (!css) return;
319
- this._wrapInlineStyle(`font-size:${css};`);
350
+ this.applyInlineStyle('font-size', css);
320
351
  }
321
352
 
322
353
  /**
@@ -324,14 +355,29 @@ export class InlineEditor {
324
355
  * 有选区包裹内容;折叠光标插入 typing span,后续输入沿用。
325
356
  */
326
357
  applyFontWeight(weight: string) {
327
- this._wrapInlineStyle(`font-weight:${weight};`);
358
+ const w = String(weight ?? '').trim();
359
+ if (!w) return;
360
+ this.applyInlineStyle('font-weight', w);
361
+ }
362
+
363
+ /**
364
+ * 统一的内联样式入口:把 `prop:value` 应用到当前选区。
365
+ * 目前供字号 / 字重使用;文字色、背景色等同样适用(前者另有 <a> 同步逻辑,暂不切)。
366
+ */
367
+ applyInlineStyle(prop: string, value: string) {
368
+ this._applyInlineStyleDecl(prop, value);
328
369
  }
329
370
 
330
371
  /**
331
- * 用 `<span style="...">` 包裹选区写入内联样式。
372
+ * 用 `<span style="prop:value">` 包裹选区写入内联样式。
373
+ *
374
+ * 关键:包裹前先把选区内**同属性**的旧声明清掉——否则内层旧值会覆盖外层新值
375
+ * (选了更大范围改字号,被包含的旧字号却不变)。清完变空壳的 span 直接拆掉,
376
+ * 避免 `<span style="">` 这类无意义标签堆积。
377
+ *
332
378
  * 有选区则包裹内容并重新选中;折叠光标则插入零宽占位 span,后续输入沿用。
333
379
  */
334
- private _wrapInlineStyle(style: string) {
380
+ private _applyInlineStyleDecl(prop: string, value: string) {
335
381
  this.edited = true;
336
382
  this._ensureFocus();
337
383
  this._restoreSelection();
@@ -341,15 +387,28 @@ export class InlineEditor {
341
387
  if (!this.el.contains(range.startContainer) || !this.el.contains(range.endContainer)) return;
342
388
 
343
389
  const span = document.createElement('span');
344
- span.setAttribute('style', style);
390
+ span.setAttribute('style', `${prop}:${value};`);
345
391
  try {
346
392
  if (!range.collapsed) {
347
- span.appendChild(range.extractContents());
393
+ const frag = range.extractContents();
394
+ // 先把 <font size> 等转成 span:字号在属性上、不在 style 上,
395
+ // 不转的话 purge 按 [style] 抓不到,外层新字号盖不过 <font> 的 UA 字号
396
+ convertFontTags(frag);
397
+ purgeInlineStyleDecl(frag, prop);
398
+ span.appendChild(frag);
348
399
  range.insertNode(span);
349
- const newRange = document.createRange();
350
- newRange.selectNodeContents(span);
351
- sel.removeAllRanges();
352
- sel.addRange(newRange);
400
+ // 先记下文本节点:下面的 span 合并可能把 span 自身拆掉,而文本节点引用仍然有效
401
+ const first = firstTextNode(span);
402
+ const last = lastTextNode(span);
403
+ // extractContents 在边界 split 会留下空壳 span(如 <span style="font-size:20px;"></span>),
404
+ // 它们不在 fragment 里、purge 抓不到,mergeRedundantSpans 也只合并不拆空壳;
405
+ // 在此一并清掉,避免编辑期无意义标签堆积导致工具条状态读数不准
406
+ unwrapEmptyInlineShells(this.el);
407
+ mergeRedundantSpans(this.el);
408
+ // 合并相邻文本节点:跨节点拆开的纯文本变量 {{user.name}} 合回来,
409
+ // 展示层高亮/替换才能匹配到;放 _selectSpanText 之前
410
+ this.el.normalize();
411
+ this._selectSpanText(sel, span, first, last);
353
412
  } else {
354
413
  // 标记 typing 占位:只设了样式却没输入内容时,提交前由 sanitizeRichHtml 拆掉空壳
355
414
  span.setAttribute('data-sm-typing', '1');
@@ -369,6 +428,21 @@ export class InlineEditor {
369
428
  this._emitSelection();
370
429
  }
371
430
 
431
+ /** 重新选中刚写入的这段内容(合并 span 后按文本节点重建,避免选区丢失) */
432
+ private _selectSpanText(sel: Selection, span: Element, first: Text | null, last: Text | null) {
433
+ const r = document.createRange();
434
+ if (first && last && first.isConnected && last.isConnected) {
435
+ r.setStart(first, 0);
436
+ r.setEnd(last, last.length);
437
+ } else if (span.isConnected) {
438
+ r.selectNodeContents(span);
439
+ } else {
440
+ return;
441
+ }
442
+ sel.removeAllRanges();
443
+ sel.addRange(r);
444
+ }
445
+
372
446
  /**
373
447
  * 下划线:链接内的下划线来自 <a> 自身的(UA 默认)样式。
374
448
  * CSS 里 text-decoration 由祖先向后代传播,**后代无法用 `text-decoration:none` 取消**,
@@ -862,6 +936,57 @@ function resolveInlineFormatsAtSelection(
862
936
  const TEXT_DECORATION_RE = /^text-decoration(-line)?\s*:/i;
863
937
  const COLOR_RE = /^color\s*:/i;
864
938
 
939
+ /** 块级标签:选区跨越这些元素时,extractContents + 包 span 会产生非法嵌套 */
940
+ const BLOCK_TAGS = new Set(['p', 'div', 'li', 'ul', 'ol', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
941
+
942
+ function isBlockElement(el: Node | null): boolean {
943
+ return el !== null && el.nodeType === 1 && BLOCK_TAGS.has((el as Element).tagName.toLowerCase());
944
+ }
945
+
946
+ /** 从 node 向上找到最近的块级祖先(不含 root 本身) */
947
+ function nearestBlockAncestor(node: Node | null, root: HTMLElement): Element | null {
948
+ let el: Node | null = node;
949
+ while (el && el !== root) {
950
+ if (isBlockElement(el)) return el as Element;
951
+ el = el.parentNode;
952
+ }
953
+ return null;
954
+ }
955
+
956
+ /**
957
+ * 选区是否完全落在「单块内联」上下文内,可以安全 extractContents + 包 span。
958
+ *
959
+ * 判断标准:选区两端(startContainer / endContainer)的最近块级祖先必须是同一个块级元素,
960
+ * 且选区内不含其它块级元素。跨块时 extractContents 会把块级内容塞进一个 <span>,
961
+ * 产生 `<span>包<p>` 的非法嵌套,此时应退回 execCommand(浏览器按文本节点逐段上色,跨块反而正确)。
962
+ *
963
+ * 同一个块级元素(p / div / li / h*)内的纯内联选区是安全的:
964
+ * extractContents 只取走块级元素内部的内联子节点,块级元素本身留在原地,
965
+ * 不会出现 `<span>包<p>` 的问题。
966
+ */
967
+ function isRangeInlineOnly(range: Range, root: HTMLElement): boolean {
968
+ const startBlock = nearestBlockAncestor(range.startContainer, root);
969
+ const endBlock = nearestBlockAncestor(range.endContainer, root);
970
+ // 两端都没有块级祖先(root 内纯内联)→ 视为同一内联上下文
971
+ if (!startBlock && !endBlock) return true;
972
+ // 一端在块内、另一端不在,或两端在不同块 → 跨块
973
+ if (startBlock !== endBlock) return false;
974
+ // 两端在同一个块内:还需确认选区中间没有夹着其它块级元素
975
+ // (如 <div><p>a</p><p>b</p></div> 里跨两个 <p> 选中,两端块级祖先是不同的 p,
976
+ // 上面已拦截;但 <p>text<div>block</div>text</p> 这种浏览器不修复的罕见结构,
977
+ // 两端块级祖先同为 p,但中间夹着 div,需额外检查)
978
+ const common = range.commonAncestorContainer;
979
+ const searchRoot: ParentNode =
980
+ common.nodeType === 1 ? (common as HTMLElement) : (common.parentNode as HTMLElement);
981
+ if (!searchRoot || searchRoot === root) return true;
982
+ for (const el of Array.from(searchRoot.querySelectorAll('*'))) {
983
+ if (!isBlockElement(el) || !range.intersectsNode(el)) continue;
984
+ // 块级元素被选区触及但不是两端共享的那一个块 → 跨块
985
+ if (el !== startBlock) return false;
986
+ }
987
+ return true;
988
+ }
989
+
865
990
  /** 选区内(折叠光标则取其所在)的 <a> 元素 */
866
991
  function collectAnchorsInRange(range: Range, root: HTMLElement): HTMLAnchorElement[] {
867
992
  const found = new Set<HTMLAnchorElement>();
@@ -1006,8 +1131,11 @@ function plainTextToRichHtml(text: string): string {
1006
1131
  export function sanitizeRichHtml(html: string): string {
1007
1132
  const tpl = document.createElement('template');
1008
1133
  tpl.innerHTML = html;
1134
+ // 先把 <font color/size/face> 转成 span,与 apply 路径共用同一转换逻辑
1135
+ convertFontTags(tpl.content);
1009
1136
  walk(tpl.content);
1010
1137
  mergeRedundantSpans(tpl.content);
1138
+ unwrapEmptyInlineShells(tpl.content);
1011
1139
  return tpl.innerHTML.replace(/\u200b/g, '');
1012
1140
 
1013
1141
  function walk(root: Node) {
@@ -1029,22 +1157,6 @@ export function sanitizeRichHtml(html: string): string {
1029
1157
  return;
1030
1158
  }
1031
1159
  }
1032
- // <font color="..." size="..." face="..."> → 转成 span style
1033
- if (tag === 'font') {
1034
- const span = document.createElement('span');
1035
- const color = el.getAttribute('color');
1036
- const size = el.getAttribute('size');
1037
- const face = el.getAttribute('face');
1038
- const styles: string[] = [el.getAttribute('style') ?? ''];
1039
- if (color) styles.push(`color:${color}`);
1040
- if (size) styles.push(`font-size:${fontSizeFromLegacy(size)}`);
1041
- if (face) styles.push(`font-family:${face}`);
1042
- const cleaned = styles.filter(Boolean).join(';');
1043
- if (cleaned) span.setAttribute('style', cleaned);
1044
- while (el.firstChild) span.appendChild(el.firstChild);
1045
- el.parentNode?.replaceChild(span, el);
1046
- return;
1047
- }
1048
1160
  // 标签级白名单
1049
1161
  const allowed = [
1050
1162
  'a',
@@ -1100,35 +1212,6 @@ export function sanitizeRichHtml(html: string): string {
1100
1212
  }
1101
1213
  }
1102
1214
 
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
-
1132
1215
  function hasMarginStyle(el: Element): boolean {
1133
1216
  const style = el.getAttribute('style') ?? '';
1134
1217
  return /(?:^|;)\s*margin(?:-(?:top|right|bottom|left))?\s*:/i.test(style);
@@ -1138,19 +1221,3 @@ function appendStyle(el: Element, style: string) {
1138
1221
  const raw = (el.getAttribute('style') ?? '').trim();
1139
1222
  el.setAttribute('style', raw ? `${raw.replace(/;+\s*$/, '')};${style}` : style);
1140
1223
  }
1141
-
1142
- function fontSizeFromLegacy(size: string): string {
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)。
1146
- const map: Record<string, string> = {
1147
- '1': '10px',
1148
- '2': '13px',
1149
- '3': '16px',
1150
- '4': '18px',
1151
- '5': '24px',
1152
- '6': '32px',
1153
- '7': '48px',
1154
- };
1155
- return map[size] ?? size;
1156
- }
@@ -424,7 +424,7 @@ export class RightPanel {
424
424
  ),
425
425
  this._textField(
426
426
  this.opts.t('rightPanel.doc.lineHeight'),
427
- doc.styles.lineHeight ?? '1.25',
427
+ doc.styles.lineHeight ?? '1.5',
428
428
  (v) =>
429
429
  this.opts.store.update((d) => {
430
430
  d.styles.lineHeight = v;
@@ -29,7 +29,7 @@ export function docToMjml(doc: EmailDoc, registry: Registry): string {
29
29
  ${doc.meta.preheader ? `<mj-preview>${escapeAttr(doc.meta.preheader)}</mj-preview>` : ''}
30
30
  <mj-attributes>
31
31
  <mj-all font-family="${escapeAttr(attrs.fontFamily)}" />
32
- <mj-text font-size="${escapeAttr(attrs.fontSize)}" font-weight="${escapeAttr(normalizeFontWeightStep(attrs.fontWeight))}" color="${escapeAttr(attrs.color)}" line-height="${escapeAttr(attrs.lineHeight ?? '1.25')}" />
32
+ <mj-text font-size="${escapeAttr(attrs.fontSize)}" font-weight="${escapeAttr(normalizeFontWeightStep(attrs.fontWeight))}" color="${escapeAttr(attrs.color)}" line-height="${escapeAttr(attrs.lineHeight ?? '1.5')}" />
33
33
  <mj-class name="link" color="${escapeAttr(attrs.linkColor)}" />
34
34
  </mj-attributes>
35
35
  <mj-style>
@@ -34,6 +34,10 @@ interface ColumnWidthRule {
34
34
  maxWidth?: string;
35
35
  }
36
36
 
37
+ /** 行内 style 中是否已有 width / max-width 声明 */
38
+ const WIDTH_DECL_RE = /(?:^|;)\s*width\s*:/i;
39
+ const MAX_WIDTH_DECL_RE = /(?:^|;)\s*max-width\s*:/i;
40
+
37
41
  function firstDeclaration(block: string, prop: string): string | undefined {
38
42
  const m = new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*([^;]+)`, 'i').exec(block);
39
43
  if (!m) return undefined;
@@ -45,14 +49,19 @@ function parseColumnWidthRules(css: string): Map<string, ColumnWidthRule> {
45
49
  const rules = new Map<string, ColumnWidthRule>();
46
50
  if (!css) return rules;
47
51
 
48
- const re = /\.mj-column-per-([^\s,{]+)\s*\{([^}]*)\}/g;
49
- let m: RegExpExecArray | null;
50
- while ((m = re.exec(css))) {
51
- const width = firstDeclaration(m[2], 'width');
52
- if (!width) continue;
53
- const className = `mj-column-per-${m[1]}`;
54
- if (rules.has(className)) continue;
55
- rules.set(className, { width, maxWidth: firstDeclaration(m[2], 'max-width') });
52
+ const ruleRe = /\.mj-column-per-([^\s,{]+)\s*\{([^}]*)\}/g;
53
+ let match = ruleRe.exec(css);
54
+ while (match) {
55
+ const width = firstDeclaration(match[2], 'width');
56
+ if (!width) {
57
+ match = ruleRe.exec(css);
58
+ continue;
59
+ }
60
+ const className = `mj-column-per-${match[1]}`;
61
+ if (!rules.has(className)) {
62
+ rules.set(className, { width, maxWidth: firstDeclaration(match[2], 'max-width') });
63
+ }
64
+ match = ruleRe.exec(css);
56
65
  }
57
66
  return rules;
58
67
  }
@@ -70,7 +79,7 @@ function applyColumnWidth(el: Element, rule: ColumnWidthRule): boolean {
70
79
  if (currentWidth && currentWidth !== '100%') return false;
71
80
 
72
81
  let next = style;
73
- if (new RegExp('(?:^|;)\\s*width\\s*:', 'i').test(next)) {
82
+ if (WIDTH_DECL_RE.test(next)) {
74
83
  // 保留前导的 `;`,否则会吞掉上一条声明的分隔符
75
84
  next = next.replace(/((?:^|;)\s*)width\s*:\s*[^;]*/i, `$1width:${rule.width}`);
76
85
  } else {
@@ -79,11 +88,8 @@ function applyColumnWidth(el: Element, rule: ColumnWidthRule): boolean {
79
88
  }
80
89
 
81
90
  if (rule.maxWidth) {
82
- if (new RegExp('(?:^|;)\\s*max-width\\s*:', 'i').test(next)) {
83
- next = next.replace(
84
- /((?:^|;)\s*)max-width\s*:\s*[^;]*/i,
85
- `$1max-width:${rule.maxWidth}`,
86
- );
91
+ if (MAX_WIDTH_DECL_RE.test(next)) {
92
+ next = next.replace(/((?:^|;)\s*)max-width\s*:\s*[^;]*/i, `$1max-width:${rule.maxWidth}`);
87
93
  } else {
88
94
  next = `${next};max-width:${rule.maxWidth}`;
89
95
  }
@@ -17,7 +17,7 @@ export interface MjRawCellTypography {
17
17
  export function mjRawCellTypographyFromStyles(s: GlobalStyles): MjRawCellTypography {
18
18
  return {
19
19
  fontSize: (s.fontSize || '16px').trim(),
20
- lineHeight: (s.lineHeight || '1.25').trim(),
20
+ lineHeight: (s.lineHeight || '1.5').trim(),
21
21
  color: (s.color || '#433f3f').trim(),
22
22
  fontFamily: (s.fontFamily || 'sans-serif').trim(),
23
23
  fontWeight: normalizeFontWeightStep(s.fontWeight),
@@ -0,0 +1,197 @@
1
+ /**
2
+ * 富文本内联样式的「净化」工具。
3
+ *
4
+ * 背景:给一段文字改字号时,编辑器是「包一层 `<span style="font-size:B">`」。
5
+ * 如果这段文字里已经有 `<span style="font-size:A">`,内层 A 会继续覆盖外层 B
6
+ * ——表现为「选了更大的范围改字号,被包含的旧字号却不变」。
7
+ *
8
+ * 因此应用内联样式前需要先把选区内**同属性**的旧声明清掉:
9
+ * - 只留其他属性(如 color),`font-size` 被新值接管;
10
+ * - 清完变成空壳(无 style、无任何其他属性)的 span / font 直接拆掉,
11
+ * 避免出现 `<span style="">text</span>` 这种无意义标签。
12
+ *
13
+ * 部分重叠场景无需特殊处理:只有被选区取走的那部分内容进入 fragment,
14
+ * 留在原处的文本仍受旧元素管辖,旧字号自然保留。
15
+ */
16
+
17
+ /** 允许被拆掉的「纯样式壳」标签(语义标签如 a / strong / li 不在内) */
18
+ const UNWRAPPABLE_TAGS = new Set(['span', 'font']);
19
+ const UNWRAPPABLE_SELECTOR = 'span, font';
20
+
21
+ function declRegexp(prop: string): RegExp {
22
+ const escaped = prop.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
23
+ // 以 `;` 或串首起头,避免误伤 `background-color` 之于 `color`
24
+ return new RegExp(`(?:^|;)\\s*${escaped}\\s*:\\s*[^;]*`, 'i');
25
+ }
26
+
27
+ /** 从 style 串中删掉某条声明(保留其余声明与分隔符) */
28
+ export function removeStyleDecl(styleAttr: string, prop: string): string {
29
+ const next = styleAttr
30
+ .replace(declRegexp(prop), '')
31
+ .replace(/;{2,}/g, ';')
32
+ .replace(/^\s*;\s*/, '')
33
+ .trim();
34
+ return next === ';' ? '' : next;
35
+ }
36
+
37
+ /** 删掉元素 style 里的某条声明;样式清空时移除 style 属性本身。返回是否有变化。 */
38
+ export function stripInlineStyleDecl(el: Element, prop: string): boolean {
39
+ if (!el.hasAttribute('style')) return false;
40
+ const before = el.getAttribute('style') ?? '';
41
+ const after = removeStyleDecl(before, prop);
42
+ if (after === before) return false;
43
+ if (after) el.setAttribute('style', after);
44
+ else el.removeAttribute('style');
45
+ return true;
46
+ }
47
+
48
+ /** 纯样式壳:span/font 且一个属性都不剩 */
49
+ export function isEmptyInlineShell(el: Element): boolean {
50
+ return UNWRAPPABLE_TAGS.has(el.tagName.toLowerCase()) && !el.hasAttributes();
51
+ }
52
+
53
+ /**
54
+ * 无内容壳:span/font 内只剩空文本节点(textContent 为空,连零宽字符都没有),
55
+ * 也没有 br / img 等占位元素——`extractContents` 在边界 split 时常留下这种残壳,
56
+ * 它仍有 style 属性但没有任何可渲染内容,留着只会让 innerHTML 变脏。
57
+ * typing 占位 span 含零宽字符,textContent 非空,不会误伤。
58
+ */
59
+ export function isContentlessShell(el: Element): boolean {
60
+ if (!UNWRAPPABLE_TAGS.has(el.tagName.toLowerCase())) return false;
61
+ if (el.hasAttribute('data-sm-typing')) return false;
62
+ if (el.querySelector('br, img')) return false;
63
+ const text = (el.textContent ?? '').replace(/​/g, '');
64
+ return text.length === 0;
65
+ }
66
+
67
+ /** 把元素自身从 DOM 里拆掉,子节点上提到原位置 */
68
+ export function unwrapElement(el: Element): void {
69
+ const parent = el.parentNode;
70
+ if (!parent) return;
71
+ while (el.firstChild) parent.insertBefore(el.firstChild, el);
72
+ parent.removeChild(el);
73
+ }
74
+
75
+ /**
76
+ * 在片段 / 子树内清除某条 inline 声明,并把因此变成空壳的 span / font 拆掉。
77
+ * 倒序遍历:先处理内层,父元素变空壳后可以直接上提,不会漏拆。
78
+ */
79
+ export function purgeInlineStyleDecl(root: ParentNode, prop: string): void {
80
+ const targets: Element[] = [];
81
+ if (root.nodeType === 1) targets.push(root as Element);
82
+ targets.push(...Array.from(root.querySelectorAll('[style]')));
83
+
84
+ for (let i = targets.length - 1; i >= 0; i--) {
85
+ const el = targets[i];
86
+ if (!el.parentNode) continue;
87
+ stripInlineStyleDecl(el, prop);
88
+ if (isEmptyInlineShell(el)) unwrapElement(el);
89
+ }
90
+ }
91
+
92
+ /**
93
+ * 旧 `<font size="1..7">` → px。必须用浏览器实际渲染 legacy 档位的那张表
94
+ * (与 HTML 规范建议值一致:1=10, 2=13, 3=16, 4=18, 5=24, 6=32, 7=48),
95
+ * 否则编辑器里看到的字号与提交进邮件的字号不一致(如 size=5 显示 24px 却存成 20px)。
96
+ */
97
+ const LEGACY_FONT_SIZE_MAP: Record<string, string> = {
98
+ '1': '10px',
99
+ '2': '13px',
100
+ '3': '16px',
101
+ '4': '18px',
102
+ '5': '24px',
103
+ '6': '32px',
104
+ '7': '48px',
105
+ };
106
+
107
+ export function fontSizeFromLegacy(size: string): string {
108
+ return LEGACY_FONT_SIZE_MAP[size] ?? size;
109
+ }
110
+
111
+ /**
112
+ * 把子树内的 `<font color/size/face>` 统一转成 `<span style>`。
113
+ *
114
+ * 背景:`<font size>` 的字号在 **属性** 上、不在 style 上,
115
+ * `purgeInlineStyleDecl` 按 `[style]` 选择器抓取会漏掉它,
116
+ * 于是选中含 `<font size>` 的范围改字号时外层新值盖不过 `<font>` 的 UA 字号。
117
+ * 因此 apply 内联样式前需先把 `<font>` 转成 span,让字号落到 style 上,
118
+ * purge 才能命中。sanitize 提交前也复用同一个转换。
119
+ *
120
+ * 只做「标签转换」,不做属性清理:转出的 span 若仍带 color/face 不会空壳、不会 unwrap。
121
+ */
122
+ export function convertFontTags(root: ParentNode): void {
123
+ const fonts = Array.from(root.querySelectorAll('font'));
124
+ for (let i = fonts.length - 1; i >= 0; i--) {
125
+ const el = fonts[i];
126
+ const parent = el.parentNode;
127
+ if (!parent) continue;
128
+ const span = document.createElement('span');
129
+ const color = el.getAttribute('color');
130
+ const size = el.getAttribute('size');
131
+ const face = el.getAttribute('face');
132
+ const styles: string[] = [el.getAttribute('style') ?? ''];
133
+ if (color) styles.push(`color:${color}`);
134
+ if (size) styles.push(`font-size:${fontSizeFromLegacy(size)}`);
135
+ if (face) styles.push(`font-family:${face}`);
136
+ const cleaned = styles.filter((s) => s.trim()).join(';');
137
+ if (cleaned) span.setAttribute('style', cleaned);
138
+ while (el.firstChild) span.appendChild(el.firstChild);
139
+ parent.replaceChild(span, el);
140
+ }
141
+ }
142
+
143
+ /** 提交前清理:拆掉纯样式空壳 + 无内容残壳 */
144
+ export function unwrapEmptyInlineShells(root: ParentNode): void {
145
+ const list = Array.from(root.querySelectorAll(UNWRAPPABLE_SELECTOR));
146
+ for (let i = list.length - 1; i >= 0; i--) {
147
+ const el = list[i];
148
+ if (el.parentNode && (isEmptyInlineShell(el) || isContentlessShell(el))) unwrapElement(el);
149
+ }
150
+ }
151
+
152
+ /**
153
+ * 合并 style/class 完全相同的父子 span:反复改字号/字重会套出多层同义 span,
154
+ * 渲染结果一样但 HTML 变胖(逼近 Gmail 102KB 截断)。合并后内层子节点上提。
155
+ */
156
+ export function mergeRedundantSpans(root: ParentNode): void {
157
+ for (let pass = 0; pass < 5; pass++) {
158
+ let changed = false;
159
+ for (const el of Array.from(root.querySelectorAll('span'))) {
160
+ const parent = el.parentElement;
161
+ if (!parent || parent.tagName.toLowerCase() !== 'span') continue;
162
+ if (spanIdentity(parent) !== spanIdentity(el)) continue;
163
+ while (el.firstChild) parent.insertBefore(el.firstChild, el);
164
+ el.remove();
165
+ changed = true;
166
+ }
167
+ if (!changed) break;
168
+ }
169
+ }
170
+
171
+ /** span 的样式身份:class + 规范化后的 style,用于判断是否可合并 */
172
+ function spanIdentity(el: Element): string {
173
+ const style = (el.getAttribute('style') ?? '')
174
+ .replace(/\s+/g, ' ')
175
+ .trim()
176
+ .toLowerCase()
177
+ .replace(/;+$/, '');
178
+ return `${(el.getAttribute('class') ?? '').trim()} ${style}`;
179
+ }
180
+
181
+ export function firstTextNode(root: Node): Text | null {
182
+ if (root.nodeType === Node.TEXT_NODE) return root as Text;
183
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
184
+ return walker.nextNode() as Text | null;
185
+ }
186
+
187
+ export function lastTextNode(root: Node): Text | null {
188
+ if (root.nodeType === Node.TEXT_NODE) return root as Text;
189
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
190
+ let last: Text | null = null;
191
+ let n = walker.nextNode();
192
+ while (n) {
193
+ last = n as Text;
194
+ n = walker.nextNode();
195
+ }
196
+ return last;
197
+ }