@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.cjs CHANGED
@@ -31,16 +31,17 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  // src/canvas.ts
32
32
  var canvas_exports = {};
33
33
  __export(canvas_exports, {
34
- EmailEditor: () => EmailEditor
34
+ EmailEditor: () => EmailEditor,
35
+ blockProblems: () => blockProblems
35
36
  });
36
37
  module.exports = __toCommonJS(canvas_exports);
37
38
 
38
39
  // src/components/editor/EmailEditor.tsx
39
- var import_grapesjs = __toESM(require("grapesjs"), 1);
40
+ var import_grapesjs2 = __toESM(require("grapesjs"), 1);
40
41
  var import_react9 = __toESM(require("@grapesjs/react"), 1);
41
- var import_grapesjs_mjml = __toESM(require("grapesjs-mjml"), 1);
42
+ var import_grapesjs_mjml2 = __toESM(require("grapesjs-mjml"), 1);
42
43
  var import_react10 = require("react");
43
- var import_core2 = require("@mjml-agent-editor/core");
44
+ var import_core4 = require("@mjml-agent-editor/core");
44
45
 
45
46
  // src/stores.tsx
46
47
  var import_react = require("react");
@@ -68,8 +69,10 @@ var DEFAULT_LABELS = {
68
69
  tabLayers: "Layers",
69
70
  searchBlocks: "Search blocks\u2026",
70
71
  noBlocksMatch: "No blocks match.",
72
+ blockAdded: (block) => `Added: ${block}`,
71
73
  blockCategoryLayout: "Layout",
72
74
  blockCategoryContent: "Content",
75
+ blockCategorySections: "Sections",
73
76
  blockLabels: {
74
77
  "mj-1-column": "1 column",
75
78
  "mj-2-columns": "2 columns",
@@ -97,7 +100,8 @@ var DEFAULT_LABELS = {
97
100
  "mj-navbar-link": "Navigation link",
98
101
  "mj-social": "Social links",
99
102
  "mj-social-element": "Social link",
100
- "mj-raw": "Raw HTML"
103
+ "mj-raw": "Raw HTML",
104
+ "mj-table": "Table"
101
105
  },
102
106
  expandAll: "Expand all",
103
107
  selectAnElement: "Select an element\u2026",
@@ -937,6 +941,30 @@ function CanvasComments({
937
941
  ] });
938
942
  }
939
943
 
944
+ // src/lib/save-baseline.ts
945
+ function createSaveBaseline() {
946
+ let stored = "";
947
+ let loaded = null;
948
+ return {
949
+ /** The MJML the store holds, as far as this editor knows. */
950
+ get stored() {
951
+ return stored;
952
+ },
953
+ /** `canvas` is what the load serialises to, or null when it repaired something to write. */
954
+ load(mjml, canvas) {
955
+ stored = mjml;
956
+ loaded = canvas;
957
+ },
958
+ saved(mjml) {
959
+ stored = mjml;
960
+ loaded = null;
961
+ },
962
+ changed(mjml) {
963
+ return mjml !== stored && mjml !== loaded;
964
+ }
965
+ };
966
+ }
967
+
940
968
  // src/lib/save-queue.ts
941
969
  function createSaveQueue(save, delayMs) {
942
970
  let timer = null;
@@ -966,6 +994,39 @@ function createSaveQueue(save, delayMs) {
966
994
  };
967
995
  }
968
996
 
997
+ // src/lib/turn-follower.ts
998
+ function createTurnFollower() {
999
+ let running = false;
1000
+ let mounted = true;
1001
+ let apply = null;
1002
+ return {
1003
+ turn(on) {
1004
+ if (on === running) return;
1005
+ running = on;
1006
+ if (mounted) apply?.(on);
1007
+ },
1008
+ /**
1009
+ * Whether the canvas is still mounted. An unmounted one's late reload must not unlock
1010
+ * the shared state a canvas mounted since has locked.
1011
+ */
1012
+ get mounted() {
1013
+ return mounted;
1014
+ },
1015
+ // Both, as React mounts twice in development.
1016
+ mount() {
1017
+ mounted = true;
1018
+ },
1019
+ unmount() {
1020
+ mounted = false;
1021
+ },
1022
+ /** A turn already under way is applied at once; none means nothing to apply. */
1023
+ ready(fn) {
1024
+ apply = fn;
1025
+ if (running) fn(true);
1026
+ }
1027
+ };
1028
+ }
1029
+
969
1030
  // src/lib/shortcuts.ts
970
1031
  function opensBlockSearch(e) {
971
1032
  if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey || e.isComposing) return false;
@@ -1282,6 +1343,7 @@ var import_react5 = require("react");
1282
1343
  var import_lucide_react5 = require("lucide-react");
1283
1344
 
1284
1345
  // src/components/editor/inspector/values.ts
1346
+ var import_core2 = require("@mjml-agent-editor/core");
1285
1347
  var SIDES = ["top", "right", "bottom", "left"];
1286
1348
  function expandBox(value) {
1287
1349
  const parts = value.trim().split(/\s+/).filter(Boolean);
@@ -1312,15 +1374,6 @@ function parseNetwork(name) {
1312
1374
  function networkName(network, share) {
1313
1375
  return share || network === "custom" ? network : `${network}${NO_SHARE}`;
1314
1376
  }
1315
- var LINK_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "mailto", "tel", "sms"]);
1316
- function isSafeLink(value) {
1317
- 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, "");
1318
- const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(decoded)?.[1];
1319
- return !scheme || LINK_SCHEMES.has(scheme.toLowerCase());
1320
- }
1321
- function isSafeNavbarLink(baseUrl, href) {
1322
- return isSafeLink(baseUrl) && isSafeLink(href) && isSafeLink(baseUrl + href);
1323
- }
1324
1377
 
