@seliseblocks/mailcraft 0.1.0 → 0.2.0
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/DOCS.md +240 -12
- package/README.md +11 -3
- package/README.md.txt +134 -0
- package/dist/mailcraft-editor.bundle.js +51 -39
- package/dist/mailcraft-editor.bundle.js.map +3 -3
- package/examples/templates/order-confirmed.html +80 -80
- package/examples/vanilla.html +242 -23
- package/package.json +7 -2
- package/src/core/assets.js +10 -15
- package/src/core/binder.js +120 -118
- package/src/core/blocks.js +14 -1
- package/src/core/css-cascade.js +117 -117
- package/src/core/editor-core.js +1530 -1492
- package/src/core/export.js +142 -55
- package/src/core/i18n/ar.js +219 -177
- package/src/core/i18n/bg.js +196 -152
- package/src/core/i18n/bn.js +218 -176
- package/src/core/i18n/ca.js +196 -152
- package/src/core/i18n/cs.js +196 -152
- package/src/core/i18n/da.js +196 -152
- package/src/core/i18n/de-CH.js +196 -152
- package/src/core/i18n/de.js +196 -152
- package/src/core/i18n/dz.js +221 -179
- package/src/core/i18n/el.js +196 -152
- package/src/core/i18n/en.js +3 -10
- package/src/core/i18n/es.js +196 -152
- package/src/core/i18n/et.js +196 -152
- package/src/core/i18n/fi.js +196 -152
- package/src/core/i18n/fr.js +196 -152
- package/src/core/i18n/hr.js +196 -152
- package/src/core/i18n/hu.js +196 -152
- package/src/core/i18n/index.js +83 -83
- package/src/core/i18n/it.js +196 -152
- package/src/core/i18n/lt.js +196 -152
- package/src/core/i18n/lv.js +196 -152
- package/src/core/i18n/nb.js +196 -152
- package/src/core/i18n/nl.js +196 -152
- package/src/core/i18n/pl.js +196 -152
- package/src/core/i18n/pt.js +196 -152
- package/src/core/i18n/ro.js +196 -152
- package/src/core/i18n/ru.js +196 -152
- package/src/core/i18n/sk.js +196 -152
- package/src/core/i18n/sl.js +196 -152
- package/src/core/i18n/sv.js +196 -152
- package/src/core/i18n/tables.js +50 -50
- package/src/core/i18n/tr.js +196 -152
- package/src/core/i18n/uk.js +196 -152
- package/src/core/icons.js +237 -235
- package/src/core/ids.js +1 -1
- package/src/core/import-html.js +1025 -959
- package/src/core/layout-style.js +100 -100
- package/src/core/parse.js +10 -10
- package/src/core/placeholder.js +15 -15
- package/src/core/sanitize.js +141 -141
- package/src/core/storage-limits.js +184 -184
- package/src/core/storage.js +85 -85
- package/src/core/theme.js +1 -1
- package/src/core/variables.js +11 -11
- package/src/index.js +9 -9
- package/src/mailcraft-editor.js +26 -14
- package/src/render/block-body.js +49 -6
- package/src/render/canvas.js +31 -2
- package/src/render/fields.js +602 -588
- package/src/render/focus-preserve.js +158 -158
- package/src/render/rte.js +241 -212
- package/src/render/screenshot.js +132 -132
- package/src/render/story.js +415 -415
- package/src/render/style.js +8 -0
- package/types/index.d.ts +419 -0
package/src/render/screenshot.js
CHANGED
|
@@ -1,132 +1,132 @@
|
|
|
1
|
-
import { renderDoc } from './canvas.js';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Full-template screenshot, zero dependencies: render the same static tree
|
|
5
|
-
* the preview modal uses (inline styles only), wrap it in an SVG
|
|
6
|
-
* `<foreignObject>`, rasterize that through an `<img>` onto a canvas, and
|
|
7
|
-
* hand back a PNG Blob.
|
|
8
|
-
*
|
|
9
|
-
* Two hard rules of SVG-loaded-as-image. The first is that the SVG has to
|
|
10
|
-
* reach the `<img>` as a `data:` URI, never a `blob:` one: Chromium treats a
|
|
11
|
-
* blob-loaded SVG that contains a `<foreignObject>` as cross-origin content,
|
|
12
|
-
* so the canvas comes out tainted and `toBlob()` throws
|
|
13
|
-
* ("Tainted canvases may not be exported") -- i.e. every capture fails, with
|
|
14
|
-
* nothing in the console to say why. A `data:` URI of the same bytes is
|
|
15
|
-
* origin-clean and rasterizes identically.
|
|
16
|
-
*
|
|
17
|
-
* The second is that it may not fetch ANY
|
|
18
|
-
* external resource -- not "CORS applies", but a total network ban -- so
|
|
19
|
-
* every image has to already be a data: URI when the SVG is built.
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* URL) is inlined here via a best-effort fetch, and a fetch the remote
|
|
23
|
-
* server refuses (no CORS header) degrades to a blank pixel instead of
|
|
24
|
-
* silently producing a tainted, unexportable canvas.
|
|
25
|
-
*
|
|
26
|
-
* Known limits, accepted: `<iframe>` embeds never paint inside foreignObject
|
|
27
|
-
* (they render as their reserved box), and web fonts are unavailable, so
|
|
28
|
-
* text falls back through the theme's system font stack -- which is what an
|
|
29
|
-
* email client would show anyway.
|
|
30
|
-
*/
|
|
31
|
-
|
|
32
|
-
const BLANK_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
|
33
|
-
|
|
34
|
-
function toDataUri(url) {
|
|
35
|
-
return fetch(url, { mode: 'cors' }).then((res) => {
|
|
36
|
-
if (!res.ok) throw new Error('fetch failed: ' + res.status);
|
|
37
|
-
return res.blob();
|
|
38
|
-
}).then((blob) => new Promise((resolve, reject) => {
|
|
39
|
-
const fr = new FileReader();
|
|
40
|
-
fr.onload = () => resolve(fr.result);
|
|
41
|
-
fr.onerror = reject;
|
|
42
|
-
fr.readAsDataURL(blob);
|
|
43
|
-
}));
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
async function inlineExternalImages(root) {
|
|
47
|
-
const jobs = [];
|
|
48
|
-
root.querySelectorAll('img').forEach((img) => {
|
|
49
|
-
const src = img.getAttribute('src') || '';
|
|
50
|
-
if (!src || src.startsWith('data:')) return;
|
|
51
|
-
jobs.push(toDataUri(src).then(
|
|
52
|
-
(uri) => { img.setAttribute('src', uri); },
|
|
53
|
-
() => { img.setAttribute('src', BLANK_PIXEL); },
|
|
54
|
-
));
|
|
55
|
-
});
|
|
56
|
-
root.querySelectorAll('[style]').forEach((el) => {
|
|
57
|
-
const m = (el.style.backgroundImage || '').match(/url\(["']?(?!data:)([^"')]+)["']?\)/);
|
|
58
|
-
if (!m) return;
|
|
59
|
-
jobs.push(toDataUri(m[1]).then(
|
|
60
|
-
(uri) => { el.style.backgroundImage = 'url("' + uri + '")'; },
|
|
61
|
-
() => { el.style.backgroundImage = 'none'; },
|
|
62
|
-
));
|
|
63
|
-
});
|
|
64
|
-
await Promise.all(jobs);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Renders the current document full-length (desktop width, independent of
|
|
69
|
-
* the device toggle and zoom) and returns a PNG Blob at `scale`x resolution.
|
|
70
|
-
* `mountInto` is any attached container inside the editor's shadow root --
|
|
71
|
-
* the tree must live in the live document briefly, off-screen, to lay out
|
|
72
|
-
* and be measured before serialization.
|
|
73
|
-
*
|
|
74
|
-
* (`export const` + async function expression, not `export async function`:
|
|
75
|
-
* build.js's transform only recognizes `export (const|function|class)`.)
|
|
76
|
-
*/
|
|
77
|
-
export const captureTemplatePng = async function (core, mountInto, scale = 2) {
|
|
78
|
-
const theme = core.state.doc.theme;
|
|
79
|
-
const pad = 32;
|
|
80
|
-
const wrapper = document.createElement('div');
|
|
81
|
-
// Off-screen but attached (fixed keeps it out of any scroll container's
|
|
82
|
-
// scrollHeight, so capturing never moves the user's canvas position).
|
|
83
|
-
wrapper.style.cssText = `position: fixed; left: -100000px; top: 0; width: ${theme.width + pad * 2}px; background: ${theme.bg}; padding: ${pad}px; box-sizing: border-box; display: flex; justify-content: center;`;
|
|
84
|
-
wrapper.setAttribute('aria-hidden', 'true');
|
|
85
|
-
// renderDoc sizes the sheet from `state.device`, so with the mobile toggle
|
|
86
|
-
// on it would return a 375px sheet floating inside this desktop-width
|
|
87
|
-
// wrapper. Borrow the state for the length of one synchronous render --
|
|
88
|
-
// nothing observes it in between, and no re-render is triggered.
|
|
89
|
-
const device = core.state.device;
|
|
90
|
-
core.state.device = 'desktop';
|
|
91
|
-
try {
|
|
92
|
-
wrapper.appendChild(renderDoc(core, false));
|
|
93
|
-
} finally {
|
|
94
|
-
core.state.device = device;
|
|
95
|
-
}
|
|
96
|
-
mountInto.appendChild(wrapper);
|
|
97
|
-
try {
|
|
98
|
-
await inlineExternalImages(wrapper);
|
|
99
|
-
// Heights depend on images' intrinsic ratios (blocks set width, not
|
|
100
|
-
// height) -- decode everything before measuring or tall images collapse.
|
|
101
|
-
await Promise.all(Array.from(wrapper.querySelectorAll('img')).map(
|
|
102
|
-
(img) => (img.decode ? img.decode().catch(() => {}) : Promise.resolve()),
|
|
103
|
-
));
|
|
104
|
-
const w = wrapper.offsetWidth;
|
|
105
|
-
const h = wrapper.offsetHeight;
|
|
106
|
-
// Serialize a *clone* with the off-screen positioning stripped -- the
|
|
107
|
-
// wrapper's own `fixed; left: -100000px` would carry into the SVG and
|
|
108
|
-
// place the content outside the drawable area.
|
|
109
|
-
const copy = wrapper.cloneNode(true);
|
|
110
|
-
copy.style.position = 'static';
|
|
111
|
-
copy.style.left = 'auto';
|
|
112
|
-
const xml = new XMLSerializer().serializeToString(copy);
|
|
113
|
-
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}"><foreignObject width="100%" height="100%">${xml}</foreignObject></svg>`;
|
|
114
|
-
|
|
115
|
-
const img = new Image();
|
|
116
|
-
await new Promise((resolve, reject) => {
|
|
117
|
-
img.onload = resolve;
|
|
118
|
-
img.onerror = () => reject(new Error('SVG rasterization failed'));
|
|
119
|
-
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
|
|
120
|
-
});
|
|
121
|
-
const canvas = document.createElement('canvas');
|
|
122
|
-
canvas.width = Math.max(1, Math.round(w * scale));
|
|
123
|
-
canvas.height = Math.max(1, Math.round(h * scale));
|
|
124
|
-
const ctx = canvas.getContext('2d');
|
|
125
|
-
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
126
|
-
return await new Promise((resolve, reject) => {
|
|
127
|
-
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('PNG encoding failed'))), 'image/png');
|
|
128
|
-
});
|
|
129
|
-
} finally {
|
|
130
|
-
wrapper.remove();
|
|
131
|
-
}
|
|
132
|
-
};
|
|
1
|
+
import { renderDoc } from './canvas.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Full-template screenshot, zero dependencies: render the same static tree
|
|
5
|
+
* the preview modal uses (inline styles only), wrap it in an SVG
|
|
6
|
+
* `<foreignObject>`, rasterize that through an `<img>` onto a canvas, and
|
|
7
|
+
* hand back a PNG Blob.
|
|
8
|
+
*
|
|
9
|
+
* Two hard rules of SVG-loaded-as-image. The first is that the SVG has to
|
|
10
|
+
* reach the `<img>` as a `data:` URI, never a `blob:` one: Chromium treats a
|
|
11
|
+
* blob-loaded SVG that contains a `<foreignObject>` as cross-origin content,
|
|
12
|
+
* so the canvas comes out tainted and `toBlob()` throws
|
|
13
|
+
* ("Tainted canvases may not be exported") -- i.e. every capture fails, with
|
|
14
|
+
* nothing in the console to say why. A `data:` URI of the same bytes is
|
|
15
|
+
* origin-clean and rasterizes identically.
|
|
16
|
+
*
|
|
17
|
+
* The second is that it may not fetch ANY
|
|
18
|
+
* external resource -- not "CORS applies", but a total network ban -- so
|
|
19
|
+
* every image has to already be a data: URI when the SVG is built. A block
|
|
20
|
+
* placeholder is one by construction (core/placeholder.js), and so is a file
|
|
21
|
+
* dropped into the local library; anything else (a provider URL, a hand-typed
|
|
22
|
+
* URL) is inlined here via a best-effort fetch, and a fetch the remote
|
|
23
|
+
* server refuses (no CORS header) degrades to a blank pixel instead of
|
|
24
|
+
* silently producing a tainted, unexportable canvas.
|
|
25
|
+
*
|
|
26
|
+
* Known limits, accepted: `<iframe>` embeds never paint inside foreignObject
|
|
27
|
+
* (they render as their reserved box), and web fonts are unavailable, so
|
|
28
|
+
* text falls back through the theme's system font stack -- which is what an
|
|
29
|
+
* email client would show anyway.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const BLANK_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
|
33
|
+
|
|
34
|
+
function toDataUri(url) {
|
|
35
|
+
return fetch(url, { mode: 'cors' }).then((res) => {
|
|
36
|
+
if (!res.ok) throw new Error('fetch failed: ' + res.status);
|
|
37
|
+
return res.blob();
|
|
38
|
+
}).then((blob) => new Promise((resolve, reject) => {
|
|
39
|
+
const fr = new FileReader();
|
|
40
|
+
fr.onload = () => resolve(fr.result);
|
|
41
|
+
fr.onerror = reject;
|
|
42
|
+
fr.readAsDataURL(blob);
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function inlineExternalImages(root) {
|
|
47
|
+
const jobs = [];
|
|
48
|
+
root.querySelectorAll('img').forEach((img) => {
|
|
49
|
+
const src = img.getAttribute('src') || '';
|
|
50
|
+
if (!src || src.startsWith('data:')) return;
|
|
51
|
+
jobs.push(toDataUri(src).then(
|
|
52
|
+
(uri) => { img.setAttribute('src', uri); },
|
|
53
|
+
() => { img.setAttribute('src', BLANK_PIXEL); },
|
|
54
|
+
));
|
|
55
|
+
});
|
|
56
|
+
root.querySelectorAll('[style]').forEach((el) => {
|
|
57
|
+
const m = (el.style.backgroundImage || '').match(/url\(["']?(?!data:)([^"')]+)["']?\)/);
|
|
58
|
+
if (!m) return;
|
|
59
|
+
jobs.push(toDataUri(m[1]).then(
|
|
60
|
+
(uri) => { el.style.backgroundImage = 'url("' + uri + '")'; },
|
|
61
|
+
() => { el.style.backgroundImage = 'none'; },
|
|
62
|
+
));
|
|
63
|
+
});
|
|
64
|
+
await Promise.all(jobs);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Renders the current document full-length (desktop width, independent of
|
|
69
|
+
* the device toggle and zoom) and returns a PNG Blob at `scale`x resolution.
|
|
70
|
+
* `mountInto` is any attached container inside the editor's shadow root --
|
|
71
|
+
* the tree must live in the live document briefly, off-screen, to lay out
|
|
72
|
+
* and be measured before serialization.
|
|
73
|
+
*
|
|
74
|
+
* (`export const` + async function expression, not `export async function`:
|
|
75
|
+
* build.js's transform only recognizes `export (const|function|class)`.)
|
|
76
|
+
*/
|
|
77
|
+
export const captureTemplatePng = async function (core, mountInto, scale = 2) {
|
|
78
|
+
const theme = core.state.doc.theme;
|
|
79
|
+
const pad = 32;
|
|
80
|
+
const wrapper = document.createElement('div');
|
|
81
|
+
// Off-screen but attached (fixed keeps it out of any scroll container's
|
|
82
|
+
// scrollHeight, so capturing never moves the user's canvas position).
|
|
83
|
+
wrapper.style.cssText = `position: fixed; left: -100000px; top: 0; width: ${theme.width + pad * 2}px; background: ${theme.bg}; padding: ${pad}px; box-sizing: border-box; display: flex; justify-content: center;`;
|
|
84
|
+
wrapper.setAttribute('aria-hidden', 'true');
|
|
85
|
+
// renderDoc sizes the sheet from `state.device`, so with the mobile toggle
|
|
86
|
+
// on it would return a 375px sheet floating inside this desktop-width
|
|
87
|
+
// wrapper. Borrow the state for the length of one synchronous render --
|
|
88
|
+
// nothing observes it in between, and no re-render is triggered.
|
|
89
|
+
const device = core.state.device;
|
|
90
|
+
core.state.device = 'desktop';
|
|
91
|
+
try {
|
|
92
|
+
wrapper.appendChild(renderDoc(core, false));
|
|
93
|
+
} finally {
|
|
94
|
+
core.state.device = device;
|
|
95
|
+
}
|
|
96
|
+
mountInto.appendChild(wrapper);
|
|
97
|
+
try {
|
|
98
|
+
await inlineExternalImages(wrapper);
|
|
99
|
+
// Heights depend on images' intrinsic ratios (blocks set width, not
|
|
100
|
+
// height) -- decode everything before measuring or tall images collapse.
|
|
101
|
+
await Promise.all(Array.from(wrapper.querySelectorAll('img')).map(
|
|
102
|
+
(img) => (img.decode ? img.decode().catch(() => {}) : Promise.resolve()),
|
|
103
|
+
));
|
|
104
|
+
const w = wrapper.offsetWidth;
|
|
105
|
+
const h = wrapper.offsetHeight;
|
|
106
|
+
// Serialize a *clone* with the off-screen positioning stripped -- the
|
|
107
|
+
// wrapper's own `fixed; left: -100000px` would carry into the SVG and
|
|
108
|
+
// place the content outside the drawable area.
|
|
109
|
+
const copy = wrapper.cloneNode(true);
|
|
110
|
+
copy.style.position = 'static';
|
|
111
|
+
copy.style.left = 'auto';
|
|
112
|
+
const xml = new XMLSerializer().serializeToString(copy);
|
|
113
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}"><foreignObject width="100%" height="100%">${xml}</foreignObject></svg>`;
|
|
114
|
+
|
|
115
|
+
const img = new Image();
|
|
116
|
+
await new Promise((resolve, reject) => {
|
|
117
|
+
img.onload = resolve;
|
|
118
|
+
img.onerror = () => reject(new Error('SVG rasterization failed'));
|
|
119
|
+
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
|
|
120
|
+
});
|
|
121
|
+
const canvas = document.createElement('canvas');
|
|
122
|
+
canvas.width = Math.max(1, Math.round(w * scale));
|
|
123
|
+
canvas.height = Math.max(1, Math.round(h * scale));
|
|
124
|
+
const ctx = canvas.getContext('2d');
|
|
125
|
+
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
126
|
+
return await new Promise((resolve, reject) => {
|
|
127
|
+
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('PNG encoding failed'))), 'image/png');
|
|
128
|
+
});
|
|
129
|
+
} finally {
|
|
130
|
+
wrapper.remove();
|
|
131
|
+
}
|
|
132
|
+
};
|