mail-editor-pancake 0.0.7 → 0.0.9
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 +174 -3
- package/package.json +1 -1
- package/packages/blocks/dist/index.d.ts +12 -0
- package/packages/blocks/dist/index.js +186 -148
- package/packages/blocks/dist/index.js.map +1 -1
- package/packages/blocks/src/index.ts +2 -0
- package/packages/blocks/src/social.ts +8 -11
- package/packages/blocks/src/socialGroup.ts +8 -11
- package/packages/blocks/src/socialShared.ts +52 -0
- package/packages/blocks/src/text.ts +21 -14
- package/packages/core/dist/index.d.ts +99 -5
- package/packages/core/dist/index.js +2475 -1480
- package/packages/core/dist/index.js.map +1 -1
- package/packages/core/dist/style.css +1 -1
- package/packages/core/src/editor/Canvas.ts +77 -34
- package/packages/core/src/editor/ColorPickerPopover.ts +291 -0
- package/packages/core/src/editor/Editor.ts +269 -32
- 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 +316 -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 +427 -33
- package/packages/core/src/index.ts +23 -1
- package/packages/core/src/store/store.ts +44 -3
- package/packages/core/src/types.ts +8 -0
- package/packages/core/src/utils/richHtmlInsert.ts +16 -0
- package/packages/core/src/utils/richTextCommand.ts +30 -0
- package/packages/core/src/utils/sectionLayout.ts +13 -0
- package/packages/core/src/variables/index.ts +91 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Registry, defineBlock } from '../registry/registry';
|
|
2
2
|
import { renderDoc } from '../renderer';
|
|
3
|
-
import { Store,
|
|
3
|
+
import { Store, createSection, pruneSectionIfEmpty } from '../store/store';
|
|
4
4
|
import type {
|
|
5
5
|
BlockDefinition,
|
|
6
6
|
EditorUiOptions,
|
|
@@ -9,7 +9,13 @@ import type {
|
|
|
9
9
|
Selection,
|
|
10
10
|
Variable,
|
|
11
11
|
} from '../types';
|
|
12
|
+
import {
|
|
13
|
+
buildLinkVariableHtml,
|
|
14
|
+
normalizeVariable,
|
|
15
|
+
variablePlaceholder,
|
|
16
|
+
} from '../variables';
|
|
12
17
|
import { clear, h } from '../utils/dom';
|
|
18
|
+
import { appendInlineToRichHtml } from '../utils/richHtmlInsert';
|
|
13
19
|
import { Canvas } from './Canvas';
|
|
14
20
|
import { ExportModal } from './ExportModal';
|
|
15
21
|
import { LeftPanel } from './LeftPanel';
|
|
@@ -65,6 +71,11 @@ export interface EditorOptions {
|
|
|
65
71
|
imageAssets?: ImageAssetsHandlers;
|
|
66
72
|
/** 右栏控件形态等可选 UI 偏好。 */
|
|
67
73
|
ui?: EditorUiOptions;
|
|
74
|
+
/**
|
|
75
|
+
* 顶栏「重置内容」恢复的目标文档(与 `initialDoc` 独立;编辑已保存邮件时仍应指向业务预置模板)。
|
|
76
|
+
* 未传时与构造时的 `initialDoc` 合并结果一致。
|
|
77
|
+
*/
|
|
78
|
+
presetDoc?: Partial<EmailDoc>;
|
|
68
79
|
}
|
|
69
80
|
|
|
70
81
|
/**
|
|
@@ -96,6 +107,12 @@ export class MailEditor {
|
|
|
96
107
|
private changeTimer: number | null = null;
|
|
97
108
|
/** 显式品牌色时覆盖 CSS 变量;未设置则由 styles 按 light/dark 使用默认紫/靛 */
|
|
98
109
|
private accentColorOverride: string | undefined;
|
|
110
|
+
/** 顶栏开关:是否在画布上始终显示 Section / Block 轻量虚线边框 */
|
|
111
|
+
private showLayoutBorders = false;
|
|
112
|
+
/** 「重置内容」时恢复的文档快照(与当前画布内容无关) */
|
|
113
|
+
private presetContentDoc: EmailDoc;
|
|
114
|
+
/** 宿主通过 setVariables 注入的列表;setValue 恢复文档后仍会写回,避免被 doc JSON 里的空数组覆盖 */
|
|
115
|
+
private configuredVariables: Variable[] = [];
|
|
99
116
|
private systemThemeMq: MediaQueryList | null = null;
|
|
100
117
|
private readonly _onSystemThemeMqChange = () => {
|
|
101
118
|
if (this.accentColorOverride) this._applyAccentVars();
|
|
@@ -157,6 +174,9 @@ export class MailEditor {
|
|
|
157
174
|
}
|
|
158
175
|
|
|
159
176
|
const initialDoc = createDefaultDoc(opts.initialDoc);
|
|
177
|
+
this.presetContentDoc = structuredClone(
|
|
178
|
+
createDefaultDoc(opts.presetDoc ?? opts.initialDoc),
|
|
179
|
+
);
|
|
160
180
|
this.store = new Store(initialDoc);
|
|
161
181
|
|
|
162
182
|
const initialAccent = normalizeAccentHex(this.opts.accentColor ?? '');
|
|
@@ -191,22 +211,91 @@ export class MailEditor {
|
|
|
191
211
|
setValue(doc: EmailDoc) {
|
|
192
212
|
this._blurRightPanelIfFocused();
|
|
193
213
|
this.store.replace(doc);
|
|
214
|
+
this._applyConfiguredVariables();
|
|
194
215
|
}
|
|
195
216
|
|
|
196
217
|
getValue(): EmailDoc {
|
|
197
218
|
return this.store.doc;
|
|
198
219
|
}
|
|
199
220
|
|
|
200
|
-
|
|
221
|
+
/**
|
|
222
|
+
* 清空画布:移除所有 Section/Block,保留 meta、styles、variables。
|
|
223
|
+
* 会提交内联编辑并清空选中。
|
|
224
|
+
*/
|
|
225
|
+
clearCanvas(): void {
|
|
226
|
+
this.canvas.commitInlineEdit();
|
|
227
|
+
this._blurRightPanelIfFocused();
|
|
201
228
|
this.store.update((d) => {
|
|
202
|
-
d.
|
|
229
|
+
d.sections = [];
|
|
203
230
|
});
|
|
231
|
+
this.store.setSelection(null);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** 将画布恢复为构造时 `presetDoc`(或 `initialDoc`)对应的预置内容。 */
|
|
235
|
+
resetToPreset(): void {
|
|
236
|
+
this._blurRightPanelIfFocused();
|
|
237
|
+
this.store.replace(structuredClone(this.presetContentDoc));
|
|
238
|
+
this._applyConfiguredVariables();
|
|
239
|
+
this.store.setSelection(null);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** 更新「重置内容」的目标(例如宿主异步加载默认模板后)。 */
|
|
243
|
+
setPresetDoc(partial: Partial<EmailDoc>): void {
|
|
244
|
+
this.presetContentDoc = structuredClone(createDefaultDoc(partial));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
setVariables(vars: Variable[]) {
|
|
248
|
+
this.configuredVariables = vars.map(normalizeVariable);
|
|
249
|
+
this._applyConfiguredVariables();
|
|
204
250
|
}
|
|
205
251
|
|
|
206
252
|
getVariables(): Variable[] {
|
|
253
|
+
if (this.configuredVariables.length) return this.configuredVariables;
|
|
207
254
|
return this.store.doc.variables;
|
|
208
255
|
}
|
|
209
256
|
|
|
257
|
+
private _applyConfiguredVariables() {
|
|
258
|
+
if (!this.configuredVariables.length) return;
|
|
259
|
+
const snapshot = this.configuredVariables.map((v) => ({ ...v }));
|
|
260
|
+
this.store.update((d) => {
|
|
261
|
+
d.variables = snapshot;
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* 插入变量 key(`{{key}}`)。
|
|
267
|
+
* @returns 是否成功插入
|
|
268
|
+
*/
|
|
269
|
+
insertVariableKey(v: Variable): boolean {
|
|
270
|
+
const normalized = normalizeVariable(v);
|
|
271
|
+
return this._insertAtFocus(variablePlaceholder(normalized.key), false);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* 插入链接 / 图片变量对应元素(`<a>` / image 块等)。
|
|
276
|
+
* 非 link/image 时退化为 insertVariableKey。
|
|
277
|
+
*/
|
|
278
|
+
insertVariableElement(v: Variable): boolean {
|
|
279
|
+
const normalized = normalizeVariable(v);
|
|
280
|
+
if (normalized.kind === 'image') {
|
|
281
|
+
return this._insertImageVariable(normalized);
|
|
282
|
+
}
|
|
283
|
+
if (normalized.kind === 'link') {
|
|
284
|
+
const token = variablePlaceholder(normalized.key);
|
|
285
|
+
const html = buildLinkVariableHtml(
|
|
286
|
+
token,
|
|
287
|
+
this.store.doc.styles.linkColor || '#ff5a00',
|
|
288
|
+
);
|
|
289
|
+
return this._insertAtFocus(html, true);
|
|
290
|
+
}
|
|
291
|
+
return this.insertVariableKey(normalized);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** 同 {@link insertVariableKey} */
|
|
295
|
+
insertVariable(v: Variable): boolean {
|
|
296
|
+
return this.insertVariableKey(v);
|
|
297
|
+
}
|
|
298
|
+
|
|
210
299
|
/** 当前界面主题(`system` 不解析为 light/dark)。 */
|
|
211
300
|
getTheme(): EditorTheme {
|
|
212
301
|
return this.theme;
|
|
@@ -345,6 +434,11 @@ export class MailEditor {
|
|
|
345
434
|
onExport: () => this._showExport(),
|
|
346
435
|
showFullscreenButton: this.opts.ui?.hideTopbarFullscreen !== true,
|
|
347
436
|
onFullscreenToggle: () => void this._toggleFullscreen(),
|
|
437
|
+
onLayoutBordersToggle: () => this._toggleLayoutBorders(),
|
|
438
|
+
showClearCanvasButton: this.opts.ui?.hideTopbarClearCanvas !== true,
|
|
439
|
+
onClearCanvas: () => this.clearCanvas(),
|
|
440
|
+
showResetContentButton: this.opts.ui?.hideTopbarResetContent !== true,
|
|
441
|
+
onResetContent: () => this.resetToPreset(),
|
|
348
442
|
});
|
|
349
443
|
|
|
350
444
|
this.toolbar = new RichTextToolbar({ positionRoot: this.root });
|
|
@@ -362,6 +456,12 @@ export class MailEditor {
|
|
|
362
456
|
this.rightPanel = new RightPanel({
|
|
363
457
|
store: this.store,
|
|
364
458
|
registry: this.registry,
|
|
459
|
+
docRootLabel: this.opts.ui?.hideMailMeta ? '版式' : '邮件',
|
|
460
|
+
onFocusSection: (sectionId) => {
|
|
461
|
+
this.canvas.commitInlineEdit();
|
|
462
|
+
this.store.setSelection({ kind: 'section', sectionId });
|
|
463
|
+
},
|
|
464
|
+
onFocusDocument: () => this._focusMailSettings(),
|
|
365
465
|
imageAssets: this.opts.imageAssets,
|
|
366
466
|
ui: this.opts.ui,
|
|
367
467
|
});
|
|
@@ -424,7 +524,7 @@ export class MailEditor {
|
|
|
424
524
|
const col = sec.columns[sel.columnIndex];
|
|
425
525
|
if (!col) return;
|
|
426
526
|
col.blocks = col.blocks.filter((b) => b.id !== sel.blockId);
|
|
427
|
-
|
|
527
|
+
pruneSectionIfEmpty(d, sel.sectionId);
|
|
428
528
|
});
|
|
429
529
|
this.store.setSelection(null);
|
|
430
530
|
}
|
|
@@ -445,9 +545,11 @@ export class MailEditor {
|
|
|
445
545
|
}
|
|
446
546
|
|
|
447
547
|
private _showVariablePopover(anchor: HTMLElement) {
|
|
448
|
-
|
|
548
|
+
this.canvas.currentInlineEditor?.saveSelection();
|
|
549
|
+
|
|
550
|
+
const vars = this.getVariables();
|
|
449
551
|
if (!vars.length) {
|
|
450
|
-
|
|
552
|
+
this._showToast('暂无可用变量');
|
|
451
553
|
return;
|
|
452
554
|
}
|
|
453
555
|
const existing = this.root.querySelector('.sm-popover');
|
|
@@ -456,25 +558,72 @@ export class MailEditor {
|
|
|
456
558
|
return;
|
|
457
559
|
}
|
|
458
560
|
|
|
459
|
-
const pop = h('div', { class: 'sm-popover' });
|
|
561
|
+
const pop = h('div', { class: 'sm-popover sm-popover--variables' });
|
|
562
|
+
let onDocClick: ((ev: MouseEvent) => void) | null = null;
|
|
563
|
+
const dismissPopover = () => {
|
|
564
|
+
pop.remove();
|
|
565
|
+
if (onDocClick) document.removeEventListener('click', onDocClick, true);
|
|
566
|
+
};
|
|
567
|
+
|
|
460
568
|
for (const v of vars) {
|
|
461
|
-
|
|
569
|
+
const token = variablePlaceholder(v.key);
|
|
570
|
+
const row = h('div', { class: 'sm-popover__row' });
|
|
571
|
+
const actions = h('div', { class: 'sm-popover__actions' });
|
|
572
|
+
if (v.kind === 'link' || v.kind === 'image') {
|
|
573
|
+
actions.append(
|
|
574
|
+
h(
|
|
575
|
+
'button',
|
|
576
|
+
{
|
|
577
|
+
class: 'sm-popover__action',
|
|
578
|
+
type: 'button',
|
|
579
|
+
onclick: (e: Event) => {
|
|
580
|
+
e.stopPropagation();
|
|
581
|
+
this.insertVariableElement(v);
|
|
582
|
+
dismissPopover();
|
|
583
|
+
},
|
|
584
|
+
},
|
|
585
|
+
['插入元素'],
|
|
586
|
+
),
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
actions.append(
|
|
590
|
+
h(
|
|
591
|
+
'button',
|
|
592
|
+
{
|
|
593
|
+
class: 'sm-popover__action sm-popover__action--copy',
|
|
594
|
+
type: 'button',
|
|
595
|
+
title: `复制 ${token}`,
|
|
596
|
+
onclick: (e: Event) => {
|
|
597
|
+
e.stopPropagation();
|
|
598
|
+
void copyVariableToken(token).then((ok) => {
|
|
599
|
+
if (!ok) return;
|
|
600
|
+
dismissPopover();
|
|
601
|
+
this._showToast('已复制变量');
|
|
602
|
+
});
|
|
603
|
+
},
|
|
604
|
+
},
|
|
605
|
+
['复制'],
|
|
606
|
+
),
|
|
607
|
+
);
|
|
608
|
+
row.append(
|
|
462
609
|
h(
|
|
463
610
|
'button',
|
|
464
611
|
{
|
|
465
612
|
class: 'sm-popover__item',
|
|
466
613
|
type: 'button',
|
|
467
614
|
onclick: () => {
|
|
468
|
-
this.
|
|
469
|
-
|
|
615
|
+
this.insertVariableKey(v);
|
|
616
|
+
dismissPopover();
|
|
470
617
|
},
|
|
471
618
|
},
|
|
472
619
|
[
|
|
473
|
-
h('span', { class: 'sm-popover__key' }, [`{{${v.key}}}`]),
|
|
474
620
|
h('span', { class: 'sm-popover__label' }, [v.label]),
|
|
621
|
+
h('span', { class: 'sm-popover__key' }, [token]),
|
|
475
622
|
],
|
|
476
623
|
),
|
|
624
|
+
actions,
|
|
477
625
|
);
|
|
626
|
+
pop.append(row);
|
|
478
627
|
}
|
|
479
628
|
|
|
480
629
|
const rect = anchor.getBoundingClientRect();
|
|
@@ -483,27 +632,62 @@ export class MailEditor {
|
|
|
483
632
|
pop.style.left = `${rect.left - rootRect.left}px`;
|
|
484
633
|
this.root.append(pop);
|
|
485
634
|
|
|
486
|
-
|
|
635
|
+
onDocClick = (ev: MouseEvent) => {
|
|
487
636
|
if (!pop.contains(ev.target as Node) && ev.target !== anchor) {
|
|
488
|
-
|
|
489
|
-
document.removeEventListener('click', onDocClick, true);
|
|
637
|
+
dismissPopover();
|
|
490
638
|
}
|
|
491
639
|
};
|
|
492
|
-
setTimeout(() =>
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
640
|
+
setTimeout(() => {
|
|
641
|
+
if (onDocClick) document.addEventListener('click', onDocClick, true);
|
|
642
|
+
}, 0);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
private _showToast(text: string) {
|
|
646
|
+
const t = h('div', { class: 'sm-toast' }, [text]);
|
|
647
|
+
this.root.append(t);
|
|
648
|
+
requestAnimationFrame(() => t.classList.add('is-visible'));
|
|
649
|
+
setTimeout(() => {
|
|
650
|
+
t.classList.remove('is-visible');
|
|
651
|
+
setTimeout(() => t.remove(), 200);
|
|
652
|
+
}, 1400);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
private _insertImageVariable(v: Variable): boolean {
|
|
656
|
+
const token = variablePlaceholder(v.key);
|
|
657
|
+
const block = this.registry.createBlock('image');
|
|
658
|
+
const props = block.props as { src: string; alt: string };
|
|
659
|
+
props.src = token;
|
|
660
|
+
props.alt = v.label?.trim() || v.key;
|
|
496
661
|
const sel = this.store.selection;
|
|
497
|
-
|
|
662
|
+
this.store.update((d) => {
|
|
663
|
+
if (sel?.kind === 'block') {
|
|
664
|
+
const sec = d.sections.find((s) => s.id === sel.sectionId);
|
|
665
|
+
const col = sec?.columns[sel.columnIndex];
|
|
666
|
+
if (col) {
|
|
667
|
+
const idx = col.blocks.findIndex((b) => b.id === sel.blockId);
|
|
668
|
+
col.blocks.splice(idx >= 0 ? idx + 1 : col.blocks.length, 0, block);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
let section = d.sections[d.sections.length - 1];
|
|
673
|
+
if (!section) {
|
|
674
|
+
section = createSection('1');
|
|
675
|
+
d.sections.push(section);
|
|
676
|
+
}
|
|
677
|
+
section.columns[0]?.blocks.push(block);
|
|
678
|
+
});
|
|
679
|
+
return true;
|
|
680
|
+
}
|
|
498
681
|
|
|
499
|
-
|
|
682
|
+
/** 在焦点处插入文本或 HTML;失败时返回 false */
|
|
683
|
+
private _insertAtFocus(content: string, asHtml: boolean): boolean {
|
|
500
684
|
const inline = this.canvas.currentInlineEditor;
|
|
501
685
|
if (inline) {
|
|
502
|
-
inline.
|
|
503
|
-
|
|
686
|
+
if (asHtml) inline.insertHtml(content);
|
|
687
|
+
else inline.insertText(content);
|
|
688
|
+
return true;
|
|
504
689
|
}
|
|
505
690
|
|
|
506
|
-
// 次之:当前聚焦的 input/textarea(属性面板)
|
|
507
691
|
const active = document.activeElement as HTMLInputElement | HTMLTextAreaElement | null;
|
|
508
692
|
if (
|
|
509
693
|
active &&
|
|
@@ -512,14 +696,14 @@ export class MailEditor {
|
|
|
512
696
|
) {
|
|
513
697
|
const start = active.selectionStart ?? active.value.length;
|
|
514
698
|
const end = active.selectionEnd ?? active.value.length;
|
|
515
|
-
active.value = active.value.slice(0, start) +
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
return;
|
|
699
|
+
active.value = active.value.slice(0, start) + content + active.value.slice(end);
|
|
700
|
+
active.dispatchEvent(new Event('input', { bubbles: true }));
|
|
701
|
+
return true;
|
|
519
702
|
}
|
|
520
703
|
|
|
521
|
-
|
|
704
|
+
const sel = this.store.selection;
|
|
522
705
|
if (sel?.kind === 'block') {
|
|
706
|
+
let updated = false;
|
|
523
707
|
this.store.update((d) => {
|
|
524
708
|
for (const s of d.sections) {
|
|
525
709
|
for (const c of s.columns) {
|
|
@@ -530,16 +714,40 @@ export class MailEditor {
|
|
|
530
714
|
def?.inlineEditable?.propKey ??
|
|
531
715
|
def?.schema.find((f) => f.type === 'text' || f.type === 'textarea')?.key;
|
|
532
716
|
if (key) {
|
|
533
|
-
const cur = String((b.props as
|
|
534
|
-
|
|
717
|
+
const cur = String((b.props as Record<string, unknown>)[key] ?? '');
|
|
718
|
+
const richInline = def?.inlineEditable && def.inlineEditable.mode !== 'plain';
|
|
719
|
+
(b.props as Record<string, unknown>)[key] = richInline
|
|
720
|
+
? appendInlineToRichHtml(cur, content)
|
|
721
|
+
: cur + content;
|
|
722
|
+
updated = true;
|
|
535
723
|
}
|
|
536
724
|
}
|
|
537
725
|
}
|
|
538
726
|
});
|
|
539
|
-
return;
|
|
727
|
+
if (updated) return true;
|
|
540
728
|
}
|
|
541
729
|
|
|
542
|
-
|
|
730
|
+
const block = this.registry.createBlock('text');
|
|
731
|
+
const wrapped = appendInlineToRichHtml('', content);
|
|
732
|
+
(block.props as { content: string }).content = wrapped;
|
|
733
|
+
this.store.update((d) => {
|
|
734
|
+
let section = d.sections[d.sections.length - 1];
|
|
735
|
+
if (!section) {
|
|
736
|
+
section = createSection('1');
|
|
737
|
+
d.sections.push(section);
|
|
738
|
+
}
|
|
739
|
+
if (sel?.kind === 'block') {
|
|
740
|
+
const sec = d.sections.find((s) => s.id === sel.sectionId);
|
|
741
|
+
const col = sec?.columns[sel.columnIndex];
|
|
742
|
+
if (col) {
|
|
743
|
+
const idx = col.blocks.findIndex((b) => b.id === sel.blockId);
|
|
744
|
+
col.blocks.splice(idx >= 0 ? idx + 1 : col.blocks.length, 0, block);
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
section.columns[0]?.blocks.push(block);
|
|
749
|
+
});
|
|
750
|
+
return true;
|
|
543
751
|
}
|
|
544
752
|
|
|
545
753
|
private _showExport() {
|
|
@@ -582,6 +790,35 @@ export class MailEditor {
|
|
|
582
790
|
console.warn('[simple-mail] 全屏不可用:', err);
|
|
583
791
|
}
|
|
584
792
|
}
|
|
793
|
+
|
|
794
|
+
private _toggleLayoutBorders() {
|
|
795
|
+
this.showLayoutBorders = !this.showLayoutBorders;
|
|
796
|
+
this.root.classList.toggle('sm-show-layout-borders', this.showLayoutBorders);
|
|
797
|
+
this.topbar.setLayoutBordersActive(this.showLayoutBorders);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
async function copyVariableToken(token: string): Promise<boolean> {
|
|
802
|
+
try {
|
|
803
|
+
await navigator.clipboard.writeText(token);
|
|
804
|
+
return true;
|
|
805
|
+
} catch {
|
|
806
|
+
// fallback
|
|
807
|
+
}
|
|
808
|
+
try {
|
|
809
|
+
const ta = document.createElement('textarea');
|
|
810
|
+
ta.value = token;
|
|
811
|
+
ta.setAttribute('readonly', 'true');
|
|
812
|
+
ta.style.position = 'fixed';
|
|
813
|
+
ta.style.left = '-9999px';
|
|
814
|
+
document.body.appendChild(ta);
|
|
815
|
+
ta.select();
|
|
816
|
+
const ok = document.execCommand('copy');
|
|
817
|
+
document.body.removeChild(ta);
|
|
818
|
+
return ok;
|
|
819
|
+
} catch {
|
|
820
|
+
return false;
|
|
821
|
+
}
|
|
585
822
|
}
|
|
586
823
|
|
|
587
824
|
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
|
+
}
|