1325
1378
  // src/components/editor/inspector/controls.tsx
1326
1379
  var import_jsx_runtime12 = require("react/jsx-runtime");
@@ -2126,6 +2179,23 @@ function withoutEditorProps(mjml) {
2126
2179
  )
2127
2180
  );
2128
2181
  }
2182
+ function withClosedTags(mjml) {
2183
+ return mjml.replace(START_TAG, (tag) => {
2184
+ const selfClosed = /^<(mj-[\w-]+)([\s\S]*?)\s*\/>$/i.exec(tag);
2185
+ return selfClosed ? `<${selfClosed[1]}${selfClosed[2]}></${selfClosed[1]}>` : tag;
2186
+ });
2187
+ }
2188
+ var TABLE = /<!--[\s\S]*?(?:-->|$)|(<mj-table\b(?:[^>"']|"[^"]*"|'[^']*')*>)((?:<!--[\s\S]*?-->|(?!<!--)[\s\S])*?)(<\/mj-table\s*>)/gi;
2189
+ function withTableRowsKept(mjml) {
2190
+ return mjml.replace(
2191
+ TABLE,
2192
+ (match, open, rows, close) => open ? `${open}<template>${rows}</template>${close}` : match
2193
+ );
2194
+ }
2195
+ function sourceForEditor(mjml) {
2196
+ const cleaned = withoutEditorProps(mjml);
2197
+ return { mjml: withTableRowsKept(withClosedTags(cleaned)), repaired: cleaned !== mjml };
2198
+ }
2129
2199
  function illegalAttributes(tag, attributes) {
2130
2200
  const allowed = ALLOWED_ATTRIBUTES[tag];
2131
2201
  if (!allowed) return [];
@@ -3002,7 +3072,10 @@ var ICONS = {
3002
3072
  "mj-navbar-link": svg(
3003
3073
  '<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"/>'
3004
3074
  ),
3005
- "mj-raw": svg('<path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/>')
3075
+ "mj-raw": svg('<path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/>'),
3076
+ "mj-table": svg(
3077
+ '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/><path d="M12 3v18"/>'
3078
+ )
3006
3079
  };
3007
3080
  function tagOf(c) {
3008
3081
  return String(c.get("tagName") || c.get("type") || "");
@@ -3465,12 +3538,12 @@ function ElementSettings({
3465
3538
  };
3466
3539
  const safeLink = (attr) => (link) => {
3467
3540
  if (tag === "mj-navbar-link" && attr === "href") {
3468
- return isSafeNavbarLink(String(component.parent()?.getAttributes()["base-url"] ?? ""), link);
3541
+ return (0, import_core2.isSafeNavbarLink)(String(component.parent()?.getAttributes()["base-url"] ?? ""), link);
3469
3542
  }
3470
3543
  if (tag === "mj-navbar" && attr === "base-url") {
3471
- return component.components().every((c) => isSafeNavbarLink(link, String(c.getAttributes().href ?? "")));
3544
+ return component.components().every((c) => (0, import_core2.isSafeNavbarLink)(link, String(c.getAttributes().href ?? "")));
3472
3545
  }
3473
- return isSafeLink(link);
3546
+ return (0, import_core2.isSafeLink)(link);
3474
3547
  };
3475
3548
  const control = (f) => {
3476
3549
  const value = attributes[f.attr] ?? "";
@@ -3778,8 +3851,15 @@ function Header({
3778
3851
  ] });
3779
3852
  }
3780
3853
 
3854
+ // src/components/editor/blocks.ts
3855
+ var import_grapesjs = __toESM(require("grapesjs"), 1);
3856
+ var import_grapesjs_mjml = __toESM(require("grapesjs-mjml"), 1);
3857
+
3781
3858
  // src/components/editor/ids.ts
3782
- var ID_RE = { sec: /\bsec-([A-Za-z0-9_-]+)\b/, obj: /\bobj-([A-Za-z0-9_-]+)\b/ };
3859
+ var ID_RE = {
3860
+ sec: /(?:^|\s)sec-([A-Za-z0-9_-]+)(?=\s|$)/,
3861
+ obj: /(?:^|\s)obj-([A-Za-z0-9_-]+)(?=\s|$)/
3862
+ };
3783
3863
  function idOf(c, prefix) {
3784
3864
  return String(c.getAttributes()["css-class"] ?? "").match(ID_RE[prefix])?.[1] ?? null;
3785
3865
  }
@@ -3808,19 +3888,292 @@ function freshenCopiedIds(root, added) {
3808
3888
  const id = idOf(c, prefix);
3809
3889
  if (!id || !used(prefix, id, c)) continue;
3810
3890
  const cssClass = String(c.getAttributes()["css-class"]);
3811
- c.addAttributes({ "css-class": cssClass.replace(`${prefix}-${id}`, `${prefix}-${newId()}`) });
3891
+ const fresh = cssClass.replace(
3892
+ ID_RE[prefix],
3893
+ (token) => token.replace(`${prefix}-${id}`, `${prefix}-${newId()}`)
3894
+ );
3895
+ c.addAttributes({ "css-class": fresh });
3896
+ }
3897
+ });
3898
+ }
3899
+
3900
+ // src/components/editor/insert-block.ts
3901
+ var addsBlock = (e) => (e.key === "Enter" || e.key === " ") && !e.repeat && !e.ctrlKey && !e.metaKey && !e.altKey;
3902
+ var tileAttributes = (id) => ({ tabindex: "0", role: "button", "data-block": id });
3903
+ var tagOf5 = (c) => String(c.get("tagName") ?? "");
3904
+ function closest(c, tag) {
3905
+ for (let cur = c; cur; cur = cur.parent() ?? void 0) if (tagOf5(cur) === tag) return cur;
3906
+ return void 0;
3907
+ }
3908
+ function childOf(parent, c) {
3909
+ let cur = c;
3910
+ while (cur.parent() && cur.parent() !== parent) cur = cur.parent();
3911
+ return cur;
3912
+ }
3913
+ function insertBlock(editor, block) {
3914
+ const content = block.getContent();
3915
+ let body;
3916
+ editor.getWrapper()?.onAll((c) => {
3917
+ if (!body && tagOf5(c) === "mj-body") body = c;
3918
+ });
3919
+ if (typeof content !== "string" || !body) return void 0;
3920
+ const selected = editor.getSelected();
3921
+ const section = closest(selected, "mj-section");
3922
+ const sections = section?.parent() ?? body;
3923
+ const afterSection = section ? { at: section.index() + 1 } : {};
3924
+ const column = closest(selected, "mj-column");
3925
+ let added;
3926
+ if (/^\s*<mj-section\b/i.test(content)) {
3927
+ added = sections.append(content, afterSection)[0];
3928
+ } else if (column && selected) {
3929
+ const at = selected === column ? {} : { at: childOf(column, selected).index() + 1 };
3930
+ added = column.append(content, at)[0];
3931
+ } else {
3932
+ const [wrapper] = sections.append(
3933
+ `<mj-section><mj-column>${content}</mj-column></mj-section>`,
3934
+ afterSection
3935
+ );
3936
+ added = wrapper?.components().at(0)?.components().at(0);
3937
+ }
3938
+ if (!added) return void 0;
3939
+ editor.select(added);
3940
+ if (block.get("activate")) added.getView()?.onActive(void 0);
3941
+ return added;
3942
+ }
3943
+
3944
+ // src/components/editor/blocks.ts
3945
+ var EDITABLE_TAGS = /* @__PURE__ */ new Set([
3946
+ "mj-section",
3947
+ "mj-group",
3948
+ "mj-column",
3949
+ "mj-text",
3950
+ "mj-image",
3951
+ "mj-button",
3952
+ "mj-divider",
3953
+ "mj-spacer",
3954
+ "mj-social",
3955
+ "mj-social-element",
3956
+ "mj-navbar",
3957
+ "mj-navbar-link"
3958
+ ]);
3959
+ var CONTENT_TAGS = /* @__PURE__ */ new Set(["mj-text", "mj-button", "mj-social-element", "mj-navbar-link"]);
3960
+ var EDITOR_INTERNALS = /data-gjs-|__p\b|custom-name/i;
3961
+ var DROPPED_MARKUP = /<(?:script|style|link|meta|iframe|object|embed|form)\b/i;
3962
+ var INLINE_IMAGE = /\b(?:src|srcset|background-url|icon)\s*=\s*(?:"[^"]*data:|'[^']*data:|data:)/i;
3963
+ var LINK_IN_MARKUP = /[\s/"'](?:xlink:)?(?:href|action|formaction)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi;
3964
+ var validator;
3965
+ function blockProblems(mjml, editor = validator ??= import_grapesjs.default.init({
3966
+ headless: true,
3967
+ storageManager: false,
3968
+ telemetry: false,
3969
+ plugins: [import_grapesjs_mjml.default]
3970
+ })) {
3971
+ if (EDITOR_INTERNALS.test(mjml)) return ["it contains GrapesJS's own attributes"];
3972
+ if (INLINE_IMAGE.test(mjml)) return ["it inlines an image as a data: URL"];
3973
+ let tree;
3974
+ try {
3975
+ tree = editor.runCommand("mjml-code-to-html", {
3976
+ mjml: `<mjml><mj-body>${withClosedTags(mjml)}</mj-body></mjml>`,
3977
+ validationLevel: "strict"
3978
+ }).json;
3979
+ } catch (e) {
3980
+ const errors = e.errors;
3981
+ if (!errors) throw e;
3982
+ return errors.map((error) => error.formattedMessage ?? error.message ?? String(error));
3983
+ }
3984
+ const problems = [];
3985
+ const top = tree.children?.find((c) => c.tagName === "mj-body")?.children ?? [];
3986
+ if (top.length !== 1 || top[0].tagName !== "mj-section") {
3987
+ problems.push("it is not exactly one mj-section");
3988
+ }
3989
+ const visit = (node, parent) => {
3990
+ const tag = node.tagName;
3991
+ const attributes = node.attributes ?? {};
3992
+ const content = node.content ?? "";
3993
+ if (!EDITABLE_TAGS.has(tag)) problems.push(`${tag} is not editable here`);
3994
+ const cssClass = attributes["css-class"] ?? "";
3995
+ if (ID_RE.sec.test(cssClass) || ID_RE.obj.test(cssClass)) {
3996
+ problems.push(`${tag} carries a sec-* or obj-* id`);
3997
+ }
3998
+ if ("mj-class" in attributes) problems.push(`${tag} uses mj-class`);
3999
+ const links = [attributes.href ?? ""];
4000
+ for (const match of content.matchAll(LINK_IN_MARKUP)) {
4001
+ links.push(match[1] ?? match[2] ?? match[3] ?? "");
4002
+ }
4003
+ const linked = tag === "mj-navbar-link" ? (0, import_core2.isSafeNavbarLink)(parent?.attributes?.["base-url"] ?? "", attributes.href ?? "") : true;
4004
+ if (!linked || !links.every(import_core2.isSafeLink)) {
4005
+ problems.push(`${tag} has a link scheme mail must not carry`);
4006
+ }
4007
+ if (CONTENT_TAGS.has(tag) ? /<mj-/i.test(content) || DROPPED_MARKUP.test(content) : content.trim()) {
4008
+ problems.push(`${tag} holds content the editor does not keep`);
4009
+ }
4010
+ node.children?.forEach((child) => visit(child, node));
4011
+ };
4012
+ top.forEach((node) => visit(node));
4013
+ return problems;
4014
+ }
4015
+ var escapeHtml3 = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
4016
+ function registerBlocks(editor, blocks, category, media) {
4017
+ for (const block of blocks) {
4018
+ const problems = editor.Blocks.get(block.id) ? [`the id is already taken`] : blockProblems(block.mjml, editor);
4019
+ if (problems.length > 0) {
4020
+ console.error(`Block "${block.id}" is left out of the palette: ${problems.join("; ")}`);
4021
+ continue;
4022
+ }
4023
+ editor.Blocks.add(block.id, {
4024
+ // GrapesJS writes the label into the tile as markup.
4025
+ label: escapeHtml3(block.label),
4026
+ category,
4027
+ media: block.media ?? media,
4028
+ content: withClosedTags(block.mjml),
4029
+ attributes: tileAttributes(block.id)
4030
+ });
4031
+ }
4032
+ }
4033
+
4034
+ // src/components/editor/builtin-blocks.ts
4035
+ var BUILTIN_BLOCKS = [
4036
+ "mj-1-column",
4037
+ "mj-2-columns",
4038
+ "mj-3-columns",
4039
+ "mj-text",
4040
+ "mj-image",
4041
+ "mj-button",
4042
+ "mj-divider",
4043
+ "mj-spacer",
4044
+ "mj-social-group",
4045
+ "mj-navbar"
4046
+ ];
4047
+ var LAYOUT_BLOCKS = /* @__PURE__ */ new Set(["mj-1-column", "mj-2-columns", "mj-3-columns"]);
4048
+ var CONTENT2 = {
4049
+ // The plugin's version links Google+ and shares the page: a bare network name makes the
4050
+ // icon open a "share this link" dialog, while `-noshare` links to the profile a footer
4051
+ // means. Without `alt` each link is an unnamed image to a screen reader.
4052
+ "mj-social-group": `<mj-social icon-size="24px" mode="horizontal">
4053
+ <mj-social-element name="facebook-noshare" href="https://facebook.com/" alt="Facebook"></mj-social-element>
4054
+ <mj-social-element name="instagram-noshare" href="https://instagram.com/" alt="Instagram"></mj-social-element>
4055
+ <mj-social-element name="x-noshare" href="https://x.com/" alt="X"></mj-social-element>
4056
+ </mj-social>`,
4057
+ // The plugin's sample is a via.placeholder.com picture, stored in the email and loaded by
4058
+ // every reader's mail client. Dropping one opens the image picker instead.
4059
+ "mj-image": "<mj-image></mj-image>"
4060
+ };
4061
+ function builtinBlock(labels) {
4062
+ return (id) => ({
4063
+ label: labels.blockLabels[id] ?? id,
4064
+ // By id: a regex on the label broke as soon as a host translated it.
4065
+ category: LAYOUT_BLOCKS.has(id) ? labels.blockCategoryLayout : labels.blockCategoryContent,
4066
+ ...id in CONTENT2 && { content: CONTENT2[id] },
4067
+ attributes: tileAttributes(id)
4068
+ });
4069
+ }
4070
+ var CANVAS_CSS = 'img:not([src]), img[src=""] { min-height: 120px; background: #f4f4f5; outline: 1px dashed #a1a1aa; outline-offset: -1px; }';
4071
+
4072
+ // src/components/editor/table-type.ts
4073
+ var import_core3 = require("@mjml-agent-editor/core");
4074
+ var DROPPED = "script, style, link, iframe, frame, object, embed, applet, base, meta, animate, set, svg, math, template";
4075
+ var LINK_ATTRIBUTES = /* @__PURE__ */ new Set(["href", "xlink:href", "action", "formaction"]);
4076
+ function cleanRows(rows) {
4077
+ rows.querySelectorAll(DROPPED).forEach((el) => el.remove());
4078
+ rows.querySelectorAll("*").forEach((el) => {
4079
+ for (const { name, value } of [...el.attributes]) {
4080
+ const lower = name.toLowerCase();
4081
+ const script = /^(java|vb)script:/i.test(value.replace(/[\u0000-\u0020]/g, ""));
4082
+ if (lower.startsWith("on") || script || LINK_ATTRIBUTES.has(lower) && !(0, import_core3.isSafeLink)(value)) {
4083
+ el.removeAttribute(name);
4084
+ }
4085
+ }
4086
+ });
4087
+ }
4088
+ var ROW_STYLE = "pointer-events: all; display: table; width: 100%;";
4089
+ function tablePreview(doc, attributes, rows) {
4090
+ const attr = (name, fallback) => String(attributes[name] ?? fallback);
4091
+ const template = doc.createElement("template");
4092
+ template.innerHTML = `<table>${rows}</table>`;
4093
+ const table = doc.createElement("table");
4094
+ table.setAttribute("cellpadding", String(parseInt(attr("cellpadding", "0"), 10) || 0));
4095
+ table.setAttribute("cellspacing", String(parseInt(attr("cellspacing", "0"), 10) || 0));
4096
+ Object.assign(table.style, {
4097
+ width: attr("width", "100%"),
4098
+ border: attr("border", "none"),
4099
+ color: attr("color", "#000000"),
4100
+ fontFamily: attr("font-family", "Ubuntu, Helvetica, Arial, sans-serif"),
4101
+ fontSize: attr("font-size", "13px"),
4102
+ lineHeight: attr("line-height", "22px"),
4103
+ tableLayout: attr("table-layout", "auto")
4104
+ });
4105
+ for (const row of template.content.querySelectorAll("tr")) {
4106
+ const tr = table.insertRow();
4107
+ for (const cell2 of row.querySelectorAll(":scope > th, :scope > td")) {
4108
+ const copy = doc.createElement(cell2.tagName.toLowerCase());
4109
+ copy.textContent = cell2.textContent;
4110
+ const align = cell2.getAttribute("align");
4111
+ if (align && /^(left|right|center)$/i.test(align)) copy.setAttribute("align", align);
4112
+ tr.append(copy);
4113
+ }
4114
+ }
4115
+ const cell = doc.createElement("td");
4116
+ cell.style.padding = attr("padding", "10px 25px");
4117
+ cell.append(table);
4118
+ return cell;
4119
+ }
4120
+ function addTableType(editor) {
4121
+ const base = editor.Components.getType("default").view.prototype;
4122
+ editor.Components.addType("mj-table", {
4123
+ isComponent: (el) => {
4124
+ if (el.tagName?.toLowerCase() !== "mj-table") return false;
4125
+ const rows = el.querySelector(":scope > template");
4126
+ if (rows) cleanRows(rows.content);
4127
+ return { type: "mj-table", content: rows?.innerHTML ?? "", components: [] };
4128
+ },
4129
+ model: { defaults: { tagName: "mj-table", droppable: false, editable: false } },
4130
+ view: {
4131
+ tagName: "tr",
4132
+ // GrapesJS clears the element's attributes, style among them, when the model's change.
4133
+ updateAttributes(...args) {
4134
+ base.updateAttributes.apply(this, args);
4135
+ this.el.style.cssText = ROW_STYLE;
4136
+ },
4137
+ updateContent() {
4138
+ const { el, model } = this;
4139
+ el.style.cssText = ROW_STYLE;
4140
+ el.replaceChildren(
4141
+ tablePreview(el.ownerDocument, model.getAttributes(), String(model.get("content") ?? ""))
4142
+ );
4143
+ }
3812
4144
  }
3813
4145
  });
3814
4146
  }
3815
4147
 
3816
4148
  // src/components/editor/plugin-types.ts
4149
+ var COLUMN_CHILDREN = /* @__PURE__ */ new Set([
4150
+ "mj-accordion",
4151
+ "mj-button",
4152
+ "mj-carousel",
4153
+ "mj-divider",
4154
+ "mj-image",
4155
+ "mj-navbar",
4156
+ "mj-raw",
4157
+ "mj-social",
4158
+ "mj-spacer",
4159
+ "mj-table",
4160
+ "mj-text"
4161
+ ]);
3817
4162
  function fixPluginTypes(editor) {
4163
+ addTableType(editor);
3818
4164
  for (const type of editor.Components.getTypes()) {
3819
- if (!type.id.startsWith("mj-")) continue;
4165
+ if (!type.id.startsWith("mj-") || type.id === "mj-table") continue;
3820
4166
  const base = type.model.prototype.getAttrToHTML;
3821
4167
  editor.Components.addType(type.id, {
3822
4168
  model: {
3823
- defaults: { "style-default": {} },
4169
+ defaults: {
4170
+ "style-default": {},
4171
+ ...type.id === "mj-column" && {
4172
+ droppable: (source) => COLUMN_CHILDREN.has(String(source.get("tagName")))
4173
+ },
4174
+ // GrapesJS's own is an SVG's markup, which the canvas wrote into `src` and saved.
4175
+ ...type.id === "mj-image" && { src: "" }
4176
+ },
3824
4177
  getAttrToHTML() {
3825
4178
  const attributes = base.call(this);
3826
4179
  delete attributes.__p;
@@ -3842,16 +4195,17 @@ var TYPE_LABEL = {
3842
4195
  "mj-image": "Image",
3843
4196
  "mj-column": "Column"
3844
4197
  };
3845
- function tagOf5(c) {
4198
+ function tagOf6(c) {
3846
4199
  return String(c.get("tagName") || c.get("type") || "");
3847
4200
  }
3848
4201
  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>';
3849
4202
  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>';
4203
+ 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>';
3850
4204
  function isSection(c) {
3851
- return tagOf5(c) === "mj-section";
4205
+ return tagOf6(c) === "mj-section";
3852
4206
  }
3853
4207
  function isCommentable(c) {
3854
- const t = tagOf5(c);
4208
+ const t = tagOf6(c);
3855
4209
  return t === "mj-section" || t in TYPE_LABEL;
3856
4210
  }
3857
4211
  function closestSection(c) {
@@ -3863,29 +4217,11 @@ function closestSection(c) {
3863
4217
  return null;
3864
4218
  }
3865
4219
  function crumbName(c, labels) {
3866
- const t = tagOf5(c);
4220
+ const t = tagOf6(c);
3867
4221
  return labels.elementNames[t] ?? t.replace(/^mj-/, "");
3868
4222
  }
3869
- var BLOCKS = [
3870
- "mj-1-column",
3871
- "mj-2-columns",
3872
- "mj-3-columns",
3873
- "mj-text",
3874
- "mj-image",
3875
- "mj-button",
3876
- "mj-divider",
3877
- "mj-spacer",
3878
- "mj-social-group",
3879
- "mj-navbar"
3880
- ];
3881
- var LAYOUT_BLOCKS = /* @__PURE__ */ new Set(["mj-1-column", "mj-2-columns", "mj-3-columns"]);
3882
- var SOCIAL_BLOCK = `<mj-social icon-size="24px" mode="horizontal">
3883
- <mj-social-element name="facebook-noshare" href="https://facebook.com/"></mj-social-element>
3884
- <mj-social-element name="instagram-noshare" href="https://instagram.com/"></mj-social-element>
3885
- <mj-social-element name="x-noshare" href="https://x.com/"></mj-social-element>
3886
- </mj-social>`;
3887
4223
  function objectLabel(c) {
3888
- const base = TYPE_LABEL[tagOf5(c)] ?? tagOf5(c);
4224
+ const base = TYPE_LABEL[tagOf6(c)] ?? tagOf6(c);
3889
4225
  const text = String(c.getEl?.()?.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 30);
3890
4226
  return text ? `${base}: \u201C${text}\u201D` : base;
3891
4227
  }
@@ -3953,7 +4289,13 @@ function setupRichText(editor, labels) {
3953
4289
  result: (r, action) => r.exec("hiliteColor", val(action, "input"))
3954
4290
  });
3955
4291
  }
3956
- function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
4292
+ function EmailEditor({
4293
+ docId,
4294
+ onReady,
4295
+ commentsRefresh,
4296
+ onOpenCountChange,
4297
+ blocks = []
4298
+ }) {
3957
4299
  const documents = useDocumentStore();
3958
4300
  const labels = useLabels();
3959
4301
  const loadingRef = (0, import_react10.useRef)(false);
@@ -3975,8 +4317,15 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
3975
4317
  const readOnlyRef = (0, import_react10.useRef)(false);
3976
4318
  const applyReadOnly = (0, import_react10.useRef)(async () => {
3977
4319
  });
3978
- (0, import_react10.useEffect)(() => void applyReadOnly.current(turnRunning), [turnRunning]);
3979
- (0, import_react10.useEffect)(() => () => setLocked(false), [setLocked]);
4320
+ const [turn] = (0, import_react10.useState)(createTurnFollower);
4321
+ (0, import_react10.useEffect)(() => turn.turn(turnRunning), [turn, turnRunning]);
4322
+ (0, import_react10.useEffect)(() => {
4323
+ turn.mount();
4324
+ return () => {
4325
+ turn.unmount();
4326
+ setLocked(false);
4327
+ };
4328
+ }, [turn, setLocked]);
3980
4329
  const [crumbs, setCrumbs] = (0, import_react10.useState)([]);
3981
4330
  const [picker, setPicker] = (0, import_react10.useState)(
3982
4331
  null
@@ -3987,7 +4336,7 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
3987
4336
  const chain = [];
3988
4337
  let cur = c;
3989
4338
  while (cur) {
3990
- const t = tagOf5(cur);
4339
+ const t = tagOf6(cur);
3991
4340
  if (!t || t === "mj-body" || t === "wrapper" || t === "Wrapper") break;
3992
4341
  chain.unshift(cur);
3993
4342
  cur = cur.parent() ?? void 0;
@@ -4036,7 +4385,7 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
4036
4385
  if (isSection(c)) addIdClass(c, "sec");
4037
4386
  if (!isCommentable(c)) return;
4038
4387
  const toolbar = [...c.get("toolbar") ?? []];
4039
- if (tagOf5(c) === "mj-image" && !toolbar.some((t) => t.command === "open-image-picker")) {
4388
+ if (tagOf6(c) === "mj-image" && !toolbar.some((t) => t.command === "open-image-picker")) {
4040
4389
  toolbar.push({
4041
4390
  command: "open-image-picker",
4042
4391
  label: IMAGE_SVG,
@@ -4056,7 +4405,7 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
4056
4405
  editor.Commands.add("open-image-picker", {
4057
4406
  run(ed) {
4058
4407
  const target = ed.getSelected();
4059
- if (!target || tagOf5(target) !== "mj-image") return;
4408
+ if (!target || tagOf6(target) !== "mj-image") return;
4060
4409
  const am = ed.AssetManager;
4061
4410
  am.open({
4062
4411
  target,
@@ -4076,6 +4425,7 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
4076
4425
  });
4077
4426
  setupRichText(editor, labels);
4078
4427
  fixPluginTypes(editor);
4428
+ registerBlocks(editor, blocks, labels.blockCategorySections, SECTION_SVG);
4079
4429
  setupLayerNames(editor, labels);
4080
4430
  const STRUCTURE = /* @__PURE__ */ new Set(["mj-wrapper", "mj-section", "mj-column"]);
4081
4431
  const showBodyInLayers = () => {
@@ -4083,14 +4433,14 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
4083
4433
  if (!body) return;
4084
4434
  editor.Layers.setRoot(body);
4085
4435
  body.onAll((c) => {
4086
- if (STRUCTURE.has(tagOf5(c))) editor.Layers.setOpen(c, true);
4436
+ if (STRUCTURE.has(tagOf6(c))) editor.Layers.setOpen(c, true);
4087
4437
  });
4088
4438
  };
4089
4439
  const keepStructureOpen = () => {
4090
4440
  const layers = editor.Layers;
4091
4441
  const opened = layers.model?.get("opened") ?? {};
4092
4442
  for (const [cid, c] of Object.entries(opened)) {
4093
- if (STRUCTURE.has(tagOf5(c))) delete opened[cid];
4443
+ if (STRUCTURE.has(tagOf6(c))) delete opened[cid];
4094
4444
  }
4095
4445
  };
4096
4446
  editor.on("component:add", (c) => {
@@ -4118,16 +4468,16 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
4118
4468
  editor.on("component:deselected", () => {
4119
4469
  buildCrumbs(editor.getSelected());
4120
4470
  });
4121
- let stored = "";
4471
+ const baseline = createSaveBaseline();
4122
4472
  const save = async () => {
4123
4473
  const view = editor.getEditing()?.getView();
4124
4474
  await view?.syncContent?.();
4125
4475
  const mjml = editor.getHtml();
4126
- if (mjml === stored) return;
4476
+ if (!baseline.changed(mjml)) return;
4127
4477
  setSave("saving");
4128
4478
  try {
4129
4479
  await documents.save(docId, { mjml, projectData: editor.getProjectData() });
4130
- stored = mjml;
4480
+ baseline.saved(mjml);
4131
4481
  setSave("saved");
4132
4482
  } catch (e) {
4133
4483
  setSave("error");
@@ -4158,6 +4508,7 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
4158
4508
  });
4159
4509
  }
4160
4510
  const lock = (on) => {
4511
+ if (!turn.mounted) return;
4161
4512
  readOnlyRef.current = on;
4162
4513
  setLocked(on);
4163
4514
  notifyState();
@@ -4183,37 +4534,36 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
4183
4534
  return;
4184
4535
  }
4185
4536
  lock(false);
4537
+ queue.schedule();
4186
4538
  };
4187
4539
  const reload = () => queue.run(async () => {
4188
4540
  const fresh = await documents.get(docId);
4189
- if (fresh.mjml !== stored) loadMjml(fresh.mjml);
4541
+ if (fresh.mjml !== baseline.stored) loadMjml(fresh.mjml);
4190
4542
  });
4191
4543
  const loadMjml = (mjml) => {
4192
4544
  loadingRef.current = true;
4193
4545
  let repaired = false;
4546
+ const adds = !mjml || (0, import_core4.listSections)(mjml).some((s) => !s.sectionId);
4194
4547
  try {
4195
4548
  queue.cancel();
4196
- stored = mjml;
4197
- const source = withoutEditorProps(mjml || import_core2.STARTER_MJML);
4198
- repaired = source !== (mjml || import_core2.STARTER_MJML);
4199
- editor.setComponents(source);
4200
- editor.getWrapper()?.find("mj-section").forEach(decorate);
4201
- for (const t of Object.keys(TYPE_LABEL)) {
4202
- editor.getWrapper()?.find(t).forEach(decorate);
4203
- }
4549
+ baseline.load(mjml, null);
4550
+ const source = sourceForEditor(mjml || import_core4.STARTER_MJML);
4551
+ repaired = source.repaired;
4552
+ editor.setComponents(source.mjml);
4204
4553
  editor.getWrapper()?.onAll((c) => {
4205
- const illegal = illegalAttributes(tagOf5(c), c.getAttributes());
4554
+ const illegal = illegalAttributes(tagOf6(c), c.getAttributes());
4206
4555
  if (illegal.length === 0) return;
4207
4556
  c.removeAttributes(illegal);
4208
4557
  repaired = true;
4209
4558
  });
4559
+ if (!repaired && !adds) baseline.load(mjml, editor.getHtml());
4210
4560
  showBodyInLayers();
4211
4561
  editor.UndoManager.clear();
4212
4562
  } finally {
4213
4563
  loadingRef.current = false;
4214
4564
  }
4215
4565
  notifyState();
4216
- if (repaired && !readOnlyRef.current) queue.schedule();
4566
+ if ((repaired || adds) && !readOnlyRef.current) queue.schedule();
4217
4567
  };
4218
4568
  const ensureHighlightStyles = () => {
4219
4569
  const cdoc = editor.Canvas.getDocument();
@@ -4235,7 +4585,10 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
4235
4585
  cdoc.head.appendChild(style);
4236
4586
  };
4237
4587
  const sectionEl = (sectionId) => {
4238
- const comp = editor.getWrapper()?.find("mj-section").find((c) => sectionIdOf(c) === sectionId);
4588
+ let comp;
4589
+ editor.getWrapper()?.onAll((c) => {
4590
+ if (!comp && isSection(c) && sectionIdOf(c) === sectionId) comp = c;
4591
+ });
4239
4592
  return comp?.getEl?.() ?? void 0;
4240
4593
  };
4241
4594
  const compiledHtml = () => {
@@ -4246,9 +4599,9 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
4246
4599
  return editor.getHtml();
4247
4600
  }
4248
4601
  };
4602
+ turn.ready((on) => void applyReadOnly.current(on));
4249
4603
  try {
4250
- const doc = await documents.get(docId);
4251
- loadMjml(doc.mjml);
4604
+ await queue.run(async () => loadMjml((await documents.get(docId)).mjml));
4252
4605
  } catch (e) {
4253
4606
  import_sonner2.toast.error(labels.documentLoadFailed);
4254
4607
  console.error(e);
@@ -4303,7 +4656,7 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
4303
4656
  return /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
4304
4657
  import_react9.default,
4305
4658
  {
4306
- grapesjs: import_grapesjs.default,
4659
+ grapesjs: import_grapesjs2.default,
4307
4660
  className: "h-full",
4308
4661
  options: {
4309
4662
  height: "100%",
@@ -4312,6 +4665,7 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
4312
4665
  // app.grapesjs.com and leave a session-storage entry, which a host would then have
4313
4666
  // to disclose and, for the entry, ask consent for.
4314
4667
  telemetry: false,
4668
+ canvasCss: CANVAS_CSS,
4315
4669
  fromElement: false,
4316
4670
  panels: { defaults: [] },
4317
4671
  blockManager: { appendTo: "#gjs-blocks" },
@@ -4331,7 +4685,12 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
4331
4685
  close: () => setPicker(null)
4332
4686
  }
4333
4687
  },
4334
- canvas: { customBadgeLabel: badgeLabel(labels) },
4688
+ canvas: {
4689
+ customBadgeLabel: badgeLabel(labels),
4690
+ // A file or text dragged in from outside lands as `<img>` or `<div>`, which no MJML
4691
+ // validator accepts; drags from the palette are unaffected.
4692
+ allowExternalDrop: false
4693
+ },
4335
4694
  deviceManager: {
4336
4695
  devices: [
4337
4696
  { id: "desktop", name: "Desktop", width: "" },
@@ -4339,14 +4698,9 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
4339
4698
  ]
4340
4699
  },
4341
4700
  plugins: [
4342
- (editor) => (0, import_grapesjs_mjml.default)(editor, {
4343
- blocks: BLOCKS,
4344
- block: (id) => ({
4345
- label: labels.blockLabels[id] ?? id,
4346
- // By id: a regex on the label broke as soon as a host translated it.
4347
- category: LAYOUT_BLOCKS.has(id) ? labels.blockCategoryLayout : labels.blockCategoryContent,
4348
- ...id === "mj-social-group" && { content: SOCIAL_BLOCK }
4349
- })
4701
+ (editor) => (0, import_grapesjs_mjml2.default)(editor, {
4702
+ blocks: BUILTIN_BLOCKS,
4703
+ block: builtinBlock(labels)
4350
4704
  })
4351
4705
  ]
4352
4706
  },
@@ -4428,7 +4782,7 @@ function LeftSidebar({
4428
4782
  const selectBody = () => {
4429
4783
  let body;
4430
4784
  editor?.getWrapper()?.onAll((c) => {
4431
- if (!body && tagOf5(c) === "mj-body") body = c;
4785
+ if (!body && tagOf6(c) === "mj-body") body = c;
4432
4786
  });
4433
4787
  if (body) editor?.select(body);
4434
4788
  };
@@ -4436,6 +4790,13 @@ function LeftSidebar({
4436
4790
  setLayersExpanded(expand);
4437
4791
  editor?.getWrapper()?.onAll((c) => c.set("open", expand));
4438
4792
  };
4793
+ const onTileKey = (e) => {
4794
+ const tile = e.target.closest("[data-block]");
4795
+ const block = tile && editor?.Blocks.get(tile.dataset.block ?? "");
4796
+ if (!editor || !block || !addsBlock(e)) return;
4797
+ e.preventDefault();
4798
+ if (insertBlock(editor, block)) (0, import_sonner2.toast)(labels.blockAdded(tile.textContent?.trim() ?? ""));
4799
+ };
4439
4800
  const filterBlocks = (q) => {
4440
4801
  setBlockSearch(q);
4441
4802
  const root = document.getElementById("gjs-blocks");
@@ -4492,7 +4853,7 @@ function LeftSidebar({
4492
4853
  /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("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: "/" })
4493
4854
  ] }),
4494
4855
  noBlockMatches && /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("p", { className: "px-3 py-6 text-center text-xs text-panel-muted-fg", children: labels.noBlocksMatch }),
4495
- /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("div", { id: "gjs-blocks", className: "min-h-0 flex-1 overflow-y-auto" })
4856
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("div", { id: "gjs-blocks", className: "min-h-0 flex-1 overflow-y-auto", onKeyDown: onTileKey })
4496
4857
  ] }),
4497
4858
  /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("div", { className: cn("min-h-0 flex-1 overflow-y-auto", view !== "settings" && "hidden"), children: /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
4498
4859
  Inspector,
@@ -4573,6 +4934,7 @@ function Breadcrumb({
4573
4934
  }
4574
4935
  // Annotate the CommonJS export names for ESM import in node:
4575
4936
  0 && (module.exports = {
4576
- EmailEditor
4937
+ EmailEditor,
4938
+ blockProblems
4577
4939
  });
4578
4940
  //# sourceMappingURL=canvas.cjs.map