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.
Files changed (60) hide show
  1. package/README.md +37 -4
  2. package/package.json +1 -1
  3. package/packages/blocks/dist/index.js +275 -263
  4. package/packages/blocks/dist/index.js.map +1 -1
  5. package/packages/blocks/package.json +1 -3
  6. package/packages/blocks/src/button.ts +1 -1
  7. package/packages/blocks/src/hero.ts +6 -3
  8. package/packages/blocks/src/i18n.ts +10 -11
  9. package/packages/blocks/src/image.ts +2 -1
  10. package/packages/blocks/src/index.ts +5 -3
  11. package/packages/blocks/src/social.ts +3 -3
  12. package/packages/blocks/src/socialGroup.ts +4 -4
  13. package/packages/blocks/src/socialShared.ts +23 -3
  14. package/packages/blocks/src/text.ts +8 -4
  15. package/packages/blocks/vite.config.ts +1 -1
  16. package/packages/core/dist/index.d.ts +96 -1
  17. package/packages/core/dist/index.js +4349 -3594
  18. package/packages/core/dist/index.js.map +1 -1
  19. package/packages/core/dist/style.css +1 -1
  20. package/packages/core/package.json +1 -3
  21. package/packages/core/src/editor/BlockCodeModal.ts +6 -7
  22. package/packages/core/src/editor/Canvas.ts +559 -21
  23. package/packages/core/src/editor/ColorPickerPopover.ts +2 -2
  24. package/packages/core/src/editor/Editor.ts +381 -41
  25. package/packages/core/src/editor/ExportModal.ts +7 -9
  26. package/packages/core/src/editor/FocusBreadcrumb.ts +3 -3
  27. package/packages/core/src/editor/ImportDocModal.ts +50 -7
  28. package/packages/core/src/editor/InlineEditor.ts +43 -39
  29. package/packages/core/src/editor/LeftPanel.ts +2 -4
  30. package/packages/core/src/editor/Modal.ts +1 -1
  31. package/packages/core/src/editor/PreviewModal.ts +3 -8
  32. package/packages/core/src/editor/RichTextToolbar.ts +26 -15
  33. package/packages/core/src/editor/RightPanel.ts +142 -126
  34. package/packages/core/src/editor/SourceView.ts +5 -3
  35. package/packages/core/src/editor/Topbar.ts +79 -34
  36. package/packages/core/src/editor/VariablePickerPanel.ts +3 -5
  37. package/packages/core/src/editor/imageAssets.ts +1 -1
  38. package/packages/core/src/editor/styles.css +258 -86
  39. package/packages/core/src/i18n/index.ts +139 -41
  40. package/packages/core/src/index.ts +11 -1
  41. package/packages/core/src/renderer/index.ts +3 -6
  42. package/packages/core/src/renderer/mjml.ts +5 -10
  43. package/packages/core/src/store/store.ts +2 -6
  44. package/packages/core/src/types.ts +3 -3
  45. package/packages/core/src/utils/accentColor.ts +3 -3
  46. package/packages/core/src/utils/docClipboard.ts +107 -2
  47. package/packages/core/src/utils/dynamicVariantHtml.ts +1 -1
  48. package/packages/core/src/utils/emailListStyles.ts +1 -1
  49. package/packages/core/src/utils/fontWeightSteps.ts +7 -6
  50. package/packages/core/src/utils/html.ts +3 -2
  51. package/packages/core/src/utils/inlineListEditing.ts +19 -35
  52. package/packages/core/src/utils/link.ts +3 -6
  53. package/packages/core/src/utils/lockedMjml.ts +2 -2
  54. package/packages/core/src/utils/modalSize.ts +4 -3
  55. package/packages/core/src/utils/richHtmlEmpty.ts +3 -1
  56. package/packages/core/src/variables/index.ts +4 -5
  57. package/packages/core/vite.config.ts +1 -1
  58. package/playground/vanilla/index.html +17 -0
  59. package/playground/vanilla/src/main.ts +152 -123
  60. 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 { Store, createSection, findBlockLocation, pruneSectionIfEmpty } from '../store/store';
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
- buildLinkVariableHtml,
16
- normalizeVariable,
17
- variablePlaceholder,
18
- } from '../variables';
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 { Topbar, type EditorMode } from './Topbar';
29
- import type { EditorTheme } from './theme';
51
+ import { type EditorMode, Topbar } from './Topbar';
30
52
  import type { ImageAssetsHandlers } from './imageAssets';
31
- import { accentPrimarySoftRgba, normalizeAccentHex } from '../utils/accentColor';
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('.sm-panel--right input, .sm-panel--right textarea, .sm-panel--right select');
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('--sm-primary-soft', accentPrimarySoftRgba(this.accentColorOverride, dark));
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()) return;
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') await anyEl.webkitRequestFullscreen();
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: 'Inter, -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif',
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.25',
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.25',
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 { html as cmHtml } from '@codemirror/lang-html';
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: string = '';
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
- 'button',
48
- { class: 'sm-btn', type: 'button', onclick: () => this._copy() },
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 type { SimpleMailT } from '../i18n';
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: `sm-focus-crumb__item sm-focus-crumb__item--current`,
65
+ class: 'sm-focus-crumb__item sm-focus-crumb__item--current',
66
66
  title: item.label,
67
67
  },
68
68
  [item.label],