mail-editor-pancake 0.2.2 → 0.2.3

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.
@@ -849,6 +849,35 @@ export class Canvas {
849
849
  return Object.keys(rest).length ? rest : undefined;
850
850
  }
851
851
 
852
+ /** 组合 palette 可一次创建原生多列 Section,并将 Block 放入指定列。 */
853
+ private _createSectionFromPaletteDrop(drop: PaletteDropResult): Section {
854
+ const section = createSection(drop.sectionLayout ?? '1');
855
+ this._applySectionAttrs(section, this._paletteSectionAttrsForDrop(drop.sectionAttrs));
856
+ if (drop.columnBlocks?.length) {
857
+ drop.columnBlocks.forEach((blocks, columnIndex) => {
858
+ section.columns[columnIndex]?.blocks.push(...blocks);
859
+ });
860
+ } else {
861
+ section.columns[0]?.blocks.push(...drop.blocks);
862
+ }
863
+ return section;
864
+ }
865
+
866
+ private _paletteDropNeedsDedicatedSection(drop: PaletteDropResult): boolean {
867
+ if (drop.sectionLayout && drop.sectionLayout !== '1') return true;
868
+ if ((drop.columnBlocks?.length ?? 0) > 1) return true;
869
+ const attrs = this._paletteSectionAttrsForDrop(drop.sectionAttrs);
870
+ return !!attrs?.dynamicVariantKey;
871
+ }
872
+
873
+ private _firstBlockInSection(section: Section): { block: Block; columnIndex: number } | undefined {
874
+ for (let columnIndex = 0; columnIndex < section.columns.length; columnIndex += 1) {
875
+ const block = section.columns[columnIndex]?.blocks[0];
876
+ if (block) return { block, columnIndex };
877
+ }
878
+ return undefined;
879
+ }
880
+
852
881
  private _handleSectionAdd(e: Sortable.SortableEvent) {
853
882
  this._commitEditingBeforeStructureChange();
854
883
  const item = e.item;
@@ -870,20 +899,18 @@ export class Canvas {
870
899
  // 路径 B:左栏内容/自定义卡片拖到 sections 之间 → 自动裹一列 Section
871
900
  if (sourceGroup === 'blocks' && blockType) {
872
901
  item.parentElement?.removeChild(item);
873
- const { blocks, sectionAttrs } = this._resolvePaletteDrop(blockType);
874
- const newSection = createSection('1');
875
- this._applySectionAttrs(newSection, this._paletteSectionAttrsForDrop(sectionAttrs));
876
- newSection.columns[0].blocks.splice(newIndex, 0, ...blocks);
902
+ const drop = this._resolvePaletteDrop(blockType);
903
+ const newSection = this._createSectionFromPaletteDrop(drop);
877
904
  this.opts.store.update((d) => {
878
905
  d.sections.splice(newIndex, 0, newSection);
879
906
  });
880
- const head = blocks[0];
907
+ const head = this._firstBlockInSection(newSection);
881
908
  if (head) {
882
909
  this.opts.store.setSelection({
883
910
  kind: 'block',
884
911
  sectionId: newSection.id,
885
- columnIndex: 0,
886
- blockId: head.id,
912
+ columnIndex: head.columnIndex,
913
+ blockId: head.block.id,
887
914
  });
888
915
  }
889
916
  return;
@@ -938,23 +965,20 @@ export class Canvas {
938
965
  if (sourceGroup === 'blocks' && blockType) {
939
966
  item.parentElement?.removeChild(item);
940
967
  const drop = this._resolvePaletteDrop(blockType);
941
- const paletteSectionAttrs = this._paletteSectionAttrsForDrop(drop.sectionAttrs);
942
- if (paletteSectionAttrs?.dynamicVariantKey) {
943
- const newSection = createSection('1');
944
- this._applySectionAttrs(newSection, paletteSectionAttrs);
945
- newSection.columns[0].blocks.push(...drop.blocks);
968
+ if (this._paletteDropNeedsDedicatedSection(drop)) {
969
+ const newSection = this._createSectionFromPaletteDrop(drop);
946
970
  this.opts.store.update((d) => {
947
971
  const secIdx = d.sections.findIndex((s) => s.id === sectionId);
948
972
  const insertAt = secIdx >= 0 ? secIdx + 1 : d.sections.length;
949
973
  d.sections.splice(insertAt, 0, newSection);
950
974
  });
951
- const head = drop.blocks[0];
975
+ const head = this._firstBlockInSection(newSection);
952
976
  if (head) {
953
977
  this.opts.store.setSelection({
954
978
  kind: 'block',
955
979
  sectionId: newSection.id,
956
- columnIndex: 0,
957
- blockId: head.id,
980
+ columnIndex: head.columnIndex,
981
+ blockId: head.block.id,
958
982
  });
959
983
  }
960
984
  return;
@@ -562,6 +562,24 @@ export class RightPanel {
562
562
  `section:${section.id}:attrs.columnGap`,
563
563
  )
564
564
  : null,
565
+ section.layout !== '1' && !a.preserveColumnsOnMobile
566
+ ? this._numberField(
567
+ this.opts.t('rightPanel.section.stackedGap'),
568
+ a.columnStackedGap ?? 0,
569
+ 0,
570
+ 64,
571
+ (v) => {
572
+ const g = Math.max(0, Math.min(64, Math.round(v)));
573
+ this.opts.store.update((d) => {
574
+ const s = findSection(d, section.id);
575
+ if (s) s.attrs.columnStackedGap = g > 0 ? g : undefined;
576
+ });
577
+ },
578
+ 1,
579
+ `section:${section.id}:attrs.stackedGap`,
580
+ this.opts.t('rightPanel.section.stackedGapHelp'),
581
+ )
582
+ : null,
565
583
  section.layout !== '1'
566
584
  ? this._switchField(
567
585
  this.opts.t('rightPanel.section.preserveMobile'),
@@ -1137,6 +1155,24 @@ export class RightPanel {
1137
1155
  onChange: (v: number) => void,
1138
1156
  step = 1,
1139
1157
  focusToken?: string,
1158
+ help?: string,
1159
+ ): HTMLElement {
1160
+ const field = this._buildNumberField(label, value, min, max, onChange, step, focusToken);
1161
+ if (!help) return field;
1162
+ return h('div', { class: 'sm-field', style: 'flex-direction:column;align-items:flex-start;gap:6px;' }, [
1163
+ field,
1164
+ h('div', { class: 'sm-field__help' }, [help]),
1165
+ ]);
1166
+ }
1167
+
1168
+ private _buildNumberField(
1169
+ label: string,
1170
+ value: number,
1171
+ min: number,
1172
+ max: number,
1173
+ onChange: (v: number) => void,
1174
+ step = 1,
1175
+ focusToken?: string,
1140
1176
  ) {
1141
1177
  const snap = (n: number) => {
1142
1178
  const c = Math.min(max, Math.max(min, n));
@@ -140,6 +140,8 @@ export const zhCNMessages: SimpleMailMessages = {
140
140
  'rightPanel.section.widthHelp': '留空=与邮件同宽;自适应等价于清空宽度',
141
141
  'rightPanel.section.padding': '内边距',
142
142
  'rightPanel.section.columnGap': '列间距 (px)',
143
+ 'rightPanel.section.stackedGap': '堆叠纵向间距 (px)',
144
+ 'rightPanel.section.stackedGapHelp': '小屏堆叠为单列后,相邻列之间的额外纵向间距;0 表示沿用块自身内边距。',
143
145
  'rightPanel.section.preserveMobile': '小屏仍并排显示多列(可能字很窄)',
144
146
  'rightPanel.section.preserveMobileHelp': '开启后 MJML 会生成 mj-group,移动端预览/导出与默认「小屏堆叠列」行为不同。',
145
147
  'rightPanel.section.columnLayout': '列布局',
@@ -447,6 +449,8 @@ export const enUSMessages: SimpleMailMessages = {
447
449
  'rightPanel.section.widthHelp': 'Empty = email width. Auto is equivalent to clearing the width.',
448
450
  'rightPanel.section.padding': 'Padding',
449
451
  'rightPanel.section.columnGap': 'Column gap (px)',
452
+ 'rightPanel.section.stackedGap': 'Stacked vertical gap (px)',
453
+ 'rightPanel.section.stackedGapHelp': 'Extra vertical space between columns once they stack on small screens. 0 keeps each block own padding.',
450
454
  'rightPanel.section.preserveMobile': 'Keep columns side by side on small screens',
451
455
  'rightPanel.section.preserveMobileHelp': 'When enabled, MJML outputs mj-group. Mobile preview/export differs from the default stacked-column behavior.',
452
456
  'rightPanel.section.columnLayout': 'Column layout',
@@ -34,6 +34,7 @@ export function docToMjml(doc: EmailDoc, registry: Registry): string {
34
34
  <mj-style>
35
35
  a { color: ${escapeAttr(attrs.linkColor)}; }
36
36
  ${sectionWidthConstraintCss(doc)}
37
+ ${columnGapMobileCss(doc)}
37
38
  </mj-style>
38
39
  </mj-head>
39
40
  <mj-body background-color="${escapeAttr(attrs.backgroundColor)}" width="${escapeAttr(docContentWidthCss(doc.meta.width))}">
@@ -55,6 +56,65 @@ function sectionWidthConstraintCss(doc: EmailDoc): string {
55
56
  .join('\n');
56
57
  }
57
58
 
59
+ /**
60
+ * 列间距相关的小屏规则。
61
+ *
62
+ * 1) 水平间距(columnGap)用 mj-column 对称 padding 实现,小屏堆叠后 padding 不消失会导致
63
+ * 相邻列内容纵向错位(首列右缩、次列左缩),需要在堆叠时清零。
64
+ * 2) 堆叠纵向间距(columnStackedGap)只挂在非末列上,作为堆叠后相邻列之间的额外间距。
65
+ *
66
+ * 共同注意点:mj-column 的 padding 不落在带 css-class 的外层 div 上,编译结果是
67
+ * `div[class] > table > tbody > tr > td{padding:...}`,所以规则必须命中这一层 td,
68
+ * 用直接子代链避免波及块自身的 padding。
69
+ * 断点取 MJML 默认堆叠断点 480px;!important 用于覆盖行内样式。
70
+ * 仅对会小屏堆叠的列生效;mj-group(preserveColumnsOnMobile)不堆叠,不注入。
71
+ */
72
+ const COLUMN_GAP_MOBILE_BREAKPOINT = 480;
73
+
74
+ function columnGapClassName(sectionId: string): string {
75
+ return `${sectionMjClassName(sectionId)}-cg`;
76
+ }
77
+
78
+ function columnStackedGapClassName(sectionId: string): string {
79
+ return `${sectionMjClassName(sectionId)}-svg`;
80
+ }
81
+
82
+ /** 该 Section 的列是否会在小屏堆叠(未包 mj-group 的多列) */
83
+ function stacksOnMobile(section: Section): boolean {
84
+ return section.columns.length > 1 && section.attrs.preserveColumnsOnMobile !== true;
85
+ }
86
+
87
+ function columnGapMobileCss(doc: EmailDoc): string {
88
+ const lines: string[] = [];
89
+
90
+ for (const s of doc.sections) {
91
+ if (!stacksOnMobile(s)) continue;
92
+ const gap = Math.max(0, s.attrs.columnGap ?? 0);
93
+ const stackedGap = Math.max(0, Math.round(s.attrs.columnStackedGap ?? 0));
94
+ if (gap <= 0 && stackedGap <= 0) continue;
95
+
96
+ if (gap > 0) {
97
+ const cls = columnGapClassName(s.id);
98
+ lines.push(
99
+ ` .${cls},`,
100
+ ` .${cls} > table > tbody > tr > td { padding-left: 0 !important; padding-right: 0 !important; }`,
101
+ );
102
+ }
103
+ if (stackedGap > 0) {
104
+ lines.push(
105
+ ` .${columnStackedGapClassName(
106
+ s.id,
107
+ )} > table > tbody > tr > td { padding-bottom: ${stackedGap}px !important; }`,
108
+ );
109
+ }
110
+ }
111
+
112
+ if (!lines.length) return '';
113
+ return ` @media only screen and (max-width:${COLUMN_GAP_MOBILE_BREAKPOINT}px) {
114
+ ${lines.join('\n')}
115
+ }`;
116
+ }
117
+
58
118
  function sectionToMjml(section: Section, registry: Registry, ctx: RenderContext): string {
59
119
  const a = section.attrs;
60
120
  const padding = [a.paddingTop, a.paddingRight, a.paddingBottom, a.paddingLeft]
@@ -67,10 +127,27 @@ function sectionToMjml(section: Section, registry: Registry, ctx: RenderContext)
67
127
  secW || dvKey ? ` css-class="${escapeAttr(sectionMjClassName(section.id))}"` : '';
68
128
  const widths = layoutWidths(section.layout);
69
129
  const gapPx = Math.max(0, section.attrs.columnGap ?? 0);
130
+ /** 仅小屏堆叠时才有水平复位与纵向间距;与下方 grouped 判定保持一致 */
131
+ const stacked = stacksOnMobile(section);
132
+ const hCls = stacked && gapPx > 0 ? columnGapClassName(section.id) : '';
133
+ const vCls =
134
+ stacked && Math.max(0, Math.round(section.attrs.columnStackedGap ?? 0)) > 0
135
+ ? columnStackedGapClassName(section.id)
136
+ : '';
137
+ const lastIndex = section.columns.length - 1;
70
138
 
71
139
  const columns = section.columns
72
140
  .map((col, i) =>
73
- columnToMjml(col, widths[i], i, section.columns.length, gapPx, registry, ctx),
141
+ columnToMjml(
142
+ col,
143
+ widths[i],
144
+ i,
145
+ section.columns.length,
146
+ gapPx,
147
+ [hCls, vCls && i < lastIndex ? vCls : ''].filter(Boolean).join(' '),
148
+ registry,
149
+ ctx,
150
+ ),
74
151
  )
75
152
  .join('\n');
76
153
 
@@ -92,6 +169,7 @@ function columnToMjml(
92
169
  columnIndex: number,
93
170
  columnCount: number,
94
171
  columnGapPx: number,
172
+ gapClassNames: string,
95
173
  registry: Registry,
96
174
  ctx: RenderContext,
97
175
  ): string {
@@ -106,6 +184,7 @@ function columnToMjml(
106
184
  const pr = columnIndex < columnCount - 1 ? `${half}px` : '0px';
107
185
  gapPad = ` padding="0px ${pr} 0px ${pl}"`;
108
186
  }
187
+ const clsAttr = gapClassNames ? ` css-class="${escapeAttr(gapClassNames)}"` : '';
109
188
 
110
189
  const rawTypo = mjRawCellTypographyFromStyles(ctx.doc.styles);
111
190
  const blocks = column.blocks
@@ -129,7 +208,7 @@ function columnToMjml(
129
208
  })
130
209
  .join('\n');
131
210
 
132
- return ` <mj-column width="${width}"${va}${bg}${gapPad}>
211
+ return ` <mj-column width="${width}"${va}${bg}${gapPad}${clsAttr}>
133
212
  ${blocks || ' <!-- empty column -->'}
134
213
  </mj-column>`;
135
214
  }
@@ -77,6 +77,13 @@ export interface SectionAttrs {
77
77
  * MJML 通过相邻 `mj-column` 对称内边距实现;画布用 flex `gap` 对齐观感。
78
78
  */
79
79
  columnGap?: number;
80
+ /**
81
+ * 多列在小屏堆叠为单列后,相邻列之间的纵向间距(px)。
82
+ * 仅在多列且未开启 `preserveColumnsOnMobile` 时生效;未设置/0 表示不额外加间距
83
+ * (沿用块自身 padding 形成的间距),避免改变已有邮件的移动端排版。
84
+ * 通过小屏媒体查询实现,不会影响桌面端行高。
85
+ */
86
+ columnStackedGap?: number;
80
87
  /**
81
88
  * 本节内容区最大宽度(窄于邮件 `meta.width` 时居中)。支持 `480`、`480px`、`90%`;留空则与邮件同宽。
82
89
  */
@@ -195,10 +202,21 @@ export interface BlockDefinition<P extends object = Record<string, unknown>> {
195
202
  ) => Block[] | PaletteDropResult;
196
203
  }
197
204
 
198
- /** 左栏组合拖入:可附带新建 Section 的 attrs(如 meta 扩展袋) */
205
+ /** 左栏组合拖入:可附带新建 Section 的 attrs、布局和按列分发的 Block。 */
199
206
  export interface PaletteDropResult {
207
+ /** 单列组合或落入既有列时插入的 Block。 */
200
208
  blocks: Block[];
201
209
  sectionAttrs?: Partial<SectionAttrs>;
210
+ /**
211
+ * 组合入口可声明要新建的 Section 布局;多列布局会始终创建独立 Section,
212
+ * 不会被插入到用户当前选中的单列中。
213
+ */
214
+ sectionLayout?: SectionLayout;
215
+ /**
216
+ * 仅创建新 Section 时使用;每个数组对应一个 Column,索引与 Section 列顺序一致。
217
+ * 超出布局列数的数组会被忽略,缺少的列保持为空。
218
+ */
219
+ columnBlocks?: Block[][];
202
220
  }
203
221
 
204
222
  export interface InlineEditableConfig<P extends object = Record<string, unknown>> {