@mjml-agent-editor/editor 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/canvas.js CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  useDocumentStore,
17
17
  useImageLibrary,
18
18
  useLabels
19
- } from "./chunk-FTEJNUJL.js";
19
+ } from "./chunk-PXFZVLNQ.js";
20
20
  import {
21
21
  Button,
22
22
  Input,
@@ -29,11 +29,35 @@ import {
29
29
  } from "./chunk-V6RR4VRD.js";
30
30
 
31
31
  // src/components/editor/EmailEditor.tsx
32
- import grapesjs from "grapesjs";
32
+ import grapesjs2 from "grapesjs";
33
33
  import GjsEditor, { Canvas, useEditorMaybe as useEditorMaybe2 } from "@grapesjs/react";
34
- import grapesjsMJML from "grapesjs-mjml";
34
+ import grapesjsMJML2 from "grapesjs-mjml";
35
35
  import { useEffect as useEffect5, useRef as useRef5, useState as useState5 } from "react";
36
- import { STARTER_MJML } from "@mjml-agent-editor/core";
36
+ import { listSections, STARTER_MJML } from "@mjml-agent-editor/core";
37
+
38
+ // src/lib/save-baseline.ts
39
+ function createSaveBaseline() {
40
+ let stored = "";
41
+ let loaded = null;
42
+ return {
43
+ /** The MJML the store holds, as far as this editor knows. */
44
+ get stored() {
45
+ return stored;
46
+ },
47
+ /** `canvas` is what the load serialises to, or null when it repaired something to write. */
48
+ load(mjml, canvas) {
49
+ stored = mjml;
50
+ loaded = canvas;
51
+ },
52
+ saved(mjml) {
53
+ stored = mjml;
54
+ loaded = null;
55
+ },
56
+ changed(mjml) {
57
+ return mjml !== stored && mjml !== loaded;
58
+ }
59
+ };
60
+ }
37
61
 
38
62
  // src/lib/save-queue.ts
39
63
  function createSaveQueue(save, delayMs) {
@@ -64,6 +88,39 @@ function createSaveQueue(save, delayMs) {
64
88
  };
65
89
  }
66
90
 
91
+ // src/lib/turn-follower.ts
92
+ function createTurnFollower() {
93
+ let running = false;
94
+ let mounted = true;
95
+ let apply = null;
96
+ return {
97
+ turn(on) {
98
+ if (on === running) return;
99
+ running = on;
100
+ if (mounted) apply?.(on);
101
+ },
102
+ /**
103
+ * Whether the canvas is still mounted. An unmounted one's late reload must not unlock
104
+ * the shared state a canvas mounted since has locked.
105
+ */
106
+ get mounted() {
107
+ return mounted;
108
+ },
109
+ // Both, as React mounts twice in development.
110
+ mount() {
111
+ mounted = true;
112
+ },
113
+ unmount() {
114
+ mounted = false;
115
+ },
116
+ /** A turn already under way is applied at once; none means nothing to apply. */
117
+ ready(fn) {
118
+ apply = fn;
119
+ if (running) fn(true);
120
+ }
121
+ };
122
+ }
123
+
67
124
  // src/lib/shortcuts.ts
68
125
  function opensBlockSearch(e) {
69
126
  if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey || e.isComposing) return false;
@@ -161,7 +218,8 @@ function ImagePicker({
161
218
  const accepts = (file) => matchesAccept(file, library?.accept);
162
219
  const full = quota !== void 0 && quota.used >= quota.limit;
163
220
  const upload = async (files) => {
164
- if (!library || !files?.length) return;
221
+ const send = library?.upload?.bind(library);
222
+ if (!send || !files?.length) return;
165
223
  const mine = session.current;
166
224
  const list = Array.from(files).filter((f) => {
167
225
  if (accepts(f)) return true;
@@ -170,7 +228,7 @@ function ImagePicker({
170
228
  });
171
229
  if (!list.length) return;
172
230
  setPending((n) => n + list.length);
173
- const results = await Promise.allSettled(list.map(async (f) => library.upload(f)));
231
+ const results = await Promise.allSettled(list.map(async (f) => send(f)));
174
232
  setPending((n) => n - list.length);
175
233
  for (const r of results) {
176
234
  if (r.status === "rejected") {
@@ -205,155 +263,163 @@ function ImagePicker({
205
263
  setDragging(false);
206
264
  if (!full) void upload(e.dataTransfer.files);
207
265
  };
208
- return /* @__PURE__ */ jsx2(Dialog, { open, onOpenChange: (next) => !next && onClose(), children: /* @__PURE__ */ jsxs(DialogContent, { className: "editor-dark gap-0 overflow-hidden border border-panel-border bg-panel p-0 text-panel-fg ring-0 sm:max-w-3xl", children: [
209
- /* @__PURE__ */ jsxs(DialogHeader, { className: "border-b border-panel-border px-5 py-4", children: [
210
- /* @__PURE__ */ jsx2(DialogTitle, { children: labels.imagePickerTitle }),
211
- quota && /* @__PURE__ */ jsx2(DialogDescription, { className: "text-panel-muted-fg", children: labels.imagePickerQuota(quota.used, quota.limit) })
212
- ] }),
213
- /* @__PURE__ */ jsxs("div", { className: "flex max-h-[70vh] flex-col gap-4 overflow-y-auto p-5", children: [
214
- library && (full ? /* @__PURE__ */ jsx2("p", { className: "rounded-lg border border-panel-border bg-panel-elevated px-4 py-3 text-sm text-panel-muted-fg", children: labels.imagePickerQuotaFull }) : /* @__PURE__ */ jsxs(
215
- "div",
216
- {
217
- onDragOver: (e) => {
218
- e.preventDefault();
219
- setDragging(true);
220
- },
221
- onDragLeave: () => setDragging(false),
222
- onDrop,
223
- className: cn(
224
- "flex flex-col items-center justify-center gap-3 rounded-xl border-2 border-dashed px-6 py-8 text-center transition-colors",
225
- dragging ? "border-brand bg-brand-soft" : "border-panel-border bg-panel-elevated"
226
- ),
227
- children: [
228
- /* @__PURE__ */ jsx2(ImageUp, { className: "size-8 text-panel-muted-fg" }),
229
- /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center justify-center gap-2 text-sm text-panel-muted-fg", children: [
230
- /* @__PURE__ */ jsx2("span", { children: labels.imagePickerDrop }),
231
- /* @__PURE__ */ jsx2(
232
- Button,
233
- {
234
- type: "button",
235
- size: "sm",
236
- className: "bg-brand text-brand-fg hover:bg-brand/90",
237
- onClick: () => fileInput.current?.click(),
238
- children: labels.imagePickerBrowse
239
- }
240
- )
241
- ] }),
242
- /* @__PURE__ */ jsx2(
243
- "input",
244
- {
245
- ref: fileInput,
246
- type: "file",
247
- accept: library.accept ?? "image/*",
248
- multiple: true,
249
- hidden: true,
250
- onChange: (e) => {
251
- void upload(e.target.files);
252
- e.target.value = "";
253
- }
254
- }
255
- )
256
- ]
257
- }
258
- )),
259
- /* @__PURE__ */ jsxs(
260
- "form",
261
- {
262
- className: "flex gap-2",
263
- onSubmit: (e) => {
264
- e.preventDefault();
265
- const value = url.trim();
266
- if (value) onSelect(value);
267
- },
268
- children: [
269
- /* @__PURE__ */ jsxs("div", { className: "relative flex-1", children: [
270
- /* @__PURE__ */ jsx2(Link2, { className: "pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-panel-muted-fg" }),
271
- /* @__PURE__ */ jsx2(
272
- Input,
273
- {
274
- value: url,
275
- onChange: (e) => setUrl(e.target.value),
276
- placeholder: labels.imagePickerUrlPlaceholder,
277
- className: "h-9 border-panel-border bg-panel-elevated pl-8 text-panel-fg placeholder:text-panel-muted-fg"
278
- }
279
- )
280
- ] }),
281
- /* @__PURE__ */ jsx2(
282
- Button,
283
- {
284
- type: "submit",
285
- size: "lg",
286
- variant: "outline",
287
- disabled: !url.trim(),
288
- className: "border-panel-border bg-panel-elevated text-panel-fg hover:bg-panel-hover hover:text-panel-fg",
289
- children: labels.imagePickerUseUrl
290
- }
291
- )
292
- ]
293
- }
294
- ),
295
- library && /* @__PURE__ */ jsxs("section", { children: [
296
- /* @__PURE__ */ jsx2("h3", { className: "mb-2 text-xs font-medium tracking-wide text-panel-muted-fg uppercase", children: labels.imagePickerGallery }),
297
- loading && images.length === 0 && pending === 0 ? /* @__PURE__ */ jsx2("div", { className: "flex justify-center py-8", children: /* @__PURE__ */ jsx2(Spinner, {}) }) : images.length === 0 && pending === 0 ? /* @__PURE__ */ jsx2("p", { className: "py-6 text-center text-sm text-panel-muted-fg", children: labels.imagePickerEmpty }) : /* @__PURE__ */ jsxs("ul", { className: "grid grid-cols-3 gap-3 sm:grid-cols-4", children: [
298
- Array.from({ length: pending }, (_, i) => /* @__PURE__ */ jsx2(
299
- "li",
266
+ return /* @__PURE__ */ jsx2(Dialog, { open, onOpenChange: (next) => !next && onClose(), children: /* @__PURE__ */ jsxs(
267
+ DialogContent,
268
+ {
269
+ onDragOver: (e) => e.preventDefault(),
270
+ onDrop: (e) => e.preventDefault(),
271
+ className: "editor-dark gap-0 overflow-hidden border border-panel-border bg-panel p-0 text-panel-fg ring-0 sm:max-w-3xl",
272
+ children: [
273
+ /* @__PURE__ */ jsxs(DialogHeader, { className: "border-b border-panel-border px-5 py-4", children: [
274
+ /* @__PURE__ */ jsx2(DialogTitle, { children: labels.imagePickerTitle }),
275
+ quota && /* @__PURE__ */ jsx2(DialogDescription, { className: "text-panel-muted-fg", children: labels.imagePickerQuota(quota.used, quota.limit) })
276
+ ] }),
277
+ /* @__PURE__ */ jsxs("div", { className: "flex max-h-[70vh] flex-col gap-4 overflow-y-auto p-5", children: [
278
+ library?.upload && (full ? /* @__PURE__ */ jsx2("p", { className: "rounded-lg border border-panel-border bg-panel-elevated px-4 py-3 text-sm text-panel-muted-fg", children: labels.imagePickerQuotaFull }) : /* @__PURE__ */ jsxs(
279
+ "div",
300
280
  {
301
- className: "flex aspect-[4/3] items-center justify-center rounded-lg border border-panel-border bg-panel-elevated",
302
- children: /* @__PURE__ */ jsx2(Spinner, {})
303
- },
304
- `pending-${i}`
281
+ onDragOver: (e) => {
282
+ e.preventDefault();
283
+ setDragging(true);
284
+ },
285
+ onDragLeave: () => setDragging(false),
286
+ onDrop,
287
+ className: cn(
288
+ "flex flex-col items-center justify-center gap-3 rounded-xl border-2 border-dashed px-6 py-8 text-center transition-colors",
289
+ dragging ? "border-brand bg-brand-soft" : "border-panel-border bg-panel-elevated"
290
+ ),
291
+ children: [
292
+ /* @__PURE__ */ jsx2(ImageUp, { className: "size-8 text-panel-muted-fg" }),
293
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center justify-center gap-2 text-sm text-panel-muted-fg", children: [
294
+ /* @__PURE__ */ jsx2("span", { children: labels.imagePickerDrop }),
295
+ /* @__PURE__ */ jsx2(
296
+ Button,
297
+ {
298
+ type: "button",
299
+ size: "sm",
300
+ className: "bg-brand text-brand-fg hover:bg-brand/90",
301
+ onClick: () => fileInput.current?.click(),
302
+ children: labels.imagePickerBrowse
303
+ }
304
+ )
305
+ ] }),
306
+ /* @__PURE__ */ jsx2(
307
+ "input",
308
+ {
309
+ ref: fileInput,
310
+ type: "file",
311
+ accept: library.accept ?? "image/*",
312
+ multiple: true,
313
+ hidden: true,
314
+ onChange: (e) => {
315
+ void upload(e.target.files);
316
+ e.target.value = "";
317
+ }
318
+ }
319
+ )
320
+ ]
321
+ }
305
322
  )),
306
- images.map((image) => {
307
- const selected = image.url === currentUrl;
308
- const confirming = confirmId === image.id;
309
- return /* @__PURE__ */ jsxs("li", { className: "group relative", children: [
310
- /* @__PURE__ */ jsx2(
311
- "button",
312
- {
313
- type: "button",
314
- onClick: () => onSelect(image.url),
315
- title: image.name,
316
- className: cn(
317
- "block aspect-[4/3] w-full overflow-hidden rounded-lg border bg-black/25 transition outline-none focus-visible:ring-2 focus-visible:ring-brand",
318
- selected ? "border-brand ring-2 ring-brand" : "border-panel-border hover:border-panel-muted-fg"
319
- ),
320
- children: /* @__PURE__ */ jsx2(
321
- "img",
323
+ library?.allowUrl !== false && /* @__PURE__ */ jsxs(
324
+ "form",
325
+ {
326
+ className: "flex gap-2",
327
+ onSubmit: (e) => {
328
+ e.preventDefault();
329
+ const value = url.trim();
330
+ if (value) onSelect(value);
331
+ },
332
+ children: [
333
+ /* @__PURE__ */ jsxs("div", { className: "relative flex-1", children: [
334
+ /* @__PURE__ */ jsx2(Link2, { className: "pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-panel-muted-fg" }),
335
+ /* @__PURE__ */ jsx2(
336
+ Input,
322
337
  {
323
- src: image.url,
324
- alt: image.name ?? "",
325
- loading: "lazy",
326
- className: "size-full object-contain"
338
+ value: url,
339
+ onChange: (e) => setUrl(e.target.value),
340
+ placeholder: labels.imagePickerUrlPlaceholder,
341
+ className: "h-9 border-panel-border bg-panel-elevated pl-8 text-panel-fg placeholder:text-panel-muted-fg"
327
342
  }
328
343
  )
329
- }
330
- ),
331
- selected && /* @__PURE__ */ jsx2("span", { className: "pointer-events-none absolute top-1.5 left-1.5 flex size-5 items-center justify-center rounded-full bg-brand text-brand-fg", children: /* @__PURE__ */ jsx2(Check, { className: "size-3" }) }),
332
- image.name && /* @__PURE__ */ jsx2("span", { className: "pointer-events-none absolute inset-x-0 bottom-0 truncate rounded-b-lg bg-black/60 px-2 py-1 text-[11px] text-white opacity-0 transition group-hover:opacity-100", children: image.name }),
333
- library.remove && /* @__PURE__ */ jsxs(
334
- "button",
344
+ ] }),
345
+ /* @__PURE__ */ jsx2(
346
+ Button,
347
+ {
348
+ type: "submit",
349
+ size: "lg",
350
+ variant: "outline",
351
+ disabled: !url.trim(),
352
+ className: "border-panel-border bg-panel-elevated text-panel-fg hover:bg-panel-hover hover:text-panel-fg",
353
+ children: labels.imagePickerUseUrl
354
+ }
355
+ )
356
+ ]
357
+ }
358
+ ),
359
+ library && /* @__PURE__ */ jsxs("section", { children: [
360
+ /* @__PURE__ */ jsx2("h3", { className: "mb-2 text-xs font-medium tracking-wide text-panel-muted-fg uppercase", children: labels.imagePickerGallery }),
361
+ loading && images.length === 0 && pending === 0 ? /* @__PURE__ */ jsx2("div", { className: "flex justify-center py-8", children: /* @__PURE__ */ jsx2(Spinner, {}) }) : images.length === 0 && pending === 0 ? /* @__PURE__ */ jsx2("p", { className: "py-6 text-center text-sm text-panel-muted-fg", children: labels.imagePickerEmpty }) : /* @__PURE__ */ jsxs("ul", { className: "grid grid-cols-3 gap-3 sm:grid-cols-4", children: [
362
+ Array.from({ length: pending }, (_, i) => /* @__PURE__ */ jsx2(
363
+ "li",
335
364
  {
336
- type: "button",
337
- onClick: () => void remove(image),
338
- onBlur: () => confirming && setConfirmId(null),
339
- title: confirming ? labels.imagePickerConfirmDelete : labels.imagePickerDelete,
340
- "aria-label": labels.imagePickerDelete,
341
- className: cn(
342
- "absolute top-1.5 right-1.5 flex h-6 items-center gap-1 rounded-md px-1.5 text-xs text-white transition focus-visible:opacity-100",
343
- confirming ? "bg-red-600 opacity-100" : "bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80"
365
+ className: "flex aspect-[4/3] items-center justify-center rounded-lg border border-panel-border bg-panel-elevated",
366
+ children: /* @__PURE__ */ jsx2(Spinner, {})
367
+ },
368
+ `pending-${i}`
369
+ )),
370
+ images.map((image) => {
371
+ const selected = image.url === currentUrl;
372
+ const confirming = confirmId === image.id;
373
+ return /* @__PURE__ */ jsxs("li", { className: "group relative", children: [
374
+ /* @__PURE__ */ jsx2(
375
+ "button",
376
+ {
377
+ type: "button",
378
+ onClick: () => onSelect(image.url),
379
+ title: image.name,
380
+ className: cn(
381
+ "block aspect-[4/3] w-full overflow-hidden rounded-lg border bg-black/25 transition outline-none focus-visible:ring-2 focus-visible:ring-brand",
382
+ selected ? "border-brand ring-2 ring-brand" : "border-panel-border hover:border-panel-muted-fg"
383
+ ),
384
+ children: /* @__PURE__ */ jsx2(
385
+ "img",
386
+ {
387
+ src: image.url,
388
+ alt: image.name ?? "",
389
+ loading: "lazy",
390
+ className: "size-full object-contain"
391
+ }
392
+ )
393
+ }
344
394
  ),
345
- children: [
346
- /* @__PURE__ */ jsx2(Trash2, { className: "size-3.5" }),
347
- confirming && labels.imagePickerConfirmDelete
348
- ]
349
- }
350
- )
351
- ] }, image.id);
352
- })
395
+ selected && /* @__PURE__ */ jsx2("span", { className: "pointer-events-none absolute top-1.5 left-1.5 flex size-5 items-center justify-center rounded-full bg-brand text-brand-fg", children: /* @__PURE__ */ jsx2(Check, { className: "size-3" }) }),
396
+ image.name && /* @__PURE__ */ jsx2("span", { className: "pointer-events-none absolute inset-x-0 bottom-0 truncate rounded-b-lg bg-black/60 px-2 py-1 text-[11px] text-white opacity-0 transition group-hover:opacity-100", children: image.name }),
397
+ library.remove && /* @__PURE__ */ jsxs(
398
+ "button",
399
+ {
400
+ type: "button",
401
+ onClick: () => void remove(image),
402
+ onBlur: () => confirming && setConfirmId(null),
403
+ title: confirming ? labels.imagePickerConfirmDelete : labels.imagePickerDelete,
404
+ "aria-label": labels.imagePickerDelete,
405
+ className: cn(
406
+ "absolute top-1.5 right-1.5 flex h-6 items-center gap-1 rounded-md px-1.5 text-xs text-white transition focus-visible:opacity-100",
407
+ confirming ? "bg-red-600 opacity-100" : "bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80"
408
+ ),
409
+ children: [
410
+ /* @__PURE__ */ jsx2(Trash2, { className: "size-3.5" }),
411
+ confirming && labels.imagePickerConfirmDelete
412
+ ]
413
+ }
414
+ )
415
+ ] }, image.id);
416
+ })
417
+ ] })
418
+ ] })
353
419
  ] })
354
- ] })
355
- ] })
356
- ] }) });
420
+ ]
421
+ }
422
+ ) });
357
423
  }
358
424
  function matchesAccept(file, accept) {
359
425
  if (!accept) return file.type.startsWith("image/");
@@ -397,6 +463,7 @@ import {
397
463
  } from "lucide-react";
398
464
 
399
465
  // src/components/editor/inspector/values.ts
466
+ import { isSafeLink, isSafeNavbarLink } from "@mjml-agent-editor/core";
400
467
  var SIDES = ["top", "right", "bottom", "left"];
401
468
  function expandBox(value) {
402
469
  const parts = value.trim().split(/\s+/).filter(Boolean);
@@ -427,15 +494,6 @@ function parseNetwork(name) {
427
494
  function networkName(network, share) {
428
495
  return share || network === "custom" ? network : `${network}${NO_SHARE}`;
429
496
  }
430
- var LINK_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "mailto", "tel", "sms"]);
431
- function isSafeLink(value) {
432
- const decoded = value.replace(/&#x([0-9a-f]+);?/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16))).replace(/&#(\d+);?/g, (_, dec) => String.fromCodePoint(Number(dec))).replace(/:/g, ":").replace(/&(Tab|NewLine);/g, "").replace(/[\u0000-\u0020]/g, "");
433
- const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(decoded)?.[1];
434
- return !scheme || LINK_SCHEMES.has(scheme.toLowerCase());
435
- }
436
- function isSafeNavbarLink(baseUrl, href) {
437
- return isSafeLink(baseUrl) && isSafeLink(href) && isSafeLink(baseUrl + href);
438
- }
439
497
 
440
498
  // src/components/editor/inspector/controls.tsx
441
499
  import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
@@ -1241,6 +1299,23 @@ function withoutEditorProps(mjml) {
1241
1299
  )
1242
1300
  );
1243
1301
  }
1302
+ function withClosedTags(mjml) {
1303
+ return mjml.replace(START_TAG, (tag) => {
1304
+ const selfClosed = /^<(mj-[\w-]+)([\s\S]*?)\s*\/>$/i.exec(tag);
1305
+ return selfClosed ? `<${selfClosed[1]}${selfClosed[2]}></${selfClosed[1]}>` : tag;
1306
+ });
1307
+ }
1308
+ var TABLE = /<!--[\s\S]*?(?:-->|$)|(<mj-table\b(?:[^>"']|"[^"]*"|'[^']*')*>)((?:<!--[\s\S]*?-->|(?!<!--)[\s\S])*?)(<\/mj-table\s*>)/gi;
1309
+ function withTableRowsKept(mjml) {
1310
+ return mjml.replace(
1311
+ TABLE,
1312
+ (match, open, rows, close) => open ? `${open}<template>${rows}</template>${close}` : match
1313
+ );
1314
+ }
1315
+ function sourceForEditor(mjml) {
1316
+ const cleaned = withoutEditorProps(mjml);
1317
+ return { mjml: withTableRowsKept(withClosedTags(cleaned)), repaired: cleaned !== mjml };
1318
+ }
1244
1319
  function illegalAttributes(tag, attributes) {
1245
1320
  const allowed = ALLOWED_ATTRIBUTES[tag];
1246
1321
  if (!allowed) return [];
@@ -2117,7 +2192,10 @@ var ICONS = {
2117
2192
  "mj-navbar-link": svg(
2118
2193
  '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>'
2119
2194
  ),
2120
- "mj-raw": svg('<path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/>')
2195
+ "mj-raw": svg('<path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/>'),
2196
+ "mj-table": svg(
2197
+ '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/><path d="M12 3v18"/>'
2198
+ )
2121
2199
  };
2122
2200
  function tagOf(c) {
2123
2201
  return String(c.get("tagName") || c.get("type") || "");
@@ -2893,8 +2971,15 @@ function Header({
2893
2971
  ] });
2894
2972
  }
2895
2973
 
2974
+ // src/components/editor/blocks.ts
2975
+ import grapesjs from "grapesjs";
2976
+ import grapesjsMJML from "grapesjs-mjml";
2977
+
2896
2978
  // src/components/editor/ids.ts
2897
- var ID_RE = { sec: /\bsec-([A-Za-z0-9_-]+)\b/, obj: /\bobj-([A-Za-z0-9_-]+)\b/ };
2979
+ var ID_RE = {
2980
+ sec: /(?:^|\s)sec-([A-Za-z0-9_-]+)(?=\s|$)/,
2981
+ obj: /(?:^|\s)obj-([A-Za-z0-9_-]+)(?=\s|$)/
2982
+ };
2898
2983
  function idOf(c, prefix) {
2899
2984
  return String(c.getAttributes()["css-class"] ?? "").match(ID_RE[prefix])?.[1] ?? null;
2900
2985
  }
@@ -2923,19 +3008,292 @@ function freshenCopiedIds(root, added) {
2923
3008
  const id = idOf(c, prefix);
2924
3009
  if (!id || !used(prefix, id, c)) continue;
2925
3010
  const cssClass = String(c.getAttributes()["css-class"]);
2926
- c.addAttributes({ "css-class": cssClass.replace(`${prefix}-${id}`, `${prefix}-${newId()}`) });
3011
+ const fresh = cssClass.replace(
3012
+ ID_RE[prefix],
3013
+ (token) => token.replace(`${prefix}-${id}`, `${prefix}-${newId()}`)
3014
+ );
3015
+ c.addAttributes({ "css-class": fresh });
3016
+ }
3017
+ });
3018
+ }
3019
+
3020
+ // src/components/editor/insert-block.ts
3021
+ var addsBlock = (e) => (e.key === "Enter" || e.key === " ") && !e.repeat && !e.ctrlKey && !e.metaKey && !e.altKey;
3022
+ var tileAttributes = (id) => ({ tabindex: "0", role: "button", "data-block": id });
3023
+ var tagOf5 = (c) => String(c.get("tagName") ?? "");
3024
+ function closest(c, tag) {
3025
+ for (let cur = c; cur; cur = cur.parent() ?? void 0) if (tagOf5(cur) === tag) return cur;
3026
+ return void 0;
3027
+ }
3028
+ function childOf(parent, c) {
3029
+ let cur = c;
3030
+ while (cur.parent() && cur.parent() !== parent) cur = cur.parent();
3031
+ return cur;
3032
+ }
3033
+ function insertBlock(editor, block) {
3034
+ const content = block.getContent();
3035
+ let body;
3036
+ editor.getWrapper()?.onAll((c) => {
3037
+ if (!body && tagOf5(c) === "mj-body") body = c;
3038
+ });
3039
+ if (typeof content !== "string" || !body) return void 0;
3040
+ const selected = editor.getSelected();
3041
+ const section = closest(selected, "mj-section");
3042
+ const sections = section?.parent() ?? body;
3043
+ const afterSection = section ? { at: section.index() + 1 } : {};
3044
+ const column = closest(selected, "mj-column");
3045
+ let added;
3046
+ if (/^\s*<mj-section\b/i.test(content)) {
3047
+ added = sections.append(content, afterSection)[0];
3048
+ } else if (column && selected) {
3049
+ const at = selected === column ? {} : { at: childOf(column, selected).index() + 1 };
3050
+ added = column.append(content, at)[0];
3051
+ } else {
3052
+ const [wrapper] = sections.append(
3053
+ `<mj-section><mj-column>${content}</mj-column></mj-section>`,
3054
+ afterSection
3055
+ );
3056
+ added = wrapper?.components().at(0)?.components().at(0);
3057
+ }
3058
+ if (!added) return void 0;
3059
+ editor.select(added);
3060
+ if (block.get("activate")) added.getView()?.onActive(void 0);
3061
+ return added;
3062
+ }
3063
+
3064
+ // src/components/editor/blocks.ts
3065
+ var EDITABLE_TAGS = /* @__PURE__ */ new Set([
3066
+ "mj-section",
3067
+ "mj-group",
3068
+ "mj-column",
3069
+ "mj-text",
3070
+ "mj-image",
3071
+ "mj-button",
3072
+ "mj-divider",
3073
+ "mj-spacer",
3074
+ "mj-social",
3075
+ "mj-social-element",
3076
+ "mj-navbar",
3077
+ "mj-navbar-link"
3078
+ ]);
3079
+ var CONTENT_TAGS = /* @__PURE__ */ new Set(["mj-text", "mj-button", "mj-social-element", "mj-navbar-link"]);
3080
+ var EDITOR_INTERNALS = /data-gjs-|__p\b|custom-name/i;
3081
+ var DROPPED_MARKUP = /<(?:script|style|link|meta|iframe|object|embed|form)\b/i;
3082
+ var INLINE_IMAGE = /\b(?:src|srcset|background-url|icon)\s*=\s*(?:"[^"]*data:|'[^']*data:|data:)/i;
3083
+ var LINK_IN_MARKUP = /[\s/"'](?:xlink:)?(?:href|action|formaction)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi;
3084
+ var validator;
3085
+ function blockProblems(mjml, editor = validator ??= grapesjs.init({
3086
+ headless: true,
3087
+ storageManager: false,
3088
+ telemetry: false,
3089
+ plugins: [grapesjsMJML]
3090
+ })) {
3091
+ if (EDITOR_INTERNALS.test(mjml)) return ["it contains GrapesJS's own attributes"];
3092
+ if (INLINE_IMAGE.test(mjml)) return ["it inlines an image as a data: URL"];
3093
+ let tree;
3094
+ try {
3095
+ tree = editor.runCommand("mjml-code-to-html", {
3096
+ mjml: `<mjml><mj-body>${withClosedTags(mjml)}</mj-body></mjml>`,
3097
+ validationLevel: "strict"
3098
+ }).json;
3099
+ } catch (e) {
3100
+ const errors = e.errors;
3101
+ if (!errors) throw e;
3102
+ return errors.map((error) => error.formattedMessage ?? error.message ?? String(error));
3103
+ }
3104
+ const problems = [];
3105
+ const top = tree.children?.find((c) => c.tagName === "mj-body")?.children ?? [];
3106
+ if (top.length !== 1 || top[0].tagName !== "mj-section") {
3107
+ problems.push("it is not exactly one mj-section");
3108
+ }
3109
+ const visit = (node, parent) => {
3110
+ const tag = node.tagName;
3111
+ const attributes = node.attributes ?? {};
3112
+ const content = node.content ?? "";
3113
+ if (!EDITABLE_TAGS.has(tag)) problems.push(`${tag} is not editable here`);
3114
+ const cssClass = attributes["css-class"] ?? "";
3115
+ if (ID_RE.sec.test(cssClass) || ID_RE.obj.test(cssClass)) {
3116
+ problems.push(`${tag} carries a sec-* or obj-* id`);
3117
+ }
3118
+ if ("mj-class" in attributes) problems.push(`${tag} uses mj-class`);
3119
+ const links = [attributes.href ?? ""];
3120
+ for (const match of content.matchAll(LINK_IN_MARKUP)) {
3121
+ links.push(match[1] ?? match[2] ?? match[3] ?? "");
3122
+ }
3123
+ const linked = tag === "mj-navbar-link" ? isSafeNavbarLink(parent?.attributes?.["base-url"] ?? "", attributes.href ?? "") : true;
3124
+ if (!linked || !links.every(isSafeLink)) {
3125
+ problems.push(`${tag} has a link scheme mail must not carry`);
3126
+ }
3127
+ if (CONTENT_TAGS.has(tag) ? /<mj-/i.test(content) || DROPPED_MARKUP.test(content) : content.trim()) {
3128
+ problems.push(`${tag} holds content the editor does not keep`);
3129
+ }
3130
+ node.children?.forEach((child) => visit(child, node));
3131
+ };
3132
+ top.forEach((node) => visit(node));
3133
+ return problems;
3134
+ }
3135
+ var escapeHtml3 = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
3136
+ function registerBlocks(editor, blocks, category, media) {
3137
+ for (const block of blocks) {
3138
+ const problems = editor.Blocks.get(block.id) ? [`the id is already taken`] : blockProblems(block.mjml, editor);
3139
+ if (problems.length > 0) {
3140
+ console.error(`Block "${block.id}" is left out of the palette: ${problems.join("; ")}`);
3141
+ continue;
3142
+ }
3143
+ editor.Blocks.add(block.id, {
3144
+ // GrapesJS writes the label into the tile as markup.
3145
+ label: escapeHtml3(block.label),
3146
+ category,
3147
+ media: block.media ?? media,
3148
+ content: withClosedTags(block.mjml),
3149
+ attributes: tileAttributes(block.id)
3150
+ });
3151
+ }
3152
+ }
3153
+
3154
+ // src/components/editor/builtin-blocks.ts
3155
+ var BUILTIN_BLOCKS = [
3156
+ "mj-1-column",
3157
+ "mj-2-columns",
3158
+ "mj-3-columns",
3159
+ "mj-text",
3160
+ "mj-image",
3161
+ "mj-button",
3162
+ "mj-divider",
3163
+ "mj-spacer",
3164
+ "mj-social-group",
3165
+ "mj-navbar"
3166
+ ];
3167
+ var LAYOUT_BLOCKS = /* @__PURE__ */ new Set(["mj-1-column", "mj-2-columns", "mj-3-columns"]);
3168
+ var CONTENT2 = {
3169
+ // The plugin's version links Google+ and shares the page: a bare network name makes the
3170
+ // icon open a "share this link" dialog, while `-noshare` links to the profile a footer
3171
+ // means. Without `alt` each link is an unnamed image to a screen reader.
3172
+ "mj-social-group": `<mj-social icon-size="24px" mode="horizontal">
3173
+ <mj-social-element name="facebook-noshare" href="https://facebook.com/" alt="Facebook"></mj-social-element>
3174
+ <mj-social-element name="instagram-noshare" href="https://instagram.com/" alt="Instagram"></mj-social-element>
3175
+ <mj-social-element name="x-noshare" href="https://x.com/" alt="X"></mj-social-element>
3176
+ </mj-social>`,
3177
+ // The plugin's sample is a via.placeholder.com picture, stored in the email and loaded by
3178
+ // every reader's mail client. Dropping one opens the image picker instead.
3179
+ "mj-image": "<mj-image></mj-image>"
3180
+ };
3181
+ function builtinBlock(labels) {
3182
+ return (id) => ({
3183
+ label: labels.blockLabels[id] ?? id,
3184
+ // By id: a regex on the label broke as soon as a host translated it.
3185
+ category: LAYOUT_BLOCKS.has(id) ? labels.blockCategoryLayout : labels.blockCategoryContent,
3186
+ ...id in CONTENT2 && { content: CONTENT2[id] },
3187
+ attributes: tileAttributes(id)
3188
+ });
3189
+ }
3190
+ var CANVAS_CSS = 'img:not([src]), img[src=""] { min-height: 120px; background: #f4f4f5; outline: 1px dashed #a1a1aa; outline-offset: -1px; }';
3191
+
3192
+ // src/components/editor/table-type.ts
3193
+ import { isSafeLink as isSafeLink2 } from "@mjml-agent-editor/core";
3194
+ var DROPPED = "script, style, link, iframe, frame, object, embed, applet, base, meta, animate, set, svg, math, template";
3195
+ var LINK_ATTRIBUTES = /* @__PURE__ */ new Set(["href", "xlink:href", "action", "formaction"]);
3196
+ function cleanRows(rows) {
3197
+ rows.querySelectorAll(DROPPED).forEach((el) => el.remove());
3198
+ rows.querySelectorAll("*").forEach((el) => {
3199
+ for (const { name, value } of [...el.attributes]) {
3200
+ const lower = name.toLowerCase();
3201
+ const script = /^(java|vb)script:/i.test(value.replace(/[\u0000-\u0020]/g, ""));
3202
+ if (lower.startsWith("on") || script || LINK_ATTRIBUTES.has(lower) && !isSafeLink2(value)) {
3203
+ el.removeAttribute(name);
3204
+ }
3205
+ }
3206
+ });
3207
+ }
3208
+ var ROW_STYLE = "pointer-events: all; display: table; width: 100%;";
3209
+ function tablePreview(doc, attributes, rows) {
3210
+ const attr = (name, fallback) => String(attributes[name] ?? fallback);
3211
+ const template = doc.createElement("template");
3212
+ template.innerHTML = `<table>${rows}</table>`;
3213
+ const table = doc.createElement("table");
3214
+ table.setAttribute("cellpadding", String(parseInt(attr("cellpadding", "0"), 10) || 0));
3215
+ table.setAttribute("cellspacing", String(parseInt(attr("cellspacing", "0"), 10) || 0));
3216
+ Object.assign(table.style, {
3217
+ width: attr("width", "100%"),
3218
+ border: attr("border", "none"),
3219
+ color: attr("color", "#000000"),
3220
+ fontFamily: attr("font-family", "Ubuntu, Helvetica, Arial, sans-serif"),
3221
+ fontSize: attr("font-size", "13px"),
3222
+ lineHeight: attr("line-height", "22px"),
3223
+ tableLayout: attr("table-layout", "auto")
3224
+ });
3225
+ for (const row of template.content.querySelectorAll("tr")) {
3226
+ const tr = table.insertRow();
3227
+ for (const cell2 of row.querySelectorAll(":scope > th, :scope > td")) {
3228
+ const copy = doc.createElement(cell2.tagName.toLowerCase());
3229
+ copy.textContent = cell2.textContent;
3230
+ const align = cell2.getAttribute("align");
3231
+ if (align && /^(left|right|center)$/i.test(align)) copy.setAttribute("align", align);
3232
+ tr.append(copy);
3233
+ }
3234
+ }
3235
+ const cell = doc.createElement("td");
3236
+ cell.style.padding = attr("padding", "10px 25px");
3237
+ cell.append(table);
3238
+ return cell;
3239
+ }
3240
+ function addTableType(editor) {
3241
+ const base = editor.Components.getType("default").view.prototype;
3242
+ editor.Components.addType("mj-table", {
3243
+ isComponent: (el) => {
3244
+ if (el.tagName?.toLowerCase() !== "mj-table") return false;
3245
+ const rows = el.querySelector(":scope > template");
3246
+ if (rows) cleanRows(rows.content);
3247
+ return { type: "mj-table", content: rows?.innerHTML ?? "", components: [] };
3248
+ },
3249
+ model: { defaults: { tagName: "mj-table", droppable: false, editable: false } },
3250
+ view: {
3251
+ tagName: "tr",
3252
+ // GrapesJS clears the element's attributes, style among them, when the model's change.
3253
+ updateAttributes(...args) {
3254
+ base.updateAttributes.apply(this, args);
3255
+ this.el.style.cssText = ROW_STYLE;
3256
+ },
3257
+ updateContent() {
3258
+ const { el, model } = this;
3259
+ el.style.cssText = ROW_STYLE;
3260
+ el.replaceChildren(
3261
+ tablePreview(el.ownerDocument, model.getAttributes(), String(model.get("content") ?? ""))
3262
+ );
3263
+ }
2927
3264
  }
2928
3265
  });
2929
3266
  }
2930
3267
 
2931
3268
  // src/components/editor/plugin-types.ts
3269
+ var COLUMN_CHILDREN = /* @__PURE__ */ new Set([
3270
+ "mj-accordion",
3271
+ "mj-button",
3272
+ "mj-carousel",
3273
+ "mj-divider",
3274
+ "mj-image",
3275
+ "mj-navbar",
3276
+ "mj-raw",
3277
+ "mj-social",
3278
+ "mj-spacer",
3279
+ "mj-table",
3280
+ "mj-text"
3281
+ ]);
2932
3282
  function fixPluginTypes(editor) {
3283
+ addTableType(editor);
2933
3284
  for (const type of editor.Components.getTypes()) {
2934
- if (!type.id.startsWith("mj-")) continue;
3285
+ if (!type.id.startsWith("mj-") || type.id === "mj-table") continue;
2935
3286
  const base = type.model.prototype.getAttrToHTML;
2936
3287
  editor.Components.addType(type.id, {
2937
3288
  model: {
2938
- defaults: { "style-default": {} },
3289
+ defaults: {
3290
+ "style-default": {},
3291
+ ...type.id === "mj-column" && {
3292
+ droppable: (source) => COLUMN_CHILDREN.has(String(source.get("tagName")))
3293
+ },
3294
+ // GrapesJS's own is an SVG's markup, which the canvas wrote into `src` and saved.
3295
+ ...type.id === "mj-image" && { src: "" }
3296
+ },
2939
3297
  getAttrToHTML() {
2940
3298
  const attributes = base.call(this);
2941
3299
  delete attributes.__p;
@@ -2957,16 +3315,17 @@ var TYPE_LABEL = {
2957
3315
  "mj-image": "Image",
2958
3316
  "mj-column": "Column"
2959
3317
  };
2960
- function tagOf5(c) {
3318
+ function tagOf6(c) {
2961
3319
  return String(c.get("tagName") || c.get("type") || "");
2962
3320
  }
2963
3321
  var SPARKLES_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/></svg>';
2964
3322
  var IMAGE_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.3 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10l-3.1-3.1a2 2 0 0 0-2.814.014L6 21"/><path d="m14 19.5 3-3 3 3"/><path d="M17 22v-5.5"/><circle cx="9" cy="9" r="2"/></svg>';
3323
+ var SECTION_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="7" x="3" y="3" rx="1"/><rect width="9" height="7" x="3" y="14" rx="1"/><rect width="5" height="7" x="16" y="14" rx="1"/></svg>';
2965
3324
  function isSection(c) {
2966
- return tagOf5(c) === "mj-section";
3325
+ return tagOf6(c) === "mj-section";
2967
3326
  }
2968
3327
  function isCommentable(c) {
2969
- const t = tagOf5(c);
3328
+ const t = tagOf6(c);
2970
3329
  return t === "mj-section" || t in TYPE_LABEL;
2971
3330
  }
2972
3331
  function closestSection(c) {
@@ -2978,29 +3337,11 @@ function closestSection(c) {
2978
3337
  return null;
2979
3338
  }
2980
3339
  function crumbName(c, labels) {
2981
- const t = tagOf5(c);
3340
+ const t = tagOf6(c);
2982
3341
  return labels.elementNames[t] ?? t.replace(/^mj-/, "");
2983
3342
  }
2984
- var BLOCKS = [
2985
- "mj-1-column",
2986
- "mj-2-columns",
2987
- "mj-3-columns",
2988
- "mj-text",
2989
- "mj-image",
2990
- "mj-button",
2991
- "mj-divider",
2992
- "mj-spacer",
2993
- "mj-social-group",
2994
- "mj-navbar"
2995
- ];
2996
- var LAYOUT_BLOCKS = /* @__PURE__ */ new Set(["mj-1-column", "mj-2-columns", "mj-3-columns"]);
2997
- var SOCIAL_BLOCK = `<mj-social icon-size="24px" mode="horizontal">
2998
- <mj-social-element name="facebook-noshare" href="https://facebook.com/"></mj-social-element>
2999
- <mj-social-element name="instagram-noshare" href="https://instagram.com/"></mj-social-element>
3000
- <mj-social-element name="x-noshare" href="https://x.com/"></mj-social-element>
3001
- </mj-social>`;
3002
3343
  function objectLabel(c) {
3003
- const base = TYPE_LABEL[tagOf5(c)] ?? tagOf5(c);
3344
+ const base = TYPE_LABEL[tagOf6(c)] ?? tagOf6(c);
3004
3345
  const text = String(c.getEl?.()?.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 30);
3005
3346
  return text ? `${base}: \u201C${text}\u201D` : base;
3006
3347
  }
@@ -3068,7 +3409,13 @@ function setupRichText(editor, labels) {
3068
3409
  result: (r, action) => r.exec("hiliteColor", val(action, "input"))
3069
3410
  });
3070
3411
  }
3071
- function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
3412
+ function EmailEditor({
3413
+ docId,
3414
+ onReady,
3415
+ commentsRefresh,
3416
+ onOpenCountChange,
3417
+ blocks = []
3418
+ }) {
3072
3419
  const documents = useDocumentStore();
3073
3420
  const labels = useLabels();
3074
3421
  const loadingRef = useRef5(false);
@@ -3090,8 +3437,15 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
3090
3437
  const readOnlyRef = useRef5(false);
3091
3438
  const applyReadOnly = useRef5(async () => {
3092
3439
  });
3093
- useEffect5(() => void applyReadOnly.current(turnRunning), [turnRunning]);
3094
- useEffect5(() => () => setLocked(false), [setLocked]);
3440
+ const [turn] = useState5(createTurnFollower);
3441
+ useEffect5(() => turn.turn(turnRunning), [turn, turnRunning]);
3442
+ useEffect5(() => {
3443
+ turn.mount();
3444
+ return () => {
3445
+ turn.unmount();
3446
+ setLocked(false);
3447
+ };
3448
+ }, [turn, setLocked]);
3095
3449
  const [crumbs, setCrumbs] = useState5([]);
3096
3450
  const [picker, setPicker] = useState5(
3097
3451
  null
@@ -3102,7 +3456,7 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
3102
3456
  const chain = [];
3103
3457
  let cur = c;
3104
3458
  while (cur) {
3105
- const t = tagOf5(cur);
3459
+ const t = tagOf6(cur);
3106
3460
  if (!t || t === "mj-body" || t === "wrapper" || t === "Wrapper") break;
3107
3461
  chain.unshift(cur);
3108
3462
  cur = cur.parent() ?? void 0;
@@ -3151,7 +3505,7 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
3151
3505
  if (isSection(c)) addIdClass(c, "sec");
3152
3506
  if (!isCommentable(c)) return;
3153
3507
  const toolbar = [...c.get("toolbar") ?? []];
3154
- if (tagOf5(c) === "mj-image" && !toolbar.some((t) => t.command === "open-image-picker")) {
3508
+ if (tagOf6(c) === "mj-image" && !toolbar.some((t) => t.command === "open-image-picker")) {
3155
3509
  toolbar.push({
3156
3510
  command: "open-image-picker",
3157
3511
  label: IMAGE_SVG,
@@ -3171,7 +3525,7 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
3171
3525
  editor.Commands.add("open-image-picker", {
3172
3526
  run(ed) {
3173
3527
  const target = ed.getSelected();
3174
- if (!target || tagOf5(target) !== "mj-image") return;
3528
+ if (!target || tagOf6(target) !== "mj-image") return;
3175
3529
  const am = ed.AssetManager;
3176
3530
  am.open({
3177
3531
  target,
@@ -3191,6 +3545,7 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
3191
3545
  });
3192
3546
  setupRichText(editor, labels);
3193
3547
  fixPluginTypes(editor);
3548
+ registerBlocks(editor, blocks, labels.blockCategorySections, SECTION_SVG);
3194
3549
  setupLayerNames(editor, labels);
3195
3550
  const STRUCTURE = /* @__PURE__ */ new Set(["mj-wrapper", "mj-section", "mj-column"]);
3196
3551
  const showBodyInLayers = () => {
@@ -3198,14 +3553,14 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
3198
3553
  if (!body) return;
3199
3554
  editor.Layers.setRoot(body);
3200
3555
  body.onAll((c) => {
3201
- if (STRUCTURE.has(tagOf5(c))) editor.Layers.setOpen(c, true);
3556
+ if (STRUCTURE.has(tagOf6(c))) editor.Layers.setOpen(c, true);
3202
3557
  });
3203
3558
  };
3204
3559
  const keepStructureOpen = () => {
3205
3560
  const layers = editor.Layers;
3206
3561
  const opened = layers.model?.get("opened") ?? {};
3207
3562
  for (const [cid, c] of Object.entries(opened)) {
3208
- if (STRUCTURE.has(tagOf5(c))) delete opened[cid];
3563
+ if (STRUCTURE.has(tagOf6(c))) delete opened[cid];
3209
3564
  }
3210
3565
  };
3211
3566
  editor.on("component:add", (c) => {
@@ -3233,16 +3588,16 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
3233
3588
  editor.on("component:deselected", () => {
3234
3589
  buildCrumbs(editor.getSelected());
3235
3590
  });
3236
- let stored = "";
3591
+ const baseline = createSaveBaseline();
3237
3592
  const save = async () => {
3238
3593
  const view = editor.getEditing()?.getView();
3239
3594
  await view?.syncContent?.();
3240
3595
  const mjml = editor.getHtml();
3241
- if (mjml === stored) return;
3596
+ if (!baseline.changed(mjml)) return;
3242
3597
  setSave("saving");
3243
3598
  try {
3244
3599
  await documents.save(docId, { mjml, projectData: editor.getProjectData() });
3245
- stored = mjml;
3600
+ baseline.saved(mjml);
3246
3601
  setSave("saved");
3247
3602
  } catch (e) {
3248
3603
  setSave("error");
@@ -3273,6 +3628,7 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
3273
3628
  });
3274
3629
  }
3275
3630
  const lock = (on) => {
3631
+ if (!turn.mounted) return;
3276
3632
  readOnlyRef.current = on;
3277
3633
  setLocked(on);
3278
3634
  notifyState();
@@ -3298,37 +3654,36 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
3298
3654
  return;
3299
3655
  }
3300
3656
  lock(false);
3657
+ queue.schedule();
3301
3658
  };
3302
3659
  const reload = () => queue.run(async () => {
3303
3660
  const fresh = await documents.get(docId);
3304
- if (fresh.mjml !== stored) loadMjml(fresh.mjml);
3661
+ if (fresh.mjml !== baseline.stored) loadMjml(fresh.mjml);
3305
3662
  });
3306
3663
  const loadMjml = (mjml) => {
3307
3664
  loadingRef.current = true;
3308
3665
  let repaired = false;
3666
+ const adds = !mjml || listSections(mjml).some((s) => !s.sectionId);
3309
3667
  try {
3310
3668
  queue.cancel();
3311
- stored = mjml;
3312
- const source = withoutEditorProps(mjml || STARTER_MJML);
3313
- repaired = source !== (mjml || STARTER_MJML);
3314
- editor.setComponents(source);
3315
- editor.getWrapper()?.find("mj-section").forEach(decorate);
3316
- for (const t of Object.keys(TYPE_LABEL)) {
3317
- editor.getWrapper()?.find(t).forEach(decorate);
3318
- }
3669
+ baseline.load(mjml, null);
3670
+ const source = sourceForEditor(mjml || STARTER_MJML);
3671
+ repaired = source.repaired;
3672
+ editor.setComponents(source.mjml);
3319
3673
  editor.getWrapper()?.onAll((c) => {
3320
- const illegal = illegalAttributes(tagOf5(c), c.getAttributes());
3674
+ const illegal = illegalAttributes(tagOf6(c), c.getAttributes());
3321
3675
  if (illegal.length === 0) return;
3322
3676
  c.removeAttributes(illegal);
3323
3677
  repaired = true;
3324
3678
  });
3679
+ if (!repaired && !adds) baseline.load(mjml, editor.getHtml());
3325
3680
  showBodyInLayers();
3326
3681
  editor.UndoManager.clear();
3327
3682
  } finally {
3328
3683
  loadingRef.current = false;
3329
3684
  }
3330
3685
  notifyState();
3331
- if (repaired && !readOnlyRef.current) queue.schedule();
3686
+ if ((repaired || adds) && !readOnlyRef.current) queue.schedule();
3332
3687
  };
3333
3688
  const ensureHighlightStyles = () => {
3334
3689
  const cdoc = editor.Canvas.getDocument();
@@ -3350,7 +3705,10 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
3350
3705
  cdoc.head.appendChild(style);
3351
3706
  };
3352
3707
  const sectionEl = (sectionId) => {
3353
- const comp = editor.getWrapper()?.find("mj-section").find((c) => sectionIdOf(c) === sectionId);
3708
+ let comp;
3709
+ editor.getWrapper()?.onAll((c) => {
3710
+ if (!comp && isSection(c) && sectionIdOf(c) === sectionId) comp = c;
3711
+ });
3354
3712
  return comp?.getEl?.() ?? void 0;
3355
3713
  };
3356
3714
  const compiledHtml = () => {
@@ -3361,9 +3719,9 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
3361
3719
  return editor.getHtml();
3362
3720
  }
3363
3721
  };
3722
+ turn.ready((on) => void applyReadOnly.current(on));
3364
3723
  try {
3365
- const doc = await documents.get(docId);
3366
- loadMjml(doc.mjml);
3724
+ await queue.run(async () => loadMjml((await documents.get(docId)).mjml));
3367
3725
  } catch (e) {
3368
3726
  toast2.error(labels.documentLoadFailed);
3369
3727
  console.error(e);
@@ -3418,7 +3776,7 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
3418
3776
  return /* @__PURE__ */ jsx8(
3419
3777
  GjsEditor,
3420
3778
  {
3421
- grapesjs,
3779
+ grapesjs: grapesjs2,
3422
3780
  className: "h-full",
3423
3781
  options: {
3424
3782
  height: "100%",
@@ -3427,6 +3785,7 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
3427
3785
  // app.grapesjs.com and leave a session-storage entry, which a host would then have
3428
3786
  // to disclose and, for the entry, ask consent for.
3429
3787
  telemetry: false,
3788
+ canvasCss: CANVAS_CSS,
3430
3789
  fromElement: false,
3431
3790
  panels: { defaults: [] },
3432
3791
  blockManager: { appendTo: "#gjs-blocks" },
@@ -3446,7 +3805,12 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
3446
3805
  close: () => setPicker(null)
3447
3806
  }
3448
3807
  },
3449
- canvas: { customBadgeLabel: badgeLabel(labels) },
3808
+ canvas: {
3809
+ customBadgeLabel: badgeLabel(labels),
3810
+ // A file or text dragged in from outside lands as `<img>` or `<div>`, which no MJML
3811
+ // validator accepts; drags from the palette are unaffected.
3812
+ allowExternalDrop: false
3813
+ },
3450
3814
  deviceManager: {
3451
3815
  devices: [
3452
3816
  { id: "desktop", name: "Desktop", width: "" },
@@ -3454,14 +3818,9 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
3454
3818
  ]
3455
3819
  },
3456
3820
  plugins: [
3457
- (editor) => grapesjsMJML(editor, {
3458
- blocks: BLOCKS,
3459
- block: (id) => ({
3460
- label: labels.blockLabels[id] ?? id,
3461
- // By id: a regex on the label broke as soon as a host translated it.
3462
- category: LAYOUT_BLOCKS.has(id) ? labels.blockCategoryLayout : labels.blockCategoryContent,
3463
- ...id === "mj-social-group" && { content: SOCIAL_BLOCK }
3464
- })
3821
+ (editor) => grapesjsMJML2(editor, {
3822
+ blocks: BUILTIN_BLOCKS,
3823
+ block: builtinBlock(labels)
3465
3824
  })
3466
3825
  ]
3467
3826
  },
@@ -3543,7 +3902,7 @@ function LeftSidebar({
3543
3902
  const selectBody = () => {
3544
3903
  let body;
3545
3904
  editor?.getWrapper()?.onAll((c) => {
3546
- if (!body && tagOf5(c) === "mj-body") body = c;
3905
+ if (!body && tagOf6(c) === "mj-body") body = c;
3547
3906
  });
3548
3907
  if (body) editor?.select(body);
3549
3908
  };
@@ -3551,6 +3910,13 @@ function LeftSidebar({
3551
3910
  setLayersExpanded(expand);
3552
3911
  editor?.getWrapper()?.onAll((c) => c.set("open", expand));
3553
3912
  };
3913
+ const onTileKey = (e) => {
3914
+ const tile = e.target.closest("[data-block]");
3915
+ const block = tile && editor?.Blocks.get(tile.dataset.block ?? "");
3916
+ if (!editor || !block || !addsBlock(e)) return;
3917
+ e.preventDefault();
3918
+ if (insertBlock(editor, block)) toast2(labels.blockAdded(tile.textContent?.trim() ?? ""));
3919
+ };
3554
3920
  const filterBlocks = (q) => {
3555
3921
  setBlockSearch(q);
3556
3922
  const root = document.getElementById("gjs-blocks");
@@ -3607,7 +3973,7 @@ function LeftSidebar({
3607
3973
  /* @__PURE__ */ jsx8("kbd", { className: "pointer-events-none absolute top-1/2 right-4 -translate-y-1/2 rounded border border-panel-border bg-panel px-1 py-0.5 font-mono text-[10px] text-panel-muted-fg", children: "/" })
3608
3974
  ] }),
3609
3975
  noBlockMatches && /* @__PURE__ */ jsx8("p", { className: "px-3 py-6 text-center text-xs text-panel-muted-fg", children: labels.noBlocksMatch }),
3610
- /* @__PURE__ */ jsx8("div", { id: "gjs-blocks", className: "min-h-0 flex-1 overflow-y-auto" })
3976
+ /* @__PURE__ */ jsx8("div", { id: "gjs-blocks", className: "min-h-0 flex-1 overflow-y-auto", onKeyDown: onTileKey })
3611
3977
  ] }),
3612
3978
  /* @__PURE__ */ jsx8("div", { className: cn("min-h-0 flex-1 overflow-y-auto", view !== "settings" && "hidden"), children: /* @__PURE__ */ jsx8(
3613
3979
  Inspector,
@@ -3687,6 +4053,7 @@ function Breadcrumb({
3687
4053
  ] }, i)) });
3688
4054
  }
3689
4055
  export {
3690
- EmailEditor
4056
+ EmailEditor,
4057
+ blockProblems
3691
4058
  };
3692
4059
  //# sourceMappingURL=canvas.js.map