mail-editor-pancake 0.0.0 → 0.0.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.
Files changed (45) hide show
  1. package/README.md +79 -6
  2. package/package.json +1 -1
  3. package/packages/blocks/dist/index.d.ts +55 -3
  4. package/packages/blocks/dist/index.js +267 -208
  5. package/packages/blocks/dist/index.js.map +1 -1
  6. package/packages/blocks/node_modules/.bin/resolve +21 -0
  7. package/packages/blocks/package.json +1 -0
  8. package/packages/blocks/src/button.ts +1 -0
  9. package/packages/blocks/src/hero.ts +2 -0
  10. package/packages/blocks/src/image.ts +1 -0
  11. package/packages/blocks/src/index.ts +19 -2
  12. package/packages/blocks/src/logo.ts +1 -0
  13. package/packages/blocks/src/social.ts +47 -22
  14. package/packages/blocks/src/socialGroup.ts +49 -22
  15. package/packages/blocks/src/socialShared.ts +39 -2
  16. package/packages/blocks/src/text.ts +25 -19
  17. package/packages/blocks/tsconfig.json +0 -3
  18. package/packages/blocks/vite.config.ts +2 -0
  19. package/packages/core/dist/index.d.ts +90 -7
  20. package/packages/core/dist/index.js +2505 -1462
  21. package/packages/core/dist/index.js.map +1 -1
  22. package/packages/core/dist/style.css +1 -1
  23. package/packages/core/src/editor/Canvas.ts +107 -53
  24. package/packages/core/src/editor/ColorPickerPopover.ts +291 -0
  25. package/packages/core/src/editor/Editor.ts +129 -8
  26. package/packages/core/src/editor/ExportModal.ts +2 -1
  27. package/packages/core/src/editor/FocusBreadcrumb.ts +120 -0
  28. package/packages/core/src/editor/ImageGalleryModal.ts +3 -3
  29. package/packages/core/src/editor/InlineEditor.ts +308 -66
  30. package/packages/core/src/editor/LeftPanel.ts +1 -2
  31. package/packages/core/src/editor/PreviewModal.ts +77 -7
  32. package/packages/core/src/editor/RichTextToolbar.ts +66 -44
  33. package/packages/core/src/editor/RightPanel.ts +773 -171
  34. package/packages/core/src/editor/SourceView.ts +2 -1
  35. package/packages/core/src/editor/Topbar.ts +150 -5
  36. package/packages/core/src/editor/styles.css +644 -81
  37. package/packages/core/src/index.ts +18 -1
  38. package/packages/core/src/renderer/mjml.ts +2 -1
  39. package/packages/core/src/store/store.ts +44 -3
  40. package/packages/core/src/types.ts +34 -3
  41. package/packages/core/src/utils/fontWeightSteps.ts +31 -0
  42. package/packages/core/src/utils/lockedMjml.ts +2 -1
  43. package/packages/core/src/utils/richTextCommand.ts +30 -0
  44. package/packages/core/src/utils/sectionLayout.ts +13 -0
  45. package/playground/vanilla/src/main.ts +1 -1
