mail-editor-pancake 0.0.8 → 0.1.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 (48) hide show
  1. package/README.md +498 -218
  2. package/package.json +2 -2
  3. package/packages/blocks/dist/index.d.ts +12 -0
  4. package/packages/blocks/dist/index.js +187 -145
  5. package/packages/blocks/dist/index.js.map +1 -1
  6. package/packages/blocks/src/html.ts +11 -3
  7. package/packages/blocks/src/index.ts +2 -0
  8. package/packages/blocks/src/social.ts +8 -11
  9. package/packages/blocks/src/socialGroup.ts +8 -11
  10. package/packages/blocks/src/socialShared.ts +52 -0
  11. package/packages/blocks/src/text.ts +8 -3
  12. package/packages/core/dist/index.d.ts +275 -12
  13. package/packages/core/dist/index.js +4099 -2194
  14. package/packages/core/dist/index.js.map +1 -1
  15. package/packages/core/dist/style.css +1 -1
  16. package/packages/core/package.json +1 -1
  17. package/packages/core/src/editor/BlockCodeModal.ts +84 -2
  18. package/packages/core/src/editor/Canvas.ts +191 -26
  19. package/packages/core/src/editor/Editor.ts +589 -76
  20. package/packages/core/src/editor/ExportModal.ts +1 -1
  21. package/packages/core/src/editor/ImportDocModal.ts +129 -0
  22. package/packages/core/src/editor/InlineEditor.ts +246 -22
  23. package/packages/core/src/editor/LeftPanel.ts +55 -14
  24. package/packages/core/src/editor/Modal.ts +20 -1
  25. package/packages/core/src/editor/PreviewModal.ts +2 -2
  26. package/packages/core/src/editor/RichTextToolbar.ts +125 -18
  27. package/packages/core/src/editor/RightPanel.ts +165 -7
  28. package/packages/core/src/editor/Topbar.ts +306 -132
  29. package/packages/core/src/editor/VariablePickerPanel.ts +145 -0
  30. package/packages/core/src/editor/styles.css +458 -59
  31. package/packages/core/src/index.ts +43 -2
  32. package/packages/core/src/renderer/index.ts +9 -1
  33. package/packages/core/src/renderer/mjml.ts +4 -5
  34. package/packages/core/src/store/store.ts +13 -2
  35. package/packages/core/src/types.ts +67 -2
  36. package/packages/core/src/utils/docClipboard.ts +89 -0
  37. package/packages/core/src/utils/dynamicVariantHtml.ts +45 -0
  38. package/packages/core/src/utils/dynamicVariantKey.ts +24 -0
  39. package/packages/core/src/utils/dynamicVariantSection.ts +98 -0
  40. package/packages/core/src/utils/emailListStyles.ts +188 -0
  41. package/packages/core/src/utils/inlineListEditing.ts +774 -0
  42. package/packages/core/src/utils/lockedMjml.ts +146 -12
  43. package/packages/core/src/utils/modalSize.ts +27 -0
  44. package/packages/core/src/utils/richHtmlEmpty.ts +43 -0
  45. package/packages/core/src/utils/richHtmlInsert.ts +16 -0
  46. package/packages/core/src/utils/sectionLayout.ts +2 -2
  47. package/packages/core/src/variables/index.ts +90 -0
  48. package/playground/vanilla/src/main.ts +26 -1
@@ -7,11 +7,26 @@ import {
7
7
  pruneSectionIfEmpty,
8
8
  type Store,
9
9
  } from '../store/store';
10
- import type { Block, Column, EmailDoc, RenderContext, Section, SectionLayout } from '../types';
10
+ import type {
11
+ Block,
12
+ Column,
13
+ EditorUiOptions,
14
+ EmailDoc,
15
+ PaletteDropResult,
16
+ RenderContext,
17
+ Section,
18
+ SectionAttrs,
19
+ SectionLayout,
20
+ } from '../types';
21
+ import { getSectionDynamicVariantKey } from '../utils/dynamicVariantSection';
11
22
  import { blockButtonWidthCss, docContentWidthCss } from '../utils/contentWidth';
23
+ import { globalListIndentCssVarValue } from '../utils/emailListStyles';
12
24
  import { normalizeFontWeightStep } from '../utils/fontWeightSteps';
13
25
  import { clear, escapeHtml, h } from '../utils/dom';
