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.
@@ -229,6 +229,8 @@ export class InlineEditor {
229
229
  richTextExecCommand('styleWithCSS', false, isListCmd ? 'false' : 'true');
230
230
  richTextExecCommand(command, false, value);
231
231
  }
232
+ // 链接内改文字色时同步到 <a> 自身:下划线由 <a> 绘制,不同步会出现「线与字异色」
233
+ if (command === 'foreColor' && value) this._syncAnchorColor(value);
232
234
  if (command === 'hiliteColor') {
233
235
  this.pendingHiliteColor = value ?? null;
234
236
  this.pendingHiliteAnchor = hiliteAnchor;
@@ -306,9 +308,15 @@ export class InlineEditor {
306
308
  this._restoreSelection();
307
309
  }
308
310
 
309
- /** 字号:走原生 fontSize 命令(styleWithCSS 下浏览器会写 span),与 foreColor 同类 */
311
+ /**
312
+ * 字号:直接写精确 px。
313
+ * 不能用 `execCommand('fontSize')`——它只接受 1–7 的 legacy 档位,
314
+ * 浏览器按自己的档位表渲染(如 5→24px、6→32px),回读时 20px 会变成 24px、28px 会变成 32px。
315
+ */
310
316
  applyFontSize(px: string) {
311
- this.exec('fontSize', pxToLegacyFontSize(px));
317
+ const css = normalizeFontSizeCss(px);
318
+ if (!css) return;
319
+ this._wrapInlineStyle(`font-size:${css};`);
312
320
  }
313
321
 
314
322
  /**
@@ -316,6 +324,14 @@ export class InlineEditor {
316
324
  * 有选区包裹内容;折叠光标插入 typing span,后续输入沿用。
317
325
  */
318
326
  applyFontWeight(weight: string) {
327
+ this._wrapInlineStyle(`font-weight:${weight};`);
328
+ }
329
+
330
+ /**
331
+ * 用 `<span style="...">` 包裹选区写入内联样式。
332
+ * 有选区则包裹内容并重新选中;折叠光标则插入零宽占位 span,后续输入沿用。
333
+ */
334
+ private _wrapInlineStyle(style: string) {
319
335
  this.edited = true;
320
336
  this._ensureFocus();
321
337
  this._restoreSelection();
@@ -324,35 +340,84 @@ export class InlineEditor {
324
340
  const range = sel.getRangeAt(0);
325
341
  if (!this.el.contains(range.startContainer) || !this.el.contains(range.endContainer)) return;
326
342
 
327
- if (!range.collapsed) {
328
- const span = document.createElement('span');
329
- span.setAttribute('style', `font-weight:${weight};`);
330
- try {
343
+ const span = document.createElement('span');
344
+ span.setAttribute('style', style);
345
+ try {
346
+ if (!range.collapsed) {
331
347
  span.appendChild(range.extractContents());
332
348
  range.insertNode(span);
333
349
  const newRange = document.createRange();
334
350
  newRange.selectNodeContents(span);
335
351
  sel.removeAllRanges();
336
352
  sel.addRange(newRange);
337
- } catch {
338
- return;
353
+ } else {
354
+ // 标记 typing 占位:只设了样式却没输入内容时,提交前由 sanitizeRichHtml 拆掉空壳
355
+ span.setAttribute('data-sm-typing', '1');
356
+ span.appendChild(document.createTextNode('\u200b'));
357
+ range.insertNode(span);
358
+ const caret = document.createRange();
359
+ caret.setStart(span.firstChild!, 1);
360
+ caret.collapse(true);
361
+ sel.removeAllRanges();
362
+ sel.addRange(caret);
339
363
  }
340
- } else {
341
- const span = document.createElement('span');
342
- span.setAttribute('style', `font-weight:${weight};`);
343
- span.appendChild(document.createTextNode('\u200b'));
344
- range.insertNode(span);
345
- const caret = document.createRange();
346
- caret.setStart(span.firstChild!, 1);
347
- caret.collapse(true);
348
- sel.removeAllRanges();
349
- sel.addRange(caret);
364
+ } catch {
365
+ return;
366
+ }
367
+
368
+ this.saveSelection();
369
+ this._emitSelection();
370
+ }
371
+
372
+ /**
373
+ * 下划线:链接内的下划线来自 <a> 自身的(UA 默认)样式。
374
+ * CSS 里 text-decoration 由祖先向后代传播,**后代无法用 `text-decoration:none` 取消**,
375
+ * 而 execCommand('underline') 只能写后代 → 链接文字的下划线永远点不掉。
376
+ * 所以选区在链接内时直接改写 <a> 自己的 text-decoration。
377
+ */
378
+ toggleUnderline() {
379
+ this.edited = true;
380
+ this._ensureFocus();
381
+ this._restoreSelection();
382
+ const sel = window.getSelection();
383
+ if (!sel || sel.rangeCount === 0) return;
384
+ const range = sel.getRangeAt(0);
385
+ if (!this.el.contains(range.startContainer) || !this.el.contains(range.endContainer)) return;
386
+
387
+ const anchors = collectAnchorsInRange(range, this.el);
388
+ if (!anchors.length) {
389
+ this.exec('underline');
390
+ return;
391
+ }
392
+ // 全部已有下划线则整体关闭,否则整体打开
393
+ const turnOn = !anchors.every(hasComputedUnderline);
394
+ for (const a of anchors) {
395
+ setStyleDecl(a, TEXT_DECORATION_RE, `text-decoration:${turnOn ? 'underline' : 'none'}`);
350
396
  }
351
397
 
352
398
  this.saveSelection();
353
399
  this._emitSelection();
354
400
  }
355
401
 
402
+ /** 选区内(或折叠光标所在)的 <a> 元素 */
403
+ private _anchorsAtSelection(): HTMLAnchorElement[] {
404
+ const sel = window.getSelection();
405
+ if (!sel || sel.rangeCount === 0) return [];
406
+ const range = sel.getRangeAt(0);
407
+ if (!this.el.contains(range.startContainer) || !this.el.contains(range.endContainer)) return [];
408
+ return collectAnchorsInRange(range, this.el);
409
+ }
410
+
411
+ /**
412
+ * 把文字色同步到选区所在的 <a>:下划线颜色取 <a> 的 color,
413
+ * 若内嵌 span 另设 color,会出现「字是 A 色、线是 B 色」且 B 无法通过工具条修改。
414
+ */
415
+ private _syncAnchorColor(color: string): void {
416
+ for (const a of this._anchorsAtSelection()) {
417
+ setStyleDecl(a, COLOR_RE, `color:${color}`);
418
+ }
419
+ }
420
+
356
421
  /** 内联编辑根节点(如 `.sm-text-content`)的盒模型矩形,供富文本条贴在正文区域外沿定位 */
357
422
  getAnchorRect(): DOMRect {
358
423
  return this.el.getBoundingClientRect();
@@ -675,7 +740,7 @@ export class InlineEditor {
675
740
  formats: {
676
741
  bold: richTextQueryCommandState('bold'),
677
742
  italic: richTextQueryCommandState('italic'),
678
- underline: richTextQueryCommandState('underline'),
743
+ underline: resolveUnderlineAtSelection(this.el, sel),
679
744
  strikethrough: richTextQueryCommandState('strikeThrough'),
680
745
  unorderedList:
681
746
  listFormats.unorderedList || richTextQueryCommandState('insertUnorderedList'),
@@ -794,6 +859,53 @@ function resolveInlineFormatsAtSelection(
794
859
  return { fontSize, fontWeight, foreColor, backColor };
795
860
  }
796
861
 
862
+ const TEXT_DECORATION_RE = /^text-decoration(-line)?\s*:/i;
863
+ const COLOR_RE = /^color\s*:/i;
864
+
865
+ /** 选区内(折叠光标则取其所在)的 <a> 元素 */
866
+ function collectAnchorsInRange(range: Range, root: HTMLElement): HTMLAnchorElement[] {
867
+ const found = new Set<HTMLAnchorElement>();
868
+ if (range.collapsed) {
869
+ const node = range.startContainer;
870
+ const el = node.nodeType === Node.TEXT_NODE ? node.parentElement : (node as HTMLElement);
871
+ const a = el?.closest('a') ?? null;
872
+ if (a && root.contains(a)) found.add(a);
873
+ } else {
874
+ for (const a of Array.from(root.querySelectorAll('a'))) {
875
+ if (range.intersectsNode(a)) found.add(a);
876
+ }
877
+ }
878
+ return [...found];
879
+ }
880
+
881
+ /** 元素当前是否真的有下划线(含 UA 默认、CSS 规则、inline style) */
882
+ function hasComputedUnderline(el: HTMLElement): boolean {
883
+ const line = window.getComputedStyle(el).textDecorationLine || '';
884
+ return /(^|\s)underline(\s|$)/.test(line);
885
+ }
886
+
887
+ /** 替换(无则追加)style 中匹配 propRe 的那条声明 */
888
+ function setStyleDecl(el: HTMLElement, propRe: RegExp, decl: string): void {
889
+ const raw = (el.getAttribute('style') ?? '').trim();
890
+ const kept = raw
891
+ .split(';')
892
+ .map((s) => s.trim())
893
+ .filter((s) => s && !propRe.test(s))
894
+ .join(';');
895
+ el.setAttribute('style', kept ? `${kept};${decl}` : decl);
896
+ }
897
+
898
+ /**
899
+ * 下划线状态:链接内的下划线由 <a> 决定,execCommand 的状态查询读不到它,
900
+ * 会出现「看着有线、按钮却是未激活」→ 点了没反应。故链接内以 <a> 的 computed 为准。
901
+ */
902
+ function resolveUnderlineAtSelection(root: HTMLElement, sel: Selection): boolean {
903
+ const host = selectionHostElement(sel, root);
904
+ const a = host?.closest('a') ?? null;
905
+ if (a && root.contains(a)) return hasComputedUnderline(a);
906
+ return richTextQueryCommandState('underline');
907
+ }
908
+
797
909
  /** 光标处文字色(foreColor / 链接局部 color;queryCommandValue 在 <a> 内常不准) */
798
910
  function resolveForeColorAtSelection(root: HTMLElement, host: HTMLElement): string | null {
799
911
  for (let el: HTMLElement | null = host; el && root.contains(el) && el !== root; el = el.parentElement) {
@@ -851,44 +963,13 @@ function selectionAnchorKey(range: Range): string {
851
963
  return `${range.startContainer.nodeType}:${range.startOffset}:${range.endContainer.nodeType}:${range.endOffset}`;
852
964
  }
853
965
 
854
- /** execCommand fontSize 只认 1–7,与工具条 px 互转 */
855
- function pxToLegacyFontSize(px: string): string {
856
- const map: Record<string, string> = {
857
- '10px': '1',
858
- '12px': '2',
859
- '13px': '3',
860
- '14px': '3',
861
- '15px': '4',
862
- '16px': '4',
863
- '18px': '4',
864
- '20px': '5',
865
- '24px': '6',
866
- '28px': '6',
867
- '32px': '7',
868
- };
869
- const key = px.trim().toLowerCase();
870
- if (map[key]) return map[key];
871
- const num = parseFloat(key);
872
- if (Number.isNaN(num)) return '4';
873
- const steps: [number, string][] = [
874
- [10, '1'],
875
- [12, '2'],
876
- [14, '3'],
877
- [16, '4'],
878
- [20, '5'],
879
- [24, '6'],
880
- [32, '7'],
881
- ];
882
- let best = '4';
883
- let bestD = Infinity;
884
- for (const [size, legacy] of steps) {
885
- const d = Math.abs(size - num);
886
- if (d < bestD) {
887
- bestD = d;
888
- best = legacy;
889
- }
890
- }
891
- return best;
966
+ /** 工具条传入的 `28px` / `28` 规范成 CSS 值;非法返回 ''(调用方忽略) */
967
+ function normalizeFontSizeCss(px: string): string {
968
+ const m = /^(\d+(?:\.\d+)?)(px)?$/i.exec((px ?? '').trim());
969
+ if (!m) return '';
970
+ const n = Number.parseFloat(m[1]);
971
+ if (!Number.isFinite(n) || n <= 0) return '';
972
+ return `${Math.min(96, Math.max(8, n))}px`;
892
973
  }
893
974
 
894
975
  function isTransparentColor(c: string): boolean {
@@ -926,6 +1007,7 @@ export function sanitizeRichHtml(html: string): string {
926
1007
  const tpl = document.createElement('template');
927
1008
  tpl.innerHTML = html;
928
1009
  walk(tpl.content);
1010
+ mergeRedundantSpans(tpl.content);
929
1011
  return tpl.innerHTML.replace(/\u200b/g, '');
930
1012
 
931
1013
  function walk(root: Node) {
@@ -938,6 +1020,15 @@ export function sanitizeRichHtml(html: string): string {
938
1020
  el.remove();
939
1021
  return;
940
1022
  }
1023
+ // 只改了字号/字重却没输入内容的 typing 占位 span:零宽字符剥掉后是空壳,拆掉避免进邮件
1024
+ if (tag === 'span' && el.getAttribute('data-sm-typing') === '1') {
1025
+ const text = (el.textContent ?? '').replace(/\u200b/g, '');
1026
+ if (text.length === 0 && !el.querySelector('br, img')) {
1027
+ while (el.firstChild) el.parentNode?.insertBefore(el.firstChild, el);
1028
+ el.remove();
1029
+ return;
1030
+ }
1031
+ }
941
1032
  // <font color="..." size="..." face="..."> → 转成 span style
942
1033
  if (tag === 'font') {
943
1034
  const span = document.createElement('span');
@@ -1009,6 +1100,35 @@ export function sanitizeRichHtml(html: string): string {
1009
1100
  }
1010
1101
  }
1011
1102
 
1103
+ /**
1104
+ * 合并 style/class 完全相同的父子 span:反复改字号/字重会套出多层同义 span,
1105
+ * 渲染结果一样但 HTML 变胖(逼近 Gmail 102KB 截断)。合并后内层子节点上提。
1106
+ */
1107
+ function mergeRedundantSpans(root: ParentNode): void {
1108
+ for (let pass = 0; pass < 5; pass++) {
1109
+ let changed = false;
1110
+ for (const el of Array.from(root.querySelectorAll('span'))) {
1111
+ const parent = el.parentElement;
1112
+ if (!parent || parent.tagName.toLowerCase() !== 'span') continue;
1113
+ if (spanIdentity(parent) !== spanIdentity(el)) continue;
1114
+ while (el.firstChild) parent.insertBefore(el.firstChild, el);
1115
+ el.remove();
1116
+ changed = true;
1117
+ }
1118
+ if (!changed) break;
1119
+ }
1120
+ }
1121
+
1122
+ /** span 的样式身份:class + 规范化后的 style,用于判断是否可合并 */
1123
+ function spanIdentity(el: Element): string {
1124
+ const style = (el.getAttribute('style') ?? '')
1125
+ .replace(/\s+/g, ' ')
1126
+ .trim()
1127
+ .toLowerCase()
1128
+ .replace(/;+$/, '');
1129
+ return `${(el.getAttribute('class') ?? '').trim()} ${style}`;
1130
+ }
1131
+
1012
1132
  function hasMarginStyle(el: Element): boolean {
1013
1133
  const style = el.getAttribute('style') ?? '';
1014
1134
  return /(?:^|;)\s*margin(?:-(?:top|right|bottom|left))?\s*:/i.test(style);
@@ -1020,15 +1140,17 @@ function appendStyle(el: Element, style: string) {
1020
1140
  }
1021
1141
 
1022
1142
  function fontSizeFromLegacy(size: string): string {
1023
- // 旧 <font size="1..7"> → px 近似
1143
+ // 旧 <font size="1..7"> → px。必须用浏览器实际渲染 legacy 档位的那张表
1144
+ // (与 HTML 规范建议值一致:1=10, 2=13, 3=16, 4=18, 5=24, 6=32, 7=48),
1145
+ // 否则编辑器里看到的字号与提交进邮件的字号不一致(如 size=5 显示 24px 却存成 20px)。
1024
1146
  const map: Record<string, string> = {
1025
1147
  '1': '10px',
1026
- '2': '12px',
1027
- '3': '14px',
1028
- '4': '16px',
1029
- '5': '20px',
1030
- '6': '24px',
1031
- '7': '32px',
1148
+ '2': '13px',
1149
+ '3': '16px',
1150
+ '4': '18px',
1151
+ '5': '24px',
1152
+ '6': '32px',
1153
+ '7': '48px',
1032
1154
  };
1033
1155
  return map[size] ?? size;
1034
1156
  }
@@ -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 (paletteBlocks.length) {
84
+ if (sectionLevel.length) {
73
85
  wrap.append(
74
- this._renderBlockGroup(
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
- paletteBlocks,
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
- private _renderBlockGroup(title: string, defs: BlockDefinition[]): HTMLElement {
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: 'sm-block-card',
145
- 'data-source-group': 'blocks',
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: 'blocks', pull: 'clone', put: false },
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
- const FONT_SIZES = ['12px', '13px', '14px', '15px', '16px', '18px', '20px', '24px', '28px', '32px'];
43
+ /** 末位 48px 对应遗留 `<font size="7">`,见 InlineEditor.fontSizeFromLegacy */
44
+ const FONT_SIZES = [
45
+ '12px',
46
+ '13px',
47
+ '14px',
48
+ '15px',
49
+ '16px',
50
+ '18px',
51
+ '20px',
52
+ '24px',
53
+ '28px',
54
+ '32px',
55
+ '48px',
56
+ ];
44
57
 
45
58
  export class RichTextToolbar {
46
59
  el: HTMLElement;
@@ -159,7 +172,8 @@ export class RichTextToolbar {
159
172
 
160
173
  this.btns.bold = this._btn('B', t('toolbar.bold'), exec('bold'), { fontWeight: '700' });
161
174
  this.btns.italic = this._btn('I', t('toolbar.italic'), exec('italic'), { fontStyle: 'italic' });
162
- this.btns.underline = this._btn('U', t('toolbar.underline'), exec('underline'), {
175
+ // 链接内的下划线归 <a> 管,走专用实现;非链接文本回落到 execCommand
176
+ this.btns.underline = this._btn('U', t('toolbar.underline'), () => this.editor?.toggleUnderline(), {
163
177
  textDecoration: 'underline',
164
178
  });
165
179
  this.btns.strikethrough = this._btn('S', t('toolbar.strikethrough'), exec('strikeThrough'), {
@@ -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) => syncItem(i, { href: (e.target as HTMLInputElement).value }),
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) => onChange((e.target as HTMLInputElement).value),
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);