@seliseblocks/mailcraft 0.2.13 → 0.2.15

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.
Files changed (59) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/DOCS.md +5 -4
  3. package/README.md +2 -1
  4. package/README.md.txt +2 -1
  5. package/dist/mailcraft-editor.bundle.js +72 -72
  6. package/dist/mailcraft-editor.bundle.js.map +3 -3
  7. package/examples/templates/back-in-stock.html +1 -8
  8. package/examples/templates/cart-left-behind.html +1 -8
  9. package/examples/templates/meet-nova-launch.html +5 -0
  10. package/examples/templates/rate-your-headphones.html +1 -8
  11. package/examples/templates/the-sunday-brief.html +5 -0
  12. package/examples/templates/your-order-shipped.html +1 -0
  13. package/examples/vanilla.html +14 -24
  14. package/package.json +1 -1
  15. package/src/core/editor-core.js +116 -1
  16. package/src/core/i18n/ar.js +2 -1
  17. package/src/core/i18n/bg.js +2 -1
  18. package/src/core/i18n/bn.js +2 -1
  19. package/src/core/i18n/ca.js +2 -1
  20. package/src/core/i18n/cs.js +2 -1
  21. package/src/core/i18n/da.js +2 -1
  22. package/src/core/i18n/de-CH.js +2 -1
  23. package/src/core/i18n/de.js +2 -1
  24. package/src/core/i18n/dz.js +2 -1
  25. package/src/core/i18n/el.js +2 -1
  26. package/src/core/i18n/en.js +2 -1
  27. package/src/core/i18n/es.js +2 -1
  28. package/src/core/i18n/et.js +2 -1
  29. package/src/core/i18n/fi.js +2 -1
  30. package/src/core/i18n/fr.js +2 -1
  31. package/src/core/i18n/hr.js +2 -1
  32. package/src/core/i18n/hu.js +2 -1
  33. package/src/core/i18n/index.js +83 -83
  34. package/src/core/i18n/it.js +2 -1
  35. package/src/core/i18n/lt.js +2 -1
  36. package/src/core/i18n/lv.js +2 -1
  37. package/src/core/i18n/nb.js +2 -1
  38. package/src/core/i18n/nl.js +2 -1
  39. package/src/core/i18n/pl.js +2 -1
  40. package/src/core/i18n/pt.js +2 -1
  41. package/src/core/i18n/ro.js +2 -1
  42. package/src/core/i18n/ru.js +2 -1
  43. package/src/core/i18n/sk.js +2 -1
  44. package/src/core/i18n/sl.js +2 -1
  45. package/src/core/i18n/sv.js +2 -1
  46. package/src/core/i18n/tr.js +2 -1
  47. package/src/core/i18n/uk.js +2 -1
  48. package/src/core/ids.js +1 -1
  49. package/src/core/layout-style.js +100 -100
  50. package/src/core/parse.js +10 -10
  51. package/src/core/placeholder.js +15 -15
  52. package/src/core/variables.js +11 -11
  53. package/src/mailcraft-editor.js +20 -8
  54. package/src/render/block-body.js +7 -1
  55. package/src/render/focus-preserve.js +158 -158
  56. package/src/render/rte.js +4 -1
  57. package/src/render/screenshot.js +81 -5
  58. package/src/render/story.js +513 -415
  59. package/types/index.d.ts +22 -7
