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.
- package/README.md +79 -6
- package/package.json +1 -1
- package/packages/blocks/dist/index.d.ts +55 -3
- package/packages/blocks/dist/index.js +267 -208
- package/packages/blocks/dist/index.js.map +1 -1
- package/packages/blocks/node_modules/.bin/resolve +21 -0
- package/packages/blocks/package.json +1 -0
- package/packages/blocks/src/button.ts +1 -0
- package/packages/blocks/src/hero.ts +2 -0
- package/packages/blocks/src/image.ts +1 -0
- package/packages/blocks/src/index.ts +19 -2
- package/packages/blocks/src/logo.ts +1 -0
- package/packages/blocks/src/social.ts +47 -22
- package/packages/blocks/src/socialGroup.ts +49 -22
- package/packages/blocks/src/socialShared.ts +39 -2
- package/packages/blocks/src/text.ts +25 -19
- package/packages/blocks/tsconfig.json +0 -3
- package/packages/blocks/vite.config.ts +2 -0
- package/packages/core/dist/index.d.ts +90 -7
- package/packages/core/dist/index.js +2505 -1462
- package/packages/core/dist/index.js.map +1 -1
- package/packages/core/dist/style.css +1 -1
- package/packages/core/src/editor/Canvas.ts +107 -53
- package/packages/core/src/editor/ColorPickerPopover.ts +291 -0
- package/packages/core/src/editor/Editor.ts +129 -8
- package/packages/core/src/editor/ExportModal.ts +2 -1
- package/packages/core/src/editor/FocusBreadcrumb.ts +120 -0
- package/packages/core/src/editor/ImageGalleryModal.ts +3 -3
- package/packages/core/src/editor/InlineEditor.ts +308 -66
- package/packages/core/src/editor/LeftPanel.ts +1 -2
- package/packages/core/src/editor/PreviewModal.ts +77 -7
- package/packages/core/src/editor/RichTextToolbar.ts +66 -44
- package/packages/core/src/editor/RightPanel.ts +773 -171
- package/packages/core/src/editor/SourceView.ts +2 -1
- package/packages/core/src/editor/Topbar.ts +150 -5
- package/packages/core/src/editor/styles.css +644 -81
- package/packages/core/src/index.ts +18 -1
- package/packages/core/src/renderer/mjml.ts +2 -1
- package/packages/core/src/store/store.ts +44 -3
- package/packages/core/src/types.ts +34 -3
- package/packages/core/src/utils/fontWeightSteps.ts +31 -0
- package/packages/core/src/utils/lockedMjml.ts +2 -1
- package/packages/core/src/utils/richTextCommand.ts +30 -0
- package/packages/core/src/utils/sectionLayout.ts +13 -0
- 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,
|
|
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?.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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: '#
|
|
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: '
|
|
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 ?? '
|
|
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
|
-
|
|
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.
|
|
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
|
},
|