Files
emailtemplate.it/public/assets/js/ui-editor.js
2026-01-20 22:15:24 +01:00

715 lines
26 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* /assets/js/ui-editor.js (KORRIGIERT: Speichern wird an iFrame-Editor delegiert) */
// Öffnen, Befüllen, Speichern (mit Live-HTML), Preview Race-Schutz & Lade-Overlay.
import { apiUpdate, toast, apiAction } from './api.js';
import { initCraftEditor } from './craft-editor.js';
export function initEditor() {
  // ... (Alle Konstanten bleiben unverändert) ...
  const dlg          = document.getElementById('editorDialog');
  const iframe       = document.getElementById('editorFrame');
const btnSave = document.getElementById('btn-save');
const btnPreview = document.getElementById('btn-preview');
const btnTest = document.getElementById('btn-test');
const btnClose = document.getElementById('btn-close');
const btnClear = document.getElementById('btn-clear-main');
const editorSelect = document.getElementById('editorTypeSelect');
const versionSelect = document.getElementById('versionSelect');
const btnRestoreVersion = document.getElementById('btn-restore-version');
const craftEditor = initCraftEditor();
  const prevDlg      = document.getElementById('previewDialog');
  const sendDlg      = document.getElementById('sendTestDialog');
  const sendForm     = document.getElementById('sendTestForm');
  const sendTo       = document.getElementById('send_to');
  const sendSubject  = document.getElementById('send_subject');
  const sendInfo     = document.getElementById('send_template_info');
  const btnCancelSend= document.getElementById('btn-cancel-send');
  const btnSendNow   = document.getElementById('btn-send-now');
  const sendSender   = document.getElementById('send_sender');
  const sendSenderHint = document.getElementById('send_sender_hint');
  const prevFrame    = document.getElementById('previewFrame');
  const btnPrevClose = document.getElementById('btn-close-preview');
let current = null; // { resource, id, name, section }
let bridgeListener = null;
let reqToken = 0; // steigender Token pro Öffnen -> ignoriert verspätete Events
let senderOptions = [];
let senderLoadPromise = null;
let currentEditorType = 'grapesjs';
let versionItems = [];
  const ok  = (m) => toast(m, true);
  const err = (m) => toast(m, false);
  // ---------- Hilfen ----------
function activeMode() {
const activeSection = window.__activeSection || current?.section || null;
return (activeSection?.slug) || (current?.resource) || 'emailtemplate';
}
function setSendContext(id, name = '') {
    if (sendDlg) {
      if (id) {
        sendDlg.dataset.templateId = String(id);
      } else {
        delete sendDlg.dataset.templateId;
      }
      if (name) sendDlg.dataset.templateName = name;
    }
    if (sendInfo) {
      if (!id) {
        sendInfo.textContent = 'Kein Template ausgewählt.';
        sendInfo.classList.add('text-rose-600');
      } else {
const label = name ? `${name} Template #${id}` : `Template #${id}`;
sendInfo.textContent = label;
sendInfo.classList.remove('text-rose-600');
}
}
}
function formatVersionDate(value) {
if (!value) return '';
try {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString('de-DE');
} catch {
return value;
}
}
function setVersionUiVisible(show) {
if (versionSelect) versionSelect.classList.toggle('hidden', !show);
if (btnRestoreVersion) btnRestoreVersion.classList.toggle('hidden', !show);
}
setVersionUiVisible(false);
function renderVersionOptions(items) {
versionItems = items || [];
if (!versionSelect) return;
const rows = Array.isArray(versionItems) ? versionItems : [];
versionSelect.innerHTML = '<option value="">Letzte Versionen</option>';
if (!rows.length) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = 'Keine Versionen vorhanden';
opt.disabled = true;
versionSelect.appendChild(opt);
versionSelect.disabled = true;
return;
}
versionSelect.disabled = false;
rows.forEach((item) => {
const opt = document.createElement('option');
const label = `#${item.version_no} ${formatVersionDate(item.created_at)}`;
opt.value = String(item.id);
opt.textContent = label;
versionSelect.appendChild(opt);
});
}
async function loadVersionsForCurrent() {
if (!current?.id) {
renderVersionOptions([]);
return;
}
try {
const res = await apiAction('content_versions.list', { method: 'GET', data: { content_id: current.id, id: current.id } });
if (!res?.ok) throw new Error(res?.error || 'Versionen konnten nicht geladen werden');
renderVersionOptions(Array.isArray(res?.items) ? res.items : []);
} catch {
renderVersionOptions([]);
}
}
  function writeHtmlToFrame(html) {
    iframe.srcdoc = `<!doctype html><html>
      <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
      <body id="gjs">${html || ''}</body>
    </html>`;
  }