@@ -1,158 +1,158 @@
1
- /**
2
- * The original relies on React's reconciliation to keep a focused `<input>`'s
3
- * DOM node alive across a per-keystroke re-render (Design-tab fields, the
4
- * campaign title, AI brief/goal/tone, search boxes, the code textarea all
5
- * commit on every `input` event, not just on blur). This renderer has no
6
- * VDOM -- it rebuilds DOM from scratch -- so without this, typing a single
7
- * character into any of those fields would lose focus immediately after.
8
- *
9
- * The fix: every such input (and every RTE-edited block: text, heading, box,
10
- * html) carries a stable `data-focus-key`. Before a rebuild, capture the
11
- * focused element's key + selection range; after, find the new element with
12
- * the same key and restore focus and the caret position, so the net effect
13
- * matches React's outcome even though the DOM node identity changed.
14
- *
15
- * Contenteditable blocks need this just as much as plain inputs: focusing
16
- * one sets `core.state.editing`, and `EditorCore.mountKeyboard`'s
17
- * `selectionchange` listener refreshes the toolbar's active states on every
18
- * caret move. Without
19
- * caret-position restoration here, that would blow away and recreate the
20
- * focused div each time, so the very first keystroke would silently drop
21
- * focus and the RTE toolbar would appear to do nothing.
22
- */
23
- export function withFocusPreserved(root, rebuild) {
24
- const active = root.activeElement;
25
- const key = active && active.dataset ? active.dataset.focusKey : null;
26
- let selStart = null; let selEnd = null; let scrollTop = null; let editable = false;
27
- // A range slider mid-drag is the one focus-preservation case where
28
- // restoring focus on a rebuilt node isn't enough: dragging its thumb is a
29
- // native, implicit mouse capture tied to that exact element, and replacing
30
- // the element (as every other rebuilt input does here) silently drops that
31
- // capture -- the thumb stops tracking the mouse and the slider feels like
32
- // it's snapping/jumping instead of gliding. So this one case skips
33
- // rebuilding the node entirely: the live element is pulled out before
34
- // `rebuild()` and spliced back into the freshly-built tree afterward.
35
- const rangeNode = key && active.tagName === 'INPUT' && active.type === 'range' ? active : null;
36
- if (rangeNode) {
37
- // nothing to capture -- the node itself is preserved below, after rebuild()
38
- } else if (key && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) {
39
- try { selStart = active.selectionStart; selEnd = active.selectionEnd; } catch { /* some input types don't support selection */ }
40
- scrollTop = active.scrollTop;
41
- } else if (key && active.isContentEditable) {
42
- editable = true;
43
- const sel = shadowSelection(root);
44
- if (sel && sel.rangeCount && active.contains(sel.anchorNode)) {
45
- const range = sel.getRangeAt(0);
46
- selStart = textOffset(active, range.startContainer, range.startOffset);
47
- selEnd = textOffset(active, range.endContainer, range.endOffset);
48
- }
49
- scrollTop = active.scrollTop;
50
- }
51
-
52
- rebuild();
53
-
54
- if (!key) return;
55
- const next = root.querySelector(`[data-focus-key="${cssEscape(key)}"]`);
56
- if (!next) return;
57
- if (rangeNode) {
58
- for (const attr of ['min', 'max', 'step']) {
59
- const v = next.getAttribute(attr);
60
- if (v === null) rangeNode.removeAttribute(attr); else rangeNode.setAttribute(attr, v);
61
- }
62
- next.replaceWith(rangeNode);
63
- return;
64
- }
65
- if (editable) {
66
- // Set the Range *before* focusing: focusing a contenteditable establishes
67
- // its own default collapsed selection as a side effect, and doing that
68
- // after we've placed the real caret fires a second, out-of-order
69
- // `selectionchange` notification for that now-stale default -- which
70
- // arrives *after* the one for our real restore, so it looks like a fresh,
71
- // later selection change and clobbers the correct caret right back to
72
- // collapsed. Setting the range first means `.focus()` adopts the
73
- // selection that's already there instead of replacing it.
74
- if (selStart != null) {
75
- try {
76
- const range = document.createRange();
77
- setPointAtOffset(range, next, selStart, true);
78
- setPointAtOffset(range, next, selEnd, false);
79
- const sel = shadowSelection(root);
80
- sel.removeAllRanges();
81
- sel.addRange(range);
82
- } catch { /* ignore -- element structure changed under the caret */ }
83
- }
84
- // `preventScroll` matters a lot here: this `.focus()` fires on every
85
- // re-render of a block mid-edit (every keystroke), not just once. Without
86
- // it, the browser's default focus-scroll-into-view runs every time --
87
- // harmless on a short template, but on a long one it yanks the canvas
88
- // back toward the focused block on every keystroke, fighting whatever
89
- // scroll position the user actually had.
90
- next.focus({ preventScroll: true });
91
- } else {
92
- next.focus({ preventScroll: true });
93
- if (selStart != null) {
94
- try { next.setSelectionRange(selStart, selEnd); } catch { /* ignore */ }
95
- }
96
- }
97
- if (scrollTop != null) next.scrollTop = scrollTop;
98
- }
99
-
100
- /**
101
- * `document.getSelection()`/`window.getSelection()` is redacted to the host
102
- * document when the real selection lives inside an open shadow root -- in
103
- * Chrome its `anchorNode` reports as `<body>` rather than the actual text
104
- * node being edited, even though the visible caret and `execCommand` both
105
- * still operate on the real position. Reading through that redacted object
106
- * (as this file needs to, to compute/restore a caret offset) silently gives
107
- * back garbage -- not an error, just always "position 0" -- which is what
108
- * made every re-render-while-typing reset the caret to the start and type
109
- * new characters in reverse. `ShadowRoot.getSelection()` (Chromium-only; no
110
- * standard equivalent yet) reports the real node/offset.
111
- */
112
- function shadowSelection(root) {
113
- return typeof root.getSelection === 'function' ? root.getSelection() : window.getSelection();
114
- }
115
-
116
- /** Character offset of (node, offset) counting only text within `root`, walking in document order. */
117
- export function textOffset(root, node, offset) {
118
- if (node.nodeType !== Node.TEXT_NODE) {
119
- // A range boundary can land on an element (e.g. offset counts child nodes) --
120
- // resolve it to the text position right before its `offset`-th child.
121
- let n = 0;
122
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
123
- let cur; let target = node.childNodes[offset];
124
- if (!target) { while (walker.nextNode()) n += walker.currentNode.nodeValue.length; return n; }
125
- while ((cur = walker.nextNode())) { if (cur === target || target.contains(cur)) return n; n += cur.nodeValue.length; }
126
- return n;
127
- }
128
- let n = 0;
129
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
130
- let cur;
131
- while ((cur = walker.nextNode())) {
132
- if (cur === node) return n + offset;
133
- n += cur.nodeValue.length;
134
- }
135
- return n;
136
- }
137
-
138
- /** Sets the start or end point of `range` to the text-offset position inside `root`. */
139
- function setPointAtOffset(range, root, targetOffset, isStart) {
140
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
141
- let n = 0; let cur; let last = null;
142
- while ((cur = walker.nextNode())) {
143
- last = cur;
144
- const len = cur.nodeValue.length;
145
- if (n + len >= targetOffset) {
146
- const point = targetOffset - n;
147
- if (isStart) range.setStart(cur, point); else range.setEnd(cur, point);
148
- return;
149
- }
150
- n += len;
151
- }
152
- if (last) { if (isStart) range.setStart(last, last.nodeValue.length); else range.setEnd(last, last.nodeValue.length); }
153
- else { if (isStart) range.setStart(root, 0); else range.setEnd(root, 0); }
154
- }
155
-
156
- function cssEscape(s) {
157
- return String(s).replace(/["\\]/g, '\\$&');
158
- }
1
+ /**
2
+ * The original relies on React's reconciliation to keep a focused `<input>`'s
3
+ * DOM node alive across a per-keystroke re-render (Design-tab fields, the
4
+ * campaign title, AI brief/goal/tone, search boxes, the code textarea all
5
+ * commit on every `input` event, not just on blur). This renderer has no
6
+ * VDOM -- it rebuilds DOM from scratch -- so without this, typing a single
7
+ * character into any of those fields would lose focus immediately after.
8
+ *
9
+ * The fix: every such input (and every RTE-edited block: text, heading, box,
10
+ * html) carries a stable `data-focus-key`. Before a rebuild, capture the
11
+ * focused element's key + selection range; after, find the new element with
12
+ * the same key and restore focus and the caret position, so the net effect
13
+ * matches React's outcome even though the DOM node identity changed.
14
+ *
15
+ * Contenteditable blocks need this just as much as plain inputs: focusing
16
+ * one sets `core.state.editing`, and `EditorCore.mountKeyboard`'s
17
+ * `selectionchange` listener refreshes the toolbar's active states on every
18
+ * caret move. Without
19
+ * caret-position restoration here, that would blow away and recreate the
20
+ * focused div each time, so the very first keystroke would silently drop
21
+ * focus and the RTE toolbar would appear to do nothing.
22
+ */
23
+ export function withFocusPreserved(root, rebuild) {
24
+ const active = root.activeElement;
25
+ const key = active && active.dataset ? active.dataset.focusKey : null;
26
+ let selStart = null; let selEnd = null; let scrollTop = null; let editable = false;
27
+ // A range slider mid-drag is the one focus-preservation case where
28
+ // restoring focus on a rebuilt node isn't enough: dragging its thumb is a
29
+ // native, implicit mouse capture tied to that exact element, and replacing
30
+ // the element (as every other rebuilt input does here) silently drops that
31
+ // capture -- the thumb stops tracking the mouse and the slider feels like
32
+ // it's snapping/jumping instead of gliding. So this one case skips
33
+ // rebuilding the node entirely: the live element is pulled out before
34
+ // `rebuild()` and spliced back into the freshly-built tree afterward.
35
+ const rangeNode = key && active.tagName === 'INPUT' && active.type === 'range' ? active : null;
36
+ if (rangeNode) {
37
+ // nothing to capture -- the node itself is preserved below, after rebuild()
38
+ } else if (key && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) {
39
+ try { selStart = active.selectionStart; selEnd = active.selectionEnd; } catch { /* some input types don't support selection */ }
40
+ scrollTop = active.scrollTop;
41
+ } else if (key && active.isContentEditable) {
42
+ editable = true;
43
+ const sel = shadowSelection(root);
44
+ if (sel && sel.rangeCount && active.contains(sel.anchorNode)) {
45
+ const range = sel.getRangeAt(0);
46
+ selStart = textOffset(active, range.startContainer, range.startOffset);
47
+ selEnd = textOffset(active, range.endContainer, range.endOffset);
48
+ }
49
+ scrollTop = active.scrollTop;
50
+ }
51
+
52
+ rebuild();
53
+
54
+ if (!key) return;
55
+ const next = root.querySelector(`[data-focus-key="${cssEscape(key)}"]`);
56
+ if (!next) return;
57
+ if (rangeNode) {
58
+ for (const attr of ['min', 'max', 'step']) {
59
+ const v = next.getAttribute(attr);
60
+ if (v === null) rangeNode.removeAttribute(attr); else rangeNode.setAttribute(attr, v);
61
+ }
62
+ next.replaceWith(rangeNode);
63
+ return;
64
+ }
65
+ if (editable) {
66
+ // Set the Range *before* focusing: focusing a contenteditable establishes
67
+ // its own default collapsed selection as a side effect, and doing that
68
+ // after we've placed the real caret fires a second, out-of-order
69
+ // `selectionchange` notification for that now-stale default -- which
70
+ // arrives *after* the one for our real restore, so it looks like a fresh,
71
+ // later selection change and clobbers the correct caret right back to
72
+ // collapsed. Setting the range first means `.focus()` adopts the
73
+ // selection that's already there instead of replacing it.
74
+ if (selStart != null) {
75
+ try {
76
+ const range = document.createRange();
77
+ setPointAtOffset(range, next, selStart, true);
78
+ setPointAtOffset(range, next, selEnd, false);
79
+ const sel = shadowSelection(root);
80
+ sel.removeAllRanges();
81
+ sel.addRange(range);
82
+ } catch { /* ignore -- element structure changed under the caret */ }
83
+ }
84
+ // `preventScroll` matters a lot here: this `.focus()` fires on every
85
+ // re-render of a block mid-edit (every keystroke), not just once. Without
86
+ // it, the browser's default focus-scroll-into-view runs every time --
87
+ // harmless on a short template, but on a long one it yanks the canvas
88
+ // back toward the focused block on every keystroke, fighting whatever
89
+ // scroll position the user actually had.
90
+ next.focus({ preventScroll: true });
91
+ } else {
92
+ next.focus({ preventScroll: true });
93
+ if (selStart != null) {
94
+ try { next.setSelectionRange(selStart, selEnd); } catch { /* ignore */ }
95
+ }
96
+ }
97
+ if (scrollTop != null) next.scrollTop = scrollTop;
98
+ }
99
+
100
+ /**
101
+ * `document.getSelection()`/`window.getSelection()` is redacted to the host
102
+ * document when the real selection lives inside an open shadow root -- in
103
+ * Chrome its `anchorNode` reports as `<body>` rather than the actual text
104
+ * node being edited, even though the visible caret and `execCommand` both
105
+ * still operate on the real position. Reading through that redacted object
106
+ * (as this file needs to, to compute/restore a caret offset) silently gives
107
+ * back garbage -- not an error, just always "position 0" -- which is what
108
+ * made every re-render-while-typing reset the caret to the start and type
109
+ * new characters in reverse. `ShadowRoot.getSelection()` (Chromium-only; no
110
+ * standard equivalent yet) reports the real node/offset.
111
+ */
112
+ function shadowSelection(root) {
113
+ return typeof root.getSelection === 'function' ? root.getSelection() : window.getSelection();
114
+ }
115
+
116
+ /** Character offset of (node, offset) counting only text within `root`, walking in document order. */
117
+ export function textOffset(root, node, offset) {
118
+ if (node.nodeType !== Node.TEXT_NODE) {
119
+ // A range boundary can land on an element (e.g. offset counts child nodes) --
120
+ // resolve it to the text position right before its `offset`-th child.
121
+ let n = 0;
122
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
123
+ let cur; let target = node.childNodes[offset];
124
+ if (!target) { while (walker.nextNode()) n += walker.currentNode.nodeValue.length; return n; }
125
+ while ((cur = walker.nextNode())) { if (cur === target || target.contains(cur)) return n; n += cur.nodeValue.length; }
126
+ return n;
127
+ }
128
+ let n = 0;
129
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
130
+ let cur;
131
+ while ((cur = walker.nextNode())) {
132
+ if (cur === node) return n + offset;
133
+ n += cur.nodeValue.length;
134
+ }
135
+ return n;
136
+ }
137
+
138
+ /** Sets the start or end point of `range` to the text-offset position inside `root`. */
139
+ function setPointAtOffset(range, root, targetOffset, isStart) {
140
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
141
+ let n = 0; let cur; let last = null;
142
+ while ((cur = walker.nextNode())) {
143
+ last = cur;
144
+ const len = cur.nodeValue.length;
145
+ if (n + len >= targetOffset) {
146
+ const point = targetOffset - n;
147
+ if (isStart) range.setStart(cur, point); else range.setEnd(cur, point);
148
+ return;
149
+ }
150
+ n += len;
151
+ }
152
+ if (last) { if (isStart) range.setStart(last, last.nodeValue.length); else range.setEnd(last, last.nodeValue.length); }
153
+ else { if (isStart) range.setStart(root, 0); else range.setEnd(root, 0); }
154
+ }
155
+
156
+ function cssEscape(s) {
157
+ return String(s).replace(/["\\]/g, '\\$&');
158
+ }
package/src/render/rte.js CHANGED
@@ -138,7 +138,10 @@ export function renderRte(core, b) {
138
138
  if (b.type === 'text' || b.type === 'heading') r1.append(
139
139
  sep(),
140
140
  btn('minus', 'Smaller text', () => core.size(b, -1)),
141
- el('span', { fontFamily: 'var(--ed-font)', fontSize: '9.5px', color: 'var(--rte-muted)', minWidth: '32px', textAlign: 'center' }, { text: (b.props.size || 16) + 'px' }),
141
+ // `selSize`, not `props.size`: with the caret inside a run the ± pair
142
+ // sized on its own (core `sizeSelection`), the readout shows that run's
143
+ // size -- the block prop no longer tells the whole story.
144
+ el('span', { fontFamily: 'var(--ed-font)', fontSize: '9.5px', color: 'var(--rte-muted)', minWidth: '32px', textAlign: 'center' }, { text: core.selSize(b) + 'px' }),
142
145
  btn('plus', 'Larger text', () => core.size(b, 1)),
143
146
  );
144
147
 
@@ -31,6 +31,37 @@ import { renderDoc } from './canvas.js';
31
31
 
32
32
  const BLANK_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
33
33
 
34
+ /** Encodings the capture can produce. Anything unrecognized falls back to PNG. */
35
+ const MIME = { png: 'image/png', jpeg: 'image/jpeg', jpg: 'image/jpeg', webp: 'image/webp' };
36
+
37
+ /**
38
+ * `{ scale, format, quality }` with every hole filled. A bare number is the
39
+ * legacy third argument (scale). `quality` only matters to the lossy formats;
40
+ * canvas encoders take 0..1, and out-of-range input falls back to the default
41
+ * rather than clamping to an extreme the caller clearly did not mean.
42
+ */
43
+ function shotOptions(options) {
44
+ const o = typeof options === 'number' ? { scale: options } : options || {};
45
+ const q = Number(o.quality);
46
+ return {
47
+ scale: Number(o.scale) > 0 ? Number(o.scale) : 2,
48
+ mime: MIME[String(o.format || 'png').toLowerCase()] || 'image/png',
49
+ quality: q >= 0 && q <= 1 ? q : 0.85,
50
+ };
51
+ }
52
+
53
+ /**
54
+ * `canvas.toBlob` wrapped as a promise. A browser that cannot encode the
55
+ * requested type is allowed by spec to hand back PNG instead (Safari does
56
+ * this for WebP), so callers read `blob.type` rather than trusting the
57
+ * request.
58
+ */
59
+ function encodeCanvas(canvas, mime, quality) {
60
+ return new Promise((resolve, reject) => {
61
+ canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('image encoding failed'))), mime, quality);
62
+ });
63
+ }
64
+
34
65
  function toDataUri(url) {
35
66
  return fetch(url, { mode: 'cors' }).then((res) => {
36
67
  if (!res.ok) throw new Error('fetch failed: ' + res.status);
@@ -66,7 +97,13 @@ async function inlineExternalImages(root) {
66
97
 
67
98
  /**
68
99
  * 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.
100
+ * the device toggle and zoom) and returns an image Blob. `options` is either
101
+ * a bare scale number (the legacy signature) or
102
+ * `{ scale = 2, format = 'png' | 'jpeg' | 'webp', quality = 0.85 }` --
103
+ * `format`/`quality` are the compression dial: PNG is lossless and biggest,
104
+ * JPEG and WebP are lossy and typically a fraction of the size on a long
105
+ * template. Check the returned `blob.type` for what was actually encoded
106
+ * (a browser without a WebP encoder hands back PNG).
70
107
  * `mountInto` is any attached container inside the editor's shadow root --
71
108
  * the tree must live in the live document briefly, off-screen, to lay out
72
109
  * and be measured before serialization.
@@ -74,7 +111,8 @@ async function inlineExternalImages(root) {
74
111
  * (`export const` + async function expression, not `export async function`:
75
112
  * build.js's transform only recognizes `export (const|function|class)`.)
76
113
  */
77
- export const captureTemplatePng = async function (core, mountInto, scale = 2) {
114
+ export const captureTemplatePng = async function (core, mountInto, options) {
115
+ const { scale, mime, quality } = shotOptions(options);
78
116
  const theme = core.state.doc.theme;
79
117
  const pad = 32;
80
118
  const wrapper = document.createElement('div');
@@ -139,11 +177,49 @@ export const captureTemplatePng = async function (core, mountInto, scale = 2) {
139
177
  canvas.width = Math.max(1, Math.round(w * fit));
140
178
  canvas.height = Math.max(1, Math.round(h * fit));
141
179
  const ctx = canvas.getContext('2d');
180
+ // The lossy formats carry no alpha channel, and an unpainted canvas pixel
181
+ // encodes as black in JPEG -- lay the page colour down first.
182
+ if (mime !== 'image/png') {
183
+ ctx.fillStyle = theme.bg || '#ffffff';
184
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
185
+ }
142
186
  ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
143
- return await new Promise((resolve, reject) => {
144
- canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('PNG encoding failed'))), 'image/png');
145
- });
187
+ return await encodeCanvas(canvas, mime, quality);
146
188
  } finally {
147
189
  wrapper.remove();
148
190
  }
149
191
  };
192
+
193
+ /**
194
+ * Re-encodes an already-captured shot without rendering the template again:
195
+ * the story viewer captures once as PNG (lossless for the preview and the
196
+ * clipboard, which only accepts `image/png`) and converts here only when the
197
+ * user downloads as JPG or WebP. `bg` fills behind the pixels for the lossy
198
+ * formats. Same caveat as the capture: read the returned `blob.type`.
199
+ */
200
+ export const transcodeShot = async function (blob, options, bg) {
201
+ const { mime, quality } = shotOptions(options);
202
+ if (mime === 'image/png' && blob.type === 'image/png') return blob;
203
+ const url = URL.createObjectURL(blob);
204
+ try {
205
+ const img = new Image();
206
+ await new Promise((resolve, reject) => {
207
+ img.onload = resolve;
208
+ img.onerror = () => reject(new Error('shot decode failed'));
209
+ img.src = url;
210
+ });
211
+ if (img.decode) await img.decode().catch(() => {});
212
+ const canvas = document.createElement('canvas');
213
+ canvas.width = Math.max(1, img.naturalWidth);
214
+ canvas.height = Math.max(1, img.naturalHeight);
215
+ const ctx = canvas.getContext('2d');
216
+ if (mime !== 'image/png') {
217
+ ctx.fillStyle = bg || '#ffffff';
218
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
219
+ }
220
+ ctx.drawImage(img, 0, 0);
221
+ return await encodeCanvas(canvas, mime, quality);
222
+ } finally {
223
+ URL.revokeObjectURL(url);
224
+ }
225
+ };