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.
- package/README.md +50 -1
- package/package.json +1 -1
- package/packages/blocks/dist/index.js +88 -89
- package/packages/blocks/dist/index.js.map +1 -1
- package/packages/blocks/src/text.ts +21 -14
- package/packages/core/dist/index.d.ts +45 -4
- package/packages/core/dist/index.js +2252 -1449
- package/packages/core/dist/index.js.map +1 -1
- package/packages/core/dist/style.css +1 -1
- package/packages/core/src/editor/Canvas.ts +68 -34
- package/packages/core/src/editor/ColorPickerPopover.ts +291 -0
- package/packages/core/src/editor/Editor.ts +72 -2
- package/packages/core/src/editor/ExportModal.ts +2 -1
- package/packages/core/src/editor/FocusBreadcrumb.ts +120 -0
- package/packages/core/src/editor/InlineEditor.ts +306 -65
- package/packages/core/src/editor/LeftPanel.ts +1 -2
- package/packages/core/src/editor/RichTextToolbar.ts +66 -44
- package/packages/core/src/editor/RightPanel.ts +491 -74
- package/packages/core/src/editor/SourceView.ts +2 -1
- package/packages/core/src/editor/Topbar.ts +98 -7
- package/packages/core/src/editor/styles.css +378 -25
- package/packages/core/src/index.ts +12 -1
- package/packages/core/src/store/store.ts +44 -3
- package/packages/core/src/types.ts +6 -0
- package/packages/core/src/utils/richTextCommand.ts +30 -0
- package/packages/core/src/utils/sectionLayout.ts +13 -0
|
@@ -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,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
|
-
|
|
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
|
-
|
|
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
|
+
}
|