@@ -0,0 +1,291 @@
1
+ import { normalizeAccentHex } from '../utils/accentColor';
2
+ import { h } from '../utils/dom';
3
+
4
+ /** 邮件常用预设色 */
5
+ export const COLOR_PICKER_PALETTE = [
6
+ '#FFFFFF',
7
+ '#F5F6F8',
8
+ '#E6E8EC',
9
+ '#9AA0A6',
10
+ '#6B7280',
11
+ '#433F3F',
12
+ '#1F2328',
13
+ '#000000',
14
+ '#DC2626',
15
+ '#EA580C',
16
+ '#FF5A00',
17
+ '#CA8A04',
18
+ '#16A34A',
19
+ '#059669',
20
+ '#2563EB',
21
+ '#4F46E5',
22
+ '#7C3AED',
23
+ '#DB2777',
24
+ '#FEE2E2',
25
+ '#FFEDD5',
26
+ '#FEF3C7',
27
+ '#D1FAE5',
28
+ '#EEF2FF',
29
+ '#F3E8FF',
30
+ ] as const;
31
+
32
+ export interface ColorPickerPopoverOptions {
33
+ anchor: HTMLElement;
34
+ layerRoot: HTMLElement;
35
+ value: string;
36
+ placeholder?: string;
37
+ /** 显示「清除」并允许提交空值 */
38
+ allowClear?: boolean;
39
+ /** 每次改色立即 onCommit(富文本/顶栏) */
40
+ liveCommit?: boolean;
41
+ onPreview?: (hex: string) => void;
42
+ onCommit: (hex: string) => void;
43
+ onClear?: () => void;
44
+ /** 打开前调用(富文本:在失焦前保存选区) */
45
+ onBeforeOpen?: () => void;
46
+ /** 为 false 时不自动 focus 十六进制输入,避免抢编辑区选区 */
47
+ autoFocusHex?: boolean;
48
+ onClosed?: () => void;
49
+ }
50
+
51
+ let activeTeardown: (() => void) | null = null;
52
+
53
+ export function isColorPickerOpen(): boolean {
54
+ return activeTeardown !== null;
55
+ }
56
+
57
+ export function closeActiveColorPicker(): void {
58
+ activeTeardown?.();
59
+ activeTeardown = null;
60
+ }
61
+
62
+ function positionLayer(layer: HTMLElement, anchor: HTMLElement, layerRoot: HTMLElement) {
63
+ const rect = anchor.getBoundingClientRect();
64
+ const rootRect = layerRoot.getBoundingClientRect();
65
+ layer.style.top = `${rect.bottom - rootRect.top + 4}px`;
66
+ layer.style.left = `${Math.max(4, rect.left - rootRect.left)}px`;
67
+
68
+ requestAnimationFrame(() => {
69
+ const popRect = layer.getBoundingClientRect();
70
+ if (popRect.right > rootRect.right - 8) {
71
+ layer.style.left = `${Math.max(4, parseFloat(layer.style.left) - (popRect.right - rootRect.right + 8))}px`;
72
+ }
73
+ if (popRect.bottom > window.innerHeight - 8) {
74
+ layer.style.top = `${rect.top - rootRect.top - popRect.height - 4}px`;
75
+ }
76
+ });
77
+ }
78
+
79
+ /** 点击色块网格缝隙等无焦点区域时,避免编辑区 blur 导致误提交 */
80
+ function guardInlineEditFocus(layer: HTMLElement) {
81
+ layer.addEventListener(
82
+ 'mousedown',
83
+ (e) => {
84
+ const t = e.target as Element;
85
+ if (t.closest('input, button, select, textarea, a, [contenteditable]')) return;
86
+ e.preventDefault();
87
+ },
88
+ true,
89
+ );
90
+ }
91
+
92
+ function syncNativeInput(native: HTMLInputElement, hex: string) {
93
+ const n = normalizeAccentHex(hex);
94
+ if (n) native.value = n;
95
+ }
96
+
97
+ /** 打开自定义色板(含关闭按钮);点击色卡触发,不再使用系统色板。 */
98
+ export function openColorPickerPopover(opts: ColorPickerPopoverOptions): void {
99
+ closeActiveColorPicker();
100
+ opts.onBeforeOpen?.();
101
+
102
+ const initialHex = normalizeAccentHex(opts.value);
103
+ let draft = initialHex ?? '#4F46E5';
104
+ let closed = false;
105
+
106
+ const layer = h('div', {
107
+ class: 'sm-color-picker-layer',
108
+ 'data-sm-color-picker-layer': '',
109
+ });
110
+ const panel = h('div', {
111
+ class: 'sm-color-picker',
112
+ role: 'dialog',
113
+ 'aria-label': '选择颜色',
114
+ });
115
+
116
+ const closeBtn = h(
117
+ 'button',
118
+ {
119
+ class: 'sm-color-picker__close',
120
+ type: 'button',
121
+ title: '关闭',
122
+ 'aria-label': '关闭',
123
+ },
124
+ ['×'],
125
+ );
126
+
127
+ const head = h('div', { class: 'sm-color-picker__head' }, [
128
+ h('span', { class: 'sm-color-picker__title' }, ['选择颜色']),
129
+ closeBtn,
130
+ ]);
131
+
132
+ const native = h('input', {
133
+ class: 'sm-color-picker__native',
134
+ type: 'color',
135
+ 'aria-label': '自定义颜色',
136
+ }) as HTMLInputElement;
137
+
138
+ const hexInput = h('input', {
139
+ class: 'sm-input sm-color-picker__hex',
140
+ type: 'text',
141
+ placeholder: opts.placeholder ?? '#RRGGBB',
142
+ }) as HTMLInputElement;
143
+
144
+ const applyDraft = (hex: string, commit: boolean) => {
145
+ const n = normalizeAccentHex(hex);
146
+ if (!n) return;
147
+ draft = n;
148
+ syncNativeInput(native, n);
149
+ hexInput.value = n;
150
+ opts.onPreview?.(n);
151
+ if (commit || opts.liveCommit) opts.onCommit(n);
152
+ };
153
+
154
+ syncNativeInput(native, draft);
155
+ hexInput.value = initialHex ?? '';
156
+
157
+ const teardown = (commit: boolean) => {
158
+ if (closed) return;
159
+ closed = true;
160
+ activeTeardown = null;
161
+ document.removeEventListener('keydown', onKey, true);
162
+ document.removeEventListener('mousedown', onDoc, true);
163
+ layer.remove();
164
+ if (commit && !opts.liveCommit) {
165
+ const n = normalizeAccentHex(draft);
166
+ if (n) opts.onCommit(n);
167
+ }
168
+ opts.onClosed?.();
169
+ };
170
+
171
+ const swatchGrid = h('div', { class: 'sm-color-picker__swatches' });
172
+ for (const hex of COLOR_PICKER_PALETTE) {
173
+ const btn = h('button', {
174
+ class: 'sm-color-picker__swatch',
175
+ type: 'button',
176
+ title: `${hex}(双击确认)`,
177
+ style: `background:${hex}`,
178
+ 'aria-label': hex,
179
+ });
180
+ btn.addEventListener('click', () => applyDraft(hex, false));
181
+ btn.addEventListener('dblclick', (e) => {
182
+ e.preventDefault();
183
+ applyDraft(hex, true);
184
+ teardown(true);
185
+ });
186
+ swatchGrid.append(btn);
187
+ }
188
+
189
+ native.addEventListener('input', () => applyDraft(native.value, false));
190
+
191
+ hexInput.addEventListener('input', () => {
192
+ const n = normalizeAccentHex(hexInput.value);
193
+ if (n) applyDraft(n, false);
194
+ });
195
+ hexInput.addEventListener('keydown', (e) => {
196
+ const ke = e as KeyboardEvent;
197
+ if (ke.key === 'Enter') {
198
+ ke.preventDefault();
199
+ const n = normalizeAccentHex(hexInput.value);
200
+ if (n) {
201
+ applyDraft(n, true);
202
+ teardown(true);
203
+ }
204
+ }
205
+ });
206
+
207
+ const footKids: HTMLElement[] = [];
208
+ if (opts.allowClear) {
209
+ footKids.push(
210
+ h(
211
+ 'button',
212
+ {
213
+ class: 'sm-btn sm-btn--ghost sm-color-picker__clear',
214
+ type: 'button',
215
+ onclick: () => {
216
+ opts.onClear?.();
217
+ teardown(false);
218
+ },
219
+ },
220
+ ['清除'],
221
+ ),
222
+ );
223
+ }
224
+
225
+ panel.append(head, swatchGrid, native, hexInput);
226
+ if (footKids.length) panel.append(h('div', { class: 'sm-color-picker__foot' }, footKids));
227
+
228
+ layer.append(panel);
229
+ guardInlineEditFocus(layer);
230
+ opts.layerRoot.append(layer);
231
+ positionLayer(layer, opts.anchor, opts.layerRoot);
232
+
233
+ closeBtn.addEventListener('click', (e) => {
234
+ e.preventDefault();
235
+ teardown(true);
236
+ });
237
+
238
+ const onKey = (e: KeyboardEvent) => {
239
+ if (e.key === 'Escape') {
240
+ e.preventDefault();
241
+ e.stopPropagation();
242
+ if (initialHex) opts.onPreview?.(initialHex);
243
+ teardown(false);
244
+ }
245
+ };
246
+
247
+ const onDoc = (ev: MouseEvent) => {
248
+ const t = ev.target as Node;
249
+ if (layer.contains(t) || opts.anchor.contains(t)) return;
250
+ teardown(true);
251
+ };
252
+
253
+ document.addEventListener('keydown', onKey, true);
254
+ setTimeout(() => document.addEventListener('mousedown', onDoc, true), 0);
255
+
256
+ activeTeardown = () => teardown(true);
257
+ if (opts.autoFocusHex !== false) hexInput.focus();
258
+ }
259
+
260
+ export interface BindColorPickerInputOptions
261
+ extends Omit<ColorPickerPopoverOptions, 'anchor' | 'layerRoot' | 'value'> {
262
+ layerRoot?: HTMLElement;
263
+ getValue?: () => string;
264
+ }
265
+
266
+ /** 拦截色卡点击,改为打开自定义色板。 */
267
+ export function bindColorPickerInput(
268
+ pick: HTMLInputElement,
269
+ opts: BindColorPickerInputOptions,
270
+ ): void {
271
+ if (!pick.title) pick.title = '点击打开色板;预设色块可双击快速确认';
272
+
273
+ pick.addEventListener('mousedown', (e) => {
274
+ e.preventDefault();
275
+ opts.onBeforeOpen?.();
276
+ });
277
+
278
+ pick.addEventListener('click', (e) => {
279
+ e.preventDefault();
280
+ e.stopPropagation();
281
+ const layerRoot =
282
+ opts.layerRoot ?? (pick.closest('.sm-root') as HTMLElement | null) ?? document.body;
283
+ const value = opts.getValue?.() ?? pick.value;
284
+ openColorPickerPopover({
285
+ ...opts,
286
+ anchor: pick,
287
+ layerRoot,
288
+ value,
289
+ });
290
+ });
291
+ }
@@ -1,6 +1,6 @@
1
1
  import { Registry, defineBlock } from '../registry/registry';
