mail-editor-pancake 0.0.9 → 0.1.1

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 (42) hide show
  1. package/README.md +461 -303
  2. package/package.json +2 -2
  3. package/packages/blocks/dist/index.js +153 -150
  4. package/packages/blocks/dist/index.js.map +1 -1
  5. package/packages/blocks/src/html.ts +11 -3
  6. package/packages/blocks/src/text.ts +8 -3
  7. package/packages/core/dist/index.d.ts +220 -10
  8. package/packages/core/dist/index.js +3963 -2250
  9. package/packages/core/dist/index.js.map +1 -1
  10. package/packages/core/dist/style.css +1 -1
  11. package/packages/core/package.json +1 -1
  12. package/packages/core/src/editor/BlockCodeModal.ts +84 -2
  13. package/packages/core/src/editor/Canvas.ts +182 -26
  14. package/packages/core/src/editor/Editor.ts +414 -68
  15. package/packages/core/src/editor/ExportModal.ts +1 -1
  16. package/packages/core/src/editor/ImportDocModal.ts +129 -0
  17. package/packages/core/src/editor/InlineEditor.ts +238 -24
  18. package/packages/core/src/editor/LeftPanel.ts +55 -14
  19. package/packages/core/src/editor/Modal.ts +20 -1
  20. package/packages/core/src/editor/PreviewModal.ts +2 -2
  21. package/packages/core/src/editor/RichTextToolbar.ts +125 -18
  22. package/packages/core/src/editor/RightPanel.ts +165 -7
  23. package/packages/core/src/editor/Topbar.ts +306 -132
  24. package/packages/core/src/editor/VariablePickerPanel.ts +145 -0
  25. package/packages/core/src/editor/styles.css +410 -52
  26. package/packages/core/src/index.ts +32 -2
  27. package/packages/core/src/renderer/index.ts +9 -1
  28. package/packages/core/src/renderer/mjml.ts +4 -5
  29. package/packages/core/src/store/store.ts +13 -2
  30. package/packages/core/src/types.ts +65 -2
  31. package/packages/core/src/utils/docClipboard.ts +89 -0
  32. package/packages/core/src/utils/dynamicVariantHtml.ts +45 -0
  33. package/packages/core/src/utils/dynamicVariantKey.ts +24 -0
  34. package/packages/core/src/utils/dynamicVariantSection.ts +98 -0
  35. package/packages/core/src/utils/emailListStyles.ts +188 -0
  36. package/packages/core/src/utils/inlineListEditing.ts +774 -0
  37. package/packages/core/src/utils/lockedMjml.ts +146 -12
  38. package/packages/core/src/utils/modalSize.ts +27 -0
  39. package/packages/core/src/utils/richHtmlEmpty.ts +43 -0
  40. package/packages/core/src/utils/sectionLayout.ts +2 -2
  41. package/packages/core/src/variables/index.ts +3 -4
  42. 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,6 +139,30 @@ 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
+
115
166
  /** 强制重绘(内联编辑中会延迟到 commit 后) */