async function readEditedHtml() {
if (currentEditorType === 'craftjs') {
return craftEditor ? craftEditor.getContent() : '';
}
const win = iframe?.contentWindow;
const doc = iframe?.contentDocument;
if (!win || !doc) return '';
const ed = win.__gjs || (win.grapesjs && win.grapesjs.editors && win.grapesjs.editors[0]) || null;
if (ed && typeof ed.getHtml === 'function') {
const html = ed.getHtml();
const css = (typeof ed.getCss === 'function') ? ed.getCss() : '';
return css ? `<style>${css}</style>\n${html}` : html;
}
const root = doc.querySelector('#gjs') || doc.body || doc.documentElement;
return root ? root.innerHTML : '';
}
  function waitForEditor(maxMs = 8000) {
    return new Promise((resolve, reject) => {
      const start = Date.now();
      (function poll() {
        const win = iframe?.contentWindow;
        const ed  = win?.__gjs || (win?.grapesjs && win.grapesjs.editors && win.grapesjs.editors[0]) || null;
        if (ed) return resolve(ed);
        if (Date.now() - start > maxMs) return reject(new Error('Editor not ready'));
        setTimeout(poll, 120);
      })();
    });
  }
  
  // 🚨 NEUE FUNKTION: Delegiert das Kommando an den Editor im iFrame
async function delegateCommand(commandName) {
    try {
      const editor = await waitForEditor(3000);
      if (editor.Commands.has(commandName)) {
        // Führt den Command im iFrame aus (z.B. 'save-data')
        editor.runCommand(commandName);
        return true;
      } else {
        err(`Delegieren fehlgeschlagen: Command '${commandName}' nicht gefunden.`);
        return false;
      }
    } catch (e) {
      err(`Delegieren fehlgeschlagen: Editor nicht bereit (${commandName}).`);
      console.error(e);
      return false;
    }
}
// ... (hideReadyBadge bleibt unverändert) ...
function hideReadyBadge(doc) {
    if (!doc) return;
    const kill = () => {
      const el = doc.getElementById('badge');
      if (el) el.style.display = 'none';
    };
    kill();
    const style = doc.createElement('style');
    style.textContent = `
      #badge { display:none !important; }
      .gjs-pn-status { display:none !important; }
      .ready-badge,
      .status-badge.ready,
      [data-status="ready"],
      [data-badge="ready"],
      .gjs-ready,
      .gjs-badge-ready { display:none !important; }
    `;
    doc.head.appendChild(style);
    const mo = new MutationObserver(() => { kill(); /* hideByText(doc); */ });
    mo.observe(doc.documentElement, { childList: true, subtree: true });
    setTimeout(() => { kill(); /* hideByText(doc); */ }, 150);
    setTimeout(() => { kill(); /* hideByText(doc); */ }, 500);
    setTimeout(() => { kill(); /* hideByText(doc); */ }, 1200);
}
function extractCraftHtml(craftJson, fallbackHtml) {
if (!craftJson) return fallbackHtml || '';
try {
const parsed = typeof craftJson === 'string' ? JSON.parse(craftJson) : craftJson;
if (parsed && typeof parsed.html === 'string') {
return parsed.html;
}
} catch {}
return fallbackHtml || '';
}
function looksCraftSerialized(payload) {
if (!payload) return false;
try {
const parsed = typeof payload === 'string' ? JSON.parse(payload) : payload;
return !!(parsed && typeof parsed === 'object' && parsed.ROOT);
} catch {
return false;
}
}
function setEditorType(nextType) {
currentEditorType = nextType === 'craftjs' ? 'craftjs' : 'grapesjs';
if (editorSelect) editorSelect.value = currentEditorType;
if (currentEditorType === 'craftjs') {
iframe?.classList?.add('hidden');
craftEditor?.show();
} else {
craftEditor?.hide();
iframe?.classList?.remove('hidden');
}
}
  
  // ... (Lade-Overlay bleibt unverändert) ...
  let veilEl = null;
  function ensureVeil() {
    if (veilEl) return veilEl;
    veilEl = document.createElement('div');
    Object.assign(veilEl.style, {
      position:'absolute', inset:'0', background:'rgba(248,250,252,.85)',
      display:'flex', alignItems:'center', justifyContent:'center',
      zIndex:'2147483000', fontFamily:'system-ui, -apple-system, Segoe UI, Roboto, Arial',
      fontSize:'14px', color:'#0f172a'
    });
    veilEl.innerHTML = `
      <div style="display:flex;flex-direction:column;gap:.6rem;align-items:center;">
        <div class="spinner" style="width:28px;height:28px;border-radius:999px;border:3px solid #cbd5e1;border-top-color:#0ea5e9;animation:spin .8s linear infinite"></div>
        <div style="font-weight:500;">Lade Editor …</div>
      </div>
      <style>@keyframes spin{to{transform:rotate(360deg)}}</style>
    `;
    const host = dlg?.querySelector('.h-full, .flex, .flex-col') || dlg;
    (host || document.body).appendChild(veilEl);
    return veilEl;
  }
  function showVeil(){ ensureVeil().style.display = 'flex'; }
  function hideVeil(){ if (veilEl) veilEl.style.display = 'none'; }
