mail-editor-pancake 0.0.7 → 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.
@@ -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,6 +101,10 @@ 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();
@@ -157,6 +166,9 @@ export class MailEditor {
157
166
  }
158
167
 
159
168
  const initialDoc = createDefaultDoc(opts.initialDoc);
169
+ this.presetContentDoc = structuredClone(
170
+ createDefaultDoc(opts.presetDoc ?? opts.initialDoc),
171
+ );
160
172
  this.store = new Store(initialDoc);
161
173
 
162
174
  const initialAccent = normalizeAccentHex(this.opts.accentColor ?? '');
@@ -197,6 +209,31 @@ export class MailEditor {
197
209
  return this.store.doc;
198
210
  }
199
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
+
200
237
  setVariables(vars: Variable[]) {
201
238
  this.store.update((d) => {
202
239
  d.variables = vars;
@@ -345,6 +382,11 @@ export class MailEditor {
345
382
  onExport: () => this._showExport(),
346
383
  showFullscreenButton: this.opts.ui?.hideTopbarFullscreen !== true,
347
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(),
348
390
  });
349
391
 
350
392
  this.toolbar = new RichTextToolbar({ positionRoot: this.root });
@@ -362,6 +404,12 @@ export class MailEditor {
362
404
  this.rightPanel = new RightPanel({
363
405
  store: this.store,
364
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(),
365
413
  imageAssets: this.opts.imageAssets,
366
414
  ui: this.opts.ui,
367
415
  });
@@ -383,6 +431,22 @@ export class MailEditor {
383
431
  this.store.setSelection(null);
384
432
  }
385
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
+
386
450
  private _setMode(m: EditorMode) {
387
451
  if (this.mode === m) return;
388
452
  if (m !== 'design') this.canvas.commitInlineEdit();
@@ -424,7 +488,7 @@ export class MailEditor {
424
488
  const col = sec.columns[sel.columnIndex];
425
489
  if (!col) return;
426
490
  col.blocks = col.blocks.filter((b) => b.id !== sel.blockId);
427
- pruneEmptySections(d);
491
+ pruneSectionIfEmpty(d, sel.sectionId);
428
492
  });
429
493
  this.store.setSelection(null);
430
494
  }
@@ -582,6 +646,12 @@ export class MailEditor {
582
646
  console.warn('[simple-mail] 全屏不可用:', err);
583
647
  }
584
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
+ }
585
655
  }
586
656
 
587
657
  function smGetFullscreenElement(): Element | null {
@@ -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
+ }