14
- import { htmlFragmentForLockedHtmlBlockCanvas } from '../utils/lockedMjml';
26
+ import {
27
+ defaultPaddingForLockedBlock,
28
+ resolveLockedMjmlCanvasContent,
29
+ } from '../utils/lockedMjml';
15
30
  import { layoutHumanLabel } from '../utils/sectionLayout';
16
31
  import { BlockCodeModal } from './BlockCodeModal';
17
32
  import { InlineEditor, type SelectionState } from './InlineEditor';
@@ -34,6 +49,8 @@ export interface CanvasOptions {
34
49
  * 应传入 MailEditor 根节点;若挂到 `document.body`,`var(--sm-code-bg)` 等会失效,弹框背景会变透明。
35
50
  */
36
51
  layerRoot: HTMLElement;
52
+ /** 与 MailEditor.opts.ui 对齐 */
53
+ ui?: EditorUiOptions;
37
54
  }
38
55
 
39
56
  /**
@@ -62,6 +79,9 @@ export class Canvas {
62
79
  private pendingRender = false;
63
80
  /** 设计模式下禁用画布内超链接导航(预览弹框 iframe 不在此 DOM 内,不受影响) */
64
81
  private linkNavSuppression = new AbortController();
82
+ /** clearSelectionOnCanvasMargin:记录本次指针是否从编辑区内按下(区分拖选松手 vs 点空白退出) */
83
+ private clearSelectionGestureTracking = new AbortController();
84
+ private pointerGestureStartedInInlineEdit = false;
65
85
  private blockCodeModal: BlockCodeModal;
66
86
 
