@seliseblocks/mailcraft 0.1.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.
Files changed (89) hide show
  1. package/DOCS.md +557 -0
  2. package/LICENSE +21 -0
  3. package/README.md +126 -0
  4. package/dist/mailcraft-editor.bundle.js +519 -0
  5. package/dist/mailcraft-editor.bundle.js.map +7 -0
  6. package/examples/templates/activate-your-account.html +54 -0
  7. package/examples/templates/back-in-stock.html +63 -0
  8. package/examples/templates/cart-left-behind.html +74 -0
  9. package/examples/templates/community-giveaway.html +81 -0
  10. package/examples/templates/frontend-futures-invite.html +69 -0
  11. package/examples/templates/give-25-get-25.html +69 -0
  12. package/examples/templates/invoice-paid.html +77 -0
  13. package/examples/templates/meet-nova-launch.html +69 -0
  14. package/examples/templates/mega-weekend-sale.html +57 -0
  15. package/examples/templates/order-confirmed.html +80 -0
  16. package/examples/templates/rate-your-headphones.html +71 -0
  17. package/examples/templates/reset-your-password.html +54 -0
  18. package/examples/templates/thankyou-promo-code.html +68 -0
  19. package/examples/templates/the-sunday-brief.html +47 -0
  20. package/examples/templates/welcome-to-your-workspace.html +62 -0
  21. package/examples/templates/your-order-shipped.html +74 -0
  22. package/examples/templates/your-password-was-changed.html +58 -0
  23. package/examples/templates/your-signin-code.html +58 -0
  24. package/examples/vanilla.html +1574 -0
  25. package/package.json +66 -0
  26. package/src/core/accent.js +194 -0
  27. package/src/core/assets.js +15 -0
  28. package/src/core/binder.js +119 -0
  29. package/src/core/blocks.js +256 -0
  30. package/src/core/css-cascade.js +117 -0
  31. package/src/core/editor-core.js +1492 -0
  32. package/src/core/export.js +58 -0
  33. package/src/core/footer.js +73 -0
  34. package/src/core/i18n/ar.js +177 -0
  35. package/src/core/i18n/bg.js +152 -0
  36. package/src/core/i18n/bn.js +176 -0
  37. package/src/core/i18n/ca.js +152 -0
  38. package/src/core/i18n/cs.js +152 -0
  39. package/src/core/i18n/da.js +152 -0
  40. package/src/core/i18n/de-CH.js +152 -0
  41. package/src/core/i18n/de.js +152 -0
  42. package/src/core/i18n/dz.js +179 -0
  43. package/src/core/i18n/el.js +152 -0
  44. package/src/core/i18n/en.js +269 -0
  45. package/src/core/i18n/es.js +152 -0
  46. package/src/core/i18n/et.js +152 -0
  47. package/src/core/i18n/fi.js +152 -0
  48. package/src/core/i18n/fr.js +152 -0
  49. package/src/core/i18n/hr.js +152 -0
  50. package/src/core/i18n/hu.js +152 -0
  51. package/src/core/i18n/index.js +83 -0
  52. package/src/core/i18n/it.js +152 -0
  53. package/src/core/i18n/lt.js +152 -0
  54. package/src/core/i18n/lv.js +152 -0
  55. package/src/core/i18n/nb.js +152 -0
  56. package/src/core/i18n/nl.js +152 -0
  57. package/src/core/i18n/pl.js +152 -0
  58. package/src/core/i18n/pt.js +152 -0
  59. package/src/core/i18n/ro.js +152 -0
  60. package/src/core/i18n/ru.js +152 -0
  61. package/src/core/i18n/sk.js +152 -0
  62. package/src/core/i18n/sl.js +152 -0
  63. package/src/core/i18n/sv.js +152 -0
  64. package/src/core/i18n/tables.js +50 -0
  65. package/src/core/i18n/tr.js +152 -0
  66. package/src/core/i18n/uk.js +152 -0
  67. package/src/core/icons.js +235 -0
  68. package/src/core/ids.js +1 -0
  69. package/src/core/import-html.js +959 -0
  70. package/src/core/layout-style.js +115 -0
  71. package/src/core/parse.js +10 -0
  72. package/src/core/placeholder.js +15 -0
  73. package/src/core/sanitize.js +141 -0
  74. package/src/core/storage-limits.js +184 -0
  75. package/src/core/storage.js +85 -0
  76. package/src/core/theme.js +1 -0
  77. package/src/core/toolbar.js +67 -0
  78. package/src/core/variables.js +11 -0
  79. package/src/create-editor.js +109 -0
  80. package/src/index.js +9 -0
  81. package/src/mailcraft-editor.js +1862 -0
  82. package/src/render/block-body.js +295 -0
  83. package/src/render/canvas.js +284 -0
  84. package/src/render/fields.js +609 -0
  85. package/src/render/focus-preserve.js +158 -0
  86. package/src/render/rte.js +212 -0
  87. package/src/render/screenshot.js +132 -0
  88. package/src/render/story.js +415 -0
  89. package/src/render/style.js +452 -0