async function buildRefLibForContext() {
return { sections: [], blocks: [] };
}
async function buildSnippetsForContext() {
return [];
}
  async function loadSenderOptions(force = false) {
    if (!sendSender) return;
    if (senderLoadPromise && !force) return senderLoadPromise;
    senderLoadPromise = apiAction('account.senders.list', { method: 'GET' })
      .then(res => {
        senderOptions = res?.items || [];
        renderSenderOptions();
      })
      .catch(() => {
        senderOptions = [];
        renderSenderOptions();
      })
      .finally(() => {
        senderLoadPromise = null;
      });
    return senderLoadPromise;
  }
  function renderSenderOptions() {
    if (!sendSender) return;
    const previous = sendSender.value;
    let html = '<option value="">Standard (System)</option>';
    senderOptions.forEach(opt => {
      const label = opt.label || opt.from_name || opt.from_email;
      html += `<option value="${opt.id}">${escapeHtml(label)} &lt;${escapeHtml(opt.from_email)}&gt;</option>`;
    });
    sendSender.innerHTML = html;
    if (previous && senderOptions.some(opt => String(opt.id) === previous)) {
      sendSender.value = previous;
    } else {
      sendSender.value = '';
    }
    if (sendSenderHint) {
      sendSenderHint.classList.toggle('hidden', senderOptions.length > 0);
    }
  }
  // ---------- Initialen HTML-Inhalt in Editor pushen (mit Token/Race-Schutz) ----------
async function pushInitialHtmlToEditor({ mode, html, snippets, ref, token, hasJson, json }) {
if (token !== reqToken) return; // veraltete Anfrage ignorieren
const win = iframe?.contentWindow;
    const doc = iframe?.contentDocument;
    // NEU: HTML wird NUR über postMessage gesendet. Die Bridge im iFrame ist verantwortlich
    // dafür, das HTML in GrapesJS zu setzen, NACHDEM ihre Plugins fertig sind.
    try {
win?.postMessage({ source:'admin', type:'init', mode, html: html || '', snippets: snippets || [], ref: ref || {}, hasJson: !!hasJson, json: json || '' }, '*');
    } catch {}
    try {
      // Warten auf Editor ist noch sinnvoll, um das Lade-Badge zu unterdrücken,
      // aber wir manipulieren den Editor NICHT MEHR direkt von hier aus.
      await waitForEditor(6000); 
      if (token !== reqToken) return;
      
      // ... (Gelöschte Logik: ed.setComponents(html) ist nun in der Bridge-Logik) ...
    } catch {
      /* Falls GJS noch nicht bereit ist, arbeiten wir nur via postMessage. */
    }
    try { hideReadyBadge(doc); } catch {}
    if (token === reqToken) hideVeil();
  }
  // ---------- Öffnen ----------