2
2
  import { renderDoc } from '../renderer';
3
- import { Store, pruneEmptySections } from '../store/store';
3
+ import { Store, pruneSectionIfEmpty } from '../store/store';
4
4
  import type {
5
5
  BlockDefinition,
6
6
  EditorUiOptions,
@@ -65,6 +65,11 @@ export interface EditorOptions {
65
65
  imageAssets?: ImageAssetsHandlers;
66
66
  /** 右栏控件形态等可选 UI 偏好。 */
67
67
  ui?: EditorUiOptions;
68
+ /**
69
+ * 顶栏「重置内容」恢复的目标文档(与 `initialDoc` 独立;编辑已保存邮件时仍应指向业务预置模板)。
70
+ * 未传时与构造时的 `initialDoc` 合并结果一致。
71
+ */
72
+ presetDoc?: Partial<EmailDoc>;
68
73
  }
69
74
 
70
75
  /**
@@ -96,11 +101,20 @@ export class MailEditor {
96
101
  private changeTimer: number | null = null;
97
102
  /** 显式品牌色时覆盖 CSS 变量;未设置则由 styles 按 light/dark 使用默认紫/靛 */
98
103
  private accentColorOverride: string | undefined;
104
+ /** 顶栏开关:是否在画布上始终显示 Section / Block 轻量虚线边框 */
105
+ private showLayoutBorders = false;
106
+ /** 「重置内容」时恢复的文档快照(与当前画布内容无关) */
107
+ private presetContentDoc: EmailDoc;
99
108
  private systemThemeMq: MediaQueryList | null = null;
100
109
  private readonly _onSystemThemeMqChange = () => {
101
110
  if (this.accentColorOverride) this._applyAccentVars();
102
111
  };
103
112
 
113
+ private readonly _onFullscreenChange = () => {
114
+ const el = smGetFullscreenElement();
115
+ this.topbar?.setFullscreenActive(el === this.root);
116
+ };
117
+
104
118
  /** design 态 Esc:块 → 父级 Section → 文档级面板(邮件设置或版式/全局样式,取决于 `ui.hideMailMeta`) */
105
119
  private _mailEscHandler = (e: KeyboardEvent) => {
106
120
  if (e.key !== 'Escape') return;
@@ -152,6 +166,9 @@ export class MailEditor {
152
166
  }
153
167
 
154
168
  const initialDoc = createDefaultDoc(opts.initialDoc);
169
+ this.presetContentDoc = structuredClone(
170
+ createDefaultDoc(opts.presetDoc ?? opts.initialDoc),
171
+ );
155
172
  this.store = new Store(initialDoc);
156
173
 
157
174
  const initialAccent = normalizeAccentHex(this.opts.accentColor ?? '');
@@ -172,6 +189,7 @@ export class MailEditor {
172
189
 
173
190
  this._buildUI();
174
191
  this._bindKeyboard();
192
+ this._bindFullscreen();
175
193
 
176
194
  this._applyAccentVars();
177
195
  this._refreshAccentMqBinding();
@@ -191,6 +209,31 @@ export class MailEditor {
191
209
  return this.store.doc;
192
210
  }
193
211
 
212
+ /**
213
+ * 清空画布:移除所有 Section/Block,保留 meta、styles、variables。
214
+ * 会提交内联编辑并清空选中。
215
+ */
216
+ clearCanvas(): void {
217
+ this.canvas.commitInlineEdit();
218
+ this._blurRightPanelIfFocused();
219
+ this.store.update((d) => {
220
+ d.sections = [];
221
+ });
222
+ this.store.setSelection(null);
223
+ }
224
+
225
+ /** 将画布恢复为构造时 `presetDoc`(或 `initialDoc`)对应的预置内容。 */
226
+ resetToPreset(): void {
227
+ this._blurRightPanelIfFocused();
228
+ this.store.replace(structuredClone(this.presetContentDoc));
229
+ this.store.setSelection(null);
230
+ }
231
+
232
+ /** 更新「重置内容」的目标(例如宿主异步加载默认模板后)。 */
233
+ setPresetDoc(partial: Partial<EmailDoc>): void {
234
+ this.presetContentDoc = structuredClone(createDefaultDoc(partial));
235
+ }
236
+
194
237
  setVariables(vars: Variable[]) {
195
238
  this.store.update((d) => {
196
239
  d.variables = vars;
@@ -259,8 +302,10 @@ export class MailEditor {
259
302
  }
260
303
 
261
304
  destroy() {
305
+ this._unbindFullscreen();
262
306
  this._unbindSystemThemeMqForAccent();
263
307
  if (this.changeTimer) window.clearTimeout(this.changeTimer);
308
+ if (smGetFullscreenElement() === this.root) void smExitFullscreen();
264
309
  document.removeEventListener('keydown', this._mailEscHandler, true);
265
310
  document.removeEventListener('keydown', this._mailUndoRedoHandler, true);
266
311
  this.canvas?.destroy?.();
@@ -330,10 +375,18 @@ export class MailEditor {
330
375
  this.store.redo();
331
376
  },
332
377
  onMailSettings: () => this._focusMailSettings(),
333
- showMailSettingsButton: this.opts.ui?.hideMailMeta !== true,
378
+ showMailSettingsButton: this.opts.ui?.hideTopbarMailSettings !== true,
379
+ showTitle: this.opts.ui?.hideTopbarTitle !== true,
334
380
  onInsertVariable: (anchor) => this._showVariablePopover(anchor),
335
381
  onPreview: () => this._showPreview(),
336
382
  onExport: () => this._showExport(),
383
+ showFullscreenButton: this.opts.ui?.hideTopbarFullscreen !== true,
384
+ onFullscreenToggle: () => void this._toggleFullscreen(),
385
+ onLayoutBordersToggle: () => this._toggleLayoutBorders(),
386
+ showClearCanvasButton: this.opts.ui?.hideTopbarClearCanvas !== true,
387
+ onClearCanvas: () => this._confirmClearCanvas(),
388
+ showResetContentButton: this.opts.ui?.hideTopbarResetContent !== true,
389
+ onResetContent: () => this._confirmResetContent(),
337
390
  });
338
391
 
339
392
  this.toolbar = new RichTextToolbar({ positionRoot: this.root });
@@ -351,6 +404,12 @@ export class MailEditor {
351
404
  this.rightPanel = new RightPanel({
352
405
  store: this.store,
353
406
  registry: this.registry,
407
+ docRootLabel: this.opts.ui?.hideMailMeta ? '版式' : '邮件',
408
+ onFocusSection: (sectionId) => {
409
+ this.canvas.commitInlineEdit();
410
+ this.store.setSelection({ kind: 'section', sectionId });
411
+ },
412
+ onFocusDocument: () => this._focusMailSettings(),
354
413
  imageAssets: this.opts.imageAssets,
355
414
  ui: this.opts.ui,
356
415
  });
@@ -372,6 +431,22 @@ export class MailEditor {
372
431
  this.store.setSelection(null);
373
432
  }
374
433
 
434
+ private _confirmClearCanvas(): void {
435
+ const ok = window.confirm(
436
+ '将移除画布上所有 Section 与组件,邮件全局样式与变量列表会保留。此操作可用撤销(⌘Z)恢复。',
437
+ );
438
+ if (!ok) return;
439
+ this.clearCanvas();
440
+ }
441
+
442
+ private _confirmResetContent(): void {
443
+ const ok = window.confirm(
444
+ '将把邮件正文恢复为预置内容,当前画布上的所有模块将被替换。确定继续?',
445
+ );
446
+ if (!ok) return;
447
+ this.resetToPreset();
448
+ }
449
+
375
450
  private _setMode(m: EditorMode) {
376
451
  if (this.mode === m) return;
377
452
  if (m !== 'design') this.canvas.commitInlineEdit();
@@ -379,10 +454,12 @@ export class MailEditor {
379
454
  this.topbar.setMode(m);
380
455
  clear(this.body);
381
456
  if (m === 'design') {
382
- this.body.style.gridTemplateColumns = '240px 1fr 300px';
457
+ this.body.classList.remove('sm-body--source');
458
+ this.body.style.removeProperty('grid-template-columns');
383
459
  this.body.append(this.leftPanel.el, this.canvas.el, this.rightPanel.el);
384
460
  } else {
385
- this.body.style.gridTemplateColumns = '1fr';
461
+ this.body.classList.add('sm-body--source');
462
+ this.body.style.removeProperty('grid-template-columns');
386
463
  this.body.append(this.sourceView.el);
387
464
  // 见 SourceView.refreshDoc:设计态源码面板未挂载时不更新,否则会一直显示构造函数时的旧快照
388
465
  queueMicrotask(() => this.sourceView.refreshDoc());
@@ -411,7 +488,7 @@ export class MailEditor {
411
488
  const col = sec.columns[sel.columnIndex];
412
489
  if (!col) return;
413
490
  col.blocks = col.blocks.filter((b) => b.id !== sel.blockId);
414
- pruneEmptySections(d);
491
+ pruneSectionIfEmpty(d, sel.sectionId);
415
492
  });
416
493
  this.store.setSelection(null);
417
494
  }
@@ -548,17 +625,61 @@ export class MailEditor {
548
625
  });
549
626
  modal.open(this.root);
550
627
  }
628
+
629
+ private _bindFullscreen() {
630
+ if (this.opts.ui?.hideTopbarFullscreen === true) return;
631
+ document.addEventListener('fullscreenchange', this._onFullscreenChange);
632
+ document.addEventListener('webkitfullscreenchange', this._onFullscreenChange);
633
+ this._onFullscreenChange();
634
+ }
635
+
636
+ private _unbindFullscreen() {
637
+ document.removeEventListener('fullscreenchange', this._onFullscreenChange);
638
+ document.removeEventListener('webkitfullscreenchange', this._onFullscreenChange);
639
+ }
640
+
641
+ private async _toggleFullscreen() {
642
+ try {
643
+ if (smGetFullscreenElement() === this.root) await smExitFullscreen();
644
+ else await smRequestFullscreen(this.root);
645
+ } catch (err) {
646
+ console.warn('[simple-mail] 全屏不可用:', err);
647
+ }
648
+ }
649
+
650
+ private _toggleLayoutBorders() {
651
+ this.showLayoutBorders = !this.showLayoutBorders;
652
+ this.root.classList.toggle('sm-show-layout-borders', this.showLayoutBorders);
653
+ this.topbar.setLayoutBordersActive(this.showLayoutBorders);
654
+ }
655
+ }
656
+
657
+ function smGetFullscreenElement(): Element | null {
658
+ const d = document as Document & { webkitFullscreenElement?: Element | null };
659
+ return document.fullscreenElement ?? d.webkitFullscreenElement ?? null;
660
+ }
661
+
662
+ async function smRequestFullscreen(el: HTMLElement): Promise<void> {
663
+ const anyEl = el as HTMLElement & { webkitRequestFullscreen?: () => Promise<void> | void };
664
+ if (typeof anyEl.requestFullscreen === 'function') await anyEl.requestFullscreen();
665
+ else if (typeof anyEl.webkitRequestFullscreen === 'function') await anyEl.webkitRequestFullscreen();
666
+ }
667
+
668
+ async function smExitFullscreen(): Promise<void> {
669
+ const d = document as Document & { webkitExitFullscreen?: () => Promise<void> | void };
670
+ if (typeof document.exitFullscreen === 'function') await document.exitFullscreen();
671
+ else if (typeof d.webkitExitFullscreen === 'function') await d.webkitExitFullscreen();
551
672
  }
552
673
 
553
674
  /* ----------------------------- 默认空文档 ------------------------------- */
554
675
 
555
676
  function createDefaultDoc(partial?: Partial<EmailDoc>): EmailDoc {
556
677
  const mergedStyles = {
557
- backgroundColor: '#f4f4f6',
678
+ backgroundColor: '#ffffff',
558
679
  contentBackgroundColor: '#ffffff',
559
680
  fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif',
560
681
  fontSize: '16px',
561
- fontWeight: 'normal',
682
+ fontWeight: '400',
562
683
  color: '#433f3f',
563
684
  linkColor: '#ff5a00',
564
685
  lineHeight: '1.25',
@@ -575,7 +696,7 @@ function createDefaultDoc(partial?: Partial<EmailDoc>): EmailDoc {
575
696
  variables: partial?.variables ?? [],
576
697
  styles: {
577
698
  ...mergedStyles,
578
- fontWeight: mergedStyles.fontWeight ?? 'normal',
699
+ fontWeight: mergedStyles.fontWeight ?? '400',
579
700
  lineHeight: mergedStyles.lineHeight ?? '1.25',
580
701
  },
581
702
  sections: partial?.sections ?? [],
@@ -6,6 +6,7 @@ import { renderDoc } from '../renderer';
6
6
  import type { Store } from '../store/store';
7
7
  import { prettyHtml } from '../utils/html';
8
8
  import { h } from '../utils/dom';
9
+ import { richTextExecCommand } from '../utils/richTextCommand';
9
10
  import { Modal } from './Modal';
10
11
 
11
12
  export interface ExportModalOptions {
@@ -99,7 +100,7 @@ export class ExportModal {
99
100
  ta.value = this.current;
100
101
  document.body.appendChild(ta);
101
102
  ta.select();
102
- document.execCommand('copy');
103
+ richTextExecCommand('copy');
103
104
  document.body.removeChild(ta);
104
105
  this._toast('已复制');
105
106
  }
@@ -0,0 +1,120 @@
1
+ import type { Registry } from '../registry/registry';
2
+ import type { Store } from '../store/store';
3
+ import { findBlockLocation, findSection } from '../store/store';
4
+ import { clear, h } from '../utils/dom';
5
+ import { layoutHumanLabel } from '../utils/sectionLayout';
6
+
7
+ export interface FocusBreadcrumbOptions {
8
+ store: Store;
9
+ registry: Registry;
10
+ /** 文档级根节点文案(如「邮件」/「版式」) */
11
+ docRootLabel: string;
12
+ onFocusDocument: () => void;
13
+ onFocusSection: (sectionId: string) => void;
14
+ }
15
+
16
+ interface CrumbItem {
17
+ label: string;
18
+ current: boolean;
19
+ onClick?: () => void;
20
+ }
21
+
22
+ /**
23
+ * 右栏聚焦路径:邮件 > 一列 > 文本。
24
+ * 点击父级等同 Esc 上浮(块 → Section → 文档级面板)。
25
+ */
26
+ export class FocusBreadcrumb {
27
+ el: HTMLElement;
28
+ private opts: FocusBreadcrumbOptions;
29
+
30
+ constructor(opts: FocusBreadcrumbOptions) {
31
+ this.opts = opts;
32
+ this.el = h('nav', {
33
+ class: 'sm-focus-crumb',
34
+ 'aria-label': '当前选中',
35
+ });
36
+ opts.store.subscribeSelection(() => this.sync());
37
+ opts.store.subscribe(() => this.sync());
38
+ this.sync();
39
+ }
40
+
41
+ setVisible(on: boolean) {
42
+ this.el.hidden = !on;
43
+ }
44
+
45
+ sync() {
46
+ clear(this.el);
47
+ const items = this._buildItems();
48
+ if (!items.length) {
49
+ this.el.hidden = true;
50
+ return;
51
+ }
52
+ this.el.hidden = false;
53
+
54
+ items.forEach((item, i) => {
55
+ if (i > 0) {
56
+ this.el.append(h('span', { class: 'sm-focus-crumb__sep', 'aria-hidden': 'true' }, ['›']));
57
+ }
58
+ if (item.current || !item.onClick) {
59
+ this.el.append(
60
+ h(
61
+ 'span',
62
+ {
63
+ class: `sm-focus-crumb__item sm-focus-crumb__item--current`,
64
+ title: item.label,
65
+ },
66
+ [item.label],
67
+ ),
68
+ );
69
+ } else {
70
+ this.el.append(
71
+ h(
72
+ 'button',
73
+ {
74
+ type: 'button',
75
+ class: 'sm-focus-crumb__item',
76
+ title: `选中:${item.label}`,
77
+ onclick: () => item.onClick!(),
78
+ },
79
+ [item.label],
80
+ ),
81
+ );
82
+ }
83
+ });
84
+ }
85
+
86
+ private _buildItems(): CrumbItem[] {
87
+ const { store, registry, docRootLabel, onFocusDocument, onFocusSection } = this.opts;
88
+ const sel = store.selection;
89
+ const doc = store.doc;
90
+
91
+ if (!sel) {
92
+ return [{ label: docRootLabel, current: true }];
93
+ }
94
+
95
+ if (sel.kind === 'section') {
96
+ const sec = findSection(doc, sel.sectionId);
97
+ if (!sec) return [{ label: docRootLabel, current: true }];
98
+ return [
99
+ { label: docRootLabel, current: false, onClick: onFocusDocument },
100
+ { label: layoutHumanLabel(sec.layout), current: true },
101
+ ];
102
+ }
103
+
104
+ const sec = findSection(doc, sel.sectionId);
105
+ const loc = findBlockLocation(doc, sel.blockId);
106
+ if (!sec || !loc) {
107
+ return [{ label: docRootLabel, current: true }];
108
+ }
109
+
110
+ const blockName = registry.get(loc.block.type)?.name ?? loc.block.type;
111
+ return [
112
+ {
113
+ label: layoutHumanLabel(sec.layout),
114
+ current: false,
115
+ onClick: () => onFocusSection(sel.sectionId),
116
+ },
117
+ { label: blockName, current: true },
118
+ ];
119
+ }
120
+ }
@@ -59,7 +59,7 @@ export function openImageGalleryModal(opts: OpenImageGalleryModalOptions): void
59
59
  const loadMoreBtn = h(
60
60
  'button',
61
61
  {
62
- class: 'sm-btn sm-gallery-modal__loadmore',
62
+ class: 'sm-btn sm-btn--secondary sm-gallery-modal__loadmore',
63
63
  type: 'button',
64
64
  style: 'display:none',
65
65
  onclick: () => {
@@ -141,7 +141,7 @@ export function openImageGalleryModal(opts: OpenImageGalleryModalOptions): void
141
141
  }) as HTMLImageElement;
142
142
  img.src = thumb;
143
143
  img.onerror = () => {
144
- img.style.opacity = '0.35';
144
+ img.style.opacity = '0.72';
145
145
  };
146
146
  const cap = h('div', { class: 'sm-gallery-modal__caption' }, [
147
147
  it.title?.trim() ? it.title : '图片',
@@ -288,7 +288,7 @@ export function openImageGalleryModal(opts: OpenImageGalleryModalOptions): void
288
288
  h(
289
289
  'button',
290
290
  {
291
- class: 'sm-btn',
291
+ class: 'sm-btn sm-btn--secondary',
292
292
  type: 'button',
293
293
  onclick: () => modal.close(),
294
294
  },