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.
- package/package.json +1 -1
- package/packages/core/dist/index.d.ts +5 -1
- package/packages/core/dist/index.js +1198 -1120
- 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 +22 -3
- package/packages/core/src/editor/InlineEditor.ts +186 -64
- package/packages/core/src/editor/RichTextToolbar.ts +16 -2
- package/packages/core/src/editor/RightPanel.ts +0 -9
- package/packages/core/src/i18n/index.ts +0 -2
- package/packages/core/src/types.ts +5 -1
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
}
|
|
338
|
-
|
|
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
|
-
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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:
|
|
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
|
-
/**
|
|
855
|
-
function
|
|
856
|
-
const
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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': '
|
|
1027
|
-
'3': '
|
|
1028
|
-
'4': '
|
|
1029
|
-
'5': '
|
|
1030
|
-
'6': '
|
|
1031
|
-
'7': '
|
|
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
|
-
|
|
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
|
-
|
|
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 等别名) */
|