3 * Content CMS: tree, page editor, media upload.
5(function (window, document) {
8 if (!window.dbx || !window.dbx.feature) {
9 console.error("[dbx][cms] dbx core missing");
13 const dbx = window.dbx;
15 const TREE_UI_ID = "dbxContent_admin.contentTree";
16 const PANEL_UI_ID = "dbxContent_admin.panels";
18 function qs(root, sel) {
19 return root ? root.querySelector(sel) : null;
22 function qsa(root, sel) {
23 return root ? Array.from(root.querySelectorAll(sel)) : [];
26 function cmsMessages(root) {
28 if (root._dbxCmsMessages) return root._dbxCmsMessages;
30 root._dbxCmsMessages = JSON.parse(root.getAttribute("data-cms-messages") || "{}");
32 root._dbxCmsMessages = {};
34 return root._dbxCmsMessages;
37 function cmsText(root, key, fallback) {
38 const messages = cmsMessages(root);
39 return Object.prototype.hasOwnProperty.call(messages, key)
40 ? String(messages[key])
41 : String(fallback || key);
44 function cmsLanguage(root) {
45 const language = String(root && root.getAttribute("data-cms-language") || "de").toLowerCase();
46 return ["de", "en", "es"].includes(language) ? language : "de";
49 function closestElement(target, selector) {
50 if (!target) return null;
51 const el = target.nodeType === 1 ? target : target.parentElement;
52 return el && el.closest ? el.closest(selector) : null;
55 function status(root, msg, type = "info") {
56 const modal = state(root).mediaBrowser;
57 const el = qs(root, "[data-cms-status]") || (modal ? qs(modal, "[data-cms-status]") : null);
59 if (msg && type === "error") {
60 dbx.warn("[cms]", msg);
64 el.className = "dbx-cms-status dbx-cms-status-" + type;
65 el.textContent = msg || "";
66 if (msg) window.setTimeout(() => { el.textContent = ""; }, 3500);
69 function apiUrl(url, params) {
70 const out = new URL(String(url || window.location.href), window.location.href);
71 Object.keys(params || {}).forEach(key => out.searchParams.set(key, params[key]));
72 out.searchParams.set("_", Date.now());
73 return out.toString();
76 function fetchJson(url, opt) {
78 if (!dbx.ajax || typeof dbx.ajax.request !== "function") {
79 return Promise.reject(new Error("ajax.js nicht geladen."));
82 return dbx.ajax.request({
84 method: opt.method || "GET",
86 body: (typeof opt.body !== "undefined") ? opt.body : null,
87 data: (typeof opt.data !== "undefined") ? opt.data : null,
88 headers: opt.headers || {},
89 timeout: Number(opt.timeout || 20000),
90 footerRuntime: opt.footerRuntime || ""
92 if (err && err.name === "AbortError") throw new Error("Serverantwort dauert zu lange.");
97 function fetchHtml(url, opt) {
99 if (!dbx.ajax || typeof dbx.ajax.request !== "function") {
100 return Promise.reject(new Error("ajax.js nicht geladen."));
103 return dbx.ajax.request({
105 method: opt.method || "GET",
107 body: (typeof opt.body !== "undefined") ? opt.body : null,
108 data: (typeof opt.data !== "undefined") ? opt.data : null,
109 headers: opt.headers || {},
110 timeout: Number(opt.timeout || 20000),
111 footerRuntime: opt.footerRuntime || ""
114 if (err && err.name === "AbortError") throw new Error("Serverantwort dauert zu lange.");
119 function ensureFeature(name) {
120 if (!name) return Promise.resolve(false);
121 if (dbx.feature && dbx.feature.has && dbx.feature.has(name)) {
122 return Promise.resolve(true);
125 return new Promise(resolve => {
126 if (!dbx.resolveFeature) {
131 dbx.resolveFeature(name, ok => resolve(ok === true));
135 function loadAssets(list) {
136 return new Promise(resolve => {
137 if (!Array.isArray(list) || !list.length) {
147 dbx.load(list, () => resolve(true));
151 function ensureOpenWin() {
152 return ensureFeature("openWin").then(ok => {
153 return ok && dbx.openWin && typeof dbx.openWin.open === "function";
157 function ensureConfirm() {
158 return ensureFeature("confirm").then(ok => {
159 return ok && dbx.confirm && typeof dbx.confirm.open === "function";
163 function syncCmsSelectTitle(target) {
164 const select = closestElement(target, "select[data-cms-select-title]");
166 const option = select.options && select.selectedIndex >= 0
167 ? select.options[select.selectedIndex]
169 select.title = option ? String(option.text || "") : "";
172 function ensureAjax() {
173 return ensureFeature("ajax").then(ok => {
174 return ok && dbx.ajax && typeof dbx.ajax.request === "function";
178 function ensureJodit() {
179 if (window.Jodit && window.Jodit.make) {
180 return Promise.resolve(true);
184 ["css", "root", "vendor/jodit/jodit.fat.min.css"],
185 ["js", "root", "vendor/jodit/jodit.fat.min.js"]
186 ]).then(() => !!(window.Jodit && window.Jodit.make));
189 function extractProcessHtml(html) {
190 const tpl = document.createElement("template");
191 tpl.innerHTML = String(html || "").trim();
192 const proc = tpl.content.querySelector("[data-dbx-process-root='1'], .dbx-process");
193 return proc ? proc.outerHTML : String(html || "");
196 function escapeHtml(text) {
197 const div = document.createElement("div");
198 div.textContent = text == null ? "" : String(text);
199 return div.innerHTML;
202 function escapeTooltipAttr(text) {
203 return escapeHtml(String(text == null ? "" : text).replace(/"/g, "'"));
206 function escapeTextareaValue(text) {
207 return String(text == null ? "" : text).replace(/<\/textarea/gi, "</textarea");
210 function fillLngProvisionContentPreviews(modal, items) {
211 if (!modal || !Array.isArray(items)) return;
212 items.forEach((item, index) => {
213 const preview = qs(modal, `[data-lng-content-preview="${index}"]`);
214 if (!preview) return;
215 const html = String(item && item.content != null ? item.content : "").trim();
216 preview.innerHTML = html;
218 preview.classList.add("is-empty");
223 function state(root) {
224 root.__dbxCms = root.__dbxCms || {
227 selectedFolder: Number(dbx.uiGet ? dbx.uiGet(LIB, TREE_UI_ID, "selectedFolder", 0) : 0) || 0,
229 selectedPage: Number(dbx.uiGet ? dbx.uiGet(LIB, TREE_UI_ID, "selectedPage", 0) : 0) || 0,
230 selectedType: dbx.uiGet ? String(dbx.uiGet(LIB, TREE_UI_ID, "selectedType", "page") || "page") : "page",
234 collapsedFolders: null,
235 selectionRestored: false,
245 return root.__dbxCms;
248 function cfgUrl(cfg, key) {
249 return cfg[key] || "";
252 function browserCfg(modal) {
253 return modal && modal.__dbxCmsCfg ? modal.__dbxCmsCfg : {};
257 * Liest ein serverseitig durch dbxForm gerendertes Medienformular.
258 * Der Medienbrowser erzeugt dadurch weder Form-Struktur noch Security-
261 function mediaBrowserFormHtml(root, selector) {
262 const template = qs(root, selector);
263 return template ? String(template.innerHTML || "").trim() : "";
267 * Uebernimmt den nach jedem JSON-Submit rotierten dbxForm-Token.
269 function applyFormSecurity(form, data) {
270 const security = data && data.form_security;
271 if (!form || !security || !security.name || !security.value) return;
272 const input = qs(form, `input[name="${String(security.name).replace(/"/g, '\\"')}"]`);
274 input.value = String(security.value);
275 // form.reset() muss ebenfalls auf den rotierten Token und nicht
276 // auf den beim ersten Rendern gesetzten Wert zurueckgehen.
277 input.defaultValue = String(security.value);
281 function mediaBrowserProfile(cfg) {
282 const media = String(cfgUrl(cfg, "media") || "");
283 if (media.indexOf("modul_images_media") >= 0) return "mod";
287 function applyMediaBrowserProfile(modal, cfg) {
288 const isMod = mediaBrowserProfile(cfg) === "mod";
289 modal.dataset.dbxMediaProfile = isMod ? "mod" : "cms";
290 const folderBar = qs(modal, ".dbx-cms-media-folderbar");
291 const externalVideo = qs(modal, "[data-cms-browser-external-video]");
292 const slotSelect = qs(modal, "[data-cms-media-browser-slot]");
293 const uploadFolder = qs(modal, "[data-cms-upload-folder]");
294 const folderSelect = qs(modal, "[data-cms-media-browser-folder]");
295 const folderTree = qs(modal, "[data-cms-media-folder-tree]");
296 const batchBtn = qs(modal, "[data-cms-media-batch-open]");
297 const rootHint = qs(modal, "[data-cms-media-root-hint]");
299 rootHint.textContent = isMod ? "Speicherort: files/mod/" : "Speicherort: files/media/";
301 if (folderBar) folderBar.hidden = isMod;
302 if (externalVideo) externalVideo.hidden = isMod;
303 if (slotSelect) slotSelect.hidden = isMod;
304 if (uploadFolder) uploadFolder.hidden = isMod;
305 if (folderSelect) folderSelect.hidden = isMod;
306 if (folderTree) folderTree.hidden = isMod || !modal.classList.contains("is-folder-tree-open");
307 if (isMod) modal.classList.remove("is-folder-tree-open");
308 if (batchBtn) batchBtn.hidden = isMod;
311 function isMediaBrowserMulti(modal) {
312 const mode = modal && modal.__dbxCmsMediaMode ? modal.__dbxCmsMediaMode : "";
313 if (modal && modal.classList.contains("is-batch-open")) return true;
314 if (modal && modal.__dbxCmsSinglePick) return false;
315 // Im Editor dienen die Auswahl-Schalter dem Batch-Resize und dem
316 // Verschieben mehrerer Medien. Sie muessen daher ebenfalls sammeln,
317 // auch wenn die normale Kartenaktion ein einzelnes Medium einfuegt.
318 return mode === "editor" || mode === "pick" || (mode === "assign" && modal.__dbxCmsAssignSlot !== "hero");
321 function mediaBrowserUsesConfirmBar(modal) {
322 const mode = modal && modal.__dbxCmsMediaMode ? modal.__dbxCmsMediaMode : "";
323 return mode === "pick" || mode === "assign";
326 function confirmPickMediaBrowser(root, modal) {
327 const rows = selectedMediaBrowserRows(modal);
329 status(root, "Bitte mindestens ein Bild auswaehlen.", "error");
330 return Promise.resolve();
333 let chain = Promise.resolve();
334 let keepOpen = false;
335 rows.forEach(row => {
336 chain = chain.then(() => {
337 if (typeof modal.__dbxCmsAfterAssign === "function") {
338 return Promise.resolve(modal.__dbxCmsAfterAssign(row)).then(result => {
339 if (result === false) keepOpen = true;
344 return chain.then(() => {
346 clearCmsLoading(root);
350 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.close === "function") {
351 dbx.openWin.close(modal.__dbxCmsWindowId);
353 clearCmsLoading(root);
354 status(root, "Auswahl uebernommen.", "success");
358 function isViewMode(cfg) {
359 return String((cfg && cfg.mode) || "").toLowerCase() === "view";
362 function syncStickyHeaderOffset(root) {
363 const header = document.getElementById("dbxHeader");
364 const headerMenus = header ? qsa(header, ".dbx-menu").filter(el => el.parentElement === header) : [];
365 const visibleBottom = headerMenus.reduce((bottom, el) => {
366 const style = window.getComputedStyle ? window.getComputedStyle(el) : null;
367 if (style && (style.display === "none" || style.visibility === "hidden")) return bottom;
368 const rect = el.getBoundingClientRect();
369 if (!rect.height || rect.bottom <= 0) return bottom;
370 return Math.max(bottom, rect.bottom);
372 const headerBottom = header ? Math.max(0, header.getBoundingClientRect().bottom || 0) : 0;
373 const fallbackBottom = Math.min(visibleBottom || 0, 160);
374 const height = header ? Math.ceil(Math.min(headerBottom || fallbackBottom || 0, 120)) : 0;
375 root.style.setProperty("--dbx-cms-sticky-top", height + "px");
377 const cmsHead = qs(root, ".dbx-cms-head");
378 const cmsHeadStyle = cmsHead && window.getComputedStyle ? window.getComputedStyle(cmsHead) : null;
379 let cmsHeadTop = cmsHeadStyle ? parseFloat(cmsHeadStyle.top || "0") : 0;
380 if (!Number.isFinite(cmsHeadTop)) cmsHeadTop = 0;
381 const cmsHeadHeight = cmsHead ? Math.ceil(cmsHead.getBoundingClientRect().height || cmsHead.offsetHeight || 0) : 0;
382 const editorTop = cmsHead ? Math.ceil(cmsHeadTop + cmsHeadHeight) : height;
383 root.style.setProperty("--dbx-cms-editor-toolbar-top", editorTop + "px");
384 const instance = getEditorInstance(root);
385 if (instance && instance.o) instance.o.toolbarStickyOffset = editorTop;
388 function bindStickyHeaderOffset(root) {
389 if (root.__dbxCmsStickyOffsetBound) return;
390 root.__dbxCmsStickyOffsetBound = true;
391 syncStickyHeaderOffset(root);
392 window.setTimeout(() => syncStickyHeaderOffset(root), 50);
393 window.setTimeout(() => syncStickyHeaderOffset(root), 300);
394 window.addEventListener("resize", () => syncStickyHeaderOffset(root), { passive: true });
397 function syncTreeFlyoutPosition(root) {
398 if (!root || root.classList.contains("is-tree-collapsed")) return;
399 const btn = qs(root, ".dbx-cms-head [data-cms-action='toggle-tree-panel']");
400 const panel = isViewMode(cmsConfig(root)) ? qs(root, ".dbx-cms-view-tree-panel") : qs(root, ".dbx-cms-tree-panel");
401 if (!btn || !panel) return;
403 const rect = btn.getBoundingClientRect();
406 const maxWidth = isViewMode(cmsConfig(root)) ? 720 : 600;
407 const left = Math.max(minEdge, Math.min(rect.left, window.innerWidth - minEdge - maxWidth));
408 const width = Math.min(maxWidth, window.innerWidth - left - minEdge);
409 const top = Math.min(rect.bottom + gap, window.innerHeight - 80);
411 root.style.setProperty("--dbx-cms-tree-left", Math.round(left) + "px");
412 root.style.setProperty("--dbx-cms-tree-top", Math.round(top) + "px");
413 root.style.setProperty("--dbx-cms-tree-viewport-top", Math.round(top) + "px");
414 root.style.setProperty("--dbx-cms-tree-width", Math.round(width) + "px");
417 * backdrop-filter/transform an einem umgebenden App-Container kann
418 * fuer position:fixed einen eigenen Bezugspunkt erzeugen. rect ist
419 * dagegen immer viewportbezogen. Nach dem ersten Layout gleichen wir
420 * genau diesen Versatz aus, damit der Tree unter seinem Schalter sitzt.
422 const placed = panel.getBoundingClientRect();
423 const correctedLeft = left + (left - placed.left);
424 const correctedTop = top + (top - placed.top);
425 if (Number.isFinite(correctedLeft) && Math.abs(placed.left - left) > 0.5) {
426 root.style.setProperty("--dbx-cms-tree-left", Math.round(correctedLeft) + "px");
428 if (Number.isFinite(correctedTop) && Math.abs(placed.top - top) > 0.5) {
429 root.style.setProperty("--dbx-cms-tree-top", Math.round(correctedTop) + "px");
433 function bindTreeFlyoutPosition(root) {
434 if (root.__dbxCmsTreeFlyoutBound) return;
435 root.__dbxCmsTreeFlyoutBound = true;
437 const update = () => syncTreeFlyoutPosition(root);
438 window.addEventListener("resize", update, { passive: true });
439 window.addEventListener("scroll", update, { passive: true });
441 const scroller = closestElement(root, ".dbx-content");
442 if (scroller) scroller.addEventListener("scroll", update, { passive: true });
445 function applyTreePanelState(root, collapsed) {
446 root.classList.toggle("is-tree-collapsed", !!collapsed);
447 root.classList.remove("is-tree-peek");
448 if (!collapsed) clearTreeForceCollapse(root);
449 qsa(root, "[data-cms-action='toggle-tree-panel']").forEach(btn => {
450 btn.setAttribute("aria-expanded", collapsed ? "false" : "true");
451 btn.setAttribute("aria-label", collapsed
452 ? cmsText(root, "tree_show", "Content-Baum anzeigen")
453 : cmsText(root, "tree_hide", "Content-Baum ausblenden"));
454 btn.setAttribute("title", collapsed
455 ? cmsText(root, "tree_show", "Content-Baum anzeigen")
456 : cmsText(root, "tree_hide", "Content-Baum ausblenden"));
457 const icon = qs(btn, "i");
459 icon.classList.toggle("bi-list", collapsed);
460 icon.classList.toggle("bi-x-lg", !collapsed);
461 icon.classList.toggle("bi-layout-sidebar-inset", false);
465 syncTreeFlyoutPosition(root);
466 window.requestAnimationFrame(() => syncTreeFlyoutPosition(root));
467 window.setTimeout(() => syncTreeFlyoutPosition(root), 0);
471 function clearTreeForceCollapse(root) {
472 if (root.__dbxCmsTreeForceTimer) {
473 window.clearTimeout(root.__dbxCmsTreeForceTimer);
474 root.__dbxCmsTreeForceTimer = null;
476 root.classList.remove("is-tree-force-collapsed");
479 function forceCollapseTreePanel(root) {
480 root.classList.add("is-tree-force-collapsed");
481 if (root.__dbxCmsTreeForceTimer) window.clearTimeout(root.__dbxCmsTreeForceTimer);
482 root.__dbxCmsTreeForceTimer = window.setTimeout(() => {
483 root.__dbxCmsTreeForceTimer = null;
484 if (root.classList.contains("is-tree-collapsed")) {
485 root.classList.remove("is-tree-force-collapsed");
490 function clearTreeHoverTimers(root) {
491 if (root.__dbxCmsTreeHoverEnterTimer) {
492 window.clearTimeout(root.__dbxCmsTreeHoverEnterTimer);
493 root.__dbxCmsTreeHoverEnterTimer = null;
495 if (root.__dbxCmsTreeHoverLeaveTimer) {
496 window.clearTimeout(root.__dbxCmsTreeHoverLeaveTimer);
497 root.__dbxCmsTreeHoverLeaveTimer = null;
501 function setTreeHoverExpanded(root, expanded) {
503 if (expanded) ensureTreeLoaded(root, cmsConfig(root));
504 root.classList.toggle("is-tree-panel-hover", !!expanded);
505 if (isViewMode(cmsConfig(root)) && root.classList.contains("is-tree-collapsed")) {
506 if (expanded && !root.classList.contains("is-tree-force-collapsed")) {
507 root.classList.add("is-tree-peek");
509 root.classList.remove("is-tree-peek");
514 function scheduleTreeHover(root, expanded) {
518 if (root.__dbxCmsTreeHoverLeaveTimer) {
519 window.clearTimeout(root.__dbxCmsTreeHoverLeaveTimer);
520 root.__dbxCmsTreeHoverLeaveTimer = null;
522 if (root.classList.contains("is-tree-panel-hover")) return;
523 if (root.__dbxCmsTreeHoverEnterTimer) window.clearTimeout(root.__dbxCmsTreeHoverEnterTimer);
524 root.__dbxCmsTreeHoverEnterTimer = window.setTimeout(() => {
525 root.__dbxCmsTreeHoverEnterTimer = null;
526 setTreeHoverExpanded(root, true);
531 if (root.__dbxCmsTreeHoverEnterTimer) {
532 window.clearTimeout(root.__dbxCmsTreeHoverEnterTimer);
533 root.__dbxCmsTreeHoverEnterTimer = null;
535 if (!root.classList.contains("is-tree-panel-hover") && !root.classList.contains("is-tree-peek")) return;
536 if (root.__dbxCmsTreeHoverLeaveTimer) window.clearTimeout(root.__dbxCmsTreeHoverLeaveTimer);
537 root.__dbxCmsTreeHoverLeaveTimer = window.setTimeout(() => {
538 root.__dbxCmsTreeHoverLeaveTimer = null;
539 setTreeHoverExpanded(root, false);
543 function initTreePanelState(root) {
544 // Der Content-Baum ist eine teure Sekundaeransicht. Jede neue CMS-
545 // Instanz startet geschlossen; Daten und Zeilen werden erst nach einer
546 // bewussten Benutzeraktion geladen.
547 applyTreePanelState(root, true);
550 function applyRightPanelState(root, collapsed) {
551 const panel = qs(root, "[data-cms-right-panel]");
554 root.classList.toggle("is-right-panel-collapsed", !!collapsed);
555 qsa(root, "[data-cms-action='toggle-right-panel']").forEach(btn => {
556 btn.setAttribute("aria-expanded", collapsed ? "false" : "true");
557 btn.setAttribute("aria-label", collapsed
558 ? cmsText(root, "right_show", "Rechte Spalte einblenden")
559 : cmsText(root, "right_hide", "Rechte Spalte ausblenden"));
560 btn.setAttribute("title", collapsed
561 ? cmsText(root, "right_show", "Rechte Spalte einblenden")
562 : cmsText(root, "right_hide", "Rechte Spalte ausblenden"));
563 const icon = qs(btn, "i");
565 icon.classList.toggle("bi-list", !!collapsed);
566 icon.classList.toggle("bi-chevron-bar-right", !collapsed);
571 function initRightPanelState(root) {
572 if (!qs(root, "[data-cms-right-panel]")) return;
573 const collapsed = !!(dbx.uiGet ? dbx.uiGet(LIB, PANEL_UI_ID, "rightPanelCollapsed", false) : false);
574 applyRightPanelState(root, collapsed);
577 function scrollCmsToTop(root) {
578 const windowBody = closestElement(root, ".dbx-window-body");
579 const appContent = closestElement(root, ".dbx-content");
580 const scrollHost = windowBody || appContent;
582 if (scrollHost && typeof scrollHost.scrollTo === "function") {
583 scrollHost.scrollTo({ top: 0, behavior: "smooth" });
586 window.scrollTo({ top: 0, behavior: "smooth" });
589 function toggleRightPanel(root, trigger) {
590 const collapsed = !root.classList.contains("is-right-panel-collapsed");
591 applyRightPanelState(root, collapsed);
592 if (dbx.uiSet) dbx.uiSet(LIB, PANEL_UI_ID, "rightPanelCollapsed", collapsed);
593 if (!collapsed && trigger && trigger.classList.contains("dbx-cms-right-panel-toggle-bottom")) {
594 window.requestAnimationFrame(() => scrollCmsToTop(root));
598 function toggleTreePanel(root, cfg) {
599 const collapsed = !root.classList.contains("is-tree-collapsed");
600 clearTreeHoverTimers(root);
601 setTreeHoverExpanded(root, false);
602 applyTreePanelState(root, collapsed);
603 if (dbx.uiSet) dbx.uiSet(LIB, PANEL_UI_ID, "treeCollapsed", collapsed);
604 if (!collapsed) ensureTreeLoaded(root, cfg || cmsConfig(root));
607 function closeTreePanel(root) {
608 if (!root || root.classList.contains("is-tree-collapsed")) return;
609 clearTreeHoverTimers(root);
610 setTreeHoverExpanded(root, false);
611 applyTreePanelState(root, true);
612 if (dbx.uiSet) dbx.uiSet(LIB, PANEL_UI_ID, "treeCollapsed", true);
615 function bindAdminTreeOutsideClose(root, cfg) {
616 if (!root || isViewMode(cfg || {}) || root.__dbxCmsAdminTreeOutsideCloseBound) return;
617 root.__dbxCmsAdminTreeOutsideCloseBound = true;
619 document.addEventListener("click", e => {
620 if (root.classList.contains("is-tree-collapsed")) return;
621 const target = e.target;
622 const panel = qs(root, ".dbx-cms-tree-panel");
623 const toggle = closestElement(target, "[data-cms-action='toggle-tree-panel']");
624 if (toggle && root.contains(toggle)) return;
625 if (panel && panel.contains(target)) return;
626 closeTreePanel(root);
630 function bindViewTreeHover(root, cfg) {
631 if (!isViewMode(cfg) || root.__dbxCmsViewTreeHoverBound) return;
632 root.__dbxCmsViewTreeHoverBound = true;
634 const treePanel = qs(root, ".dbx-cms-view-tree-panel");
635 const viewPanel = qs(root, ".dbx-cms-view-panel");
638 treePanel.addEventListener("mouseenter", () => {
639 if (root.classList.contains("is-tree-collapsed") && !root.classList.contains("is-tree-force-collapsed")) scheduleTreeHover(root, true);
641 treePanel.addEventListener("mouseleave", () => {
642 scheduleTreeHover(root, false);
647 viewPanel.addEventListener("mouseenter", () => {
648 clearTreeHoverTimers(root);
649 setTreeHoverExpanded(root, false);
650 applyTreePanelState(root, true);
651 clearTreeForceCollapse(root);
652 if (dbx.uiSet) dbx.uiSet(LIB, PANEL_UI_ID, "treeCollapsed", true);
656 document.addEventListener("pointermove", e => {
657 if (!root.classList.contains("is-tree-force-collapsed")) return;
658 if (treePanel && treePanel.contains(e.target)) return;
659 clearTreeForceCollapse(root);
662 root.addEventListener("mouseover", e => {
663 if (!root.classList.contains("is-tree-collapsed")) return;
664 if (treePanel && treePanel.contains(e.target)) {
665 if (root.classList.contains("is-tree-force-collapsed")) return;
666 scheduleTreeHover(root, true);
669 if (viewPanel && viewPanel.contains(e.target)) {
670 clearTreeHoverTimers(root);
671 setTreeHoverExpanded(root, false);
672 applyTreePanelState(root, true);
673 clearTreeForceCollapse(root);
674 if (dbx.uiSet) dbx.uiSet(LIB, PANEL_UI_ID, "treeCollapsed", true);
679 function getCollapsedFolders(root) {
680 const s = state(root);
681 if (s.collapsedFolders && typeof s.collapsedFolders === "object") {
682 return s.collapsedFolders;
684 const saved = dbx.uiGet ? dbx.uiGet(LIB, TREE_UI_ID, "collapsedFolders", {}) : {};
685 s.collapsedFolders = (saved && typeof saved === "object" && !Array.isArray(saved)) ? saved : {};
686 return s.collapsedFolders;
689 function setFolderCollapsed(root, id, collapsed) {
690 const folders = getCollapsedFolders(root);
691 const key = String(id || "");
698 if (dbx.uiSet) dbx.uiSet(LIB, TREE_UI_ID, "collapsedFolders", folders);
701 function isFolderCollapsed(root, id, search) {
702 if (search) return false;
703 const folders = getCollapsedFolders(root);
704 return folders[String(id || "")] === true;
707 function setSelectedFolder(root, id) {
708 const s = state(root);
709 s.selectedFolder = Number(id || 0);
710 if (dbx.uiSet) dbx.uiSet(LIB, TREE_UI_ID, "selectedFolder", s.selectedFolder);
713 function setSelectedPage(root, id) {
714 const s = state(root);
715 s.selectedPage = Number(id || 0);
716 if (dbx.uiSet) dbx.uiSet(LIB, TREE_UI_ID, "selectedPage", s.selectedPage);
719 function setSelectedType(root, type) {
720 const s = state(root);
721 s.selectedType = type === "folder" ? "folder" : "page";
722 if (dbx.uiSet) dbx.uiSet(LIB, TREE_UI_ID, "selectedType", s.selectedType);
723 updateHeaderActionTooltips(root);
726 function revealTreeSelection(root) {
727 const s = state(root);
728 const type = s.selectedType === "folder" ? "folder" : "page";
729 const id = type === "folder" ? s.selectedFolder : s.selectedPage;
735 let node = findNode(root, type, id);
736 let parent = Number(node && node._parent || 0);
737 const seen = new Set();
738 while (parent > 0 && !seen.has(parent)) {
740 setFolderCollapsed(root, parent, false);
741 node = findNode(root, "folder", parent);
742 parent = Number(node && node._parent || 0);
746 window.requestAnimationFrame(() => {
747 const row = qs(root, ".dbx-cms-tree-row.is-active");
748 if (row && row.scrollIntoView) {
749 row.scrollIntoView({ block: "nearest", inline: "nearest" });
754 function openPreview(root) {
755 const id = Number(getField(root, "id") || 0);
757 status(root, "Bitte zuerst eine Seite auswaehlen.", "error");
761 const title = getField(root, "title") || "Content Vorschau";
762 const url = "?dbx_modul=dbxContent&dbx_run1=show&dbx_cid=" + encodeURIComponent(id) + "&dbx_window=1";
765 title: "Vorschau: " + title,
777 ensureOpenWin().then(ok => {
779 dbx.openWin.open(cfg, root);
781 status(root, "openWin.js nicht geladen.", "error");
786 function getEditorInstance(root) {
787 return root && root.__dbxCmsJodit ? root.__dbxCmsJodit : null;
790 function editorHeightGroup(root) {
791 return qs(root, ".dbx-cms-editor-group");
794 function syncEditorHeight(root) {
795 const group = editorHeightGroup(root);
796 const surface = editorSurface(root);
797 if (!group || !surface) return;
799 const instance = getEditorInstance(root);
800 const container = instance && instance.container ? instance.container : qs(root, ".jodit-container");
801 const workplace = qs(root, ".jodit-workplace");
802 const source = qs(root, ".jodit-source textarea, .jodit-source");
803 const sized = [container, workplace, surface, source].filter(Boolean);
805 if (container && container.classList && container.classList.contains("jodit_fullsize")) {
806 sized.forEach(el => {
807 if (!el.style) return;
808 el.style.removeProperty("height");
809 el.style.removeProperty("max-height");
810 el.style.removeProperty("min-height");
815 sized.forEach(el => {
816 if (!el.style) return;
817 el.style.height = "auto";
818 el.style.maxHeight = "none";
819 el.style.minHeight = "0px";
823 const contentHeight = Math.ceil(Math.max(
824 surface.scrollHeight || 0,
825 source ? (source.scrollHeight || 0) : 0
827 const nextHeight = Math.max(base, contentHeight);
828 const value = nextHeight + "px";
830 group.style.setProperty("--dbx-cms-editor-min-height", value);
831 sized.forEach(el => {
832 if (!el.style) return;
833 el.style.minHeight = value;
837 function scheduleEditorHeight(root) {
839 if (root.__dbxCmsEditorHeightFrame) {
840 window.cancelAnimationFrame(root.__dbxCmsEditorHeightFrame);
842 if (Array.isArray(root.__dbxCmsEditorHeightTimers)) {
843 root.__dbxCmsEditorHeightTimers.forEach(timer => window.clearTimeout(timer));
845 root.__dbxCmsEditorHeightFrame = window.requestAnimationFrame(() => {
846 root.__dbxCmsEditorHeightFrame = null;
847 syncEditorHeight(root);
849 // Ein einziger Nachlauf reicht für Schrift- und Medien-Reflow. Zuvor
850 // entstanden bei jedem Tastendruck drei weitere Layout-Timer.
851 root.__dbxCmsEditorHeightTimers = [180].map(delay => window.setTimeout(() => {
852 syncEditorHeight(root);
853 root.__dbxCmsEditorHeightTimers = [];
857 function bindEditorHeight(root) {
858 if (!root || root.__dbxCmsEditorHeightBound) return;
859 root.__dbxCmsEditorHeightBound = true;
861 window.addEventListener("resize", () => scheduleEditorHeight(root), { passive: true });
862 root.addEventListener("input", e => {
863 const surface = editorSurface(root);
864 if (surface && (e.target === surface || surface.contains(e.target))) {
865 scheduleEditorHeight(root);
868 root.addEventListener("load", e => {
869 const surface = editorSurface(root);
870 if (surface && e.target && surface.contains(e.target)) {
871 scheduleEditorHeight(root);
876 function setDirty(root, dirty) {
877 const s = state(root);
879 root.classList.toggle("is-dirty", s.dirty);
881 const el = qs(root, "[data-cms-dirty-state]");
884 const text = el.querySelector("span");
885 if (text) text.textContent = s.dirty
886 ? cmsText(root, "unsaved", "Ungespeichert")
887 : cmsText(root, "saved", "Gespeichert");
888 el.setAttribute("title", s.dirty
889 ? cmsText(root, "unsaved_title", "Ungespeicherte Änderungen")
890 : cmsText(root, "saved_title", "Alle Änderungen gespeichert"));
893 function setSaving(root, saving) {
894 const s = state(root);
896 root.classList.toggle("is-saving", s.saving);
897 root.setAttribute("aria-busy", s.saving ? "true" : "false");
899 qsa(root, "[data-cms-action='save'], [data-cms-action='save-settings'], [data-cms-action='save-folder']").forEach(btn => {
900 btn.disabled = s.saving;
901 btn.classList.toggle("is-saving", s.saving);
905 function updateHeaderActionTooltips(root) {
907 const isFolder = root.classList.contains("is-folder-editing");
908 const saveTitle = isFolder
909 ? cmsText(root, "folder_save_title", "Ordner speichern")
910 : cmsText(root, "page_save_title", "Seite speichern");
911 const deleteTitle = isFolder
912 ? cmsText(root, "folder_delete_title", "Ordner löschen")
913 : cmsText(root, "page_delete_title", "Seite löschen");
914 const saveBtn = qs(root, ".dbx-cms-head [data-cms-action='save']");
915 const deleteBtn = qs(root, ".dbx-cms-head [data-cms-action='delete']");
916 const duplicateBtn = qs(root, ".dbx-cms-head [data-cms-action='duplicate-page']");
917 const lngBtn = qs(root, ".dbx-cms-head [data-cms-action='lng-provision']");
918 const lngResetBtn = qs(root, ".dbx-cms-head [data-cms-action='lng-reset-sync']");
921 saveBtn.setAttribute("title", saveTitle);
922 saveBtn.setAttribute("aria-label", saveTitle);
925 deleteBtn.setAttribute("title", deleteTitle);
926 deleteBtn.setAttribute("aria-label", deleteTitle);
929 const canDuplicate = !isFolder && state(root).selectedType === "page" && !state(root).duplicating;
930 duplicateBtn.disabled = !canDuplicate;
931 duplicateBtn.setAttribute("title", canDuplicate
932 ? cmsText(root, "duplicate_title", "Ausgewählte Seite duplizieren")
933 : cmsText(root, "duplicate_select_title", "Zum Duplizieren zuerst eine Seite auswählen"));
934 duplicateBtn.setAttribute("aria-label", duplicateBtn.getAttribute("title"));
937 const cfg = cmsConfig(root);
938 const show = isMasterLngCfg(cfg, root);
939 lngBtn.hidden = !show;
940 lngBtn.style.display = show ? "" : "none";
943 const cfg = cmsConfig(root);
944 const show = isMasterLngCfg(cfg, root);
945 lngResetBtn.hidden = !show;
946 lngResetBtn.style.display = show ? "" : "none";
950 function currentCmsLng(root) {
951 const tab = qs(root, ".dbx-cms-lng-tab.is-active");
952 return tab ? String(tab.getAttribute("data-cms-lng") || "").toLowerCase() : "";
955 function cmsLngParams(root) {
956 const lng = currentCmsLng(root);
957 return lng ? { dbx_lng: lng } : {};
960 function isMasterLngCfg(cfg, root) {
961 const activeTab = qs(root, ".dbx-cms-lng-tab.is-active");
962 if (activeTab && activeTab.classList.contains("is-master")) {
966 let master = String((cfg && (cfg.masterlng || cfg.master_lng)) || "").toLowerCase().trim();
967 if (!/^[a-z]{2,3}$/.test(master)) {
968 const masterTab = qs(root, ".dbx-cms-lng-tab.is-master");
969 master = masterTab ? String(masterTab.getAttribute("data-cms-lng") || "de").toLowerCase() : "de";
972 const current = currentCmsLng(root);
973 return !current || current === master;
976 function maybeOpenLngProvisionAfterCreate(root, cfg, data) {
980 if (!isMasterLngCfg(cfg, root)) {
981 dbx.warn("[cms] provision dialog skipped: not master language tab");
985 if (Number(data.open_lng_provision) === 1) {
986 window.setTimeout(() => openLngProvisionDialog(root, cfg), 0);
990 const targets = Array.isArray(data.lng_sync_targets) ? data.lng_sync_targets : [];
991 if (targets.length) {
992 window.setTimeout(() => showLngSyncResultModal(root, data), 0);
996 function showLngSyncResultModal(root, data) {
997 const old = document.querySelector("[data-cms-lng-dialog]");
998 if (old) old.remove();
1000 const targets = Array.isArray(data.lng_sync_targets)
1001 ? data.lng_sync_targets.map(lng => String(lng || "").toLowerCase()).filter(Boolean)
1003 if (!targets.length) return;
1005 const updated = Array.isArray(data.lng_sync_updated) ? data.lng_sync_updated : [];
1006 const skipped = Array.isArray(data.lng_sync_skipped) ? data.lng_sync_skipped : [];
1007 const errors = Array.isArray(data.lng_sync_errors) ? data.lng_sync_errors.filter(Boolean) : [];
1008 const provider = String(data.lng_translate_provider || "copy").toLowerCase();
1009 const providerLabel = (!provider || provider === "undef") ? "copy" : provider;
1011 const updatedLngs = new Set(updated.map(item => String(item && item.lng || "").toLowerCase()).filter(Boolean));
1012 const skippedByLng = new Map();
1013 skipped.forEach(item => {
1014 const lng = String(item && item.lng || "").toLowerCase();
1015 if (lng) skippedByLng.set(lng, String(item && item.reason || ""));
1018 const reasonLabels = {
1019 manual: ["Manuell", "warning"],
1020 missing: ["Sprachversion fehlt", "warning"],
1021 not_found: ["Nicht gefunden", "danger"],
1022 folder_missing: ["Zielordner fehlt", "warning"],
1023 up_to_date: ["Bereits aktuell", "secondary"]
1025 const rows = targets.map(lng => {
1026 let label = "Keine Aenderung";
1027 let tone = "secondary";
1028 if (updatedLngs.has(lng)) {
1029 label = "Synchronisiert";
1031 } else if (skippedByLng.has(lng)) {
1032 const mapped = reasonLabels[skippedByLng.get(lng)] || ["Uebersprungen", "secondary"];
1036 return `<div class="dbx-cms-lng-row dbx-cms-lng-result-row">
1037 <strong>${escapeHtml(lng.toUpperCase())}</strong>
1038 <span class="badge text-bg-${escapeHtml(tone)}">${escapeHtml(label)}</span>
1042 const providerHint = providerLabel === "copy"
1043 ? "Die Inhalte wurden aus der Master-Sprache uebernommen. Eine automatische Uebersetzung ist nicht konfiguriert."
1044 : `Uebersetzungsdienst: ${escapeHtml(providerLabel)}`;
1045 const warningText = formatTranslateWarnings(data.translate_warnings);
1046 const warningsHtml = [warningText, ...errors.map(String)]
1048 .map(message => `<div class="dbx-cms-lng-warn">${escapeHtml(message)}</div>`)
1050 const mediaCopied = Number(data.lng_media_copied || 0);
1051 const mediaHtml = mediaCopied > 0
1052 ? `<div class="text-muted small">${mediaCopied} Medien-Verknuepfung(en) wurden uebernommen.</div>`
1055 const resultHtml = `<div class="d-grid gap-2">${rows}${mediaHtml}${warningsHtml}</div>`;
1056 ensureConfirm().then(ok => {
1058 showLngSyncResultFallback(root, providerHint, resultHtml, providerLabel);
1061 const oldDialog = document.querySelector("[data-cms-lng-dialog]");
1062 if (oldDialog) oldDialog.remove();
1063 return dbx.confirm.open({
1064 id: "cms-lng-sync-result",
1066 title: '<i class="bi bi-translate"></i> Sprachsynchronisierung',
1067 question: resultHtml,
1070 labelcancel: '<i class="bi bi-check-lg"></i> Schliessen',
1072 backdropclose: false,
1076 dbx.warn("[cms] sync result dialog fallback", err);
1077 showLngSyncResultFallback(root, providerHint, resultHtml, providerLabel);
1081 function cmsLanguageDialogZIndex() {
1083 document.querySelectorAll(".dbx-window, .dbx-window-overlay, .dbx-confirm-overlay, .dbx-confirm-dialog").forEach(el => {
1084 const value = parseInt(window.getComputedStyle(el).zIndex, 10);
1085 if (Number.isFinite(value)) max = Math.max(max, value + 20);
1087 return Math.min(2147483646, max);
1090 function showLngSyncResultFallback(root, providerHint, resultHtml, providerLabel) {
1091 const old = document.querySelector("[data-cms-lng-dialog]");
1092 if (old) old.remove();
1094 const modal = document.createElement("div");
1095 modal.className = "dbx-cms-lng-dialog dbx-cms-lng-result-dialog";
1096 modal.setAttribute("data-cms-lng-dialog", "1");
1097 modal.setAttribute("data-cms-lng-result-dialog", "1");
1098 modal.style.zIndex = String(cmsLanguageDialogZIndex());
1100 <div class="dbx-cms-lng-dialog-backdrop" data-cms-lng-close></div>
1101 <div class="dbx-cms-lng-dialog-panel" role="dialog" aria-modal="true" aria-label="Sprachsynchronisierung">
1102 <div class="dbx-cms-lng-dialog-head">
1103 <strong><i class="bi bi-translate me-2" aria-hidden="true"></i>Sprachsynchronisierung</strong>
1104 <button type="button" class="btn btn-sm btn-outline-secondary" data-cms-lng-close aria-label="Schliessen">×</button>
1106 <div class="dbx-cms-lng-dialog-body">
1107 <div class="alert alert-info py-2 mb-0">${providerHint}</div>
1110 <div class="dbx-cms-lng-dialog-foot">
1111 <span class="text-muted small">Provider: ${escapeHtml(providerLabel)}</span>
1112 <button type="button" class="btn btn-primary btn-sm" data-cms-lng-close>Schliessen</button>
1116 document.body.appendChild(modal);
1117 const close = () => {
1118 document.removeEventListener("keydown", onKeyDown);
1121 const onKeyDown = event => {
1122 if (event.key === "Escape") close();
1124 qsa(modal, "[data-cms-lng-close]").forEach(btn => btn.addEventListener("click", close));
1125 document.addEventListener("keydown", onKeyDown);
1128 function openLngProvisionDialog(root, cfg) {
1129 const s = state(root);
1130 const type = root.classList.contains("is-folder-editing") || s.selectedType === "folder" ? "folder" : "page";
1131 const id = type === "folder"
1132 ? Number(s.selectedFolder || getField(root, "folder") || 0)
1133 : Number(s.selectedPage || getField(root, "id") || 0);
1136 status(root, "Bitte zuerst eine Seite oder einen Ordner waehlen.", "error");
1139 if (!isMasterLngCfg(cfg, root)) {
1140 status(root, "Uebertragung nur in der Master-Sprache moeglich.", "error");
1144 const url = cfgUrl(cfg, "lngpreview");
1146 status(root, "Lng-Preview-URL fehlt.", "error");
1150 fetchJson(apiUrl(url, cmsLngParams(root)), {
1152 headers: { "Content-Type": "application/json" },
1153 body: JSON.stringify({ type, id })
1155 if (!data || !data.ok) {
1156 throw new Error(data && data.msg ? data.msg : "Vorschau fehlgeschlagen");
1158 showLngProvisionModal(root, cfg, type, id, data.preview || {}, data.provider || "", data.translate_warnings || []);
1159 const warn = formatTranslateWarnings(data.translate_warnings);
1160 if (warn) status(root, warn, "warning");
1162 status(root, err && err.message ? err.message : "Vorschau fehlgeschlagen.", "error");
1166 function showLngProvisionModal(root, cfg, type, id, preview, provider, translateWarnings) {
1167 const old = document.querySelector("[data-cms-lng-dialog]");
1168 if (old) old.remove();
1170 const prov = String(provider || "").toLowerCase();
1171 const provLabel = (!prov || prov === "undef") ? "copy" : prov;
1172 const items = Array.isArray(preview.items) ? preview.items : [];
1173 const warnGlobal = formatTranslateWarnings(translateWarnings);
1174 const warnGlobalHtml = warnGlobal ? `<div class="dbx-cms-lng-warn">${escapeHtml(warnGlobal)}</div>` : "";
1175 const treeOptionHtml = type === "folder"
1176 ? `<label class="dbx-cms-lng-check mb-2"><input type="checkbox" data-lng-provision-tree> Gesamten Unterbaum inkl. Seiten uebertragen</label>`
1178 const rows = items.map((item, index) => {
1179 const lng = String(item.lng || "").toUpperCase();
1180 const exists = Number(item.exists || 0) === 1;
1181 const warnings = Array.isArray(item.warnings) ? item.warnings.join(" ") : "";
1182 const warnHtml = warnings ? `<div class="dbx-cms-lng-warn">${escapeHtml(warnings)}</div>` : "";
1183 const existsLabel = exists ? " (vorhanden)" : " (neu)";
1185 if (type === "folder") {
1186 return `<div class="dbx-cms-lng-row" data-lng-row="${index}">
1187 <label class="dbx-cms-lng-check"><input type="checkbox" data-lng-enabled checked> <strong>${escapeHtml(lng)}</strong>${escapeHtml(existsLabel)}</label>
1188 <label>Name<input class="form-control form-control-sm" data-lng-name value="${escapeHtml(item.name || "")}"></label>
1190 <input type="hidden" data-lng-code value="${escapeHtml(item.lng || "")}">
1194 return `<div class="dbx-cms-lng-row" data-lng-row="${index}">
1195 <label class="dbx-cms-lng-check"><input type="checkbox" data-lng-enabled checked> <strong>${escapeHtml(lng)}</strong>${escapeHtml(existsLabel)}</label>
1196 <label>Titel<input class="form-control form-control-sm" data-lng-title value="${escapeHtml(item.title || "")}"></label>
1197 <label>Permalink<input class="form-control form-control-sm" data-lng-permalink value="${escapeHtml(item.permalink || "")}"></label>
1198 <label>Beschreibung<input class="form-control form-control-sm" data-lng-description value="${escapeHtml(item.description || "")}"></label>
1199 <input type="hidden" data-lng-folder value="${Number(item.folder || 0)}">
1200 <input type="hidden" data-lng-code value="${escapeHtml(item.lng || "")}">
1201 <details class="dbx-cms-lng-content-details"><summary>Inhalt (Vorschau)</summary>
1202 <div class="dbx-cms-lng-content-preview dbx-content-page" data-lng-content-preview="${index}"></div>
1204 <textarea class="d-none" data-lng-content aria-hidden="true">${escapeTextareaValue(item.content || "")}</textarea>
1209 const modal = document.createElement("div");
1210 modal.className = "dbx-cms-lng-dialog";
1211 modal.setAttribute("data-cms-lng-dialog", "1");
1212 modal.style.zIndex = String(cmsLanguageDialogZIndex());
1214 <div class="dbx-cms-lng-dialog-backdrop" data-cms-lng-close></div>
1215 <div class="dbx-cms-lng-dialog-panel" role="dialog" aria-modal="true" aria-label="Uebersetzungen pruefen">
1216 <div class="dbx-cms-lng-dialog-head">
1217 <strong>In andere Sprachen uebertragen</strong>
1218 <span class="text-muted">Provider: ${escapeHtml(provLabel)}</span>
1219 <button type="button" class="btn btn-sm btn-outline-secondary" data-cms-lng-close>×</button>
1221 <div class="dbx-cms-lng-dialog-body">
1224 ${rows || '<p class="text-muted">Keine Zielsprachen konfiguriert.</p>'}
1226 <div class="dbx-cms-lng-dialog-foot">
1227 <button type="button" class="btn btn-outline-secondary btn-sm" data-cms-lng-close>Abbrechen</button>
1228 <button type="button" class="btn btn-primary btn-sm" data-cms-lng-submit>Uebernehmen</button>
1232 document.body.appendChild(modal);
1233 fillLngProvisionContentPreviews(modal, items);
1235 qsa(modal, "[data-cms-lng-close]").forEach(btn => {
1236 btn.addEventListener("click", () => modal.remove());
1239 const submit = qs(modal, "[data-cms-lng-submit]");
1241 submit.addEventListener("click", () => {
1242 const payloadItems = qsa(modal, "[data-lng-row]").map(row => {
1243 const lng = qs(row, "[data-lng-code]");
1244 const enabled = qs(row, "[data-lng-enabled]");
1246 lng: lng ? lng.value : "",
1247 enabled: enabled && enabled.checked ? 1 : 0
1249 if (type === "folder") {
1250 const name = qs(row, "[data-lng-name]");
1251 out.name = name ? name.value : "";
1253 const title = qs(row, "[data-lng-title]");
1254 const permalink = qs(row, "[data-lng-permalink]");
1255 const description = qs(row, "[data-lng-description]");
1256 const content = qs(row, "[data-lng-content]");
1257 const folder = qs(row, "[data-lng-folder]");
1258 out.title = title ? title.value : "";
1259 out.permalink = permalink ? permalink.value : "";
1260 out.description = description ? description.value : "";
1261 out.content = content ? content.value : "";
1262 out.folder = folder ? Number(folder.value || 0) : 0;
1267 const treeMode = type === "folder" && !!(qs(modal, "[data-lng-provision-tree]") && qs(modal, "[data-lng-provision-tree]").checked);
1268 const provUrl = cfgUrl(cfg, treeMode ? "lngprovisiontree" : "lngprovision");
1269 const payload = treeMode
1270 ? { id, lngs: payloadItems.filter(item => Number(item.enabled) === 1).map(item => item.lng) }
1271 : { type, id, items: payloadItems };
1273 fetchJson(apiUrl(provUrl, cmsLngParams(root)), {
1275 headers: { "Content-Type": "application/json" },
1276 body: JSON.stringify(payload)
1278 if (!data || !data.ok) {
1279 const errs = data && data.result && Array.isArray(data.result.errors) ? data.result.errors.join(" ") : "";
1280 const transWarn = formatTranslateWarnings(data && data.result ? data.result.translate_warnings : null);
1281 throw new Error([data && data.msg, errs, transWarn].filter(Boolean).join(" ") || "Uebertragung fehlgeschlagen");
1284 let msg = treeMode ? "Unterbaum in andere Sprachen uebernommen." : "Sprachvarianten uebernommen.";
1285 const mediaCopied = Number((data.result && data.result.media_copied) || 0);
1286 if (mediaCopied > 0) msg += " " + mediaCopied + " Medien-Verknuepfung(en) kopiert.";
1287 const transWarn = formatTranslateWarnings(data.result && data.result.translate_warnings);
1288 if (transWarn) msg += " " + transWarn;
1289 status(root, msg, transWarn ? "warning" : "success");
1290 return loadTree(root, cfg);
1292 status(root, err && err.message ? err.message : "Uebertragung fehlgeschlagen.", "error");
1298 function formatTranslateWarnings(warnings) {
1299 if (!Array.isArray(warnings) || !warnings.length) return "";
1300 const msgs = warnings.map(w => w && w.message ? String(w.message) : "").filter(Boolean);
1301 if (!msgs.length) return "";
1302 return "Uebersetzung: " + [...new Set(msgs)].join(" ");
1305 function formatLngSaveStatus(base, data) {
1306 const parts = [base];
1307 const synced = Number(data.lng_synced || 0);
1308 const provider = String(data.lng_translate_provider || "").toLowerCase();
1310 parts.push(synced + " Sprachversion(en) synchronisiert" + (provider === "copy" ? " (Text kopiert)." : "."));
1312 const media = Number(data.lng_media_copied || 0);
1313 if (media > 0) parts.push(media + " Medien-Verknuepfung(en) uebernommen.");
1314 const inlineAdded = Number(data.inline_media_sync?.added || 0);
1315 const inlineRemoved = Number(data.inline_media_sync?.removed || 0);
1316 if (inlineAdded > 0) parts.push(inlineAdded + " Inline-Medium/Medien verknuepft.");
1317 if (inlineRemoved > 0) parts.push(inlineRemoved + " Inline-Medium/Medien aus der Liste entfernt.");
1318 if (Number(data.lng_forked || 0) === 1) parts.push("Sprachverknuepfung getrennt (manuell).");
1319 if (Number(data.open_lng_provision || 0) === 1) parts.push("Fehlende Sprachen — Dialog folgt.");
1320 const warn = formatTranslateWarnings(data.translate_warnings);
1321 if (warn) parts.push(warn);
1322 const syncErrors = Array.isArray(data.lng_sync_errors) ? data.lng_sync_errors.filter(Boolean) : [];
1323 if (syncErrors.length) parts.push("Sprachsync: " + [...new Set(syncErrors.map(String))].join(" "));
1324 const text = parts.join(" ");
1325 const hasWarn = Number(data.lng_forked || 0) === 1 || !!warn || syncErrors.length > 0;
1326 return { text, type: hasWarn ? "warning" : "success" };
1329 function resetLngSync(root, cfg) {
1330 const s = state(root);
1331 const type = root.classList.contains("is-folder-editing") || s.selectedType === "folder" ? "folder" : "page";
1332 const id = type === "folder"
1333 ? Number(s.selectedFolder || getFolderField(root, "id") || 0)
1334 : Number(s.selectedPage || getField(root, "id") || 0);
1335 const url = cfgUrl(cfg, "lngresetsync");
1338 status(root, "Bitte zuerst eine Seite oder einen Ordner waehlen.", "error");
1339 return Promise.resolve();
1341 if (!isMasterLngCfg(cfg, root)) {
1342 status(root, "Auto-Sync nur in der Master-Sprache setzbar.", "error");
1343 return Promise.resolve();
1346 return ensureConfirm().then(ok => {
1347 if (!ok) return null;
1348 return dbx.confirm.open({
1349 id: "cms-lng-reset-sync-" + id,
1351 title: "<i class=\"bi bi-link-45deg\"></i> Auto-Sync aktivieren",
1352 question: "Verknuepfte Sprachversionen wieder auf <strong>Auto-Sync</strong> stellen?",
1353 hint: "Manuelle Aenderungen in Slave-Sprachen bleiben erhalten, werden aber kuenftig wieder vom Master ueberschrieben.",
1355 labelyes: "Ja, Auto-Sync",
1356 labelno: "Abbrechen",
1358 backdropclose: false,
1362 if (!result || result.action !== "yes") return null;
1363 return fetchJson(apiUrl(url), {
1365 headers: { "Content-Type": "application/json" },
1366 body: JSON.stringify({ type, id })
1370 if (!data.ok) throw new Error(data && data.msg ? data.msg : "Auto-Sync konnte nicht gesetzt werden.");
1371 const count = Array.isArray(data.result && data.result.updated) ? data.result.updated.length : 0;
1372 status(root, count > 0 ? "Auto-Sync fuer " + count + " Sprache(n) aktiviert." : "Keine Sprachversion zum Aktualisieren gefunden.", count > 0 ? "success" : "warning");
1373 return loadTree(root, cfg);
1375 status(root, err && err.message ? err.message : "Auto-Sync konnte nicht gesetzt werden.", "error");
1379 function syncStatusLabel(sync, isMaster) {
1380 if (Number(isMaster) === 1) return "Master";
1381 const s = String(sync || "auto").toLowerCase();
1382 return s === "manual" ? "manuell" : "auto";
1385 function runSimpleDeleteConfirm(root, title, question, hint) {
1386 return ensureConfirm().then(ok => {
1388 status(root, "Confirm-Lib ist nicht geladen.", "error");
1391 return dbx.confirm.open({
1392 id: "cms-delete-simple-" + Date.now(),
1398 labelyes: "<i class=\"bi bi-trash\"></i> Loeschen",
1399 labelno: "<i class=\"bi bi-x-lg\"></i> Abbrechen",
1401 backdropclose: false,
1407 function executeLngDelete(root, cfg, type, id, deleteLngs, deleteUrl) {
1408 return fetchJson(apiUrl(deleteUrl), {
1410 headers: { "Content-Type": "application/json" },
1411 body: JSON.stringify({ id, delete_lngs: deleteLngs })
1413 if (!data || !data.ok) {
1414 throw new Error(data && data.msg ? data.msg : "delete failed");
1416 const count = Array.isArray(data.deleted) ? data.deleted.length : 0;
1417 const warnings = Array.isArray(data.warnings) ? data.warnings.filter(Boolean) : [];
1419 ? (type === "folder" ? "Ordner" : "Seite") + " in " + count + " Sprachen geloescht."
1420 : (type === "folder" ? "Ordner geloescht." : "Seite geloescht.");
1421 if (warnings.length) {
1422 msg += " Hinweis: " + warnings.join(" ");
1424 status(root, msg, warnings.length ? "warning" : "success");
1429 function showLngDeleteModal(root, cfg, type, id, label, preview) {
1430 const old = qs(root, "[data-cms-lng-delete-dialog]");
1431 if (old) old.remove();
1433 const items = Array.isArray(preview.items) ? preview.items : [];
1434 const rows = items.map((item, index) => {
1435 const lng = String(item.lng || "").toUpperCase();
1436 const checked = Number(item.checked || 0) === 1;
1437 const deletable = Number(item.deletable || 0) === 1;
1438 const syncLabel = syncStatusLabel(item.lng_sync, item.is_master);
1439 const syncClass = Number(item.is_master) === 1 ? "master" : String(item.lng_sync || "auto").toLowerCase();
1440 const blockReason = String(item.block_reason || "").trim();
1441 const disabled = deletable ? "" : " disabled";
1442 const checkedAttr = checked && deletable ? " checked" : "";
1443 const blockHtml = blockReason
1444 ? `<div class="dbx-cms-lng-warn">${escapeHtml(blockReason)}</div>`
1447 return `<div class="dbx-cms-lng-row dbx-cms-lng-delete-row" data-lng-delete-row="${index}">
1448 <label class="dbx-cms-lng-check">
1449 <input type="checkbox" data-lng-delete-enabled${disabled}${checkedAttr}>
1450 <strong>${escapeHtml(lng)}</strong>
1451 <span class="dbx-cms-lng-badge is-${escapeHtml(syncClass)}">${escapeHtml(syncLabel)}</span>
1452 <span class="text-muted">${escapeHtml(item.label || "")}</span>
1455 <input type="hidden" data-lng-delete-code value="${escapeHtml(item.lng || "")}">
1459 const entityLabel = type === "folder" ? "Ordner" : "Seite";
1460 const modal = document.createElement("div");
1461 modal.className = "dbx-cms-lng-dialog";
1462 modal.setAttribute("data-cms-lng-delete-dialog", "1");
1464 <div class="dbx-cms-lng-dialog-backdrop" data-cms-lng-delete-close></div>
1465 <div class="dbx-cms-lng-dialog-panel" role="dialog" aria-modal="true" aria-label="Sprachversionen loeschen">
1466 <div class="dbx-cms-lng-dialog-head">
1467 <strong>${escapeHtml(entityLabel)} in mehreren Sprachen loeschen</strong>
1468 <button type="button" class="btn btn-sm btn-outline-secondary" data-cms-lng-delete-close>×</button>
1470 <div class="dbx-cms-lng-dialog-body">
1471 <p class="mb-2">Welche Sprachversionen von <strong>${escapeHtml(label || entityLabel)}</strong> sollen geloescht werden?</p>
1472 <p class="text-muted small mb-3">Auto-Sync-Versionen sind vorausgewaehlt. Manuelle Versionen nur bei Bedarf aktivieren.</p>
1473 ${rows || '<p class="text-muted">Keine verknuepften Sprachversionen gefunden.</p>'}
1475 <div class="dbx-cms-lng-dialog-foot">
1476 <button type="button" class="btn btn-outline-secondary btn-sm" data-cms-lng-delete-close>Abbrechen</button>
1477 <button type="button" class="btn btn-danger btn-sm" data-cms-lng-delete-submit><i class="bi bi-trash"></i> Loeschen</button>
1481 root.appendChild(modal);
1483 qsa(modal, "[data-cms-lng-delete-close]").forEach(btn => {
1484 btn.addEventListener("click", () => modal.remove());
1487 const submit = qs(modal, "[data-cms-lng-delete-submit]");
1489 submit.addEventListener("click", () => {
1490 const deleteLngs = qsa(modal, "[data-lng-delete-row]").map(row => {
1491 const enabled = qs(row, "[data-lng-delete-enabled]");
1492 const code = qs(row, "[data-lng-delete-code]");
1493 if (!enabled || !enabled.checked || enabled.disabled) return "";
1494 return code ? code.value : "";
1497 if (!deleteLngs.length) {
1498 status(root, "Bitte mindestens eine loeschbare Sprachversion auswaehlen.", "error");
1502 const deleteUrl = cfgUrl(cfg, type === "folder" ? "deletefolder" : "deletepage");
1503 executeLngDelete(root, cfg, type, id, deleteLngs, deleteUrl).then(data => {
1505 if (type === "folder") {
1506 hideFolderEditor(root);
1507 const s = state(root);
1508 if (Number(s.selectedFolder || 0) === id) {
1509 setSelectedFolder(root, 0);
1510 setSelectedType(root, "page");
1513 setSelectedPage(root, 0);
1514 setSelectedType(root, "page");
1515 setEditorHtml(root, "");
1516 renderMedia(root, []);
1518 return loadTree(root, cfg);
1520 status(root, err && err.message ? err.message : "Loeschen fehlgeschlagen.", "error");
1526 function openLngDeleteDialog(root, cfg, type, id, label) {
1527 const previewUrl = cfgUrl(cfg, "lngdeletepreview");
1528 const deleteUrl = cfgUrl(cfg, type === "folder" ? "deletefolder" : "deletepage");
1529 const entityLabel = type === "folder" ? "Ordner" : "Seite";
1530 const title = `<i class="bi bi-trash"></i> ${entityLabel} loeschen`;
1531 const question = `${entityLabel} <strong>${escapeHtml(label || entityLabel)}</strong> wirklich loeschen?`;
1532 const hint = type === "folder"
1533 ? "Der Ordner wird nur geloescht, wenn keine Seiten und keine Unterordner enthalten sind."
1534 : "Die Medien werden aus der Seite geloest, Dateien bleiben im Medienbestand.";
1536 if (!isMasterLngCfg(cfg, root) || !previewUrl || !deleteUrl) {
1537 return runSimpleDeleteConfirm(root, title, question, hint).then(result => {
1538 if (!result || result.action !== "yes") return null;
1539 return executeLngDelete(root, cfg, type, id, [], deleteUrl).then(data => {
1540 if (type === "folder") {
1541 hideFolderEditor(root);
1542 const s = state(root);
1543 if (Number(s.selectedFolder || 0) === id) {
1544 setSelectedFolder(root, 0);
1545 setSelectedType(root, "page");
1548 setSelectedPage(root, 0);
1549 setSelectedType(root, "page");
1550 setEditorHtml(root, "");
1551 renderMedia(root, []);
1553 return loadTree(root, cfg);
1558 return fetchJson(apiUrl(previewUrl), {
1560 headers: { "Content-Type": "application/json" },
1561 body: JSON.stringify({ type, id })
1563 if (!data || !data.ok) {
1564 throw new Error(data && data.msg ? data.msg : "Vorschau fehlgeschlagen");
1566 const items = Array.isArray(data.preview && data.preview.items) ? data.preview.items : [];
1567 if (items.length <= 1) {
1568 return runSimpleDeleteConfirm(root, title, question, hint).then(result => {
1569 if (!result || result.action !== "yes") return null;
1570 return executeLngDelete(root, cfg, type, id, [], deleteUrl).then(data => {
1571 if (type === "folder") {
1572 hideFolderEditor(root);
1573 const s = state(root);
1574 if (Number(s.selectedFolder || 0) === id) {
1575 setSelectedFolder(root, 0);
1576 setSelectedType(root, "page");
1579 setSelectedPage(root, 0);
1580 setSelectedType(root, "page");
1581 setEditorHtml(root, "");
1582 renderMedia(root, []);
1584 return loadTree(root, cfg);
1588 showLngDeleteModal(root, cfg, type, id, label, data.preview || {});
1591 status(root, err && err.message ? err.message : "Vorschau fehlgeschlagen.", "error");
1595 function clearCmsLoading(root) {
1596 const anchors = [root, document.body, document.documentElement].filter(Boolean);
1597 anchors.forEach(anchor => {
1598 anchor.classList.remove("dbx-ajax-loading", "is-loading");
1599 anchor.removeAttribute("aria-busy");
1600 anchor.style.removeProperty("filter");
1601 anchor.style.removeProperty("opacity");
1606 el.classList.remove("dbx-ajax-loading", "is-loading");
1607 if (el === document.body) break;
1608 el = el.parentElement;
1611 qsa(document, ".dbx-ajax-loading, .is-loading, [data-cms-action]").forEach(el => {
1612 el.classList.remove("dbx-ajax-loading", "is-loading");
1615 qsa(document, ".jodit-dialog__overlay, .jodit-dialog_overlay, .jodit-ui-modal-overlay, .jodit-popup__overlay, .jodit-icon_loader, .jodit-icon-loader, .jodit-loader, .jodit-ui-loader, .jodit_spinner, .jodit-wait").forEach(el => {
1616 el.style.display = "none";
1617 el.style.background = "transparent";
1618 el.style.boxShadow = "none";
1619 el.style.backdropFilter = "none";
1622 qsa(document, ".dbx-loader, .dbx-loading, .dbx-wait, .dbx-backdrop, .modal-backdrop").forEach(el => {
1623 el.classList.remove("show", "active", "dbx-ajax-loading", "is-loading");
1624 el.style.display = "none";
1625 el.style.background = "transparent";
1626 el.style.boxShadow = "none";
1627 el.style.backdropFilter = "none";
1631 function currentSelectionLabel(id, title, fallback) {
1632 const numericId = Number(id || 0);
1633 const text = String(title || fallback || "").trim();
1634 if (numericId > 0 && text) return "(" + numericId + ") " + text;
1635 if (numericId > 0) return "(" + numericId + ")";
1636 return text || fallback || "";
1639 function updateCurrentSelectionTitle(root, type, id, title) {
1640 const el = qs(root, "[data-cms-current-title]");
1642 const fallback = type === "folder" ? "Neuer Ordner" : "Keine Seite ausgewaehlt";
1643 el.textContent = currentSelectionLabel(id, title, fallback);
1646 function updateViewPageTitle(root, title) {
1647 const el = qs(root, "[data-cms-page-title]");
1649 const text = String(title || "").trim();
1650 el.textContent = text !== "" ? text : "Keine Seite ausgewaehlt";
1653 function openContentAdmin(root) {
1654 const s = state(root);
1655 const pageId = Number((s.page && (s.page.id || s.page)) || s.selectedPage || 0);
1657 status(root, "Bitte zuerst eine Seite waehlen.", "info");
1661 const btn = qs(root, "[data-cms-action='open-admin']");
1662 const base = btn ? String(btn.getAttribute("data-cms-admin-base") || "").trim() : "";
1663 const url = (base || "?dbx_modul=dbxContent_admin&dbx_run1=cms") + "&cid=" + encodeURIComponent(pageId);
1666 title: "Content bearbeiten",
1673 ensureFeature("openWin").then(ok => {
1674 if (ok && dbx.openWin && typeof dbx.openWin.open === "function") {
1675 dbx.openWin.open(winCfg);
1678 dbx.warn("[cms] openWin.js konnte nicht geladen werden; CMS wird im aktuellen Fenster geoeffnet.");
1679 if (dbx.utilities && dbx.utilities.leaveGuard) {
1680 dbx.utilities.leaveGuard.allowIfInternal(url);
1682 window.location.assign(url);
1686 function markDirty(root) {
1687 const s = state(root);
1688 if (s.loading || s.saving || s.suppressDirty) return;
1689 s.dirtyVersion = (Number(s.dirtyVersion || 0) + 1);
1690 setDirty(root, true);
1692 if (root.classList.contains("is-folder-editing")) {
1693 updateCurrentSelectionTitle(root, "folder", getFolderField(root, "id"), getFolderField(root, "name"));
1696 updateCurrentSelectionTitle(root, "page", getField(root, "id"), getField(root, "title"));
1699 function clearDirtyAfterSave(root) {
1700 const s = state(root);
1701 const version = Number(s.dirtyVersion || 0);
1702 setDirty(root, false);
1703 [80, 300].forEach(delay => {
1704 window.setTimeout(() => {
1705 const current = state(root);
1706 if (!current.loading && !current.saving && Number(current.dirtyVersion || 0) === version) {
1707 setDirty(root, false);
1713 function suppressDirtyFor(root, delay) {
1714 const s = state(root);
1715 const token = Date.now() + Math.random();
1716 s.suppressDirty = true;
1717 s.suppressDirtyToken = token;
1718 window.setTimeout(() => {
1719 const current = state(root);
1720 if (current.suppressDirtyToken === token) {
1721 current.suppressDirty = false;
1722 current.suppressDirtyToken = null;
1724 }, Number(delay || 0));
1727 function mediaHtml(row) {
1729 const url = row.url || "";
1730 if (!url) return "";
1732 const mime = String(row.mime || "");
1733 const fileName = String(row.file_name || url);
1734 const title = escapeHtml(row.title || row.alt || fileName || "Medium");
1735 const alt = escapeHtml(row.alt || row.title || fileName || "");
1737 const id = Number(row.id || row.media_id || 0);
1738 const videoUrl = row.embed_url || row.external_url || row.url || "";
1739 const videoDataAttr = isVideoRow(row)
1740 ? ` data-cms-video-url="${escapeHtml(videoUrl)}" data-cms-video-type="${escapeHtml(row.media_type || "")}" data-cms-video-mime="${escapeHtml(row.mime || "")}" data-cms-video-muted="0" data-cms-video-align="left"`
1742 const mediaAttr = id > 0 ? ` data-cms-media-id="${id}" data-cms-media-slot="inline"${videoDataAttr}` : ` data-cms-media-slot="inline"${videoDataAttr}`;
1744 if (mime.startsWith("image/") || /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(fileName)) {
1745 return `<p class="dbx-cms-inline-media" data-cms-media-slot="inline"><img class="dbx-cms-inline-image" src="${escapeHtml(url)}" alt="${alt}" title="${title}"${mediaAttr}></p><p></p>`;
1748 if (isExternalVideoRow(row)) {
1749 return `<figure class="dbx-cms-inline-media dbx-cms-inline-video-block"${mediaAttr}>${mediaPlayerInnerHtml(row, id)}</figure><p></p>`;
1752 if (mime.startsWith("video/") || /\.(mp4|webm|ogv|ogg|mov|m4v)$/i.test(fileName)) {
1753 return `<figure class="dbx-cms-inline-media dbx-cms-inline-video-block"${mediaAttr}>${mediaPlayerInnerHtml(row, id)}</figure><p></p>`;
1756 return `<p><a href="${escapeHtml(url)}" target="_blank" rel="noopener">${title}</a></p>`;
1759 function cmsMarkerName(marker) {
1760 const name = String(marker || "").replace(/^dbx:/i, "").trim().toLowerCase() || "marker";
1761 return ["hero_text", "hero-text", "herotext"].includes(name) ? "hero" : name;
1764 function cmsMarkerClassName(marker) {
1765 return cmsMarkerName(marker).replace(/[^a-z0-9_-]+/gi, "-") || "marker";
1768 function cmsMarkerLabel(marker, label) {
1771 "dbx:split": "col-2 Trenner",
1772 "dbx:col2": "col-2 Trenner",
1773 "dbx:col3a": "col-3a Trenner",
1774 "dbx:col3b": "col-3b Trenner",
1775 "dbx:header": "Header",
1776 "dbx:teaser": "Header",
1777 "dbx:footer": "Footer",
1778 "dbx:pagebreak": "Druck Seitenumbruch"
1780 const canonicalMarker = "dbx:" + cmsMarkerName(marker);
1781 if (canonicalMarker === "dbx:hero") return "Hero";
1782 return label || labels[canonicalMarker] || canonicalMarker || "dbx:marker";
1785 function cmsMarkerHtml(marker, label) {
1786 if (marker === "dbx:split") marker = "dbx:col2";
1787 const name = cmsMarkerName(marker);
1788 marker = "dbx:" + name;
1789 const className = cmsMarkerClassName(marker);
1790 return `<hr class="dbx-cms-marker dbx-cms-marker-${escapeHtml(className)}" contenteditable="false" draggable="false" tabindex="0" data-dbx-marker="dbx:${escapeHtml(name)}" data-label="${escapeHtml(cmsMarkerLabel(marker, label))}">`;
1793 function cmsMarkerElement(marker, label, doc) {
1794 const tpl = (doc || document).createElement("template");
1795 tpl.innerHTML = cmsMarkerHtml(marker, label);
1796 return tpl.content.firstElementChild;
1799 function markerNameFromElement(node) {
1800 if (!node || node.nodeType !== 1 || !node.getAttribute) return "";
1801 const raw = node.getAttribute("data-dbx-marker") || node.getAttribute("data-dbx-marker-comment") || "";
1802 return raw ? cmsMarkerName(raw) : "";
1805 function ignorableMarkerSibling(node) {
1806 if (!node) return false;
1807 if (node.nodeType === 3) return String(node.nodeValue || "").replace(/\uFEFF/g, "").trim() === "";
1808 if (node.nodeType !== 1) return false;
1809 const tag = node.tagName || "";
1810 if (tag === "BR") return true;
1811 if (!/^(P|DIV|SPAN)$/i.test(tag)) return false;
1812 if (node.querySelector && node.querySelector(".dbx-cms-marker,[data-dbx-marker],img,video,iframe,table,hr")) return false;
1813 return String(node.textContent || "").replace(/\uFEFF/g, "").trim() === "";
1816 function nearbyMarkerSibling(node, dir) {
1817 let cur = dir < 0 ? node?.previousSibling : node?.nextSibling;
1818 while (cur && ignorableMarkerSibling(cur)) cur = dir < 0 ? cur.previousSibling : cur.nextSibling;
1819 return cur && cur.nodeType === 1 ? cur : null;
1822 function hasSameMarkerNeighbor(node, name) {
1823 return markerNameFromElement(nearbyMarkerSibling(node, -1)) === name ||
1824 markerNameFromElement(nearbyMarkerSibling(node, 1)) === name;
1827 function dedupeAdjacentMarkers(container) {
1828 if (!container) return;
1829 qsa(container, ".dbx-cms-marker,[data-dbx-marker]").forEach(marker => {
1830 if (!marker.parentNode) return;
1831 const name = markerNameFromElement(marker);
1833 const prev = nearbyMarkerSibling(marker, -1);
1834 if (markerNameFromElement(prev) === name) marker.remove();
1838 function normalizeCommentMarkers(container) {
1839 if (!container) return;
1840 const doc = container.ownerDocument || document;
1841 const comments = [];
1842 (function collect(node) {
1843 Array.from(node && node.childNodes || []).forEach(child => {
1844 if (child.nodeType === 8) comments.push(child);
1845 else collect(child);
1848 comments.forEach(node => {
1849 const text = String(node.nodeValue || "").trim();
1850 const match = text.match(/^dbx:([a-z0-9_-]+)/i);
1852 const name = cmsMarkerName("dbx:" + match[1].toLowerCase());
1853 if (node.parentNode && hasSameMarkerNeighbor(node, name)) {
1854 node.parentNode.removeChild(node);
1857 const marker = cmsMarkerElement("dbx:" + name, null, doc);
1858 if (marker && node.parentNode) node.parentNode.replaceChild(marker, node);
1861 if (node.parentNode) node.parentNode.removeChild(node);
1863 dedupeAdjacentMarkers(container);
1866 function cmsMarkerMenuIcon() {
1867 return '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M4 4h12.5a1 1 0 0 1 .8 1.6L14.9 9l2.4 3.4a1 1 0 0 1-.8 1.6H6v6H4V4Zm2 2v6h8.55l-1.7-2.4a1 1 0 0 1 0-1.16L14.55 6H6Z"/></svg>';
1870 function cmsHrIcon() {
1871 return '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M4 11h16v2H4v-2Zm2-5h12v2H6V6Zm0 10h12v2H6v-2Z"/></svg>';
1874 function cmsSaveIcon() {
1875 return '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M5 3h13l1 1v17H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Zm0 2v14h12V5h-2v5H7V5H5Zm4 0v3h4V5H9Zm-1 8h8v2H8v-2Zm0 3h8v2H8v-2Z"/></svg>';
1878 function dedupeSingletonMarkers(container) {
1879 if (!container) return;
1880 const seen = new Set();
1881 qsa(container, ".dbx-cms-marker,[data-dbx-marker]").forEach(marker => {
1882 const name = markerNameFromElement(marker);
1883 if (name !== "hero") return;
1884 if (seen.has(name)) {
1892 function cmsTextStyleIcon() {
1893 return '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M5 4h7.2c2.7 0 4.5 1.5 4.5 3.8 0 1.25-.57 2.25-1.57 2.9 1.42.57 2.27 1.83 2.27 3.55 0 2.55-1.95 4.25-4.9 4.25H5V4Zm3 2.6v3.1h3.8c1.18 0 1.85-.57 1.85-1.55 0-1-.67-1.55-1.85-1.55H8Zm0 5.55v3.75h4.22c1.35 0 2.1-.68 2.1-1.88 0-1.18-.75-1.87-2.1-1.87H8Z"/><path d="M19 5h2v14h-2V5Z"/></svg>';
1896 function cmsModBrowserIcon() {
1897 return '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect x="3" y="4" width="8" height="6" rx="1" fill="none" stroke="currentColor" stroke-width="1.6"/><rect x="13" y="4" width="8" height="6" rx="1" fill="none" stroke="currentColor" stroke-width="1.6"/><rect x="3" y="14" width="18" height="6" rx="1" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M6 7h2M15 7h2M6 17h12" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>';
1900 function modPlaceholderFromItem(item) {
1903 url: item?.url || "",
1904 label: item?.label || "",
1905 default_modul: item?.default_modul || "",
1906 default_params: item?.default_params || "",
1907 default_alt: item?.default_alt || item?.label || "Modul"
1911 function modPlaceholderHtml(item, insertId) {
1912 const row = modPlaceholderFromItem(item);
1913 const url = row.url || "";
1914 const alt = row.default_alt || "Modul";
1915 const modul = row.default_modul || "";
1916 const params = row.default_params || "";
1917 const modAttr = modul ? ` data-cms-mod-modul="${escapeHtml(modul)}"` : "";
1918 const parAttr = params ? ` data-cms-mod-params="${escapeHtml(params)}"` : "";
1919 const insertAttr = insertId ? ` data-cms-mod-insert-id="${escapeHtml(insertId)}"` : "";
1920 return `<p class="dbx-cms-mod-placeholder dbx-cms-inline-media" contenteditable="false" draggable="true"${insertAttr}${modAttr}${parAttr}><img class="dbx-cms-mod-image" src="${escapeHtml(url)}" alt="${escapeHtml(alt)}"${modAttr}${parAttr} style="width:100%;max-width:100%;height:auto;display:block;" contenteditable="false"></p><p></p>`;
1923 function inlineModTarget(root, target) {
1924 const surface = editorSurface(root);
1925 const mod = closestElement(target, ".dbx-cms-mod-placeholder");
1926 return mod && surface && surface.contains(mod) ? mod : null;
1929 function normalizeModPlaceholders(container) {
1930 if (!container) return;
1931 qsa(container, ".dbx-cms-mod-placeholder").forEach(wrap => {
1932 wrap.setAttribute("contenteditable", "false");
1933 wrap.classList.add("dbx-cms-inline-media");
1934 const img = qs(wrap, "img") || qs(wrap, ".dbx-cms-mod-image");
1936 img.classList.add("dbx-cms-mod-image");
1937 img.style.width = "100%";
1938 img.style.maxWidth = "100%";
1939 img.style.height = "auto";
1940 img.style.display = "block";
1941 img.setAttribute("contenteditable", "false");
1942 img.removeAttribute("data-dbx");
1943 const modul = wrap.getAttribute("data-cms-mod-modul") || "";
1944 const params = wrap.getAttribute("data-cms-mod-params") || "";
1946 img.setAttribute("data-cms-mod-modul", modul);
1947 wrap.setAttribute("data-cms-mod-modul", modul);
1950 img.setAttribute("data-cms-mod-params", params);
1951 wrap.setAttribute("data-cms-mod-params", params);
1953 const alt = img.getAttribute("alt") || "";
1954 if (alt) wrap.setAttribute("title", alt);
1958 function modPlaceholderValues(wrapper) {
1959 const img = wrapper ? (qs(wrapper, "img") || qs(wrapper, ".dbx-cms-mod-image")) : null;
1961 modul: wrapper?.getAttribute("data-cms-mod-modul") || img?.getAttribute("data-cms-mod-modul") || "",
1962 params: wrapper?.getAttribute("data-cms-mod-params") || img?.getAttribute("data-cms-mod-params") || "",
1963 alt: img?.getAttribute("alt") || wrapper?.getAttribute("title") || ""
1967 function selectEditorModPlaceholder(root, wrap) {
1968 const surface = editorSurface(root);
1969 qsa(surface, ".dbx-cms-mod-placeholder.is-selected").forEach(el => {
1970 el.classList.remove("is-selected");
1971 el.removeAttribute("aria-selected");
1973 if (wrap && surface && surface.contains(wrap)) {
1974 selectEditorMarker(root, null);
1975 selectEditorMissingMedia(root, null);
1976 wrap.classList.add("is-selected");
1977 wrap.setAttribute("aria-selected", "true");
1978 state(root).selectedModPlaceholder = wrap;
1979 hideEditorCaretHint(root);
1980 selectEditorNode(root, wrap);
1982 state(root).selectedModPlaceholder = null;
1986 function isEmptyEditorParagraph(el) {
1987 return !!(el && el.nodeType === 1 && /^P$/i.test(el.tagName || "") && !nodeHasEditorContent(el));
1990 function removeEditorModPlaceholder(root, wrap) {
1991 wrap = wrap || state(root).selectedModPlaceholder;
1992 const surface = editorSurface(root);
1993 if (!wrap || !wrap.parentNode || !surface || !surface.contains(wrap)) return false;
1994 const next = wrap.nextElementSibling;
1995 selectEditorModPlaceholder(root, null);
1997 if (isEmptyEditorParagraph(next)) next.remove();
1998 syncEditorDom(root);
2003 function insertModPlaceholder(root, item, cfg) {
2004 const insertId = "mod_" + Date.now().toString(36) + "_" + Math.random().toString(36).slice(2, 8);
2005 const html = modPlaceholderHtml(item, insertId);
2006 if (!html) return false;
2007 insertEditorHtml(root, html);
2008 const surface = editorSurface(root);
2009 normalizeModPlaceholders(surface);
2010 setField(root, "content", getEditorHtml(root));
2012 scheduleEditorHeight(root);
2013 const inserted = qs(surface, `.dbx-cms-mod-placeholder[data-cms-mod-insert-id="${insertId}"]`);
2015 inserted.removeAttribute("data-cms-mod-insert-id");
2016 selectEditorModPlaceholder(root, inserted);
2017 if (inserted.scrollIntoView) inserted.scrollIntoView({ block: "center", inline: "nearest" });
2018 window.setTimeout(() => openModPlaceholderOptions(root, inserted, cfg || cmsConfig(root) || {}), 0);
2023 function slashCommandHit(root, token) {
2024 const surface = editorSurface(root);
2025 const doc = surface ? (surface.ownerDocument || document) : document;
2026 const sel = doc.getSelection ? doc.getSelection() : null;
2027 if (!surface || !sel || !sel.rangeCount || !sel.isCollapsed) return null;
2028 const range = sel.getRangeAt(0);
2029 if (!rangeInsideSurface(surface, range)) return null;
2030 const node = range.startContainer;
2031 const offset = range.startOffset || 0;
2032 if (!node || node.nodeType !== 3 || offset < token.length) return null;
2033 const before = String(node.nodeValue || "").slice(offset - token.length, offset);
2034 if (before.toLowerCase() !== token.toLowerCase()) return null;
2035 return { node, start: offset - token.length, end: offset };
2038 function removeSlashCommandHit(root, hit) {
2039 if (!hit || !hit.node) return false;
2040 const text = String(hit.node.nodeValue || "");
2041 hit.node.nodeValue = text.slice(0, hit.start) + text.slice(hit.end);
2042 const surface = editorSurface(root);
2043 const doc = surface ? (surface.ownerDocument || document) : document;
2044 const sel = doc.getSelection ? doc.getSelection() : null;
2045 if (sel && doc.createRange) {
2046 const range = doc.createRange();
2047 range.setStart(hit.node, hit.start);
2048 range.collapse(true);
2049 sel.removeAllRanges();
2050 sel.addRange(range);
2051 state(root).editorRange = range.cloneRange();
2053 syncEditorAfterContextAction(root);
2054 setField(root, "content", getEditorHtml(root));
2058 function handleEditorSlashCommands(root, cfg) {
2059 if (!root || root.__dbxCmsSlashModOpening) return;
2060 const hit = slashCommandHit(root, "/mod");
2062 root.__dbxCmsSlashModOpening = true;
2063 removeSlashCommandHit(root, hit);
2064 saveEditorSelection(root);
2065 openModBrowser(root, cfg || cmsConfig(root) || {});
2066 window.setTimeout(() => {
2067 root.__dbxCmsSlashModOpening = false;
2071 function bindEditorSlashCommands(root, cfg) {
2072 const surface = editorSurface(root);
2073 if (!surface || surface.__dbxCmsSlashCommandsBound) return;
2074 surface.__dbxCmsSlashCommandsBound = true;
2075 const run = () => window.setTimeout(() => handleEditorSlashCommands(root, cfg || {}), 0);
2076 surface.addEventListener("input", run);
2077 surface.addEventListener("keyup", e => {
2078 if (e.ctrlKey || e.metaKey || e.altKey) return;
2079 if (e.key === "d" || e.key === "D" || e.key === "Enter" || e.key === " ") run();
2083 function mediaRowFromItem(item) {
2085 url: item?.getAttribute("data-url") || "",
2086 mime: item?.getAttribute("data-mime") || "",
2087 file_name: item?.getAttribute("data-file-name") || "",
2088 file_path: item?.getAttribute("data-file-path") || "",
2089 thumb_url: item?.getAttribute("data-thumb-url") || "",
2090 media_type: item?.getAttribute("data-media-type") || "",
2091 width: item?.getAttribute("data-width") || "",
2092 height: item?.getAttribute("data-height") || "",
2093 title: item?.getAttribute("data-title") || "",
2094 alt: item?.getAttribute("data-alt") || "",
2095 slot: item?.getAttribute("data-slot") || "",
2096 media_folder: item?.getAttribute("data-media-folder") || "",
2097 id: item?.getAttribute("data-media-id") || ""
2101 function currentMediaSlot(root) {
2102 const filter = qs(root, "[data-cms-media-filter]");
2103 const value = filter ? filter.value : state(root).mediaFilter;
2104 return value && value !== "all" ? value : "gallery";
2107 function syncUploadSlot(root) {
2108 const input = qs(root, "[data-cms-upload-slot]");
2109 if (input) input.value = currentMediaSlot(root);
2110 const externalInput = qs(root, "[data-cms-external-video-slot]");
2111 if (externalInput) externalInput.value = currentMediaSlot(root);
2114 function insertMediaRow(root, row) {
2115 const html = mediaHtml(row);
2117 leaveInlineMediaCaption(root);
2118 if (!insertEditorFragment(root, html)) insertEditorHtml(root, html);
2121 function applyInlineMediaAssignment(root, row) {
2122 if (!row || !row.url) return;
2123 insertMediaRow(root, row);
2124 setLocalMediaSlot(root, row.id, "inline");
2128 function mediaRowWithUsage(row, usage, fallbackSlot) {
2129 const out = Object.assign({}, row || {});
2130 usage = usage || {};
2131 if (usage.id) out.usage_id = usage.id;
2132 if (usage.id) out.current_usage_id = usage.id;
2133 out.slot = usage.slot || out.slot || fallbackSlot || "gallery";
2134 if (usage.sorter) out.sorter = usage.sorter;
2135 if (usage.template) out.template = usage.template;
2136 if (usage.caption) out.caption = usage.caption;
2140 function upsertLocalMediaRow(root, row) {
2141 const id = Number(row && row.id || 0);
2143 const s = state(root);
2144 const rows = Array.isArray(s.mediaRows) ? s.mediaRows.slice() : [];
2145 const pos = rows.findIndex(item => Number(item.id || 0) === id);
2146 if (pos >= 0) rows[pos] = Object.assign({}, rows[pos], row);
2147 else rows.push(row);
2148 renderMedia(root, rows);
2151 function setLocalMediaSlot(root, id, slot) {
2152 id = Number(id || 0);
2154 const s = state(root);
2155 const row = (s.mediaRows || []).find(item => Number(item.id || 0) === id);
2162 function inlineVideoDataAttributes(row, id, options) {
2164 options = options || {};
2165 const videoUrl = row.embed_url || row.external_url || row.url || "";
2167 `data-cms-media-id="${id}"`,
2168 'data-cms-media-slot="inline"',
2169 `data-cms-video-url="${escapeHtml(videoUrl)}"`,
2170 `data-cms-video-type="${escapeHtml(row.media_type || "")}"`,
2171 `data-cms-video-mime="${escapeHtml(row.mime || "")}"`,
2172 `data-cms-video-autoplay="${options.autoplay ? "1" : "0"}"`,
2173 `data-cms-video-loop="${options.loop ? "1" : "0"}"`,
2174 `data-cms-video-muted="${options.muted ? "1" : "0"}"`,
2175 `data-cms-video-align="${inlineVideoAlignValue(options.align)}"`
2177 if (options.width) attrs.push(`data-cms-video-width="${escapeHtml(options.width)}"`);
2178 if (options.height) attrs.push(`data-cms-video-height="${escapeHtml(options.height)}"`);
2179 return " " + attrs.join(" ");
2182 function inlineVideoOptionsFromElement(el) {
2183 const media = el ? qs(el, ".dbx-cms-inline-video-thumb,.dbx-cms-inline-video-empty,video,iframe,img") : null;
2184 const attr = name => String((el && el.getAttribute && el.getAttribute(name)) || (media && media.getAttribute && media.getAttribute(name)) || "");
2185 const marginLeft = String(el?.style?.marginLeft || "").toLowerCase();
2186 const marginRight = String(el?.style?.marginRight || "").toLowerCase();
2187 let align = inlineVideoAlignValue(attr("data-cms-video-align"), "");
2188 if (!align && marginLeft === "auto" && marginRight === "auto") align = "center";
2189 if (!align && marginLeft === "auto") align = "right";
2191 width: cssSizeValue(attr("data-cms-video-width") || el?.style?.width || media?.style?.width || ""),
2192 height: cssSizeValue(attr("data-cms-video-height") || el?.style?.height || media?.style?.height || ""),
2193 autoplay: attr("data-cms-video-autoplay") === "1",
2194 loop: attr("data-cms-video-loop") === "1",
2195 muted: attr("data-cms-video-muted") === "1",
2196 align: align || "left"
2200 function inlineVideoAlignValue(value, fallback) {
2201 value = String(value || "").trim().toLowerCase();
2202 if (value === "center" || value === "zentriert") return "center";
2203 if (value === "right" || value === "rechts") return "right";
2204 if (value === "left" || value === "links") return "left";
2205 return fallback === undefined ? "left" : fallback;
2208 function applyInlineVideoAlignment(wrapper, align) {
2209 if (!wrapper) return;
2210 align = inlineVideoAlignValue(align);
2211 wrapper.setAttribute("data-cms-video-align", align);
2212 wrapper.style.float = "";
2213 if (align === "center") {
2214 wrapper.style.marginLeft = "auto";
2215 wrapper.style.marginRight = "auto";
2218 if (align === "right") {
2219 wrapper.style.marginLeft = "auto";
2220 wrapper.style.marginRight = "0px";
2223 wrapper.style.marginLeft = "";
2224 wrapper.style.marginRight = "";
2227 function syncInlineVideoOptionsToMedia(wrapper, options) {
2228 if (!wrapper) return;
2229 options = options || {};
2230 const targets = [wrapper].concat(qsa(wrapper, ".dbx-cms-inline-video-thumb,.dbx-cms-inline-video-empty,.dbx-cms-inline-video-player,video,iframe,img"));
2231 targets.forEach(el => {
2232 if (!el || !el.setAttribute) return;
2233 if (options.width) el.setAttribute("data-cms-video-width", options.width);
2234 else el.removeAttribute("data-cms-video-width");
2235 if (options.height) el.setAttribute("data-cms-video-height", options.height);
2236 else el.removeAttribute("data-cms-video-height");
2237 el.setAttribute("data-cms-video-autoplay", options.autoplay ? "1" : "0");
2238 el.setAttribute("data-cms-video-loop", options.loop ? "1" : "0");
2239 el.setAttribute("data-cms-video-muted", options.muted ? "1" : "0");
2240 el.setAttribute("data-cms-video-align", inlineVideoAlignValue(options.align));
2244 function inlineVideoOptionsButtonHtml() {
2245 return '<button type="button" class="dbx-cms-inline-video-options-btn" data-cms-inline-video-options-open contenteditable="false" tabindex="-1" title="Video Optionen" aria-label="Video Optionen"><i class="bi bi-sliders"></i></button>';
2248 function mediaPlayerInnerHtml(row, id, options) {
2250 id = Number(id || row.id || row.media_id || 0);
2251 if (id <= 0) return "";
2252 const title = escapeHtml(row.title || row.alt || row.file_name || "Video");
2253 const thumb = row.thumb_url || "";
2254 const mediaAttr = inlineVideoDataAttributes(row, id, options || {});
2256 ? `<img class="dbx-cms-inline-video-thumb" src="${escapeHtml(thumb)}" alt="${title}" title="${title}"${mediaAttr}>`
2257 : `<span class="dbx-cms-inline-video-empty"${mediaAttr}>${title}</span>`;
2258 return `${img}<span class="dbx-cms-inline-video-play" aria-hidden="true"><i class="bi bi-play-fill"></i></span>${inlineVideoOptionsButtonHtml()}`;
2261 function inlineVideoTarget(root, target) {
2262 const surface = editorSurface(root);
2263 if (!surface || !target) return null;
2264 const direct = closestElement(target, ".dbx-cms-inline-video-block");
2265 if (direct && surface.contains(direct)) return direct;
2266 const media = closestElement(target, ".dbx-cms-inline-video-player,iframe,video");
2267 return media && surface && surface.contains(media) ? media : null;
2270 function inlineVideoTargetAtPoint(root, e) {
2271 const surface = editorSurface(root);
2272 if (!surface || !e || !Number.isFinite(Number(e.clientX)) || !Number.isFinite(Number(e.clientY))) return null;
2273 const x = Number(e.clientX);
2274 const y = Number(e.clientY);
2275 const selectors = ".dbx-cms-inline-video-block,.dbx-cms-inline-video-player,iframe,video";
2276 return qsa(surface, selectors).find(el => {
2277 const target = closestElement(el, ".dbx-cms-inline-video-block") || el;
2278 const rect = target.getBoundingClientRect ? target.getBoundingClientRect() : null;
2279 return rect && x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
2283 function inlineVideoEventTarget(root, e) {
2284 return inlineVideoTarget(root, e && e.target) || inlineVideoTargetAtPoint(root, e);
2287 function cmsRootFromEditorElement(el) {
2288 if (!el) return null;
2289 const direct = closestElement(el, ".dbx-cms");
2290 if (direct) return direct;
2291 return qsa(document, ".dbx-cms[data-dbx]").find(root => {
2292 const surface = editorSurface(root);
2293 return root.contains(el) || (surface && surface.contains(el));
2297 function isInlineVideoResizeHandleEvent(wrapper, e) {
2298 if (!wrapper || !e || !wrapper.getBoundingClientRect) return false;
2299 const rect = wrapper.getBoundingClientRect();
2301 return e.clientX >= rect.right - grip && e.clientY >= rect.bottom - grip;
2304 function cssSizeValue(value) {
2305 value = String(value || "").trim();
2306 if (!value) return "";
2307 if (/^\d+(\.\d+)?$/.test(value)) return value + "px";
2308 if (/^\d+(\.\d+)?(px|%|vw|vh|rem|em)$/i.test(value)) return value;
2309 if (/^auto$/i.test(value)) return "auto";
2313 function openInlineVideoOptions(root, media) {
2314 if (!media) return false;
2315 const modal = ensureInlineVideoOptionsDialog(root);
2316 modal.__dbxCmsInlineVideo = media;
2317 const options = inlineVideoOptionsFromElement(media);
2318 const width = qs(modal, "[data-cms-video-options-width]");
2319 const height = qs(modal, "[data-cms-video-options-height]");
2320 const align = qs(modal, "[data-cms-video-options-align]");
2321 const autoplay = qs(modal, "[data-cms-video-options-autoplay]");
2322 const loop = qs(modal, "[data-cms-video-options-loop]");
2323 const muted = qs(modal, "[data-cms-video-options-muted]");
2324 if (width) width.value = options.width || "";
2325 if (height) height.value = options.height || "";
2326 if (align) align.value = options.align || "left";
2327 if (autoplay) autoplay.checked = !!options.autoplay;
2328 if (loop) loop.checked = !!options.loop;
2329 if (muted) muted.value = options.muted ? "1" : "0";
2330 modal.hidden = false;
2331 openInlineVideoOptionsWindow(root, modal);
2335 function applyInlineVideoOptions(root, modal) {
2336 const media = modal && modal.__dbxCmsInlineVideo;
2337 if (!media) return false;
2338 const width = cssSizeValue(qs(modal, "[data-cms-video-options-width]")?.value || "");
2339 const height = cssSizeValue(qs(modal, "[data-cms-video-options-height]")?.value || "");
2340 const align = inlineVideoAlignValue(qs(modal, "[data-cms-video-options-align]")?.value || "left");
2341 const autoplay = qs(modal, "[data-cms-video-options-autoplay]")?.checked;
2342 const loop = qs(modal, "[data-cms-video-options-loop]")?.checked;
2343 let muted = qs(modal, "[data-cms-video-options-muted]")?.value === "1";
2344 if (autoplay) muted = true;
2348 autoplay: !!autoplay,
2354 media.style.width = width;
2355 media.setAttribute("data-cms-video-width", width);
2357 media.style.width = "";
2358 media.removeAttribute("data-cms-video-width");
2361 media.style.height = height;
2362 media.setAttribute("data-cms-video-height", height);
2364 media.style.height = "";
2365 media.removeAttribute("data-cms-video-height");
2367 media.setAttribute("data-cms-video-autoplay", autoplay ? "1" : "0");
2368 media.setAttribute("data-cms-video-loop", loop ? "1" : "0");
2369 media.setAttribute("data-cms-video-muted", muted ? "1" : "0");
2370 syncInlineVideoOptionsToMedia(media, options);
2371 applyInlineVideoAlignment(media, align);
2372 syncEditorAfterContextAction(root);
2374 closeInlineVideoOptionsWindow(modal);
2378 function closeInlineVideoOptionsWindow(modal) {
2380 const winId = modal.__dbxCmsWindowId || "";
2381 modal.__dbxCmsInlineVideo = null;
2382 modal.hidden = true;
2383 modal.classList.remove("is-open");
2384 modal.__dbxCmsWindowId = null;
2385 modal.removeAttribute("role");
2386 modal.removeAttribute("aria-modal");
2387 modal.removeAttribute("aria-label");
2388 if (winId && dbx.openWin && typeof dbx.openWin.close === "function") {
2389 dbx.openWin.close(winId);
2394 function ensureInlineVideoOptionsDialog(root) {
2395 const s = state(root);
2396 let modal = s.videoOptionsModal || qs(root, "[data-cms-video-options]");
2398 modal.__dbxCmsRoot = root;
2399 s.videoOptionsModal = modal;
2400 if (!qs(modal, "[data-cms-video-options-loop]")) {
2401 const autoplayLabel = qs(modal, "[data-cms-video-options-autoplay]")?.closest("label");
2402 if (autoplayLabel && autoplayLabel.parentNode) {
2403 const loopLabel = document.createElement("label");
2404 loopLabel.className = "dbx-cms-video-options-check";
2405 loopLabel.innerHTML = '<input type="checkbox" data-cms-video-options-loop> Auto Loop';
2406 autoplayLabel.insertAdjacentElement("afterend", loopLabel);
2409 if (!qs(modal, "[data-cms-video-options-align]")) {
2410 const heightLabel = qs(modal, "[data-cms-video-options-height]")?.closest("label");
2411 if (heightLabel && heightLabel.parentNode) {
2412 const alignLabel = document.createElement("label");
2413 alignLabel.innerHTML = 'Ausrichtung <select class="form-select form-select-sm" data-cms-video-options-align><option value="left">Links</option><option value="center">Horizontal zentriert</option><option value="right">Rechts</option></select>';
2414 heightLabel.insertAdjacentElement("afterend", alignLabel);
2419 modal = document.createElement("div");
2420 modal.className = "dbx-cms-video-options";
2421 modal.setAttribute("data-cms-video-options", "1");
2422 modal.__dbxCmsRoot = root;
2423 modal.hidden = true;
2425 <div class="dbx-cms-video-options-body">
2426 <label>Breite <input type="text" class="form-control form-control-sm" data-cms-video-options-width placeholder="z.B. 640px, 80%, leer = Standard"></label>
2427 <label>Hoehe <input type="text" class="form-control form-control-sm" data-cms-video-options-height placeholder="z.B. 240px, leer = 16:9"></label>
2428 <label>Ausrichtung <select class="form-select form-select-sm" data-cms-video-options-align><option value="left">Links</option><option value="center">Horizontal zentriert</option><option value="right">Rechts</option></select></label>
2429 <label class="dbx-cms-video-options-check"><input type="checkbox" data-cms-video-options-autoplay> Autoplay</label>
2430 <label class="dbx-cms-video-options-check"><input type="checkbox" data-cms-video-options-loop> Auto Loop</label>
2431 <label>Ton <select class="form-select form-select-sm" data-cms-video-options-muted><option value="0">Ton an</option><option value="1">Ton aus</option></select></label>
2433 <div class="dbx-cms-video-options-actions">
2434 <button type="button" class="btn btn-outline-secondary btn-sm" data-cms-video-options-close><i class="bi bi-x-lg"></i><span>Abbrechen</span></button>
2435 <button type="button" class="btn btn-primary btn-sm" data-cms-video-options-apply><i class="bi bi-check2"></i><span>Uebernehmen</span></button>
2437 modal.addEventListener("click", e => {
2438 if (closestElement(e.target, "[data-cms-video-options-close]")) {
2440 e.stopPropagation();
2441 closeInlineVideoOptionsWindow(modal);
2444 if (closestElement(e.target, "[data-cms-video-options-apply]")) {
2446 e.stopPropagation();
2447 applyInlineVideoOptions(root, modal);
2450 root.appendChild(modal);
2451 s.videoOptionsModal = modal;
2455 function bindInlineVideoOptionsDocumentEvents() {
2456 if (document.__dbxCmsVideoOptionsEventsBound) return;
2457 document.__dbxCmsVideoOptionsEventsBound = true;
2458 const openOptionsFromButton = e => {
2459 if (e.button !== undefined && e.button !== 0) return false;
2460 const openBtn = closestElement(e.target, "[data-cms-inline-video-options-open]");
2461 if (!openBtn) return false;
2462 const root = cmsRootFromEditorElement(openBtn);
2463 const video = closestElement(openBtn, ".dbx-cms-inline-video-block");
2464 const surface = root ? editorSurface(root) : null;
2465 if (!root || !video || (surface && !surface.contains(video))) return false;
2467 e.stopPropagation();
2468 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
2469 const now = Date.now();
2470 if (!openBtn.__dbxCmsVideoOptionsOpenedAt || now - openBtn.__dbxCmsVideoOptionsOpenedAt > 250) {
2471 openBtn.__dbxCmsVideoOptionsOpenedAt = now;
2472 openInlineVideoOptions(root, video);
2476 document.addEventListener("pointerdown", openOptionsFromButton, true);
2477 document.addEventListener("mousedown", openOptionsFromButton, true);
2478 document.addEventListener("click", e => {
2479 if (openOptionsFromButton(e)) return;
2480 const closeBtn = closestElement(e.target, "[data-cms-video-options-close]");
2481 const applyBtn = closestElement(e.target, "[data-cms-video-options-apply]");
2482 if (!closeBtn && !applyBtn) return;
2483 const modal = closestElement(e.target, "[data-cms-video-options]");
2486 e.stopPropagation();
2487 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
2489 closeInlineVideoOptionsWindow(modal);
2492 const root = modal.__dbxCmsRoot || closestElement(modal, ".dbx-cms");
2493 if (root) applyInlineVideoOptions(root, modal);
2494 else closeInlineVideoOptionsWindow(modal);
2498 function openInlineVideoOptionsWindow(root, modal) {
2499 if (!modal) return false;
2500 const currentWindow = closestElement(modal, ".dbx-window");
2501 const oldOptionsWindowId = modal.__dbxCmsWindowId || "";
2502 if (currentWindow && currentWindow.parentNode) {
2503 document.body.appendChild(modal);
2504 if (oldOptionsWindowId && currentWindow.id === oldOptionsWindowId && dbx.openWin && typeof dbx.openWin.close === "function") {
2505 dbx.openWin.close(oldOptionsWindowId);
2507 } else if (modal.parentNode !== document.body) {
2508 document.body.appendChild(modal);
2510 modal.__dbxCmsRoot = root;
2511 modal.__dbxCmsWindowId = null;
2512 modal.hidden = false;
2513 modal.classList.add("is-open");
2514 modal.setAttribute("role", "dialog");
2515 modal.setAttribute("aria-modal", "true");
2516 modal.setAttribute("aria-label", "Video Optionen");
2517 const first = qs(modal, "[data-cms-video-options-width]") || qs(modal, "input,select,button");
2518 if (first && first.focus) window.setTimeout(() => first.focus({ preventScroll: true }), 0);
2522 function repairInlineVideoPlayers(root, container) {
2523 if (!container) return;
2524 const rows = state(root).mediaRows || [];
2525 qsa(container, ".dbx-cms-inline-media[data-cms-media-id]").forEach(wrapper => {
2526 const id = Number(wrapper.getAttribute("data-cms-media-id") || 0);
2528 const row = rows.find(item => Number(item.id || item.media_id || 0) === id) || null;
2529 const needsVideo = wrapper.classList.contains("dbx-cms-inline-video-block") || (row && isVideoRow(row));
2530 if (!needsVideo || wrapper.querySelector(".dbx-cms-inline-video-thumb,.dbx-cms-inline-video-empty")) return;
2531 wrapper.classList.add("dbx-cms-inline-video-block");
2532 wrapper.removeAttribute("contenteditable");
2533 const options = inlineVideoOptionsFromElement(wrapper);
2534 wrapper.innerHTML = mediaPlayerInnerHtml(row, id, options);
2535 syncInlineVideoOptionsToMedia(wrapper, options);
2539 function repairInlineVideoHtml(root, html) {
2540 const wrap = document.createElement("div");
2541 wrap.innerHTML = String(html || "");
2542 qsa(wrap, "video").forEach(video => {
2543 if (closestElement(video, ".dbx-cms-inline-video-block")) return;
2545 const source = qs(video, "source[src]");
2546 const url = String(video.getAttribute("src") || source?.getAttribute("src") || "");
2547 const idMatch = String(
2548 video.getAttribute("data-cms-media-id") || source?.getAttribute("data-cms-media-id") || url
2549 ).match(/(?:dbx_mid=)?([0-9]+)/i);
2550 const id = Number(idMatch && idMatch[1] || 0);
2551 if (id <= 0) return;
2554 width: cssSizeValue(video.getAttribute("width") || video.style?.width || ""),
2555 height: cssSizeValue(video.getAttribute("height") || video.style?.height || ""),
2556 autoplay: video.hasAttribute("autoplay"),
2557 loop: video.hasAttribute("loop"),
2558 muted: video.hasAttribute("muted")
2560 const row = mediaRowById(root, id) || {
2563 mime: source?.getAttribute("type") || "video/mp4",
2564 media_type: "video",
2565 thumb_url: video.getAttribute("poster") || "",
2566 title: video.getAttribute("aria-label") || video.getAttribute("title") || "Video"
2568 const holder = document.createElement("div");
2569 holder.innerHTML = `<figure class="dbx-cms-inline-media dbx-cms-inline-video-block"${inlineVideoDataAttributes(row, id, options)}>${mediaPlayerInnerHtml(row, id, options)}</figure>`;
2570 const figure = holder.firstElementChild;
2571 if (figure) video.replaceWith(figure);
2573 repairInlineVideoPlayers(root, wrap);
2574 return wrap.innerHTML;
2577 function mediaRowById(root, id) {
2578 id = Number(id || 0);
2579 if (!id) return null;
2580 return (state(root).mediaRows || []).find(row => Number(row.id || row.media_id || 0) === id) || null;
2583 function youtubeEmbedFromThumb(src) {
2584 const match = String(src || "").match(/img\.youtube\.com\/vi\/([^/]+)/i);
2585 return match ? "https://www.youtube.com/embed/" + encodeURIComponent(match[1]) : "";
2588 function inlineVideoRowFromWrapper(root, wrapper, id) {
2589 const row = mediaRowById(root, id);
2590 if (row) return row;
2591 const media = qs(wrapper, "[data-cms-media-id]") || wrapper;
2592 const thumb = qs(wrapper, ".dbx-cms-inline-video-thumb, img");
2593 const youtubeUrl = youtubeEmbedFromThumb(thumb?.getAttribute("src") || "");
2594 const url = wrapper.getAttribute("data-cms-video-url") || media?.getAttribute("data-cms-video-url") || youtubeUrl || (id ? `index.php?dbx_modul=dbxContent&dbx_run1=media&dbx_mid=${id}` : "");
2595 if (!url) return null;
2598 media_type: wrapper.getAttribute("data-cms-video-type") || media?.getAttribute("data-cms-video-type") || (youtubeUrl ? "external_video" : "video"),
2599 mime: wrapper.getAttribute("data-cms-video-mime") || media?.getAttribute("data-cms-video-mime") || "",
2601 embed_url: youtubeUrl ? url : "",
2602 thumb_url: thumb?.getAttribute("src") || "",
2603 title: thumb?.getAttribute("title") || thumb?.getAttribute("alt") || "Video"
2607 function urlWithParams(url, params) {
2609 const out = new URL(String(url || ""), window.location.href);
2610 Object.keys(params || {}).forEach(key => out.searchParams.set(key, params[key]));
2611 return out.toString();
2613 return String(url || "");
2617 function youtubeVideoIdFromUrl(url) {
2618 const match = String(url || "").match(/(?:embed\/|v=|youtu\.be\/)([A-Za-z0-9_-]{11})/i);
2619 return match ? match[1] : "";
2622 function externalVideoPlayerParams(url, options) {
2623 options = options || {};
2624 const params = { playsinline: "1", rel: "0" };
2625 if (options.autoplay) params.autoplay = "1";
2626 params.mute = options.muted ? "1" : "0";
2629 const videoId = youtubeVideoIdFromUrl(url);
2630 if (videoId) params.playlist = videoId;
2635 function inlineVideoEditorPlayerHtml(row, id, options) {
2637 options = options || {};
2638 id = Number(id || row.id || row.media_id || 0);
2639 const title = escapeHtml(row.title || row.alt || row.file_name || "Video");
2640 const mediaAttr = inlineVideoDataAttributes(row, id, options);
2641 if (isExternalVideoRow(row)) {
2642 let url = row.embed_url || row.external_url || row.url || "";
2643 if (!url) return "";
2644 url = urlWithParams(url, externalVideoPlayerParams(url, options));
2645 return `<iframe class="dbx-cms-inline-video-player" src="${escapeHtml(url)}" title="${title}" allowfullscreen${mediaAttr}></iframe>${inlineVideoOptionsButtonHtml()}`;
2647 const url = row.url || (id ? `index.php?dbx_modul=dbxContent&dbx_run1=media&dbx_mid=${id}` : "");
2648 if (!url) return "";
2649 const mime = row.mime ? ` type="${escapeHtml(row.mime)}"` : "";
2650 const poster = row.thumb_url ? ` poster="${escapeHtml(row.thumb_url)}"` : "";
2651 const autoplayAttr = options.autoplay ? " autoplay" : "";
2652 const loopAttr = options.loop ? " loop" : "";
2653 const mutedAttr = options.muted ? " muted" : "";
2654 return `<video class="dbx-cms-inline-video-player" controls${autoplayAttr}${loopAttr}${mutedAttr} playsinline preload="metadata"${poster}${mediaAttr}><source src="${escapeHtml(url)}"${mime}></video>${inlineVideoOptionsButtonHtml()}`;
2657 function playInlineVideoBlock(root, wrapper) {
2658 const surface = editorSurface(root);
2659 if (!wrapper || !surface || !surface.contains(wrapper)) return false;
2660 if (wrapper.querySelector(".dbx-cms-inline-video-player, iframe, video")) return false;
2661 const id = Number(wrapper.getAttribute("data-cms-media-id") || qs(wrapper, "[data-cms-media-id]")?.getAttribute("data-cms-media-id") || 0);
2662 const row = inlineVideoRowFromWrapper(root, wrapper, id);
2663 if (!row || !isVideoRow(row)) {
2664 status(root, "Video konnte im Editor nicht geladen werden.", "error");
2667 syncInlineVideoBlockSizes(surface);
2668 const html = inlineVideoEditorPlayerHtml(row, id, inlineVideoOptionsFromElement(wrapper));
2669 if (!html) return false;
2670 wrapper.innerHTML = html;
2671 wrapper.classList.add("is-playing");
2672 syncEditorDom(root);
2676 function renderHeroPreview(root) {
2677 const preview = qs(root, "[data-cms-hero-preview]");
2678 if (!preview) return;
2680 const templateValue = String(getField(root, "hero_template") || "").toLowerCase();
2681 if (templateValue === "none" || templateValue === "no-hero") {
2682 preview.innerHTML = '<div class="dbx-cms-hero-preview-empty">Kein Hero ausgewaehlt.</div>';
2686 const idValue = String(getField(root, "hero_image_id") || "");
2687 const id = Number(idValue);
2688 const s = state(root);
2689 const rows = s.mediaRows || [];
2690 const row = rows.find(item => Number(item.id || 0) === id)
2691 || (s.heroPreviewRow && Number(s.heroPreviewRow.id || 0) === id ? s.heroPreviewRow : null);
2693 if (idValue === "parent") {
2694 const parentRow = s.heroParentPreviewRow || null;
2695 if (parentRow && parentRow.url) {
2696 const folderName = parentRow.parent_folder_name || parentRow.folder_name || "Parent";
2697 preview.innerHTML = `<img src="${escapeHtml(parentRow.thumb_url || parentRow.url)}" alt="${escapeHtml(parentRow.alt || parentRow.title || "Hero-Bild")}"><figcaption class="dbx-cms-hero-preview-origin">${escapeHtml(folderName)}</figcaption>`;
2700 preview.innerHTML = '<div class="dbx-cms-hero-preview-empty">'
2701 + escapeHtml(cmsText(root, "hero_parent_empty", "Kein Hero im übergeordneten Ordner."))
2707 preview.innerHTML = '<div class="dbx-cms-hero-preview-empty">Kein Hero-Bild ausgewaehlt.</div>';
2711 if (!row || !row.url) {
2712 preview.innerHTML = '<div class="dbx-cms-hero-preview-empty">Hero-Bild wird geladen.</div>';
2716 const isImage = String(row.mime || "").startsWith("image/") || /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(row.file_name || row.url || "");
2718 preview.innerHTML = '<div class="dbx-cms-hero-preview-empty">Das gewaehlte Medium ist kein Bild.</div>';
2722 preview.innerHTML = `<img src="${escapeHtml(row.thumb_url || row.url)}" alt="${escapeHtml(row.alt || row.title || "Hero-Bild")}">`;
2725 function applyHeroTemplateChoice(root, source) {
2726 const isFolder = source && source.hasAttribute && source.hasAttribute("data-cms-folder-field");
2727 const value = String(isFolder ? getFolderField(root, "hero_template") : getField(root, "hero_template")).toLowerCase();
2728 const folderPanel = qs(root, "[data-cms-folder-editor]");
2730 setField(root, "hero_template", value);
2731 if (folderPanel && !folderPanel.hidden) setFolderField(root, "hero_template", value);
2733 if (value === "parent") {
2734 setField(root, "hero_image_id", "parent");
2735 if (folderPanel && !folderPanel.hidden) setFolderField(root, "hero_image_id", "parent");
2736 renderHeroPreview(root);
2739 if (value !== "none" && value !== "no-hero") {
2740 renderHeroPreview(root);
2743 state(root).heroPreviewRow = null;
2744 setField(root, "hero_template", "none");
2745 setField(root, "hero_image_id", "0");
2746 if (folderPanel && !folderPanel.hidden) {
2747 setFolderField(root, "hero_template", "none");
2748 setFolderField(root, "hero_image_id", "0");
2750 renderHeroPreview(root);
2754 function renderSeoPreview(root) {
2755 const preview = qs(root, "[data-cms-seo-preview]");
2756 if (!preview) return;
2758 const id = Number(getField(root, "seo_image_id") || 0);
2759 const s = state(root);
2760 const rows = s.mediaRows || [];
2761 const row = rows.find(item => Number(item.id || 0) === id)
2762 || (s.seoPreviewRow && Number(s.seoPreviewRow.id || 0) === id ? s.seoPreviewRow : null);
2765 preview.innerHTML = '<div class="dbx-cms-seo-preview-empty">Kein OG-Bild ausgewaehlt (Hero-Fallback).</div>';
2769 if (!row || !row.url) {
2770 preview.innerHTML = '<div class="dbx-cms-seo-preview-empty">OG-Bild wird geladen.</div>';
2774 const isImage = String(row.mime || "").startsWith("image/") || /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(row.file_name || row.url || "");
2776 preview.innerHTML = '<div class="dbx-cms-seo-preview-empty">Das gewaehlte Medium ist kein Bild.</div>';
2780 preview.innerHTML = `<img src="${escapeHtml(row.thumb_url || row.url)}" alt="${escapeHtml(row.alt || row.title || "OG-Bild")}">`;
2783 function mediaBrowserRows(modal) {
2784 return qsa(modal, "[data-cms-media-browser-item]").map(mediaRowFromItem);
2787 function updateMediaBrowserConfirm(modal) {
2788 const bar = qs(modal, "[data-cms-media-browser-confirmbar]");
2789 const count = qs(modal, "[data-cms-media-browser-count]");
2790 const selected = modal.__dbxCmsSelectedIds || new Set();
2791 const needsConfirm = mediaBrowserUsesConfirmBar(modal);
2792 if (bar) bar.hidden = !needsConfirm;
2793 if (count) count.textContent = String(selected.size);
2794 updateMediaBrowserBatchUi(modal);
2797 function selectedMediaBrowserImageRows(modal) {
2798 const selected = modal && modal.__dbxCmsSelectedIds ? modal.__dbxCmsSelectedIds : new Set();
2799 return mediaBrowserAllRows(modal).filter(row => selected.has(Number(row.id || 0)) && canEditImage(row));
2802 function mediaBrowserAllRows(modal) {
2803 return Array.isArray(modal && modal.__dbxCmsRows) ? modal.__dbxCmsRows : mediaBrowserRows(modal);
2806 function batchControlHost(browserModal) {
2807 if (!browserModal) return null;
2808 return browserModal.__dbxCmsBatchPanel || browserModal;
2811 function updateMediaBrowserBatchUi(modal) {
2812 const selectedImages = selectedMediaBrowserImageRows(modal).length;
2813 [modal, modal && modal.__dbxCmsBatchPanel].forEach(host => {
2815 const count = qs(host, "[data-cms-media-browser-resize-count]");
2816 if (count) count.textContent = String(selectedImages);
2820 function firstMediaFolderOption(select, prefix) {
2821 prefix = String(prefix || "");
2822 return Array.from(select?.options || []).find(option => {
2823 const value = String(option.value || "");
2824 return value && value !== "all" && (!prefix || value.indexOf(prefix) === 0);
2828 function mediaFolderLabel(folder) {
2829 return String(folder || "").trim();
2832 function mediaOriginLabel(row) {
2834 let folder = String(row.media_folder || "").trim().replace(/\\/g, "/");
2836 let path = String(row.file_path || row.path || "").trim().replace(/\\/g, "/");
2837 path = path.replace(/^files\/media\//i, "").replace(/^media\//i, "");
2838 const slash = path.lastIndexOf("/");
2839 folder = slash >= 0 ? path.slice(0, slash) : "";
2841 folder = folder.replace(/^files\/media\//i, "").replace(/^media\//i, "").replace(/^\/+|\/+$/g, "");
2842 return folder || "Root";
2845 function mediaSlotLabel(slot) {
2846 const slotLabels = { inline: "Im Text", hero: "Hero", gallery: "Galerie", shop: "Shop" };
2847 slot = String(slot || "").trim();
2848 return slotLabels[slot] || slot || "Verwendung";
2851 function mediaUsagePages(row) {
2852 return Array.isArray(row && row.usage_pages) ? row.usage_pages : [];
2855 function mediaUsageSlots(row) {
2856 const slots = new Set();
2857 mediaUsagePages(row).forEach(page => {
2858 (Array.isArray(page.slots) ? page.slots : []).forEach(slot => {
2859 slot = String(slot || "").trim();
2860 if (slot) slots.add(slot);
2863 if (!slots.size && Number(row?.current_usage_id || row?.usage_id || 0) > 0) {
2864 const slot = String(row?.slot || "").trim();
2865 if (slot) slots.add(slot);
2867 return Array.from(slots);
2870 function currentEditorMediaSlots(root) {
2871 const slotsById = new Map();
2872 const add = (id, slot) => {
2873 id = Number(id || 0);
2874 slot = String(slot || "").trim();
2875 if (!id || !slot) return;
2876 if (!slotsById.has(id)) slotsById.set(id, new Set());
2877 slotsById.get(id).add(slot);
2880 collectInlineMediaIdsFromEditor(root).forEach(id => add(id, "inline"));
2881 const heroId = Number(getField(root, "hero_image_id") || 0);
2882 if (heroId > 0) add(heroId, "hero");
2883 (state(root).mediaRows || []).forEach(row => {
2884 const slot = String(row?.slot || "").trim();
2885 if (slot === "gallery" || slot === "shop") add(row.id || row.media_id, slot);
2890 function reconcileMediaBrowserUsageWithEditor(root, rows) {
2891 const contentId = Number(getField(root, "id") || 0);
2892 if (!contentId || root.classList.contains("is-folder-editing")) return rows;
2893 const slotsById = currentEditorMediaSlots(root);
2894 const title = getField(root, "title") || "";
2895 const folderId = Number(getField(root, "folder") || 0);
2897 return (rows || []).map(source => {
2898 const row = Object.assign({}, source || {});
2899 const mediaId = Number(row.id || row.media_id || 0);
2900 const pages = mediaUsagePages(row)
2901 .filter(page => Number(page.content_id || page.id || 0) !== contentId)
2902 .map(page => Object.assign({}, page, {
2903 slots: Array.isArray(page.slots) ? page.slots.slice() : []
2905 const currentSlots = Array.from(slotsById.get(mediaId) || []);
2906 if (currentSlots.length) {
2909 content_id: contentId,
2911 folder_id: folderId,
2915 row.slot = currentSlots[0];
2917 pages.sort((a, b) => Number(a.content_id || a.id || 0) - Number(b.content_id || b.id || 0));
2918 row.usage_pages = pages;
2919 row.used_count = pages.reduce((count, page) => count + Math.max(1, (page.slots || []).length), 0);
2924 function mediaUsageLabel(row) {
2926 const pages = mediaUsagePages(row);
2927 const shown = pages.slice(0, 3).map(page => {
2928 const id = Number(page.content_id || page.id || 0);
2929 const slots = (Array.isArray(page.slots) ? page.slots : []).map(mediaSlotLabel).filter(Boolean);
2930 return "#" + id + (slots.length ? " (" + slots.join(", ") + ")" : "");
2932 const suffix = pages.length > 3 ? ", ..." : "";
2933 const count = Number(row.used_count || 0);
2934 if (shown) return "Verwendet: " + shown + suffix;
2935 if (count > 0) return count === 1 ? "Verwendet 1x" : "Verwendet " + count + "x";
2936 return "Nicht verwendet";
2939 function mediaUsageTooltip(row) {
2941 const pages = mediaUsagePages(row);
2943 return pages.map(page => {
2944 const id = Number(page.content_id || page.id || 0);
2945 const folderId = Number(page.folder_id || 0);
2946 const folder = String(page.folder_title || "").trim();
2947 const title = String(page.title || "").trim();
2948 const slots = Array.isArray(page.slots) ? page.slots.map(mediaSlotLabel).filter(Boolean).join(", ") : mediaSlotLabel(row.slot || "");
2950 + (folderId > 0 || folder ? " | Ordner: " + (folder || ("#" + folderId)) : "")
2951 + (title ? " | " + title : "")
2952 + (slots ? " | " + slots : "");
2955 return mediaUsageLabel(row);
2958 function mediaUsageTooltipRows(row) {
2960 const pages = mediaUsagePages(row);
2961 return pages.map(page => {
2962 const id = Number(page.content_id || page.id || 0);
2963 const folderId = Number(page.folder_id || 0);
2964 const folder = String(page.folder_title || "").trim();
2965 const title = String(page.title || "").trim();
2967 id: id > 0 ? "#" + id : "",
2968 folder: folder || (folderId > 0 ? "#" + folderId : "-"),
2974 function mediaUsageTooltipHtml(row) {
2975 const rows = mediaUsageTooltipRows(row);
2976 const tooltip = window.dbx && dbx.utilities && dbx.utilities.tooltip;
2977 if (tooltip && typeof tooltip.htmlList === "function") {
2978 return tooltip.htmlList(rows, {
2979 title: rows.length ? "Verwendet von" : "",
2980 empty: mediaUsageLabel(row)
2984 return `<div>${escapeHtml(mediaUsageLabel(row))}</div>`;
2986 return `<strong>Verwendet von</strong><br>` + rows.map(item => {
2987 return `${escapeHtml(item.id)} ${escapeHtml(item.folder)} ${escapeHtml(item.title)}`;
2991 function mediaBrowserItemHtml(row, selected, needsConfirm) {
2993 selected = selected || new Set();
2994 return `<div class="dbx-cms-media-browser-item${selected.has(Number(row.id || 0)) ? " is-selected" : ""}"
2995 data-cms-media-browser-item
2996 data-media-id="${escapeHtml(row.id || "")}"
2997 data-url="${escapeHtml(row.url || "")}"
2998 data-thumb-url="${escapeHtml(row.thumb_url || "")}"
2999 data-mime="${escapeHtml(row.mime || "")}"
3000 data-media-type="${escapeHtml(row.media_type || "")}"
3001 data-width="${escapeHtml(row.width || "")}"
3002 data-height="${escapeHtml(row.height || "")}"
3003 data-file-name="${escapeHtml(row.file_name || "")}"
3004 data-file-path="${escapeHtml(row.file_path || "")}"
3005 data-title="${escapeHtml(row.title || "")}"
3006 data-alt="${escapeHtml(row.alt || "")}"
3007 data-media-folder="${escapeHtml(row.media_folder || "")}"
3008 data-slot="${escapeHtml(row.slot || "")}"
3009 draggable="${isExternalVideoRow(row) ? "false" : "true"}">
3010 <button type="button" class="dbx-cms-media-browser-pickarea" data-cms-media-browser-pick draggable="${isExternalVideoRow(row) ? "false" : "true"}" title="${needsConfirm ? "Medium fuer Auswahl markieren" : "Medium in den Editor einfuegen"}">
3011 <span>${mediaPreviewHtml(row)}</span>
3012 <strong>${escapeHtml(row.title || row.file_name || "Medium")}</strong>
3013 ${canEditImage(row) || needsConfirm ? '<em class="dbx-cms-media-browser-check"><i class="bi bi-check2"></i></em>' : ''}
3015 <div class="dbx-cms-media-browser-actions">
3016 <span class="dbx-cms-media-browser-meta">
3017 <span class="dbx-cms-media-browser-origin">${escapeHtml(mediaOriginLabel(row))}</span>
3018 <span class="dbx-cms-media-browser-usage" tabindex="0" data-dbx-tooltip="${escapeTooltipAttr(mediaUsageTooltipHtml(row))}">${escapeHtml(mediaUsageLabel(row))}</span>
3020 ${canEditImage(row) ? '<button type="button" class="btn btn-outline-secondary btn-sm" data-cms-media-browser-select title="Bild fuer Batch Resize auswaehlen"><i class="bi bi-check2-square"></i></button>' : ''}
3021 ${canEditImage(row) ? '<button type="button" class="btn btn-outline-primary btn-sm" data-cms-media-browser-edit title="Bild zuschneiden oder resizen"><i class="bi bi-crop"></i></button>' : ''}
3022 <button type="button" class="btn btn-outline-danger btn-sm" data-cms-media-browser-delete title="Mediendatei wirklich loeschen">
3023 <i class="bi bi-trash"></i>
3029 function mediaBrowserExplorerItemHtml(row, selected) {
3031 selected = selected || new Set();
3032 const id = Number(row.id || 0);
3033 const title = row.title || row.file_name || "Medium";
3034 const draggable = isExternalVideoRow(row) ? "false" : "true";
3035 const originLabel = mediaOriginLabel(row);
3036 const usageLabel = mediaUsageLabel(row);
3037 const usageTooltip = mediaUsageTooltipHtml(row);
3038 return `<div class="dbx-cms-media-explorer-item dbx-cms-media-browser-item${selected.has(id) ? " is-selected" : ""}"
3039 data-cms-media-browser-item
3040 data-cms-media-tree-media
3041 data-media-id="${escapeHtml(row.id || "")}"
3042 data-url="${escapeHtml(row.url || "")}"
3043 data-thumb-url="${escapeHtml(row.thumb_url || "")}"
3044 data-mime="${escapeHtml(row.mime || "")}"
3045 data-media-type="${escapeHtml(row.media_type || "")}"
3046 data-width="${escapeHtml(row.width || "")}"
3047 data-height="${escapeHtml(row.height || "")}"
3048 data-file-name="${escapeHtml(row.file_name || "")}"
3049 data-file-path="${escapeHtml(row.file_path || "")}"
3050 data-title="${escapeHtml(row.title || "")}"
3051 data-alt="${escapeHtml(row.alt || "")}"
3052 data-media-folder="${escapeHtml(row.media_folder || "")}"
3053 data-slot="${escapeHtml(row.slot || "")}"
3054 draggable="${draggable}">
3055 <button type="button" class="dbx-cms-media-explorer-pick dbx-cms-media-browser-pickarea" data-cms-media-browser-pick draggable="${draggable}" title="Medium auswaehlen">
3056 <span class="dbx-cms-media-explorer-thumb">${mediaPreviewHtml(row)}</span>
3057 <strong>${escapeHtml(title)}</strong>
3058 <small>${escapeHtml(row.file_name || "")}</small>
3059 <em class="dbx-cms-media-browser-check"><i class="bi bi-check2"></i></em>
3061 <div class="dbx-cms-media-explorer-actions">
3062 <span class="dbx-cms-media-browser-meta">
3063 <span class="dbx-cms-media-browser-origin">${escapeHtml(originLabel)}</span>
3064 <span class="dbx-cms-media-browser-usage" tabindex="0" data-dbx-tooltip="${escapeTooltipAttr(usageTooltip)}">${escapeHtml(usageLabel)}</span>
3066 ${canEditImage(row) ? '<button type="button" class="btn btn-outline-secondary btn-sm" data-cms-media-browser-select title="Bild fuer Batch Resize auswaehlen"><i class="bi bi-check2-square"></i></button>' : ''}
3067 ${canEditImage(row) ? '<button type="button" class="btn btn-outline-primary btn-sm" data-cms-media-browser-edit title="Bild zuschneiden oder resizen"><i class="bi bi-crop"></i></button>' : ''}
3068 <button type="button" class="btn btn-outline-danger btn-sm" data-cms-media-browser-delete title="Mediendatei wirklich loeschen"><i class="bi bi-trash"></i></button>
3073 function renderMediaRowsChunked(host, rows, rowHtml, afterDone) {
3075 rows = Array.isArray(rows) ? rows : [];
3076 const token = ((host.__dbxMediaRenderToken || 0) + 1);
3077 host.__dbxMediaRenderToken = token;
3078 if (host.__dbxMediaRenderScrollHandler) {
3079 host.removeEventListener("scroll", host.__dbxMediaRenderScrollHandler);
3080 host.__dbxMediaRenderScrollHandler = null;
3082 host.innerHTML = "";
3084 if (afterDone) afterDone();
3087 const batchSize = 18;
3089 let rendering = false;
3090 const appendBatch = () => {
3091 if (host.__dbxMediaRenderToken !== token || rendering) return;
3093 const html = rows.slice(offset, offset + batchSize).map(rowHtml).join("");
3095 host.insertAdjacentHTML("beforeend", html);
3096 setupMediaLazyImages(host);
3098 offset += batchSize;
3105 const appendWhenNeeded = () => {
3106 if (host.__dbxMediaRenderToken !== token || offset >= rows.length) return;
3107 const distance = host.scrollHeight - host.scrollTop - host.clientHeight;
3108 if (distance < 220) appendBatch();
3109 if (host.scrollHeight <= host.clientHeight + 40 && offset < rows.length) {
3110 window.requestAnimationFrame(appendWhenNeeded);
3114 host.__dbxMediaRenderScrollHandler = () => {
3115 window.requestAnimationFrame(appendWhenNeeded);
3116 scheduleMediaLazyLoad(host);
3118 host.addEventListener("scroll", host.__dbxMediaRenderScrollHandler, { passive: true });
3120 window.requestAnimationFrame(appendWhenNeeded);
3123 function mediaBrowserSkeletonHtml(count) {
3124 count = Math.max(6, Math.min(36, Number(count || 18)));
3126 for (let i = 0; i < count; i++) {
3127 html += `<div class="dbx-cms-media-browser-item dbx-cms-media-browser-skeleton" aria-hidden="true">
3128 <span class="dbx-cms-media-browser-skeleton-thumb"></span>
3129 <span class="dbx-cms-media-browser-skeleton-line"></span>
3130 <span class="dbx-cms-media-browser-skeleton-meta"></span>
3136 function mediaFolderDiskPath(folder) {
3137 folder = String(folder || "").trim();
3138 if (!folder) return "";
3139 if (folder === "mod") return "files/mod/";
3140 return "files/media/" + folder.replace(/\\/g, "/") + "/";
3143 function mediaBrowserQueryParams(folder) {
3144 const params = { sync: 0 };
3145 folder = String(folder || "all");
3146 if (folder === "all") {
3147 params.media_folder = "all";
3149 params.media_folder = folder;
3150 if (folder === "youtube" || folder.indexOf("youtube/") === 0) {
3151 params.media_type = "external_video";
3152 } else if (folder.indexOf("videos/") === 0 || folder === "videos" || folder.indexOf("video/") === 0 || folder === "video" || folder === "img/video" || folder.indexOf("img/video/") === 0) {
3153 params.media_type = "video";
3154 } else if (folder.indexOf("file/") === 0 || folder === "file") {
3155 params.media_type = "file";
3156 } else if (folder === "module" || folder === "mod") {
3158 params.media_type = "image";
3161 params.media_type = "image";
3167 function mediaTypeFromFolder(folder) {
3168 folder = String(folder || "");
3169 if (folder.indexOf("videos/") === 0 || folder === "videos" || folder.indexOf("video/") === 0 || folder === "video") return "video";
3170 if (folder.indexOf("file/") === 0 || folder === "file") return "file";
3174 function buildNewMediaFolderPath(parentFolder, name) {
3175 parentFolder = String(parentFolder || "").trim();
3176 name = String(name || "").trim().replace(/\\/g, "/");
3177 if (!name) return "";
3178 if (name.indexOf("/") >= 0) return name;
3179 if (!parentFolder) return "img/" + name;
3180 const base = parentFolder.split("/")[0] || "img";
3181 if (base === "img" || base === "video" || base === "file" || base === "module") {
3182 return base + "/" + name;
3184 return parentFolder + "/" + name;
3187 function ensureMediaBrowserFolderUi(modal) {
3189 const uploadForm = qs(modal, "[data-cms-browser-upload]");
3190 const uploadFolder = qs(modal, "[data-cms-upload-folder]");
3191 if (uploadForm && uploadFolder && !uploadFolder.closest(".dbx-cms-upload-folder-label")) {
3192 const label = document.createElement("label");
3193 label.className = "dbx-cms-upload-folder-label";
3194 label.innerHTML = '<span class="form-label small mb-1">Zielordner</span>';
3195 uploadFolder.parentNode.insertBefore(label, uploadFolder);
3196 label.appendChild(uploadFolder);
3198 const typeSelect = qs(modal, "[data-cms-folder-type]");
3200 const folderBar = typeSelect.closest(".dbx-cms-media-folderbar");
3201 const parent = document.createElement("select");
3202 parent.className = typeSelect.className;
3203 parent.setAttribute("data-cms-folder-parent", "1");
3204 parent.title = "Vorhandenen Ordner als Basis waehlen";
3205 typeSelect.replaceWith(parent);
3206 if (folderBar && !qs(folderBar, ".dbx-cms-media-folderbar-title")) {
3207 const title = document.createElement("span");
3208 title.className = "small text-muted dbx-cms-media-folderbar-title";
3209 title.textContent = "Neuer Unterordner:";
3210 folderBar.insertBefore(title, parent);
3213 if (!modal.__dbxCmsUploadFolderSyncBound) {
3214 modal.__dbxCmsUploadFolderSyncBound = true;
3215 modal.addEventListener("change", e => {
3216 const uploadSelect = closestElement(e.target, "[data-cms-upload-folder]");
3217 if (!uploadSelect || !modal.contains(uploadSelect)) return;
3218 uploadSelect.__dbxCmsTouched = true;
3219 const parentSelect = qs(modal, "[data-cms-folder-parent]");
3220 if (parentSelect && Array.from(parentSelect.options).some(option => option.value === uploadSelect.value)) {
3221 parentSelect.value = uploadSelect.value;
3227 function disposeMediaBrowserModal(root, modal) {
3229 if (modal.__dbxCmsBatchWindowId && dbx.openWin && typeof dbx.openWin.close === "function") {
3230 dbx.openWin.close(modal.__dbxCmsBatchWindowId);
3232 if (modal.__dbxCmsBatchPanel) {
3233 modal.__dbxCmsBatchPanel.remove();
3234 modal.__dbxCmsBatchPanel = null;
3236 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.close === "function") {
3237 dbx.openWin.close(modal.__dbxCmsWindowId);
3240 if (state(root).mediaBrowser === modal) state(root).mediaBrowser = null;
3243 function mediaBatchWindowMarkup() {
3245 <div class="dbx-cms-media-batch-toolbar">
3246 <span class="dbx-cms-media-browser-resize-count"><strong data-cms-media-browser-resize-count>0</strong> Bilder ausgewaehlt</span>
3247 <select class="form-select form-select-sm" data-cms-media-browser-resize-preset title="Resize-Groesse">
3248 <option value="">Groesse waehlen</option>
3249 <option value="800x600">800 x 600</option>
3250 <option value="1024x768">1024 x 768</option>
3251 <option value="1280x720">1280 x 720 HD</option>
3252 <option value="1600x900">1600 x 900</option>
3253 <option value="1920x1080">1920 x 1080 Full HD</option>
3254 <option value="2560x1440">2560 x 1440 QHD</option>
3256 <input type="number" class="form-control form-control-sm" data-cms-bulk-resize-width placeholder="Breite">
3257 <input type="number" class="form-control form-control-sm" data-cms-bulk-resize-height placeholder="Hoehe">
3258 <label class="dbx-cms-media-resize-ratio" title="Seitenverhaeltnis beim Resize behalten">
3259 <input type="checkbox" data-cms-bulk-resize-ratio checked>
3262 <button type="button" class="btn btn-outline-primary btn-sm" data-cms-action="bulk-resize-media" data-cms-resize-scope="selected">
3263 <i class="bi bi-check2-square"></i>
3264 <span>Auswahl resizen</span>
3266 <button type="button" class="btn btn-outline-primary btn-sm" data-cms-action="bulk-resize-media" data-cms-resize-scope="all">
3267 <i class="bi bi-images"></i>
3268 <span>Alle angezeigten resizen</span>
3270 <button type="button" class="btn btn-outline-secondary btn-sm" data-cms-media-batch-close>
3271 <i class="bi bi-arrow-left"></i>
3272 <span>Zurueck</span>
3275 <div class="dbx-cms-media-batch-list" data-cms-media-batch-list>
3276 <div class="dbx-cms-empty">Bilder zum Batch-Resize auswaehlen.</div>
3280 function bindMediaBatchWindowEvents(root, cfg, browserModal, batchPanel) {
3281 if (!batchPanel || batchPanel.__dbxCmsBatchEventsBound) return;
3282 batchPanel.__dbxCmsBatchEventsBound = true;
3283 batchPanel.__dbxCmsBrowserModal = browserModal;
3285 batchPanel.addEventListener("click", e => {
3286 e.stopPropagation();
3287 const cfg = browserCfg(browserModal);
3289 const batchClose = closestElement(e.target, "[data-cms-media-batch-close]");
3290 if (batchClose && batchPanel.contains(batchClose)) {
3292 batchPanel.hidden = true;
3293 if (browserModal) browserModal.classList.remove("is-batch-open");
3297 const batchItem = closestElement(e.target, "[data-cms-batch-item]");
3298 if (batchItem && batchPanel.contains(batchItem)) {
3300 toggleMediaBrowserSelection(browserModal, batchItem);
3304 const bulkResize = closestElement(e.target, "[data-cms-action='bulk-resize-media']");
3305 if (bulkResize && batchPanel.contains(bulkResize)) {
3307 bulkResizeMedia(root, cfg, bulkResize.getAttribute("data-cms-resize-scope") || "selected", browserModal);
3311 const processClose = closestElement(e.target, "[data-cms-media-process-close]");
3312 if (processClose && batchPanel.contains(processClose)) {
3314 const panel = closestElement(processClose, "[data-cms-media-process-panel]");
3316 panel.hidden = true;
3317 panel.innerHTML = "";
3319 if (browserModal) browserModal.classList.remove("is-process-open");
3320 clearCmsLoading(root);
3324 batchPanel.addEventListener("change", e => {
3325 e.stopPropagation();
3326 const preset = closestElement(e.target, "[data-cms-media-browser-resize-preset]");
3327 if (preset && batchPanel.contains(preset)) {
3328 mediaBrowserPreset(batchPanel, preset.value);
3331 const ratioInput = closestElement(e.target, "[data-cms-bulk-resize-ratio]");
3332 if (ratioInput && batchPanel.contains(ratioInput)) {
3333 status(root, ratioInput.checked ? "Resize behaelt das Seitenverhaeltnis." : "Resize nutzt exakte Breite und Hoehe.", "info");
3338 function mediaBatchWindowIsOpen(browserModal) {
3339 const panel = browserModal && browserModal.__dbxCmsBatchPanel;
3340 if (!panel || !browserModal.__dbxCmsBatchWindowId) return false;
3341 return document.documentElement.contains(panel) && !!closestElement(panel, ".dbx-window");
3344 function elevateMediaBatchWindow(browserModal) {
3345 const panel = browserModal && browserModal.__dbxCmsBatchPanel;
3346 const win = (browserModal && browserModal.__dbxCmsBatchWindowId ? document.getElementById(browserModal.__dbxCmsBatchWindowId) : null)
3347 || (panel ? closestElement(panel, ".dbx-window") : null);
3349 win.classList.add("dbx-cms-media-batch-window-frame");
3352 function openMediaBatchWindow(root, cfg, browserModal) {
3353 if (!browserModal) return;
3354 let batchPanel = browserModal.__dbxCmsBatchPanel;
3355 if (!batchPanel || !document.documentElement.contains(batchPanel) || !qs(batchPanel, "[data-cms-media-batch-list]")) {
3356 if (batchPanel) batchPanel.remove();
3357 batchPanel = document.createElement("div");
3358 batchPanel.className = "dbx-cms-media-batch-window";
3359 batchPanel.setAttribute("data-cms-media-batch-window", "1");
3360 batchPanel.innerHTML = mediaBatchWindowMarkup();
3361 const processPanel = qs(browserModal, "[data-cms-media-process-panel]");
3362 if (processPanel && processPanel.parentNode) {
3363 processPanel.parentNode.insertBefore(batchPanel, processPanel);
3365 browserModal.appendChild(batchPanel);
3367 browserModal.__dbxCmsBatchPanel = batchPanel;
3368 bindMediaBatchWindowEvents(root, cfg, browserModal, batchPanel);
3370 browserModal.__dbxCmsBatchWindowId = null;
3371 browserModal.classList.remove("is-folder-tree-open", "is-process-open");
3372 browserModal.classList.add("is-batch-open");
3373 const processPanel = qs(browserModal, "[data-cms-media-process-panel]");
3375 processPanel.hidden = true;
3376 processPanel.innerHTML = "";
3378 batchPanel.hidden = false;
3379 batchPanel.__dbxCmsBrowserModal = browserModal;
3380 batchPanel.__dbxCmsCfg = cfg || browserCfg(browserModal);
3381 updateMediaBrowserBatchUi(browserModal);
3382 renderMediaBatchList(browserModal);
3385 function mediaBrowserNeedsRebuild(modal) {
3386 if (!modal) return false;
3387 return !!qs(modal, "[data-cms-folder-type]")
3388 || !qs(modal, ".dbx-cms-upload-target")
3389 || !qs(modal, "[data-cms-media-folder-toggle]")
3390 || !qs(modal, "[data-cms-media-folder-tree]")
3391 || !qs(modal, "[data-cms-media-process-panel]")
3392 || !qs(modal, "[data-cms-folder-rename]")
3393 || !!qs(modal, "[data-cms-media-browser-slot] option[value='header']")
3394 || !!qs(modal, "[data-cms-media-tools-toggle]")
3395 || !qs(modal, "[data-cms-media-batch-open]");
3398 function uploadFolderOptions(folders) {
3399 return (folders || []).filter(folder => {
3400 const value = String(folder || "");
3401 if (!value || value === "all") return false;
3402 if (value === "youtube" || value.indexOf("youtube/") === 0) return false;
3403 if (value === "module" || value.indexOf("module/") === 0) return false;
3404 if (value === "img/video" || value.indexOf("img/video/") === 0) return false;
3409 function syncUploadFolderSelect(modal, uploadFolders, preferred) {
3410 const uploadFolder = qs(modal, "[data-cms-upload-folder]");
3411 if (!uploadFolder || uploadFolder.__dbxCmsTouched) return;
3412 const filterFolder = qs(modal, "[data-cms-media-browser-folder]");
3413 const browse = String(filterFolder?.value || "");
3414 const candidates = [
3415 String(preferred || ""),
3416 browse !== "all" ? browse : "",
3417 String(modal.__dbxCmsMediaFolder || "")
3419 for (let i = 0; i < candidates.length; i++) {
3420 const cand = candidates[i];
3421 if (cand && uploadFolders.includes(cand)) {
3422 uploadFolder.value = cand;
3426 const imageFolder = uploadFolders.find(folder => folder.indexOf("img/") === 0);
3427 if (imageFolder) uploadFolder.value = imageFolder;
3428 else if (uploadFolders.length) uploadFolder.value = uploadFolders[0];
3431 function setSelectOptions(select, folders, includeAll) {
3432 if (!select) return;
3433 const current = select.value || "";
3435 if (includeAll) options.push(`<option value="all">${escapeHtml(cmsText(select.closest(".dbx-cms"), "media_all_folders", "Alle Verzeichnisse"))}</option>`);
3436 (folders || []).forEach(folder => {
3437 const label = mediaFolderLabel(folder);
3438 const hint = mediaFolderDiskPath(folder);
3439 options.push(`<option value="${escapeHtml(folder)}" title="${escapeHtml(hint)}">${escapeHtml(label)}</option>`);
3441 select.innerHTML = options.join("");
3442 if (current && Array.from(select.options).some(option => option.value === current)) {
3443 select.value = current;
3447 function mediaFolderParent(folder) {
3448 folder = String(folder || "").replace(/\/+$/g, "");
3449 const idx = folder.lastIndexOf("/");
3450 return idx > 0 ? folder.slice(0, idx) : "";
3453 function mediaFolderName(folder) {
3454 folder = String(folder || "").replace(/\/+$/g, "");
3455 return folder.split("/").pop() || folder;
3458 function mediaFolderDepth(folder) {
3459 folder = String(folder || "").replace(/^\/+|\/+$/g, "");
3460 return folder ? Math.max(0, folder.split("/").length - 1) : 0;
3463 function mediaFolderTreeViewSize(modal) {
3464 const value = String(modal && modal.__dbxCmsMediaTreeSize || "medium");
3465 return ["small", "medium", "large"].includes(value) ? value : "medium";
3468 function renderMediaFolderTree(modal, folders) {
3469 const tree = qs(modal, "[data-cms-media-folder-tree]");
3471 const uploadFolders = uploadFolderOptions(folders || modal.__dbxCmsFolders || []);
3472 const rows = Array.isArray(modal.__dbxCmsRows) ? modal.__dbxCmsRows : [];
3473 const selected = modal.__dbxCmsSelectedIds || new Set();
3474 const counts = rows.reduce((out, row) => {
3475 const folder = String(row.media_folder || "");
3476 if (folder) out[folder] = (out[folder] || 0) + 1;
3479 if (!uploadFolders.length) {
3481 <div class="dbx-cms-media-folder-tree-head">
3483 <strong><i class="bi bi-folder2-open"></i> ${escapeHtml(cmsText(modal.closest(".dbx-cms"), "media_folders_title", "Medienordner"))}</strong>
3484 <span>${escapeHtml(cmsText(modal.closest(".dbx-cms"), "media_no_folders", "Keine Medienordner vorhanden."))}</span>
3486 <button type="button" class="btn btn-outline-secondary btn-sm" data-cms-media-folder-tree-close>
3487 <i class="bi bi-arrow-left"></i>
3488 <span>${escapeHtml(cmsText(modal.closest(".dbx-cms"), "media_back", "Zurück"))}</span>
3493 const folderSet = new Set(uploadFolders);
3494 let currentFolder = String(modal.__dbxCmsMediaTreeFolder || modal.__dbxCmsMediaFolder || "").trim();
3495 const selectFilter = String(qs(modal, "[data-cms-media-browser-folder]")?.value || "");
3496 if ((!currentFolder || !folderSet.has(currentFolder)) && selectFilter && selectFilter !== "all" && folderSet.has(selectFilter)) {
3497 currentFolder = selectFilter;
3499 if (!currentFolder || !folderSet.has(currentFolder)) {
3500 currentFolder = uploadFolders.find(folder => (counts[folder] || 0) > 0) || uploadFolders[0];
3502 modal.__dbxCmsMediaTreeFolder = currentFolder;
3503 const viewSize = mediaFolderTreeViewSize(modal);
3504 const folderRows = rows.filter(row => String(row.media_folder || "") === currentFolder);
3505 const folderOptions = uploadFolders.map(folder => {
3506 const depth = mediaFolderDepth(folder);
3507 const label = mediaFolderName(folder);
3508 const count = counts[folder] || 0;
3509 const active = folder === currentFolder;
3510 return `<button type="button"
3511 class="dbx-cms-media-explorer-folder${active ? " is-active" : ""}"
3512 data-cms-media-folder-drop
3513 data-cms-media-tree-folder-select
3514 data-folder="${escapeHtml(folder)}"
3516 style="--dbx-folder-depth:${depth}"
3517 title="${escapeHtml(folder)}">
3518 <span class="dbx-cms-media-explorer-indent"></span>
3519 <i class="bi ${active ? "bi-folder2-open" : "bi-folder"}"></i>
3520 <span class="dbx-cms-media-explorer-folder-text">
3521 <strong>${escapeHtml(label)}</strong>
3522 <small>${escapeHtml(folder)}</small>
3527 const sizeButtons = ["small", "medium", "large"].map(size => {
3529 small: cmsText(modal.closest(".dbx-cms"), "media_view_small", "Klein"),
3530 medium: cmsText(modal.closest(".dbx-cms"), "media_view_medium", "Mittel"),
3531 large: cmsText(modal.closest(".dbx-cms"), "media_view_large", "Groß")
3533 const icons = { small: "bi-grid-3x3-gap", medium: "bi-grid", large: "bi-grid-1x2" };
3534 return `<button type="button" class="btn btn-outline-primary btn-sm${viewSize === size ? " active" : ""}" data-cms-media-tree-size="${size}" title="Ansicht ${labels[size]}">
3535 <i class="bi ${icons[size]}"></i>
3536 <span>${labels[size]}</span>
3540 <div class="dbx-cms-media-folder-tree-head">
3542 <strong><i class="bi bi-folder2-open"></i> ${escapeHtml(cmsText(modal.closest(".dbx-cms"), "media_folders_title", "Medienordner"))}</strong>
3543 <span>${escapeHtml(cmsText(modal.closest(".dbx-cms"), "media_folder_instruction", "Ordner links wählen, Medien rechts markieren oder per Drag verschieben."))}</span>
3545 <button type="button" class="btn btn-outline-secondary btn-sm" data-cms-media-folder-tree-close>
3546 <i class="bi bi-arrow-left"></i>
3547 <span>${escapeHtml(cmsText(modal.closest(".dbx-cms"), "media_back", "Zurück"))}</span>
3550 <div class="dbx-cms-media-explorer" data-cms-media-tree data-view-size="${escapeHtml(viewSize)}">
3551 <div class="dbx-cms-media-explorer-sidebar">
3552 <div class="dbx-cms-media-explorer-sidebar-title">
3553 <i class="bi bi-folder2"></i>
3554 <span>${escapeHtml(cmsText(modal.closest(".dbx-cms"), "media_folder_label", "Ordner"))}</span>
3556 <div class="dbx-cms-media-explorer-folders">
3560 <div class="dbx-cms-media-explorer-main">
3561 <div class="dbx-cms-media-explorer-toolbar">
3563 <strong>${escapeHtml(currentFolder)}</strong>
3564 <span>${escapeHtml(cmsText(modal.closest(".dbx-cms"), "media_count", "{count} Medien").replace("{count}", String(folderRows.length)))}</span>
3566 <div class="btn-group btn-group-sm" role="group" aria-label="${escapeHtml(cmsText(modal.closest(".dbx-cms"), "media_view", "Ansicht"))}">
3570 <div class="dbx-cms-media-explorer-grid">
3571 ${folderRows.length ? '' : '<div class="dbx-cms-media-tree-empty">' + escapeHtml(cmsText(modal.closest(".dbx-cms"), "media_folder_empty", "Keine Medien in diesem Ordner.")) + '</div>'}
3575 const grid = qs(tree, ".dbx-cms-media-explorer-grid");
3576 if (folderRows.length && grid) {
3577 renderMediaRowsChunked(
3580 row => mediaBrowserExplorerItemHtml(row, selected),
3581 () => updateMediaBrowserBatchUi(modal)
3586 function setMediaBrowserFolderTreeMode(modal, open) {
3588 const tree = qs(modal, "[data-cms-media-folder-tree]");
3589 const toggle = qs(modal, "[data-cms-media-folder-toggle]");
3592 modal.classList.toggle("is-folder-tree-open", open);
3594 modal.classList.remove("is-batch-open", "is-process-open");
3595 const batchPanel = modal.__dbxCmsBatchPanel || null;
3596 if (batchPanel) batchPanel.hidden = true;
3597 const processPanel = qs(modal, "[data-cms-media-process-panel]");
3599 processPanel.hidden = true;
3600 processPanel.innerHTML = "";
3603 tree.classList.toggle("is-open", open);
3604 tree.hidden = !open;
3606 toggle.classList.toggle("is-active", open);
3607 toggle.setAttribute("aria-pressed", open ? "true" : "false");
3609 if (open) renderMediaFolderTree(modal, modal.__dbxCmsFolders || []);
3612 function refreshMediaFolderControls(root, cfg, modal) {
3613 const url = cfgUrl(cfg, "mediafolders");
3614 if (!url || !modal) return Promise.resolve([]);
3615 const profile = mediaBrowserProfile(cfg);
3616 return fetchJson(apiUrl(url), { timeout: 20000 })
3618 let folders = Array.isArray(data && data.folders) ? data.folders : [];
3619 if (profile === "mod") {
3620 folders = folders.filter(folder => String(folder || "") === "mod");
3621 if (!folders.length) folders = ["mod"];
3623 folders = folders.filter(folder => {
3624 const value = String(folder || "");
3625 return value && value !== "module" && value.indexOf("module/") !== 0;
3628 const uploadFolders = uploadFolderOptions(folders);
3629 modal.__dbxCmsFolders = folders;
3630 modal.__dbxCmsUploadFolders = uploadFolders;
3631 if (data && data.root) {
3632 modal.dataset.dbxMediaRoot = String(data.root);
3633 const rootHint = qs(modal, "[data-cms-media-root-hint]");
3634 if (rootHint) rootHint.textContent = "Speicherort: " + String(data.root);
3636 setSelectOptions(qs(modal, "[data-cms-media-browser-folder]"), folders, true);
3637 setSelectOptions(qs(modal, "[data-cms-upload-folder]"), uploadFolders, false);
3638 renderMediaFolderTree(modal, folders);
3639 const parentSelect = qs(modal, "[data-cms-folder-parent]");
3641 setSelectOptions(parentSelect, uploadFolders, false);
3642 const uploadFolder = qs(modal, "[data-cms-upload-folder]");
3643 if (uploadFolder && uploadFolder.value && uploadFolders.includes(uploadFolder.value)) {
3644 parentSelect.value = uploadFolder.value;
3647 syncUploadFolderSelect(modal, uploadFolders, modal.__dbxCmsMediaFolder || "");
3651 dbx.warn("[cms] media folders load failed", err);
3652 return modal.__dbxCmsFolders || [];
3656 function createMediaFolder(root, cfg, modal) {
3657 const url = cfgUrl(cfg, "mediafoldercreate");
3658 const input = qs(modal, "[data-cms-folder-name]");
3659 const parent = qs(modal, "[data-cms-folder-parent]");
3660 const uploadFolder = qs(modal, "[data-cms-upload-folder]");
3661 const name = String(input?.value || "").trim();
3662 const parentVal = String(parent?.value || uploadFolder?.value || "").trim();
3663 const folderPath = buildNewMediaFolderPath(parentVal, name);
3664 if (!url || !name || !folderPath) {
3665 status(root, "Bitte Basis-Ordner und Ordnernamen eintragen.", "error");
3666 return Promise.resolve();
3668 return fetchJson(apiUrl(url), {
3670 headers: { "Content-Type": "application/json" },
3671 body: JSON.stringify({ media_type: mediaTypeFromFolder(folderPath), media_folder: folderPath })
3673 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "folder create failed");
3674 if (input) input.value = "";
3675 status(root, "Medienordner angelegt.", "success");
3676 return refreshMediaFolderControls(root, cfg, modal).then(() => {
3677 const uploadFolder = qs(modal, "[data-cms-upload-folder]");
3678 const filterFolder = qs(modal, "[data-cms-media-browser-folder]");
3679 const uploadFolders = modal.__dbxCmsUploadFolders || uploadFolderOptions(modal.__dbxCmsFolders || []);
3681 if (filterFolder && (data.folder === "all" || (modal.__dbxCmsFolders || []).includes(data.folder))) {
3682 filterFolder.value = data.folder;
3684 if (uploadFolder && uploadFolders.includes(data.folder)) uploadFolder.value = data.folder;
3686 syncUploadFolderSelect(modal, uploadFolders, data.folder || "");
3689 dbx.error("[cms] media folder create failed", err);
3690 status(root, err && err.message ? err.message : "Medienordner konnte nicht angelegt werden.", "error");
3694 function deleteSelectedMediaFolder(root, cfg, modal) {
3695 const url = cfgUrl(cfg, "mediafolderdelete");
3696 const select = qs(modal, "[data-cms-upload-folder]");
3697 const folder = String(select?.value || "");
3698 if (!url || !folder) return Promise.resolve();
3700 return ensureConfirm().then(ok => {
3702 status(root, "Confirm-Lib ist nicht geladen.", "error");
3706 return dbx.confirm.open({
3707 id: "cms-delete-media-folder-" + folder,
3709 title: "<i class=\"bi bi-trash\"></i> Medienordner loeschen",
3710 question: "Medienordner <strong>" + escapeHtml(folder) + "</strong> wirklich loeschen?",
3711 hint: "Der Ordner kann nur geloescht werden, wenn er leer ist.",
3713 labelyes: "<i class=\"bi bi-trash\"></i> Loeschen",
3714 labelno: "<i class=\"bi bi-x-lg\"></i> Abbrechen",
3716 backdropclose: false,
3720 if (!result || result.action !== "yes") return null;
3722 return fetchJson(apiUrl(url), {
3724 headers: { "Content-Type": "application/json" },
3725 body: JSON.stringify({ media_folder: folder })
3728 if (!data) return null;
3729 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "folder delete failed");
3730 status(root, "Medienordner geloescht.", "success");
3731 return refreshMediaFolderControls(root, cfg, modal);
3733 dbx.error("[cms] media folder delete failed", err);
3734 status(root, err && err.message ? err.message : "Medienordner konnte nicht geloescht werden.", "error");
3738 function renameSelectedMediaFolder(root, cfg, modal) {
3739 const url = cfgUrl(cfg, "mediafolderrename");
3740 const select = qs(modal, "[data-cms-upload-folder]");
3741 const input = qs(modal, "[data-cms-folder-rename-name]");
3742 const from = String(select?.value || "").trim();
3743 const newName = String(input?.value || "").trim();
3744 if (!url || !from || !newName) {
3745 status(root, "Bitte Ordner und neuen Namen eintragen.", "error");
3746 return Promise.resolve();
3748 const segments = from.split("/");
3749 const toFolder = newName.indexOf("/") >= 0
3751 : (segments.length > 1 ? segments.slice(0, -1).concat(newName) : [segments[0] || "img", newName]).join("/");
3752 if (toFolder === from) {
3753 status(root, "Der neue Name ist identisch.", "error");
3754 return Promise.resolve();
3756 return fetchJson(apiUrl(url), {
3758 headers: { "Content-Type": "application/json" },
3759 body: JSON.stringify({ from_folder: from, to_folder: toFolder })
3761 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "folder rename failed");
3762 if (input) input.value = "";
3763 status(root, data.msg || "Medienordner umbenannt.", "success");
3764 return refreshMediaFolderControls(root, cfg, modal).then(() => {
3765 const uploadFolder = qs(modal, "[data-cms-upload-folder]");
3766 const filterFolder = qs(modal, "[data-cms-media-browser-folder]");
3767 const uploadFolders = modal.__dbxCmsUploadFolders || uploadFolderOptions(modal.__dbxCmsFolders || []);
3768 if (data.to_folder) {
3769 if (filterFolder && (modal.__dbxCmsFolders || []).includes(data.to_folder)) {
3770 filterFolder.value = data.to_folder;
3771 modal.__dbxCmsMediaFolder = data.to_folder;
3773 if (uploadFolder && uploadFolders.includes(data.to_folder)) uploadFolder.value = data.to_folder;
3775 syncUploadFolderSelect(modal, uploadFolders, data.to_folder || "");
3776 return openMediaBrowser(modal.__dbxCmsRoot || root, cfg, {
3777 mode: modal.__dbxCmsMediaMode || "editor",
3778 slot: modal.__dbxCmsAssignSlot || currentMediaSlot(root),
3779 mediaFolder: data.to_folder || modal.__dbxCmsMediaFolder || "",
3780 formDataExtra: modal.__dbxCmsFormDataExtra || null,
3781 afterAssign: modal.__dbxCmsAfterAssign
3785 dbx.error("[cms] media folder rename failed", err);
3786 status(root, err && err.message ? err.message : "Medienordner konnte nicht umbenannt werden.", "error");
3790 function moveMediaFolderToFolder(root, cfg, modal, source, target) {
3791 const url = cfgUrl(cfg, "mediafolderrename");
3792 source = String(source || "").trim();
3793 target = String(target || "").trim();
3795 status(root, "Ordner verschieben ist nicht konfiguriert.", "error");
3796 return Promise.resolve();
3798 if (!source || !target) {
3799 status(root, "Quell- und Zielordner fehlen.", "error");
3800 return Promise.resolve();
3802 if (source === target || target.indexOf(source + "/") === 0) {
3803 status(root, "Ordner kann nicht in sich selbst verschoben werden.", "error");
3804 return Promise.resolve();
3806 const currentParent = mediaFolderParent(source);
3807 if (currentParent === target) {
3808 status(root, "Ordner liegt bereits dort.", "warning");
3809 return Promise.resolve();
3811 const toFolder = target.replace(/\/+$/g, "") + "/" + mediaFolderName(source);
3812 if ((modal.__dbxCmsFolders || []).includes(toFolder)) {
3813 status(root, "Zielordner existiert bereits.", "error");
3814 return Promise.resolve();
3816 return fetchJson(apiUrl(url), {
3818 headers: { "Content-Type": "application/json" },
3819 body: JSON.stringify({ from_folder: source, to_folder: toFolder })
3821 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "folder move failed");
3822 modal.__dbxCmsMediaTreeFolder = data.to_folder || toFolder;
3823 status(root, data.msg || "Medienordner verschoben.", "success");
3824 return openMediaBrowser(modal.__dbxCmsRoot || root, cfg, {
3825 mode: modal.__dbxCmsMediaMode || "editor",
3826 slot: modal.__dbxCmsAssignSlot || currentMediaSlot(root),
3827 mediaFolder: data.to_folder || toFolder,
3828 formDataExtra: modal.__dbxCmsFormDataExtra || null,
3829 afterAssign: modal.__dbxCmsAfterAssign
3832 dbx.error("[cms] media folder move failed", err);
3833 status(root, err && err.message ? err.message : "Medienordner konnte nicht verschoben werden.", "error");
3837 function moveSelectedMedia(root, cfg, modal) {
3838 const url = cfgUrl(cfg, "mediamove");
3839 const targetSelect = qs(modal, "[data-cms-media-move-folder]");
3840 const target = String(targetSelect?.value || "").trim();
3841 const rows = selectedMediaBrowserRows(modal);
3843 status(root, "Verschieben ist nicht konfiguriert.", "error");
3844 return Promise.resolve();
3847 status(root, "Bitte Zielordner waehlen.", "error");
3848 return Promise.resolve();
3851 status(root, "Bitte mindestens ein Medium auswaehlen.", "error");
3852 return Promise.resolve();
3854 const movable = rows.filter(row => !isExternalVideoRow(row));
3855 if (!movable.length) {
3856 status(root, "YouTube-Eintraege koennen nicht verschoben werden.", "error");
3857 return Promise.resolve();
3859 let chain = Promise.resolve();
3861 movable.forEach(row => {
3862 chain = chain.then(() => fetchJson(apiUrl(url), {
3864 headers: { "Content-Type": "application/json" },
3865 body: JSON.stringify({ media_id: row.id, media_folder: target })
3867 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "media move failed");
3871 return chain.then(() => {
3872 modal.__dbxCmsSelectedIds = new Set();
3873 status(root, moved === 1 ? "Medium verschoben." : moved + " Medien verschoben.", "success");
3874 return openMediaBrowser(modal.__dbxCmsRoot || root, cfg, {
3875 mode: modal.__dbxCmsMediaMode || "editor",
3876 slot: modal.__dbxCmsAssignSlot || currentMediaSlot(root),
3877 mediaFolder: modal.__dbxCmsMediaFolder || "",
3878 formDataExtra: modal.__dbxCmsFormDataExtra || null,
3879 afterAssign: modal.__dbxCmsAfterAssign
3882 dbx.error("[cms] media move failed", err);
3883 status(root, err && err.message ? err.message : "Medium konnte nicht verschoben werden.", "error");
3887 function draggedMediaRows(modal, mediaId) {
3888 mediaId = Number(mediaId || 0);
3889 const selected = modal.__dbxCmsSelectedIds || new Set();
3890 if (mediaId > 0 && selected.has(mediaId)) {
3891 const rows = selectedMediaBrowserRows(modal);
3892 if (rows.length) return rows;
3894 const item = mediaId > 0 ? qs(modal, `[data-cms-media-browser-item][data-media-id="${mediaId}"]`) : null;
3895 return item ? [mediaRowFromItem(item)] : [];
3898 function mediaFolderAcceptsRow(folder, row) {
3899 folder = String(folder || "");
3900 const type = String(row && row.media_type || "").toLowerCase();
3901 const mime = String(row && row.mime || "").toLowerCase();
3902 if (type === "video" || mime.indexOf("video/") === 0) return folder === "videos" || folder.indexOf("videos/") === 0 || folder === "video" || folder.indexOf("video/") === 0;
3903 if (type === "file") return folder === "file" || folder.indexOf("file/") === 0;
3904 return folder.indexOf("img/") === 0 && folder !== "img/video" && folder.indexOf("img/video/") !== 0;
3907 function moveMediaRowsToFolder(root, cfg, modal, rows, target) {
3908 const url = cfgUrl(cfg, "mediamove");
3909 target = String(target || "").trim();
3911 status(root, "Verschieben ist nicht konfiguriert.", "error");
3912 return Promise.resolve();
3915 status(root, "Bitte Zielordner waehlen.", "error");
3916 return Promise.resolve();
3918 const incompatible = (rows || []).filter(row => row && !isExternalVideoRow(row) && !mediaFolderAcceptsRow(target, row));
3919 if (incompatible.length) {
3920 status(root, "Zielordner passt nicht zum Medientyp.", "error");
3921 return Promise.resolve();
3923 const movable = (rows || []).filter(row => row && !isExternalVideoRow(row) && String(row.media_folder || "") !== target);
3924 if (!movable.length) {
3925 status(root, "Keine verschiebbaren Medien fuer diesen Ordner.", "warning");
3926 return Promise.resolve();
3929 return movable.reduce((chain, row) => {
3930 return chain.then(() => fetchJson(apiUrl(url), {
3932 headers: { "Content-Type": "application/json" },
3933 body: JSON.stringify({ media_id: row.id, media_folder: target })
3935 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "media move failed");
3937 if (data.row) patchMediaBrowserRow(modal, data.row);
3939 }, Promise.resolve()).then(() => {
3940 modal.__dbxCmsSelectedIds = new Set();
3941 modal.__dbxCmsMediaTreeFolder = target;
3942 status(root, moved === 1 ? "Medium verschoben." : moved + " Medien verschoben.", "success");
3943 return openMediaBrowser(modal.__dbxCmsRoot || root, cfg, {
3944 mode: modal.__dbxCmsMediaMode || "editor",
3945 slot: modal.__dbxCmsAssignSlot || currentMediaSlot(root),
3946 mediaFolder: modal.__dbxCmsMediaFolder || "",
3947 formDataExtra: modal.__dbxCmsFormDataExtra || null,
3948 afterAssign: modal.__dbxCmsAfterAssign
3951 dbx.error("[cms] media tree move failed", err);
3952 status(root, err && err.message ? err.message : "Medium konnte nicht verschoben werden.", "error");
3956 function setMediaBrowserSelection(modal, mediaId, selected) {
3957 mediaId = Number(mediaId || 0);
3958 if (!modal || mediaId <= 0) return;
3959 if (!modal.__dbxCmsSelectedIds) modal.__dbxCmsSelectedIds = new Set();
3960 if (selected && !isMediaBrowserMulti(modal)) {
3961 modal.__dbxCmsSelectedIds.clear();
3962 qsa(modal, "[data-cms-media-browser-item].is-selected").forEach(el => {
3963 el.classList.remove("is-selected");
3965 const batchPanel = modal.__dbxCmsBatchPanel;
3967 qsa(batchPanel, "[data-cms-batch-item].is-selected").forEach(el => {
3968 el.classList.remove("is-selected");
3972 if (selected) modal.__dbxCmsSelectedIds.add(mediaId);
3973 else modal.__dbxCmsSelectedIds.delete(mediaId);
3974 qsa(modal, `[data-cms-media-browser-item][data-media-id="${mediaId}"]`).forEach(el => {
3975 el.classList.toggle("is-selected", selected);
3977 const batchPanel = modal.__dbxCmsBatchPanel;
3979 qsa(batchPanel, `[data-cms-batch-item][data-media-id="${mediaId}"]`).forEach(el => {
3980 el.classList.toggle("is-selected", selected);
3983 updateMediaBrowserConfirm(modal);
3984 updateMediaBrowserBatchUi(modal);
3987 function toggleMediaBrowserSelection(modal, item) {
3988 const id = Number(item?.getAttribute("data-media-id") || 0);
3990 const selected = !(modal.__dbxCmsSelectedIds || new Set()).has(id);
3991 setMediaBrowserSelection(modal, id, selected);
3994 function renderMediaBatchList(browserModal) {
3995 const batchPanel = browserModal && browserModal.__dbxCmsBatchPanel;
3996 const list = batchPanel && qs(batchPanel, "[data-cms-media-batch-list]");
3997 if (!list || !browserModal) return;
3998 const sourceRows = Array.isArray(browserModal.__dbxCmsFilteredRows)
3999 ? browserModal.__dbxCmsFilteredRows
4000 : (browserModal.__dbxCmsRows || []);
4001 const rows = sourceRows.filter(canEditImage);
4002 const selected = browserModal.__dbxCmsSelectedIds || new Set();
4004 list.innerHTML = '<div class="dbx-cms-empty">Keine bearbeitbaren Bilder im aktuellen Medienbrowser-Filter.</div>';
4007 list.innerHTML = rows.map(row => `
4008 <button type="button"
4009 class="dbx-cms-media-batch-item${selected.has(Number(row.id || 0)) ? " is-selected" : ""}"
4011 data-media-id="${escapeHtml(row.id || "")}"
4012 title="${escapeHtml(row.title || row.file_name || "Bild")}">
4013 <span class="dbx-cms-media-batch-thumb">${mediaPreviewHtml(row)}</span>
4014 <span class="dbx-cms-media-batch-name">${escapeHtml(row.title || row.file_name || "Bild")}</span>
4015 <em class="dbx-cms-media-browser-check"><i class="bi bi-check2"></i></em>
4016 </button>`).join("");
4017 setupMediaLazyImages(list);
4020 function patchMediaBrowserRow(browserModal, row) {
4021 if (!browserModal || !row) return;
4022 const id = Number(row.id || 0);
4023 if (id <= 0) return;
4024 const rows = browserModal.__dbxCmsRows;
4025 if (Array.isArray(rows)) {
4026 const idx = rows.findIndex(item => Number(item.id || 0) === id);
4027 if (idx >= 0) rows[idx] = Object.assign({}, rows[idx], row);
4028 else rows.push(row);
4030 const thumb = row.thumb_url || row.url || "";
4031 qsa(browserModal, `[data-cms-media-browser-item][data-media-id="${id}"]`).forEach(el => {
4032 if (row.url) el.setAttribute("data-url", row.url);
4033 if (row.thumb_url) el.setAttribute("data-thumb-url", row.thumb_url);
4034 if (row.width !== undefined && row.width !== null) el.setAttribute("data-width", row.width);
4035 if (row.height !== undefined && row.height !== null) el.setAttribute("data-height", row.height);
4036 if (row.file_name) el.setAttribute("data-file-name", row.file_name);
4037 if (row.file_path) el.setAttribute("data-file-path", row.file_path);
4038 if (row.title) el.setAttribute("data-title", row.title);
4039 if (row.alt) el.setAttribute("data-alt", row.alt);
4040 if (row.media_folder) el.setAttribute("data-media-folder", row.media_folder);
4041 const img = qs(el, ".dbx-cms-media-browser-pickarea img");
4044 img.removeAttribute("data-dbx-media-src");
4047 renderMediaBatchList(browserModal);
4050 function selectedMediaBrowserRows(modal) {
4051 const selected = modal.__dbxCmsSelectedIds || new Set();
4052 return mediaBrowserAllRows(modal).filter(row => selected.has(Number(row.id || 0)));
4055 function closeMediaBrowserModal(root, modal) {
4057 modal.hidden = true;
4058 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.close === "function") {
4059 dbx.openWin.close(modal.__dbxCmsWindowId);
4061 clearCmsLoading(root);
4064 function leaveInlineMediaCaption(root) {
4065 const sel = window.getSelection ? window.getSelection() : null;
4066 if (!sel || !sel.rangeCount) return;
4067 let node = sel.anchorNode;
4068 if (node && node.nodeType !== 1) node = node.parentElement;
4069 const figure = node && node.closest ? node.closest("figure, .dbx-cms-inline-media, .dbx-cms-inline-image, .dbx-cms-inline-video") : null;
4070 const editor = qs(root, "[data-cms-editor]");
4071 if (!figure || !editor || !editor.contains(figure)) return;
4072 const range = document.createRange();
4073 range.setStartAfter(figure);
4074 range.collapse(true);
4075 sel.removeAllRanges();
4076 sel.addRange(range);
4079 function selectedUploadFiles(form) {
4080 const input = qs(form, 'input[type="file"][name="file"]');
4081 return input && input.files && input.files.length ? Array.from(input.files) : [];
4084 function selectedUploadFile(form) {
4085 const files = selectedUploadFiles(form);
4086 return files.length ? files[0] : null;
4089 function setUploadFiles(form, files) {
4090 const input = qs(form, 'input[type="file"][name="file"]');
4091 files = Array.from(files || []).filter(Boolean);
4092 if (!input || !files.length || !window.DataTransfer) return false;
4093 const dt = new DataTransfer();
4094 files.forEach(file => dt.items.add(file));
4095 input.files = dt.files;
4096 updateUploadLabel(form);
4100 function setUploadFile(form, file) {
4101 return setUploadFiles(form, file ? [file] : []);
4104 function updateUploadLabel(form) {
4105 const label = qs(form, "[data-cms-upload-label]");
4106 const files = selectedUploadFiles(form);
4108 if (files.length > 1) {
4109 label.textContent = files.length + " Dateien ausgewaehlt";
4111 label.textContent = files.length ? files[0].name : "Datei auswaehlen oder hier ablegen";
4114 form.classList.toggle("has-file", files.length > 0);
4117 function formatBytes(bytes) {
4118 bytes = Number(bytes || 0);
4119 if (bytes >= 1024 * 1024 * 1024) return (bytes / 1024 / 1024 / 1024).toFixed(1).replace(/\.0$/, "") + " GB";
4120 if (bytes >= 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1).replace(/\.0$/, "") + " MB";
4121 if (bytes >= 1024) return (bytes / 1024).toFixed(1).replace(/\.0$/, "") + " KB";
4122 return bytes + " B";
4125 function isExternalVideoRow(row) {
4126 const type = String(row && row.media_type || "").toLowerCase();
4127 const storageType = String(row && row.storage_type || "").toLowerCase();
4128 return type === "external_video" || (type === "video" && storageType === "external");
4131 function isVideoRow(row) {
4132 return isExternalVideoRow(row) || String(row.media_type || "").toLowerCase() === "video" || String(row.mime || "").startsWith("video/") || /\.(mp4|webm|ogv|ogg|mov|m4v)$/i.test(row.file_name || row.url || "");
4135 function isImageRow(row) {
4136 return String(row.media_type || "").toLowerCase() === "image" || String(row.mime || "").startsWith("image/") || /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(row.file_name || row.url || "");
4139 function canEditImage(row) {
4140 return isImageRow(row) && !/\.svg$/i.test(row.file_name || row.url || "");
4143 const mediaLazyPlaceholder = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='320' height='220' viewBox='0 0 320 220'%3E%3Crect width='320' height='220' rx='12' fill='%23dbeafe'/%3E%3Crect x='24' y='24' width='272' height='172' rx='10' fill='%23eff6ff' stroke='%2393c5fd' stroke-width='3'/%3E%3Ccircle cx='118' cy='90' r='22' fill='%23bfdbfe'/%3E%3Cpath d='M64 166l62-54 42 34 28-24 60 44z' fill='%2360a5fa' opacity='.55'/%3E%3C/svg%3E";
4145 function mediaLazyImageHtml(src, label, extraHtml) {
4146 src = String(src || "");
4147 if (!src) return "";
4148 return `<img class="dbx-cms-media-lazy-image" src="${mediaLazyPlaceholder}" data-dbx-media-src="${escapeHtml(src)}" alt="${label}" loading="lazy" decoding="async">${extraHtml || ""}`;
4151 function mediaLazyLoadVisible(scope) {
4153 const rootRect = scope.getBoundingClientRect();
4155 qsa(scope, "img[data-dbx-media-src]").forEach(img => {
4156 const rect = img.getBoundingClientRect();
4158 rect.bottom < rootRect.top - margin ||
4159 rect.top > rootRect.bottom + margin ||
4160 rect.right < rootRect.left - margin ||
4161 rect.left > rootRect.right + margin
4165 const src = img.getAttribute("data-dbx-media-src");
4166 if (!src || img.__dbxMediaLazyLoading) return;
4167 img.__dbxMediaLazyLoading = true;
4168 const finish = () => {
4169 img.removeEventListener("load", finish);
4170 img.removeEventListener("error", finish);
4171 img.removeAttribute("data-dbx-media-src");
4172 img.__dbxMediaLazyLoading = false;
4174 img.addEventListener("load", finish);
4175 img.addEventListener("error", finish);
4180 function scheduleMediaLazyLoad(scope) {
4182 if (scope.__dbxMediaLazyFrame) return;
4183 scope.__dbxMediaLazyFrame = window.requestAnimationFrame(() => {
4184 scope.__dbxMediaLazyFrame = 0;
4185 mediaLazyLoadVisible(scope);
4189 function setupMediaLazyImages(scope) {
4191 const images = qsa(scope, "img[data-dbx-media-src]");
4192 if (!images.length) return;
4193 if (!scope.__dbxMediaLazyBound) {
4194 scope.__dbxMediaLazyBound = true;
4195 scope.addEventListener("scroll", () => scheduleMediaLazyLoad(scope), { passive: true });
4196 window.addEventListener("resize", () => scheduleMediaLazyLoad(scope), { passive: true });
4198 scheduleMediaLazyLoad(scope);
4201 function mediaPreviewHtml(row) {
4202 const src = row.thumb_url || row.url || "";
4203 const label = escapeHtml(row.alt || row.title || "");
4204 if (isImageRow(row) && src) return mediaLazyImageHtml(src, label);
4205 if (isExternalVideoRow(row)) {
4206 return row.thumb_url
4207 ? mediaLazyImageHtml(row.thumb_url, label, '<i class="bi bi-play-fill dbx-cms-media-video-icon"></i>')
4208 : '<i class="bi bi-camera-video"></i>';
4210 if (isVideoRow(row)) {
4211 return row.thumb_url
4212 ? mediaLazyImageHtml(row.thumb_url, label, '<i class="bi bi-play-fill dbx-cms-media-video-icon"></i>')
4213 : '<i class="bi bi-camera-video"></i>';
4215 return '<i class="bi bi-file-earmark"></i>';
4218 function mediaProcessHeadMarkup() {
4219 return '<div class="dbx-cms-media-process-head"><strong><i class="bi bi-tools"></i> Medienwartung</strong><button type="button" class="btn btn-outline-secondary btn-sm" data-cms-media-process-close title="Zurueck zum Medienbrowser"><i class="bi bi-arrow-left"></i><span>Zurueck</span></button></div>';
4222 function mediaMaintenanceFolderOptions(modal, includeAll, preferred) {
4223 let folders = uploadFolderOptions(modal && modal.__dbxCmsFolders || [])
4224 .filter(folder => String(folder || "").indexOf("img/") === 0);
4225 if (!folders.length && modal) {
4226 folders = Array.from(qs(modal, "[data-cms-upload-folder]")?.options || [])
4227 .map(option => String(option.value || ""))
4228 .filter(folder => folder.indexOf("img/") === 0);
4230 const current = String(preferred || "");
4232 if (includeAll) options.push('<option value="all">Alle Ordner</option>');
4233 folders.forEach(folder => {
4234 options.push(`<option value="${escapeHtml(folder)}"${folder === current ? " selected" : ""}>${escapeHtml(mediaFolderLabel(folder))}</option>`);
4236 return options.join("");
4239 function renderMediaMaintenanceHome(root, cfg, browserModal, batchPanel) {
4240 const panel = (browserModal ? qs(browserModal, "[data-cms-media-process-panel]") : null)
4241 || (batchPanel ? qs(batchPanel, "[data-cms-media-process-panel]") : null);
4244 browserModal.classList.remove("is-folder-tree-open", "is-batch-open");
4245 browserModal.classList.add("is-process-open");
4246 const batch = browserModal.__dbxCmsBatchPanel || null;
4247 if (batch) batch.hidden = true;
4249 const folderSelect = browserModal ? qs(browserModal, "[data-cms-media-browser-folder]") : null;
4250 const selectedFolder = String(folderSelect?.value || browserModal?.__dbxCmsMediaFolder || "");
4251 const preferredSource = selectedFolder && selectedFolder !== "all" ? selectedFolder : "all";
4252 panel.hidden = false;
4253 panel.innerHTML = mediaProcessHeadMarkup() + `
4254 <div class="dbx-cms-media-maintenance-grid">
4255 <section class="dbx-cms-media-maintenance-card">
4256 <strong><i class="bi bi-arrow-repeat"></i> Medien und Nutzung pruefen</strong>
4257 <p>Vergleicht Seiten, Ordner, Hero-, Inline-, Galerie- und Shop-Verwendungen mit der Datenbank. Fehlende Zuordnungen werden ergaenzt, falsche und alte Eintraege entfernt und die Datenbanken anschliessend komprimiert.</p>
4258 <button type="button" class="btn btn-outline-primary btn-sm" data-cms-media-process-start>
4259 <i class="bi bi-play-fill"></i>
4260 <span>Analyse & Reparatur starten</span>
4263 <section class="dbx-cms-media-maintenance-card">
4264 <strong><i class="bi bi-image"></i> Nicht verwendete Bilder</strong>
4267 <select class="form-select form-select-sm" data-cms-media-unused-source>
4268 ${mediaMaintenanceFolderOptions(browserModal, true, preferredSource)}
4272 <span>Zielordner fuer Verschieben</span>
4273 <select class="form-select form-select-sm" data-cms-media-unused-target>
4274 ${mediaMaintenanceFolderOptions(browserModal, false, "")}
4277 <div class="dbx-cms-media-maintenance-actions">
4278 <button type="button" class="btn btn-outline-danger btn-sm" data-cms-media-unused-action="delete">
4279 <i class="bi bi-trash"></i>
4280 <span>Unbenutzte loeschen</span>
4282 <button type="button" class="btn btn-outline-primary btn-sm" data-cms-media-unused-action="move">
4283 <i class="bi bi-folder-symlink"></i>
4284 <span>Unbenutzte verschieben</span>
4287 <p class="dbx-cms-media-maintenance-note" data-cms-media-unused-result>Es werden nur Bilder ohne erkannte Verwendung verarbeitet.</p>
4290 if (dbx.scan) dbx.scan(panel);
4293 function startMediaMaintenance(root, cfg, browserModal, batchPanel) {
4294 ensureConfirm().then(ok => {
4296 status(root, "Confirm-Lib ist nicht geladen.", "error");
4299 return dbx.confirm.open({
4300 id: "cms-media-maintenance-" + Date.now(),
4302 title: '<i class="bi bi-tools"></i> Medienwartung',
4303 question: "Mediennutzung jetzt vollstaendig analysieren und korrigieren?",
4304 hint: "Nachweislich ungueltige und deaktivierte Datenbankeintraege werden dauerhaft entfernt. Danach werden Medien- und Content-Datenbank komprimiert.",
4306 labelyes: '<i class="bi bi-play-fill"></i> Analyse starten',
4307 labelno: '<i class="bi bi-x-lg"></i> Abbrechen',
4309 backdropclose: false,
4313 if (!result || result.action !== "yes") return;
4314 runMediaMaintenance(root, cfg, browserModal, batchPanel);
4318 function runMediaMaintenance(root, cfg, browserModal, batchPanel) {
4319 const url = cfgUrl(cfg || {}, "mediaprocess");
4320 const panel = (browserModal ? qs(browserModal, "[data-cms-media-process-panel]") : null)
4321 || (batchPanel ? qs(batchPanel, "[data-cms-media-process-panel]") : null);
4322 if (!url || !panel) {
4323 status(root, "Medienwartung ist nicht konfiguriert.", "error");
4327 browserModal.classList.remove("is-folder-tree-open", "is-batch-open");
4328 browserModal.classList.add("is-process-open");
4329 const batch = browserModal.__dbxCmsBatchPanel || null;
4330 if (batch) batch.hidden = true;
4333 const token = "media-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8);
4334 panel.hidden = false;
4335 const processHead = mediaProcessHeadMarkup();
4336 panel.innerHTML = processHead + '<div class="dbx-cms-empty">Medienwartung wird vorbereitet...</div>';
4338 fetchHtml(apiUrl(url, { reset: 1, proc_key: token }), { timeout: 20000 })
4340 panel.innerHTML = processHead + (html ? extractProcessHtml(html) : '<div class="dbx-cms-empty">Medienwartung konnte nicht gestartet werden.</div>');
4341 if (dbx.scan) dbx.scan(panel);
4342 const proc = qs(panel, ".dbx-process");
4343 if (proc && proc.getAttribute("data-process-status") === "finished" && browserModal) {
4344 status(root, "Medienwartung abgeschlossen.", "success");
4348 dbx.error("[cms] media maintenance failed", err);
4349 panel.innerHTML = '<div class="dbx-cms-empty">Medienwartung konnte nicht gestartet werden.</div>';
4350 status(root, err && err.message ? err.message : "Medienwartung konnte nicht gestartet werden.", "error");
4352 .finally(() => clearCmsLoading(root));
4355 function renderUnusedMediaProcess(panel, state) {
4356 state = state || {};
4357 const percent = Math.max(0, Math.min(100, Number(state.percent || 0)));
4358 const title = state.title || "Unbenutzte Bilder";
4359 const message = state.message || "Wartung wird vorbereitet...";
4360 const detail = state.detail || "";
4361 const done = state.done === true;
4362 panel.innerHTML = mediaProcessHeadMarkup() + `
4363 <div class="dbx-cms-media-unused-process" data-cms-media-unused-process>
4364 <div class="dbx-cms-media-unused-process-title">
4365 <strong><i class="bi ${done ? "bi-check2-circle" : "bi-arrow-repeat"}"></i> ${escapeHtml(title)}</strong>
4366 <span>${percent}%</span>
4368 <div class="dbx-cms-media-unused-progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${percent}">
4369 <span style="width:${percent}%"></span>
4371 <p>${escapeHtml(message)}</p>
4372 ${detail ? `<pre>${escapeHtml(detail)}</pre>` : ""}
4374 if (dbx.scan) dbx.scan(panel);
4377 function executeUnusedMediaMaintenance(root, cfg, browserModal, action) {
4378 const url = cfgUrl(cfg || {}, "mediaunused");
4379 const panel = browserModal ? qs(browserModal, "[data-cms-media-process-panel]") : null;
4380 if (!url || !panel) {
4381 status(root, "Wartung fuer unbenutzte Bilder ist nicht konfiguriert.", "error");
4384 action = action === "move" ? "move" : "delete";
4385 const source = String(qs(panel, "[data-cms-media-unused-source]")?.value || "all");
4386 const target = String(qs(panel, "[data-cms-media-unused-target]")?.value || "");
4387 if (action === "move" && !target) {
4388 status(root, "Bitte Zielordner waehlen.", "error");
4391 const sourceLabel = source === "all" ? "allen Ordnern" : "Ordner " + source;
4392 const question = action === "delete"
4393 ? "Alle nicht verwendeten Bilder aus " + sourceLabel + " wirklich loeschen?"
4394 : "Alle nicht verwendeten Bilder aus " + sourceLabel + " nach " + target + " verschieben?";
4395 ensureConfirm().then(ok => {
4397 status(root, "Confirm-Lib ist nicht geladen.", "error");
4400 return dbx.confirm.open({
4401 id: "cms-media-unused-" + action + "-" + Date.now(),
4403 title: "<i class=\"bi bi-tools\"></i> Medienwartung",
4404 question: escapeHtml(question),
4405 hint: "Verwendete Bilder werden serverseitig erneut geprueft und uebersprungen.",
4407 labelyes: action === "delete" ? "<i class=\"bi bi-trash\"></i> Loeschen" : "<i class=\"bi bi-folder-symlink\"></i> Verschieben",
4408 labelno: "<i class=\"bi bi-x-lg\"></i> Abbrechen",
4410 backdropclose: false,
4414 if (!result || result.action !== "yes") return null;
4415 if (browserModal) browserModal.classList.add("is-process-open");
4416 panel.hidden = false;
4417 renderUnusedMediaProcess(panel, {
4419 title: action === "delete" ? "Unbenutzte Bilder loeschen" : "Unbenutzte Bilder verschieben",
4420 message: "Server prueft Verwendungen und verarbeitet die Bilder..."
4422 return fetchJson(apiUrl(url), {
4424 headers: { "Content-Type": "application/json" },
4425 body: JSON.stringify({ action, source_folder: source, target_folder: target }),
4430 if (!data.ok) throw new Error(data && data.msg ? data.msg : "Wartung konnte nicht ausgefuehrt werden.");
4431 const detail = "Geprueft: " + Number(data.checked || 0)
4432 + "\nVerarbeitet: " + Number(data.affected || 0)
4433 + "\nUebersprungen, weil verwendet: " + Number(data.skipped_used || 0)
4434 + (Array.isArray(data.errors) && data.errors.length ? "\nFehler:\n" + data.errors.join("\n") : "");
4435 renderUnusedMediaProcess(panel, {
4438 title: action === "delete" ? "Loeschen abgeschlossen" : "Verschieben abgeschlossen",
4439 message: data.msg || "Wartung abgeschlossen.",
4442 status(root, data.msg || "Wartung abgeschlossen.", Number(data.affected || 0) > 0 ? "success" : "warning");
4444 window.setTimeout(() => {
4445 browserModal.classList.remove("is-process-open");
4446 openMediaBrowser(browserModal.__dbxCmsRoot || root, browserCfg(browserModal), {
4447 mode: browserModal.__dbxCmsMediaMode || "editor",
4448 slot: browserModal.__dbxCmsAssignSlot || currentMediaSlot(root),
4449 mediaFolder: source !== "all" ? source : (browserModal.__dbxCmsMediaFolder || ""),
4450 formDataExtra: browserModal.__dbxCmsFormDataExtra || null,
4451 afterAssign: browserModal.__dbxCmsAfterAssign
4456 dbx.error("[cms] unused media maintenance failed", err);
4457 renderUnusedMediaProcess(panel, {
4460 title: "Wartung fehlgeschlagen",
4461 message: err && err.message ? err.message : "Wartung konnte nicht ausgefuehrt werden."
4463 status(root, err && err.message ? err.message : "Wartung konnte nicht ausgefuehrt werden.", "error");
4467 function openMediaBrowserWindow(root, modal) {
4468 if (!modal) return false;
4469 modal.__dbxCmsCallerRoot = root || null;
4470 if (!dbx.openWin || typeof dbx.openWin.open !== "function") {
4471 ensureOpenWin().then(ok => {
4472 if (ok) openMediaBrowserWindow(root, modal);
4473 else status(root, "openWin.js nicht geladen.", "error");
4477 const currentWindow = closestElement(modal, ".dbx-window");
4478 const currentIsBrowserWindow = currentWindow && currentWindow.classList.contains("dbx-cms-media-browser-window");
4479 if (currentIsBrowserWindow) {
4480 modal.hidden = false;
4481 raiseMediaBrowserWindow(currentWindow, modal);
4482 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.bringToFront === "function") {
4483 dbx.openWin.bringToFront(modal.__dbxCmsWindowId);
4485 raiseMediaBrowserWindow(currentWindow, modal);
4488 const id = dbx.openWin.open({
4489 title: '<i class="bi bi-images"></i> Medienbrowser',
4507 modal.__dbxCmsWindowId = id;
4508 modal.hidden = false;
4509 const win = document.getElementById(id) || closestElement(modal, ".dbx-window");
4510 raiseMediaBrowserWindow(win, modal);
4515 function raiseMediaBrowserWindow(win, modal) {
4516 win = win || closestElement(modal, ".dbx-window");
4517 if (!win && modal && modal.__dbxCmsWindowId) win = document.getElementById(modal.__dbxCmsWindowId);
4519 const apply = () => {
4520 win.classList.add("dbx-cms-media-browser-window");
4521 if (dbx.openWin && typeof dbx.openWin.bringToFront === "function" && win.id) {
4522 dbx.openWin.bringToFront(win.id);
4526 window.requestAnimationFrame(apply);
4527 window.setTimeout(apply, 80);
4530 function mediaBrowserPreset(modal, value) {
4531 if (!modal || !value) return;
4532 const width = qs(modal, "[data-cms-bulk-resize-width]");
4533 const height = qs(modal, "[data-cms-bulk-resize-height]");
4534 if (!width || !height) return;
4535 const parts = String(value).toLowerCase().split("x");
4536 width.value = Number(parts[0] || 0) || "";
4537 height.value = Number(parts[1] || 0) || "";
4540 function bindMediaBrowserEvents(root, cfg, modal) {
4541 if (!modal || modal.__dbxCmsEventsBound) return;
4542 modal.__dbxCmsEventsBound = true;
4544 const batchBtn = qs(modal, "[data-cms-media-batch-open]");
4545 if (batchBtn && !batchBtn.__dbxCmsBatchBound) {
4546 batchBtn.__dbxCmsBatchBound = true;
4547 batchBtn.addEventListener("mousedown", e => e.stopPropagation());
4548 batchBtn.addEventListener("click", e => {
4550 e.stopPropagation();
4551 openMediaBatchWindow(modal.__dbxCmsRoot || root, browserCfg(modal), modal);
4555 modal.addEventListener("click", e => {
4556 e.stopPropagation();
4557 const cfg = browserCfg(modal);
4559 const browserClose = closestElement(e.target, "[data-cms-media-browser-close]");
4560 if (browserClose && modal.contains(browserClose)) {
4562 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.close === "function") {
4563 dbx.openWin.close(modal.__dbxCmsWindowId);
4565 modal.hidden = true;
4567 clearCmsLoading(root);
4571 const batchOpen = closestElement(e.target, "[data-cms-media-batch-open]");
4572 if (batchOpen && modal.contains(batchOpen)) {
4574 e.stopPropagation();
4575 openMediaBatchWindow(root, cfg, modal);
4579 const browserMaintenance = closestElement(e.target, "[data-cms-media-maintenance]");
4580 if (browserMaintenance && modal.contains(browserMaintenance)) {
4582 e.stopPropagation();
4583 const batchPanel = modal.__dbxCmsBatchPanel || null;
4584 if (batchPanel) batchPanel.hidden = true;
4585 renderMediaMaintenanceHome(root, cfg, modal, null);
4589 const processStart = closestElement(e.target, "[data-cms-media-process-start]");
4590 if (processStart && modal.contains(processStart)) {
4592 e.stopPropagation();
4593 startMediaMaintenance(root, cfg, modal, null);
4597 const unusedAction = closestElement(e.target, "[data-cms-media-unused-action]");
4598 if (unusedAction && modal.contains(unusedAction)) {
4600 e.stopPropagation();
4601 executeUnusedMediaMaintenance(root, cfg, modal, unusedAction.getAttribute("data-cms-media-unused-action"));
4605 const processClose = closestElement(e.target, "[data-cms-media-process-close]");
4606 if (processClose && modal.contains(processClose)) {
4608 e.stopPropagation();
4609 const panel = closestElement(processClose, "[data-cms-media-process-panel]");
4611 panel.hidden = true;
4612 panel.innerHTML = "";
4614 modal.classList.remove("is-process-open");
4615 clearCmsLoading(root);
4619 const folderCreate = closestElement(e.target, "[data-cms-folder-create]");
4620 if (folderCreate && modal.contains(folderCreate)) {
4622 createMediaFolder(root, cfg, modal);
4626 const folderDelete = closestElement(e.target, "[data-cms-folder-delete]");
4627 if (folderDelete && modal.contains(folderDelete)) {
4629 deleteSelectedMediaFolder(root, cfg, modal);
4633 const folderRename = closestElement(e.target, "[data-cms-folder-rename]");
4634 if (folderRename && modal.contains(folderRename)) {
4636 renameSelectedMediaFolder(root, cfg, modal);
4640 const folderToggle = closestElement(e.target, "[data-cms-media-folder-toggle]");
4641 if (folderToggle && modal.contains(folderToggle)) {
4643 const tree = qs(modal, "[data-cms-media-folder-tree]");
4645 setMediaBrowserFolderTreeMode(modal, !modal.classList.contains("is-folder-tree-open"));
4650 const folderTreeClose = closestElement(e.target, "[data-cms-media-folder-tree-close]");
4651 if (folderTreeClose && modal.contains(folderTreeClose)) {
4653 e.stopPropagation();
4654 setMediaBrowserFolderTreeMode(modal, false);
4658 const treeSize = closestElement(e.target, "[data-cms-media-tree-size]");
4659 if (treeSize && modal.contains(treeSize)) {
4661 e.stopPropagation();
4662 modal.__dbxCmsMediaTreeSize = String(treeSize.getAttribute("data-cms-media-tree-size") || "medium");
4663 renderMediaFolderTree(modal, modal.__dbxCmsFolders || []);
4667 const treeFolderSelect = closestElement(e.target, "[data-cms-media-tree-folder-select]");
4668 if (treeFolderSelect && modal.contains(treeFolderSelect)) {
4670 e.stopPropagation();
4671 modal.__dbxCmsMediaTreeFolder = String(treeFolderSelect.getAttribute("data-folder") || "");
4672 renderMediaFolderTree(modal, modal.__dbxCmsFolders || []);
4676 const folderDropClick = closestElement(e.target, "[data-cms-media-folder-drop]");
4677 if (folderDropClick && modal.contains(folderDropClick)) {
4679 e.stopPropagation();
4680 const rows = selectedMediaBrowserRows(modal);
4682 status(root, "Medien markieren oder per Drag auf den Zielordner ziehen.", "info");
4685 moveMediaRowsToFolder(root, cfg, modal, rows, folderDropClick.getAttribute("data-folder") || "");
4689 const selectBtn = closestElement(e.target, "[data-cms-media-browser-select]");
4690 if (selectBtn && modal.contains(selectBtn)) {
4692 e.stopPropagation();
4693 const item = closestElement(selectBtn, "[data-cms-media-browser-item]");
4694 if (item) toggleMediaBrowserSelection(modal, item);
4698 const browserConfirm = closestElement(e.target, "[data-cms-media-browser-confirm]");
4699 if (browserConfirm && modal.contains(browserConfirm)) {
4701 if (modal.__dbxCmsMediaMode === "pick") {
4702 confirmPickMediaBrowser(root, modal);
4705 const slot = modal.__dbxCmsAssignSlot || currentMediaSlot(root);
4706 const rows = selectedMediaBrowserRows(modal);
4708 status(root, "Bitte mindestens ein Bild auswaehlen.", "error");
4712 let chain = Promise.resolve();
4713 rows.forEach(row => {
4714 chain = chain.then(() => assignMedia(root, cfg, row, slot).then(assignedRow => {
4715 if (!assignedRow) return;
4716 if (slot === "inline") {
4717 insertMediaRow(root, assignedRow);
4718 setLocalMediaSlot(root, assignedRow.id, "inline");
4720 if (typeof modal.__dbxCmsAfterAssign === "function") {
4721 modal.__dbxCmsAfterAssign(assignedRow);
4726 modal.hidden = true;
4727 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.close === "function") dbx.openWin.close(modal.__dbxCmsWindowId);
4728 clearCmsLoading(root);
4729 status(root, "Auswahl uebernommen.", "success");
4734 const browserDelete = closestElement(e.target, "[data-cms-media-browser-delete]");
4735 if (browserDelete && modal.contains(browserDelete)) {
4737 const item = closestElement(browserDelete, "[data-cms-media-browser-item]");
4738 const mode = modal.__dbxCmsMediaMode === "assign" ? "assign" : "editor";
4739 deleteMedia(root, cfg, Number(item?.getAttribute("data-media-id") || 0))
4740 .then(() => openMediaBrowser(modal.__dbxCmsRoot || root, cfg, {
4742 slot: modal.__dbxCmsAssignSlot || currentMediaSlot(root),
4743 mediaFolder: modal.__dbxCmsMediaFolder || "",
4744 formDataExtra: modal.__dbxCmsFormDataExtra || null,
4745 afterAssign: modal.__dbxCmsAfterAssign
4750 const browserEdit = closestElement(e.target, "[data-cms-media-browser-edit]");
4751 if (browserEdit && modal.contains(browserEdit)) {
4753 const item = closestElement(browserEdit, "[data-cms-media-browser-item]");
4754 openMediaEdit(root, cfg, mediaRowFromItem(item));
4758 const browserPick = closestElement(e.target, "[data-cms-media-browser-pick]");
4759 if (browserPick && modal.contains(browserPick)) {
4761 const item = closestElement(browserPick, "[data-cms-media-browser-item]") || browserPick;
4762 const mediaRow = mediaRowFromItem(item);
4763 const mode = modal.__dbxCmsMediaMode || "editor";
4764 if (mode === "pick" || mode === "assign") {
4765 toggleMediaBrowserSelection(modal, item);
4768 assignMedia(root, cfg, mediaRow, "inline").then(assignedRow => {
4769 if (!assignedRow) return;
4770 insertMediaRow(root, assignedRow);
4771 setLocalMediaSlot(root, assignedRow.id, "inline");
4772 modal.hidden = true;
4773 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.close === "function") dbx.openWin.close(modal.__dbxCmsWindowId);
4774 clearCmsLoading(root);
4779 modal.addEventListener("change", e => {
4780 e.stopPropagation();
4781 const uploadForm = closestElement(e.target, "[data-cms-browser-upload]");
4782 if (uploadForm && modal.contains(uploadForm)) updateUploadLabel(uploadForm);
4783 const uploadFolderSelect = closestElement(e.target, "[data-cms-upload-folder]");
4784 if (uploadFolderSelect && modal.contains(uploadFolderSelect)) uploadFolderSelect.__dbxCmsTouched = true;
4787 modal.addEventListener("dragstart", e => {
4788 const item = closestElement(e.target, "[data-cms-media-browser-item]");
4789 if (item && modal.contains(item)) {
4790 const id = Number(item.getAttribute("data-media-id") || 0);
4791 if (!id || isExternalVideoRow(mediaRowFromItem(item))) {
4795 modal.__dbxCmsDraggedMediaId = id;
4796 modal.__dbxCmsDraggedFolder = "";
4797 item.classList.add("is-dragging");
4798 if (e.dataTransfer) {
4799 e.dataTransfer.effectAllowed = "move";
4800 e.dataTransfer.setData("text/plain", String(id));
4801 e.dataTransfer.setData("application/x-dbx-media-id", String(id));
4806 const folder = closestElement(e.target, "[data-cms-media-tree-folder-select]");
4807 if (!folder || !modal.contains(folder)) return;
4808 const folderPath = String(folder.getAttribute("data-folder") || "");
4813 modal.__dbxCmsDraggedFolder = folderPath;
4814 modal.__dbxCmsDraggedMediaId = 0;
4815 folder.classList.add("is-dragging");
4816 if (e.dataTransfer) {
4817 e.dataTransfer.effectAllowed = "move";
4818 e.dataTransfer.setData("text/plain", folderPath);
4819 e.dataTransfer.setData("application/x-dbx-media-folder", folderPath);
4823 modal.addEventListener("dragend", e => {
4824 const item = closestElement(e.target, "[data-cms-media-browser-item]");
4825 if (item) item.classList.remove("is-dragging");
4826 const folder = closestElement(e.target, "[data-cms-media-tree-folder-select]");
4827 if (folder) folder.classList.remove("is-dragging");
4828 qsa(modal, "[data-cms-media-folder-drop].is-dragover").forEach(el => el.classList.remove("is-dragover"));
4829 modal.__dbxCmsDraggedMediaId = 0;
4830 modal.__dbxCmsDraggedFolder = "";
4833 modal.addEventListener("dragover", e => {
4834 e.stopPropagation();
4835 const folderDrop = closestElement(e.target, "[data-cms-media-folder-drop]");
4836 if (folderDrop && modal.contains(folderDrop)) {
4838 folderDrop.classList.add("is-dragover");
4839 if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
4842 const dropzone = closestElement(e.target, "[data-cms-dropzone]");
4843 if (!dropzone || !modal.contains(dropzone)) return;
4845 dropzone.classList.add("is-dragover");
4848 modal.addEventListener("dragleave", e => {
4849 e.stopPropagation();
4850 const folderDrop = closestElement(e.target, "[data-cms-media-folder-drop]");
4851 if (folderDrop && modal.contains(folderDrop)) {
4852 folderDrop.classList.remove("is-dragover");
4855 const dropzone = closestElement(e.target, "[data-cms-dropzone]");
4856 if (!dropzone || !modal.contains(dropzone)) return;
4857 dropzone.classList.remove("is-dragover");
4860 modal.addEventListener("drop", e => {
4861 e.stopPropagation();
4862 const folderDrop = closestElement(e.target, "[data-cms-media-folder-drop]");
4863 if (folderDrop && modal.contains(folderDrop)) {
4865 folderDrop.classList.remove("is-dragover");
4866 const target = folderDrop.getAttribute("data-folder") || "";
4867 const draggedFolder = String((e.dataTransfer && e.dataTransfer.getData("application/x-dbx-media-folder")) || modal.__dbxCmsDraggedFolder || "");
4868 if (draggedFolder) {
4869 moveMediaFolderToFolder(root, browserCfg(modal), modal, draggedFolder, target);
4872 const id = Number((e.dataTransfer && (e.dataTransfer.getData("application/x-dbx-media-id") || e.dataTransfer.getData("text/plain"))) || modal.__dbxCmsDraggedMediaId || 0);
4873 moveMediaRowsToFolder(root, browserCfg(modal), modal, draggedMediaRows(modal, id), target);
4876 const dropzone = closestElement(e.target, "[data-cms-dropzone]");
4877 if (!dropzone || !modal.contains(dropzone)) return;
4879 dropzone.classList.remove("is-dragover");
4880 const form = closestElement(dropzone, "form");
4881 const files = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length ? e.dataTransfer.files : null;
4882 if (!form || !files || !files.length) return;
4883 if (!setUploadFiles(form, files)) {
4884 status(root, "Datei bitte ueber die Dateiauswahl waehlen.", "error");
4889 function openMediaBrowser(root, cfg, options) {
4890 options = options || {};
4891 cfg = Object.assign({}, cfg || {});
4892 const mode = options.mode === "assign" ? "assign" : (options.mode === "pick" ? "pick" : "editor");
4893 const assignSlot = options.slot || (mode === "assign" ? currentMediaSlot(root) : "inline");
4894 let mediaFolder = String(options.mediaFolder || options.media_folder || "").trim();
4895 if (!mediaFolder && mediaBrowserProfile(cfg) === "mod") mediaFolder = "mod";
4896 const formDataExtra = options.formDataExtra && typeof options.formDataExtra === "object" ? options.formDataExtra : null;
4897 const afterAssign = typeof options.afterAssign === "function" ? options.afterAssign : null;
4898 clearCmsLoading(root);
4899 const mediaUrl = cfgUrl(cfg || {}, "media");
4901 status(root, "Medienbrowser ist nicht konfiguriert.", "error");
4905 let modal = state(root).mediaBrowser;
4906 if (!modal || !document.documentElement.contains(modal)) {
4907 modal = qs(root, "[data-cms-media-browser]");
4909 if (mediaBrowserNeedsRebuild(modal)) {
4910 disposeMediaBrowserModal(root, modal);
4914 const uploadFormHtml = mediaBrowserFormHtml(root, "[data-cms-media-upload-template]");
4915 const externalVideoFormHtml = mediaBrowserFormHtml(root, "[data-cms-external-video-template]");
4916 if (!uploadFormHtml) {
4917 status(root, "Das dbxForm-Uploadformular fuer den Medienbrowser fehlt.", "error");
4920 modal = document.createElement("div");
4921 modal.className = "dbx-cms-media-browser";
4922 modal.setAttribute("data-cms-media-browser", "1");
4924 <div class="dbx-cms-media-browser-head">
4926 <strong><i class="bi bi-images"></i> Medienbrowser</strong>
4927 <div class="small text-muted" data-cms-media-root-hint></div>
4929 <button type="button" class="btn btn-outline-secondary btn-sm" data-cms-media-browser-close title="Schliessen"><i class="bi bi-x-lg"></i></button>
4931 <div class="dbx-cms-status dbx-cms-media-browser-status" data-cms-status aria-live="polite"></div>
4932 <details class="dbx-cms-media-upload-panel" data-cms-media-upload-panel>
4934 <span class="dbx-cms-media-upload-summary-main">
4935 <i class="bi bi-chevron-right dbx-cms-toggle-icon"></i>
4936 <span>Upload und YouTube</span>
4938 <span class="dbx-cms-media-upload-summary-actions">
4939 <button type="button" class="btn btn-outline-primary btn-sm" data-cms-media-batch-open title="Batch Resize">
4940 <i class="bi bi-tools"></i>
4943 <button type="button" class="btn btn-outline-primary btn-sm" data-cms-media-maintenance title="Medienwartung im Medienbrowser starten">
4944 <i class="bi bi-tools"></i>
4945 <span>Wartung</span>
4950 <div class="dbx-cms-media-folderbar">
4951 <span class="small text-muted dbx-cms-media-folderbar-title">Neuer Unterordner:</span>
4952 <select class="form-select form-select-sm" data-cms-folder-parent title="Vorhandenen Ordner als Basis waehlen"></select>
4953 <input type="text" class="form-control form-control-sm" data-cms-folder-name placeholder="Ordnername">
4954 <button type="button" class="btn btn-outline-primary btn-sm" data-cms-folder-create title="Ordner anlegen">
4955 <i class="bi bi-folder-plus"></i>
4957 <button type="button" class="btn btn-outline-danger btn-sm" data-cms-folder-delete title="Ausgewaehlten Upload-Ordner loeschen">
4958 <i class="bi bi-folder-x"></i>
4960 <input type="text" class="form-control form-control-sm" data-cms-folder-rename-name placeholder="Neuer Ordnername">
4961 <button type="button" class="btn btn-outline-secondary btn-sm" data-cms-folder-rename title="Ordner umbenennen">
4962 <i class="bi bi-pencil"></i>
4965 ${externalVideoFormHtml}
4967 <div class="dbx-cms-media-process-panel" data-cms-media-process-panel hidden></div>
4968 <div class="dbx-cms-media-browser-tools">
4969 <button type="button" class="btn btn-outline-primary btn-sm dbx-cms-media-folder-toggle" data-cms-media-folder-toggle title="Medienordner anzeigen">
4970 <i class="bi bi-list-ul"></i>
4972 <input type="text" class="form-control form-control-sm" data-cms-media-browser-search placeholder="Medien suchen">
4973 <select class="form-select form-select-sm" data-cms-media-browser-folder title="Verzeichnis anzeigen">
4974 <option value="all">Alle Verzeichnisse</option>
4976 <select class="form-select form-select-sm" data-cms-media-browser-slot title="Bereich anzeigen">
4977 <option value="all">Alle</option>
4978 <option value="gallery">Galerie</option>
4979 <option value="hero">Hero</option>
4980 <option value="inline">Im Text</option>
4981 <option value="shop">Shop</option>
4984 <div class="dbx-cms-media-folder-tree" data-cms-media-folder-tree hidden></div>
4985 <div class="dbx-cms-media-browser-list" data-cms-media-browser-list>
4986 ${mediaBrowserSkeletonHtml(24)}
4988 <div class="dbx-cms-media-browser-confirm" data-cms-media-browser-confirmbar hidden>
4989 <span><strong data-cms-media-browser-count>0</strong> Medien ausgewaehlt</span>
4990 <button type="button" class="btn btn-primary btn-sm" data-cms-media-browser-confirm>
4991 <i class="bi bi-check2"></i>
4992 <span>Auswahl uebernehmen</span>
4995 // Medienmanager koennen selbst innerhalb eines grossen
4996 // dbxForm-Formulars liegen (z. B. Artikel bearbeiten). Das Modal
4997 // wird dann als Geschwisterelement angehaengt, damit seine beiden
4998 // Formulare niemals in ein anderes <form> verschachtelt werden.
4999 const ownerForm = closestElement(root, "form");
5000 const modalHost = ownerForm && ownerForm.parentElement ? ownerForm.parentElement : root;
5001 modalHost.appendChild(modal);
5003 const browserUpload = qs(modal, "[data-cms-browser-upload]");
5004 if (browserUpload) {
5005 browserUpload.addEventListener("submit", e => {
5007 const currentMode = modal.__dbxCmsMediaMode || "editor";
5008 const currentSlot = modal.__dbxCmsAssignSlot || (currentMode === "assign" ? currentMediaSlot(root) : "inline");
5009 uploadMedia(root, browserCfg(modal), browserUpload, {
5010 pickMode: currentMode === "pick",
5011 insertUploaded: currentMode === "editor",
5012 formDataExtra: modal.__dbxCmsFormDataExtra || null,
5013 afterUpload: data => {
5014 const rows = data && Array.isArray(data.rows) ? data.rows : (data && data.row ? [data.row] : []);
5015 const responses = data && Array.isArray(data.responses) ? data.responses : [];
5016 const moduleResp = responses.find(item => item && Array.isArray(item.items)) || null;
5017 if (currentMode === "pick" && data && data.ok && moduleResp && Array.isArray(moduleResp.items)) {
5018 if (typeof modal.__dbxCmsAfterAssign === "function") {
5019 modal.__dbxCmsAfterAssign({ items: moduleResp.items, filename: moduleResp.filename || "" });
5021 modal.hidden = true;
5022 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.close === "function") {
5023 dbx.openWin.close(modal.__dbxCmsWindowId);
5025 clearCmsLoading(root);
5028 if (currentMode === "pick" && rows.length) {
5029 let chain = Promise.resolve();
5030 rows.forEach(row => {
5031 chain = chain.then(() => {
5032 if (typeof modal.__dbxCmsAfterAssign === "function") {
5033 return Promise.resolve(modal.__dbxCmsAfterAssign(row));
5038 modal.hidden = true;
5039 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.close === "function") {
5040 dbx.openWin.close(modal.__dbxCmsWindowId);
5042 clearCmsLoading(root);
5046 if (currentMode === "assign" && rows.length) {
5047 rows.forEach(row => {
5048 if (currentSlot === "inline") applyInlineMediaAssignment(root, row);
5049 else setLocalMediaSlot(root, row.id, currentSlot);
5050 if (typeof modal.__dbxCmsAfterAssign === "function") modal.__dbxCmsAfterAssign(row);
5053 const refreshFolder = data.uploadFolder
5054 || (rows[0] && rows[0].media_folder)
5055 || qs(modal, "[data-cms-upload-folder]")?.value
5056 || modal.__dbxCmsMediaFolder
5058 openMediaBrowser(root, browserCfg(modal), {
5061 mediaFolder: refreshFolder,
5062 formDataExtra: modal.__dbxCmsFormDataExtra || null,
5063 afterAssign: modal.__dbxCmsAfterAssign
5069 const browserExternalVideo = qs(modal, "[data-cms-browser-external-video]");
5070 if (browserExternalVideo) {
5071 browserExternalVideo.addEventListener("submit", e => {
5073 const currentMode = modal.__dbxCmsMediaMode || "editor";
5074 const currentSlot = modal.__dbxCmsAssignSlot || (currentMode === "assign" ? currentMediaSlot(root) : "inline");
5075 addExternalVideo(root, browserCfg(modal), browserExternalVideo, {
5076 insertExternal: currentMode === "editor",
5078 afterExternal: data => {
5079 const row = data && data.row ? data.row : null;
5080 if (currentMode === "assign" && row) {
5081 if (currentSlot === "inline") applyInlineMediaAssignment(root, row);
5082 else setLocalMediaSlot(root, row.id, currentSlot);
5083 if (typeof modal.__dbxCmsAfterAssign === "function") modal.__dbxCmsAfterAssign(row);
5085 const refreshFolder = (row && row.media_folder)
5086 || qs(modal, "[data-cms-upload-folder]")?.value
5087 || modal.__dbxCmsMediaFolder
5089 openMediaBrowser(root, browserCfg(modal), {
5092 mediaFolder: refreshFolder,
5093 formDataExtra: modal.__dbxCmsFormDataExtra || null,
5094 afterAssign: modal.__dbxCmsAfterAssign
5100 state(root).mediaBrowser = modal;
5102 state(root).mediaBrowser = modal;
5103 modal.__dbxCmsCfg = cfg;
5104 modal.__dbxCmsRoot = root;
5105 ensureMediaBrowserFolderUi(modal);
5106 applyMediaBrowserProfile(modal, cfg);
5107 bindMediaBrowserEvents(root, cfg, modal);
5109 const list = qs(modal, "[data-cms-media-browser-list]");
5110 const search = qs(modal, "[data-cms-media-browser-search]");
5111 const slotSelect = qs(modal, "[data-cms-media-browser-slot]");
5112 const folderSelect = qs(modal, "[data-cms-media-browser-folder]");
5113 const uploadFolder = qs(modal, "[data-cms-upload-folder]");
5114 const uploadSlot = qs(modal, 'input[name="slot"]');
5115 const uploadTemplate = qs(modal, 'input[name="template"]');
5116 const externalVideoSlot = qs(modal, '[data-cms-browser-external-video] input[name="slot"]');
5117 modal.__dbxCmsMediaMode = mode;
5118 modal.__dbxCmsAssignSlot = assignSlot;
5119 modal.__dbxCmsAfterAssign = afterAssign;
5120 modal.__dbxCmsMediaFolder = mediaFolder;
5121 modal.__dbxCmsFormDataExtra = formDataExtra;
5122 modal.__dbxCmsSinglePick = options.singlePick === true;
5123 modal.__dbxCmsSelectedIds = new Set();
5124 updateMediaBrowserConfirm(modal);
5125 if (slotSelect) slotSelect.value = "all";
5126 if (folderSelect) folderSelect.value = mediaFolder || folderSelect.value || "all";
5127 if (uploadSlot) uploadSlot.value = assignSlot;
5128 if (uploadTemplate) uploadTemplate.value = mode === "assign" ? "" : "image-inline";
5129 if (externalVideoSlot) externalVideoSlot.value = assignSlot;
5130 clearCmsLoading(root);
5131 modal.hidden = false;
5132 openMediaBrowserWindow(root, modal);
5133 modal.classList.add("is-loading");
5134 modal.setAttribute("aria-busy", "true");
5135 const requestId = Date.now() + "-" + Math.random();
5136 modal.__dbxCmsMediaRequest = requestId;
5137 let initialMediaParams = null;
5138 if (search) search.value = "";
5139 if (list) list.innerHTML = mediaBrowserSkeletonHtml(24);
5141 const requestedFolder = String(mediaFolder || folderSelect?.value || "all");
5142 const mediaParams = mediaBrowserQueryParams(requestedFolder);
5143 mediaParams.sync = 0;
5144 mediaParams.limit = 28;
5145 mediaParams.offset = 0;
5146 if (formDataExtra && formDataExtra.xmodul) mediaParams.xmodul = formDataExtra.xmodul;
5147 initialMediaParams = Object.assign({}, mediaParams);
5149 fetchJson(apiUrl(mediaUrl, mediaParams), { timeout: 30000 })
5151 if (modal.__dbxCmsMediaRequest !== requestId) return;
5152 if (!data || !data.ok) throw new Error("bad response");
5153 const rows = reconcileMediaBrowserUsageWithEditor(root, Array.isArray(data.rows) ? data.rows : []);
5154 modal.__dbxCmsRows = rows;
5155 renderMediaFolderTree(modal, modal.__dbxCmsFolders || []);
5156 const render = () => {
5157 const term = String(search?.value || "").toLowerCase();
5158 const slotFilter = String(slotSelect?.value || "all");
5159 const folderFilter = String(folderSelect?.value || "all");
5160 const selected = modal.__dbxCmsSelectedIds || new Set();
5161 const multi = isMediaBrowserMulti(modal);
5162 const needsConfirm = mediaBrowserUsesConfirmBar(modal);
5163 const filterRows = row => {
5164 const hay = String((row.title || "") + " " + (row.file_name || "") + " " + (row.alt || "")).toLowerCase();
5165 const matchTerm = !term || hay.includes(term);
5166 const matchSlot = slotFilter === "all" || mediaUsageSlots(row).includes(slotFilter);
5167 const matchFolder = folderFilter === "all" || String(row.media_folder || "") === folderFilter;
5168 return matchTerm && matchSlot && matchFolder;
5170 const filtered = !term && slotFilter === "all" ? rows : rows.filter(filterRows);
5171 modal.__dbxCmsFilteredRows = filtered;
5172 if (!filtered.length) {
5173 list.innerHTML = '<div class="dbx-cms-empty">Keine passenden Medien gefunden.</div>';
5174 updateMediaBrowserBatchUi(modal);
5177 renderMediaRowsChunked(
5180 row => mediaBrowserItemHtml(row, selected, needsConfirm),
5182 updateMediaBrowserConfirm(modal);
5183 updateMediaBrowserBatchUi(modal);
5184 renderMediaBatchList(modal);
5189 const folderRefresh = new Promise(resolve => window.setTimeout(resolve, 250))
5190 .then(() => refreshMediaFolderControls(root, cfg, modal))
5192 if (mediaFolder && folderSelect) folderSelect.value = mediaFolder;
5193 const uploadFolders = modal.__dbxCmsUploadFolders || uploadFolderOptions(modal.__dbxCmsFolders || []);
5194 syncUploadFolderSelect(modal, uploadFolders, mediaFolder);
5196 if (search && !search.__dbxCmsMediaBrowserBound) {
5197 search.__dbxCmsMediaBrowserBound = true;
5198 search.addEventListener("input", render);
5200 if (slotSelect && !slotSelect.__dbxCmsMediaBrowserBound) {
5201 slotSelect.__dbxCmsMediaBrowserBound = true;
5202 slotSelect.addEventListener("change", render);
5204 if (folderSelect && !folderSelect.__dbxCmsMediaBrowserBound) {
5205 folderSelect.__dbxCmsMediaBrowserBound = true;
5206 folderSelect.addEventListener("change", () => {
5207 const uploadFolders = modal.__dbxCmsUploadFolders || uploadFolderOptions(modal.__dbxCmsFolders || []);
5208 syncUploadFolderSelect(modal, uploadFolders, folderSelect.value);
5209 openMediaBrowser(modal.__dbxCmsRoot || root, browserCfg(modal), {
5212 mediaFolder: folderSelect.value,
5213 formDataExtra: modal.__dbxCmsFormDataExtra || null,
5214 afterAssign: modal.__dbxCmsAfterAssign
5219 let hasMore = Number(data.has_more || 0) === 1;
5220 let nextOffset = Number(data.next_offset || rows.length || 0);
5221 const loadRemaining = () => {
5222 if (!hasMore || modal.__dbxCmsMediaRequest !== requestId || !initialMediaParams) return;
5223 const params = Object.assign({}, initialMediaParams, {
5228 fetchJson(apiUrl(mediaUrl, params), { timeout: 30000 }).then(nextData => {
5229 if (modal.__dbxCmsMediaRequest !== requestId || !nextData || !nextData.ok) return;
5230 const incoming = reconcileMediaBrowserUsageWithEditor(root, Array.isArray(nextData.rows) ? nextData.rows : []);
5231 const known = new Set(rows.map(row => Number(row.id || row.media_id || 0)));
5232 incoming.forEach(row => {
5233 const id = Number(row.id || row.media_id || 0);
5234 if (!id || known.has(id)) return;
5238 modal.__dbxCmsRows = rows;
5239 hasMore = Number(nextData.has_more || 0) === 1;
5240 nextOffset = Number(nextData.next_offset || (nextOffset + incoming.length));
5242 const hasActiveFilter = String(search?.value || "").trim() !== ""
5243 || String(slotSelect?.value || "all") !== "all";
5244 if (hasActiveFilter) {
5245 if (!hasMore) render();
5246 } else if (list && list.__dbxCmsMediaRenderScrollHandler) {
5247 list.__dbxCmsMediaRenderScrollHandler();
5249 if (hasMore) window.setTimeout(loadRemaining, 0);
5251 dbx.warn("[cms] remaining media could not be loaded", err);
5254 if (hasMore) window.setTimeout(loadRemaining, 0);
5255 folderRefresh.then(() => renderMediaFolderTree(modal, modal.__dbxCmsFolders || []));
5258 if (modal.__dbxCmsMediaRequest !== requestId) return;
5259 dbx.error("[cms] media browser failed", err);
5260 if (list) list.innerHTML = '<div class="dbx-cms-empty">Medien konnten nicht geladen werden.</div>';
5263 if (modal.__dbxCmsMediaRequest === requestId) {
5264 modal.classList.remove("is-loading");
5265 modal.removeAttribute("aria-busy");
5266 clearCmsLoading(root);
5267 window.setTimeout(() => clearCmsLoading(root), 50);
5272 function openModBrowserWindow(root, modal) {
5273 if (!modal) return false;
5274 if (!dbx.openWin || typeof dbx.openWin.open !== "function") {
5275 ensureOpenWin().then(ok => {
5276 if (ok) openModBrowserWindow(root, modal);
5277 else status(root, "openWin.js nicht geladen.", "error");
5281 const currentWindow = closestElement(modal, ".dbx-window");
5282 const ownWindowId = modal.__dbxCmsWindowId || "";
5283 if (currentWindow && ownWindowId && currentWindow.id === ownWindowId) {
5284 modal.hidden = false;
5285 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.bringToFront === "function") {
5286 dbx.openWin.bringToFront(modal.__dbxCmsWindowId);
5290 if (currentWindow && (!ownWindowId || currentWindow.id !== ownWindowId)) {
5291 document.body.appendChild(modal);
5293 const id = dbx.openWin.open({
5294 title: '<i class="bi bi-puzzle"></i> Modul Aufruf',
5312 modal.__dbxCmsWindowId = id;
5313 modal.hidden = false;
5314 if (dbx.openWin && typeof dbx.openWin.bringToFront === "function") {
5315 dbx.openWin.bringToFront(id);
5321 function closeModBrowserWindow(modal) {
5323 const winId = modal.__dbxCmsWindowId || "";
5324 modal.__dbxCmsWindowId = null;
5325 if (winId && dbx.openWin && typeof dbx.openWin.close === "function") {
5326 dbx.openWin.close(winId);
5329 modal.hidden = true;
5332 function renderModBrowserImages(list, items) {
5334 if (!items.length) {
5335 list.innerHTML = '<div class="dbx-cms-empty">Keine Modul-Bilder in files/mod gefunden.</div>';
5338 list.innerHTML = items.map(item => `
5339 <div class="dbx-cms-mod-browser-item" data-cms-mod-browser-item
5340 data-url="${escapeHtml(item.url || "")}"
5341 data-label="${escapeHtml(item.label || "")}"
5342 data-modul="${escapeHtml(item.default_modul || "")}"
5343 data-params="${escapeHtml(item.default_params || "")}"
5344 data-alt="${escapeHtml(item.default_alt || item.label || "")}">
5345 <button type="button" class="dbx-cms-mod-browser-pick" data-cms-mod-browser-pick title="In Editor einfuegen">
5346 <img src="${escapeHtml(item.url || "")}" alt="${escapeHtml(item.label || "")}" loading="lazy">
5347 <span class="dbx-cms-mod-browser-label">${escapeHtml(item.label || "")}</span>
5348 <small class="dbx-cms-mod-browser-params">${escapeHtml(item.description || item.default_params || "")}</small>
5353 function renderModBrowserModules(list, items) {
5355 if (!items.length) {
5356 list.innerHTML = '<div class="dbx-cms-empty">Keine Module gefunden.</div>';
5359 list.innerHTML = items.filter(item => Number(item.image_count || 0) > 0).map(item => {
5360 const count = Number(item.image_count || 0);
5361 const runs = Array.isArray(item.run1_actions) ? item.run1_actions.slice(0, 4).join(", ") : "";
5362 const hint = count > 0
5363 ? (count + " Bild" + (count === 1 ? "" : "er"))
5366 <div class="dbx-cms-mod-browser-module" data-cms-mod-browser-module data-modul="${escapeHtml(item.id || "")}">
5367 <button type="button" class="dbx-cms-mod-browser-module-pick" data-cms-mod-browser-module-pick>
5368 <span class="dbx-cms-mod-browser-module-name"><i class="bi bi-puzzle"></i> ${escapeHtml(item.label || item.id || "")}</span>
5369 <span class="dbx-cms-mod-browser-module-meta">${escapeHtml(hint)}</span>
5370 ${runs ? `<small class="dbx-cms-mod-browser-module-runs">${escapeHtml(runs)}</small>` : ""}
5374 if (!list.innerHTML.trim()) {
5375 list.innerHTML = '<div class="dbx-cms-empty">Keine Module mit Bildern in files/mod gefunden.</div>';
5379 function setModBrowserStep(modal, step, modul) {
5381 modal.__dbxCmsModStep = step || "modules";
5382 modal.__dbxCmsModSelected = modul || "";
5383 modal.classList.toggle("is-image-step", step === "images");
5384 const title = qs(modal, "[data-cms-mod-browser-title]");
5385 const back = qs(modal, "[data-cms-mod-browser-back]");
5387 title.textContent = step === "images"
5388 ? ("Modul: " + (modul || ""))
5391 if (back) back.hidden = step !== "images";
5394 function loadModBrowserModules(root, cfg, modal) {
5395 const modulesUrl = cfgUrl(cfg, "modmodules");
5396 const list = qs(modal, "[data-cms-mod-browser-list]");
5398 if (list) list.innerHTML = '<div class="dbx-cms-empty">Modul-Liste URL fehlt.</div>';
5399 return Promise.resolve();
5401 if (list) list.innerHTML = '<div class="dbx-cms-empty">Lade Module...</div>';
5402 setModBrowserStep(modal, "modules", "");
5403 return fetchJson(apiUrl(modulesUrl)).then(data => {
5404 const items = data && Array.isArray(data.items) ? data.items : [];
5405 modal.__dbxCmsModModules = items;
5406 renderModBrowserModules(list, items);
5408 dbx.error("[cms] mod modules failed", err);
5409 if (list) list.innerHTML = '<div class="dbx-cms-empty">Module konnten nicht geladen werden.</div>';
5413 function loadModBrowserCatalog(root, cfg, modal, modul) {
5414 const catalogUrl = cfgUrl(cfg, "modcatalog");
5415 const list = qs(modal, "[data-cms-mod-browser-list]");
5416 if (!catalogUrl || !modul) {
5417 if (list) list.innerHTML = '<div class="dbx-cms-empty">Modul-Katalog URL fehlt.</div>';
5418 return Promise.resolve();
5420 if (list) list.innerHTML = '<div class="dbx-cms-empty">Lade Modul-Bilder...</div>';
5421 setModBrowserStep(modal, "images", modul);
5422 return fetchJson(apiUrl(catalogUrl, { modul: modul })).then(data => {
5423 const items = data && Array.isArray(data.items) ? data.items : [];
5424 renderModBrowserImages(list, items);
5426 dbx.error("[cms] mod catalog failed", err);
5427 if (list) list.innerHTML = '<div class="dbx-cms-empty">Modul-Bilder konnten nicht geladen werden.</div>';
5431 function bindModBrowserEvents(root, cfg, modal) {
5432 if (!modal || modal.__dbxCmsModEventsBound) return;
5433 modal.__dbxCmsModEventsBound = true;
5435 modal.addEventListener("click", e => {
5436 e.stopPropagation();
5438 const backBtn = closestElement(e.target, "[data-cms-mod-browser-back]");
5439 if (backBtn && modal.contains(backBtn)) {
5441 loadModBrowserModules(root, cfg, modal);
5445 const modulePick = closestElement(e.target, "[data-cms-mod-browser-module-pick]");
5446 if (modulePick && modal.contains(modulePick)) {
5448 const row = closestElement(modulePick, "[data-cms-mod-browser-module]");
5449 const modul = row ? (row.getAttribute("data-modul") || "") : "";
5451 loadModBrowserCatalog(root, cfg, modal, modul);
5455 const pickBtn = closestElement(e.target, "[data-cms-mod-browser-pick]");
5456 if (pickBtn && modal.contains(pickBtn)) {
5458 const item = closestElement(pickBtn, "[data-cms-mod-browser-item]");
5461 url: item.getAttribute("data-url") || "",
5462 label: item.getAttribute("data-label") || "",
5463 default_modul: item.getAttribute("data-modul") || "",
5464 default_params: item.getAttribute("data-params") || "",
5465 default_alt: item.getAttribute("data-alt") || ""
5467 insertModPlaceholder(root, row, cfg);
5468 closeModBrowserWindow(modal);
5473 function openModBrowser(root, cfg) {
5474 cfg = cfg || cmsConfig(root) || {};
5475 saveEditorSelection(root);
5476 if (!cfgUrl(cfg, "modmodules") || !cfgUrl(cfg, "modcatalog")) {
5477 status(root, "Modul-Browser URLs fehlen.", "error");
5480 let modal = state(root).modBrowser;
5481 if (!modal || !document.documentElement.contains(modal)) {
5482 modal = qs(root, "[data-cms-mod-browser]");
5485 modal = document.createElement("div");
5486 modal.className = "dbx-cms-mod-browser";
5487 modal.setAttribute("data-cms-mod-browser", "1");
5489 <div class="dbx-cms-mod-browser-head">
5490 <button type="button" class="btn btn-outline-secondary btn-sm" data-cms-mod-browser-back hidden title="Zurueck zur Modulliste">
5491 <i class="bi bi-arrow-left"></i>
5493 <span data-cms-mod-browser-title></span>
5495 <div class="dbx-cms-mod-browser-list" data-cms-mod-browser-list>
5496 <div class="dbx-cms-empty">Lade Module...</div>
5498 root.appendChild(modal);
5499 state(root).modBrowser = modal;
5501 state(root).modBrowser = modal;
5502 modal.__dbxCmsRoot = root;
5503 bindModBrowserEvents(root, cfg, modal);
5504 modal.hidden = false;
5505 openModBrowserWindow(root, modal);
5506 loadModBrowserModules(root, cfg, modal);
5509 function modOptionsNeedsRebuild(modal) {
5510 return !!qs(modal, "[data-cms-mod-options-dbx]") || !!qs(modal, "select[data-cms-mod-options-modul]");
5513 function ensureModPlaceholderOptionsDialog(root) {
5514 const s = state(root);
5515 let modal = s.modOptionsModal || qs(root, "[data-cms-mod-options]");
5516 if (modal && modOptionsNeedsRebuild(modal)) {
5519 s.modOptionsModal = null;
5522 modal.__dbxCmsRoot = root;
5523 s.modOptionsModal = modal;
5526 modal = document.createElement("div");
5527 modal.className = "dbx-cms-mod-options";
5528 modal.setAttribute("data-cms-mod-options", "1");
5529 modal.__dbxCmsRoot = root;
5530 modal.hidden = true;
5532 <div class="dbx-cms-mod-options-body">
5533 <label>Alt-Text (img alt)
5534 <input type="text" class="form-control form-control-sm" data-cms-mod-options-alt placeholder="Alternativtext des Pseudo-Bildes">
5536 <div class="dbx-cms-mod-options-module">
5537 <span class="dbx-cms-mod-options-module-label">Modul</span>
5538 <strong class="dbx-cms-mod-options-module-value" data-cms-mod-options-modul></strong>
5541 <input type="text" class="form-control form-control-sm" data-cms-mod-options-params placeholder="z.B. dbx_run1=show&root=0">
5542 <small class="text-muted">Query-String fuer [modul=...]parameter[/modul]</small>
5545 <div class="dbx-cms-mod-options-actions">
5546 <button type="button" class="btn btn-outline-secondary btn-sm" data-cms-mod-options-close><i class="bi bi-x-lg"></i><span>Abbrechen</span></button>
5547 <button type="button" class="btn btn-primary btn-sm" data-cms-mod-options-apply><i class="bi bi-check2"></i><span>Uebernehmen</span></button>
5549 modal.addEventListener("click", e => {
5550 if (closestElement(e.target, "[data-cms-mod-options-close]")) {
5552 closeModPlaceholderOptionsWindow(modal);
5555 if (closestElement(e.target, "[data-cms-mod-options-apply]")) {
5557 applyModPlaceholderOptions(root, modal);
5560 root.appendChild(modal);
5561 s.modOptionsModal = modal;
5565 function closeModPlaceholderOptionsWindow(modal) {
5567 modal.__dbxCmsModPlaceholder = null;
5568 modal.hidden = true;
5569 const winId = modal.__dbxCmsWindowId || "";
5570 if (winId && dbx.openWin && typeof dbx.openWin.close === "function") {
5571 dbx.openWin.close(winId);
5572 modal.__dbxCmsWindowId = null;
5576 function openModPlaceholderOptions(root, wrapper, cfg) {
5577 if (!wrapper) return false;
5578 cfg = cfg || cmsConfig(root) || {};
5579 const modal = ensureModPlaceholderOptionsDialog(root);
5580 modal.__dbxCmsModPlaceholder = wrapper;
5581 modal.__dbxCmsCfg = cfg;
5582 const values = modPlaceholderValues(wrapper);
5583 const params = qs(modal, "[data-cms-mod-options-params]");
5584 const alt = qs(modal, "[data-cms-mod-options-alt]");
5585 const modulEl = qs(modal, "[data-cms-mod-options-modul]");
5586 if (params) params.value = values.params || "";
5587 if (alt) alt.value = values.alt || "";
5588 if (modulEl) modulEl.textContent = values.modul || "–";
5589 modal.hidden = false;
5590 if (!dbx.openWin || typeof dbx.openWin.open !== "function") {
5591 ensureOpenWin().then(ok => {
5592 if (ok) openModPlaceholderOptionsWindow(root, modal);
5596 openModPlaceholderOptionsWindow(root, modal);
5600 function openModPlaceholderOptionsWindow(root, modal) {
5601 if (!modal) return false;
5602 if (!dbx.openWin || typeof dbx.openWin.open !== "function") {
5603 ensureOpenWin().then(ok => {
5604 if (ok) openModPlaceholderOptionsWindow(root, modal);
5605 else status(root, "openWin.js nicht geladen.", "error");
5609 const currentWindow = closestElement(modal, ".dbx-window");
5610 const ownWindowId = modal.__dbxCmsWindowId || "";
5611 if (currentWindow && currentWindow.isConnected && ownWindowId && currentWindow.id === ownWindowId) {
5612 modal.hidden = false;
5613 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.bringToFront === "function") {
5614 dbx.openWin.bringToFront(modal.__dbxCmsWindowId);
5618 if (currentWindow && currentWindow.isConnected && (!ownWindowId || currentWindow.id !== ownWindowId)) {
5619 document.body.appendChild(modal);
5620 } else if (currentWindow && !currentWindow.isConnected) {
5621 modal.__dbxCmsWindowId = null;
5622 if (modal.parentNode) modal.parentNode.removeChild(modal);
5624 const id = dbx.openWin.open({
5625 title: '<i class="bi bi-puzzle"></i> Modul-Platzhalter',
5643 modal.__dbxCmsWindowId = id;
5644 modal.hidden = false;
5645 if (dbx.openWin && typeof dbx.openWin.bringToFront === "function") {
5646 dbx.openWin.bringToFront(id);
5652 function applyModPlaceholderOptions(root, modal) {
5653 const wrapper = modal && modal.__dbxCmsModPlaceholder;
5654 if (!wrapper) return false;
5655 const values = modPlaceholderValues(wrapper);
5656 const modul = values.modul || "";
5657 const params = qs(modal, "[data-cms-mod-options-params]")?.value || "";
5658 const alt = qs(modal, "[data-cms-mod-options-alt]")?.value || "";
5659 const img = qs(wrapper, "img") || qs(wrapper, ".dbx-cms-mod-image");
5661 status(root, "Modul konnte am Platzhalter nicht erkannt werden.", "warning");
5664 wrapper.setAttribute("data-cms-mod-params", params);
5665 wrapper.setAttribute("title", alt);
5667 img.setAttribute("alt", alt);
5668 img.setAttribute("title", alt);
5669 img.removeAttribute("data-dbx");
5670 img.setAttribute("data-cms-mod-params", params);
5672 normalizeModPlaceholders(editorSurface(root));
5673 syncEditorAfterContextAction(root);
5675 closeModPlaceholderOptionsWindow(modal);
5679 function bindModPlaceholderEvents(root) {
5680 if (root.__dbxCmsModPlaceholderBound) return;
5681 root.__dbxCmsModPlaceholderBound = true;
5682 root.addEventListener("dblclick", e => {
5683 const video = inlineVideoEventTarget(root, e);
5686 e.stopPropagation();
5687 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
5688 openInlineVideoOptions(root, video);
5691 const mod = inlineModTarget(root, e.target);
5694 e.stopPropagation();
5695 openModPlaceholderOptions(root, mod, cmsConfig(root) || {});
5699 function visibleJoditDialogInputs(panel) {
5700 return qsa(panel, "input, textarea").filter(input => {
5701 const type = String(input.getAttribute("type") || "text").toLowerCase();
5702 if (["hidden", "file", "button", "submit", "reset", "checkbox", "radio"].includes(type)) return false;
5703 if (input.disabled || input.readOnly) return false;
5704 return !!input.getClientRects().length;
5708 function joditDialogValueInputs(panel) {
5709 return qsa(panel, "input, textarea").filter(input => {
5710 const type = String(input.getAttribute("type") || "text").toLowerCase();
5711 if (["hidden", "file", "button", "submit", "reset", "checkbox", "radio"].includes(type)) return false;
5712 return !input.disabled && !input.readOnly;
5716 function findJoditImageDialogPathInput(panel) {
5717 if (panel && panel.__dbxCmsJoditPathInput && panel.contains(panel.__dbxCmsJoditPathInput)) {
5718 return panel.__dbxCmsJoditPathInput;
5720 const visibleInputs = visibleJoditDialogInputs(panel);
5721 const inputs = visibleInputs.length ? visibleInputs : joditDialogValueInputs(panel);
5722 return inputs.find(input => /src|url|path|image|bild/i.test(String(input.name || input.id || input.getAttribute("placeholder") || "")))
5723 || inputs.find(input => String(input.value || "").indexOf("index.php") >= 0 || /\.(jpg|jpeg|png|gif|webp|svg)(\?|#|$)/i.test(input.value || ""))
5728 function joditImageDialogPanel(target) {
5729 const panel = closestElement(target, ".jodit-dialog__panel, .jodit-dialog");
5730 if (!panel) return null;
5731 const text = String(panel.textContent || "");
5732 if (!/Bildeigenschaften|Image properties|Bild/i.test(text)) return null;
5733 return findJoditImageDialogPathInput(panel) ? panel : null;
5736 function hideJoditUploadControl(el) {
5738 const node = closestElement(el, ".jodit-drag-and-drop__file-box, .jodit-form__group, .jodit-ui-block, .jodit-ui-group, .jodit-upload, .jodit-uploader, label, div") || el;
5739 node.classList.add("dbx-cms-jodit-upload-hidden");
5743 function compactJoditImageDialog(panel) {
5745 panel.classList.add("dbx-cms-jodit-image-dialog");
5746 qsa(panel, "input[type='file'], .jodit-drag-and-drop__file-box, .jodit_uploadfile_button, .jodit-uploader, .jodit-upload").forEach(hideJoditUploadControl);
5747 qsa(panel, "button, a, [role='button']").forEach(btn => {
5748 const label = String((btn.textContent || "") + " " + (btn.title || "") + " " + (btn.getAttribute("aria-label") || ""));
5749 if (/Hochladen|Upload/i.test(label)) {
5750 if (isJoditImageDialogMediaTrigger(panel, btn)) {
5751 btn.title = "Bild aus Medienbrowser auswaehlen";
5754 btn.classList.add("dbx-cms-jodit-upload-hidden");
5758 ensureJoditImageDialogMediaButton(panel);
5759 hideJoditImageDialogPathField(panel);
5762 function joditImageDialogPathContainer(pathInput) {
5763 if (!pathInput) return null;
5764 const dialog = closestElement(pathInput, ".jodit-dialog__panel, .jodit-dialog");
5765 let node = pathInput.parentElement;
5766 for (let i = 0; node && node !== dialog && i < 5; i++) {
5767 const inputs = joditDialogValueInputs(node);
5768 const text = String(node.textContent || "");
5769 if (inputs.length === 1 && inputs[0] === pathInput && /Pfad|URL|Src|Quelle|Source/i.test(text)) return node;
5770 node = node.parentElement;
5775 function joditImagePreviewHost(panel) {
5776 const img = qsa(panel, "img").find(item => {
5777 if (item.closest && item.closest(".jodit-dialog__header")) return false;
5778 return !!item.getClientRects().length;
5780 if (!img) return null;
5781 const dialog = closestElement(img, ".jodit-dialog__panel, .jodit-dialog");
5782 let host = img.parentElement;
5783 for (let i = 0; host && host !== dialog && i < 4; i++) {
5784 if (host.children.length <= 4) break;
5785 host = host.parentElement;
5787 if (!host || host === dialog) host = img.parentElement;
5788 if (host) host.classList.add("dbx-cms-jodit-preview-picker");
5792 function joditSingleInputBox(input) {
5793 if (!input) return null;
5794 const dialog = closestElement(input, ".jodit-dialog__panel, .jodit-dialog");
5795 let node = closestElement(input, ".jodit-ui-input, .jodit-form__group, .jodit-ui-block, div") || input.parentElement;
5796 for (let i = 0; node && node !== dialog && i < 5; i++) {
5797 if (joditDialogValueInputs(node).length === 1) return node;
5798 node = node.parentElement;
5800 return input.parentElement || input;
5803 function arrangeJoditImageSizeControls(panel) {
5804 const pathInput = findJoditImageDialogPathInput(panel);
5805 const inputs = joditDialogValueInputs(panel).filter(input => input !== pathInput);
5806 const pathIndex = joditDialogValueInputs(panel).indexOf(pathInput);
5807 const beforePath = pathIndex > 0 ? joditDialogValueInputs(panel).slice(0, pathIndex) : inputs;
5808 const sizeInputs = beforePath
5809 .filter(input => input !== pathInput && !input.closest(".dbx-cms-jodit-openwin"))
5810 .filter(input => /^\s*\d+(\.\d+)?\s*$/.test(String(input.value || "")) || String(input.getAttribute("type") || "").toLowerCase() === "number")
5812 if (sizeInputs.length < 2) return;
5814 let row = qs(panel, ".dbx-cms-jodit-size-row");
5816 row = document.createElement("div");
5817 row.className = "dbx-cms-jodit-size-row";
5820 const firstBox = joditSingleInputBox(sizeInputs[0]);
5821 const secondBox = joditSingleInputBox(sizeInputs[1]);
5822 const common = firstBox && secondBox ? (firstBox.parentElement === secondBox.parentElement ? firstBox.parentElement : null) : null;
5823 const lockButton = common ? qsa(common, "button, .jodit-ui-button, [role='button']").find(btn => {
5824 const text = String((btn.textContent || "") + " " + (btn.title || "") + " " + (btn.getAttribute("aria-label") || "") + " " + (btn.className || ""));
5825 return /lock|proportion|ratio|verhaeltnis|verh\u00e4ltnis/i.test(text) || qsa(btn, "svg,.jodit-icon").length;
5828 const preview = joditImagePreviewHost(panel);
5829 if (preview && row.parentElement !== preview.parentElement) {
5830 preview.insertAdjacentElement("afterend", row);
5831 } else if (!row.parentElement && firstBox && firstBox.parentElement) {
5832 firstBox.insertAdjacentElement("beforebegin", row);
5835 if (firstBox) row.appendChild(firstBox);
5836 if (lockButton) row.appendChild(lockButton);
5837 if (secondBox) row.appendChild(secondBox);
5840 function ensureJoditImageDialogMediaButton(panel) {
5841 const pathInput = findJoditImageDialogPathInput(panel);
5842 if (!pathInput || qs(panel, "[data-cms-jodit-media-select]")) return;
5843 panel.__dbxCmsJoditPathInput = pathInput;
5844 const btn = document.createElement("button");
5845 btn.type = "button";
5846 btn.className = "btn btn-outline-primary btn-sm dbx-cms-hero-pick dbx-cms-jodit-media-select";
5847 btn.setAttribute("data-cms-jodit-media-select", "1");
5848 btn.title = "Bild aus Medienbrowser auswaehlen";
5849 btn.innerHTML = '<i class="bi bi-image"></i><i class="bi bi-camera-video"></i><i class="bi bi-upload"></i><span>'
5850 + escapeHtml(cmsText(root, "selection_label", "Auswahl")) + '</span>';
5851 const preview = joditImagePreviewHost(panel);
5853 preview.insertAdjacentElement("beforebegin", btn);
5856 const container = joditImageDialogPathContainer(pathInput);
5857 if (container && container.parentElement) {
5858 container.insertAdjacentElement("beforebegin", btn);
5861 pathInput.insertAdjacentElement("beforebegin", btn);
5864 function hideJoditImageDialogPathField(panel) {
5865 const pathInput = findJoditImageDialogPathInput(panel);
5866 if (!pathInput) return;
5867 panel.__dbxCmsJoditPathInput = pathInput;
5868 const container = joditImageDialogPathContainer(pathInput);
5869 if (container && container !== pathInput && joditDialogValueInputs(container).length === 1) {
5870 container.classList.add("dbx-cms-jodit-path-hidden");
5871 container.hidden = true;
5874 pathInput.classList.add("dbx-cms-jodit-path-hidden");
5875 pathInput.hidden = true;
5876 pathInput.setAttribute("aria-hidden", "true");
5879 function findJoditDialogLinkInput(panel) {
5880 const pathInput = findJoditImageDialogPathInput(panel);
5881 const inputs = joditDialogValueInputs(panel).filter(input => input !== pathInput);
5882 return inputs.find(input => /link|href|url/i.test(String(input.name || input.id || input.getAttribute("placeholder") || "")))
5883 || inputs[inputs.length - 1]
5887 function ensureJoditImageOpenWinControls(panel) {
5888 if (qs(panel, "[data-cms-jodit-openwin]")) return;
5889 const linkInput = findJoditDialogLinkInput(panel);
5890 if (!linkInput) return;
5891 const host = document.createElement("div");
5892 host.className = "dbx-cms-jodit-openwin";
5893 host.setAttribute("data-cms-jodit-openwin", "1");
5895 <label class="dbx-cms-jodit-openwin-field">
5896 <span>Link oeffnen</span>
5897 <select class="form-select form-select-sm" data-cms-jodit-link-mode>
5898 <option value="">Normal</option>
5899 <option value="blank">Neuer Tab</option>
5900 <option value="openwin">openWin</option>
5903 <div class="dbx-cms-jodit-openwin-options" data-cms-jodit-openwin-options hidden>
5904 <label><span>Groesse</span><select class="form-select form-select-sm" data-cms-jodit-openwin-size>
5905 <option value="900x620">900 x 620</option>
5906 <option value="1024x760">1024 x 760</option>
5907 <option value="80%x80%">80% x 80%</option>
5908 <option value="1320x860">1320 x 860</option>
5910 <label><span>Position</span><select class="form-select form-select-sm" data-cms-jodit-openwin-position>
5911 <option value="center">Zentriert</option>
5912 <option value="center-top">Zentriert oben</option>
5913 <option value="cascade">Versetzt</option>
5914 <option value="fullscreen">Vollbild</option>
5918 const linkBox = joditSingleInputBox(linkInput);
5919 if (linkBox && linkBox.parentElement) linkBox.insertAdjacentElement("afterend", host);
5920 else linkInput.insertAdjacentElement("afterend", host);
5922 const newTab = qsa(panel, "input[type='checkbox']").find(input => /tab|target|blank|neu/i.test(String(input.name || input.id || input.closest("label")?.textContent || "")));
5923 const mode = qs(host, "[data-cms-jodit-link-mode]");
5924 const options = qs(host, "[data-cms-jodit-openwin-options]");
5925 if (newTab && newTab.checked) mode.value = "blank";
5926 mode.addEventListener("change", () => {
5927 if (options) options.hidden = mode.value !== "openwin";
5928 if (newTab) newTab.checked = mode.value === "blank";
5932 function readJoditOpenWinSettings(panel) {
5933 const mode = qs(panel, "[data-cms-jodit-link-mode]");
5934 const size = qs(panel, "[data-cms-jodit-openwin-size]");
5935 const position = qs(panel, "[data-cms-jodit-openwin-position]");
5936 const value = String(mode?.value || "");
5937 const parts = String(size?.value || "900x620").split("x");
5940 width: parts[0] || "900",
5941 height: parts[1] || "620",
5942 position: String(position?.value || "center")
5946 function findImageByDialogPath(root, path) {
5947 const surface = editorSurface(root);
5948 if (!surface) return null;
5949 const clean = String(path || "").split("#")[0];
5950 return qsa(surface, "img").find(img => {
5951 const src = String(img.getAttribute("src") || img.src || "").split("#")[0];
5952 return src === clean || src.endsWith(clean) || clean.endsWith(src);
5956 function applyJoditOpenWinSettings(root, panel, settings) {
5957 if (!settings || !settings.mode) return;
5958 const linkInput = findJoditDialogLinkInput(panel);
5959 const href = String(linkInput?.value || "").trim();
5961 const pathInput = findJoditImageDialogPathInput(panel);
5962 const img = findImageByDialogPath(root, pathInput ? pathInput.value : "");
5963 const link = img ? closestElement(img, "a") : null;
5965 if (settings.mode === "blank") {
5966 link.setAttribute("target", "_blank");
5967 link.setAttribute("rel", "noopener");
5968 link.classList.remove("dbx-win");
5969 link.removeAttribute("data-dbx");
5970 link.removeAttribute("data-url");
5973 if (settings.mode === "openwin") {
5974 const title = img.getAttribute("alt") || img.getAttribute("title") || "Information";
5975 const dbxData = "lib=openWin|url=" + href + "|title=" + title + "|width=" + settings.width + "|height=" + settings.height + "|position=" + settings.position + "|reload=1|minimizable=1|maximizable=1";
5976 link.classList.add("dbx-win");
5977 link.removeAttribute("target");
5978 link.setAttribute("href", href);
5979 link.setAttribute("data-url", href);
5980 link.setAttribute("data-title", title);
5981 link.setAttribute("data-width", settings.width);
5982 link.setAttribute("data-height", settings.height);
5983 link.setAttribute("data-position", settings.position);
5984 link.setAttribute("data-dbx", dbxData);
5985 syncEditorDom(root);
5990 function isJoditImageDialogMediaTrigger(panel, target) {
5991 const trigger = closestElement(target, "button, a, [role='button'], .jodit-ui-button, .jodit-button, .jodit-input__icon, .jodit-icon");
5992 if (!panel || !trigger || !panel.contains(trigger)) return false;
5993 if (trigger.hasAttribute("data-cms-jodit-media-select")) return true;
5994 if (closestElement(trigger, ".jodit-dialog__header, .jodit-dialog__footer")) return false;
5995 const label = String((trigger.textContent || "") + " " + (trigger.title || "") + " " + (trigger.getAttribute("aria-label") || ""));
5996 if (/Abbrechen|Anwenden|Loeschen|L\u00f6schen|Schliessen|Schlie\u00dfen|Fortgeschritten/i.test(label)) return false;
5998 const pathInput = findJoditImageDialogPathInput(panel);
5999 if (!pathInput) return false;
6000 const inputRect = pathInput.getBoundingClientRect();
6001 const triggerRect = trigger.getBoundingClientRect();
6002 if (!inputRect.width || !triggerRect.width) return false;
6003 return triggerRect.left >= inputRect.right - 12
6004 && triggerRect.top <= inputRect.bottom + 8
6005 && triggerRect.bottom >= inputRect.top - 8;
6008 function setJoditDialogFieldValue(field, value, overwrite) {
6009 if (!field || value == null) return;
6010 if (!overwrite && String(field.value || "").trim()) return;
6011 field.value = String(value || "");
6012 field.dispatchEvent(new Event("input", { bubbles: true }));
6013 field.dispatchEvent(new Event("change", { bubbles: true }));
6016 function applyMediaToJoditImageDialog(root, panel, row) {
6017 if (!row || !row.url) return false;
6018 if (!isImageRow(row)) {
6019 status(root, "Bitte ein Bild auswaehlen.", "error");
6022 const inputs = visibleJoditDialogInputs(panel);
6023 const pathInput = findJoditImageDialogPathInput(panel);
6024 setJoditDialogFieldValue(pathInput, row.url, true);
6026 const afterPath = inputs.slice(Math.max(0, inputs.indexOf(pathInput) + 1))
6027 .filter(input => String(input.getAttribute("type") || "text").toLowerCase() !== "number");
6028 const titleInput = afterPath.find(input => /title|titel/i.test(String(input.name || input.id || input.getAttribute("placeholder") || ""))) || afterPath[0] || null;
6029 const altInput = afterPath.find(input => /alt|alternative/i.test(String(input.name || input.id || input.getAttribute("placeholder") || ""))) || afterPath.find(input => input !== titleInput) || null;
6030 const label = row.title || row.alt || row.file_name || "";
6031 setJoditDialogFieldValue(titleInput, row.title || label, false);
6032 setJoditDialogFieldValue(altInput, row.alt || label, false);
6033 qsa(panel, "img").forEach(img => {
6034 if (img.closest && img.closest(".jodit-dialog__header")) return;
6035 img.src = row.thumb_url || row.url;
6036 img.alt = row.alt || label;
6038 status(root, "Bild aus Medienbrowser uebernommen.", "success");
6042 function openJoditImageDialogMediaBrowser(root, cfg, panel) {
6043 compactJoditImageDialog(panel);
6044 openMediaBrowser(root, cfg || {}, {
6049 return applyMediaToJoditImageDialog(root, panel, row);
6054 function openEditorMediaBrowser(root, cfg) {
6055 openMediaBrowser(root, cfg || {}, {
6060 return assignMedia(root, cfg || {}, row, "inline").then(assignedRow => {
6061 if (!assignedRow) return false;
6062 insertMediaRow(root, assignedRow);
6063 setLocalMediaSlot(root, assignedRow.id, "inline");
6070 function bindJoditImageDialogMediaPicker(root, cfg) {
6071 if (root.__dbxCmsJoditImagePickerBound) return;
6072 root.__dbxCmsJoditImagePickerBound = true;
6074 document.addEventListener("click", e => {
6075 const panel = joditImageDialogPanel(e.target);
6077 compactJoditImageDialog(panel);
6078 if (!isJoditImageDialogMediaTrigger(panel, e.target)) return;
6080 e.stopPropagation();
6081 if (e.stopImmediatePropagation) e.stopImmediatePropagation();
6082 openJoditImageDialogMediaBrowser(root, cfg || {}, panel);
6085 document.addEventListener("focusin", e => {
6086 const panel = joditImageDialogPanel(e.target);
6087 if (panel) compactJoditImageDialog(panel);
6090 // Kein MutationObserver hier: Jodit baut den Dialog dynamisch um und Firefox
6091 // kann sonst bei DOM-Korrekturen in eine dauernde Mutation-Schleife laufen.
6094 function initEditor(root, cfg) {
6095 const editor = qs(root, "[data-cms-editor]");
6096 if (!editor || editor.__dbxJoditReady) return;
6098 if (!window.Jodit || !window.Jodit.make) {
6099 dbx.warn("[cms] Jodit not loaded, fallback contenteditable active");
6103 editor.__dbxJoditReady = true;
6104 bindModPlaceholderEvents(root);
6105 const cmsMarkers = [
6106 { label: "Hero", marker: "dbx:hero" },
6107 { label: "Header", marker: "dbx:header" },
6108 { label: "Footer", marker: "dbx:footer" },
6109 { label: "col-2 Trenner", marker: "dbx:col2" },
6110 { label: "col-3a Trenner", marker: "dbx:col3a" },
6111 { label: "col-3b Trenner", marker: "dbx:col3b" },
6112 { label: cmsText(root, "editor_print_break", "Druck-Seitenumbruch"), marker: "dbx:pagebreak" }
6115 window.Jodit.defaultOptions.controls.dbxMarkerMenu = {
6116 tooltip: cmsText(root, "editor_marker_tooltip", "Trenner für Content-Bereiche einfügen"),
6117 icon: cmsMarkerMenuIcon(),
6119 popup: function (jodit, current, close) {
6120 const box = document.createElement("div");
6121 box.className = "dbx-cms-marker-menu";
6122 cmsMarkers.forEach(item => {
6123 const btn = document.createElement("button");
6124 btn.type = "button";
6125 btn.className = "dbx-cms-marker-menu-item";
6126 btn.textContent = item.label;
6127 btn.addEventListener("mousedown", e => {
6130 btn.addEventListener("click", e => {
6132 insertMarker(root, item.marker, item.label);
6133 if (typeof close === "function") close();
6135 box.appendChild(btn);
6140 window.Jodit.defaultOptions.controls.dbxImageBrowser = {
6141 tooltip: cmsText(root, "editor_media_tooltip", "Medium aus dem Medienbrowser einfügen"),
6144 openEditorMediaBrowser(root, cfg || {});
6147 window.Jodit.defaultOptions.controls.dbxModBrowser = {
6148 tooltip: cmsText(root, "editor_module_tooltip", "Modulaufruf einfügen"),
6149 icon: cmsModBrowserIcon(),
6152 saveEditorSelection(root);
6153 openModBrowser(root, cfg || {});
6156 window.Jodit.defaultOptions.controls.dbxBootstrapComponents = {
6157 tooltip: cmsText(root, "editor_bootstrap_tooltip", "Bootstrap-Content-Komponente einfügen"),
6158 icon: cmsBootstrapComponentIcon(),
6160 popup: function (jodit, current, close) {
6161 const box = document.createElement("div");
6162 box.className = "dbx-cms-marker-menu";
6163 bootstrapComponentItems(root).forEach(item => {
6164 const btn = document.createElement("button");
6165 btn.type = "button";
6166 btn.className = "dbx-cms-marker-menu-item";
6167 btn.textContent = item.label;
6168 btn.addEventListener("mousedown", e => e.preventDefault());
6169 btn.addEventListener("click", e => {
6171 insertBootstrapComponent(root, item.html);
6172 if (typeof close === "function") close();
6174 box.appendChild(btn);
6179 window.Jodit.defaultOptions.controls.dbxTextStyle = {
6180 tooltip: cmsText(root, "editor_text_format", "Textformatierung"),
6181 icon: cmsTextStyleIcon(),
6183 popup: function (jodit, current, close) {
6184 const box = document.createElement("div");
6185 box.className = "dbx-cms-marker-menu dbx-cms-text-style-menu";
6187 { label: cmsText(root, "editor_bold", "Fett"), mark: "B", command: "bold", className: "is-bold" },
6188 { label: cmsText(root, "editor_italic", "Kursiv"), mark: "I", command: "italic", className: "is-italic" },
6189 { label: cmsText(root, "editor_underline", "Unterstrichen"), mark: "U", command: "underline", className: "is-underlined" },
6190 { label: cmsText(root, "editor_strike", "Durchgestrichen"), mark: "S", command: "strikethrough", className: "is-struck" }
6192 const alignments = [
6193 { label: cmsText(root, "editor_align_left", "Linksbündig"), icon: "bi-text-left", command: "justifyLeft" },
6194 { label: cmsText(root, "editor_align_center", "Zentriert"), icon: "bi-text-center", command: "justifyCenter" },
6195 { label: cmsText(root, "editor_align_right", "Rechtsbündig"), icon: "bi-text-right", command: "justifyRight" },
6196 { label: cmsText(root, "editor_align_justify", "Blocksatz"), icon: "bi-justify", command: "justifyFull" }
6198 const appendCommand = item => {
6199 const btn = document.createElement("button");
6200 btn.type = "button";
6201 btn.className = "dbx-cms-marker-menu-item dbx-cms-text-style-menu-item";
6202 const mark = item.icon
6203 ? '<i class="bi ' + item.icon + ' dbx-cms-text-style-menu-icon" aria-hidden="true"></i>'
6204 : '<span class="dbx-cms-text-style-menu-mark ' + item.className + '">' + item.mark + '</span>';
6205 btn.innerHTML = mark + '<span>' + item.label + '</span>';
6206 btn.addEventListener("mousedown", e => e.preventDefault());
6207 btn.addEventListener("click", e => {
6209 execEditorCommand(root, item.command);
6210 if (typeof close === "function") close();
6212 box.appendChild(btn);
6214 styles.forEach(appendCommand);
6215 const divider = document.createElement("span");
6216 divider.className = "dbx-cms-text-style-menu-divider";
6217 divider.setAttribute("aria-hidden", "true");
6218 box.appendChild(divider);
6219 alignments.forEach(appendCommand);
6223 window.Jodit.defaultOptions.controls.dbxHr = {
6224 tooltip: cmsText(root, "editor_horizontal_rule", "Horizontale Linie an der Cursorposition einfügen"),
6228 insertEditorPlainHr(root);
6231 window.Jodit.defaultOptions.controls.dbxSave = {
6232 tooltip: cmsText(root, "editor_save_all", "Alles speichern"),
6233 icon: cmsSaveIcon(),
6236 saveCurrentCms(root, cfg || {});
6240 bindJoditImageDialogMediaPicker(root, cfg || {});
6242 root.__dbxCmsJodit = window.Jodit.make(editor, {
6243 language: cmsLanguage(root),
6244 popupRoot: document.body,
6247 toolbarSticky: false,
6251 askBeforePasteHTML: false,
6252 askBeforePasteFromWord: false,
6254 removeEmptyElements: false
6256 toolbarAdaptive: false,
6257 showCharsCounter: false,
6258 showWordsCounter: false,
6259 showXPathInStatusbar: false,
6260 imageDefaultWidth: 720,
6263 "source", "dbxSave", "|",
6264 "dbxTextStyle", "|",
6265 "ul", "ol", "outdent", "indent", "|",
6266 "paragraph", "brush", "|",
6267 "link", "dbxImageBrowser", "dbxModBrowser", "dbxBootstrapComponents", "video", "dbxHr", "|",
6268 "dbxMarkerMenu", "|",
6269 "undo", "redo", "|",
6273 change: function () {
6274 const surface = editorSurface(root);
6275 // Jodit liefert hier bereits den aktuellen HTML-Wert. Die
6276 // vollständige DOM-Normalisierung erfolgt bei strukturellen
6277 // Aktionen und vor dem Speichern, nicht bei jedem Zeichen.
6278 const html = String(this.value || (surface ? surface.innerHTML : "") || "");
6279 setField(root, "content", html);
6281 scheduleEditorHeight(root);
6282 scheduleEditorMediaRender(root, html);
6284 focus: function () {
6285 window.requestAnimationFrame(() => saveEditorSelection(root));
6287 afterSelectionChange: function () {
6288 window.requestAnimationFrame(() => saveEditorSelection(root));
6292 bindEditorSlashCommands(root, cfg || {});
6293 bindBootstrapCardEditingGuards(root);
6294 bindEditorHeight(root);
6295 window.setTimeout(() => {
6296 normalizeEditorMarkers(root);
6297 const surface = editorSurface(root);
6298 normalizeBootstrapComponents(surface);
6299 bindBootstrapCardEditingGuards(root);
6300 bindEditorSlashCommands(root, cfg || {});
6301 normalizeModPlaceholders(surface);
6302 bindEditorMarkerEventsRetry(root);
6303 bindEditorSaveButton(root, cfg || {}, 0);
6304 scheduleEditorHeight(root);
6308 function bindEditorSaveButton(root, cfg, attempt) {
6309 attempt = Number(attempt || 0);
6310 const instance = getEditorInstance(root);
6311 const container = instance && instance.container ? instance.container : root;
6312 let buttons = qsa(container, ".jodit-toolbar-button");
6313 if (!buttons.length) buttons = qsa(root, ".jodit-toolbar-button");
6314 const saveButton = buttons.find(btn => /dbxsave/i.test(btn.className || "")) || buttons[1] || null;
6315 const button = saveButton ? qs(saveButton, "button") : null;
6317 if (attempt < 20) window.setTimeout(() => bindEditorSaveButton(root, cfg || {}, attempt + 1), 100);
6320 if (button.__dbxCmsSaveBound) return;
6321 button.__dbxCmsSaveBound = true;
6322 button.setAttribute("title", cmsText(root, "editor_save_all", "Alles speichern"));
6323 button.setAttribute("aria-label", cmsText(root, "editor_save_all", "Alles speichern"));
6324 button.addEventListener("click", event => {
6325 event.preventDefault();
6326 event.stopPropagation();
6327 if (typeof event.stopImmediatePropagation === "function") event.stopImmediatePropagation();
6328 saveCurrentCms(root, cfg || {});
6332 function setEditorHtml(root, html) {
6333 html = repairInlineVideoHtml(root, html);
6334 const instance = getEditorInstance(root);
6335 const editor = qs(root, "[data-cms-editor]");
6336 if (instance) instance.value = html || "";
6337 else if (editor) editor.innerHTML = html || "";
6338 setField(root, "content", html || "");
6339 bindEditorHeight(root);
6340 scheduleEditorHeight(root);
6341 window.setTimeout(() => {
6342 normalizeEditorMarkers(root);
6343 const surface = editorSurface(root);
6344 normalizeBootstrapComponents(surface);
6345 repairInlineVideoPlayers(root, surface);
6346 normalizeInlineMediaLayout(surface);
6347 normalizeModPlaceholders(surface);
6348 if (surface) cleanEditorRuntimeNodes(surface);
6349 const repairedHtml = surface ? surface.innerHTML : getEditorHtml(root);
6350 const current = getEditorInstance(root);
6351 if (current) current.value = repairedHtml || "";
6352 if (/(<video\b|<iframe\b|dbx-cms-inline-video-block)/i.test(html) && !/(<video\b|<iframe\b|dbx-cms-inline-video-block)/i.test(repairedHtml || "")) {
6353 setField(root, "content", html || "");
6355 setField(root, "content", repairedHtml || "");
6357 bindEditorMarkerEventsRetry(root);
6358 scheduleEditorHeight(root);
6362 function getEditorHtml(root) {
6363 const instance = getEditorInstance(root);
6364 const surface = editorSurface(root);
6366 normalizeBootstrapComponents(surface);
6367 normalizeInlineMediaLayout(surface);
6368 cleanEditorRuntimeNodes(surface);
6369 const html = surface.innerHTML || "";
6370 if (instance) instance.value = html;
6371 setField(root, "content", html);
6374 if (instance) return instance.value || "";
6375 const editor = qs(root, "[data-cms-editor]");
6376 return editor ? editor.innerHTML : getField(root, "content");
6379 function editorSurface(root) {
6380 const instance = getEditorInstance(root);
6381 if (instance && instance.editor) return instance.editor;
6382 return qs(root, ".jodit-wysiwyg") || qs(root, "[data-cms-editor]");
6385 function nodeElement(node) {
6386 if (!node) return null;
6387 return node.nodeType === 1 ? node : node.parentElement;
6390 function rangeInsideSurface(surface, range) {
6391 if (!surface || !range) return false;
6392 const start = nodeElement(range.startContainer);
6393 const end = nodeElement(range.endContainer);
6394 return !!(start && end && surface.contains(start) && surface.contains(end));
6397 function nodeHasEditorContent(node) {
6398 if (!node) return false;
6399 if (node.nodeType === 3) {
6400 return String(node.nodeValue || "").replace(/\uFEFF/g, "").replace(/\u00a0/g, " ").trim() !== "";
6402 if (node.nodeType !== 1 && node.nodeType !== 11) return false;
6403 if (node.nodeType === 1) {
6404 const tag = node.tagName || "";
6405 if (tag === "BR") return false;
6406 if (/^(IMG|VIDEO|AUDIO|IFRAME|OBJECT|EMBED|TABLE|HR)$/i.test(tag)) return true;
6408 return Array.from(node.childNodes || []).some(nodeHasEditorContent);
6411 function setEditorCaretAfterNode(root, node) {
6412 const surface = editorSurface(root);
6413 if (!surface || !node) return false;
6414 const doc = surface.ownerDocument || document;
6415 const sel = doc.getSelection ? doc.getSelection() : null;
6416 if (!sel) return false;
6417 const range = doc.createRange();
6418 if (!node.parentNode || !surface.contains(node)) {
6419 node = surface.lastChild;
6421 range.selectNodeContents(surface);
6422 range.collapse(false);
6423 sel.removeAllRanges();
6424 sel.addRange(range);
6425 state(root).editorRange = range.cloneRange();
6429 const next = node.nextSibling;
6430 if (next && next.nodeType === 1 && /^(P|DIV|H1|H2|H3|H4|H5|H6|BLOCKQUOTE|LI)$/i.test(next.tagName || "")) {
6431 range.setStart(next, 0);
6433 range.setStartAfter(node);
6435 range.collapse(true);
6436 sel.removeAllRanges();
6437 sel.addRange(range);
6438 state(root).editorRange = range.cloneRange();
6442 function cleanEditorRuntimeNodes(container) {
6443 normalizeCommentMarkers(container);
6444 syncInlineVideoBlockSizes(container);
6445 qsa(container, "[data-jodit-temp], [data-jodit-selection_marker], span[id^='jodit-selection_marker_']").forEach(el => el.remove());
6446 qsa(container, "[data-cms-inline-video-options-open]").forEach(el => el.remove());
6447 qsa(container, ".dbx-cms-inline-media video, .dbx-cms-inline-media iframe, .dbx-cms-inline-media source").forEach(media => {
6448 const src = String(media.getAttribute("src") || "");
6449 const poster = String(media.getAttribute("poster") || "");
6450 if (/^data:/i.test(src) || /^blob:/i.test(src)) media.removeAttribute("src");
6451 if (/^data:/i.test(poster) || /^blob:/i.test(poster)) media.removeAttribute("poster");
6452 if (!media.getAttribute("data-cms-media-slot")) media.setAttribute("data-cms-media-slot", "inline");
6453 if (/^video$/i.test(media.tagName || "")) {
6454 media.setAttribute("controls", "controls");
6455 media.setAttribute("preload", "none");
6456 media.setAttribute("playsinline", "playsinline");
6459 qsa(container, ".dbx-cms-inline-media, figure.dbx-cms-inline-video-block").forEach(wrapper => {
6460 if (!wrapper.getAttribute("data-cms-media-slot")) wrapper.setAttribute("data-cms-media-slot", "inline");
6461 if (wrapper.classList.contains("dbx-cms-inline-video-block")) {
6462 wrapper.removeAttribute("contenteditable");
6463 const videoMedia = qs(wrapper, "[data-cms-media-id], img[src*='dbx_mid='], video[src*='dbx_mid='], source[src*='dbx_mid=']");
6464 const videoIdMatch = String(
6465 wrapper.getAttribute("data-cms-media-id")
6466 || videoMedia?.getAttribute("data-cms-media-id")
6467 || videoMedia?.getAttribute("src")
6469 ).match(/(?:dbx_mid=)?([0-9]+)/i);
6470 const videoId = Number(videoIdMatch && videoIdMatch[1] || 0);
6471 if (videoId > 0) wrapper.setAttribute("data-cms-media-id", String(videoId));
6473 if (wrapper.getAttribute("data-cms-media-id") && !inlineMediaWrapperHasContent(wrapper)) wrapper.remove();
6474 const inlineImage = qs(wrapper, "img");
6475 if (inlineImage && !wrapper.classList.contains("dbx-cms-inline-video-block")) {
6476 const srcMatch = String(inlineImage.getAttribute("src") || "").match(/dbx_mid=([0-9]+)/i);
6477 if (srcMatch && Number(srcMatch[1] || 0) > 0) {
6478 inlineImage.setAttribute("data-cms-media-id", String(Number(srcMatch[1])));
6480 wrapper.removeAttribute("data-cms-media-id");
6483 removeEmptyEditorParagraphs(container);
6484 normalizeInlineMissingMedia(container);
6487 function removeEmptyEditorParagraphs(container) {
6488 if (!container) return;
6489 qsa(container, "p").forEach(paragraph => {
6490 if (paragraph.matches(".dbx-cms-inline-media, .dbx-cms-mod-placeholder, [data-cms-media-id], [data-dbx-marker]")) return;
6491 if (qs(paragraph, "img,video,audio,iframe,object,embed,table,hr,svg,canvas,input,textarea,select,button,[data-cms-media-id],[data-dbx-marker]")) return;
6493 const text = String(paragraph.textContent || "")
6494 .replace(/[\s\u00a0\u200b\ufeff]+/g, "");
6495 if (text === "") paragraph.remove();
6499 function inlineMediaWrapperHasContent(wrap) {
6500 if (!wrap) return false;
6501 return !!qs(wrap, "img, video, iframe, source, .dbx-cms-inline-video-thumb, .dbx-cms-inline-video-empty, .dbx-cms-inline-media-missing");
6504 function inlineMissingMediaWrap(doc, id, label) {
6505 const p = doc.createElement("p");
6506 p.className = "dbx-cms-inline-media dbx-cms-inline-media-missing-wrap";
6507 if (id > 0) p.setAttribute("data-cms-media-id", String(id));
6508 p.setAttribute("data-cms-media-slot", "inline");
6509 p.setAttribute("contenteditable", "false");
6510 p.setAttribute("tabindex", "0");
6511 p.setAttribute("title", "Fehlende Mediendatei auswählen, Entf zum Löschen");
6512 const span = doc.createElement("span");
6513 span.className = "dbx-cms-inline-media-missing";
6514 span.setAttribute("aria-hidden", "true");
6515 span.textContent = label || ("Mediendatei #" + id + " nicht verfuegbar");
6516 p.appendChild(span);
6520 function normalizeInlineMissingMedia(container) {
6521 if (!container) return;
6522 const doc = container.ownerDocument || document;
6523 qsa(container, ".dbx-cms-inline-media-missing-wrap").forEach(wrap => {
6524 wrap.setAttribute("contenteditable", "false");
6525 wrap.classList.add("dbx-cms-inline-media");
6526 wrap.classList.remove("dbx-cms-mod-placeholder");
6527 const id = Number(wrap.getAttribute("data-cms-media-id") || 0);
6528 if (id > 0 && !wrap.getAttribute("data-cms-media-slot")) wrap.setAttribute("data-cms-media-slot", "inline");
6530 qsa(container, ".dbx-cms-inline-media-missing").forEach(span => {
6531 if (closestElement(span, ".dbx-cms-inline-media-missing-wrap")) return;
6532 const id = Number(span.getAttribute("data-cms-media-id") || span.parentElement?.getAttribute("data-cms-media-id") || 0);
6533 const label = String(span.textContent || "").trim() || ("Mediendatei #" + id + " nicht verfuegbar");
6534 const host = span.parentElement;
6535 const wrap = inlineMissingMediaWrap(doc, id, label);
6536 if (host && (host.classList.contains("dbx-cms-mod-placeholder") || host.classList.contains("dbx-cms-inline-media"))) {
6537 host.replaceWith(wrap);
6539 span.replaceWith(wrap);
6544 function inlineVideoMediaSize(media) {
6545 if (!media) return { width: "", height: "" };
6546 let width = media.style && media.style.width ? media.style.width : "";
6547 let height = media.style && media.style.height ? media.style.height : "";
6548 if (!width) width = media.getAttribute("width") || media.getAttribute("data-cms-video-width") || "";
6549 if (!height) height = media.getAttribute("height") || media.getAttribute("data-cms-video-height") || "";
6551 width: cssSizeValue(width),
6552 height: cssSizeValue(height)
6556 function persistInlineVideoRenderedSize(wrapper) {
6557 if (!wrapper || !wrapper.getBoundingClientRect) return false;
6558 const rect = wrapper.getBoundingClientRect();
6559 if (!rect || rect.width < 32 || rect.height < 24) return false;
6560 const width = Math.round(rect.width) + "px";
6561 const height = Math.round(rect.height) + "px";
6562 const beforeWidth = cssSizeValue(wrapper.getAttribute("data-cms-video-width") || wrapper.style.width || "");
6563 const beforeHeight = cssSizeValue(wrapper.getAttribute("data-cms-video-height") || wrapper.style.height || "");
6564 const changed = beforeWidth !== width || beforeHeight !== height;
6565 wrapper.style.width = width;
6566 wrapper.style.height = height;
6567 wrapper.setAttribute("data-cms-video-width", width);
6568 wrapper.setAttribute("data-cms-video-height", height);
6569 const options = inlineVideoOptionsFromElement(wrapper);
6570 options.width = width;
6571 options.height = height;
6572 syncInlineVideoOptionsToMedia(wrapper, options);
6576 function beginInlineVideoResizeTrack(root, wrapper) {
6577 if (!root || !wrapper) return;
6578 const rect = wrapper.getBoundingClientRect ? wrapper.getBoundingClientRect() : null;
6579 state(root).inlineVideoResizeTrack = {
6581 width: rect ? Math.round(rect.width) : 0,
6582 height: rect ? Math.round(rect.height) : 0
6586 function finishInlineVideoResizeTrack(root) {
6587 const s = state(root);
6588 const track = s.inlineVideoResizeTrack;
6589 s.inlineVideoResizeTrack = null;
6590 if (!track || !track.wrapper || !track.wrapper.isConnected) return false;
6591 const rect = track.wrapper.getBoundingClientRect ? track.wrapper.getBoundingClientRect() : null;
6592 if (!rect) return false;
6593 const width = Math.round(rect.width);
6594 const height = Math.round(rect.height);
6595 if (Math.abs(width - track.width) < 2 && Math.abs(height - track.height) < 2) return false;
6596 if (!persistInlineVideoRenderedSize(track.wrapper)) return false;
6597 syncEditorDom(root);
6601 function syncInlineVideoBlockSizes(container) {
6602 if (!container) return;
6603 qsa(container, ".dbx-cms-inline-video-block").forEach(wrapper => {
6604 if (!qs(wrapper, "[data-cms-inline-video-options-open]")) {
6605 wrapper.insertAdjacentHTML("beforeend", inlineVideoOptionsButtonHtml());
6607 const media = qs(wrapper, ".dbx-cms-inline-video-thumb, img, video, iframe");
6608 const size = inlineVideoMediaSize(media);
6609 const wrapperWidth = cssSizeValue(wrapper.style.width || wrapper.getAttribute("data-cms-video-width") || "");
6610 const wrapperHeight = cssSizeValue(wrapper.style.height || wrapper.getAttribute("data-cms-video-height") || "");
6611 let width = wrapperWidth || size.width;
6612 let height = wrapperHeight || size.height;
6614 wrapper.style.width = width;
6615 wrapper.setAttribute("data-cms-video-width", width);
6617 if (height && height !== "auto") {
6618 wrapper.style.height = height;
6619 wrapper.setAttribute("data-cms-video-height", height);
6622 media.removeAttribute("width");
6623 media.removeAttribute("height");
6625 media.style.width = "";
6626 media.style.height = "";
6632 function plainMarkerName(text) {
6633 const match = String(text || "").trim().match(/^dbx:(split|col2|col3a|col3b|header|teaser|footer|pagebreak)$/i);
6634 if (!match) return "";
6635 return match[1].toLowerCase() === "split" ? "col2" : match[1].toLowerCase();
6638 function normalizePlainTextMarkers(container) {
6639 if (!container) return;
6640 normalizeCommentMarkers(container);
6641 const doc = container.ownerDocument || document;
6642 qsa(container, "p,div").forEach(el => {
6643 if (el.querySelector(".dbx-cms-marker,[data-dbx-marker],img,video,iframe,table,hr,ul,ol")) return;
6644 const name = plainMarkerName(el.textContent);
6646 const hr = cmsMarkerElement("dbx:" + name, null, doc);
6647 if (hr) el.replaceWith(hr);
6649 Array.from(container.childNodes || []).forEach(node => {
6650 if (node.nodeType !== 3) return;
6651 const name = plainMarkerName(node.nodeValue);
6653 const hr = cmsMarkerElement("dbx:" + name, null, doc);
6654 if (hr) node.replaceWith(hr);
6658 function normalizeInlineMediaLayout(container) {
6659 if (!container) return;
6660 qsa(container, ".dbx-cms-inline-media img, .dbx-cms-inline-media video, .dbx-cms-inline-media iframe").forEach(media => {
6661 const wrapper = closestElement(media, ".dbx-cms-inline-media");
6662 if (!wrapper) return;
6663 const floatValue = String(media.style.float || "").toLowerCase();
6664 if (floatValue !== "left" && floatValue !== "right") return;
6666 wrapper.style.float = floatValue;
6667 wrapper.style.marginLeft = media.style.marginLeft || "";
6668 wrapper.style.marginRight = media.style.marginRight || "";
6669 media.style.float = "";
6670 media.style.marginLeft = "";
6671 media.style.marginRight = "";
6672 media.style.display = "";
6676 function topEditorChild(surface, range) {
6677 if (!surface || !range) return null;
6678 if (range.startContainer === surface) {
6679 const child = surface.childNodes[Math.min(range.startOffset, Math.max(surface.childNodes.length - 1, 0))];
6680 return child && child.nodeType === 1 ? child : null;
6682 let el = nodeElement(range.startContainer);
6683 if (!el || el === surface || !surface.contains(el)) return null;
6684 while (el.parentElement && el.parentElement !== surface) el = el.parentElement;
6685 return el.parentElement === surface ? el : null;
6688 function canSplitForMarker(block) {
6689 if (!block || block.nodeType !== 1) return false;
6690 return /^(P|DIV|H1|H2|H3|H4|H5|H6|BLOCKQUOTE|UL|OL|LI)$/i.test(block.tagName || "")
6691 && !/^(TABLE|FIGURE)$/i.test(block.tagName || "");
6694 function insertFragmentAfter(parent, fragment, afterNode) {
6695 let last = afterNode;
6696 Array.from(fragment.childNodes || []).forEach(node => {
6697 parent.insertBefore(node, last ? last.nextSibling : null);
6703 function insertEditorHrNode(root, hrNode) {
6704 const instance = getEditorInstance(root);
6705 if (instance && typeof instance.setMode === "function") {
6706 instance.setMode(window.Jodit && window.Jodit.MODE_WYSIWYG ? window.Jodit.MODE_WYSIWYG : 1);
6709 restoreEditorSelection(root);
6711 const surface = editorSurface(root);
6712 if (!surface) return false;
6713 const doc = surface.ownerDocument || document;
6714 const sel = doc.getSelection ? doc.getSelection() : null;
6715 let range = sel && sel.rangeCount ? sel.getRangeAt(0).cloneRange() : state(root).editorRange;
6716 if (!hrNode) return false;
6718 if (!rangeInsideSurface(surface, range)) {
6719 surface.appendChild(hrNode);
6720 setEditorCaretAfterNode(root, hrNode);
6721 normalizeEditorMarkers(root);
6722 bindEditorMarkerEventsRetry(root);
6723 syncEditorDom(root);
6727 if (!range.collapsed) {
6728 range.deleteContents();
6730 sel.removeAllRanges();
6731 sel.addRange(range);
6735 const block = topEditorChild(surface, range);
6736 if (canSplitForMarker(block) && block.contains(nodeElement(range.startContainer))) {
6737 const parent = block.parentElement;
6738 const suffixRange = range.cloneRange();
6739 suffixRange.setEndAfter(block);
6740 const suffix = suffixRange.extractContents();
6741 const blockHasContent = nodeHasEditorContent(block);
6743 if (blockHasContent) {
6744 parent.insertBefore(hrNode, block.nextSibling);
6746 parent.insertBefore(hrNode, block);
6750 if (nodeHasEditorContent(suffix)) {
6751 insertFragmentAfter(parent, suffix, hrNode);
6753 } else if (range.startContainer === surface) {
6754 surface.insertBefore(hrNode, surface.childNodes[range.startOffset] || null);
6755 } else if (block && block.parentElement === surface) {
6756 surface.insertBefore(hrNode, block.nextSibling);
6758 range.insertNode(hrNode);
6761 normalizeEditorMarkers(root);
6762 bindEditorMarkerEventsRetry(root);
6763 syncEditorDom(root);
6764 setEditorCaretAfterNode(root, hrNode);
6768 function insertEditorPlainHr(root) {
6769 const surface = editorSurface(root);
6770 const doc = surface ? (surface.ownerDocument || document) : document;
6771 return insertEditorHrNode(root, doc.createElement("hr"));
6774 function insertEditorMarkerElement(root, marker, label) {
6775 const surface = editorSurface(root);
6776 const doc = surface ? (surface.ownerDocument || document) : document;
6777 return insertEditorHrNode(root, cmsMarkerElement(marker, label, doc));
6780 function saveEditorSelection(root) {
6781 const surface = editorSurface(root);
6782 if (!surface) return false;
6783 if (state(root).selectedMarker || state(root).selectedMissingMedia) {
6784 hideEditorCaretHint(root);
6787 const doc = surface.ownerDocument || document;
6788 const sel = doc.getSelection ? doc.getSelection() : null;
6789 if (!sel || !sel.rangeCount) {
6790 hideEditorCaretHint(root);
6793 const range = sel.getRangeAt(0);
6794 const common = range.commonAncestorContainer;
6795 const commonEl = nodeElement(common);
6796 if (common !== surface && (!commonEl || !surface.contains(commonEl))) {
6797 hideEditorCaretHint(root);
6800 state(root).editorRange = range.cloneRange();
6801 refreshEditorCaretHint(root);
6805 function refreshEditorCaretHint(root) {
6806 // Der native Browser-/Jodit-Caret ist positionsgenau. Der zusaetzlich
6807 // berechnete Overlay-Caret driftete nach Scroll- und Layoutaenderungen
6808 // und erzwang bei jeder Auswahl eine synchrone Layoutmessung.
6809 hideEditorCaretHint(root);
6812 function hideEditorCaretHint(root) {
6813 const s = state(root);
6814 if (s.editorCaretHintTimer) {
6815 window.clearTimeout(s.editorCaretHintTimer);
6816 s.editorCaretHintTimer = null;
6818 const hint = qs(root, "[data-cms-editor-caret-hint]");
6819 if (hint) hint.remove();
6822 function restoreEditorSelection(root) {
6823 const surface = editorSurface(root);
6824 if (!surface) return false;
6825 const doc = surface.ownerDocument || document;
6826 const range = state(root).editorRange;
6827 if (!range || !range.startContainer || !range.endContainer) {
6828 if (surface.focus) surface.focus();
6832 const start = nodeElement(range.startContainer);
6833 const end = nodeElement(range.endContainer);
6834 if (!start || !end || !surface.contains(start) || !surface.contains(end)) {
6835 state(root).editorRange = null;
6836 if (surface.focus) surface.focus();
6840 const sel = doc.getSelection ? doc.getSelection() : null;
6841 if (!sel) return false;
6842 if (surface.focus) surface.focus({ preventScroll: true });
6843 sel.removeAllRanges();
6844 sel.addRange(range);
6848 function pushEditorHtml(root) {
6849 const surface = editorSurface(root);
6850 const html = surface ? (surface.innerHTML || "") : getEditorHtml(root);
6851 const instance = getEditorInstance(root);
6852 if (instance && instance.value !== html) instance.value = html;
6853 setField(root, "content", html);
6855 scheduleEditorHeight(root);
6858 function hoistEditorMarkersToSurface(surface) {
6859 if (!surface) return;
6860 qsa(surface, ".dbx-cms-marker,[data-dbx-marker]").forEach(marker => {
6861 if (marker.parentElement === surface) return;
6862 let block = marker.parentElement;
6863 while (block && block.parentElement && block.parentElement !== surface) {
6864 block = block.parentElement;
6866 if (block && block.parentElement === surface) {
6867 surface.insertBefore(marker, block.nextSibling);
6869 surface.appendChild(marker);
6874 function surfaceEditorBlocks(surface, ignoreMarker) {
6875 if (!surface) return [];
6876 return Array.from(surface.children).filter(el => {
6877 return el.nodeType === 1 && el !== ignoreMarker;
6881 function markerSurfacePlacement(surface, x, y, ignoreMarker) {
6882 if (!surface) return null;
6883 const blocks = surfaceEditorBlocks(surface, ignoreMarker);
6884 if (!blocks.length) {
6885 return { ref: null, before: true, target: null };
6888 for (let i = 0; i < blocks.length; i++) {
6889 const block = blocks[i];
6890 const rect = block.getBoundingClientRect ? block.getBoundingClientRect() : null;
6891 if (!rect || !rect.height) continue;
6892 if (y < rect.top + rect.height / 2) {
6893 return { ref: block, before: true, target: block };
6897 const last = blocks[blocks.length - 1];
6898 return { ref: last, before: false, target: last };
6901 function syncEditorDom(root, options) {
6902 options = options || {};
6903 const surface = editorSurface(root);
6904 normalizeEditorMarkers(root);
6905 repairInlineVideoPlayers(root, surface);
6906 normalizeInlineMediaLayout(surface);
6907 if (surface) cleanEditorRuntimeNodes(surface);
6908 const html = surface ? surface.innerHTML : getEditorHtml(root);
6909 const instance = getEditorInstance(root);
6910 if (instance) instance.value = html || "";
6911 setField(root, "content", html || "");
6912 if (!options.silent) markDirty(root);
6913 scheduleEditorHeight(root);
6914 scheduleEditorMediaRender(root, html);
6917 function saveCurrentCms(root, cfg) {
6918 if (root.classList.contains("is-folder-editing")) return saveFolder(root, cfg || {});
6919 syncEditorDom(root, { silent: true });
6920 return savePage(root, cfg || {});
6923 function duplicateCurrentPage(root, cfg) {
6924 const s = state(root);
6925 const id = Number(getField(root, "id") || 0);
6926 const url = cfgUrl(cfg, "duplicatepage");
6927 if (root.classList.contains("is-folder-editing") || s.selectedType !== "page" || !id || !url) {
6928 status(root, cmsText(root, "page_select_first", "Bitte zuerst eine Seite auswählen."), "error");
6929 return Promise.resolve();
6932 status(root, "Bitte ungespeicherte Aenderungen vor dem Duplizieren speichern.", "warning");
6933 return Promise.resolve();
6935 if (s.saving || s.duplicating) return Promise.resolve();
6937 s.duplicating = true;
6938 updateHeaderActionTooltips(root);
6939 status(root, cmsText(root, "page_duplicating", "Seite wird dupliziert..."), "info");
6941 return fetchJson(apiUrl(url, cmsLngParams(root)), {
6943 headers: { "Content-Type": "application/json" },
6944 body: JSON.stringify({ id })
6946 if (!data || !data.ok || !data.id) {
6947 throw new Error(data && data.msg ? data.msg : "duplicate failed");
6949 return loadTree(root, cfg)
6950 .then(() => loadPage(root, cfg, data.id))
6952 status(root, data.msg || cmsText(root, "page_duplicated", "Seite wurde dupliziert."), "success");
6953 maybeOpenLngProvisionAfterCreate(root, cfg, data);
6957 dbx.error("[cms] duplicate page failed", err);
6958 status(root, err && err.message ? err.message : cmsText(root, "page_duplicate_error", "Seite konnte nicht dupliziert werden."), "error");
6960 s.duplicating = false;
6961 updateHeaderActionTooltips(root);
6965 function deleteCurrentCms(root, cfg) {
6966 if (root.classList.contains("is-folder-editing")) return deleteFolder(root, cfg || {});
6967 return deletePage(root, cfg || {});
6970 function bindCmsKeyboardShortcuts(root, cfg) {
6971 if (!root || root.__dbxCmsKeyboardShortcutsBound) return;
6972 root.__dbxCmsKeyboardShortcutsBound = true;
6973 root.addEventListener("keydown", e => {
6974 const key = String(e.key || "").toLowerCase();
6975 if (!(e.ctrlKey || e.metaKey) || key !== "s") return;
6976 if (!root.contains(e.target)) return;
6978 e.stopPropagation();
6979 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
6980 saveCurrentCms(root, cfg || {});
6984 function cmsConfig(root) {
6985 if (!root || !root.getAttribute || !dbx.parseData) return {};
6986 const cfgList = dbx.parseData(root.getAttribute("data-dbx"));
6987 return cfgList.find(item => item.lib === LIB) || {};
6990 function editorDropTarget(root, target) {
6991 const surface = editorSurface(root);
6992 if (!surface) return null;
6993 let el = target && target.nodeType === 1 ? target : target?.parentElement;
6994 if (!el || !surface.contains(el)) return surface;
6995 const selector = ".dbx-cms-marker,p,h1,h2,h3,h4,ul,ol,blockquote,figure,table,img,hr";
6996 const block = el.closest ? el.closest(selector) : null;
6997 return block && surface.contains(block) ? block : surface;
7000 function clearEditorDropMarks(root) {
7001 const surface = editorSurface(root);
7002 if (!surface) return;
7003 qsa(surface, ".is-drop-before,.is-drop-after").forEach(el => el.classList.remove("is-drop-before", "is-drop-after"));
7006 function ensureLeadingEditorParagraph(surface) {
7007 if (!surface) return null;
7008 const first = surface.firstElementChild;
7009 if (!first || !first.classList.contains("dbx-cms-marker")) return null;
7010 const doc = surface.ownerDocument || document;
7011 const p = doc.createElement("p");
7012 p.innerHTML = "<br>";
7013 surface.insertBefore(p, first);
7017 function setEditorCaretInElement(root, el, offset) {
7018 const surface = editorSurface(root);
7019 if (!surface || !el) return false;
7020 const doc = surface.ownerDocument || document;
7021 const sel = doc.getSelection ? doc.getSelection() : null;
7022 if (!sel) return false;
7023 const range = doc.createRange();
7024 offset = Number(offset || 0);
7025 if (el.firstChild) {
7026 range.setStart(el.firstChild, Math.min(offset, el.firstChild.length || 0));
7028 range.setStart(el, 0);
7030 range.collapse(true);
7031 if (surface.focus) surface.focus({ preventScroll: true });
7032 sel.removeAllRanges();
7033 sel.addRange(range);
7034 state(root).editorRange = range.cloneRange();
7035 refreshEditorCaretHint(root);
7039 function focusEditorAboveMarker(root, marker) {
7040 const surface = editorSurface(root);
7041 if (!surface || !marker) return false;
7042 let lead = marker.previousElementSibling;
7043 if (!lead || !/^(P|DIV|H[1-6]|BLOCKQUOTE|UL|OL)$/i.test(lead.tagName)) {
7044 if (marker === surface.firstElementChild) {
7045 lead = ensureLeadingEditorParagraph(surface);
7048 const doc = surface.ownerDocument || document;
7049 lead = doc.createElement("p");
7050 lead.innerHTML = "<br>";
7051 surface.insertBefore(lead, marker);
7054 if (!lead) return false;
7055 selectEditorMarker(root, null);
7056 return setEditorCaretInElement(root, lead, 0);
7059 function createMarkerDragGhost(marker, rect) {
7060 const doc = marker.ownerDocument || document;
7061 const ghost = doc.createElement("div");
7062 ghost.className = marker.className;
7063 ghost.classList.remove("is-selected", "is-hovered", "is-dragging", "is-dragging-source");
7064 ghost.classList.add("dbx-cms-marker-drag-ghost");
7065 ghost.setAttribute("data-label", marker.getAttribute("data-label") || "");
7066 ghost.setAttribute("aria-hidden", "true");
7067 ghost.style.position = "fixed";
7068 ghost.style.zIndex = "2147483647";
7069 ghost.style.pointerEvents = "none";
7070 ghost.style.width = Math.max(rect.width, 120) + "px";
7071 ghost.style.height = Math.max(rect.height, 34) + "px";
7072 ghost.style.left = rect.left + "px";
7073 ghost.style.top = rect.top + "px";
7074 (doc.body || document.body).appendChild(ghost);
7078 function updateMarkerDragGhost(drag, clientX, clientY) {
7079 if (!drag || !drag.ghost) return;
7080 drag.ghost.style.left = (clientX - drag.ghostOffsetX) + "px";
7081 drag.ghost.style.top = (clientY - drag.ghostOffsetY) + "px";
7084 function removeMarkerDragGhost(ghost) {
7085 if (ghost && ghost.parentNode) ghost.parentNode.removeChild(ghost);
7088 function clearMarkerPointerDrag(root) {
7089 const drag = state(root).pointerDragMarker;
7091 removeMarkerDragGhost(drag.ghost);
7092 if (drag.marker) drag.marker.classList.remove("is-dragging", "is-dragging-source");
7093 clearEditorDropMarks(root);
7094 state(root).pointerDragMarker = null;
7097 function normalizeEditorMarkers(root) {
7098 const surface = editorSurface(root);
7099 if (!surface) return;
7100 const selectedName = markerNameFromElement(state(root).selectedMarker);
7101 normalizeCommentMarkers(surface);
7102 normalizePlainTextMarkers(surface);
7103 qsa(surface, ".dbx-cms-marker,[data-dbx-marker]").forEach(marker => {
7104 const raw = marker.getAttribute("data-dbx-marker") || marker.getAttribute("data-dbx-marker-comment") || "";
7105 const name = cmsMarkerName(raw);
7106 if (marker.tagName !== "HR") {
7107 const hr = document.createElement("hr");
7108 Array.from(marker.attributes || []).forEach(attr => {
7109 if (attr.name === "data-dbx-marker-comment") return;
7110 hr.setAttribute(attr.name, attr.value);
7112 marker.replaceWith(hr);
7115 Array.from(marker.classList).forEach(className => {
7116 if (className.indexOf("dbx-cms-marker-") === 0) marker.classList.remove(className);
7118 marker.classList.add("dbx-cms-marker", "dbx-cms-marker-" + cmsMarkerClassName(name));
7119 marker.setAttribute("data-dbx-marker", "dbx:" + name);
7120 if (name === "hero" || !marker.getAttribute("data-label")) marker.setAttribute("data-label", cmsMarkerLabel("dbx:" + name));
7121 marker.setAttribute("contenteditable", "false");
7122 marker.setAttribute("draggable", "false");
7123 marker.setAttribute("tabindex", "0");
7124 marker.setAttribute("role", "button");
7125 marker.setAttribute("title", "Marker auswählen, ziehen zum Verschieben, Entf zum Löschen");
7127 hoistEditorMarkersToSurface(surface);
7128 dedupeSingletonMarkers(surface);
7129 dedupeAdjacentMarkers(surface);
7130 ensureLeadingEditorParagraph(surface);
7132 const restored = qsa(surface, ".dbx-cms-marker").find(item => markerNameFromElement(item) === selectedName);
7133 if (restored) selectEditorMarker(root, restored);
7137 function clearEditorMarkerHover(surface, except) {
7138 if (!surface) return;
7139 qsa(surface, ".dbx-cms-marker.is-hovered").forEach(el => {
7140 if (el !== except) el.classList.remove("is-hovered");
7144 function selectEditorMarker(root, marker) {
7145 const surface = editorSurface(root);
7146 selectEditorMissingMedia(root, null);
7147 qsa(surface, ".dbx-cms-marker.is-selected").forEach(el => {
7148 if (el !== marker) {
7149 el.classList.remove("is-selected");
7150 el.removeAttribute("aria-selected");
7153 const s = state(root);
7154 if (marker && surface && surface.contains(marker)) {
7155 marker.classList.add("is-selected");
7156 marker.setAttribute("aria-selected", "true");
7157 s.selectedMarker = marker;
7158 clearEditorMarkerHover(surface, marker);
7159 hideEditorCaretHint(root);
7160 const doc = surface.ownerDocument || document;
7161 const sel = doc.getSelection ? doc.getSelection() : null;
7162 if (sel) sel.removeAllRanges();
7163 if (marker.focus) marker.focus({ preventScroll: true });
7165 s.selectedMarker = null;
7166 qsa(surface, ".dbx-cms-marker[aria-selected]").forEach(el => el.removeAttribute("aria-selected"));
7170 function handleEditorMarkerClick(root, marker, e) {
7171 if (!marker) return false;
7172 selectEditorMarker(root, marker);
7174 if (typeof e.preventDefault === "function") e.preventDefault();
7175 if (typeof e.stopPropagation === "function") e.stopPropagation();
7176 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
7181 function emptyEditorParagraph(el) {
7182 if (!el || el.nodeType !== 1 || el.tagName !== "P") return false;
7183 if (el.querySelector("img,video,table,hr,.dbx-cms-marker")) return false;
7184 return String(el.textContent || "").trim() === "" && !el.querySelector("br");
7187 function removeEditorMarker(root, marker) {
7188 const surface = editorSurface(root);
7189 if (!marker || !surface || !surface.contains(marker)) return false;
7190 const next = marker.nextElementSibling;
7192 if (emptyEditorParagraph(next)) next.remove();
7193 state(root).selectedMarker = null;
7194 clearEditorDropMarks(root);
7195 syncEditorDom(root);
7199 function rangeIntersectsNode(range, node) {
7200 if (!range || !node) return false;
7201 if (range.intersectsNode) {
7203 return range.intersectsNode(node);
7211 function editorRangeFromPoint(surface, x, y) {
7212 const doc = surface ? (surface.ownerDocument || document) : document;
7213 if (!surface || !doc) return null;
7215 if (doc.caretRangeFromPoint) {
7216 range = doc.caretRangeFromPoint(x, y);
7217 } else if (doc.caretPositionFromPoint) {
7218 const pos = doc.caretPositionFromPoint(x, y);
7220 range = doc.createRange();
7221 range.setStart(pos.offsetNode, pos.offset);
7222 range.collapse(true);
7225 if (!range) return null;
7226 const start = nodeElement(range.startContainer);
7227 return start && surface.contains(start) ? range : null;
7230 function setEditorCaretFromPoint(root, x, y) {
7231 const surface = editorSurface(root);
7232 const doc = surface ? (surface.ownerDocument || document) : document;
7233 const range = editorRangeFromPoint(surface, x, y);
7234 const sel = doc && doc.getSelection ? doc.getSelection() : null;
7235 if (!range || !sel) return false;
7236 if (surface.focus) surface.focus({ preventScroll: true });
7237 sel.removeAllRanges();
7238 sel.addRange(range);
7239 state(root).editorRange = range.cloneRange();
7243 function showEditorMarkerDropHint(root, x, y, ignoreMarker) {
7244 const surface = editorSurface(root);
7245 clearEditorDropMarks(root);
7246 if (!surface) return;
7247 const placement = markerSurfacePlacement(surface, x, y, ignoreMarker);
7248 const target = placement && placement.target ? placement.target : null;
7249 if (!target) return;
7250 target.classList.toggle("is-drop-before", !!placement.before);
7251 target.classList.toggle("is-drop-after", !placement.before);
7254 function finishEditorMarkerDrop(root, marker) {
7255 if (!marker) return;
7256 marker.classList.remove("is-dragging", "is-dragging-source");
7257 marker.removeAttribute("data-cms-drag-token");
7258 const drag = state(root).pointerDragMarker;
7259 if (drag && drag.ghost) removeMarkerDragGhost(drag.ghost);
7260 clearEditorDropMarks(root);
7261 state(root).dragMarker = null;
7262 state(root).pointerDragMarker = null;
7263 const surface = editorSurface(root);
7264 hoistEditorMarkersToSurface(surface);
7265 normalizeEditorMarkers(root);
7266 selectEditorMarker(root, marker);
7267 pushEditorHtml(root);
7270 function moveEditorMarkerToPoint(root, marker, x, y) {
7271 const surface = editorSurface(root);
7272 if (!marker || !surface || !surface.contains(marker)) return false;
7273 const placement = markerSurfacePlacement(surface, x, y, marker);
7274 if (!placement) return false;
7276 if (marker.parentElement) marker.parentElement.removeChild(marker);
7278 if (placement.ref && placement.before) {
7279 surface.insertBefore(marker, placement.ref);
7280 } else if (placement.ref) {
7281 surface.insertBefore(marker, placement.ref.nextSibling);
7283 surface.appendChild(marker);
7286 finishEditorMarkerDrop(root, marker);
7290 function editorSelectionRange(root) {
7291 const surface = editorSurface(root);
7292 if (!surface) return null;
7293 const doc = surface.ownerDocument || document;
7294 const sel = doc.getSelection ? doc.getSelection() : null;
7295 if (!sel || !sel.rangeCount) return null;
7296 const range = sel.getRangeAt(0);
7297 return rangeInsideSurface(surface, range) ? range : null;
7300 function editorSelectionText(root) {
7301 const range = editorSelectionRange(root);
7302 return range && !range.collapsed ? String(range.cloneContents().textContent || "") : "";
7305 function selectEditorContents(root) {
7306 const surface = editorSurface(root);
7307 if (!surface) return false;
7308 const doc = surface.ownerDocument || document;
7309 const sel = doc.getSelection ? doc.getSelection() : null;
7310 if (!sel) return false;
7311 const range = doc.createRange();
7312 range.selectNodeContents(surface);
7313 if (surface.focus) surface.focus({ preventScroll: true });
7314 sel.removeAllRanges();
7315 sel.addRange(range);
7316 state(root).editorRange = range.cloneRange();
7320 function editorContextBlock(root, target) {
7321 const surface = editorSurface(root);
7322 if (!surface) return null;
7323 const el = closestBootstrapComponent(root, target) || closestElement(target, ".dbx-cms-marker,figure,table,img,video,hr,p,h1,h2,h3,h4,ul,ol,blockquote");
7324 return el && surface.contains(el) ? el : null;
7327 function closestBootstrapComponent(root, target) {
7328 const surface = editorSurface(root);
7329 if (!surface || !target) return null;
7330 const row = closestElement(target, ".row");
7331 if (row && surface.contains(row) && (bootstrapRowColumns(row).length || qs(row, ".card"))) {
7334 const tabsPart = closestElement(target, ".nav-tabs,.tab-content");
7335 if (tabsPart && surface.contains(tabsPart)) {
7336 const tabsWrap = tabsPart.parentElement;
7337 const tabsChildren = tabsWrap ? Array.from(tabsWrap.children || []) : [];
7338 if (tabsWrap && surface.contains(tabsWrap)
7339 && tabsChildren.some(child => child.classList?.contains("nav-tabs"))
7340 && tabsChildren.some(child => child.classList?.contains("tab-content"))) {
7345 const el = closestElement(target, ".alert,.card,.list-group,.accordion,.table-responsive");
7346 if (!el || !surface.contains(el)) return null;
7351 * CMS-Spaltenboxen verwenden Bootstrap-rows mit direkten col-Kindern.
7352 * Die Layoutaktionen ändern ausschließlich die Spaltenklassen; Inhalte,
7353 * Medien und Module bleiben erhalten. Beim Auflösen werden die Inhalte
7354 * in ihrer bisherigen Reihenfolge aus den Spalten herausgehoben.
7356 function bootstrapRowColumns(row) {
7357 if (!row || !row.classList || !row.classList.contains("row")) return [];
7358 return Array.from(row.children || []).filter(column => {
7359 return column.classList && Array.from(column.classList).some(name => /^col(?:$|-)/.test(name));
7363 function bootstrapColumnRow(root, target) {
7364 const surface = editorSurface(root);
7365 const row = closestElement(target, ".row");
7366 return row && surface && surface.contains(row) && bootstrapRowColumns(row).length ? row : null;
7369 function clearBootstrapColumnClasses(column) {
7370 if (!column || !column.classList) return;
7371 Array.from(column.classList).forEach(name => {
7372 if (/^col(?:$|-)/.test(name)) column.classList.remove(name);
7376 function finishBootstrapColumnAction(root, focusNode) {
7377 normalizeBootstrapComponents(editorSurface(root));
7378 syncEditorDom(root);
7380 scheduleEditorHeight(root);
7381 if (focusNode && focusNode.parentNode) selectEditorNode(root, focusNode);
7385 function setBootstrapColumnLayout(root, row, mode) {
7386 const columns = bootstrapRowColumns(row);
7387 if (!columns.length) return false;
7388 columns.forEach(column => {
7389 clearBootstrapColumnClasses(column);
7390 column.classList.add("col-12");
7391 if (mode === "responsive") column.classList.add("col-md");
7393 return finishBootstrapColumnAction(root, row);
7396 function addBootstrapColumn(root, row) {
7397 const columns = bootstrapRowColumns(row);
7398 if (!row || !columns.length) return false;
7399 const doc = row.ownerDocument || document;
7400 const column = doc.createElement("div");
7401 const responsive = columns.some(existing => {
7402 return Array.from(existing.classList || []).some(name => /^col-(?:sm|md|lg|xl|xxl)(?:-|$)/.test(name));
7404 column.className = responsive ? "col-12 col-md" : "col-12";
7405 const paragraph = doc.createElement("p");
7406 paragraph.textContent = cmsText(root, "editor_columns_new", "Neue Spalte");
7407 column.appendChild(paragraph);
7408 row.appendChild(column);
7409 return finishBootstrapColumnAction(root, column);
7412 function dissolveBootstrapColumns(root, row) {
7413 const columns = bootstrapRowColumns(row);
7414 if (!row || !row.parentNode || !columns.length) return false;
7415 const doc = row.ownerDocument || document;
7416 const fragment = doc.createDocumentFragment();
7417 let firstMoved = null;
7418 Array.from(row.childNodes || []).forEach(node => {
7419 if (node.nodeType === 1 && columns.includes(node)) {
7420 while (node.firstChild) {
7421 const child = node.firstChild;
7422 if (!firstMoved && child.nodeType === 1) firstMoved = child;
7423 fragment.appendChild(child);
7427 if (!firstMoved && node.nodeType === 1) firstMoved = node;
7428 fragment.appendChild(node);
7430 row.parentNode.insertBefore(fragment, row);
7432 return finishBootstrapColumnAction(root, firstMoved);
7435 function contextMissingMediaTarget(root, target) {
7436 const surface = editorSurface(root);
7437 const wrap = closestElement(target, ".dbx-cms-inline-media-missing-wrap");
7438 return wrap && surface && surface.contains(wrap) ? wrap : null;
7441 function selectEditorMissingMedia(root, wrap) {
7442 const surface = editorSurface(root);
7443 qsa(surface, ".dbx-cms-inline-media-missing-wrap.is-selected").forEach(el => {
7444 el.classList.remove("is-selected");
7445 el.removeAttribute("aria-selected");
7447 if (wrap && surface && surface.contains(wrap)) {
7448 selectEditorMarker(root, null);
7449 wrap.classList.add("is-selected");
7450 wrap.setAttribute("aria-selected", "true");
7451 state(root).selectedMissingMedia = wrap;
7452 hideEditorCaretHint(root);
7453 const doc = surface.ownerDocument || document;
7454 const sel = doc.getSelection ? doc.getSelection() : null;
7455 if (sel) sel.removeAllRanges();
7456 if (wrap.focus) wrap.focus({ preventScroll: true });
7458 state(root).selectedMissingMedia = null;
7459 qsa(surface, ".dbx-cms-inline-media-missing-wrap[aria-selected]").forEach(el => el.removeAttribute("aria-selected"));
7463 function removeEditorMissingMedia(root, wrap) {
7464 wrap = wrap || state(root).selectedMissingMedia;
7465 if (!wrap || !wrap.parentNode) return false;
7466 selectEditorMissingMedia(root, null);
7468 syncEditorDom(root);
7473 function handleEditorMissingMediaClick(root, wrap, e) {
7474 if (!wrap) return false;
7475 selectEditorMissingMedia(root, wrap);
7477 if (typeof e.preventDefault === "function") e.preventDefault();
7478 if (typeof e.stopPropagation === "function") e.stopPropagation();
7479 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
7484 function removableEditorContextTarget(root, target) {
7485 const surface = editorSurface(root);
7486 if (!surface) return null;
7487 const component = closestBootstrapComponent(root, target);
7488 if (component && surface.contains(component)) return component;
7489 const mod = inlineModTarget(root, target);
7490 if (mod && surface.contains(mod)) return mod;
7491 const missing = closestElement(target, ".dbx-cms-inline-media-missing-wrap");
7492 if (missing && surface.contains(missing)) return missing;
7493 const inlineMedia = closestElement(target, ".dbx-cms-inline-media");
7494 if (inlineMedia && surface.contains(inlineMedia)) return inlineMedia;
7495 const el = editorContextBlock(root, target);
7496 if (!el || !surface.contains(el)) return null;
7497 if ((el.tagName === "IMG" || el.tagName === "VIDEO") && el.parentElement && el.parentElement.tagName === "FIGURE") {
7498 return el.parentElement;
7503 function editorElementSibling(el, dir) {
7504 let node = dir < 0 ? el?.previousSibling : el?.nextSibling;
7505 while (node && node.nodeType !== 1) node = dir < 0 ? node.previousSibling : node.nextSibling;
7506 return node || null;
7509 function moveEditorContextBlock(root, block, dir) {
7510 const surface = editorSurface(root);
7511 if (!block || !surface || !surface.contains(block) || block === surface) return false;
7512 const sibling = editorElementSibling(block, dir);
7513 if (!sibling || !block.parentElement) return false;
7514 if (dir < 0) block.parentElement.insertBefore(block, sibling);
7515 else block.parentElement.insertBefore(sibling, block);
7516 if (block.matches && block.matches(".dbx-cms-marker")) selectEditorMarker(root, block);
7517 syncEditorDom(root);
7521 function closeEditorContextMenu(root) {
7522 const s = state(root);
7523 if (typeof s.editorContextCleanup === "function") s.editorContextCleanup();
7524 if (s.editorContextMenu && s.editorContextMenu.parentNode) s.editorContextMenu.parentNode.removeChild(s.editorContextMenu);
7525 s.editorContextCleanup = null;
7526 s.editorContextMenu = null;
7529 function clipboardWriteText(text) {
7530 if (navigator.clipboard && navigator.clipboard.writeText) {
7531 return navigator.clipboard.writeText(text).catch(() => false);
7533 return Promise.resolve(false);
7536 function clipboardReadText() {
7537 if (navigator.clipboard && navigator.clipboard.readText) {
7538 return navigator.clipboard.readText().catch(() => "");
7540 return Promise.resolve("");
7543 function copyEditorContext(root, target) {
7544 const marker = closestElement(target, ".dbx-cms-marker");
7545 if (marker && editorSurface(root)?.contains(marker)) {
7546 state(root).editorClipboardHtml = marker.outerHTML;
7547 return clipboardWriteText(marker.outerHTML);
7549 restoreEditorSelection(root);
7550 const text = editorSelectionText(root);
7551 const copied = document.execCommand && document.execCommand("copy");
7552 if (!copied && text) return clipboardWriteText(text);
7553 return Promise.resolve(copied);
7556 function cutEditorContext(root, target) {
7557 const marker = closestElement(target, ".dbx-cms-marker");
7558 if (marker && editorSurface(root)?.contains(marker)) {
7559 state(root).editorClipboardHtml = marker.outerHTML;
7560 return clipboardWriteText(marker.outerHTML).finally(() => removeEditorMarker(root, marker));
7562 restoreEditorSelection(root);
7563 const text = editorSelectionText(root);
7564 const cut = document.execCommand && document.execCommand("cut");
7566 syncEditorDom(root);
7567 return Promise.resolve(true);
7570 return clipboardWriteText(text).finally(() => {
7571 execEditorCommand(root, "delete");
7572 syncEditorDom(root);
7575 return Promise.resolve(false);
7578 function pasteEditorContext(root) {
7579 restoreEditorSelection(root);
7580 return clipboardReadText().then(text => {
7582 if (document.execCommand && document.execCommand("insertText", false, text)) {
7583 syncEditorDom(root);
7586 insertEditorHtml(root, escapeHtml(text));
7589 const html = state(root).editorClipboardHtml || "";
7591 insertEditorHtml(root, html);
7594 if (document.execCommand && document.execCommand("paste")) {
7595 syncEditorDom(root);
7602 function deleteEditorContext(root, target) {
7603 const marker = closestElement(target, ".dbx-cms-marker") || state(root).selectedMarker;
7604 if (marker && editorSurface(root)?.contains(marker)) return removeEditorMarker(root, marker);
7605 const missing = contextMissingMediaTarget(root, target) || state(root).selectedMissingMedia;
7606 if (missing && editorSurface(root)?.contains(missing)) return removeEditorMissingMedia(root, missing);
7607 const mod = inlineModTarget(root, target) || state(root).selectedModPlaceholder;
7608 if (mod && editorSurface(root)?.contains(mod)) return removeEditorModPlaceholder(root, mod);
7609 const removable = removableEditorContextTarget(root, target);
7612 syncEditorDom(root);
7615 restoreEditorSelection(root);
7616 execEditorCommand(root, "delete");
7617 syncEditorDom(root);
7621 function selectEditorNode(root, node) {
7622 const surface = editorSurface(root);
7623 if (!surface || !node || !surface.contains(node)) return false;
7624 const doc = surface.ownerDocument || document;
7625 const sel = doc.getSelection ? doc.getSelection() : null;
7626 if (!sel) return false;
7627 const range = doc.createRange();
7628 range.selectNode(node);
7629 if (surface.focus) surface.focus({ preventScroll: true });
7630 sel.removeAllRanges();
7631 sel.addRange(range);
7632 state(root).editorRange = range.cloneRange();
7636 function syncEditorAfterContextAction(root) {
7637 const instance = getEditorInstance(root);
7638 if (instance && instance.synchronizeValues) instance.synchronizeValues();
7639 syncEditorDom(root);
7642 function contextImageTarget(root, target) {
7643 if (inlineModTarget(root, target)) return null;
7644 const surface = editorSurface(root);
7645 const img = closestElement(target, "img");
7646 if (img && surface && surface.contains(img)) return img;
7648 // Alte Inhalte können eine absolut positionierte Textebene über einem
7649 // Bild enthalten. Die Textebene darf die Bildaktionen nicht blockieren.
7650 const overlay = closestElement(target, ".position-absolute,[style*='position:absolute'],[style*='position: absolute']");
7651 const host = overlay?.parentElement || null;
7652 if (!host || !surface || !surface.contains(host)) return null;
7653 const isRelative = host.classList?.contains("position-relative")
7654 || /position\s*:\s*relative/i.test(host.getAttribute("style") || "");
7655 if (!isRelative) return null;
7656 return Array.from(host.children || []).find(child => child.tagName === "IMG") || null;
7659 function contextLinkTarget(root, target) {
7660 const surface = editorSurface(root);
7661 const link = closestElement(target, "a");
7662 return link && surface && surface.contains(link) ? link : null;
7665 function contextTableCell(root, target) {
7666 const surface = editorSurface(root);
7667 const cell = closestElement(target, "td,th");
7668 return cell && surface && surface.contains(cell) ? cell : null;
7671 function contextTableTarget(root, target) {
7672 const surface = editorSurface(root);
7673 const table = closestElement(target, "table");
7674 return table && surface && surface.contains(table) ? table : null;
7677 function openEditorImageProperties(root, img) {
7678 const instance = getEditorInstance(root);
7679 if (!instance || !img) return false;
7680 selectEditorNode(root, img);
7681 if (instance.e && instance.e.fire) {
7682 instance.e.fire("openImageProperties", img);
7688 function alignContextImage(root, img, mode) {
7689 if (!img) return false;
7690 const media = closestElement(img, ".dbx-cms-inline-media");
7691 const target = media && editorSurface(root)?.contains(media) ? media : img;
7692 [img, target].forEach(el => {
7694 el.style.float = "";
7695 el.style.marginLeft = "";
7696 el.style.marginRight = "";
7697 el.style.display = "";
7699 if (mode === "left") {
7700 target.style.float = "left";
7701 target.style.marginRight = "1.5rem";
7702 } else if (mode === "right") {
7703 target.style.float = "right";
7704 target.style.marginLeft = "1.5rem";
7705 } else if (mode === "center") {
7706 target.style.display = "block";
7707 target.style.marginLeft = "auto";
7708 target.style.marginRight = "auto";
7710 syncEditorAfterContextAction(root);
7714 function openEditorLinkDialog(root, targetNode) {
7715 const instance = getEditorInstance(root);
7716 if (!instance) return false;
7717 if (targetNode) selectEditorNode(root, targetNode);
7718 if (instance.execCommand) {
7719 instance.execCommand("openLinkDialog");
7725 function removeContextLink(root, link) {
7726 if (!link || !link.parentNode) return false;
7727 while (link.firstChild) link.parentNode.insertBefore(link.firstChild, link);
7729 syncEditorAfterContextAction(root);
7733 function tableApi(root) {
7734 const instance = getEditorInstance(root);
7735 if (!instance || !instance.getInstance) return null;
7737 return instance.getInstance("Table", instance.o);
7743 function runTableContextAction(root, cell, action) {
7744 const table = contextTableTarget(root, cell);
7745 const api = tableApi(root);
7746 if (!table || !cell || !api) return false;
7747 const row = cell.parentElement;
7750 api.appendRow(table, row, false);
7753 api.appendRow(table, row, true);
7756 api.removeRow(table, row.rowIndex);
7758 case "column-before":
7759 api.appendColumn(table, cell, false);
7761 case "column-after":
7762 api.appendColumn(table, cell, true);
7764 case "column-delete":
7765 api.removeColumn(table, cell.cellIndex);
7768 cell.innerHTML = "<br>";
7770 case "table-delete":
7776 syncEditorAfterContextAction(root);
7780 function contextMenuButton(label, icon, action, disabled) {
7781 const btn = document.createElement("button");
7782 btn.type = "button";
7783 btn.className = "dbx-cms-context-menu-item";
7784 btn.disabled = !!disabled;
7786 const iconEl = document.createElement("i");
7787 iconEl.className = "bi " + icon;
7788 iconEl.setAttribute("aria-hidden", "true");
7789 btn.appendChild(iconEl);
7791 const labelEl = document.createElement("span");
7792 labelEl.textContent = label;
7793 btn.appendChild(labelEl);
7794 btn.addEventListener("mousedown", e => e.preventDefault());
7795 btn.addEventListener("click", e => {
7797 e.stopPropagation();
7798 if (btn.disabled) return;
7799 const root = btn.__dbxCmsRoot;
7800 closeEditorContextMenu(root);
7801 Promise.resolve(action()).catch(err => dbx.warn("[cms] context action failed", err));
7806 function showEditorContextMenu(root, e) {
7807 const surface = editorSurface(root);
7808 if (!surface) return;
7809 closeEditorContextMenu(root);
7811 const target = e.target;
7812 const marker = closestElement(target, ".dbx-cms-marker");
7813 const missingMedia = contextMissingMediaTarget(root, target);
7814 const hasSelection = !!editorSelectionText(root);
7815 const img = contextImageTarget(root, target);
7816 const videoMedia = inlineVideoEventTarget(root, e);
7817 const modPlaceholder = inlineModTarget(root, target);
7818 const component = closestBootstrapComponent(root, target);
7819 const columnRow = bootstrapColumnRow(root, target);
7820 const cell = contextTableCell(root, target);
7821 const table = contextTableTarget(root, target);
7823 if (marker && surface.contains(marker)) {
7824 selectEditorMarker(root, marker);
7825 } else if (missingMedia) {
7826 selectEditorMissingMedia(root, missingMedia);
7828 selectEditorNode(root, cell);
7829 } else if (modPlaceholder) {
7830 selectEditorModPlaceholder(root, modPlaceholder);
7831 } else if (component && surface.contains(component)) {
7832 selectEditorNode(root, component);
7833 } else if (videoMedia) {
7834 selectEditorNode(root, videoMedia);
7836 selectEditorNode(root, img);
7837 } else if (!hasSelection) {
7838 setEditorCaretFromPoint(root, e.clientX, e.clientY);
7839 selectEditorMarker(root, null);
7842 const menu = document.createElement("div");
7843 menu.className = "dbx-cms-context-menu";
7844 menu.setAttribute("role", "menu");
7845 menu.setAttribute("aria-label", cmsText(root, "editor_context_menu", "Editor-Kontextmenü"));
7847 const movable = removableEditorContextTarget(root, target);
7848 const hasContextTarget = !!(marker || missingMedia || modPlaceholder || component || videoMedia || img || table || movable);
7850 [cmsText(root, "editor_context_undo", "Rückgängig"), "bi-arrow-counterclockwise", () => execEditorCommand(root, "undo"), false],
7851 [cmsText(root, "editor_context_redo", "Wiederholen"), "bi-arrow-clockwise", () => execEditorCommand(root, "redo"), false],
7852 [cmsText(root, "editor_context_select_all", "Alles markieren"), "bi-check2-square", () => selectEditorContents(root), false],
7853 [cmsText(root, "editor_context_block_up", "Block nach oben"), "bi-arrow-up", () => moveEditorContextBlock(root, movable, -1), !movable],
7854 [cmsText(root, "editor_context_block_down", "Block nach unten"), "bi-arrow-down", () => moveEditorContextBlock(root, movable, 1), !movable],
7855 [cmsText(root, "editor_context_module", "Modul-Platzhalter"), "bi-puzzle", () => openModPlaceholderOptions(root, modPlaceholder, cmsConfig(root) || {}), !modPlaceholder],
7856 [cmsText(root, "editor_context_video", "Video-Optionen"), "bi-camera-video", () => openInlineVideoOptions(root, videoMedia), !videoMedia],
7857 [cmsText(root, "editor_image_edit", "Bild bearbeiten"), "bi-pencil-square", () => openCmsImageEditor(root, img), !img],
7858 [cmsText(root, "editor_image_remove", "Bild aus Inhalt entfernen"), "bi-image-alt", () => removeEditorImage(root, img), !img],
7859 [cmsText(root, "editor_context_copy", "Kopieren"), "bi-clipboard", () => copyEditorContext(root, target), !hasSelection && !hasContextTarget],
7860 [cmsText(root, "editor_context_cut", "Ausschneiden"), "bi-scissors", () => cutEditorContext(root, target), !hasSelection && !hasContextTarget],
7861 [cmsText(root, "editor_context_paste", "Einfügen"), "bi-clipboard-plus", () => pasteEditorContext(root), false],
7862 [cmsText(root, "editor_context_delete", "Löschen"), "bi-trash", () => deleteEditorContext(root, target), !hasSelection && !hasContextTarget]
7869 [cmsText(root, "editor_columns_stacked", "Spalten untereinander"), "bi-layout-three-columns", () => setBootstrapColumnLayout(root, columnRow, "stacked"), false],
7870 [cmsText(root, "editor_columns_responsive", "Spalten nebeneinander"), "bi-layout-split", () => setBootstrapColumnLayout(root, columnRow, "responsive"), false],
7871 [cmsText(root, "editor_column_add", "Spalte hinzufügen"), "bi-plus-square", () => addBootstrapColumn(root, columnRow), false],
7872 [cmsText(root, "editor_columns_dissolve", "Spalten auflösen"), "bi-x-diamond", () => dissolveBootstrapColumns(root, columnRow), false]
7876 items.forEach(item => {
7877 const btn = contextMenuButton(item[0], item[1], item[2], item[3]);
7878 btn.__dbxCmsRoot = root;
7879 menu.appendChild(btn);
7882 document.body.appendChild(menu);
7883 const vw = window.innerWidth || document.documentElement.clientWidth || 0;
7884 const vh = window.innerHeight || document.documentElement.clientHeight || 0;
7885 const rect = menu.getBoundingClientRect();
7886 const left = Math.max(8, Math.min(e.clientX, vw - rect.width - 8));
7887 const top = Math.max(8, Math.min(e.clientY, vh - rect.height - 8));
7888 menu.style.left = left + "px";
7889 menu.style.top = top + "px";
7891 const close = evt => {
7892 if (evt && menu.contains(evt.target)) return;
7893 closeEditorContextMenu(root);
7895 const closeOnKey = evt => {
7896 if (evt.key === "Escape") closeEditorContextMenu(root);
7899 window.setTimeout(() => {
7900 document.addEventListener("mousedown", close, true);
7901 document.addEventListener("keydown", closeOnKey, true);
7902 win.addEventListener("scroll", close, true);
7903 win.addEventListener("resize", close, true);
7905 state(root).editorContextMenu = menu;
7906 state(root).editorContextCleanup = () => {
7907 document.removeEventListener("mousedown", close, true);
7908 document.removeEventListener("keydown", closeOnKey, true);
7909 win.removeEventListener("scroll", close, true);
7910 win.removeEventListener("resize", close, true);
7914 function editorMarkerFromEvent(root, e) {
7915 const surface = editorSurface(root);
7916 const marker = closestElement(e.target, ".dbx-cms-marker");
7917 return marker && surface && surface.contains(marker) ? marker : null;
7920 function editorRootFromMarker(marker) {
7921 if (!marker) return null;
7922 const surface = closestElement(marker, ".jodit-wysiwyg, [data-cms-editor]");
7923 const root = closestElement(marker, ".dbx-cms");
7924 return root && surface && root.contains(surface) ? root : null;
7927 function editorMarkerAtPoint(x, y) {
7928 if (!Number.isFinite(Number(x)) || !Number.isFinite(Number(y))) return null;
7929 const direct = document.elementFromPoint ? document.elementFromPoint(x, y) : null;
7930 const directMarker = closestElement(direct, ".dbx-cms-marker");
7931 if (directMarker) return directMarker;
7932 return qsa(document, ".jodit-wysiwyg .dbx-cms-marker, [data-cms-editor] .dbx-cms-marker").find(marker => {
7933 const rect = marker.getBoundingClientRect ? marker.getBoundingClientRect() : null;
7934 return rect && x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
7938 function editorMarkerFromPointerEvent(e) {
7939 return closestElement(e.target, ".dbx-cms-marker") || editorMarkerAtPoint(e.clientX, e.clientY);
7942 function bindEditorMarkerEvents(root) {
7943 const surface = editorSurface(root);
7944 if (!surface) return;
7946 ["mouseup", "keyup", "input", "touchend"].forEach(type => {
7947 surface.addEventListener(type, () => saveEditorSelection(root), true);
7949 const doc = surface.ownerDocument || document;
7950 if (!root.__dbxCmsSelectionBound && doc && doc.addEventListener) {
7951 root.__dbxCmsSelectionBound = true;
7952 doc.addEventListener("selectionchange", () => saveEditorSelection(root));
7954 if (!root.__dbxCmsMarkerKeyBound) {
7955 root.__dbxCmsMarkerKeyBound = true;
7956 root.addEventListener("keydown", e => {
7957 if (e.key !== "Delete" && e.key !== "Backspace") return;
7958 const marker = state(root).selectedMarker;
7959 const mod = state(root).selectedModPlaceholder;
7960 const currentSurface = editorSurface(root);
7961 if (marker && currentSurface && currentSurface.contains(marker)) {
7963 e.stopPropagation();
7964 removeEditorMarker(root, marker);
7967 if (mod && currentSurface && currentSurface.contains(mod)) {
7969 e.stopPropagation();
7970 removeEditorModPlaceholder(root, mod);
7974 if (!root.__dbxCmsInlineVideoResizeBound) {
7975 root.__dbxCmsInlineVideoResizeBound = true;
7976 const finish = () => finishInlineVideoResizeTrack(root);
7977 document.addEventListener("mouseup", finish, true);
7978 document.addEventListener("touchend", finish, true);
7979 document.addEventListener("pointerup", finish, true);
7981 if (!root.__dbxCmsMarkerContainerBound) {
7982 root.__dbxCmsMarkerContainerBound = true;
7983 root.addEventListener("mousedown", e => {
7984 const marker = editorMarkerFromEvent(root, e) || editorMarkerFromPointerEvent(e);
7985 if (marker && editorRootFromMarker(marker) !== root) return;
7986 if (!marker) return;
7987 selectEditorMarker(root, marker);
7988 e.stopPropagation();
7990 root.addEventListener("click", e => {
7991 const marker = editorMarkerFromEvent(root, e) || editorMarkerFromPointerEvent(e);
7992 if (marker && editorRootFromMarker(marker) !== root) return;
7993 if (!marker) return;
7994 handleEditorMarkerClick(root, marker, e);
7998 if (surface.__dbxCmsMarkerEventsBound) return;
7999 surface.__dbxCmsMarkerEventsBound = true;
8001 surface.addEventListener("mousedown", e => {
8002 const marker = closestElement(e.target, ".dbx-cms-marker");
8003 if (marker && surface.contains(marker)) {
8004 selectEditorMarker(root, marker);
8005 e.stopPropagation();
8008 const missingMedia = contextMissingMediaTarget(root, e.target);
8010 handleEditorMissingMediaClick(root, missingMedia, e);
8013 const modPlaceholder = inlineModTarget(root, e.target);
8014 if (modPlaceholder) {
8015 selectEditorModPlaceholder(root, modPlaceholder);
8017 e.stopPropagation();
8018 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
8021 const videoBlock = inlineVideoEventTarget(root, e);
8023 beginInlineVideoResizeTrack(root, videoBlock);
8024 if (e.button !== undefined && e.button !== 0) return;
8025 if (!isInlineVideoResizeHandleEvent(videoBlock, e)) {
8027 e.stopPropagation();
8028 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
8029 openInlineVideoOptions(root, videoBlock);
8033 const first = surface.firstElementChild;
8034 if (first && first.classList.contains("dbx-cms-marker")) {
8035 const rect = first.getBoundingClientRect();
8036 if (e.clientY < rect.top + 8) {
8037 focusEditorAboveMarker(root, first);
8039 e.stopPropagation();
8043 selectEditorMarker(root, null);
8044 selectEditorMissingMedia(root, null);
8045 selectEditorModPlaceholder(root, null);
8048 surface.addEventListener("mouseover", e => {
8049 const marker = closestElement(e.target, ".dbx-cms-marker");
8050 clearEditorMarkerHover(surface, marker);
8051 if (marker && surface.contains(marker)) marker.classList.add("is-hovered");
8054 surface.addEventListener("mouseout", e => {
8055 const marker = closestElement(e.target, ".dbx-cms-marker");
8056 if (!marker || !surface.contains(marker)) return;
8057 const related = e.relatedTarget;
8058 if (!related || !marker.contains(related)) marker.classList.remove("is-hovered");
8061 surface.addEventListener("focusin", () => {
8062 if (!state(root).selectedMarker && !state(root).selectedMissingMedia) refreshEditorCaretHint(root);
8065 surface.addEventListener("focusout", e => {
8066 const next = e.relatedTarget;
8067 if (next && surface.contains(next)) return;
8068 if (next && closestElement(next, ".dbx-cms-marker")) return;
8069 hideEditorCaretHint(root);
8072 surface.addEventListener("click", e => {
8073 const marker = closestElement(e.target, ".dbx-cms-marker");
8074 if (marker && surface.contains(marker)) {
8075 handleEditorMarkerClick(root, marker, e);
8078 const missingMedia = contextMissingMediaTarget(root, e.target);
8080 handleEditorMissingMediaClick(root, missingMedia, e);
8083 const modPlaceholder = inlineModTarget(root, e.target);
8084 if (modPlaceholder) {
8085 selectEditorModPlaceholder(root, modPlaceholder);
8087 e.stopPropagation();
8088 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
8091 const videoBlock = inlineVideoEventTarget(root, e);
8093 if (e.button !== undefined && e.button !== 0) return;
8095 e.stopPropagation();
8096 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
8097 openInlineVideoOptions(root, videoBlock);
8100 saveEditorSelection(root);
8101 selectEditorMarker(root, null);
8102 selectEditorMissingMedia(root, null);
8105 surface.addEventListener("keydown", e => {
8106 if (e.key !== "Delete" && e.key !== "Backspace") return;
8107 const marker = closestElement(e.target, ".dbx-cms-marker") || state(root).selectedMarker;
8108 if (marker && surface.contains(marker)) {
8110 e.stopPropagation();
8111 removeEditorMarker(root, marker);
8114 const missingMedia = contextMissingMediaTarget(root, e.target) || state(root).selectedMissingMedia;
8115 if (missingMedia && surface.contains(missingMedia)) {
8117 e.stopPropagation();
8118 removeEditorMissingMedia(root, missingMedia);
8121 const mod = inlineModTarget(root, e.target) || state(root).selectedModPlaceholder;
8122 if (mod && surface.contains(mod)) {
8124 e.stopPropagation();
8125 removeEditorModPlaceholder(root, mod);
8129 surface.addEventListener("contextmenu", e => {
8130 if (!surface.contains(e.target)) return;
8132 e.stopPropagation();
8133 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
8134 showEditorContextMenu(root, e);
8138 function bindEditorMarkerEventsRetry(root, attempt) {
8139 attempt = Number(attempt || 0);
8140 bindEditorMarkerEvents(root);
8141 const surface = editorSurface(root);
8142 if (surface && surface.__dbxCmsMarkerEventsBound) return;
8143 if (attempt < 20) window.setTimeout(() => bindEditorMarkerEventsRetry(root, attempt + 1), 100);
8146 function bindGlobalCmsEditorEvents() {
8147 if (document.__dbxCmsEditorGlobalEventsBound) return;
8148 document.__dbxCmsEditorGlobalEventsBound = true;
8149 if (document.documentElement) document.documentElement.setAttribute("data-dbx-cms-global-events", "1");
8150 document.addEventListener("pointerdown", e => {
8151 if (e.button !== 0) return;
8152 const marker = editorMarkerFromPointerEvent(e);
8153 const root = editorRootFromMarker(marker);
8155 selectEditorMarker(root, marker);
8156 state(root).pointerDragMarker = {
8162 e.stopPropagation();
8164 document.addEventListener("pointermove", e => {
8165 qsa(document, ".dbx-cms").forEach(currentRoot => {
8166 const drag = state(currentRoot).pointerDragMarker;
8167 if (!drag || !drag.marker) return;
8168 const dx = Math.abs(e.clientX - drag.startX);
8169 const dy = Math.abs(e.clientY - drag.startY);
8170 if (!drag.dragging && dx < 6 && dy < 6) return;
8171 if (!drag.dragging) {
8172 drag.dragging = true;
8173 const rect = drag.marker.getBoundingClientRect();
8174 drag.ghostOffsetX = drag.startX - rect.left;
8175 drag.ghostOffsetY = drag.startY - rect.top;
8176 drag.ghost = createMarkerDragGhost(drag.marker, rect);
8177 drag.marker.classList.add("is-dragging-source");
8180 updateMarkerDragGhost(drag, e.clientX, e.clientY);
8181 showEditorMarkerDropHint(currentRoot, e.clientX, e.clientY, drag.marker);
8184 document.addEventListener("pointerup", e => {
8185 qsa(document, ".dbx-cms").forEach(root => {
8186 const drag = state(root).pointerDragMarker;
8187 if (!drag || !drag.marker) return;
8188 if (drag.dragging) {
8190 e.stopPropagation();
8191 if (!moveEditorMarkerToPoint(root, drag.marker, e.clientX, e.clientY)) {
8192 clearMarkerPointerDrag(root);
8195 selectEditorMarker(root, drag.marker);
8196 clearMarkerPointerDrag(root);
8200 document.addEventListener("pointercancel", () => {
8201 qsa(document, ".dbx-cms").forEach(root => clearMarkerPointerDrag(root));
8203 document.addEventListener("keydown", e => {
8204 const key = String(e.key || "").toLowerCase();
8205 const targetRoot = closestElement(e.target, ".dbx-cms");
8206 if ((e.ctrlKey || e.metaKey) && key === "s" && targetRoot) {
8208 e.stopPropagation();
8209 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
8210 saveCurrentCms(targetRoot, cmsConfig(targetRoot));
8213 if (e.key !== "Delete" && e.key !== "Backspace") return;
8214 const root = targetRoot || editorRootFromMarker(closestElement(e.target, ".dbx-cms-marker"));
8215 const marker = root ? state(root).selectedMarker : null;
8216 const missingMedia = root ? state(root).selectedMissingMedia : null;
8217 const mod = root ? state(root).selectedModPlaceholder : null;
8218 const surface = root ? editorSurface(root) : null;
8219 if (marker && surface && surface.contains(marker)) {
8221 e.stopPropagation();
8222 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
8223 removeEditorMarker(root, marker);
8226 if (missingMedia && surface && surface.contains(missingMedia)) {
8228 e.stopPropagation();
8229 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
8230 removeEditorMissingMedia(root, missingMedia);
8233 if (mod && surface && surface.contains(mod)) {
8235 e.stopPropagation();
8236 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
8237 removeEditorModPlaceholder(root, mod);
8242 function insertEditorHtml(root, html) {
8243 const instance = getEditorInstance(root);
8244 if (instance && typeof instance.setMode === "function") {
8245 instance.setMode(window.Jodit && window.Jodit.MODE_WYSIWYG ? window.Jodit.MODE_WYSIWYG : 1);
8247 hideEditorCaretHint(root);
8248 restoreEditorSelection(root);
8249 if (instance && instance.s && instance.s.insertHTML) {
8250 instance.s.insertHTML(html);
8251 normalizeEditorMarkers(root);
8252 normalizeBootstrapComponents(editorSurface(root));
8253 bindEditorMarkerEventsRetry(root);
8254 syncEditorDom(root);
8255 saveEditorSelection(root);
8258 document.execCommand("insertHTML", false, html);
8259 normalizeEditorMarkers(root);
8260 normalizeBootstrapComponents(editorSurface(root));
8261 bindEditorMarkerEventsRetry(root);
8262 syncEditorDom(root);
8263 saveEditorSelection(root);
8266 function bootstrapComponentItems(root) {
8267 const openWinData = "lib=openWin|title=Information|width=900|height=80%|position=center-top|reload=1|minimizable=1|maximizable=1";
8268 const firstColumn = escapeHtml(cmsText(root, "editor_columns_first", "Inhalt der ersten Spalte."));
8269 const secondColumn = escapeHtml(cmsText(root, "editor_columns_second", "Inhalt der zweiten Spalte."));
8270 const thirdColumn = escapeHtml(cmsText(root, "editor_columns_third", "Inhalt der dritten Spalte."));
8273 label: "Hinweis / Alert",
8274 html: '<div class="alert alert-info" role="alert"><h4 class="alert-heading">Hinweis</h4><p>Kurzer Hinweistext fuer den Inhalt.</p></div><p></p>'
8278 html: '<div class="card"><div class="card-body"><h3 class="card-title">Karten-Titel</h3><p class="card-text">Kurzer Text fuer diese Karte.</p><a class="btn btn-primary" href="#">Mehr erfahren</a></div></div><p></p>'
8282 html: '<div class="row row-cols-1 row-cols-md-3 g-3"><div class="col"><div class="card h-100"><div class="card-body"><h3 class="card-title">Erste Karte</h3><p class="card-text">Kurzer Text.</p></div></div></div><div class="col"><div class="card h-100"><div class="card-body"><h3 class="card-title">Zweite Karte</h3><p class="card-text">Kurzer Text.</p></div></div></div><div class="col"><div class="card h-100"><div class="card-body"><h3 class="card-title">Dritte Karte</h3><p class="card-text">Kurzer Text.</p></div></div></div></div><p></p>'
8285 label: cmsText(root, "editor_columns_two", "2 Spalten"),
8286 html: `<div class="row g-3"><div class="col-12 col-md"><p>${firstColumn}</p></div><div class="col-12 col-md"><p>${secondColumn}</p></div></div><p></p>`
8289 label: cmsText(root, "editor_columns_three", "3 Spalten"),
8290 html: `<div class="row g-3"><div class="col-12 col-md"><p>${firstColumn}</p></div><div class="col-12 col-md"><p>${secondColumn}</p></div><div class="col-12 col-md"><p>${thirdColumn}</p></div></div><p></p>`
8293 label: "List Group",
8294 html: '<div class="list-group"><a class="list-group-item list-group-item-action active" href="#">Aktiver Punkt</a><a class="list-group-item list-group-item-action" href="#">Weiterer Punkt</a><a class="list-group-item list-group-item-action" href="#">Dritter Punkt</a></div><p></p>'
8297 label: "CTA Button",
8298 html: '<p><a class="btn btn-primary" href="#">Informationen anfragen</a> <a class="btn btn-outline-primary" href="#">Mehr erfahren</a></p><p></p>'
8301 label: "openWin Link",
8302 html: '<p><a class="btn btn-outline-primary dbx-win" href="kontakt" data-dbx="' + openWinData + '|url=kontakt">Im Fenster oeffnen</a></p><p></p>'
8306 html: '<div class="accordion" id="dbxCmsAccordion"><div class="accordion-item"><h3 class="accordion-header"><button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#dbxCmsAccordionOne" aria-expanded="true" aria-controls="dbxCmsAccordionOne">Erster Bereich</button></h3><div id="dbxCmsAccordionOne" class="accordion-collapse collapse show" data-bs-parent="#dbxCmsAccordion"><div class="accordion-body">Inhalt des ersten Bereichs.</div></div></div><div class="accordion-item"><h3 class="accordion-header"><button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#dbxCmsAccordionTwo" aria-expanded="false" aria-controls="dbxCmsAccordionTwo">Zweiter Bereich</button></h3><div id="dbxCmsAccordionTwo" class="accordion-collapse collapse" data-bs-parent="#dbxCmsAccordion"><div class="accordion-body">Inhalt des zweiten Bereichs.</div></div></div></div><p></p>'
8310 html: '<div><ul class="nav nav-tabs" role="tablist"><li class="nav-item" role="presentation"><button class="nav-link active" type="button" data-bs-toggle="tab" data-bs-target="#dbxCmsTabOne" role="tab">Tab 1</button></li><li class="nav-item" role="presentation"><button class="nav-link" type="button" data-bs-toggle="tab" data-bs-target="#dbxCmsTabTwo" role="tab">Tab 2</button></li></ul><div class="tab-content border border-top-0 p-3"><div class="tab-pane fade show active" id="dbxCmsTabOne" role="tabpanel">Inhalt Tab 1.</div><div class="tab-pane fade" id="dbxCmsTabTwo" role="tabpanel">Inhalt Tab 2.</div></div></div><p></p>'
8314 html: '<div class="table-responsive"><table class="table table-striped table-hover align-middle"><thead><tr><th>Spalte 1</th><th>Spalte 2</th><th>Spalte 3</th></tr></thead><tbody><tr><td>Wert</td><td>Wert</td><td>Wert</td></tr><tr><td>Wert</td><td>Wert</td><td>Wert</td></tr></tbody></table></div><p></p>'
8319 function cmsBootstrapComponentIcon() {
8320 return '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M7 8h10M7 12h4M13 12h4M7 16h10" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>';
8323 function insertBootstrapComponent(root, html) {
8324 const uid = "dbxCms" + Date.now().toString(36) + Math.floor(Math.random() * 1000).toString(36);
8325 html = String(html || "")
8326 .replaceAll("dbxCmsAccordion", uid + "Accordion")
8327 .replaceAll("dbxCmsAccordionOne", uid + "AccordionOne")
8328 .replaceAll("dbxCmsAccordionTwo", uid + "AccordionTwo")
8329 .replaceAll("dbxCmsTabOne", uid + "TabOne")
8330 .replaceAll("dbxCmsTabTwo", uid + "TabTwo");
8331 insertEditorFragment(root, html);
8332 normalizeEditorMarkers(root);
8333 normalizeBootstrapComponents(editorSurface(root));
8334 syncEditorDom(root);
8336 scheduleEditorHeight(root);
8339 function normalizeBootstrapComponents(surface) {
8340 if (!surface) return;
8341 const doc = surface.ownerDocument || document;
8342 qsa(surface, ".card").forEach(card => {
8343 let body = Array.from(card.children || []).find(child => child.classList && child.classList.contains("card-body"));
8345 body = doc.createElement("div");
8346 body.className = "card-body";
8347 const insertAfter = Array.from(card.children || []).reverse().find(child => {
8348 if (!child.classList) return false;
8349 return child.classList.contains("card-img-top")
8350 || child.classList.contains("card-img")
8351 || child.classList.contains("card-header")
8352 || child.classList.contains("badge")
8353 || child.classList.contains("position-absolute");
8355 card.insertBefore(body, insertAfter ? insertAfter.nextSibling : card.firstChild);
8358 Array.from(card.childNodes || []).forEach(node => {
8359 if (node === body) return;
8360 if (node.nodeType === 3) {
8361 if (String(node.nodeValue || "").trim() === "") return;
8362 body.appendChild(node);
8365 if (node.nodeType !== 1) return;
8366 if (node.classList && (
8367 node.classList.contains("card-body")
8368 || node.classList.contains("card-img-top")
8369 || node.classList.contains("card-img")
8370 || node.classList.contains("card-header")
8371 || node.classList.contains("card-footer")
8372 || node.classList.contains("list-group")
8373 || node.classList.contains("dbx-cms-inline-media")
8374 || node.classList.contains("dbx-media")
8375 || node.classList.contains("position-absolute")
8377 if (isEmptyEditorBlock(node)) {
8381 if (/^(H[1-6]|P|A|BUTTON|UL|OL|DIV|SPAN|SMALL|STRONG|EM)$/i.test(node.tagName || "")) {
8382 body.appendChild(node);
8386 qsa(card, ".position-absolute.badge, .position-absolute .badge, .badge.position-absolute").forEach(badge => {
8387 if (!card.contains(badge)) return;
8388 badge.setAttribute("contenteditable", "false");
8389 Array.from(badge.children || []).forEach(child => {
8390 if (/^(H[1-6]|P|DIV|A|BUTTON|UL|OL)$/i.test(child.tagName || "")) body.appendChild(child);
8394 qsa(card, "img.card-img-top,img.card-img,img.card-img-bottom").forEach(img => {
8395 img.setAttribute("contenteditable", "false");
8396 img.setAttribute("draggable", "false");
8399 qsa(body, "p,div").forEach(block => {
8400 if (block === body) return;
8401 if (!isEmptyEditorBlock(block)) return;
8405 if (!nodeHasEditorContent(body)) {
8406 const p = doc.createElement("p");
8407 p.innerHTML = "<br>";
8408 body.appendChild(p);
8413 function isEmptyEditorBlock(block) {
8414 if (!block || block.nodeType !== 1) return false;
8415 if (!/^(P|DIV)$/i.test(block.tagName || "")) return false;
8416 if (block.querySelector("img,video,iframe,object,embed,table,hr,ul,ol,.dbx-cms-marker,.dbx-cms-inline-media")) return false;
8417 return String(block.textContent || "").replace(/\u00a0/g, " ").replace(/\uFEFF/g, "").trim() === "";
8420 function editableCardBlockFromSelection(surface) {
8421 if (!surface) return null;
8422 const doc = surface.ownerDocument || document;
8423 const sel = doc.getSelection ? doc.getSelection() : null;
8424 if (!sel || !sel.rangeCount) return null;
8425 const range = sel.getRangeAt(0);
8426 if (!range.collapsed || !surface.contains(range.commonAncestorContainer)) return null;
8427 const el = nodeElement(range.startContainer);
8428 const body = closestElement(el, ".card-body");
8429 if (!body || !surface.contains(body)) return null;
8430 const block = closestElement(el, "p,div,h1,h2,h3,h4,h5,h6,li") || body;
8431 if (!body.contains(block)) return null;
8432 return { body, block, range };
8435 function rangeIsAtElementBoundary(range, element, boundary) {
8436 if (!range || !element) return false;
8437 const probe = range.cloneRange();
8438 if (boundary === "start") {
8439 probe.selectNodeContents(element);
8440 probe.setEnd(range.startContainer, range.startOffset);
8442 probe.selectNodeContents(element);
8443 probe.setStart(range.startContainer, range.startOffset);
8445 return String(probe.toString() || "").replace(/\u00a0/g, " ").replace(/\uFEFF/g, "").trim() === "";
8448 function firstEditableCardBodyBlock(body) {
8449 return Array.from(body.children || []).find(child => {
8450 if (!/^(P|DIV|H1|H2|H3|H4|H5|H6|UL|OL|LI|A|BUTTON)$/i.test(child.tagName || "")) return false;
8451 return nodeHasEditorContent(child);
8455 function lastEditableCardBodyBlock(body) {
8456 return Array.from(body.children || []).reverse().find(child => {
8457 if (!/^(P|DIV|H1|H2|H3|H4|H5|H6|UL|OL|LI|A|BUTTON)$/i.test(child.tagName || "")) return false;
8458 return nodeHasEditorContent(child);
8462 function setEditorCaretInCardBody(root, body) {
8463 const first = firstEditableCardBodyBlock(body) || body;
8464 return setEditorCaretInElement(root, first, 0);
8467 function handleBootstrapCardDeleteKey(root, event) {
8468 const surface = editorSurface(root);
8469 const ctx = editableCardBlockFromSelection(surface);
8470 if (!ctx) return false;
8471 const key = String(event.key || "");
8472 if (key !== "Backspace" && key !== "Delete") return false;
8473 const { body, block, range } = ctx;
8475 if (block !== body && isEmptyEditorBlock(block)) {
8476 event.preventDefault();
8477 event.stopPropagation();
8478 if (typeof event.stopImmediatePropagation === "function") event.stopImmediatePropagation();
8479 const next = block.nextElementSibling;
8480 const prev = block.previousElementSibling;
8482 const target = key === "Delete" ? (next || prev || body) : (prev || next || body);
8483 setEditorCaretInElement(root, target, 0);
8484 normalizeBootstrapComponents(surface);
8485 syncEditorDom(root);
8489 const first = firstEditableCardBodyBlock(body);
8490 if (key === "Backspace" && first && (block === first || first.contains(block)) && rangeIsAtElementBoundary(range, block, "start")) {
8491 event.preventDefault();
8492 event.stopPropagation();
8493 if (typeof event.stopImmediatePropagation === "function") event.stopImmediatePropagation();
8494 setEditorCaretInCardBody(root, body);
8498 const last = lastEditableCardBodyBlock(body);
8499 if (key === "Delete" && last && (block === last || last.contains(block)) && rangeIsAtElementBoundary(range, block, "end")) {
8500 event.preventDefault();
8501 event.stopPropagation();
8502 if (typeof event.stopImmediatePropagation === "function") event.stopImmediatePropagation();
8508 function editorMediaId(node) {
8509 if (!node || !node.getAttribute) return 0;
8510 const ownId = Number(node.getAttribute("data-cms-media-id") || 0);
8511 if (ownId > 0) return ownId;
8512 const src = String(node.getAttribute("src") || "");
8513 const match = src.match(/dbx_mid=([0-9]+)/i);
8514 return Number(match && match[1] || 0);
8517 function editorMediaNodeById(root, id) {
8518 id = Number(id || 0);
8519 const surface = editorSurface(root);
8520 if (!surface || !id) return null;
8521 return qsa(surface, "[data-cms-media-id],img[src*='dbx_mid='],video[src*='dbx_mid='],iframe[src*='dbx_mid=']")
8522 .find(node => editorMediaId(node) === id) || null;
8525 function editorImageRow(root, img) {
8526 const id = editorMediaId(img);
8527 const stored = mediaRowById(root, id);
8528 if (stored) return stored;
8529 const src = String(img?.getAttribute("src") || "");
8535 media_type: "image",
8537 title: img?.getAttribute("title") || img?.getAttribute("alt") || "Bild",
8538 alt: img?.getAttribute("alt") || "",
8539 width: img?.naturalWidth || img?.getAttribute("width") || "",
8540 height: img?.naturalHeight || img?.getAttribute("height") || ""
8544 function openCmsImageEditor(root, img) {
8545 if (!img) return false;
8546 const row = editorImageRow(root, img);
8547 if (Number(row.id || 0) > 0) {
8548 openMediaEdit(root, cmsConfig(root) || {}, row);
8551 return openEditorImageProperties(root, img);
8554 function removeEditorImage(root, img) {
8555 if (!img || !img.parentNode) return false;
8556 const wrapper = closestElement(img, ".dbx-cms-inline-media,figure");
8557 const target = wrapper && editorSurface(root)?.contains(wrapper) ? wrapper : img;
8559 syncEditorDom(root);
8563 function bindBootstrapCardEditingGuards(root) {
8565 const surface = editorSurface(root);
8566 if (!surface || surface.__dbxCmsBootstrapCardGuardsBound) return;
8567 surface.__dbxCmsBootstrapCardGuardsBound = true;
8568 surface.addEventListener("keydown", event => {
8569 handleBootstrapCardDeleteKey(root, event);
8571 surface.addEventListener("input", () => {
8572 window.clearTimeout(surface.__dbxCmsBootstrapNormalizeTimer);
8573 surface.__dbxCmsBootstrapNormalizeTimer = window.setTimeout(() => {
8574 surface.__dbxCmsBootstrapNormalizeTimer = null;
8575 normalizeBootstrapComponents(surface);
8580 function insertEditorFragment(root, html) {
8581 const surface = editorSurface(root);
8582 if (!surface) return false;
8583 const doc = surface.ownerDocument || document;
8584 const tpl = doc.createElement("template");
8585 tpl.innerHTML = String(html || "");
8586 if (!tpl.content || !tpl.content.childNodes.length) return false;
8588 const instance = getEditorInstance(root);
8589 if (instance && typeof instance.setMode === "function") {
8590 instance.setMode(window.Jodit && window.Jodit.MODE_WYSIWYG ? window.Jodit.MODE_WYSIWYG : 1);
8592 hideEditorCaretHint(root);
8593 restoreEditorSelection(root);
8594 if (surface.focus) surface.focus({ preventScroll: true });
8596 const sel = doc.getSelection ? doc.getSelection() : null;
8597 let range = sel && sel.rangeCount ? sel.getRangeAt(0) : null;
8598 if (!range || !surface.contains(range.commonAncestorContainer)) {
8599 range = doc.createRange();
8600 range.selectNodeContents(surface);
8601 range.collapse(false);
8604 const nodes = Array.from(tpl.content.childNodes);
8605 range.deleteContents();
8606 range.insertNode(tpl.content);
8608 normalizeEditorMarkers(root);
8609 normalizeBootstrapComponents(surface);
8610 bindEditorMarkerEventsRetry(root);
8611 syncEditorDom(root);
8612 const caretNode = nodes.slice().reverse().find(node => node.parentNode && surface.contains(node)) || surface.lastChild;
8613 setEditorCaretAfterNode(root, caretNode);
8614 saveEditorSelection(root);
8618 function execEditorCommand(root, command) {
8619 const instance = getEditorInstance(root);
8620 if (instance && instance.execCommand) {
8621 instance.execCommand(command);
8622 setField(root, "content", instance.value || "");
8624 scheduleEditorHeight(root);
8627 document.execCommand(command, false, null);
8629 scheduleEditorHeight(root);
8632 function serializeCmsMarkers(html) {
8633 const box = document.createElement("div");
8634 box.innerHTML = html || "";
8635 normalizeCommentMarkers(box);
8636 cleanEditorRuntimeNodes(box);
8637 normalizePlainTextMarkers(box);
8638 qsa(box, ".dbx-cms-marker[data-dbx-marker-comment]").forEach(marker => {
8639 const markerText = marker.getAttribute("data-dbx-marker-comment") || "";
8640 const name = cmsMarkerName(markerText);
8641 const hr = document.createElement("hr");
8642 hr.className = "dbx-cms-marker dbx-cms-marker-" + cmsMarkerClassName(name);
8643 hr.setAttribute("data-dbx-marker", "dbx:" + name);
8644 hr.setAttribute("data-label", marker.textContent || markerText);
8645 hr.setAttribute("contenteditable", "false");
8646 hr.setAttribute("draggable", "false");
8647 hr.setAttribute("tabindex", "0");
8648 marker.replaceWith(hr);
8650 qsa(box, ".dbx-cms-marker,[data-dbx-marker]").forEach(marker => {
8651 const name = cmsMarkerName(marker.getAttribute("data-dbx-marker") || "");
8652 if (marker.tagName !== "HR") {
8653 const hr = document.createElement("hr");
8654 Array.from(marker.attributes || []).forEach(attr => {
8655 if (attr.name === "data-dbx-marker-comment") return;
8656 hr.setAttribute(attr.name, attr.value);
8658 marker.replaceWith(hr);
8661 marker.classList.remove("is-selected", "is-dragging", "is-drop-before", "is-drop-after");
8662 Array.from(marker.classList).forEach(className => {
8663 if (className.indexOf("dbx-cms-marker-") === 0) marker.classList.remove(className);
8665 marker.classList.add("dbx-cms-marker", "dbx-cms-marker-" + cmsMarkerClassName(name));
8666 marker.removeAttribute("data-cms-drag-token");
8667 marker.setAttribute("data-dbx-marker", "dbx:" + name);
8668 if (name === "hero" || !marker.getAttribute("data-label")) marker.setAttribute("data-label", cmsMarkerLabel("dbx:" + name));
8669 marker.setAttribute("contenteditable", "false");
8670 marker.setAttribute("draggable", "false");
8671 marker.setAttribute("tabindex", "0");
8673 dedupeSingletonMarkers(box);
8674 dedupeAdjacentMarkers(box);
8675 return box.innerHTML;
8678 function folderTreeEditButton(id) {
8679 return `<button type="button" class="dbx-cms-tree-edit" data-cms-folder-edit-btn data-id="${escapeHtml(id)}" title="Ordner bearbeiten" aria-label="Ordner bearbeiten"><i class="bi bi-pencil-square"></i></button>`;
8682 function bindTreeSearchClear(root, input) {
8683 if (!input || input.__dbxCmsTreeSearchClearBound) return;
8684 const wrap = input.closest(".dbx-input-clearable");
8687 input.__dbxCmsTreeSearchClearBound = true;
8688 wrap.classList.add("dbx-cms-tree-search-wrap");
8690 let btn = wrap.querySelector(".dbx-clear-btn");
8692 btn = document.createElement("button");
8693 btn.type = "button";
8694 btn.className = "dbx-clear-btn";
8695 btn.setAttribute("aria-label", "Suche zurücksetzen");
8696 btn.setAttribute("title", "Suche zurücksetzen");
8697 btn.innerHTML = '<i class="bi bi-x-lg" aria-hidden="true"></i>';
8698 wrap.appendChild(btn);
8700 btn.classList.add("dbx-cms-tree-search-clear");
8702 const sync = () => {
8703 if (String(input.value || "").length > 0) {
8704 btn.removeAttribute("hidden");
8706 btn.setAttribute("hidden", "");
8710 input.addEventListener("input", sync);
8711 btn.addEventListener("click", e => {
8714 input.dispatchEvent(new Event("input", { bubbles: true }));
8715 input.dispatchEvent(new Event("change", { bubbles: true }));
8723 function renderTree(root) {
8724 const s = state(root);
8725 const box = qs(root, "[data-cms-tree]");
8726 const searchRaw = String(qs(root, "[data-cms-search]")?.value || "").trim();
8727 const search = searchRaw.toLowerCase();
8728 const searchIsNumeric = /^\d+$/.test(searchRaw);
8731 function treeSearchText(node) {
8732 const rights = root.classList.contains("dbx-cms-view") ? "" : String(node._rights || "");
8734 String(node._id || ""),
8735 String(node._title || ""),
8737 ].join(" ").toLowerCase();
8740 function nodeMatches(node) {
8741 if (!search) return true;
8742 if (searchIsNumeric && String(node._id || "") === searchRaw) return true;
8743 return treeSearchText(node).includes(search);
8746 function hasSearchMatch(node) {
8747 if (!search) return true;
8748 if (nodeMatches(node)) return true;
8749 const children = Array.isArray(node._children) ? node._children : [];
8750 return children.some(hasSearchMatch);
8753 function searchClass(node, children) {
8754 if (!search) return "";
8755 const exactId = searchIsNumeric && String(node._id || "") === searchRaw;
8756 if (exactId) return " is-search-match is-search-id-match";
8757 if (nodeMatches(node)) return " is-search-match";
8758 if (children.some(hasSearchMatch)) return " is-search-path";
8762 function renderNode(node) {
8763 const children = Array.isArray(node._children) ? node._children : [];
8764 if (search && !hasSearchMatch(node)) return "";
8765 const type = node._type === "folder" ? "folder" : "page";
8766 const icon = type === "folder" ? "bi-folder2-open" : "bi-file-earmark-text";
8768 (type === "page" && s.selectedType !== "folder" && Number(node._id) === Number(s.selectedPage)) ||
8769 (type === "folder" && s.selectedType === "folder" && Number(node._id) === Number(s.selectedFolder))
8772 const matchedClass = searchClass(node, children);
8773 const collapsed = type === "folder" && children.length ? isFolderCollapsed(root, node._id, search) : false;
8774 const expandedAttr = collapsed ? "false" : "true";
8775 const toggleClass = collapsed ? "bi-chevron-right" : "bi-chevron-down";
8776 const collapsedClass = collapsed ? " is-collapsed" : "";
8777 const editBtn = type === "folder" ? folderTreeEditButton(node._id) : "";
8778 const toggle = type === "folder" && children.length
8779 ? `<span class="dbx-cms-tree-toggle" data-cms-tree-toggle data-id="${escapeHtml(node._id)}" title="Ordner ein- oder ausklappen"><i class="bi ${toggleClass}"></i></span>`
8780 : `<span class="dbx-cms-tree-toggle-spacer"></span>`;
8781 if (node._row_html) {
8782 let html = String(node._row_html);
8783 if (active && !html.includes(" is-active")) {
8784 html = html.replace("dbx-cms-tree-row", "dbx-cms-tree-row is-active");
8787 html = html.replace(/\bdbx-cms-tree-row\b/, "dbx-cms-tree-row" + matchedClass);
8789 if (!html.includes("data-cms-tree-toggle") && !html.includes("dbx-cms-tree-toggle-spacer")) {
8790 html = html.replace('<span class="dbx-cms-tree-icons">', toggle + '<span class="dbx-cms-tree-icons">');
8792 if (type === "folder") {
8793 html = html.replace(/\bdbx-cms-tree-row\b/, "dbx-cms-tree-row" + collapsedClass);
8794 const rowAria = /(<div role="button"[^>]*?)\saria-expanded="[^"]*"/;
8795 if (rowAria.test(html)) {
8796 html = html.replace(rowAria, '$1 aria-expanded="' + expandedAttr + '"');
8798 html = html.replace('<div role="button"', '<div role="button" aria-expanded="' + expandedAttr + '"');
8800 if (!html.includes("data-cms-folder-edit-btn")) {
8801 if (html.includes('class="dbx-cms-lng-badges"')) {
8802 html = html.replace(/<span class="dbx-cms-lng-badges"/, editBtn + '<span class="dbx-cms-lng-badges"');
8804 html = html.replace(/(<span class="dbx-cms-tree-meta">)([\s\S]*?)(<\/span>)/, '$1$2' + editBtn + '$3');
8808 if (children.length) {
8809 html += `<div class="dbx-cms-tree-children"${collapsed ? " hidden" : ""}>`;
8810 children.forEach(child => {
8811 if (!search || hasSearchMatch(child)) html += renderNode(child);
8817 const rights = type === "folder" && node._rights ? `<span class="dbx-cms-rights" data-cms-folder-edit title="Ordnerrechte bearbeiten">${escapeHtml(node._rights)}</span>` : "";
8818 const lngBadges = node._lng_badges ? String(node._lng_badges) : "";
8819 const meta = `${rights}${editBtn}${lngBadges}`;
8820 const title = type === "page"
8821 ? `<span class="dbx-cms-page-id">(${escapeHtml(node._id)})</span> ${escapeHtml(node._title)}`
8822 : escapeHtml(node._title);
8825 if (!search || hasSearchMatch(node)) {
8826 html += `<div role="button" tabindex="0" draggable="true" class="dbx-cms-tree-row dbx-cms-tree-${type}${active}${matchedClass}${collapsedClass}" data-type="${type}" data-id="${escapeHtml(node._id)}" data-folder="${escapeHtml(node._parent || node._id)}"${type === "folder" ? ` aria-expanded="${expandedAttr}"` : ""}>`;
8828 html += `<span class="dbx-cms-tree-icons"><i class="bi ${icon}"></i>`;
8829 html += `</span><span class="dbx-cms-tree-label">${title}</span><span class="dbx-cms-tree-meta">${meta}</span>`;
8833 if (children.length) {
8834 html += `<div class="dbx-cms-tree-children"${collapsed ? " hidden" : ""}>`;
8835 children.forEach(child => {
8836 if (!search || hasSearchMatch(child)) html += renderNode(child);
8844 if (!s.tree.length) {
8845 box.innerHTML = '<div class="dbx-cms-empty">Keine Content-Struktur vorhanden.</div>';
8849 box.innerHTML = s.tree.map(renderNode).join("");
8851 const firstMatch = qs(box, ".dbx-cms-tree-row.is-search-id-match") || qs(box, ".dbx-cms-tree-row.is-search-match");
8853 requestAnimationFrame(() => {
8854 firstMatch.scrollIntoView({ block: "center", inline: "nearest" });
8860 function toggleTreeFolder(root, row, forceCollapsed) {
8862 const id = Number(row.getAttribute("data-id") || 0);
8863 if (!id || row.getAttribute("data-type") !== "folder") return;
8864 const collapsed = typeof forceCollapsed === "boolean"
8866 : !isFolderCollapsed(root, id, false);
8867 setFolderCollapsed(root, id, collapsed);
8871 function loadTree(root, cfg) {
8872 const s = state(root);
8873 const url = cfgUrl(cfg, "tree");
8874 const box = qs(root, "[data-cms-tree]");
8876 if (box) box.innerHTML = '<div class="dbx-cms-empty">Tree-URL fehlt.</div>';
8877 return Promise.resolve();
8879 if (s.treeLoading && s.treePromise) return s.treePromise;
8880 s.treeLoading = true;
8881 if (box) box.innerHTML = '<div class="dbx-cms-empty">Tree wird geladen...</div>';
8883 s.treePromise = fetchJson(apiUrl(url, cmsLngParams(root)))
8885 s.tree = Array.isArray(data.nodes) ? data.nodes : [];
8886 s.flat = Array.isArray(data.flat) ? data.flat : [];
8887 s.treeLoaded = true;
8890 if (!s.selectionRestored) {
8891 s.selectionRestored = true;
8892 if (isViewMode(cfg)) {
8893 const requestedPage = Number(cfg && cfg.cid ? cfg.cid : 0) || 0;
8894 if (requestedPage > 0) {
8895 if (root.getAttribute("data-cms-initial-page-loaded") === "1") {
8896 const node = s.flat.find(n => n._type === "page" && Number(n._id) === requestedPage) || {};
8897 s.page = { id: requestedPage, title: node._title || "" };
8898 setSelectedPage(root, requestedPage);
8899 setSelectedType(root, "page");
8900 updateViewPageTitle(root, node._title || "");
8901 root.removeAttribute("data-cms-initial-page-loaded");
8902 revealTreeSelection(root);
8905 return loadViewPage(root, cfg, requestedPage);
8907 if (s.selectedPage > 0) {
8908 return loadViewPage(root, cfg, s.selectedPage);
8910 const firstViewPage = s.flat.find(n => n._type === "page");
8911 if (firstViewPage) return loadViewPage(root, cfg, firstViewPage._id);
8914 const requestedEditPage = Number(cfg && cfg.cid ? cfg.cid : 0) || 0;
8915 if (requestedEditPage > 0) {
8916 s.selectedPage = requestedEditPage;
8917 s.selectedType = "page";
8918 setSelectedPage(root, requestedEditPage);
8919 setSelectedType(root, "page");
8920 return loadPage(root, cfg, requestedEditPage).then(() => revealTreeSelection(root));
8922 if (s.selectedType === "folder" && s.selectedFolder) {
8923 const folder = findNode(root, "folder", s.selectedFolder);
8925 showFolderEditor(root, folder);
8926 loadMedia(root, cfg);
8927 revealTreeSelection(root);
8931 if (s.selectedPage > 0) {
8932 return loadPage(root, cfg, s.selectedPage);
8934 const firstPage = s.flat.find(n => n._type === "page");
8935 if (firstPage) return loadPage(root, cfg, firstPage._id);
8937 revealTreeSelection(root);
8940 s.treeLoaded = false;
8941 dbx.error("[cms] tree load failed", err);
8943 box.innerHTML = '<div class="dbx-cms-empty">Tree konnte nicht geladen werden.</div>';
8945 status(root, "Tree konnte nicht geladen werden.", "error");
8948 s.treeLoading = false;
8949 s.treePromise = null;
8951 return s.treePromise;
8954 function ensureTreeLoaded(root, cfg) {
8955 const s = state(root);
8956 if (s.treeLoaded) return Promise.resolve(s.tree);
8957 return loadTree(root, cfg || cmsConfig(root));
8960 function loadInitialSelection(root, cfg) {
8961 const s = state(root);
8962 if (s.selectionRestored) return Promise.resolve();
8963 s.selectionRestored = true;
8965 const requestedPage = Number(cfg && cfg.cid ? cfg.cid : 0) || 0;
8966 const requestedFolder = Number(cfg && cfg.fid ? cfg.fid : 0) || 0;
8968 if (isViewMode(cfg)) {
8969 const pageId = requestedPage || Number(s.selectedPage || 0);
8971 setSelectedPage(root, pageId);
8972 setSelectedType(root, "page");
8973 if (root.getAttribute("data-cms-initial-page-loaded") === "1") {
8974 s.page = { id: pageId, title: "" };
8975 root.removeAttribute("data-cms-initial-page-loaded");
8976 return Promise.resolve();
8978 return loadViewPage(root, cfg, pageId);
8980 return Promise.resolve();
8983 if (requestedFolder > 0) {
8984 const folderId = requestedFolder;
8985 return ensureTreeLoaded(root, cfg).then(() => {
8986 const folder = findNode(root, "folder", folderId);
8987 if (!folder) return;
8988 setSelectedFolder(root, folderId);
8989 setSelectedType(root, "folder");
8990 showFolderEditor(root, folder);
8991 return loadMedia(root, cfg);
8995 const pageId = requestedPage || Number(s.selectedPage || 0);
8997 setSelectedPage(root, pageId);
8998 setSelectedType(root, "page");
8999 return loadPage(root, cfg, pageId);
9002 return Promise.resolve();
9005 function setSelectValues(select, value) {
9006 if (!select) return;
9007 const values = String(value || "").split(",").map(v => v.trim()).filter(Boolean);
9008 Array.from(select.options).forEach(option => { option.selected = values.includes(option.value); });
9009 syncCmsSelect(select);
9010 select.dispatchEvent(new Event("change", { bubbles: true }));
9013 function getSelectValues(select) {
9014 if (!select) return "";
9015 return Array.from(select.selectedOptions).map(option => option.value).join(",");
9018 function setFolderField(root, name, value) {
9019 const el = qs(root, `[data-cms-folder-field="${name}"]`);
9021 el.value = value == null ? "" : String(value);
9023 if (name === "hero_image_id") renderHeroPreview(root);
9024 if (name === "seo_image_id") renderSeoPreview(root);
9027 function getFolderField(root, name) {
9028 const el = qs(root, `[data-cms-folder-field="${name}"]`);
9029 return el ? el.value : "";
9032 function findNode(root, type, id) {
9033 const list = state(root).flat || [];
9034 return list.find(node => node._type === type && Number(node._id) === Number(id)) || null;
9037 function buildParentOptions(root, currentId, selectedParent) {
9038 const s = state(root);
9039 const select = qs(root, '[data-cms-folder-field="parent_id"]');
9040 if (!select) return;
9042 const forbidden = new Set();
9043 function markForbidden(node) {
9044 if (!node || node._type !== "folder") return;
9045 forbidden.add(Number(node._id));
9046 (node._children || []).forEach(markForbidden);
9048 if (currentId > 0) markForbidden(findNode(root, "folder", currentId));
9050 let html = '<option value="0">Root / erste Ebene</option>';
9051 function walk(nodes, depth) {
9052 (nodes || []).forEach(node => {
9053 if (node._type !== "folder") return;
9054 const id = Number(node._id || 0);
9055 if (!forbidden.has(id)) {
9056 const pad = depth > 0 ? Array(depth + 1).join("-- ") : "";
9057 html += `<option value="${escapeHtml(id)}">${escapeHtml(pad + (node._title || "Ordner " + id))}</option>`;
9059 walk(node._children || [], depth + 1);
9062 walk(s.tree || [], 0);
9063 select.innerHTML = html;
9064 select.value = String(selectedParent || 0);
9065 syncCmsSelect(select);
9068 function showFolderEditor(root, folder, anchor) {
9069 const panel = qs(root, "[data-cms-folder-editor]");
9070 const title = qs(root, "[data-cms-folder-title]");
9071 const rights = qs(root, "[data-cms-folder-rights]");
9072 const s = state(root);
9074 s.folder = folder || null;
9075 if (!panel || !folder) return;
9077 s.heroPreviewRow = null;
9078 root.classList.add("is-folder-editing");
9079 updateHeaderActionTooltips(root);
9081 const id = Number(folder._id || folder.id || 0);
9082 const parentId = Number(folder._parent ?? folder.parent_id ?? s.selectedFolder ?? 0);
9083 const name = folder._title || folder.name || "";
9084 const template = folder._template || folder.template || "";
9085 const folderRights = folder._rights || folder.group_read || (parentId > 0 ? "parent" : "*");
9086 const settingFields = ["hero_template", "hero_image_id", "hero_margin_top", "hero_height", "hero_variant", "hero_sticky", "hero_scroll_layer"];
9087 updateCurrentSelectionTitle(root, "folder", id, name);
9089 panel.hidden = false;
9090 panel.classList.toggle("is-new", id <= 0);
9091 if (title) title.textContent = id > 0 ? "Ordner bearbeiten" : "Neuen Ordner anlegen";
9092 setFolderField(root, "id", id);
9093 setFolderField(root, "name", name);
9094 buildParentOptions(root, id, parentId);
9095 setFolderField(root, "template", template);
9096 setSelectValues(rights, folderRights);
9097 settingFields.forEach(key => {
9098 const value = folder["_" + key] || folder[key] || "parent";
9099 setFolderField(root, key, value);
9100 setField(root, key, value);
9102 renderHeroPreview(root);
9103 const deleteButton = qs(panel, "[data-cms-folder-delete]");
9104 if (deleteButton) deleteButton.hidden = id <= 0;
9106 panel.style.removeProperty("left");
9107 panel.style.removeProperty("top");
9109 if (typeof window.multiselect2 === "function") {
9110 window.multiselect2(rights && (rights.id || rights.name) ? (rights.id || rights.name) : "group_read");
9113 const first = qs(panel, '[data-cms-folder-field="name"]');
9114 if (first) window.setTimeout(() => first.focus(), 20);
9117 function hideFolderEditor(root) {
9118 const panel = qs(root, "[data-cms-folder-editor]");
9119 if (panel) panel.hidden = true;
9120 root.classList.remove("is-folder-editing");
9121 updateHeaderActionTooltips(root);
9122 const pageTitle = getField(root, "title");
9123 updateCurrentSelectionTitle(root, "page", getField(root, "id"), pageTitle);
9126 function saveFolder(root, cfg) {
9127 const s = state(root);
9128 const folder = s.folder;
9129 const url = cfgUrl(cfg, "savefolder");
9130 if (s.saving) return Promise.resolve();
9131 if (!folder || !url) {
9132 status(root, cmsText(root, "folder_select_first", "Bitte zuerst einen Ordner im Baum wählen."), "error");
9136 const id = Number(getFolderField(root, "id") || 0);
9137 const name = getFolderField(root, "name").trim();
9139 status(root, cmsText(root, "folder_name_required", "Bitte eine Ordner-Bezeichnung eintragen."), "error");
9143 const rights = getSelectValues(qs(root, "[data-cms-folder-rights]"));
9144 setSaving(root, true);
9145 return fetchJson(apiUrl(url, cmsLngParams(root)), {
9147 headers: { "Content-Type": "application/json" },
9148 body: JSON.stringify({
9151 parent_id: Number(getFolderField(root, "parent_id") || 0),
9152 template: getFolderField(root, "template"),
9154 hero_template: getField(root, "hero_template") || getFolderField(root, "hero_template") || "parent",
9155 hero_image_id: getField(root, "hero_image_id") || getFolderField(root, "hero_image_id") || "parent",
9156 hero_margin_top: getField(root, "hero_margin_top") || getFolderField(root, "hero_margin_top") || "parent",
9157 hero_height: getField(root, "hero_height") || getFolderField(root, "hero_height") || "parent",
9158 hero_variant: getField(root, "hero_variant") || getFolderField(root, "hero_variant") || "parent",
9159 hero_sticky: getField(root, "hero_sticky") || getFolderField(root, "hero_sticky") || "parent",
9160 hero_scroll_layer: getField(root, "hero_scroll_layer") || getFolderField(root, "hero_scroll_layer") || "parent",
9163 if (!data || !data.ok) throw new Error("folder save failed");
9164 const synced = Number(data.lng_synced || 0);
9165 const saveStatus = formatLngSaveStatus(
9166 cmsText(root, "folder_saved", "Ordner gespeichert."),
9169 status(root, saveStatus.text, saveStatus.type);
9170 clearDirtyAfterSave(root);
9171 setSelectedFolder(root, data.id || id || 0);
9172 setSelectedType(root, "folder");
9173 return loadTree(root, cfg).then(() => {
9174 const fresh = findNode(root, "folder", data.id || id);
9176 const mediaRows = (s.mediaRows || []).slice();
9177 const heroPreviewRow = s.heroPreviewRow || null;
9178 showFolderEditor(root, fresh);
9179 s.mediaRows = mediaRows;
9180 s.heroPreviewRow = heroPreviewRow;
9181 renderHeroPreview(root);
9183 revealTreeSelection(root);
9185 maybeOpenLngProvisionAfterCreate(root, cfg, data);
9188 dbx.error("[cms] folder save failed", err);
9189 status(root, cmsText(root, "folder_save_error", "Ordner konnte nicht gespeichert werden."), "error");
9191 setSaving(root, false);
9192 clearCmsLoading(root);
9196 function deleteFolder(root, cfg) {
9197 const id = Number(getFolderField(root, "id") || 0);
9198 const name = getFolderField(root, "name") || "Ordner";
9199 const url = cfgUrl(cfg, "deletefolder");
9201 status(root, "Nur gespeicherte Ordner koennen geloescht werden.", "error");
9205 return openLngDeleteDialog(root, cfg, "folder", id, name).catch(err => {
9206 const msg = err && err.message ? err.message : cmsText(root, "folder_delete_error", "Ordner konnte nicht gelöscht werden.");
9207 dbx.error("[cms] folder delete failed", err);
9208 status(root, msg, "error");
9212 function moveNode(root, cfg, type, id, targetFolder, position) {
9213 const url = cfgUrl(cfg, "movenode");
9214 if (!url || !type || !id || targetFolder < 0) return Promise.resolve();
9219 target_folder: targetFolder
9221 if (position && position.before_id) payload.before_id = position.before_id;
9222 if (position && position.after_id) payload.after_id = position.after_id;
9224 return fetchJson(apiUrl(url), {
9226 headers: { "Content-Type": "application/json" },
9227 body: JSON.stringify(payload)
9229 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "move failed");
9230 status(root, "Tree-Eintrag verschoben.", "success");
9231 return loadTree(root, cfg);
9233 dbx.error("[cms] move node failed", err);
9234 status(root, err && err.message ? err.message : "Tree-Eintrag konnte nicht verschoben werden.", "error");
9238 function syncSelectTitle(el) {
9239 if (!el || el.tagName !== "SELECT") return;
9240 const selected = el.selectedOptions && el.selectedOptions.length ? el.selectedOptions[0] : null;
9241 el.title = selected ? selected.textContent.trim() : "";
9244 function selectedOptionText(select) {
9245 const selected = select && select.selectedOptions && select.selectedOptions.length ? select.selectedOptions[0] : null;
9246 return selected ? selected.textContent.trim() : "";
9249 function syncCmsSelect(select) {
9250 syncSelectTitle(select);
9251 if (select && typeof select.__dbxCmsSelectRender === "function") {
9252 select.__dbxCmsSelectRender();
9254 const root = closestElement(select, ".dbx-cms");
9255 if (root) syncContentTemplateEditLink(root, select);
9258 function contentTemplateEditorUrl(template) {
9259 template = String(template || "").trim();
9260 if (!/^c-[A-Za-z0-9][A-Za-z0-9_-]*$/.test(template)) return "";
9262 const file = "dbx/modules/dbxContent/tpl/htm/" + template + ".htm";
9263 return "?dbx_modul=dbxEditor&dbx_run1=edit&file=" + encodeURIComponent(file) + "&dbx_window=1";
9266 function syncContentTemplateEditLink(root, select) {
9267 if (!root || !select) return;
9268 const field = select.getAttribute("data-cms-field") || select.getAttribute("data-cms-folder-field") || "";
9269 if (field !== "template") return;
9271 const wrapper = closestElement(select, ".dbx-cms-field-template");
9272 const link = qs(wrapper, "[data-cms-content-template-edit]");
9275 const template = String(select.value || "").trim();
9276 const url = contentTemplateEditorUrl(template);
9277 const enabled = url !== "";
9279 link.setAttribute("href", enabled ? url : "#");
9280 link.setAttribute("data-url", enabled ? url : "");
9281 link.setAttribute("data-title", enabled ? "Content-Template bearbeiten: " + template : "Content-Template bearbeiten");
9282 link.setAttribute("aria-disabled", enabled ? "false" : "true");
9283 link.setAttribute("tabindex", enabled ? "0" : "-1");
9284 link.setAttribute("title", enabled
9285 ? template + " im ACE-Template-Editor bearbeiten"
9286 : "Zuerst ein c-* Content-Template auswaehlen");
9287 link.classList.toggle("disabled", !enabled);
9290 function openContentTemplateEditor(root, link) {
9291 const wrapper = closestElement(link, ".dbx-cms-field-template");
9292 const select = qs(wrapper, 'select[data-cms-field="template"], select[data-cms-folder-field="template"]');
9293 const template = String(select && select.value || "").trim();
9294 const url = contentTemplateEditorUrl(template);
9296 if (!select || !url) {
9297 if (select) syncContentTemplateEditLink(root, select);
9298 status(root, "Bitte zuerst ein c-* Content-Template auswaehlen.", "error");
9302 if (link.__dbxCmsTemplateConfirming) return;
9303 link.__dbxCmsTemplateConfirming = true;
9305 ensureConfirm().then(ok => {
9306 if (!ok) throw new Error("confirm.js nicht geladen.");
9308 return dbx.confirm.open({
9309 id: "dbx-cms-content-template-edit",
9311 title: '<i class="bi bi-pencil-square"></i> Content-Template bearbeiten',
9312 question: "Das Content-Template <strong>" + escapeHtml(template) + "</strong> wirklich bearbeiten?",
9313 hint: "<strong>Achtung:</strong> Eine Aenderung betrifft jede Seite, die dieses Content-Template verwendet.",
9315 labelyes: '<i class="bi bi-pencil-square"></i> Ja, bearbeiten',
9316 labelno: "Abbrechen"
9319 if (!result || result.action !== "yes") return null;
9321 return Promise.all([ensureAjax(), ensureOpenWin()]).then(ready => {
9322 if (!ready[0]) throw new Error("ajax.js nicht geladen.");
9323 if (!ready[1]) throw new Error("openWin.js nicht geladen.");
9325 return dbx.openWin.open({
9327 title: '<i class="bi bi-pencil-square"></i> Content-Template bearbeiten: ' + escapeHtml(template),
9342 dbx.error("[cms] content template editor failed", err);
9343 status(root, err && err.message ? err.message : "Content-Template-Editor konnte nicht geoeffnet werden.", "error");
9345 link.__dbxCmsTemplateConfirming = false;
9349 function syncDetailsToggleIcon(details) {
9350 const icon = qs(details, ".dbx-cms-toggle-icon:not(.dbx-cms-toggle-icon-open):not(.dbx-cms-toggle-icon-closed)");
9352 icon.classList.toggle("bi-chevron-down", !!details.open);
9353 icon.classList.toggle("bi-chevron-right", !details.open);
9355 const openIcon = qs(details, ".dbx-cms-toggle-icon-open");
9356 const closedIcon = qs(details, ".dbx-cms-toggle-icon-closed");
9357 if (openIcon) openIcon.style.display = details.open ? "inline-block" : "none";
9358 if (closedIcon) closedIcon.style.display = details.open ? "none" : "inline-block";
9361 function initDetailsToggles(root) {
9362 const saved = dbx.uiGet ? dbx.uiGet(LIB, PANEL_UI_ID, "openPanels", {}) : {};
9363 const openPanels = (saved && typeof saved === "object" && !Array.isArray(saved)) ? saved : {};
9364 qsa(root, ".dbx-cms-page-panel, .dbx-cms-settings-panel").forEach((details, index) => {
9365 const key = details.getAttribute("data-cms-ui-state") || ("panel-" + index);
9366 details.setAttribute("data-cms-ui-state", key);
9367 if (Object.prototype.hasOwnProperty.call(openPanels, key)) {
9368 details.open = !!openPanels[key];
9370 syncDetailsToggleIcon(details);
9371 if (details.__dbxCmsToggleReady) return;
9372 details.__dbxCmsToggleReady = true;
9373 details.addEventListener("toggle", () => {
9374 syncDetailsToggleIcon(details);
9375 const state = dbx.uiGet ? dbx.uiGet(LIB, PANEL_UI_ID, "openPanels", {}) : {};
9376 const next = (state && typeof state === "object" && !Array.isArray(state)) ? state : {};
9377 next[key] = !!details.open;
9378 if (dbx.uiSet) dbx.uiSet(LIB, PANEL_UI_ID, "openPanels", next);
9383 function closeCmsSelects(root, except) {
9384 qsa(root, ".dbx-cms-select.is-open").forEach(wrapper => {
9385 if (wrapper === except) return;
9386 wrapper.classList.remove("is-open");
9387 const button = qs(wrapper, ".dbx-cms-select-control");
9388 const menu = qs(wrapper, ".dbx-cms-select-menu");
9389 if (button) button.setAttribute("aria-expanded", "false");
9390 if (menu) menu.hidden = true;
9394 function buildCmsSelect(root, select) {
9395 if (!select || select.multiple || select.dataset.dbxCmsSelectReady === "1") return;
9397 select.dataset.dbxCmsSelectReady = "1";
9398 select.classList.add("dbx-cms-select-source");
9400 const wrapper = document.createElement("div");
9401 wrapper.className = "dbx-cms-select";
9403 const button = document.createElement("button");
9404 button.type = "button";
9405 button.className = "dbx-cms-select-control";
9406 button.setAttribute("aria-haspopup", "listbox");
9407 button.setAttribute("aria-expanded", "false");
9409 const value = document.createElement("span");
9410 value.className = "dbx-cms-select-value";
9412 const icon = document.createElement("i");
9413 icon.className = "bi bi-chevron-down";
9414 icon.setAttribute("aria-hidden", "true");
9416 const menu = document.createElement("div");
9417 menu.className = "dbx-cms-select-menu";
9418 menu.setAttribute("role", "listbox");
9421 button.appendChild(value);
9422 button.appendChild(icon);
9423 wrapper.appendChild(button);
9424 wrapper.appendChild(menu);
9425 select.insertAdjacentElement("afterend", wrapper);
9428 closeCmsSelects(root, wrapper);
9429 wrapper.classList.add("is-open");
9430 button.setAttribute("aria-expanded", "true");
9431 menu.hidden = false;
9436 wrapper.classList.remove("is-open");
9437 button.setAttribute("aria-expanded", "false");
9442 const text = selectedOptionText(select) || cmsText(root, "selection_label", "Auswahl");
9443 value.textContent = text;
9444 button.title = text;
9445 button.disabled = select.disabled;
9446 menu.innerHTML = "";
9448 Array.from(select.options).forEach(option => {
9449 if (option.disabled) return;
9450 const row = document.createElement("button");
9451 row.type = "button";
9452 row.className = "dbx-cms-select-option";
9453 row.dataset.value = option.value;
9454 row.setAttribute("role", "option");
9455 row.setAttribute("aria-selected", option.selected ? "true" : "false");
9456 if (option.selected) row.classList.add("is-selected");
9458 const label = document.createElement("span");
9459 label.textContent = option.textContent.trim();
9460 const check = document.createElement("i");
9461 check.className = option.selected ? "bi bi-check2" : "bi";
9462 check.setAttribute("aria-hidden", "true");
9463 row.appendChild(label);
9464 row.appendChild(check);
9466 row.addEventListener("click", event => {
9467 event.preventDefault();
9468 event.stopPropagation();
9469 select.value = option.value;
9470 syncSelectTitle(select);
9471 select.dispatchEvent(new Event("input", { bubbles: true }));
9472 select.dispatchEvent(new Event("change", { bubbles: true }));
9478 menu.appendChild(row);
9481 syncSelectTitle(select);
9484 button.addEventListener("click", event => {
9485 event.preventDefault();
9486 event.stopPropagation();
9487 if (wrapper.classList.contains("is-open")) close();
9490 button.addEventListener("keydown", event => {
9491 if (event.key === "Escape") {
9495 if (event.key === "Enter" || event.key === " " || event.key === "ArrowDown") {
9496 event.preventDefault();
9500 select.addEventListener("change", render);
9501 select.__dbxCmsSelectRender = render;
9505 function initCmsSelects(root) {
9506 qsa(root, "select[data-cms-field]:not([multiple]), select[data-cms-folder-field]:not([multiple])").forEach(select => {
9507 buildCmsSelect(root, select);
9508 syncContentTemplateEditLink(root, select);
9512 function cmsFieldValue(value) {
9513 return value == null ? "" : value;
9516 function setField(root, name, value) {
9517 const el = qs(root, `[data-cms-field="${name}"]`);
9520 const values = String(value == null ? "" : value).split(",").map(v => v.trim()).filter(Boolean);
9521 Array.from(el.options).forEach(opt => { opt.selected = values.includes(opt.value); });
9525 el.value = value == null ? "" : String(value);
9529 function getField(root, name) {
9530 const el = qs(root, `[data-cms-field="${name}"]`);
9533 return Array.from(el.selectedOptions).map(opt => opt.value).join(",");
9538 function loadViewPage(root, cfg, id, opt) {
9540 const s = state(root);
9541 const url = cfgUrl(cfg, "viewpage");
9542 const box = qs(root, "[data-cms-content-view]");
9543 if (!url || !id) return Promise.resolve();
9544 if (box) box.innerHTML = '<div class="dbx-cms-empty">' + escapeHtml(cmsText(root, "page_loading", "Seite wird geladen...")) + '</div>';
9546 return fetchJson(apiUrl(url, { id }), { footerRuntime: opt.footerRuntime || "" })
9548 if (!data || !data.ok) throw new Error("bad response");
9549 const pageId = Number(data.id || id);
9550 s.page = { id: pageId, title: data.title || "" };
9551 setSelectedPage(root, pageId);
9552 setSelectedType(root, "page");
9553 if (box) box.innerHTML = data.html || '<div class="dbx-cms-empty">Keine Ansicht vorhanden.</div>';
9554 updateViewPageTitle(root, data.title || "");
9555 revealTreeSelection(root);
9558 dbx.error("[cms] content view load failed", err);
9559 if (box) box.innerHTML = '<div class="dbx-cms-empty">' + escapeHtml(cmsText(root, "page_load_error", "Seite konnte nicht geladen werden.")) + '</div>';
9560 status(root, cmsText(root, "page_load_error", "Seite konnte nicht geladen werden."), "error");
9564 function loadPage(root, cfg, id) {
9565 const s = state(root);
9566 const url = cfgUrl(cfg, "page");
9567 const pageId = Number(id || 0);
9568 if (!url || pageId <= 0) return Promise.resolve();
9570 const loadSeq = Number(s.pageLoadSeq || 0) + 1;
9571 s.pageLoadSeq = loadSeq;
9574 return fetchJson(apiUrl(url, Object.assign({ id: pageId }, cmsLngParams(root))))
9576 if (loadSeq !== s.pageLoadSeq) return;
9577 if (!data || !data.ok) throw new Error("bad response");
9578 const row = data.row || {};
9580 hideFolderEditor(root);
9582 s.heroPreviewRow = data.hero_preview_media || null;
9583 s.heroParentPreviewRow = data.hero_parent_preview_media || null;
9584 s.seoPreviewRow = data.seo_preview_media || null;
9585 setSelectedPage(root, row.id || pageId);
9586 setSelectedFolder(root, row.folder || 0);
9587 setSelectedType(root, "page");
9589 ["id", "folder", "title", "menu_title", "permalink", "description", "keywords", "template", "activ", "hero_template", "hero_image_id", "hero_margin_top", "hero_height", "hero_variant", "hero_sticky", "hero_scroll_layer", "gallery_template", "gallery_visible_count", "gallery_image_size", "gallery_lightbox_width", "gallery_overflow", "gallery_click_behavior"].forEach(key => {
9590 setField(root, key, cmsFieldValue(row[key]));
9594 suppressDirtyFor(root, 300);
9595 setEditorHtml(root, row.content || "");
9597 dbx.error("[cms] editor update failed", err);
9600 updateCurrentSelectionTitle(root, "page", row.id || pageId, row.title || "Unbenannte Seite");
9602 revealTreeSelection(root);
9603 renderMedia(root, data.media || []);
9604 renderSeoPreview(root);
9605 setDirty(root, false);
9609 if (loadSeq !== s.pageLoadSeq) return;
9611 dbx.error("[cms] page load failed", err);
9612 status(root, cmsText(root, "page_load_error", "Seite konnte nicht geladen werden."), "error");
9616 function selectTreePage(root, cfg, id) {
9617 const pageId = Number(id || 0);
9618 if (!pageId) return Promise.resolve();
9619 status(root, cmsText(root, "page_loading", "Seite wird geladen..."), "info");
9620 closeTreePanel(root);
9621 return loadPage(root, cfg, pageId);
9624 function selectTreeFolder(root, cfg, row, options) {
9625 if (!row || !root.contains(row) || row.getAttribute("data-type") !== "folder") return;
9626 const id = Number(row.getAttribute("data-id") || 0);
9628 setSelectedFolder(root, id);
9629 setSelectedType(root, "folder");
9631 if (!options || !options.silent) {
9632 status(root, cmsText(root, "folder_selected", "Ordner gewählt."), "info");
9636 function openFolderEditorFromRow(root, cfg, row) {
9637 if (!row || !root.contains(row) || row.getAttribute("data-type") !== "folder") return;
9638 const id = Number(row.getAttribute("data-id") || 0);
9639 const folder = findNode(root, "folder", id);
9640 if (!folder) return;
9641 setSelectedFolder(root, id);
9642 setSelectedType(root, "folder");
9643 showFolderEditor(root, folder, row);
9644 loadMedia(root, cfg);
9645 revealTreeSelection(root);
9646 status(root, cmsText(root, "folder_edit", "Ordner bearbeiten."), "info");
9647 closeTreePanel(root);
9650 function activateTreeRow(root, cfg, row) {
9651 if (!row || !root.contains(row)) return;
9652 const type = row.getAttribute("data-type");
9653 const id = Number(row.getAttribute("data-id") || 0);
9654 const stamp = String(type || "") + ":" + String(id || 0);
9655 const s = state(root);
9656 const now = Date.now();
9657 if (s.lastTreeActivate === stamp && now - Number(s.lastTreeActivateAt || 0) < 350) {
9660 s.lastTreeActivate = stamp;
9661 s.lastTreeActivateAt = now;
9663 if (isViewMode(cfg)) {
9664 if (type === "page") {
9665 loadViewPage(root, cfg, id, { footerRuntime: "visible" }).finally(() => {
9666 applyTreePanelState(root, true);
9667 forceCollapseTreePanel(root);
9668 if (dbx.uiSet) dbx.uiSet(LIB, PANEL_UI_ID, "treeCollapsed", true);
9673 if (type === "folder") {
9674 selectTreeFolder(root, cfg, row, { silent: true });
9677 if (type === "page") {
9678 selectTreePage(root, cfg, id);
9682 function loadMedia(root, cfg) {
9683 const url = cfgUrl(cfg, "media");
9684 const pageId = Number(getField(root, "id") || 0);
9685 const folderId = root.classList.contains("is-folder-editing") ? Number(getFolderField(root, "id") || 0) : 0;
9686 if (!url || (!pageId && !folderId)) return Promise.resolve();
9688 const params = folderId > 0 ? { folder_id: folderId, usage: 1 } : { content_id: pageId, usage: 1 };
9689 return fetchJson(apiUrl(url, params))
9691 if (!data || !data.ok) throw new Error("bad response");
9692 renderMedia(root, data.rows || []);
9695 dbx.warn("[cms] media refresh failed", err);
9699 function reloadCms(root, cfg) {
9700 const s = state(root);
9701 const wasDirty = !!s.dirty;
9702 const selectedType = root.classList.contains("is-folder-editing") || s.selectedType === "folder"
9705 const folderId = selectedType === "folder"
9706 ? Number(getFolderField(root, "id") || s.selectedFolder || 0)
9708 const pageId = selectedType === "page"
9709 ? Number(getField(root, "id") || s.selectedPage || 0)
9711 const finish = () => {
9712 setDirty(root, false);
9716 ? "Neu geladen. Nicht gespeicherte Änderungen wurden verworfen."
9717 : "CMS neu geladen.",
9722 status(root, "CMS wird neu geladen...", "info");
9723 s.selectionRestored = true;
9725 return loadTree(root, cfg).then(() => {
9726 if (selectedType === "folder" && folderId > 0) {
9727 const folder = findNode(root, "folder", folderId);
9729 setSelectedFolder(root, folderId);
9730 setSelectedType(root, "folder");
9731 showFolderEditor(root, folder);
9732 revealTreeSelection(root);
9733 return loadMedia(root, cfg).then(finish);
9737 const currentPageId = pageId > 0 && s.flat.some(node => (
9738 node._type === "page" && Number(node._id) === pageId
9740 const firstPage = currentPageId ? null : s.flat.find(node => node._type === "page");
9741 const reloadPageId = currentPageId || Number(firstPage && firstPage._id || 0);
9743 if (reloadPageId > 0) {
9744 return loadPage(root, cfg, reloadPageId).then(finish);
9747 hideFolderEditor(root);
9748 renderMedia(root, []);
9751 dbx.error("[cms] reload failed", err);
9752 status(root, "CMS konnte nicht neu geladen werden.", "error");
9756 function collectPage(root) {
9757 const html = serializeCmsMarkers(getEditorHtml(root));
9758 setField(root, "content", html);
9761 id: Number(getField(root, "id") || 0),
9762 folder: Number(getField(root, "folder") || 0),
9763 title: getField(root, "title"),
9764 menu_title: getField(root, "menu_title"),
9765 permalink: getField(root, "permalink"),
9766 description: getField(root, "description"),
9767 keywords: getField(root, "keywords"),
9768 template: getField(root, "template"),
9769 hero_template: getField(root, "hero_template") || "parent",
9770 hero_image_id: getField(root, "hero_image_id") || "parent",
9771 hero_margin_top: getField(root, "hero_margin_top") || "parent",
9772 hero_height: getField(root, "hero_height") || "parent",
9773 hero_variant: getField(root, "hero_variant") || "parent",
9774 hero_sticky: getField(root, "hero_sticky") || "parent",
9775 hero_scroll_layer: getField(root, "hero_scroll_layer") || "parent",
9776 gallery_template: "image-gallery",
9777 gallery_visible_count: "3",
9778 gallery_image_size: getField(root, "gallery_image_size") || "original",
9779 gallery_lightbox_width: getField(root, "gallery_lightbox_width") || "100vw",
9780 gallery_overflow: getField(root, "gallery_overflow") || "grid",
9781 gallery_click_behavior: getField(root, "gallery_click_behavior") || "lightbox",
9782 activ: getField(root, "activ"),
9784 inline_media_ids: collectInlineMediaIdsFromEditor(root)
9788 function collectInlineMediaIdsFromEditor(root) {
9789 const surface = editorSurface(root);
9790 const ids = new Set();
9791 const registerId = (value) => {
9792 const id = Number(value || 0);
9793 if (id > 0) ids.add(id);
9795 const registerNode = (node) => {
9796 if (!node || !node.getAttribute) return;
9797 const sourceNode = node.matches?.(".dbx-cms-inline-media")
9798 ? (qs(node, "img[src*='dbx_mid='],video[src*='dbx_mid='],iframe[src*='dbx_mid='],source[src*='dbx_mid=']")
9799 || qs(node, "img[data-cms-media-id],video[data-cms-media-id],iframe[data-cms-media-id],source[data-cms-media-id]"))
9801 const src = String(sourceNode?.getAttribute?.("src") || "");
9802 const match = src.match(/dbx_mid=([0-9]+)/i);
9804 registerId(match[1]);
9807 registerId(node.getAttribute("data-cms-media-id") || sourceNode?.getAttribute?.("data-cms-media-id"));
9811 const selector = ".dbx-cms-inline-media,img[data-cms-media-id],video[data-cms-media-id],iframe[data-cms-media-id],source[data-cms-media-id],img[src*='dbx_mid='],video[src*='dbx_mid='],iframe[src*='dbx_mid='],source[src*='dbx_mid=']";
9812 qsa(surface, selector).forEach(node => {
9813 const wrapper = closestElement(node, ".dbx-cms-inline-media");
9814 if (wrapper && wrapper !== node) return;
9815 if (node.matches?.(".dbx-cms-inline-media") && !inlineMediaWrapperHasContent(node)) return;
9820 return Array.from(ids).filter(Boolean);
9823 function inlineMediaRowsFromEditor(root, rows) {
9824 const ids = collectInlineMediaIdsFromEditor(root);
9825 return ids.map(id => {
9826 const stored = (rows || []).find(row => Number(row.id || row.media_id || 0) === id)
9827 || mediaRowById(root, id);
9829 return Object.assign({}, stored, {
9833 usage_id: String(stored.slot || "") === "inline" ? stored.usage_id : ""
9837 const node = editorMediaNodeById(root, id);
9838 const image = node?.tagName === "IMG" ? node : qs(node, "img");
9839 const src = String(image?.getAttribute("src") || node?.getAttribute("src") || "");
9845 thumb_url: image?.getAttribute("src") || src,
9846 // Getrennte Fragmente verhindern, dass Dokumentationsparser die
9847 // MIME-Wildcards als Beginn verschachtelter Kommentare lesen.
9848 mime: node?.tagName === "VIDEO" ? "video/" + "*" : "image/" + "*",
9849 media_type: node?.tagName === "VIDEO" || closestElement(node, ".dbx-cms-inline-video-block") ? "video" : "image",
9850 title: image?.getAttribute("title") || image?.getAttribute("alt") || node?.getAttribute("title") || ("Medium #" + id),
9851 alt: image?.getAttribute("alt") || "",
9852 width: image?.naturalWidth || image?.getAttribute("width") || "",
9853 height: image?.naturalHeight || image?.getAttribute("height") || ""
9858 function editorInlineMediaSignature(root, html) {
9859 const ids = typeof html === "string"
9860 ? inlineMediaIds(html)
9861 : collectInlineMediaIdsFromEditor(root);
9863 .map(id => Number(id || 0))
9868 function scheduleEditorMediaRender(root, html) {
9870 const signature = editorInlineMediaSignature(root, html);
9871 if (signature === root.__dbxCmsMediaRenderSignature) return;
9872 root.__dbxCmsMediaRenderSignature = signature;
9873 window.clearTimeout(root.__dbxCmsMediaRenderTimer);
9874 root.__dbxCmsMediaRenderTimer = window.setTimeout(() => {
9875 root.__dbxCmsMediaRenderTimer = null;
9880 function focusInlineMediaInEditor(root, id) {
9881 const node = editorMediaNodeById(root, id);
9882 const surface = editorSurface(root);
9883 if (!node || !surface) return false;
9884 qsa(surface, ".is-dbx-cms-selected").forEach(selected => selected.classList.remove("is-dbx-cms-selected"));
9885 const target = closestElement(node, ".dbx-cms-inline-media") || node;
9886 target.classList.add("is-dbx-cms-selected");
9887 selectEditorNode(root, target);
9888 if (target.scrollIntoView) target.scrollIntoView({ behavior: "smooth", block: "center" });
9892 function inlineMediaIds(html) {
9893 const ids = new Set();
9894 String(html || "").replace(/dbx_mid=([0-9]+)/gi, (_, id) => {
9895 ids.add(Number(id));
9898 String(html || "").replace(/data-cms-media-id=["']?([0-9]+)/gi, (_, id) => {
9899 ids.add(Number(id));
9902 return Array.from(ids).filter(Boolean);
9905 function removeInlineMediaFromEditor(root, id) {
9906 id = Number(id || 0);
9907 if (!id) return false;
9909 const html = getEditorHtml(root);
9910 const doc = new DOMParser().parseFromString('<div data-root>' + String(html || "") + '</div>', "text/html");
9911 const wrap = doc.querySelector("[data-root]");
9912 if (!wrap) return false;
9914 let changed = false;
9915 const mediaNodes = Array.from(wrap.querySelectorAll("[data-cms-media-id], img, video, iframe, source"));
9916 mediaNodes.forEach(node => {
9917 if (!node || !node.getAttribute) return;
9919 const dataId = Number(node.getAttribute("data-cms-media-id") || 0);
9920 const src = node.getAttribute("src") || node.getAttribute("href") || "";
9921 const srcMatch = new RegExp("(?:dbx_mid=|data-cms-media-id=)" + id + "(?:[^0-9]|$)", "i").test(src);
9922 if (dataId !== id && !srcMatch) return;
9924 const target = node.closest(".dbx-cms-inline-media, .dbx-cms-inline-media-missing-wrap, figure") || node;
9925 if (target && target.parentNode) {
9926 target.parentNode.removeChild(target);
9931 if (!changed) return false;
9933 setEditorHtml(root, wrap.innerHTML);
9935 scheduleEditorMediaRender(root);
9939 function savePage(root, cfg) {
9940 const url = cfgUrl(cfg, "save");
9941 const s = state(root);
9942 if (!url || s.saving) return Promise.resolve();
9943 let saveCommitted = false;
9944 let committedStatus = cmsText(root, "page_saved", "Seite gespeichert.");
9946 setSaving(root, true);
9948 return fetchJson(apiUrl(url, cmsLngParams(root)), {
9950 headers: { "Content-Type": "application/json" },
9951 body: JSON.stringify(collectPage(root)),
9954 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "save failed");
9955 saveCommitted = true;
9956 const row = data.row || {};
9957 if (row && row.id) {
9959 suppressDirtyFor(root, 250);
9960 ["id", "folder", "title", "menu_title", "permalink", "description", "keywords", "template", "activ", "hero_template", "hero_image_id", "hero_margin_top", "hero_height", "hero_variant", "hero_sticky", "hero_scroll_layer", "gallery_template", "gallery_visible_count", "gallery_image_size", "gallery_lightbox_width", "gallery_overflow", "gallery_click_behavior"].forEach(key => {
9961 if (row[key] !== undefined) setField(root, key, cmsFieldValue(row[key]));
9963 setSelectedPage(root, row.id || data.id || 0);
9964 setSelectedFolder(root, row.folder || 0);
9965 setSelectedType(root, "page");
9967 updateCurrentSelectionTitle(root, "page", row.id || data.id || 0, row.title || "Unbenannte Seite");
9968 if (row.content !== undefined) {
9970 setEditorHtml(root, row.content || "");
9972 dbx.warn("[cms] editor sync after save failed", err);
9977 s.heroPreviewRow = data.hero_preview_media || s.heroPreviewRow || null;
9978 s.heroParentPreviewRow = data.hero_parent_preview_media || s.heroParentPreviewRow || null;
9979 s.seoPreviewRow = data.seo_preview_media || s.seoPreviewRow || null;
9980 if (Array.isArray(data.media)) renderMedia(root, data.media);
9981 renderHeroPreview(root);
9982 renderSeoPreview(root);
9983 const saveStatus = formatLngSaveStatus(cmsText(root, "page_saved", "Seite gespeichert."), data);
9984 committedStatus = saveStatus.text;
9985 status(root, saveStatus.text, saveStatus.type);
9986 clearDirtyAfterSave(root);
9987 clearCmsLoading(root);
9988 return loadMedia(root, cfg)
9990 dbx.warn("[cms] media refresh after save failed", err);
9992 .then(() => loadTree(root, cfg))
9994 dbx.warn("[cms] refresh after save failed", err);
9995 status(root, "Seite gespeichert. Ansicht konnte nicht aktualisiert werden.", "success");
9998 revealTreeSelection(root);
9999 maybeOpenLngProvisionAfterCreate(root, cfg, data);
10004 if (saveCommitted) {
10005 dbx.warn("[cms] post-save refresh failed", err);
10006 status(root, committedStatus + " Ansicht konnte nicht vollstaendig aktualisiert werden.", "success");
10007 clearDirtyAfterSave(root);
10009 dbx.error("[cms] save failed", err);
10010 status(root, err && err.message ? err.message : cmsText(root, "page_save_error", "Seite konnte nicht gespeichert werden."), "error");
10013 setSaving(root, false);
10014 clearCmsLoading(root);
10018 function deletePage(root, cfg) {
10019 const id = Number(getField(root, "id") || 0);
10020 const title = getField(root, "title") || "Seite";
10021 const url = cfgUrl(cfg, "deletepage");
10023 status(root, cmsText(root, "page_select_first", "Bitte zuerst eine Seite auswählen."), "error");
10024 return Promise.resolve();
10027 return openLngDeleteDialog(root, cfg, "page", id, title).catch(err => {
10028 dbx.error("[cms] page delete failed", err);
10029 status(root, err && err.message ? err.message : cmsText(root, "page_delete_error", "Seite konnte nicht gelöscht werden."), "error");
10033 function mediaSlotMatchesBox(boxSlot, slot, mediaFilter) {
10034 if (boxSlot === "gallery") {
10035 // Shop media belongs to one concrete CMS document and is managed
10036 // alongside its regular gallery media in that document's sidebar.
10037 return slot === "gallery" || slot === "shop";
10039 if (boxSlot === "inline" || boxSlot === "shop") return slot === boxSlot;
10040 if (boxSlot === "all" && mediaFilter !== "all" && slot !== mediaFilter) return false;
10041 if (slot === "hero" && boxSlot !== "all") return false;
10045 function renderMedia(root, rows) {
10046 const boxes = qsa(root, "[data-cms-media]");
10047 if (!boxes.length) return;
10048 root.__dbxCmsMediaRenderSignature = editorInlineMediaSignature(root);
10049 const s = state(root);
10050 if (Array.isArray(rows)) s.mediaRows = rows.slice();
10051 rows = s.mediaRows || [];
10052 renderHeroPreview(root);
10053 renderSeoPreview(root);
10055 syncUploadSlot(root);
10056 const prefixes = { hero: "h", gallery: "g", inline: "i", shop: "s" };
10057 const mediaFilter = String(qs(root, "[data-cms-media-filter]")?.value || state(root).mediaFilter || "all");
10059 boxes.forEach(box => {
10060 const boxSlot = String(box.getAttribute("data-cms-media") || "all");
10061 const visible = boxSlot === "inline"
10062 ? inlineMediaRowsFromEditor(root, rows)
10063 : (Array.isArray(rows) ? rows : []).filter(row => {
10064 const slot = String(row.slot || "").trim();
10065 return mediaSlotMatchesBox(boxSlot, slot, mediaFilter);
10068 if (!visible.length) {
10069 const empty = boxSlot === "gallery"
10070 ? cmsText(root, "media_gallery_empty", "Keine Medien in der Galerie.")
10071 : (boxSlot === "inline"
10072 ? cmsText(root, "media_inline_empty", "Keine Medien im Text.")
10073 : (mediaFilter === "hero"
10074 ? cmsText(root, "media_hero_empty", "Kein Hero-Medium zugeordnet.")
10075 : cmsText(root, "media_area_empty", "Keine Medien in diesem Bereich.")));
10076 box.innerHTML = `<div class="dbx-cms-empty">${empty}</div>`;
10080 const counters = {};
10081 box.innerHTML = visible.map(row => {
10082 const preview = mediaPreviewHtml(row);
10083 const canEmbed = !!row.url;
10084 const slot = String(row.slot || "inline");
10085 counters[slot] = (counters[slot] || 0) + 1;
10086 const badge = (prefixes[slot] || "m") + counters[slot];
10087 const originLabel = mediaOriginLabel(row);
10088 const actions = boxSlot === "inline"
10089 ? `<button type="button" class="btn btn-outline-primary btn-sm" data-cms-inline-focus title="${escapeHtml(cmsText(root, "media_inline_focus", "Im Editor auswählen"))}"><i class="bi bi-crosshair"></i></button>
10090 ${canEditImage(row) ? `<button type="button" class="btn btn-outline-primary btn-sm" data-cms-media-edit-one title="${escapeHtml(cmsText(root, "media_inline_edit", "Bild bearbeiten"))}"><i class="bi bi-pencil-square"></i></button>` : ""}
10091 <button type="button" class="btn btn-outline-primary btn-sm" data-cms-inline-remove title="${escapeHtml(cmsText(root, "media_inline_remove", "Aus Inhalt entfernen"))}"><i class="bi bi-trash"></i></button>`
10092 : `${canEmbed ? '<button type="button" class="btn btn-outline-primary btn-sm" data-cms-media-embed title="Medium direkt in den Editor einfuegen"><i class="bi bi-image"></i></button>' : ''}
10093 <button type="button" class="btn btn-outline-primary btn-sm" data-cms-media-remove title="Zuordnung aus dieser Seite entfernen"><i class="bi bi-trash"></i></button>`;
10094 return `<div class="dbx-cms-media-item" draggable="true" data-media-id="${escapeHtml(row.id || "")}" data-usage-id="${escapeHtml(row.usage_id || row.current_usage_id || "")}" data-media-slot="${escapeHtml(slot)}" data-media-folder="${escapeHtml(row.media_folder || "")}" data-url="${escapeHtml(row.url || "")}" data-thumb-url="${escapeHtml(row.thumb_url || "")}" data-mime="${escapeHtml(row.mime || "")}" data-media-type="${escapeHtml(row.media_type || "")}" data-file-name="${escapeHtml(row.file_name || "")}" data-file-path="${escapeHtml(row.file_path || "")}" data-title="${escapeHtml(row.title || "")}" data-alt="${escapeHtml(row.alt || "")}" data-width="${escapeHtml(row.width || "")}" data-height="${escapeHtml(row.height || "")}">
10095 <span class="dbx-cms-media-preview"><span class="dbx-cms-media-badge">${escapeHtml(badge)}</span>${preview}</span>
10096 <span class="dbx-cms-media-meta">
10097 <span class="dbx-cms-media-name">${escapeHtml(row.title || row.file_name || "Medium")}</span>
10098 <span class="dbx-cms-media-slot">${escapeHtml(originLabel)}</span>
10100 <span class="dbx-cms-media-actions">
10105 setupMediaLazyImages(box);
10109 function removeMedia(root, cfg, id, usageId, slot) {
10110 const url = cfgUrl(cfg, "removemedia");
10111 if (!url || (!id && !usageId)) return Promise.resolve();
10112 const pageId = Number(getField(root, "id") || 0);
10113 const folderId = root.classList.contains("is-folder-editing")
10114 ? Number(getFolderField(root, "id") || 0)
10115 : Number(getField(root, "folder") || 0);
10117 id: Number(id || 0),
10118 usage_id: Number(usageId || 0),
10119 content_id: pageId,
10120 folder_id: folderId || 0,
10123 return fetchJson(apiUrl(url), {
10125 headers: { "Content-Type": "application/json" },
10126 body: JSON.stringify(payload)
10129 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "remove failed");
10130 if (slot === "inline") removeInlineMediaFromEditor(root, id);
10131 if (Array.isArray(data.media || data.rows)) renderMedia(root, data.media || data.rows);
10132 status(root, "Medien-Zuordnung entfernt.", "success");
10133 return Array.isArray(data.media || data.rows) ? Promise.resolve(data) : loadMedia(root, cfg);
10136 dbx.error("[cms] media remove failed", err);
10137 status(root, "Medien-Zuordnung konnte nicht entfernt werden.", "error");
10141 function deleteMedia(root, cfg, id) {
10142 const url = cfgUrl(cfg, "deletemedia");
10143 if (!url || !id) return Promise.resolve();
10145 return fetchJson(apiUrl(url, { id }), {
10147 headers: { "Content-Type": "application/json" },
10148 body: JSON.stringify({ id: Number(id) })
10150 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "delete failed");
10151 status(root, "Mediendatei geloescht.", "success");
10152 return loadMedia(root, cfg);
10154 dbx.error("[cms] media delete failed", err);
10155 status(root, "Mediendatei konnte nicht geloescht werden.", "error");
10159 function autoRatioValue(changed, other, originalWidth, originalHeight) {
10160 const value = Number(changed?.value || 0);
10161 if (!changed || !other || !value || !originalWidth || !originalHeight) return;
10162 if (changed.hasAttribute("data-cms-media-edit-width")) {
10163 other.value = Math.max(1, Math.round(value * originalHeight / originalWidth));
10165 other.value = Math.max(1, Math.round(value * originalWidth / originalHeight));
10169 function mediaEditNeedsRebuild(modal) {
10170 return !modal || !!qs(modal, ".dbx-cms-media-edit-actions") || !qs(modal, "[data-cms-media-edit-status]");
10173 function fitMediaEditDialog(modal) {
10174 const maxW = Math.min(1380, Math.floor(window.innerWidth * 0.96));
10175 const maxH = Math.min(1010, Math.floor(window.innerHeight * 0.94));
10176 const width = Math.min(760, maxW);
10177 const height = Math.min(640, maxH);
10178 modal.style.width = width + "px";
10179 modal.style.height = height + "px";
10180 modal.style.maxWidth = maxW + "px";
10181 modal.style.maxHeight = maxH + "px";
10184 function ensureMediaEditDialog(root) {
10185 const s = state(root);
10186 let modal = s.mediaEditDialog || qs(root, "[data-cms-media-edit]");
10187 if (modal && !document.documentElement.contains(modal)) {
10189 s.mediaEditDialog = null;
10191 if (mediaEditNeedsRebuild(modal)) {
10192 if (modal) modal.remove();
10194 s.mediaEditDialog = null;
10197 if (modal.parentNode !== document.body) document.body.appendChild(modal);
10198 modal.__dbxCmsRoot = root;
10199 s.mediaEditDialog = modal;
10202 modal = document.createElement("div");
10203 modal.className = "dbx-cms-media-edit";
10204 modal.setAttribute("data-cms-media-edit", "1");
10205 modal.__dbxCmsRoot = root;
10206 modal.hidden = true;
10207 modal.innerHTML = `
10208 <div class="dbx-cms-media-edit-head">
10209 <strong><i class="bi bi-crop"></i> Bild bearbeiten</strong>
10210 <button type="button" class="btn btn-outline-secondary btn-sm" data-cms-media-edit-close title="Schliessen"><i class="bi bi-x-lg"></i></button>
10212 <div class="dbx-cms-media-edit-status" data-cms-media-edit-status aria-live="polite" hidden></div>
10213 <div class="dbx-cms-media-edit-body">
10214 <div class="dbx-cms-media-edit-preview" data-cms-media-edit-preview>
10215 <div class="dbx-cms-media-edit-stage" data-cms-media-edit-stage>
10216 <img src="" alt="" draggable="false" data-cms-media-edit-image>
10217 <span class="dbx-cms-media-edit-selection" data-cms-media-edit-selection hidden>
10218 <span class="dbx-cms-media-edit-selection-size" data-cms-media-edit-selection-size></span>
10221 <span class="dbx-cms-media-edit-info" data-cms-media-edit-info>X 0 px · Y 0 px · 0 x 0 px</span>
10223 <div class="dbx-cms-media-edit-grid">
10224 <label>X <input type="number" class="form-control form-control-sm" data-cms-media-edit-x value="0"></label>
10225 <label>Y <input type="number" class="form-control form-control-sm" data-cms-media-edit-y value="0"></label>
10226 <label>Breite <input type="number" class="form-control form-control-sm" data-cms-media-edit-width></label>
10227 <label>Hoehe <input type="number" class="form-control form-control-sm" data-cms-media-edit-height></label>
10228 <label class="dbx-cms-media-edit-ratio"><input type="checkbox" data-cms-media-edit-ratio checked> Ratio Resize</label>
10229 <div class="dbx-cms-media-edit-side-actions">
10230 <button type="button" class="btn btn-outline-primary btn-sm" data-cms-media-edit-resize><i class="bi bi-arrows-angle-contract"></i><span>Resize</span></button>
10231 <button type="button" class="btn btn-primary btn-sm" data-cms-media-edit-crop><i class="bi bi-crop"></i><span>Zuschneiden</span></button>
10232 <span class="dbx-cms-media-edit-side-separator" aria-hidden="true"></span>
10233 <span class="dbx-cms-media-edit-hint">
10234 <span>Mit der Maus den gewuenschten Bildausschnitt waehlen.</span>
10235 <button type="button" class="btn btn-danger btn-sm" data-cms-media-edit-crop-apply>
10236 <i class="bi bi-crop"></i><span>Ausschnitt uebernehmen</span>
10242 document.body.appendChild(modal);
10243 s.mediaEditDialog = modal;
10247 function setMediaEditLocalStatus(modal, message, type) {
10248 const local = qs(modal, "[data-cms-media-edit-status]");
10249 if (!local) return;
10250 message = String(message || "");
10251 local.textContent = message;
10252 local.hidden = !message;
10253 local.className = "dbx-cms-media-edit-status" + (message && type ? " is-" + type : "");
10256 function reportMediaEditStatus(root, modal, message, type) {
10257 status(root, message, type);
10258 setMediaEditLocalStatus(modal, message, type);
10261 function mediaEditImageSize(modal) {
10262 const img = qs(modal, "[data-cms-media-edit-image]");
10263 const row = modal.__dbxCmsEditRow || {};
10265 width: Number(img?.naturalWidth || row.width || 0),
10266 height: Number(img?.naturalHeight || row.height || 0)
10270 function clampMediaCrop(value, min, max) {
10271 return Math.max(min, Math.min(max, value));
10274 function renderMediaCropSelection(modal) {
10275 const preview = qs(modal, "[data-cms-media-edit-preview]");
10276 const stage = qs(modal, "[data-cms-media-edit-stage]");
10277 const img = qs(modal, "[data-cms-media-edit-image]");
10278 const selection = qs(modal, "[data-cms-media-edit-selection]");
10279 const info = qs(modal, "[data-cms-media-edit-info]");
10280 if (!preview || !stage || !img || !selection || !img.complete) {
10281 if (info) info.textContent = "X 0 px · Y 0 px · 0 x 0 px";
10285 const natural = mediaEditImageSize(modal);
10286 const imageRect = img.getBoundingClientRect();
10287 const stageRect = stage.getBoundingClientRect();
10288 if (!natural.width || !natural.height || !imageRect.width || !imageRect.height) return;
10290 const x = clampMediaCrop(Number(qs(modal, "[data-cms-media-edit-x]")?.value || 0), 0, natural.width - 1);
10291 const y = clampMediaCrop(Number(qs(modal, "[data-cms-media-edit-y]")?.value || 0), 0, natural.height - 1);
10292 const width = clampMediaCrop(Number(qs(modal, "[data-cms-media-edit-width]")?.value || natural.width), 1, natural.width - x);
10293 const height = clampMediaCrop(Number(qs(modal, "[data-cms-media-edit-height]")?.value || natural.height), 1, natural.height - y);
10294 const scaleX = imageRect.width / natural.width;
10295 const scaleY = imageRect.height / natural.height;
10296 const infoText = "X " + Math.round(x) + " px · Y " + Math.round(y) + " px · "
10297 + Math.round(width) + " x " + Math.round(height) + " px";
10298 if (info) info.textContent = infoText;
10300 selection.style.left = (imageRect.left - stageRect.left + x * scaleX) + "px";
10301 selection.style.top = (imageRect.top - stageRect.top + y * scaleY) + "px";
10302 selection.style.width = (width * scaleX) + "px";
10303 selection.style.height = (height * scaleY) + "px";
10305 const size = qs(selection, "[data-cms-media-edit-selection-size]");
10306 const isFullFrame = x === 0 && y === 0 && width >= natural.width && height >= natural.height;
10307 if (!modal.__dbxCmsCropActive && isFullFrame) {
10308 selection.hidden = true;
10309 if (size) size.textContent = "";
10312 selection.hidden = false;
10314 if (size) size.textContent = Math.round(width) + " × " + Math.round(height);
10317 function setMediaCropFields(modal, crop) {
10318 modal.__dbxCmsCropActive = true;
10319 const natural = mediaEditImageSize(modal);
10320 if (!natural.width || !natural.height) return;
10321 const x = clampMediaCrop(Math.round(Math.min(crop.x1, crop.x2)), 0, natural.width - 1);
10322 const y = clampMediaCrop(Math.round(Math.min(crop.y1, crop.y2)), 0, natural.height - 1);
10323 const right = clampMediaCrop(Math.round(Math.max(crop.x1, crop.x2)), x + 1, natural.width);
10324 const bottom = clampMediaCrop(Math.round(Math.max(crop.y1, crop.y2)), y + 1, natural.height);
10325 const width = qs(modal, "[data-cms-media-edit-width]");
10326 const height = qs(modal, "[data-cms-media-edit-height]");
10327 const inputX = qs(modal, "[data-cms-media-edit-x]");
10328 const inputY = qs(modal, "[data-cms-media-edit-y]");
10329 if (inputX) inputX.value = x;
10330 if (inputY) inputY.value = y;
10331 if (width) width.value = right - x;
10332 if (height) height.value = bottom - y;
10333 renderMediaCropSelection(modal);
10336 function mediaCropPoint(modal, event) {
10337 const img = qs(modal, "[data-cms-media-edit-image]");
10338 const natural = mediaEditImageSize(modal);
10339 if (!img || !natural.width || !natural.height) return null;
10340 const rect = img.getBoundingClientRect();
10341 if (!rect.width || !rect.height) return null;
10343 x: clampMediaCrop((event.clientX - rect.left) * natural.width / rect.width, 0, natural.width),
10344 y: clampMediaCrop((event.clientY - rect.top) * natural.height / rect.height, 0, natural.height),
10345 inside: event.clientX >= rect.left && event.clientX <= rect.right
10346 && event.clientY >= rect.top && event.clientY <= rect.bottom
10350 function mediaEditPayload(modal, action) {
10351 const row = modal.__dbxCmsEditRow || {};
10354 id: Number(row.id || 0),
10355 width: Number(qs(modal, "[data-cms-media-edit-width]")?.value || 0),
10356 height: Number(qs(modal, "[data-cms-media-edit-height]")?.value || 0),
10357 ratio: qs(modal, "[data-cms-media-edit-ratio]")?.checked ? 1 : 0,
10358 x: Number(qs(modal, "[data-cms-media-edit-x]")?.value || 0),
10359 y: Number(qs(modal, "[data-cms-media-edit-y]")?.value || 0)
10363 function resetMediaEditSelection(modal) {
10364 const x = qs(modal, "[data-cms-media-edit-x]");
10365 const y = qs(modal, "[data-cms-media-edit-y]");
10366 const w = qs(modal, "[data-cms-media-edit-width]");
10367 const h = qs(modal, "[data-cms-media-edit-height]");
10368 const selection = qs(modal, "[data-cms-media-edit-selection]");
10369 const natural = mediaEditImageSize(modal);
10370 modal.__dbxCmsCropActive = false;
10371 if (x) x.value = "0";
10372 if (y) y.value = "0";
10373 if (w && natural.width) w.value = natural.width;
10374 if (h && natural.height) h.value = natural.height;
10375 if (selection) selection.hidden = true;
10376 renderMediaCropSelection(modal);
10379 function previewMediaCrop(root, modal) {
10380 const img = qs(modal, "[data-cms-media-edit-image]");
10381 const selection = qs(modal, "[data-cms-media-edit-selection]");
10382 const payload = mediaEditPayload(modal, "crop");
10383 const natural = mediaEditImageSize(modal);
10385 reportMediaEditStatus(root, modal, "Kein Bild ausgewaehlt.", "error");
10388 if (!modal.__dbxCmsCropActive) {
10389 reportMediaEditStatus(root, modal, "Bitte zuerst einen Bildausschnitt waehlen.", "error");
10392 if (!img || !img.complete || !natural.width || !natural.height || payload.width < 1 || payload.height < 1) {
10393 reportMediaEditStatus(root, modal, "Bitte einen gueltigen Ausschnitt waehlen.", "error");
10396 const x = clampMediaCrop(Math.round(payload.x), 0, natural.width - 1);
10397 const y = clampMediaCrop(Math.round(payload.y), 0, natural.height - 1);
10398 const width = clampMediaCrop(Math.round(payload.width), 1, natural.width - x);
10399 const height = clampMediaCrop(Math.round(payload.height), 1, natural.height - y);
10400 const canvas = document.createElement("canvas");
10401 canvas.width = width;
10402 canvas.height = height;
10403 const ctx = canvas.getContext("2d");
10405 reportMediaEditStatus(root, modal, "Vorschau konnte nicht erstellt werden.", "error");
10409 ctx.drawImage(img, x, y, width, height, 0, 0, width, height);
10411 dbx.error("[cms] crop preview failed", err);
10412 reportMediaEditStatus(root, modal, "Vorschau konnte nicht erstellt werden.", "error");
10415 const previous = modal.__dbxCmsPendingCrop || { x: 0, y: 0 };
10416 modal.__dbxCmsPendingCrop = {
10418 x: Math.round(Number(previous.x || 0) + x),
10419 y: Math.round(Number(previous.y || 0) + y),
10423 img.onload = () => {
10424 resetMediaEditSelection(modal);
10425 fitMediaEditDialog(modal);
10427 img.src = canvas.toDataURL("image/png");
10428 if (selection) selection.hidden = true;
10429 reportMediaEditStatus(root, modal, "Ausschnitt als Vorschau erstellt. Mit Ausschnitt uebernehmen speichern.", "info");
10433 function refreshMediaEditPreview(modal, row) {
10434 if (!modal || !row) return;
10435 const img = qs(modal, "[data-cms-media-edit-image]");
10436 const w = qs(modal, "[data-cms-media-edit-width]");
10437 const h = qs(modal, "[data-cms-media-edit-height]");
10438 const x = qs(modal, "[data-cms-media-edit-x]");
10439 const y = qs(modal, "[data-cms-media-edit-y]");
10440 const selection = qs(modal, "[data-cms-media-edit-selection]");
10441 modal.__dbxCmsEditRow = row;
10442 modal.__dbxCmsPendingCrop = null;
10443 modal.__dbxCmsCropActive = false;
10444 if (x) x.value = "0";
10445 if (y) y.value = "0";
10446 if (selection) selection.hidden = true;
10447 const baseUrl = row.url || row.thumb_url || "";
10448 const url = baseUrl ? apiUrl(baseUrl) : "";
10449 if (w) w.value = row.width || "";
10450 if (h) h.value = row.height || "";
10451 if (!img || !url) return;
10452 img.onload = () => {
10453 const natural = mediaEditImageSize(modal);
10454 if (w && natural.width) w.value = natural.width;
10455 if (h && natural.height) h.value = natural.height;
10456 if (selection) selection.hidden = true;
10457 fitMediaEditDialog(modal);
10458 renderMediaCropSelection(modal);
10463 function reopenMediaBrowserAfterEdit(root, browser) {
10464 if (!browser || browser.hidden) return;
10465 openMediaBrowser(browser.__dbxCmsRoot || root, browserCfg(browser), {
10466 mode: browser.__dbxCmsMediaMode || "editor",
10467 slot: browser.__dbxCmsAssignSlot || currentMediaSlot(root),
10468 mediaFolder: browser.__dbxCmsMediaFolder || "",
10469 formDataExtra: browser.__dbxCmsFormDataExtra || null,
10470 afterAssign: browser.__dbxCmsAfterAssign
10474 function commitMediaEditAction(root, cfg, modal, action, options) {
10475 options = options || {};
10476 let payload = mediaEditPayload(modal, action);
10478 reportMediaEditStatus(root, modal, "Kein Bild ausgewaehlt.", "error");
10479 return Promise.resolve(false);
10481 if (action === "resize" && modal.__dbxCmsPendingCrop) {
10482 reportMediaEditStatus(root, modal, "Bitte den Ausschnitt zuerst uebernehmen oder den Dialog neu oeffnen.", "warning");
10483 return Promise.resolve(false);
10485 if (action === "crop" && modal.__dbxCmsPendingCrop) {
10486 payload = Object.assign({}, payload, {
10487 x: Number(modal.__dbxCmsPendingCrop.x || 0),
10488 y: Number(modal.__dbxCmsPendingCrop.y || 0),
10489 width: Number(modal.__dbxCmsPendingCrop.width || 0),
10490 height: Number(modal.__dbxCmsPendingCrop.height || 0)
10493 if (action === "crop" && !modal.__dbxCmsPendingCrop && !modal.__dbxCmsCropActive) {
10494 reportMediaEditStatus(root, modal, "Bitte zuerst einen Bildausschnitt waehlen.", "error");
10495 return Promise.resolve(false);
10497 if (action === "crop" && (payload.width < 1 || payload.height < 1)) {
10498 reportMediaEditStatus(root, modal, "Bitte einen gueltigen Ausschnitt waehlen.", "error");
10499 return Promise.resolve(false);
10501 const successMsg = action === "crop"
10502 ? "Bild zugeschnitten."
10503 : "Bild bearbeitet.";
10504 return editMedia(root, cfg, payload, {
10509 if (!data || !data.ok) {
10510 setMediaEditLocalStatus(modal, "Bild konnte nicht bearbeitet werden.", "error");
10513 const updated = Array.isArray(data.rows) && data.rows[0] ? data.rows[0] : null;
10515 setMediaEditLocalStatus(modal, "Die aktualisierten Bilddaten fehlen.", "error");
10518 setMediaEditLocalStatus(modal, successMsg, "success");
10519 const browserModal = qs(document, "[data-cms-media-browser]");
10520 if (browserModal) patchMediaBrowserRow(browserModal, updated);
10521 loadMedia(root, cfg);
10522 if (options.closeAfter) {
10523 modal.hidden = true;
10524 reopenMediaBrowserAfterEdit(root, qs(document, "[data-cms-media-browser]"));
10527 refreshMediaEditPreview(modal, updated);
10532 function bindMediaEditEvents(root, modal) {
10533 if (!modal || modal.__dbxCmsEventsBound) return;
10534 modal.__dbxCmsEventsBound = true;
10536 // Direkt am Dialog binden. Der Medienbrowser lebt in einem openWin-Fenster
10537 // und weitere UI-Layer koennen die delegierten Root-Events abfangen.
10538 modal.addEventListener("click", e => {
10539 const close = closestElement(e.target, "[data-cms-media-edit-close]");
10540 if (close && modal.contains(close)) {
10541 e.preventDefault();
10542 e.stopPropagation();
10543 modal.hidden = true;
10547 const resize = closestElement(e.target, "[data-cms-media-edit-resize]");
10548 const cropApply = closestElement(e.target, "[data-cms-media-edit-crop-apply]");
10549 const cropSave = closestElement(e.target, "[data-cms-media-edit-crop]");
10550 const cfg = modal.__dbxCmsCfg || {};
10552 if (cropApply && modal.contains(cropApply)) {
10553 e.preventDefault();
10554 e.stopPropagation();
10555 commitMediaEditAction(root, cfg, modal, "crop", { closeAfter: false });
10559 if (cropSave && modal.contains(cropSave)) {
10560 e.preventDefault();
10561 e.stopPropagation();
10562 previewMediaCrop(root, modal);
10566 if (resize && modal.contains(resize)) {
10567 e.preventDefault();
10568 e.stopPropagation();
10569 commitMediaEditAction(root, cfg, modal, "resize", { closeAfter: false });
10573 modal.addEventListener("input", e => {
10574 const editInput = closestElement(e.target, "[data-cms-media-edit-x], [data-cms-media-edit-y], [data-cms-media-edit-width], [data-cms-media-edit-height]");
10575 if (!editInput || !modal.contains(editInput)) return;
10576 modal.__dbxCmsCropActive = true;
10577 if (editInput.hasAttribute("data-cms-media-edit-x") || editInput.hasAttribute("data-cms-media-edit-y")) {
10578 renderMediaCropSelection(modal);
10581 const ratio = qs(modal, "[data-cms-media-edit-ratio]");
10582 if (ratio && ratio.checked && !modal.__dbxCmsCropSelecting) {
10583 const row = modal.__dbxCmsEditRow || {};
10584 const w = qs(modal, "[data-cms-media-edit-width]");
10585 const h = qs(modal, "[data-cms-media-edit-height]");
10586 autoRatioValue(editInput, editInput === w ? h : w, Number(row.width || 0), Number(row.height || 0));
10588 renderMediaCropSelection(modal);
10591 const preview = qs(modal, "[data-cms-media-edit-preview]");
10593 preview.addEventListener("pointerdown", e => {
10594 if (e.button !== undefined && e.button !== 0) return;
10595 const point = mediaCropPoint(modal, e);
10596 if (!point || !point.inside) return;
10597 e.preventDefault();
10598 e.stopPropagation();
10599 modal.__dbxCmsCropSelecting = {
10600 pointerId: e.pointerId,
10606 if (preview.setPointerCapture && e.pointerId !== undefined) {
10607 preview.setPointerCapture(e.pointerId);
10609 setMediaCropFields(modal, modal.__dbxCmsCropSelecting);
10612 preview.addEventListener("pointermove", e => {
10613 const crop = modal.__dbxCmsCropSelecting;
10614 if (!crop || (e.pointerId !== undefined && crop.pointerId !== e.pointerId)) return;
10615 const point = mediaCropPoint(modal, e);
10616 if (!point) return;
10617 e.preventDefault();
10620 setMediaCropFields(modal, crop);
10623 const finishSelection = e => {
10624 const crop = modal.__dbxCmsCropSelecting;
10625 if (!crop || (e.pointerId !== undefined && crop.pointerId !== e.pointerId)) return;
10626 const point = mediaCropPoint(modal, e);
10630 setMediaCropFields(modal, crop);
10632 if (preview.releasePointerCapture && e.pointerId !== undefined && preview.hasPointerCapture?.(e.pointerId)) {
10633 preview.releasePointerCapture(e.pointerId);
10635 modal.__dbxCmsCropSelecting = null;
10637 preview.addEventListener("pointerup", finishSelection);
10638 preview.addEventListener("pointercancel", finishSelection);
10641 window.addEventListener("resize", () => {
10642 if (!modal.hidden) {
10643 fitMediaEditDialog(modal);
10644 renderMediaCropSelection(modal);
10649 function openMediaEdit(root, cfg, row) {
10650 if (!row || !canEditImage(row)) {
10651 status(root, "Nur Rasterbilder koennen bearbeitet werden.", "error");
10654 const modal = ensureMediaEditDialog(root);
10655 bindMediaEditEvents(root, modal);
10656 // Der Medienbrowser kann aus einem CMS-openWin heraus ein weiteres openWin oeffnen.
10657 // Der Bildeditor liegt deshalb als Body-Layer ausserhalb des CMS-Fenster-Stacking-Contexts.
10658 const windowLevels = qsa(document, ".dbx-window")
10659 .map(win => Number(window.getComputedStyle(win).zIndex || 0))
10660 .filter(Number.isFinite);
10661 modal.style.zIndex = String(Math.max(3200, ...windowLevels) + 10);
10662 modal.__dbxCmsCfg = cfg || {};
10663 modal.__dbxCmsEditRow = row;
10664 modal.__dbxCmsPendingCrop = null;
10665 modal.__dbxCmsCropActive = false;
10666 setMediaEditLocalStatus(modal, "", "");
10667 const img = qs(modal, "[data-cms-media-edit-image]");
10668 const w = qs(modal, "[data-cms-media-edit-width]");
10669 const h = qs(modal, "[data-cms-media-edit-height]");
10670 const x = qs(modal, "[data-cms-media-edit-x]");
10671 const y = qs(modal, "[data-cms-media-edit-y]");
10672 const ratio = qs(modal, "[data-cms-media-edit-ratio]");
10674 img.onload = () => {
10675 const natural = mediaEditImageSize(modal);
10676 if (w && natural.width) w.value = natural.width;
10677 if (h && natural.height) h.value = natural.height;
10678 fitMediaEditDialog(modal);
10679 renderMediaCropSelection(modal);
10681 img.src = apiUrl(row.url || row.thumb_url || "");
10683 if (w) w.value = row.width || "";
10684 if (h) h.value = row.height || "";
10685 if (x) x.value = "0";
10686 if (y) y.value = "0";
10687 if (ratio) ratio.checked = true;
10688 modal.hidden = false;
10689 if (img && img.complete) {
10690 fitMediaEditDialog(modal);
10691 renderMediaCropSelection(modal);
10695 function editMedia(root, cfg, payload, options) {
10696 options = options || {};
10697 const url = cfgUrl(cfg, "editmedia");
10699 status(root, "Medienbearbeitung ist nicht konfiguriert.", "error");
10700 return Promise.resolve();
10702 return fetchJson(apiUrl(url), {
10704 headers: { "Content-Type": "application/json" },
10705 body: JSON.stringify(payload || {})
10707 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "edit failed");
10708 if (options.silent !== true) status(root, options.successMsg || "Bild bearbeitet.", "success");
10709 if (options.reload === false) return data;
10710 return loadMedia(root, cfg).then(() => data);
10712 dbx.error("[cms] media edit failed", err);
10713 if (options.silent !== true) status(root, err && err.message ? err.message : "Bild konnte nicht bearbeitet werden.", "error");
10718 function mediaRowsForResize(root, scope, modal) {
10720 if (scope === "all") {
10721 const shown = Array.isArray(modal.__dbxCmsFilteredRows)
10722 ? modal.__dbxCmsFilteredRows
10723 : mediaBrowserAllRows(modal);
10724 return shown.filter(canEditImage);
10726 if (scope === "visible") {
10727 return mediaBrowserRows(modal).filter(canEditImage);
10729 return selectedMediaBrowserImageRows(modal);
10732 const rows = state(root).mediaRows || [];
10733 if (scope === "all") {
10734 return rows.filter(canEditImage);
10736 if (scope === "visible") {
10737 const visibleIds = qsa(root, ".dbx-cms-media-item").map(el => Number(el.getAttribute("data-media-id") || 0)).filter(Boolean);
10738 return rows.filter(row => visibleIds.includes(Number(row.id || 0)) && canEditImage(row));
10740 const ids = qsa(root, "[data-cms-media-select]:checked").map(el => Number(el.value || 0)).filter(Boolean);
10741 return rows.filter(row => ids.includes(Number(row.id || 0)) && canEditImage(row));
10744 function bulkResizeMedia(root, cfg, scope, modal) {
10745 scope = scope || "selected";
10746 const rows = mediaRowsForResize(root, scope, modal);
10747 const ids = rows.map(row => Number(row.id || 0)).filter(Boolean);
10748 const host = batchControlHost(modal) || modal || root;
10749 const width = Number(qs(host, "[data-cms-bulk-resize-width]")?.value || 0);
10750 const height = Number(qs(host, "[data-cms-bulk-resize-height]")?.value || 0);
10751 const ratio = qs(host, "[data-cms-bulk-resize-ratio]")?.checked !== false;
10753 status(root, scope === "selected" ? "Bitte erst Bilder auswaehlen." : "Keine bearbeitbaren Bilder gefunden.", "error");
10756 if (!width && !height) {
10757 status(root, "Bitte Breite oder Hoehe fuer Resize eintragen.", "error");
10760 let chain = Promise.resolve({ ok: 0, failed: 0 });
10761 ids.forEach(id => {
10762 chain = chain.then(result => editMedia(root, cfg, {
10767 ratio: ratio ? 1 : 0
10772 if (ok) result.ok++;
10773 else result.failed++;
10774 status(root, "Resize laeuft: " + result.ok + " von " + ids.length + " Bildern bearbeitet.", "info");
10779 chain.then(result => {
10781 status(root, "Keine Bilder konnten resized werden.", "error");
10784 status(root, result.failed ? (result.ok + " Bilder resized, " + result.failed + " fehlgeschlagen.") : (result.ok + " Bilder resized."), result.failed ? "error" : "success");
10785 loadMedia(root, cfg);
10786 if (!modal || modal.hidden) return;
10787 openMediaBrowser(modal.__dbxCmsRoot || root, browserCfg(modal), {
10788 mode: modal.__dbxCmsMediaMode || "editor",
10789 slot: modal.__dbxCmsAssignSlot || currentMediaSlot(root),
10790 mediaFolder: modal.__dbxCmsMediaFolder || "",
10791 formDataExtra: modal.__dbxCmsFormDataExtra || null,
10792 afterAssign: modal.__dbxCmsAfterAssign
10797 function assignMedia(root, cfg, row, slot) {
10798 const url = cfgUrl(cfg, "assignmedia");
10799 const isFolderEditing = root.classList.contains("is-folder-editing");
10800 let pageId = Number(getField(root, "id") || 0);
10801 const folderId = isFolderEditing
10802 ? Number(getFolderField(root, "id") || 0)
10803 : Number(getField(root, "folder") || 0);
10804 const mediaId = Number(row && row.id || 0);
10805 const targetSlot = slot || currentMediaSlot(root);
10806 if (isFolderEditing && targetSlot === "hero") pageId = 0;
10807 if (!url || !mediaId || (!pageId && !(folderId && targetSlot === "hero"))) {
10808 status(root, "Bitte erst eine Seite und ein Bild waehlen.", "error");
10809 return Promise.resolve();
10812 return fetchJson(apiUrl(url), {
10814 headers: { "Content-Type": "application/json" },
10815 body: JSON.stringify({
10817 content_id: pageId,
10818 folder_id: folderId,
10822 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "assign failed");
10823 status(root, "Bild der Seite zugeordnet.", "success");
10824 const assignedRow = mediaRowWithUsage(data.row || row, data.usage || {}, targetSlot);
10825 return loadMedia(root, cfg).then(() => {
10826 upsertLocalMediaRow(root, assignedRow);
10827 return assignedRow;
10830 dbx.error("[cms] media assign failed", err);
10831 status(root, "Bild konnte nicht zugeordnet werden.", "error");
10836 function saveMediaOrder(root, cfg, list) {
10837 const url = cfgUrl(cfg, "sortmedia");
10838 const pageId = Number(getField(root, "id") || 0);
10839 if (!url || !pageId) return Promise.resolve();
10840 const scope = list || root;
10841 const ids = qsa(scope, ".dbx-cms-media-item").map(item => Number(item.getAttribute("data-usage-id") || item.getAttribute("data-media-id") || 0)).filter(Boolean);
10842 if (!ids.length) return Promise.resolve();
10843 const slot = String((list && list.getAttribute("data-cms-media")) || "");
10845 return fetchJson(apiUrl(url), {
10847 headers: { "Content-Type": "application/json" },
10848 body: JSON.stringify({
10849 content_id: pageId,
10850 slot: slot || "all",
10854 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "sort failed");
10855 status(root, "Medien sortiert.", "success");
10856 return loadMedia(root, cfg);
10858 dbx.error("[cms] media sort failed", err);
10859 status(root, "Medien konnten nicht sortiert werden.", "error");
10863 function setMediaSlot(root, cfg, id, slot) {
10864 const url = cfgUrl(cfg, "setmediaslot");
10865 const pageId = Number(getField(root, "id") || 0);
10866 if (!url || !id || !slot) return Promise.resolve();
10867 const item = qs(root, `.dbx-cms-media-item[data-media-id="${String(id).replace(/"/g, '\\"')}"]`);
10868 const usageId = Number(item?.getAttribute("data-usage-id") || 0);
10870 return fetchJson(apiUrl(url), {
10872 headers: { "Content-Type": "application/json" },
10873 body: JSON.stringify({
10876 content_id: pageId,
10880 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "slot failed");
10881 status(root, "Medium zugeordnet.", "success");
10882 const updatedRow = data.row ? mediaRowWithUsage(data.row, data.usage || {}, slot) : null;
10883 return loadMedia(root, cfg).then(() => {
10884 if (updatedRow) upsertLocalMediaRow(root, updatedRow);
10888 dbx.error("[cms] media slot failed", err);
10889 status(root, "Medium konnte nicht zugeordnet werden.", "error");
10890 return loadMedia(root, cfg).then(() => null);
10892 clearCmsLoading(root);
10893 window.setTimeout(() => clearCmsLoading(root), 50);
10897 function addExternalVideo(root, cfg, form, options) {
10898 options = options || {};
10899 const url = cfgUrl(cfg, "externalvideo");
10900 const externalUrl = String(qs(form, 'input[name="url"]')?.value || "").trim();
10901 const isFolderEditing = !!closestElement(form, "[data-cms-folder-panel]");
10902 const pageId = isFolderEditing ? 0 : Number(getField(root, "id") || 0);
10903 const folderId = isFolderEditing
10904 ? Number(getFolderField(root, "id") || 0)
10905 : Number(getField(root, "folder") || 0);
10906 const slotInput = qs(form, 'input[name="slot"]');
10907 const slot = options.slot || (slotInput && slotInput.value ? slotInput.value : currentMediaSlot(root));
10909 status(root, "Externe Videos sind nicht konfiguriert.", "error");
10910 return Promise.resolve(null);
10912 if (!externalUrl) {
10913 status(root, "Bitte eine YouTube-URL eintragen.", "error");
10914 return Promise.resolve(null);
10916 if (!pageId && !(folderId && slot === "hero")) {
10917 status(root, "Bitte erst eine Seite speichern/auswaehlen.", "error");
10918 return Promise.resolve(null);
10921 const body = new FormData(form);
10922 body.set("provider", "youtube");
10923 body.set("external_url", externalUrl);
10924 body.set("content_id", pageId);
10925 body.set("folder_id", folderId || "0");
10926 body.set("slot", slot);
10927 body.set("media_folder", "youtube");
10929 return fetchJson(apiUrl(url), {
10933 applyFormSecurity(form, data);
10934 if (!data || !data.ok || !data.row) throw new Error(data && data.msg ? data.msg : "external video failed");
10935 status(root, "Externes Video hinzugefuegt.", "success");
10936 const assignedRow = mediaRowWithUsage(data.row, data.usage || {}, slot);
10937 if (options.insertExternal) insertMediaRow(root, assignedRow);
10939 return loadMedia(root, cfg).then(() => {
10940 upsertLocalMediaRow(root, assignedRow);
10941 if (typeof options.afterExternal === "function") options.afterExternal(Object.assign({}, data, { row: assignedRow }));
10942 return Object.assign({}, data, { row: assignedRow });
10945 dbx.error("[cms] external video failed", err);
10946 status(root, err && err.message ? err.message : "Externes Video konnte nicht hinzugefuegt werden.", "error");
10951 function uploadMedia(root, cfg, form, options) {
10952 options = options || {};
10953 const url = cfgUrl(cfg, "upload");
10954 const moduleUploadUrl = String(url || "").indexOf("modul_images_upload") >= 0;
10955 options.moduleUpload = moduleUploadUrl;
10956 const isFolderEditing = root.classList.contains("is-folder-editing");
10957 let pageId = Number(getField(root, "id") || 0);
10958 const folderId = isFolderEditing
10959 ? Number(getFolderField(root, "id") || 0)
10960 : Number(getField(root, "folder") || 0);
10961 const slotInput = qs(form, 'input[name="slot"]');
10962 const slot = slotInput && slotInput.value ? slotInput.value : currentMediaSlot(root);
10963 if (isFolderEditing && slot === "hero") pageId = 0;
10964 const browserModal = state(root).mediaBrowser;
10965 const browserOpen = browserModal && !browserModal.hidden;
10967 status(root, "Upload ist nicht konfiguriert.", "error");
10970 if (!options.pickMode && !options.moduleUpload && !browserOpen && !pageId && !(folderId && slot === "hero")) {
10971 status(root, "Bitte erst eine Seite speichern/auswaehlen.", "error");
10974 const files = selectedUploadFiles(form);
10975 if (!files.length) {
10976 status(root, "Bitte zuerst eine Datei auswaehlen oder in die Upload-Zone ziehen.", "error");
10977 updateUploadLabel(form);
10980 const selectedUploadFolder = qs(form, "[data-cms-upload-folder]")?.value || "";
10981 const maxUpload = Number(cfg && cfg.uploadmax || 0);
10982 if (maxUpload > 0) {
10983 const tooLarge = files.find(file => Number(file.size || 0) > maxUpload);
10985 status(root, "Datei ist zu gross: " + tooLarge.name + " (" + formatBytes(tooLarge.size) + "). Erlaubt sind maximal " + formatBytes(maxUpload) + ".", "error");
10990 const uploadOne = function (file) {
10991 const body = new FormData(form);
10992 body.delete("file");
10993 body.append("file", file, file.name);
10994 if (!options.moduleUpload) {
10995 body.set("content_id", pageId);
10996 body.set("folder_id", folderId || "0");
10998 const uploadFolderSelect = qs(form, "[data-cms-upload-folder]");
10999 if (!options.moduleUpload) {
11000 if (String(file.type || "").startsWith("video/")) {
11001 const videoFolder = firstMediaFolderOption(uploadFolderSelect, "videos") || firstMediaFolderOption(uploadFolderSelect, "video");
11002 if (videoFolder && !/^(videos|video)(\/|$)/.test(String(body.get("media_folder") || ""))) {
11003 body.set("media_folder", videoFolder);
11005 } else if (String(file.type || "").startsWith("image/")) {
11006 const imageFolder = firstMediaFolderOption(uploadFolderSelect, "img/");
11007 if (imageFolder && String(body.get("media_folder") || "").indexOf("img/") !== 0) {
11008 body.set("media_folder", imageFolder);
11012 if (form.hasAttribute("data-cms-upload")) {
11013 body.set("slot", slot);
11015 Object.keys(options.formDataExtra || {}).forEach(key => {
11016 if (options.formDataExtra[key] != null) {
11017 body.set(key, options.formDataExtra[key]);
11021 return fetchJson(apiUrl(url), {
11026 applyFormSecurity(form, data);
11027 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "upload failed");
11028 data.upload_folder = String(body.get("media_folder") || "");
11033 files.reduce((chain, file) => {
11034 return chain.then(results => uploadOne(file).then(data => {
11035 results.push(data);
11038 }, Promise.resolve([]))
11040 const rows = data.map(item => item && item.row ? mediaRowWithUsage(item.row, item.usage || {}, slot) : null).filter(Boolean);
11041 const moduleUpload = data.some(item => item && Array.isArray(item.items));
11042 const uploadMsg = rows.length > 1
11043 ? rows.length + " Medien hochgeladen."
11044 : (moduleUpload ? "Modulbild gespeichert." : "Medium hochgeladen.");
11045 status(root, uploadMsg, "success");
11046 if (options.insertUploaded) rows.forEach(row => insertMediaRow(root, row));
11048 const uploadFolderAfterReset = qs(form, "[data-cms-upload-folder]");
11049 const refreshUploadFolder = data.find(item => item && item.upload_folder)?.upload_folder
11050 || (rows[0] && rows[0].media_folder)
11051 || selectedUploadFolder
11053 if (uploadFolderAfterReset && refreshUploadFolder && Array.from(uploadFolderAfterReset.options).some(option => option.value === refreshUploadFolder)) {
11054 uploadFolderAfterReset.value = refreshUploadFolder;
11056 updateUploadLabel(form);
11057 const modal = state(root).mediaBrowser;
11058 const browserOpen = modal && !modal.hidden;
11060 if (moduleUpload && options.pickMode) {
11061 reload = Promise.resolve();
11062 } else if (browserOpen) {
11063 reload = Promise.resolve();
11065 reload = loadMedia(root, cfg);
11067 reload.then(() => rows.forEach(row => upsertLocalMediaRow(root, row)));
11068 if (typeof options.afterUpload === "function") {
11069 reload.then(() => options.afterUpload({
11072 row: rows[0] || null,
11075 uploadFolder: refreshUploadFolder
11081 dbx.error("[cms] upload failed", err);
11082 const message = err && err.message ? String(err.message) : "";
11083 const uploadErr = message === "Decoding failed"
11084 ? "Upload fehlgeschlagen. Die Serverantwort konnte nicht gelesen werden; bitte Upload-Limit und Dateigroesse pruefen."
11085 : (message || "Upload fehlgeschlagen.");
11086 status(root, uploadErr, "error");
11089 clearCmsLoading(root);
11093 function insertMarker(root, marker, label) {
11094 const editor = qs(root, "[data-cms-editor]");
11095 if (!editor) return;
11096 if (marker === "dbx:split") marker = "dbx:col2";
11097 const name = cmsMarkerName(marker);
11098 if (name === "hero") {
11099 const existing = qsa(editorSurface(root), ".dbx-cms-marker,[data-dbx-marker]")
11100 .find(item => markerNameFromElement(item) === name);
11102 insertEditorHrNode(root, existing);
11106 insertEditorMarkerElement(root, marker, label);
11109 function bind(root, cfg) {
11110 initDetailsToggles(root);
11111 initCmsSelects(root);
11112 bindAdminTreeOutsideClose(root, cfg || {});
11113 bindViewTreeHover(root, cfg || {});
11114 bindCmsKeyboardShortcuts(root, cfg || {});
11115 updateHeaderActionTooltips(root);
11117 if (!root.__dbxCmsMediaProcessEvents && dbx.event && typeof dbx.event.on === "function") {
11118 root.__dbxCmsMediaProcessEvents = true;
11119 dbx.event.on("process:after", data => {
11120 const proc = data && data.root;
11121 if (!proc || !proc.getAttribute) return;
11122 const modal = closestElement(proc, "[data-cms-media-browser]");
11123 if (!modal || !root.contains(modal)) return;
11124 if (proc.getAttribute("data-process-status") !== "finished" || proc.__dbxCmsFinishedHandled) return;
11125 proc.__dbxCmsFinishedHandled = true;
11126 status(root, "Medienwartung abgeschlossen.", "success");
11130 if (!root.__dbxCmsTreePointerBound) {
11131 root.__dbxCmsTreePointerBound = true;
11132 let treePress = null;
11133 root.addEventListener("pointerdown", e => {
11134 if (e.button !== 0) return;
11135 const row = closestElement(e.target, ".dbx-cms-tree-row");
11136 if (!row || !root.contains(row) || closestElement(e.target, "[data-cms-tree-toggle]") || closestElement(e.target, "[data-cms-folder-edit-btn]")) return;
11137 treePress = { row, x: e.clientX, y: e.clientY, pointerId: e.pointerId, dragIntent: false };
11138 root.__dbxCmsTreePress = treePress;
11140 root.addEventListener("pointermove", e => {
11141 if (!treePress || treePress.pointerId !== e.pointerId) return;
11142 if (Math.abs(e.clientX - treePress.x) > 8 || Math.abs(e.clientY - treePress.y) > 8) {
11143 treePress.dragIntent = true;
11146 root.addEventListener("pointerup", e => {
11147 if (e.button !== 0 || !treePress || treePress.pointerId !== e.pointerId) return;
11148 const start = treePress;
11150 root.__dbxCmsTreePress = null;
11151 const row = closestElement(e.target, ".dbx-cms-tree-row");
11152 if (!row || row !== start.row || row.classList.contains("is-dragging")) return;
11153 if (Math.abs(e.clientX - start.x) > 8 || Math.abs(e.clientY - start.y) > 8) return;
11154 activateTreeRow(root, cfg, row);
11156 root.addEventListener("pointercancel", e => {
11157 if (!treePress || treePress.pointerId !== e.pointerId) return;
11159 root.__dbxCmsTreePress = null;
11163 root.addEventListener("click", e => {
11164 if (!closestElement(e.target, ".dbx-cms-select")) {
11165 closeCmsSelects(root);
11168 const contentTemplateEdit = closestElement(e.target, "[data-cms-content-template-edit]");
11169 if (contentTemplateEdit && root.contains(contentTemplateEdit)) {
11170 e.preventDefault();
11171 e.stopPropagation();
11172 if (e.stopImmediatePropagation) e.stopImmediatePropagation();
11173 openContentTemplateEditor(root, contentTemplateEdit);
11177 const row = closestElement(e.target, ".dbx-cms-tree-row");
11178 if (row && root.contains(row)) {
11179 const editBtn = closestElement(e.target, "[data-cms-folder-edit-btn]");
11180 if (editBtn && root.contains(editBtn)) {
11181 e.preventDefault();
11182 e.stopPropagation();
11183 openFolderEditorFromRow(root, cfg, row);
11186 const rightsEdit = closestElement(e.target, "[data-cms-folder-edit]");
11187 if (rightsEdit && root.contains(rightsEdit) && row.getAttribute("data-type") === "folder") {
11188 e.preventDefault();
11189 e.stopPropagation();
11190 openFolderEditorFromRow(root, cfg, row);
11193 const toggle = closestElement(e.target, "[data-cms-tree-toggle]");
11194 if (toggle && root.contains(toggle)) {
11195 e.preventDefault();
11196 e.stopPropagation();
11197 toggleTreeFolder(root, row);
11200 if (row.getAttribute("data-type") === "folder") {
11201 selectTreeFolder(root, cfg, row);
11204 activateTreeRow(root, cfg, row);
11208 const folderClose = closestElement(e.target, "[data-cms-folder-close]");
11209 if (folderClose && root.contains(folderClose)) {
11210 hideFolderEditor(root);
11214 const action = closestElement(e.target, "[data-cms-action]");
11215 if (action && root.contains(action)) {
11216 e.preventDefault();
11217 e.stopPropagation();
11218 if (e.stopImmediatePropagation) e.stopImmediatePropagation();
11220 const name = action.getAttribute("data-cms-action");
11221 if (name === "toggle-tree-panel") toggleTreePanel(root, cfg);
11222 if (name === "toggle-right-panel") toggleRightPanel(root, action);
11223 if (name === "save") saveCurrentCms(root, cfg);
11224 if (name === "save-settings") saveCurrentCms(root, cfg);
11225 if (name === "delete") deleteCurrentCms(root, cfg);
11226 if (name === "delete-page") deletePage(root, cfg);
11227 if (name === "save-folder") saveFolder(root, cfg);
11228 if (name === "delete-folder") deleteFolder(root, cfg);
11229 if (name === "reload") reloadCms(root, cfg);
11230 if (name === "open-admin") openContentAdmin(root);
11231 if (name === "duplicate-page") duplicateCurrentPage(root, cfg);
11232 if (name === "new-page") {
11233 const folder = state(root).selectedFolder || Number(getField(root, "folder") || 0);
11234 fetchJson(apiUrl(cfgUrl(cfg, "newpage"), { folder })).then(data => {
11235 if (data && data.ok) {
11236 return loadTree(root, cfg).then(() => loadPage(root, cfg, data.id)).then(() => {
11237 maybeOpenLngProvisionAfterCreate(root, cfg, data);
11240 throw new Error(data && data.msg ? data.msg : "new page failed");
11242 dbx.error("[cms] new page failed", err);
11243 status(root, err && err.message ? err.message : "Seite konnte nicht angelegt werden.", "error");
11246 if (name === "new-folder") {
11247 const parent = state(root).selectedFolder || Number(getField(root, "folder") || 0) || 0;
11248 status(root, "Bezeichnung fuer neuen Ordner eintragen und speichern.", "info");
11249 showFolderEditor(root, {
11253 _rights: parent > 0 ? "parent" : "*",
11257 if (name === "preview") {
11260 if (name === "lng-provision") {
11261 openLngProvisionDialog(root, cfg);
11263 if (name === "lng-reset-sync") {
11264 resetLngSync(root, cfg);
11266 if (name === "assign-media") {
11267 openMediaBrowser(root, cfg, { mode: "assign", slot: action.getAttribute("data-cms-slot") || currentMediaSlot(root) });
11269 if (name === "bulk-resize-media") {
11270 bulkResizeMedia(root, cfg, action.getAttribute("data-cms-resize-scope") || "selected");
11272 if (name === "assign-hero-media") {
11273 openMediaBrowser(root, cfg, {
11278 if (!row || !row.id) return;
11279 state(root).heroPreviewRow = row;
11280 setField(root, "hero_image_id", row.id);
11281 if (!getField(root, "hero_template") || getField(root, "hero_template") === "parent" || getField(root, "hero_template") === "none") {
11282 setField(root, "hero_template", "image-hero");
11285 renderHeroPreview(root);
11287 status(root, "Hero-Bild ausgewaehlt.", "success");
11291 if (name === "assign-seo-media") {
11292 openMediaBrowser(root, cfg, {
11296 if (!row || !row.id) return;
11297 state(root).seoPreviewRow = row;
11298 setField(root, "seo_image_id", row.id);
11300 renderSeoPreview(root);
11301 status(root, "OG-Bild ausgewaehlt.", "success");
11305 if (name === "clear-seo-media") {
11306 state(root).seoPreviewRow = null;
11307 setField(root, "seo_image_id", 0);
11309 renderSeoPreview(root);
11310 status(root, "OG-Bild entfernt.", "success");
11315 const browserClose = closestElement(e.target, "[data-cms-media-browser-close]");
11316 if (browserClose && root.contains(browserClose)) {
11317 const modal = closestElement(browserClose, "[data-cms-media-browser]");
11318 if (modal) modal.hidden = true;
11319 clearCmsLoading(root);
11323 const browserMaintenance = closestElement(e.target, "[data-cms-media-maintenance]");
11324 if (browserMaintenance && root.contains(browserMaintenance)) {
11325 const batchPanel = closestElement(browserMaintenance, "[data-cms-media-batch-window]");
11326 const browserModal = batchPanel && batchPanel.__dbxCmsBrowserModal
11327 ? batchPanel.__dbxCmsBrowserModal
11328 : closestElement(browserMaintenance, "[data-cms-media-browser]");
11329 renderMediaMaintenanceHome(root, cfg, browserModal, batchPanel || null);
11333 const processStart = closestElement(e.target, "[data-cms-media-process-start]");
11334 if (processStart && root.contains(processStart)) {
11335 const batchPanel = closestElement(processStart, "[data-cms-media-batch-window]");
11336 const browserModal = batchPanel && batchPanel.__dbxCmsBrowserModal
11337 ? batchPanel.__dbxCmsBrowserModal
11338 : closestElement(processStart, "[data-cms-media-browser]");
11339 startMediaMaintenance(root, cfg, browserModal, batchPanel || null);
11343 const unusedAction = closestElement(e.target, "[data-cms-media-unused-action]");
11344 if (unusedAction && root.contains(unusedAction)) {
11345 const browserModal = closestElement(unusedAction, "[data-cms-media-browser]");
11346 executeUnusedMediaMaintenance(root, cfg, browserModal, unusedAction.getAttribute("data-cms-media-unused-action"));
11350 const processClose = closestElement(e.target, "[data-cms-media-process-close]");
11351 if (processClose && root.contains(processClose)) {
11352 const panel = closestElement(processClose, "[data-cms-media-process-panel]");
11354 panel.hidden = true;
11355 panel.innerHTML = "";
11357 const modal = closestElement(processClose, "[data-cms-media-browser]");
11358 if (modal) modal.classList.remove("is-process-open");
11359 clearCmsLoading(root);
11363 const browserConfirm = closestElement(e.target, "[data-cms-media-browser-confirm]");
11364 if (browserConfirm && root.contains(browserConfirm)) {
11365 const modal = closestElement(browserConfirm, "[data-cms-media-browser]");
11366 if (modal && modal.__dbxCmsMediaMode === "pick") {
11367 confirmPickMediaBrowser(root, modal);
11370 const slot = modal && modal.__dbxCmsAssignSlot || currentMediaSlot(root);
11371 const rows = modal ? selectedMediaBrowserRows(modal) : [];
11372 if (!rows.length) {
11373 status(root, "Bitte mindestens ein Bild auswaehlen.", "error");
11377 let chain = Promise.resolve();
11378 rows.forEach(row => {
11379 chain = chain.then(() => assignMedia(root, cfg, row, slot).then(assignedRow => {
11380 if (!assignedRow) return;
11381 if (slot === "inline") {
11382 insertMediaRow(root, assignedRow);
11383 setLocalMediaSlot(root, assignedRow.id, "inline");
11385 if (modal && typeof modal.__dbxCmsAfterAssign === "function") {
11386 modal.__dbxCmsAfterAssign(assignedRow);
11392 modal.hidden = true;
11393 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.close === "function") {
11394 dbx.openWin.close(modal.__dbxCmsWindowId);
11397 clearCmsLoading(root);
11398 status(root, "Auswahl uebernommen.", "success");
11403 const browserDelete = closestElement(e.target, "[data-cms-media-browser-delete]");
11404 if (browserDelete && root.contains(browserDelete)) {
11405 const item = closestElement(browserDelete, "[data-cms-media-browser-item]");
11406 const modal = closestElement(browserDelete, "[data-cms-media-browser]");
11407 const mode = modal && modal.__dbxCmsMediaMode === "assign" ? "assign" : "editor";
11408 deleteMedia(root, cfg, Number(item?.getAttribute("data-media-id") || 0))
11409 .then(() => openMediaBrowser(root, cfg, { mode }));
11413 const browserEdit = closestElement(e.target, "[data-cms-media-browser-edit]");
11414 if (browserEdit && root.contains(browserEdit)) {
11415 const item = closestElement(browserEdit, "[data-cms-media-browser-item]");
11416 openMediaEdit(root, cfg, mediaRowFromItem(item));
11420 const browserPick = closestElement(e.target, "[data-cms-media-browser-pick]");
11421 if (browserPick && root.contains(browserPick)) {
11422 const item = closestElement(browserPick, "[data-cms-media-browser-item]") || browserPick;
11423 const mediaRow = mediaRowFromItem(item);
11424 const modal = closestElement(browserPick, "[data-cms-media-browser]");
11425 const mode = modal && modal.__dbxCmsMediaMode ? modal.__dbxCmsMediaMode : "editor";
11426 if (mode === "pick" || mode === "assign") {
11427 if (modal) toggleMediaBrowserSelection(modal, item);
11430 assignMedia(root, cfg, mediaRow, "inline").then(assignedRow => {
11431 if (!assignedRow) return;
11432 insertMediaRow(root, assignedRow);
11433 setLocalMediaSlot(root, assignedRow.id, "inline");
11434 if (modal) modal.hidden = true;
11435 clearCmsLoading(root);
11440 const cmd = closestElement(e.target, "[data-cms-cmd]");
11441 if (cmd && root.contains(cmd)) {
11442 execEditorCommand(root, cmd.getAttribute("data-cms-cmd"));
11446 const marker = closestElement(e.target, "[data-cms-marker]");
11447 if (marker && root.contains(marker)) {
11448 insertMarker(root, marker.getAttribute("data-cms-marker"));
11452 const inlineFocus = closestElement(e.target, "[data-cms-inline-focus]");
11453 if (inlineFocus && root.contains(inlineFocus)) {
11454 const item = closestElement(inlineFocus, ".dbx-cms-media-item");
11455 focusInlineMediaInEditor(root, Number(item?.getAttribute("data-media-id") || 0));
11459 const inlineRemove = closestElement(e.target, "[data-cms-inline-remove]");
11460 if (inlineRemove && root.contains(inlineRemove)) {
11461 const item = closestElement(inlineRemove, ".dbx-cms-media-item");
11462 const id = Number(item?.getAttribute("data-media-id") || 0);
11463 if (removeInlineMediaFromEditor(root, id)) {
11465 status(root, cmsText(root, "media_inline_removed", "Medium wurde aus dem Inhalt entfernt."), "success");
11470 const mediaRemove = closestElement(e.target, "[data-cms-media-remove]");
11471 if (mediaRemove && root.contains(mediaRemove)) {
11472 const item = closestElement(mediaRemove, ".dbx-cms-media-item");
11473 const id = Number(item?.getAttribute("data-media-id") || 0);
11474 const usageId = Number(item?.getAttribute("data-usage-id") || 0);
11475 const slot = String(item?.getAttribute("data-media-slot") || "");
11476 removeMedia(root, cfg, id, usageId, slot);
11480 const mediaEdit = closestElement(e.target, "[data-cms-media-edit-one]");
11481 if (mediaEdit && root.contains(mediaEdit)) {
11482 const item = closestElement(mediaEdit, ".dbx-cms-media-item");
11483 openMediaEdit(root, cfg, mediaRowFromItem(item));
11487 const toolsToggle = closestElement(e.target, "[data-cms-media-tools-toggle]");
11488 if (toolsToggle && root.contains(toolsToggle)) {
11489 const menu = qs(root, "[data-cms-media-tools-menu]");
11490 if (menu) menu.hidden = !menu.hidden;
11494 if (!closestElement(e.target, ".dbx-cms-media-tools")) {
11495 const menu = qs(root, "[data-cms-media-tools-menu]");
11496 if (menu) menu.hidden = true;
11499 const bulkResize = closestElement(e.target, "[data-cms-action='bulk-resize-media']");
11500 if (bulkResize && root.contains(bulkResize)) {
11501 bulkResizeMedia(root, cfg, bulkResize.getAttribute("data-cms-resize-scope") || "selected");
11505 const editModal = closestElement(e.target, "[data-cms-media-edit]");
11506 if (editModal && root.contains(editModal)) {
11507 if (closestElement(e.target, "[data-cms-media-edit-close]")) {
11508 editModal.hidden = true;
11511 const resize = closestElement(e.target, "[data-cms-media-edit-resize]");
11512 const cropApply = closestElement(e.target, "[data-cms-media-edit-crop-apply]");
11513 const cropSave = closestElement(e.target, "[data-cms-media-edit-crop]");
11515 commitMediaEditAction(root, cfg, editModal, "crop", { closeAfter: false });
11519 previewMediaCrop(root, editModal);
11523 commitMediaEditAction(root, cfg, editModal, "resize", { closeAfter: false });
11528 const videoOptionsModal = closestElement(e.target, "[data-cms-video-options]");
11529 if (videoOptionsModal && root.contains(videoOptionsModal)) {
11530 if (closestElement(e.target, "[data-cms-video-options-close]")) {
11531 closeInlineVideoOptionsWindow(videoOptionsModal);
11534 if (closestElement(e.target, "[data-cms-video-options-apply]")) {
11535 applyInlineVideoOptions(root, videoOptionsModal);
11540 const inlineVideoOptionsOpen = closestElement(e.target, "[data-cms-inline-video-options-open]");
11541 if (inlineVideoOptionsOpen && root.contains(inlineVideoOptionsOpen)) {
11542 const video = inlineVideoTarget(root, inlineVideoOptionsOpen);
11544 e.preventDefault();
11545 e.stopPropagation();
11546 openInlineVideoOptions(root, video);
11551 const inlineVideoPlay = inlineVideoEventTarget(root, e);
11552 if (inlineVideoPlay && root.contains(inlineVideoPlay) && !closestElement(e.target, ".dbx-cms-context-menu")) {
11553 if (!closestElement(e.target, "[data-cms-inline-video-options-open]")
11554 && (closestElement(e.target, ".dbx-cms-inline-video-play") || !inlineVideoPlay.querySelector(".dbx-cms-inline-video-player, iframe, video"))) {
11555 e.preventDefault();
11556 e.stopPropagation();
11557 playInlineVideoBlock(root, inlineVideoPlay);
11560 if (!closestElement(e.target, "[data-cms-inline-video-options-open]") && !isInlineVideoResizeHandleEvent(inlineVideoPlay, e)) {
11561 e.preventDefault();
11562 e.stopPropagation();
11563 openInlineVideoOptions(root, inlineVideoPlay);
11568 const mediaInsert = closestElement(e.target, "[data-cms-media-insert]");
11569 if (mediaInsert && root.contains(mediaInsert)) {
11570 const item = closestElement(mediaInsert, ".dbx-cms-media-item");
11571 const url = item?.getAttribute("data-url") || "";
11572 if (url) insertMarker(root, "media:" + url);
11575 const mediaEmbed = closestElement(e.target, "[data-cms-media-embed]");
11576 if (mediaEmbed && root.contains(mediaEmbed)) {
11577 const item = closestElement(mediaEmbed, ".dbx-cms-media-item");
11578 const mediaRow = mediaRowFromItem(item);
11579 insertMediaRow(root, mediaRow);
11580 setLocalMediaSlot(root, mediaRow.id, "inline");
11585 const search = qs(root, "[data-cms-search]");
11587 bindTreeSearchClear(root, search);
11588 search.addEventListener("input", () => renderTree(root));
11591 qsa(root, "[data-cms-upload], [data-cms-hero-upload]").forEach(upload => {
11592 if (upload.__dbxCmsUploadBound) return;
11593 upload.__dbxCmsUploadBound = true;
11594 upload.addEventListener("submit", e => {
11595 e.preventDefault();
11596 if (upload.hasAttribute("data-cms-hero-upload")) {
11597 uploadMedia(root, cfg, upload, {
11598 afterUpload(data) {
11599 if (!data || !data.row || !data.row.id) return;
11600 state(root).heroPreviewRow = data.row;
11601 setField(root, "hero_image_id", data.row.id);
11602 if (!getField(root, "hero_template") || getField(root, "hero_template") === "parent" || getField(root, "hero_template") === "none") {
11603 setField(root, "hero_template", "image-hero");
11606 renderHeroPreview(root);
11607 status(root, "Hero-Bild hochgeladen.", "success");
11612 uploadMedia(root, cfg, upload);
11614 upload.addEventListener("change", () => updateUploadLabel(upload));
11617 qsa(root, "[data-cms-external-video]").forEach(form => {
11618 if (form.__dbxCmsExternalVideoBound) return;
11619 form.__dbxCmsExternalVideoBound = true;
11620 form.addEventListener("submit", e => {
11621 e.preventDefault();
11622 addExternalVideo(root, cfg, form);
11626 root.addEventListener("keydown", e => {
11627 const row = closestElement(e.target, ".dbx-cms-tree-row");
11628 if (!row || !root.contains(row) || row.getAttribute("data-type") !== "folder") return;
11629 if (e.key === "ArrowLeft") {
11630 e.preventDefault();
11631 toggleTreeFolder(root, row, true);
11632 } else if (e.key === "ArrowRight") {
11633 e.preventDefault();
11634 toggleTreeFolder(root, row, false);
11638 root.addEventListener("change", e => {
11639 const contentTemplate = closestElement(e.target, '[data-cms-field="template"], [data-cms-folder-field="template"]');
11640 if (contentTemplate && root.contains(contentTemplate)) {
11641 syncContentTemplateEditLink(root, contentTemplate);
11643 const uploadForm = closestElement(e.target, "[data-cms-upload], [data-cms-hero-upload], [data-cms-browser-upload]");
11644 if (uploadForm && root.contains(uploadForm)) updateUploadLabel(uploadForm);
11645 const mediaFilter = closestElement(e.target, "[data-cms-media-filter]");
11646 if (mediaFilter && root.contains(mediaFilter)) {
11647 state(root).mediaFilter = mediaFilter.value || "all";
11648 syncUploadSlot(root);
11651 const ratioInput = closestElement(e.target, "[data-cms-bulk-resize-ratio]");
11652 if (ratioInput && root.contains(ratioInput)) {
11653 status(root, ratioInput.checked ? "Resize behaelt das Seitenverhaeltnis." : "Resize nutzt exakte Breite und Hoehe.", "info");
11655 const heroTemplate = closestElement(e.target, '[data-cms-field="hero_template"], [data-cms-folder-field="hero_template"]');
11656 if (heroTemplate && root.contains(heroTemplate)) {
11657 applyHeroTemplateChoice(root, heroTemplate);
11661 root.addEventListener("input", e => {
11662 const editInput = closestElement(e.target, "[data-cms-media-edit-width], [data-cms-media-edit-height]");
11663 if (editInput && root.contains(editInput)) {
11664 const modal = closestElement(editInput, "[data-cms-media-edit]");
11665 const ratio = qs(modal, "[data-cms-media-edit-ratio]");
11666 if (!modal || !ratio || !ratio.checked) return;
11667 const row = modal.__dbxCmsEditRow || {};
11668 const w = qs(modal, "[data-cms-media-edit-width]");
11669 const h = qs(modal, "[data-cms-media-edit-height]");
11670 autoRatioValue(editInput, editInput === w ? h : w, Number(row.width || 0), Number(row.height || 0));
11674 root.addEventListener("dragover", e => {
11675 const dropzone = closestElement(e.target, "[data-cms-dropzone]");
11676 if (!dropzone || !root.contains(dropzone)) return;
11677 e.preventDefault();
11678 dropzone.classList.add("is-dragover");
11681 root.addEventListener("dragleave", e => {
11682 const dropzone = closestElement(e.target, "[data-cms-dropzone]");
11683 if (!dropzone || !root.contains(dropzone)) return;
11684 dropzone.classList.remove("is-dragover");
11687 root.addEventListener("drop", e => {
11688 const dropzone = closestElement(e.target, "[data-cms-dropzone]");
11689 if (!dropzone || !root.contains(dropzone)) return;
11690 e.preventDefault();
11691 dropzone.classList.remove("is-dragover");
11692 const form = closestElement(dropzone, "form");
11693 const files = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length ? e.dataTransfer.files : null;
11694 if (!form || !files || !files.length) return;
11695 if (!setUploadFiles(form, files)) {
11696 status(root, "Datei bitte ueber die Dateiauswahl waehlen.", "error");
11700 qsa(root, "[data-cms-field]").forEach(el => {
11701 if (el.__dbxCmsDirtyBound) return;
11702 el.__dbxCmsDirtyBound = true;
11704 el.addEventListener("input", () => markDirty(root));
11705 el.addEventListener("change", () => {
11707 if (el.getAttribute("data-cms-field") === "hero_template") applyHeroTemplateChoice(root, el);
11712 root.addEventListener("dragstart", e => {
11713 const media = closestElement(e.target, ".dbx-cms-media-item");
11714 if (media && root.contains(media)) {
11715 e.dataTransfer.effectAllowed = "move";
11716 e.dataTransfer.setData("application/x-dbx-cms-media", media.getAttribute("data-media-id") || "");
11717 media.classList.add("is-dragging");
11721 const row = closestElement(e.target, ".dbx-cms-tree-row");
11722 if (!row || !root.contains(row)) return;
11723 const treePress = root.__dbxCmsTreePress;
11724 root.__dbxCmsTreeDrag = treePress && treePress.row === row ? {
11728 dragIntent: treePress.dragIntent,
11731 e.dataTransfer.effectAllowed = "move";
11732 e.dataTransfer.setData("application/x-dbx-cms-node", JSON.stringify({
11733 type: row.getAttribute("data-type"),
11734 id: Number(row.getAttribute("data-id") || 0)
11736 row.classList.add("is-dragging");
11739 root.addEventListener("dragend", e => {
11740 clearEditorDropMarks(root);
11741 state(root).dragMarker = null;
11742 state(root).pointerDragMarker = null;
11744 const media = closestElement(e.target, ".dbx-cms-media-item");
11745 if (media) media.classList.remove("is-dragging");
11746 qsa(root, ".dbx-cms-media-item.is-drop-before,.dbx-cms-media-item.is-drop-after").forEach(el => el.classList.remove("is-drop-before", "is-drop-after"));
11748 const row = closestElement(e.target, ".dbx-cms-tree-row");
11749 if (row) row.classList.remove("is-dragging");
11750 qsa(root, ".dbx-cms-tree-row.is-drop-target,.dbx-cms-tree-row.is-drop-before,.dbx-cms-tree-row.is-drop-after").forEach(el => {
11751 el.classList.remove("is-drop-target", "is-drop-before", "is-drop-after");
11754 const treeDrag = root.__dbxCmsTreeDrag;
11755 root.__dbxCmsTreeDrag = null;
11756 if (row && treeDrag && treeDrag.row === row && !treeDrag.dropped && !treeDrag.dragIntent) {
11757 const moved = Math.abs((e.clientX || treeDrag.x) - treeDrag.x) > 8 || Math.abs((e.clientY || treeDrag.y) - treeDrag.y) > 8;
11758 if (!moved) activateTreeRow(root, cfg, row);
11762 root.addEventListener("dragover", e => {
11763 const media = closestElement(e.target, ".dbx-cms-media-item");
11764 if (media && root.contains(media) && Array.from(e.dataTransfer.types || []).includes("application/x-dbx-cms-media")) {
11765 e.preventDefault();
11766 e.dataTransfer.dropEffect = "move";
11767 qsa(root, ".dbx-cms-media-item.is-drop-before,.dbx-cms-media-item.is-drop-after").forEach(el => {
11768 if (el !== media) el.classList.remove("is-drop-before", "is-drop-after");
11770 const rect = media.getBoundingClientRect();
11771 const before = e.clientY < rect.top + rect.height / 2;
11772 media.classList.toggle("is-drop-before", before);
11773 media.classList.toggle("is-drop-after", !before);
11777 const row = closestElement(e.target, ".dbx-cms-tree-row");
11778 if (!row || !root.contains(row)) return;
11779 const type = row.getAttribute("data-type");
11780 const hasData = Array.from(e.dataTransfer.types || []).includes("application/x-dbx-cms-node");
11781 if (!hasData) return;
11784 data = JSON.parse(e.dataTransfer.getData("application/x-dbx-cms-node") || "{}");
11786 if (!data || !data.type || !data.id) return;
11787 if (root.__dbxCmsTreeDrag) root.__dbxCmsTreeDrag.dragIntent = true;
11788 if (data.type === "folder" && type !== "folder") return;
11790 e.preventDefault();
11791 e.dataTransfer.dropEffect = "move";
11792 qsa(root, ".dbx-cms-tree-row.is-drop-target,.dbx-cms-tree-row.is-drop-before,.dbx-cms-tree-row.is-drop-after").forEach(el => {
11793 if (el !== row) el.classList.remove("is-drop-target", "is-drop-before", "is-drop-after");
11796 const rect = row.getBoundingClientRect();
11797 const y = rect.height ? (e.clientY - rect.top) / rect.height : 0.5;
11798 if (type === "page") {
11799 const before = y < 0.5;
11800 row.classList.toggle("is-drop-before", before);
11801 row.classList.toggle("is-drop-after", !before);
11802 } else if (data.type === "folder") {
11803 const before = y < 0.28;
11804 const after = y > 0.72;
11805 row.classList.toggle("is-drop-before", before);
11806 row.classList.toggle("is-drop-after", after);
11807 row.classList.toggle("is-drop-target", !before && !after);
11809 row.classList.add("is-drop-target");
11813 root.addEventListener("dragleave", e => {
11814 const surface = editorSurface(root);
11815 if (surface && !surface.contains(e.relatedTarget)) {
11816 clearEditorDropMarks(root);
11819 const media = closestElement(e.target, ".dbx-cms-media-item");
11820 if (media && !media.contains(e.relatedTarget)) media.classList.remove("is-drop-before", "is-drop-after");
11822 const row = closestElement(e.target, ".dbx-cms-tree-row");
11823 if (row && !row.contains(e.relatedTarget)) row.classList.remove("is-drop-target", "is-drop-before", "is-drop-after");
11826 root.addEventListener("drop", e => {
11827 const media = closestElement(e.target, ".dbx-cms-media-item");
11828 if (media && root.contains(media) && Array.from(e.dataTransfer.types || []).includes("application/x-dbx-cms-media")) {
11829 e.preventDefault();
11830 const fromId = e.dataTransfer.getData("application/x-dbx-cms-media");
11831 const from = qsa(root, ".dbx-cms-media-item").find(item => item.getAttribute("data-media-id") === fromId);
11832 if (!from || from === media) return;
11833 if (from.parentElement !== media.parentElement) return;
11834 const rect = media.getBoundingClientRect();
11835 const before = e.clientY < rect.top + rect.height / 2;
11836 media.classList.remove("is-drop-before", "is-drop-after");
11837 if (before) media.parentElement.insertBefore(from, media);
11838 else media.parentElement.insertBefore(from, media.nextSibling);
11839 saveMediaOrder(root, cfg, media.parentElement);
11843 const row = closestElement(e.target, ".dbx-cms-tree-row");
11844 if (!row || !root.contains(row)) return;
11845 e.preventDefault();
11846 if (root.__dbxCmsTreeDrag) {
11847 root.__dbxCmsTreeDrag.dropped = true;
11848 root.__dbxCmsTreeDrag.dragIntent = true;
11850 row.classList.remove("is-drop-target", "is-drop-before", "is-drop-after");
11854 data = JSON.parse(e.dataTransfer.getData("application/x-dbx-cms-node") || "{}");
11856 if (!data || !data.type || !data.id) return;
11858 const targetType = row.getAttribute("data-type");
11859 const targetId = Number(row.getAttribute("data-id") || 0);
11860 let targetFolder = targetType === "folder" ? targetId : Number(row.getAttribute("data-folder") || 0);
11861 const position = {};
11863 if (targetType === "folder" && data.type === "folder") {
11864 const rect = row.getBoundingClientRect();
11865 const y = rect.height ? (e.clientY - rect.top) / rect.height : 0.5;
11866 if (y < 0.28 || y > 0.72) {
11867 targetFolder = Number(row.getAttribute("data-folder") || 0);
11868 if (y < 0.28) position.before_id = targetId;
11869 else position.after_id = targetId;
11871 } else if (targetType === "page" && data.type === "page") {
11872 const rect = row.getBoundingClientRect();
11873 if (e.clientY < rect.top + rect.height / 2) position.before_id = targetId;
11874 else position.after_id = targetId;
11875 } else if (targetType !== "folder") {
11879 if (Number(data.id) === targetId && (position.before_id || position.after_id)) {
11883 if (data.type === "folder" && Number(data.id) === targetFolder) {
11884 status(root, "Ordner kann nicht in sich selbst verschoben werden.", "error");
11888 moveNode(root, cfg, data.type, Number(data.id), targetFolder, position);
11892 bindGlobalCmsEditorEvents();
11893 bindInlineVideoOptionsDocumentEvents();
11895 function bindTreeRuntimeEnhancements(root) {
11896 qsa(root || document, ".dbx-cms[data-dbx]").forEach(el => {
11897 const cfg = cmsConfig(el);
11898 bindAdminTreeOutsideClose(el, cfg || {});
11899 bindViewTreeHover(el, cfg || {});
11903 const cmsFeature = {
11907 ["css", "design", "c-cms.css"],
11908 ["css", "design", "c-form.css"],
11909 ["css", "design", "c-grid.css"]
11912 ["js", "lib", "ajax.js"],
11913 ["js", "lib", "form.js"]
11917 if (!el || el.__dbxCmsReady) return;
11918 el.__dbxCmsReady = true;
11919 qsa(el, "select[data-cms-select-title]").forEach(syncCmsSelectTitle);
11920 el.addEventListener("focusin", event => syncCmsSelectTitle(event.target));
11921 el.addEventListener("change", event => syncCmsSelectTitle(event.target));
11922 const initialCid = Number(cfg && cfg.cid ? cfg.cid : 0) || 0;
11923 const initialFid = Number(cfg && cfg.fid ? cfg.fid : 0) || 0;
11924 if (initialFid > 0) {
11925 state(el).selectedFolder = initialFid;
11926 state(el).selectedPage = 0;
11927 state(el).selectedType = "folder";
11928 } else if (initialCid > 0) {
11929 state(el).selectedPage = initialCid;
11930 state(el).selectedType = "page";
11932 bindStickyHeaderOffset(el);
11933 bindTreeFlyoutPosition(el);
11934 initTreePanelState(el);
11935 initRightPanelState(el);
11936 bindTreeRuntimeEnhancements(el);
11937 if (!isViewMode(cfg || {})) {
11938 ensureJodit().then(ok => {
11940 initEditor(el, cfg || {});
11942 dbx.warn("[cms] Jodit not loaded, fallback contenteditable active");
11947 bind(el, cfg || {});
11949 dbx.error("[cms] bind failed", err);
11951 loadInitialSelection(el, cfg || {});
11955 qsa(root || document, ".dbx-cms[data-dbx]").forEach(el => {
11956 if (el.__dbxCmsReady) return;
11957 const cfgList = dbx.parseData(el.getAttribute("data-dbx"));
11958 const cfg = cfgList.find(item => item.lib === LIB) || {};
11959 this.init(el, cfg);
11964 dbx.cmsMediaBrowser = {
11965 open: openMediaBrowser
11968 dbx.feature.register(LIB, cmsFeature);
11970})(window, document);