mail-editor-pancake 0.2.4 → 0.2.7
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/blocks/dist/index.js +126 -126
- package/packages/blocks/dist/index.js.map +1 -1
- package/packages/blocks/src/button.ts +9 -3
- package/packages/core/dist/index.d.ts +55 -0
- package/packages/core/dist/index.js +2414 -2144
- package/packages/core/dist/index.js.map +1 -1
- package/packages/core/dist/style.css +1 -1
- package/packages/core/src/editor/Canvas.ts +8 -10
- package/packages/core/src/editor/Editor.ts +1 -0
- package/packages/core/src/editor/InlineEditor.ts +141 -74
- package/packages/core/src/editor/LeftPanel.ts +41 -7
- package/packages/core/src/editor/RightPanel.ts +31 -3
- package/packages/core/src/editor/styles.css +4 -0
- package/packages/core/src/i18n/index.ts +4 -0
- package/packages/core/src/index.ts +6 -0
- package/packages/core/src/renderer/index.ts +2 -0
- package/packages/core/src/renderer/mjml.ts +16 -7
- package/packages/core/src/types.ts +14 -0
- package/packages/core/src/utils/columnWidths.ts +157 -0
- package/packages/core/src/utils/dynamicVariantHtml.ts +2 -7
- package/packages/core/src/utils/html.ts +31 -0
- package/packages/core/src/utils/link.ts +51 -0
- package/packages/core/src/utils/paletteDrop.ts +46 -0
- package/packages/core/src/utils/richTextStyle.ts +197 -0
|
@@ -2,6 +2,7 @@ import Sortable from 'sortablejs';
|
|
|
2
2
|
import type { Registry } from '../registry/registry';
|
|
3
3
|
import type { BlockDefinition, SectionLayout } from '../types';
|
|
4
4
|
import { clear, h } from '../utils/dom';
|
|
5
|
+
import { paletteDropIsSectionLevel } from '../utils/paletteDrop';
|
|
5
6
|
import type { SimpleMailT } from '../i18n';
|
|
6
7
|
|
|
7
8
|
export interface LeftPanelOptions {
|
|
@@ -13,6 +14,8 @@ export interface LeftPanelOptions {
|
|
|
13
14
|
customPaletteTooltipSuffix?: string;
|
|
14
15
|
/** 不显示在左栏的 block type(仍可在 Registry 中 createBlock) */
|
|
15
16
|
hiddenPaletteBlockTypes?: string[];
|
|
17
|
+
/** 与画布一致:开启后带 `sectionAttrs.dynamicVariantKey` 的组合块会创建独立 Section */
|
|
18
|
+
enableDynamicVariantKey?: boolean;
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
interface LayoutCard {
|
|
@@ -66,14 +69,33 @@ export class LeftPanel {
|
|
|
66
69
|
...filterPalette(this.opts.registry.byCategory('custom')),
|
|
67
70
|
];
|
|
68
71
|
|
|
72
|
+
/** 自带布局的组件与布局卡片同组,拖拽时只能落在 Section 之间(见 paletteDropIsSectionLevel) */
|
|
73
|
+
const sectionLevel: BlockDefinition[] = [];
|
|
74
|
+
const blockLevel: BlockDefinition[] = [];
|
|
75
|
+
const dropOpts = { enableDynamicVariantKey: this.opts.enableDynamicVariantKey };
|
|
76
|
+
for (const def of paletteBlocks) {
|
|
77
|
+
if (paletteDropIsSectionLevel(def, this.opts.registry, dropOpts)) sectionLevel.push(def);
|
|
78
|
+
else blockLevel.push(def);
|
|
79
|
+
}
|
|
80
|
+
|
|
69
81
|
const wrap = h('div', { class: 'sm-blocks' });
|
|
70
82
|
|
|
71
83
|
wrap.append(this._renderLayoutGroup());
|
|
72
|
-
if (
|
|
84
|
+
if (sectionLevel.length) {
|
|
73
85
|
wrap.append(
|
|
74
|
-
this.
|
|
86
|
+
this._renderBlockCardsGroup(
|
|
87
|
+
this.opts.t('leftPanel.sectionBlocks'),
|
|
88
|
+
sectionLevel,
|
|
89
|
+
'sections',
|
|
90
|
+
),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
if (blockLevel.length) {
|
|
94
|
+
wrap.append(
|
|
95
|
+
this._renderBlockCardsGroup(
|
|
75
96
|
this.opts.blockGroupTitle ?? this.opts.t('leftPanel.components'),
|
|
76
|
-
|
|
97
|
+
blockLevel,
|
|
98
|
+
'blocks',
|
|
77
99
|
),
|
|
78
100
|
);
|
|
79
101
|
}
|
|
@@ -125,7 +147,16 @@ export class LeftPanel {
|
|
|
125
147
|
return def.name;
|
|
126
148
|
}
|
|
127
149
|
|
|
128
|
-
|
|
150
|
+
/**
|
|
151
|
+
* 组件卡片网格。`sourceGroup` 决定画布哪一层接收:
|
|
152
|
+
* - `blocks`:列内(Column);
|
|
153
|
+
* - `sections`:Section 之间(自带布局的组件,表现与布局卡片一致)。
|
|
154
|
+
*/
|
|
155
|
+
private _renderBlockCardsGroup(
|
|
156
|
+
title: string,
|
|
157
|
+
defs: BlockDefinition[],
|
|
158
|
+
sourceGroup: 'blocks' | 'sections',
|
|
159
|
+
): HTMLElement {
|
|
129
160
|
const grid = h('div', { class: 'sm-blocks__grid' });
|
|
130
161
|
for (const def of defs) {
|
|
131
162
|
const isCustom = def.category === 'custom';
|
|
@@ -141,8 +172,11 @@ export class LeftPanel {
|
|
|
141
172
|
h(
|
|
142
173
|
'div',
|
|
143
174
|
{
|
|
144
|
-
class:
|
|
145
|
-
|
|
175
|
+
class:
|
|
176
|
+
sourceGroup === 'sections'
|
|
177
|
+
? 'sm-block-card sm-block-card--section'
|
|
178
|
+
: 'sm-block-card',
|
|
179
|
+
'data-source-group': sourceGroup,
|
|
146
180
|
'data-block-type': def.type,
|
|
147
181
|
title: this._blockCardTitle(def),
|
|
148
182
|
},
|
|
@@ -151,7 +185,7 @@ export class LeftPanel {
|
|
|
151
185
|
);
|
|
152
186
|
}
|
|
153
187
|
Sortable.create(grid, {
|
|
154
|
-
group: { name:
|
|
188
|
+
group: { name: sourceGroup, pull: 'clone', put: false },
|
|
155
189
|
sort: false,
|
|
156
190
|
animation: 120,
|
|
157
191
|
ghostClass: 'sm-ghost',
|
|
@@ -3,6 +3,7 @@ import { html as cmHtml } from '@codemirror/lang-html';
|
|
|
3
3
|
import { EditorState } from '@codemirror/state';
|
|
4
4
|
import type { Registry } from '../registry/registry';
|
|
5
5
|
import { htmlContainsMjmlTags } from '../utils/lockedMjml';
|
|
6
|
+
import { invalidHrefMessage } from '../utils/link';
|
|
6
7
|
import {
|
|
7
8
|
findBlockLocation,
|
|
8
9
|
findSection,
|
|
@@ -723,6 +724,8 @@ export class RightPanel {
|
|
|
723
724
|
onChange,
|
|
724
725
|
field.placeholder ?? '',
|
|
725
726
|
fp,
|
|
727
|
+
field.validate ??
|
|
728
|
+
(field.type === 'url' ? (v: string) => invalidHrefMessage(v, this.opts.t) : undefined),
|
|
726
729
|
);
|
|
727
730
|
if (field.help) row.append(h('div', { class: 'sm-field__help' }, [field.help]));
|
|
728
731
|
return row;
|
|
@@ -841,14 +844,25 @@ export class RightPanel {
|
|
|
841
844
|
opt.selected = true;
|
|
842
845
|
sel.insertBefore(opt, sel.firstChild);
|
|
843
846
|
}
|
|
847
|
+
const hrefWarnEl = h('div', { class: 'sm-field__warn', style: 'display:none' });
|
|
848
|
+
const syncHrefWarn = (v: string) => {
|
|
849
|
+
const msg = invalidHrefMessage(v, this.opts.t);
|
|
850
|
+
hrefWarnEl.textContent = msg ?? '';
|
|
851
|
+
hrefWarnEl.style.display = msg ? '' : 'none';
|
|
852
|
+
};
|
|
844
853
|
const inp = h('input', {
|
|
845
854
|
class: 'sm-input sm-social-link-list__href',
|
|
846
855
|
type: 'url',
|
|
847
856
|
placeholder: 'https://',
|
|
848
857
|
value: rowData.href,
|
|
849
858
|
...(focusPrefix ? { 'data-sm-focus': `${focusPrefix}:row:${i}:href` } : {}),
|
|
850
|
-
oninput: (e: Event) =>
|
|
859
|
+
oninput: (e: Event) => {
|
|
860
|
+
const v = (e.target as HTMLInputElement).value;
|
|
861
|
+
syncItem(i, { href: v });
|
|
862
|
+
syncHrefWarn(v);
|
|
863
|
+
},
|
|
851
864
|
});
|
|
865
|
+
syncHrefWarn(rowData.href);
|
|
852
866
|
const rm = h(
|
|
853
867
|
'button',
|
|
854
868
|
{
|
|
@@ -860,7 +874,7 @@ export class RightPanel {
|
|
|
860
874
|
['×'],
|
|
861
875
|
);
|
|
862
876
|
mainRow.append(sel, inp, rm);
|
|
863
|
-
rowWrap.append(mainRow);
|
|
877
|
+
rowWrap.append(mainRow, hrefWarnEl);
|
|
864
878
|
|
|
865
879
|
const extras = h('div', { class: 'sm-social-link-list__row-extras' });
|
|
866
880
|
const bgResolved = defaultSocialIconBackground(rowData.network);
|
|
@@ -934,7 +948,16 @@ export class RightPanel {
|
|
|
934
948
|
onChange: (v: string) => void,
|
|
935
949
|
placeholder = '',
|
|
936
950
|
focusToken?: string,
|
|
951
|
+
validate?: (v: string) => string | null,
|
|
937
952
|
) {
|
|
953
|
+
const warnEl = h('div', { class: 'sm-field__warn', style: 'display:none' });
|
|
954
|
+
const syncWarn = (v: string) => {
|
|
955
|
+
if (!validate) return;
|
|
956
|
+
const msg = validate(v);
|
|
957
|
+
warnEl.textContent = msg ?? '';
|
|
958
|
+
warnEl.style.display = msg ? '' : 'none';
|
|
959
|
+
};
|
|
960
|
+
syncWarn(value);
|
|
938
961
|
return h('div', { class: 'sm-field' }, [
|
|
939
962
|
h('label', { class: 'sm-field__label' }, [label]),
|
|
940
963
|
h('input', {
|
|
@@ -943,8 +966,13 @@ export class RightPanel {
|
|
|
943
966
|
value,
|
|
944
967
|
placeholder,
|
|
945
968
|
...(focusToken ? { 'data-sm-focus': focusToken } : {}),
|
|
946
|
-
oninput: (e: Event) =>
|
|
969
|
+
oninput: (e: Event) => {
|
|
970
|
+
const v = (e.target as HTMLInputElement).value;
|
|
971
|
+
onChange(v);
|
|
972
|
+
syncWarn(v);
|
|
973
|
+
},
|
|
947
974
|
}),
|
|
975
|
+
warnEl,
|
|
948
976
|
]);
|
|
949
977
|
}
|
|
950
978
|
|
|
@@ -638,6 +638,10 @@
|
|
|
638
638
|
font-size: 12px;
|
|
639
639
|
color: var(--sm-text);
|
|
640
640
|
}
|
|
641
|
+
/* 自带布局的组件:与布局卡片同组,落点在 Section 之间而非列内 */
|
|
642
|
+
.sm-block-card--section {
|
|
643
|
+
border-style: dashed;
|
|
644
|
+
}
|
|
641
645
|
|
|
642
646
|
/* 中栏:画布 ------------------------------------------------------------- */
|
|
643
647
|
.sm-canvas-wrap {
|
|
@@ -75,6 +75,7 @@ export const zhCNMessages: SimpleMailMessages = {
|
|
|
75
75
|
'topbar.exitFullscreen': '退出全屏',
|
|
76
76
|
'leftPanel.layout': '布局',
|
|
77
77
|
'leftPanel.components': '组件',
|
|
78
|
+
'leftPanel.sectionBlocks': '组合布局',
|
|
78
79
|
'leftPanel.customTooltipSuffix': 'Navimow 定制组件,拖入列内使用',
|
|
79
80
|
'leftPanel.oneColumn': '一列',
|
|
80
81
|
'leftPanel.multiColumn': '多列',
|
|
@@ -100,6 +101,7 @@ export const zhCNMessages: SimpleMailMessages = {
|
|
|
100
101
|
'rightPanel.block.restoreProps': '恢复为属性模式',
|
|
101
102
|
'rightPanel.block.unregistered': '未注册的组件: {type}',
|
|
102
103
|
'rightPanel.block.mjmlWarning': '检测到 MJML 标签(如 <mj-text>)。此处应只写 HTML,否则预览会显示源码。简单链接请用「文本」块。',
|
|
104
|
+
'rightPanel.block.invalidLink': '该链接无法跳转(如 1234、#、缺少 https://)。Outlook 网页端会清除此类链接,导致按钮/图标不可见。',
|
|
103
105
|
'rightPanel.doc.title': '邮件设置',
|
|
104
106
|
'rightPanel.doc.layoutTitle': '版式',
|
|
105
107
|
'rightPanel.doc.subject': '主题',
|
|
@@ -383,6 +385,7 @@ export const enUSMessages: SimpleMailMessages = {
|
|
|
383
385
|
'topbar.exitFullscreen': 'Exit fullscreen',
|
|
384
386
|
'leftPanel.layout': 'Layout',
|
|
385
387
|
'leftPanel.components': 'Components',
|
|
388
|
+
'leftPanel.sectionBlocks': 'Layout Blocks',
|
|
386
389
|
'leftPanel.customTooltipSuffix': 'Custom Navimow block. Drag it into a column.',
|
|
387
390
|
'leftPanel.oneColumn': 'One column',
|
|
388
391
|
'leftPanel.multiColumn': 'Columns',
|
|
@@ -408,6 +411,7 @@ export const enUSMessages: SimpleMailMessages = {
|
|
|
408
411
|
'rightPanel.block.restoreProps': 'Restore property mode',
|
|
409
412
|
'rightPanel.block.unregistered': 'Unregistered block: {type}',
|
|
410
413
|
'rightPanel.block.mjmlWarning': 'MJML tags were detected, such as <mj-text>. Write HTML only here, otherwise the preview will show source code. Use a Text block for simple links.',
|
|
414
|
+
'rightPanel.block.invalidLink': 'This link is not navigable (e.g. 1234, #, or a URL without https://). Outlook on the web removes such links, which makes the button or icon invisible.',
|
|
411
415
|
'rightPanel.doc.title': 'Email settings',
|
|
412
416
|
'rightPanel.doc.layoutTitle': 'Layout',
|
|
413
417
|
'rightPanel.doc.subject': 'Subject',
|
|
@@ -21,6 +21,12 @@ export {
|
|
|
21
21
|
resolveLockedMjmlCanvasContent,
|
|
22
22
|
} from './utils/lockedMjml';
|
|
23
23
|
export { renderDoc, replaceVariables } from './renderer';
|
|
24
|
+
export { isRenderableHref, resolveHref, invalidHrefMessage } from './utils/link';
|
|
25
|
+
export {
|
|
26
|
+
paletteDropHasSectionLayout,
|
|
27
|
+
paletteDropIsSectionLevel,
|
|
28
|
+
resolvePaletteDropResult,
|
|
29
|
+
} from './utils/paletteDrop';
|
|
24
30
|
export { annotateDynamicVariantHtmlAttributes } from './utils/dynamicVariantHtml';
|
|
25
31
|
export {
|
|
26
32
|
DYNAMIC_VARIANT_HTML_ATTR,
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
normalizeEmailListsInHtml,
|
|
7
7
|
resolveGlobalListIndentPx,
|
|
8
8
|
} from '../utils/emailListStyles';
|
|
9
|
+
import { inlineColumnWidthsInHtml } from '../utils/columnWidths';
|
|
9
10
|
import { docToMjml } from './mjml';
|
|
10
11
|
|
|
11
12
|
export interface RenderResult {
|
|
@@ -45,6 +46,7 @@ export function renderDoc(
|
|
|
45
46
|
|
|
46
47
|
let html = compiled.html;
|
|
47
48
|
html = annotateDynamicVariantHtmlAttributes(html, doc);
|
|
49
|
+
html = inlineColumnWidthsInHtml(html);
|
|
48
50
|
html = normalizeEmailListsInHtml(html, resolveGlobalListIndentPx(doc.styles));
|
|
49
51
|
if (opts.withSampleVariables) {
|
|
50
52
|
html = replaceVariables(html, doc.variables);
|
|
@@ -2,6 +2,7 @@ import type { Registry } from '../registry/registry';
|
|
|
2
2
|
import type { Column, EmailDoc, RenderContext, Section, SectionLayout } from '../types';
|
|
3
3
|
import { getSectionDynamicVariantKey, sectionMjClassName } from '../utils/dynamicVariantKey';
|
|
4
4
|
import { blockButtonWidthCss, docContentWidthCss } from '../utils/contentWidth';
|
|
5
|
+
import { COLUMN_NO_STACK_CLASS } from '../utils/columnWidths';
|
|
5
6
|
import { escapeAttr } from '../utils/dom';
|
|
6
7
|
import { normalizeFontWeightStep } from '../utils/fontWeightSteps';
|
|
7
8
|
import {
|
|
@@ -69,7 +70,7 @@ function sectionWidthConstraintCss(doc: EmailDoc): string {
|
|
|
69
70
|
* 断点取 MJML 默认堆叠断点 480px;!important 用于覆盖行内样式。
|
|
70
71
|
* 仅对会小屏堆叠的列生效;mj-group(preserveColumnsOnMobile)不堆叠,不注入。
|
|
71
72
|
*/
|
|
72
|
-
const COLUMN_GAP_MOBILE_BREAKPOINT =
|
|
73
|
+
const COLUMN_GAP_MOBILE_BREAKPOINT = 479;
|
|
73
74
|
|
|
74
75
|
function columnGapClassName(sectionId: string): string {
|
|
75
76
|
return `${sectionMjClassName(sectionId)}-cg`;
|
|
@@ -136,6 +137,10 @@ function sectionToMjml(section: Section, registry: Registry, ctx: RenderContext)
|
|
|
136
137
|
: '';
|
|
137
138
|
const lastIndex = section.columns.length - 1;
|
|
138
139
|
|
|
140
|
+
const multiCol = section.columns.length > 1;
|
|
141
|
+
/** 多列 + 显式开启「移动端保留列」时包 mj-group,其列任何宽度下都不堆叠 */
|
|
142
|
+
const preserveColumns = multiCol && section.attrs.preserveColumnsOnMobile === true;
|
|
143
|
+
|
|
139
144
|
const columns = section.columns
|
|
140
145
|
.map((col, i) =>
|
|
141
146
|
columnToMjml(
|
|
@@ -144,7 +149,13 @@ function sectionToMjml(section: Section, registry: Registry, ctx: RenderContext)
|
|
|
144
149
|
i,
|
|
145
150
|
section.columns.length,
|
|
146
151
|
gapPx,
|
|
147
|
-
[
|
|
152
|
+
[
|
|
153
|
+
hCls,
|
|
154
|
+
vCls && i < lastIndex ? vCls : '',
|
|
155
|
+
preserveColumns ? COLUMN_NO_STACK_CLASS : '',
|
|
156
|
+
]
|
|
157
|
+
.filter(Boolean)
|
|
158
|
+
.join(' '),
|
|
148
159
|
registry,
|
|
149
160
|
ctx,
|
|
150
161
|
),
|
|
@@ -152,11 +163,9 @@ function sectionToMjml(section: Section, registry: Registry, ctx: RenderContext)
|
|
|
152
163
|
.join('\n');
|
|
153
164
|
|
|
154
165
|
/** 多列 + 显式开启时包 mj-group,阻止小屏列堆叠(MJML 官方语义) */
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
? ` <mj-group>\n${indent(columns, 2)}\n </mj-group>`
|
|
159
|
-
: columns;
|
|
166
|
+
const grouped = preserveColumns
|
|
167
|
+
? ` <mj-group>\n${indent(columns, 2)}\n </mj-group>`
|
|
168
|
+
: columns;
|
|
160
169
|
|
|
161
170
|
return ` <mj-section padding="${padding}"${bg}${secCls}>
|
|
162
171
|
${grouped}
|
|
@@ -163,6 +163,13 @@ export interface BlockSchemaField {
|
|
|
163
163
|
step?: number;
|
|
164
164
|
placeholder?: string;
|
|
165
165
|
help?: string;
|
|
166
|
+
/**
|
|
167
|
+
* 输入即时校验:返回文案则在字段下方红字提示,返回 null 表示通过。
|
|
168
|
+
* 只提示、不阻塞保存(运营可能故意留占位链接或使用短链)。
|
|
169
|
+
* `type: 'url'` 未设置时使用内核默认规则(见 `isRenderableHref`),
|
|
170
|
+
* 拦截 `#`、`1234`、缺协议的域名等会被 Outlook 网页端清除的链接。
|
|
171
|
+
*/
|
|
172
|
+
validate?: (value: string) => string | null;
|
|
166
173
|
/** 为 true 时右栏显示「继承全局」开关:开=留空跟随邮件全局,关=写入当前全局值为块级固定值 */
|
|
167
174
|
inheritGlobal?: boolean;
|
|
168
175
|
}
|
|
@@ -196,6 +203,13 @@ export interface BlockDefinition<P extends object = Record<string, unknown>> {
|
|
|
196
203
|
* 在用户提交时按 `mode` 写回 props(rich:清理后的 innerHTML;html:原始 innerHTML;plain:textContent)。
|
|
197
204
|
*/
|
|
198
205
|
inlineEditable?: InlineEditableConfig<P>;
|
|
206
|
+
/**
|
|
207
|
+
* 左栏拖入层级:
|
|
208
|
+
* - `section`:自带布局(如双列卡组),与布局卡片同组,只能落在 Section 之间,不能落进 Column;
|
|
209
|
+
* - `block`:落在 Column 内(默认)。
|
|
210
|
+
* 缺省时按 `expandPaletteDrop` 结果自动判定(多列布局 / 多列分发即视为 `section`)。
|
|
211
|
+
*/
|
|
212
|
+
paletteDropLevel?: 'block' | 'section';
|
|
199
213
|
/**
|
|
200
214
|
* 从左栏拖入时在目标列(或自动包裹的单列 Section)中一次性插入多个块,
|
|
201
215
|
* 用于「组合模板」:画布内仅为通用 image/text 等,便于逐项修改。
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 列宽内联化(编译后处理)。
|
|
3
|
+
*
|
|
4
|
+
* MJML 的多列布局是「行内移动端宽度 + 媒体查询桌面宽度」两段式:
|
|
5
|
+
* 列 div 的行内样式写死 `width:100%`,桌面宽度由
|
|
6
|
+
* `@media only screen and (min-width:480px) { .mj-column-per-50 { width:50% !important; } }` 提供。
|
|
7
|
+
*
|
|
8
|
+
* 但部分邮件客户端与网页邮箱(临时邮箱预览、部分 sanitize 过度的转发页面等)会剥离
|
|
9
|
+
* `<style>` 与 `class`,媒体查询随之失效,行内的 `width:100%` 就会让多列退化成纵向堆叠。
|
|
10
|
+
*
|
|
11
|
+
* 这里把媒体查询里的列宽写成行内样式:
|
|
12
|
+
* - 支持 `<style>` 的客户端:媒体查询带 `!important`,优先级高于行内样式,行为与之前完全一致;
|
|
13
|
+
* - 剥离 `<style>` 的客户端:行内宽度兜底,至少保住多列布局。
|
|
14
|
+
*
|
|
15
|
+
* 同时补一条小屏重置规则(仅作用于本次内联过、且不在 mj-group 内的列),
|
|
16
|
+
* 让支持 `<style>` 的客户端在 480px 以下仍然按 MJML 原语义堆叠。
|
|
17
|
+
*/
|
|
18
|
+
import { serializeParsedHtmlDocument } from './html';
|
|
19
|
+
|
|
20
|
+
/** 与 MJML 默认堆叠断点(min-width:480px)互补的小屏断点 */
|
|
21
|
+
const MOBILE_STACK_BREAKPOINT = 479;
|
|
22
|
+
|
|
23
|
+
/** 打在「已内联宽度、且需要小屏堆叠」的列上 */
|
|
24
|
+
const STACKABLE_COLUMN_CLASS = 'sm-stack-col';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 打在 mj-group(移动端保留列)内的 mj-column 上。
|
|
28
|
+
* 这类列任何宽度下都不堆叠,因此不参与小屏重置。
|
|
29
|
+
*/
|
|
30
|
+
export const COLUMN_NO_STACK_CLASS = 'sm-nostack';
|
|
31
|
+
|
|
32
|
+
interface ColumnWidthRule {
|
|
33
|
+
width: string;
|
|
34
|
+
maxWidth?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 行内 style 中是否已有 width / max-width 声明 */
|
|
38
|
+
const WIDTH_DECL_RE = /(?:^|;)\s*width\s*:/i;
|
|
39
|
+
const MAX_WIDTH_DECL_RE = /(?:^|;)\s*max-width\s*:/i;
|
|
40
|
+
|
|
41
|
+
function firstDeclaration(block: string, prop: string): string | undefined {
|
|
42
|
+
const m = new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*([^;]+)`, 'i').exec(block);
|
|
43
|
+
if (!m) return undefined;
|
|
44
|
+
return m[1].replace(/!\s*important/gi, '').trim();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 从编译产物 head 的 CSS 里抽取 `.mj-column-per-XX { width/ max-width }` 规则 */
|
|
48
|
+
function parseColumnWidthRules(css: string): Map<string, ColumnWidthRule> {
|
|
49
|
+
const rules = new Map<string, ColumnWidthRule>();
|
|
50
|
+
if (!css) return rules;
|
|
51
|
+
|
|
52
|
+
const ruleRe = /\.mj-column-per-([^\s,{]+)\s*\{([^}]*)\}/g;
|
|
53
|
+
let match = ruleRe.exec(css);
|
|
54
|
+
while (match) {
|
|
55
|
+
const width = firstDeclaration(match[2], 'width');
|
|
56
|
+
if (!width) {
|
|
57
|
+
match = ruleRe.exec(css);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const className = `mj-column-per-${match[1]}`;
|
|
61
|
+
if (!rules.has(className)) {
|
|
62
|
+
rules.set(className, { width, maxWidth: firstDeclaration(match[2], 'max-width') });
|
|
63
|
+
}
|
|
64
|
+
match = ruleRe.exec(css);
|
|
65
|
+
}
|
|
66
|
+
return rules;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function collectStyleText(parsed: Document): string {
|
|
70
|
+
return Array.from(parsed.querySelectorAll('style'))
|
|
71
|
+
.map((s) => s.textContent || '')
|
|
72
|
+
.join('\n');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 行内样式里已写明宽度且不是 100% 时(如 mj-group 内的列)保持原样 */
|
|
76
|
+
function applyColumnWidth(el: Element, rule: ColumnWidthRule): boolean {
|
|
77
|
+
const style = el.getAttribute('style') || '';
|
|
78
|
+
const currentWidth = firstDeclaration(style, 'width')?.toLowerCase();
|
|
79
|
+
if (currentWidth && currentWidth !== '100%') return false;
|
|
80
|
+
|
|
81
|
+
let next = style;
|
|
82
|
+
if (WIDTH_DECL_RE.test(next)) {
|
|
83
|
+
// 保留前导的 `;`,否则会吞掉上一条声明的分隔符
|
|
84
|
+
next = next.replace(/((?:^|;)\s*)width\s*:\s*[^;]*/i, `$1width:${rule.width}`);
|
|
85
|
+
} else {
|
|
86
|
+
const base = next.trim().replace(/;+$/, '');
|
|
87
|
+
next = base ? `${base};width:${rule.width}` : `width:${rule.width}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (rule.maxWidth) {
|
|
91
|
+
if (MAX_WIDTH_DECL_RE.test(next)) {
|
|
92
|
+
next = next.replace(/((?:^|;)\s*)max-width\s*:\s*[^;]*/i, `$1max-width:${rule.maxWidth}`);
|
|
93
|
+
} else {
|
|
94
|
+
next = `${next};max-width:${rule.maxWidth}`;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
next = next.replace(/;{2,}/g, ';').replace(/^\s*;/, '');
|
|
99
|
+
|
|
100
|
+
if (next === style) return false;
|
|
101
|
+
el.setAttribute('style', next);
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function appendStackResetRule(parsed: Document): void {
|
|
106
|
+
const style = parsed.createElement('style');
|
|
107
|
+
style.setAttribute('type', 'text/css');
|
|
108
|
+
style.textContent = `\n @media only screen and (max-width:${MOBILE_STACK_BREAKPOINT}px) {\n .${STACKABLE_COLUMN_CLASS} { width:100% !important; max-width:100% !important; }\n }\n `;
|
|
109
|
+
parsed.head.appendChild(style);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 把 MJML 媒体查询中的列宽内联到列元素上,避免 `<style>` / `class` 被剥离后多列纵向堆叠。
|
|
114
|
+
* 无法解析 HTML 或未找到列宽规则时原样返回。
|
|
115
|
+
*/
|
|
116
|
+
export function inlineColumnWidthsInHtml(html: string): string {
|
|
117
|
+
let parsed: Document;
|
|
118
|
+
try {
|
|
119
|
+
parsed = new DOMParser().parseFromString(html, 'text/html');
|
|
120
|
+
} catch {
|
|
121
|
+
return html;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const rules = parseColumnWidthRules(collectStyleText(parsed));
|
|
125
|
+
if (!rules.size) return html;
|
|
126
|
+
|
|
127
|
+
let changed = false;
|
|
128
|
+
let stackable = false;
|
|
129
|
+
|
|
130
|
+
for (const el of Array.from(parsed.querySelectorAll('[class]'))) {
|
|
131
|
+
const classes = (el.getAttribute('class') || '').split(/\s+/).filter(Boolean);
|
|
132
|
+
if (!classes.length) continue;
|
|
133
|
+
|
|
134
|
+
let matched: ColumnWidthRule | undefined;
|
|
135
|
+
for (const c of classes) {
|
|
136
|
+
const rule = rules.get(c);
|
|
137
|
+
if (rule) {
|
|
138
|
+
matched = rule;
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (!matched) continue;
|
|
143
|
+
|
|
144
|
+
if (!applyColumnWidth(el, matched)) continue;
|
|
145
|
+
changed = true;
|
|
146
|
+
|
|
147
|
+
if (!classes.includes(COLUMN_NO_STACK_CLASS)) {
|
|
148
|
+
el.classList.add(STACKABLE_COLUMN_CLASS);
|
|
149
|
+
stackable = true;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!changed) return html;
|
|
154
|
+
if (stackable) appendStackResetRule(parsed);
|
|
155
|
+
|
|
156
|
+
return serializeParsedHtmlDocument(parsed, html);
|
|
157
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { EmailDoc } from '../types';
|
|
2
|
+
import { serializeParsedHtmlDocument } from './html';
|
|
2
3
|
import {
|
|
3
4
|
DYNAMIC_VARIANT_HTML_ATTR,
|
|
4
5
|
getSectionDynamicVariantKey,
|
|
@@ -6,12 +7,6 @@ import {
|
|
|
6
7
|
sectionMjClassName,
|
|
7
8
|
} from './dynamicVariantKey';
|
|
8
9
|
|
|
9
|
-
function serializeParsedHtml(doc: Document): string {
|
|
10
|
-
const head = doc.head?.innerHTML || '';
|
|
11
|
-
const body = doc.body?.innerHTML || '';
|
|
12
|
-
return `${head}${body}`;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
10
|
/**
|
|
16
11
|
* 编译后为带 `dynamicVariantKey` 的 Section 根节点写入 `data-dv="{key}"`,供宿主 DOM 抽取。
|
|
17
12
|
*/
|
|
@@ -38,7 +33,7 @@ export function annotateDynamicVariantHtmlAttributes(html: string, doc: EmailDoc
|
|
|
38
33
|
if (!target && candidates.length) target = candidates[0];
|
|
39
34
|
if (target) target.setAttribute(DYNAMIC_VARIANT_HTML_ATTR, key);
|
|
40
35
|
}
|
|
41
|
-
return
|
|
36
|
+
return serializeParsedHtmlDocument(parsed, html);
|
|
42
37
|
} catch {
|
|
43
38
|
return html;
|
|
44
39
|
}
|
|
@@ -1,3 +1,34 @@
|
|
|
1
|
+
import { escapeAttr } from './dom';
|
|
2
|
+
|
|
3
|
+
function openTagOf(el: Element | null): string {
|
|
4
|
+
if (!el) return '';
|
|
5
|
+
const tag = el.tagName.toLowerCase();
|
|
6
|
+
const attrs = Array.from(el.attributes)
|
|
7
|
+
.map((a) => ` ${a.name}="${escapeAttr(a.value)}"`)
|
|
8
|
+
.join('');
|
|
9
|
+
return `<${tag}${attrs}>`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 把解析后的文档还原成字符串,尽量保留原文档的 DOCTYPE、`<html>` 与 `<body>` 开标签属性
|
|
14
|
+
* (`head.innerHTML + body.innerHTML` 会丢掉这三者)。
|
|
15
|
+
* 原文档是片段(没有 html / body 标签)时退化为片段拼接。
|
|
16
|
+
*/
|
|
17
|
+
export function serializeParsedHtmlDocument(doc: Document, original: string): string {
|
|
18
|
+
const head = doc.head?.innerHTML || '';
|
|
19
|
+
const bodyInner = doc.body?.innerHTML || '';
|
|
20
|
+
|
|
21
|
+
const hasHtml = /<html[\s>]/i.test(original);
|
|
22
|
+
const hasBody = /<body[\s>]/i.test(original);
|
|
23
|
+
|
|
24
|
+
if (!hasHtml) {
|
|
25
|
+
return hasBody ? `${openTagOf(doc.body)}${bodyInner}</body>` : `${head}${bodyInner}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const doctype = /^[\s\S]*?<!DOCTYPE[^>]*>/i.exec(original)?.[0] ?? '';
|
|
29
|
+
return `${doctype}${openTagOf(doc.documentElement)}${head}${openTagOf(doc.body)}${bodyInner}</body></html>`;
|
|
30
|
+
}
|
|
31
|
+
|
|
1
32
|
/**
|
|
2
33
|
* 极轻量 HTML 格式化器(约 50 行)。
|
|
3
34
|
*
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 邮件链接可用性判定。
|
|
3
|
+
*
|
|
4
|
+
* Outlook 网页端(outlook.com / OWA)会清洗「不可跳转」的 href:空值、`#`、
|
|
5
|
+
* 纯数字 / 相对路径(如 `1234`)、以及非标准协议(javascript:、data: 等)。
|
|
6
|
+
* 被清洗后 `<a>` 会连同内容一起消失,表现为按钮 / 图标「元素在但看不见」。
|
|
7
|
+
*
|
|
8
|
+
* 两个用途:
|
|
9
|
+
* 1. 右栏 `type: 'url'` 字段的默认校验,输入即时提示;
|
|
10
|
+
* 2. 渲染层兜底:不可跳转时不输出 href(如 MJML 会退化为 `<p>`,样式保留、仅不可点)。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** 变量占位(发送时替换),如 {{couponLink}} */
|
|
14
|
+
const VARIABLE_PATTERN = /\{\{[^{}]*\}\}/
|
|
15
|
+
/** 放行协议;其余一律不放行,兼顾安全(屏蔽 javascript: / data: 等) */
|
|
16
|
+
const ALLOWED_SCHEMES = ['http:', 'https:', 'mailto:', 'tel:', 'sms:']
|
|
17
|
+
|
|
18
|
+
export function isRenderableHref(raw: unknown): boolean {
|
|
19
|
+
const href = String(raw ?? '').trim();
|
|
20
|
+
if (!href || href === '#') return false;
|
|
21
|
+
if (VARIABLE_PATTERN.test(href)) return true;
|
|
22
|
+
|
|
23
|
+
/** 协议相对写法 //example.com */
|
|
24
|
+
const normalized = href.startsWith('//') ? `https:${href}` : href;
|
|
25
|
+
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(normalized)?.[1]?.toLowerCase();
|
|
26
|
+
/** 无协议即相对路径 / 纯数字,邮件客户端不会保留 */
|
|
27
|
+
if (!scheme || !ALLOWED_SCHEMES.includes(`${scheme}:`)) return false;
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const url = new URL(normalized);
|
|
31
|
+
return scheme === 'mailto' || scheme === 'tel' || scheme === 'sms' ? true : !!url.hostname;
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 可跳转则原样返回,否则返回空串,由调用方降级为不可点元素 */
|
|
38
|
+
export function resolveHref(raw: unknown): string {
|
|
39
|
+
const href = String(raw ?? '').trim();
|
|
40
|
+
return isRenderableHref(href) ? href : '';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 右栏链接字段默认校验:空值放行(允许暂不填),填了但不可跳转则给提示 */
|
|
44
|
+
export function invalidHrefMessage(
|
|
45
|
+
raw: unknown,
|
|
46
|
+
t: (key: string) => string,
|
|
47
|
+
): string | null {
|
|
48
|
+
const href = String(raw ?? '').trim();
|
|
49
|
+
if (!href || isRenderableHref(href)) return null;
|
|
50
|
+
return t('rightPanel.block.invalidLink');
|
|
51
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Registry } from '../registry/registry';
|
|
2
|
+
import type { BlockDefinition, PaletteDropResult } from '../types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* palette 拖入语义的判定。
|
|
6
|
+
*
|
|
7
|
+
* 画布是四层模型(Section → Column → Block),左栏也分两组:
|
|
8
|
+
* - `sections`:布局卡片,落在 Section 之间;
|
|
9
|
+
* - `blocks`:组件卡片,落在 Column 内。
|
|
10
|
+
*
|
|
11
|
+
* 自带布局的组件(如一次生成双列 Section 的卡组)实际会新建 Section,
|
|
12
|
+
* 若仍归到 `blocks`,拖拽时会显示「可放进列内」的占位,松手却落到该 Section 之后,
|
|
13
|
+
* 与预期不符。这里统一判定,让它与布局卡片同组,拖拽表现与布局一致。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** 拖入结果是否自带 Section 布局(多列布局 / 多列分发) */
|
|
17
|
+
export function paletteDropHasSectionLayout(drop: PaletteDropResult): boolean {
|
|
18
|
+
if (drop.sectionLayout && drop.sectionLayout !== '1') return true;
|
|
19
|
+
return (drop.columnBlocks?.length ?? 0) > 1;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 解析 palette 拖入结果:未声明 `expandPaletteDrop` 时按单块处理 */
|
|
23
|
+
export function resolvePaletteDropResult(
|
|
24
|
+
def: BlockDefinition,
|
|
25
|
+
registry: Registry,
|
|
26
|
+
): PaletteDropResult {
|
|
27
|
+
if (def.expandPaletteDrop) {
|
|
28
|
+
const out = def.expandPaletteDrop((t) => registry.createBlock(t));
|
|
29
|
+
return Array.isArray(out) ? { blocks: out } : out;
|
|
30
|
+
}
|
|
31
|
+
return { blocks: [registry.createBlock(def.type)] };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 该 palette 卡片是否按「布局」处理:只能落在 Section 之间,不能落进 Column */
|
|
35
|
+
export function paletteDropIsSectionLevel(
|
|
36
|
+
def: BlockDefinition,
|
|
37
|
+
registry: Registry,
|
|
38
|
+
opts?: { enableDynamicVariantKey?: boolean },
|
|
39
|
+
): boolean {
|
|
40
|
+
if (def.paletteDropLevel) return def.paletteDropLevel === 'section';
|
|
41
|
+
if (!def.expandPaletteDrop) return false;
|
|
42
|
+
const drop = resolvePaletteDropResult(def, registry);
|
|
43
|
+
if (paletteDropHasSectionLayout(drop)) return true;
|
|
44
|
+
/** 开启动态变量节时,带 dynamicVariantKey 的组合块同样会新建独立 Section */
|
|
45
|
+
return opts?.enableDynamicVariantKey === true && !!drop.sectionAttrs?.dynamicVariantKey;
|
|
46
|
+
}
|