@@ -0,0 +1,1492 @@
1
+ import { uid } from './ids.js';
2
+ import { mk, mkRow, GROUPS, LAYOUTS, migrateDoc, normalizeDoc, blankDoc } from './blocks.js';
3
+ import { binder, decorate, group } from './binder.js';
4
+ import { seedAssets, KB, FOLDERS } from './assets.js';
5
+ import { ALL_FOLDER_ID, normalizeAsset, resolveLimits, providerProblems } from './storage.js';
6
+ import { validateFiles, limitsProblem } from './storage-limits.js';
7
+ import { migrateTokens, cleanHtml, escHtml } from './sanitize.js';
8
+ import { buildHtml as buildHtmlFn } from './export.js';
9
+ import { htmlToDoc } from './import-html.js';
10
+ import { boxCss } from './layout-style.js';
11
+ import { vars as varsFn, TOKEN, INSERT_KEYS } from './variables.js';
12
+ import { createTranslator } from './i18n/index.js';
13
+
14
+ const clone = (o) => JSON.parse(JSON.stringify(o));
15
+ const STORAGE_KEY = 'mailcraft.v3';
16
+ const TAB_KEY_PREFIX = STORAGE_KEY + '.tab.';
17
+ const TAB_ID_KEY = 'mailcraft.tab';
18
+ /** How long another tab's draft slot survives without a save before `sweepDrafts` reclaims it. */
19
+ const DRAFT_TTL = 7 * 24 * 60 * 60 * 1000;
20
+ /**
21
+ * How many uploads run at once. Not unbounded: a presign plus a PUT per file,
22
+ * fanned out across a twenty-file drop, is the kind of burst a gateway
23
+ * rate-limits. Not one at a time either -- that made a large drop as slow as
24
+ * the sum of its round trips.
25
+ */
26
+ const UPLOAD_CONCURRENCY = 3;
27
+ const BORDER_STYLES = [
28
+ { value: 'solid', label: 'Solid' },
29
+ { value: 'dashed', label: 'Dashed' },
30
+ { value: 'dotted', label: 'Dotted' },
31
+ { value: 'double', label: 'Double' },
32
+ ];
33
+
34
+ /** Character offset of (node, offset) counting only text within `root` -- mirrors `textOffset` in render/focus-preserve.js (kept local: core/ doesn't otherwise depend on render/). */
35
+ function charOffset(root, node, offset) {
36
+ if (node.nodeType !== Node.TEXT_NODE) {
37
+ let n = 0;
38
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
39
+ let cur; const target = node.childNodes[offset];
40
+ if (!target) { while (walker.nextNode()) n += walker.currentNode.nodeValue.length; return n; }
41
+ while ((cur = walker.nextNode())) { if (cur === target || target.contains(cur)) return n; n += cur.nodeValue.length; }
42
+ return n;
43
+ }
44
+ let n = 0;
45
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
46
+ let cur;
47
+ while ((cur = walker.nextNode())) {
48
+ if (cur === node) return n + offset;
49
+ n += cur.nodeValue.length;
50
+ }
51
+ return n;
52
+ }
53
+
54
+ /**
55
+ * Ported from the original prototype's `class Component extends DCLogic` --
56
+ * same state shape, same method names and logic, with `this.setState(patch, cb)`
57
+ * replaced by direct mutation + `emit()` (there is no React underneath this
58
+ * package). Rendering lives entirely in render/ -- this class only ever
59
+ * touches `this.state`, never the DOM.
60
+ */
61
+ /**
62
+ * HTML -> { rows, theme } with a never-drop-content fallback: anything the
63
+ * importer cannot classify into rows survives as a single raw-html row rather
64
+ * than vanishing. Shared by `importHtml` (Code modal / host API) and
65
+ * html-sourced templates so both paths degrade identically.
66
+ */
67
+ function parseHtmlSource(src) {
68
+ const parsed = htmlToDoc(src);
69
+ let rows = parsed.rows;
70
+ if (!rows.length) {
71
+ const row = mkRow([100], [mk('html')]);
72
+ row.props.py = 0; row.props.px = 0;
73
+ let body = src;
74
+ try { body = new DOMParser().parseFromString(String(src || ''), 'text/html').body.innerHTML || src; } catch { /* keep raw src */ }
75
+ row.cols[0].blocks[0].props.code = String(body || '').trim();
76
+ rows = [row];
77
+ }
78
+ return { rows, theme: parsed.theme || {} };
79
+ }
80
+
81
+ /** Full document from an HTML string: imported rows plus whatever theme keys the source declared, over blank-doc defaults for the rest. */
82
+ function docFromHtml(src) {
83
+ const doc = blankDoc();
84
+ const { rows, theme } = parseHtmlSource(src);
85
+ doc.rows = rows;
86
+ Object.assign(doc.theme, theme);
87
+ return doc;
88
+ }
89
+
90
+ /**
91
+ * AI draft goals and tones.
92
+ *
93
+ * These are prompt text, not UI chrome: `runAi` splices the selected strings
94
+ * straight into the sentence it sends the provider, so they stay English here
95
+ * and are deliberately NOT routed through `t()` -- a translated goal would
96
+ * change what the model is asked for, not just what the operator reads. The
97
+ * goal list tracks the email jobs the shipped example templates already cover
98
+ * (welcome, receipt, cart, restock, referral, review, newsletter, ...) so the
99
+ * dropdown offers work an operator recognises rather than five generic verbs.
100
+ *
101
+ * Goals carry a `group` for the `<optgroup>` label; the first entry is the
102
+ * default in `state.aiGoal`.
103
+ */
104
+ export const AI_GOALS = [
105
+ { group: 'Draft a new email', items: [
106
+ 'Full email draft',
107
+ 'Welcome or onboarding',
108
+ 'Product announcement',
109
+ 'Promotion or sale',
110
+ 'Event or webinar invite',
111
+ 'Newsletter intro',
112
+ 'Abandoned cart reminder',
113
+ 'Back in stock alert',
114
+ 'Re-engagement nudge',
115
+ 'Referral or reward invite',
116
+ 'Feedback or review request',
117
+ 'Thank you or post-purchase',
118
+ 'Transactional notice',
119
+ ] },
120
+ { group: 'Rework existing copy', items: [
121
+ 'Headline options',
122
+ 'Shorten existing copy',
123
+ 'Rewrite for clarity',
124
+ ] },
125
+ ];
126
+
127
+ /** Flat list -- tone has no natural grouping, and twelve entries still scan in one pass. */
128
+ export const AI_TONES = [
129
+ 'Confident, plain',
130
+ 'Warm and personal',
131
+ 'Friendly and casual',
132
+ 'Direct and minimal',
133
+ 'Playful',
134
+ 'Enthusiastic and bold',
135
+ 'Urgent and time-sensitive',
136
+ 'Premium and understated',
137
+ 'Reassuring and calm',
138
+ 'Apologetic and accountable',
139
+ 'Formal',
140
+ 'Technical and precise',
141
+ ];
142
+
143
+ /** Every goal string, flattened -- the order the `<select>` presents them in. */
144
+ export const AI_GOAL_VALUES = AI_GOALS.flatMap((g) => g.items);
145
+
146
+ export class EditorCore {
147
+ constructor({ variables, aiProvider, iconProvider, messages, storageProvider, storageLimits } = {}) {
148
+ this.variablesRaw = variables ?? null;
149
+ this.aiProvider = aiProvider ?? null;
150
+ this.iconProvider = iconProvider ?? null;
151
+ this.storageProvider = storageProvider ?? null;
152
+ this.storageLimits = storageLimits ?? null;
153
+ this.messages = messages ?? null;
154
+ this.t = createTranslator(this.messages);
155
+ this.listeners = new Set();
156
+ this.drag = null;
157
+ this.editEl = null;
158
+ this.savedRange = null;
159
+ this.rteActive = false;
160
+ this.fileEl = null;
161
+ // `mount()` restores the persisted draft, but `connectedCallback` runs it
162
+ // *after* the host may already have pushed a document in -- the
163
+ // `createElement()` -> `loadTemplate()` -> `append()` pattern the element's
164
+ // constructor comment advertises. Without these two flags that restore
165
+ // silently threw the host's template away and showed the previous draft
166
+ // instead, and re-connecting the element (a framework remount, or a move in
167
+ // the DOM) did the same to a document already on screen.
168
+ this.docSetByHost = false;
169
+ this.mounted = false;
170
+
171
+ this.state = {
172
+ doc: blankDoc(),
173
+ // Empty, not a sample name: this ends up in the exported <title> and the
174
+ // download filename, so a placeholder here ships in a host's real email.
175
+ sel: null, hover: null, tab: 'design', chrome: 'light', device: 'desktop', mode: 'rows', zoom: 1, advancedOpen: false,
176
+ assets: seedAssets(), assetFolder: 'All files', assetQuery: '', libraryOpen: false, assetTarget: null,
177
+ folders: null, assetCursor: null, assetsLoading: false, assetsError: null, assetsLoaded: false, uploading: 0,
178
+ exportOpen: false, exportCode: '', copied: false, aiOpen: false, aiGoal: AI_GOAL_VALUES[0], aiTone: AI_TONES[0],
179
+ aiBrief: '', aiBusy: false, aiResults: [], previewOpen: false, toast: null, drop: null, rowDrop: null,
180
+ editing: null, linkDraft: null, codeOpen: false, codeSrc: '', codeLive: '', codeDirty: false, codeDevice: 'desktop',
181
+ history: [], future: [], now: Date.now(), savedStatus: 'idle', savedAt: null, libHot: false,
182
+ };
183
+ }
184
+
185
+ subscribe(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); }
186
+
187
+ /**
188
+ * `window.getSelection()`/`document.getSelection()` reports `anchorNode`
189
+ * as `<body>` (not the real, deep node) once the actual selection lives
190
+ * inside an open shadow root -- which it always does here, since the
191
+ * editor is a shadow-DOM web component. `ShadowRoot.getSelection()`
192
+ * (Chromium-only; no standard equivalent yet) reports the real node/offset
193
+ * and is what every selection-reading method below needs, or they silently
194
+ * read garbage instead of erroring. `this.exportRoot` is the element's
195
+ * shadow root (set by `mount()`); the `window.getSelection()` fallback
196
+ * only matters for a browser without `ShadowRoot.getSelection`.
197
+ */
198
+ getSelection() {
199
+ return this.exportRoot && typeof this.exportRoot.getSelection === 'function' ? this.exportRoot.getSelection() : window.getSelection();
200
+ }
201
+
202
+ /**
203
+ * Batched like React's `setState`, via a microtask instead of a reconciler:
204
+ * `render()` tears down and rebuilds the whole canvas (no diffing), which
205
+ * removes whatever DOM node currently has focus. A *synchronous* render
206
+ * mid-gesture -- e.g. from `onBlur`'s own `setState` while the browser is
207
+ * still in the middle of moving focus from one block to another on a
208
+ * single click -- yanks out the node the browser is about to focus next,
209
+ * so that native focus-transition silently fails: the click's `blur`
210
+ * fires, but its `focus` on the new block never does. Deferring the
211
+ * render to a microtask lets a whole synchronous gesture (blur -> focus,
212
+ * or several `setState` calls in a row) finish first -- exactly what
213
+ * React's batching gives you for free -- and coalesces it into one render
214
+ * reflecting the final state, instead of one destructive render per call.
215
+ */
216
+ emit() {
217
+ if (this._emitScheduled) return;
218
+ this._emitScheduled = true;
219
+ const schedule = typeof requestAnimationFrame === 'function'
220
+ ? requestAnimationFrame
221
+ : (fn) => setTimeout(fn, 16);
222
+ this._emitHandle = schedule(() => {
223
+ this._emitScheduled = false;
224
+ this._emitHandle = null;
225
+ for (const fn of this.listeners) fn();
226
+ });
227
+ }
228
+
229
+ /** `this.setState(patch, cb)` from the original, minus React. */
230
+ setState(patch, cb) {
231
+ Object.assign(this.state, typeof patch === 'function' ? patch(this.state) : patch);
232
+ this.emit();
233
+ if (cb) cb();
234
+ }
235
+
236
+ // ---- lifecycle ---------------------------------------------------------
237
+
238
+ /** `componentDidMount`. `exportRoot` is the DOM root `buildHtml()`'s DOM-read-back (`grab`) searches -- the element's shadow root. */
239
+ mount(exportRoot) {
240
+ this.exportRoot = exportRoot;
241
+ // Per-tab persistence: every tab autosaving to one shared key made
242
+ // concurrent tabs clobber (and, briefly, mirror) each other -- loading a
243
+ // template in one tab replaced the document in all of them. Each tab
244
+ // instead owns a private draft slot named by an id kept in sessionStorage
245
+ // (survives a reload of *this* tab, never shared with other tabs), so
246
+ // every tab is an independent document. The shared STORAGE_KEY lives on
247
+ // as the "most recent work" seed: still written by every persist, and a
248
+ // brand-new tab (no draft of its own yet) starts from it -- the same
249
+ // reopen-where-I-left-off behavior as before.
250
+ try {
251
+ let tabId = sessionStorage.getItem(TAB_ID_KEY);
252
+ if (!tabId) { tabId = uid(); sessionStorage.setItem(TAB_ID_KEY, tabId); }
253
+ this.tabKey = TAB_KEY_PREFIX + tabId;
254
+ } catch { this.tabKey = null; }
255
+ const firstMount = !this.mounted;
256
+ this.mounted = true;
257
+ // First mount only, and never over a document the host already supplied.
258
+ try {
259
+ const raw = firstMount && !this.docSetByHost
260
+ && ((this.tabKey && localStorage.getItem(this.tabKey)) || localStorage.getItem(STORAGE_KEY));
261
+ if (raw) {
262
+ const s = JSON.parse(migrateTokens(raw));
263
+ // A draft blob is as untrusted as any other input -- it may have been
264
+ // written by an older build, or edited by hand in devtools.
265
+ if (s && s.doc) s.doc = normalizeDoc(s.doc);
266
+ // `device` was never actually written by `persist()` below, but a
267
+ // stale/foreign blob could still carry the retired 'dark' inbox-preview
268
+ // value -- normalize anything that isn't 'mobile' to 'desktop' rather
269
+ // than trust it verbatim.
270
+ if (s && s.doc) this.setState({ doc: s.doc, assets: s.assets || this.state.assets, chrome: s.chrome || 'light', device: s.device === 'mobile' ? 'mobile' : 'desktop' });
271
+ }
272
+ } catch { /* ignore */ }
273
+ this.sweepDrafts();
274
+ this.tick = setInterval(() => {
275
+ if (!this.hasCountdown()) return;
276
+ // Surgical: with a countdown block in the document this ticked a full
277
+ // re-render every second -- open dropdowns died within a second and the
278
+ // editor never idled. The element repaints just the countdown digits.
279
+ this.state.now = Date.now();
280
+ if (this.onTick) this.onTick(); else this.setState({ now: this.state.now });
281
+ }, 1000);
282
+ }
283
+
284
+ /**
285
+ * Reclaims other tabs' draft slots that haven't been saved to in DRAFT_TTL.
286
+ * Without this, every browser tab ever opened would leave a full document
287
+ * blob in localStorage forever and eventually hit the origin quota. This
288
+ * tab's own slot and the shared STORAGE_KEY seed are never touched, and an
289
+ * unreadable blob counts as stale.
290
+ */
291
+ sweepDrafts() {
292
+ try {
293
+ for (let i = localStorage.length - 1; i >= 0; i--) {
294
+ const k = localStorage.key(i);
295
+ if (!k || !k.startsWith(TAB_KEY_PREFIX) || k === this.tabKey) continue;
296
+ let stale = true;
297
+ try {
298
+ const b = JSON.parse(localStorage.getItem(k));
299
+ stale = !b || !b.t || Date.now() - b.t > DRAFT_TTL;
300
+ } catch { /* unreadable -> stale */ }
301
+ if (stale) localStorage.removeItem(k);
302
+ }
303
+ } catch { /* ignore */ }
304
+ }
305
+
306
+ /** Wires the window-level keydown shortcuts and the selectionchange listener the RTE toolbar needs to preserve the selection across a toolbar click. `isTyping(target)` and `shadowActiveElement()` are supplied by the element since only it knows its shadow root. */
307
+ mountKeyboard(isTyping) {
308
+ this.onKey = (e) => {
309
+ // Shadow DOM retargets events for listeners outside the root: on this
310
+ // window-level listener, `e.target` is always the <mailcraft-editor>
311
+ // host, never the inner input -- which made `isTyping` always false, so
312
+ // Backspace while editing an inspector field deleted the selected
313
+ // block. `composedPath()[0]` is the true, pre-retargeting target.
314
+ const target = e.composedPath ? e.composedPath()[0] : e.target;
315
+ const typing = isTyping(target);
316
+ if (e.key === 'Escape') {
317
+ // Escape inside a field means "leave the field", not "drop the
318
+ // selection and close every modal" -- blur it; a second Escape then
319
+ // does the usual deselect/close.
320
+ if (typing && target && typeof target.blur === 'function') { target.blur(); return; }
321
+ this.setState({ sel: null, libraryOpen: false, exportOpen: false, aiOpen: false, codeOpen: false, previewOpen: false });
322
+ return;
323
+ }
324
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z') {
325
+ // Inside a real form field the browser's own field undo is what the
326
+ // user means; hijacking it for document undo while mid-edit was part
327
+ // of the "inputs behave weird" report. Contenteditable blocks keep
328
+ // document undo -- their edits only commit on blur.
329
+ if (typing && !(target && target.isContentEditable)) return;
330
+ e.preventDefault(); e.shiftKey ? this.redo() : this.undo(); return;
331
+ }
332
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'e') { e.preventDefault(); this.openExport(); return; }
333
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k' && this.state.editing) { e.preventDefault(); this.openLink(); return; }
334
+ if (typing) return;
335
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'd') { e.preventDefault(); this.dupSel(); return; }
336
+ if ((e.key === 'Backspace' || e.key === 'Delete') && this.state.sel) { e.preventDefault(); this.delSel(); }
337
+ };
338
+ window.addEventListener('keydown', this.onKey);
339
+ /**
340
+ * Restoring the caret after a render (focus-preserve.js) sets a Range,
341
+ * which fires `selectionchange` exactly like a real user selection would
342
+ * -- so without checking whether the caret *actually* moved, this handler
343
+ * would request another toolbar refresh in reaction to its own restoration,
344
+ * restore (and fire `selectionchange`) again, forever. Comparing the
345
+ * resolved character offsets (stable across a rebuild, unlike DOM node
346
+ * identity) instead of reacting to every notification is what makes this
347
+ * immune to that -- and to needing to know or guess the exact timing of
348
+ * how many `selectionchange` events a browser coalesces per mutation.
349
+ */
350
+ this._lastSelBlock = null; this._lastSelStart = null; this._lastSelEnd = null;
351
+ this.onSelect = () => {
352
+ if (!this.state.editing) return;
353
+ const sel = this.getSelection();
354
+ if (!(sel && sel.rangeCount && this.editEl && this.editEl.contains(sel.anchorNode))) return;
355
+ const range = sel.getRangeAt(0);
356
+ const start = charOffset(this.editEl, range.startContainer, range.startOffset);
357
+ const end = charOffset(this.editEl, range.endContainer, range.endOffset);
358
+ if (this._lastSelBlock === this.state.editing && this._lastSelStart === start && this._lastSelEnd === end) return;
359
+ this._lastSelBlock = this.state.editing; this._lastSelStart = start; this._lastSelEnd = end;
360
+ this.savedRange = range.cloneRange();
361
+ // Formatting-state changes only affect the small floating RTE toolbar.
362
+ // Rebuilding the entire editor here made every caret movement tear down
363
+ // the canvas, inspector and modal DOM, which was the largest source of
364
+ // typing/selection jitter. The element supplies a surgical toolbar
365
+ // refresh callback instead.
366
+ if (this.onFormatChange) this.onFormatChange();
367
+ };
368
+ document.addEventListener('selectionchange', this.onSelect);
369
+ }
370
+
371
+ unmountKeyboard() {
372
+ window.removeEventListener('keydown', this.onKey);
373
+ document.removeEventListener('selectionchange', this.onSelect);
374
+ }
375
+
376
+ unmount() {
377
+ clearTimeout(this._searchTimer);
378
+ clearTimeout(this._limitsWarnTimer);
379
+ this.abortAssets('list');
380
+ this.abortAssets('upload');
381
+ clearInterval(this.tick);
382
+ clearTimeout(this.tt);
383
+ clearTimeout(this.codeTimer);
384
+ clearTimeout(this._persistTimer);
385
+ if (this._emitHandle != null) {
386
+ if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(this._emitHandle);
387
+ else clearTimeout(this._emitHandle);
388
+ }
389
+ this._emitHandle = null;
390
+ this._emitScheduled = false;
391
+ this.unmountKeyboard();
392
+ }
393
+
394
+ // ---- rich text editing ----------------------------------------------
395
+
396
+ exec(cmd, arg) {
397
+ this.rteActive = true;
398
+ try {
399
+ if (this.editEl) {
400
+ // `preventScroll`: `editEl` is already the block being actively
401
+ // edited (on-screen, wherever the user has scrolled to) -- this just
402
+ // restores focus after a toolbar-button click stole it, not a fresh
403
+ // navigation, so it shouldn't yank a long template's scroll position.
404
+ this.editEl.focus({ preventScroll: true });
405
+ const sel = this.getSelection();
406
+ // Only fall back to the cached `savedRange` when the live selection
407
+ // isn't currently inside the block being edited (e.g. it was lost
408
+ // while a dropdown or the link popover had focus) -- toolbar buttons
409
+ // preventDefault their mousedown specifically so the real selection
410
+ // survives the click, and unconditionally overwriting it here with
411
+ // `savedRange` fought that: a render tearing down and rebuilding the
412
+ // edited block's DOM (no diffing -- see `dropLine`, render/canvas.js)
413
+ // collapses the live selection as a side effect and can still leave
414
+ // `savedRange` reflecting that instead of the real, current one.
415
+ const liveSelectionValid = sel && sel.rangeCount && this.editEl.contains(sel.anchorNode) && this.editEl.contains(sel.focusNode);
416
+ if (!liveSelectionValid && this.savedRange && sel) { sel.removeAllRanges(); sel.addRange(this.savedRange); }
417
+ }
418
+ document.execCommand(cmd, false, arg);
419
+ const sel2 = this.getSelection();
420
+ if (sel2 && sel2.rangeCount && this.editEl && this.editEl.contains(sel2.anchorNode)) this.savedRange = sel2.getRangeAt(0).cloneRange();
421
+ } catch { /* ignore */ }
422
+ setTimeout(() => { this.rteActive = false; }, 0);
423
+ }
424
+
425
+ pasteClean(plainOnly) {
426
+ return (e) => {
427
+ const dt = e.clipboardData; if (!dt) return;
428
+ e.preventDefault();
429
+ const html = dt.getData('text/html');
430
+ const text = dt.getData('text/plain') || '';
431
+ const out = (!plainOnly && html) ? cleanHtml(html) : escHtml(text).replace(/\r?\n/g, '<br />');
432
+ this.exec('insertHTML', out);
433
+ };
434
+ }
435
+
436
+ syncEdit(b) {
437
+ const elNode = this.editEl; if (!elNode) return;
438
+ const key = b.type === 'heading' ? 'text' : (b.type === 'html' ? 'code' : 'html');
439
+ const val = b.type === 'heading' ? elNode.textContent : elNode.innerHTML;
440
+ if (b.props[key] !== val) this.setProp(b.id, key, val);
441
+ }
442
+
443
+ anchorAt() {
444
+ const sel = this.getSelection();
445
+ const node = sel && sel.anchorNode ? (sel.anchorNode.nodeType === 1 ? sel.anchorNode : sel.anchorNode.parentElement) : null;
446
+ return node && node.closest ? node.closest('a') : null;
447
+ }
448
+
449
+ currentTag() {
450
+ const sel = this.getSelection();
451
+ const node = sel && sel.anchorNode ? (sel.anchorNode.nodeType === 1 ? sel.anchorNode : sel.anchorNode.parentElement) : null;
452
+ const elNode = node && node.closest ? node.closest('h1,h2,h3,h4,h5,h6,blockquote,p') : null;
453
+ return elNode ? elNode.tagName.toLowerCase() : '';
454
+ }
455
+
456
+ /**
457
+ * Everything the RTE toolbar displays about the current selection, as one
458
+ * comparable string. `selectionchange` fires for every pixel of a drag
459
+ * selection; rebuilding the ~25-control toolbar each time made selecting
460
+ * text visibly stutter. Comparing this fingerprint lets the refresh skip
461
+ * every tick where nothing the toolbar shows has actually changed --
462
+ * dragging through uniformly-formatted text rebuilds nothing at all.
463
+ */
464
+ formatFingerprint(b) {
465
+ let s = b.id + '|' + this.currentTag() + '|' + (b.props.size || 16) + '|' + (this.state.linkDraft ? 1 : 0) + '|';
466
+ for (const cmd of ['bold', 'italic', 'underline', 'strikeThrough', 'superscript', 'subscript', 'justifyLeft', 'justifyCenter', 'justifyRight', 'justifyFull', 'insertUnorderedList', 'insertOrderedList']) {
467
+ let on = false;
468
+ try { on = document.queryCommandState(cmd); } catch { /* ignore */ }
469
+ s += on ? '1' : '0';
470
+ }
471
+ return s;
472
+ }
473
+
474
+ openLink = () => {
475
+ const a = this.anchorAt();
476
+ this.rteActive = true;
477
+ this.setState({ linkDraft: { href: a ? (a.getAttribute('href') || '') : 'https://', blank: a ? a.target === '_blank' : true, editing: !!a } });
478
+ };
479
+
480
+ applyLink = (b) => {
481
+ const d = this.state.linkDraft; if (!d) return;
482
+ this.exec('createLink', d.href);
483
+ const elNode = this.editEl;
484
+ if (elNode) {
485
+ elNode.querySelectorAll('a').forEach((a) => {
486
+ if (a.getAttribute('href') !== d.href) return;
487
+ if (d.blank) { a.setAttribute('target', '_blank'); a.setAttribute('rel', 'noopener'); }
488
+ else { a.removeAttribute('target'); a.removeAttribute('rel'); }
489
+ });
490
+ }
491
+ this.syncEdit(b);
492
+ this.setState({ linkDraft: null });
493
+ };
494
+
495
+ removeLink = (b) => {
496
+ this.exec('unlink');
497
+ this.syncEdit(b);
498
+ this.setState({ linkDraft: null });
499
+ };
500
+
501
+ size(b, delta) {
502
+ // fold any uncommitted inline formatting into state first, or the re-render
503
+ // from the size change would rewrite innerHTML from stale props
504
+ const elNode = this.editEl;
505
+ if (elNode) {
506
+ const key = b.type === 'heading' ? 'text' : (b.type === 'html' ? 'code' : 'html');
507
+ const val = b.type === 'heading' ? elNode.textContent : elNode.innerHTML;
508
+ if (b.props[key] !== val) this.setProp(b.id, key, val);
509
+ }
510
+ const cur = Number(b.props.size) || 16;
511
+ this.setProp(b.id, 'size', Math.max(10, Math.min(64, cur + delta)));
512
+ }
513
+
514
+ hasCountdown() { return this.state.doc.rows.some((r) => r.cols.some((c) => c.blocks.some((b) => b.type === 'countdown'))); }
515
+
516
+ persist(doc, assets, chrome) {
517
+ try {
518
+ const blob = JSON.stringify({
519
+ doc: doc || this.state.doc,
520
+ // With a provider the library belongs to the backend, not to this
521
+ // draft: the files are already durable there, and writing them here
522
+ // only risks reviving tiles for files since deleted. Without one it
523
+ // is base64 in localStorage, which is what fills the origin quota.
524
+ assets: this.storageProvider ? [] : (assets || this.state.assets), chrome: chrome || this.state.chrome,
525
+ // `t` is what sweepDrafts ages slots by; mount's reader ignores it.
526
+ t: Date.now(),
527
+ });
528
+ // The tab's own slot is this tab's document; the shared key is only the
529
+ // seed a future fresh tab starts from (most recent work wins there).
530
+ if (this.tabKey) localStorage.setItem(this.tabKey, blob);
531
+ localStorage.setItem(STORAGE_KEY, blob);
532
+ // Direct mutation + surgical label refresh, NOT setState: autosave runs
533
+ // 400ms after every commit, so a setState here re-rendered the entire
534
+ // editor just to change the header's "Saved" label -- which, half a
535
+ // second after any edit, silently destroyed whatever the user had since
536
+ // opened (a native <select>'s option list, most visibly) and doubled
537
+ // the render cost of every single interaction.
538
+ this.state.savedStatus = 'saved';
539
+ this.state.savedAt = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
540
+ if (this.onSavedChange) this.onSavedChange();
541
+ } catch {
542
+ this.state.savedStatus = 'error';
543
+ if (this.onSavedChange) this.onSavedChange();
544
+ }
545
+ }
546
+
547
+ commit(fn) {
548
+ const prev = JSON.stringify(this.state.doc);
549
+ const doc = JSON.parse(prev);
550
+ fn(doc);
551
+ this.setState({ doc, history: this.state.history.slice(-40).concat(prev), future: [] });
552
+ // A drag on any continuous control (a range slider especially) fires
553
+ // `commit` dozens of times a second. `persist` does a full JSON.stringify
554
+ // of the doc plus a *synchronous* localStorage.setItem -- both real costs
555
+ // -- and was running on every single one of those commits, right in the
556
+ // middle of the drag. That's enough main-thread work per tick to visibly
557
+ // stutter the slider. Debouncing to fire once after things settle keeps
558
+ // autosave working (nothing is lost -- the last doc always wins) without
559
+ // paying that cost on every intermediate value.
560
+ clearTimeout(this._persistTimer);
561
+ this._persistTimer = setTimeout(() => this.persist(doc), 400);
562
+ }
563
+
564
+ undo() {
565
+ const hist = this.state.history.slice(); const prev = hist.pop(); if (!prev) return;
566
+ const doc = JSON.parse(prev);
567
+ this.setState({ doc, history: hist, future: this.state.future.concat(JSON.stringify(this.state.doc)), sel: null }, () => this.persist(doc));
568
+ }
569
+
570
+ redo() {
571
+ const fut = this.state.future.slice(); const next = fut.pop(); if (!next) return;
572
+ const doc = JSON.parse(next);
573
+ this.setState({ doc, future: fut, history: this.state.history.concat(JSON.stringify(this.state.doc)), sel: null }, () => this.persist(doc));
574
+ }
575
+
576
+ flash(msg) {
577
+ // Direct mutation + surgical refresh (same reason as persist above): the
578
+ // toast's auto-dismissal 1.9s later was a spontaneous full re-render --
579
+ // it destroyed any dropdown the user had opened since, to hide a label.
580
+ this.state.toast = msg;
581
+ if (this.onToast) this.onToast(); else this.setState({ toast: msg });
582
+ clearTimeout(this.tt);
583
+ this.tt = setTimeout(() => {
584
+ this.state.toast = null;
585
+ if (this.onToast) this.onToast(); else this.setState({ toast: null });
586
+ }, 1900);
587
+ }
588
+ select(type, id) { this.setState({ sel: { type, id }, tab: 'design' }); }
589
+
590
+ find(doc, id) {
591
+ for (const r of doc.rows) {
592
+ if (r.id === id) return { row: r };
593
+ for (let ci = 0; ci < r.cols.length; ci++) {
594
+ const bi = r.cols[ci].blocks.findIndex((b) => b.id === id);
595
+ if (bi > -1) return { row: r, col: r.cols[ci], ci, bi, block: r.cols[ci].blocks[bi] };
596
+ }
597
+ }
598
+ return {};
599
+ }
600
+
601
+ selObj() { return this.state.sel ? this.find(this.state.doc, this.state.sel.id) : {}; }
602
+
603
+ setProp(id, key, val) {
604
+ this.commit((doc) => {
605
+ const f = this.find(doc, id);
606
+ const target = f.block ? f.block.props : (f.row ? f.row.props : null);
607
+ if (target) target[key] = val;
608
+ });
609
+ }
610
+
611
+ setTheme(key, val) { this.commit((doc) => { doc.theme[key] = val; }); }
612
+
613
+ /** Column-level styling (`bg`, `radius`, `padY`, `padX` on a col object -- all optional, absent means unstyled). What lets one section hold differently-colored card columns, which row-level props can't express. */
614
+ setColProp(rowId, ci, key, val) {
615
+ this.commit((doc) => {
616
+ const row = this.find(doc, rowId).row;
617
+ if (row && row.cols[ci]) row.cols[ci][key] = val;
618
+ });
619
+ }
620
+
621
+ /**
622
+ * Sections store padding as a linked pair (`py` top+bottom, `px`
623
+ * left+right). Splitting copies the pair onto the four side props so
624
+ * nothing moves visually at the moment of the toggle; re-linking folds the
625
+ * sides back to their averages and deletes the overrides -- rendering
626
+ * falls back to the pair wherever a side prop is absent (layout-style.js
627
+ * `rowPad`), which is also what keeps pre-split documents rendering
628
+ * unchanged.
629
+ */
630
+ togglePadSplit(id) {
631
+ this.commit((doc) => {
632
+ const f = this.find(doc, id);
633
+ const row = f.row; if (!row) return;
634
+ const p = row.props;
635
+ const on = !p.padSplit;
636
+ if (on) {
637
+ if (p.pt === undefined) { p.pt = p.py; p.pb = p.py; p.pl = p.px; p.pr = p.px; }
638
+ } else {
639
+ p.py = Math.round(((p.pt ?? p.py) + (p.pb ?? p.py)) / 2);
640
+ p.px = Math.round(((p.pl ?? p.px) + (p.pr ?? p.px)) / 2);
641
+ delete p.pt; delete p.pb; delete p.pl; delete p.pr;
642
+ }
643
+ p.padSplit = on;
644
+ });
645
+ }
646
+
647
+ // ---- tree mutations -----------------------------------------------------
648
+
649
+ insertBlock(type, rowId, ci, index) {
650
+ const block = mk(type);
651
+ this.commit((doc) => {
652
+ if (this.state.mode === 'stack' || !rowId) {
653
+ doc.rows.splice(typeof index === 'number' ? index : doc.rows.length, 0, mkRow([100], [block]));
654
+ } else {
655
+ const f = this.find(doc, rowId);
656
+ const col = f.row.cols[ci] || f.row.cols[0];
657
+ col.blocks.splice(typeof index === 'number' ? index : col.blocks.length, 0, block);
658
+ }
659
+ });
660
+ this.setState({ sel: { type: 'block', id: block.id }, tab: 'design', drop: null, rowDrop: null });
661
+ }
662
+
663
+ insertGroup(id, rowId, ci, index) {
664
+ const gr = GROUPS[id]; if (!gr) return;
665
+ const rows = gr.build();
666
+ if (rowId) {
667
+ const blocks = rows.reduce((acc, r) => acc.concat(r.cols.reduce((a, c) => a.concat(c.blocks), [])), []);
668
+ this.commit((doc) => {
669
+ const f = this.find(doc, rowId);
670
+ const col = f.row.cols[ci] || f.row.cols[0];
671
+ col.blocks.splice(typeof index === 'number' ? index : col.blocks.length, 0, ...blocks);
672
+ });
673
+ this.setState({ sel: blocks[0] ? { type: 'block', id: blocks[0].id } : null, tab: 'design', drop: null, rowDrop: null });
674
+ return;
675
+ }
676
+ this.commit((doc) => { doc.rows.splice(typeof index === 'number' ? index : doc.rows.length, 0, ...rows); });
677
+ this.setState({ sel: { type: 'row', id: rows[0].id }, tab: 'design', drop: null, rowDrop: null });
678
+ }
679
+
680
+ insertRow(spans, index, withHtml) {
681
+ const row = withHtml ? mkRow([100], [mk('html')]) : mkRow(spans);
682
+ if (withHtml) { row.props.py = 0; row.props.px = 0; }
683
+ this.commit((doc) => { doc.rows.splice(typeof index === 'number' ? index : doc.rows.length, 0, row); });
684
+ this.setState({ sel: { type: withHtml ? 'block' : 'row', id: withHtml ? row.cols[0].blocks[0].id : row.id }, tab: 'design', drop: null, rowDrop: null });
685
+ }
686
+
687
+ moveBlock(blockId, rowId, ci, index) {
688
+ this.commit((doc) => {
689
+ const from = this.find(doc, blockId); if (!from.block) return;
690
+ const [b] = from.col.blocks.splice(from.bi, 1);
691
+ const to = this.find(doc, rowId);
692
+ if (!to.row) { doc.rows.push(mkRow([100], [b])); return; }
693
+ const col = to.row.cols[ci] || to.row.cols[0];
694
+ let at = typeof index === 'number' ? index : col.blocks.length;
695
+ if (col === from.col && from.bi < at) at--;
696
+ col.blocks.splice(at, 0, b);
697
+ });
698
+ this.setState({ drop: null, rowDrop: null });
699
+ }
700
+
701
+ moveRow(rowId, index) {
702
+ this.commit((doc) => {
703
+ const i = doc.rows.findIndex((r) => r.id === rowId); if (i < 0) return;
704
+ const [r] = doc.rows.splice(i, 1);
705
+ let at = index; if (i < at) at--;
706
+ doc.rows.splice(Math.max(0, Math.min(at, doc.rows.length)), 0, r);
707
+ });
708
+ this.setState({ drop: null, rowDrop: null });
709
+ }
710
+
711
+ moveBlockToNewRow(blockId, index) {
712
+ this.commit((doc) => {
713
+ const f = this.find(doc, blockId); if (!f.block) return;
714
+ const [b] = f.col.blocks.splice(f.bi, 1);
715
+ doc.rows.splice(index, 0, mkRow([100], [b]));
716
+ });
717
+ this.setState({ drop: null, rowDrop: null });
718
+ }
719
+
720
+ dupSel() {
721
+ const sel = this.state.sel; if (!sel) return;
722
+ this.commit((doc) => {
723
+ const f = this.find(doc, sel.id);
724
+ if (f.block) { const c = clone(f.block); c.id = uid(); f.col.blocks.splice(f.bi + 1, 0, c); }
725
+ else if (f.row) {
726
+ const c = clone(f.row); c.id = uid();
727
+ c.cols.forEach((col) => { col.id = uid(); col.blocks.forEach((b) => { b.id = uid(); }); });
728
+ doc.rows.splice(doc.rows.indexOf(f.row) + 1, 0, c);
729
+ }
730
+ });
731
+ this.flash(this.t('toast.duplicated'));
732
+ }
733
+
734
+ delSel() {
735
+ const sel = this.state.sel; if (!sel) return;
736
+ this.commit((doc) => {
737
+ const f = this.find(doc, sel.id);
738
+ if (f.block) f.col.blocks.splice(f.bi, 1);
739
+ else if (f.row) doc.rows.splice(doc.rows.indexOf(f.row), 1);
740
+ });
741
+ this.setState({ sel: null });
742
+ }
743
+
744
+ nudge(id, dir) {
745
+ this.commit((doc) => {
746
+ const f = this.find(doc, id);
747
+ if (f.block) {
748
+ const at = f.bi + dir; if (at < 0 || at >= f.col.blocks.length) return;
749
+ const [b] = f.col.blocks.splice(f.bi, 1); f.col.blocks.splice(at, 0, b);
750
+ } else if (f.row) {
751
+ const i = doc.rows.indexOf(f.row); const at = i + dir;
752
+ if (at < 0 || at >= doc.rows.length) return;
753
+ const [r] = doc.rows.splice(i, 1); doc.rows.splice(at, 0, r);
754
+ }
755
+ });
756
+ }
757
+
758
+ // ---- drag & drop --------------------------------------------------------
759
+
760
+ startDrag(payload) {
761
+ return (e) => {
762
+ this.drag = payload;
763
+ try { e.dataTransfer.setData('text/plain', JSON.stringify(payload)); e.dataTransfer.effectAllowed = 'copyMove'; } catch { /* ignore */ }
764
+ };
765
+ }
766
+
767
+ indexFromPoint(el, y) {
768
+ if (!el || !el.children) return 0;
769
+ const kids = Array.from(el.children).filter((c) => c.getAttribute && c.getAttribute('data-mc-slot') === '1');
770
+ let idx = kids.length;
771
+ for (let i = 0; i < kids.length; i++) {
772
+ const r = kids[i].getBoundingClientRect();
773
+ if (r.height === 0) continue;
774
+ if (y < r.top + r.height / 2) { idx = i; break; }
775
+ }
776
+ return idx;
777
+ }
778
+
779
+ colDragOver(rowId, ci) {
780
+ return (e) => {
781
+ const d = this.drag; if (!d) return;
782
+ e.preventDefault(); e.stopPropagation();
783
+ if (d.kind === 'row' || d.kind === 'move-row') return;
784
+ const index = this.indexFromPoint(e.currentTarget, e.clientY);
785
+ const cur = this.state.drop;
786
+ if (!cur || cur.rowId !== rowId || cur.ci !== ci || cur.index !== index) this.setState({ drop: { rowId, ci, index }, rowDrop: null });
787
+ };
788
+ }
789
+
790
+ colDrop(rowId, ci) {
791
+ return (e) => {
792
+ const d = this.drag; if (!d) return;
793
+ e.preventDefault(); e.stopPropagation();
794
+ const index = this.indexFromPoint(e.currentTarget, e.clientY);
795
+ if (d.kind === 'block') this.insertBlock(d.type, rowId, ci, index);
796
+ else if (d.kind === 'move-block') this.moveBlock(d.id, rowId, ci, index);
797
+ else if (d.kind === 'group') this.insertGroup(d.id, rowId, ci, index);
798
+ else if (d.kind === 'asset') this.dropAsset(d.assetId, rowId, ci, index);
799
+ this.drag = null;
800
+ };
801
+ }
802
+
803
+ canvasDragOver = (e) => {
804
+ const d = this.drag; if (!d) return;
805
+ e.preventDefault();
806
+ const index = this.indexFromPoint(e.currentTarget, e.clientY);
807
+ if (this.state.rowDrop !== index) this.setState({ rowDrop: index, drop: null });
808
+ };
809
+
810
+ canvasDrop = (e) => {
811
+ const d = this.drag; if (!d) return;
812
+ e.preventDefault();
813
+ const index = this.indexFromPoint(e.currentTarget, e.clientY);
814
+ if (d.kind === 'row') this.insertRow(d.spans, index, d.html);
815
+ else if (d.kind === 'move-row') this.moveRow(d.id, index);
816
+ else if (d.kind === 'block') this.insertBlock(d.type, null, 0, index);
817
+ else if (d.kind === 'move-block') this.moveBlockToNewRow(d.id, index);
818
+ else if (d.kind === 'group') this.insertGroup(d.id, null, 0, index);
819
+ else if (d.kind === 'asset') this.dropAsset(d.assetId, null, 0, index);
820
+ this.drag = null;
821
+ };
822
+
823
+ dropAsset(assetId, rowId, ci, index) {
824
+ const a = this.state.assets.find((x) => x.id === assetId); if (!a) return;
825
+ const block = mk('image');
826
+ block.props.src = a.url; block.props.alt = a.name.replace(/\.[a-z]+$/i, '').replace(/[-_]/g, ' ');
827
+ this.commit((doc) => {
828
+ if (!rowId || this.state.mode === 'stack') doc.rows.splice(typeof index === 'number' ? index : doc.rows.length, 0, mkRow([100], [block]));
829
+ else {
830
+ const f = this.find(doc, rowId); const col = f.row.cols[ci] || f.row.cols[0];
831
+ col.blocks.splice(typeof index === 'number' ? index : col.blocks.length, 0, block);
832
+ }
833
+ });
834
+ this.setState({ sel: { type: 'block', id: block.id }, tab: 'design', drop: null, rowDrop: null });
835
+ }
836
+
837
+ // ---- assets --------------------------------------------------------------
838
+
839
+ /**
840
+ * Host-supplied storage. Assigning one takes the library over completely:
841
+ * the seeded demo files go away, folders and pages come from the backend,
842
+ * and every upload and delete round-trips through the provider. Assigning
843
+ * `null` restores the built-in behaviour.
844
+ */
845
+ setStorageProvider(provider) {
846
+ const problems = provider ? providerProblems(provider) : [];
847
+ if (problems.length) { console.warn('[mailcraft] ' + problems.join('; ')); return; }
848
+ this.storageProvider = provider || null;
849
+ this.abortAssets('list');
850
+ this.abortAssets('upload');
851
+ if (!provider) {
852
+ this.setState({ assets: seedAssets(), folders: null, assetFolder: FOLDERS[0], assetCursor: null, assetsError: null });
853
+ return;
854
+ }
855
+ this.setState({ assets: [], folders: null, assetFolder: ALL_FOLDER_ID, assetCursor: null, assetsError: null, assetsLoaded: false });
856
+ // Surface a missing upload policy while the integrator is looking at the
857
+ // console, not later when a user drops a file and gets a refusal. Deferred
858
+ // a turn because `storageProvider` and `storageLimits` are normally set on
859
+ // consecutive lines, in either order.
860
+ clearTimeout(this._limitsWarnTimer);
861
+ this._limitsWarnTimer = setTimeout(() => {
862
+ if (this.storageProvider && limitsProblem(this.limits())) {
863
+ console.warn('[mailcraft] storageProvider is set but storageLimits is not -- every upload will be refused. See core/storage-limits.js.');
864
+ }
865
+ }, 0);
866
+ this.refreshAssets();
867
+ }
868
+
869
+ /**
870
+ * Applies a host-supplied template as an undoable edit. Template galleries
871
+ * are host UI, not editor UI -- there is no tab and no catalogue in the
872
+ * package -- so this method is the whole seam. Accepts `doc` (a document
873
+ * object), `build()` (one made per use), or raw `html` (run through the
874
+ * importer). The other direction is `getContent()`, which is how a host
875
+ * captures the current document to store as a template of its own.
876
+ */
877
+ loadTemplate(tpl) {
878
+ if (!tpl) return;
879
+ const built = typeof tpl.build === 'function' ? tpl.build()
880
+ : tpl.doc ? tpl.doc
881
+ : (typeof tpl.html === 'string' && tpl.html.trim()) ? docFromHtml(tpl.html)
882
+ : null;
883
+ if (!built) return;
884
+ const doc = normalizeDoc(JSON.parse(JSON.stringify(built)));
885
+ if (!doc) return;
886
+ this.docSetByHost = true;
887
+ this.setState({
888
+ doc,
889
+ sel: null,
890
+ history: this.state.history.concat(JSON.stringify(this.state.doc)),
891
+ future: [],
892
+ }, () => this.persist(doc));
893
+ this.flash(this.t('toast.templateLoaded', { name: tpl.name || '' }));
894
+ }
895
+
896
+ setStorageLimits(limits) { this.storageLimits = limits || null; this.emit(); }
897
+
898
+ /**
899
+ * Listings and uploads get separate abort scopes on purpose. Changing folder
900
+ * should cancel the listing it supersedes, but must not kill uploads already
901
+ * in flight -- a user who drops files and then browses elsewhere still wants
902
+ * those files. Both are cancelled when the provider changes or the editor
903
+ * unmounts.
904
+ *
905
+ * A provider that ignores `signal` simply runs to completion; `refreshAssets`
906
+ * still discards the superseded result by token, so cancellation is an
907
+ * optimisation on top of correctness, not the thing that provides it.
908
+ */
909
+ abortAssets(scope) {
910
+ const key = scope === 'upload' ? '_uploadAbort' : '_listAbort';
911
+ if (this[key]) this[key].abort();
912
+ this[key] = typeof AbortController === 'function' ? new AbortController() : null;
913
+ return this[key] ? this[key].signal : undefined;
914
+ }
915
+
916
+ /** `editor.storageLimits` over `provider.limits`, per key. */
917
+ limits() { return resolveLimits(this.storageLimits, this.storageProvider && this.storageProvider.limits); }
918
+
919
+ /** Sidebar entries. Counts appear only where they are actually known -- a cursor-paged backend cannot say how many files a folder holds without walking all of it, and a wrong count is worse than none. */
920
+ folderOptions() {
921
+ const s = this.state;
922
+ if (!this.storageProvider) {
923
+ return FOLDERS.map((name) => ({
924
+ id: name,
925
+ name,
926
+ count: name === FOLDERS[0] ? s.assets.length : s.assets.filter((a) => a.folder === name).length,
927
+ }));
928
+ }
929
+ return [{ id: ALL_FOLDER_ID, name: this.t('library.allFiles'), count: null }]
930
+ .concat((s.folders || []).map((f) => ({ id: f.id, name: f.name, count: null })));
931
+ }
932
+
933
+ /** With a provider, folder and search are the backend's job -- re-filtering the current page here would hide matches that live on the next one. */
934
+ visibleAssets() {
935
+ const s = this.state;
936
+ if (this.storageProvider) return s.assets;
937
+ const q = (s.assetQuery || '').trim().toLowerCase();
938
+ return s.assets.filter((a) => (s.assetFolder === FOLDERS[0] || a.folder === s.assetFolder) && (!q || a.name.toLowerCase().includes(q)));
939
+ }
940
+
941
+ openLibrary(assetTarget) {
942
+ this.setState({ libraryOpen: true, assetTarget: assetTarget || null });
943
+ if (this.storageProvider && !this.state.assetsLoaded && !this.state.assetsLoading) this.refreshAssets();
944
+ }
945
+
946
+ setAssetFolder(id) {
947
+ this.setState({ assetFolder: id });
948
+ if (this.storageProvider) this.refreshAssets();
949
+ }
950
+
951
+ setAssetQuery(q) {
952
+ this.setState({ assetQuery: q });
953
+ if (!this.storageProvider) return;
954
+ clearTimeout(this._searchTimer);
955
+ this._searchTimer = setTimeout(() => this.refreshAssets(), 320);
956
+ }
957
+
958
+ /**
959
+ * Loads a page from the provider; `append` continues from the cursor the last
960
+ * page returned. Each run takes a token and drops its own result if another
961
+ * run started meanwhile, so a slow query for a folder the user has already
962
+ * navigated away from cannot overwrite the list they are now looking at.
963
+ */
964
+ async refreshAssets({ append = false } = {}) {
965
+ const provider = this.storageProvider;
966
+ if (!provider) return;
967
+ const token = (this._assetToken = (this._assetToken || 0) + 1);
968
+ const signal = this.abortAssets('list');
969
+ this.setState({ assetsLoading: true, assetsError: null });
970
+ try {
971
+ if (!this.state.folders && provider.folders) {
972
+ // A folder list is optional chrome; failing to get one must not take
973
+ // the file list down with it.
974
+ try {
975
+ const folders = await provider.folders({ signal });
976
+ if (token === this._assetToken) this.setState({ folders });
977
+ } catch { /* ignore */ }
978
+ }
979
+ const page = await provider.list({
980
+ folderId: this.folderId(),
981
+ cursor: append ? this.state.assetCursor : null,
982
+ query: (this.state.assetQuery || '').trim(),
983
+ signal,
984
+ });
985
+ if (token !== this._assetToken) return;
986
+ const items = ((page && page.items) || []).map((a) => normalizeAsset(a));
987
+ this.setState({
988
+ assets: append ? this.state.assets.concat(items) : items,
989
+ assetCursor: (page && page.cursor) || null,
990
+ assetsLoading: false,
991
+ assetsLoaded: true,
992
+ });
993
+ } catch (e) {
994
+ // An abort is this class superseding its own request -- not a failure to
995
+ // report, and the run that replaced it owns the UI now.
996
+ if (token !== this._assetToken || (e && e.name === 'AbortError')) return;
997
+ this.setState({ assetsLoading: false, assetsError: (e && e.message) || String(e) });
998
+ }
999
+ }
1000
+
1001
+ loadMoreAssets() { if (this.state.assetCursor && !this.state.assetsLoading) this.refreshAssets({ append: true }); }
1002
+
1003
+ /** The selected folder as the provider sees it; the built-in "all files" entry is not a real folder. */
1004
+ folderId() { return this.state.assetFolder === FOLDERS[0] ? ALL_FOLDER_ID : this.state.assetFolder; }
1005
+
1006
+ /**
1007
+ * Validate, then upload. Both halves are the host's policy: what may be
1008
+ * uploaded comes from `storageLimits`, where it goes comes from the provider.
1009
+ *
1010
+ * With neither configured this is the original prototype behaviour. With a
1011
+ * provider but no limits it refuses -- that combination is a misconfiguration
1012
+ * rather than a mode, and quietly accepting anything is the exact thing the
1013
+ * limits exist to prevent.
1014
+ */
1015
+ async addFiles(list) {
1016
+ const files = Array.from(list || []);
1017
+ if (!files.length) return;
1018
+ const provider = this.storageProvider;
1019
+ const limits = this.limits();
1020
+
1021
+ if (!provider && !limits) { this.addFilesAsDataUrls(files); return; }
1022
+
1023
+ const { accepted, rejected } = await validateFiles(files, limits);
1024
+ // The first reason shows immediately; the rest queue behind it, spaced a
1025
+ // little longer than a toast lives. A ten-file drop where nothing passes
1026
+ // would otherwise show only whichever rejection happened to land last.
1027
+ rejected.forEach((r, i) => {
1028
+ if (i === 0) this.flash(this.t(r.key, r.params));
1029
+ else setTimeout(() => this.flash(this.t(r.key, r.params)), i * 2100);
1030
+ });
1031
+ if (!accepted.length) return;
1032
+ if (!provider) { this.addFilesAsDataUrls(accepted.map((a) => a.file)); return; }
1033
+
1034
+ const folderId = this.folderId();
1035
+ const signal = this.abortAssets('upload');
1036
+ // Results are written by index rather than pushed, so the library shows the
1037
+ // files in the order they were dropped however the pool happens to finish.
1038
+ const results = new Array(accepted.length);
1039
+ let next = 0;
1040
+ this.setState({ uploading: accepted.length });
1041
+
1042
+ const worker = async () => {
1043
+ for (let i = next++; i < accepted.length; i = next++) {
1044
+ const item = accepted[i];
1045
+ try {
1046
+ const asset = await provider.upload(item.file, { folderId, width: item.w, height: item.ht, signal });
1047
+ results[i] = normalizeAsset(asset, item);
1048
+ } catch (e) {
1049
+ if (!(e && e.name === 'AbortError')) {
1050
+ this.flash(this.t('storage.errUploadFailed', { name: item.name, reason: (e && e.message) || '' }));
1051
+ }
1052
+ }
1053
+ this.setState({ uploading: Math.max(0, this.state.uploading - 1) });
1054
+ }
1055
+ };
1056
+ await Promise.all(Array.from({ length: Math.min(UPLOAD_CONCURRENCY, accepted.length) }, worker));
1057
+
1058
+ this.setState({ uploading: 0 });
1059
+ const added = results.filter(Boolean);
1060
+ if (added.length) this.finishUpload(added);
1061
+ }
1062
+
1063
+ /**
1064
+ * The no-provider path, unchanged from the prototype and kept so the package
1065
+ * still runs with nothing wired. Worth knowing before shipping on it: Gmail
1066
+ * and Outlook both strip `data:` images, so this is a demo mode, not a way to
1067
+ * send mail.
1068
+ */
1069
+ addFilesAsDataUrls(list) {
1070
+ const files = Array.from(list || []).filter((f) => /^image\//.test(f.type));
1071
+ if (!files.length) return;
1072
+ let pending = files.length; const added = [];
1073
+ const done = () => { if (--pending === 0 && added.length) this.finishUpload(added); };
1074
+ files.forEach((f) => {
1075
+ const fr = new FileReader();
1076
+ fr.onload = () => {
1077
+ const img = new Image();
1078
+ img.onload = () => { added.push({ id: uid(), name: f.name, url: fr.result, folder: 'Uploads', w: img.width, ht: img.height, size: f.size }); done(); };
1079
+ img.onerror = () => { added.push({ id: uid(), name: f.name, url: fr.result, folder: 'Uploads', w: 0, ht: 0, size: f.size }); done(); };
1080
+ img.src = fr.result;
1081
+ };
1082
+ fr.onerror = done;
1083
+ fr.readAsDataURL(f);
1084
+ });
1085
+ }
1086
+
1087
+ finishUpload(added) {
1088
+ const assets = added.concat(this.state.assets);
1089
+ const patch = { assets, libHot: false };
1090
+ // "Uploads" is a real folder only in the built-in seed set; with a provider
1091
+ // the file already sits in whichever folder the user was looking at.
1092
+ if (!this.storageProvider) patch.assetFolder = 'Uploads';
1093
+ this.setState(patch, () => { if (!this.storageProvider) this.persist(null, assets); });
1094
+ this.flash(added.length === 1 ? this.t('toast.fileUploadedOne') : this.t('toast.fileUploadedMany', { count: added.length }));
1095
+ }
1096
+
1097
+ /** DEL on a library tile. Where the provider owns the file, a failed delete leaves the tile alone rather than showing a removal that did not happen. */
1098
+ async removeAsset(a) {
1099
+ const provider = this.storageProvider;
1100
+ if (provider && provider.remove) {
1101
+ try { await provider.remove(a); }
1102
+ catch (e) { this.flash(this.t('storage.errDeleteFailed', { name: a.name, reason: (e && e.message) || '' })); return; }
1103
+ }
1104
+ const assets = this.state.assets.filter((x) => x.id !== a.id);
1105
+ this.setState({ assets }, () => { if (!provider) this.persist(null, assets); });
1106
+ this.flash(this.t('toast.assetDeleted', { name: a.name }));
1107
+ }
1108
+
1109
+ useAsset(a) {
1110
+ const t = this.state.assetTarget;
1111
+ if (t) {
1112
+ this.setProp(t.id || t, t.key || 'src', a.url);
1113
+ this.setState({ libraryOpen: false, assetTarget: null });
1114
+ this.flash(this.t('toast.imageReplaced'));
1115
+ } else {
1116
+ const block = mk('image');
1117
+ block.props.src = a.url; block.props.alt = a.name.replace(/\.[a-z]+$/i, '').replace(/[-_]/g, ' ');
1118
+ const sel = this.selObj();
1119
+ this.commit((doc) => {
1120
+ if (sel.row && this.state.mode === 'rows') this.find(doc, sel.row.id).row.cols[sel.ci || 0].blocks.push(block);
1121
+ else doc.rows.push(mkRow([100], [block]));
1122
+ });
1123
+ this.setState({ libraryOpen: false, sel: { type: 'block', id: block.id }, tab: 'design' });
1124
+ this.flash(this.t('toast.imageAdded'));
1125
+ }
1126
+ }
1127
+
1128
+ // ---- export / code view --------------------------------------------------
1129
+
1130
+ buildHtml() { return buildHtmlFn(this.state, this.exportRoot, boxCss); }
1131
+
1132
+ openExport = () => {
1133
+ this.setState({ exportOpen: true, exportCode: this.buildHtml(), copied: false, libraryOpen: false, aiOpen: false, codeOpen: false });
1134
+ };
1135
+
1136
+ copyExport = () => {
1137
+ if (navigator.clipboard) navigator.clipboard.writeText(this.state.exportCode).then(() => this.setState({ copied: true }), () => this.setState({ copied: true }));
1138
+ else this.setState({ copied: true });
1139
+ this.flash(this.t('toast.htmlCopied'));
1140
+ };
1141
+
1142
+ downloadExport = () => {
1143
+ const blob = new Blob([this.state.exportCode], { type: 'text/html' });
1144
+ const a = document.createElement('a');
1145
+ a.href = URL.createObjectURL(blob);
1146
+ a.download = 'email.html';
1147
+ a.click();
1148
+ setTimeout(() => URL.revokeObjectURL(a.href), 1500);
1149
+ };
1150
+
1151
+ openCode = () => {
1152
+ const src = this.buildHtml();
1153
+ this.setState({ codeOpen: true, codeSrc: src, codeLive: src, codeDirty: false, libraryOpen: false, exportOpen: false, aiOpen: false });
1154
+ };
1155
+
1156
+ setCodeSrc = (value) => {
1157
+ // Source typing only changes the code editor surface. Updating the state
1158
+ // directly and asking that surface to repaint avoids rebuilding the full
1159
+ // canvas and inspector on every keystroke.
1160
+ this.state.codeSrc = value;
1161
+ this.state.codeDirty = true;
1162
+ if (this.onCodeSourceChange) this.onCodeSourceChange();
1163
+ clearTimeout(this.codeTimer);
1164
+ this.codeTimer = setTimeout(() => this.setState({ codeLive: this.state.codeSrc }), 350);
1165
+ };
1166
+
1167
+ /** Shared HTML -> doc entry point for both the Code modal's "Apply" action and the host-facing `importHtml` API. Recognizable shapes (image/button/heading/text/divider/list/table, per `core/import-html.js`) become native blocks; anything structural that can't be classified falls back to one raw `html` block per row, and a totally unparseable/empty result falls back to a single row wrapping the whole body -- content is never dropped. Returns the number of rows produced. */
1168
+ importHtml(src) {
1169
+ const { rows, theme } = parseHtmlSource(src);
1170
+ this.docSetByHost = true;
1171
+ // The theme patch carries only keys the source actually declared (page
1172
+ // background, content width, font stack) -- merged, not replaced, so an
1173
+ // import that says nothing about e.g. link color leaves it alone.
1174
+ this.commit((doc) => { doc.rows = rows; Object.assign(doc.theme, theme); });
1175
+ return rows.length;
1176
+ }
1177
+
1178
+ applyCode = () => {
1179
+ try {
1180
+ const n = this.importHtml(this.state.codeSrc);
1181
+ this.setState({ codeOpen: false, codeDirty: false, sel: null });
1182
+ this.flash(n === 1 ? this.t('toast.sourceAppliedOne') : this.t('toast.sourceAppliedMany', { rows: n }));
1183
+ } catch {
1184
+ this.flash(this.t('toast.parseError'));
1185
+ }
1186
+ };
1187
+
1188
+ // ---- AI copy ---------------------------------------------------------
1189
+
1190
+ runAi = async () => {
1191
+ const { aiGoal, aiTone, aiBrief } = this.state;
1192
+ this.setState({ aiBusy: true, aiResults: [] });
1193
+ const prompt = 'You write marketing email copy. Goal: ' + aiGoal + '. Tone: ' + aiTone + '. Brief: ' + aiBrief +
1194
+ '. Return ONLY JSON: {"headline":"...","body":"...","cta":"..."} — body max 55 words, plain sentences, no emoji, may use {{ first_name }}.';
1195
+ let out = null;
1196
+ try {
1197
+ if (this.aiProvider) {
1198
+ const raw = await this.aiProvider(prompt);
1199
+ const m = String(raw).match(/\{[\s\S]*\}/);
1200
+ if (m) out = JSON.parse(m[0]);
1201
+ }
1202
+ } catch { out = null; }
1203
+ if (!out) {
1204
+ const s = (aiBrief || 'the new drop').replace(/\.$/, '');
1205
+ out = {
1206
+ headline: s.split(/[,.]/)[0].slice(0, 58),
1207
+ body: 'Hi {{ first_name }} — ' + s + '. We kept it short: one link, one decision, no pressure.',
1208
+ cta: 'See it',
1209
+ };
1210
+ }
1211
+ const addText = (html, size, msg) => { const b = mk('text'); b.props.html = html; if (size) b.props.size = size; this.commit((doc) => { doc.rows.push(mkRow([100], [b])); }); this.setState({ aiOpen: false, sel: { type: 'block', id: b.id }, tab: 'design' }); this.flash(msg); };
1212
+ this.setState({
1213
+ aiBusy: false,
1214
+ aiResults: [
1215
+ { kind: this.t('ai.kindHeadline'), text: out.headline, action: this.t('ai.actionInsertHeading'), onUse: () => addText('<strong style="font-size:27px;line-height:1.15;display:block">' + out.headline + '</strong>', 16, this.t('toast.headingAdded')) },
1216
+ { kind: this.t('ai.kindBody'), text: out.body, action: this.t('ai.actionInsertText'), onUse: () => addText(out.body, 16, this.t('toast.textBlockAdded')) },
1217
+ { kind: this.t('ai.kindButton'), text: out.cta, action: this.t('ai.actionInsertButton'), onUse: () => { const b = mk('button'); b.props.label = out.cta; this.commit((doc) => { doc.rows.push(mkRow([100], [b])); }); this.setState({ aiOpen: false, sel: { type: 'block', id: b.id }, tab: 'design' }); this.flash(this.t('toast.buttonAdded')); } },
1218
+ ],
1219
+ });
1220
+ };
1221
+
1222
+ // ---- variables -----------------------------------------------------------
1223
+
1224
+ // ---- inspector field descriptors (ported from fields()/boxFields()/themeFields()) --
1225
+
1226
+ boxFields() {
1227
+ const f = this.selObj();
1228
+ if (!f.block) return [];
1229
+ const b = f.block;
1230
+ const B = binder(() => this.find(this.state.doc, b.id).block.props, (k, v) => this.setProp(b.id, k, v), this);
1231
+ return decorate([
1232
+ B.head('Box & border'),
1233
+ B.color('Background color', 'bBg'),
1234
+ B.range('Border thickness', 'bBorder', 0, 20, 1, 'px'),
1235
+ ...(b.props.bBorder ? [
1236
+ B.sel('Border style', 'bStyle', BORDER_STYLES), B.color('Border color', 'bLine'),
1237
+ B.tog('Top border', 'bTop', true), B.tog('Right border', 'bRight', true),
1238
+ B.tog('Bottom border', 'bBottom', true), B.tog('Left border', 'bLeft', true),
1239
+ ] : []),
1240
+ B.range('Rounded corners', 'bRadius', 0, 100, 1, 'px'),
1241
+ B.range('Space inside', 'bPad', 0, 100, 2, 'px'),
1242
+ ]);
1243
+ }
1244
+
1245
+ fields() {
1246
+ const sel = this.state.sel; if (!sel) return [];
1247
+ const f = this.selObj();
1248
+ const ALIGN = [{ value: 'left', label: 'Left' }, { value: 'center', label: 'Center' }, { value: 'right', label: 'Right' }];
1249
+ if (f.block) {
1250
+ const b = f.block;
1251
+ const B = binder(() => this.find(this.state.doc, b.id).block.props, (k, v) => this.setProp(b.id, k, v), this);
1252
+ // No leading "Block — X" head: the sticky panel header directly above
1253
+ // already reads "Block properties / X", so it only added a duplicate
1254
+ // line and a dead divider strip under the title.
1255
+ const base = [];
1256
+ const padF = [B.head('Spacing'), group(null, [B.range('Above & below', 'py', 0, 160, 2, 'px'), B.range('Left & right', 'px', 0, 120, 2, 'px')])];
1257
+ switch (b.type) {
1258
+ case 'text': return decorate(base.concat(
1259
+ /<a(?:\s|>)/i.test(String(b.props.html || ''))
1260
+ ? [{ kind: 'richLinks', label: 'Links', html: b.props.html || '', onChange: (v) => this.setProp(b.id, 'html', v) }]
1261
+ : [],
1262
+ [B.area('Text', 'html'), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.range('Text size', 'size', 8, 96, 1, 'px'), B.range('Line spacing', 'lh', 0.8, 3, 0.05, ''), B.seg('Align', 'align', ALIGN), B.sel('Text weight', 'weight', [{ value: '400', label: 'Regular' }, { value: '500', label: 'Medium' }, { value: '700', label: 'Bold' }]), B.color('Text color', 'color')], padF));
1263
+ case 'image': return decorate(base.concat([B.btn('Choose from library', () => this.openLibrary({ id: b.id, key: 'src' })), B.text('Alt text', 'alt', 'Describe the image'), B.text('Link URL', 'href', 'https://'), B.range('Width', 'width', 5, 100, 1, '%'), B.seg('Align', 'align', ALIGN), B.range('Rounded corners', 'radius', 0, 200, 1, 'px')], padF));
1264
+ case 'button': return decorate(base.concat([B.text('Label', 'label'), B.text('Link URL', 'href', 'https://'), B.color('Button color', 'bg'), B.color('Text color', 'color'), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.range('Text size', 'size', 8, 48, 1, 'px'), B.range('Rounded corners', 'radius', 0, 60, 1, 'px'), B.range('Outline thickness', 'borderW', 0, 6, 1, 'px')].concat(b.props.borderW ? [B.sel('Outline style', 'borderStyle', BORDER_STYLES), B.color('Outline color', 'borderColor')] : []).concat([B.range('Button height', 'py', 0, 60, 1, 'px'), B.range('Button width', 'px', 0, 120, 2, 'px'), B.seg('Align', 'align', ALIGN), B.tog('Full width', 'full')])));
1265
+ case 'divider': return decorate(base.concat([B.range('Thickness', 'thickness', 1, 20, 1, 'px'), B.sel('Line style', 'lineStyle', BORDER_STYLES), B.range('Width', 'width', 5, 100, 5, '%'), B.color('Color', 'color'), B.range('Space above & below', 'py', 0, 160, 2, 'px')]));
1266
+ case 'spacer': return decorate(base.concat([B.range('Height', 'height', 0, 400, 2, 'px')]));
1267
+ case 'social': return decorate(base.concat([
1268
+ // Custom kind (render/fields.js `renderSocialItems`): a per-network
1269
+ // card list with an add-dropdown, replacing the raw Name|URL
1270
+ // textarea. Same `items` string underneath.
1271
+ { kind: 'social', label: 'Networks', value: b.props.items || '', onChange: (v) => this.setProp(b.id, 'items', v) },
1272
+ B.seg('Icon palette', 'palette', [{ value: 'custom', label: 'Custom' }, { value: 'brand', label: 'Brand' }]),
1273
+ B.seg('Icon shape', 'shape', [{ value: 'outline', label: 'Outline' }, { value: 'bare', label: 'Bare' }, { value: 'circle', label: 'Circle' }, { value: 'square', label: 'Square' }]),
1274
+ B.tog('Show network names', 'showLabel'),
1275
+ B.sel('Font', 'fontFamily', this.fontOptions(true)),
1276
+ B.seg('Align', 'align', ALIGN), B.range('Icon size', 'size', 10, 64, 1, 'px'), B.range('Space between icons', 'gap', 0, 80, 2, 'px'), B.color('Icon color', 'color'),
1277
+ ]));
1278
+ case 'video': return decorate(base.concat([B.btn('Choose thumbnail', () => this.openLibrary({ id: b.id, key: 'src' })), B.text('Video URL', 'href', 'https://'), B.text('Caption', 'caption'), B.color('Badge color', 'badge')]));
1279
+ case 'html': return decorate(base.concat([B.area('Raw HTML', 'code')]));
1280
+ case 'countdown': return decorate(base.concat([B.text('Ends at (YYYY-MM-DDTHH:MM)', 'target'), B.text('Label', 'label'), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.color('Color', 'color')]));
1281
+ case 'menu': return decorate(base.concat([B.area('Items — one per line as Label|URL', 'items'), B.seg('Align', 'align', ALIGN), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.range('Text size', 'size', 8, 32, 1, 'px'), B.range('Space between items', 'gap', 0, 80, 2, 'px'), B.color('Text color', 'color')]));
1282
+ case 'heading': return decorate(base.concat([B.area('Text', 'text'), B.sel('Heading level', 'level', [{ value: 'h1', label: 'H1 — largest' }, { value: 'h2', label: 'H2' }, { value: 'h3', label: 'H3' }, { value: 'h4', label: 'H4 — smallest' }]), B.seg('Font style', 'font', [{ value: 'condensed', label: 'Condensed' }, { value: 'body', label: 'Body' }]), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.range('Text size', 'size', 12, 120, 1, 'px'), B.range('Line spacing', 'lh', 0.8, 2.2, 0.02, ''), B.seg('Align', 'align', ALIGN), B.sel('Text weight', 'weight', [{ value: '400', label: 'Regular' }, { value: '600', label: 'Semibold' }, { value: '700', label: 'Bold' }]), B.color('Text color', 'color')], padF));
1283
+ case 'list': return decorate(base.concat([B.area('Items — one per line', 'items'), B.tog('Numbered', 'ordered'), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.range('Text size', 'size', 8, 48, 1, 'px'), B.range('Line spacing', 'lh', 0.8, 3, 0.05, ''), B.range('Space between items', 'gap', 0, 60, 1, 'px'), B.color('Text color', 'color')], padF));
1284
+ case 'table': return decorate(base.concat([
1285
+ // Custom kind (render/fields.js `renderTableGrid`): a real grid of
1286
+ // cell inputs with add/remove row+column controls, replacing the
1287
+ // raw pipe-separated textarea (and its separate add buttons). Same
1288
+ // `data` string underneath.
1289
+ { kind: 'tablegrid', label: 'Cells', value: b.props.data || '', header: !!b.props.header, onChange: (v) => this.setProp(b.id, 'data', v) },
1290
+ B.tog('Header row', 'header'), B.tog('Cell borders', 'borders'), ...(b.props.borders ? [B.range('Border thickness', 'borderWidth', 1, 10, 1, 'px'), B.sel('Border style', 'borderStyle', BORDER_STYLES)] : []), B.tog('Zebra stripes', 'striped'),
1291
+ B.sel('Font', 'fontFamily', this.fontOptions(true)),
1292
+ B.range('Space in cells', 'pad', 0, 50, 1, 'px'), B.range('Text size', 'size', 8, 32, 1, 'px'), B.range('Width', 'width', 10, 100, 5, '%'),
1293
+ B.color('Header color', 'headBg'), B.color('Line color', 'lineColor'), B.seg('Align', 'align', ALIGN),
1294
+ ]));
1295
+ case 'embed': return decorate(base.concat([B.text('Embed URL', 'src', 'https://'), B.range('Height', 'height', 40, 1200, 10, 'px'), B.text('Accessible label', 'label'), B.range('Padding', 'py', 0, 120, 2, 'px')]));
1296
+ case 'css': return decorate(base.concat([B.area('CSS', 'code'), B.text('Note to yourself', 'note')]));
1297
+ case 'box': return decorate(base.concat([B.area('Content (HTML allowed)', 'html'), B.color('Background color', 'bg'), B.btn('Choose background image', () => this.openLibrary({ id: b.id, key: 'bgImage' })), B.text('Background image URL', 'bgImage', 'https://'), B.range('Space inside', 'pad', 0, 160, 2, 'px'), B.range('Border thickness', 'border', 0, 20, 1, 'px')].concat(b.props.border ? [
1298
+ B.sel('Border style', 'borderStyle', BORDER_STYLES), B.color('Border color', 'lineColor'),
1299
+ B.tog('Top border', 'topBorder', true), B.tog('Right border', 'rightBorder', true),
1300
+ B.tog('Bottom border', 'bottomBorder', true), B.tog('Left border', 'leftBorder', true),
1301
+ ] : []).concat([B.range('Rounded corners', 'radius', 0, 200, 1, 'px'), B.range('Min height', 'minH', 0, 1200, 10, 'px'), B.range('Max width', 'maxW', 10, 100, 5, '%'), B.seg('Align', 'align', ALIGN), B.tog('Drop shadow', 'shadow')])));
1302
+ case 'svg': return decorate(base.concat([B.area('SVG markup', 'code'), B.seg('Align', 'align', ALIGN), B.range('Width', 'width', 5, 100, 5, '%'), B.range('Padding', 'py', 0, 120, 2, 'px')]));
1303
+ case 'codeblock': return decorate(base.concat([B.area('Code', 'code'), B.color('Background', 'bg'), B.color('Text color', 'color'), B.range('Size', 'size', 8, 32, 0.5, 'px'), B.range('Padding', 'pad', 0, 80, 2, 'px')]));
1304
+ default: return [];
1305
+ }
1306
+ }
1307
+ if (f.row) {
1308
+ const r = f.row;
1309
+ const p = r.props;
1310
+ const B = binder(() => this.find(this.state.doc, r.id).row.props, (k, v) => this.setProp(r.id, k, v), this);
1311
+ const curIdx = LAYOUTS.findIndex((l) => l.spans.length === r.cols.length && l.spans.join() === r.cols.map((c) => c.span).join());
1312
+ const adv = !!this.state.advancedOpen;
1313
+ const uiToggle = (label, on, onChange) => ({ kind: 'toggle', label, on: !!on, onChange });
1314
+ // Grouped and progressive rather than one flat wall of every control:
1315
+ // plain-language groups first; anything conditional (border sides,
1316
+ // image fit) appears only once it applies; the CSS-flavored layout
1317
+ // machinery (flex/grid, max-width, vertical align, raw image URL)
1318
+ // lives behind the "Advanced options" switch, an editor-UI flag
1319
+ // (this.state, never the doc) so it doesn't dirty or save with the
1320
+ // document.
1321
+ return decorate([
1322
+ // No leading "Section — N columns" head: the panel header already
1323
+ // says "Section properties / Section", and the Columns select right
1324
+ // here shows the layout.
1325
+ {
1326
+ kind: 'select', label: 'Columns', value: String(curIdx > -1 ? curIdx : 0),
1327
+ options: LAYOUTS.map((l, i) => ({ value: String(i), label: l.label })),
1328
+ onChange: (v) => {
1329
+ const spans = LAYOUTS[Number(v)].spans;
1330
+ this.commit((doc) => {
1331
+ const row = this.find(doc, r.id).row;
1332
+ const kept = row.cols.slice();
1333
+ // Spread keeps per-column styling (bg/radius/padY/padX) on
1334
+ // columns that survive the structure change.
1335
+ row.cols = spans.map((s, i) => Object.assign({}, kept[i] || {}, { id: (kept[i] && kept[i].id) || uid(), span: s, blocks: (kept[i] && kept[i].blocks) || [] }));
1336
+ const orphan = kept.slice(spans.length).reduce((a, c) => a.concat(c.blocks), []);
1337
+ if (orphan.length) row.cols[row.cols.length - 1].blocks = row.cols[row.cols.length - 1].blocks.concat(orphan);
1338
+ });
1339
+ },
1340
+ },
1341
+ ...(r.cols.length > 1 ? [
1342
+ B.range('Space between columns', 'gap', 0, 120, 2, 'px'),
1343
+ B.tog('Stack columns on mobile', 'stackMobile'),
1344
+ ] : []),
1345
+ // Per-column styling, only for multi-column sections (a single
1346
+ // column's background is just the section background).
1347
+ ...(r.cols.length > 1 ? r.cols.reduce((acc, c, ci) => {
1348
+ const CB = binder(() => this.find(this.state.doc, r.id).row.cols[ci], (k, v) => this.setColProp(r.id, ci, k, v), this);
1349
+ return acc.concat([
1350
+ B.head('Column ' + (ci + 1)),
1351
+ CB.color('Background color', 'bg'),
1352
+ CB.range('Border thickness', 'border', 0, 20, 1, 'px'),
1353
+ ...(c.border ? [CB.sel('Border style', 'borderStyle', BORDER_STYLES), CB.color('Border color', 'lineColor')] : []),
1354
+ CB.range('Rounded corners', 'radius', 0, 100, 1, 'px'),
1355
+ group('Space inside', [CB.range('Top & bottom', 'padY', 0, 100, 2, 'px'), CB.range('Left & right', 'padX', 0, 100, 2, 'px')]),
1356
+ ]);
1357
+ }, []) : []),
1358
+
1359
+ B.head('Background'),
1360
+ B.color('Background color', 'bg'),
1361
+ B.btn(p.bgImage ? 'Change background image' : 'Add background image', () => this.openLibrary({ id: r.id, key: 'bgImage' })),
1362
+ ...(p.bgImage ? [
1363
+ B.btn('Remove background image', () => this.setProp(r.id, 'bgImage', '')),
1364
+ B.sel('Image fit', 'bgSize', [{ value: 'cover', label: 'Fill the section' }, { value: 'contain', label: 'Fit inside' }, { value: 'auto', label: 'Actual size' }]),
1365
+ B.sel('Image position', 'bgPos', [{ value: 'center', label: 'Center' }, { value: 'top', label: 'Top' }, { value: 'bottom', label: 'Bottom' }, { value: 'left', label: 'Left' }, { value: 'right', label: 'Right' }]),
1366
+ B.range('Darken image', 'overlay', 0, 100, 1, '%'),
1367
+ ] : []),
1368
+
1369
+ B.head('Border & corners'),
1370
+ B.range('Border thickness', 'border', 0, 20, 1, 'px'),
1371
+ B.sel('Border style', 'borderStyle', BORDER_STYLES),
1372
+ ...(p.border ? [
1373
+ B.color('Border color', 'lineColor'),
1374
+ B.tog('Top border', 'bTop', true),
1375
+ B.tog('Right border', 'bRight', true),
1376
+ B.tog('Bottom border', 'bBottom', true),
1377
+ B.tog('Left border', 'bLeft', true),
1378
+ ] : []),
1379
+ B.range('Rounded corners', 'radius', 0, 200, 1, 'px'),
1380
+ // Stored as the raw CSS string so an imported template's exact shadow
1381
+ // survives; the toggle writes/clears a standard soft card shadow.
1382
+ // (Note: many email clients ignore box-shadow -- it's progressive.)
1383
+ uiToggle('Drop shadow', !!p.shadow, () => this.setProp(r.id, 'shadow', p.shadow ? '' : '0 1px 2px rgba(15,23,42,0.06), 0 8px 24px rgba(15,23,42,0.10)')),
1384
+
1385
+ B.head('Spacing'),
1386
+ // The group's "More options" switch is not a B.tog: the split/merge
1387
+ // needs to seed or fold the side props in the same commit -- see
1388
+ // togglePadSplit.
1389
+ group('Space inside', p.padSplit ? [
1390
+ B.range('Top', 'pt', 0, 200, 2, 'px'),
1391
+ B.range('Right', 'pr', 0, 160, 2, 'px'),
1392
+ B.range('Bottom', 'pb', 0, 200, 2, 'px'),
1393
+ B.range('Left', 'pl', 0, 160, 2, 'px'),
1394
+ ] : [
1395
+ B.range('Top & bottom', 'py', 0, 200, 2, 'px'),
1396
+ B.range('Left & right', 'px', 0, 160, 2, 'px'),
1397
+ ], { on: !!p.padSplit, onChange: () => this.togglePadSplit(r.id) }),
1398
+ group('Space outside', [
1399
+ B.range('Top', 'mt', 0, 160, 2, 'px'),
1400
+ B.range('Right', 'mr', 0, 160, 2, 'px'),
1401
+ B.range('Bottom', 'mb', 0, 160, 2, 'px'),
1402
+ B.range('Left', 'ml', 0, 160, 2, 'px'),
1403
+ ]),
1404
+
1405
+ uiToggle('Advanced options', adv, () => this.setState({ advancedOpen: !this.state.advancedOpen })),
1406
+ ...(adv ? [
1407
+ B.head('Advanced'),
1408
+ B.seg('Layout engine', 'layout', [{ value: 'columns', label: 'Columns' }, { value: 'flex', label: 'Flex' }, { value: 'grid', label: 'Grid' }]),
1409
+ ...(p.layout === 'flex' ? [
1410
+ B.sel('Flex direction', 'flexDir', [{ value: 'row', label: 'Row' }, { value: 'row-reverse', label: 'Row reverse' }, { value: 'column', label: 'Column' }, { value: 'column-reverse', label: 'Column reverse' }]),
1411
+ B.sel('Justify content', 'justify', [{ value: 'flex-start', label: 'Start' }, { value: 'center', label: 'Center' }, { value: 'flex-end', label: 'End' }, { value: 'space-between', label: 'Space between' }, { value: 'space-around', label: 'Space around' }]),
1412
+ B.sel('Align items', 'alignItems', [{ value: 'stretch', label: 'Stretch' }, { value: 'flex-start', label: 'Start' }, { value: 'center', label: 'Center' }, { value: 'flex-end', label: 'End' }]),
1413
+ B.tog('Wrap flex items', 'wrap'),
1414
+ ] : []),
1415
+ ...(p.layout === 'grid' ? [B.range('Grid columns', 'gridCols', 1, 12, 1, '')] : []),
1416
+ B.text('Background image URL', 'bgImage', 'https://'),
1417
+ B.sel('Image repeat', 'bgRepeat', [{ value: 'no-repeat', label: 'No repeat' }, { value: 'repeat', label: 'Tile' }, { value: 'repeat-x', label: 'Tile across' }]),
1418
+ B.range('Max width', 'maxW', 10, 100, 5, '%'),
1419
+ B.seg('Vertical align', 'valign', [{ value: 'top', label: 'Top' }, { value: 'middle', label: 'Middle' }, { value: 'bottom', label: 'Bottom' }]),
1420
+ ] : []),
1421
+ ]);
1422
+ }
1423
+ return [];
1424
+ }
1425
+
1426
+ /**
1427
+ * Email-safe font stacks, shared by the theme's global select and every
1428
+ * block's own Font select. `inherit: true` prepends the blocks' default --
1429
+ * an empty value meaning "use the theme font" (renderers read
1430
+ * `p.fontFamily || theme.font`, so old documents need no migration).
1431
+ */
1432
+ fontOptions(inherit) {
1433
+ const stacks = [
1434
+ { value: '"Helvetica Neue", Helvetica, Arial, sans-serif', label: 'Helvetica Neue' },
1435
+ { value: 'Helvetica, Arial, sans-serif', label: 'Helvetica / Arial' },
1436
+ { value: '-apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif', label: 'System UI' },
1437
+ { value: 'Verdana, Geneva, sans-serif', label: 'Verdana' },
1438
+ { value: 'Tahoma, Geneva, sans-serif', label: 'Tahoma' },
1439
+ { value: '"Trebuchet MS", Helvetica, sans-serif', label: 'Trebuchet MS' },
1440
+ { value: 'Georgia, "Times New Roman", serif', label: 'Georgia' },
1441
+ { value: '"Times New Roman", Times, serif', label: 'Times New Roman' },
1442
+ { value: '"Palatino Linotype", Palatino, Georgia, serif', label: 'Palatino' },
1443
+ { value: 'ui-monospace, "Courier New", monospace', label: 'Monospace' },
1444
+ ];
1445
+ return inherit ? [{ value: '', label: 'Inherit — theme font' }].concat(stacks) : stacks;
1446
+ }
1447
+
1448
+ themeFields() {
1449
+ const B = binder(() => this.state.doc.theme, (k, v) => this.setTheme(k, v), this);
1450
+ return decorate([
1451
+ B.head('Canvas'),
1452
+ B.slider('Content area width', 'width', 280, 900, 5, 'px'),
1453
+ B.color('Background color', 'bg'),
1454
+ B.color('Content area background color', 'contentBg'),
1455
+ B.head('Type & color'),
1456
+ B.sel('Default font', 'font', this.fontOptions(false)),
1457
+ B.color('Text color', 'text'),
1458
+ B.color('Link color', 'link'),
1459
+ ]);
1460
+ }
1461
+
1462
+ vars() { return varsFn(this.variablesRaw); }
1463
+
1464
+ insertSnippet(code, label) {
1465
+ const sel = this.selObj();
1466
+ const key = sel.block ? INSERT_KEYS[sel.block.type] : null;
1467
+ if (key) {
1468
+ this.setProp(sel.block.id, key, (sel.block.props[key] || '') + (key === 'items' || key === 'data' ? '\n' : ' ') + code);
1469
+ this.flash(this.t('toast.snippetInserted', { name: label || this.t('toast.snippetDefaultLabel') }));
1470
+ } else {
1471
+ if (navigator.clipboard) navigator.clipboard.writeText(code);
1472
+ this.flash(this.t('toast.snippetCopied', { name: label || this.t('toast.snippetDefaultLabel') }));
1473
+ }
1474
+ }
1475
+
1476
+ insertTag(token) { this.insertSnippet(TOKEN(token), TOKEN(token)); }
1477
+
1478
+ // ---- host-facing API (setContent/getContent/etc. live on the element) ----
1479
+
1480
+ /**
1481
+ * `setContent`. Normalized on the way in for the same reason the HTML path
1482
+ * is: a host's stored JSON may predate this build or have been assembled by
1483
+ * a backend that only wrote the fields it cared about. Sparse input used to
1484
+ * reach the renderer intact and fail later at export time.
1485
+ */
1486
+ loadDoc(input) {
1487
+ const doc = normalizeDoc(input);
1488
+ if (!doc) return;
1489
+ this.docSetByHost = true;
1490
+ this.setState({ doc, sel: null, history: this.state.history.concat(JSON.stringify(this.state.doc)), future: [] }, () => this.persist(doc));
1491
+ }
1492
+ }