@mjml-agent-editor/editor 0.2.0 → 0.3.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-K7KQC7LX.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;
@@ -397,6 +454,7 @@ import {
397
454
  } from "lucide-react";
398
455
 
399
456
  // src/components/editor/inspector/values.ts
457
+ import { isSafeLink, isSafeNavbarLink } from "@mjml-agent-editor/core";
400
458
  var SIDES = ["top", "right", "bottom", "left"];
401
459
  function expandBox(value) {
402
460
  const parts = value.trim().split(/\s+/).filter(Boolean);
@@ -427,15 +485,6 @@ function parseNetwork(name) {
427
485
  function networkName(network, share) {
428
486
  return share || network === "custom" ? network : `${network}${NO_SHARE}`;
429
487
  }
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
488
 
440
489
  // src/components/editor/inspector/controls.tsx
441
490
  import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
@@ -1241,6 +1290,23 @@ function withoutEditorProps(mjml) {
1241
1290
  )
1242
1291
  );
1243
1292
  }
1293
+ function withClosedTags(mjml) {
1294
+ return mjml.replace(START_TAG, (tag) => {
1295
+ const selfClosed = /^<(mj-[\w-]+)([\s\S]*?)\s*\/>$/i.exec(tag);
1296
+ return selfClosed ? `<${selfClosed[1]}${selfClosed[2]}></${selfClosed[1]}>` : tag;
1297
+ });
1298
+ }
1299
+ var TABLE = /<!--[\s\S]*?(?:-->|$)|(<mj-table\b(?:[^>"']|"[^"]*"|'[^']*')*>)((?:<!--[\s\S]*?-->|(?!<!--)[\s\S])*?)(<\/mj-table\s*>)/gi;
1300
+ function withTableRowsKept(mjml) {
1301
+ return mjml.replace(
1302
+ TABLE,
1303
+ (match, open, rows, close) => open ? `${open}<template>${rows}</template>${close}` : match
1304
+ );
1305
+ }
1306
+ function sourceForEditor(mjml) {
1307
+ const cleaned = withoutEditorProps(mjml);
1308
+ return { mjml: withTableRowsKept(withClosedTags(cleaned)), repaired: cleaned !== mjml };
1309
+ }
1244
1310
  function illegalAttributes(tag, attributes) {
1245
1311
  const allowed = ALLOWED_ATTRIBUTES[tag];
1246
1312
  if (!allowed) return [];
@@ -2117,7 +2183,10 @@ var ICONS = {
2117
2183
  "mj-navbar-link": svg(
2118
2184
  '<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
2185
  ),
2120
- "mj-raw": svg('<path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/>')
2186
+ "mj-raw": svg('<path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/>'),
2187
+ "mj-table": svg(
2188
+ '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/><path d="M12 3v18"/>'
2189
+ )
2121
2190
  };
2122
2191
  function tagOf(c) {
2123
2192
  return String(c.get("tagName") || c.get("type") || "");
@@ -2893,8 +2962,15 @@ function Header({
2893
2962
  ] });
2894
2963
  }
2895
2964
 
2965
+ // src/components/editor/blocks.ts
2966
+ import grapesjs from "grapesjs";
2967
+ import grapesjsMJML from "grapesjs-mjml";
2968
+
2896
2969
  // src/components/editor/ids.ts
2897
- var ID_RE = { sec: /\bsec-([A-Za-z0-9_-]+)\b/, obj: /\bobj-([A-Za-z0-9_-]+)\b/ };
2970
+ var ID_RE = {
2971
+ sec: /(?:^|\s)sec-([A-Za-z0-9_-]+)(?=\s|$)/,
2972
+ obj: /(?:^|\s)obj-([A-Za-z0-9_-]+)(?=\s|$)/
2973
+ };
2898
2974
  function idOf(c, prefix) {
2899
2975
  return String(c.getAttributes()["css-class"] ?? "").match(ID_RE[prefix])?.[1] ?? null;
2900
2976
  }
@@ -2923,19 +2999,292 @@ function freshenCopiedIds(root, added) {
2923
2999
  const id = idOf(c, prefix);
2924
3000
  if (!id || !used(prefix, id, c)) continue;
2925
3001
  const cssClass = String(c.getAttributes()["css-class"]);
2926
- c.addAttributes({ "css-class": cssClass.replace(`${prefix}-${id}`, `${prefix}-${newId()}`) });
3002
+ const fresh = cssClass.replace(
3003
+ ID_RE[prefix],
3004
+ (token) => token.replace(`${prefix}-${id}`, `${prefix}-${newId()}`)
3005
+ );
3006
+ c.addAttributes({ "css-class": fresh });
3007
+ }
3008
+ });
3009
+ }
3010
+
3011
+ // src/components/editor/insert-block.ts
3012
+ var addsBlock = (e) => (e.key === "Enter" || e.key === " ") && !e.repeat && !e.ctrlKey && !e.metaKey && !e.altKey;
3013
+ var tileAttributes = (id) => ({ tabindex: "0", role: "button", "data-block": id });
3014
+ var tagOf5 = (c) => String(c.get("tagName") ?? "");
3015
+ function closest(c, tag) {
3016
+ for (let cur = c; cur; cur = cur.parent() ?? void 0) if (tagOf5(cur) === tag) return cur;
3017
+ return void 0;
3018
+ }
3019
+ function childOf(parent, c) {
3020
+ let cur = c;
3021
+ while (cur.parent() && cur.parent() !== parent) cur = cur.parent();
3022
+ return cur;
3023
+ }
3024
+ function insertBlock(editor, block) {
3025
+ const content = block.getContent();
3026
+ let body;
3027
+ editor.getWrapper()?.onAll((c) => {
3028
+ if (!body && tagOf5(c) === "mj-body") body = c;
3029
+ });
3030
+ if (typeof content !== "string" || !body) return void 0;
3031
+ const selected = editor.getSelected();
3032
+ const section = closest(selected, "mj-section");
3033
+ const sections = section?.parent() ?? body;
3034
+ const afterSection = section ? { at: section.index() + 1 } : {};
3035
+ const column = closest(selected, "mj-column");
3036
+ let added;
3037
+ if (/^\s*<mj-section\b/i.test(content)) {
3038
+ added = sections.append(content, afterSection)[0];
3039
+ } else if (column && selected) {
3040
+ const at = selected === column ? {} : { at: childOf(column, selected).index() + 1 };
3041
+ added = column.append(content, at)[0];
3042
+ } else {
3043
+ const [wrapper] = sections.append(
3044
+ `<mj-section><mj-column>${content}</mj-column></mj-section>`,
3045
+ afterSection
3046
+ );
3047
+ added = wrapper?.components().at(0)?.components().at(0);
3048
+ }
3049
+ if (!added) return void 0;
3050
+ editor.select(added);
3051
+ if (block.get("activate")) added.getView()?.onActive(void 0);
3052
+ return added;
3053
+ }
3054
+
3055
+ // src/components/editor/blocks.ts
3056
+ var EDITABLE_TAGS = /* @__PURE__ */ new Set([
3057
+ "mj-section",
3058
+ "mj-group",
3059
+ "mj-column",
3060
+ "mj-text",
3061
+ "mj-image",
3062
+ "mj-button",
3063
+ "mj-divider",
3064
+ "mj-spacer",
3065
+ "mj-social",
3066
+ "mj-social-element",
3067
+ "mj-navbar",
3068
+ "mj-navbar-link"
3069
+ ]);
3070
+ var CONTENT_TAGS = /* @__PURE__ */ new Set(["mj-text", "mj-button", "mj-social-element", "mj-navbar-link"]);
3071
+ var EDITOR_INTERNALS = /data-gjs-|__p\b|custom-name/i;
3072
+ var DROPPED_MARKUP = /<(?:script|style|link|meta|iframe|object|embed|form)\b/i;
3073
+ var INLINE_IMAGE = /\b(?:src|srcset|background-url|icon)\s*=\s*(?:"[^"]*data:|'[^']*data:|data:)/i;
3074
+ var LINK_IN_MARKUP = /[\s/"'](?:xlink:)?(?:href|action|formaction)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi;
3075
+ var validator;
3076
+ function blockProblems(mjml, editor = validator ??= grapesjs.init({
3077
+ headless: true,
3078
+ storageManager: false,
3079
+ telemetry: false,
3080
+ plugins: [grapesjsMJML]
3081
+ })) {
3082
+ if (EDITOR_INTERNALS.test(mjml)) return ["it contains GrapesJS's own attributes"];
3083
+ if (INLINE_IMAGE.test(mjml)) return ["it inlines an image as a data: URL"];
3084
+ let tree;
3085
+ try {
3086
+ tree = editor.runCommand("mjml-code-to-html", {
3087
+ mjml: `<mjml><mj-body>${withClosedTags(mjml)}</mj-body></mjml>`,
3088
+ validationLevel: "strict"
3089
+ }).json;
3090
+ } catch (e) {
3091
+ const errors = e.errors;
3092
+ if (!errors) throw e;
3093
+ return errors.map((error) => error.formattedMessage ?? error.message ?? String(error));
3094
+ }
3095
+ const problems = [];
3096
+ const top = tree.children?.find((c) => c.tagName === "mj-body")?.children ?? [];
3097
+ if (top.length !== 1 || top[0].tagName !== "mj-section") {
3098
+ problems.push("it is not exactly one mj-section");
3099
+ }
3100
+ const visit = (node, parent) => {
3101
+ const tag = node.tagName;
3102
+ const attributes = node.attributes ?? {};
3103
+ const content = node.content ?? "";
3104
+ if (!EDITABLE_TAGS.has(tag)) problems.push(`${tag} is not editable here`);
3105
+ const cssClass = attributes["css-class"] ?? "";
3106
+ if (ID_RE.sec.test(cssClass) || ID_RE.obj.test(cssClass)) {
3107
+ problems.push(`${tag} carries a sec-* or obj-* id`);
3108
+ }
3109
+ if ("mj-class" in attributes) problems.push(`${tag} uses mj-class`);
3110
+ const links = [attributes.href ?? ""];
3111
+ for (const match of content.matchAll(LINK_IN_MARKUP)) {
3112
+ links.push(match[1] ?? match[2] ?? match[3] ?? "");
3113
+ }
3114
+ const linked = tag === "mj-navbar-link" ? isSafeNavbarLink(parent?.attributes?.["base-url"] ?? "", attributes.href ?? "") : true;
3115
+ if (!linked || !links.every(isSafeLink)) {
3116
+ problems.push(`${tag} has a link scheme mail must not carry`);
3117
+ }
3118
+ if (CONTENT_TAGS.has(tag) ? /<mj-/i.test(content) || DROPPED_MARKUP.test(content) : content.trim()) {
3119
+ problems.push(`${tag} holds content the editor does not keep`);
3120
+ }
3121
+ node.children?.forEach((child) => visit(child, node));
3122
+ };
3123
+ top.forEach((node) => visit(node));
3124
+ return problems;
3125
+ }
3126
+ var escapeHtml3 = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
3127
+ function registerBlocks(editor, blocks, category, media) {
3128
+ for (const block of blocks) {
3129
+ const problems = editor.Blocks.get(block.id) ? [`the id is already taken`] : blockProblems(block.mjml, editor);
3130
+ if (problems.length > 0) {
3131
+ console.error(`Block "${block.id}" is left out of the palette: ${problems.join("; ")}`);
3132
+ continue;
3133
+ }
3134
+ editor.Blocks.add(block.id, {
3135
+ // GrapesJS writes the label into the tile as markup.
3136
+ label: escapeHtml3(block.label),
3137
+ category,
3138
+ media: block.media ?? media,
3139
+ content: withClosedTags(block.mjml),
3140
+ attributes: tileAttributes(block.id)
3141
+ });
3142
+ }
3143
+ }
3144
+
3145
+ // src/components/editor/builtin-blocks.ts
3146
+ var BUILTIN_BLOCKS = [
3147
+ "mj-1-column",
3148
+ "mj-2-columns",
3149
+ "mj-3-columns",
3150
+ "mj-text",
3151
+ "mj-image",
3152
+ "mj-button",
3153
+ "mj-divider",
3154
+ "mj-spacer",
3155
+ "mj-social-group",
3156
+ "mj-navbar"
3157
+ ];
3158
+ var LAYOUT_BLOCKS = /* @__PURE__ */ new Set(["mj-1-column", "mj-2-columns", "mj-3-columns"]);
3159
+ var CONTENT2 = {
3160
+ // The plugin's version links Google+ and shares the page: a bare network name makes the
3161
+ // icon open a "share this link" dialog, while `-noshare` links to the profile a footer
3162
+ // means. Without `alt` each link is an unnamed image to a screen reader.
3163
+ "mj-social-group": `<mj-social icon-size="24px" mode="horizontal">
3164
+ <mj-social-element name="facebook-noshare" href="https://facebook.com/" alt="Facebook"></mj-social-element>
3165
+ <mj-social-element name="instagram-noshare" href="https://instagram.com/" alt="Instagram"></mj-social-element>
3166
+ <mj-social-element name="x-noshare" href="https://x.com/" alt="X"></mj-social-element>
3167
+ </mj-social>`,
3168
+ // The plugin's sample is a via.placeholder.com picture, stored in the email and loaded by
3169
+ // every reader's mail client. Dropping one opens the image picker instead.
3170
+ "mj-image": "<mj-image></mj-image>"
3171
+ };
3172
+ function builtinBlock(labels) {
3173
+ return (id) => ({
3174
+ label: labels.blockLabels[id] ?? id,
3175
+ // By id: a regex on the label broke as soon as a host translated it.
3176
+ category: LAYOUT_BLOCKS.has(id) ? labels.blockCategoryLayout : labels.blockCategoryContent,
3177
+ ...id in CONTENT2 && { content: CONTENT2[id] },
3178
+ attributes: tileAttributes(id)
3179
+ });
3180
+ }
3181
+ var CANVAS_CSS = 'img:not([src]), img[src=""] { min-height: 120px; background: #f4f4f5; outline: 1px dashed #a1a1aa; outline-offset: -1px; }';
3182
+
3183
+ // src/components/editor/table-type.ts
3184
+ import { isSafeLink as isSafeLink2 } from "@mjml-agent-editor/core";
3185
+ var DROPPED = "script, style, link, iframe, frame, object, embed, applet, base, meta, animate, set, svg, math, template";
3186
+ var LINK_ATTRIBUTES = /* @__PURE__ */ new Set(["href", "xlink:href", "action", "formaction"]);
3187
+ function cleanRows(rows) {
3188
+ rows.querySelectorAll(DROPPED).forEach((el) => el.remove());
3189
+ rows.querySelectorAll("*").forEach((el) => {
3190
+ for (const { name, value } of [...el.attributes]) {
3191
+ const lower = name.toLowerCase();
3192
+ const script = /^(java|vb)script:/i.test(value.replace(/[\u0000-\u0020]/g, ""));
3193
+ if (lower.startsWith("on") || script || LINK_ATTRIBUTES.has(lower) && !isSafeLink2(value)) {
3194
+ el.removeAttribute(name);
3195
+ }
3196
+ }
3197
+ });
3198
+ }
3199
+ var ROW_STYLE = "pointer-events: all; display: table; width: 100%;";
3200
+ function tablePreview(doc, attributes, rows) {
3201
+ const attr = (name, fallback) => String(attributes[name] ?? fallback);
3202
+ const template = doc.createElement("template");
3203
+ template.innerHTML = `<table>${rows}</table>`;
3204
+ const table = doc.createElement("table");
3205
+ table.setAttribute("cellpadding", String(parseInt(attr("cellpadding", "0"), 10) || 0));
3206
+ table.setAttribute("cellspacing", String(parseInt(attr("cellspacing", "0"), 10) || 0));
3207
+ Object.assign(table.style, {
3208
+ width: attr("width", "100%"),
3209
+ border: attr("border", "none"),
3210
+ color: attr("color", "#000000"),
3211
+ fontFamily: attr("font-family", "Ubuntu, Helvetica, Arial, sans-serif"),
3212
+ fontSize: attr("font-size", "13px"),
3213
+ lineHeight: attr("line-height", "22px"),
3214
+ tableLayout: attr("table-layout", "auto")
3215
+ });
3216
+ for (const row of template.content.querySelectorAll("tr")) {
3217
+ const tr = table.insertRow();
3218
+ for (const cell2 of row.querySelectorAll(":scope > th, :scope > td")) {
3219
+ const copy = doc.createElement(cell2.tagName.toLowerCase());
3220
+ copy.textContent = cell2.textContent;
3221
+ const align = cell2.getAttribute("align");
3222
+ if (align && /^(left|right|center)$/i.test(align)) copy.setAttribute("align", align);
3223
+ tr.append(copy);
3224
+ }
3225
+ }
3226
+ const cell = doc.createElement("td");
3227
+ cell.style.padding = attr("padding", "10px 25px");
3228
+ cell.append(table);
3229
+ return cell;
3230
+ }
3231
+ function addTableType(editor) {
3232
+ const base = editor.Components.getType("default").view.prototype;
3233
+ editor.Components.addType("mj-table", {
3234
+ isComponent: (el) => {
3235
+ if (el.tagName?.toLowerCase() !== "mj-table") return false;
3236
+ const rows = el.querySelector(":scope > template");
3237
+ if (rows) cleanRows(rows.content);
3238
+ return { type: "mj-table", content: rows?.innerHTML ?? "", components: [] };
3239
+ },
3240
+ model: { defaults: { tagName: "mj-table", droppable: false, editable: false } },
3241
+ view: {
3242
+ tagName: "tr",
3243
+ // GrapesJS clears the element's attributes, style among them, when the model's change.
3244
+ updateAttributes(...args) {
3245
+ base.updateAttributes.apply(this, args);
3246
+ this.el.style.cssText = ROW_STYLE;
3247
+ },
3248
+ updateContent() {
3249
+ const { el, model } = this;
3250
+ el.style.cssText = ROW_STYLE;
3251
+ el.replaceChildren(
3252
+ tablePreview(el.ownerDocument, model.getAttributes(), String(model.get("content") ?? ""))
3253
+ );
3254
+ }
2927
3255
  }
2928
3256
  });
2929
3257
  }
2930
3258
 
2931
3259
  // src/components/editor/plugin-types.ts
3260
+ var COLUMN_CHILDREN = /* @__PURE__ */ new Set([
3261
+ "mj-accordion",
3262
+ "mj-button",
3263
+ "mj-carousel",
3264
+ "mj-divider",
3265
+ "mj-image",
3266
+ "mj-navbar",
3267
+ "mj-raw",
3268
+ "mj-social",
3269
+ "mj-spacer",
3270
+ "mj-table",
3271
+ "mj-text"
3272
+ ]);
2932
3273
  function fixPluginTypes(editor) {
3274
+ addTableType(editor);
2933
3275
  for (const type of editor.Components.getTypes()) {
2934
- if (!type.id.startsWith("mj-")) continue;
3276
+ if (!type.id.startsWith("mj-") || type.id === "mj-table") continue;
2935
3277
  const base = type.model.prototype.getAttrToHTML;
2936
3278
  editor.Components.addType(type.id, {
2937
3279
  model: {
2938
- defaults: { "style-default": {} },
3280
+ defaults: {
3281
+ "style-default": {},
3282
+ ...type.id === "mj-column" && {
3283
+ droppable: (source) => COLUMN_CHILDREN.has(String(source.get("tagName")))
3284
+ },
3285
+ // GrapesJS's own is an SVG's markup, which the canvas wrote into `src` and saved.
3286
+ ...type.id === "mj-image" && { src: "" }
3287
+ },
2939
3288
  getAttrToHTML() {
2940
3289
  const attributes = base.call(this);
2941
3290
  delete attributes.__p;
@@ -2957,16 +3306,17 @@ var TYPE_LABEL = {
2957
3306
  "mj-image": "Image",
2958
3307
  "mj-column": "Column"
2959
3308
  };
2960
- function tagOf5(c) {
3309
+ function tagOf6(c) {
2961
3310
  return String(c.get("tagName") || c.get("type") || "");
2962
3311
  }
2963
3312
  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
3313
  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>';
3314
+ 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
3315
  function isSection(c) {
2966
- return tagOf5(c) === "mj-section";
3316
+ return tagOf6(c) === "mj-section";
2967
3317
  }
2968
3318
  function isCommentable(c) {
2969
- const t = tagOf5(c);
3319
+ const t = tagOf6(c);
2970
3320
  return t === "mj-section" || t in TYPE_LABEL;
2971
3321
  }
2972
3322
  function closestSection(c) {
@@ -2978,29 +3328,11 @@ function closestSection(c) {
2978
3328
  return null;
2979
3329
  }
2980
3330
  function crumbName(c, labels) {
2981
- const t = tagOf5(c);
3331
+ const t = tagOf6(c);
2982
3332
  return labels.elementNames[t] ?? t.replace(/^mj-/, "");
2983
3333
  }
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
3334
  function objectLabel(c) {
3003
- const base = TYPE_LABEL[tagOf5(c)] ?? tagOf5(c);
3335
+ const base = TYPE_LABEL[tagOf6(c)] ?? tagOf6(c);
3004
3336
  const text = String(c.getEl?.()?.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 30);
3005
3337
  return text ? `${base}: \u201C${text}\u201D` : base;
3006
3338
  }
@@ -3068,7 +3400,13 @@ function setupRichText(editor, labels) {
3068
3400
  result: (r, action) => r.exec("hiliteColor", val(action, "input"))
3069
3401
  });
3070
3402
  }
3071
- function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
3403
+ function EmailEditor({
3404
+ docId,
3405
+ onReady,
3406
+ commentsRefresh,
3407
+ onOpenCountChange,
3408
+ blocks = []
3409
+ }) {
3072
3410
  const documents = useDocumentStore();
3073
3411
  const labels = useLabels();
3074
3412
  const loadingRef = useRef5(false);
@@ -3090,8 +3428,15 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
3090
3428
  const readOnlyRef = useRef5(false);
3091
3429
  const applyReadOnly = useRef5(async () => {
3092
3430
  });
3093
- useEffect5(() => void applyReadOnly.current(turnRunning), [turnRunning]);
3094
- useEffect5(() => () => setLocked(false), [setLocked]);
3431
+ const [turn] = useState5(createTurnFollower);
3432
+ useEffect5(() => turn.turn(turnRunning), [turn, turnRunning]);
3433
+ useEffect5(() => {
3434
+ turn.mount();
3435
+ return () => {
3436
+ turn.unmount();
3437
+ setLocked(false);
3438
+ };
3439
+ }, [turn, setLocked]);
3095
3440
  const [crumbs, setCrumbs] = useState5([]);
3096
3441
  const [picker, setPicker] = useState5(
3097
3442
  null
@@ -3102,7 +3447,7 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
3102
3447
  const chain = [];
3103
3448
  let cur = c;
3104
3449
  while (cur) {
3105
- const t = tagOf5(cur);
3450
+ const t = tagOf6(cur);
3106
3451
  if (!t || t === "mj-body" || t === "wrapper" || t === "Wrapper") break;
3107
3452
  chain.unshift(cur);
3108
3453
  cur = cur.parent() ?? void 0;
@@ -3151,7 +3496,7 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
3151
3496
  if (isSection(c)) addIdClass(c, "sec");
3152
3497
  if (!isCommentable(c)) return;
3153
3498
  const toolbar = [...c.get("toolbar") ?? []];
3154
- if (tagOf5(c) === "mj-image" && !toolbar.some((t) => t.command === "open-image-picker")) {
3499
+ if (tagOf6(c) === "mj-image" && !toolbar.some((t) => t.command === "open-image-picker")) {
3155
3500
  toolbar.push({
3156
3501
  command: "open-image-picker",
3157
3502
  label: IMAGE_SVG,
@@ -3171,7 +3516,7 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
3171
3516
  editor.Commands.add("open-image-picker", {
3172
3517
  run(ed) {
3173
3518
  const target = ed.getSelected();
3174
- if (!target || tagOf5(target) !== "mj-image") return;
3519
+ if (!target || tagOf6(target) !== "mj-image") return;
3175
3520
  const am = ed.AssetManager;
3176
3521
  am.open({
3177
3522
  target,
@@ -3191,6 +3536,7 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
3191
3536
  });
3192
3537
  setupRichText(editor, labels);
3193
3538
  fixPluginTypes(editor);
3539
+ registerBlocks(editor, blocks, labels.blockCategorySections, SECTION_SVG);
3194
3540
  setupLayerNames(editor, labels);
3195
3541
  const STRUCTURE = /* @__PURE__ */ new Set(["mj-wrapper", "mj-section", "mj-column"]);
3196
3542
  const showBodyInLayers = () => {
@@ -3198,14 +3544,14 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
3198
3544
  if (!body) return;
3199
3545
  editor.Layers.setRoot(body);
3200
3546
  body.onAll((c) => {
3201
- if (STRUCTURE.has(tagOf5(c))) editor.Layers.setOpen(c, true);
3547
+ if (STRUCTURE.has(tagOf6(c))) editor.Layers.setOpen(c, true);
3202
3548
  });
3203
3549
  };
3204
3550
  const keepStructureOpen = () => {
3205
3551
  const layers = editor.Layers;
3206
3552
  const opened = layers.model?.get("opened") ?? {};
3207
3553
  for (const [cid, c] of Object.entries(opened)) {
3208
- if (STRUCTURE.has(tagOf5(c))) delete opened[cid];
3554
+ if (STRUCTURE.has(tagOf6(c))) delete opened[cid];
3209
3555
  }
3210
3556
  };
3211
3557
  editor.on("component:add", (c) => {
@@ -3233,16 +3579,16 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
3233
3579
  editor.on("component:deselected", () => {
3234
3580
  buildCrumbs(editor.getSelected());
3235
3581
  });
3236
- let stored = "";
3582
+ const baseline = createSaveBaseline();
3237
3583
  const save = async () => {
3238
3584
  const view = editor.getEditing()?.getView();
3239
3585
  await view?.syncContent?.();
3240
3586
  const mjml = editor.getHtml();
3241
- if (mjml === stored) return;
3587
+ if (!baseline.changed(mjml)) return;
3242
3588
  setSave("saving");
3243
3589
  try {
3244
3590
  await documents.save(docId, { mjml, projectData: editor.getProjectData() });
3245
- stored = mjml;
3591
+ baseline.saved(mjml);
3246
3592
  setSave("saved");
3247
3593
  } catch (e) {
3248
3594
  setSave("error");
@@ -3273,6 +3619,7 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
3273
3619
  });
3274
3620
  }
3275
3621
  const lock = (on) => {
3622
+ if (!turn.mounted) return;
3276
3623
  readOnlyRef.current = on;
3277
3624
  setLocked(on);
3278
3625
  notifyState();
@@ -3298,37 +3645,36 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
3298
3645
  return;
3299
3646
  }
3300
3647
  lock(false);
3648
+ queue.schedule();
3301
3649
  };
3302
3650
  const reload = () => queue.run(async () => {
3303
3651
  const fresh = await documents.get(docId);
3304
- if (fresh.mjml !== stored) loadMjml(fresh.mjml);
3652
+ if (fresh.mjml !== baseline.stored) loadMjml(fresh.mjml);
3305
3653
  });
3306
3654
  const loadMjml = (mjml) => {
3307
3655
  loadingRef.current = true;
3308
3656
  let repaired = false;
3657
+ const adds = !mjml || listSections(mjml).some((s) => !s.sectionId);
3309
3658
  try {
3310
3659
  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
- }
3660
+ baseline.load(mjml, null);
3661
+ const source = sourceForEditor(mjml || STARTER_MJML);
3662
+ repaired = source.repaired;
3663
+ editor.setComponents(source.mjml);
3319
3664
  editor.getWrapper()?.onAll((c) => {
3320
- const illegal = illegalAttributes(tagOf5(c), c.getAttributes());
3665
+ const illegal = illegalAttributes(tagOf6(c), c.getAttributes());
3321
3666
  if (illegal.length === 0) return;
3322
3667
  c.removeAttributes(illegal);
3323
3668
  repaired = true;
3324
3669
  });
3670
+ if (!repaired && !adds) baseline.load(mjml, editor.getHtml());
3325
3671
  showBodyInLayers();
3326
3672
  editor.UndoManager.clear();
3327
3673
  } finally {
3328
3674
  loadingRef.current = false;
3329
3675
  }
3330
3676
  notifyState();
3331
- if (repaired && !readOnlyRef.current) queue.schedule();
3677
+ if ((repaired || adds) && !readOnlyRef.current) queue.schedule();
3332
3678
  };
3333
3679
  const ensureHighlightStyles = () => {
3334
3680
  const cdoc = editor.Canvas.getDocument();
@@ -3350,7 +3696,10 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
3350
3696
  cdoc.head.appendChild(style);
3351
3697
  };
3352
3698
  const sectionEl = (sectionId) => {
3353
- const comp = editor.getWrapper()?.find("mj-section").find((c) => sectionIdOf(c) === sectionId);
3699
+ let comp;
3700
+ editor.getWrapper()?.onAll((c) => {
3701
+ if (!comp && isSection(c) && sectionIdOf(c) === sectionId) comp = c;
3702
+ });
3354
3703
  return comp?.getEl?.() ?? void 0;
3355
3704
  };
3356
3705
  const compiledHtml = () => {
@@ -3361,9 +3710,9 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
3361
3710
  return editor.getHtml();
3362
3711
  }
3363
3712
  };
3713
+ turn.ready((on) => void applyReadOnly.current(on));
3364
3714
  try {
3365
- const doc = await documents.get(docId);
3366
- loadMjml(doc.mjml);
3715
+ await queue.run(async () => loadMjml((await documents.get(docId)).mjml));
3367
3716
  } catch (e) {
3368
3717
  toast2.error(labels.documentLoadFailed);
3369
3718
  console.error(e);
@@ -3418,7 +3767,7 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
3418
3767
  return /* @__PURE__ */ jsx8(
3419
3768
  GjsEditor,
3420
3769
  {
3421
- grapesjs,
3770
+ grapesjs: grapesjs2,
3422
3771
  className: "h-full",
3423
3772
  options: {
3424
3773
  height: "100%",
@@ -3427,6 +3776,7 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
3427
3776
  // app.grapesjs.com and leave a session-storage entry, which a host would then have
3428
3777
  // to disclose and, for the entry, ask consent for.
3429
3778
  telemetry: false,
3779
+ canvasCss: CANVAS_CSS,
3430
3780
  fromElement: false,
3431
3781
  panels: { defaults: [] },
3432
3782
  blockManager: { appendTo: "#gjs-blocks" },
@@ -3446,7 +3796,12 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
3446
3796
  close: () => setPicker(null)
3447
3797
  }
3448
3798
  },
3449
- canvas: { customBadgeLabel: badgeLabel(labels) },
3799
+ canvas: {
3800
+ customBadgeLabel: badgeLabel(labels),
3801
+ // A file or text dragged in from outside lands as `<img>` or `<div>`, which no MJML
3802
+ // validator accepts; drags from the palette are unaffected.
3803
+ allowExternalDrop: false
3804
+ },
3450
3805
  deviceManager: {
3451
3806
  devices: [
3452
3807
  { id: "desktop", name: "Desktop", width: "" },
@@ -3454,14 +3809,9 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
3454
3809
  ]
3455
3810
  },
3456
3811
  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
- })
3812
+ (editor) => grapesjsMJML2(editor, {
3813
+ blocks: BUILTIN_BLOCKS,
3814
+ block: builtinBlock(labels)
3465
3815
  })
3466
3816
  ]
3467
3817
  },
@@ -3543,7 +3893,7 @@ function LeftSidebar({
3543
3893
  const selectBody = () => {
3544
3894
  let body;
3545
3895
  editor?.getWrapper()?.onAll((c) => {
3546
- if (!body && tagOf5(c) === "mj-body") body = c;
3896
+ if (!body && tagOf6(c) === "mj-body") body = c;
3547
3897
  });
3548
3898
  if (body) editor?.select(body);
3549
3899
  };
@@ -3551,6 +3901,13 @@ function LeftSidebar({
3551
3901
  setLayersExpanded(expand);
3552
3902
  editor?.getWrapper()?.onAll((c) => c.set("open", expand));
3553
3903
  };
3904
+ const onTileKey = (e) => {
3905
+ const tile = e.target.closest("[data-block]");
3906
+ const block = tile && editor?.Blocks.get(tile.dataset.block ?? "");
3907
+ if (!editor || !block || !addsBlock(e)) return;
3908
+ e.preventDefault();
3909
+ if (insertBlock(editor, block)) toast2(labels.blockAdded(tile.textContent?.trim() ?? ""));
3910
+ };
3554
3911
  const filterBlocks = (q) => {
3555
3912
  setBlockSearch(q);
3556
3913
  const root = document.getElementById("gjs-blocks");
@@ -3607,7 +3964,7 @@ function LeftSidebar({
3607
3964
  /* @__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
3965
  ] }),
3609
3966
  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" })
3967
+ /* @__PURE__ */ jsx8("div", { id: "gjs-blocks", className: "min-h-0 flex-1 overflow-y-auto", onKeyDown: onTileKey })
3611
3968
  ] }),
3612
3969
  /* @__PURE__ */ jsx8("div", { className: cn("min-h-0 flex-1 overflow-y-auto", view !== "settings" && "hidden"), children: /* @__PURE__ */ jsx8(
3613
3970
  Inspector,
@@ -3687,6 +4044,7 @@ function Breadcrumb({
3687
4044
  ] }, i)) });
3688
4045
  }
3689
4046
  export {
3690
- EmailEditor
4047
+ EmailEditor,
4048
+ blockProblems
3691
4049
  };
3692
4050
  //# sourceMappingURL=canvas.js.map