mail-editor-pancake 0.0.0 → 0.0.8
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/README.md +79 -6
- package/package.json +1 -1
- package/packages/blocks/dist/index.d.ts +55 -3
- package/packages/blocks/dist/index.js +267 -208
- package/packages/blocks/dist/index.js.map +1 -1
- package/packages/blocks/node_modules/.bin/resolve +21 -0
- package/packages/blocks/package.json +1 -0
- package/packages/blocks/src/button.ts +1 -0
- package/packages/blocks/src/hero.ts +2 -0
- package/packages/blocks/src/image.ts +1 -0
- package/packages/blocks/src/index.ts +19 -2
- package/packages/blocks/src/logo.ts +1 -0
- package/packages/blocks/src/social.ts +47 -22
- package/packages/blocks/src/socialGroup.ts +49 -22
- package/packages/blocks/src/socialShared.ts +39 -2
- package/packages/blocks/src/text.ts +25 -19
- package/packages/blocks/tsconfig.json +0 -3
- package/packages/blocks/vite.config.ts +2 -0
- package/packages/core/dist/index.d.ts +90 -7
- package/packages/core/dist/index.js +2505 -1462
- package/packages/core/dist/index.js.map +1 -1
- package/packages/core/dist/style.css +1 -1
- package/packages/core/src/editor/Canvas.ts +107 -53
- package/packages/core/src/editor/ColorPickerPopover.ts +291 -0
- package/packages/core/src/editor/Editor.ts +129 -8
- package/packages/core/src/editor/ExportModal.ts +2 -1
- package/packages/core/src/editor/FocusBreadcrumb.ts +120 -0
- package/packages/core/src/editor/ImageGalleryModal.ts +3 -3
- package/packages/core/src/editor/InlineEditor.ts +308 -66
- package/packages/core/src/editor/LeftPanel.ts +1 -2
- package/packages/core/src/editor/PreviewModal.ts +77 -7
- package/packages/core/src/editor/RichTextToolbar.ts +66 -44
- package/packages/core/src/editor/RightPanel.ts +773 -171
- package/packages/core/src/editor/SourceView.ts +2 -1
- package/packages/core/src/editor/Topbar.ts +150 -5
- package/packages/core/src/editor/styles.css +644 -81
- package/packages/core/src/index.ts +18 -1
- package/packages/core/src/renderer/mjml.ts +2 -1
- package/packages/core/src/store/store.ts +44 -3
- package/packages/core/src/types.ts +34 -3
- package/packages/core/src/utils/fontWeightSteps.ts +31 -0
- package/packages/core/src/utils/lockedMjml.ts +2 -1
- package/packages/core/src/utils/richTextCommand.ts +30 -0
- package/packages/core/src/utils/sectionLayout.ts +13 -0
- package/playground/vanilla/src/main.ts +1 -1
|
@@ -8,14 +8,13 @@ export type PreviewDevice = 'pc' | 'pad' | 'phone';
|
|
|
8
8
|
|
|
9
9
|
interface DeviceSpec {
|
|
10
10
|
width: number;
|
|
11
|
-
height: number;
|
|
12
11
|
label: string;
|
|
13
12
|
}
|
|
14
13
|
|
|
15
14
|
const DEVICES: Record<PreviewDevice, DeviceSpec> = {
|
|
16
|
-
pc: { width: 800,
|
|
17
|
-
pad: { width: 640,
|
|
18
|
-
phone: { width: 375,
|
|
15
|
+
pc: { width: 800, label: 'PC' },
|
|
16
|
+
pad: { width: 640, label: 'Pad' },
|
|
17
|
+
phone: { width: 375, label: 'Phone' },
|
|
19
18
|
};
|
|
20
19
|
|
|
21
20
|
export interface PreviewModalOptions {
|
|
@@ -35,14 +34,18 @@ export class PreviewModal {
|
|
|
35
34
|
private iframe: HTMLIFrameElement;
|
|
36
35
|
private viewport: HTMLElement;
|
|
37
36
|
private deviceBtns: Record<PreviewDevice, HTMLButtonElement> = {} as any;
|
|
37
|
+
private iframeRo?: ResizeObserver;
|
|
38
|
+
private layoutRaf: number | null = null;
|
|
39
|
+
private onWinResize?: () => void;
|
|
38
40
|
|
|
39
41
|
constructor(opts: PreviewModalOptions) {
|
|
40
42
|
this.opts = opts;
|
|
41
43
|
this.modal = new Modal({
|
|
42
44
|
title: '预览邮件',
|
|
43
45
|
className: 'sm-modal--preview',
|
|
44
|
-
width: 'min(960px,
|
|
45
|
-
height
|
|
46
|
+
width: 'min(960px, calc(100vw - 32px))',
|
|
47
|
+
/** 高度随 iframe 内容测量,见 _fitPreviewHeight;不超过屏高由 CSS max-height 约束 */
|
|
48
|
+
onClose: () => this._teardownLayoutHooks(),
|
|
46
49
|
});
|
|
47
50
|
|
|
48
51
|
this.iframe = h('iframe', {
|
|
@@ -50,6 +53,10 @@ export class PreviewModal {
|
|
|
50
53
|
sandbox: 'allow-same-origin',
|
|
51
54
|
title: 'preview',
|
|
52
55
|
}) as HTMLIFrameElement;
|
|
56
|
+
this.iframe.addEventListener('load', () => {
|
|
57
|
+
this._bindIframeResizeObserver();
|
|
58
|
+
this._scheduleFitPreviewHeight();
|
|
59
|
+
});
|
|
53
60
|
|
|
54
61
|
this.viewport = h('div', { class: 'sm-preview__viewport' }, [this.iframe]);
|
|
55
62
|
this.modal.body.classList.add('sm-modal__body--center');
|
|
@@ -77,16 +84,20 @@ export class PreviewModal {
|
|
|
77
84
|
this._render();
|
|
78
85
|
this.setDevice(this.device);
|
|
79
86
|
this.modal.open(parent);
|
|
87
|
+
this.onWinResize = () => this._scheduleFitPreviewHeight();
|
|
88
|
+
window.addEventListener('resize', this.onWinResize);
|
|
80
89
|
}
|
|
81
90
|
|
|
82
91
|
setDevice(d: PreviewDevice) {
|
|
83
92
|
this.device = d;
|
|
84
93
|
const spec = DEVICES[d];
|
|
85
94
|
this.viewport.style.setProperty('--sm-preview-w', `${spec.width}px`);
|
|
86
|
-
this.viewport.style.setProperty('--sm-preview-h', `${spec.height}px`);
|
|
87
95
|
for (const k of Object.keys(this.deviceBtns) as PreviewDevice[]) {
|
|
88
96
|
this.deviceBtns[k].classList.toggle('sm-segmented__item--active', k === d);
|
|
89
97
|
}
|
|
98
|
+
requestAnimationFrame(() =>
|
|
99
|
+
requestAnimationFrame(() => this._scheduleFitPreviewHeight()),
|
|
100
|
+
);
|
|
90
101
|
}
|
|
91
102
|
|
|
92
103
|
private _render() {
|
|
@@ -96,6 +107,65 @@ export class PreviewModal {
|
|
|
96
107
|
// 用 srcdoc 而非 src=blob:,避免 about:srcdoc 偶发的相对资源问题
|
|
97
108
|
this.iframe.srcdoc = result.html;
|
|
98
109
|
}
|
|
110
|
+
|
|
111
|
+
/** 标题栏 + body 上下 padding + 与视口的小间距 */
|
|
112
|
+
private static readonly VIEWPORT_RESERVED_Y = 112;
|
|
113
|
+
|
|
114
|
+
private _scheduleFitPreviewHeight() {
|
|
115
|
+
if (this.layoutRaf != null) cancelAnimationFrame(this.layoutRaf);
|
|
116
|
+
this.layoutRaf = requestAnimationFrame(() => {
|
|
117
|
+
this.layoutRaf = null;
|
|
118
|
+
this._fitPreviewHeight();
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private _fitPreviewHeight() {
|
|
123
|
+
if (!this.iframe.isConnected) return;
|
|
124
|
+
try {
|
|
125
|
+
const doc = this.iframe.contentDocument;
|
|
126
|
+
const root = doc?.documentElement;
|
|
127
|
+
const body = doc?.body;
|
|
128
|
+
if (!root || !body) return;
|
|
129
|
+
const raw = Math.max(root.scrollHeight, body.scrollHeight);
|
|
130
|
+
const maxPx = Math.max(
|
|
131
|
+
240,
|
|
132
|
+
window.innerHeight - PreviewModal.VIEWPORT_RESERVED_Y,
|
|
133
|
+
);
|
|
134
|
+
const minPx = 200;
|
|
135
|
+
const next = Math.min(Math.max(Math.ceil(raw), minPx), maxPx);
|
|
136
|
+
this.viewport.style.height = `${next}px`;
|
|
137
|
+
} catch {
|
|
138
|
+
/* srcdoc 同域,极端情况下忽略 */
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private _bindIframeResizeObserver() {
|
|
143
|
+
this.iframeRo?.disconnect();
|
|
144
|
+
try {
|
|
145
|
+
const doc = this.iframe.contentDocument;
|
|
146
|
+
const body = doc?.body;
|
|
147
|
+
if (!body || typeof ResizeObserver === 'undefined') return;
|
|
148
|
+
this.iframeRo = new ResizeObserver(() => this._scheduleFitPreviewHeight());
|
|
149
|
+
this.iframeRo.observe(body);
|
|
150
|
+
const html = doc.documentElement;
|
|
151
|
+
if (html) this.iframeRo.observe(html);
|
|
152
|
+
} catch {
|
|
153
|
+
/* ignore */
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private _teardownLayoutHooks() {
|
|
158
|
+
if (this.onWinResize) {
|
|
159
|
+
window.removeEventListener('resize', this.onWinResize);
|
|
160
|
+
this.onWinResize = undefined;
|
|
161
|
+
}
|
|
162
|
+
this.iframeRo?.disconnect();
|
|
163
|
+
this.iframeRo = undefined;
|
|
164
|
+
if (this.layoutRaf != null) {
|
|
165
|
+
cancelAnimationFrame(this.layoutRaf);
|
|
166
|
+
this.layoutRaf = null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
99
169
|
}
|
|
100
170
|
|
|
101
171
|
function iconForDevice(d: PreviewDevice): SVGElement {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { bindColorPickerInput } from './ColorPickerPopover';
|
|
1
2
|
import { h } from '../utils/dom';
|
|
3
|
+
import { FONT_WEIGHT_STEP_OPTIONS, normalizeFontWeightStep } from '../utils/fontWeightSteps';
|
|
2
4
|
import type { InlineEditor, SelectionState } from './InlineEditor';
|
|
3
5
|
|
|
4
6
|
/**
|
|
@@ -34,13 +36,6 @@ const FONT_FAMILIES = [
|
|
|
34
36
|
|
|
35
37
|
const FONT_SIZES = ['12px', '13px', '14px', '15px', '16px', '18px', '20px', '24px', '28px', '32px'];
|
|
36
38
|
|
|
37
|
-
const FONT_WEIGHTS = [
|
|
38
|
-
{ label: '常规', value: '400' },
|
|
39
|
-
{ label: '中等', value: '500' },
|
|
40
|
-
{ label: '半粗', value: '600' },
|
|
41
|
-
{ label: '加粗', value: '700' },
|
|
42
|
-
];
|
|
43
|
-
|
|
44
39
|
export class RichTextToolbar {
|
|
45
40
|
el: HTMLElement;
|
|
46
41
|
private opts: RichTextToolbarOptions;
|
|
@@ -115,14 +110,15 @@ export class RichTextToolbar {
|
|
|
115
110
|
this.btns.unlink.style.display = hasLink ? '' : 'none';
|
|
116
111
|
|
|
117
112
|
if (f.foreColor) {
|
|
118
|
-
const hex =
|
|
113
|
+
const hex = colorToHexInput(f.foreColor);
|
|
119
114
|
if (hex) this.inputColor.value = hex;
|
|
120
115
|
}
|
|
116
|
+
this.inputBgColor.value = f.backColor
|
|
117
|
+
? (colorToHexInput(f.backColor) ?? '#ffffff')
|
|
118
|
+
: '#ffffff';
|
|
121
119
|
if (f.fontName) this.selectFontFamily.value = matchFamily(f.fontName);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (px && FONT_SIZES.includes(px)) this.selectFontSize.value = px;
|
|
125
|
-
}
|
|
120
|
+
this.selectFontSize.value = matchFontSize(f.fontSize);
|
|
121
|
+
this.selectFontWeight.value = matchFontWeight(f.fontWeight);
|
|
126
122
|
|
|
127
123
|
const ed = this.editor;
|
|
128
124
|
requestAnimationFrame(() => {
|
|
@@ -212,7 +208,7 @@ export class RichTextToolbar {
|
|
|
212
208
|
title: '字号',
|
|
213
209
|
onchange: (e: Event) => {
|
|
214
210
|
const v = (e.target as HTMLSelectElement).value;
|
|
215
|
-
if (v) this.
|
|
211
|
+
if (v) this.editor?.applyFontSize(v);
|
|
216
212
|
},
|
|
217
213
|
});
|
|
218
214
|
{
|
|
@@ -234,7 +230,7 @@ export class RichTextToolbar {
|
|
|
234
230
|
title: '字重',
|
|
235
231
|
onchange: (e: Event) => {
|
|
236
232
|
const v = (e.target as HTMLSelectElement).value;
|
|
237
|
-
if (v) this.
|
|
233
|
+
if (v) this.editor?.applyFontWeight(v);
|
|
238
234
|
},
|
|
239
235
|
});
|
|
240
236
|
{
|
|
@@ -243,7 +239,7 @@ export class RichTextToolbar {
|
|
|
243
239
|
placeholder.textContent = '字重';
|
|
244
240
|
this.selectFontWeight.append(placeholder);
|
|
245
241
|
}
|
|
246
|
-
for (const w of
|
|
242
|
+
for (const w of FONT_WEIGHT_STEP_OPTIONS) {
|
|
247
243
|
const opt = document.createElement('option');
|
|
248
244
|
opt.value = w.value;
|
|
249
245
|
opt.textContent = w.label;
|
|
@@ -256,8 +252,15 @@ export class RichTextToolbar {
|
|
|
256
252
|
type: 'color',
|
|
257
253
|
title: '文字颜色',
|
|
258
254
|
value: '#433f3f',
|
|
259
|
-
oninput: (e: Event) => this.editor?.exec('foreColor', (e.target as HTMLInputElement).value),
|
|
260
255
|
}) as HTMLInputElement;
|
|
256
|
+
bindColorPickerInput(this.inputColor, {
|
|
257
|
+
layerRoot: this.opts.positionRoot,
|
|
258
|
+
liveCommit: true,
|
|
259
|
+
autoFocusHex: false,
|
|
260
|
+
onBeforeOpen: () => this.editor?.saveSelection(),
|
|
261
|
+
onCommit: (hex) => this.editor?.exec('foreColor', hex),
|
|
262
|
+
onClosed: () => this.editor?.refocus(),
|
|
263
|
+
});
|
|
261
264
|
|
|
262
265
|
// 文字背景色
|
|
263
266
|
this.inputBgColor = h('input', {
|
|
@@ -265,8 +268,15 @@ export class RichTextToolbar {
|
|
|
265
268
|
type: 'color',
|
|
266
269
|
title: '文字背景色',
|
|
267
270
|
value: '#fff7e6',
|
|
268
|
-
oninput: (e: Event) => this.editor?.exec('hiliteColor', (e.target as HTMLInputElement).value),
|
|
269
271
|
}) as HTMLInputElement;
|
|
272
|
+
bindColorPickerInput(this.inputBgColor, {
|
|
273
|
+
layerRoot: this.opts.positionRoot,
|
|
274
|
+
liveCommit: true,
|
|
275
|
+
autoFocusHex: false,
|
|
276
|
+
onBeforeOpen: () => this.editor?.saveSelection(),
|
|
277
|
+
onCommit: (hex) => this.editor?.exec('hiliteColor', hex),
|
|
278
|
+
onClosed: () => this.editor?.refocus(),
|
|
279
|
+
});
|
|
270
280
|
|
|
271
281
|
// 链接:插入链接(无链接时显示)
|
|
272
282
|
this.btns.link = this._iconBtn(
|
|
@@ -361,30 +371,6 @@ export class RichTextToolbar {
|
|
|
361
371
|
this.inputLink.value = '';
|
|
362
372
|
}
|
|
363
373
|
|
|
364
|
-
private _wrapStyle(prop: string, value: string) {
|
|
365
|
-
if (!this.editor) return;
|
|
366
|
-
// execCommand 没有"任意 inline style"命令;自己处理:把存档选区的内容包到一个 span。
|
|
367
|
-
this.editor.refocus(); // focus + restoreSelection
|
|
368
|
-
const sel = window.getSelection();
|
|
369
|
-
if (!sel || sel.rangeCount === 0) return;
|
|
370
|
-
const range = sel.getRangeAt(0);
|
|
371
|
-
if (range.collapsed) return; // 没选中文字,无意义
|
|
372
|
-
const span = document.createElement('span');
|
|
373
|
-
span.setAttribute('style', `${prop}:${value};`);
|
|
374
|
-
try {
|
|
375
|
-
span.appendChild(range.extractContents());
|
|
376
|
-
range.insertNode(span);
|
|
377
|
-
// 选中 span 内容方便连续操作
|
|
378
|
-
const newRange = document.createRange();
|
|
379
|
-
newRange.selectNodeContents(span);
|
|
380
|
-
sel.removeAllRanges();
|
|
381
|
-
sel.addRange(newRange);
|
|
382
|
-
this.editor.saveSelection();
|
|
383
|
-
} catch {
|
|
384
|
-
// 跨节点结构复杂时 extractContents 偶尔抛错,忽略并保持原样
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
|
|
388
374
|
/**
|
|
389
375
|
* 相对 `positionRoot` 定位:默认贴在**正文容器**上沿上方,避免压在选区文字上;
|
|
390
376
|
* 若顶端空间不足则贴在容器下沿下方。
|
|
@@ -475,9 +461,45 @@ function matchFamily(name: string): string {
|
|
|
475
461
|
return '';
|
|
476
462
|
}
|
|
477
463
|
|
|
478
|
-
function
|
|
479
|
-
if (
|
|
480
|
-
const m =
|
|
464
|
+
function matchFontSize(raw: string | null): string {
|
|
465
|
+
if (!raw) return '';
|
|
466
|
+
const m = raw.trim().match(/^(\d+(?:\.\d+)?)px$/i);
|
|
467
|
+
if (!m) return '';
|
|
468
|
+
const px = `${Math.round(parseFloat(m[1]))}px`;
|
|
469
|
+
if (FONT_SIZES.includes(px)) return px;
|
|
470
|
+
const num = parseFloat(px);
|
|
471
|
+
let best = FONT_SIZES[0];
|
|
472
|
+
let bestD = Infinity;
|
|
473
|
+
for (const s of FONT_SIZES) {
|
|
474
|
+
const d = Math.abs(parseFloat(s) - num);
|
|
475
|
+
if (d < bestD) {
|
|
476
|
+
bestD = d;
|
|
477
|
+
best = s;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return best;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function matchFontWeight(raw: string | null): string {
|
|
484
|
+
if (!raw) return '';
|
|
485
|
+
const step = normalizeFontWeightStep(raw);
|
|
486
|
+
return FONT_WEIGHT_STEP_OPTIONS.some((w) => w.value === step) ? step : '';
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function colorToHexInput(color: string): string | null {
|
|
490
|
+
const s = color.trim();
|
|
491
|
+
if (!s) return null;
|
|
492
|
+
if (s.startsWith('#')) {
|
|
493
|
+
if (/^#[0-9a-f]{3}$/i.test(s)) {
|
|
494
|
+
const r = s[1];
|
|
495
|
+
const g = s[2];
|
|
496
|
+
const b = s[3];
|
|
497
|
+
return `#${r}${r}${g}${g}${b}${b}`.toLowerCase();
|
|
498
|
+
}
|
|
499
|
+
if (/^#[0-9a-f]{6}$/i.test(s)) return s.toLowerCase();
|
|
500
|
+
return s;
|
|
501
|
+
}
|
|
502
|
+
const m = s.match(/^rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)/i);
|
|
481
503
|
if (!m) return null;
|
|
482
504
|
const toHex = (n: string) => Number(n).toString(16).padStart(2, '0');
|
|
483
505
|
return `#${toHex(m[1])}${toHex(m[2])}${toHex(m[3])}`;
|