mail-editor-pancake 0.2.3 → 0.2.6
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 +126 -126
- package/packages/blocks/dist/index.js.map +1 -1
- package/packages/blocks/src/button.ts +9 -3
- package/packages/core/dist/index.d.ts +60 -1
- package/packages/core/dist/index.js +1845 -1606
- package/packages/core/dist/index.js.map +1 -1
- package/packages/core/dist/style.css +1 -1
- package/packages/core/src/editor/Canvas.ts +9 -11
- package/packages/core/src/editor/Editor.ts +23 -3
- package/packages/core/src/editor/InlineEditor.ts +186 -64
- package/packages/core/src/editor/LeftPanel.ts +41 -7
- package/packages/core/src/editor/RichTextToolbar.ts +16 -2
- package/packages/core/src/editor/RightPanel.ts +31 -12
- package/packages/core/src/editor/styles.css +4 -0
- package/packages/core/src/i18n/index.ts +4 -2
- package/packages/core/src/index.ts +6 -0
- package/packages/core/src/renderer/index.ts +2 -0
- package/packages/core/src/renderer/mjml.ts +16 -7
- package/packages/core/src/types.ts +19 -1
- package/packages/core/src/utils/columnWidths.ts +151 -0
- package/packages/core/src/utils/dynamicVariantHtml.ts +2 -7
- package/packages/core/src/utils/html.ts +31 -0
- package/packages/core/src/utils/link.ts +51 -0
- package/packages/core/src/utils/paletteDrop.ts +46 -0
|
@@ -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
|
}
|
|
@@ -2,6 +2,7 @@ import Sortable from 'sortablejs';
|
|
|
2
2
|
import type { Registry } from '../registry/registry';
|
|
3
3
|
import type { BlockDefinition, SectionLayout } from '../types';
|
|
4
4
|
import { clear, h } from '../utils/dom';
|
|
5
|
+
import { paletteDropIsSectionLevel } from '../utils/paletteDrop';
|
|
5
6
|
import type { SimpleMailT } from '../i18n';
|
|
6
7
|
|
|
7
8
|
export interface LeftPanelOptions {
|
|
@@ -13,6 +14,8 @@ export interface LeftPanelOptions {
|
|
|
13
14
|
customPaletteTooltipSuffix?: string;
|
|
14
15
|
/** 不显示在左栏的 block type(仍可在 Registry 中 createBlock) */
|
|
15
16
|
hiddenPaletteBlockTypes?: string[];
|
|
17
|
+
/** 与画布一致:开启后带 `sectionAttrs.dynamicVariantKey` 的组合块会创建独立 Section */
|
|
18
|
+
enableDynamicVariantKey?: boolean;
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
interface LayoutCard {
|
|
@@ -66,14 +69,33 @@ export class LeftPanel {
|
|
|
66
69
|
...filterPalette(this.opts.registry.byCategory('custom')),
|
|
67
70
|
];
|
|
68
71
|
|
|
72
|
+
/** 自带布局的组件与布局卡片同组,拖拽时只能落在 Section 之间(见 paletteDropIsSectionLevel) */
|
|
73
|
+
const sectionLevel: BlockDefinition[] = [];
|
|
74
|
+
const blockLevel: BlockDefinition[] = [];
|
|
75
|
+
const dropOpts = { enableDynamicVariantKey: this.opts.enableDynamicVariantKey };
|
|
76
|
+
for (const def of paletteBlocks) {
|
|
77
|
+
if (paletteDropIsSectionLevel(def, this.opts.registry, dropOpts)) sectionLevel.push(def);
|
|
78
|
+
else blockLevel.push(def);
|
|
79
|
+
}
|
|
80
|
+
|
|
69
81
|
const wrap = h('div', { class: 'sm-blocks' });
|
|
70
82
|
|
|
71
83
|
wrap.append(this._renderLayoutGroup());
|
|
72
|
-
if (
|
|
84
|
+
if (sectionLevel.length) {
|
|
73
85
|
wrap.append(
|
|
74
|
-
this.
|
|
86
|
+
this._renderBlockCardsGroup(
|
|
87
|
+
this.opts.t('leftPanel.sectionBlocks'),
|
|
88
|
+
sectionLevel,
|
|
89
|
+
'sections',
|
|
90
|
+
),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
if (blockLevel.length) {
|
|
94
|
+
wrap.append(
|
|
95
|
+
this._renderBlockCardsGroup(
|
|
75
96
|
this.opts.blockGroupTitle ?? this.opts.t('leftPanel.components'),
|
|
76
|
-
|
|
97
|
+
blockLevel,
|
|
98
|
+
'blocks',
|
|
77
99
|
),
|
|
78
100
|
);
|
|
79
101
|
}
|
|
@@ -125,7 +147,16 @@ export class LeftPanel {
|
|
|
125
147
|
return def.name;
|
|
126
148
|
}
|
|
127
149
|
|
|
128
|
-
|
|
150
|
+
/**
|
|
151
|
+
* 组件卡片网格。`sourceGroup` 决定画布哪一层接收:
|
|
152
|
+
* - `blocks`:列内(Column);
|
|
153
|
+
* - `sections`:Section 之间(自带布局的组件,表现与布局卡片一致)。
|
|
154
|
+
*/
|
|
155
|
+
private _renderBlockCardsGroup(
|
|
156
|
+
title: string,
|
|
157
|
+
defs: BlockDefinition[],
|
|
158
|
+
sourceGroup: 'blocks' | 'sections',
|
|
159
|
+
): HTMLElement {
|
|
129
160
|
const grid = h('div', { class: 'sm-blocks__grid' });
|
|
130
161
|
for (const def of defs) {
|
|
131
162
|
const isCustom = def.category === 'custom';
|
|
@@ -141,8 +172,11 @@ export class LeftPanel {
|
|
|
141
172
|
h(
|
|
142
173
|
'div',
|
|
143
174
|
{
|
|
144
|
-
class:
|
|
145
|
-
|
|
175
|
+
class:
|
|
176
|
+
sourceGroup === 'sections'
|
|
177
|
+
? 'sm-block-card sm-block-card--section'
|
|
178
|
+
: 'sm-block-card',
|
|
179
|
+
'data-source-group': sourceGroup,
|
|
146
180
|
'data-block-type': def.type,
|
|
147
181
|
title: this._blockCardTitle(def),
|
|
148
182
|
},
|
|
@@ -151,7 +185,7 @@ export class LeftPanel {
|
|
|
151
185
|
);
|
|
152
186
|
}
|
|
153
187
|
Sortable.create(grid, {
|
|
154
|
-
group: { name:
|
|
188
|
+
group: { name: sourceGroup, pull: 'clone', put: false },
|
|
155
189
|
sort: false,
|
|
156
190
|
animation: 120,
|
|
157
191
|
ghostClass: 'sm-ghost',
|
|
@@ -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'), {
|
|
@@ -3,6 +3,7 @@ import { html as cmHtml } from '@codemirror/lang-html';
|
|
|
3
3
|
import { EditorState } from '@codemirror/state';
|
|
4
4
|
import type { Registry } from '../registry/registry';
|
|
5
5
|
import { htmlContainsMjmlTags } from '../utils/lockedMjml';
|
|
6
|
+
import { invalidHrefMessage } from '../utils/link';
|
|
6
7
|
import {
|
|
7
8
|
findBlockLocation,
|
|
8
9
|
findSection,
|
|
@@ -395,15 +396,6 @@ export class RightPanel {
|
|
|
395
396
|
}),
|
|
396
397
|
'doc:styles.backgroundColor',
|
|
397
398
|
),
|
|
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
399
|
this._textField(
|
|
408
400
|
this.opts.t('rightPanel.doc.fontFamily'),
|
|
409
401
|
doc.styles.fontFamily,
|
|
@@ -732,6 +724,8 @@ export class RightPanel {
|
|
|
732
724
|
onChange,
|
|
733
725
|
field.placeholder ?? '',
|
|
734
726
|
fp,
|
|
727
|
+
field.validate ??
|
|
728
|
+
(field.type === 'url' ? (v: string) => invalidHrefMessage(v, this.opts.t) : undefined),
|
|
735
729
|
);
|
|
736
730
|
if (field.help) row.append(h('div', { class: 'sm-field__help' }, [field.help]));
|
|
737
731
|
return row;
|
|
@@ -850,14 +844,25 @@ export class RightPanel {
|
|
|
850
844
|
opt.selected = true;
|
|
851
845
|
sel.insertBefore(opt, sel.firstChild);
|
|
852
846
|
}
|
|
847
|
+
const hrefWarnEl = h('div', { class: 'sm-field__warn', style: 'display:none' });
|
|
848
|
+
const syncHrefWarn = (v: string) => {
|
|
849
|
+
const msg = invalidHrefMessage(v, this.opts.t);
|
|
850
|
+
hrefWarnEl.textContent = msg ?? '';
|
|
851
|
+
hrefWarnEl.style.display = msg ? '' : 'none';
|
|
852
|
+
};
|
|
853
853
|
const inp = h('input', {
|
|
854
854
|
class: 'sm-input sm-social-link-list__href',
|
|
855
855
|
type: 'url',
|
|
856
856
|
placeholder: 'https://',
|
|
857
857
|
value: rowData.href,
|
|
858
858
|
...(focusPrefix ? { 'data-sm-focus': `${focusPrefix}:row:${i}:href` } : {}),
|
|
859
|
-
oninput: (e: Event) =>
|
|
859
|
+
oninput: (e: Event) => {
|
|
860
|
+
const v = (e.target as HTMLInputElement).value;
|
|
861
|
+
syncItem(i, { href: v });
|
|
862
|
+
syncHrefWarn(v);
|
|
863
|
+
},
|
|
860
864
|
});
|
|
865
|
+
syncHrefWarn(rowData.href);
|
|
861
866
|
const rm = h(
|
|
862
867
|
'button',
|
|
863
868
|
{
|
|
@@ -869,7 +874,7 @@ export class RightPanel {
|
|
|
869
874
|
['×'],
|
|
870
875
|
);
|
|
871
876
|
mainRow.append(sel, inp, rm);
|
|
872
|
-
rowWrap.append(mainRow);
|
|
877
|
+
rowWrap.append(mainRow, hrefWarnEl);
|
|
873
878
|
|
|
874
879
|
const extras = h('div', { class: 'sm-social-link-list__row-extras' });
|
|
875
880
|
const bgResolved = defaultSocialIconBackground(rowData.network);
|
|
@@ -943,7 +948,16 @@ export class RightPanel {
|
|
|
943
948
|
onChange: (v: string) => void,
|
|
944
949
|
placeholder = '',
|
|
945
950
|
focusToken?: string,
|
|
951
|
+
validate?: (v: string) => string | null,
|
|
946
952
|
) {
|
|
953
|
+
const warnEl = h('div', { class: 'sm-field__warn', style: 'display:none' });
|
|
954
|
+
const syncWarn = (v: string) => {
|
|
955
|
+
if (!validate) return;
|
|
956
|
+
const msg = validate(v);
|
|
957
|
+
warnEl.textContent = msg ?? '';
|
|
958
|
+
warnEl.style.display = msg ? '' : 'none';
|
|
959
|
+
};
|
|
960
|
+
syncWarn(value);
|
|
947
961
|
return h('div', { class: 'sm-field' }, [
|
|
948
962
|
h('label', { class: 'sm-field__label' }, [label]),
|
|
949
963
|
h('input', {
|
|
@@ -952,8 +966,13 @@ export class RightPanel {
|
|
|
952
966
|
value,
|
|
953
967
|
placeholder,
|
|
954
968
|
...(focusToken ? { 'data-sm-focus': focusToken } : {}),
|
|
955
|
-
oninput: (e: Event) =>
|
|
969
|
+
oninput: (e: Event) => {
|
|
970
|
+
const v = (e.target as HTMLInputElement).value;
|
|
971
|
+
onChange(v);
|
|
972
|
+
syncWarn(v);
|
|
973
|
+
},
|
|
956
974
|
}),
|
|
975
|
+
warnEl,
|
|
957
976
|
]);
|
|
958
977
|
}
|
|
959
978
|
|
|
@@ -638,6 +638,10 @@
|
|
|
638
638
|
font-size: 12px;
|
|
639
639
|
color: var(--sm-text);
|
|
640
640
|
}
|
|
641
|
+
/* 自带布局的组件:与布局卡片同组,落点在 Section 之间而非列内 */
|
|
642
|
+
.sm-block-card--section {
|
|
643
|
+
border-style: dashed;
|
|
644
|
+
}
|
|
641
645
|
|
|
642
646
|
/* 中栏:画布 ------------------------------------------------------------- */
|
|
643
647
|
.sm-canvas-wrap {
|
|
@@ -75,6 +75,7 @@ export const zhCNMessages: SimpleMailMessages = {
|
|
|
75
75
|
'topbar.exitFullscreen': '退出全屏',
|
|
76
76
|
'leftPanel.layout': '布局',
|
|
77
77
|
'leftPanel.components': '组件',
|
|
78
|
+
'leftPanel.sectionBlocks': '组合布局',
|
|
78
79
|
'leftPanel.customTooltipSuffix': 'Navimow 定制组件,拖入列内使用',
|
|
79
80
|
'leftPanel.oneColumn': '一列',
|
|
80
81
|
'leftPanel.multiColumn': '多列',
|
|
@@ -100,6 +101,7 @@ export const zhCNMessages: SimpleMailMessages = {
|
|
|
100
101
|
'rightPanel.block.restoreProps': '恢复为属性模式',
|
|
101
102
|
'rightPanel.block.unregistered': '未注册的组件: {type}',
|
|
102
103
|
'rightPanel.block.mjmlWarning': '检测到 MJML 标签(如 <mj-text>)。此处应只写 HTML,否则预览会显示源码。简单链接请用「文本」块。',
|
|
104
|
+
'rightPanel.block.invalidLink': '该链接无法跳转(如 1234、#、缺少 https://)。Outlook 网页端会清除此类链接,导致按钮/图标不可见。',
|
|
103
105
|
'rightPanel.doc.title': '邮件设置',
|
|
104
106
|
'rightPanel.doc.layoutTitle': '版式',
|
|
105
107
|
'rightPanel.doc.subject': '主题',
|
|
@@ -108,7 +110,6 @@ export const zhCNMessages: SimpleMailMessages = {
|
|
|
108
110
|
'rightPanel.doc.contentWidthPlaceholder': '如 600、600px、100%',
|
|
109
111
|
'rightPanel.doc.globalStyles': '全局样式',
|
|
110
112
|
'rightPanel.doc.pageBackground': '页面背景',
|
|
111
|
-
'rightPanel.doc.contentBackground': '内容背景',
|
|
112
113
|
'rightPanel.doc.fontFamily': '字体',
|
|
113
114
|
'rightPanel.doc.lineHeight': '行高',
|
|
114
115
|
'rightPanel.doc.lineHeightPlaceholder': '如 1.25 或 24px',
|
|
@@ -384,6 +385,7 @@ export const enUSMessages: SimpleMailMessages = {
|
|
|
384
385
|
'topbar.exitFullscreen': 'Exit fullscreen',
|
|
385
386
|
'leftPanel.layout': 'Layout',
|
|
386
387
|
'leftPanel.components': 'Components',
|
|
388
|
+
'leftPanel.sectionBlocks': 'Layout Blocks',
|
|
387
389
|
'leftPanel.customTooltipSuffix': 'Custom Navimow block. Drag it into a column.',
|
|
388
390
|
'leftPanel.oneColumn': 'One column',
|
|
389
391
|
'leftPanel.multiColumn': 'Columns',
|
|
@@ -409,6 +411,7 @@ export const enUSMessages: SimpleMailMessages = {
|
|
|
409
411
|
'rightPanel.block.restoreProps': 'Restore property mode',
|
|
410
412
|
'rightPanel.block.unregistered': 'Unregistered block: {type}',
|
|
411
413
|
'rightPanel.block.mjmlWarning': 'MJML tags were detected, such as <mj-text>. Write HTML only here, otherwise the preview will show source code. Use a Text block for simple links.',
|
|
414
|
+
'rightPanel.block.invalidLink': 'This link is not navigable (e.g. 1234, #, or a URL without https://). Outlook on the web removes such links, which makes the button or icon invisible.',
|
|
412
415
|
'rightPanel.doc.title': 'Email settings',
|
|
413
416
|
'rightPanel.doc.layoutTitle': 'Layout',
|
|
414
417
|
'rightPanel.doc.subject': 'Subject',
|
|
@@ -417,7 +420,6 @@ export const enUSMessages: SimpleMailMessages = {
|
|
|
417
420
|
'rightPanel.doc.contentWidthPlaceholder': 'e.g. 600, 600px, or 100%',
|
|
418
421
|
'rightPanel.doc.globalStyles': 'Global styles',
|
|
419
422
|
'rightPanel.doc.pageBackground': 'Page background',
|
|
420
|
-
'rightPanel.doc.contentBackground': 'Content background',
|
|
421
423
|
'rightPanel.doc.fontFamily': 'Font family',
|
|
422
424
|
'rightPanel.doc.lineHeight': 'Line height',
|
|
423
425
|
'rightPanel.doc.lineHeightPlaceholder': 'e.g. 1.25 or 24px',
|
|
@@ -21,6 +21,12 @@ export {
|
|
|
21
21
|
resolveLockedMjmlCanvasContent,
|
|
22
22
|
} from './utils/lockedMjml';
|
|
23
23
|
export { renderDoc, replaceVariables } from './renderer';
|
|
24
|
+
export { isRenderableHref, resolveHref, invalidHrefMessage } from './utils/link';
|
|
25
|
+
export {
|
|
26
|
+
paletteDropHasSectionLayout,
|
|
27
|
+
paletteDropIsSectionLevel,
|
|
28
|
+
resolvePaletteDropResult,
|
|
29
|
+
} from './utils/paletteDrop';
|
|
24
30
|
export { annotateDynamicVariantHtmlAttributes } from './utils/dynamicVariantHtml';
|
|
25
31
|
export {
|
|
26
32
|
DYNAMIC_VARIANT_HTML_ATTR,
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
normalizeEmailListsInHtml,
|
|
7
7
|
resolveGlobalListIndentPx,
|
|
8
8
|
} from '../utils/emailListStyles';
|
|
9
|
+
import { inlineColumnWidthsInHtml } from '../utils/columnWidths';
|
|
9
10
|
import { docToMjml } from './mjml';
|
|
10
11
|
|
|
11
12
|
export interface RenderResult {
|
|
@@ -45,6 +46,7 @@ export function renderDoc(
|
|
|
45
46
|
|
|
46
47
|
let html = compiled.html;
|
|
47
48
|
html = annotateDynamicVariantHtmlAttributes(html, doc);
|
|
49
|
+
html = inlineColumnWidthsInHtml(html);
|
|
48
50
|
html = normalizeEmailListsInHtml(html, resolveGlobalListIndentPx(doc.styles));
|
|
49
51
|
if (opts.withSampleVariables) {
|
|
50
52
|
html = replaceVariables(html, doc.variables);
|