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.
- package/package.json +1 -1
- package/packages/core/dist/index.d.ts +19 -1
- package/packages/core/dist/index.js +1510 -1416
- package/packages/core/dist/index.js.map +1 -1
- package/packages/core/src/editor/Canvas.ts +39 -15
- package/packages/core/src/editor/RightPanel.ts +36 -0
- package/packages/core/src/i18n/index.ts +4 -0
- package/packages/core/src/renderer/mjml.ts +81 -2
- package/packages/core/src/types.ts +19 -1
|
@@ -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
|
|
874
|
-
const newSection =
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
942
|
-
|
|
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 =
|
|
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:
|
|
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(
|
|
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
|
|
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>> {
|