116
167
  refresh() {
117
168
  if (this.editingBlockId) {
@@ -123,11 +174,46 @@ export class Canvas {
123
174
 
124
175
  destroy() {
125
176
  this.linkNavSuppression.abort();
177
+ this.clearSelectionGestureTracking.abort();
126
178
  this._exitEditing(false);
127
179
  this._destroySortables();
128
180
  this.blockCodeModal.destroy();
129
181
  }
130
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
+
131
217
  /**
132
218
  * 点击画布白底上未落到 Section 内的区域(如 min-height 下方留白、空文档提示)时清空选中,
133
219
  * Section/Block 上的点击会在冒泡到此前被 stopPropagation,不会误清。
@@ -139,6 +225,7 @@ export class Canvas {
139
225
  const el = t instanceof Element ? t : t.parentElement;
140
226
  if (!el) return;
141
227
  if (el === this.inner || el.closest('.sm-empty-doc')) {
228
+ if (this._shouldSuppressClearSelectionFromTextDrag()) return;
142
229
  this.commitInlineEdit();
143
230
  this.opts.store.setSelection(null);
144
231
  }
@@ -153,6 +240,7 @@ export class Canvas {
153
240
  if (!this.el.contains(t)) return;
154
241
  if (this.inner.contains(t)) return;
155
242
  if (this.addBar.contains(t)) return;
243
+ if (this._shouldSuppressClearSelectionFromTextDrag()) return;
156
244
  this.commitInlineEdit();
157
245
  this.opts.store.setSelection(null);
158
246
  });
@@ -202,6 +290,10 @@ export class Canvas {
202
290
  this.inner.style.color = doc.styles.color;
203
291
  this.inner.style.lineHeight = doc.styles.lineHeight ?? '1.25';
204
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
+ );
205
297
 
206
298
  if (!doc.sections.length) {
207
299
  this.inner.append(
@@ -244,7 +336,13 @@ export class Canvas {
244
336
  }px ${a.paddingLeft ?? 0}px`;
245
337
 
246
338
  const layoutShort = layoutHumanLabel(section.layout);
247
- 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}`;
248
346
 
249
347
  const sw = blockButtonWidthCss(a.width);
250
348
  const box =
@@ -256,7 +354,7 @@ export class Canvas {
256
354
  });
257
355
 
258
356
  const wrap = h('div', {
259
- class: 'sm-section',
357
+ class: `sm-section${dvKey ? ' sm-section--dynamic-variant' : ''}`,
260
358
  title: `${sectionChip}。子组件铺满列内时:按 Esc 或 Alt+点击块可选中本节。`,
261
359
  style: `padding:${padding};${a.backgroundColor ? `background:${a.backgroundColor};` : ''}${box}`,
262
360
  });
@@ -389,23 +487,24 @@ export class Canvas {
389
487
  const def = this.opts.registry.get(block.type);
390
488
  let inner = '';
391
489
  if (block.lockedMjml) {
392
- if (block.type === 'html') {
393
- 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 {
394
495
  paddingTop?: number;
395
496
  paddingRight?: number;
396
497
  paddingBottom?: number;
397
498
  paddingLeft?: number;
398
- };
399
- const frag = htmlFragmentForLockedHtmlBlockCanvas(block.lockedMjml, {
400
- top: Number(p.paddingTop ?? 8),
401
- right: Number(p.paddingRight ?? 0),
402
- bottom: Number(p.paddingBottom ?? 8),
403
- left: Number(p.paddingLeft ?? 0),
404
- });
405
- 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>`;
406
505
  } else {
407
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(
408
- block.lockedMjml,
507
+ resolved.source,
409
508
  )}</div>`;
410
509
  }
411
510
  } else if (def?.renderPreview) {
@@ -520,7 +619,13 @@ export class Canvas {
520
619
  });
521
620
  });
522
621
 
523
- 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) {
524
629
  el.addEventListener('dblclick', (e) => {
525
630
  e.stopPropagation();
526
631
  this._enterEditing(block);
@@ -533,8 +638,7 @@ export class Canvas {
533
638
  private _renderAddBar(): HTMLElement {
534
639
  const layouts: { layout: SectionLayout; label: string }[] = [
535
640
  { layout: '1', label: '一列' },
536
- { layout: '1-1', label: '两列' },
537
- { layout: '1-1-1', label: '三列' },
641
+ { layout: '1-1', label: '多列' },
538
642
  ];
539
643
  const bar = h('div', { class: 'sm-add-section-bar' }, [h('span', {}, ['+ 添加 Section:'])]);
540
644
  for (const l of layouts) {
@@ -646,6 +750,7 @@ export class Canvas {
646
750
  mode: def.inlineEditable.mode,
647
751
  multiline: !!def.inlineEditable.multiline,
648
752
  placeholder: def.inlineEditable.placeholder,
753
+ globalStyles: this.opts.store.doc.styles,
649
754
  onCommit: (value) => {
650
755
  const propKey = def.inlineEditable!.propKey;
651
756
  const editingId = block.id;
@@ -702,12 +807,33 @@ export class Canvas {
702
807
  if (this.editingBlockId) this.commitInlineEdit();
703
808
  }
704
809
 
705
- private _blocksFromPaletteDrop(blockType: string): Block[] {
810
+ private _resolvePaletteDrop(blockType: string): PaletteDropResult {
706
811
  const def = this.opts.registry.get(blockType);
707
812
  if (def?.expandPaletteDrop) {
708
- 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;
709
816
  }
710
- return [this.opts.registry.createBlock(blockType)];
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 };
826
+ }
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;
711
837
  }
712
838
 
713
839
  private _handleSectionAdd(e: Sortable.SortableEvent) {
@@ -731,8 +857,9 @@ export class Canvas {
731
857
  // 路径 B:左栏内容/自定义卡片拖到 sections 之间 → 自动裹一列 Section
732
858
  if (sourceGroup === 'blocks' && blockType) {
733
859
  item.parentElement?.removeChild(item);
734
- const blocks = this._blocksFromPaletteDrop(blockType);
860
+ const { blocks, sectionAttrs } = this._resolvePaletteDrop(blockType);
735
861
  const newSection = createSection('1');
862
+ this._applySectionAttrs(newSection, this._paletteSectionAttrsForDrop(sectionAttrs));
736
863
  newSection.columns[0].blocks.splice(newIndex, 0, ...blocks);
737
864
  this.opts.store.update((d) => {
738
865
  d.sections.splice(newIndex, 0, newSection);
@@ -797,7 +924,29 @@ export class Canvas {
797
924
 
798
925
  if (sourceGroup === 'blocks' && blockType) {
799
926
  item.parentElement?.removeChild(item);
800
- 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;
801
950
  this.opts.store.update((d) => {
802
951
  const sec = d.sections.find((s) => s.id === sectionId);
803
952
  if (!sec) return;
@@ -893,9 +1042,16 @@ export class Canvas {
893
1042
  }
894
1043
 
895
1044
  /**
896
- * 内联编辑中拖选文字,若 mouseup 落在 Section padding 等非 Block 区域,
897
- * 浏览器仍会向 Section 冒泡 click;此时选区仍在编辑区内,不应切换右侧面板到 Section。
1045
+ * 内联编辑中从编辑区拖选文字,若 mouseup 落在 Section padding / 灰底 / 留白,
1046
+ * 浏览器仍可能对 mouseup 目标派发 click;选区仍在编辑区内时不应提交或改选中。
898
1047
  */
1048
+ private _shouldSuppressClearSelectionFromTextDrag(): boolean {
1049
+ return (
1050
+ this.pointerGestureStartedInInlineEdit &&
1051
+ this._shouldSuppressSectionSelectFromTextInteraction()
1052
+ );
1053
+ }
1054
+
899
1055
  private _shouldSuppressSectionSelectFromTextInteraction(): boolean {
900
1056
  if (!this.editingBlockId) return false;
901
1057
  const editingEl = this.inner.querySelector('.sm-inline-editing');