67
87
  constructor(opts: CanvasOptions) {
@@ -78,6 +98,7 @@ export class Canvas {
78
98
 
79
99
  this._bindDesignModeLinkSuppression();
80
100
  if (opts.clearSelectionOnCanvasMargin) {
101
+ this._bindClearSelectionGestureTracking();
81
102
  this._bindClearSelectionOnCanvasWhitespace();
82
103
  this._bindClearSelectionOnCanvasMargin();
83
104
  }
@@ -88,7 +109,13 @@ export class Canvas {
88
109
 
89
110
  this._render();
90
111
 
91
- opts.store.subscribe(() => {
112
+ opts.store.subscribe((detail) => {
113
+ // 编辑态下若走了文档级 undo/redo,必须丢弃未提交 DOM,否则失焦提交会与 store 脱节(整节消失等)
114
+ if (this.editingBlockId && detail?.source === 'history') {
115
+ this.abortInlineEdit();
116
+ this._render();
117
+ return;
118
+ }
92
119
  if (this.editingBlockId) {
93
120
  this.pendingRender = true;
94
121
  return;
@@ -112,13 +139,81 @@ export class Canvas {
112
139
  this.inlineEditor?.commit();
113
140
  }
114
141
 
142
+ get isInlineEditing(): boolean {
143
+ return this.editingBlockId != null;
144
+ }
145
+
146
+ /**
147
+ * 放弃未提交的内联编辑(不写入 store)。
148
+ * 用于文档级撤销/重做:编辑期间画布不重绘,若直接 undo 会导致 DOM 与 doc 不一致。
149
+ */
150
+ abortInlineEdit() {
151
+ if (!this.editingBlockId) return;
152
+ const id = this.editingBlockId;
153
+ this.editingBlockId = null;
154
+ this.pendingRender = false;
155
+ if (this.inlineEditor) {
156
+ this.inlineEditor.destroy(false);
157
+ this.inlineEditor = null;
158
+ }
159
+ this.opts.toolbar.detach();
160
+ const blockEl = this.inner.querySelector(
161
+ `.sm-block[data-id="${cssEscape(id)}"]`,
162
+ ) as HTMLElement | null;
163
+ blockEl?.classList.remove('is-editing');
164
+ }
165
+
166
+ /** 强制重绘(内联编辑中会延迟到 commit 后) */
167
+ refresh() {
168
+ if (this.editingBlockId) {
169
+ this.pendingRender = true;
170
+ return;
171
+ }
172
+ this._render();
173
+ }
174
+
115
175
  destroy() {
116
176
  this.linkNavSuppression.abort();
177
+ this.clearSelectionGestureTracking.abort();
117
178
  this._exitEditing(false);
118
179
  this._destroySortables();
119
180
  this.blockCodeModal.destroy();
120
181
  }
121
182
 
183
+ /**
184
+ * clearSelectionOnCanvasMargin:mousedown 在编辑区内、mouseup 落在灰底/留白时,
185
+ * 浏览器仍可能对 mouseup 目标派发 click;需与 Section padding 的拖选抑制同理。
186
+ */
187
+ private _bindClearSelectionGestureTracking() {
188
+ const opts = { capture: true, signal: this.clearSelectionGestureTracking.signal } as const;
189
+ document.addEventListener(
190
+ 'mousedown',
191
+ (e: MouseEvent) => {
192
+ if (e.button !== 0) {
193
+ this.pointerGestureStartedInInlineEdit = false;
194
+ return;
195
+ }
196
+ if (!this.editingBlockId) {
197
+ this.pointerGestureStartedInInlineEdit = false;
198
+ return;
199
+ }
200
+ const editingEl = this.inner.querySelector('.sm-inline-editing');
201
+ if (!editingEl) {
202
+ this.pointerGestureStartedInInlineEdit = false;
203
+ return;
204
+ }
205
+ const t = e.target;
206
+ if (!(t instanceof Node)) {
207
+ this.pointerGestureStartedInInlineEdit = false;
208
+ return;
209
+ }
210
+ const node = t.nodeType === Node.TEXT_NODE ? t.parentNode : t;
211
+ this.pointerGestureStartedInInlineEdit = !!(node && editingEl.contains(node));
212
+ },
213
+ opts,
214
+ );
215
+ }
216
+
122
217
  /**
123
218
  * 点击画布白底上未落到 Section 内的区域(如 min-height 下方留白、空文档提示)时清空选中,
124
219
  * Section/Block 上的点击会在冒泡到此前被 stopPropagation,不会误清。
@@ -130,6 +225,7 @@ export class Canvas {
130
225
  const el = t instanceof Element ? t : t.parentElement;
131
226
  if (!el) return;
132
227
  if (el === this.inner || el.closest('.sm-empty-doc')) {
228
+ if (this._shouldSuppressClearSelectionFromTextDrag()) return;
133
229
  this.commitInlineEdit();
134
230
  this.opts.store.setSelection(null);
135
231
  }
@@ -144,6 +240,7 @@ export class Canvas {
144
240
  if (!this.el.contains(t)) return;
145
241
  if (this.inner.contains(t)) return;
146
242
  if (this.addBar.contains(t)) return;
243
+ if (this._shouldSuppressClearSelectionFromTextDrag()) return;
147
244
  this.commitInlineEdit();
148
245
  this.opts.store.setSelection(null);
149
246
  });
@@ -193,6 +290,10 @@ export class Canvas {
193
290
  this.inner.style.color = doc.styles.color;
194
291
  this.inner.style.lineHeight = doc.styles.lineHeight ?? '1.25';
195
292
  this.inner.style.setProperty('--sm-editor-link-color', doc.styles.linkColor);
293
+ this.inner.style.setProperty(
294
+ '--sm-list-indent-default',
295
+ globalListIndentCssVarValue(doc.styles),
296
+ );
196
297
 
197
298
  if (!doc.sections.length) {
198
299
  this.inner.append(
@@ -235,7 +336,13 @@ export class Canvas {
235
336
  }px ${a.paddingLeft ?? 0}px`;
236
337
 
237
338
  const layoutShort = layoutHumanLabel(section.layout);
238
- const sectionChip = `区块 ${sectionIndex + 1} · ${layoutShort}`;
339
+ const dvKey =
340
+ this.opts.ui?.enableDynamicVariantKey === true
341
+ ? getSectionDynamicVariantKey(section)
342
+ : undefined;
343
+ const sectionChip = dvKey
344
+ ? `动态变量 ${sectionIndex + 1} · {{${dvKey}}}`
345
+ : `区块 ${sectionIndex + 1} · ${layoutShort}`;
239
346
 
240
347
  const sw = blockButtonWidthCss(a.width);
241
348
  const box =
@@ -247,7 +354,7 @@ export class Canvas {
247
354
  });
248
355
 
249
356
  const wrap = h('div', {
250
- class: 'sm-section',
357
+ class: `sm-section${dvKey ? ' sm-section--dynamic-variant' : ''}`,
251
358
  title: `${sectionChip}。子组件铺满列内时:按 Esc 或 Alt+点击块可选中本节。`,
252
359
  style: `padding:${padding};${a.backgroundColor ? `background:${a.backgroundColor};` : ''}${box}`,
253
360
  });
@@ -380,23 +487,24 @@ export class Canvas {
380
487
  const def = this.opts.registry.get(block.type);
381
488
  let inner = '';
382
489
  if (block.lockedMjml) {
383
- if (block.type === 'html') {
384
- const p = block.props as {
490
+ const isHtmlLike = block.type === 'html' || def?.inlineEditable?.mode === 'html';
491
+ const padding = defaultPaddingForLockedBlock(
492
+ block.type,
493
+ block.lockedMjml,
494
+ block.props as {
385
495
  paddingTop?: number;
386
496
  paddingRight?: number;
387
497
  paddingBottom?: number;
388
498
  paddingLeft?: number;
389
- };
390
- const frag = htmlFragmentForLockedHtmlBlockCanvas(block.lockedMjml, {
391
- top: Number(p.paddingTop ?? 8),
392
- right: Number(p.paddingRight ?? 0),
393
- bottom: Number(p.paddingBottom ?? 8),
394
- left: Number(p.paddingLeft ?? 0),
395
- });
396
- inner = `<div class="sm-html-content">${frag}</div>`;
499
+ },
500
+ );
501
+ const resolved = resolveLockedMjmlCanvasContent(block.lockedMjml, padding);
502
+ if (resolved.mode === 'preview') {
503
+ const wrapClass = isHtmlLike ? 'sm-html-content' : 'sm-locked-preview';
504
+ inner = `<div class="${wrapClass}">${resolved.html}</div>`;
397
505
  } else {
398
506
  inner = `<div style="padding:8px;color:#92400e;background:#fffbeb;border:1px dashed #f59e0b;font-family:monospace;font-size:11px;white-space:pre-wrap;">${escapeHtml(
399
- block.lockedMjml,
507
+ resolved.source,
400
508
  )}</div>`;
401
509
  }
402
510
  } else if (def?.renderPreview) {
@@ -511,7 +619,13 @@ export class Canvas {
511
619
  });
512
620
  });
513
621
 
514
- if (def?.inlineEditable && !block.lockedMjml) {
622
+ if (block.lockedMjml) {
623
+ el.addEventListener('dblclick', (e) => {
624
+ e.stopPropagation();
625
+ e.preventDefault();
626
+ this.blockCodeModal.open(block.id, displayName);
627
+ });
628
+ } else if (def?.inlineEditable) {
515
629
  el.addEventListener('dblclick', (e) => {
516
630
  e.stopPropagation();
517
631
  this._enterEditing(block);
@@ -524,8 +638,7 @@ export class Canvas {
524
638
  private _renderAddBar(): HTMLElement {
525
639
  const layouts: { layout: SectionLayout; label: string }[] = [
526
640
  { layout: '1', label: '一列' },
527
- { layout: '1-1', label: '两列' },
528
- { layout: '1-1-1', label: '三列' },
641
+ { layout: '1-1', label: '多列' },
529
642
  ];
530
643
  const bar = h('div', { class: 'sm-add-section-bar' }, [h('span', {}, ['+ 添加 Section:'])]);
531
644
  for (const l of layouts) {
@@ -637,6 +750,7 @@ export class Canvas {
637
750
  mode: def.inlineEditable.mode,
638
751
  multiline: !!def.inlineEditable.multiline,
639
752
  placeholder: def.inlineEditable.placeholder,
753
+ globalStyles: this.opts.store.doc.styles,
640
754
  onCommit: (value) => {
641
755
  const propKey = def.inlineEditable!.propKey;
642
756
  const editingId = block.id;
@@ -693,12 +807,33 @@ export class Canvas {
693
807
  if (this.editingBlockId) this.commitInlineEdit();
694
808
  }
695
809
 
696
- private _blocksFromPaletteDrop(blockType: string): Block[] {
810
+ private _resolvePaletteDrop(blockType: string): PaletteDropResult {
697
811
  const def = this.opts.registry.get(blockType);
698
812
  if (def?.expandPaletteDrop) {
699
- return def.expandPaletteDrop((t) => this.opts.registry.createBlock(t));
813
+ const out = def.expandPaletteDrop((t) => this.opts.registry.createBlock(t));
814
+ if (Array.isArray(out)) return { blocks: out };
815
+ return out;
816
+ }
817
+ return { blocks: [this.opts.registry.createBlock(blockType)] };
818
+ }
819
+
820
+ private _applySectionAttrs(target: Section, partial?: Partial<SectionAttrs>) {
821
+ if (!partial) return;
822
+ const { meta, ...rest } = partial;
823
+ Object.assign(target.attrs, rest);
824
+ if (meta && typeof meta === 'object') {
825
+ target.attrs.meta = { ...(target.attrs.meta ?? {}), ...meta };
700
826
  }
701
- return [this.opts.registry.createBlock(blockType)];
827
+ }
828
+
829
+ /** 未开启 `enableDynamicVariantKey` 时剥离 palette 上的动态变量节属性 */
830
+ private _paletteSectionAttrsForDrop(
831
+ sectionAttrs?: Partial<SectionAttrs>,
832
+ ): Partial<SectionAttrs> | undefined {
833
+ if (!sectionAttrs) return undefined;
834
+ if (this.opts.ui?.enableDynamicVariantKey === true) return sectionAttrs;
835
+ const { dynamicVariantKey: _dv, ...rest } = sectionAttrs;
836
+ return Object.keys(rest).length ? rest : undefined;
702
837
  }
703
838
 
704
839
  private _handleSectionAdd(e: Sortable.SortableEvent) {
@@ -722,8 +857,9 @@ export class Canvas {
722
857
  // 路径 B:左栏内容/自定义卡片拖到 sections 之间 → 自动裹一列 Section
723
858
  if (sourceGroup === 'blocks' && blockType) {
724
859
  item.parentElement?.removeChild(item);
725
- const blocks = this._blocksFromPaletteDrop(blockType);
860
+ const { blocks, sectionAttrs } = this._resolvePaletteDrop(blockType);
726
861
  const newSection = createSection('1');
862
+ this._applySectionAttrs(newSection, this._paletteSectionAttrsForDrop(sectionAttrs));
727
863
  newSection.columns[0].blocks.splice(newIndex, 0, ...blocks);
728
864
  this.opts.store.update((d) => {
729
865
  d.sections.splice(newIndex, 0, newSection);
@@ -788,7 +924,29 @@ export class Canvas {
788
924
 
789
925
  if (sourceGroup === 'blocks' && blockType) {
790
926
  item.parentElement?.removeChild(item);
791
- const blocks = this._blocksFromPaletteDrop(blockType);
927
+ const drop = this._resolvePaletteDrop(blockType);
928
+ const paletteSectionAttrs = this._paletteSectionAttrsForDrop(drop.sectionAttrs);
929
+ if (paletteSectionAttrs?.dynamicVariantKey) {
930
+ const newSection = createSection('1');
931
+ this._applySectionAttrs(newSection, paletteSectionAttrs);
932
+ newSection.columns[0].blocks.push(...drop.blocks);
933
+ this.opts.store.update((d) => {
934
+ const secIdx = d.sections.findIndex((s) => s.id === sectionId);
935
+ const insertAt = secIdx >= 0 ? secIdx + 1 : d.sections.length;
936
+ d.sections.splice(insertAt, 0, newSection);
937
+ });
938
+ const head = drop.blocks[0];
939
+ if (head) {
940
+ this.opts.store.setSelection({
941
+ kind: 'block',
942
+ sectionId: newSection.id,
943
+ columnIndex: 0,
944
+ blockId: head.id,
945
+ });
946
+ }
947
+ return;
948
+ }
949
+ const blocks = drop.blocks;
792
950
  this.opts.store.update((d) => {
793
951
  const sec = d.sections.find((s) => s.id === sectionId);
794
952
  if (!sec) return;
@@ -884,9 +1042,16 @@ export class Canvas {
884
1042
  }
885
1043
 
886
1044
  /**
887
- * 内联编辑中拖选文字,若 mouseup 落在 Section padding 等非 Block 区域,
888
- * 浏览器仍会向 Section 冒泡 click;此时选区仍在编辑区内,不应切换右侧面板到 Section。
1045
+ * 内联编辑中从编辑区拖选文字,若 mouseup 落在 Section padding / 灰底 / 留白,
1046
+ * 浏览器仍可能对 mouseup 目标派发 click;选区仍在编辑区内时不应提交或改选中。
889
1047
  */
1048
+ private _shouldSuppressClearSelectionFromTextDrag(): boolean {
1049
+ return (
1050
+ this.pointerGestureStartedInInlineEdit &&
1051
+ this._shouldSuppressSectionSelectFromTextInteraction()
1052
+ );
1053
+ }
1054
+
890
1055
  private _shouldSuppressSectionSelectFromTextInteraction(): boolean {
891
1056
  if (!this.editingBlockId) return false;
892
1057
  const editingEl = this.inner.querySelector('.sm-inline-editing');