async function open(item, resource, sectionOverride) {
const section = item?.section || sectionOverride || window.__activeSection || null;
current = {
      resource: 'content',
      id: Number(item?.id || 0),
      name: item?.name || '',
section: section,
    };
    if (!current.id) return err('Ungültige ID');
if (!current.section) return err('Section nicht gefunden');
    // globaler Kontext
    window.__currentItemId    = current.id;
window.__currentEditorCtx = { id: current.id, mode: current.section.slug, section: current.section };
setSendContext(current.section?.is_template ? current.id : 0, current.name);
if (btnTest) btnTest.classList.toggle('hidden', !current.section?.is_template);
setVersionUiVisible(true);
    // Neuen Token erzeugen & alten Listener entfernen
    reqToken++;
    const myToken = reqToken;
    if (bridgeListener) window.removeEventListener('message', bridgeListener);
    bridgeListener = null;
    // Overlay zeigen
    showVeil();
    // Daten parallel laden (fresh HTML + kontextgefilterte Snippets + Referenzen)
    let fresh = '';
    let snippets = [];
let refLib = { sections: [], blocks: [] };
let hasJson = false;
let jsonState = '';
let editorType = 'grapesjs';
let craftJson = '';
    await Promise.all([
      (async() => {
try {
const row = await apiAction('content.get', { method: 'GET', data: { id: current.id, section_id: current.section.id } });
const rawContent = row?.content ?? row?.item?.content ?? '';
const trimmed = String(rawContent || '').trim();
const looksJson = trimmed.startsWith('{') || trimmed.startsWith('[');
jsonState = looksJson ? rawContent : '';
hasJson = looksJson;
// API liefert jetzt top-level html/content; fallback auf item.*
fresh = row?.html ?? row?.item?.html ?? '';
if (!fresh && !looksJson) {
fresh = rawContent;
}
editorType = String(row?.editor_type ?? row?.item?.editor_type ?? 'grapesjs').toLowerCase();
craftJson = row?.craft_json ?? row?.item?.craft_json ?? '';
} catch {}
})(),
      (async() => { snippets = await buildSnippetsForContext(current); })(),
      (async() => { refLib   = await buildRefLibForContext(current); })(),
(async() => { await loadVersionsForCurrent(); })()
    ]);
editorType = editorType === 'craftjs' ? 'craftjs' : 'grapesjs';
setEditorType(editorType);
if (editorType === 'craftjs') {
const craftHtml = extractCraftHtml(craftJson, fresh);
craftEditor?.setContent(craftHtml, craftJson);
hideVeil();
if (dlg && typeof dlg.showModal === 'function') dlg.showModal();
if (!looksCraftSerialized(craftJson) && craftEditor?.serializeFromHtml) {
const seed = craftEditor.serializeFromHtml(craftHtml);
try {
await apiUpdate('content', current.id, {
editor_type: 'craftjs',
html: craftHtml,
craft_json: seed,
section_id: current.section.id,
});
} catch {}
}
return;
}
    // iFrame-Load -> Bridge-Ready abhören
    iframe.onload = function () {
      if (myToken !== reqToken) return;
      try { hideReadyBadge(iframe.contentDocument); } catch {}
bridgeListener = (ev) => {
const d = ev?.data || {};
if (!d) return;
if (d.source === 'bridge-core' && d.type === 'rte-blur') {
try { console.log(d.detail || '[RTE BLUR]'); } catch {}
return;
}
// wir erwarten Nachrichten aus der Bridge/Editor
if (d.source !== 'bridge' && d.source !== 'editor') return;
        if (myToken !== reqToken) return;
        // NEU: Wenn der Editor meldet, dass er *gespeichert* hat,
        // aktualisieren wir die Liste im Elternfenster
        if (d.type === 'save:success') {
          ok('Gespeichert');
          try {
            if (typeof window.reloadActiveList === 'function') window.reloadActiveList();
            else if (typeof window.__reloadList === 'function') window.__reloadList(current.section);
          } catch {}
          return;
        }
        
        // neue Bridge meldet gjs:ready; ältere evtl. core-ready/bridge:ready
        if (d.type === 'gjs:ready' || d.type === 'core-ready' || d.type === 'bridge:ready' || d.type === 'bridge:booted') {
          pushInitialHtmlToEditor({
            mode: current.section.slug,
            html: fresh,
            snippets,
ref: {
sections: (refLib.sections || []).map(r => ({ id:r.id, name:r.name, html:r.html || '' })),
blocks: (refLib.blocks || []).map(r => ({ id:r.id, name:r.name, html:r.html || '' }))
},
token: myToken,
hasJson,
json: jsonState
});
}
};
      window.addEventListener('message', bridgeListener);
      // Fallback, falls kein Ready ankommt
      setTimeout(() => {
pushInitialHtmlToEditor({
mode: current.section.slug,
html: fresh,
snippets,
ref: {
sections: (refLib.sections || []).map(r => ({ id:r.id, name:r.name, html:r.html || '' })),
blocks: (refLib.blocks || []).map(r => ({ id:r.id, name:r.name, html:r.html || '' }))
},
token: myToken,
hasJson,
json: jsonState
});
}, 1200);
};
    // Jetzt den Editor-Core laden (erst NACH about:blank)
    iframe.src = `editor/editor-core.php?mode=${encodeURIComponent(current.section.slug)}&id=${current.id}&section_id=${current.section.id}&t=${Date.now()}`;
    dlg?.showModal?.();
  }
