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.
- package/package.json +1 -1
- package/packages/blocks/dist/index.js +1 -1
- package/packages/blocks/dist/index.js.map +1 -1
- package/packages/blocks/src/text.ts +7 -2
- package/packages/core/dist/index.js +1926 -1817
- package/packages/core/dist/index.js.map +1 -1
- package/packages/core/src/editor/Canvas.ts +1 -1
- package/packages/core/src/editor/Editor.ts +2 -2
- package/packages/core/src/editor/InlineEditor.ts +141 -74
- package/packages/core/src/editor/RightPanel.ts +1 -1
- package/packages/core/src/renderer/mjml.ts +1 -1
- package/packages/core/src/utils/columnWidths.ts +20 -14
- package/packages/core/src/utils/lockedMjml.ts +1 -1
- package/packages/core/src/utils/richTextStyle.ts +197 -0
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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',
|
|
390
|
+
span.setAttribute('style', `${prop}:${value};`);
|
|
345
391
|
try {
|
|
346
392
|
if (!range.collapsed) {
|
|
347
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
}
|
|
@@ -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.
|
|
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
|
|
49
|
-
let
|
|
50
|
-
while (
|
|
51
|
-
const width = firstDeclaration(
|
|
52
|
-
if (!width)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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 (
|
|
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 (
|
|
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.
|
|
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
|
+
}
|