mail-editor-pancake 0.2.7 → 0.3.0
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 +37 -4
- package/package.json +1 -1
- package/packages/blocks/dist/index.js +275 -263
- package/packages/blocks/dist/index.js.map +1 -1
- package/packages/blocks/package.json +1 -3
- package/packages/blocks/src/button.ts +1 -1
- package/packages/blocks/src/hero.ts +6 -3
- package/packages/blocks/src/i18n.ts +10 -11
- package/packages/blocks/src/image.ts +2 -1
- package/packages/blocks/src/index.ts +5 -3
- package/packages/blocks/src/social.ts +3 -3
- package/packages/blocks/src/socialGroup.ts +4 -4
- package/packages/blocks/src/socialShared.ts +23 -3
- package/packages/blocks/src/text.ts +8 -4
- package/packages/blocks/vite.config.ts +1 -1
- package/packages/core/dist/index.d.ts +96 -1
- package/packages/core/dist/index.js +4349 -3594
- package/packages/core/dist/index.js.map +1 -1
- package/packages/core/dist/style.css +1 -1
- package/packages/core/package.json +1 -3
- package/packages/core/src/editor/BlockCodeModal.ts +6 -7
- package/packages/core/src/editor/Canvas.ts +559 -21
- package/packages/core/src/editor/ColorPickerPopover.ts +2 -2
- package/packages/core/src/editor/Editor.ts +381 -41
- package/packages/core/src/editor/ExportModal.ts +7 -9
- package/packages/core/src/editor/FocusBreadcrumb.ts +3 -3
- package/packages/core/src/editor/ImportDocModal.ts +50 -7
- package/packages/core/src/editor/InlineEditor.ts +43 -39
- package/packages/core/src/editor/LeftPanel.ts +2 -4
- package/packages/core/src/editor/Modal.ts +1 -1
- package/packages/core/src/editor/PreviewModal.ts +3 -8
- package/packages/core/src/editor/RichTextToolbar.ts +26 -15
- package/packages/core/src/editor/RightPanel.ts +142 -126
- package/packages/core/src/editor/SourceView.ts +5 -3
- package/packages/core/src/editor/Topbar.ts +79 -34
- package/packages/core/src/editor/VariablePickerPanel.ts +3 -5
- package/packages/core/src/editor/imageAssets.ts +1 -1
- package/packages/core/src/editor/styles.css +258 -86
- package/packages/core/src/i18n/index.ts +139 -41
- package/packages/core/src/index.ts +11 -1
- package/packages/core/src/renderer/index.ts +3 -6
- package/packages/core/src/renderer/mjml.ts +5 -10
- package/packages/core/src/store/store.ts +2 -6
- package/packages/core/src/types.ts +3 -3
- package/packages/core/src/utils/accentColor.ts +3 -3
- package/packages/core/src/utils/docClipboard.ts +107 -2
- package/packages/core/src/utils/dynamicVariantHtml.ts +1 -1
- package/packages/core/src/utils/emailListStyles.ts +1 -1
- package/packages/core/src/utils/fontWeightSteps.ts +7 -6
- package/packages/core/src/utils/html.ts +3 -2
- package/packages/core/src/utils/inlineListEditing.ts +19 -35
- package/packages/core/src/utils/link.ts +3 -6
- package/packages/core/src/utils/lockedMjml.ts +2 -2
- package/packages/core/src/utils/modalSize.ts +4 -3
- package/packages/core/src/utils/richHtmlEmpty.ts +3 -1
- package/packages/core/src/variables/index.ts +4 -5
- package/packages/core/vite.config.ts +1 -1
- package/playground/vanilla/index.html +17 -0
- package/playground/vanilla/src/main.ts +152 -123
- package/playground/vanilla/vite.config.ts +5 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { type SimpleMailT, createI18nContext } from '../i18n';
|
|
1
2
|
import { normalizeAccentHex } from '../utils/accentColor';
|
|
2
3
|
import { h } from '../utils/dom';
|
|
3
|
-
import { createI18nContext, type SimpleMailT } from '../i18n';
|
|
4
4
|
|
|
5
5
|
/** 邮件常用预设色 */
|
|
6
6
|
export const COLOR_PICKER_PALETTE = [
|
|
@@ -70,7 +70,7 @@ function positionLayer(layer: HTMLElement, anchor: HTMLElement, layerRoot: HTMLE
|
|
|
70
70
|
requestAnimationFrame(() => {
|
|
71
71
|
const popRect = layer.getBoundingClientRect();
|
|
72
72
|
if (popRect.right > rootRect.right - 8) {
|
|
73
|
-
layer.style.left = `${Math.max(4, parseFloat(layer.style.left) - (popRect.right - rootRect.right + 8))}px`;
|
|
73
|
+
layer.style.left = `${Math.max(4, Number.parseFloat(layer.style.left) - (popRect.right - rootRect.right + 8))}px`;
|
|
74
74
|
}
|
|
75
75
|
if (popRect.bottom > window.innerHeight - 8) {
|
|
76
76
|
layer.style.top = `${rect.top - rootRect.top - popRect.height - 4}px`;
|
|
@@ -1,6 +1,19 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type SimpleMailI18nContext,
|
|
3
|
+
type SimpleMailLocale,
|
|
4
|
+
type SimpleMailMessagesInput,
|
|
5
|
+
type SimpleMailT,
|
|
6
|
+
createI18nContext,
|
|
7
|
+
} from '../i18n';
|
|
1
8
|
import { Registry, defineBlock } from '../registry/registry';
|
|
2
9
|
import { renderDoc } from '../renderer';
|
|
3
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
Store,
|
|
12
|
+
createSection,
|
|
13
|
+
findBlockLocation,
|
|
14
|
+
findSection,
|
|
15
|
+
pruneSectionIfEmpty,
|
|
16
|
+
} from '../store/store';
|
|
4
17
|
import type {
|
|
5
18
|
Block,
|
|
6
19
|
BlockDefinition,
|
|
@@ -8,40 +21,36 @@ import type {
|
|
|
8
21
|
EmailDoc,
|
|
9
22
|
GlobalStyles,
|
|
10
23
|
RenderEngine,
|
|
24
|
+
Section,
|
|
11
25
|
Selection,
|
|
12
26
|
Variable,
|
|
13
27
|
} from '../types';
|
|
28
|
+
import { accentPrimarySoftRgba, normalizeAccentHex } from '../utils/accentColor';
|
|
14
29
|
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
30
|
+
type SelectionClipboardEnvelope,
|
|
31
|
+
collectSelectionVariableKeys,
|
|
32
|
+
parseDocClipboard,
|
|
33
|
+
parseSelectionClipboard,
|
|
34
|
+
regenerateBlockId,
|
|
35
|
+
regenerateDocIds,
|
|
36
|
+
remapSectionIds,
|
|
37
|
+
serializeDocClipboard,
|
|
38
|
+
serializeSelectionClipboard,
|
|
39
|
+
} from '../utils/docClipboard';
|
|
19
40
|
import { clear, h } from '../utils/dom';
|
|
20
41
|
import { appendInlineToRichHtml } from '../utils/richHtmlInsert';
|
|
42
|
+
import { buildLinkVariableHtml, normalizeVariable, variablePlaceholder } from '../variables';
|
|
21
43
|
import { Canvas } from './Canvas';
|
|
22
44
|
import { ExportModal } from './ExportModal';
|
|
45
|
+
import { ImportDocModal, readTextFromClipboard, writeTextToClipboard } from './ImportDocModal';
|
|
23
46
|
import { LeftPanel } from './LeftPanel';
|
|
24
47
|
import { PreviewModal } from './PreviewModal';
|
|
25
48
|
import { RichTextToolbar } from './RichTextToolbar';
|
|
26
49
|
import { RightPanel } from './RightPanel';
|
|
27
50
|
import { SourceView } from './SourceView';
|
|
28
|
-
import {
|
|
29
|
-
import type { EditorTheme } from './theme';
|
|
51
|
+
import { type EditorMode, Topbar } from './Topbar';
|
|
30
52
|
import type { ImageAssetsHandlers } from './imageAssets';
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
33
|
-
parseDocClipboard,
|
|
34
|
-
regenerateDocIds,
|
|
35
|
-
serializeDocClipboard,
|
|
36
|
-
} from '../utils/docClipboard';
|
|
37
|
-
import { ImportDocModal, readTextFromClipboard, writeTextToClipboard } from './ImportDocModal';
|
|
38
|
-
import {
|
|
39
|
-
createI18nContext,
|
|
40
|
-
type SimpleMailI18nContext,
|
|
41
|
-
type SimpleMailLocale,
|
|
42
|
-
type SimpleMailMessagesInput,
|
|
43
|
-
type SimpleMailT,
|
|
44
|
-
} from '../i18n';
|
|
53
|
+
import type { EditorTheme } from './theme';
|
|
45
54
|
|
|
46
55
|
import './styles.css';
|
|
47
56
|
|
|
@@ -160,6 +169,19 @@ export class MailEditor {
|
|
|
160
169
|
return;
|
|
161
170
|
}
|
|
162
171
|
if (this.mode !== 'design') return;
|
|
172
|
+
if (this.canvas.isSectionSelectMode) {
|
|
173
|
+
// 节选择模式:Esc 优先退出模式(弹窗打开时仍优先关弹窗)
|
|
174
|
+
if (document.querySelector('.sm-modal__mask.is-open')) return;
|
|
175
|
+
const t = e.target as HTMLElement | null;
|
|
176
|
+
if (t?.closest?.('.sm-modal__mask')) return;
|
|
177
|
+
const ae = document.activeElement as HTMLElement | null;
|
|
178
|
+
if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.isContentEditable)) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
e.preventDefault();
|
|
182
|
+
this._exitSectionSelectMode();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
163
185
|
if (!this.store.selection) return;
|
|
164
186
|
if (document.querySelector('.sm-modal__mask.is-open')) return;
|
|
165
187
|
const t = e.target as HTMLElement | null;
|
|
@@ -202,12 +224,13 @@ export class MailEditor {
|
|
|
202
224
|
|
|
203
225
|
/** 内联编辑、右栏表单等应使用控件/浏览器本地撤销,而非文档 history */
|
|
204
226
|
private _shouldDeferToLocalUndoRedo(target: Node): boolean {
|
|
205
|
-
const el =
|
|
206
|
-
target instanceof Element ? target : (target.parentElement as Element | null);
|
|
227
|
+
const el = target instanceof Element ? target : (target.parentElement as Element | null);
|
|
207
228
|
if (!el) return false;
|
|
208
229
|
if (el.closest('.sm-inline-editing')) return true;
|
|
209
230
|
if (el.closest('.cm-editor')) return true;
|
|
210
|
-
const panelField = el.closest(
|
|
231
|
+
const panelField = el.closest(
|
|
232
|
+
'.sm-panel--right input, .sm-panel--right textarea, .sm-panel--right select',
|
|
233
|
+
);
|
|
211
234
|
if (panelField) return true;
|
|
212
235
|
return false;
|
|
213
236
|
}
|
|
@@ -363,10 +386,7 @@ export class MailEditor {
|
|
|
363
386
|
if (normalized.kind === 'link') {
|
|
364
387
|
const token = variablePlaceholder(normalized.key);
|
|
365
388
|
if (this._tryApplyLinkVariableToken(token)) return true;
|
|
366
|
-
const html = buildLinkVariableHtml(
|
|
367
|
-
token,
|
|
368
|
-
this.store.doc.styles.linkColor || '#ff5a00',
|
|
369
|
-
);
|
|
389
|
+
const html = buildLinkVariableHtml(token, this.store.doc.styles.linkColor || '#ff5a00');
|
|
370
390
|
return this._insertAtFocus(html, true);
|
|
371
391
|
}
|
|
372
392
|
return this.insertVariableKey(normalized);
|
|
@@ -435,6 +455,190 @@ export class MailEditor {
|
|
|
435
455
|
return ok;
|
|
436
456
|
}
|
|
437
457
|
|
|
458
|
+
/**
|
|
459
|
+
* 复制局部设计稿:选中 Section / Block 的 JSON 写入剪贴板,可在其他画布「追加」粘贴。
|
|
460
|
+
* @param target 显式指定目标(工具条调用时传入);缺省用当前选中项
|
|
461
|
+
*/
|
|
462
|
+
async copySelectionDesign(
|
|
463
|
+
target?: { sectionId: string } | { blockId: string },
|
|
464
|
+
): Promise<boolean> {
|
|
465
|
+
this.canvas.commitInlineEdit();
|
|
466
|
+
const doc = this.store.doc;
|
|
467
|
+
let sections: Section[] = [];
|
|
468
|
+
let blocks: Block[] = [];
|
|
469
|
+
|
|
470
|
+
if (target && 'sectionId' in target) {
|
|
471
|
+
const sec = findSection(doc, target.sectionId);
|
|
472
|
+
if (sec) sections = [sec];
|
|
473
|
+
} else if (target && 'blockId' in target) {
|
|
474
|
+
const loc = findBlockLocation(doc, target.blockId);
|
|
475
|
+
if (loc) blocks = [loc.block];
|
|
476
|
+
} else {
|
|
477
|
+
const sel = this.store.selection;
|
|
478
|
+
if (sel?.kind === 'section') {
|
|
479
|
+
const sec = findSection(doc, sel.sectionId);
|
|
480
|
+
if (sec) sections = [sec];
|
|
481
|
+
} else if (sel?.kind === 'block') {
|
|
482
|
+
const loc = findBlockLocation(doc, sel.blockId);
|
|
483
|
+
if (loc) blocks = [loc.block];
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (!sections.length && !blocks.length) {
|
|
488
|
+
this._showToast(this.i18n.t('toast.copySelectionNoTarget'));
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return this._writeSelectionClipboard(sections, blocks);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* 批量复制混合勾选(整节 + 散块)的局部设计稿到剪贴板。
|
|
497
|
+
* 统一走 sections 通道:散块按母节骨架重建(保 attrs / 列结构,各列仅保留选中块),粘贴后所见即所得。
|
|
498
|
+
* @returns 是否成功写入剪贴板
|
|
499
|
+
*/
|
|
500
|
+
async copySectionsDesign(sectionIds: string[], blockIds: string[] = []): Promise<boolean> {
|
|
501
|
+
this.canvas.commitInlineEdit();
|
|
502
|
+
const sections = this._buildSectionsFromChecked(sectionIds, blockIds);
|
|
503
|
+
if (!sections.length) {
|
|
504
|
+
this._showToast(this.i18n.t('toast.copySelectionNoTarget'));
|
|
505
|
+
return false;
|
|
506
|
+
}
|
|
507
|
+
return this._writeSelectionClipboard(sections, []);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* 批量导出混合勾选(整节 + 散块)的局部设计稿为 JSON 文件(与剪贴板内容同构,可手动粘贴追加)。
|
|
512
|
+
* @returns 是否成功触发下载
|
|
513
|
+
*/
|
|
514
|
+
exportSectionsDesign(sectionIds: string[], blockIds: string[] = []): boolean {
|
|
515
|
+
this.canvas.commitInlineEdit();
|
|
516
|
+
const sections = this._buildSectionsFromChecked(sectionIds, blockIds);
|
|
517
|
+
if (!sections.length) {
|
|
518
|
+
this._showToast(this.i18n.t('toast.copySelectionNoTarget'));
|
|
519
|
+
return false;
|
|
520
|
+
}
|
|
521
|
+
const json = serializeSelectionClipboard({ sections, variables: this.store.doc.variables });
|
|
522
|
+
const blob = new Blob([json], { type: 'application/json' });
|
|
523
|
+
const url = URL.createObjectURL(blob);
|
|
524
|
+
const link = document.createElement('a');
|
|
525
|
+
link.href = url;
|
|
526
|
+
link.download = `simple-mail-sections-${new Date().toISOString().slice(0, 10)}.json`;
|
|
527
|
+
document.body.append(link);
|
|
528
|
+
link.click();
|
|
529
|
+
link.remove();
|
|
530
|
+
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
531
|
+
this._showToast(this.i18n.t('toast.exportSelectionOk'));
|
|
532
|
+
return true;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* 批量删除 Section(单条撤销记录,Ctrl/Cmd+Z 可整体恢复)。
|
|
537
|
+
* @returns 实际删除的节数量
|
|
538
|
+
*/
|
|
539
|
+
removeSections(sectionIds: string[]): number {
|
|
540
|
+
this.canvas.commitInlineEdit();
|
|
541
|
+
this._blurRightPanelIfFocused();
|
|
542
|
+
const idSet = new Set(sectionIds);
|
|
543
|
+
let removed = 0;
|
|
544
|
+
this.store.update((d) => {
|
|
545
|
+
const before = d.sections.length;
|
|
546
|
+
d.sections = d.sections.filter((s) => !idSet.has(s.id));
|
|
547
|
+
removed = before - d.sections.length;
|
|
548
|
+
});
|
|
549
|
+
this.store.setSelection(null);
|
|
550
|
+
if (removed > 0) this._showToast(this.i18n.t('toast.removeSectionsOk', { count: removed }));
|
|
551
|
+
return removed;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* 批量删除选择模式下的混合勾选(单条撤销记录)。
|
|
556
|
+
* 只删显式命中的内容:整选节整节删除;散选块仅从列中移除,节壳保留(可再圈选清理)。
|
|
557
|
+
* @returns 实际删除的节 + 块总数
|
|
558
|
+
*/
|
|
559
|
+
removeCheckedSelection(sectionIds: string[], blockIds: string[]): number {
|
|
560
|
+
this.canvas.commitInlineEdit();
|
|
561
|
+
this._blurRightPanelIfFocused();
|
|
562
|
+
const secSet = new Set(sectionIds);
|
|
563
|
+
const blockSet = new Set(blockIds);
|
|
564
|
+
let removedSections = 0;
|
|
565
|
+
let removedBlocks = 0;
|
|
566
|
+
this.store.update((d) => {
|
|
567
|
+
const before = d.sections.length;
|
|
568
|
+
d.sections = d.sections.filter((s) => !secSet.has(s.id));
|
|
569
|
+
removedSections = before - d.sections.length;
|
|
570
|
+
for (const sec of d.sections) {
|
|
571
|
+
for (const col of sec.columns) {
|
|
572
|
+
const n = col.blocks.length;
|
|
573
|
+
col.blocks = col.blocks.filter((b) => !blockSet.has(b.id));
|
|
574
|
+
removedBlocks += n - col.blocks.length;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
this.store.setSelection(null);
|
|
579
|
+
const total = removedSections + removedBlocks;
|
|
580
|
+
if (total > 0) {
|
|
581
|
+
this._showToast(
|
|
582
|
+
this.i18n.t('toast.removeCheckedOk', {
|
|
583
|
+
sections: removedSections,
|
|
584
|
+
blocks: removedBlocks,
|
|
585
|
+
}),
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
return total;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* 按混合勾选构建局部设计稿节列表(统一走 sections 通道):
|
|
593
|
+
* - 整选节:原样携带(id 由粘贴端重生成)
|
|
594
|
+
* - 含散选块的节:按母节骨架重建——保留 attrs / layout / 列结构,各列仅保留选中块
|
|
595
|
+
*/
|
|
596
|
+
private _buildSectionsFromChecked(sectionIds: string[], blockIds: string[]): Section[] {
|
|
597
|
+
const secSet = new Set(sectionIds);
|
|
598
|
+
const blockSet = new Set(blockIds);
|
|
599
|
+
const out: Section[] = [];
|
|
600
|
+
for (const sec of this.store.doc.sections) {
|
|
601
|
+
if (secSet.has(sec.id)) {
|
|
602
|
+
out.push(sec);
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
const hasHit = sec.columns.some((c) => c.blocks.some((b) => blockSet.has(b.id)));
|
|
606
|
+
if (!hasHit) continue;
|
|
607
|
+
const rebuilt = structuredClone(sec);
|
|
608
|
+
for (let i = 0; i < rebuilt.columns.length; i++) {
|
|
609
|
+
const src = sec.columns[i];
|
|
610
|
+
rebuilt.columns[i].blocks = src.blocks
|
|
611
|
+
.filter((b) => blockSet.has(b.id))
|
|
612
|
+
.map((b) => structuredClone(b));
|
|
613
|
+
}
|
|
614
|
+
out.push(rebuilt);
|
|
615
|
+
}
|
|
616
|
+
return out;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** 序列化局部设计稿写入剪贴板并 toast 反馈 */
|
|
620
|
+
private async _writeSelectionClipboard(sections: Section[], blocks: Block[]): Promise<boolean> {
|
|
621
|
+
const ok = await writeTextToClipboard(
|
|
622
|
+
serializeSelectionClipboard({ sections, blocks, variables: this.store.doc.variables }),
|
|
623
|
+
);
|
|
624
|
+
this._showToast(
|
|
625
|
+
ok ? this.i18n.t('toast.copySelectionOk') : this.i18n.t('toast.copyDesignFailed'),
|
|
626
|
+
);
|
|
627
|
+
return ok;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* 程序化粘贴局部设计稿:把 `simple-mail/selection` 信封内容**追加**到当前画布(不覆盖)。
|
|
632
|
+
* 未注册的 block type 会被跳过;内容引用到的变量会按 key 并入当前文档变量列表。
|
|
633
|
+
* @returns 是否成功解析并追加
|
|
634
|
+
*/
|
|
635
|
+
importSelectionDesignFromJson(raw: string): boolean {
|
|
636
|
+
const parsed = parseSelectionClipboard(raw);
|
|
637
|
+
if (!parsed) return false;
|
|
638
|
+
this._applyImportedSelection(parsed);
|
|
639
|
+
return true;
|
|
640
|
+
}
|
|
641
|
+
|
|
438
642
|
/** 打开导入设计稿对话框(从剪贴板或手动粘贴 JSON,覆盖当前画布)。 */
|
|
439
643
|
openImportDocDesign(): void {
|
|
440
644
|
this.canvas.commitInlineEdit();
|
|
@@ -497,7 +701,10 @@ export class MailEditor {
|
|
|
497
701
|
}
|
|
498
702
|
const dark = this._chromeIsEffectivelyDark();
|
|
499
703
|
this.root.style.setProperty('--sm-primary', this.accentColorOverride);
|
|
500
|
-
this.root.style.setProperty(
|
|
704
|
+
this.root.style.setProperty(
|
|
705
|
+
'--sm-primary-soft',
|
|
706
|
+
accentPrimarySoftRgba(this.accentColorOverride, dark),
|
|
707
|
+
);
|
|
501
708
|
}
|
|
502
709
|
|
|
503
710
|
private _unbindSystemThemeMqForAccent() {
|
|
@@ -548,6 +755,10 @@ export class MailEditor {
|
|
|
548
755
|
showFullscreenButton: this.opts.ui?.hideTopbarFullscreen !== true,
|
|
549
756
|
onFullscreenToggle: () => void this._toggleFullscreen(),
|
|
550
757
|
onLayoutBordersToggle: () => this._toggleLayoutBorders(),
|
|
758
|
+
onSectionSelectToggle:
|
|
759
|
+
this.opts.ui?.hideTopbarSectionSelect === true
|
|
760
|
+
? undefined
|
|
761
|
+
: () => this._toggleSectionSelectMode(),
|
|
551
762
|
showClearCanvasButton: this.opts.ui?.hideTopbarClearCanvas !== true,
|
|
552
763
|
onClearCanvas: () => this.clearCanvas(),
|
|
553
764
|
showResetContentButton: this.opts.ui?.hideTopbarResetContent !== true,
|
|
@@ -563,8 +774,7 @@ export class MailEditor {
|
|
|
563
774
|
this.leftPanel = new LeftPanel({
|
|
564
775
|
registry: this.registry,
|
|
565
776
|
t: this.i18n.t,
|
|
566
|
-
blockGroupTitle:
|
|
567
|
-
ui?.paletteBlockGroupTitle ?? ui?.blockCategoryLabels?.content,
|
|
777
|
+
blockGroupTitle: ui?.paletteBlockGroupTitle ?? ui?.blockCategoryLabels?.content,
|
|
568
778
|
customPaletteTooltipSuffix: ui?.customPaletteTooltipSuffix,
|
|
569
779
|
hiddenPaletteBlockTypes: ui?.hiddenPaletteBlockTypes,
|
|
570
780
|
enableDynamicVariantKey: ui?.enableDynamicVariantKey,
|
|
@@ -579,7 +789,13 @@ export class MailEditor {
|
|
|
579
789
|
clearSelectionOnCanvasMargin: this.opts.clearSelectionOnCanvasMargin === true,
|
|
580
790
|
layerRoot: this.root,
|
|
581
791
|
ui: this.opts.ui,
|
|
792
|
+
imageAssets: this.opts.imageAssets,
|
|
582
793
|
t: this.i18n.t,
|
|
794
|
+
onCopySelectionDesign: (target) => void this.copySelectionDesign(target),
|
|
795
|
+
onSectionSelectCopy: (ids, blockIds) => void this.copySectionsDesign(ids, blockIds),
|
|
796
|
+
onSectionSelectExport: (ids, blockIds) => this.exportSectionsDesign(ids, blockIds),
|
|
797
|
+
onSectionSelectRemove: (ids, blockIds) => this.removeCheckedSelection(ids, blockIds),
|
|
798
|
+
onSectionSelectModeChange: (active) => this.topbar.setSectionSelectActive(active),
|
|
583
799
|
});
|
|
584
800
|
this.rightPanel = new RightPanel({
|
|
585
801
|
store: this.store,
|
|
@@ -622,6 +838,7 @@ export class MailEditor {
|
|
|
622
838
|
|
|
623
839
|
private _setMode(m: EditorMode) {
|
|
624
840
|
if (this.mode === m) return;
|
|
841
|
+
if (this.canvas.isSectionSelectMode) this._exitSectionSelectMode();
|
|
625
842
|
this._closeVariablePicker();
|
|
626
843
|
this._dismissVariablePopover();
|
|
627
844
|
if (m !== 'design') this.canvas.commitInlineEdit();
|
|
@@ -692,7 +909,8 @@ export class MailEditor {
|
|
|
692
909
|
private _bindVariablePickerOutsideClick(anchor: HTMLElement) {
|
|
693
910
|
this._unbindVariablePickerOutsideClick();
|
|
694
911
|
const handler = (ev: MouseEvent) => {
|
|
695
|
-
if (!this.rightPanel.isVariablePickerOpen() || this.rightPanel.isVariablePickerPinned())
|
|
912
|
+
if (!this.rightPanel.isVariablePickerOpen() || this.rightPanel.isVariablePickerPinned())
|
|
913
|
+
return;
|
|
696
914
|
const target = ev.target as Node;
|
|
697
915
|
if (this.rightPanel.el.contains(target)) return;
|
|
698
916
|
if (anchor.contains(target) || target === anchor) return;
|
|
@@ -876,11 +1094,7 @@ export class MailEditor {
|
|
|
876
1094
|
/**
|
|
877
1095
|
* 按当前选中插入块:block → 其后;section → 该 section 首列末尾;无选中 → 文档最后一节首列末尾。
|
|
878
1096
|
*/
|
|
879
|
-
private _insertBlockRelativeToSelection(
|
|
880
|
-
d: EmailDoc,
|
|
881
|
-
sel: Selection | null,
|
|
882
|
-
block: Block,
|
|
883
|
-
): void {
|
|
1097
|
+
private _insertBlockRelativeToSelection(d: EmailDoc, sel: Selection | null, block: Block): void {
|
|
884
1098
|
if (sel?.kind === 'block') {
|
|
885
1099
|
const sec = d.sections.find((s) => s.id === sel.sectionId);
|
|
886
1100
|
const col = sec?.columns[sel.columnIndex];
|
|
@@ -1031,6 +1245,7 @@ export class MailEditor {
|
|
|
1031
1245
|
this.importDocModal = new ImportDocModal({
|
|
1032
1246
|
readClipboard: readTextFromClipboard,
|
|
1033
1247
|
onApply: (doc) => this._applyImportedDoc(doc),
|
|
1248
|
+
onApplySelection: (envelope) => this._applyImportedSelection(envelope),
|
|
1034
1249
|
t: this.i18n.t,
|
|
1035
1250
|
});
|
|
1036
1251
|
}
|
|
@@ -1049,6 +1264,118 @@ export class MailEditor {
|
|
|
1049
1264
|
this._showToast(this.i18n.t('toast.importDesignOk'));
|
|
1050
1265
|
}
|
|
1051
1266
|
|
|
1267
|
+
/**
|
|
1268
|
+
* 追加局部设计稿:过滤未注册类型 → 变量并集 → 按当前选中定位落点 → id 重生成后写入。
|
|
1269
|
+
* 全程走 store.update(可撤销),不覆盖任何现有内容。
|
|
1270
|
+
*/
|
|
1271
|
+
private _applyImportedSelection(source: SelectionClipboardEnvelope) {
|
|
1272
|
+
this.canvas.commitInlineEdit();
|
|
1273
|
+
this._blurRightPanelIfFocused();
|
|
1274
|
+
|
|
1275
|
+
const skippedTypes = new Set<string>();
|
|
1276
|
+
const filterBlocks = (blocks: Block[]): Block[] => {
|
|
1277
|
+
const kept: Block[] = [];
|
|
1278
|
+
for (const b of blocks) {
|
|
1279
|
+
if (this.registry.get(b.type)) kept.push(b);
|
|
1280
|
+
else skippedTypes.add(b.type);
|
|
1281
|
+
}
|
|
1282
|
+
return kept;
|
|
1283
|
+
};
|
|
1284
|
+
|
|
1285
|
+
const sections = source.sections
|
|
1286
|
+
.map((sec) => ({
|
|
1287
|
+
...sec,
|
|
1288
|
+
columns: sec.columns.map((c) => ({ ...c, blocks: filterBlocks(c.blocks) })),
|
|
1289
|
+
}))
|
|
1290
|
+
.filter((sec) => sec.columns.some((c) => c.blocks.length > 0));
|
|
1291
|
+
const blocks = filterBlocks(source.blocks);
|
|
1292
|
+
|
|
1293
|
+
if (!sections.length && !blocks.length) {
|
|
1294
|
+
this._showToast(
|
|
1295
|
+
skippedTypes.size
|
|
1296
|
+
? this.i18n.t('toast.pasteSelectionSkipped', { types: [...skippedTypes].join(', ') })
|
|
1297
|
+
: this.i18n.t('toast.pasteSelectionEmpty'),
|
|
1298
|
+
);
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// 变量并集:只并入「内容引用到 && 当前文档缺失」的 key
|
|
1303
|
+
const usedKeys = collectSelectionVariableKeys(source);
|
|
1304
|
+
const existingKeys = new Set(this.store.doc.variables.map((v) => v.key));
|
|
1305
|
+
const mergedVars = source.variables.filter(
|
|
1306
|
+
(v) => usedKeys.has(v.key) && !existingKeys.has(v.key),
|
|
1307
|
+
);
|
|
1308
|
+
|
|
1309
|
+
const sel = this.store.selection;
|
|
1310
|
+
let nextSelection: Selection | null = null;
|
|
1311
|
+
|
|
1312
|
+
this.store.update((d) => {
|
|
1313
|
+
if (mergedVars.length) {
|
|
1314
|
+
d.variables = [...d.variables, ...mergedVars.map((v) => ({ ...v }))];
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
if (sections.length) {
|
|
1318
|
+
// 落点:选中 Section(或选中 Block 的所属节)之后;无选中则追加末尾
|
|
1319
|
+
const anchorId =
|
|
1320
|
+
sel?.kind === 'section'
|
|
1321
|
+
? sel.sectionId
|
|
1322
|
+
: sel?.kind === 'block'
|
|
1323
|
+
? findBlockLocation(d, sel.blockId)?.section.id
|
|
1324
|
+
: undefined;
|
|
1325
|
+
const anchorIdx = anchorId ? d.sections.findIndex((s) => s.id === anchorId) : -1;
|
|
1326
|
+
const clones = sections.map((s) => remapSectionIds(s));
|
|
1327
|
+
const at = anchorIdx >= 0 ? anchorIdx + 1 : d.sections.length;
|
|
1328
|
+
d.sections.splice(at, 0, ...clones);
|
|
1329
|
+
nextSelection = { kind: 'section', sectionId: clones[0].id };
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
if (blocks.length) {
|
|
1333
|
+
const clones = blocks.map((b) => regenerateBlockId(b));
|
|
1334
|
+
if (sel?.kind === 'block') {
|
|
1335
|
+
const loc = findBlockLocation(d, sel.blockId);
|
|
1336
|
+
if (loc) {
|
|
1337
|
+
const col = loc.section.columns[loc.columnIndex];
|
|
1338
|
+
const at = col.blocks.findIndex((b) => b.id === loc.block.id);
|
|
1339
|
+
col.blocks.splice(at < 0 ? col.blocks.length : at + 1, 0, ...clones);
|
|
1340
|
+
nextSelection = {
|
|
1341
|
+
kind: 'block',
|
|
1342
|
+
sectionId: loc.section.id,
|
|
1343
|
+
columnIndex: loc.columnIndex,
|
|
1344
|
+
blockId: clones[0].id,
|
|
1345
|
+
};
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
if (sel?.kind === 'section') {
|
|
1350
|
+
const sec = findSection(d, sel.sectionId);
|
|
1351
|
+
if (sec?.columns.length) {
|
|
1352
|
+
sec.columns[0].blocks.push(...clones);
|
|
1353
|
+
nextSelection = {
|
|
1354
|
+
kind: 'block',
|
|
1355
|
+
sectionId: sec.id,
|
|
1356
|
+
columnIndex: 0,
|
|
1357
|
+
blockId: clones[0].id,
|
|
1358
|
+
};
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
// 无可用落点:包一层单列 Section 追加到末尾(与 autoWrapSection 行为一致)
|
|
1363
|
+
const host = createSection('1');
|
|
1364
|
+
host.columns[0].blocks = clones;
|
|
1365
|
+
d.sections.push(host);
|
|
1366
|
+
nextSelection = { kind: 'section', sectionId: host.id };
|
|
1367
|
+
}
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
this.store.setSelection(nextSelection);
|
|
1371
|
+
|
|
1372
|
+
this._showToast(
|
|
1373
|
+
skippedTypes.size
|
|
1374
|
+
? this.i18n.t('toast.pasteSelectionSkipped', { types: [...skippedTypes].join(', ') })
|
|
1375
|
+
: this.i18n.t('toast.pasteSelectionOk'),
|
|
1376
|
+
);
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1052
1379
|
private _showExport() {
|
|
1053
1380
|
// 切回设计模式时如果还在内联编辑,先把内容提交进 store
|
|
1054
1381
|
this.canvas.commitInlineEdit();
|
|
@@ -1140,6 +1467,17 @@ export class MailEditor {
|
|
|
1140
1467
|
this.root.classList.toggle('sm-show-layout-borders', this.showLayoutBorders);
|
|
1141
1468
|
this.topbar.setLayoutBordersActive(this.showLayoutBorders);
|
|
1142
1469
|
}
|
|
1470
|
+
|
|
1471
|
+
/** 顶栏「选择节」入口:进入 / 退出 Section 批量选择模式(批量复制 / 导出 / 删除) */
|
|
1472
|
+
private _toggleSectionSelectMode() {
|
|
1473
|
+
// 顶栏高亮由 canvas.onSectionSelectModeChange 回调统一同步(顶栏按钮 / 画布「完成」/ Esc / ⌘点击直达同源)
|
|
1474
|
+
if (this.canvas.isSectionSelectMode) this._exitSectionSelectMode();
|
|
1475
|
+
else this.canvas.enterSectionSelectMode();
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
private _exitSectionSelectMode() {
|
|
1479
|
+
this.canvas.exitSectionSelectMode();
|
|
1480
|
+
}
|
|
1143
1481
|
}
|
|
1144
1482
|
|
|
1145
1483
|
/** 在 .sm-root 坐标系下定位浮层,靠右的锚点右对齐,并限制在根节点内 */
|
|
@@ -1211,7 +1549,8 @@ function smGetFullscreenElement(): Element | null {
|
|
|
1211
1549
|
async function smRequestFullscreen(el: HTMLElement): Promise<void> {
|
|
1212
1550
|
const anyEl = el as HTMLElement & { webkitRequestFullscreen?: () => Promise<void> | void };
|
|
1213
1551
|
if (typeof anyEl.requestFullscreen === 'function') await anyEl.requestFullscreen();
|
|
1214
|
-
else if (typeof anyEl.webkitRequestFullscreen === 'function')
|
|
1552
|
+
else if (typeof anyEl.webkitRequestFullscreen === 'function')
|
|
1553
|
+
await anyEl.webkitRequestFullscreen();
|
|
1215
1554
|
}
|
|
1216
1555
|
|
|
1217
1556
|
async function smExitFullscreen(): Promise<void> {
|
|
@@ -1248,12 +1587,13 @@ function migrateLegacyStyles(styles: Partial<GlobalStyles>): Partial<GlobalStyle
|
|
|
1248
1587
|
function createDefaultDoc(partial?: Partial<EmailDoc>): EmailDoc {
|
|
1249
1588
|
const mergedStyles = {
|
|
1250
1589
|
backgroundColor: '#ffffff',
|
|
1251
|
-
fontFamily:
|
|
1590
|
+
fontFamily:
|
|
1591
|
+
'Inter, -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif',
|
|
1252
1592
|
fontSize: '16px',
|
|
1253
1593
|
fontWeight: '400',
|
|
1254
1594
|
color: '#433f3f',
|
|
1255
1595
|
linkColor: '#ff5a00',
|
|
1256
|
-
lineHeight: '1.
|
|
1596
|
+
lineHeight: '1.5',
|
|
1257
1597
|
...migrateLegacyStyles(partial?.styles ?? {}),
|
|
1258
1598
|
};
|
|
1259
1599
|
return {
|
|
@@ -1268,7 +1608,7 @@ function createDefaultDoc(partial?: Partial<EmailDoc>): EmailDoc {
|
|
|
1268
1608
|
styles: {
|
|
1269
1609
|
...mergedStyles,
|
|
1270
1610
|
fontWeight: mergedStyles.fontWeight ?? '400',
|
|
1271
|
-
lineHeight: mergedStyles.lineHeight ?? '1.
|
|
1611
|
+
lineHeight: mergedStyles.lineHeight ?? '1.5',
|
|
1272
1612
|
},
|
|
1273
1613
|
sections: partial?.sections ?? [],
|
|
1274
1614
|
};
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
+
import { html as cmHtml } from '@codemirror/lang-html';
|
|
1
2
|
import { EditorState } from '@codemirror/state';
|
|
2
3
|
import { EditorView, basicSetup } from 'codemirror';
|
|
3
|
-
import {
|
|
4
|
+
import type { SimpleMailT } from '../i18n';
|
|
4
5
|
import type { Registry } from '../registry/registry';
|
|
5
6
|
import { renderDoc } from '../renderer';
|
|
6
7
|
import type { Store } from '../store/store';
|
|
7
|
-
import { prettyHtml } from '../utils/html';
|
|
8
8
|
import { h } from '../utils/dom';
|
|
9
|
+
import { prettyHtml } from '../utils/html';
|
|
9
10
|
import { richTextExecCommand } from '../utils/richTextCommand';
|
|
10
11
|
import { Modal } from './Modal';
|
|
11
|
-
import type { SimpleMailT } from '../i18n';
|
|
12
12
|
|
|
13
13
|
export interface ExportModalOptions {
|
|
14
14
|
store: Store;
|
|
@@ -27,7 +27,7 @@ export class ExportModal {
|
|
|
27
27
|
private opts: ExportModalOptions;
|
|
28
28
|
private modal: Modal;
|
|
29
29
|
private cm: EditorView | null = null;
|
|
30
|
-
private current
|
|
30
|
+
private current = '';
|
|
31
31
|
|
|
32
32
|
constructor(opts: ExportModalOptions) {
|
|
33
33
|
this.opts = opts;
|
|
@@ -43,11 +43,9 @@ export class ExportModal {
|
|
|
43
43
|
const editorHost = h('div', { class: 'sm-export__editor' });
|
|
44
44
|
this.modal.body.append(editorHost);
|
|
45
45
|
|
|
46
|
-
const copyBtn = h(
|
|
47
|
-
'
|
|
48
|
-
|
|
49
|
-
[this.opts.t('export.copyHtml')],
|
|
50
|
-
);
|
|
46
|
+
const copyBtn = h('button', { class: 'sm-btn', type: 'button', onclick: () => this._copy() }, [
|
|
47
|
+
this.opts.t('export.copyHtml'),
|
|
48
|
+
]);
|
|
51
49
|
const beautifyBtn = h(
|
|
52
50
|
'button',
|
|
53
51
|
{ class: 'sm-btn', type: 'button', onclick: () => this._beautify() },
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
+
import type { SimpleMailT } from '../i18n';
|
|
1
2
|
import type { Registry } from '../registry/registry';
|
|
2
3
|
import type { Store } from '../store/store';
|
|
3
4
|
import { findBlockLocation, findSection } from '../store/store';
|
|
4
|
-
import { clear, h } from '../utils/dom';
|
|
5
5
|
import type { SectionLayout } from '../types';
|
|
6
|
-
import
|
|
6
|
+
import { clear, h } from '../utils/dom';
|
|
7
7
|
|
|
8
8
|
export interface FocusBreadcrumbOptions {
|
|
9
9
|
store: Store;
|
|
@@ -62,7 +62,7 @@ export class FocusBreadcrumb {
|
|
|
62
62
|
h(
|
|
63
63
|
'span',
|
|
64
64
|
{
|
|
65
|
-
class:
|
|
65
|
+
class: 'sm-focus-crumb__item sm-focus-crumb__item--current',
|
|
66
66
|
title: item.label,
|
|
67
67
|
},
|
|
68
68
|
[item.label],
|