mail-editor-pancake 0.2.8 → 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 +1 -2
  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 +4346 -3591
  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 +558 -20
  23. package/packages/core/src/editor/ColorPickerPopover.ts +2 -2
  24. package/packages/core/src/editor/Editor.ts +379 -39
  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 +141 -125
  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 +4 -9
  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 +1 -1
  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,14 +1,17 @@
1
1
  import Sortable from 'sortablejs';
2
+ import type { SimpleMailT } from '../i18n';
2
3
  import type { Registry } from '../registry/registry';
3
4
  import {
5
+ type Store,
4
6
  createSection,
5
7
  findBlockLocation,
6
8
  findSection,
7
9
  pruneSectionIfEmpty,
8
- type Store,
9
10
  } from '../store/store';
10
11
  import type {
11
12
  Block,
13
+ BlockDefinition,
14
+ BlockSchemaField,
12
15
  Column,
13
16
  EditorUiOptions,
14
17
  EmailDoc,
@@ -18,21 +21,19 @@ import type {
18
21
  SectionAttrs,
19
22
  SectionLayout,
20
23
  } from '../types';
21
- import { getSectionDynamicVariantKey } from '../utils/dynamicVariantSection';
22
24
  import { blockButtonWidthCss, docContentWidthCss } from '../utils/contentWidth';
25
+ import { clear, escapeHtml, h } from '../utils/dom';
26
+ import { getSectionDynamicVariantKey } from '../utils/dynamicVariantSection';
23
27
  import { globalListIndentCssVarValue } from '../utils/emailListStyles';
24
28
  import { normalizeFontWeightStep } from '../utils/fontWeightSteps';
25
- import { clear, escapeHtml, h } from '../utils/dom';
26
- import {
27
- defaultPaddingForLockedBlock,
28
- resolveLockedMjmlCanvasContent,
29
- } from '../utils/lockedMjml';
30
- import { layoutHumanLabel } from '../utils/sectionLayout';
29
+ import { defaultPaddingForLockedBlock, resolveLockedMjmlCanvasContent } from '../utils/lockedMjml';
31
30
  import { paletteDropHasSectionLayout, resolvePaletteDropResult } from '../utils/paletteDrop';
31
+ import { layoutHumanLabel } from '../utils/sectionLayout';
32
32
  import { BlockCodeModal } from './BlockCodeModal';
33
+ import { openImageGalleryModal } from './ImageGalleryModal';
33
34
  import { InlineEditor, type SelectionState } from './InlineEditor';
34
35
  import type { RichTextToolbar } from './RichTextToolbar';
35
- import type { SimpleMailT } from '../i18n';
36
+ import type { ImageAssetsHandlers } from './imageAssets';
36
37
 
37
38
  export interface CanvasOptions {
38
39
  store: Store;
@@ -53,6 +54,16 @@ export interface CanvasOptions {
53
54
  layerRoot: HTMLElement;
54
55
  /** 与 MailEditor.opts.ui 对齐 */
55
56
  ui?: EditorUiOptions;
57
+ /** 与 MailEditor.opts.imageAssets 对齐:启用图库(showGallery)时,双击图片类块可直接打开图库换图 */
58
+ imageAssets?: ImageAssetsHandlers;
59
+ /** Section / Block 工具条「复制设计稿」回调(写局部 JSON 到剪贴板,供其他画布追加) */
60
+ onCopySelectionDesign?: (target: { sectionId: string } | { blockId: string }) => void;
61
+ /** 「选择节」批量模式动作(由 Editor 实现:写剪贴板 / 下载 JSON / 批量删除);块为散选、节为整选,二者可混合 */
62
+ onSectionSelectCopy?: (sectionIds: string[], blockIds: string[]) => void;
63
+ onSectionSelectExport?: (sectionIds: string[], blockIds: string[]) => void;
64
+ onSectionSelectRemove?: (sectionIds: string[], blockIds: string[]) => void;
65
+ /** 进入 / 退出节选择模式时通知宿主同步 UI(如顶栏按钮高亮);画布内「完成」按钮与 ⌘/Ctrl+点击直达均会触发 */
66
+ onSectionSelectModeChange?: (active: boolean) => void;
56
67
  t: SimpleMailT;
57
68
  }
58
69
 
@@ -86,6 +97,26 @@ export class Canvas {
86
97
  private clearSelectionGestureTracking = new AbortController();
87
98
  private pointerGestureStartedInInlineEdit = false;
88
99
  private blockCodeModal: BlockCodeModal;
100
+ /** 节选择模式(批量复制 / 导出 / 删除):点击 Section 勾选而非单选 */
101
+ private sectionSelectActive = false;
102
+ /** 完整选中的节(点击节空白 / 选择圈命中) */
103
+ private checkedSectionIds = new Set<string>();
104
+ /** 散选的块(所属节未被完整选中;不变式由 _normalizeCheckedSets 维护) */
105
+ private checkedBlockIds = new Set<string>();
106
+ /**
107
+ * 区间锚点:最近一次点击的命中(节或块)。Shift+点击以它与目标为界做同类区间:
108
+ * 节↔节 = 节区间;同列块↔块 = 列内区间;跨类 / 跨列退化为单点切换。
109
+ */
110
+ private sectionCheckAnchor: {
111
+ kind: 'section' | 'block';
112
+ id: string;
113
+ sectionId: string;
114
+ columnIndex: number;
115
+ } | null = null;
116
+ private sectionSelectBar: HTMLElement | null = null;
117
+ private sectionSelectCountEl: HTMLElement | null = null;
118
+ private sectionSelectAllBtn: HTMLButtonElement | null = null;
119
+ private sectionSelectActionBtns: HTMLButtonElement[] = [];
89
120
 
90
121
  constructor(opts: CanvasOptions) {
91
122
  this.opts = opts;
@@ -101,6 +132,10 @@ export class Canvas {
101
132
  });
102
133
 
103
134
  this._bindDesignModeLinkSuppression();
135
+ // 节选择模式下 Shift+点击 = 区间勾选:阻止浏览器默认的 Shift 扩展文本选区
136
+ this.inner.addEventListener('mousedown', (e: MouseEvent) => {
137
+ if (this.sectionSelectActive && e.shiftKey) e.preventDefault();
138
+ });
104
139
  if (opts.clearSelectionOnCanvasMargin) {
105
140
  this._bindClearSelectionGestureTracking();
106
141
  this._bindClearSelectionOnCanvasWhitespace();
@@ -179,6 +214,7 @@ export class Canvas {
179
214
  destroy() {
180
215
  this.linkNavSuppression.abort();
181
216
  this.clearSelectionGestureTracking.abort();
217
+ this.exitSectionSelectMode();
182
218
  this._exitEditing(false);
183
219
  this._destroySortables();
184
220
  this.blockCodeModal.destroy();
@@ -244,6 +280,7 @@ export class Canvas {
244
280
  if (!this.el.contains(t)) return;
245
281
  if (this.inner.contains(t)) return;
246
282
  if (this.addBar.contains(t)) return;
283
+ if (this.sectionSelectBar?.contains(t)) return;
247
284
  if (this._shouldSuppressClearSelectionFromTextDrag()) return;
248
285
  this.commitInlineEdit();
249
286
  this.opts.store.setSelection(null);
@@ -300,11 +337,7 @@ export class Canvas {
300
337
  );
301
338
 
302
339
  if (!doc.sections.length) {
303
- this.inner.append(
304
- h('div', { class: 'sm-empty-doc' }, [
305
- this.opts.t('canvas.emptyHint'),
306
- ]),
307
- );
340
+ this.inner.append(h('div', { class: 'sm-empty-doc' }, [this.opts.t('canvas.emptyHint')]));
308
341
  } else {
309
342
  for (let si = 0; si < doc.sections.length; si++) {
310
343
  this.inner.append(this._renderSection(doc.sections[si], doc, si));
@@ -330,6 +363,11 @@ export class Canvas {
330
363
  });
331
364
  this.sortableRefs.push(top);
332
365
 
366
+ if (this.sectionSelectActive) {
367
+ this._setSortablesDisabled(true);
368
+ this._syncSectionChecks();
369
+ }
370
+
333
371
  this._syncSelection();
334
372
  }
335
373
 
@@ -340,7 +378,9 @@ export class Canvas {
340
378
  }px ${a.paddingLeft ?? 0}px`;
341
379
 
342
380
  const layoutShort =
343
- section.layout === '1' ? this.opts.t('rightPanel.layout.oneColumn') : layoutHumanLabel(section.layout);
381
+ section.layout === '1'
382
+ ? this.opts.t('rightPanel.layout.oneColumn')
383
+ : layoutHumanLabel(section.layout);
344
384
  const dvKey =
345
385
  this.opts.ui?.enableDynamicVariantKey === true
346
386
  ? getSectionDynamicVariantKey(section)
@@ -354,7 +394,9 @@ export class Canvas {
354
394
 
355
395
  const sw = blockButtonWidthCss(a.width);
356
396
  const box =
357
- sw != null ? `max-width:${sw};width:100%;margin-left:auto;margin-right:auto;box-sizing:border-box;` : '';
397
+ sw != null
398
+ ? `max-width:${sw};width:100%;margin-left:auto;margin-right:auto;box-sizing:border-box;`
399
+ : '';
358
400
 
359
401
  const host = h('div', {
360
402
  class: 'sm-section-host',
@@ -392,6 +434,21 @@ export class Canvas {
392
434
  },
393
435
  [iconCopy()],
394
436
  ),
437
+ this.opts.onCopySelectionDesign
438
+ ? h(
439
+ 'button',
440
+ {
441
+ class: 'sm-tool-btn',
442
+ type: 'button',
443
+ title: this.opts.t('canvas.copySectionDesignTitle'),
444
+ onclick: (e: Event) => {
445
+ e.stopPropagation();
446
+ this.opts.onCopySelectionDesign?.({ sectionId: section.id });
447
+ },
448
+ },
449
+ [iconCopyDesign()],
450
+ )
451
+ : null,
395
452
  h(
396
453
  'button',
397
454
  {
@@ -419,10 +476,27 @@ export class Canvas {
419
476
 
420
477
  wrap.append(cols);
421
478
 
422
- host.append(wrap, toolbar);
479
+ const check = h('div', { class: 'sm-section-check', 'aria-hidden': 'true' }, [
480
+ iconCheck(),
481
+ iconMinus(),
482
+ ]);
483
+ host.append(wrap, toolbar, check);
423
484
 
424
485
  host.addEventListener('click', (e) => {
486
+ if (this.sectionSelectActive) {
487
+ e.stopPropagation();
488
+ if (e.shiftKey) this._shiftClickSection(section.id);
489
+ else this._toggleSectionFull(section.id);
490
+ return;
491
+ }
425
492
  if ((e.target as HTMLElement).closest('.sm-block')) return;
493
+ /** ⌘/Ctrl+点击节空白:直达节选择模式并勾选该节(macOS ⌘ / Windows·Linux Ctrl) */
494
+ if (e.metaKey || e.ctrlKey) {
495
+ e.preventDefault();
496
+ e.stopPropagation();
497
+ this._enterSectionSelectModeWith(section.id);
498
+ return;
499
+ }
426
500
  // 文本拖选时在 Section padding 等区域松开会冒泡 click,不应误切成 Section 选中
427
501
  if (!e.altKey && this._shouldSuppressSectionSelectFromTextInteraction()) return;
428
502
  e.stopPropagation();
@@ -596,6 +670,21 @@ export class Canvas {
596
670
  },
597
671
  [iconCopy()],
598
672
  ),
673
+ this.opts.onCopySelectionDesign
674
+ ? h(
675
+ 'button',
676
+ {
677
+ class: 'sm-tool-btn',
678
+ type: 'button',
679
+ title: this.opts.t('canvas.copyBlockDesignTitle'),
680
+ onclick: (e: Event) => {
681
+ e.stopPropagation();
682
+ this.opts.onCopySelectionDesign?.({ blockId: block.id });
683
+ },
684
+ },
685
+ [iconCopyDesign()],
686
+ )
687
+ : null,
599
688
  h(
600
689
  'button',
601
690
  {
@@ -615,6 +704,12 @@ export class Canvas {
615
704
  el.append(blockToolbar, content);
616
705
 
617
706
  el.addEventListener('click', (e) => {
707
+ if (this.sectionSelectActive) {
708
+ e.stopPropagation();
709
+ if (e.shiftKey) this._shiftClickBlock(section.id, columnIndex, block.id);
710
+ else this._toggleBlockCheck(section.id, columnIndex, block.id);
711
+ return;
712
+ }
618
713
  if (this.editingBlockId === block.id) return; // 编辑中不抢焦点
619
714
  /** Alt(mac Option):穿透选中父级 Section,解决单列内边距为 0 时块铺满无法点到 Section */
620
715
  if (e.altKey) {
@@ -622,6 +717,13 @@ export class Canvas {
622
717
  this.opts.store.setSelection({ kind: 'section', sectionId: section.id });
623
718
  return;
624
719
  }
720
+ /** ⌘/Ctrl+点击块:直达选择模式并勾选该块(不带走同节其他块) */
721
+ if (e.metaKey || e.ctrlKey) {
722
+ e.preventDefault();
723
+ e.stopPropagation();
724
+ this._enterSectionSelectModeWithBlock(section.id, columnIndex, block.id);
725
+ return;
726
+ }
625
727
  e.stopPropagation();
626
728
  this.opts.store.setSelection({
627
729
  kind: 'block',
@@ -642,6 +744,18 @@ export class Canvas {
642
744
  e.stopPropagation();
643
745
  this._enterEditing(block);
644
746
  });
747
+ } else {
748
+ // 启用图库时:双击图片类块(含 type: 'image' 字段,如 image / logo / hero)直接打开图库换图,
749
+ // 与右栏「图库」按钮共用同一份 adapter 与权限配置
750
+ const galleryField = this._galleryImageField(def);
751
+ if (galleryField) {
752
+ el.addEventListener('dblclick', (e) => {
753
+ if (this.sectionSelectActive) return; // 批量选择模式下双击仍只作勾选
754
+ e.stopPropagation();
755
+ e.preventDefault();
756
+ this._openGalleryForBlock(block, galleryField);
757
+ });
758
+ }
645
759
  }
646
760
 
647
761
  return el;
@@ -677,7 +791,53 @@ export class Canvas {
677
791
  /* ----------------------------- 内联编辑 -------------------------------- */
678
792
 
679
793
  /** 空文档时:在画布白底区域双击,插入一列 Section + 空文本并进入编辑 */
794
+ /** 图库启用(showGallery 且配置了 imageGallery / pickImageFromGallery)时返回块内第一个 `type: 'image'` 字段,作为双击换图入口 */
795
+ private _galleryImageField(def: BlockDefinition | undefined): BlockSchemaField | null {
796
+ const assets = this.opts.imageAssets;
797
+ if (!assets || assets.showGallery !== true) return null;
798
+ if (!assets.imageGallery && !assets.pickImageFromGallery) return null;
799
+ return def?.schema.find((f) => f.type === 'image') ?? null;
800
+ }
801
+
802
+ /**
803
+ * 双击图片类块打开图库换图,选中后写入 field 对应的 props 字段
804
+ * (image.src / logo.src / hero.backgroundUrl…)。
805
+ * 优先内置图库弹层(与右栏「图库」按钮同一 adapter / 权限),
806
+ * 宿主自管场景走 pickImageFromGallery 回调。
807
+ */
808
+ private _openGalleryForBlock(block: Block, field: BlockSchemaField) {
809
+ const assets = this.opts.imageAssets;
810
+ if (!assets) return;
811
+ const propKey = field.key;
812
+ const currentUrl = String((block.props as Record<string, unknown>)[propKey] ?? '');
813
+ const applyUrl = (url: string | null) => {
814
+ const value = url == null ? '' : String(url).trim();
815
+ if (!value) return;
816
+ const blockId = block.id;
817
+ this.opts.store.update((d) => {
818
+ const loc = findBlockLocation(d, blockId);
819
+ if (loc) (loc.block.props as Record<string, unknown>)[propKey] = value;
820
+ });
821
+ };
822
+ if (assets.imageGallery) {
823
+ openImageGalleryModal({
824
+ adapter: assets.imageGallery,
825
+ permissions: assets.permissions,
826
+ onPick: (url) => applyUrl(url),
827
+ t: this.opts.t,
828
+ parent: this.opts.layerRoot,
829
+ });
830
+ return;
831
+ }
832
+ assets
833
+ .pickImageFromGallery?.({ blockId: block.id, propKey, currentUrl })
834
+ .then(applyUrl, (e) => {
835
+ console.error('[simple-mail] pickImageFromGallery', e);
836
+ });
837
+ }
838
+
680
839
  private _onEmptyCanvasDblClick(e: MouseEvent) {
840
+ if (this.sectionSelectActive) return;
681
841
  if (this.opts.store.doc.sections.length > 0) return;
682
842
  const t = e.target as HTMLElement | null;
683
843
  if (!t || !this.inner.contains(t)) return;
@@ -711,6 +871,7 @@ export class Canvas {
711
871
 
712
872
  /** 空列双击:插入正文组件(content 为空)并直接进入编辑 */
713
873
  private _insertEmptyTextAndEdit(sectionId: string, columnIndex: number) {
874
+ if (this.sectionSelectActive) return;
714
875
  const reg = this.opts.registry;
715
876
  if (!reg.get('text')?.inlineEditable) return;
716
877
  const newBlock = reg.createBlock('text');
@@ -731,6 +892,7 @@ export class Canvas {
731
892
  }
732
893
 
733
894
  private _enterEditing(block: Block) {
895
+ if (this.sectionSelectActive) return;
734
896
  if (this.editingBlockId === block.id) return;
735
897
  this._exitEditing(true);
736
898
 
@@ -866,7 +1028,9 @@ export class Canvas {
866
1028
  return !!attrs?.dynamicVariantKey;
867
1029
  }
868
1030
 
869
- private _firstBlockInSection(section: Section): { block: Block; columnIndex: number } | undefined {
1031
+ private _firstBlockInSection(
1032
+ section: Section,
1033
+ ): { block: Block; columnIndex: number } | undefined {
870
1034
  for (let columnIndex = 0; columnIndex < section.columns.length; columnIndex += 1) {
871
1035
  const block = section.columns[columnIndex]?.blocks[0];
872
1036
  if (block) return { block, columnIndex };
@@ -1028,6 +1192,359 @@ export class Canvas {
1028
1192
  });
1029
1193
  }
1030
1194
 
1195
+ /* ------------------------- 节选择模式(批量操作) ------------------------- */
1196
+
1197
+ get isSectionSelectMode(): boolean {
1198
+ return this.sectionSelectActive;
1199
+ }
1200
+
1201
+ /** 进入节选择模式:屏蔽单选 / 拖拽 / 内联编辑,点击块勾块、点击节空白/圈勾整节,可批量复制 / 导出 / 删除 */
1202
+ enterSectionSelectMode() {
1203
+ if (this.sectionSelectActive) return;
1204
+ this.commitInlineEdit();
1205
+ this.sectionSelectActive = true;
1206
+ this.checkedSectionIds.clear();
1207
+ this.checkedBlockIds.clear();
1208
+ this.sectionCheckAnchor = null;
1209
+ this.opts.store.setSelection(null);
1210
+ this.el.classList.add('sm-section-select-mode');
1211
+ this._setSortablesDisabled(true);
1212
+ this._renderSectionSelectBar();
1213
+ this._syncSectionChecks();
1214
+ this.opts.onSectionSelectModeChange?.(true);
1215
+ }
1216
+
1217
+ /** 退出节选择模式,恢复常规画布交互 */
1218
+ exitSectionSelectMode() {
1219
+ if (!this.sectionSelectActive) return;
1220
+ this.sectionSelectActive = false;
1221
+ this.checkedSectionIds.clear();
1222
+ this.checkedBlockIds.clear();
1223
+ this.sectionCheckAnchor = null;
1224
+ this.el.classList.remove('sm-section-select-mode');
1225
+ this.sectionSelectBar?.remove();
1226
+ this.sectionSelectBar = null;
1227
+ this.sectionSelectCountEl = null;
1228
+ this.sectionSelectAllBtn = null;
1229
+ this.sectionSelectActionBtns = [];
1230
+ this._setSortablesDisabled(false);
1231
+ this._syncSectionChecks();
1232
+ this.opts.onSectionSelectModeChange?.(false);
1233
+ }
1234
+
1235
+ /** 点节(空白 / 选择圈):未全选 → 整节选中;已全选 → 清空该节(取消其内散选块) */
1236
+ private _toggleSectionFull(id: string) {
1237
+ const sec = findSection(this.opts.store.doc, id);
1238
+ if (!sec) return;
1239
+ if (this.checkedSectionIds.has(id)) {
1240
+ this.checkedSectionIds.delete(id);
1241
+ } else {
1242
+ for (const c of sec.columns) {
1243
+ for (const b of c.blocks) this.checkedBlockIds.delete(b.id);
1244
+ }
1245
+ this.checkedSectionIds.add(id);
1246
+ }
1247
+ this.sectionCheckAnchor = { kind: 'section', id, sectionId: id, columnIndex: -1 };
1248
+ this._syncSectionChecks();
1249
+ }
1250
+
1251
+ /**
1252
+ * 点块:切换该块选中态,不带走同节其他块。
1253
+ * 整节选中时点其中一块 = 取消该块,其余块退散为逐块选中(半选)。
1254
+ */
1255
+ private _toggleBlockCheck(sectionId: string, columnIndex: number, blockId: string) {
1256
+ const sec = findSection(this.opts.store.doc, sectionId);
1257
+ if (!sec) return;
1258
+ if (this.checkedSectionIds.has(sectionId)) {
1259
+ this.checkedSectionIds.delete(sectionId);
1260
+ for (const c of sec.columns) {
1261
+ for (const b of c.blocks) if (b.id !== blockId) this.checkedBlockIds.add(b.id);
1262
+ }
1263
+ } else if (this.checkedBlockIds.has(blockId)) {
1264
+ this.checkedBlockIds.delete(blockId);
1265
+ } else {
1266
+ this.checkedBlockIds.add(blockId);
1267
+ }
1268
+ this.sectionCheckAnchor = { kind: 'block', id: blockId, sectionId, columnIndex };
1269
+ this._normalizeCheckedSets();
1270
+ this._syncSectionChecks();
1271
+ }
1272
+
1273
+ /** Shift+点击节:锚也是节时做节区间(替换全部勾选,Finder 语义);否则退化为单点切换 */
1274
+ private _shiftClickSection(targetId: string) {
1275
+ const ids = this.opts.store.doc.sections.map((s) => s.id);
1276
+ const targetIdx = ids.indexOf(targetId);
1277
+ if (targetIdx < 0) return;
1278
+ const anchor = this.sectionCheckAnchor;
1279
+ const anchorIdx = anchor?.kind === 'section' ? ids.indexOf(anchor.id) : -1;
1280
+ if (anchorIdx < 0) {
1281
+ this._toggleSectionFull(targetId);
1282
+ return;
1283
+ }
1284
+ const from = Math.min(anchorIdx, targetIdx);
1285
+ const to = Math.max(anchorIdx, targetIdx);
1286
+ this.checkedSectionIds = new Set(ids.slice(from, to + 1));
1287
+ this.checkedBlockIds.clear();
1288
+ this._syncSectionChecks();
1289
+ }
1290
+
1291
+ /** Shift+点击块:锚是同节同列的块时做列内区间(替换全部勾选);跨类 / 跨列退化为单点切换 */
1292
+ private _shiftClickBlock(sectionId: string, columnIndex: number, blockId: string) {
1293
+ const sec = findSection(this.opts.store.doc, sectionId);
1294
+ const col = sec?.columns[columnIndex];
1295
+ if (!sec || !col) return;
1296
+ const ids = col.blocks.map((b) => b.id);
1297
+ const targetIdx = ids.indexOf(blockId);
1298
+ if (targetIdx < 0) return;
1299
+ const anchor = this.sectionCheckAnchor;
1300
+ const anchorIdx =
1301
+ anchor?.kind === 'block' &&
1302
+ anchor.sectionId === sectionId &&
1303
+ anchor.columnIndex === columnIndex
1304
+ ? ids.indexOf(anchor.id)
1305
+ : -1;
1306
+ if (anchorIdx < 0) {
1307
+ this._toggleBlockCheck(sectionId, columnIndex, blockId);
1308
+ return;
1309
+ }
1310
+ const from = Math.min(anchorIdx, targetIdx);
1311
+ const to = Math.max(anchorIdx, targetIdx);
1312
+ this.checkedSectionIds.clear();
1313
+ this.checkedBlockIds = new Set(ids.slice(from, to + 1));
1314
+ this._syncSectionChecks();
1315
+ }
1316
+
1317
+ /**
1318
+ * ⌘/Ctrl+点击直达:进入选择模式并整节勾选(macOS ⌘ / Windows·Linux Ctrl,
1319
+ * 与文件管理器「加选」一致的手势,省去先点顶栏入口的往返)。
1320
+ */
1321
+ private _enterSectionSelectModeWith(sectionId: string) {
1322
+ this.enterSectionSelectMode();
1323
+ this.checkedSectionIds.add(sectionId);
1324
+ this.sectionCheckAnchor = { kind: 'section', id: sectionId, sectionId, columnIndex: -1 };
1325
+ this._syncSectionChecks();
1326
+ }
1327
+
1328
+ /** ⌘/Ctrl+点击块直达:进入选择模式并勾选该块(锚为块,后续可 Shift 列内区间) */
1329
+ private _enterSectionSelectModeWithBlock(
1330
+ sectionId: string,
1331
+ columnIndex: number,
1332
+ blockId: string,
1333
+ ) {
1334
+ this.enterSectionSelectMode();
1335
+ this.checkedBlockIds.add(blockId);
1336
+ this.sectionCheckAnchor = { kind: 'block', id: blockId, sectionId, columnIndex };
1337
+ this._syncSectionChecks();
1338
+ }
1339
+
1340
+ /** 节的勾选三态:无选 / 半选(部分散选块)/ 全选(整节) */
1341
+ private _sectionCheckState(section: Section): 'none' | 'partial' | 'full' {
1342
+ if (this.checkedSectionIds.has(section.id)) return 'full';
1343
+ let total = 0;
1344
+ let hit = 0;
1345
+ for (const c of section.columns) {
1346
+ for (const b of c.blocks) {
1347
+ total++;
1348
+ if (this.checkedBlockIds.has(b.id)) hit++;
1349
+ }
1350
+ }
1351
+ if (total === 0 || hit === 0) return 'none';
1352
+ return hit === total ? 'full' : 'partial';
1353
+ }
1354
+
1355
+ /**
1356
+ * 勾选集归一化,维护两条不变式:
1357
+ * 1. 失效 id(节/块被删)及时清理
1358
+ * 2. 节内全部块散选时折叠为整节选中;散选块的所属节被整选时清掉该块
1359
+ */
1360
+ private _normalizeCheckedSets() {
1361
+ const sections = this.opts.store.doc.sections;
1362
+ const aliveSectionIds = new Set(sections.map((s) => s.id));
1363
+ for (const id of [...this.checkedSectionIds]) {
1364
+ if (!aliveSectionIds.has(id)) this.checkedSectionIds.delete(id);
1365
+ }
1366
+ const aliveBlockIds = new Set<string>();
1367
+ for (const s of sections) {
1368
+ for (const c of s.columns) for (const b of c.blocks) aliveBlockIds.add(b.id);
1369
+ }
1370
+ for (const id of [...this.checkedBlockIds]) {
1371
+ if (!aliveBlockIds.has(id)) this.checkedBlockIds.delete(id);
1372
+ }
1373
+ for (const s of sections) {
1374
+ if (this.checkedSectionIds.has(s.id)) {
1375
+ for (const c of s.columns) {
1376
+ for (const b of c.blocks) this.checkedBlockIds.delete(b.id);
1377
+ }
1378
+ continue;
1379
+ }
1380
+ const total = s.columns.reduce((n, c) => n + c.blocks.length, 0);
1381
+ if (total === 0) continue;
1382
+ const hit = s.columns.reduce(
1383
+ (n, c) => n + c.blocks.filter((b) => this.checkedBlockIds.has(b.id)).length,
1384
+ 0,
1385
+ );
1386
+ if (hit === total) {
1387
+ this.checkedSectionIds.add(s.id);
1388
+ for (const c of s.columns) {
1389
+ for (const b of c.blocks) this.checkedBlockIds.delete(b.id);
1390
+ }
1391
+ }
1392
+ }
1393
+ }
1394
+
1395
+ private _toggleSelectAllSections() {
1396
+ const doc = this.opts.store.doc;
1397
+ if (doc.sections.length > 0 && this.checkedSectionIds.size === doc.sections.length) {
1398
+ this.checkedSectionIds.clear();
1399
+ this.checkedBlockIds.clear();
1400
+ } else {
1401
+ this.checkedSectionIds = new Set(doc.sections.map((s) => s.id));
1402
+ this.checkedBlockIds.clear();
1403
+ }
1404
+ this._syncSectionChecks();
1405
+ }
1406
+
1407
+ /** 同步勾选态:归一化 → 节三态圈 / 块高亮 → 计数 / 全选 / 动作按钮可用性 */
1408
+ private _syncSectionChecks() {
1409
+ this._normalizeCheckedSets();
1410
+ const doc = this.opts.store.doc;
1411
+ const sectionById = new Map(doc.sections.map((s) => [s.id, s]));
1412
+
1413
+ for (const host of this.inner.querySelectorAll<HTMLElement>('.sm-section-host')) {
1414
+ host.classList.remove('is-check-selected', 'is-check-partial');
1415
+ const id = host.getAttribute('data-id') ?? '';
1416
+ const sec = sectionById.get(id);
1417
+ if (!sec) continue;
1418
+ const state = this._sectionCheckState(sec);
1419
+ if (state === 'full') host.classList.add('is-check-selected');
1420
+ else if (state === 'partial') host.classList.add('is-check-partial');
1421
+ }
1422
+ for (const el of this.inner.querySelectorAll('.sm-block.is-check-selected')) {
1423
+ el.classList.remove('is-check-selected');
1424
+ }
1425
+ for (const id of this.checkedBlockIds) {
1426
+ this.inner
1427
+ .querySelector(`.sm-block[data-id="${cssEscape(id)}"]`)
1428
+ ?.classList.add('is-check-selected');
1429
+ }
1430
+
1431
+ const secCount = this.checkedSectionIds.size;
1432
+ const blockCount = this.checkedBlockIds.size;
1433
+ if (this.sectionSelectCountEl) {
1434
+ this.sectionSelectCountEl.textContent =
1435
+ secCount > 0 && blockCount > 0
1436
+ ? this.opts.t('sectionSelect.countMixed', { sections: secCount, blocks: blockCount })
1437
+ : blockCount > 0
1438
+ ? this.opts.t('sectionSelect.countBlocks', { count: blockCount })
1439
+ : this.opts.t('sectionSelect.count', { count: secCount });
1440
+ }
1441
+ if (this.sectionSelectAllBtn) {
1442
+ const all = doc.sections.length > 0 && secCount === doc.sections.length;
1443
+ this.sectionSelectAllBtn.textContent = this.opts.t(
1444
+ all ? 'sectionSelect.unselectAll' : 'sectionSelect.selectAll',
1445
+ );
1446
+ this.sectionSelectAllBtn.disabled = doc.sections.length === 0;
1447
+ }
1448
+ for (const btn of this.sectionSelectActionBtns) {
1449
+ btn.disabled = secCount + blockCount === 0;
1450
+ }
1451
+ }
1452
+
1453
+ private _setSortablesDisabled(disabled: boolean) {
1454
+ for (const s of this.sortableRefs) {
1455
+ try {
1456
+ s.option('disabled', disabled);
1457
+ } catch {
1458
+ /* sortable 已销毁时忽略 */
1459
+ }
1460
+ }
1461
+ }
1462
+
1463
+ private _renderSectionSelectBar() {
1464
+ const t = this.opts.t;
1465
+ this.sectionSelectCountEl = h('span', { class: 'sm-section-select-bar__count' }, [
1466
+ t('sectionSelect.count', { count: 0 }),
1467
+ ]);
1468
+ this.sectionSelectAllBtn = h(
1469
+ 'button',
1470
+ {
1471
+ class: 'sm-section-select-bar__btn',
1472
+ type: 'button',
1473
+ onclick: () => this._toggleSelectAllSections(),
1474
+ },
1475
+ [t('sectionSelect.selectAll')],
1476
+ ) as HTMLButtonElement;
1477
+ const copyBtn = h(
1478
+ 'button',
1479
+ {
1480
+ class: 'sm-section-select-bar__btn',
1481
+ type: 'button',
1482
+ onclick: () => this._runSectionSelectAction('copy'),
1483
+ },
1484
+ [t('sectionSelect.copyDesign')],
1485
+ ) as HTMLButtonElement;
1486
+ const exportBtn = h(
1487
+ 'button',
1488
+ {
1489
+ class: 'sm-section-select-bar__btn',
1490
+ type: 'button',
1491
+ onclick: () => this._runSectionSelectAction('export'),
1492
+ },
1493
+ [t('sectionSelect.exportJson')],
1494
+ ) as HTMLButtonElement;
1495
+ const deleteBtn = h(
1496
+ 'button',
1497
+ {
1498
+ class: 'sm-section-select-bar__btn sm-section-select-bar__btn--danger',
1499
+ type: 'button',
1500
+ onclick: () => this._runSectionSelectAction('remove'),
1501
+ },
1502
+ [t('common.delete')],
1503
+ ) as HTMLButtonElement;
1504
+ const doneBtn = h(
1505
+ 'button',
1506
+ {
1507
+ class: 'sm-section-select-bar__btn sm-section-select-bar__btn--primary',
1508
+ type: 'button',
1509
+ onclick: () => this.exitSectionSelectMode(),
1510
+ },
1511
+ [t('sectionSelect.done')],
1512
+ ) as HTMLButtonElement;
1513
+ this.sectionSelectActionBtns = [copyBtn, exportBtn, deleteBtn];
1514
+ this.sectionSelectBar = h('div', { class: 'sm-section-select-bar', role: 'toolbar' }, [
1515
+ this.sectionSelectCountEl,
1516
+ this.sectionSelectAllBtn,
1517
+ copyBtn,
1518
+ exportBtn,
1519
+ deleteBtn,
1520
+ doneBtn,
1521
+ ]);
1522
+ this.el.append(this.sectionSelectBar);
1523
+ }
1524
+
1525
+ private _runSectionSelectAction(kind: 'copy' | 'export' | 'remove') {
1526
+ const sectionIds = [...this.checkedSectionIds];
1527
+ const blockIds = [...this.checkedBlockIds];
1528
+ if (!sectionIds.length && !blockIds.length) return;
1529
+ if (kind === 'remove') {
1530
+ const t = this.opts.t;
1531
+ const msg =
1532
+ sectionIds.length > 0 && blockIds.length > 0
1533
+ ? t('sectionSelect.deleteConfirmMixed', {
1534
+ sections: sectionIds.length,
1535
+ blocks: blockIds.length,
1536
+ })
1537
+ : blockIds.length > 0
1538
+ ? t('sectionSelect.deleteConfirmBlocks', { count: blockIds.length })
1539
+ : t('sectionSelect.deleteConfirm', { count: sectionIds.length });
1540
+ if (!window.confirm(msg)) return;
1541
+ this.opts.onSectionSelectRemove?.(sectionIds, blockIds);
1542
+ return;
1543
+ }
1544
+ if (kind === 'copy') this.opts.onSectionSelectCopy?.(sectionIds, blockIds);
1545
+ else this.opts.onSectionSelectExport?.(sectionIds, blockIds);
1546
+ }
1547
+
1031
1548
  /* -------------------------------- 操作 ---------------------------------- */
1032
1549
 
1033
1550
  private _removeSection(id: string) {
@@ -1125,8 +1642,7 @@ export class Canvas {
1125
1642
  /* ---------------------------------- utils --------------------------------- */
1126
1643
 
1127
1644
  function layoutFlexStyle(_layout: SectionLayout, columnGap?: number): string {
1128
- const gap =
1129
- columnGap != null && columnGap > 0 ? `gap:${Math.min(columnGap, 200)}px;` : '';
1645
+ const gap = columnGap != null && columnGap > 0 ? `gap:${Math.min(columnGap, 200)}px;` : '';
1130
1646
  return `display:flex;flex-wrap:nowrap;width:100%;${gap}`;
1131
1647
  }
1132
1648
 
@@ -1169,6 +1685,12 @@ function iconTrash() {
1169
1685
  '<path d="M5 6h10M8 6V4h4v2M6 6l1 10h6l1-10" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linejoin="round"/>',
1170
1686
  );
1171
1687
  }
1688
+ /** 「复制设计稿」:双层文档图形,与顶栏 iconCopyDoc 保持一致 */
1689
+ function iconCopyDesign() {
1690
+ return svg(
1691
+ '<rect x="6.5" y="6.5" width="9" height="11" rx="1.2" stroke="currentColor" stroke-width="1.4" fill="none"/><rect x="4.5" y="3.5" width="9" height="11" rx="1.2" stroke="currentColor" stroke-width="1.4" fill="var(--sm-surface, #fff)"/>',
1692
+ );
1693
+ }
1172
1694
  function iconEdit() {
1173
1695
  return svg(
1174
1696
  '<path d="M4 14l8-8 2 2-8 8H4v-2z" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linejoin="round"/>',
@@ -1179,3 +1701,19 @@ function iconCode() {
1179
1701
  '<path d="M7 7l-3 3 3 3M13 7l3 3-3 3" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
1180
1702
  );
1181
1703
  }
1704
+ /** 勾选(节选择模式气泡):全选态 */
1705
+ function iconCheck() {
1706
+ const el = svg(
1707
+ '<path d="M4.5 10.5l3.4 3.4 7.6-7.8" stroke="currentColor" stroke-width="2.2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
1708
+ );
1709
+ el.classList.add('sm-section-check__icon-check');
1710
+ return el;
1711
+ }
1712
+ /** 减号(节选择模式气泡):半选态(节内部分块被散选) */
1713
+ function iconMinus() {
1714
+ const el = svg(
1715
+ '<path d="M5 10h10" stroke="currentColor" stroke-width="2.2" fill="none" stroke-linecap="round"/>',
1716
+ );
1717
+ el.classList.add('sm-section-check__icon-minus');
1718
+ return el;
1719
+ }