// ---------- Speichern (DELEGIERT) ----------
// 🚨 KORRIGIERT: Delegiert Speichern an den iFrame, der die JSON-Daten holt!
async function save() {
if (!current?.id) return err('Keine aktive ID');
if (currentEditorType === 'craftjs') {
const html = craftEditor ? craftEditor.getContent() : '';
const craftJson = craftEditor && craftEditor.getCraftJson
? craftEditor.getCraftJson()
: JSON.stringify({ html });
const payload = { html, craft_json: craftJson, editor_type: 'craftjs', section_id: current.section.id };
const res = await apiUpdate('content', current.id, payload);
if (res?.ok) ok('Gespeichert');
else err(res?.error || 'Speichern fehlgeschlagen');
if (res?.ok) setTimeout(loadVersionsForCurrent, 300);
return res?.ok;
}
const okSave = await delegateCommand('save-data');
if (okSave) setTimeout(loadVersionsForCurrent, 800);
return okSave;
}
  // ... (Der Rest der Funktionen bleibt unverändert) ...
async function clearEditor() {
if (currentEditorType === 'craftjs') {
craftEditor?.clear();
return;
}
const win = iframe?.contentWindow;
const ed = win?.__gjs || (win?.grapesjs && win.grapesjs.editors && win.grapesjs.editors[0]) || null;
if (ed) {
ed.setComponents('');
ed.setStyle('');
    } else {
      writeHtmlToFrame('');
    }
  }
  async function openPreview() {
    const html = await readEditedHtml();
    prevFrame.srcdoc = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body>${html || '<em>(leer)</em>'}</body></html>`;
    prevDlg?.showModal?.();
  }
  
  async function openSend(ctx = null) {
if (!current?.section?.is_template) {
err('Kein Template geladen');
return;
}
const ctxId = ctx?.id ? Number(ctx.id) : (window.__currentItemId || current?.id || 0);
    if (!ctxId) {
      err('Kein Template geladen');
      return;
    }
    window.__currentItemId = ctxId;
    const ctxName = ctx?.name ?? sendDlg?.dataset?.templateName ?? current?.name ?? '';
    setSendContext(ctxId, ctxName);
    if (sendSubject) sendSubject.value = ctx?.subject || 'Testversand';
    if (sendTo) sendTo.value = ctx?.to || '';
    await loadSenderOptions(true);
    sendDlg?.showModal?.();
  }
  function closeSend(){
    sendDlg?.close?.();
    if (sendSender) sendSender.value = '';
  }
  async function doSend(ev){
    ev?.preventDefault?.();
    const to = (sendTo?.value || '').trim();
    if(!to){ err('Bitte Empfänger angeben'); return; }
    const win = iframe?.contentWindow;
    const ctx = (win && win.__currentEditorCtx) || {};
    const id  = (window.__currentItemId || ctx?.id || 0);
    if(!id){ toast("Kein Template geladen", false); return; }
    // Hier wird der gespeicherte HTML-Code verwendet, nicht der Live-HTML, da apiAction
    // keine Live-Daten erwartet. Es geht um template_id.
    const payload = {
      template_id: id,
      to,
      subject: (sendSubject?.value || 'Testversand'),
    };
    if (sendSender && sendSender.value) {
      payload.sender_id = Number(sendSender.value);
    }
    const r = await apiAction('templates.test_send', { method:'POST', data: payload });
    if(r?.ok){ toast("Testversand ausgelöst"); closeSend(); } else { toast("Senden fehlgeschlagen", false); }
  }
  function closePreview(){ prevDlg?.close?.(); }
function close() {
    // nächstes Öffnen invalidiert laufende asyncs
    reqToken++;
    try { iframe.contentWindow?.postMessage({source:'admin',type:'reset'}, '*'); } catch {}
    if (bridgeListener) window.removeEventListener('message', bridgeListener);
    bridgeListener = null;
    hideVeil();
    dlg?.close?.();
    // iFrame zurück auf blank
    iframe.src = 'about:blank#' + Date.now();
    // Kontext leeren
    current = null;
    window.__currentItemId = undefined;
    window.__currentEditorCtx = undefined;
}
async function switchEditor(nextType) {
if (!current?.id) return;
const target = nextType === 'craftjs' ? 'craftjs' : 'grapesjs';
if (target === currentEditorType) return;
const confirmed = window.confirm('Editor wechseln? Ungespeicherte Änderungen gehen verloren.');
if (!confirmed) {
if (editorSelect) editorSelect.value = currentEditorType;
return;
}
if (currentEditorType === 'grapesjs' && target === 'craftjs') {
const html = await readEditedHtml();
const craftJson = craftEditor && craftEditor.serializeFromHtml
? craftEditor.serializeFromHtml(html)
: JSON.stringify({ html });
const res = await apiUpdate('content', current.id, {
editor_type: 'craftjs',
html,
craft_json: craftJson,
section_id: current.section.id,
});
if (!res?.ok) {
err(res?.error || 'Editorwechsel fehlgeschlagen');
if (editorSelect) editorSelect.value = currentEditorType;
return;
}
setEditorType('craftjs');
craftEditor?.setContent(html, craftJson);
iframe.src = 'about:blank#' + Date.now();
ok('Editor gewechselt');
return;
}
if (currentEditorType === 'craftjs' && target === 'grapesjs') {
const html = craftEditor ? craftEditor.getContent() : '';
const res = await apiUpdate('content', current.id, {
editor_type: 'grapesjs',
html,
section_id: current.section.id,
});
if (!res?.ok) {
err(res?.error || 'Editorwechsel fehlgeschlagen');
if (editorSelect) editorSelect.value = currentEditorType;
return;
}
ok('Editor gewechselt');
await open({ id: current.id, name: current.name, section: current.section }, 'content', current.section);
}
}
// Buttons
btnSave && (btnSave.onclick = save);
btnClear && (btnClear.onclick = clearEditor);
btnClose && (btnClose.onclick = close);
  btnPrevClose && (btnPrevClose.onclick = closePreview);
btnPreview && (btnPreview.onclick = openPreview);
btnTest && (btnTest.onclick = openSend);
btnCancelSend&& (btnCancelSend.onclick= closeSend);
sendForm && (sendForm.onsubmit = doSend);
editorSelect && (editorSelect.onchange = () => switchEditor(editorSelect.value));
btnRestoreVersion && (btnRestoreVersion.onclick = async () => {
if (!current?.id) return;
const versionId = Number(versionSelect?.value || 0);
if (!versionId) {
err('Bitte eine Version auswählen');
return;
}
if (!confirm('Version wiederherstellen? Der aktuelle Stand wird überschrieben.')) return;
try {
const res = await apiAction('content_versions.restore', { method: 'POST', data: { id: versionId, content_id: current.id } });
if (!res?.ok) throw new Error(res?.error || 'Wiederherstellen fehlgeschlagen');
ok('Version wiederhergestellt');
await open({ id: current.id, name: current.name, section: current.section }, null, current.section);
} catch (e) {
err(e.message || 'Wiederherstellen fehlgeschlagen');
}
});
window.AdminTestSend = window.AdminTestSend || {};
window.AdminTestSend.open = (opts = {}) => {
const targetId = Number(opts.id || window.__currentItemId || 0);
if (!targetId) {
err('Testversand: Keine ID vorhanden');
return;
}
if (!current?.section?.is_template) {
err('Kein Template geladen');
return;
}
window.__currentItemId = targetId;
setSendContext(targetId, opts.name || '');
openSend({ id: targetId, name: opts.name || '' });
};
  // Public API
  window.EditorUI = { open, save, close, clear: clearEditor, preview: openPreview };
}
function escapeHtml(str) {
  return String(str || '')
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}
// Default-Export + globaler Fallback
export default initEditor;
window.initEditor = initEditor;