@seliseblocks/mailcraft 0.2.14 → 0.2.16
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/CHANGELOG.md +17 -0
- package/DOCS.md +5 -4
- package/README.md +2 -1
- package/README.md.txt +2 -1
- package/dist/mailcraft-editor.bundle.js +71 -71
- package/dist/mailcraft-editor.bundle.js.map +3 -3
- package/examples/templates/back-in-stock.html +1 -8
- package/examples/templates/cart-left-behind.html +1 -8
- package/examples/templates/meet-nova-launch.html +5 -0
- package/examples/templates/rate-your-headphones.html +1 -8
- package/examples/templates/the-sunday-brief.html +5 -0
- package/examples/templates/your-order-shipped.html +1 -0
- package/examples/vanilla.html +14 -24
- package/package.json +1 -1
- package/src/core/i18n/ar.js +2 -1
- package/src/core/i18n/bg.js +2 -1
- package/src/core/i18n/bn.js +2 -1
- package/src/core/i18n/ca.js +2 -1
- package/src/core/i18n/cs.js +2 -1
- package/src/core/i18n/da.js +2 -1
- package/src/core/i18n/de-CH.js +2 -1
- package/src/core/i18n/de.js +2 -1
- package/src/core/i18n/dz.js +2 -1
- package/src/core/i18n/el.js +2 -1
- package/src/core/i18n/en.js +2 -1
- package/src/core/i18n/es.js +2 -1
- package/src/core/i18n/et.js +2 -1
- package/src/core/i18n/fi.js +2 -1
- package/src/core/i18n/fr.js +2 -1
- package/src/core/i18n/hr.js +2 -1
- package/src/core/i18n/hu.js +2 -1
- package/src/core/i18n/index.js +83 -83
- package/src/core/i18n/it.js +2 -1
- package/src/core/i18n/lt.js +2 -1
- package/src/core/i18n/lv.js +2 -1
- package/src/core/i18n/nb.js +2 -1
- package/src/core/i18n/nl.js +2 -1
- package/src/core/i18n/pl.js +2 -1
- package/src/core/i18n/pt.js +2 -1
- package/src/core/i18n/ro.js +2 -1
- package/src/core/i18n/ru.js +2 -1
- package/src/core/i18n/sk.js +2 -1
- package/src/core/i18n/sl.js +2 -1
- package/src/core/i18n/sv.js +2 -1
- package/src/core/i18n/tr.js +2 -1
- package/src/core/i18n/uk.js +2 -1
- package/src/core/ids.js +1 -1
- 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/variables.js +11 -11
- package/src/mailcraft-editor.js +26 -10
- package/src/render/focus-preserve.js +158 -158
- package/src/render/screenshot.js +95 -7
- package/src/render/story.js +541 -415
- package/src/render/style.js +1 -1
- package/types/index.d.ts +22 -7
package/src/core/parse.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
export function parseItems(s) {
|
|
2
|
-
return String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).map((l) => {
|
|
3
|
-
const i = l.indexOf('|');
|
|
4
|
-
return i < 0 ? { label: l, href: '#' } : { label: l.slice(0, i).trim(), href: l.slice(i + 1).trim() };
|
|
5
|
-
});
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export function cellsOf(p) {
|
|
9
|
-
return String(p.data || '').split('\n').filter((l) => l.trim()).map((l) => l.split('|').map((c) => c.trim()));
|
|
10
|
-
}
|
|
1
|
+
export function parseItems(s) {
|
|
2
|
+
return String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).map((l) => {
|
|
3
|
+
const i = l.indexOf('|');
|
|
4
|
+
return i < 0 ? { label: l, href: '#' } : { label: l.slice(0, i).trim(), href: l.slice(i + 1).trim() };
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function cellsOf(p) {
|
|
9
|
+
return String(p.data || '').split('\n').filter((l) => l.trim()).map((l) => l.split('|').map((c) => c.trim()));
|
|
10
|
+
}
|
package/src/core/placeholder.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
/** Data-URI placeholder image generator, ported verbatim from the original. */
|
|
2
|
-
function enc(s) {
|
|
3
|
-
return encodeURIComponent(s).replace(/\(/g, '%28').replace(/\)/g, '%29');
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
export function PH(label, w, ht) {
|
|
7
|
-
return 'data:image/svg+xml;utf8,' + enc(
|
|
8
|
-
'<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + ht + '">' +
|
|
9
|
-
'<defs><pattern id="s" width="9" height="9" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">' +
|
|
10
|
-
'<rect width="9" height="9" fill="#ececed"/><line x1="0" y1="0" x2="0" y2="9" stroke="#cfd3d8" stroke-width="3"/></pattern></defs>' +
|
|
11
|
-
'<rect width="100%" height="100%" fill="url(#s)"/>' +
|
|
12
|
-
'<rect x="0.5" y="0.5" width="' + (w - 1) + '" height="' + (ht - 1) + '" fill="none" stroke="#9aa2ab"/>' +
|
|
13
|
-
'<text x="50%" y="50%" dy="4" text-anchor="middle" font-family="ui-monospace,monospace" font-size="' + Math.max(11, Math.round(w / 40)) + '" fill="#5b6672">' + label + '</text></svg>',
|
|
14
|
-
);
|
|
15
|
-
}
|
|
1
|
+
/** Data-URI placeholder image generator, ported verbatim from the original. */
|
|
2
|
+
function enc(s) {
|
|
3
|
+
return encodeURIComponent(s).replace(/\(/g, '%28').replace(/\)/g, '%29');
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function PH(label, w, ht) {
|
|
7
|
+
return 'data:image/svg+xml;utf8,' + enc(
|
|
8
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + ht + '">' +
|
|
9
|
+
'<defs><pattern id="s" width="9" height="9" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">' +
|
|
10
|
+
'<rect width="9" height="9" fill="#ececed"/><line x1="0" y1="0" x2="0" y2="9" stroke="#cfd3d8" stroke-width="3"/></pattern></defs>' +
|
|
11
|
+
'<rect width="100%" height="100%" fill="url(#s)"/>' +
|
|
12
|
+
'<rect x="0.5" y="0.5" width="' + (w - 1) + '" height="' + (ht - 1) + '" fill="none" stroke="#9aa2ab"/>' +
|
|
13
|
+
'<text x="50%" y="50%" dy="4" text-anchor="middle" font-family="ui-monospace,monospace" font-size="' + Math.max(11, Math.round(w / 40)) + '" fill="#5b6672">' + label + '</text></svg>',
|
|
14
|
+
);
|
|
15
|
+
}
|
package/src/core/variables.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
export const DEFAULT_VARS = 'first_name\nlast_name\nemail\ncompany\ncity\norder_id\nplan\ndiscount\nunsubscribe_url';
|
|
2
|
-
export const TOKEN = (t) => '{' + '{ ' + t + ' }' + '}';
|
|
3
|
-
|
|
4
|
-
/** Variables are supplied by the host application -- the editor only ever shows the tokens, never a substituted value. */
|
|
5
|
-
export function vars(raw) {
|
|
6
|
-
const list = Array.isArray(raw) ? raw : String(raw == null ? DEFAULT_VARS : raw).split(/[\n,]/);
|
|
7
|
-
return list.map((v) => String(v).trim().replace(/^\{\{\s*|\s*\}\}$/g, '')).filter(Boolean);
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/** Which prop field a merge tag lands in when inserted from the Data tab, keyed by the selected block's type. */
|
|
11
|
-
export const INSERT_KEYS = { text: 'html', heading: 'text', button: 'label', html: 'code', codeblock: 'code', quote: 'text', list: 'items', table: 'data' };
|
|
1
|
+
export const DEFAULT_VARS = 'first_name\nlast_name\nemail\ncompany\ncity\norder_id\nplan\ndiscount\nunsubscribe_url';
|
|
2
|
+
export const TOKEN = (t) => '{' + '{ ' + t + ' }' + '}';
|
|
3
|
+
|
|
4
|
+
/** Variables are supplied by the host application -- the editor only ever shows the tokens, never a substituted value. */
|
|
5
|
+
export function vars(raw) {
|
|
6
|
+
const list = Array.isArray(raw) ? raw : String(raw == null ? DEFAULT_VARS : raw).split(/[\n,]/);
|
|
7
|
+
return list.map((v) => String(v).trim().replace(/^\{\{\s*|\s*\}\}$/g, '')).filter(Boolean);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Which prop field a merge tag lands in when inserted from the Data tab, keyed by the selected block's type. */
|
|
11
|
+
export const INSERT_KEYS = { text: 'html', heading: 'text', button: 'label', html: 'code', codeblock: 'code', quote: 'text', list: 'items', table: 'data' };
|
package/src/mailcraft-editor.js
CHANGED
|
@@ -96,8 +96,10 @@ function tip(node, label, dir, align) {
|
|
|
96
96
|
* a host's own table, an imported locale from `core/i18n/`, or both merged via
|
|
97
97
|
* `defineMessages`; see `core/i18n/index.js`).
|
|
98
98
|
* Methods: `getContent()`, `setContent(doc)`, `importHtml(html)`, `exportHtml()`,
|
|
99
|
-
* `loadTemplate(tpl)`, `screenshotPng()`
|
|
100
|
-
* (full-template
|
|
99
|
+
* `loadTemplate(tpl)`, `screenshotPng(options)`
|
|
100
|
+
* (full-template image as a Blob; `options.format`/`quality`/`scale` pick
|
|
101
|
+
* PNG, JPEG or WebP and the compression), `previewScreenshot()` (story-style
|
|
102
|
+
* viewer with its own format picker),
|
|
101
103
|
* `downloadScreenshot()`, `copyScreenshot()`, `undo()`, `redo()`.
|
|
102
104
|
* Events: `change` (detail: doc), `export` (detail: html string).
|
|
103
105
|
*/
|
|
@@ -473,20 +475,34 @@ export class MailCraftEditor extends ElementBase {
|
|
|
473
475
|
return html;
|
|
474
476
|
}
|
|
475
477
|
|
|
476
|
-
/**
|
|
477
|
-
|
|
478
|
+
/**
|
|
479
|
+
* Full-template screenshot as an image Blob (2x resolution by default,
|
|
480
|
+
* desktop width, independent of the current device/zoom view). `options`
|
|
481
|
+
* is the compression dial: `{ format: 'png' | 'jpeg' | 'webp', quality:
|
|
482
|
+
* 0..1 (lossy formats, default 0.85), scale (default 2) }`. PNG is
|
|
483
|
+
* lossless; JPEG/WebP are typically a fraction of the size on a long
|
|
484
|
+
* template. Read the returned `blob.type` for what was actually encoded --
|
|
485
|
+
* a browser without a WebP encoder hands back PNG. See
|
|
486
|
+
* render/screenshot.js for the technique and its limits.
|
|
487
|
+
*/
|
|
488
|
+
screenshotPng(options) { return captureTemplatePng(this.core, this.mc, options); }
|
|
478
489
|
|
|
479
|
-
/** Opens the story-style viewer over the editor and captures into it -- what the Screenshot button does. Nothing touches the filesystem until the user picks Download inside it. */
|
|
480
|
-
previewScreenshot() {
|
|
490
|
+
/** Opens the story-style viewer over the editor and captures into it -- what the Screenshot button does. The export dialog, which hosts that button, is parked while the viewer is up and restored when it closes, so the two never read as stacked modals. Nothing touches the filesystem until the user picks Download inside it. */
|
|
491
|
+
previewScreenshot() {
|
|
492
|
+
const fromExport = this.core.state.exportOpen;
|
|
493
|
+
if (fromExport) this.core.setState({ exportOpen: false });
|
|
494
|
+
this.story.open(fromExport ? { onClose: () => this.core.setState({ exportOpen: true }) } : undefined);
|
|
495
|
+
}
|
|
481
496
|
|
|
482
|
-
/** `screenshotPng()` plus the same save-a-file flow as the HTML export download, with success/failure toasts. Pass an already-captured blob (the story viewer does) to save that one instead of rendering a second time. */
|
|
483
|
-
async downloadScreenshot(blob) {
|
|
497
|
+
/** `screenshotPng(options)` plus the same save-a-file flow as the HTML export download, with success/failure toasts. Pass an already-captured blob (the story viewer does) to save that one instead of rendering a second time; the filename extension follows the blob's actual type. */
|
|
498
|
+
async downloadScreenshot(blob, options) {
|
|
484
499
|
const t = this.core.t;
|
|
485
500
|
try {
|
|
486
|
-
const png = blob || await this.screenshotPng();
|
|
501
|
+
const png = blob || await this.screenshotPng(options);
|
|
502
|
+
const ext = { 'image/jpeg': 'jpg', 'image/webp': 'webp' }[png.type] || 'png';
|
|
487
503
|
const a = document.createElement('a');
|
|
488
504
|
a.href = URL.createObjectURL(png);
|
|
489
|
-
a.download = 'email.
|
|
505
|
+
a.download = 'email.' + ext;
|
|
490
506
|
a.click();
|
|
491
507
|
setTimeout(() => URL.revokeObjectURL(a.href), 1500);
|
|
492
508
|
this.core.flash(t('toast.pngSaved'));
|
|
@@ -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/screenshot.js
CHANGED
|
@@ -31,8 +31,46 @@ 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
|
+
|
|
65
|
+
// Inlined bytes are cached per URL for the session: every capture (open,
|
|
66
|
+
// retry, reopen) used to re-fetch each remote image, which is most of a slow
|
|
67
|
+
// open on a photo-heavy template. A failed fetch is evicted so a transient
|
|
68
|
+
// network error does not pin the blank-pixel fallback forever.
|
|
69
|
+
const inlineCache = new Map();
|
|
70
|
+
|
|
34
71
|
function toDataUri(url) {
|
|
35
|
-
|
|
72
|
+
if (inlineCache.has(url)) return inlineCache.get(url);
|
|
73
|
+
const job = fetch(url, { mode: 'cors' }).then((res) => {
|
|
36
74
|
if (!res.ok) throw new Error('fetch failed: ' + res.status);
|
|
37
75
|
return res.blob();
|
|
38
76
|
}).then((blob) => new Promise((resolve, reject) => {
|
|
@@ -40,7 +78,12 @@ function toDataUri(url) {
|
|
|
40
78
|
fr.onload = () => resolve(fr.result);
|
|
41
79
|
fr.onerror = reject;
|
|
42
80
|
fr.readAsDataURL(blob);
|
|
43
|
-
}))
|
|
81
|
+
})).catch((err) => {
|
|
82
|
+
inlineCache.delete(url);
|
|
83
|
+
throw err;
|
|
84
|
+
});
|
|
85
|
+
inlineCache.set(url, job);
|
|
86
|
+
return job;
|
|
44
87
|
}
|
|
45
88
|
|
|
46
89
|
async function inlineExternalImages(root) {
|
|
@@ -66,7 +109,13 @@ async function inlineExternalImages(root) {
|
|
|
66
109
|
|
|
67
110
|
/**
|
|
68
111
|
* Renders the current document full-length (desktop width, independent of
|
|
69
|
-
* the device toggle and zoom) and returns
|
|
112
|
+
* the device toggle and zoom) and returns an image Blob. `options` is either
|
|
113
|
+
* a bare scale number (the legacy signature) or
|
|
114
|
+
* `{ scale = 2, format = 'png' | 'jpeg' | 'webp', quality = 0.85 }` --
|
|
115
|
+
* `format`/`quality` are the compression dial: PNG is lossless and biggest,
|
|
116
|
+
* JPEG and WebP are lossy and typically a fraction of the size on a long
|
|
117
|
+
* template. Check the returned `blob.type` for what was actually encoded
|
|
118
|
+
* (a browser without a WebP encoder hands back PNG).
|
|
70
119
|
* `mountInto` is any attached container inside the editor's shadow root --
|
|
71
120
|
* the tree must live in the live document briefly, off-screen, to lay out
|
|
72
121
|
* and be measured before serialization.
|
|
@@ -74,7 +123,8 @@ async function inlineExternalImages(root) {
|
|
|
74
123
|
* (`export const` + async function expression, not `export async function`:
|
|
75
124
|
* build.js's transform only recognizes `export (const|function|class)`.)
|
|
76
125
|
*/
|
|
77
|
-
export const captureTemplatePng = async function (core, mountInto,
|
|
126
|
+
export const captureTemplatePng = async function (core, mountInto, options) {
|
|
127
|
+
const { scale, mime, quality } = shotOptions(options);
|
|
78
128
|
const theme = core.state.doc.theme;
|
|
79
129
|
const pad = 32;
|
|
80
130
|
const wrapper = document.createElement('div');
|
|
@@ -139,11 +189,49 @@ export const captureTemplatePng = async function (core, mountInto, scale = 2) {
|
|
|
139
189
|
canvas.width = Math.max(1, Math.round(w * fit));
|
|
140
190
|
canvas.height = Math.max(1, Math.round(h * fit));
|
|
141
191
|
const ctx = canvas.getContext('2d');
|
|
192
|
+
// The lossy formats carry no alpha channel, and an unpainted canvas pixel
|
|
193
|
+
// encodes as black in JPEG -- lay the page colour down first.
|
|
194
|
+
if (mime !== 'image/png') {
|
|
195
|
+
ctx.fillStyle = theme.bg || '#ffffff';
|
|
196
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
197
|
+
}
|
|
142
198
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
143
|
-
return await
|
|
144
|
-
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('PNG encoding failed'))), 'image/png');
|
|
145
|
-
});
|
|
199
|
+
return await encodeCanvas(canvas, mime, quality);
|
|
146
200
|
} finally {
|
|
147
201
|
wrapper.remove();
|
|
148
202
|
}
|
|
149
203
|
};
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Re-encodes an already-captured shot without rendering the template again:
|
|
207
|
+
* the story viewer captures once as PNG (lossless for the preview and the
|
|
208
|
+
* clipboard, which only accepts `image/png`) and converts here only when the
|
|
209
|
+
* user downloads as JPG or WebP. `bg` fills behind the pixels for the lossy
|
|
210
|
+
* formats. Same caveat as the capture: read the returned `blob.type`.
|
|
211
|
+
*/
|
|
212
|
+
export const transcodeShot = async function (blob, options, bg) {
|
|
213
|
+
const { mime, quality } = shotOptions(options);
|
|
214
|
+
if (mime === 'image/png' && blob.type === 'image/png') return blob;
|
|
215
|
+
const url = URL.createObjectURL(blob);
|
|
216
|
+
try {
|
|
217
|
+
const img = new Image();
|
|
218
|
+
await new Promise((resolve, reject) => {
|
|
219
|
+
img.onload = resolve;
|
|
220
|
+
img.onerror = () => reject(new Error('shot decode failed'));
|
|
221
|
+
img.src = url;
|
|
222
|
+
});
|
|
223
|
+
if (img.decode) await img.decode().catch(() => {});
|
|
224
|
+
const canvas = document.createElement('canvas');
|
|
225
|
+
canvas.width = Math.max(1, img.naturalWidth);
|
|
226
|
+
canvas.height = Math.max(1, img.naturalHeight);
|
|
227
|
+
const ctx = canvas.getContext('2d');
|
|
228
|
+
if (mime !== 'image/png') {
|
|
229
|
+
ctx.fillStyle = bg || '#ffffff';
|
|
230
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
231
|
+
}
|
|
232
|
+
ctx.drawImage(img, 0, 0);
|
|
233
|
+
return await encodeCanvas(canvas, mime, quality);
|
|
234
|
+
} finally {
|
|
235
|
+
URL.revokeObjectURL(url);
|
|
236
|
+
}
|
|
237
|
+
};
|