document.addEventListener("DOMContentLoaded", function () { // pagesguru-core-marketing.js v1.3 // Changes in v1.3 (email-editor hardening, informed by // grapesjs-preset-newsletter's architecture): // - Communication editor no longer loads webpage plugins // (preset-webpage, bootstrap-5, lory-slider, style-bg/gradient/filter, // custom-code, blocks-basic); keeps parser-postcss + tui-image-editor // - Communication editor gets a minimal email canvas shell (grey // background, 600px white container, email table/td resets) instead of // the iMIS page skeleton; website editor frameContent unchanged // - Communication editor gets a reduced, email-safe Style Manager // (typography / background-color / spacing / borders / dimensions) // - Inliner exposed as command "pg-get-inlined-html"; save path runs it // through the command and aborts loudly (no stale persist) on failure // - Communication save now emits a complete email document // (buildEmailDocument): doctype + MSO namespaces + head " + "" + "" + '' + '
' + inner + "
" + (metaScript || "") + "" ); } // ── Communication Creator: "Insert field" / "Insert link" dropdowns ──── // Mirrors the two dropdowns iMIS puts on the Telerik RTE toolbar // (CommunicationCreator tool group) so authors don't have to switch back // to the RadEditor to insert merge fields or resolved links. // Active only for the Communication editor; wired in initGrapes. // // "Insert field" inserts the plain-text merge token (e.g. // {#party.FirstName}) at the current selection — identical to what the // Telerik dropdown produces. // // "Insert link" inserts a placeholder anchor carrying the token in // data-imis-link for downstream resolution. NOTE: the attribute name is // PagesGuru's own convention; confirm what iMIS actually expects before // relying on send-time resolution. const PG_FIELD_TOKENS = [ ["party.Address1", "{#party.Address1}"], ["party.Address2", "{#party.Address2}"], ["party.Address3", "{#party.Address3}"], ["party.BirthDate", "{#party.BirthDate}"], ["party.City", "{#party.City}"], ["party.Company", "{#party.Company}"], ["party.Country", "{#party.Country}"], ["party.County", "{#party.County}"], ["party.Email", "{#party.Email}"], ["party.FirstName", "{#party.FirstName}"], ["party.FullAddress", "{#party.FullAddress}"], ["party.Informal", "{#party.Informal}"], ["party.LastName", "{#party.LastName}"], ["party.MajorKey", "{#party.MajorKey}"], ["party.MiddleName", "{#party.MiddleName}"], ["party.Mobile", "{#party.Mobile}"], ["party.Name", "{#party.Name}"], ["party.PartyId", "{#party.PartyId}"], ["party.Phone", "{#party.Phone}"], ["party.Prefix", "{#party.Prefix}"], ["party.State", "{#party.State}"], ["party.Suffix", "{#party.Suffix}"], ["party.UniformId", "{#party.UniformId}"], ["party.Zip", "{#party.Zip}"], ]; // Insert a merge-field token as text at the current selection, or append // to the wrapper when nothing is selected. function pgInsertFieldToken(editor, token) { if (!editor || !token) return; const sel = editor.getSelected && editor.getSelected(); if (sel && typeof sel.append === "function") { sel.append(token); } else { editor.addComponents(token); } } // Render the "Insert field" dropdown into this editor's options panel. // Scoped to gjsDiv so a page hosting both editors never targets the // wrong panel. Link insertion lives in pgLinkPopover (iMIS link dialogs). function pgAddCommunicationDropdowns(editor) { if (!editor || editor.__pgCommDropdowns) return; const optionsEl = gjsDiv.querySelector(".gjs-pn-options"); if (!optionsEl || optionsEl.querySelector(".pg-comm-dropdown")) return; editor.__pgCommDropdowns = true; function buildSelect(placeholder, items, onPick) { const sel = document.createElement("select"); sel.className = "pg-comm-dropdown"; sel.style.cssText = "margin:2px 4px;font-size:12px;max-width:150px;vertical-align:middle;"; const ph = document.createElement("option"); ph.value = ""; ph.textContent = placeholder; ph.selected = true; ph.disabled = true; sel.appendChild(ph); items.forEach(function (pair) { const opt = document.createElement("option"); opt.value = pair[0]; opt.textContent = pair[1]; sel.appendChild(opt); }); sel.addEventListener("change", function () { const v = sel.value; if (v) { const pair = items.find(function (p) { return p[0] === v; }); onPick(v, pair ? pair[1] : v); } sel.selectedIndex = 0; }); return sel; } const fieldSel = buildSelect( "Insert field", PG_FIELD_TOKENS, function (key) { const pair = PG_FIELD_TOKENS.find(function (p) { return p[0] === key; }); pgInsertFieldToken(editor, pair ? pair[1] : "{#" + key + "}"); }, ); optionsEl.appendChild(fieldSel); } // ── end Communication dropdowns ───────────────────────────────────────── function saveGjsIntoRadEditor(editor) { if (!editor || !bodyInput) return; // Rebuild guard. loadProjectData rebuilds the canvas asynchronously and // fires "update" events — autosave's 250ms debounce would then save // while the new frame's theme CSS may still be loading, inlining // against a half-loaded cascade and baking wrong widths/colors into // RadEditor. Contract v1 removed loadProjectData from the AI edit path // (edits apply via component.replaceWith, no rebuild), so nothing sets // this flag today. It stays as the safety seam for any FUTURE caller of // loadProjectData on a live comm editor: set it before the call, clear // it once window.pgEmailCanvasReady(editor) is true (with a timeout). if (editor.__pgRebuilding) { console.warn( "ZENTSO", "Save skipped: canvas rebuilding." ); return; } let html = editor.getHtml(); // Strip   artifacts introduced by RTE editing html = html.replace(/&nbsp;/g, " "); html = html.replace(/ /g, " "); //html = html.replace(/[ \t]+(|<\/)/g, "$1"); html = html.replace(/[ \t]+()/g, "$1"); //console.log("RAW HTML:", html); const css = editor.getCss(); const js = typeof editor.getJs === "function" ? editor.getJs() : ""; const data = editor.getProjectData(); if (data && data.assets) { delete data.assets; } const metaScript = ``; // Communication (email) editor: emit a complete email document. // The inliner resolves theme CSS into inline styles and converts the // grid to real tables (Outlook-safe on its own). On top of that, // buildEmailDocument() adds a head ${html || ""} ${metaScript} `.trim(); } const rad = typeof $find === "function" ? $find(editorId) : null; if (rad && typeof rad.set_html === "function") { rad.set_html(combined); } } const FC_CONTAINER_TYPE = "zentso-fc-container"; const FC_SLIDE_TYPE = "zentso-fc-slide"; let _fcSyncing = 0; function _withFcGuard(fn) { _fcSyncing++; try { return fn(); } finally { setTimeout(function () { _fcSyncing = Math.max(0, _fcSyncing - 1); }, 50); } } function _patchFcSlide(slideComp) { if (!slideComp || slideComp.__pgSlidePatched) return; if (typeof slideComp._syncDOM !== "function") return; slideComp.__pgSlidePatched = true; const orig = slideComp._syncDOM.bind(slideComp); slideComp._syncDOM = function () { return _withFcGuard(function () { return orig.apply(slideComp, arguments); }); }; } function _patchFcContainer(containerComp) { if (!containerComp || containerComp.__pgContainerPatched) return; containerComp.__pgContainerPatched = true; ["_syncSlides", "_refreshIndicators", "addSlide"].forEach(function (method) { if (typeof containerComp[method] !== "function") return; const orig = containerComp[method].bind(containerComp); containerComp[method] = function () { const args = arguments; return _withFcGuard(function () { return orig.apply(containerComp, args); }); }; }); function walkSlides(comps) { if (!comps || typeof comps.each !== "function") return; comps.each(function (c) { if (c.attributes && c.attributes.type === FC_SLIDE_TYPE) { _patchFcSlide(c); } if (c.components) { walkSlides(c.components()); } }); } if (containerComp.components) { walkSlides(containerComp.components()); } containerComp.on("component:add", function (child) { if (child && child.attributes && child.attributes.type === FC_SLIDE_TYPE) { _patchFcSlide(child); } }); } function wireAutosave(editor) { let autosaveTimer = null; editor.on("update", function () { if (_fcSyncing > 0 || (editor.__fcSyncing || 0) > 0) return; clearTimeout(autosaveTimer); autosaveTimer = setTimeout(function () { saveGjsIntoRadEditor(editor); console.log("GrapesJS autosaved at", new Date().toLocaleTimeString()); }, 250); }); const form = bodyInput.closest("form"); if (form) { form.addEventListener("submit", function () { saveGjsIntoRadEditor(editor); }); } setInterval(function () { if (_fcSyncing > 0 || (editor.__fcSyncing || 0) > 0) return; saveGjsIntoRadEditor(editor); console.log( "GrapesJS periodic autosave at", new Date().toLocaleTimeString() ); }, 60000); } function addSaveCommand(editor) { const pfx = editor.getConfig().stylePrefix; const modal = editor.Modal; const cmdm = editor.Commands; const pnm = editor.Panels; // ── HTML Edit command ────────────────────────────────────────────────── const codeViewer = editor.CodeManager.getViewer("CodeMirror").clone(); codeViewer.set({ codeName: "htmlmixed", readOnly: 0, theme: "hopscotch", autoBeautify: true, autoCloseTags: true, autoCloseBrackets: true, lineWrapping: true, styleActiveLine: true, smartIndent: true, indentWithTabs: true, }); const codeContainer = document.createElement("div"); const btnWrapper = document.createElement("div"); btnWrapper.style.cssText = "display:flex; justify-content:flex-end; margin-top:8px;"; const btnEdit = document.createElement("button"); btnEdit.type = "button"; btnEdit.innerHTML = "Update and Close"; btnEdit.className = pfx + "btn-prim " + pfx + "btn-import"; btnEdit.onclick = function () { const code = codeViewer.editor.getValue(); editor.DomComponents.getWrapper().set("content", ""); editor.setComponents(code.trim()); modal.close(); }; btnWrapper.appendChild(btnEdit); cmdm.add("html-edit", { run: function (editor, sender) { sender && sender.set("active", 0); let viewer = codeViewer.editor; modal.setTitle("Edit HTML code"); if (!viewer) { const txtarea = document.createElement("textarea"); codeContainer.appendChild(txtarea); codeContainer.appendChild(btnWrapper); codeViewer.init(txtarea); viewer = codeViewer.editor; } const innerHtml = editor.getHtml(); const css = editor.getCss(); modal.setContent(""); modal.setContent(codeContainer); codeViewer.setContent(innerHtml + ""); modal.open(); viewer.refresh(); }, }); // ── AI edit command (Communication editor) ───────────────────────────── // Opens an AI assistant popup that mirrors the "Edit this card" UI: // a free-text prompt plus preset writing/image actions. Requests are // POSTed to a CloudToolz endpoint (dummy for now) configured via // window.PagesGuruConfig.ai.apiUrl. Nothing is applied to the canvas // until the endpoint returns; on error the failure is surfaced, never // swallowed. cmdm.add("pg-ai-edit", { run: function (editor, sender) { sender && sender.set && sender.set("active", 0); const aiApiUrl = (resolvedConfig.ai && resolvedConfig.ai.apiUrl) || ""; // ── Theme (black/white, hover-highlighted buttons) ────────────── // Injected once into ; scoped to .pg-ai-modal-dialog so it // never bleeds into other modals (e.g. html-edit) that reuse the // same editor.Modal instance. if (!document.getElementById("pg-ai-modal-style")) { const styleTag = document.createElement("style"); styleTag.id = "pg-ai-modal-style"; styleTag.textContent = ".pg-ai-modal-dialog .gjs-mdl-content{background:#000 !important;color:#fff !important;}" + ".pg-ai-modal-dialog .gjs-mdl-header{background:#000 !important;border-bottom:1px solid #262626 !important;}" + ".pg-ai-modal-dialog .gjs-mdl-title{color:#fff !important;}" + ".pg-ai-modal-dialog .gjs-mdl-btn-close{color:#fff !important;}" + ".pg-ai-modal-dialog .gjs-mdl-btn-close:hover{color:#999 !important;}" + ".pg-ai-textarea::placeholder{color:rgba(255,255,255,.5);}" + ".pg-ai-send-btn{align-self:center;white-space:nowrap;background:#000;color:#fff;" + "border:1px solid #fff;border-radius:8px;padding:8px 16px;font:inherit;cursor:pointer;" + "transition:background .15s ease,color .15s ease;}" + ".pg-ai-send-btn:hover:not(:disabled){background:#fff;color:#000;}" + ".pg-ai-send-btn:disabled{opacity:.5;cursor:default;}" + ".pg-ai-chip{border:1px solid #555;background:#000;border-radius:9999px;padding:6px 12px;" + "font:inherit;cursor:pointer;color:#fff;" + "transition:background .15s ease,color .15s ease,border-color .15s ease;}" + ".pg-ai-chip:hover:not(:disabled){background:#fff;color:#000;border-color:#fff;}" + ".pg-ai-chip:disabled{opacity:.5;cursor:default;}"; document.head.appendChild(styleTag); } const wrap = document.createElement("div"); wrap.style.cssText = "font-size:14px; background:#000; color:#fff;"; const label = document.createElement("div"); label.textContent = "Edit this communication"; label.style.cssText = "font-weight:600; margin-bottom:8px; color:#fff;"; const inputRow = document.createElement("div"); inputRow.style.cssText = "display:flex; gap:8px; align-items:flex-end; border:1px solid #333;" + "background:#0d0d0d; border-radius:12px; padding:10px 12px; margin-bottom:12px;"; const textarea = document.createElement("textarea"); textarea.rows = 2; textarea.className = "pg-ai-textarea"; textarea.placeholder = "How would you like to edit this email?"; textarea.style.cssText = "flex:1; border:0; outline:0; resize:vertical; font:inherit;" + "background:transparent; color:#fff; min-height:44px;"; const sendBtn = document.createElement("button"); sendBtn.type = "button"; sendBtn.textContent = "Send"; sendBtn.className = "pg-ai-send-btn"; inputRow.appendChild(textarea); inputRow.appendChild(sendBtn); const status = document.createElement("div"); status.style.cssText = "min-height:18px; font-size:12px; color:#aaa; margin-bottom:8px;"; function makeGroup(title, actions) { const g = document.createElement("div"); g.style.cssText = "margin-bottom:10px;"; const h = document.createElement("div"); h.textContent = title; h.style.cssText = "font-size:12px; color:#aaa; margin-bottom:6px;"; const row = document.createElement("div"); row.style.cssText = "display:flex; flex-wrap:wrap; gap:8px;"; actions.forEach(function (a) { const chip = document.createElement("button"); chip.type = "button"; chip.textContent = a.label; chip.dataset.action = a.action; chip.className = "pg-ai-chip"; chip.onclick = function () { runAi(a.action, textarea.value.trim()); }; row.appendChild(chip); }); g.appendChild(h); g.appendChild(row); return g; } const writingGroup = makeGroup("Writing", [ { label: "Improve writing", action: "improve_writing" }, { label: "Fix spelling & grammar", action: "fix_grammar" }, { label: "Translate", action: "translate" }, { label: "Make longer", action: "make_longer" }, { label: "Make shorter", action: "make_shorter" }, { label: "Simplify language", action: "simplify" }, { label: "Be more specific", action: "be_specific" }, ]); const stylingGroup = makeGroup("Styling", [ { label: "Make this more visual", action: "more_visual" }, /* { label: "Add an image", action: "add_image" }, { label: "Add a chart", action: "add_chart" },*/ ]); wrap.appendChild(label); wrap.appendChild(inputRow); wrap.appendChild(status); wrap.appendChild(writingGroup); wrap.appendChild(stylingGroup); let inFlight = false; function setBusy(busy, msg) { inFlight = busy; sendBtn.disabled = busy; wrap.querySelectorAll("button[data-action]").forEach(function (b) { b.disabled = busy; b.style.opacity = busy ? "0.5" : "1"; b.style.pointerEvents = busy ? "none" : "auto"; }); status.style.color = "#aaa"; status.textContent = msg || ""; } // ── Contract v1 helpers ──────────────────────────────────────── // Every element component must carry attributes.id so the patch // contract can anchor edits to it: the AI returns fragments keyed // by id, and the client resolves them via wrapper.find('#id'). // Components dropped from blocks often ship WITHOUT attributes.id // (only styled ones get it) — annotate the whole tree before // serializing. getId() returns attributes.id when present, so this // never changes an existing id. Persisted ids are harmless: the // email inliner strips id attributes from the exported document. function ensureComponentIds(comp) { const type = comp.get && comp.get("type"); if (type !== "textnode" && type !== "comment") { const attrs = comp.getAttributes ? comp.getAttributes() : {}; if (!attrs.id && comp.addAttributes) { comp.addAttributes({ id: comp.getId() }); } } if (comp.components) { comp.components().each(ensureComponentIds); } } // Client-side validation of a single {id, html} edit. The server // validates too (contract §2); this is belt-and-braces so a // misbehaving endpoint can never inject script or clobber the // wrong component. Returns null if valid, else a reason string. function validateEdit(edit, wrapper) { if ( !edit || typeof edit.id !== "string" || typeof edit.html !== "string" || !edit.html.trim() ) { return "malformed edit"; } if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(edit.id)) { return "invalid id"; } if ( /<\s*(script|style|iframe|object|embed|form|video|link|meta)\b/i.test( edit.html ) ) { return "forbidden element"; } if (/\son\w+\s*=/i.test(edit.html)) { return "event handler attribute"; } if (/javascript\s*:/i.test(edit.html)) { return "javascript: URL"; } // Exactly one root element whose id matches. (DOMParser via // text/html: fine for the div/p/hN/a fragments the email editor // holds; table fragments would be re-rooted, but tables only // exist post-inline, never in editor content.) let parsed; try { parsed = new DOMParser().parseFromString( edit.html, "text/html" ); } catch (e) { return "unparseable html"; } const roots = parsed && parsed.body ? parsed.body.children : []; if (roots.length !== 1) { return "must have exactly one root element"; } if (roots[0].id !== edit.id) { return "root id mismatch"; } if (!wrapper.find("#" + edit.id)[0]) { return "unknown component id"; } return null; } function runAi(action, prompt) { if (inFlight) return; if (!aiApiUrl) { status.style.color = "#ff6b6b"; status.textContent = "AI endpoint not configured (PagesGuruConfig.ai.apiUrl)."; return; } if (action === "custom_prompt" && !prompt) { status.style.color = "#ff6b6b"; status.textContent = "Enter a prompt first."; return; } setBusy(true, "Generating..."); // Scope: a selected component narrows the edit to that subtree; // otherwise the whole document is offered. Both go through the // SAME response contract (a patch list) — scope only changes how // much HTML the endpoint sees. GrapesJS keeps the last-clicked // component selected, so document-wide edits require // deliberately deselecting first; log what this request actually // targets so a mis-scoped run identifies itself. const selected = editor.getSelected(); const scope = selected ? "selection" : "document"; console.debug( "ZENTSO", "AI edit request:", action, "scope=" + scope, selected ? "selectedId=" + selected.getId() : "" ); // Contract v1: the endpoint edits id-anchored HTML fragments. // projectData is no longer sent — the server neither needs nor // may return it. Annotate ids across the whole tree first so // every element in the serialized HTML is addressable. try { ensureComponentIds(editor.getWrapper()); } catch (e) { console.warn("ZENTSO", "Component id annotation failed:", e); } const requestBody = { contractVersion: 1, action: action, prompt: prompt || "", context: "communication", scope: scope, editorId: editorId, css: editor.getCss(), }; if (selected) { requestBody.selectedId = selected.getId(); requestBody.html = selected.toHTML(); } else { requestBody.html = editor.getHtml(); } fetch(aiApiUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(requestBody), }) .then(function (res) { // Try to read the structured error body even on non-2xx. return res.json().then( function (data) { if (!res.ok) { throw new Error( (data && data.message) || "AI request failed: " + res.status ); } return data; }, function () { throw new Error("AI request failed: " + res.status); } ); }) .then(function (data) { if (!data || data.status === "error") { throw new Error( (data && data.message) || "AI request failed." ); } // ── Contract v1: unified patch application ────────── // Both scopes answer with {status:"ok", edits:[{id, html}]}. // Each edit replaces exactly one component, resolved by id; // untouched components are never re-parsed, so their types, // traits, and style bindings survive by construction. There // is NO loadProjectData here — no canvas rebuild, no // rebuild-window flag, no readiness settle loop. The canvas // frame and its injected styles are untouched throughout. if (!Array.isArray(data.edits)) { console.error( "ZENTSO", "AI response contract mismatch. Expected " + "{status:'ok', edits:[{id, html}]}. Received keys:", Object.keys(data), data.projectData ? "Response carries projectData — endpoint is " + "answering the RETIRED pre-v1 document contract." : "", "Full response:", data ); throw new Error("AI response missing edits array."); } if (data.edits.length === 0) { setBusy(false, ""); status.textContent = "No changes suggested."; return; } const wrapper = editor.getWrapper(); // Validate every edit and resolve its target BEFORE applying // any: a partial apply after a mid-batch failure would leave // the document in a state neither the user nor the endpoint // produced. const resolved = []; const rejected = []; data.edits.forEach(function (edit) { const reason = validateEdit(edit, wrapper); if (reason) { rejected.push({ id: edit && edit.id, reason: reason, }); return; } resolved.push({ edit: edit, target: wrapper.find("#" + edit.id)[0], }); }); // Contract §2.6: no nested edits. Applying an outer edit // destroys inner targets, so keep the outermost only. const targets = resolved.map(function (r) { return r.target; }); const flat = resolved.filter(function (r) { let p = r.target.parent && r.target.parent(); while (p) { if (targets.indexOf(p) !== -1) { rejected.push({ id: r.edit.id, reason: "nested inside another edit", }); return false; } p = p.parent && p.parent(); } return true; }); if (rejected.length) { console.error( "ZENTSO", "AI edits rejected by client validation:", rejected ); } if (!flat.length) { throw new Error( "AI response contained no valid edits (" + rejected.length + " rejected — see console)." ); } // Disjoint targets: application order is irrelevant. flat.forEach(function (r) { r.target.replaceWith(r.edit.html); }); console.debug( "ZENTSO", "AI edit applied:", flat.length + " component(s)", rejected.length ? rejected.length + " rejected" : "" ); // One explicit persist for the whole batch. replaceWith also // fires update events, so the debounced autosave would catch // it anyway — this just makes the save deterministic. saveGjsIntoRadEditor(editor); setBusy(false, ""); modal.close(); }) .catch(function (err) { console.error("ZENTSO", "AI edit failed:", err); setBusy(false, ""); status.style.color = "#ff6b6b"; status.textContent = (err && err.message) || "AI request failed."; }); } sendBtn.onclick = function () { runAi("custom_prompt", textarea.value.trim()); }; textarea.addEventListener("keydown", function (e) { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); runAi("custom_prompt", textarea.value.trim()); } }); modal.setTitle("AI assistant"); modal.setContent(""); modal.setContent(wrap); modal.open(); // Scope the black theme to this modal only: html-edit reuses the // same editor.Modal instance and must keep its own default styling. const dialogEl = modal.getContentEl && modal.getContentEl() ? modal.getContentEl().closest(".gjs-mdl-dialog") : null; if (dialogEl) { dialogEl.classList.add("pg-ai-modal-dialog"); editor.once("modal:close", function () { dialogEl.classList.remove("pg-ai-modal-dialog"); }); } setTimeout(function () { textarea.focus(); }, 0); }, }); // ── Save command ─────────────────────────────────────────────────────── cmdm.add("save-content", { run() { saveGjsIntoRadEditor(editor); }, }); // ── Panel buttons ────────────────────────────────────────────────────── pnm.addButton("options", [ { id: "edit", label: ` `, attributes: { title: "Edit code" }, command: "html-edit", }, { id: "save-button", label: ` `, attributes: { title: "Save" }, command: "save-content", }, ]); } function cleanProjectData(data) { if (!data || !data.pages) return data; function cleanComponents(components) { if (!Array.isArray(components)) return components; return components.map((comp) => { // Clean all   from textnode content if (comp.type === "textnode" && comp.content) { comp.content = comp.content .replace(/ /g, " ") // regular   .replace(/&nbsp;/g, " ") // double encoded &nbsp; .replace(/\u00a0/g, " "); // unicode non-breaking space } // Recurse into children if (comp.components) { comp.components = cleanComponents(comp.components); } return comp; }); } data.pages = data.pages.map((page) => { if (page.frames) { page.frames = page.frames.map((frame) => { if (frame.component) { frame.component = cleanComponents([frame.component])[0]; } return frame; }); } return page; }); return data; } function loadFromRadIntoGjs(editor) { const rad = typeof $find === "function" ? $find(editorId) : null; const sourceHtml = rad && typeof rad.get_html === "function" ? rad.get_html() : bodyInput.value || ""; //console.log("SOURCE HTML ON LOAD:", sourceHtml); if (!sourceHtml || !sourceHtml.trim()) { editor.setComponents(""); editor.setStyle(""); return; } const temp = document.createElement("div"); temp.innerHTML = sourceHtml; const metaTag = temp.querySelector( 'script#gjs-metadata[type="application/json"]', ); if (metaTag) { try { let projectData = JSON.parse(metaTag.textContent); projectData = cleanProjectData(projectData); editor.loadProjectData(projectData); return; } catch (e) { console.warn( "Could not parse GJS metadata, falling back to HTML/CSS parsing.", e, ); } } // The exported email document carries static head styles // (#pg-email-head-styles). They are document chrome, not authored // styles — feeding them back into the editor as project CSS would // duplicate them on every save/load cycle. Drop before extraction. const persistedHeadStyles = temp.querySelector( "style#pg-email-head-styles", ); if (persistedHeadStyles) persistedHeadStyles.remove(); const styleTags = Array.from(temp.querySelectorAll("style")); const scriptTags = Array.from(temp.querySelectorAll("script")); const css = styleTags.map((x) => x.innerHTML || "").join("\n"); styleTags.forEach((x) => x.remove()); scriptTags.forEach((x) => { if (x.id === "gjs-metadata" && x.type === "application/json") return; x.remove(); }); const html = temp.innerHTML; editor.setComponents(html || ""); editor.setStyle(css || ""); } function pgNeutralizeModalSubmitButtons(editor) { const fix = (root) => { root.querySelectorAll("button:not([type])").forEach((b) => { b.setAttribute("type", "button"); }); }; editor.on("modal:open", () => { const contentEl = editor.Modal.getContentEl(); if (!contentEl) return; const dialog = contentEl.closest(".gjs-mdl-dialog") || contentEl; fix(dialog); const mo = new MutationObserver(() => fix(dialog)); mo.observe(dialog, { childList: true, subtree: true }); editor.once("modal:close", () => mo.disconnect()); }); } function initGrapes() { if (grapesBootstrapped) return; if (!window.grapesjs) return; grapesBootstrapped = true; const externalPlugins = resolveConfiguredPluginFns(); const isCommEditor = editorId === COMMUNICATION_EDITOR_ID; // ── Editor-type plugin sets ───────────────────────────────────────── // The communication (email) editor deliberately loads NO webpage // plugins (preset-webpage, bootstrap-5, sliders, bg/gradient/filter, // custom-code): they register blocks and style properties that either // don't render in email clients or produce markup the inliner would // have to strip again. This mirrors grapesjs-preset-newsletter's // approach of treating the email editor as a different editor, not a // webpage editor with extra blocks. const websitePlugins = [ ...externalPlugins, "grapesjs-preset-webpage", "grapesjs-bootstrap-5", "grapesjs-parser-postcss", "grapesjs-tui-image-editor", "grapesjs-custom-code", "gjs-blocks-basic", "grapesjs-style-bg", "grapesjs-style-gradient", "grapesjs-style-filter", "grapesjs-lory-slider", ]; const emailPlugins = [ ...externalPlugins, "grapesjs-parser-postcss", "grapesjs-tui-image-editor", ]; // ── Email-safe Style Manager ──────────────────────────────────────── // Reduced property set for the communication editor (equivalent of // preset-newsletter's updateStyleManager). Everything here survives // computed-style inlining AND renders in Outlook's Word engine. // Deliberately absent: flex/position/transform/filter/gradient/shadow. const emailStyleManager = { sectors: [ { name: "Typography", open: true, buildProps: [ "font-family", "font-size", "font-weight", "letter-spacing", "color", "line-height", "text-align", "text-decoration", ], }, { name: "Background", open: false, buildProps: ["background-color"], }, { name: "Spacing", open: false, buildProps: ["padding", "margin"], }, { name: "Borders", open: false, buildProps: ["border", "border-radius"], }, { name: "Dimensions", open: false, buildProps: ["width", "max-width", "height"], }, ], }; // ── Email canvas shell ────────────────────────────────────────────── // Minimal email-context frame for the communication editor. The // #pg-email-canvas-base resets mirror buildEmailDocument()'s head // styles so the canvas is WYSIWYG against the exported document, and // the table/td resets (vertical-align:top, mso-table-*space) get // picked up by the computed-style inliner exactly like // preset-newsletter's cellStyle/tableStyle defaults. Canvas-only; // never persisted. // // The base CSS lives in a shared constant because it is the PRIMARY // 600px constraint: it caps the wrapper itself, and the inliner // freezes every child's computed width against that cap. frameContent // is only guaranteed on the first frame build — loadProjectData (AI // edits) can rebuild the frame from projectData's own frame records // and skip frameContent entirely, silently dropping this
`; const websiteFrameContent = ` Skip to main content
`; const initConfig = { container: "#" + gjsDiv.id, allowScripts: true, height: "100%", jsInHtml: true, showOffsets: true, fromElement: false, components: "", noticeOnUnload: false, storageManager: false, assetManager: { embedAsBase64: false, assets: [], }, plugins: isCommEditor ? emailPlugins : websitePlugins, colorPicker: { appendTo: "parent", offset: { top: 26, left: -166 }, }, canvas: { styles: resolvedConfig.canvasStyleUrls, frameContent: isCommEditor ? emailFrameContent : websiteFrameContent, }, pluginsOpts: isCommEditor ? {} : { "grapesjs-bootstrap-5": {}, "gjs-blocks-basic": { blocks: ["map"], category: "Extra", }, "grapesjs-preset-webpage": { blocks: [], useCustomTheme: true, }, }, // The website editor gets its right-hand panel system (views buttons // + views-container) from grapesjs-preset-webpage. The communication // editor excludes that preset, so it must define the panels itself: // without a "views-container" panel the BlockManager registers its // blocks but has no element to render into (empty right sidebar). panels: { defaults: isCommEditor ? [ { id: "devices-c", buttons: [ { id: "device-desktop", command: "pg-set-device-desktop", active: true, togglable: false, attributes: { title: "Desktop" }, label: ` `, }, { id: "device-tablet", command: "pg-set-device-tablet", togglable: false, attributes: { title: "Tablet" }, label: ` `, }, { id: "device-mobile", command: "pg-set-device-mobile", togglable: false, attributes: { title: "Mobile" }, label: ` `, }, ], }, { id: "options", buttons: [ { id: "pg-ai", command: "pg-ai-edit", togglable: false, attributes: { title: "AI" }, label: ` `, }, { id: "undo", command: "core:undo", togglable: false, attributes: { title: "Undo" }, label: ` `, }, { id: "redo", command: "core:redo", togglable: false, attributes: { title: "Redo" }, label: ` `, }, { id: "preview", command: "core:preview", togglable: false, attributes: { title: "Preview" }, label: ` `, }, { id: "swv", className: "fa fa-square-o", command: "core:component-outline", active: true, togglable: true, attributes: { title: "View components" }, }, ], }, { id: "views", buttons: [ { id: "open-sm", command: "core:open-styles", togglable: false, attributes: { title: "Styles" }, label: ` `, }, { id: "open-tm", command: "core:open-traits", togglable: false, attributes: { title: "Settings" }, label: ` `, }, { id: "open-layers", command: "core:open-layers", togglable: false, attributes: { title: "Layers" }, label: ` `, }, { id: "open-blocks", command: "core:open-blocks", togglable: false, active: true, attributes: { title: "Blocks" }, label: ` `, }, ], }, { id: "views-container" }, ] : [ { id: "options", buttons: [ { id: "swv", className: "fa fa-square-o", command: "core:component-outline", active: true, togglable: true, attributes: { title: "View components" }, }, ], }, ], }, }; if (isCommEditor) { initConfig.styleManager = emailStyleManager; // Explicit device set: don't rely on GrapesJS defaults. Desktop is // unconstrained (the email canvas preview style already caps the // content at 600px); tablet/mobile emulate common client widths. initConfig.deviceManager = { devices: [ { id: "desktop", name: "Desktop", width: "" }, { id: "tablet", name: "Tablet", width: "770px", widthMedia: "992px" }, { id: "mobile", name: "Mobile", width: "375px", widthMedia: "480px" }, ], }; } gjsEditor = grapesjs.init(initConfig); // ── Device-switch commands (communication editor only) ────────────── // The website editor gets set-device-* commands from // grapesjs-preset-webpage; the email editor registers its own so the // devices-c panel buttons resolve. Prefixed pg- to avoid clashing if // the preset is ever present on the same page. if (isCommEditor) { gjsEditor.Commands.add("pg-set-device-desktop", { run: (ed) => ed.setDevice("Desktop"), }); gjsEditor.Commands.add("pg-set-device-tablet", { run: (ed) => ed.setDevice("Tablet"), }); gjsEditor.Commands.add("pg-set-device-mobile", { run: (ed) => ed.setDevice("Mobile"), }); } // ── Inlined-HTML export command (communication editor only) ───────── // Same seam as preset-newsletter's gjs-get-inlined-html: the inliner // is exposed as a command so it is runnable from the console and from // saveGjsIntoRadEditor via one code path. Fails loudly, not silently: // a missing inliner in the email editor means broken Outlook output. if (isCommEditor) { gjsEditor.Commands.add("pg-get-inlined-html", { run(ed) { if ( !window.PagesGuruEmailInliner || typeof window.PagesGuruEmailInliner.inline !== "function" ) { throw new Error( "ZENTSO: PagesGuruEmailInliner missing. " + "foundation-marketing-components.js not loaded?" ); } // Guard at the point of consumption: the inliner reads computed // styles from the live canvas, so the 600px caps (base wrapper // cap + preview pin) MUST be in the CURRENT frame document. // loadProjectData (AI edits) rebuilds the frame asynchronously // and can discard the injected