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 closestElement(target, selector) {
27 if (!target) return null;
28 const el = target.nodeType === 1 ? target : target.parentElement;
29 return el && el.closest ? el.closest(selector) : null;
32 function status(root, msg, type = "info") {
33 const modal = state(root).mediaBrowser;
34 const el = qs(root, "[data-cms-status]") || (modal ? qs(modal, "[data-cms-status]") : null);
36 if (msg && type === "error") {
37 dbx.warn("[cms]", msg);
41 el.className = "dbx-cms-status dbx-cms-status-" + type;
42 el.textContent = msg || "";
43 if (msg) window.setTimeout(() => { el.textContent = ""; }, 3500);
46 function apiUrl(url, params) {
47 const out = new URL(String(url || window.location.href), window.location.href);
48 Object.keys(params || {}).forEach(key => out.searchParams.set(key, params[key]));
49 out.searchParams.set("_", Date.now());
50 return out.toString();
53 function fetchJson(url, opt) {
55 if (!dbx.ajax || typeof dbx.ajax.request !== "function") {
56 return Promise.reject(new Error("ajax.js nicht geladen."));
59 return dbx.ajax.request({
61 method: opt.method || "GET",
63 body: (typeof opt.body !== "undefined") ? opt.body : null,
64 data: (typeof opt.data !== "undefined") ? opt.data : null,
65 headers: opt.headers || {},
66 timeout: Number(opt.timeout || 20000),
67 footerRuntime: opt.footerRuntime || ""
69 if (err && err.name === "AbortError") throw new Error("Serverantwort dauert zu lange.");
74 function fetchHtml(url, opt) {
76 if (!dbx.ajax || typeof dbx.ajax.request !== "function") {
77 return Promise.reject(new Error("ajax.js nicht geladen."));
80 return dbx.ajax.request({
82 method: opt.method || "GET",
84 body: (typeof opt.body !== "undefined") ? opt.body : null,
85 data: (typeof opt.data !== "undefined") ? opt.data : null,
86 headers: opt.headers || {},
87 timeout: Number(opt.timeout || 20000),
88 footerRuntime: opt.footerRuntime || ""
91 if (err && err.name === "AbortError") throw new Error("Serverantwort dauert zu lange.");
96 function ensureFeature(name) {
97 if (!name) return Promise.resolve(false);
98 if (dbx.feature && dbx.feature.has && dbx.feature.has(name)) {
99 return Promise.resolve(true);
102 return new Promise(resolve => {
103 if (!dbx.resolveFeature) {
108 dbx.resolveFeature(name, ok => resolve(ok === true));
112 function loadAssets(list) {
113 return new Promise(resolve => {
114 if (!Array.isArray(list) || !list.length) {
124 dbx.load(list, () => resolve(true));
128 function ensureOpenWin() {
129 return ensureFeature("openWin").then(ok => {
130 return ok && dbx.openWin && typeof dbx.openWin.open === "function";
134 function ensureConfirm() {
135 return ensureFeature("confirm").then(ok => {
136 return ok && dbx.confirm && typeof dbx.confirm.open === "function";
140 function ensureJodit() {
141 if (window.Jodit && window.Jodit.make) {
142 return Promise.resolve(true);
146 ["css", "root", "vendor/jodit/jodit.fat.min.css"],
147 ["js", "root", "vendor/jodit/jodit.fat.min.js"]
148 ]).then(() => !!(window.Jodit && window.Jodit.make));
151 function extractProcessHtml(html) {
152 const tpl = document.createElement("template");
153 tpl.innerHTML = String(html || "").trim();
154 const proc = tpl.content.querySelector("[data-dbx-process-root='1'], .dbx-process");
155 return proc ? proc.outerHTML : String(html || "");
158 function escapeHtml(text) {
159 const div = document.createElement("div");
160 div.textContent = text == null ? "" : String(text);
161 return div.innerHTML;
164 function escapeTooltipAttr(text) {
165 return escapeHtml(String(text == null ? "" : text).replace(/"/g, "'"));
168 function escapeTextareaValue(text) {
169 return String(text == null ? "" : text).replace(/<\/textarea/gi, "</textarea");
172 function fillLngProvisionContentPreviews(modal, items) {
173 if (!modal || !Array.isArray(items)) return;
174 items.forEach((item, index) => {
175 const preview = qs(modal, `[data-lng-content-preview="${index}"]`);
176 if (!preview) return;
177 const html = String(item && item.content != null ? item.content : "").trim();
178 preview.innerHTML = html;
180 preview.classList.add("is-empty");
185 function state(root) {
186 root.__dbxCms = root.__dbxCms || {
189 selectedFolder: Number(dbx.uiGet ? dbx.uiGet(LIB, TREE_UI_ID, "selectedFolder", 0) : 0) || 0,
191 selectedPage: Number(dbx.uiGet ? dbx.uiGet(LIB, TREE_UI_ID, "selectedPage", 0) : 0) || 0,
192 selectedType: dbx.uiGet ? String(dbx.uiGet(LIB, TREE_UI_ID, "selectedType", "page") || "page") : "page",
196 collapsedFolders: null,
197 selectionRestored: false,
204 return root.__dbxCms;
207 function cfgUrl(cfg, key) {
208 return cfg[key] || "";
211 function browserCfg(modal) {
212 return modal && modal.__dbxCmsCfg ? modal.__dbxCmsCfg : {};
215 function mediaBrowserProfile(cfg) {
216 const media = String(cfgUrl(cfg, "media") || "");
217 if (media.indexOf("modul_images_media") >= 0) return "mod";
221 function applyMediaBrowserProfile(modal, cfg) {
222 const isMod = mediaBrowserProfile(cfg) === "mod";
223 modal.dataset.dbxMediaProfile = isMod ? "mod" : "cms";
224 const folderBar = qs(modal, ".dbx-cms-media-folderbar");
225 const externalVideo = qs(modal, "[data-cms-browser-external-video]");
226 const slotSelect = qs(modal, "[data-cms-media-browser-slot]");
227 const uploadFolder = qs(modal, "[data-cms-upload-folder]");
228 const folderSelect = qs(modal, "[data-cms-media-browser-folder]");
229 const folderTree = qs(modal, "[data-cms-media-folder-tree]");
230 const batchBtn = qs(modal, "[data-cms-media-batch-open]");
231 const rootHint = qs(modal, "[data-cms-media-root-hint]");
233 rootHint.textContent = isMod ? "Speicherort: files/mod/" : "Speicherort: files/media/";
235 if (folderBar) folderBar.hidden = isMod;
236 if (externalVideo) externalVideo.hidden = isMod;
237 if (slotSelect) slotSelect.hidden = isMod;
238 if (uploadFolder) uploadFolder.hidden = isMod;
239 if (folderSelect) folderSelect.hidden = isMod;
240 if (folderTree) folderTree.hidden = isMod || !modal.classList.contains("is-folder-tree-open");
241 if (isMod) modal.classList.remove("is-folder-tree-open");
242 if (batchBtn) batchBtn.hidden = isMod;
245 function isMediaBrowserMulti(modal) {
246 const mode = modal && modal.__dbxCmsMediaMode ? modal.__dbxCmsMediaMode : "";
247 if (modal && modal.__dbxCmsSinglePick) return false;
248 return mode === "pick" || (mode === "assign" && modal.__dbxCmsAssignSlot !== "hero");
251 function mediaBrowserUsesConfirmBar(modal) {
252 const mode = modal && modal.__dbxCmsMediaMode ? modal.__dbxCmsMediaMode : "";
253 return mode === "pick" || mode === "assign";
256 function confirmPickMediaBrowser(root, modal) {
257 const rows = selectedMediaBrowserRows(modal);
259 status(root, "Bitte mindestens ein Bild auswaehlen.", "error");
260 return Promise.resolve();
263 let chain = Promise.resolve();
264 let keepOpen = false;
265 rows.forEach(row => {
266 chain = chain.then(() => {
267 if (typeof modal.__dbxCmsAfterAssign === "function") {
268 return Promise.resolve(modal.__dbxCmsAfterAssign(row)).then(result => {
269 if (result === false) keepOpen = true;
274 return chain.then(() => {
276 clearCmsLoading(root);
280 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.close === "function") {
281 dbx.openWin.close(modal.__dbxCmsWindowId);
283 clearCmsLoading(root);
284 status(root, "Auswahl uebernommen.", "success");
288 function isViewMode(cfg) {
289 return String((cfg && cfg.mode) || "").toLowerCase() === "view";
292 function syncStickyHeaderOffset(root) {
293 const header = document.getElementById("dbxHeader");
294 const headerMenus = header ? qsa(header, ".dbx-menu").filter(el => el.parentElement === header) : [];
295 const visibleBottom = headerMenus.reduce((bottom, el) => {
296 const style = window.getComputedStyle ? window.getComputedStyle(el) : null;
297 if (style && (style.display === "none" || style.visibility === "hidden")) return bottom;
298 const rect = el.getBoundingClientRect();
299 if (!rect.height || rect.bottom <= 0) return bottom;
300 return Math.max(bottom, rect.bottom);
302 const headerBottom = header ? Math.max(0, header.getBoundingClientRect().bottom || 0) : 0;
303 const fallbackBottom = Math.min(visibleBottom || 0, 160);
304 const height = header ? Math.ceil(Math.min(headerBottom || fallbackBottom || 0, 120)) : 0;
305 root.style.setProperty("--dbx-cms-sticky-top", height + "px");
307 const cmsHead = qs(root, ".dbx-cms-head");
308 const cmsHeadStyle = cmsHead && window.getComputedStyle ? window.getComputedStyle(cmsHead) : null;
309 let cmsHeadTop = cmsHeadStyle ? parseFloat(cmsHeadStyle.top || "0") : 0;
310 if (!Number.isFinite(cmsHeadTop)) cmsHeadTop = 0;
311 const cmsHeadHeight = cmsHead ? Math.ceil(cmsHead.getBoundingClientRect().height || cmsHead.offsetHeight || 0) : 0;
312 const editorTop = cmsHead ? Math.ceil(cmsHeadTop + cmsHeadHeight) : height;
313 root.style.setProperty("--dbx-cms-editor-toolbar-top", editorTop + "px");
314 const instance = getEditorInstance(root);
315 if (instance && instance.o) instance.o.toolbarStickyOffset = editorTop;
318 function bindStickyHeaderOffset(root) {
319 if (root.__dbxCmsStickyOffsetBound) return;
320 root.__dbxCmsStickyOffsetBound = true;
321 syncStickyHeaderOffset(root);
322 window.setTimeout(() => syncStickyHeaderOffset(root), 50);
323 window.setTimeout(() => syncStickyHeaderOffset(root), 300);
324 window.addEventListener("resize", () => syncStickyHeaderOffset(root), { passive: true });
327 function syncTreeFlyoutPosition(root) {
328 if (!root || root.classList.contains("is-tree-collapsed")) return;
329 const btn = qs(root, ".dbx-cms-head [data-cms-action='toggle-tree-panel']");
330 const panel = isViewMode(cmsConfig(root)) ? qs(root, ".dbx-cms-view-tree-panel") : qs(root, ".dbx-cms-tree-panel");
331 if (!btn || !panel) return;
333 const rect = btn.getBoundingClientRect();
336 const maxWidth = isViewMode(cmsConfig(root)) ? 720 : 600;
337 const left = Math.max(minEdge, Math.min(rect.left, window.innerWidth - minEdge - maxWidth));
338 const width = Math.min(maxWidth, window.innerWidth - left - minEdge);
339 const top = Math.min(rect.bottom + gap, window.innerHeight - 80);
341 root.style.setProperty("--dbx-cms-tree-left", Math.round(left) + "px");
342 root.style.setProperty("--dbx-cms-tree-top", Math.round(top) + "px");
343 root.style.setProperty("--dbx-cms-tree-width", Math.round(width) + "px");
346 function bindTreeFlyoutPosition(root) {
347 if (root.__dbxCmsTreeFlyoutBound) return;
348 root.__dbxCmsTreeFlyoutBound = true;
350 const update = () => syncTreeFlyoutPosition(root);
351 window.addEventListener("resize", update, { passive: true });
352 window.addEventListener("scroll", update, { passive: true });
354 const scroller = closestElement(root, ".dbx-content");
355 if (scroller) scroller.addEventListener("scroll", update, { passive: true });
358 function applyTreePanelState(root, collapsed) {
359 root.classList.toggle("is-tree-collapsed", !!collapsed);
360 root.classList.remove("is-tree-peek");
361 if (!collapsed) clearTreeForceCollapse(root);
362 qsa(root, "[data-cms-action='toggle-tree-panel']").forEach(btn => {
363 btn.setAttribute("aria-expanded", collapsed ? "false" : "true");
364 btn.setAttribute("aria-label", collapsed ? "Content Tree anzeigen" : "Content Tree ausblenden");
365 btn.setAttribute("title", collapsed ? "Content Tree anzeigen" : "Content Tree ausblenden");
366 const icon = qs(btn, "i");
368 icon.classList.toggle("bi-list", collapsed);
369 icon.classList.toggle("bi-x-lg", !collapsed);
370 icon.classList.toggle("bi-layout-sidebar-inset", false);
374 syncTreeFlyoutPosition(root);
375 window.requestAnimationFrame(() => syncTreeFlyoutPosition(root));
376 window.setTimeout(() => syncTreeFlyoutPosition(root), 0);
380 function clearTreeForceCollapse(root) {
381 if (root.__dbxCmsTreeForceTimer) {
382 window.clearTimeout(root.__dbxCmsTreeForceTimer);
383 root.__dbxCmsTreeForceTimer = null;
385 root.classList.remove("is-tree-force-collapsed");
388 function forceCollapseTreePanel(root) {
389 root.classList.add("is-tree-force-collapsed");
390 if (root.__dbxCmsTreeForceTimer) window.clearTimeout(root.__dbxCmsTreeForceTimer);
391 root.__dbxCmsTreeForceTimer = window.setTimeout(() => {
392 root.__dbxCmsTreeForceTimer = null;
393 if (root.classList.contains("is-tree-collapsed")) {
394 root.classList.remove("is-tree-force-collapsed");
399 function clearTreeHoverTimers(root) {
400 if (root.__dbxCmsTreeHoverEnterTimer) {
401 window.clearTimeout(root.__dbxCmsTreeHoverEnterTimer);
402 root.__dbxCmsTreeHoverEnterTimer = null;
404 if (root.__dbxCmsTreeHoverLeaveTimer) {
405 window.clearTimeout(root.__dbxCmsTreeHoverLeaveTimer);
406 root.__dbxCmsTreeHoverLeaveTimer = null;
410 function setTreeHoverExpanded(root, expanded) {
412 root.classList.toggle("is-tree-panel-hover", !!expanded);
413 if (isViewMode(cmsConfig(root)) && root.classList.contains("is-tree-collapsed")) {
414 if (expanded && !root.classList.contains("is-tree-force-collapsed")) {
415 root.classList.add("is-tree-peek");
417 root.classList.remove("is-tree-peek");
422 function scheduleTreeHover(root, expanded) {
426 if (root.__dbxCmsTreeHoverLeaveTimer) {
427 window.clearTimeout(root.__dbxCmsTreeHoverLeaveTimer);
428 root.__dbxCmsTreeHoverLeaveTimer = null;
430 if (root.classList.contains("is-tree-panel-hover")) return;
431 if (root.__dbxCmsTreeHoverEnterTimer) window.clearTimeout(root.__dbxCmsTreeHoverEnterTimer);
432 root.__dbxCmsTreeHoverEnterTimer = window.setTimeout(() => {
433 root.__dbxCmsTreeHoverEnterTimer = null;
434 setTreeHoverExpanded(root, true);
439 if (root.__dbxCmsTreeHoverEnterTimer) {
440 window.clearTimeout(root.__dbxCmsTreeHoverEnterTimer);
441 root.__dbxCmsTreeHoverEnterTimer = null;
443 if (!root.classList.contains("is-tree-panel-hover") && !root.classList.contains("is-tree-peek")) return;
444 if (root.__dbxCmsTreeHoverLeaveTimer) window.clearTimeout(root.__dbxCmsTreeHoverLeaveTimer);
445 root.__dbxCmsTreeHoverLeaveTimer = window.setTimeout(() => {
446 root.__dbxCmsTreeHoverLeaveTimer = null;
447 setTreeHoverExpanded(root, false);
451 function initTreePanelState(root, cfg) {
452 const defaultCollapsed = isViewMode(cfg || {});
453 const collapsed = !!(dbx.uiGet ? dbx.uiGet(LIB, PANEL_UI_ID, "treeCollapsed", defaultCollapsed) : defaultCollapsed);
454 applyTreePanelState(root, collapsed);
457 function applyRightPanelState(root, collapsed) {
458 const panel = qs(root, "[data-cms-right-panel]");
461 root.classList.toggle("is-right-panel-collapsed", !!collapsed);
462 qsa(root, "[data-cms-action='toggle-right-panel']").forEach(btn => {
463 btn.setAttribute("aria-expanded", collapsed ? "false" : "true");
464 btn.setAttribute("aria-label", collapsed ? "Rechte Spalte einblenden" : "Rechte Spalte ausblenden");
465 btn.setAttribute("title", collapsed ? "Rechte Spalte einblenden" : "Rechte Spalte ausblenden");
466 const icon = qs(btn, "i");
468 icon.classList.toggle("bi-list", !!collapsed);
469 icon.classList.toggle("bi-chevron-bar-right", !collapsed);
474 function initRightPanelState(root) {
475 if (!qs(root, "[data-cms-right-panel]")) return;
476 const collapsed = !!(dbx.uiGet ? dbx.uiGet(LIB, PANEL_UI_ID, "rightPanelCollapsed", false) : false);
477 applyRightPanelState(root, collapsed);
480 function scrollCmsToTop(root) {
481 const windowBody = closestElement(root, ".dbx-window-body");
482 const appContent = closestElement(root, ".dbx-content");
483 const scrollHost = windowBody || appContent;
485 if (scrollHost && typeof scrollHost.scrollTo === "function") {
486 scrollHost.scrollTo({ top: 0, behavior: "smooth" });
489 window.scrollTo({ top: 0, behavior: "smooth" });
492 function toggleRightPanel(root, trigger) {
493 const collapsed = !root.classList.contains("is-right-panel-collapsed");
494 applyRightPanelState(root, collapsed);
495 if (dbx.uiSet) dbx.uiSet(LIB, PANEL_UI_ID, "rightPanelCollapsed", collapsed);
496 if (!collapsed && trigger && trigger.classList.contains("dbx-cms-right-panel-toggle-bottom")) {
497 window.requestAnimationFrame(() => scrollCmsToTop(root));
501 function toggleTreePanel(root) {
502 const collapsed = !root.classList.contains("is-tree-collapsed");
503 clearTreeHoverTimers(root);
504 setTreeHoverExpanded(root, false);
505 applyTreePanelState(root, collapsed);
506 if (dbx.uiSet) dbx.uiSet(LIB, PANEL_UI_ID, "treeCollapsed", collapsed);
509 function closeTreePanel(root) {
510 if (!root || root.classList.contains("is-tree-collapsed")) return;
511 clearTreeHoverTimers(root);
512 setTreeHoverExpanded(root, false);
513 applyTreePanelState(root, true);
514 if (dbx.uiSet) dbx.uiSet(LIB, PANEL_UI_ID, "treeCollapsed", true);
517 function bindAdminTreeOutsideClose(root, cfg) {
518 if (!root || isViewMode(cfg || {}) || root.__dbxCmsAdminTreeOutsideCloseBound) return;
519 root.__dbxCmsAdminTreeOutsideCloseBound = true;
521 document.addEventListener("click", e => {
522 if (root.classList.contains("is-tree-collapsed")) return;
523 const target = e.target;
524 const panel = qs(root, ".dbx-cms-tree-panel");
525 const toggle = closestElement(target, "[data-cms-action='toggle-tree-panel']");
526 if (toggle && root.contains(toggle)) return;
527 if (panel && panel.contains(target)) return;
528 closeTreePanel(root);
532 function bindViewTreeHover(root, cfg) {
533 if (!isViewMode(cfg) || root.__dbxCmsViewTreeHoverBound) return;
534 root.__dbxCmsViewTreeHoverBound = true;
536 const treePanel = qs(root, ".dbx-cms-view-tree-panel");
537 const viewPanel = qs(root, ".dbx-cms-view-panel");
540 treePanel.addEventListener("mouseenter", () => {
541 if (root.classList.contains("is-tree-collapsed") && !root.classList.contains("is-tree-force-collapsed")) scheduleTreeHover(root, true);
543 treePanel.addEventListener("mouseleave", () => {
544 scheduleTreeHover(root, false);
549 viewPanel.addEventListener("mouseenter", () => {
550 clearTreeHoverTimers(root);
551 setTreeHoverExpanded(root, false);
552 applyTreePanelState(root, true);
553 clearTreeForceCollapse(root);
554 if (dbx.uiSet) dbx.uiSet(LIB, PANEL_UI_ID, "treeCollapsed", true);
558 document.addEventListener("pointermove", e => {
559 if (!root.classList.contains("is-tree-force-collapsed")) return;
560 if (treePanel && treePanel.contains(e.target)) return;
561 clearTreeForceCollapse(root);
564 root.addEventListener("mouseover", e => {
565 if (!root.classList.contains("is-tree-collapsed")) return;
566 if (treePanel && treePanel.contains(e.target)) {
567 if (root.classList.contains("is-tree-force-collapsed")) return;
568 scheduleTreeHover(root, true);
571 if (viewPanel && viewPanel.contains(e.target)) {
572 clearTreeHoverTimers(root);
573 setTreeHoverExpanded(root, false);
574 applyTreePanelState(root, true);
575 clearTreeForceCollapse(root);
576 if (dbx.uiSet) dbx.uiSet(LIB, PANEL_UI_ID, "treeCollapsed", true);
581 function getCollapsedFolders(root) {
582 const s = state(root);
583 if (s.collapsedFolders && typeof s.collapsedFolders === "object") {
584 return s.collapsedFolders;
586 const saved = dbx.uiGet ? dbx.uiGet(LIB, TREE_UI_ID, "collapsedFolders", {}) : {};
587 s.collapsedFolders = (saved && typeof saved === "object" && !Array.isArray(saved)) ? saved : {};
588 return s.collapsedFolders;
591 function setFolderCollapsed(root, id, collapsed) {
592 const folders = getCollapsedFolders(root);
593 const key = String(id || "");
600 if (dbx.uiSet) dbx.uiSet(LIB, TREE_UI_ID, "collapsedFolders", folders);
603 function isFolderCollapsed(root, id, search) {
604 if (search) return false;
605 const folders = getCollapsedFolders(root);
606 return folders[String(id || "")] === true;
609 function setSelectedFolder(root, id) {
610 const s = state(root);
611 s.selectedFolder = Number(id || 0);
612 if (dbx.uiSet) dbx.uiSet(LIB, TREE_UI_ID, "selectedFolder", s.selectedFolder);
615 function setSelectedPage(root, id) {
616 const s = state(root);
617 s.selectedPage = Number(id || 0);
618 if (dbx.uiSet) dbx.uiSet(LIB, TREE_UI_ID, "selectedPage", s.selectedPage);
621 function setSelectedType(root, type) {
622 const s = state(root);
623 s.selectedType = type === "folder" ? "folder" : "page";
624 if (dbx.uiSet) dbx.uiSet(LIB, TREE_UI_ID, "selectedType", s.selectedType);
625 updateHeaderActionTooltips(root);
628 function revealTreeSelection(root) {
629 const s = state(root);
630 const type = s.selectedType === "folder" ? "folder" : "page";
631 const id = type === "folder" ? s.selectedFolder : s.selectedPage;
637 let node = findNode(root, type, id);
638 let parent = Number(node && node._parent || 0);
639 const seen = new Set();
640 while (parent > 0 && !seen.has(parent)) {
642 setFolderCollapsed(root, parent, false);
643 node = findNode(root, "folder", parent);
644 parent = Number(node && node._parent || 0);
648 window.requestAnimationFrame(() => {
649 const row = qs(root, ".dbx-cms-tree-row.is-active");
650 if (row && row.scrollIntoView) {
651 row.scrollIntoView({ block: "nearest", inline: "nearest" });
656 function openPreview(root) {
657 const id = Number(getField(root, "id") || 0);
659 status(root, "Bitte zuerst eine Seite auswaehlen.", "error");
663 const title = getField(root, "title") || "Content Vorschau";
664 const url = "?dbx_modul=dbxContent&dbx_run1=show&dbx_cid=" + encodeURIComponent(id) + "&dbx_window=1";
667 title: "Vorschau: " + title,
679 ensureOpenWin().then(ok => {
681 dbx.openWin.open(cfg, root);
683 status(root, "openWin.js nicht geladen.", "error");
688 function getEditorInstance(root) {
689 return root && root.__dbxCmsJodit ? root.__dbxCmsJodit : null;
692 function editorHeightGroup(root) {
693 return qs(root, ".dbx-cms-editor-group");
696 function syncEditorHeight(root) {
697 const group = editorHeightGroup(root);
698 const surface = editorSurface(root);
699 if (!group || !surface) return;
701 const instance = getEditorInstance(root);
702 const container = instance && instance.container ? instance.container : qs(root, ".jodit-container");
703 const workplace = qs(root, ".jodit-workplace");
704 const source = qs(root, ".jodit-source textarea, .jodit-source");
705 const sized = [container, workplace, surface, source].filter(Boolean);
707 if (container && container.classList && container.classList.contains("jodit_fullsize")) {
708 sized.forEach(el => {
709 if (!el.style) return;
710 el.style.removeProperty("height");
711 el.style.removeProperty("max-height");
712 el.style.removeProperty("min-height");
717 sized.forEach(el => {
718 if (!el.style) return;
719 el.style.height = "auto";
720 el.style.maxHeight = "none";
721 el.style.minHeight = "0px";
725 const contentHeight = Math.ceil(Math.max(
726 surface.scrollHeight || 0,
727 source ? (source.scrollHeight || 0) : 0
729 const nextHeight = Math.max(base, contentHeight);
730 const value = nextHeight + "px";
732 group.style.setProperty("--dbx-cms-editor-min-height", value);
733 sized.forEach(el => {
734 if (!el.style) return;
735 el.style.minHeight = value;
739 function scheduleEditorHeight(root) {
741 if (root.__dbxCmsEditorHeightFrame) {
742 window.cancelAnimationFrame(root.__dbxCmsEditorHeightFrame);
744 root.__dbxCmsEditorHeightFrame = window.requestAnimationFrame(() => {
745 root.__dbxCmsEditorHeightFrame = null;
746 syncEditorHeight(root);
748 [80, 250, 800].forEach(delay => window.setTimeout(() => syncEditorHeight(root), delay));
751 function bindEditorHeight(root) {
752 if (!root || root.__dbxCmsEditorHeightBound) return;
753 root.__dbxCmsEditorHeightBound = true;
755 window.addEventListener("resize", () => scheduleEditorHeight(root), { passive: true });
756 root.addEventListener("input", e => {
757 const surface = editorSurface(root);
758 if (surface && (e.target === surface || surface.contains(e.target))) {
759 scheduleEditorHeight(root);
762 root.addEventListener("load", e => {
763 const surface = editorSurface(root);
764 if (surface && e.target && surface.contains(e.target)) {
765 scheduleEditorHeight(root);
770 function setDirty(root, dirty) {
771 const s = state(root);
773 root.classList.toggle("is-dirty", s.dirty);
775 const el = qs(root, "[data-cms-dirty-state]");
778 const text = el.querySelector("span");
779 if (text) text.textContent = s.dirty ? "Ungespeichert" : "Gespeichert";
780 el.setAttribute("title", s.dirty ? "Ungespeicherte Änderungen" : "Alle Änderungen gespeichert");
783 function setSaving(root, saving) {
784 const s = state(root);
786 root.classList.toggle("is-saving", s.saving);
787 root.setAttribute("aria-busy", s.saving ? "true" : "false");
789 qsa(root, "[data-cms-action='save'], [data-cms-action='save-settings'], [data-cms-action='save-folder']").forEach(btn => {
790 btn.disabled = s.saving;
791 btn.classList.toggle("is-saving", s.saving);
795 function updateHeaderActionTooltips(root) {
797 const isFolder = root.classList.contains("is-folder-editing");
798 const saveTitle = isFolder ? "Ordner speichern" : "Seite speichern";
799 const deleteTitle = isFolder ? "Ordner loeschen" : "Seite loeschen";
800 const saveBtn = qs(root, ".dbx-cms-head [data-cms-action='save']");
801 const deleteBtn = qs(root, ".dbx-cms-head [data-cms-action='delete']");
802 const duplicateBtn = qs(root, ".dbx-cms-head [data-cms-action='duplicate-page']");
803 const lngBtn = qs(root, ".dbx-cms-head [data-cms-action='lng-provision']");
804 const lngResetBtn = qs(root, ".dbx-cms-head [data-cms-action='lng-reset-sync']");
807 saveBtn.setAttribute("title", saveTitle);
808 saveBtn.setAttribute("aria-label", saveTitle);
811 deleteBtn.setAttribute("title", deleteTitle);
812 deleteBtn.setAttribute("aria-label", deleteTitle);
815 const canDuplicate = !isFolder && state(root).selectedType === "page" && !state(root).duplicating;
816 duplicateBtn.disabled = !canDuplicate;
817 duplicateBtn.setAttribute("title", canDuplicate ? "Ausgewaehlte Seite duplizieren" : "Zum Duplizieren zuerst eine Seite auswaehlen");
818 duplicateBtn.setAttribute("aria-label", duplicateBtn.getAttribute("title"));
821 const cfg = cmsConfig(root);
822 const show = isMasterLngCfg(cfg, root);
823 lngBtn.hidden = !show;
824 lngBtn.style.display = show ? "" : "none";
827 const cfg = cmsConfig(root);
828 const show = isMasterLngCfg(cfg, root);
829 lngResetBtn.hidden = !show;
830 lngResetBtn.style.display = show ? "" : "none";
834 function currentCmsLng(root) {
835 const tab = qs(root, ".dbx-cms-lng-tab.is-active");
836 return tab ? String(tab.getAttribute("data-cms-lng") || "").toLowerCase() : "";
839 function cmsLngParams(root) {
840 const lng = currentCmsLng(root);
841 return lng ? { dbx_lng: lng } : {};
844 function isMasterLngCfg(cfg, root) {
845 const activeTab = qs(root, ".dbx-cms-lng-tab.is-active");
846 if (activeTab && activeTab.classList.contains("is-master")) {
850 let master = String((cfg && (cfg.masterlng || cfg.master_lng)) || "").toLowerCase().trim();
851 if (!/^[a-z]{2,3}$/.test(master)) {
852 const masterTab = qs(root, ".dbx-cms-lng-tab.is-master");
853 master = masterTab ? String(masterTab.getAttribute("data-cms-lng") || "de").toLowerCase() : "de";
856 const current = currentCmsLng(root);
857 return !current || current === master;
860 function maybeOpenLngProvisionAfterCreate(root, cfg, data) {
861 if (!data || Number(data.open_lng_provision) !== 1) {
864 if (!isMasterLngCfg(cfg, root)) {
865 dbx.warn("[cms] provision dialog skipped: not master language tab");
868 window.setTimeout(() => openLngProvisionDialog(root, cfg), 0);
871 function openLngProvisionDialog(root, cfg) {
872 const s = state(root);
873 const type = root.classList.contains("is-folder-editing") || s.selectedType === "folder" ? "folder" : "page";
874 const id = type === "folder"
875 ? Number(s.selectedFolder || getField(root, "folder") || 0)
876 : Number(s.selectedPage || getField(root, "id") || 0);
879 status(root, "Bitte zuerst eine Seite oder einen Ordner waehlen.", "error");
882 if (!isMasterLngCfg(cfg, root)) {
883 status(root, "Uebertragung nur in der Master-Sprache moeglich.", "error");
887 const url = cfgUrl(cfg, "lngpreview");
889 status(root, "Lng-Preview-URL fehlt.", "error");
893 fetchJson(apiUrl(url, cmsLngParams(root)), {
895 headers: { "Content-Type": "application/json" },
896 body: JSON.stringify({ type, id })
898 if (!data || !data.ok) {
899 throw new Error(data && data.msg ? data.msg : "Vorschau fehlgeschlagen");
901 showLngProvisionModal(root, cfg, type, id, data.preview || {}, data.provider || "", data.translate_warnings || []);
902 const warn = formatTranslateWarnings(data.translate_warnings);
903 if (warn) status(root, warn, "warning");
905 status(root, err && err.message ? err.message : "Vorschau fehlgeschlagen.", "error");
909 function showLngProvisionModal(root, cfg, type, id, preview, provider, translateWarnings) {
910 const old = qs(root, "[data-cms-lng-dialog]");
911 if (old) old.remove();
913 const prov = String(provider || "").toLowerCase();
914 const provLabel = (!prov || prov === "undef") ? "copy" : prov;
915 const items = Array.isArray(preview.items) ? preview.items : [];
916 const warnGlobal = formatTranslateWarnings(translateWarnings);
917 const warnGlobalHtml = warnGlobal ? `<div class="dbx-cms-lng-warn">${escapeHtml(warnGlobal)}</div>` : "";
918 const treeOptionHtml = type === "folder"
919 ? `<label class="dbx-cms-lng-check mb-2"><input type="checkbox" data-lng-provision-tree> Gesamten Unterbaum inkl. Seiten uebertragen</label>`
921 const rows = items.map((item, index) => {
922 const lng = String(item.lng || "").toUpperCase();
923 const exists = Number(item.exists || 0) === 1;
924 const warnings = Array.isArray(item.warnings) ? item.warnings.join(" ") : "";
925 const warnHtml = warnings ? `<div class="dbx-cms-lng-warn">${escapeHtml(warnings)}</div>` : "";
926 const existsLabel = exists ? " (vorhanden)" : " (neu)";
928 if (type === "folder") {
929 return `<div class="dbx-cms-lng-row" data-lng-row="${index}">
930 <label class="dbx-cms-lng-check"><input type="checkbox" data-lng-enabled checked> <strong>${escapeHtml(lng)}</strong>${escapeHtml(existsLabel)}</label>
931 <label>Name<input class="form-control form-control-sm" data-lng-name value="${escapeHtml(item.name || "")}"></label>
933 <input type="hidden" data-lng-code value="${escapeHtml(item.lng || "")}">
937 return `<div class="dbx-cms-lng-row" data-lng-row="${index}">
938 <label class="dbx-cms-lng-check"><input type="checkbox" data-lng-enabled checked> <strong>${escapeHtml(lng)}</strong>${escapeHtml(existsLabel)}</label>
939 <label>Titel<input class="form-control form-control-sm" data-lng-title value="${escapeHtml(item.title || "")}"></label>
940 <label>Permalink<input class="form-control form-control-sm" data-lng-permalink value="${escapeHtml(item.permalink || "")}"></label>
941 <label>Beschreibung<input class="form-control form-control-sm" data-lng-description value="${escapeHtml(item.description || "")}"></label>
942 <input type="hidden" data-lng-folder value="${Number(item.folder || 0)}">
943 <input type="hidden" data-lng-code value="${escapeHtml(item.lng || "")}">
944 <details class="dbx-cms-lng-content-details"><summary>Inhalt (Vorschau)</summary>
945 <div class="dbx-cms-lng-content-preview dbx-content-page" data-lng-content-preview="${index}"></div>
947 <textarea class="d-none" data-lng-content aria-hidden="true">${escapeTextareaValue(item.content || "")}</textarea>
952 const modal = document.createElement("div");
953 modal.className = "dbx-cms-lng-dialog";
954 modal.setAttribute("data-cms-lng-dialog", "1");
956 <div class="dbx-cms-lng-dialog-backdrop" data-cms-lng-close></div>
957 <div class="dbx-cms-lng-dialog-panel" role="dialog" aria-modal="true" aria-label="Uebersetzungen pruefen">
958 <div class="dbx-cms-lng-dialog-head">
959 <strong>In andere Sprachen uebertragen</strong>
960 <span class="text-muted">Provider: ${escapeHtml(provLabel)}</span>
961 <button type="button" class="btn btn-sm btn-outline-secondary" data-cms-lng-close>×</button>
963 <div class="dbx-cms-lng-dialog-body">
966 ${rows || '<p class="text-muted">Keine Zielsprachen konfiguriert.</p>'}
968 <div class="dbx-cms-lng-dialog-foot">
969 <button type="button" class="btn btn-outline-secondary btn-sm" data-cms-lng-close>Abbrechen</button>
970 <button type="button" class="btn btn-primary btn-sm" data-cms-lng-submit>Uebernehmen</button>
974 root.appendChild(modal);
975 fillLngProvisionContentPreviews(modal, items);
977 qsa(modal, "[data-cms-lng-close]").forEach(btn => {
978 btn.addEventListener("click", () => modal.remove());
981 const submit = qs(modal, "[data-cms-lng-submit]");
983 submit.addEventListener("click", () => {
984 const payloadItems = qsa(modal, "[data-lng-row]").map(row => {
985 const lng = qs(row, "[data-lng-code]");
986 const enabled = qs(row, "[data-lng-enabled]");
988 lng: lng ? lng.value : "",
989 enabled: enabled && enabled.checked ? 1 : 0
991 if (type === "folder") {
992 const name = qs(row, "[data-lng-name]");
993 out.name = name ? name.value : "";
995 const title = qs(row, "[data-lng-title]");
996 const permalink = qs(row, "[data-lng-permalink]");
997 const description = qs(row, "[data-lng-description]");
998 const content = qs(row, "[data-lng-content]");
999 const folder = qs(row, "[data-lng-folder]");
1000 out.title = title ? title.value : "";
1001 out.permalink = permalink ? permalink.value : "";
1002 out.description = description ? description.value : "";
1003 out.content = content ? content.value : "";
1004 out.folder = folder ? Number(folder.value || 0) : 0;
1009 const treeMode = type === "folder" && !!(qs(modal, "[data-lng-provision-tree]") && qs(modal, "[data-lng-provision-tree]").checked);
1010 const provUrl = cfgUrl(cfg, treeMode ? "lngprovisiontree" : "lngprovision");
1011 const payload = treeMode
1012 ? { id, lngs: payloadItems.filter(item => Number(item.enabled) === 1).map(item => item.lng) }
1013 : { type, id, items: payloadItems };
1015 fetchJson(apiUrl(provUrl, cmsLngParams(root)), {
1017 headers: { "Content-Type": "application/json" },
1018 body: JSON.stringify(payload)
1020 if (!data || !data.ok) {
1021 const errs = data && data.result && Array.isArray(data.result.errors) ? data.result.errors.join(" ") : "";
1022 const transWarn = formatTranslateWarnings(data && data.result ? data.result.translate_warnings : null);
1023 throw new Error([data && data.msg, errs, transWarn].filter(Boolean).join(" ") || "Uebertragung fehlgeschlagen");
1026 let msg = treeMode ? "Unterbaum in andere Sprachen uebernommen." : "Sprachvarianten uebernommen.";
1027 const mediaCopied = Number((data.result && data.result.media_copied) || 0);
1028 if (mediaCopied > 0) msg += " " + mediaCopied + " Medien-Verknuepfung(en) kopiert.";
1029 const transWarn = formatTranslateWarnings(data.result && data.result.translate_warnings);
1030 if (transWarn) msg += " " + transWarn;
1031 status(root, msg, transWarn ? "warning" : "success");
1032 return loadTree(root, cfg);
1034 status(root, err && err.message ? err.message : "Uebertragung fehlgeschlagen.", "error");
1040 function formatTranslateWarnings(warnings) {
1041 if (!Array.isArray(warnings) || !warnings.length) return "";
1042 const msgs = warnings.map(w => w && w.message ? String(w.message) : "").filter(Boolean);
1043 if (!msgs.length) return "";
1044 return "Uebersetzung: " + [...new Set(msgs)].join(" ");
1047 function formatLngSaveStatus(base, data) {
1048 const parts = [base];
1049 const synced = Number(data.lng_synced || 0);
1050 if (synced > 0) parts.push(synced + " Sprachversion(en) synchronisiert.");
1051 const media = Number(data.lng_media_copied || 0);
1052 if (media > 0) parts.push(media + " Medien-Verknuepfung(en) uebernommen.");
1053 const inlineAdded = Number(data.inline_media_sync?.added || 0);
1054 const inlineRemoved = Number(data.inline_media_sync?.removed || 0);
1055 if (inlineAdded > 0) parts.push(inlineAdded + " Inline-Medium/Medien verknuepft.");
1056 if (inlineRemoved > 0) parts.push(inlineRemoved + " Inline-Medium/Medien aus der Liste entfernt.");
1057 if (Number(data.lng_forked || 0) === 1) parts.push("Sprachverknuepfung getrennt (manuell).");
1058 if (Number(data.open_lng_provision || 0) === 1) parts.push("Fehlende Sprachen — Dialog folgt.");
1059 const warn = formatTranslateWarnings(data.translate_warnings);
1060 if (warn) parts.push(warn);
1061 const text = parts.join(" ");
1062 const hasWarn = Number(data.lng_forked || 0) === 1 || !!warn;
1063 return { text, type: hasWarn ? "warning" : "success" };
1066 function resetLngSync(root, cfg) {
1067 const s = state(root);
1068 const type = root.classList.contains("is-folder-editing") || s.selectedType === "folder" ? "folder" : "page";
1069 const id = type === "folder"
1070 ? Number(s.selectedFolder || getFolderField(root, "id") || 0)
1071 : Number(s.selectedPage || getField(root, "id") || 0);
1072 const url = cfgUrl(cfg, "lngresetsync");
1075 status(root, "Bitte zuerst eine Seite oder einen Ordner waehlen.", "error");
1076 return Promise.resolve();
1078 if (!isMasterLngCfg(cfg, root)) {
1079 status(root, "Auto-Sync nur in der Master-Sprache setzbar.", "error");
1080 return Promise.resolve();
1083 return ensureConfirm().then(ok => {
1084 if (!ok) return null;
1085 return dbx.confirm.open({
1086 id: "cms-lng-reset-sync-" + id,
1088 title: "<i class=\"bi bi-link-45deg\"></i> Auto-Sync aktivieren",
1089 question: "Verknuepfte Sprachversionen wieder auf <strong>Auto-Sync</strong> stellen?",
1090 hint: "Manuelle Aenderungen in Slave-Sprachen bleiben erhalten, werden aber kuenftig wieder vom Master ueberschrieben.",
1092 labelyes: "Ja, Auto-Sync",
1093 labelno: "Abbrechen",
1095 backdropclose: false,
1099 if (!result || result.action !== "yes") return null;
1100 return fetchJson(apiUrl(url), {
1102 headers: { "Content-Type": "application/json" },
1103 body: JSON.stringify({ type, id })
1107 if (!data.ok) throw new Error(data && data.msg ? data.msg : "Auto-Sync konnte nicht gesetzt werden.");
1108 const count = Array.isArray(data.result && data.result.updated) ? data.result.updated.length : 0;
1109 status(root, count > 0 ? "Auto-Sync fuer " + count + " Sprache(n) aktiviert." : "Keine Sprachversion zum Aktualisieren gefunden.", count > 0 ? "success" : "warning");
1110 return loadTree(root, cfg);
1112 status(root, err && err.message ? err.message : "Auto-Sync konnte nicht gesetzt werden.", "error");
1116 function syncStatusLabel(sync, isMaster) {
1117 if (Number(isMaster) === 1) return "Master";
1118 const s = String(sync || "auto").toLowerCase();
1119 return s === "manual" ? "manuell" : "auto";
1122 function runSimpleDeleteConfirm(root, title, question, hint) {
1123 return ensureConfirm().then(ok => {
1125 status(root, "Confirm-Lib ist nicht geladen.", "error");
1128 return dbx.confirm.open({
1129 id: "cms-delete-simple-" + Date.now(),
1135 labelyes: "<i class=\"bi bi-trash\"></i> Loeschen",
1136 labelno: "<i class=\"bi bi-x-lg\"></i> Abbrechen",
1138 backdropclose: false,
1144 function executeLngDelete(root, cfg, type, id, deleteLngs, deleteUrl) {
1145 return fetchJson(apiUrl(deleteUrl), {
1147 headers: { "Content-Type": "application/json" },
1148 body: JSON.stringify({ id, delete_lngs: deleteLngs })
1150 if (!data || !data.ok) {
1151 throw new Error(data && data.msg ? data.msg : "delete failed");
1153 const count = Array.isArray(data.deleted) ? data.deleted.length : 0;
1154 const warnings = Array.isArray(data.warnings) ? data.warnings.filter(Boolean) : [];
1156 ? (type === "folder" ? "Ordner" : "Seite") + " in " + count + " Sprachen geloescht."
1157 : (type === "folder" ? "Ordner geloescht." : "Seite geloescht.");
1158 if (warnings.length) {
1159 msg += " Hinweis: " + warnings.join(" ");
1161 status(root, msg, warnings.length ? "warning" : "success");
1166 function showLngDeleteModal(root, cfg, type, id, label, preview) {
1167 const old = qs(root, "[data-cms-lng-delete-dialog]");
1168 if (old) old.remove();
1170 const items = Array.isArray(preview.items) ? preview.items : [];
1171 const rows = items.map((item, index) => {
1172 const lng = String(item.lng || "").toUpperCase();
1173 const checked = Number(item.checked || 0) === 1;
1174 const deletable = Number(item.deletable || 0) === 1;
1175 const syncLabel = syncStatusLabel(item.lng_sync, item.is_master);
1176 const syncClass = Number(item.is_master) === 1 ? "master" : String(item.lng_sync || "auto").toLowerCase();
1177 const blockReason = String(item.block_reason || "").trim();
1178 const disabled = deletable ? "" : " disabled";
1179 const checkedAttr = checked && deletable ? " checked" : "";
1180 const blockHtml = blockReason
1181 ? `<div class="dbx-cms-lng-warn">${escapeHtml(blockReason)}</div>`
1184 return `<div class="dbx-cms-lng-row dbx-cms-lng-delete-row" data-lng-delete-row="${index}">
1185 <label class="dbx-cms-lng-check">
1186 <input type="checkbox" data-lng-delete-enabled${disabled}${checkedAttr}>
1187 <strong>${escapeHtml(lng)}</strong>
1188 <span class="dbx-cms-lng-badge is-${escapeHtml(syncClass)}">${escapeHtml(syncLabel)}</span>
1189 <span class="text-muted">${escapeHtml(item.label || "")}</span>
1192 <input type="hidden" data-lng-delete-code value="${escapeHtml(item.lng || "")}">
1196 const entityLabel = type === "folder" ? "Ordner" : "Seite";
1197 const modal = document.createElement("div");
1198 modal.className = "dbx-cms-lng-dialog";
1199 modal.setAttribute("data-cms-lng-delete-dialog", "1");
1201 <div class="dbx-cms-lng-dialog-backdrop" data-cms-lng-delete-close></div>
1202 <div class="dbx-cms-lng-dialog-panel" role="dialog" aria-modal="true" aria-label="Sprachversionen loeschen">
1203 <div class="dbx-cms-lng-dialog-head">
1204 <strong>${escapeHtml(entityLabel)} in mehreren Sprachen loeschen</strong>
1205 <button type="button" class="btn btn-sm btn-outline-secondary" data-cms-lng-delete-close>×</button>
1207 <div class="dbx-cms-lng-dialog-body">
1208 <p class="mb-2">Welche Sprachversionen von <strong>${escapeHtml(label || entityLabel)}</strong> sollen geloescht werden?</p>
1209 <p class="text-muted small mb-3">Auto-Sync-Versionen sind vorausgewaehlt. Manuelle Versionen nur bei Bedarf aktivieren.</p>
1210 ${rows || '<p class="text-muted">Keine verknuepften Sprachversionen gefunden.</p>'}
1212 <div class="dbx-cms-lng-dialog-foot">
1213 <button type="button" class="btn btn-outline-secondary btn-sm" data-cms-lng-delete-close>Abbrechen</button>
1214 <button type="button" class="btn btn-danger btn-sm" data-cms-lng-delete-submit><i class="bi bi-trash"></i> Loeschen</button>
1218 root.appendChild(modal);
1220 qsa(modal, "[data-cms-lng-delete-close]").forEach(btn => {
1221 btn.addEventListener("click", () => modal.remove());
1224 const submit = qs(modal, "[data-cms-lng-delete-submit]");
1226 submit.addEventListener("click", () => {
1227 const deleteLngs = qsa(modal, "[data-lng-delete-row]").map(row => {
1228 const enabled = qs(row, "[data-lng-delete-enabled]");
1229 const code = qs(row, "[data-lng-delete-code]");
1230 if (!enabled || !enabled.checked || enabled.disabled) return "";
1231 return code ? code.value : "";
1234 if (!deleteLngs.length) {
1235 status(root, "Bitte mindestens eine loeschbare Sprachversion auswaehlen.", "error");
1239 const deleteUrl = cfgUrl(cfg, type === "folder" ? "deletefolder" : "deletepage");
1240 executeLngDelete(root, cfg, type, id, deleteLngs, deleteUrl).then(data => {
1242 if (type === "folder") {
1243 hideFolderEditor(root);
1244 const s = state(root);
1245 if (Number(s.selectedFolder || 0) === id) {
1246 setSelectedFolder(root, 0);
1247 setSelectedType(root, "page");
1250 setSelectedPage(root, 0);
1251 setSelectedType(root, "page");
1252 setEditorHtml(root, "");
1253 renderMedia(root, []);
1255 return loadTree(root, cfg);
1257 status(root, err && err.message ? err.message : "Loeschen fehlgeschlagen.", "error");
1263 function openLngDeleteDialog(root, cfg, type, id, label) {
1264 const previewUrl = cfgUrl(cfg, "lngdeletepreview");
1265 const deleteUrl = cfgUrl(cfg, type === "folder" ? "deletefolder" : "deletepage");
1266 const entityLabel = type === "folder" ? "Ordner" : "Seite";
1267 const title = `<i class="bi bi-trash"></i> ${entityLabel} loeschen`;
1268 const question = `${entityLabel} <strong>${escapeHtml(label || entityLabel)}</strong> wirklich loeschen?`;
1269 const hint = type === "folder"
1270 ? "Der Ordner wird nur geloescht, wenn keine Seiten und keine Unterordner enthalten sind."
1271 : "Die Medien werden aus der Seite geloest, Dateien bleiben im Medienbestand.";
1273 if (!isMasterLngCfg(cfg, root) || !previewUrl || !deleteUrl) {
1274 return runSimpleDeleteConfirm(root, title, question, hint).then(result => {
1275 if (!result || result.action !== "yes") return null;
1276 return executeLngDelete(root, cfg, type, id, [], deleteUrl).then(data => {
1277 if (type === "folder") {
1278 hideFolderEditor(root);
1279 const s = state(root);
1280 if (Number(s.selectedFolder || 0) === id) {
1281 setSelectedFolder(root, 0);
1282 setSelectedType(root, "page");
1285 setSelectedPage(root, 0);
1286 setSelectedType(root, "page");
1287 setEditorHtml(root, "");
1288 renderMedia(root, []);
1290 return loadTree(root, cfg);
1295 return fetchJson(apiUrl(previewUrl), {
1297 headers: { "Content-Type": "application/json" },
1298 body: JSON.stringify({ type, id })
1300 if (!data || !data.ok) {
1301 throw new Error(data && data.msg ? data.msg : "Vorschau fehlgeschlagen");
1303 const items = Array.isArray(data.preview && data.preview.items) ? data.preview.items : [];
1304 if (items.length <= 1) {
1305 return runSimpleDeleteConfirm(root, title, question, hint).then(result => {
1306 if (!result || result.action !== "yes") return null;
1307 return executeLngDelete(root, cfg, type, id, [], deleteUrl).then(data => {
1308 if (type === "folder") {
1309 hideFolderEditor(root);
1310 const s = state(root);
1311 if (Number(s.selectedFolder || 0) === id) {
1312 setSelectedFolder(root, 0);
1313 setSelectedType(root, "page");
1316 setSelectedPage(root, 0);
1317 setSelectedType(root, "page");
1318 setEditorHtml(root, "");
1319 renderMedia(root, []);
1321 return loadTree(root, cfg);
1325 showLngDeleteModal(root, cfg, type, id, label, data.preview || {});
1328 status(root, err && err.message ? err.message : "Vorschau fehlgeschlagen.", "error");
1332 function clearCmsLoading(root) {
1333 const anchors = [root, document.body, document.documentElement].filter(Boolean);
1334 anchors.forEach(anchor => {
1335 anchor.classList.remove("dbx-ajax-loading", "is-loading");
1336 anchor.removeAttribute("aria-busy");
1337 anchor.style.removeProperty("filter");
1338 anchor.style.removeProperty("opacity");
1343 el.classList.remove("dbx-ajax-loading", "is-loading");
1344 if (el === document.body) break;
1345 el = el.parentElement;
1348 qsa(document, ".dbx-ajax-loading, .is-loading, [data-cms-action]").forEach(el => {
1349 el.classList.remove("dbx-ajax-loading", "is-loading");
1352 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 => {
1353 el.style.display = "none";
1354 el.style.background = "transparent";
1355 el.style.boxShadow = "none";
1356 el.style.backdropFilter = "none";
1359 qsa(document, ".dbx-loader, .dbx-loading, .dbx-wait, .dbx-backdrop, .modal-backdrop").forEach(el => {
1360 el.classList.remove("show", "active", "dbx-ajax-loading", "is-loading");
1361 el.style.display = "none";
1362 el.style.background = "transparent";
1363 el.style.boxShadow = "none";
1364 el.style.backdropFilter = "none";
1368 function currentSelectionLabel(id, title, fallback) {
1369 const numericId = Number(id || 0);
1370 const text = String(title || fallback || "").trim();
1371 if (numericId > 0 && text) return "(" + numericId + ") " + text;
1372 if (numericId > 0) return "(" + numericId + ")";
1373 return text || fallback || "";
1376 function updateCurrentSelectionTitle(root, type, id, title) {
1377 const el = qs(root, "[data-cms-current-title]");
1379 const fallback = type === "folder" ? "Neuer Ordner" : "Keine Seite ausgewaehlt";
1380 el.textContent = currentSelectionLabel(id, title, fallback);
1383 function updateViewPageTitle(root, title) {
1384 const el = qs(root, "[data-cms-page-title]");
1386 const text = String(title || "").trim();
1387 el.textContent = text !== "" ? text : "Keine Seite ausgewaehlt";
1390 function openContentAdmin(root) {
1391 const s = state(root);
1392 const pageId = Number((s.page && (s.page.id || s.page)) || s.selectedPage || 0);
1394 status(root, "Bitte zuerst eine Seite waehlen.", "info");
1398 const btn = qs(root, "[data-cms-action='open-admin']");
1399 const base = btn ? String(btn.getAttribute("data-cms-admin-base") || "").trim() : "";
1400 const url = (base || "?dbx_modul=dbxContent_admin&dbx_run1=cms") + "&cid=" + encodeURIComponent(pageId);
1403 title: "Content bearbeiten",
1410 if (window.dbx && dbx.openWin && typeof dbx.openWin.open === "function") {
1411 dbx.openWin.open(winCfg);
1415 window.open(url, "_blank", "noopener");
1418 function markDirty(root) {
1419 const s = state(root);
1420 if (s.loading || s.saving || s.suppressDirty) return;
1421 s.dirtyVersion = (Number(s.dirtyVersion || 0) + 1);
1422 setDirty(root, true);
1424 if (root.classList.contains("is-folder-editing")) {
1425 updateCurrentSelectionTitle(root, "folder", getFolderField(root, "id"), getFolderField(root, "name"));
1428 updateCurrentSelectionTitle(root, "page", getField(root, "id"), getField(root, "title"));
1431 function clearDirtyAfterSave(root) {
1432 const s = state(root);
1433 const version = Number(s.dirtyVersion || 0);
1434 setDirty(root, false);
1435 [80, 300].forEach(delay => {
1436 window.setTimeout(() => {
1437 const current = state(root);
1438 if (!current.loading && !current.saving && Number(current.dirtyVersion || 0) === version) {
1439 setDirty(root, false);
1445 function suppressDirtyFor(root, delay) {
1446 const s = state(root);
1447 const token = Date.now() + Math.random();
1448 s.suppressDirty = true;
1449 s.suppressDirtyToken = token;
1450 window.setTimeout(() => {
1451 const current = state(root);
1452 if (current.suppressDirtyToken === token) {
1453 current.suppressDirty = false;
1454 current.suppressDirtyToken = null;
1456 }, Number(delay || 0));
1459 function mediaHtml(row) {
1461 const url = row.url || "";
1462 if (!url) return "";
1464 const mime = String(row.mime || "");
1465 const fileName = String(row.file_name || url);
1466 const title = escapeHtml(row.title || row.alt || fileName || "Medium");
1467 const alt = escapeHtml(row.alt || row.title || fileName || "");
1469 const id = Number(row.id || row.media_id || 0);
1470 const videoUrl = row.embed_url || row.external_url || row.url || "";
1471 const videoDataAttr = isVideoRow(row)
1472 ? ` 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"`
1474 const mediaAttr = id > 0 ? ` data-cms-media-id="${id}" data-cms-media-slot="inline"${videoDataAttr}` : ` data-cms-media-slot="inline"${videoDataAttr}`;
1476 if (mime.startsWith("image/") || /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(fileName)) {
1477 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>`;
1480 if (isExternalVideoRow(row)) {
1481 return `<figure class="dbx-cms-inline-media dbx-cms-inline-video-block"${mediaAttr}>${mediaPlayerInnerHtml(row, id)}</figure><p></p>`;
1484 if (mime.startsWith("video/") || /\.(mp4|webm|ogv|ogg|mov|m4v)$/i.test(fileName)) {
1485 return `<figure class="dbx-cms-inline-media dbx-cms-inline-video-block"${mediaAttr}>${mediaPlayerInnerHtml(row, id)}</figure><p></p>`;
1488 return `<p><a href="${escapeHtml(url)}" target="_blank" rel="noopener">${title}</a></p>`;
1491 function cmsMarkerName(marker) {
1492 return String(marker || "").replace(/^dbx:/, "") || "marker";
1495 function cmsMarkerClassName(marker) {
1496 return cmsMarkerName(marker).replace(/[^a-z0-9_-]+/gi, "-") || "marker";
1499 function cmsMarkerLabel(marker, label) {
1501 "dbx:hero": "Hero-Text",
1502 "dbx:split": "col-2 Trenner",
1503 "dbx:col2": "col-2 Trenner",
1504 "dbx:col3a": "col-3a Trenner",
1505 "dbx:col3b": "col-3b Trenner",
1506 "dbx:header": "Header",
1507 "dbx:teaser": "Header",
1508 "dbx:footer": "Footer",
1509 "dbx:pagebreak": "Druck Seitenumbruch"
1511 return label || labels[marker] || marker || "dbx:marker";
1514 function cmsMarkerHtml(marker, label) {
1515 if (marker === "dbx:split") marker = "dbx:col2";
1516 const name = cmsMarkerName(marker);
1517 const className = cmsMarkerClassName(marker);
1518 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))}">`;
1521 function cmsMarkerElement(marker, label, doc) {
1522 const tpl = (doc || document).createElement("template");
1523 tpl.innerHTML = cmsMarkerHtml(marker, label);
1524 return tpl.content.firstElementChild;
1527 function markerNameFromElement(node) {
1528 if (!node || node.nodeType !== 1 || !node.getAttribute) return "";
1529 const raw = node.getAttribute("data-dbx-marker") || node.getAttribute("data-dbx-marker-comment") || "";
1530 return raw ? cmsMarkerName(raw) : "";
1533 function ignorableMarkerSibling(node) {
1534 if (!node) return false;
1535 if (node.nodeType === 3) return String(node.nodeValue || "").replace(/\uFEFF/g, "").trim() === "";
1536 if (node.nodeType !== 1) return false;
1537 const tag = node.tagName || "";
1538 if (tag === "BR") return true;
1539 if (!/^(P|DIV|SPAN)$/i.test(tag)) return false;
1540 if (node.querySelector && node.querySelector(".dbx-cms-marker,[data-dbx-marker],img,video,iframe,table,hr")) return false;
1541 return String(node.textContent || "").replace(/\uFEFF/g, "").trim() === "";
1544 function nearbyMarkerSibling(node, dir) {
1545 let cur = dir < 0 ? node?.previousSibling : node?.nextSibling;
1546 while (cur && ignorableMarkerSibling(cur)) cur = dir < 0 ? cur.previousSibling : cur.nextSibling;
1547 return cur && cur.nodeType === 1 ? cur : null;
1550 function hasSameMarkerNeighbor(node, name) {
1551 return markerNameFromElement(nearbyMarkerSibling(node, -1)) === name ||
1552 markerNameFromElement(nearbyMarkerSibling(node, 1)) === name;
1555 function dedupeAdjacentMarkers(container) {
1556 if (!container) return;
1557 qsa(container, ".dbx-cms-marker,[data-dbx-marker]").forEach(marker => {
1558 if (!marker.parentNode) return;
1559 const name = markerNameFromElement(marker);
1561 const prev = nearbyMarkerSibling(marker, -1);
1562 if (markerNameFromElement(prev) === name) marker.remove();
1566 function normalizeCommentMarkers(container) {
1567 if (!container) return;
1568 const doc = container.ownerDocument || document;
1569 const comments = [];
1570 (function collect(node) {
1571 Array.from(node && node.childNodes || []).forEach(child => {
1572 if (child.nodeType === 8) comments.push(child);
1573 else collect(child);
1576 comments.forEach(node => {
1577 const text = String(node.nodeValue || "").trim();
1578 const match = text.match(/^dbx:([a-z0-9_-]+)/i);
1580 const name = cmsMarkerName("dbx:" + match[1].toLowerCase());
1581 if (node.parentNode && hasSameMarkerNeighbor(node, name)) {
1582 node.parentNode.removeChild(node);
1585 const marker = cmsMarkerElement("dbx:" + name, null, doc);
1586 if (marker && node.parentNode) node.parentNode.replaceChild(marker, node);
1589 if (node.parentNode) node.parentNode.removeChild(node);
1591 dedupeAdjacentMarkers(container);
1594 function cmsMarkerMenuIcon() {
1595 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>';
1598 function cmsHrIcon() {
1599 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>';
1602 function cmsSaveIcon() {
1603 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>';
1606 function cmsTextStyleIcon() {
1607 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>';
1610 function cmsModBrowserIcon() {
1611 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>';
1614 function modPlaceholderFromItem(item) {
1617 url: item?.url || "",
1618 label: item?.label || "",
1619 default_modul: item?.default_modul || "",
1620 default_params: item?.default_params || "",
1621 default_alt: item?.default_alt || item?.label || "Modul"
1625 function modPlaceholderHtml(item, insertId) {
1626 const row = modPlaceholderFromItem(item);
1627 const url = row.url || "";
1628 const alt = row.default_alt || "Modul";
1629 const modul = row.default_modul || "";
1630 const params = row.default_params || "";
1631 const modAttr = modul ? ` data-cms-mod-modul="${escapeHtml(modul)}"` : "";
1632 const parAttr = params ? ` data-cms-mod-params="${escapeHtml(params)}"` : "";
1633 const insertAttr = insertId ? ` data-cms-mod-insert-id="${escapeHtml(insertId)}"` : "";
1634 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>`;
1637 function inlineModTarget(root, target) {
1638 const surface = editorSurface(root);
1639 const mod = closestElement(target, ".dbx-cms-mod-placeholder");
1640 return mod && surface && surface.contains(mod) ? mod : null;
1643 function normalizeModPlaceholders(container) {
1644 if (!container) return;
1645 qsa(container, ".dbx-cms-mod-placeholder").forEach(wrap => {
1646 wrap.setAttribute("contenteditable", "false");
1647 wrap.classList.add("dbx-cms-inline-media");
1648 const img = qs(wrap, "img") || qs(wrap, ".dbx-cms-mod-image");
1650 img.classList.add("dbx-cms-mod-image");
1651 img.style.width = "100%";
1652 img.style.maxWidth = "100%";
1653 img.style.height = "auto";
1654 img.style.display = "block";
1655 img.setAttribute("contenteditable", "false");
1656 img.removeAttribute("data-dbx");
1657 const modul = wrap.getAttribute("data-cms-mod-modul") || "";
1658 const params = wrap.getAttribute("data-cms-mod-params") || "";
1660 img.setAttribute("data-cms-mod-modul", modul);
1661 wrap.setAttribute("data-cms-mod-modul", modul);
1664 img.setAttribute("data-cms-mod-params", params);
1665 wrap.setAttribute("data-cms-mod-params", params);
1667 const alt = img.getAttribute("alt") || "";
1668 if (alt) wrap.setAttribute("title", alt);
1672 function modPlaceholderValues(wrapper) {
1673 const img = wrapper ? (qs(wrapper, "img") || qs(wrapper, ".dbx-cms-mod-image")) : null;
1675 modul: wrapper?.getAttribute("data-cms-mod-modul") || img?.getAttribute("data-cms-mod-modul") || "",
1676 params: wrapper?.getAttribute("data-cms-mod-params") || img?.getAttribute("data-cms-mod-params") || "",
1677 alt: img?.getAttribute("alt") || wrapper?.getAttribute("title") || ""
1681 function selectEditorModPlaceholder(root, wrap) {
1682 const surface = editorSurface(root);
1683 qsa(surface, ".dbx-cms-mod-placeholder.is-selected").forEach(el => {
1684 el.classList.remove("is-selected");
1685 el.removeAttribute("aria-selected");
1687 if (wrap && surface && surface.contains(wrap)) {
1688 selectEditorMarker(root, null);
1689 selectEditorMissingMedia(root, null);
1690 wrap.classList.add("is-selected");
1691 wrap.setAttribute("aria-selected", "true");
1692 state(root).selectedModPlaceholder = wrap;
1693 hideEditorCaretHint(root);
1694 selectEditorNode(root, wrap);
1696 state(root).selectedModPlaceholder = null;
1700 function isEmptyEditorParagraph(el) {
1701 return !!(el && el.nodeType === 1 && /^P$/i.test(el.tagName || "") && !nodeHasEditorContent(el));
1704 function removeEditorModPlaceholder(root, wrap) {
1705 wrap = wrap || state(root).selectedModPlaceholder;
1706 const surface = editorSurface(root);
1707 if (!wrap || !wrap.parentNode || !surface || !surface.contains(wrap)) return false;
1708 const next = wrap.nextElementSibling;
1709 selectEditorModPlaceholder(root, null);
1711 if (isEmptyEditorParagraph(next)) next.remove();
1712 syncEditorDom(root);
1717 function insertModPlaceholder(root, item, cfg) {
1718 const insertId = "mod_" + Date.now().toString(36) + "_" + Math.random().toString(36).slice(2, 8);
1719 const html = modPlaceholderHtml(item, insertId);
1720 if (!html) return false;
1721 insertEditorHtml(root, html);
1722 const surface = editorSurface(root);
1723 normalizeModPlaceholders(surface);
1724 setField(root, "content", getEditorHtml(root));
1726 scheduleEditorHeight(root);
1727 const inserted = qs(surface, `.dbx-cms-mod-placeholder[data-cms-mod-insert-id="${insertId}"]`);
1729 inserted.removeAttribute("data-cms-mod-insert-id");
1730 selectEditorModPlaceholder(root, inserted);
1731 if (inserted.scrollIntoView) inserted.scrollIntoView({ block: "center", inline: "nearest" });
1732 window.setTimeout(() => openModPlaceholderOptions(root, inserted, cfg || cmsConfig(root) || {}), 0);
1737 function slashCommandHit(root, token) {
1738 const surface = editorSurface(root);
1739 const doc = surface ? (surface.ownerDocument || document) : document;
1740 const sel = doc.getSelection ? doc.getSelection() : null;
1741 if (!surface || !sel || !sel.rangeCount || !sel.isCollapsed) return null;
1742 const range = sel.getRangeAt(0);
1743 if (!rangeInsideSurface(surface, range)) return null;
1744 const node = range.startContainer;
1745 const offset = range.startOffset || 0;
1746 if (!node || node.nodeType !== 3 || offset < token.length) return null;
1747 const before = String(node.nodeValue || "").slice(offset - token.length, offset);
1748 if (before.toLowerCase() !== token.toLowerCase()) return null;
1749 return { node, start: offset - token.length, end: offset };
1752 function removeSlashCommandHit(root, hit) {
1753 if (!hit || !hit.node) return false;
1754 const text = String(hit.node.nodeValue || "");
1755 hit.node.nodeValue = text.slice(0, hit.start) + text.slice(hit.end);
1756 const surface = editorSurface(root);
1757 const doc = surface ? (surface.ownerDocument || document) : document;
1758 const sel = doc.getSelection ? doc.getSelection() : null;
1759 if (sel && doc.createRange) {
1760 const range = doc.createRange();
1761 range.setStart(hit.node, hit.start);
1762 range.collapse(true);
1763 sel.removeAllRanges();
1764 sel.addRange(range);
1765 state(root).editorRange = range.cloneRange();
1767 syncEditorAfterContextAction(root);
1768 setField(root, "content", getEditorHtml(root));
1772 function handleEditorSlashCommands(root, cfg) {
1773 if (!root || root.__dbxCmsSlashModOpening) return;
1774 const hit = slashCommandHit(root, "/mod");
1776 root.__dbxCmsSlashModOpening = true;
1777 removeSlashCommandHit(root, hit);
1778 saveEditorSelection(root);
1779 openModBrowser(root, cfg || cmsConfig(root) || {});
1780 window.setTimeout(() => {
1781 root.__dbxCmsSlashModOpening = false;
1785 function bindEditorSlashCommands(root, cfg) {
1786 const surface = editorSurface(root);
1787 if (!surface || surface.__dbxCmsSlashCommandsBound) return;
1788 surface.__dbxCmsSlashCommandsBound = true;
1789 const run = () => window.setTimeout(() => handleEditorSlashCommands(root, cfg || {}), 0);
1790 surface.addEventListener("input", run);
1791 surface.addEventListener("keyup", e => {
1792 if (e.ctrlKey || e.metaKey || e.altKey) return;
1793 if (e.key === "d" || e.key === "D" || e.key === "Enter" || e.key === " ") run();
1797 function mediaRowFromItem(item) {
1799 url: item?.getAttribute("data-url") || "",
1800 mime: item?.getAttribute("data-mime") || "",
1801 file_name: item?.getAttribute("data-file-name") || "",
1802 file_path: item?.getAttribute("data-file-path") || "",
1803 thumb_url: item?.getAttribute("data-thumb-url") || "",
1804 media_type: item?.getAttribute("data-media-type") || "",
1805 width: item?.getAttribute("data-width") || "",
1806 height: item?.getAttribute("data-height") || "",
1807 title: item?.getAttribute("data-title") || "",
1808 alt: item?.getAttribute("data-alt") || "",
1809 slot: item?.getAttribute("data-slot") || "",
1810 media_folder: item?.getAttribute("data-media-folder") || "",
1811 id: item?.getAttribute("data-media-id") || ""
1815 function currentMediaSlot(root) {
1816 const filter = qs(root, "[data-cms-media-filter]");
1817 const value = filter ? filter.value : state(root).mediaFilter;
1818 return value && value !== "all" ? value : "gallery";
1821 function syncUploadSlot(root) {
1822 const input = qs(root, "[data-cms-upload-slot]");
1823 if (input) input.value = currentMediaSlot(root);
1824 const externalInput = qs(root, "[data-cms-external-video-slot]");
1825 if (externalInput) externalInput.value = currentMediaSlot(root);
1828 function insertMediaRow(root, row) {
1829 const html = mediaHtml(row);
1831 leaveInlineMediaCaption(root);
1832 if (!insertEditorFragment(root, html)) insertEditorHtml(root, html);
1835 function applyInlineMediaAssignment(root, row) {
1836 if (!row || !row.url) return;
1837 insertMediaRow(root, row);
1838 setLocalMediaSlot(root, row.id, "inline");
1842 function mediaRowWithUsage(row, usage, fallbackSlot) {
1843 const out = Object.assign({}, row || {});
1844 usage = usage || {};
1845 if (usage.id) out.usage_id = usage.id;
1846 if (usage.id) out.current_usage_id = usage.id;
1847 out.slot = usage.slot || out.slot || fallbackSlot || "gallery";
1848 if (usage.sorter) out.sorter = usage.sorter;
1849 if (usage.template) out.template = usage.template;
1850 if (usage.caption) out.caption = usage.caption;
1854 function upsertLocalMediaRow(root, row) {
1855 const id = Number(row && row.id || 0);
1857 const s = state(root);
1858 const rows = Array.isArray(s.mediaRows) ? s.mediaRows.slice() : [];
1859 const pos = rows.findIndex(item => Number(item.id || 0) === id);
1860 if (pos >= 0) rows[pos] = Object.assign({}, rows[pos], row);
1861 else rows.push(row);
1862 renderMedia(root, rows);
1865 function setLocalMediaSlot(root, id, slot) {
1866 id = Number(id || 0);
1868 const s = state(root);
1869 const row = (s.mediaRows || []).find(item => Number(item.id || 0) === id);
1876 function inlineVideoDataAttributes(row, id, options) {
1878 options = options || {};
1879 const videoUrl = row.embed_url || row.external_url || row.url || "";
1881 `data-cms-media-id="${id}"`,
1882 'data-cms-media-slot="inline"',
1883 `data-cms-video-url="${escapeHtml(videoUrl)}"`,
1884 `data-cms-video-type="${escapeHtml(row.media_type || "")}"`,
1885 `data-cms-video-mime="${escapeHtml(row.mime || "")}"`,
1886 `data-cms-video-autoplay="${options.autoplay ? "1" : "0"}"`,
1887 `data-cms-video-loop="${options.loop ? "1" : "0"}"`,
1888 `data-cms-video-muted="${options.muted ? "1" : "0"}"`
1890 if (options.width) attrs.push(`data-cms-video-width="${escapeHtml(options.width)}"`);
1891 if (options.height) attrs.push(`data-cms-video-height="${escapeHtml(options.height)}"`);
1892 return " " + attrs.join(" ");
1895 function inlineVideoOptionsFromElement(el) {
1896 const media = el ? qs(el, ".dbx-cms-inline-video-thumb,.dbx-cms-inline-video-empty,video,iframe,img") : null;
1897 const attr = name => String((el && el.getAttribute && el.getAttribute(name)) || (media && media.getAttribute && media.getAttribute(name)) || "");
1899 width: cssSizeValue(attr("data-cms-video-width") || el?.style?.width || media?.style?.width || ""),
1900 height: cssSizeValue(attr("data-cms-video-height") || el?.style?.height || media?.style?.height || ""),
1901 autoplay: attr("data-cms-video-autoplay") === "1",
1902 loop: attr("data-cms-video-loop") === "1",
1903 muted: attr("data-cms-video-muted") === "1"
1907 function syncInlineVideoOptionsToMedia(wrapper, options) {
1908 if (!wrapper) return;
1909 options = options || {};
1910 const targets = [wrapper].concat(qsa(wrapper, ".dbx-cms-inline-video-thumb,.dbx-cms-inline-video-empty,.dbx-cms-inline-video-player,video,iframe,img"));
1911 targets.forEach(el => {
1912 if (!el || !el.setAttribute) return;
1913 if (options.width) el.setAttribute("data-cms-video-width", options.width);
1914 else el.removeAttribute("data-cms-video-width");
1915 if (options.height) el.setAttribute("data-cms-video-height", options.height);
1916 else el.removeAttribute("data-cms-video-height");
1917 el.setAttribute("data-cms-video-autoplay", options.autoplay ? "1" : "0");
1918 el.setAttribute("data-cms-video-loop", options.loop ? "1" : "0");
1919 el.setAttribute("data-cms-video-muted", options.muted ? "1" : "0");
1923 function inlineVideoOptionsButtonHtml() {
1924 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>';
1927 function mediaPlayerInnerHtml(row, id, options) {
1929 id = Number(id || row.id || row.media_id || 0);
1930 if (id <= 0) return "";
1931 const title = escapeHtml(row.title || row.alt || row.file_name || "Video");
1932 const thumb = row.thumb_url || "";
1933 const mediaAttr = inlineVideoDataAttributes(row, id, options || {});
1935 ? `<img class="dbx-cms-inline-video-thumb" src="${escapeHtml(thumb)}" alt="${title}" title="${title}"${mediaAttr}>`
1936 : `<span class="dbx-cms-inline-video-empty"${mediaAttr}>${title}</span>`;
1937 return `${img}<span class="dbx-cms-inline-video-play" aria-hidden="true"><i class="bi bi-play-fill"></i></span>${inlineVideoOptionsButtonHtml()}`;
1940 function inlineVideoTarget(root, target) {
1941 const surface = editorSurface(root);
1942 if (!surface || !target) return null;
1943 const direct = closestElement(target, ".dbx-cms-inline-video-block");
1944 if (direct && surface.contains(direct)) return direct;
1945 const media = closestElement(target, ".dbx-cms-inline-video-player,iframe,video");
1946 return media && surface && surface.contains(media) ? media : null;
1949 function inlineVideoTargetAtPoint(root, e) {
1950 const surface = editorSurface(root);
1951 if (!surface || !e || !Number.isFinite(Number(e.clientX)) || !Number.isFinite(Number(e.clientY))) return null;
1952 const x = Number(e.clientX);
1953 const y = Number(e.clientY);
1954 const selectors = ".dbx-cms-inline-video-block,.dbx-cms-inline-video-player,iframe,video";
1955 return qsa(surface, selectors).find(el => {
1956 const target = closestElement(el, ".dbx-cms-inline-video-block") || el;
1957 const rect = target.getBoundingClientRect ? target.getBoundingClientRect() : null;
1958 return rect && x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
1962 function inlineVideoEventTarget(root, e) {
1963 return inlineVideoTarget(root, e && e.target) || inlineVideoTargetAtPoint(root, e);
1966 function cmsRootFromEditorElement(el) {
1967 if (!el) return null;
1968 const direct = closestElement(el, ".dbx-cms");
1969 if (direct) return direct;
1970 return qsa(document, ".dbx-cms[data-dbx]").find(root => {
1971 const surface = editorSurface(root);
1972 return root.contains(el) || (surface && surface.contains(el));
1976 function isInlineVideoResizeHandleEvent(wrapper, e) {
1977 if (!wrapper || !e || !wrapper.getBoundingClientRect) return false;
1978 const rect = wrapper.getBoundingClientRect();
1980 return e.clientX >= rect.right - grip && e.clientY >= rect.bottom - grip;
1983 function cssSizeValue(value) {
1984 value = String(value || "").trim();
1985 if (!value) return "";
1986 if (/^\d+(\.\d+)?$/.test(value)) return value + "px";
1987 if (/^\d+(\.\d+)?(px|%|vw|vh|rem|em)$/i.test(value)) return value;
1988 if (/^auto$/i.test(value)) return "auto";
1992 function openInlineVideoOptions(root, media) {
1993 if (!media) return false;
1994 const modal = ensureInlineVideoOptionsDialog(root);
1995 modal.__dbxCmsInlineVideo = media;
1996 const options = inlineVideoOptionsFromElement(media);
1997 const width = qs(modal, "[data-cms-video-options-width]");
1998 const height = qs(modal, "[data-cms-video-options-height]");
1999 const autoplay = qs(modal, "[data-cms-video-options-autoplay]");
2000 const loop = qs(modal, "[data-cms-video-options-loop]");
2001 const muted = qs(modal, "[data-cms-video-options-muted]");
2002 if (width) width.value = options.width || "";
2003 if (height) height.value = options.height || "";
2004 if (autoplay) autoplay.checked = !!options.autoplay;
2005 if (loop) loop.checked = !!options.loop;
2006 if (muted) muted.value = options.muted ? "1" : "0";
2007 modal.hidden = false;
2008 openInlineVideoOptionsWindow(root, modal);
2012 function applyInlineVideoOptions(root, modal) {
2013 const media = modal && modal.__dbxCmsInlineVideo;
2014 if (!media) return false;
2015 const width = cssSizeValue(qs(modal, "[data-cms-video-options-width]")?.value || "");
2016 const height = cssSizeValue(qs(modal, "[data-cms-video-options-height]")?.value || "");
2017 const autoplay = qs(modal, "[data-cms-video-options-autoplay]")?.checked;
2018 const loop = qs(modal, "[data-cms-video-options-loop]")?.checked;
2019 let muted = qs(modal, "[data-cms-video-options-muted]")?.value === "1";
2020 if (autoplay) muted = true;
2024 autoplay: !!autoplay,
2029 media.style.width = width;
2030 media.setAttribute("data-cms-video-width", width);
2032 media.style.width = "";
2033 media.removeAttribute("data-cms-video-width");
2036 media.style.height = height;
2037 media.setAttribute("data-cms-video-height", height);
2039 media.style.height = "";
2040 media.removeAttribute("data-cms-video-height");
2042 media.setAttribute("data-cms-video-autoplay", autoplay ? "1" : "0");
2043 media.setAttribute("data-cms-video-loop", loop ? "1" : "0");
2044 media.setAttribute("data-cms-video-muted", muted ? "1" : "0");
2045 syncInlineVideoOptionsToMedia(media, options);
2046 syncEditorAfterContextAction(root);
2048 closeInlineVideoOptionsWindow(modal);
2052 function closeInlineVideoOptionsWindow(modal) {
2054 const winId = modal.__dbxCmsWindowId || "";
2055 modal.__dbxCmsInlineVideo = null;
2056 modal.hidden = true;
2057 modal.classList.remove("is-open");
2058 modal.__dbxCmsWindowId = null;
2059 modal.removeAttribute("role");
2060 modal.removeAttribute("aria-modal");
2061 modal.removeAttribute("aria-label");
2062 if (winId && dbx.openWin && typeof dbx.openWin.close === "function") {
2063 dbx.openWin.close(winId);
2068 function ensureInlineVideoOptionsDialog(root) {
2069 const s = state(root);
2070 let modal = s.videoOptionsModal || qs(root, "[data-cms-video-options]");
2072 modal.__dbxCmsRoot = root;
2073 s.videoOptionsModal = modal;
2074 if (!qs(modal, "[data-cms-video-options-loop]")) {
2075 const autoplayLabel = qs(modal, "[data-cms-video-options-autoplay]")?.closest("label");
2076 if (autoplayLabel && autoplayLabel.parentNode) {
2077 const loopLabel = document.createElement("label");
2078 loopLabel.className = "dbx-cms-video-options-check";
2079 loopLabel.innerHTML = '<input type="checkbox" data-cms-video-options-loop> Auto Loop';
2080 autoplayLabel.insertAdjacentElement("afterend", loopLabel);
2085 modal = document.createElement("div");
2086 modal.className = "dbx-cms-video-options";
2087 modal.setAttribute("data-cms-video-options", "1");
2088 modal.__dbxCmsRoot = root;
2089 modal.hidden = true;
2091 <div class="dbx-cms-video-options-body">
2092 <label>Breite <input type="text" class="form-control form-control-sm" data-cms-video-options-width placeholder="z.B. 640px, 80%, leer = Standard"></label>
2093 <label>Hoehe <input type="text" class="form-control form-control-sm" data-cms-video-options-height placeholder="z.B. 240px, leer = 16:9"></label>
2094 <label class="dbx-cms-video-options-check"><input type="checkbox" data-cms-video-options-autoplay> Autoplay</label>
2095 <label class="dbx-cms-video-options-check"><input type="checkbox" data-cms-video-options-loop> Auto Loop</label>
2096 <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>
2098 <div class="dbx-cms-video-options-actions">
2099 <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>
2100 <button type="button" class="btn btn-primary btn-sm" data-cms-video-options-apply><i class="bi bi-check2"></i><span>Uebernehmen</span></button>
2102 modal.addEventListener("click", e => {
2103 if (closestElement(e.target, "[data-cms-video-options-close]")) {
2105 e.stopPropagation();
2106 closeInlineVideoOptionsWindow(modal);
2109 if (closestElement(e.target, "[data-cms-video-options-apply]")) {
2111 e.stopPropagation();
2112 applyInlineVideoOptions(root, modal);
2115 root.appendChild(modal);
2116 s.videoOptionsModal = modal;
2120 function bindInlineVideoOptionsDocumentEvents() {
2121 if (document.__dbxCmsVideoOptionsEventsBound) return;
2122 document.__dbxCmsVideoOptionsEventsBound = true;
2123 const openOptionsFromButton = e => {
2124 if (e.button !== undefined && e.button !== 0) return false;
2125 const openBtn = closestElement(e.target, "[data-cms-inline-video-options-open]");
2126 if (!openBtn) return false;
2127 const root = cmsRootFromEditorElement(openBtn);
2128 const video = closestElement(openBtn, ".dbx-cms-inline-video-block");
2129 const surface = root ? editorSurface(root) : null;
2130 if (!root || !video || (surface && !surface.contains(video))) return false;
2132 e.stopPropagation();
2133 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
2134 const now = Date.now();
2135 if (!openBtn.__dbxCmsVideoOptionsOpenedAt || now - openBtn.__dbxCmsVideoOptionsOpenedAt > 250) {
2136 openBtn.__dbxCmsVideoOptionsOpenedAt = now;
2137 openInlineVideoOptions(root, video);
2141 document.addEventListener("pointerdown", openOptionsFromButton, true);
2142 document.addEventListener("mousedown", openOptionsFromButton, true);
2143 document.addEventListener("click", e => {
2144 if (openOptionsFromButton(e)) return;
2145 const closeBtn = closestElement(e.target, "[data-cms-video-options-close]");
2146 const applyBtn = closestElement(e.target, "[data-cms-video-options-apply]");
2147 if (!closeBtn && !applyBtn) return;
2148 const modal = closestElement(e.target, "[data-cms-video-options]");
2151 e.stopPropagation();
2152 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
2154 closeInlineVideoOptionsWindow(modal);
2157 const root = modal.__dbxCmsRoot || closestElement(modal, ".dbx-cms");
2158 if (root) applyInlineVideoOptions(root, modal);
2159 else closeInlineVideoOptionsWindow(modal);
2163 function openInlineVideoOptionsWindow(root, modal) {
2164 if (!modal) return false;
2165 const currentWindow = closestElement(modal, ".dbx-window");
2166 const oldOptionsWindowId = modal.__dbxCmsWindowId || "";
2167 if (currentWindow && currentWindow.parentNode) {
2168 document.body.appendChild(modal);
2169 if (oldOptionsWindowId && currentWindow.id === oldOptionsWindowId && dbx.openWin && typeof dbx.openWin.close === "function") {
2170 dbx.openWin.close(oldOptionsWindowId);
2172 } else if (modal.parentNode !== document.body) {
2173 document.body.appendChild(modal);
2175 modal.__dbxCmsRoot = root;
2176 modal.__dbxCmsWindowId = null;
2177 modal.hidden = false;
2178 modal.classList.add("is-open");
2179 modal.setAttribute("role", "dialog");
2180 modal.setAttribute("aria-modal", "true");
2181 modal.setAttribute("aria-label", "Video Optionen");
2182 const first = qs(modal, "[data-cms-video-options-width]") || qs(modal, "input,select,button");
2183 if (first && first.focus) window.setTimeout(() => first.focus({ preventScroll: true }), 0);
2187 function repairInlineVideoPlayers(root, container) {
2188 if (!container) return;
2189 const rows = state(root).mediaRows || [];
2190 qsa(container, ".dbx-cms-inline-media[data-cms-media-id]").forEach(wrapper => {
2191 const id = Number(wrapper.getAttribute("data-cms-media-id") || 0);
2193 const row = rows.find(item => Number(item.id || item.media_id || 0) === id) || null;
2194 const needsVideo = wrapper.classList.contains("dbx-cms-inline-video-block") || (row && isVideoRow(row));
2195 if (!needsVideo || wrapper.querySelector(".dbx-cms-inline-video-thumb,.dbx-cms-inline-video-empty")) return;
2196 wrapper.classList.add("dbx-cms-inline-video-block");
2197 wrapper.removeAttribute("contenteditable");
2198 const options = inlineVideoOptionsFromElement(wrapper);
2199 wrapper.innerHTML = mediaPlayerInnerHtml(row, id, options);
2200 syncInlineVideoOptionsToMedia(wrapper, options);
2204 function repairInlineVideoHtml(root, html) {
2205 const wrap = document.createElement("div");
2206 wrap.innerHTML = String(html || "");
2207 repairInlineVideoPlayers(root, wrap);
2208 return wrap.innerHTML;
2211 function mediaRowById(root, id) {
2212 id = Number(id || 0);
2213 if (!id) return null;
2214 return (state(root).mediaRows || []).find(row => Number(row.id || row.media_id || 0) === id) || null;
2217 function youtubeEmbedFromThumb(src) {
2218 const match = String(src || "").match(/img\.youtube\.com\/vi\/([^/]+)/i);
2219 return match ? "https://www.youtube.com/embed/" + encodeURIComponent(match[1]) : "";
2222 function inlineVideoRowFromWrapper(root, wrapper, id) {
2223 const row = mediaRowById(root, id);
2224 if (row) return row;
2225 const media = qs(wrapper, "[data-cms-media-id]") || wrapper;
2226 const thumb = qs(wrapper, ".dbx-cms-inline-video-thumb, img");
2227 const youtubeUrl = youtubeEmbedFromThumb(thumb?.getAttribute("src") || "");
2228 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}` : "");
2229 if (!url) return null;
2232 media_type: wrapper.getAttribute("data-cms-video-type") || media?.getAttribute("data-cms-video-type") || (youtubeUrl ? "external_video" : "video"),
2233 mime: wrapper.getAttribute("data-cms-video-mime") || media?.getAttribute("data-cms-video-mime") || "",
2235 embed_url: youtubeUrl ? url : "",
2236 thumb_url: thumb?.getAttribute("src") || "",
2237 title: thumb?.getAttribute("title") || thumb?.getAttribute("alt") || "Video"
2241 function urlWithParams(url, params) {
2243 const out = new URL(String(url || ""), window.location.href);
2244 Object.keys(params || {}).forEach(key => out.searchParams.set(key, params[key]));
2245 return out.toString();
2247 return String(url || "");
2251 function youtubeVideoIdFromUrl(url) {
2252 const match = String(url || "").match(/(?:embed\/|v=|youtu\.be\/)([A-Za-z0-9_-]{11})/i);
2253 return match ? match[1] : "";
2256 function externalVideoPlayerParams(url, options) {
2257 options = options || {};
2258 const params = { playsinline: "1", rel: "0" };
2259 if (options.autoplay) params.autoplay = "1";
2260 params.mute = options.muted ? "1" : "0";
2263 const videoId = youtubeVideoIdFromUrl(url);
2264 if (videoId) params.playlist = videoId;
2269 function inlineVideoEditorPlayerHtml(row, id, options) {
2271 options = options || {};
2272 id = Number(id || row.id || row.media_id || 0);
2273 const title = escapeHtml(row.title || row.alt || row.file_name || "Video");
2274 const mediaAttr = inlineVideoDataAttributes(row, id, options);
2275 if (isExternalVideoRow(row)) {
2276 let url = row.embed_url || row.external_url || row.url || "";
2277 if (!url) return "";
2278 url = urlWithParams(url, externalVideoPlayerParams(url, options));
2279 return `<iframe class="dbx-cms-inline-video-player" src="${escapeHtml(url)}" title="${title}" allowfullscreen${mediaAttr}></iframe>${inlineVideoOptionsButtonHtml()}`;
2281 const url = row.url || (id ? `index.php?dbx_modul=dbxContent&dbx_run1=media&dbx_mid=${id}` : "");
2282 if (!url) return "";
2283 const mime = row.mime ? ` type="${escapeHtml(row.mime)}"` : "";
2284 const poster = row.thumb_url ? ` poster="${escapeHtml(row.thumb_url)}"` : "";
2285 const autoplayAttr = options.autoplay ? " autoplay" : "";
2286 const loopAttr = options.loop ? " loop" : "";
2287 const mutedAttr = options.muted ? " muted" : "";
2288 return `<video class="dbx-cms-inline-video-player" controls${autoplayAttr}${loopAttr}${mutedAttr} playsinline preload="metadata"${poster}${mediaAttr}><source src="${escapeHtml(url)}"${mime}></video>${inlineVideoOptionsButtonHtml()}`;
2291 function playInlineVideoBlock(root, wrapper) {
2292 const surface = editorSurface(root);
2293 if (!wrapper || !surface || !surface.contains(wrapper)) return false;
2294 if (wrapper.querySelector(".dbx-cms-inline-video-player, iframe, video")) return false;
2295 const id = Number(wrapper.getAttribute("data-cms-media-id") || qs(wrapper, "[data-cms-media-id]")?.getAttribute("data-cms-media-id") || 0);
2296 const row = inlineVideoRowFromWrapper(root, wrapper, id);
2297 if (!row || !isVideoRow(row)) {
2298 status(root, "Video konnte im Editor nicht geladen werden.", "error");
2301 syncInlineVideoBlockSizes(surface);
2302 const html = inlineVideoEditorPlayerHtml(row, id, {
2303 autoplay: wrapper.getAttribute("data-cms-video-autoplay") === "1",
2304 loop: wrapper.getAttribute("data-cms-video-loop") === "1",
2305 muted: wrapper.getAttribute("data-cms-video-muted") === "1"
2307 if (!html) return false;
2308 wrapper.innerHTML = html;
2309 wrapper.classList.add("is-playing");
2310 syncEditorDom(root);
2314 function renderHeroPreview(root) {
2315 const preview = qs(root, "[data-cms-hero-preview]");
2316 if (!preview) return;
2318 const templateValue = String(getField(root, "hero_template") || "").toLowerCase();
2319 if (templateValue === "none" || templateValue === "no-hero") {
2320 preview.innerHTML = '<div class="dbx-cms-hero-preview-empty">Kein Hero ausgewaehlt.</div>';
2324 const idValue = String(getField(root, "hero_image_id") || "");
2325 const id = Number(idValue);
2326 const s = state(root);
2327 const rows = s.mediaRows || [];
2328 const row = rows.find(item => Number(item.id || 0) === id)
2329 || (s.heroPreviewRow && Number(s.heroPreviewRow.id || 0) === id ? s.heroPreviewRow : null);
2331 if (idValue === "parent") {
2332 const parentRow = s.heroParentPreviewRow || null;
2333 if (parentRow && parentRow.url) {
2334 const folderName = parentRow.parent_folder_name || parentRow.folder_name || "Parent";
2335 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>`;
2338 preview.innerHTML = '<div class="dbx-cms-hero-preview-empty">Kein Hero im parent Ordner.</div>';
2343 preview.innerHTML = '<div class="dbx-cms-hero-preview-empty">Kein Hero-Bild ausgewaehlt.</div>';
2347 if (!row || !row.url) {
2348 preview.innerHTML = '<div class="dbx-cms-hero-preview-empty">Hero-Bild wird geladen.</div>';
2352 const isImage = String(row.mime || "").startsWith("image/") || /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(row.file_name || row.url || "");
2354 preview.innerHTML = '<div class="dbx-cms-hero-preview-empty">Das gewaehlte Medium ist kein Bild.</div>';
2358 preview.innerHTML = `<img src="${escapeHtml(row.thumb_url || row.url)}" alt="${escapeHtml(row.alt || row.title || "Hero-Bild")}">`;
2361 function applyHeroTemplateChoice(root, source) {
2362 const isFolder = source && source.hasAttribute && source.hasAttribute("data-cms-folder-field");
2363 const value = String(isFolder ? getFolderField(root, "hero_template") : getField(root, "hero_template")).toLowerCase();
2364 const folderPanel = qs(root, "[data-cms-folder-editor]");
2366 setField(root, "hero_template", value);
2367 if (folderPanel && !folderPanel.hidden) setFolderField(root, "hero_template", value);
2369 if (value === "parent") {
2370 setField(root, "hero_image_id", "parent");
2371 if (folderPanel && !folderPanel.hidden) setFolderField(root, "hero_image_id", "parent");
2372 renderHeroPreview(root);
2375 if (value !== "none" && value !== "no-hero") {
2376 renderHeroPreview(root);
2379 state(root).heroPreviewRow = null;
2380 setField(root, "hero_template", "none");
2381 setField(root, "hero_image_id", "0");
2382 if (folderPanel && !folderPanel.hidden) {
2383 setFolderField(root, "hero_template", "none");
2384 setFolderField(root, "hero_image_id", "0");
2386 renderHeroPreview(root);
2390 function renderSeoPreview(root) {
2391 const preview = qs(root, "[data-cms-seo-preview]");
2392 if (!preview) return;
2394 const id = Number(getField(root, "seo_image_id") || 0);
2395 const s = state(root);
2396 const rows = s.mediaRows || [];
2397 const row = rows.find(item => Number(item.id || 0) === id)
2398 || (s.seoPreviewRow && Number(s.seoPreviewRow.id || 0) === id ? s.seoPreviewRow : null);
2401 preview.innerHTML = '<div class="dbx-cms-seo-preview-empty">Kein OG-Bild ausgewaehlt (Hero-Fallback).</div>';
2405 if (!row || !row.url) {
2406 preview.innerHTML = '<div class="dbx-cms-seo-preview-empty">OG-Bild wird geladen.</div>';
2410 const isImage = String(row.mime || "").startsWith("image/") || /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(row.file_name || row.url || "");
2412 preview.innerHTML = '<div class="dbx-cms-seo-preview-empty">Das gewaehlte Medium ist kein Bild.</div>';
2416 preview.innerHTML = `<img src="${escapeHtml(row.thumb_url || row.url)}" alt="${escapeHtml(row.alt || row.title || "OG-Bild")}">`;
2419 function mediaBrowserRows(modal) {
2420 return qsa(modal, "[data-cms-media-browser-item]").map(mediaRowFromItem);
2423 function updateMediaBrowserConfirm(modal) {
2424 const bar = qs(modal, "[data-cms-media-browser-confirmbar]");
2425 const count = qs(modal, "[data-cms-media-browser-count]");
2426 const selected = modal.__dbxCmsSelectedIds || new Set();
2427 const needsConfirm = mediaBrowserUsesConfirmBar(modal);
2428 if (bar) bar.hidden = !needsConfirm;
2429 if (count) count.textContent = String(selected.size);
2430 updateMediaBrowserBatchUi(modal);
2433 function selectedMediaBrowserImageRows(modal) {
2434 const selected = modal && modal.__dbxCmsSelectedIds ? modal.__dbxCmsSelectedIds : new Set();
2435 return mediaBrowserAllRows(modal).filter(row => selected.has(Number(row.id || 0)) && canEditImage(row));
2438 function mediaBrowserAllRows(modal) {
2439 return Array.isArray(modal && modal.__dbxCmsRows) ? modal.__dbxCmsRows : mediaBrowserRows(modal);
2442 function batchControlHost(browserModal) {
2443 if (!browserModal) return null;
2444 return browserModal.__dbxCmsBatchPanel || browserModal;
2447 function updateMediaBrowserBatchUi(modal) {
2448 const selectedImages = selectedMediaBrowserImageRows(modal).length;
2449 [modal, modal && modal.__dbxCmsBatchPanel].forEach(host => {
2451 const count = qs(host, "[data-cms-media-browser-resize-count]");
2452 if (count) count.textContent = String(selectedImages);
2456 function firstMediaFolderOption(select, prefix) {
2457 prefix = String(prefix || "");
2458 return Array.from(select?.options || []).find(option => {
2459 const value = String(option.value || "");
2460 return value && value !== "all" && (!prefix || value.indexOf(prefix) === 0);
2464 function mediaFolderLabel(folder) {
2465 return String(folder || "").trim();
2468 function mediaOriginLabel(row) {
2470 let folder = String(row.media_folder || "").trim().replace(/\\/g, "/");
2472 let path = String(row.file_path || row.path || "").trim().replace(/\\/g, "/");
2473 path = path.replace(/^files\/media\//i, "").replace(/^media\//i, "");
2474 const slash = path.lastIndexOf("/");
2475 folder = slash >= 0 ? path.slice(0, slash) : "";
2477 folder = folder.replace(/^files\/media\//i, "").replace(/^media\//i, "").replace(/^\/+|\/+$/g, "");
2478 return folder || "Root";
2481 function mediaSlotLabel(slot) {
2482 const slotLabels = { inline: "Im Text", hero: "Hero", gallery: "Galerie", shop: "Shop" };
2483 slot = String(slot || "").trim();
2484 return slotLabels[slot] || slot || "Verwendung";
2487 function mediaUsagePages(row) {
2488 return Array.isArray(row && row.usage_pages) ? row.usage_pages : [];
2491 function mediaUsageLabel(row) {
2493 const slot = String(row.slot || "").trim();
2494 const pages = mediaUsagePages(row);
2495 const ids = pages.map(page => Number(page.content_id || page.id || 0)).filter(Boolean);
2496 const shownIds = ids.slice(0, 3).map(id => "#" + id).join(", ");
2497 const suffix = ids.length > 3 ? ", ..." : "";
2498 const currentUsageId = Number(row.current_usage_id || row.usage_id || 0);
2499 if (currentUsageId > 0 && slot) {
2500 return mediaSlotLabel(slot) + (shownIds ? ": " + shownIds + suffix : "");
2502 const count = Number(row.used_count || 0);
2503 if (ids.length > 0) return "Verwendet: " + shownIds + suffix;
2504 if (count > 0) return count === 1 ? "Verwendet 1x" : "Verwendet " + count + "x";
2505 return "Nicht verwendet";
2508 function mediaUsageTooltip(row) {
2510 const pages = mediaUsagePages(row);
2512 return pages.map(page => {
2513 const id = Number(page.content_id || page.id || 0);
2514 const folderId = Number(page.folder_id || 0);
2515 const folder = String(page.folder_title || "").trim();
2516 const title = String(page.title || "").trim();
2517 const slots = Array.isArray(page.slots) ? page.slots.map(mediaSlotLabel).filter(Boolean).join(", ") : mediaSlotLabel(row.slot || "");
2519 + (folderId > 0 || folder ? " | Ordner: " + (folder || ("#" + folderId)) : "")
2520 + (title ? " | " + title : "")
2521 + (slots ? " | " + slots : "");
2524 return mediaUsageLabel(row);
2527 function mediaUsageTooltipRows(row) {
2529 const pages = mediaUsagePages(row);
2530 return pages.map(page => {
2531 const id = Number(page.content_id || page.id || 0);
2532 const folderId = Number(page.folder_id || 0);
2533 const folder = String(page.folder_title || "").trim();
2534 const title = String(page.title || "").trim();
2536 id: id > 0 ? "#" + id : "",
2537 folder: folder || (folderId > 0 ? "#" + folderId : "-"),
2543 function mediaUsageTooltipHtml(row) {
2544 const rows = mediaUsageTooltipRows(row);
2545 const tooltip = window.dbx && dbx.utilities && dbx.utilities.tooltip;
2546 if (tooltip && typeof tooltip.htmlList === "function") {
2547 return tooltip.htmlList(rows, {
2548 title: rows.length ? "Verwendet von" : "",
2549 empty: mediaUsageLabel(row)
2553 return `<div>${escapeHtml(mediaUsageLabel(row))}</div>`;
2555 return `<strong>Verwendet von</strong><br>` + rows.map(item => {
2556 return `${escapeHtml(item.id)} ${escapeHtml(item.folder)} ${escapeHtml(item.title)}`;
2560 function mediaBrowserItemHtml(row, selected, needsConfirm) {
2562 selected = selected || new Set();
2563 return `<div class="dbx-cms-media-browser-item${selected.has(Number(row.id || 0)) ? " is-selected" : ""}"
2564 data-cms-media-browser-item
2565 data-media-id="${escapeHtml(row.id || "")}"
2566 data-url="${escapeHtml(row.url || "")}"
2567 data-thumb-url="${escapeHtml(row.thumb_url || "")}"
2568 data-mime="${escapeHtml(row.mime || "")}"
2569 data-media-type="${escapeHtml(row.media_type || "")}"
2570 data-width="${escapeHtml(row.width || "")}"
2571 data-height="${escapeHtml(row.height || "")}"
2572 data-file-name="${escapeHtml(row.file_name || "")}"
2573 data-file-path="${escapeHtml(row.file_path || "")}"
2574 data-title="${escapeHtml(row.title || "")}"
2575 data-alt="${escapeHtml(row.alt || "")}"
2576 data-media-folder="${escapeHtml(row.media_folder || "")}"
2577 data-slot="${escapeHtml(row.slot || "")}"
2578 draggable="${isExternalVideoRow(row) ? "false" : "true"}">
2579 <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"}">
2580 <span>${mediaPreviewHtml(row)}</span>
2581 <strong>${escapeHtml(row.title || row.file_name || "Medium")}</strong>
2582 ${canEditImage(row) || needsConfirm ? '<em class="dbx-cms-media-browser-check"><i class="bi bi-check2"></i></em>' : ''}
2584 <div class="dbx-cms-media-browser-actions">
2585 <span class="dbx-cms-media-browser-meta">
2586 <span class="dbx-cms-media-browser-origin">${escapeHtml(mediaOriginLabel(row))}</span>
2587 <span class="dbx-cms-media-browser-usage" tabindex="0" data-dbx-tooltip="${escapeTooltipAttr(mediaUsageTooltipHtml(row))}">${escapeHtml(mediaUsageLabel(row))}</span>
2589 ${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>' : ''}
2590 ${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>' : ''}
2591 <button type="button" class="btn btn-outline-danger btn-sm" data-cms-media-browser-delete title="Mediendatei wirklich loeschen">
2592 <i class="bi bi-trash"></i>
2598 function mediaBrowserExplorerItemHtml(row, selected) {
2600 selected = selected || new Set();
2601 const id = Number(row.id || 0);
2602 const title = row.title || row.file_name || "Medium";
2603 const draggable = isExternalVideoRow(row) ? "false" : "true";
2604 const originLabel = mediaOriginLabel(row);
2605 const usageLabel = mediaUsageLabel(row);
2606 const usageTooltip = mediaUsageTooltipHtml(row);
2607 return `<div class="dbx-cms-media-explorer-item dbx-cms-media-browser-item${selected.has(id) ? " is-selected" : ""}"
2608 data-cms-media-browser-item
2609 data-cms-media-tree-media
2610 data-media-id="${escapeHtml(row.id || "")}"
2611 data-url="${escapeHtml(row.url || "")}"
2612 data-thumb-url="${escapeHtml(row.thumb_url || "")}"
2613 data-mime="${escapeHtml(row.mime || "")}"
2614 data-media-type="${escapeHtml(row.media_type || "")}"
2615 data-width="${escapeHtml(row.width || "")}"
2616 data-height="${escapeHtml(row.height || "")}"
2617 data-file-name="${escapeHtml(row.file_name || "")}"
2618 data-file-path="${escapeHtml(row.file_path || "")}"
2619 data-title="${escapeHtml(row.title || "")}"
2620 data-alt="${escapeHtml(row.alt || "")}"
2621 data-media-folder="${escapeHtml(row.media_folder || "")}"
2622 data-slot="${escapeHtml(row.slot || "")}"
2623 draggable="${draggable}">
2624 <button type="button" class="dbx-cms-media-explorer-pick dbx-cms-media-browser-pickarea" data-cms-media-browser-pick draggable="${draggable}" title="Medium auswaehlen">
2625 <span class="dbx-cms-media-explorer-thumb">${mediaPreviewHtml(row)}</span>
2626 <strong>${escapeHtml(title)}</strong>
2627 <small>${escapeHtml(row.file_name || "")}</small>
2628 <em class="dbx-cms-media-browser-check"><i class="bi bi-check2"></i></em>
2630 <div class="dbx-cms-media-explorer-actions">
2631 <span class="dbx-cms-media-browser-meta">
2632 <span class="dbx-cms-media-browser-origin">${escapeHtml(originLabel)}</span>
2633 <span class="dbx-cms-media-browser-usage" tabindex="0" data-dbx-tooltip="${escapeTooltipAttr(usageTooltip)}">${escapeHtml(usageLabel)}</span>
2635 ${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>' : ''}
2636 ${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>' : ''}
2637 <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>
2642 function renderMediaRowsChunked(host, rows, rowHtml, afterDone) {
2644 rows = Array.isArray(rows) ? rows : [];
2645 const token = ((host.__dbxMediaRenderToken || 0) + 1);
2646 host.__dbxMediaRenderToken = token;
2647 if (host.__dbxMediaRenderScrollHandler) {
2648 host.removeEventListener("scroll", host.__dbxMediaRenderScrollHandler);
2649 host.__dbxMediaRenderScrollHandler = null;
2651 host.innerHTML = "";
2653 if (afterDone) afterDone();
2656 const batchSize = 18;
2658 let rendering = false;
2659 const appendBatch = () => {
2660 if (host.__dbxMediaRenderToken !== token || rendering) return;
2662 const html = rows.slice(offset, offset + batchSize).map(rowHtml).join("");
2664 host.insertAdjacentHTML("beforeend", html);
2665 setupMediaLazyImages(host);
2667 offset += batchSize;
2674 const appendWhenNeeded = () => {
2675 if (host.__dbxMediaRenderToken !== token || offset >= rows.length) return;
2676 const distance = host.scrollHeight - host.scrollTop - host.clientHeight;
2677 if (distance < 220) appendBatch();
2678 if (host.scrollHeight <= host.clientHeight + 40 && offset < rows.length) {
2679 window.requestAnimationFrame(appendWhenNeeded);
2683 host.__dbxMediaRenderScrollHandler = () => {
2684 window.requestAnimationFrame(appendWhenNeeded);
2685 scheduleMediaLazyLoad(host);
2687 host.addEventListener("scroll", host.__dbxMediaRenderScrollHandler, { passive: true });
2689 window.requestAnimationFrame(appendWhenNeeded);
2692 function mediaBrowserSkeletonHtml(count) {
2693 count = Math.max(6, Math.min(36, Number(count || 18)));
2695 for (let i = 0; i < count; i++) {
2696 html += `<div class="dbx-cms-media-browser-item dbx-cms-media-browser-skeleton" aria-hidden="true">
2697 <span class="dbx-cms-media-browser-skeleton-thumb"></span>
2698 <span class="dbx-cms-media-browser-skeleton-line"></span>
2699 <span class="dbx-cms-media-browser-skeleton-meta"></span>
2705 function mediaFolderDiskPath(folder) {
2706 folder = String(folder || "").trim();
2707 if (!folder) return "";
2708 if (folder === "mod") return "files/mod/";
2709 return "files/media/" + folder.replace(/\\/g, "/") + "/";
2712 function mediaBrowserQueryParams(folder) {
2713 const params = { sync: 0 };
2714 folder = String(folder || "all");
2715 if (folder === "all") {
2716 params.media_folder = "all";
2718 params.media_folder = folder;
2719 if (folder === "youtube" || folder.indexOf("youtube/") === 0) {
2720 params.media_type = "external_video";
2721 } else if (folder.indexOf("videos/") === 0 || folder === "videos" || folder.indexOf("video/") === 0 || folder === "video" || folder === "img/video" || folder.indexOf("img/video/") === 0) {
2722 params.media_type = "video";
2723 } else if (folder.indexOf("file/") === 0 || folder === "file") {
2724 params.media_type = "file";
2725 } else if (folder === "module" || folder === "mod") {
2727 params.media_type = "image";
2730 params.media_type = "image";
2736 function mediaTypeFromFolder(folder) {
2737 folder = String(folder || "");
2738 if (folder.indexOf("videos/") === 0 || folder === "videos" || folder.indexOf("video/") === 0 || folder === "video") return "video";
2739 if (folder.indexOf("file/") === 0 || folder === "file") return "file";
2743 function buildNewMediaFolderPath(parentFolder, name) {
2744 parentFolder = String(parentFolder || "").trim();
2745 name = String(name || "").trim().replace(/\\/g, "/");
2746 if (!name) return "";
2747 if (name.indexOf("/") >= 0) return name;
2748 if (!parentFolder) return "img/" + name;
2749 const base = parentFolder.split("/")[0] || "img";
2750 if (base === "img" || base === "video" || base === "file" || base === "module") {
2751 return base + "/" + name;
2753 return parentFolder + "/" + name;
2756 function ensureMediaBrowserFolderUi(modal) {
2758 const uploadForm = qs(modal, "[data-cms-browser-upload]");
2759 const uploadFolder = qs(modal, "[data-cms-upload-folder]");
2760 if (uploadForm && uploadFolder && !uploadFolder.closest(".dbx-cms-upload-folder-label")) {
2761 const label = document.createElement("label");
2762 label.className = "dbx-cms-upload-folder-label";
2763 label.innerHTML = '<span class="form-label small mb-1">Zielordner</span>';
2764 uploadFolder.parentNode.insertBefore(label, uploadFolder);
2765 label.appendChild(uploadFolder);
2767 const typeSelect = qs(modal, "[data-cms-folder-type]");
2769 const folderBar = typeSelect.closest(".dbx-cms-media-folderbar");
2770 const parent = document.createElement("select");
2771 parent.className = typeSelect.className;
2772 parent.setAttribute("data-cms-folder-parent", "1");
2773 parent.title = "Vorhandenen Ordner als Basis waehlen";
2774 typeSelect.replaceWith(parent);
2775 if (folderBar && !qs(folderBar, ".dbx-cms-media-folderbar-title")) {
2776 const title = document.createElement("span");
2777 title.className = "small text-muted dbx-cms-media-folderbar-title";
2778 title.textContent = "Neuer Unterordner:";
2779 folderBar.insertBefore(title, parent);
2782 if (!modal.__dbxCmsUploadFolderSyncBound) {
2783 modal.__dbxCmsUploadFolderSyncBound = true;
2784 modal.addEventListener("change", e => {
2785 const uploadSelect = closestElement(e.target, "[data-cms-upload-folder]");
2786 if (!uploadSelect || !modal.contains(uploadSelect)) return;
2787 uploadSelect.__dbxCmsTouched = true;
2788 const parentSelect = qs(modal, "[data-cms-folder-parent]");
2789 if (parentSelect && Array.from(parentSelect.options).some(option => option.value === uploadSelect.value)) {
2790 parentSelect.value = uploadSelect.value;
2796 function disposeMediaBrowserModal(root, modal) {
2798 if (modal.__dbxCmsBatchWindowId && dbx.openWin && typeof dbx.openWin.close === "function") {
2799 dbx.openWin.close(modal.__dbxCmsBatchWindowId);
2801 if (modal.__dbxCmsBatchPanel) {
2802 modal.__dbxCmsBatchPanel.remove();
2803 modal.__dbxCmsBatchPanel = null;
2805 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.close === "function") {
2806 dbx.openWin.close(modal.__dbxCmsWindowId);
2809 if (state(root).mediaBrowser === modal) state(root).mediaBrowser = null;
2812 function mediaBatchWindowMarkup() {
2814 <div class="dbx-cms-media-batch-toolbar">
2815 <span class="dbx-cms-media-browser-resize-count"><strong data-cms-media-browser-resize-count>0</strong> Bilder ausgewaehlt</span>
2816 <select class="form-select form-select-sm" data-cms-media-browser-resize-preset title="Resize-Groesse">
2817 <option value="">Groesse waehlen</option>
2818 <option value="800x600">800 x 600</option>
2819 <option value="1024x768">1024 x 768</option>
2820 <option value="1280x720">1280 x 720 HD</option>
2821 <option value="1600x900">1600 x 900</option>
2822 <option value="1920x1080">1920 x 1080 Full HD</option>
2823 <option value="2560x1440">2560 x 1440 QHD</option>
2825 <input type="number" class="form-control form-control-sm" data-cms-bulk-resize-width placeholder="Breite">
2826 <input type="number" class="form-control form-control-sm" data-cms-bulk-resize-height placeholder="Hoehe">
2827 <label class="dbx-cms-media-resize-ratio" title="Seitenverhaeltnis beim Resize behalten">
2828 <input type="checkbox" data-cms-bulk-resize-ratio checked>
2831 <button type="button" class="btn btn-outline-primary btn-sm" data-cms-action="bulk-resize-media" data-cms-resize-scope="selected">
2832 <i class="bi bi-check2-square"></i>
2833 <span>Auswahl resizen</span>
2835 <button type="button" class="btn btn-outline-primary btn-sm" data-cms-action="bulk-resize-media" data-cms-resize-scope="all">
2836 <i class="bi bi-images"></i>
2837 <span>Alle resizen</span>
2839 <button type="button" class="btn btn-outline-secondary btn-sm" data-cms-media-batch-close>
2840 <i class="bi bi-arrow-left"></i>
2841 <span>Zurueck</span>
2844 <div class="dbx-cms-media-batch-list" data-cms-media-batch-list>
2845 <div class="dbx-cms-empty">Bilder zum Batch-Resize auswaehlen.</div>
2849 function bindMediaBatchWindowEvents(root, cfg, browserModal, batchPanel) {
2850 if (!batchPanel || batchPanel.__dbxCmsBatchEventsBound) return;
2851 batchPanel.__dbxCmsBatchEventsBound = true;
2852 batchPanel.__dbxCmsBrowserModal = browserModal;
2854 batchPanel.addEventListener("click", e => {
2855 e.stopPropagation();
2856 const cfg = browserCfg(browserModal);
2858 const batchClose = closestElement(e.target, "[data-cms-media-batch-close]");
2859 if (batchClose && batchPanel.contains(batchClose)) {
2861 batchPanel.hidden = true;
2862 if (browserModal) browserModal.classList.remove("is-batch-open");
2866 const batchItem = closestElement(e.target, "[data-cms-batch-item]");
2867 if (batchItem && batchPanel.contains(batchItem)) {
2869 toggleMediaBrowserSelection(browserModal, batchItem);
2873 const bulkResize = closestElement(e.target, "[data-cms-action='bulk-resize-media']");
2874 if (bulkResize && batchPanel.contains(bulkResize)) {
2876 bulkResizeMedia(root, cfg, bulkResize.getAttribute("data-cms-resize-scope") || "selected", browserModal);
2880 const processClose = closestElement(e.target, "[data-cms-media-process-close]");
2881 if (processClose && batchPanel.contains(processClose)) {
2883 const panel = closestElement(processClose, "[data-cms-media-process-panel]");
2885 panel.hidden = true;
2886 panel.innerHTML = "";
2888 if (browserModal) browserModal.classList.remove("is-process-open");
2889 clearCmsLoading(root);
2893 batchPanel.addEventListener("change", e => {
2894 e.stopPropagation();
2895 const preset = closestElement(e.target, "[data-cms-media-browser-resize-preset]");
2896 if (preset && batchPanel.contains(preset)) {
2897 mediaBrowserPreset(batchPanel, preset.value);
2900 const ratioInput = closestElement(e.target, "[data-cms-bulk-resize-ratio]");
2901 if (ratioInput && batchPanel.contains(ratioInput)) {
2902 status(root, ratioInput.checked ? "Resize behaelt das Seitenverhaeltnis." : "Resize nutzt exakte Breite und Hoehe.", "info");
2907 function mediaBatchWindowIsOpen(browserModal) {
2908 const panel = browserModal && browserModal.__dbxCmsBatchPanel;
2909 if (!panel || !browserModal.__dbxCmsBatchWindowId) return false;
2910 return document.documentElement.contains(panel) && !!closestElement(panel, ".dbx-window");
2913 function elevateMediaBatchWindow(browserModal) {
2914 const panel = browserModal && browserModal.__dbxCmsBatchPanel;
2915 const win = (browserModal && browserModal.__dbxCmsBatchWindowId ? document.getElementById(browserModal.__dbxCmsBatchWindowId) : null)
2916 || (panel ? closestElement(panel, ".dbx-window") : null);
2918 win.classList.add("dbx-cms-media-batch-window-frame");
2921 function openMediaBatchWindow(root, cfg, browserModal) {
2922 if (!browserModal) return;
2923 let batchPanel = browserModal.__dbxCmsBatchPanel;
2924 if (!batchPanel || !document.documentElement.contains(batchPanel) || !qs(batchPanel, "[data-cms-media-batch-list]")) {
2925 if (batchPanel) batchPanel.remove();
2926 batchPanel = document.createElement("div");
2927 batchPanel.className = "dbx-cms-media-batch-window";
2928 batchPanel.setAttribute("data-cms-media-batch-window", "1");
2929 batchPanel.innerHTML = mediaBatchWindowMarkup();
2930 const processPanel = qs(browserModal, "[data-cms-media-process-panel]");
2931 if (processPanel && processPanel.parentNode) {
2932 processPanel.parentNode.insertBefore(batchPanel, processPanel);
2934 browserModal.appendChild(batchPanel);
2936 browserModal.__dbxCmsBatchPanel = batchPanel;
2937 bindMediaBatchWindowEvents(root, cfg, browserModal, batchPanel);
2939 browserModal.__dbxCmsBatchWindowId = null;
2940 browserModal.classList.remove("is-folder-tree-open", "is-process-open");
2941 browserModal.classList.add("is-batch-open");
2942 const processPanel = qs(browserModal, "[data-cms-media-process-panel]");
2944 processPanel.hidden = true;
2945 processPanel.innerHTML = "";
2947 batchPanel.hidden = false;
2948 batchPanel.__dbxCmsBrowserModal = browserModal;
2949 batchPanel.__dbxCmsCfg = cfg || browserCfg(browserModal);
2950 updateMediaBrowserBatchUi(browserModal);
2951 renderMediaBatchList(browserModal);
2954 function mediaBrowserNeedsRebuild(modal) {
2955 if (!modal) return false;
2956 return !!qs(modal, "[data-cms-folder-type]")
2957 || !qs(modal, ".dbx-cms-upload-target")
2958 || !qs(modal, "[data-cms-media-folder-toggle]")
2959 || !qs(modal, "[data-cms-media-folder-tree]")
2960 || !qs(modal, "[data-cms-media-process-panel]")
2961 || !qs(modal, "[data-cms-folder-rename]")
2962 || !!qs(modal, "[data-cms-media-browser-slot] option[value='header']")
2963 || !!qs(modal, "[data-cms-media-tools-toggle]")
2964 || !qs(modal, "[data-cms-media-batch-open]");
2967 function uploadFolderOptions(folders) {
2968 return (folders || []).filter(folder => {
2969 const value = String(folder || "");
2970 if (!value || value === "all") return false;
2971 if (value === "youtube" || value.indexOf("youtube/") === 0) return false;
2972 if (value === "module" || value.indexOf("module/") === 0) return false;
2973 if (value === "img/video" || value.indexOf("img/video/") === 0) return false;
2978 function syncUploadFolderSelect(modal, uploadFolders, preferred) {
2979 const uploadFolder = qs(modal, "[data-cms-upload-folder]");
2980 if (!uploadFolder || uploadFolder.__dbxCmsTouched) return;
2981 const filterFolder = qs(modal, "[data-cms-media-browser-folder]");
2982 const browse = String(filterFolder?.value || "");
2983 const candidates = [
2984 String(preferred || ""),
2985 browse !== "all" ? browse : "",
2986 String(modal.__dbxCmsMediaFolder || "")
2988 for (let i = 0; i < candidates.length; i++) {
2989 const cand = candidates[i];
2990 if (cand && uploadFolders.includes(cand)) {
2991 uploadFolder.value = cand;
2995 const imageFolder = uploadFolders.find(folder => folder.indexOf("img/") === 0);
2996 if (imageFolder) uploadFolder.value = imageFolder;
2997 else if (uploadFolders.length) uploadFolder.value = uploadFolders[0];
3000 function setSelectOptions(select, folders, includeAll) {
3001 if (!select) return;
3002 const current = select.value || "";
3004 if (includeAll) options.push('<option value="all">Alle Verzeichnisse</option>');
3005 (folders || []).forEach(folder => {
3006 const label = mediaFolderLabel(folder);
3007 const hint = mediaFolderDiskPath(folder);
3008 options.push(`<option value="${escapeHtml(folder)}" title="${escapeHtml(hint)}">${escapeHtml(label)}</option>`);
3010 select.innerHTML = options.join("");
3011 if (current && Array.from(select.options).some(option => option.value === current)) {
3012 select.value = current;
3016 function mediaFolderParent(folder) {
3017 folder = String(folder || "").replace(/\/+$/g, "");
3018 const idx = folder.lastIndexOf("/");
3019 return idx > 0 ? folder.slice(0, idx) : "";
3022 function mediaFolderName(folder) {
3023 folder = String(folder || "").replace(/\/+$/g, "");
3024 return folder.split("/").pop() || folder;
3027 function mediaFolderDepth(folder) {
3028 folder = String(folder || "").replace(/^\/+|\/+$/g, "");
3029 return folder ? Math.max(0, folder.split("/").length - 1) : 0;
3032 function mediaFolderTreeViewSize(modal) {
3033 const value = String(modal && modal.__dbxCmsMediaTreeSize || "medium");
3034 return ["small", "medium", "large"].includes(value) ? value : "medium";
3037 function renderMediaFolderTree(modal, folders) {
3038 const tree = qs(modal, "[data-cms-media-folder-tree]");
3040 const uploadFolders = uploadFolderOptions(folders || modal.__dbxCmsFolders || []);
3041 const rows = Array.isArray(modal.__dbxCmsRows) ? modal.__dbxCmsRows : [];
3042 const selected = modal.__dbxCmsSelectedIds || new Set();
3043 const counts = rows.reduce((out, row) => {
3044 const folder = String(row.media_folder || "");
3045 if (folder) out[folder] = (out[folder] || 0) + 1;
3048 if (!uploadFolders.length) {
3050 <div class="dbx-cms-media-folder-tree-head">
3052 <strong><i class="bi bi-folder2-open"></i> Medienordner</strong>
3053 <span>Keine Medienordner vorhanden.</span>
3055 <button type="button" class="btn btn-outline-secondary btn-sm" data-cms-media-folder-tree-close>
3056 <i class="bi bi-arrow-left"></i>
3057 <span>Zurueck</span>
3062 const folderSet = new Set(uploadFolders);
3063 let currentFolder = String(modal.__dbxCmsMediaTreeFolder || modal.__dbxCmsMediaFolder || "").trim();
3064 const selectFilter = String(qs(modal, "[data-cms-media-browser-folder]")?.value || "");
3065 if ((!currentFolder || !folderSet.has(currentFolder)) && selectFilter && selectFilter !== "all" && folderSet.has(selectFilter)) {
3066 currentFolder = selectFilter;
3068 if (!currentFolder || !folderSet.has(currentFolder)) {
3069 currentFolder = uploadFolders.find(folder => (counts[folder] || 0) > 0) || uploadFolders[0];
3071 modal.__dbxCmsMediaTreeFolder = currentFolder;
3072 const viewSize = mediaFolderTreeViewSize(modal);
3073 const folderRows = rows.filter(row => String(row.media_folder || "") === currentFolder);
3074 const folderOptions = uploadFolders.map(folder => {
3075 const depth = mediaFolderDepth(folder);
3076 const label = mediaFolderName(folder);
3077 const count = counts[folder] || 0;
3078 const active = folder === currentFolder;
3079 return `<button type="button"
3080 class="dbx-cms-media-explorer-folder${active ? " is-active" : ""}"
3081 data-cms-media-folder-drop
3082 data-cms-media-tree-folder-select
3083 data-folder="${escapeHtml(folder)}"
3085 style="--dbx-folder-depth:${depth}"
3086 title="${escapeHtml(folder)}">
3087 <span class="dbx-cms-media-explorer-indent"></span>
3088 <i class="bi ${active ? "bi-folder2-open" : "bi-folder"}"></i>
3089 <span class="dbx-cms-media-explorer-folder-text">
3090 <strong>${escapeHtml(label)}</strong>
3091 <small>${escapeHtml(folder)}</small>
3096 const sizeButtons = ["small", "medium", "large"].map(size => {
3097 const labels = { small: "Klein", medium: "Mittel", large: "Gross" };
3098 const icons = { small: "bi-grid-3x3-gap", medium: "bi-grid", large: "bi-grid-1x2" };
3099 return `<button type="button" class="btn btn-outline-primary btn-sm${viewSize === size ? " active" : ""}" data-cms-media-tree-size="${size}" title="Ansicht ${labels[size]}">
3100 <i class="bi ${icons[size]}"></i>
3101 <span>${labels[size]}</span>
3105 <div class="dbx-cms-media-folder-tree-head">
3107 <strong><i class="bi bi-folder2-open"></i> Medienordner</strong>
3108 <span>Ordner links waehlen, Medien rechts markieren oder per Drag verschieben.</span>
3110 <button type="button" class="btn btn-outline-secondary btn-sm" data-cms-media-folder-tree-close>
3111 <i class="bi bi-arrow-left"></i>
3112 <span>Zurueck</span>
3115 <div class="dbx-cms-media-explorer" data-cms-media-tree data-view-size="${escapeHtml(viewSize)}">
3116 <div class="dbx-cms-media-explorer-sidebar">
3117 <div class="dbx-cms-media-explorer-sidebar-title">
3118 <i class="bi bi-folder2"></i>
3121 <div class="dbx-cms-media-explorer-folders">
3125 <div class="dbx-cms-media-explorer-main">
3126 <div class="dbx-cms-media-explorer-toolbar">
3128 <strong>${escapeHtml(currentFolder)}</strong>
3129 <span>${folderRows.length} Medien</span>
3131 <div class="btn-group btn-group-sm" role="group" aria-label="Ansicht">
3135 <div class="dbx-cms-media-explorer-grid">
3136 ${folderRows.length ? '' : '<div class="dbx-cms-media-tree-empty">Keine Medien in diesem Ordner.</div>'}
3140 const grid = qs(tree, ".dbx-cms-media-explorer-grid");
3141 if (folderRows.length && grid) {
3142 renderMediaRowsChunked(
3145 row => mediaBrowserExplorerItemHtml(row, selected),
3146 () => updateMediaBrowserBatchUi(modal)
3151 function setMediaBrowserFolderTreeMode(modal, open) {
3153 const tree = qs(modal, "[data-cms-media-folder-tree]");
3154 const toggle = qs(modal, "[data-cms-media-folder-toggle]");
3157 modal.classList.toggle("is-folder-tree-open", open);
3159 modal.classList.remove("is-batch-open", "is-process-open");
3160 const batchPanel = modal.__dbxCmsBatchPanel || null;
3161 if (batchPanel) batchPanel.hidden = true;
3162 const processPanel = qs(modal, "[data-cms-media-process-panel]");
3164 processPanel.hidden = true;
3165 processPanel.innerHTML = "";
3168 tree.classList.toggle("is-open", open);
3169 tree.hidden = !open;
3171 toggle.classList.toggle("is-active", open);
3172 toggle.setAttribute("aria-pressed", open ? "true" : "false");
3174 if (open) renderMediaFolderTree(modal, modal.__dbxCmsFolders || []);
3177 function refreshMediaFolderControls(root, cfg, modal) {
3178 const url = cfgUrl(cfg, "mediafolders");
3179 if (!url || !modal) return Promise.resolve([]);
3180 const profile = mediaBrowserProfile(cfg);
3181 return fetchJson(apiUrl(url), { timeout: 20000 })
3183 let folders = Array.isArray(data && data.folders) ? data.folders : [];
3184 if (profile === "mod") {
3185 folders = folders.filter(folder => String(folder || "") === "mod");
3186 if (!folders.length) folders = ["mod"];
3188 folders = folders.filter(folder => {
3189 const value = String(folder || "");
3190 return value && value !== "module" && value.indexOf("module/") !== 0;
3193 const uploadFolders = uploadFolderOptions(folders);
3194 modal.__dbxCmsFolders = folders;
3195 modal.__dbxCmsUploadFolders = uploadFolders;
3196 if (data && data.root) {
3197 modal.dataset.dbxMediaRoot = String(data.root);
3198 const rootHint = qs(modal, "[data-cms-media-root-hint]");
3199 if (rootHint) rootHint.textContent = "Speicherort: " + String(data.root);
3201 setSelectOptions(qs(modal, "[data-cms-media-browser-folder]"), folders, true);
3202 setSelectOptions(qs(modal, "[data-cms-upload-folder]"), uploadFolders, false);
3203 renderMediaFolderTree(modal, folders);
3204 const parentSelect = qs(modal, "[data-cms-folder-parent]");
3206 setSelectOptions(parentSelect, uploadFolders, false);
3207 const uploadFolder = qs(modal, "[data-cms-upload-folder]");
3208 if (uploadFolder && uploadFolder.value && uploadFolders.includes(uploadFolder.value)) {
3209 parentSelect.value = uploadFolder.value;
3212 syncUploadFolderSelect(modal, uploadFolders, modal.__dbxCmsMediaFolder || "");
3216 dbx.warn("[cms] media folders load failed", err);
3217 return modal.__dbxCmsFolders || [];
3221 function createMediaFolder(root, cfg, modal) {
3222 const url = cfgUrl(cfg, "mediafoldercreate");
3223 const input = qs(modal, "[data-cms-folder-name]");
3224 const parent = qs(modal, "[data-cms-folder-parent]");
3225 const uploadFolder = qs(modal, "[data-cms-upload-folder]");
3226 const name = String(input?.value || "").trim();
3227 const parentVal = String(parent?.value || uploadFolder?.value || "").trim();
3228 const folderPath = buildNewMediaFolderPath(parentVal, name);
3229 if (!url || !name || !folderPath) {
3230 status(root, "Bitte Basis-Ordner und Ordnernamen eintragen.", "error");
3231 return Promise.resolve();
3233 return fetchJson(apiUrl(url), {
3235 headers: { "Content-Type": "application/json" },
3236 body: JSON.stringify({ media_type: mediaTypeFromFolder(folderPath), media_folder: folderPath })
3238 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "folder create failed");
3239 if (input) input.value = "";
3240 status(root, "Medienordner angelegt.", "success");
3241 return refreshMediaFolderControls(root, cfg, modal).then(() => {
3242 const uploadFolder = qs(modal, "[data-cms-upload-folder]");
3243 const filterFolder = qs(modal, "[data-cms-media-browser-folder]");
3244 const uploadFolders = modal.__dbxCmsUploadFolders || uploadFolderOptions(modal.__dbxCmsFolders || []);
3246 if (filterFolder && (data.folder === "all" || (modal.__dbxCmsFolders || []).includes(data.folder))) {
3247 filterFolder.value = data.folder;
3249 if (uploadFolder && uploadFolders.includes(data.folder)) uploadFolder.value = data.folder;
3251 syncUploadFolderSelect(modal, uploadFolders, data.folder || "");
3254 dbx.error("[cms] media folder create failed", err);
3255 status(root, err && err.message ? err.message : "Medienordner konnte nicht angelegt werden.", "error");
3259 function deleteSelectedMediaFolder(root, cfg, modal) {
3260 const url = cfgUrl(cfg, "mediafolderdelete");
3261 const select = qs(modal, "[data-cms-upload-folder]");
3262 const folder = String(select?.value || "");
3263 if (!url || !folder) return Promise.resolve();
3265 return ensureConfirm().then(ok => {
3267 status(root, "Confirm-Lib ist nicht geladen.", "error");
3271 return dbx.confirm.open({
3272 id: "cms-delete-media-folder-" + folder,
3274 title: "<i class=\"bi bi-trash\"></i> Medienordner loeschen",
3275 question: "Medienordner <strong>" + escapeHtml(folder) + "</strong> wirklich loeschen?",
3276 hint: "Der Ordner kann nur geloescht werden, wenn er leer ist.",
3278 labelyes: "<i class=\"bi bi-trash\"></i> Loeschen",
3279 labelno: "<i class=\"bi bi-x-lg\"></i> Abbrechen",
3281 backdropclose: false,
3285 if (!result || result.action !== "yes") return null;
3287 return fetchJson(apiUrl(url), {
3289 headers: { "Content-Type": "application/json" },
3290 body: JSON.stringify({ media_folder: folder })
3293 if (!data) return null;
3294 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "folder delete failed");
3295 status(root, "Medienordner geloescht.", "success");
3296 return refreshMediaFolderControls(root, cfg, modal);
3298 dbx.error("[cms] media folder delete failed", err);
3299 status(root, err && err.message ? err.message : "Medienordner konnte nicht geloescht werden.", "error");
3303 function renameSelectedMediaFolder(root, cfg, modal) {
3304 const url = cfgUrl(cfg, "mediafolderrename");
3305 const select = qs(modal, "[data-cms-upload-folder]");
3306 const input = qs(modal, "[data-cms-folder-rename-name]");
3307 const from = String(select?.value || "").trim();
3308 const newName = String(input?.value || "").trim();
3309 if (!url || !from || !newName) {
3310 status(root, "Bitte Ordner und neuen Namen eintragen.", "error");
3311 return Promise.resolve();
3313 const segments = from.split("/");
3314 const toFolder = newName.indexOf("/") >= 0
3316 : (segments.length > 1 ? segments.slice(0, -1).concat(newName) : [segments[0] || "img", newName]).join("/");
3317 if (toFolder === from) {
3318 status(root, "Der neue Name ist identisch.", "error");
3319 return Promise.resolve();
3321 return fetchJson(apiUrl(url), {
3323 headers: { "Content-Type": "application/json" },
3324 body: JSON.stringify({ from_folder: from, to_folder: toFolder })
3326 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "folder rename failed");
3327 if (input) input.value = "";
3328 status(root, data.msg || "Medienordner umbenannt.", "success");
3329 return refreshMediaFolderControls(root, cfg, modal).then(() => {
3330 const uploadFolder = qs(modal, "[data-cms-upload-folder]");
3331 const filterFolder = qs(modal, "[data-cms-media-browser-folder]");
3332 const uploadFolders = modal.__dbxCmsUploadFolders || uploadFolderOptions(modal.__dbxCmsFolders || []);
3333 if (data.to_folder) {
3334 if (filterFolder && (modal.__dbxCmsFolders || []).includes(data.to_folder)) {
3335 filterFolder.value = data.to_folder;
3336 modal.__dbxCmsMediaFolder = data.to_folder;
3338 if (uploadFolder && uploadFolders.includes(data.to_folder)) uploadFolder.value = data.to_folder;
3340 syncUploadFolderSelect(modal, uploadFolders, data.to_folder || "");
3341 return openMediaBrowser(modal.__dbxCmsRoot || root, cfg, {
3342 mode: modal.__dbxCmsMediaMode || "editor",
3343 slot: modal.__dbxCmsAssignSlot || currentMediaSlot(root),
3344 mediaFolder: data.to_folder || modal.__dbxCmsMediaFolder || "",
3345 formDataExtra: modal.__dbxCmsFormDataExtra || null,
3346 afterAssign: modal.__dbxCmsAfterAssign
3350 dbx.error("[cms] media folder rename failed", err);
3351 status(root, err && err.message ? err.message : "Medienordner konnte nicht umbenannt werden.", "error");
3355 function moveMediaFolderToFolder(root, cfg, modal, source, target) {
3356 const url = cfgUrl(cfg, "mediafolderrename");
3357 source = String(source || "").trim();
3358 target = String(target || "").trim();
3360 status(root, "Ordner verschieben ist nicht konfiguriert.", "error");
3361 return Promise.resolve();
3363 if (!source || !target) {
3364 status(root, "Quell- und Zielordner fehlen.", "error");
3365 return Promise.resolve();
3367 if (source === target || target.indexOf(source + "/") === 0) {
3368 status(root, "Ordner kann nicht in sich selbst verschoben werden.", "error");
3369 return Promise.resolve();
3371 const currentParent = mediaFolderParent(source);
3372 if (currentParent === target) {
3373 status(root, "Ordner liegt bereits dort.", "warning");
3374 return Promise.resolve();
3376 const toFolder = target.replace(/\/+$/g, "") + "/" + mediaFolderName(source);
3377 if ((modal.__dbxCmsFolders || []).includes(toFolder)) {
3378 status(root, "Zielordner existiert bereits.", "error");
3379 return Promise.resolve();
3381 return fetchJson(apiUrl(url), {
3383 headers: { "Content-Type": "application/json" },
3384 body: JSON.stringify({ from_folder: source, to_folder: toFolder })
3386 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "folder move failed");
3387 modal.__dbxCmsMediaTreeFolder = data.to_folder || toFolder;
3388 status(root, data.msg || "Medienordner verschoben.", "success");
3389 return openMediaBrowser(modal.__dbxCmsRoot || root, cfg, {
3390 mode: modal.__dbxCmsMediaMode || "editor",
3391 slot: modal.__dbxCmsAssignSlot || currentMediaSlot(root),
3392 mediaFolder: data.to_folder || toFolder,
3393 formDataExtra: modal.__dbxCmsFormDataExtra || null,
3394 afterAssign: modal.__dbxCmsAfterAssign
3397 dbx.error("[cms] media folder move failed", err);
3398 status(root, err && err.message ? err.message : "Medienordner konnte nicht verschoben werden.", "error");
3402 function moveSelectedMedia(root, cfg, modal) {
3403 const url = cfgUrl(cfg, "mediamove");
3404 const targetSelect = qs(modal, "[data-cms-media-move-folder]");
3405 const target = String(targetSelect?.value || "").trim();
3406 const rows = selectedMediaBrowserRows(modal);
3408 status(root, "Verschieben ist nicht konfiguriert.", "error");
3409 return Promise.resolve();
3412 status(root, "Bitte Zielordner waehlen.", "error");
3413 return Promise.resolve();
3416 status(root, "Bitte mindestens ein Medium auswaehlen.", "error");
3417 return Promise.resolve();
3419 const movable = rows.filter(row => !isExternalVideoRow(row));
3420 if (!movable.length) {
3421 status(root, "YouTube-Eintraege koennen nicht verschoben werden.", "error");
3422 return Promise.resolve();
3424 let chain = Promise.resolve();
3426 movable.forEach(row => {
3427 chain = chain.then(() => fetchJson(apiUrl(url), {
3429 headers: { "Content-Type": "application/json" },
3430 body: JSON.stringify({ media_id: row.id, media_folder: target })
3432 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "media move failed");
3436 return chain.then(() => {
3437 modal.__dbxCmsSelectedIds = new Set();
3438 status(root, moved === 1 ? "Medium verschoben." : moved + " Medien verschoben.", "success");
3439 return openMediaBrowser(modal.__dbxCmsRoot || root, cfg, {
3440 mode: modal.__dbxCmsMediaMode || "editor",
3441 slot: modal.__dbxCmsAssignSlot || currentMediaSlot(root),
3442 mediaFolder: modal.__dbxCmsMediaFolder || "",
3443 formDataExtra: modal.__dbxCmsFormDataExtra || null,
3444 afterAssign: modal.__dbxCmsAfterAssign
3447 dbx.error("[cms] media move failed", err);
3448 status(root, err && err.message ? err.message : "Medium konnte nicht verschoben werden.", "error");
3452 function draggedMediaRows(modal, mediaId) {
3453 mediaId = Number(mediaId || 0);
3454 const selected = modal.__dbxCmsSelectedIds || new Set();
3455 if (mediaId > 0 && selected.has(mediaId)) {
3456 const rows = selectedMediaBrowserRows(modal);
3457 if (rows.length) return rows;
3459 const item = mediaId > 0 ? qs(modal, `[data-cms-media-browser-item][data-media-id="${mediaId}"]`) : null;
3460 return item ? [mediaRowFromItem(item)] : [];
3463 function mediaFolderAcceptsRow(folder, row) {
3464 folder = String(folder || "");
3465 const type = String(row && row.media_type || "").toLowerCase();
3466 const mime = String(row && row.mime || "").toLowerCase();
3467 if (type === "video" || mime.indexOf("video/") === 0) return folder === "videos" || folder.indexOf("videos/") === 0 || folder === "video" || folder.indexOf("video/") === 0;
3468 if (type === "file") return folder === "file" || folder.indexOf("file/") === 0;
3469 return folder.indexOf("img/") === 0 && folder !== "img/video" && folder.indexOf("img/video/") !== 0;
3472 function moveMediaRowsToFolder(root, cfg, modal, rows, target) {
3473 const url = cfgUrl(cfg, "mediamove");
3474 target = String(target || "").trim();
3476 status(root, "Verschieben ist nicht konfiguriert.", "error");
3477 return Promise.resolve();
3480 status(root, "Bitte Zielordner waehlen.", "error");
3481 return Promise.resolve();
3483 const incompatible = (rows || []).filter(row => row && !isExternalVideoRow(row) && !mediaFolderAcceptsRow(target, row));
3484 if (incompatible.length) {
3485 status(root, "Zielordner passt nicht zum Medientyp.", "error");
3486 return Promise.resolve();
3488 const movable = (rows || []).filter(row => row && !isExternalVideoRow(row) && String(row.media_folder || "") !== target);
3489 if (!movable.length) {
3490 status(root, "Keine verschiebbaren Medien fuer diesen Ordner.", "warning");
3491 return Promise.resolve();
3494 return movable.reduce((chain, row) => {
3495 return chain.then(() => fetchJson(apiUrl(url), {
3497 headers: { "Content-Type": "application/json" },
3498 body: JSON.stringify({ media_id: row.id, media_folder: target })
3500 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "media move failed");
3502 if (data.row) patchMediaBrowserRow(modal, data.row);
3504 }, Promise.resolve()).then(() => {
3505 modal.__dbxCmsSelectedIds = new Set();
3506 modal.__dbxCmsMediaTreeFolder = target;
3507 status(root, moved === 1 ? "Medium verschoben." : moved + " Medien verschoben.", "success");
3508 return openMediaBrowser(modal.__dbxCmsRoot || root, cfg, {
3509 mode: modal.__dbxCmsMediaMode || "editor",
3510 slot: modal.__dbxCmsAssignSlot || currentMediaSlot(root),
3511 mediaFolder: modal.__dbxCmsMediaFolder || "",
3512 formDataExtra: modal.__dbxCmsFormDataExtra || null,
3513 afterAssign: modal.__dbxCmsAfterAssign
3516 dbx.error("[cms] media tree move failed", err);
3517 status(root, err && err.message ? err.message : "Medium konnte nicht verschoben werden.", "error");
3521 function setMediaBrowserSelection(modal, mediaId, selected) {
3522 mediaId = Number(mediaId || 0);
3523 if (!modal || mediaId <= 0) return;
3524 if (!modal.__dbxCmsSelectedIds) modal.__dbxCmsSelectedIds = new Set();
3525 if (selected && !isMediaBrowserMulti(modal)) {
3526 modal.__dbxCmsSelectedIds.clear();
3527 qsa(modal, "[data-cms-media-browser-item].is-selected").forEach(el => {
3528 el.classList.remove("is-selected");
3530 const batchPanel = modal.__dbxCmsBatchPanel;
3532 qsa(batchPanel, "[data-cms-batch-item].is-selected").forEach(el => {
3533 el.classList.remove("is-selected");
3537 if (selected) modal.__dbxCmsSelectedIds.add(mediaId);
3538 else modal.__dbxCmsSelectedIds.delete(mediaId);
3539 qsa(modal, `[data-cms-media-browser-item][data-media-id="${mediaId}"]`).forEach(el => {
3540 el.classList.toggle("is-selected", selected);
3542 const batchPanel = modal.__dbxCmsBatchPanel;
3544 qsa(batchPanel, `[data-cms-batch-item][data-media-id="${mediaId}"]`).forEach(el => {
3545 el.classList.toggle("is-selected", selected);
3548 updateMediaBrowserConfirm(modal);
3549 updateMediaBrowserBatchUi(modal);
3552 function toggleMediaBrowserSelection(modal, item) {
3553 const id = Number(item?.getAttribute("data-media-id") || 0);
3555 const selected = !(modal.__dbxCmsSelectedIds || new Set()).has(id);
3556 setMediaBrowserSelection(modal, id, selected);
3559 function renderMediaBatchList(browserModal) {
3560 const batchPanel = browserModal && browserModal.__dbxCmsBatchPanel;
3561 const list = batchPanel && qs(batchPanel, "[data-cms-media-batch-list]");
3562 if (!list || !browserModal) return;
3563 const rows = (browserModal.__dbxCmsRows || []).filter(canEditImage);
3564 const selected = browserModal.__dbxCmsSelectedIds || new Set();
3566 list.innerHTML = '<div class="dbx-cms-empty">Keine bearbeitbaren Bilder im aktuellen Medienbrowser-Filter.</div>';
3569 list.innerHTML = rows.map(row => `
3570 <button type="button"
3571 class="dbx-cms-media-batch-item${selected.has(Number(row.id || 0)) ? " is-selected" : ""}"
3573 data-media-id="${escapeHtml(row.id || "")}"
3574 title="${escapeHtml(row.title || row.file_name || "Bild")}">
3575 <span class="dbx-cms-media-batch-thumb">${mediaPreviewHtml(row)}</span>
3576 <span class="dbx-cms-media-batch-name">${escapeHtml(row.title || row.file_name || "Bild")}</span>
3577 <em class="dbx-cms-media-browser-check"><i class="bi bi-check2"></i></em>
3578 </button>`).join("");
3579 setupMediaLazyImages(list);
3582 function patchMediaBrowserRow(browserModal, row) {
3583 if (!browserModal || !row) return;
3584 const id = Number(row.id || 0);
3585 if (id <= 0) return;
3586 const rows = browserModal.__dbxCmsRows;
3587 if (Array.isArray(rows)) {
3588 const idx = rows.findIndex(item => Number(item.id || 0) === id);
3589 if (idx >= 0) rows[idx] = Object.assign({}, rows[idx], row);
3590 else rows.push(row);
3592 const thumb = row.thumb_url || row.url || "";
3593 qsa(browserModal, `[data-cms-media-browser-item][data-media-id="${id}"]`).forEach(el => {
3594 if (row.url) el.setAttribute("data-url", row.url);
3595 if (row.thumb_url) el.setAttribute("data-thumb-url", row.thumb_url);
3596 if (row.width !== undefined && row.width !== null) el.setAttribute("data-width", row.width);
3597 if (row.height !== undefined && row.height !== null) el.setAttribute("data-height", row.height);
3598 if (row.file_name) el.setAttribute("data-file-name", row.file_name);
3599 if (row.file_path) el.setAttribute("data-file-path", row.file_path);
3600 if (row.title) el.setAttribute("data-title", row.title);
3601 if (row.alt) el.setAttribute("data-alt", row.alt);
3602 if (row.media_folder) el.setAttribute("data-media-folder", row.media_folder);
3603 const img = qs(el, ".dbx-cms-media-browser-pickarea img");
3606 img.removeAttribute("data-dbx-media-src");
3609 renderMediaBatchList(browserModal);
3612 function selectedMediaBrowserRows(modal) {
3613 const selected = modal.__dbxCmsSelectedIds || new Set();
3614 return mediaBrowserAllRows(modal).filter(row => selected.has(Number(row.id || 0)));
3617 function closeMediaBrowserModal(root, modal) {
3619 modal.hidden = true;
3620 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.close === "function") {
3621 dbx.openWin.close(modal.__dbxCmsWindowId);
3623 clearCmsLoading(root);
3626 function leaveInlineMediaCaption(root) {
3627 const sel = window.getSelection ? window.getSelection() : null;
3628 if (!sel || !sel.rangeCount) return;
3629 let node = sel.anchorNode;
3630 if (node && node.nodeType !== 1) node = node.parentElement;
3631 const figure = node && node.closest ? node.closest("figure, .dbx-cms-inline-media, .dbx-cms-inline-image, .dbx-cms-inline-video") : null;
3632 const editor = qs(root, "[data-cms-editor]");
3633 if (!figure || !editor || !editor.contains(figure)) return;
3634 const range = document.createRange();
3635 range.setStartAfter(figure);
3636 range.collapse(true);
3637 sel.removeAllRanges();
3638 sel.addRange(range);
3641 function selectedUploadFiles(form) {
3642 const input = qs(form, 'input[type="file"][name="file"]');
3643 return input && input.files && input.files.length ? Array.from(input.files) : [];
3646 function selectedUploadFile(form) {
3647 const files = selectedUploadFiles(form);
3648 return files.length ? files[0] : null;
3651 function setUploadFiles(form, files) {
3652 const input = qs(form, 'input[type="file"][name="file"]');
3653 files = Array.from(files || []).filter(Boolean);
3654 if (!input || !files.length || !window.DataTransfer) return false;
3655 const dt = new DataTransfer();
3656 files.forEach(file => dt.items.add(file));
3657 input.files = dt.files;
3658 updateUploadLabel(form);
3662 function setUploadFile(form, file) {
3663 return setUploadFiles(form, file ? [file] : []);
3666 function updateUploadLabel(form) {
3667 const label = qs(form, "[data-cms-upload-label]");
3668 const files = selectedUploadFiles(form);
3670 if (files.length > 1) {
3671 label.textContent = files.length + " Dateien ausgewaehlt";
3673 label.textContent = files.length ? files[0].name : "Datei auswaehlen oder hier ablegen";
3676 form.classList.toggle("has-file", files.length > 0);
3679 function formatBytes(bytes) {
3680 bytes = Number(bytes || 0);
3681 if (bytes >= 1024 * 1024 * 1024) return (bytes / 1024 / 1024 / 1024).toFixed(1).replace(/\.0$/, "") + " GB";
3682 if (bytes >= 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1).replace(/\.0$/, "") + " MB";
3683 if (bytes >= 1024) return (bytes / 1024).toFixed(1).replace(/\.0$/, "") + " KB";
3684 return bytes + " B";
3687 function isExternalVideoRow(row) {
3688 const type = String(row && row.media_type || "").toLowerCase();
3689 const storageType = String(row && row.storage_type || "").toLowerCase();
3690 return type === "external_video" || (type === "video" && storageType === "external");
3693 function isVideoRow(row) {
3694 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 || "");
3697 function isImageRow(row) {
3698 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 || "");
3701 function canEditImage(row) {
3702 return isImageRow(row) && !/\.svg$/i.test(row.file_name || row.url || "");
3705 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";
3707 function mediaLazyImageHtml(src, label, extraHtml) {
3708 src = String(src || "");
3709 if (!src) return "";
3710 return `<img class="dbx-cms-media-lazy-image" src="${mediaLazyPlaceholder}" data-dbx-media-src="${escapeHtml(src)}" alt="${label}" loading="lazy" decoding="async">${extraHtml || ""}`;
3713 function mediaLazyLoadVisible(scope) {
3715 const rootRect = scope.getBoundingClientRect();
3717 qsa(scope, "img[data-dbx-media-src]").forEach(img => {
3718 const rect = img.getBoundingClientRect();
3720 rect.bottom < rootRect.top - margin ||
3721 rect.top > rootRect.bottom + margin ||
3722 rect.right < rootRect.left - margin ||
3723 rect.left > rootRect.right + margin
3727 const src = img.getAttribute("data-dbx-media-src");
3728 if (!src || img.__dbxMediaLazyLoading) return;
3729 img.__dbxMediaLazyLoading = true;
3730 const finish = () => {
3731 img.removeEventListener("load", finish);
3732 img.removeEventListener("error", finish);
3733 img.removeAttribute("data-dbx-media-src");
3734 img.__dbxMediaLazyLoading = false;
3736 img.addEventListener("load", finish);
3737 img.addEventListener("error", finish);
3742 function scheduleMediaLazyLoad(scope) {
3744 if (scope.__dbxMediaLazyFrame) return;
3745 scope.__dbxMediaLazyFrame = window.requestAnimationFrame(() => {
3746 scope.__dbxMediaLazyFrame = 0;
3747 mediaLazyLoadVisible(scope);
3751 function setupMediaLazyImages(scope) {
3753 const images = qsa(scope, "img[data-dbx-media-src]");
3754 if (!images.length) return;
3755 if (!scope.__dbxMediaLazyBound) {
3756 scope.__dbxMediaLazyBound = true;
3757 scope.addEventListener("scroll", () => scheduleMediaLazyLoad(scope), { passive: true });
3758 window.addEventListener("resize", () => scheduleMediaLazyLoad(scope), { passive: true });
3760 scheduleMediaLazyLoad(scope);
3763 function mediaPreviewHtml(row) {
3764 const src = row.thumb_url || row.url || "";
3765 const label = escapeHtml(row.alt || row.title || "");
3766 if (isImageRow(row) && src) return mediaLazyImageHtml(src, label);
3767 if (isExternalVideoRow(row)) {
3768 return row.thumb_url
3769 ? mediaLazyImageHtml(row.thumb_url, label, '<i class="bi bi-play-fill dbx-cms-media-video-icon"></i>')
3770 : '<i class="bi bi-camera-video"></i>';
3772 if (isVideoRow(row)) {
3773 return row.thumb_url
3774 ? mediaLazyImageHtml(row.thumb_url, label, '<i class="bi bi-play-fill dbx-cms-media-video-icon"></i>')
3775 : '<i class="bi bi-camera-video"></i>';
3777 return '<i class="bi bi-file-earmark"></i>';
3780 function mediaProcessHeadMarkup() {
3781 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>';
3784 function mediaMaintenanceFolderOptions(modal, includeAll, preferred) {
3785 const folders = uploadFolderOptions(modal && modal.__dbxCmsFolders || [])
3786 .filter(folder => String(folder || "").indexOf("img/") === 0);
3787 const current = String(preferred || "");
3789 if (includeAll) options.push('<option value="all">Alle Ordner</option>');
3790 folders.forEach(folder => {
3791 options.push(`<option value="${escapeHtml(folder)}"${folder === current ? " selected" : ""}>${escapeHtml(mediaFolderLabel(folder))}</option>`);
3793 return options.join("");
3796 function renderMediaMaintenanceHome(root, cfg, browserModal, batchPanel) {
3797 const panel = (browserModal ? qs(browserModal, "[data-cms-media-process-panel]") : null)
3798 || (batchPanel ? qs(batchPanel, "[data-cms-media-process-panel]") : null);
3801 browserModal.classList.remove("is-folder-tree-open", "is-batch-open");
3802 browserModal.classList.add("is-process-open");
3803 const batch = browserModal.__dbxCmsBatchPanel || null;
3804 if (batch) batch.hidden = true;
3806 const folderSelect = browserModal ? qs(browserModal, "[data-cms-media-browser-folder]") : null;
3807 const selectedFolder = String(folderSelect?.value || browserModal?.__dbxCmsMediaFolder || "");
3808 const preferredSource = selectedFolder && selectedFolder !== "all" ? selectedFolder : "all";
3809 panel.hidden = false;
3810 panel.innerHTML = mediaProcessHeadMarkup() + `
3811 <div class="dbx-cms-media-maintenance-grid">
3812 <section class="dbx-cms-media-maintenance-card">
3813 <strong><i class="bi bi-arrow-repeat"></i> Medien pruefen</strong>
3814 <p>Synchronisiert Mediendateien und Vorschaubilder mit der Datenbank.</p>
3815 <button type="button" class="btn btn-outline-primary btn-sm" data-cms-media-process-start>
3816 <i class="bi bi-play-fill"></i>
3817 <span>Wartung starten</span>
3820 <section class="dbx-cms-media-maintenance-card">
3821 <strong><i class="bi bi-image"></i> Nicht verwendete Bilder</strong>
3824 <select class="form-select form-select-sm" data-cms-media-unused-source>
3825 ${mediaMaintenanceFolderOptions(browserModal, true, preferredSource)}
3829 <span>Zielordner fuer Verschieben</span>
3830 <select class="form-select form-select-sm" data-cms-media-unused-target>
3831 ${mediaMaintenanceFolderOptions(browserModal, false, "")}
3834 <div class="dbx-cms-media-maintenance-actions">
3835 <button type="button" class="btn btn-outline-danger btn-sm" data-cms-media-unused-action="delete">
3836 <i class="bi bi-trash"></i>
3837 <span>Unbenutzte loeschen</span>
3839 <button type="button" class="btn btn-outline-primary btn-sm" data-cms-media-unused-action="move">
3840 <i class="bi bi-folder-symlink"></i>
3841 <span>Unbenutzte verschieben</span>
3844 <p class="dbx-cms-media-maintenance-note" data-cms-media-unused-result>Es werden nur Bilder ohne erkannte Verwendung verarbeitet.</p>
3847 if (dbx.scan) dbx.scan(panel);
3850 function startMediaMaintenance(root, cfg, browserModal, batchPanel) {
3851 const url = cfgUrl(cfg || {}, "mediaprocess");
3852 const panel = (browserModal ? qs(browserModal, "[data-cms-media-process-panel]") : null)
3853 || (batchPanel ? qs(batchPanel, "[data-cms-media-process-panel]") : null);
3854 if (!url || !panel) {
3855 status(root, "Medienwartung ist nicht konfiguriert.", "error");
3859 browserModal.classList.remove("is-folder-tree-open", "is-batch-open");
3860 browserModal.classList.add("is-process-open");
3861 const batch = browserModal.__dbxCmsBatchPanel || null;
3862 if (batch) batch.hidden = true;
3865 const token = "media-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8);
3866 panel.hidden = false;
3867 const processHead = mediaProcessHeadMarkup();
3868 panel.innerHTML = processHead + '<div class="dbx-cms-empty">Medienwartung wird vorbereitet...</div>';
3870 fetchHtml(apiUrl(url, { reset: 1, proc_key: token }), { timeout: 20000 })
3872 panel.innerHTML = processHead + (html ? extractProcessHtml(html) : '<div class="dbx-cms-empty">Medienwartung konnte nicht gestartet werden.</div>');
3873 if (dbx.scan) dbx.scan(panel);
3874 const proc = qs(panel, ".dbx-process");
3875 if (proc && proc.getAttribute("data-process-status") === "finished" && browserModal) {
3876 status(root, "Medienwartung abgeschlossen.", "success");
3877 openMediaBrowser(root, cfg, {
3878 mode: browserModal.__dbxCmsMediaMode || "editor",
3879 slot: browserModal.__dbxCmsAssignSlot || currentMediaSlot(root),
3880 mediaFolder: browserModal.__dbxCmsMediaFolder || "",
3881 formDataExtra: browserModal.__dbxCmsFormDataExtra || null,
3882 afterAssign: browserModal.__dbxCmsAfterAssign
3887 dbx.error("[cms] media maintenance failed", err);
3888 panel.innerHTML = '<div class="dbx-cms-empty">Medienwartung konnte nicht gestartet werden.</div>';
3889 status(root, err && err.message ? err.message : "Medienwartung konnte nicht gestartet werden.", "error");
3891 .finally(() => clearCmsLoading(root));
3894 function renderUnusedMediaProcess(panel, state) {
3895 state = state || {};
3896 const percent = Math.max(0, Math.min(100, Number(state.percent || 0)));
3897 const title = state.title || "Unbenutzte Bilder";
3898 const message = state.message || "Wartung wird vorbereitet...";
3899 const detail = state.detail || "";
3900 const done = state.done === true;
3901 panel.innerHTML = mediaProcessHeadMarkup() + `
3902 <div class="dbx-cms-media-unused-process" data-cms-media-unused-process>
3903 <div class="dbx-cms-media-unused-process-title">
3904 <strong><i class="bi ${done ? "bi-check2-circle" : "bi-arrow-repeat"}"></i> ${escapeHtml(title)}</strong>
3905 <span>${percent}%</span>
3907 <div class="dbx-cms-media-unused-progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${percent}">
3908 <span style="width:${percent}%"></span>
3910 <p>${escapeHtml(message)}</p>
3911 ${detail ? `<pre>${escapeHtml(detail)}</pre>` : ""}
3913 if (dbx.scan) dbx.scan(panel);
3916 function executeUnusedMediaMaintenance(root, cfg, browserModal, action) {
3917 const url = cfgUrl(cfg || {}, "mediaunused");
3918 const panel = browserModal ? qs(browserModal, "[data-cms-media-process-panel]") : null;
3919 if (!url || !panel) {
3920 status(root, "Wartung fuer unbenutzte Bilder ist nicht konfiguriert.", "error");
3923 action = action === "move" ? "move" : "delete";
3924 const source = String(qs(panel, "[data-cms-media-unused-source]")?.value || "all");
3925 const target = String(qs(panel, "[data-cms-media-unused-target]")?.value || "");
3926 if (action === "move" && !target) {
3927 status(root, "Bitte Zielordner waehlen.", "error");
3930 const sourceLabel = source === "all" ? "allen Ordnern" : "Ordner " + source;
3931 const question = action === "delete"
3932 ? "Alle nicht verwendeten Bilder aus " + sourceLabel + " wirklich loeschen?"
3933 : "Alle nicht verwendeten Bilder aus " + sourceLabel + " nach " + target + " verschieben?";
3934 ensureConfirm().then(ok => {
3936 status(root, "Confirm-Lib ist nicht geladen.", "error");
3939 return dbx.confirm.open({
3940 id: "cms-media-unused-" + action + "-" + Date.now(),
3942 title: "<i class=\"bi bi-tools\"></i> Medienwartung",
3943 question: escapeHtml(question),
3944 hint: "Verwendete Bilder werden serverseitig erneut geprueft und uebersprungen.",
3946 labelyes: action === "delete" ? "<i class=\"bi bi-trash\"></i> Loeschen" : "<i class=\"bi bi-folder-symlink\"></i> Verschieben",
3947 labelno: "<i class=\"bi bi-x-lg\"></i> Abbrechen",
3949 backdropclose: false,
3953 if (!result || result.action !== "yes") return null;
3954 if (browserModal) browserModal.classList.add("is-process-open");
3955 panel.hidden = false;
3956 renderUnusedMediaProcess(panel, {
3958 title: action === "delete" ? "Unbenutzte Bilder loeschen" : "Unbenutzte Bilder verschieben",
3959 message: "Server prueft Verwendungen und verarbeitet die Bilder..."
3961 return fetchJson(apiUrl(url), {
3963 headers: { "Content-Type": "application/json" },
3964 body: JSON.stringify({ action, source_folder: source, target_folder: target }),
3969 if (!data.ok) throw new Error(data && data.msg ? data.msg : "Wartung konnte nicht ausgefuehrt werden.");
3970 const detail = "Geprueft: " + Number(data.checked || 0)
3971 + "\nVerarbeitet: " + Number(data.affected || 0)
3972 + "\nUebersprungen, weil verwendet: " + Number(data.skipped_used || 0)
3973 + (Array.isArray(data.errors) && data.errors.length ? "\nFehler:\n" + data.errors.join("\n") : "");
3974 renderUnusedMediaProcess(panel, {
3977 title: action === "delete" ? "Loeschen abgeschlossen" : "Verschieben abgeschlossen",
3978 message: data.msg || "Wartung abgeschlossen.",
3981 status(root, data.msg || "Wartung abgeschlossen.", Number(data.affected || 0) > 0 ? "success" : "warning");
3983 window.setTimeout(() => {
3984 browserModal.classList.remove("is-process-open");
3985 openMediaBrowser(browserModal.__dbxCmsRoot || root, browserCfg(browserModal), {
3986 mode: browserModal.__dbxCmsMediaMode || "editor",
3987 slot: browserModal.__dbxCmsAssignSlot || currentMediaSlot(root),
3988 mediaFolder: source !== "all" ? source : (browserModal.__dbxCmsMediaFolder || ""),
3989 formDataExtra: browserModal.__dbxCmsFormDataExtra || null,
3990 afterAssign: browserModal.__dbxCmsAfterAssign
3995 dbx.error("[cms] unused media maintenance failed", err);
3996 renderUnusedMediaProcess(panel, {
3999 title: "Wartung fehlgeschlagen",
4000 message: err && err.message ? err.message : "Wartung konnte nicht ausgefuehrt werden."
4002 status(root, err && err.message ? err.message : "Wartung konnte nicht ausgefuehrt werden.", "error");
4006 function openMediaBrowserWindow(root, modal) {
4007 if (!modal) return false;
4008 modal.__dbxCmsCallerRoot = root || null;
4009 if (!dbx.openWin || typeof dbx.openWin.open !== "function") {
4010 ensureOpenWin().then(ok => {
4011 if (ok) openMediaBrowserWindow(root, modal);
4012 else status(root, "openWin.js nicht geladen.", "error");
4016 const currentWindow = closestElement(modal, ".dbx-window");
4017 const currentIsBrowserWindow = currentWindow && currentWindow.classList.contains("dbx-cms-media-browser-window");
4018 if (currentIsBrowserWindow) {
4019 modal.hidden = false;
4020 raiseMediaBrowserWindow(currentWindow, modal);
4021 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.bringToFront === "function") {
4022 dbx.openWin.bringToFront(modal.__dbxCmsWindowId);
4024 raiseMediaBrowserWindow(currentWindow, modal);
4027 const id = dbx.openWin.open({
4028 title: '<i class="bi bi-images"></i> Medienbrowser',
4046 modal.__dbxCmsWindowId = id;
4047 modal.hidden = false;
4048 const win = document.getElementById(id) || closestElement(modal, ".dbx-window");
4049 raiseMediaBrowserWindow(win, modal);
4054 function raiseMediaBrowserWindow(win, modal) {
4055 win = win || closestElement(modal, ".dbx-window");
4056 if (!win && modal && modal.__dbxCmsWindowId) win = document.getElementById(modal.__dbxCmsWindowId);
4058 const apply = () => {
4059 win.classList.add("dbx-cms-media-browser-window");
4060 if (dbx.openWin && typeof dbx.openWin.bringToFront === "function" && win.id) {
4061 dbx.openWin.bringToFront(win.id);
4065 window.requestAnimationFrame(apply);
4066 window.setTimeout(apply, 80);
4069 function mediaBrowserPreset(modal, value) {
4070 if (!modal || !value) return;
4071 const width = qs(modal, "[data-cms-bulk-resize-width]");
4072 const height = qs(modal, "[data-cms-bulk-resize-height]");
4073 if (!width || !height) return;
4074 const parts = String(value).toLowerCase().split("x");
4075 width.value = Number(parts[0] || 0) || "";
4076 height.value = Number(parts[1] || 0) || "";
4079 function bindMediaBrowserEvents(root, cfg, modal) {
4080 if (!modal || modal.__dbxCmsEventsBound) return;
4081 modal.__dbxCmsEventsBound = true;
4083 const batchBtn = qs(modal, "[data-cms-media-batch-open]");
4084 if (batchBtn && !batchBtn.__dbxCmsBatchBound) {
4085 batchBtn.__dbxCmsBatchBound = true;
4086 batchBtn.addEventListener("mousedown", e => e.stopPropagation());
4087 batchBtn.addEventListener("click", e => {
4089 e.stopPropagation();
4090 openMediaBatchWindow(modal.__dbxCmsRoot || root, browserCfg(modal), modal);
4094 modal.addEventListener("click", e => {
4095 e.stopPropagation();
4096 const cfg = browserCfg(modal);
4098 const browserClose = closestElement(e.target, "[data-cms-media-browser-close]");
4099 if (browserClose && modal.contains(browserClose)) {
4101 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.close === "function") {
4102 dbx.openWin.close(modal.__dbxCmsWindowId);
4104 modal.hidden = true;
4106 clearCmsLoading(root);
4110 const batchOpen = closestElement(e.target, "[data-cms-media-batch-open]");
4111 if (batchOpen && modal.contains(batchOpen)) {
4113 e.stopPropagation();
4114 openMediaBatchWindow(root, cfg, modal);
4118 const browserMaintenance = closestElement(e.target, "[data-cms-media-maintenance]");
4119 if (browserMaintenance && modal.contains(browserMaintenance)) {
4121 e.stopPropagation();
4122 const batchPanel = modal.__dbxCmsBatchPanel || null;
4123 if (batchPanel) batchPanel.hidden = true;
4124 renderMediaMaintenanceHome(root, cfg, modal, null);
4128 const processStart = closestElement(e.target, "[data-cms-media-process-start]");
4129 if (processStart && modal.contains(processStart)) {
4131 e.stopPropagation();
4132 startMediaMaintenance(root, cfg, modal, null);
4136 const unusedAction = closestElement(e.target, "[data-cms-media-unused-action]");
4137 if (unusedAction && modal.contains(unusedAction)) {
4139 e.stopPropagation();
4140 executeUnusedMediaMaintenance(root, cfg, modal, unusedAction.getAttribute("data-cms-media-unused-action"));
4144 const processClose = closestElement(e.target, "[data-cms-media-process-close]");
4145 if (processClose && modal.contains(processClose)) {
4147 e.stopPropagation();
4148 const panel = closestElement(processClose, "[data-cms-media-process-panel]");
4150 panel.hidden = true;
4151 panel.innerHTML = "";
4153 modal.classList.remove("is-process-open");
4154 clearCmsLoading(root);
4158 const folderCreate = closestElement(e.target, "[data-cms-folder-create]");
4159 if (folderCreate && modal.contains(folderCreate)) {
4161 createMediaFolder(root, cfg, modal);
4165 const folderDelete = closestElement(e.target, "[data-cms-folder-delete]");
4166 if (folderDelete && modal.contains(folderDelete)) {
4168 deleteSelectedMediaFolder(root, cfg, modal);
4172 const folderRename = closestElement(e.target, "[data-cms-folder-rename]");
4173 if (folderRename && modal.contains(folderRename)) {
4175 renameSelectedMediaFolder(root, cfg, modal);
4179 const folderToggle = closestElement(e.target, "[data-cms-media-folder-toggle]");
4180 if (folderToggle && modal.contains(folderToggle)) {
4182 const tree = qs(modal, "[data-cms-media-folder-tree]");
4184 setMediaBrowserFolderTreeMode(modal, !modal.classList.contains("is-folder-tree-open"));
4189 const folderTreeClose = closestElement(e.target, "[data-cms-media-folder-tree-close]");
4190 if (folderTreeClose && modal.contains(folderTreeClose)) {
4192 e.stopPropagation();
4193 setMediaBrowserFolderTreeMode(modal, false);
4197 const treeSize = closestElement(e.target, "[data-cms-media-tree-size]");
4198 if (treeSize && modal.contains(treeSize)) {
4200 e.stopPropagation();
4201 modal.__dbxCmsMediaTreeSize = String(treeSize.getAttribute("data-cms-media-tree-size") || "medium");
4202 renderMediaFolderTree(modal, modal.__dbxCmsFolders || []);
4206 const treeFolderSelect = closestElement(e.target, "[data-cms-media-tree-folder-select]");
4207 if (treeFolderSelect && modal.contains(treeFolderSelect)) {
4209 e.stopPropagation();
4210 modal.__dbxCmsMediaTreeFolder = String(treeFolderSelect.getAttribute("data-folder") || "");
4211 renderMediaFolderTree(modal, modal.__dbxCmsFolders || []);
4215 const folderDropClick = closestElement(e.target, "[data-cms-media-folder-drop]");
4216 if (folderDropClick && modal.contains(folderDropClick)) {
4218 e.stopPropagation();
4219 const rows = selectedMediaBrowserRows(modal);
4221 status(root, "Medien markieren oder per Drag auf den Zielordner ziehen.", "info");
4224 moveMediaRowsToFolder(root, cfg, modal, rows, folderDropClick.getAttribute("data-folder") || "");
4228 const selectBtn = closestElement(e.target, "[data-cms-media-browser-select]");
4229 if (selectBtn && modal.contains(selectBtn)) {
4231 e.stopPropagation();
4232 const item = closestElement(selectBtn, "[data-cms-media-browser-item]");
4233 if (item) toggleMediaBrowserSelection(modal, item);
4237 const browserConfirm = closestElement(e.target, "[data-cms-media-browser-confirm]");
4238 if (browserConfirm && modal.contains(browserConfirm)) {
4240 if (modal.__dbxCmsMediaMode === "pick") {
4241 confirmPickMediaBrowser(root, modal);
4244 const slot = modal.__dbxCmsAssignSlot || currentMediaSlot(root);
4245 const rows = selectedMediaBrowserRows(modal);
4247 status(root, "Bitte mindestens ein Bild auswaehlen.", "error");
4251 let chain = Promise.resolve();
4252 rows.forEach(row => {
4253 chain = chain.then(() => assignMedia(root, cfg, row, slot).then(assignedRow => {
4254 if (!assignedRow) return;
4255 if (slot === "inline") {
4256 insertMediaRow(root, assignedRow);
4257 setLocalMediaSlot(root, assignedRow.id, "inline");
4259 if (typeof modal.__dbxCmsAfterAssign === "function") {
4260 modal.__dbxCmsAfterAssign(assignedRow);
4265 modal.hidden = true;
4266 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.close === "function") dbx.openWin.close(modal.__dbxCmsWindowId);
4267 clearCmsLoading(root);
4268 status(root, "Auswahl uebernommen.", "success");
4273 const browserDelete = closestElement(e.target, "[data-cms-media-browser-delete]");
4274 if (browserDelete && modal.contains(browserDelete)) {
4276 const item = closestElement(browserDelete, "[data-cms-media-browser-item]");
4277 const mode = modal.__dbxCmsMediaMode === "assign" ? "assign" : "editor";
4278 deleteMedia(root, cfg, Number(item?.getAttribute("data-media-id") || 0))
4279 .then(() => openMediaBrowser(modal.__dbxCmsRoot || root, cfg, {
4281 slot: modal.__dbxCmsAssignSlot || currentMediaSlot(root),
4282 mediaFolder: modal.__dbxCmsMediaFolder || "",
4283 formDataExtra: modal.__dbxCmsFormDataExtra || null,
4284 afterAssign: modal.__dbxCmsAfterAssign
4289 const browserEdit = closestElement(e.target, "[data-cms-media-browser-edit]");
4290 if (browserEdit && modal.contains(browserEdit)) {
4292 const item = closestElement(browserEdit, "[data-cms-media-browser-item]");
4293 openMediaEdit(root, cfg, mediaRowFromItem(item));
4297 const browserPick = closestElement(e.target, "[data-cms-media-browser-pick]");
4298 if (browserPick && modal.contains(browserPick)) {
4300 const item = closestElement(browserPick, "[data-cms-media-browser-item]") || browserPick;
4301 const mediaRow = mediaRowFromItem(item);
4302 const mode = modal.__dbxCmsMediaMode || "editor";
4303 if (mode === "pick" || mode === "assign") {
4304 toggleMediaBrowserSelection(modal, item);
4307 assignMedia(root, cfg, mediaRow, "inline").then(assignedRow => {
4308 if (!assignedRow) return;
4309 insertMediaRow(root, assignedRow);
4310 setLocalMediaSlot(root, assignedRow.id, "inline");
4311 modal.hidden = true;
4312 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.close === "function") dbx.openWin.close(modal.__dbxCmsWindowId);
4313 clearCmsLoading(root);
4318 modal.addEventListener("change", e => {
4319 e.stopPropagation();
4320 const uploadForm = closestElement(e.target, "[data-cms-browser-upload]");
4321 if (uploadForm && modal.contains(uploadForm)) updateUploadLabel(uploadForm);
4322 const uploadFolderSelect = closestElement(e.target, "[data-cms-upload-folder]");
4323 if (uploadFolderSelect && modal.contains(uploadFolderSelect)) uploadFolderSelect.__dbxCmsTouched = true;
4326 modal.addEventListener("dragstart", e => {
4327 const item = closestElement(e.target, "[data-cms-media-browser-item]");
4328 if (item && modal.contains(item)) {
4329 const id = Number(item.getAttribute("data-media-id") || 0);
4330 if (!id || isExternalVideoRow(mediaRowFromItem(item))) {
4334 modal.__dbxCmsDraggedMediaId = id;
4335 modal.__dbxCmsDraggedFolder = "";
4336 item.classList.add("is-dragging");
4337 if (e.dataTransfer) {
4338 e.dataTransfer.effectAllowed = "move";
4339 e.dataTransfer.setData("text/plain", String(id));
4340 e.dataTransfer.setData("application/x-dbx-media-id", String(id));
4345 const folder = closestElement(e.target, "[data-cms-media-tree-folder-select]");
4346 if (!folder || !modal.contains(folder)) return;
4347 const folderPath = String(folder.getAttribute("data-folder") || "");
4352 modal.__dbxCmsDraggedFolder = folderPath;
4353 modal.__dbxCmsDraggedMediaId = 0;
4354 folder.classList.add("is-dragging");
4355 if (e.dataTransfer) {
4356 e.dataTransfer.effectAllowed = "move";
4357 e.dataTransfer.setData("text/plain", folderPath);
4358 e.dataTransfer.setData("application/x-dbx-media-folder", folderPath);
4362 modal.addEventListener("dragend", e => {
4363 const item = closestElement(e.target, "[data-cms-media-browser-item]");
4364 if (item) item.classList.remove("is-dragging");
4365 const folder = closestElement(e.target, "[data-cms-media-tree-folder-select]");
4366 if (folder) folder.classList.remove("is-dragging");
4367 qsa(modal, "[data-cms-media-folder-drop].is-dragover").forEach(el => el.classList.remove("is-dragover"));
4368 modal.__dbxCmsDraggedMediaId = 0;
4369 modal.__dbxCmsDraggedFolder = "";
4372 modal.addEventListener("dragover", e => {
4373 e.stopPropagation();
4374 const folderDrop = closestElement(e.target, "[data-cms-media-folder-drop]");
4375 if (folderDrop && modal.contains(folderDrop)) {
4377 folderDrop.classList.add("is-dragover");
4378 if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
4381 const dropzone = closestElement(e.target, "[data-cms-dropzone]");
4382 if (!dropzone || !modal.contains(dropzone)) return;
4384 dropzone.classList.add("is-dragover");
4387 modal.addEventListener("dragleave", e => {
4388 e.stopPropagation();
4389 const folderDrop = closestElement(e.target, "[data-cms-media-folder-drop]");
4390 if (folderDrop && modal.contains(folderDrop)) {
4391 folderDrop.classList.remove("is-dragover");
4394 const dropzone = closestElement(e.target, "[data-cms-dropzone]");
4395 if (!dropzone || !modal.contains(dropzone)) return;
4396 dropzone.classList.remove("is-dragover");
4399 modal.addEventListener("drop", e => {
4400 e.stopPropagation();
4401 const folderDrop = closestElement(e.target, "[data-cms-media-folder-drop]");
4402 if (folderDrop && modal.contains(folderDrop)) {
4404 folderDrop.classList.remove("is-dragover");
4405 const target = folderDrop.getAttribute("data-folder") || "";
4406 const draggedFolder = String((e.dataTransfer && e.dataTransfer.getData("application/x-dbx-media-folder")) || modal.__dbxCmsDraggedFolder || "");
4407 if (draggedFolder) {
4408 moveMediaFolderToFolder(root, browserCfg(modal), modal, draggedFolder, target);
4411 const id = Number((e.dataTransfer && (e.dataTransfer.getData("application/x-dbx-media-id") || e.dataTransfer.getData("text/plain"))) || modal.__dbxCmsDraggedMediaId || 0);
4412 moveMediaRowsToFolder(root, browserCfg(modal), modal, draggedMediaRows(modal, id), target);
4415 const dropzone = closestElement(e.target, "[data-cms-dropzone]");
4416 if (!dropzone || !modal.contains(dropzone)) return;
4418 dropzone.classList.remove("is-dragover");
4419 const form = closestElement(dropzone, "form");
4420 const files = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length ? e.dataTransfer.files : null;
4421 if (!form || !files || !files.length) return;
4422 if (!setUploadFiles(form, files)) {
4423 status(root, "Datei bitte ueber die Dateiauswahl waehlen.", "error");
4428 function openMediaBrowser(root, cfg, options) {
4429 options = options || {};
4430 cfg = Object.assign({}, cfg || {});
4431 const mode = options.mode === "assign" ? "assign" : (options.mode === "pick" ? "pick" : "editor");
4432 const assignSlot = options.slot || (mode === "assign" ? currentMediaSlot(root) : "inline");
4433 let mediaFolder = String(options.mediaFolder || options.media_folder || "").trim();
4434 if (!mediaFolder && mediaBrowserProfile(cfg) === "mod") mediaFolder = "mod";
4435 const formDataExtra = options.formDataExtra && typeof options.formDataExtra === "object" ? options.formDataExtra : null;
4436 const afterAssign = typeof options.afterAssign === "function" ? options.afterAssign : null;
4437 clearCmsLoading(root);
4438 const mediaUrl = cfgUrl(cfg || {}, "media");
4440 status(root, "Medienbrowser ist nicht konfiguriert.", "error");
4444 let modal = state(root).mediaBrowser;
4445 if (!modal || !document.documentElement.contains(modal)) {
4446 modal = qs(root, "[data-cms-media-browser]");
4448 if (mediaBrowserNeedsRebuild(modal)) {
4449 disposeMediaBrowserModal(root, modal);
4453 modal = document.createElement("div");
4454 modal.className = "dbx-cms-media-browser";
4455 modal.setAttribute("data-cms-media-browser", "1");
4457 <div class="dbx-cms-media-browser-head">
4459 <strong><i class="bi bi-images"></i> Medienbrowser</strong>
4460 <div class="small text-muted" data-cms-media-root-hint></div>
4462 <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>
4464 <div class="dbx-cms-status dbx-cms-media-browser-status" data-cms-status aria-live="polite"></div>
4465 <details class="dbx-cms-media-upload-panel" data-cms-media-upload-panel>
4467 <span class="dbx-cms-media-upload-summary-main">
4468 <i class="bi bi-chevron-right dbx-cms-toggle-icon"></i>
4469 <span>Upload und YouTube</span>
4471 <span class="dbx-cms-media-upload-summary-actions">
4472 <button type="button" class="btn btn-outline-primary btn-sm" data-cms-media-batch-open title="Batch Resize">
4473 <i class="bi bi-tools"></i>
4476 <button type="button" class="btn btn-outline-primary btn-sm" data-cms-media-maintenance title="Medienwartung im Medienbrowser starten">
4477 <i class="bi bi-tools"></i>
4478 <span>Wartung</span>
4482 <form class="dbx-cms-browser-upload" data-cms-browser-upload>
4483 <label class="dbx-cms-upload-drop" data-cms-dropzone>
4484 <input type="file" name="file" accept="image/*,video/*,.pdf" multiple>
4485 <span class="dbx-cms-upload-icon"><i class="bi bi-cloud-arrow-up"></i></span>
4486 <span class="dbx-cms-upload-text" data-cms-upload-label>Medium hochladen oder hier ablegen</span>
4488 <div class="dbx-cms-upload-target">
4489 <label class="dbx-cms-upload-folder-label">
4490 <span class="form-label small mb-1">Zielordner</span>
4491 <select class="form-select form-select-sm" name="media_folder" data-cms-upload-folder title="Zielordner fuer Upload"></select>
4493 <button type="submit" class="btn btn-primary btn-sm" title="Medium hochladen und einfuegen">
4494 <i class="bi bi-upload"></i>
4498 <input type="hidden" name="slot" value="inline">
4499 <input type="hidden" name="template" value="image-inline">
4500 <input type="hidden" name="max_image_size" value="2560">
4502 <div class="dbx-cms-media-folderbar">
4503 <span class="small text-muted dbx-cms-media-folderbar-title">Neuer Unterordner:</span>
4504 <select class="form-select form-select-sm" data-cms-folder-parent title="Vorhandenen Ordner als Basis waehlen"></select>
4505 <input type="text" class="form-control form-control-sm" data-cms-folder-name placeholder="Ordnername">
4506 <button type="button" class="btn btn-outline-primary btn-sm" data-cms-folder-create title="Ordner anlegen">
4507 <i class="bi bi-folder-plus"></i>
4509 <button type="button" class="btn btn-outline-danger btn-sm" data-cms-folder-delete title="Ausgewaehlten Upload-Ordner loeschen">
4510 <i class="bi bi-folder-x"></i>
4512 <input type="text" class="form-control form-control-sm" data-cms-folder-rename-name placeholder="Neuer Ordnername">
4513 <button type="button" class="btn btn-outline-secondary btn-sm" data-cms-folder-rename title="Ordner umbenennen">
4514 <i class="bi bi-pencil"></i>
4517 <form class="dbx-cms-browser-external-video" data-cms-browser-external-video>
4518 <input type="url" class="form-control form-control-sm" name="url" placeholder="YouTube-URL einfuegen">
4519 <input type="hidden" name="slot" value="inline">
4520 <button type="submit" class="btn btn-outline-primary btn-sm" title="YouTube-Video hinzufuegen">
4521 <i class="bi bi-youtube"></i>
4522 <span>YouTube</span>
4526 <div class="dbx-cms-media-process-panel" data-cms-media-process-panel hidden></div>
4527 <div class="dbx-cms-media-browser-tools">
4528 <button type="button" class="btn btn-outline-primary btn-sm dbx-cms-media-folder-toggle" data-cms-media-folder-toggle title="Medienordner anzeigen">
4529 <i class="bi bi-list-ul"></i>
4531 <input type="text" class="form-control form-control-sm" data-cms-media-browser-search placeholder="Medien suchen">
4532 <select class="form-select form-select-sm" data-cms-media-browser-folder title="Verzeichnis anzeigen">
4533 <option value="all">Alle Verzeichnisse</option>
4535 <select class="form-select form-select-sm" data-cms-media-browser-slot title="Bereich anzeigen">
4536 <option value="all">Alle</option>
4537 <option value="gallery">Galerie</option>
4538 <option value="hero">Hero</option>
4539 <option value="inline">Im Text</option>
4540 <option value="shop">Shop</option>
4543 <div class="dbx-cms-media-folder-tree" data-cms-media-folder-tree hidden></div>
4544 <div class="dbx-cms-media-browser-list" data-cms-media-browser-list>
4545 ${mediaBrowserSkeletonHtml(24)}
4547 <div class="dbx-cms-media-browser-confirm" data-cms-media-browser-confirmbar hidden>
4548 <span><strong data-cms-media-browser-count>0</strong> Medien ausgewaehlt</span>
4549 <button type="button" class="btn btn-primary btn-sm" data-cms-media-browser-confirm>
4550 <i class="bi bi-check2"></i>
4551 <span>Auswahl uebernehmen</span>
4554 root.appendChild(modal);
4556 const browserUpload = qs(modal, "[data-cms-browser-upload]");
4557 if (browserUpload) {
4558 browserUpload.addEventListener("submit", e => {
4560 const currentMode = modal.__dbxCmsMediaMode || "editor";
4561 const currentSlot = modal.__dbxCmsAssignSlot || (currentMode === "assign" ? currentMediaSlot(root) : "inline");
4562 uploadMedia(root, browserCfg(modal), browserUpload, {
4563 pickMode: currentMode === "pick",
4564 insertUploaded: currentMode === "editor",
4565 formDataExtra: modal.__dbxCmsFormDataExtra || null,
4566 afterUpload: data => {
4567 const rows = data && Array.isArray(data.rows) ? data.rows : (data && data.row ? [data.row] : []);
4568 const responses = data && Array.isArray(data.responses) ? data.responses : [];
4569 const moduleResp = responses.find(item => item && Array.isArray(item.items)) || null;
4570 if (currentMode === "pick" && data && data.ok && moduleResp && Array.isArray(moduleResp.items)) {
4571 if (typeof modal.__dbxCmsAfterAssign === "function") {
4572 modal.__dbxCmsAfterAssign({ items: moduleResp.items, filename: moduleResp.filename || "" });
4574 modal.hidden = true;
4575 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.close === "function") {
4576 dbx.openWin.close(modal.__dbxCmsWindowId);
4578 clearCmsLoading(root);
4581 if (currentMode === "pick" && rows.length) {
4582 let chain = Promise.resolve();
4583 rows.forEach(row => {
4584 chain = chain.then(() => {
4585 if (typeof modal.__dbxCmsAfterAssign === "function") {
4586 return Promise.resolve(modal.__dbxCmsAfterAssign(row));
4591 modal.hidden = true;
4592 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.close === "function") {
4593 dbx.openWin.close(modal.__dbxCmsWindowId);
4595 clearCmsLoading(root);
4599 if (currentMode === "assign" && rows.length) {
4600 rows.forEach(row => {
4601 if (currentSlot === "inline") applyInlineMediaAssignment(root, row);
4602 else setLocalMediaSlot(root, row.id, currentSlot);
4603 if (typeof modal.__dbxCmsAfterAssign === "function") modal.__dbxCmsAfterAssign(row);
4606 const refreshFolder = data.uploadFolder
4607 || (rows[0] && rows[0].media_folder)
4608 || qs(modal, "[data-cms-upload-folder]")?.value
4609 || modal.__dbxCmsMediaFolder
4611 openMediaBrowser(root, browserCfg(modal), {
4614 mediaFolder: refreshFolder,
4615 formDataExtra: modal.__dbxCmsFormDataExtra || null,
4616 afterAssign: modal.__dbxCmsAfterAssign
4622 const browserExternalVideo = qs(modal, "[data-cms-browser-external-video]");
4623 if (browserExternalVideo) {
4624 browserExternalVideo.addEventListener("submit", e => {
4626 const currentMode = modal.__dbxCmsMediaMode || "editor";
4627 const currentSlot = modal.__dbxCmsAssignSlot || (currentMode === "assign" ? currentMediaSlot(root) : "inline");
4628 addExternalVideo(root, browserCfg(modal), browserExternalVideo, {
4629 insertExternal: currentMode === "editor",
4631 afterExternal: data => {
4632 const row = data && data.row ? data.row : null;
4633 if (currentMode === "assign" && row) {
4634 if (currentSlot === "inline") applyInlineMediaAssignment(root, row);
4635 else setLocalMediaSlot(root, row.id, currentSlot);
4636 if (typeof modal.__dbxCmsAfterAssign === "function") modal.__dbxCmsAfterAssign(row);
4638 const refreshFolder = (row && row.media_folder)
4639 || qs(modal, "[data-cms-upload-folder]")?.value
4640 || modal.__dbxCmsMediaFolder
4642 openMediaBrowser(root, browserCfg(modal), {
4645 mediaFolder: refreshFolder,
4646 formDataExtra: modal.__dbxCmsFormDataExtra || null,
4647 afterAssign: modal.__dbxCmsAfterAssign
4653 state(root).mediaBrowser = modal;
4655 state(root).mediaBrowser = modal;
4656 modal.__dbxCmsCfg = cfg;
4657 modal.__dbxCmsRoot = root;
4658 ensureMediaBrowserFolderUi(modal);
4659 applyMediaBrowserProfile(modal, cfg);
4660 bindMediaBrowserEvents(root, cfg, modal);
4662 const list = qs(modal, "[data-cms-media-browser-list]");
4663 const search = qs(modal, "[data-cms-media-browser-search]");
4664 const slotSelect = qs(modal, "[data-cms-media-browser-slot]");
4665 const folderSelect = qs(modal, "[data-cms-media-browser-folder]");
4666 const uploadFolder = qs(modal, "[data-cms-upload-folder]");
4667 const uploadSlot = qs(modal, 'input[name="slot"]');
4668 const uploadTemplate = qs(modal, 'input[name="template"]');
4669 const externalVideoSlot = qs(modal, '[data-cms-browser-external-video] input[name="slot"]');
4670 modal.__dbxCmsMediaMode = mode;
4671 modal.__dbxCmsAssignSlot = assignSlot;
4672 modal.__dbxCmsAfterAssign = afterAssign;
4673 modal.__dbxCmsMediaFolder = mediaFolder;
4674 modal.__dbxCmsFormDataExtra = formDataExtra;
4675 modal.__dbxCmsSinglePick = options.singlePick === true;
4676 modal.__dbxCmsSelectedIds = new Set();
4677 updateMediaBrowserConfirm(modal);
4678 if (slotSelect) slotSelect.value = "all";
4679 if (folderSelect) folderSelect.value = mediaFolder || folderSelect.value || "all";
4680 if (uploadSlot) uploadSlot.value = assignSlot;
4681 if (uploadTemplate) uploadTemplate.value = mode === "assign" ? "" : "image-inline";
4682 if (externalVideoSlot) externalVideoSlot.value = assignSlot;
4683 clearCmsLoading(root);
4684 modal.hidden = false;
4685 openMediaBrowserWindow(root, modal);
4686 modal.classList.add("is-loading");
4687 modal.setAttribute("aria-busy", "true");
4688 const requestId = Date.now() + "-" + Math.random();
4689 modal.__dbxCmsMediaRequest = requestId;
4690 if (search) search.value = "";
4691 if (list) list.innerHTML = mediaBrowserSkeletonHtml(24);
4693 refreshMediaFolderControls(root, cfg, modal).then(() => {
4694 if (mediaFolder && folderSelect) {
4695 folderSelect.value = mediaFolder;
4697 const uploadFolders = modal.__dbxCmsUploadFolders || uploadFolderOptions(modal.__dbxCmsFolders || []);
4698 syncUploadFolderSelect(modal, uploadFolders, mediaFolder);
4699 const mediaParams = mediaBrowserQueryParams(String(folderSelect?.value || mediaFolder || "all"));
4700 if (!modal.__dbxCmsMediaSynced) mediaParams.sync = 1;
4701 if (formDataExtra && formDataExtra.xmodul) mediaParams.xmodul = formDataExtra.xmodul;
4702 return fetchJson(apiUrl(mediaUrl, mediaParams), { timeout: 30000 });
4705 if (modal.__dbxCmsMediaRequest !== requestId) return;
4706 if (!data || !data.ok) throw new Error("bad response");
4707 const rows = Array.isArray(data.rows) ? data.rows : [];
4708 modal.__dbxCmsMediaSynced = true;
4709 modal.__dbxCmsRows = rows;
4710 renderMediaFolderTree(modal, modal.__dbxCmsFolders || []);
4711 const render = () => {
4712 const term = String(search?.value || "").toLowerCase();
4713 const slotFilter = String(slotSelect?.value || "all");
4714 const folderFilter = String(folderSelect?.value || "all");
4715 const selected = modal.__dbxCmsSelectedIds || new Set();
4716 const multi = isMediaBrowserMulti(modal);
4717 const needsConfirm = mediaBrowserUsesConfirmBar(modal);
4718 const filtered = rows.filter(row => {
4719 const hay = String((row.title || "") + " " + (row.file_name || "") + " " + (row.alt || "")).toLowerCase();
4720 const matchTerm = !term || hay.includes(term);
4721 const matchSlot = slotFilter === "all" || String(row.slot || "").trim() === slotFilter;
4722 const matchFolder = folderFilter === "all" || String(row.media_folder || "") === folderFilter;
4723 return matchTerm && matchSlot && matchFolder;
4725 if (!filtered.length) {
4726 list.innerHTML = '<div class="dbx-cms-empty">Keine passenden Medien gefunden.</div>';
4727 updateMediaBrowserBatchUi(modal);
4730 renderMediaRowsChunked(
4733 row => mediaBrowserItemHtml(row, selected, needsConfirm),
4735 updateMediaBrowserConfirm(modal);
4736 updateMediaBrowserBatchUi(modal);
4737 renderMediaBatchList(modal);
4742 if (search && !search.__dbxCmsMediaBrowserBound) {
4743 search.__dbxCmsMediaBrowserBound = true;
4744 search.addEventListener("input", render);
4746 if (slotSelect && !slotSelect.__dbxCmsMediaBrowserBound) {
4747 slotSelect.__dbxCmsMediaBrowserBound = true;
4748 slotSelect.addEventListener("change", render);
4750 if (folderSelect && !folderSelect.__dbxCmsMediaBrowserBound) {
4751 folderSelect.__dbxCmsMediaBrowserBound = true;
4752 folderSelect.addEventListener("change", () => {
4753 const uploadFolders = modal.__dbxCmsUploadFolders || uploadFolderOptions(modal.__dbxCmsFolders || []);
4754 syncUploadFolderSelect(modal, uploadFolders, folderSelect.value);
4755 openMediaBrowser(modal.__dbxCmsRoot || root, browserCfg(modal), {
4758 mediaFolder: folderSelect.value,
4759 formDataExtra: modal.__dbxCmsFormDataExtra || null,
4760 afterAssign: modal.__dbxCmsAfterAssign
4766 if (modal.__dbxCmsMediaRequest !== requestId) return;
4767 dbx.error("[cms] media browser failed", err);
4768 if (list) list.innerHTML = '<div class="dbx-cms-empty">Medien konnten nicht geladen werden.</div>';
4771 if (modal.__dbxCmsMediaRequest === requestId) {
4772 modal.classList.remove("is-loading");
4773 modal.removeAttribute("aria-busy");
4774 clearCmsLoading(root);
4775 window.setTimeout(() => clearCmsLoading(root), 50);
4780 function openModBrowserWindow(root, modal) {
4781 if (!modal) return false;
4782 if (!dbx.openWin || typeof dbx.openWin.open !== "function") {
4783 ensureOpenWin().then(ok => {
4784 if (ok) openModBrowserWindow(root, modal);
4785 else status(root, "openWin.js nicht geladen.", "error");
4789 const currentWindow = closestElement(modal, ".dbx-window");
4790 const ownWindowId = modal.__dbxCmsWindowId || "";
4791 if (currentWindow && ownWindowId && currentWindow.id === ownWindowId) {
4792 modal.hidden = false;
4793 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.bringToFront === "function") {
4794 dbx.openWin.bringToFront(modal.__dbxCmsWindowId);
4798 if (currentWindow && (!ownWindowId || currentWindow.id !== ownWindowId)) {
4799 document.body.appendChild(modal);
4801 const id = dbx.openWin.open({
4802 title: '<i class="bi bi-puzzle"></i> Modul Aufruf',
4820 modal.__dbxCmsWindowId = id;
4821 modal.hidden = false;
4822 if (dbx.openWin && typeof dbx.openWin.bringToFront === "function") {
4823 dbx.openWin.bringToFront(id);
4829 function closeModBrowserWindow(modal) {
4831 const winId = modal.__dbxCmsWindowId || "";
4832 modal.__dbxCmsWindowId = null;
4833 if (winId && dbx.openWin && typeof dbx.openWin.close === "function") {
4834 dbx.openWin.close(winId);
4837 modal.hidden = true;
4840 function renderModBrowserImages(list, items) {
4842 if (!items.length) {
4843 list.innerHTML = '<div class="dbx-cms-empty">Keine Modul-Bilder in files/mod gefunden.</div>';
4846 list.innerHTML = items.map(item => `
4847 <div class="dbx-cms-mod-browser-item" data-cms-mod-browser-item
4848 data-url="${escapeHtml(item.url || "")}"
4849 data-label="${escapeHtml(item.label || "")}"
4850 data-modul="${escapeHtml(item.default_modul || "")}"
4851 data-params="${escapeHtml(item.default_params || "")}"
4852 data-alt="${escapeHtml(item.default_alt || item.label || "")}">
4853 <button type="button" class="dbx-cms-mod-browser-pick" data-cms-mod-browser-pick title="In Editor einfuegen">
4854 <img src="${escapeHtml(item.url || "")}" alt="${escapeHtml(item.label || "")}" loading="lazy">
4855 <span class="dbx-cms-mod-browser-label">${escapeHtml(item.label || "")}</span>
4856 <small class="dbx-cms-mod-browser-params">${escapeHtml(item.description || item.default_params || "")}</small>
4861 function renderModBrowserModules(list, items) {
4863 if (!items.length) {
4864 list.innerHTML = '<div class="dbx-cms-empty">Keine Module gefunden.</div>';
4867 list.innerHTML = items.filter(item => Number(item.image_count || 0) > 0).map(item => {
4868 const count = Number(item.image_count || 0);
4869 const runs = Array.isArray(item.run1_actions) ? item.run1_actions.slice(0, 4).join(", ") : "";
4870 const hint = count > 0
4871 ? (count + " Bild" + (count === 1 ? "" : "er"))
4874 <div class="dbx-cms-mod-browser-module" data-cms-mod-browser-module data-modul="${escapeHtml(item.id || "")}">
4875 <button type="button" class="dbx-cms-mod-browser-module-pick" data-cms-mod-browser-module-pick>
4876 <span class="dbx-cms-mod-browser-module-name"><i class="bi bi-puzzle"></i> ${escapeHtml(item.label || item.id || "")}</span>
4877 <span class="dbx-cms-mod-browser-module-meta">${escapeHtml(hint)}</span>
4878 ${runs ? `<small class="dbx-cms-mod-browser-module-runs">${escapeHtml(runs)}</small>` : ""}
4882 if (!list.innerHTML.trim()) {
4883 list.innerHTML = '<div class="dbx-cms-empty">Keine Module mit Bildern in files/mod gefunden.</div>';
4887 function setModBrowserStep(modal, step, modul) {
4889 modal.__dbxCmsModStep = step || "modules";
4890 modal.__dbxCmsModSelected = modul || "";
4891 modal.classList.toggle("is-image-step", step === "images");
4892 const title = qs(modal, "[data-cms-mod-browser-title]");
4893 const back = qs(modal, "[data-cms-mod-browser-back]");
4895 title.textContent = step === "images"
4896 ? ("Modul: " + (modul || ""))
4899 if (back) back.hidden = step !== "images";
4902 function loadModBrowserModules(root, cfg, modal) {
4903 const modulesUrl = cfgUrl(cfg, "modmodules");
4904 const list = qs(modal, "[data-cms-mod-browser-list]");
4906 if (list) list.innerHTML = '<div class="dbx-cms-empty">Modul-Liste URL fehlt.</div>';
4907 return Promise.resolve();
4909 if (list) list.innerHTML = '<div class="dbx-cms-empty">Lade Module...</div>';
4910 setModBrowserStep(modal, "modules", "");
4911 return fetchJson(apiUrl(modulesUrl)).then(data => {
4912 const items = data && Array.isArray(data.items) ? data.items : [];
4913 modal.__dbxCmsModModules = items;
4914 renderModBrowserModules(list, items);
4916 dbx.error("[cms] mod modules failed", err);
4917 if (list) list.innerHTML = '<div class="dbx-cms-empty">Module konnten nicht geladen werden.</div>';
4921 function loadModBrowserCatalog(root, cfg, modal, modul) {
4922 const catalogUrl = cfgUrl(cfg, "modcatalog");
4923 const list = qs(modal, "[data-cms-mod-browser-list]");
4924 if (!catalogUrl || !modul) {
4925 if (list) list.innerHTML = '<div class="dbx-cms-empty">Modul-Katalog URL fehlt.</div>';
4926 return Promise.resolve();
4928 if (list) list.innerHTML = '<div class="dbx-cms-empty">Lade Modul-Bilder...</div>';
4929 setModBrowserStep(modal, "images", modul);
4930 return fetchJson(apiUrl(catalogUrl, { modul: modul })).then(data => {
4931 const items = data && Array.isArray(data.items) ? data.items : [];
4932 renderModBrowserImages(list, items);
4934 dbx.error("[cms] mod catalog failed", err);
4935 if (list) list.innerHTML = '<div class="dbx-cms-empty">Modul-Bilder konnten nicht geladen werden.</div>';
4939 function bindModBrowserEvents(root, cfg, modal) {
4940 if (!modal || modal.__dbxCmsModEventsBound) return;
4941 modal.__dbxCmsModEventsBound = true;
4943 modal.addEventListener("click", e => {
4944 e.stopPropagation();
4946 const backBtn = closestElement(e.target, "[data-cms-mod-browser-back]");
4947 if (backBtn && modal.contains(backBtn)) {
4949 loadModBrowserModules(root, cfg, modal);
4953 const modulePick = closestElement(e.target, "[data-cms-mod-browser-module-pick]");
4954 if (modulePick && modal.contains(modulePick)) {
4956 const row = closestElement(modulePick, "[data-cms-mod-browser-module]");
4957 const modul = row ? (row.getAttribute("data-modul") || "") : "";
4959 loadModBrowserCatalog(root, cfg, modal, modul);
4963 const pickBtn = closestElement(e.target, "[data-cms-mod-browser-pick]");
4964 if (pickBtn && modal.contains(pickBtn)) {
4966 const item = closestElement(pickBtn, "[data-cms-mod-browser-item]");
4969 url: item.getAttribute("data-url") || "",
4970 label: item.getAttribute("data-label") || "",
4971 default_modul: item.getAttribute("data-modul") || "",
4972 default_params: item.getAttribute("data-params") || "",
4973 default_alt: item.getAttribute("data-alt") || ""
4975 insertModPlaceholder(root, row, cfg);
4976 closeModBrowserWindow(modal);
4981 function openModBrowser(root, cfg) {
4982 cfg = cfg || cmsConfig(root) || {};
4983 saveEditorSelection(root);
4984 if (!cfgUrl(cfg, "modmodules") || !cfgUrl(cfg, "modcatalog")) {
4985 status(root, "Modul-Browser URLs fehlen.", "error");
4988 let modal = state(root).modBrowser;
4989 if (!modal || !document.documentElement.contains(modal)) {
4990 modal = qs(root, "[data-cms-mod-browser]");
4993 modal = document.createElement("div");
4994 modal.className = "dbx-cms-mod-browser";
4995 modal.setAttribute("data-cms-mod-browser", "1");
4997 <div class="dbx-cms-mod-browser-head">
4998 <button type="button" class="btn btn-outline-secondary btn-sm" data-cms-mod-browser-back hidden title="Zurueck zur Modulliste">
4999 <i class="bi bi-arrow-left"></i>
5001 <span data-cms-mod-browser-title></span>
5003 <div class="dbx-cms-mod-browser-list" data-cms-mod-browser-list>
5004 <div class="dbx-cms-empty">Lade Module...</div>
5006 root.appendChild(modal);
5007 state(root).modBrowser = modal;
5009 state(root).modBrowser = modal;
5010 modal.__dbxCmsRoot = root;
5011 bindModBrowserEvents(root, cfg, modal);
5012 modal.hidden = false;
5013 openModBrowserWindow(root, modal);
5014 loadModBrowserModules(root, cfg, modal);
5017 function modOptionsNeedsRebuild(modal) {
5018 return !!qs(modal, "[data-cms-mod-options-dbx]") || !!qs(modal, "select[data-cms-mod-options-modul]");
5021 function ensureModPlaceholderOptionsDialog(root) {
5022 const s = state(root);
5023 let modal = s.modOptionsModal || qs(root, "[data-cms-mod-options]");
5024 if (modal && modOptionsNeedsRebuild(modal)) {
5027 s.modOptionsModal = null;
5030 modal.__dbxCmsRoot = root;
5031 s.modOptionsModal = modal;
5034 modal = document.createElement("div");
5035 modal.className = "dbx-cms-mod-options";
5036 modal.setAttribute("data-cms-mod-options", "1");
5037 modal.__dbxCmsRoot = root;
5038 modal.hidden = true;
5040 <div class="dbx-cms-mod-options-body">
5041 <label>Alt-Text (img alt)
5042 <input type="text" class="form-control form-control-sm" data-cms-mod-options-alt placeholder="Alternativtext des Pseudo-Bildes">
5044 <div class="dbx-cms-mod-options-module">
5045 <span class="dbx-cms-mod-options-module-label">Modul</span>
5046 <strong class="dbx-cms-mod-options-module-value" data-cms-mod-options-modul></strong>
5049 <input type="text" class="form-control form-control-sm" data-cms-mod-options-params placeholder="z.B. dbx_run1=show&root=0">
5050 <small class="text-muted">Query-String fuer [modul=...]parameter[/modul]</small>
5053 <div class="dbx-cms-mod-options-actions">
5054 <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>
5055 <button type="button" class="btn btn-primary btn-sm" data-cms-mod-options-apply><i class="bi bi-check2"></i><span>Uebernehmen</span></button>
5057 modal.addEventListener("click", e => {
5058 if (closestElement(e.target, "[data-cms-mod-options-close]")) {
5060 closeModPlaceholderOptionsWindow(modal);
5063 if (closestElement(e.target, "[data-cms-mod-options-apply]")) {
5065 applyModPlaceholderOptions(root, modal);
5068 root.appendChild(modal);
5069 s.modOptionsModal = modal;
5073 function closeModPlaceholderOptionsWindow(modal) {
5075 modal.__dbxCmsModPlaceholder = null;
5076 modal.hidden = true;
5077 const winId = modal.__dbxCmsWindowId || "";
5078 if (winId && dbx.openWin && typeof dbx.openWin.close === "function") {
5079 dbx.openWin.close(winId);
5080 modal.__dbxCmsWindowId = null;
5084 function openModPlaceholderOptions(root, wrapper, cfg) {
5085 if (!wrapper) return false;
5086 cfg = cfg || cmsConfig(root) || {};
5087 const modal = ensureModPlaceholderOptionsDialog(root);
5088 modal.__dbxCmsModPlaceholder = wrapper;
5089 modal.__dbxCmsCfg = cfg;
5090 const values = modPlaceholderValues(wrapper);
5091 const params = qs(modal, "[data-cms-mod-options-params]");
5092 const alt = qs(modal, "[data-cms-mod-options-alt]");
5093 const modulEl = qs(modal, "[data-cms-mod-options-modul]");
5094 if (params) params.value = values.params || "";
5095 if (alt) alt.value = values.alt || "";
5096 if (modulEl) modulEl.textContent = values.modul || "–";
5097 modal.hidden = false;
5098 if (!dbx.openWin || typeof dbx.openWin.open !== "function") {
5099 ensureOpenWin().then(ok => {
5100 if (ok) openModPlaceholderOptionsWindow(root, modal);
5104 openModPlaceholderOptionsWindow(root, modal);
5108 function openModPlaceholderOptionsWindow(root, modal) {
5109 if (!modal) return false;
5110 if (!dbx.openWin || typeof dbx.openWin.open !== "function") {
5111 ensureOpenWin().then(ok => {
5112 if (ok) openModPlaceholderOptionsWindow(root, modal);
5113 else status(root, "openWin.js nicht geladen.", "error");
5117 const currentWindow = closestElement(modal, ".dbx-window");
5118 const ownWindowId = modal.__dbxCmsWindowId || "";
5119 if (currentWindow && currentWindow.isConnected && ownWindowId && currentWindow.id === ownWindowId) {
5120 modal.hidden = false;
5121 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.bringToFront === "function") {
5122 dbx.openWin.bringToFront(modal.__dbxCmsWindowId);
5126 if (currentWindow && currentWindow.isConnected && (!ownWindowId || currentWindow.id !== ownWindowId)) {
5127 document.body.appendChild(modal);
5128 } else if (currentWindow && !currentWindow.isConnected) {
5129 modal.__dbxCmsWindowId = null;
5130 if (modal.parentNode) modal.parentNode.removeChild(modal);
5132 const id = dbx.openWin.open({
5133 title: '<i class="bi bi-puzzle"></i> Modul-Platzhalter',
5151 modal.__dbxCmsWindowId = id;
5152 modal.hidden = false;
5153 if (dbx.openWin && typeof dbx.openWin.bringToFront === "function") {
5154 dbx.openWin.bringToFront(id);
5160 function applyModPlaceholderOptions(root, modal) {
5161 const wrapper = modal && modal.__dbxCmsModPlaceholder;
5162 if (!wrapper) return false;
5163 const values = modPlaceholderValues(wrapper);
5164 const modul = values.modul || "";
5165 const params = qs(modal, "[data-cms-mod-options-params]")?.value || "";
5166 const alt = qs(modal, "[data-cms-mod-options-alt]")?.value || "";
5167 const img = qs(wrapper, "img") || qs(wrapper, ".dbx-cms-mod-image");
5169 status(root, "Modul konnte am Platzhalter nicht erkannt werden.", "warning");
5172 wrapper.setAttribute("data-cms-mod-params", params);
5173 wrapper.setAttribute("title", alt);
5175 img.setAttribute("alt", alt);
5176 img.setAttribute("title", alt);
5177 img.removeAttribute("data-dbx");
5178 img.setAttribute("data-cms-mod-params", params);
5180 normalizeModPlaceholders(editorSurface(root));
5181 syncEditorAfterContextAction(root);
5183 closeModPlaceholderOptionsWindow(modal);
5187 function bindModPlaceholderEvents(root) {
5188 if (root.__dbxCmsModPlaceholderBound) return;
5189 root.__dbxCmsModPlaceholderBound = true;
5190 root.addEventListener("dblclick", e => {
5191 const video = inlineVideoEventTarget(root, e);
5194 e.stopPropagation();
5195 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
5196 openInlineVideoOptions(root, video);
5199 const mod = inlineModTarget(root, e.target);
5202 e.stopPropagation();
5203 openModPlaceholderOptions(root, mod, cmsConfig(root) || {});
5207 function visibleJoditDialogInputs(panel) {
5208 return qsa(panel, "input, textarea").filter(input => {
5209 const type = String(input.getAttribute("type") || "text").toLowerCase();
5210 if (["hidden", "file", "button", "submit", "reset", "checkbox", "radio"].includes(type)) return false;
5211 if (input.disabled || input.readOnly) return false;
5212 return !!input.getClientRects().length;
5216 function joditDialogValueInputs(panel) {
5217 return qsa(panel, "input, textarea").filter(input => {
5218 const type = String(input.getAttribute("type") || "text").toLowerCase();
5219 if (["hidden", "file", "button", "submit", "reset", "checkbox", "radio"].includes(type)) return false;
5220 return !input.disabled && !input.readOnly;
5224 function findJoditImageDialogPathInput(panel) {
5225 if (panel && panel.__dbxCmsJoditPathInput && panel.contains(panel.__dbxCmsJoditPathInput)) {
5226 return panel.__dbxCmsJoditPathInput;
5228 const visibleInputs = visibleJoditDialogInputs(panel);
5229 const inputs = visibleInputs.length ? visibleInputs : joditDialogValueInputs(panel);
5230 return inputs.find(input => /src|url|path|image|bild/i.test(String(input.name || input.id || input.getAttribute("placeholder") || "")))
5231 || inputs.find(input => String(input.value || "").indexOf("index.php") >= 0 || /\.(jpg|jpeg|png|gif|webp|svg)(\?|#|$)/i.test(input.value || ""))
5236 function joditImageDialogPanel(target) {
5237 const panel = closestElement(target, ".jodit-dialog__panel, .jodit-dialog");
5238 if (!panel) return null;
5239 const text = String(panel.textContent || "");
5240 if (!/Bildeigenschaften|Image properties|Bild/i.test(text)) return null;
5241 return findJoditImageDialogPathInput(panel) ? panel : null;
5244 function hideJoditUploadControl(el) {
5246 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;
5247 node.classList.add("dbx-cms-jodit-upload-hidden");
5251 function compactJoditImageDialog(panel) {
5253 panel.classList.add("dbx-cms-jodit-image-dialog");
5254 qsa(panel, "input[type='file'], .jodit-drag-and-drop__file-box, .jodit_uploadfile_button, .jodit-uploader, .jodit-upload").forEach(hideJoditUploadControl);
5255 qsa(panel, "button, a, [role='button']").forEach(btn => {
5256 const label = String((btn.textContent || "") + " " + (btn.title || "") + " " + (btn.getAttribute("aria-label") || ""));
5257 if (/Hochladen|Upload/i.test(label)) {
5258 if (isJoditImageDialogMediaTrigger(panel, btn)) {
5259 btn.title = "Bild aus Medienbrowser auswaehlen";
5262 btn.classList.add("dbx-cms-jodit-upload-hidden");
5266 ensureJoditImageDialogMediaButton(panel);
5267 hideJoditImageDialogPathField(panel);
5270 function joditImageDialogPathContainer(pathInput) {
5271 if (!pathInput) return null;
5272 const dialog = closestElement(pathInput, ".jodit-dialog__panel, .jodit-dialog");
5273 let node = pathInput.parentElement;
5274 for (let i = 0; node && node !== dialog && i < 5; i++) {
5275 const inputs = joditDialogValueInputs(node);
5276 const text = String(node.textContent || "");
5277 if (inputs.length === 1 && inputs[0] === pathInput && /Pfad|URL|Src|Quelle|Source/i.test(text)) return node;
5278 node = node.parentElement;
5283 function joditImagePreviewHost(panel) {
5284 const img = qsa(panel, "img").find(item => {
5285 if (item.closest && item.closest(".jodit-dialog__header")) return false;
5286 return !!item.getClientRects().length;
5288 if (!img) return null;
5289 const dialog = closestElement(img, ".jodit-dialog__panel, .jodit-dialog");
5290 let host = img.parentElement;
5291 for (let i = 0; host && host !== dialog && i < 4; i++) {
5292 if (host.children.length <= 4) break;
5293 host = host.parentElement;
5295 if (!host || host === dialog) host = img.parentElement;
5296 if (host) host.classList.add("dbx-cms-jodit-preview-picker");
5300 function joditSingleInputBox(input) {
5301 if (!input) return null;
5302 const dialog = closestElement(input, ".jodit-dialog__panel, .jodit-dialog");
5303 let node = closestElement(input, ".jodit-ui-input, .jodit-form__group, .jodit-ui-block, div") || input.parentElement;
5304 for (let i = 0; node && node !== dialog && i < 5; i++) {
5305 if (joditDialogValueInputs(node).length === 1) return node;
5306 node = node.parentElement;
5308 return input.parentElement || input;
5311 function arrangeJoditImageSizeControls(panel) {
5312 const pathInput = findJoditImageDialogPathInput(panel);
5313 const inputs = joditDialogValueInputs(panel).filter(input => input !== pathInput);
5314 const pathIndex = joditDialogValueInputs(panel).indexOf(pathInput);
5315 const beforePath = pathIndex > 0 ? joditDialogValueInputs(panel).slice(0, pathIndex) : inputs;
5316 const sizeInputs = beforePath
5317 .filter(input => input !== pathInput && !input.closest(".dbx-cms-jodit-openwin"))
5318 .filter(input => /^\s*\d+(\.\d+)?\s*$/.test(String(input.value || "")) || String(input.getAttribute("type") || "").toLowerCase() === "number")
5320 if (sizeInputs.length < 2) return;
5322 let row = qs(panel, ".dbx-cms-jodit-size-row");
5324 row = document.createElement("div");
5325 row.className = "dbx-cms-jodit-size-row";
5328 const firstBox = joditSingleInputBox(sizeInputs[0]);
5329 const secondBox = joditSingleInputBox(sizeInputs[1]);
5330 const common = firstBox && secondBox ? (firstBox.parentElement === secondBox.parentElement ? firstBox.parentElement : null) : null;
5331 const lockButton = common ? qsa(common, "button, .jodit-ui-button, [role='button']").find(btn => {
5332 const text = String((btn.textContent || "") + " " + (btn.title || "") + " " + (btn.getAttribute("aria-label") || "") + " " + (btn.className || ""));
5333 return /lock|proportion|ratio|verhaeltnis|verh\u00e4ltnis/i.test(text) || qsa(btn, "svg,.jodit-icon").length;
5336 const preview = joditImagePreviewHost(panel);
5337 if (preview && row.parentElement !== preview.parentElement) {
5338 preview.insertAdjacentElement("afterend", row);
5339 } else if (!row.parentElement && firstBox && firstBox.parentElement) {
5340 firstBox.insertAdjacentElement("beforebegin", row);
5343 if (firstBox) row.appendChild(firstBox);
5344 if (lockButton) row.appendChild(lockButton);
5345 if (secondBox) row.appendChild(secondBox);
5348 function ensureJoditImageDialogMediaButton(panel) {
5349 const pathInput = findJoditImageDialogPathInput(panel);
5350 if (!pathInput || qs(panel, "[data-cms-jodit-media-select]")) return;
5351 panel.__dbxCmsJoditPathInput = pathInput;
5352 const btn = document.createElement("button");
5353 btn.type = "button";
5354 btn.className = "btn btn-outline-primary btn-sm dbx-cms-hero-pick dbx-cms-jodit-media-select";
5355 btn.setAttribute("data-cms-jodit-media-select", "1");
5356 btn.title = "Bild aus Medienbrowser auswaehlen";
5357 btn.innerHTML = '<i class="bi bi-image"></i><i class="bi bi-camera-video"></i><i class="bi bi-upload"></i><span>Auswahl</span>';
5358 const preview = joditImagePreviewHost(panel);
5360 preview.insertAdjacentElement("beforebegin", btn);
5363 const container = joditImageDialogPathContainer(pathInput);
5364 if (container && container.parentElement) {
5365 container.insertAdjacentElement("beforebegin", btn);
5368 pathInput.insertAdjacentElement("beforebegin", btn);
5371 function hideJoditImageDialogPathField(panel) {
5372 const pathInput = findJoditImageDialogPathInput(panel);
5373 if (!pathInput) return;
5374 panel.__dbxCmsJoditPathInput = pathInput;
5375 const container = joditImageDialogPathContainer(pathInput);
5376 if (container && container !== pathInput && joditDialogValueInputs(container).length === 1) {
5377 container.classList.add("dbx-cms-jodit-path-hidden");
5378 container.hidden = true;
5381 pathInput.classList.add("dbx-cms-jodit-path-hidden");
5382 pathInput.hidden = true;
5383 pathInput.setAttribute("aria-hidden", "true");
5386 function findJoditDialogLinkInput(panel) {
5387 const pathInput = findJoditImageDialogPathInput(panel);
5388 const inputs = joditDialogValueInputs(panel).filter(input => input !== pathInput);
5389 return inputs.find(input => /link|href|url/i.test(String(input.name || input.id || input.getAttribute("placeholder") || "")))
5390 || inputs[inputs.length - 1]
5394 function ensureJoditImageOpenWinControls(panel) {
5395 if (qs(panel, "[data-cms-jodit-openwin]")) return;
5396 const linkInput = findJoditDialogLinkInput(panel);
5397 if (!linkInput) return;
5398 const host = document.createElement("div");
5399 host.className = "dbx-cms-jodit-openwin";
5400 host.setAttribute("data-cms-jodit-openwin", "1");
5402 <label class="dbx-cms-jodit-openwin-field">
5403 <span>Link oeffnen</span>
5404 <select class="form-select form-select-sm" data-cms-jodit-link-mode>
5405 <option value="">Normal</option>
5406 <option value="blank">Neuer Tab</option>
5407 <option value="openwin">openWin</option>
5410 <div class="dbx-cms-jodit-openwin-options" data-cms-jodit-openwin-options hidden>
5411 <label><span>Groesse</span><select class="form-select form-select-sm" data-cms-jodit-openwin-size>
5412 <option value="900x620">900 x 620</option>
5413 <option value="1024x760">1024 x 760</option>
5414 <option value="80%x80%">80% x 80%</option>
5415 <option value="1320x860">1320 x 860</option>
5417 <label><span>Position</span><select class="form-select form-select-sm" data-cms-jodit-openwin-position>
5418 <option value="center">Zentriert</option>
5419 <option value="center-top">Zentriert oben</option>
5420 <option value="cascade">Versetzt</option>
5421 <option value="fullscreen">Vollbild</option>
5425 const linkBox = joditSingleInputBox(linkInput);
5426 if (linkBox && linkBox.parentElement) linkBox.insertAdjacentElement("afterend", host);
5427 else linkInput.insertAdjacentElement("afterend", host);
5429 const newTab = qsa(panel, "input[type='checkbox']").find(input => /tab|target|blank|neu/i.test(String(input.name || input.id || input.closest("label")?.textContent || "")));
5430 const mode = qs(host, "[data-cms-jodit-link-mode]");
5431 const options = qs(host, "[data-cms-jodit-openwin-options]");
5432 if (newTab && newTab.checked) mode.value = "blank";
5433 mode.addEventListener("change", () => {
5434 if (options) options.hidden = mode.value !== "openwin";
5435 if (newTab) newTab.checked = mode.value === "blank";
5439 function readJoditOpenWinSettings(panel) {
5440 const mode = qs(panel, "[data-cms-jodit-link-mode]");
5441 const size = qs(panel, "[data-cms-jodit-openwin-size]");
5442 const position = qs(panel, "[data-cms-jodit-openwin-position]");
5443 const value = String(mode?.value || "");
5444 const parts = String(size?.value || "900x620").split("x");
5447 width: parts[0] || "900",
5448 height: parts[1] || "620",
5449 position: String(position?.value || "center")
5453 function findImageByDialogPath(root, path) {
5454 const surface = editorSurface(root);
5455 if (!surface) return null;
5456 const clean = String(path || "").split("#")[0];
5457 return qsa(surface, "img").find(img => {
5458 const src = String(img.getAttribute("src") || img.src || "").split("#")[0];
5459 return src === clean || src.endsWith(clean) || clean.endsWith(src);
5463 function applyJoditOpenWinSettings(root, panel, settings) {
5464 if (!settings || !settings.mode) return;
5465 const linkInput = findJoditDialogLinkInput(panel);
5466 const href = String(linkInput?.value || "").trim();
5468 const pathInput = findJoditImageDialogPathInput(panel);
5469 const img = findImageByDialogPath(root, pathInput ? pathInput.value : "");
5470 const link = img ? closestElement(img, "a") : null;
5472 if (settings.mode === "blank") {
5473 link.setAttribute("target", "_blank");
5474 link.setAttribute("rel", "noopener");
5475 link.classList.remove("dbx-win");
5476 link.removeAttribute("data-dbx");
5477 link.removeAttribute("data-url");
5480 if (settings.mode === "openwin") {
5481 const title = img.getAttribute("alt") || img.getAttribute("title") || "Information";
5482 const dbxData = "lib=openWin|url=" + href + "|title=" + title + "|width=" + settings.width + "|height=" + settings.height + "|position=" + settings.position + "|reload=1|minimizable=1|maximizable=1";
5483 link.classList.add("dbx-win");
5484 link.removeAttribute("target");
5485 link.setAttribute("href", href);
5486 link.setAttribute("data-url", href);
5487 link.setAttribute("data-title", title);
5488 link.setAttribute("data-width", settings.width);
5489 link.setAttribute("data-height", settings.height);
5490 link.setAttribute("data-position", settings.position);
5491 link.setAttribute("data-dbx", dbxData);
5492 syncEditorDom(root);
5497 function isJoditImageDialogMediaTrigger(panel, target) {
5498 const trigger = closestElement(target, "button, a, [role='button'], .jodit-ui-button, .jodit-button, .jodit-input__icon, .jodit-icon");
5499 if (!panel || !trigger || !panel.contains(trigger)) return false;
5500 if (trigger.hasAttribute("data-cms-jodit-media-select")) return true;
5501 if (closestElement(trigger, ".jodit-dialog__header, .jodit-dialog__footer")) return false;
5502 const label = String((trigger.textContent || "") + " " + (trigger.title || "") + " " + (trigger.getAttribute("aria-label") || ""));
5503 if (/Abbrechen|Anwenden|Loeschen|L\u00f6schen|Schliessen|Schlie\u00dfen|Fortgeschritten/i.test(label)) return false;
5505 const pathInput = findJoditImageDialogPathInput(panel);
5506 if (!pathInput) return false;
5507 const inputRect = pathInput.getBoundingClientRect();
5508 const triggerRect = trigger.getBoundingClientRect();
5509 if (!inputRect.width || !triggerRect.width) return false;
5510 return triggerRect.left >= inputRect.right - 12
5511 && triggerRect.top <= inputRect.bottom + 8
5512 && triggerRect.bottom >= inputRect.top - 8;
5515 function setJoditDialogFieldValue(field, value, overwrite) {
5516 if (!field || value == null) return;
5517 if (!overwrite && String(field.value || "").trim()) return;
5518 field.value = String(value || "");
5519 field.dispatchEvent(new Event("input", { bubbles: true }));
5520 field.dispatchEvent(new Event("change", { bubbles: true }));
5523 function applyMediaToJoditImageDialog(root, panel, row) {
5524 if (!row || !row.url) return false;
5525 if (!isImageRow(row)) {
5526 status(root, "Bitte ein Bild auswaehlen.", "error");
5529 const inputs = visibleJoditDialogInputs(panel);
5530 const pathInput = findJoditImageDialogPathInput(panel);
5531 setJoditDialogFieldValue(pathInput, row.url, true);
5533 const afterPath = inputs.slice(Math.max(0, inputs.indexOf(pathInput) + 1))
5534 .filter(input => String(input.getAttribute("type") || "text").toLowerCase() !== "number");
5535 const titleInput = afterPath.find(input => /title|titel/i.test(String(input.name || input.id || input.getAttribute("placeholder") || ""))) || afterPath[0] || null;
5536 const altInput = afterPath.find(input => /alt|alternative/i.test(String(input.name || input.id || input.getAttribute("placeholder") || ""))) || afterPath.find(input => input !== titleInput) || null;
5537 const label = row.title || row.alt || row.file_name || "";
5538 setJoditDialogFieldValue(titleInput, row.title || label, false);
5539 setJoditDialogFieldValue(altInput, row.alt || label, false);
5540 qsa(panel, "img").forEach(img => {
5541 if (img.closest && img.closest(".jodit-dialog__header")) return;
5542 img.src = row.thumb_url || row.url;
5543 img.alt = row.alt || label;
5545 status(root, "Bild aus Medienbrowser uebernommen.", "success");
5549 function openJoditImageDialogMediaBrowser(root, cfg, panel) {
5550 compactJoditImageDialog(panel);
5551 openMediaBrowser(root, cfg || {}, {
5556 return applyMediaToJoditImageDialog(root, panel, row);
5561 function openEditorMediaBrowser(root, cfg) {
5562 openMediaBrowser(root, cfg || {}, {
5567 return assignMedia(root, cfg || {}, row, "inline").then(assignedRow => {
5568 if (!assignedRow) return false;
5569 insertMediaRow(root, assignedRow);
5570 setLocalMediaSlot(root, assignedRow.id, "inline");
5577 function bindJoditImageDialogMediaPicker(root, cfg) {
5578 if (root.__dbxCmsJoditImagePickerBound) return;
5579 root.__dbxCmsJoditImagePickerBound = true;
5581 document.addEventListener("click", e => {
5582 const panel = joditImageDialogPanel(e.target);
5584 compactJoditImageDialog(panel);
5585 if (!isJoditImageDialogMediaTrigger(panel, e.target)) return;
5587 e.stopPropagation();
5588 if (e.stopImmediatePropagation) e.stopImmediatePropagation();
5589 openJoditImageDialogMediaBrowser(root, cfg || {}, panel);
5592 document.addEventListener("focusin", e => {
5593 const panel = joditImageDialogPanel(e.target);
5594 if (panel) compactJoditImageDialog(panel);
5597 // Kein MutationObserver hier: Jodit baut den Dialog dynamisch um und Firefox
5598 // kann sonst bei DOM-Korrekturen in eine dauernde Mutation-Schleife laufen.
5601 function initEditor(root, cfg) {
5602 const editor = qs(root, "[data-cms-editor]");
5603 if (!editor || editor.__dbxJoditReady) return;
5605 if (!window.Jodit || !window.Jodit.make) {
5606 dbx.warn("[cms] Jodit not loaded, fallback contenteditable active");
5610 editor.__dbxJoditReady = true;
5611 bindModPlaceholderEvents(root);
5612 const cmsMarkers = [
5613 { label: "Hero-Text", marker: "dbx:hero" },
5614 { label: "Header", marker: "dbx:header" },
5615 { label: "Footer", marker: "dbx:footer" },
5616 { label: "col-2 Trenner", marker: "dbx:col2" },
5617 { label: "col-3a Trenner", marker: "dbx:col3a" },
5618 { label: "col-3b Trenner", marker: "dbx:col3b" },
5619 { label: "Druck Seitenumbruch", marker: "dbx:pagebreak" }
5622 window.Jodit.defaultOptions.controls.dbxMarkerMenu = {
5623 tooltip: "Trenner Marker fuer Bereiche des Content",
5624 icon: cmsMarkerMenuIcon(),
5626 popup: function (jodit, current, control, close) {
5627 const box = document.createElement("div");
5628 box.className = "dbx-cms-marker-menu";
5629 cmsMarkers.forEach(item => {
5630 const btn = document.createElement("button");
5631 btn.type = "button";
5632 btn.className = "dbx-cms-marker-menu-item";
5633 btn.textContent = item.label;
5634 btn.addEventListener("mousedown", e => {
5637 btn.addEventListener("click", e => {
5639 insertMarker(root, item.marker, item.label);
5640 if (typeof close === "function") close();
5642 box.appendChild(btn);
5646 exec: function (jodit) {
5647 if (jodit && jodit.e && jodit.e.fire) jodit.e.fire("togglePopup", "dbxMarkerMenu");
5650 window.Jodit.defaultOptions.controls.dbxImageBrowser = {
5651 tooltip: "Medium aus Medienbrowser einfuegen",
5654 openEditorMediaBrowser(root, cfg || {});
5657 window.Jodit.defaultOptions.controls.dbxModBrowser = {
5658 tooltip: "Modul Aufruf einfuegen",
5659 icon: cmsModBrowserIcon(),
5662 saveEditorSelection(root);
5663 openModBrowser(root, cfg || {});
5666 window.Jodit.defaultOptions.controls.dbxBootstrapComponents = {
5667 tooltip: "Bootstrap Content-Komponente einfuegen",
5668 icon: cmsBootstrapComponentIcon(),
5670 popup: function (jodit, current, control, close) {
5671 const box = document.createElement("div");
5672 box.className = "dbx-cms-marker-menu";
5673 bootstrapComponentItems().forEach(item => {
5674 const btn = document.createElement("button");
5675 btn.type = "button";
5676 btn.className = "dbx-cms-marker-menu-item";
5677 btn.textContent = item.label;
5678 btn.addEventListener("mousedown", e => e.preventDefault());
5679 btn.addEventListener("click", e => {
5681 insertBootstrapComponent(root, item.html);
5682 if (typeof close === "function") close();
5684 box.appendChild(btn);
5688 exec: function (jodit) {
5689 if (jodit && jodit.e && jodit.e.fire) jodit.e.fire("togglePopup", "dbxBootstrapComponents");
5692 window.Jodit.defaultOptions.controls.dbxTextStyle = {
5693 tooltip: "Textformatierung",
5694 icon: cmsTextStyleIcon(),
5696 popup: function (jodit, current, control, close) {
5697 const box = document.createElement("div");
5698 box.className = "dbx-cms-marker-menu dbx-cms-text-style-menu";
5700 { label: "Fett", mark: "B", command: "bold", className: "is-bold" },
5701 { label: "Kursiv", mark: "I", command: "italic", className: "is-italic" },
5702 { label: "Unterstrichen", mark: "U", command: "underline", className: "is-underlined" },
5703 { label: "Durchgestrichen", mark: "S", command: "strikethrough", className: "is-struck" }
5705 const btn = document.createElement("button");
5706 btn.type = "button";
5707 btn.className = "dbx-cms-marker-menu-item dbx-cms-text-style-menu-item";
5708 btn.innerHTML = '<span class="dbx-cms-text-style-menu-mark ' + item.className + '">' + item.mark + '</span><span>' + item.label + '</span>';
5709 btn.addEventListener("mousedown", e => e.preventDefault());
5710 btn.addEventListener("click", e => {
5712 execEditorCommand(root, item.command);
5713 if (typeof close === "function") close();
5715 box.appendChild(btn);
5719 exec: function (jodit) {
5720 if (jodit && jodit.e && jodit.e.fire) jodit.e.fire("togglePopup", "dbxTextStyle");
5723 window.Jodit.defaultOptions.controls.dbxHr = {
5724 tooltip: "Horizontale Linie an Cursorposition einfuegen",
5728 insertEditorPlainHr(root);
5731 window.Jodit.defaultOptions.controls.dbxSave = {
5732 tooltip: "Alles speichern",
5733 icon: cmsSaveIcon(),
5736 saveCurrentCms(root, cfg || {});
5740 bindJoditImageDialogMediaPicker(root, cfg || {});
5742 root.__dbxCmsJodit = window.Jodit.make(editor, {
5746 toolbarSticky: false,
5750 askBeforePasteHTML: false,
5751 askBeforePasteFromWord: false,
5753 removeEmptyElements: false
5755 toolbarAdaptive: false,
5756 showCharsCounter: false,
5757 showWordsCounter: false,
5758 showXPathInStatusbar: false,
5759 imageDefaultWidth: 720,
5762 "source", "dbxSave", "|",
5763 "dbxTextStyle", "|",
5764 "ul", "ol", "outdent", "indent", "|",
5765 "paragraph", "brush", "|",
5766 "link", "dbxImageBrowser", "dbxModBrowser", "dbxBootstrapComponents", "video", "dbxHr", "|",
5767 "dbxMarkerMenu", "|",
5768 "undo", "redo", "|",
5772 change: function () {
5773 normalizeEditorMarkers(root);
5774 const surface = editorSurface(root);
5775 normalizeBootstrapComponents(surface);
5776 normalizeInlineMediaLayout(surface);
5777 normalizeModPlaceholders(surface);
5778 const html = surface ? (surface.innerHTML || "") : (this.value || "");
5779 if (html !== (this.value || "")) this.value = html;
5780 setField(root, "content", html);
5782 scheduleEditorHeight(root);
5784 focus: function () {
5785 window.requestAnimationFrame(() => saveEditorSelection(root));
5787 afterSelectionChange: function () {
5788 window.requestAnimationFrame(() => saveEditorSelection(root));
5792 bindEditorSlashCommands(root, cfg || {});
5793 bindBootstrapCardEditingGuards(root);
5794 bindEditorHeight(root);
5795 window.setTimeout(() => {
5796 normalizeEditorMarkers(root);
5797 const surface = editorSurface(root);
5798 normalizeBootstrapComponents(surface);
5799 bindBootstrapCardEditingGuards(root);
5800 bindEditorSlashCommands(root, cfg || {});
5801 normalizeModPlaceholders(surface);
5802 bindEditorMarkerEventsRetry(root);
5803 bindEditorSaveButton(root, cfg || {}, 0);
5804 scheduleEditorHeight(root);
5808 function bindEditorSaveButton(root, cfg, attempt) {
5809 attempt = Number(attempt || 0);
5810 const instance = getEditorInstance(root);
5811 const container = instance && instance.container ? instance.container : root;
5812 let buttons = qsa(container, ".jodit-toolbar-button");
5813 if (!buttons.length) buttons = qsa(root, ".jodit-toolbar-button");
5814 const saveButton = buttons.find(btn => /dbxsave/i.test(btn.className || "")) || buttons[1] || null;
5815 const button = saveButton ? qs(saveButton, "button") : null;
5817 if (attempt < 20) window.setTimeout(() => bindEditorSaveButton(root, cfg || {}, attempt + 1), 100);
5820 if (button.__dbxCmsSaveBound) return;
5821 button.__dbxCmsSaveBound = true;
5822 button.setAttribute("title", "Alles speichern");
5823 button.setAttribute("aria-label", "Alles speichern");
5824 button.addEventListener("click", event => {
5825 event.preventDefault();
5826 event.stopPropagation();
5827 if (typeof event.stopImmediatePropagation === "function") event.stopImmediatePropagation();
5828 saveCurrentCms(root, cfg || {});
5832 function setEditorHtml(root, html) {
5833 html = repairInlineVideoHtml(root, html);
5834 const instance = getEditorInstance(root);
5835 const editor = qs(root, "[data-cms-editor]");
5836 if (instance) instance.value = html || "";
5837 else if (editor) editor.innerHTML = html || "";
5838 setField(root, "content", html || "");
5839 bindEditorHeight(root);
5840 scheduleEditorHeight(root);
5841 window.setTimeout(() => {
5842 normalizeEditorMarkers(root);
5843 const surface = editorSurface(root);
5844 normalizeBootstrapComponents(surface);
5845 repairInlineVideoPlayers(root, surface);
5846 normalizeInlineMediaLayout(surface);
5847 normalizeModPlaceholders(surface);
5848 if (surface) cleanEditorRuntimeNodes(surface);
5849 const repairedHtml = surface ? surface.innerHTML : getEditorHtml(root);
5850 const current = getEditorInstance(root);
5851 if (current) current.value = repairedHtml || "";
5852 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 || "")) {
5853 setField(root, "content", html || "");
5855 setField(root, "content", repairedHtml || "");
5857 bindEditorMarkerEventsRetry(root);
5858 scheduleEditorHeight(root);
5862 function getEditorHtml(root) {
5863 const instance = getEditorInstance(root);
5864 const surface = editorSurface(root);
5866 normalizeBootstrapComponents(surface);
5867 normalizeInlineMediaLayout(surface);
5868 cleanEditorRuntimeNodes(surface);
5869 const html = surface.innerHTML || "";
5870 if (instance) instance.value = html;
5871 setField(root, "content", html);
5874 if (instance) return instance.value || "";
5875 const editor = qs(root, "[data-cms-editor]");
5876 return editor ? editor.innerHTML : getField(root, "content");
5879 function editorSurface(root) {
5880 const instance = getEditorInstance(root);
5881 if (instance && instance.editor) return instance.editor;
5882 return qs(root, ".jodit-wysiwyg") || qs(root, "[data-cms-editor]");
5885 function nodeElement(node) {
5886 if (!node) return null;
5887 return node.nodeType === 1 ? node : node.parentElement;
5890 function rangeInsideSurface(surface, range) {
5891 if (!surface || !range) return false;
5892 const start = nodeElement(range.startContainer);
5893 const end = nodeElement(range.endContainer);
5894 return !!(start && end && surface.contains(start) && surface.contains(end));
5897 function nodeHasEditorContent(node) {
5898 if (!node) return false;
5899 if (node.nodeType === 3) {
5900 return String(node.nodeValue || "").replace(/\uFEFF/g, "").replace(/\u00a0/g, " ").trim() !== "";
5902 if (node.nodeType !== 1 && node.nodeType !== 11) return false;
5903 if (node.nodeType === 1) {
5904 const tag = node.tagName || "";
5905 if (tag === "BR") return false;
5906 if (/^(IMG|VIDEO|AUDIO|IFRAME|OBJECT|EMBED|TABLE|HR)$/i.test(tag)) return true;
5908 return Array.from(node.childNodes || []).some(nodeHasEditorContent);
5911 function setEditorCaretAfterNode(root, node) {
5912 const surface = editorSurface(root);
5913 if (!surface || !node) return false;
5914 const doc = surface.ownerDocument || document;
5915 const sel = doc.getSelection ? doc.getSelection() : null;
5916 if (!sel) return false;
5917 const range = doc.createRange();
5918 const next = node.nextSibling;
5919 if (next && next.nodeType === 1 && /^(P|DIV|H1|H2|H3|H4|H5|H6|BLOCKQUOTE|LI)$/i.test(next.tagName || "")) {
5920 range.setStart(next, 0);
5922 range.setStartAfter(node);
5924 range.collapse(true);
5925 sel.removeAllRanges();
5926 sel.addRange(range);
5927 state(root).editorRange = range.cloneRange();
5931 function cleanEditorRuntimeNodes(container) {
5932 normalizeCommentMarkers(container);
5933 syncInlineVideoBlockSizes(container);
5934 qsa(container, "[data-jodit-temp], [data-jodit-selection_marker], span[id^='jodit-selection_marker_']").forEach(el => el.remove());
5935 qsa(container, "[data-cms-inline-video-options-open]").forEach(el => el.remove());
5936 qsa(container, ".dbx-cms-inline-media video, .dbx-cms-inline-media iframe, .dbx-cms-inline-media source").forEach(media => {
5937 const src = String(media.getAttribute("src") || "");
5938 const poster = String(media.getAttribute("poster") || "");
5939 if (/^data:/i.test(src) || /^blob:/i.test(src)) media.removeAttribute("src");
5940 if (/^data:/i.test(poster) || /^blob:/i.test(poster)) media.removeAttribute("poster");
5941 if (!media.getAttribute("data-cms-media-slot")) media.setAttribute("data-cms-media-slot", "inline");
5942 if (/^video$/i.test(media.tagName || "")) {
5943 media.setAttribute("controls", "controls");
5944 media.setAttribute("preload", "none");
5945 media.setAttribute("playsinline", "playsinline");
5948 qsa(container, ".dbx-cms-inline-media, figure.dbx-cms-inline-video-block").forEach(wrapper => {
5949 if (!wrapper.getAttribute("data-cms-media-slot")) wrapper.setAttribute("data-cms-media-slot", "inline");
5950 if (wrapper.classList.contains("dbx-cms-inline-video-block")) wrapper.removeAttribute("contenteditable");
5951 if (wrapper.getAttribute("data-cms-media-id") && !inlineMediaWrapperHasContent(wrapper)) wrapper.remove();
5952 const inlineImage = qs(wrapper, "img");
5953 if (inlineImage && !wrapper.classList.contains("dbx-cms-inline-video-block")) {
5954 const srcMatch = String(inlineImage.getAttribute("src") || "").match(/dbx_mid=([0-9]+)/i);
5955 if (srcMatch && Number(srcMatch[1] || 0) > 0) {
5956 inlineImage.setAttribute("data-cms-media-id", String(Number(srcMatch[1])));
5958 wrapper.removeAttribute("data-cms-media-id");
5961 normalizeInlineMissingMedia(container);
5964 function inlineMediaWrapperHasContent(wrap) {
5965 if (!wrap) return false;
5966 return !!qs(wrap, "img, video, iframe, source, .dbx-cms-inline-video-thumb, .dbx-cms-inline-video-empty, .dbx-cms-inline-media-missing");
5969 function inlineMissingMediaWrap(doc, id, label) {
5970 const p = doc.createElement("p");
5971 p.className = "dbx-cms-inline-media dbx-cms-inline-media-missing-wrap";
5972 if (id > 0) p.setAttribute("data-cms-media-id", String(id));
5973 p.setAttribute("data-cms-media-slot", "inline");
5974 p.setAttribute("contenteditable", "false");
5975 p.setAttribute("tabindex", "0");
5976 p.setAttribute("title", "Fehlende Mediendatei auswaehlen, Entf zum Loeschen");
5977 const span = doc.createElement("span");
5978 span.className = "dbx-cms-inline-media-missing";
5979 span.setAttribute("aria-hidden", "true");
5980 span.textContent = label || ("Mediendatei #" + id + " nicht verfuegbar");
5981 p.appendChild(span);
5985 function normalizeInlineMissingMedia(container) {
5986 if (!container) return;
5987 const doc = container.ownerDocument || document;
5988 qsa(container, ".dbx-cms-inline-media-missing-wrap").forEach(wrap => {
5989 wrap.setAttribute("contenteditable", "false");
5990 wrap.classList.add("dbx-cms-inline-media");
5991 wrap.classList.remove("dbx-cms-mod-placeholder");
5992 const id = Number(wrap.getAttribute("data-cms-media-id") || 0);
5993 if (id > 0 && !wrap.getAttribute("data-cms-media-slot")) wrap.setAttribute("data-cms-media-slot", "inline");
5995 qsa(container, ".dbx-cms-inline-media-missing").forEach(span => {
5996 if (closestElement(span, ".dbx-cms-inline-media-missing-wrap")) return;
5997 const id = Number(span.getAttribute("data-cms-media-id") || span.parentElement?.getAttribute("data-cms-media-id") || 0);
5998 const label = String(span.textContent || "").trim() || ("Mediendatei #" + id + " nicht verfuegbar");
5999 const host = span.parentElement;
6000 const wrap = inlineMissingMediaWrap(doc, id, label);
6001 if (host && (host.classList.contains("dbx-cms-mod-placeholder") || host.classList.contains("dbx-cms-inline-media"))) {
6002 host.replaceWith(wrap);
6004 span.replaceWith(wrap);
6009 function inlineVideoMediaSize(media) {
6010 if (!media) return { width: "", height: "" };
6011 let width = media.style && media.style.width ? media.style.width : "";
6012 let height = media.style && media.style.height ? media.style.height : "";
6013 if (!width) width = media.getAttribute("width") || media.getAttribute("data-cms-video-width") || "";
6014 if (!height) height = media.getAttribute("height") || media.getAttribute("data-cms-video-height") || "";
6016 width: cssSizeValue(width),
6017 height: cssSizeValue(height)
6021 function persistInlineVideoRenderedSize(wrapper) {
6022 if (!wrapper || !wrapper.getBoundingClientRect) return false;
6023 const rect = wrapper.getBoundingClientRect();
6024 if (!rect || rect.width < 32 || rect.height < 24) return false;
6025 const width = Math.round(rect.width) + "px";
6026 const height = Math.round(rect.height) + "px";
6027 const beforeWidth = cssSizeValue(wrapper.getAttribute("data-cms-video-width") || wrapper.style.width || "");
6028 const beforeHeight = cssSizeValue(wrapper.getAttribute("data-cms-video-height") || wrapper.style.height || "");
6029 const changed = beforeWidth !== width || beforeHeight !== height;
6030 wrapper.style.width = width;
6031 wrapper.style.height = height;
6032 wrapper.setAttribute("data-cms-video-width", width);
6033 wrapper.setAttribute("data-cms-video-height", height);
6034 const options = inlineVideoOptionsFromElement(wrapper);
6035 options.width = width;
6036 options.height = height;
6037 syncInlineVideoOptionsToMedia(wrapper, options);
6041 function beginInlineVideoResizeTrack(root, wrapper) {
6042 if (!root || !wrapper) return;
6043 const rect = wrapper.getBoundingClientRect ? wrapper.getBoundingClientRect() : null;
6044 state(root).inlineVideoResizeTrack = {
6046 width: rect ? Math.round(rect.width) : 0,
6047 height: rect ? Math.round(rect.height) : 0
6051 function finishInlineVideoResizeTrack(root) {
6052 const s = state(root);
6053 const track = s.inlineVideoResizeTrack;
6054 s.inlineVideoResizeTrack = null;
6055 if (!track || !track.wrapper || !track.wrapper.isConnected) return false;
6056 const rect = track.wrapper.getBoundingClientRect ? track.wrapper.getBoundingClientRect() : null;
6057 if (!rect) return false;
6058 const width = Math.round(rect.width);
6059 const height = Math.round(rect.height);
6060 if (Math.abs(width - track.width) < 2 && Math.abs(height - track.height) < 2) return false;
6061 if (!persistInlineVideoRenderedSize(track.wrapper)) return false;
6062 syncEditorDom(root);
6066 function syncInlineVideoBlockSizes(container) {
6067 if (!container) return;
6068 qsa(container, ".dbx-cms-inline-video-block").forEach(wrapper => {
6069 if (!qs(wrapper, "[data-cms-inline-video-options-open]")) {
6070 wrapper.insertAdjacentHTML("beforeend", inlineVideoOptionsButtonHtml());
6072 const media = qs(wrapper, ".dbx-cms-inline-video-thumb, img, video, iframe");
6073 const size = inlineVideoMediaSize(media);
6074 let width = size.width || cssSizeValue(wrapper.style.width || wrapper.getAttribute("data-cms-video-width") || "");
6075 let height = size.height || cssSizeValue(wrapper.style.height || wrapper.getAttribute("data-cms-video-height") || "");
6076 const rect = wrapper.getBoundingClientRect ? wrapper.getBoundingClientRect() : null;
6077 const defaultWidth = Math.min((container.getBoundingClientRect ? container.getBoundingClientRect().width : 720) || 720, 720);
6078 if (!width && rect && rect.width > 0 && Math.abs(rect.width - defaultWidth) > 2) width = Math.round(rect.width) + "px";
6079 if (!height && rect && rect.height > 0) {
6080 const ratioHeight = rect.width > 0 ? rect.width * 9 / 16 : 0;
6081 if (!ratioHeight || Math.abs(rect.height - ratioHeight) > 2) height = Math.round(rect.height) + "px";
6084 wrapper.style.width = width;
6085 wrapper.setAttribute("data-cms-video-width", width);
6087 if (height && height !== "auto") {
6088 wrapper.style.height = height;
6089 wrapper.setAttribute("data-cms-video-height", height);
6092 media.removeAttribute("width");
6093 media.removeAttribute("height");
6095 media.style.width = "";
6096 media.style.height = "";
6102 function plainMarkerName(text) {
6103 const match = String(text || "").trim().match(/^dbx:(split|col2|col3a|col3b|header|teaser|footer|pagebreak)$/i);
6104 if (!match) return "";
6105 return match[1].toLowerCase() === "split" ? "col2" : match[1].toLowerCase();
6108 function normalizePlainTextMarkers(container) {
6109 if (!container) return;
6110 normalizeCommentMarkers(container);
6111 const doc = container.ownerDocument || document;
6112 qsa(container, "p,div").forEach(el => {
6113 if (el.querySelector(".dbx-cms-marker,[data-dbx-marker],img,video,iframe,table,hr,ul,ol")) return;
6114 const name = plainMarkerName(el.textContent);
6116 const hr = cmsMarkerElement("dbx:" + name, null, doc);
6117 if (hr) el.replaceWith(hr);
6119 Array.from(container.childNodes || []).forEach(node => {
6120 if (node.nodeType !== 3) return;
6121 const name = plainMarkerName(node.nodeValue);
6123 const hr = cmsMarkerElement("dbx:" + name, null, doc);
6124 if (hr) node.replaceWith(hr);
6128 function normalizeInlineMediaLayout(container) {
6129 if (!container) return;
6130 qsa(container, ".dbx-cms-inline-media img, .dbx-cms-inline-media video, .dbx-cms-inline-media iframe").forEach(media => {
6131 const wrapper = closestElement(media, ".dbx-cms-inline-media");
6132 if (!wrapper) return;
6133 const floatValue = String(media.style.float || "").toLowerCase();
6134 if (floatValue !== "left" && floatValue !== "right") return;
6136 wrapper.style.float = floatValue;
6137 wrapper.style.marginLeft = media.style.marginLeft || "";
6138 wrapper.style.marginRight = media.style.marginRight || "";
6139 media.style.float = "";
6140 media.style.marginLeft = "";
6141 media.style.marginRight = "";
6142 media.style.display = "";
6146 function topEditorChild(surface, range) {
6147 if (!surface || !range) return null;
6148 if (range.startContainer === surface) {
6149 const child = surface.childNodes[Math.min(range.startOffset, Math.max(surface.childNodes.length - 1, 0))];
6150 return child && child.nodeType === 1 ? child : null;
6152 let el = nodeElement(range.startContainer);
6153 if (!el || el === surface || !surface.contains(el)) return null;
6154 while (el.parentElement && el.parentElement !== surface) el = el.parentElement;
6155 return el.parentElement === surface ? el : null;
6158 function canSplitForMarker(block) {
6159 if (!block || block.nodeType !== 1) return false;
6160 return /^(P|DIV|H1|H2|H3|H4|H5|H6|BLOCKQUOTE|UL|OL|LI)$/i.test(block.tagName || "")
6161 && !/^(TABLE|FIGURE)$/i.test(block.tagName || "");
6164 function insertFragmentAfter(parent, fragment, afterNode) {
6165 let last = afterNode;
6166 Array.from(fragment.childNodes || []).forEach(node => {
6167 parent.insertBefore(node, last ? last.nextSibling : null);
6173 function insertEditorHrNode(root, hrNode) {
6174 const instance = getEditorInstance(root);
6175 if (instance && typeof instance.setMode === "function") {
6176 instance.setMode(window.Jodit && window.Jodit.MODE_WYSIWYG ? window.Jodit.MODE_WYSIWYG : 1);
6179 restoreEditorSelection(root);
6181 const surface = editorSurface(root);
6182 if (!surface) return false;
6183 const doc = surface.ownerDocument || document;
6184 const sel = doc.getSelection ? doc.getSelection() : null;
6185 let range = sel && sel.rangeCount ? sel.getRangeAt(0).cloneRange() : state(root).editorRange;
6186 if (!hrNode) return false;
6188 if (!rangeInsideSurface(surface, range)) {
6189 surface.appendChild(hrNode);
6190 setEditorCaretAfterNode(root, hrNode);
6191 normalizeEditorMarkers(root);
6192 bindEditorMarkerEventsRetry(root);
6193 syncEditorDom(root);
6197 if (!range.collapsed) {
6198 range.deleteContents();
6200 sel.removeAllRanges();
6201 sel.addRange(range);
6205 const block = topEditorChild(surface, range);
6206 if (canSplitForMarker(block) && block.contains(nodeElement(range.startContainer))) {
6207 const parent = block.parentElement;
6208 const suffixRange = range.cloneRange();
6209 suffixRange.setEndAfter(block);
6210 const suffix = suffixRange.extractContents();
6211 const blockHasContent = nodeHasEditorContent(block);
6213 if (blockHasContent) {
6214 parent.insertBefore(hrNode, block.nextSibling);
6216 parent.insertBefore(hrNode, block);
6220 if (nodeHasEditorContent(suffix)) {
6221 insertFragmentAfter(parent, suffix, hrNode);
6223 } else if (range.startContainer === surface) {
6224 surface.insertBefore(hrNode, surface.childNodes[range.startOffset] || null);
6225 } else if (block && block.parentElement === surface) {
6226 surface.insertBefore(hrNode, block.nextSibling);
6228 range.insertNode(hrNode);
6231 normalizeEditorMarkers(root);
6232 bindEditorMarkerEventsRetry(root);
6233 syncEditorDom(root);
6234 setEditorCaretAfterNode(root, hrNode);
6238 function insertEditorPlainHr(root) {
6239 const surface = editorSurface(root);
6240 const doc = surface ? (surface.ownerDocument || document) : document;
6241 return insertEditorHrNode(root, doc.createElement("hr"));
6244 function insertEditorMarkerElement(root, marker, label) {
6245 const surface = editorSurface(root);
6246 const doc = surface ? (surface.ownerDocument || document) : document;
6247 return insertEditorHrNode(root, cmsMarkerElement(marker, label, doc));
6250 function saveEditorSelection(root) {
6251 const surface = editorSurface(root);
6252 if (!surface) return false;
6253 if (state(root).selectedMarker || state(root).selectedMissingMedia) {
6254 hideEditorCaretHint(root);
6257 const doc = surface.ownerDocument || document;
6258 const sel = doc.getSelection ? doc.getSelection() : null;
6259 if (!sel || !sel.rangeCount) {
6260 hideEditorCaretHint(root);
6263 const range = sel.getRangeAt(0);
6264 const common = range.commonAncestorContainer;
6265 const commonEl = nodeElement(common);
6266 if (common !== surface && (!commonEl || !surface.contains(commonEl))) {
6267 hideEditorCaretHint(root);
6270 state(root).editorRange = range.cloneRange();
6271 refreshEditorCaretHint(root);
6275 function refreshEditorCaretHint(root) {
6276 const surface = editorSurface(root);
6277 if (!surface || state(root).selectedMarker || state(root).selectedMissingMedia) {
6278 hideEditorCaretHint(root);
6281 const doc = surface.ownerDocument || document;
6282 const sel = doc.getSelection ? doc.getSelection() : null;
6283 if (!sel || !sel.rangeCount) {
6284 hideEditorCaretHint(root);
6287 const range = sel.getRangeAt(0);
6288 if (!range.collapsed) {
6289 hideEditorCaretHint(root);
6292 showEditorCaretHint(root, range);
6295 function hideEditorCaretHint(root) {
6296 const s = state(root);
6297 if (s.editorCaretHintTimer) {
6298 window.clearTimeout(s.editorCaretHintTimer);
6299 s.editorCaretHintTimer = null;
6301 const hint = qs(root, "[data-cms-editor-caret-hint]");
6302 if (hint) hint.hidden = true;
6305 function editorCaretBlock(surface, range) {
6306 if (!surface || !range) return null;
6307 let el = nodeElement(range.startContainer);
6308 if (!el || el === surface) {
6309 const child = surface.childNodes[Math.max(0, Math.min(range.startOffset || 0, surface.childNodes.length - 1))];
6310 el = nodeElement(child) || surface;
6312 while (el && el !== surface) {
6313 if (/^(P|DIV|H1|H2|H3|H4|H5|H6|BLOCKQUOTE|LI|TD|TH)$/i.test(el.tagName || "")) return el;
6314 el = el.parentElement;
6319 function editorCaretBlockRect(surface, range) {
6320 const block = editorCaretBlock(surface, range);
6321 if (!block || !block.getBoundingClientRect) return null;
6322 const blockRect = block.getBoundingClientRect();
6323 if (!blockRect || !blockRect.width || !blockRect.height) return null;
6324 const style = window.getComputedStyle ? window.getComputedStyle(block) : null;
6325 const paddingLeft = Number.parseFloat(style?.paddingLeft || "0") || 0;
6326 const paddingTop = Number.parseFloat(style?.paddingTop || "0") || 0;
6327 let lineHeight = Number.parseFloat(style?.lineHeight || "0") || 0;
6329 const fontSize = Number.parseFloat(style?.fontSize || "16") || 16;
6330 lineHeight = fontSize * 1.55;
6333 left: blockRect.left + paddingLeft,
6334 top: blockRect.top + paddingTop,
6335 height: Math.max(18, Math.min(blockRect.height, lineHeight || 24)),
6340 function caretRectLooksValid(rect, surface, range) {
6341 if (!rect || !rect.height) return false;
6342 const surfaceRect = surface.getBoundingClientRect ? surface.getBoundingClientRect() : null;
6343 if (surfaceRect && (rect.top < surfaceRect.top - 4 || rect.top > surfaceRect.bottom + 4)) return false;
6344 const block = editorCaretBlock(surface, range);
6345 if (!block || !block.getBoundingClientRect) return true;
6346 const blockRect = block.getBoundingClientRect();
6347 return rect.top >= blockRect.top - 4 && rect.top <= blockRect.bottom + 4;
6350 function editorCaretLineHeight(surface, range) {
6351 const block = editorCaretBlock(surface, range);
6352 if (!block) return 18;
6353 const style = window.getComputedStyle ? window.getComputedStyle(block) : null;
6354 let lineHeight = Number.parseFloat(style?.lineHeight || "0") || 0;
6355 if (!lineHeight || style?.lineHeight === "normal") {
6356 lineHeight = (Number.parseFloat(style?.fontSize || "16") || 16) * 1.45;
6358 return Math.max(14, Math.min(lineHeight, 22));
6361 function showEditorCaretHint(root, range) {
6362 const surface = editorSurface(root);
6363 if (!surface || !range || !range.collapsed) {
6364 hideEditorCaretHint(root);
6369 const rects = range.getClientRects ? Array.from(range.getClientRects()) : [];
6370 rect = rects.find(item => item && item.height > 0 && item.height <= 36) || null;
6371 if (!rect && range.startContainer && range.startContainer.nodeType === 3) {
6372 const probe = range.cloneRange();
6373 const text = range.startContainer.nodeValue || "";
6374 if (range.startOffset < text.length) probe.setEnd(range.startContainer, range.startOffset + 1);
6375 else if (range.startOffset > 0) probe.setStart(range.startContainer, range.startOffset - 1);
6376 rect = Array.from(probe.getClientRects ? probe.getClientRects() : []).find(item => item && item.height > 0 && item.height <= 36) || null;
6378 if (!caretRectLooksValid(rect, surface, range)) {
6379 hideEditorCaretHint(root);
6383 const lineHeight = editorCaretLineHeight(surface, range);
6384 let hint = qs(root, "[data-cms-editor-caret-hint]");
6386 hint = document.createElement("span");
6387 hint.className = "dbx-cms-editor-caret-hint";
6388 hint.setAttribute("data-cms-editor-caret-hint", "1");
6389 hint.setAttribute("aria-hidden", "true");
6390 root.appendChild(hint);
6393 hint.hidden = false;
6394 hint.style.left = Math.round(rect.left) + "px";
6395 hint.style.top = Math.round(rect.top + Math.max(0, (rect.height - lineHeight) / 2)) + "px";
6396 hint.style.height = Math.round(lineHeight) + "px";
6398 const s = state(root);
6399 if (s.editorCaretHintTimer) {
6400 window.clearTimeout(s.editorCaretHintTimer);
6401 s.editorCaretHintTimer = null;
6405 function restoreEditorSelection(root) {
6406 const surface = editorSurface(root);
6407 if (!surface) return false;
6408 const doc = surface.ownerDocument || document;
6409 const range = state(root).editorRange;
6410 if (!range || !range.startContainer || !range.endContainer) {
6411 if (surface.focus) surface.focus();
6415 const start = nodeElement(range.startContainer);
6416 const end = nodeElement(range.endContainer);
6417 if (!start || !end || !surface.contains(start) || !surface.contains(end)) {
6418 state(root).editorRange = null;
6419 if (surface.focus) surface.focus();
6423 const sel = doc.getSelection ? doc.getSelection() : null;
6424 if (!sel) return false;
6425 if (surface.focus) surface.focus({ preventScroll: true });
6426 sel.removeAllRanges();
6427 sel.addRange(range);
6431 function pushEditorHtml(root) {
6432 const surface = editorSurface(root);
6433 const html = surface ? (surface.innerHTML || "") : getEditorHtml(root);
6434 const instance = getEditorInstance(root);
6435 if (instance && instance.value !== html) instance.value = html;
6436 setField(root, "content", html);
6438 scheduleEditorHeight(root);
6441 function hoistEditorMarkersToSurface(surface) {
6442 if (!surface) return;
6443 qsa(surface, ".dbx-cms-marker,[data-dbx-marker]").forEach(marker => {
6444 if (marker.parentElement === surface) return;
6445 let block = marker.parentElement;
6446 while (block && block.parentElement && block.parentElement !== surface) {
6447 block = block.parentElement;
6449 if (block && block.parentElement === surface) {
6450 surface.insertBefore(marker, block.nextSibling);
6452 surface.appendChild(marker);
6457 function surfaceEditorBlocks(surface, ignoreMarker) {
6458 if (!surface) return [];
6459 return Array.from(surface.children).filter(el => {
6460 return el.nodeType === 1 && el !== ignoreMarker;
6464 function markerSurfacePlacement(surface, x, y, ignoreMarker) {
6465 if (!surface) return null;
6466 const blocks = surfaceEditorBlocks(surface, ignoreMarker);
6467 if (!blocks.length) {
6468 return { ref: null, before: true, target: null };
6471 for (let i = 0; i < blocks.length; i++) {
6472 const block = blocks[i];
6473 const rect = block.getBoundingClientRect ? block.getBoundingClientRect() : null;
6474 if (!rect || !rect.height) continue;
6475 if (y < rect.top + rect.height / 2) {
6476 return { ref: block, before: true, target: block };
6480 const last = blocks[blocks.length - 1];
6481 return { ref: last, before: false, target: last };
6484 function syncEditorDom(root, options) {
6485 options = options || {};
6486 const surface = editorSurface(root);
6487 normalizeEditorMarkers(root);
6488 repairInlineVideoPlayers(root, surface);
6489 normalizeInlineMediaLayout(surface);
6490 if (surface) cleanEditorRuntimeNodes(surface);
6491 const html = surface ? surface.innerHTML : getEditorHtml(root);
6492 const instance = getEditorInstance(root);
6493 if (instance) instance.value = html || "";
6494 setField(root, "content", html || "");
6495 if (!options.silent) markDirty(root);
6496 scheduleEditorHeight(root);
6499 function saveCurrentCms(root, cfg) {
6500 if (root.classList.contains("is-folder-editing")) return saveFolder(root, cfg || {});
6501 syncEditorDom(root, { silent: true });
6502 return savePage(root, cfg || {});
6505 function duplicateCurrentPage(root, cfg) {
6506 const s = state(root);
6507 const id = Number(getField(root, "id") || 0);
6508 const url = cfgUrl(cfg, "duplicatepage");
6509 if (root.classList.contains("is-folder-editing") || s.selectedType !== "page" || !id || !url) {
6510 status(root, "Bitte zuerst eine Seite auswaehlen.", "error");
6511 return Promise.resolve();
6514 status(root, "Bitte ungespeicherte Aenderungen vor dem Duplizieren speichern.", "warning");
6515 return Promise.resolve();
6517 if (s.saving || s.duplicating) return Promise.resolve();
6519 s.duplicating = true;
6520 updateHeaderActionTooltips(root);
6521 status(root, "Seite wird dupliziert...", "info");
6523 return fetchJson(apiUrl(url, cmsLngParams(root)), {
6525 headers: { "Content-Type": "application/json" },
6526 body: JSON.stringify({ id })
6528 if (!data || !data.ok || !data.id) {
6529 throw new Error(data && data.msg ? data.msg : "duplicate failed");
6531 return loadTree(root, cfg)
6532 .then(() => loadPage(root, cfg, data.id))
6534 status(root, data.msg || "Seite wurde dupliziert.", "success");
6535 maybeOpenLngProvisionAfterCreate(root, cfg, data);
6539 dbx.error("[cms] duplicate page failed", err);
6540 status(root, err && err.message ? err.message : "Seite konnte nicht dupliziert werden.", "error");
6542 s.duplicating = false;
6543 updateHeaderActionTooltips(root);
6547 function deleteCurrentCms(root, cfg) {
6548 if (root.classList.contains("is-folder-editing")) return deleteFolder(root, cfg || {});
6549 return deletePage(root, cfg || {});
6552 function bindCmsKeyboardShortcuts(root, cfg) {
6553 if (!root || root.__dbxCmsKeyboardShortcutsBound) return;
6554 root.__dbxCmsKeyboardShortcutsBound = true;
6555 root.addEventListener("keydown", e => {
6556 const key = String(e.key || "").toLowerCase();
6557 if (!(e.ctrlKey || e.metaKey) || key !== "s") return;
6558 if (!root.contains(e.target)) return;
6560 e.stopPropagation();
6561 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
6562 saveCurrentCms(root, cfg || {});
6566 function cmsConfig(root) {
6567 if (!root || !root.getAttribute || !dbx.parseData) return {};
6568 const cfgList = dbx.parseData(root.getAttribute("data-dbx"));
6569 return cfgList.find(item => item.lib === LIB) || {};
6572 function editorDropTarget(root, target) {
6573 const surface = editorSurface(root);
6574 if (!surface) return null;
6575 let el = target && target.nodeType === 1 ? target : target?.parentElement;
6576 if (!el || !surface.contains(el)) return surface;
6577 const selector = ".dbx-cms-marker,p,h1,h2,h3,h4,ul,ol,blockquote,figure,table,img,hr";
6578 const block = el.closest ? el.closest(selector) : null;
6579 return block && surface.contains(block) ? block : surface;
6582 function clearEditorDropMarks(root) {
6583 const surface = editorSurface(root);
6584 if (!surface) return;
6585 qsa(surface, ".is-drop-before,.is-drop-after").forEach(el => el.classList.remove("is-drop-before", "is-drop-after"));
6588 function ensureLeadingEditorParagraph(surface) {
6589 if (!surface) return null;
6590 const first = surface.firstElementChild;
6591 if (!first || !first.classList.contains("dbx-cms-marker")) return null;
6592 const doc = surface.ownerDocument || document;
6593 const p = doc.createElement("p");
6594 p.innerHTML = "<br>";
6595 surface.insertBefore(p, first);
6599 function setEditorCaretInElement(root, el, offset) {
6600 const surface = editorSurface(root);
6601 if (!surface || !el) return false;
6602 const doc = surface.ownerDocument || document;
6603 const sel = doc.getSelection ? doc.getSelection() : null;
6604 if (!sel) return false;
6605 const range = doc.createRange();
6606 offset = Number(offset || 0);
6607 if (el.firstChild) {
6608 range.setStart(el.firstChild, Math.min(offset, el.firstChild.length || 0));
6610 range.setStart(el, 0);
6612 range.collapse(true);
6613 if (surface.focus) surface.focus({ preventScroll: true });
6614 sel.removeAllRanges();
6615 sel.addRange(range);
6616 state(root).editorRange = range.cloneRange();
6617 refreshEditorCaretHint(root);
6621 function focusEditorAboveMarker(root, marker) {
6622 const surface = editorSurface(root);
6623 if (!surface || !marker) return false;
6624 let lead = marker.previousElementSibling;
6625 if (!lead || !/^(P|DIV|H[1-6]|BLOCKQUOTE|UL|OL)$/i.test(lead.tagName)) {
6626 if (marker === surface.firstElementChild) {
6627 lead = ensureLeadingEditorParagraph(surface);
6630 const doc = surface.ownerDocument || document;
6631 lead = doc.createElement("p");
6632 lead.innerHTML = "<br>";
6633 surface.insertBefore(lead, marker);
6636 if (!lead) return false;
6637 selectEditorMarker(root, null);
6638 return setEditorCaretInElement(root, lead, 0);
6641 function createMarkerDragGhost(marker, rect) {
6642 const doc = marker.ownerDocument || document;
6643 const ghost = doc.createElement("div");
6644 ghost.className = marker.className;
6645 ghost.classList.remove("is-selected", "is-hovered", "is-dragging", "is-dragging-source");
6646 ghost.classList.add("dbx-cms-marker-drag-ghost");
6647 ghost.setAttribute("data-label", marker.getAttribute("data-label") || "");
6648 ghost.setAttribute("aria-hidden", "true");
6649 ghost.style.position = "fixed";
6650 ghost.style.zIndex = "2147483647";
6651 ghost.style.pointerEvents = "none";
6652 ghost.style.width = Math.max(rect.width, 120) + "px";
6653 ghost.style.height = Math.max(rect.height, 34) + "px";
6654 ghost.style.left = rect.left + "px";
6655 ghost.style.top = rect.top + "px";
6656 (doc.body || document.body).appendChild(ghost);
6660 function updateMarkerDragGhost(drag, clientX, clientY) {
6661 if (!drag || !drag.ghost) return;
6662 drag.ghost.style.left = (clientX - drag.ghostOffsetX) + "px";
6663 drag.ghost.style.top = (clientY - drag.ghostOffsetY) + "px";
6666 function removeMarkerDragGhost(ghost) {
6667 if (ghost && ghost.parentNode) ghost.parentNode.removeChild(ghost);
6670 function clearMarkerPointerDrag(root) {
6671 const drag = state(root).pointerDragMarker;
6673 removeMarkerDragGhost(drag.ghost);
6674 if (drag.marker) drag.marker.classList.remove("is-dragging", "is-dragging-source");
6675 clearEditorDropMarks(root);
6676 state(root).pointerDragMarker = null;
6679 function normalizeEditorMarkers(root) {
6680 const surface = editorSurface(root);
6681 if (!surface) return;
6682 const selectedName = markerNameFromElement(state(root).selectedMarker);
6683 normalizeCommentMarkers(surface);
6684 normalizePlainTextMarkers(surface);
6685 qsa(surface, ".dbx-cms-marker,[data-dbx-marker]").forEach(marker => {
6686 const raw = marker.getAttribute("data-dbx-marker") || marker.getAttribute("data-dbx-marker-comment") || "";
6687 const name = cmsMarkerName(raw);
6688 if (marker.tagName !== "HR") {
6689 const hr = document.createElement("hr");
6690 Array.from(marker.attributes || []).forEach(attr => {
6691 if (attr.name === "data-dbx-marker-comment") return;
6692 hr.setAttribute(attr.name, attr.value);
6694 marker.replaceWith(hr);
6697 marker.classList.add("dbx-cms-marker", "dbx-cms-marker-" + cmsMarkerClassName(name));
6698 marker.setAttribute("data-dbx-marker", "dbx:" + name);
6699 if (!marker.getAttribute("data-label")) marker.setAttribute("data-label", cmsMarkerLabel("dbx:" + name));
6700 marker.setAttribute("contenteditable", "false");
6701 marker.setAttribute("draggable", "false");
6702 marker.setAttribute("tabindex", "0");
6703 marker.setAttribute("role", "button");
6704 marker.setAttribute("title", "Marker auswaehlen, ziehen zum Verschieben, Entf zum Loeschen");
6706 hoistEditorMarkersToSurface(surface);
6707 dedupeAdjacentMarkers(surface);
6708 ensureLeadingEditorParagraph(surface);
6710 const restored = qsa(surface, ".dbx-cms-marker").find(item => markerNameFromElement(item) === selectedName);
6711 if (restored) selectEditorMarker(root, restored);
6715 function clearEditorMarkerHover(surface, except) {
6716 if (!surface) return;
6717 qsa(surface, ".dbx-cms-marker.is-hovered").forEach(el => {
6718 if (el !== except) el.classList.remove("is-hovered");
6722 function selectEditorMarker(root, marker) {
6723 const surface = editorSurface(root);
6724 selectEditorMissingMedia(root, null);
6725 qsa(surface, ".dbx-cms-marker.is-selected").forEach(el => {
6726 if (el !== marker) {
6727 el.classList.remove("is-selected");
6728 el.removeAttribute("aria-selected");
6731 const s = state(root);
6732 if (marker && surface && surface.contains(marker)) {
6733 marker.classList.add("is-selected");
6734 marker.setAttribute("aria-selected", "true");
6735 s.selectedMarker = marker;
6736 clearEditorMarkerHover(surface, marker);
6737 hideEditorCaretHint(root);
6738 const doc = surface.ownerDocument || document;
6739 const sel = doc.getSelection ? doc.getSelection() : null;
6740 if (sel) sel.removeAllRanges();
6741 if (marker.focus) marker.focus({ preventScroll: true });
6743 s.selectedMarker = null;
6744 qsa(surface, ".dbx-cms-marker[aria-selected]").forEach(el => el.removeAttribute("aria-selected"));
6748 function handleEditorMarkerClick(root, marker, e) {
6749 if (!marker) return false;
6750 selectEditorMarker(root, marker);
6752 if (typeof e.preventDefault === "function") e.preventDefault();
6753 if (typeof e.stopPropagation === "function") e.stopPropagation();
6754 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
6759 function emptyEditorParagraph(el) {
6760 if (!el || el.nodeType !== 1 || el.tagName !== "P") return false;
6761 if (el.querySelector("img,video,table,hr,.dbx-cms-marker")) return false;
6762 return String(el.textContent || "").trim() === "" && !el.querySelector("br");
6765 function removeEditorMarker(root, marker) {
6766 const surface = editorSurface(root);
6767 if (!marker || !surface || !surface.contains(marker)) return false;
6768 const next = marker.nextElementSibling;
6770 if (emptyEditorParagraph(next)) next.remove();
6771 state(root).selectedMarker = null;
6772 clearEditorDropMarks(root);
6773 syncEditorDom(root);
6777 function rangeIntersectsNode(range, node) {
6778 if (!range || !node) return false;
6779 if (range.intersectsNode) {
6781 return range.intersectsNode(node);
6789 function editorRangeFromPoint(surface, x, y) {
6790 const doc = surface ? (surface.ownerDocument || document) : document;
6791 if (!surface || !doc) return null;
6793 if (doc.caretRangeFromPoint) {
6794 range = doc.caretRangeFromPoint(x, y);
6795 } else if (doc.caretPositionFromPoint) {
6796 const pos = doc.caretPositionFromPoint(x, y);
6798 range = doc.createRange();
6799 range.setStart(pos.offsetNode, pos.offset);
6800 range.collapse(true);
6803 if (!range) return null;
6804 const start = nodeElement(range.startContainer);
6805 return start && surface.contains(start) ? range : null;
6808 function setEditorCaretFromPoint(root, x, y) {
6809 const surface = editorSurface(root);
6810 const doc = surface ? (surface.ownerDocument || document) : document;
6811 const range = editorRangeFromPoint(surface, x, y);
6812 const sel = doc && doc.getSelection ? doc.getSelection() : null;
6813 if (!range || !sel) return false;
6814 if (surface.focus) surface.focus({ preventScroll: true });
6815 sel.removeAllRanges();
6816 sel.addRange(range);
6817 state(root).editorRange = range.cloneRange();
6821 function showEditorMarkerDropHint(root, x, y, ignoreMarker) {
6822 const surface = editorSurface(root);
6823 clearEditorDropMarks(root);
6824 if (!surface) return;
6825 const placement = markerSurfacePlacement(surface, x, y, ignoreMarker);
6826 const target = placement && placement.target ? placement.target : null;
6827 if (!target) return;
6828 target.classList.toggle("is-drop-before", !!placement.before);
6829 target.classList.toggle("is-drop-after", !placement.before);
6832 function finishEditorMarkerDrop(root, marker) {
6833 if (!marker) return;
6834 marker.classList.remove("is-dragging", "is-dragging-source");
6835 marker.removeAttribute("data-cms-drag-token");
6836 const drag = state(root).pointerDragMarker;
6837 if (drag && drag.ghost) removeMarkerDragGhost(drag.ghost);
6838 clearEditorDropMarks(root);
6839 state(root).dragMarker = null;
6840 state(root).pointerDragMarker = null;
6841 const surface = editorSurface(root);
6842 hoistEditorMarkersToSurface(surface);
6843 normalizeEditorMarkers(root);
6844 selectEditorMarker(root, marker);
6845 pushEditorHtml(root);
6848 function moveEditorMarkerToPoint(root, marker, x, y) {
6849 const surface = editorSurface(root);
6850 if (!marker || !surface || !surface.contains(marker)) return false;
6851 const placement = markerSurfacePlacement(surface, x, y, marker);
6852 if (!placement) return false;
6854 if (marker.parentElement) marker.parentElement.removeChild(marker);
6856 if (placement.ref && placement.before) {
6857 surface.insertBefore(marker, placement.ref);
6858 } else if (placement.ref) {
6859 surface.insertBefore(marker, placement.ref.nextSibling);
6861 surface.appendChild(marker);
6864 finishEditorMarkerDrop(root, marker);
6868 function editorSelectionRange(root) {
6869 const surface = editorSurface(root);
6870 if (!surface) return null;
6871 const doc = surface.ownerDocument || document;
6872 const sel = doc.getSelection ? doc.getSelection() : null;
6873 if (!sel || !sel.rangeCount) return null;
6874 const range = sel.getRangeAt(0);
6875 return rangeInsideSurface(surface, range) ? range : null;
6878 function editorSelectionText(root) {
6879 const range = editorSelectionRange(root);
6880 return range && !range.collapsed ? String(range.cloneContents().textContent || "") : "";
6883 function selectEditorContents(root) {
6884 const surface = editorSurface(root);
6885 if (!surface) return false;
6886 const doc = surface.ownerDocument || document;
6887 const sel = doc.getSelection ? doc.getSelection() : null;
6888 if (!sel) return false;
6889 const range = doc.createRange();
6890 range.selectNodeContents(surface);
6891 if (surface.focus) surface.focus({ preventScroll: true });
6892 sel.removeAllRanges();
6893 sel.addRange(range);
6894 state(root).editorRange = range.cloneRange();
6898 function editorContextBlock(root, target) {
6899 const surface = editorSurface(root);
6900 if (!surface) return null;
6901 const el = closestBootstrapComponent(root, target) || closestElement(target, ".dbx-cms-marker,figure,table,img,video,hr,p,h1,h2,h3,h4,ul,ol,blockquote");
6902 return el && surface.contains(el) ? el : null;
6905 function closestBootstrapComponent(root, target) {
6906 const surface = editorSurface(root);
6907 if (!surface || !target) return null;
6908 const el = closestElement(target, ".row,.alert,.card,.list-group,.accordion,.table-responsive,.nav-tabs,.tab-content");
6909 if (!el || !surface.contains(el)) return null;
6910 if (el.classList.contains("row") && !qs(el, ".col,.card")) return null;
6914 function contextMissingMediaTarget(root, target) {
6915 const surface = editorSurface(root);
6916 const wrap = closestElement(target, ".dbx-cms-inline-media-missing-wrap");
6917 return wrap && surface && surface.contains(wrap) ? wrap : null;
6920 function selectEditorMissingMedia(root, wrap) {
6921 const surface = editorSurface(root);
6922 qsa(surface, ".dbx-cms-inline-media-missing-wrap.is-selected").forEach(el => {
6923 el.classList.remove("is-selected");
6924 el.removeAttribute("aria-selected");
6926 if (wrap && surface && surface.contains(wrap)) {
6927 selectEditorMarker(root, null);
6928 wrap.classList.add("is-selected");
6929 wrap.setAttribute("aria-selected", "true");
6930 state(root).selectedMissingMedia = wrap;
6931 hideEditorCaretHint(root);
6932 const doc = surface.ownerDocument || document;
6933 const sel = doc.getSelection ? doc.getSelection() : null;
6934 if (sel) sel.removeAllRanges();
6935 if (wrap.focus) wrap.focus({ preventScroll: true });
6937 state(root).selectedMissingMedia = null;
6938 qsa(surface, ".dbx-cms-inline-media-missing-wrap[aria-selected]").forEach(el => el.removeAttribute("aria-selected"));
6942 function removeEditorMissingMedia(root, wrap) {
6943 wrap = wrap || state(root).selectedMissingMedia;
6944 if (!wrap || !wrap.parentNode) return false;
6945 selectEditorMissingMedia(root, null);
6947 syncEditorDom(root);
6952 function handleEditorMissingMediaClick(root, wrap, e) {
6953 if (!wrap) return false;
6954 selectEditorMissingMedia(root, wrap);
6956 if (typeof e.preventDefault === "function") e.preventDefault();
6957 if (typeof e.stopPropagation === "function") e.stopPropagation();
6958 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
6963 function removableEditorContextTarget(root, target) {
6964 const surface = editorSurface(root);
6965 if (!surface) return null;
6966 const component = closestBootstrapComponent(root, target);
6967 if (component && surface.contains(component)) return component;
6968 const mod = inlineModTarget(root, target);
6969 if (mod && surface.contains(mod)) return mod;
6970 const missing = closestElement(target, ".dbx-cms-inline-media-missing-wrap");
6971 if (missing && surface.contains(missing)) return missing;
6972 const el = closestElement(target, ".dbx-cms-marker,figure,table,img,video,hr");
6973 if (!el || !surface.contains(el)) return null;
6974 if ((el.tagName === "IMG" || el.tagName === "VIDEO") && el.parentElement && el.parentElement.tagName === "FIGURE") {
6975 return el.parentElement;
6980 function editorElementSibling(el, dir) {
6981 let node = dir < 0 ? el?.previousSibling : el?.nextSibling;
6982 while (node && node.nodeType !== 1) node = dir < 0 ? node.previousSibling : node.nextSibling;
6983 return node || null;
6986 function moveEditorContextBlock(root, block, dir) {
6987 const surface = editorSurface(root);
6988 if (!block || !surface || !surface.contains(block) || block === surface) return false;
6989 const sibling = editorElementSibling(block, dir);
6990 if (!sibling || !block.parentElement) return false;
6991 if (dir < 0) block.parentElement.insertBefore(block, sibling);
6992 else block.parentElement.insertBefore(sibling, block);
6993 if (block.matches && block.matches(".dbx-cms-marker")) selectEditorMarker(root, block);
6994 syncEditorDom(root);
6998 function closeEditorContextMenu(root) {
6999 const s = state(root);
7000 if (typeof s.editorContextCleanup === "function") s.editorContextCleanup();
7001 if (s.editorContextMenu && s.editorContextMenu.parentNode) s.editorContextMenu.parentNode.removeChild(s.editorContextMenu);
7002 s.editorContextCleanup = null;
7003 s.editorContextMenu = null;
7006 function clipboardWriteText(text) {
7007 if (navigator.clipboard && navigator.clipboard.writeText) {
7008 return navigator.clipboard.writeText(text).catch(() => false);
7010 return Promise.resolve(false);
7013 function clipboardReadText() {
7014 if (navigator.clipboard && navigator.clipboard.readText) {
7015 return navigator.clipboard.readText().catch(() => "");
7017 return Promise.resolve("");
7020 function copyEditorContext(root, target) {
7021 const marker = closestElement(target, ".dbx-cms-marker");
7022 if (marker && editorSurface(root)?.contains(marker)) {
7023 state(root).editorClipboardHtml = marker.outerHTML;
7024 return clipboardWriteText(marker.outerHTML);
7026 restoreEditorSelection(root);
7027 const text = editorSelectionText(root);
7028 const copied = document.execCommand && document.execCommand("copy");
7029 if (!copied && text) return clipboardWriteText(text);
7030 return Promise.resolve(copied);
7033 function cutEditorContext(root, target) {
7034 const marker = closestElement(target, ".dbx-cms-marker");
7035 if (marker && editorSurface(root)?.contains(marker)) {
7036 state(root).editorClipboardHtml = marker.outerHTML;
7037 return clipboardWriteText(marker.outerHTML).finally(() => removeEditorMarker(root, marker));
7039 restoreEditorSelection(root);
7040 const text = editorSelectionText(root);
7041 const cut = document.execCommand && document.execCommand("cut");
7043 syncEditorDom(root);
7044 return Promise.resolve(true);
7047 return clipboardWriteText(text).finally(() => {
7048 execEditorCommand(root, "delete");
7049 syncEditorDom(root);
7052 return Promise.resolve(false);
7055 function pasteEditorContext(root) {
7056 restoreEditorSelection(root);
7057 return clipboardReadText().then(text => {
7059 if (document.execCommand && document.execCommand("insertText", false, text)) {
7060 syncEditorDom(root);
7063 insertEditorHtml(root, escapeHtml(text));
7066 const html = state(root).editorClipboardHtml || "";
7068 insertEditorHtml(root, html);
7071 if (document.execCommand && document.execCommand("paste")) {
7072 syncEditorDom(root);
7079 function deleteEditorContext(root, target) {
7080 const marker = closestElement(target, ".dbx-cms-marker") || state(root).selectedMarker;
7081 if (marker && editorSurface(root)?.contains(marker)) return removeEditorMarker(root, marker);
7082 const missing = contextMissingMediaTarget(root, target) || state(root).selectedMissingMedia;
7083 if (missing && editorSurface(root)?.contains(missing)) return removeEditorMissingMedia(root, missing);
7084 const mod = inlineModTarget(root, target) || state(root).selectedModPlaceholder;
7085 if (mod && editorSurface(root)?.contains(mod)) return removeEditorModPlaceholder(root, mod);
7086 const removable = removableEditorContextTarget(root, target);
7089 syncEditorDom(root);
7092 restoreEditorSelection(root);
7093 execEditorCommand(root, "delete");
7094 syncEditorDom(root);
7098 function selectEditorNode(root, node) {
7099 const surface = editorSurface(root);
7100 if (!surface || !node || !surface.contains(node)) return false;
7101 const doc = surface.ownerDocument || document;
7102 const sel = doc.getSelection ? doc.getSelection() : null;
7103 if (!sel) return false;
7104 const range = doc.createRange();
7105 range.selectNode(node);
7106 if (surface.focus) surface.focus({ preventScroll: true });
7107 sel.removeAllRanges();
7108 sel.addRange(range);
7109 state(root).editorRange = range.cloneRange();
7113 function syncEditorAfterContextAction(root) {
7114 const instance = getEditorInstance(root);
7115 if (instance && instance.synchronizeValues) instance.synchronizeValues();
7116 syncEditorDom(root);
7119 function contextImageTarget(root, target) {
7120 if (inlineModTarget(root, target)) return null;
7121 const surface = editorSurface(root);
7122 const img = closestElement(target, "img");
7123 return img && surface && surface.contains(img) ? img : null;
7126 function contextLinkTarget(root, target) {
7127 const surface = editorSurface(root);
7128 const link = closestElement(target, "a");
7129 return link && surface && surface.contains(link) ? link : null;
7132 function contextTableCell(root, target) {
7133 const surface = editorSurface(root);
7134 const cell = closestElement(target, "td,th");
7135 return cell && surface && surface.contains(cell) ? cell : null;
7138 function contextTableTarget(root, target) {
7139 const surface = editorSurface(root);
7140 const table = closestElement(target, "table");
7141 return table && surface && surface.contains(table) ? table : null;
7144 function openEditorImageProperties(root, img) {
7145 const instance = getEditorInstance(root);
7146 if (!instance || !img) return false;
7147 selectEditorNode(root, img);
7148 if (instance.e && instance.e.fire) {
7149 instance.e.fire("openImageProperties", img);
7155 function alignContextImage(root, img, mode) {
7156 if (!img) return false;
7157 const media = closestElement(img, ".dbx-cms-inline-media");
7158 const target = media && editorSurface(root)?.contains(media) ? media : img;
7159 [img, target].forEach(el => {
7161 el.style.float = "";
7162 el.style.marginLeft = "";
7163 el.style.marginRight = "";
7164 el.style.display = "";
7166 if (mode === "left") {
7167 target.style.float = "left";
7168 target.style.marginRight = "1.5rem";
7169 } else if (mode === "right") {
7170 target.style.float = "right";
7171 target.style.marginLeft = "1.5rem";
7172 } else if (mode === "center") {
7173 target.style.display = "block";
7174 target.style.marginLeft = "auto";
7175 target.style.marginRight = "auto";
7177 syncEditorAfterContextAction(root);
7181 function openEditorLinkDialog(root, targetNode) {
7182 const instance = getEditorInstance(root);
7183 if (!instance) return false;
7184 if (targetNode) selectEditorNode(root, targetNode);
7185 if (instance.execCommand) {
7186 instance.execCommand("openLinkDialog");
7192 function removeContextLink(root, link) {
7193 if (!link || !link.parentNode) return false;
7194 while (link.firstChild) link.parentNode.insertBefore(link.firstChild, link);
7196 syncEditorAfterContextAction(root);
7200 function tableApi(root) {
7201 const instance = getEditorInstance(root);
7202 if (!instance || !instance.getInstance) return null;
7204 return instance.getInstance("Table", instance.o);
7210 function runTableContextAction(root, cell, action) {
7211 const table = contextTableTarget(root, cell);
7212 const api = tableApi(root);
7213 if (!table || !cell || !api) return false;
7214 const row = cell.parentElement;
7217 api.appendRow(table, row, false);
7220 api.appendRow(table, row, true);
7223 api.removeRow(table, row.rowIndex);
7225 case "column-before":
7226 api.appendColumn(table, cell, false);
7228 case "column-after":
7229 api.appendColumn(table, cell, true);
7231 case "column-delete":
7232 api.removeColumn(table, cell.cellIndex);
7235 cell.innerHTML = "<br>";
7237 case "table-delete":
7243 syncEditorAfterContextAction(root);
7247 function contextMenuButton(label, icon, action, disabled) {
7248 const btn = document.createElement("button");
7249 btn.type = "button";
7250 btn.className = "dbx-cms-context-menu-item";
7251 btn.disabled = !!disabled;
7253 const iconEl = document.createElement("i");
7254 iconEl.className = "bi " + icon;
7255 iconEl.setAttribute("aria-hidden", "true");
7256 btn.appendChild(iconEl);
7258 const labelEl = document.createElement("span");
7259 labelEl.textContent = label;
7260 btn.appendChild(labelEl);
7261 btn.addEventListener("mousedown", e => e.preventDefault());
7262 btn.addEventListener("click", e => {
7264 e.stopPropagation();
7265 if (btn.disabled) return;
7266 const root = btn.__dbxCmsRoot;
7267 closeEditorContextMenu(root);
7268 Promise.resolve(action()).catch(err => dbx.warn("[cms] context action failed", err));
7273 function showEditorContextMenu(root, e) {
7274 const surface = editorSurface(root);
7275 if (!surface) return;
7276 closeEditorContextMenu(root);
7278 const target = e.target;
7279 const marker = closestElement(target, ".dbx-cms-marker");
7280 const missingMedia = contextMissingMediaTarget(root, target);
7281 const hasSelection = !!editorSelectionText(root);
7282 const img = contextImageTarget(root, target);
7283 const videoMedia = inlineVideoEventTarget(root, e);
7284 const modPlaceholder = inlineModTarget(root, target);
7285 const component = closestBootstrapComponent(root, target);
7286 const cell = contextTableCell(root, target);
7287 const table = contextTableTarget(root, target);
7289 if (marker && surface.contains(marker)) {
7290 selectEditorMarker(root, marker);
7291 } else if (missingMedia) {
7292 selectEditorMissingMedia(root, missingMedia);
7294 selectEditorNode(root, cell);
7295 } else if (modPlaceholder) {
7296 selectEditorModPlaceholder(root, modPlaceholder);
7297 } else if (component && surface.contains(component)) {
7298 selectEditorNode(root, component);
7299 } else if (videoMedia) {
7300 selectEditorNode(root, videoMedia);
7302 selectEditorNode(root, img);
7303 } else if (!hasSelection) {
7304 setEditorCaretFromPoint(root, e.clientX, e.clientY);
7305 selectEditorMarker(root, null);
7308 const menu = document.createElement("div");
7309 menu.className = "dbx-cms-context-menu";
7310 menu.setAttribute("role", "menu");
7311 menu.setAttribute("aria-label", "Editor Kontextmenue");
7313 const movable = removableEditorContextTarget(root, target);
7314 const hasContextTarget = !!(marker || missingMedia || modPlaceholder || component || videoMedia || img || table || movable);
7316 ["Rueckgaengig", "bi-arrow-counterclockwise", () => execEditorCommand(root, "undo"), false],
7317 ["Wiederholen", "bi-arrow-clockwise", () => execEditorCommand(root, "redo"), false],
7318 ["Alles markieren", "bi-check2-square", () => selectEditorContents(root), false],
7319 ["Block nach oben", "bi-arrow-up", () => moveEditorContextBlock(root, movable, -1), !movable],
7320 ["Block nach unten", "bi-arrow-down", () => moveEditorContextBlock(root, movable, 1), !movable],
7321 ["Modul Platzhalter", "bi-puzzle", () => openModPlaceholderOptions(root, modPlaceholder, cmsConfig(root) || {}), !modPlaceholder],
7322 ["Video Optionen", "bi-camera-video", () => openInlineVideoOptions(root, videoMedia), !videoMedia],
7323 ["Kopieren", "bi-clipboard", () => copyEditorContext(root, target), !hasSelection && !hasContextTarget],
7324 ["Ausschneiden", "bi-scissors", () => cutEditorContext(root, target), !hasSelection && !hasContextTarget],
7325 ["Einfuegen", "bi-clipboard-plus", () => pasteEditorContext(root), false],
7326 ["Loeschen", "bi-trash", () => deleteEditorContext(root, target), !hasSelection && !hasContextTarget]
7329 items.forEach(item => {
7330 const btn = contextMenuButton(item[0], item[1], item[2], item[3]);
7331 btn.__dbxCmsRoot = root;
7332 menu.appendChild(btn);
7335 document.body.appendChild(menu);
7336 const vw = window.innerWidth || document.documentElement.clientWidth || 0;
7337 const vh = window.innerHeight || document.documentElement.clientHeight || 0;
7338 const rect = menu.getBoundingClientRect();
7339 const left = Math.max(8, Math.min(e.clientX, vw - rect.width - 8));
7340 const top = Math.max(8, Math.min(e.clientY, vh - rect.height - 8));
7341 menu.style.left = left + "px";
7342 menu.style.top = top + "px";
7344 const close = evt => {
7345 if (evt && menu.contains(evt.target)) return;
7346 closeEditorContextMenu(root);
7348 const closeOnKey = evt => {
7349 if (evt.key === "Escape") closeEditorContextMenu(root);
7352 window.setTimeout(() => {
7353 document.addEventListener("mousedown", close, true);
7354 document.addEventListener("keydown", closeOnKey, true);
7355 win.addEventListener("scroll", close, true);
7356 win.addEventListener("resize", close, true);
7358 state(root).editorContextMenu = menu;
7359 state(root).editorContextCleanup = () => {
7360 document.removeEventListener("mousedown", close, true);
7361 document.removeEventListener("keydown", closeOnKey, true);
7362 win.removeEventListener("scroll", close, true);
7363 win.removeEventListener("resize", close, true);
7367 function editorMarkerFromEvent(root, e) {
7368 const surface = editorSurface(root);
7369 const marker = closestElement(e.target, ".dbx-cms-marker");
7370 return marker && surface && surface.contains(marker) ? marker : null;
7373 function editorRootFromMarker(marker) {
7374 if (!marker) return null;
7375 const surface = closestElement(marker, ".jodit-wysiwyg, [data-cms-editor]");
7376 const root = closestElement(marker, ".dbx-cms");
7377 return root && surface && root.contains(surface) ? root : null;
7380 function editorMarkerAtPoint(x, y) {
7381 if (!Number.isFinite(Number(x)) || !Number.isFinite(Number(y))) return null;
7382 const direct = document.elementFromPoint ? document.elementFromPoint(x, y) : null;
7383 const directMarker = closestElement(direct, ".dbx-cms-marker");
7384 if (directMarker) return directMarker;
7385 return qsa(document, ".jodit-wysiwyg .dbx-cms-marker, [data-cms-editor] .dbx-cms-marker").find(marker => {
7386 const rect = marker.getBoundingClientRect ? marker.getBoundingClientRect() : null;
7387 return rect && x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
7391 function editorMarkerFromPointerEvent(e) {
7392 return closestElement(e.target, ".dbx-cms-marker") || editorMarkerAtPoint(e.clientX, e.clientY);
7395 function bindEditorMarkerEvents(root) {
7396 const surface = editorSurface(root);
7397 if (!surface) return;
7399 ["mouseup", "keyup", "input", "touchend"].forEach(type => {
7400 surface.addEventListener(type, () => saveEditorSelection(root), true);
7402 const doc = surface.ownerDocument || document;
7403 if (!root.__dbxCmsSelectionBound && doc && doc.addEventListener) {
7404 root.__dbxCmsSelectionBound = true;
7405 doc.addEventListener("selectionchange", () => saveEditorSelection(root));
7407 if (!root.__dbxCmsMarkerKeyBound) {
7408 root.__dbxCmsMarkerKeyBound = true;
7409 root.addEventListener("keydown", e => {
7410 if (e.key !== "Delete" && e.key !== "Backspace") return;
7411 const marker = state(root).selectedMarker;
7412 const mod = state(root).selectedModPlaceholder;
7413 const currentSurface = editorSurface(root);
7414 if (marker && currentSurface && currentSurface.contains(marker)) {
7416 e.stopPropagation();
7417 removeEditorMarker(root, marker);
7420 if (mod && currentSurface && currentSurface.contains(mod)) {
7422 e.stopPropagation();
7423 removeEditorModPlaceholder(root, mod);
7427 if (!root.__dbxCmsInlineVideoResizeBound) {
7428 root.__dbxCmsInlineVideoResizeBound = true;
7429 const finish = () => finishInlineVideoResizeTrack(root);
7430 document.addEventListener("mouseup", finish, true);
7431 document.addEventListener("touchend", finish, true);
7432 document.addEventListener("pointerup", finish, true);
7434 if (!root.__dbxCmsMarkerContainerBound) {
7435 root.__dbxCmsMarkerContainerBound = true;
7436 root.addEventListener("mousedown", e => {
7437 const marker = editorMarkerFromEvent(root, e) || editorMarkerFromPointerEvent(e);
7438 if (marker && editorRootFromMarker(marker) !== root) return;
7439 if (!marker) return;
7440 selectEditorMarker(root, marker);
7441 e.stopPropagation();
7443 root.addEventListener("click", e => {
7444 const marker = editorMarkerFromEvent(root, e) || editorMarkerFromPointerEvent(e);
7445 if (marker && editorRootFromMarker(marker) !== root) return;
7446 if (!marker) return;
7447 handleEditorMarkerClick(root, marker, e);
7451 if (surface.__dbxCmsMarkerEventsBound) return;
7452 surface.__dbxCmsMarkerEventsBound = true;
7454 surface.addEventListener("mousedown", e => {
7455 const marker = closestElement(e.target, ".dbx-cms-marker");
7456 if (marker && surface.contains(marker)) {
7457 selectEditorMarker(root, marker);
7458 e.stopPropagation();
7461 const missingMedia = contextMissingMediaTarget(root, e.target);
7463 handleEditorMissingMediaClick(root, missingMedia, e);
7466 const modPlaceholder = inlineModTarget(root, e.target);
7467 if (modPlaceholder) {
7468 selectEditorModPlaceholder(root, modPlaceholder);
7470 e.stopPropagation();
7471 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
7474 const videoBlock = inlineVideoEventTarget(root, e);
7476 beginInlineVideoResizeTrack(root, videoBlock);
7477 if (e.button !== undefined && e.button !== 0) return;
7478 if (!isInlineVideoResizeHandleEvent(videoBlock, e)) {
7480 e.stopPropagation();
7481 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
7482 openInlineVideoOptions(root, videoBlock);
7486 const first = surface.firstElementChild;
7487 if (first && first.classList.contains("dbx-cms-marker")) {
7488 const rect = first.getBoundingClientRect();
7489 if (e.clientY < rect.top + 8) {
7490 focusEditorAboveMarker(root, first);
7492 e.stopPropagation();
7496 selectEditorMarker(root, null);
7497 selectEditorMissingMedia(root, null);
7498 selectEditorModPlaceholder(root, null);
7501 surface.addEventListener("mouseover", e => {
7502 const marker = closestElement(e.target, ".dbx-cms-marker");
7503 clearEditorMarkerHover(surface, marker);
7504 if (marker && surface.contains(marker)) marker.classList.add("is-hovered");
7507 surface.addEventListener("mouseout", e => {
7508 const marker = closestElement(e.target, ".dbx-cms-marker");
7509 if (!marker || !surface.contains(marker)) return;
7510 const related = e.relatedTarget;
7511 if (!related || !marker.contains(related)) marker.classList.remove("is-hovered");
7514 surface.addEventListener("focusin", () => {
7515 if (!state(root).selectedMarker && !state(root).selectedMissingMedia) refreshEditorCaretHint(root);
7518 surface.addEventListener("focusout", e => {
7519 const next = e.relatedTarget;
7520 if (next && surface.contains(next)) return;
7521 if (next && closestElement(next, ".dbx-cms-marker")) return;
7522 hideEditorCaretHint(root);
7525 surface.addEventListener("click", e => {
7526 const marker = closestElement(e.target, ".dbx-cms-marker");
7527 if (marker && surface.contains(marker)) {
7528 handleEditorMarkerClick(root, marker, e);
7531 const missingMedia = contextMissingMediaTarget(root, e.target);
7533 handleEditorMissingMediaClick(root, missingMedia, e);
7536 const modPlaceholder = inlineModTarget(root, e.target);
7537 if (modPlaceholder) {
7538 selectEditorModPlaceholder(root, modPlaceholder);
7540 e.stopPropagation();
7541 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
7544 const videoBlock = inlineVideoEventTarget(root, e);
7546 if (e.button !== undefined && e.button !== 0) return;
7548 e.stopPropagation();
7549 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
7550 openInlineVideoOptions(root, videoBlock);
7553 saveEditorSelection(root);
7554 selectEditorMarker(root, null);
7555 selectEditorMissingMedia(root, null);
7558 surface.addEventListener("keydown", e => {
7559 if (e.key !== "Delete" && e.key !== "Backspace") return;
7560 const marker = closestElement(e.target, ".dbx-cms-marker") || state(root).selectedMarker;
7561 if (marker && surface.contains(marker)) {
7563 e.stopPropagation();
7564 removeEditorMarker(root, marker);
7567 const missingMedia = contextMissingMediaTarget(root, e.target) || state(root).selectedMissingMedia;
7568 if (missingMedia && surface.contains(missingMedia)) {
7570 e.stopPropagation();
7571 removeEditorMissingMedia(root, missingMedia);
7574 const mod = inlineModTarget(root, e.target) || state(root).selectedModPlaceholder;
7575 if (mod && surface.contains(mod)) {
7577 e.stopPropagation();
7578 removeEditorModPlaceholder(root, mod);
7582 surface.addEventListener("contextmenu", e => {
7583 if (!surface.contains(e.target)) return;
7585 e.stopPropagation();
7586 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
7587 showEditorContextMenu(root, e);
7591 function bindEditorMarkerEventsRetry(root, attempt) {
7592 attempt = Number(attempt || 0);
7593 bindEditorMarkerEvents(root);
7594 const surface = editorSurface(root);
7595 if (surface && surface.__dbxCmsMarkerEventsBound) return;
7596 if (attempt < 20) window.setTimeout(() => bindEditorMarkerEventsRetry(root, attempt + 1), 100);
7599 function bindGlobalCmsEditorEvents() {
7600 if (document.__dbxCmsEditorGlobalEventsBound) return;
7601 document.__dbxCmsEditorGlobalEventsBound = true;
7602 if (document.documentElement) document.documentElement.setAttribute("data-dbx-cms-global-events", "1");
7603 document.addEventListener("pointerdown", e => {
7604 if (e.button !== 0) return;
7605 const marker = editorMarkerFromPointerEvent(e);
7606 const root = editorRootFromMarker(marker);
7608 selectEditorMarker(root, marker);
7609 state(root).pointerDragMarker = {
7615 e.stopPropagation();
7617 document.addEventListener("pointermove", e => {
7618 qsa(document, ".dbx-cms").forEach(currentRoot => {
7619 const drag = state(currentRoot).pointerDragMarker;
7620 if (!drag || !drag.marker) return;
7621 const dx = Math.abs(e.clientX - drag.startX);
7622 const dy = Math.abs(e.clientY - drag.startY);
7623 if (!drag.dragging && dx < 6 && dy < 6) return;
7624 if (!drag.dragging) {
7625 drag.dragging = true;
7626 const rect = drag.marker.getBoundingClientRect();
7627 drag.ghostOffsetX = drag.startX - rect.left;
7628 drag.ghostOffsetY = drag.startY - rect.top;
7629 drag.ghost = createMarkerDragGhost(drag.marker, rect);
7630 drag.marker.classList.add("is-dragging-source");
7633 updateMarkerDragGhost(drag, e.clientX, e.clientY);
7634 showEditorMarkerDropHint(currentRoot, e.clientX, e.clientY, drag.marker);
7637 document.addEventListener("pointerup", e => {
7638 qsa(document, ".dbx-cms").forEach(root => {
7639 const drag = state(root).pointerDragMarker;
7640 if (!drag || !drag.marker) return;
7641 if (drag.dragging) {
7643 e.stopPropagation();
7644 if (!moveEditorMarkerToPoint(root, drag.marker, e.clientX, e.clientY)) {
7645 clearMarkerPointerDrag(root);
7648 selectEditorMarker(root, drag.marker);
7649 clearMarkerPointerDrag(root);
7653 document.addEventListener("pointercancel", () => {
7654 qsa(document, ".dbx-cms").forEach(root => clearMarkerPointerDrag(root));
7656 document.addEventListener("keydown", e => {
7657 const key = String(e.key || "").toLowerCase();
7658 const targetRoot = closestElement(e.target, ".dbx-cms");
7659 if ((e.ctrlKey || e.metaKey) && key === "s" && targetRoot) {
7661 e.stopPropagation();
7662 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
7663 saveCurrentCms(targetRoot, cmsConfig(targetRoot));
7666 if (e.key !== "Delete" && e.key !== "Backspace") return;
7667 const root = targetRoot || editorRootFromMarker(closestElement(e.target, ".dbx-cms-marker"));
7668 const marker = root ? state(root).selectedMarker : null;
7669 const missingMedia = root ? state(root).selectedMissingMedia : null;
7670 const mod = root ? state(root).selectedModPlaceholder : null;
7671 const surface = root ? editorSurface(root) : null;
7672 if (marker && surface && surface.contains(marker)) {
7674 e.stopPropagation();
7675 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
7676 removeEditorMarker(root, marker);
7679 if (missingMedia && surface && surface.contains(missingMedia)) {
7681 e.stopPropagation();
7682 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
7683 removeEditorMissingMedia(root, missingMedia);
7686 if (mod && surface && surface.contains(mod)) {
7688 e.stopPropagation();
7689 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
7690 removeEditorModPlaceholder(root, mod);
7695 function insertEditorHtml(root, html) {
7696 const instance = getEditorInstance(root);
7697 if (instance && typeof instance.setMode === "function") {
7698 instance.setMode(window.Jodit && window.Jodit.MODE_WYSIWYG ? window.Jodit.MODE_WYSIWYG : 1);
7700 hideEditorCaretHint(root);
7701 restoreEditorSelection(root);
7702 if (instance && instance.s && instance.s.insertHTML) {
7703 instance.s.insertHTML(html);
7704 normalizeEditorMarkers(root);
7705 normalizeBootstrapComponents(editorSurface(root));
7706 bindEditorMarkerEventsRetry(root);
7707 syncEditorDom(root);
7708 saveEditorSelection(root);
7711 document.execCommand("insertHTML", false, html);
7712 normalizeEditorMarkers(root);
7713 normalizeBootstrapComponents(editorSurface(root));
7714 bindEditorMarkerEventsRetry(root);
7715 syncEditorDom(root);
7716 saveEditorSelection(root);
7719 function bootstrapComponentItems() {
7720 const openWinData = "lib=openWin|title=Information|width=900|height=80%|position=center-top|reload=1|minimizable=1|maximizable=1";
7723 label: "Hinweis / Alert",
7724 html: '<div class="alert alert-info" role="alert"><h4 class="alert-heading">Hinweis</h4><p>Kurzer Hinweistext fuer den Inhalt.</p></div><p></p>'
7728 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>'
7732 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>'
7735 label: "List Group",
7736 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>'
7739 label: "CTA Button",
7740 html: '<p><a class="btn btn-primary" href="#">Informationen anfragen</a> <a class="btn btn-outline-primary" href="#">Mehr erfahren</a></p><p></p>'
7743 label: "openWin Link",
7744 html: '<p><a class="btn btn-outline-primary dbx-win" href="kontakt" data-dbx="' + openWinData + '|url=kontakt">Im Fenster oeffnen</a></p><p></p>'
7748 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>'
7752 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>'
7756 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>'
7761 function cmsBootstrapComponentIcon() {
7762 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>';
7765 function insertBootstrapComponent(root, html) {
7766 const uid = "dbxCms" + Date.now().toString(36) + Math.floor(Math.random() * 1000).toString(36);
7767 html = String(html || "")
7768 .replaceAll("dbxCmsAccordion", uid + "Accordion")
7769 .replaceAll("dbxCmsAccordionOne", uid + "AccordionOne")
7770 .replaceAll("dbxCmsAccordionTwo", uid + "AccordionTwo")
7771 .replaceAll("dbxCmsTabOne", uid + "TabOne")
7772 .replaceAll("dbxCmsTabTwo", uid + "TabTwo");
7773 insertEditorFragment(root, html);
7774 normalizeEditorMarkers(root);
7775 normalizeBootstrapComponents(editorSurface(root));
7776 syncEditorDom(root);
7778 scheduleEditorHeight(root);
7781 function normalizeBootstrapComponents(surface) {
7782 if (!surface) return;
7783 const doc = surface.ownerDocument || document;
7784 qsa(surface, ".card").forEach(card => {
7785 let body = Array.from(card.children || []).find(child => child.classList && child.classList.contains("card-body"));
7787 body = doc.createElement("div");
7788 body.className = "card-body";
7789 const insertAfter = Array.from(card.children || []).reverse().find(child => {
7790 if (!child.classList) return false;
7791 return child.classList.contains("card-img-top")
7792 || child.classList.contains("card-img")
7793 || child.classList.contains("card-header")
7794 || child.classList.contains("badge")
7795 || child.classList.contains("position-absolute");
7797 card.insertBefore(body, insertAfter ? insertAfter.nextSibling : card.firstChild);
7800 Array.from(card.childNodes || []).forEach(node => {
7801 if (node === body) return;
7802 if (node.nodeType === 3) {
7803 if (String(node.nodeValue || "").trim() === "") return;
7804 body.appendChild(node);
7807 if (node.nodeType !== 1) return;
7808 if (node.classList && (
7809 node.classList.contains("card-body")
7810 || node.classList.contains("card-img-top")
7811 || node.classList.contains("card-img")
7812 || node.classList.contains("card-header")
7813 || node.classList.contains("card-footer")
7814 || node.classList.contains("list-group")
7815 || node.classList.contains("dbx-cms-inline-media")
7816 || node.classList.contains("dbx-media")
7817 || node.classList.contains("position-absolute")
7819 if (isEmptyEditorBlock(node)) {
7823 if (/^(H[1-6]|P|A|BUTTON|UL|OL|DIV|SPAN|SMALL|STRONG|EM)$/i.test(node.tagName || "")) {
7824 body.appendChild(node);
7828 qsa(card, ".position-absolute.badge, .position-absolute .badge, .badge.position-absolute").forEach(badge => {
7829 if (!card.contains(badge)) return;
7830 badge.setAttribute("contenteditable", "false");
7831 Array.from(badge.children || []).forEach(child => {
7832 if (/^(H[1-6]|P|DIV|A|BUTTON|UL|OL)$/i.test(child.tagName || "")) body.appendChild(child);
7836 qsa(card, "img.card-img-top,img.card-img,img.card-img-bottom").forEach(img => {
7837 img.setAttribute("contenteditable", "false");
7838 img.setAttribute("draggable", "false");
7841 qsa(body, "p,div").forEach(block => {
7842 if (block === body) return;
7843 if (!isEmptyEditorBlock(block)) return;
7847 if (!nodeHasEditorContent(body)) {
7848 const p = doc.createElement("p");
7849 p.innerHTML = "<br>";
7850 body.appendChild(p);
7855 function isEmptyEditorBlock(block) {
7856 if (!block || block.nodeType !== 1) return false;
7857 if (!/^(P|DIV)$/i.test(block.tagName || "")) return false;
7858 if (block.querySelector("img,video,iframe,object,embed,table,hr,ul,ol,.dbx-cms-marker,.dbx-cms-inline-media")) return false;
7859 return String(block.textContent || "").replace(/\u00a0/g, " ").replace(/\uFEFF/g, "").trim() === "";
7862 function editableCardBlockFromSelection(surface) {
7863 if (!surface) return null;
7864 const doc = surface.ownerDocument || document;
7865 const sel = doc.getSelection ? doc.getSelection() : null;
7866 if (!sel || !sel.rangeCount) return null;
7867 const range = sel.getRangeAt(0);
7868 if (!range.collapsed || !surface.contains(range.commonAncestorContainer)) return null;
7869 const el = nodeElement(range.startContainer);
7870 const body = closestElement(el, ".card-body");
7871 if (!body || !surface.contains(body)) return null;
7872 const block = closestElement(el, "p,div,h1,h2,h3,h4,h5,h6,li") || body;
7873 if (!body.contains(block)) return null;
7874 return { body, block, range };
7877 function rangeIsAtElementBoundary(range, element, boundary) {
7878 if (!range || !element) return false;
7879 const probe = range.cloneRange();
7880 if (boundary === "start") {
7881 probe.selectNodeContents(element);
7882 probe.setEnd(range.startContainer, range.startOffset);
7884 probe.selectNodeContents(element);
7885 probe.setStart(range.startContainer, range.startOffset);
7887 return String(probe.toString() || "").replace(/\u00a0/g, " ").replace(/\uFEFF/g, "").trim() === "";
7890 function firstEditableCardBodyBlock(body) {
7891 return Array.from(body.children || []).find(child => {
7892 if (!/^(P|DIV|H1|H2|H3|H4|H5|H6|UL|OL|LI|A|BUTTON)$/i.test(child.tagName || "")) return false;
7893 return nodeHasEditorContent(child);
7897 function lastEditableCardBodyBlock(body) {
7898 return Array.from(body.children || []).reverse().find(child => {
7899 if (!/^(P|DIV|H1|H2|H3|H4|H5|H6|UL|OL|LI|A|BUTTON)$/i.test(child.tagName || "")) return false;
7900 return nodeHasEditorContent(child);
7904 function setEditorCaretInCardBody(root, body) {
7905 const first = firstEditableCardBodyBlock(body) || body;
7906 return setEditorCaretInElement(root, first, 0);
7909 function handleBootstrapCardDeleteKey(root, event) {
7910 const surface = editorSurface(root);
7911 const ctx = editableCardBlockFromSelection(surface);
7912 if (!ctx) return false;
7913 const key = String(event.key || "");
7914 if (key !== "Backspace" && key !== "Delete") return false;
7915 const { body, block, range } = ctx;
7917 if (block !== body && isEmptyEditorBlock(block)) {
7918 event.preventDefault();
7919 event.stopPropagation();
7920 if (typeof event.stopImmediatePropagation === "function") event.stopImmediatePropagation();
7921 const next = block.nextElementSibling;
7922 const prev = block.previousElementSibling;
7924 const target = key === "Delete" ? (next || prev || body) : (prev || next || body);
7925 setEditorCaretInElement(root, target, 0);
7926 normalizeBootstrapComponents(surface);
7927 syncEditorDom(root);
7931 const first = firstEditableCardBodyBlock(body);
7932 if (key === "Backspace" && first && (block === first || first.contains(block)) && rangeIsAtElementBoundary(range, block, "start")) {
7933 event.preventDefault();
7934 event.stopPropagation();
7935 if (typeof event.stopImmediatePropagation === "function") event.stopImmediatePropagation();
7936 setEditorCaretInCardBody(root, body);
7940 const last = lastEditableCardBodyBlock(body);
7941 if (key === "Delete" && last && (block === last || last.contains(block)) && rangeIsAtElementBoundary(range, block, "end")) {
7942 event.preventDefault();
7943 event.stopPropagation();
7944 if (typeof event.stopImmediatePropagation === "function") event.stopImmediatePropagation();
7950 function bindBootstrapCardEditingGuards(root) {
7952 const surface = editorSurface(root);
7953 if (!surface || surface.__dbxCmsBootstrapCardGuardsBound) return;
7954 surface.__dbxCmsBootstrapCardGuardsBound = true;
7955 surface.addEventListener("keydown", event => {
7956 handleBootstrapCardDeleteKey(root, event);
7958 surface.addEventListener("input", () => {
7959 window.requestAnimationFrame(() => {
7960 normalizeBootstrapComponents(surface);
7965 function insertEditorFragment(root, html) {
7966 const surface = editorSurface(root);
7967 if (!surface) return false;
7968 const doc = surface.ownerDocument || document;
7969 const tpl = doc.createElement("template");
7970 tpl.innerHTML = String(html || "");
7971 if (!tpl.content || !tpl.content.childNodes.length) return false;
7973 const instance = getEditorInstance(root);
7974 if (instance && typeof instance.setMode === "function") {
7975 instance.setMode(window.Jodit && window.Jodit.MODE_WYSIWYG ? window.Jodit.MODE_WYSIWYG : 1);
7977 hideEditorCaretHint(root);
7978 restoreEditorSelection(root);
7979 if (surface.focus) surface.focus({ preventScroll: true });
7981 const sel = doc.getSelection ? doc.getSelection() : null;
7982 let range = sel && sel.rangeCount ? sel.getRangeAt(0) : null;
7983 if (!range || !surface.contains(range.commonAncestorContainer)) {
7984 range = doc.createRange();
7985 range.selectNodeContents(surface);
7986 range.collapse(false);
7989 const nodes = Array.from(tpl.content.childNodes);
7990 range.deleteContents();
7991 range.insertNode(tpl.content);
7993 normalizeEditorMarkers(root);
7994 normalizeBootstrapComponents(surface);
7995 bindEditorMarkerEventsRetry(root);
7996 syncEditorDom(root);
7997 setEditorCaretAfterNode(root, nodes[nodes.length - 1]);
7998 saveEditorSelection(root);
8002 function execEditorCommand(root, command) {
8003 const instance = getEditorInstance(root);
8004 if (instance && instance.execCommand) {
8005 instance.execCommand(command);
8006 setField(root, "content", instance.value || "");
8008 scheduleEditorHeight(root);
8011 document.execCommand(command, false, null);
8013 scheduleEditorHeight(root);
8016 function serializeCmsMarkers(html) {
8017 const box = document.createElement("div");
8018 box.innerHTML = html || "";
8019 normalizeCommentMarkers(box);
8020 cleanEditorRuntimeNodes(box);
8021 normalizePlainTextMarkers(box);
8022 qsa(box, ".dbx-cms-marker[data-dbx-marker-comment]").forEach(marker => {
8023 const markerText = marker.getAttribute("data-dbx-marker-comment") || "";
8024 const name = cmsMarkerName(markerText);
8025 const hr = document.createElement("hr");
8026 hr.className = "dbx-cms-marker dbx-cms-marker-" + cmsMarkerClassName(name);
8027 hr.setAttribute("data-dbx-marker", "dbx:" + name);
8028 hr.setAttribute("data-label", marker.textContent || markerText);
8029 hr.setAttribute("contenteditable", "false");
8030 hr.setAttribute("draggable", "false");
8031 hr.setAttribute("tabindex", "0");
8032 marker.replaceWith(hr);
8034 qsa(box, ".dbx-cms-marker,[data-dbx-marker]").forEach(marker => {
8035 const name = cmsMarkerName(marker.getAttribute("data-dbx-marker") || "");
8036 if (marker.tagName !== "HR") {
8037 const hr = document.createElement("hr");
8038 Array.from(marker.attributes || []).forEach(attr => {
8039 if (attr.name === "data-dbx-marker-comment") return;
8040 hr.setAttribute(attr.name, attr.value);
8042 marker.replaceWith(hr);
8045 marker.classList.remove("is-selected", "is-dragging", "is-drop-before", "is-drop-after");
8046 marker.classList.add("dbx-cms-marker", "dbx-cms-marker-" + cmsMarkerClassName(name));
8047 marker.removeAttribute("data-cms-drag-token");
8048 marker.setAttribute("data-dbx-marker", "dbx:" + name);
8049 if (!marker.getAttribute("data-label")) marker.setAttribute("data-label", cmsMarkerLabel("dbx:" + name));
8050 marker.setAttribute("contenteditable", "false");
8051 marker.setAttribute("draggable", "false");
8052 marker.setAttribute("tabindex", "0");
8054 dedupeAdjacentMarkers(box);
8055 return box.innerHTML;
8058 function folderTreeEditButton(id) {
8059 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>`;
8062 function bindTreeSearchClear(root, input) {
8063 if (!input || input.__dbxCmsTreeSearchClearBound) return;
8064 const wrap = input.closest(".dbx-input-clearable");
8067 input.__dbxCmsTreeSearchClearBound = true;
8068 wrap.classList.add("dbx-cms-tree-search-wrap");
8070 let btn = wrap.querySelector(".dbx-clear-btn");
8072 btn = document.createElement("button");
8073 btn.type = "button";
8074 btn.className = "dbx-clear-btn";
8075 btn.setAttribute("aria-label", "Suche leeren");
8076 btn.setAttribute("tabindex", "-1");
8077 btn.innerHTML = "×";
8078 wrap.appendChild(btn);
8080 btn.classList.add("dbx-cms-tree-search-clear");
8082 const sync = () => {
8083 if (String(input.value || "").length > 0) {
8084 btn.removeAttribute("hidden");
8086 btn.setAttribute("hidden", "");
8090 input.addEventListener("input", sync);
8091 btn.addEventListener("click", e => {
8094 input.dispatchEvent(new Event("input", { bubbles: true }));
8095 input.dispatchEvent(new Event("change", { bubbles: true }));
8103 function renderTree(root) {
8104 const s = state(root);
8105 const box = qs(root, "[data-cms-tree]");
8106 const searchRaw = String(qs(root, "[data-cms-search]")?.value || "").trim();
8107 const search = searchRaw.toLowerCase();
8108 const searchIsNumeric = /^\d+$/.test(searchRaw);
8111 function treeSearchText(node) {
8112 const rights = root.classList.contains("dbx-cms-view") ? "" : String(node._rights || "");
8114 String(node._id || ""),
8115 String(node._title || ""),
8117 ].join(" ").toLowerCase();
8120 function nodeMatches(node) {
8121 if (!search) return true;
8122 if (searchIsNumeric && String(node._id || "") === searchRaw) return true;
8123 return treeSearchText(node).includes(search);
8126 function hasSearchMatch(node) {
8127 if (!search) return true;
8128 if (nodeMatches(node)) return true;
8129 const children = Array.isArray(node._children) ? node._children : [];
8130 return children.some(hasSearchMatch);
8133 function searchClass(node, children) {
8134 if (!search) return "";
8135 const exactId = searchIsNumeric && String(node._id || "") === searchRaw;
8136 if (exactId) return " is-search-match is-search-id-match";
8137 if (nodeMatches(node)) return " is-search-match";
8138 if (children.some(hasSearchMatch)) return " is-search-path";
8142 function renderNode(node) {
8143 const children = Array.isArray(node._children) ? node._children : [];
8144 if (search && !hasSearchMatch(node)) return "";
8145 const type = node._type === "folder" ? "folder" : "page";
8146 const icon = type === "folder" ? "bi-folder2-open" : "bi-file-earmark-text";
8148 (type === "page" && s.selectedType !== "folder" && Number(node._id) === Number(s.selectedPage)) ||
8149 (type === "folder" && s.selectedType === "folder" && Number(node._id) === Number(s.selectedFolder))
8152 const matchedClass = searchClass(node, children);
8153 const collapsed = type === "folder" && children.length ? isFolderCollapsed(root, node._id, search) : false;
8154 const expandedAttr = collapsed ? "false" : "true";
8155 const toggleClass = collapsed ? "bi-chevron-right" : "bi-chevron-down";
8156 const collapsedClass = collapsed ? " is-collapsed" : "";
8157 const editBtn = type === "folder" ? folderTreeEditButton(node._id) : "";
8158 const toggle = type === "folder" && children.length
8159 ? `<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>`
8160 : `<span class="dbx-cms-tree-toggle-spacer"></span>`;
8161 if (node._row_html) {
8162 let html = String(node._row_html);
8163 if (active && !html.includes(" is-active")) {
8164 html = html.replace("dbx-cms-tree-row", "dbx-cms-tree-row is-active");
8167 html = html.replace(/\bdbx-cms-tree-row\b/, "dbx-cms-tree-row" + matchedClass);
8169 if (!html.includes("data-cms-tree-toggle") && !html.includes("dbx-cms-tree-toggle-spacer")) {
8170 html = html.replace('<span class="dbx-cms-tree-icons">', toggle + '<span class="dbx-cms-tree-icons">');
8172 if (type === "folder") {
8173 html = html.replace(/\bdbx-cms-tree-row\b/, "dbx-cms-tree-row" + collapsedClass);
8174 const rowAria = /(<div role="button"[^>]*?)\saria-expanded="[^"]*"/;
8175 if (rowAria.test(html)) {
8176 html = html.replace(rowAria, '$1 aria-expanded="' + expandedAttr + '"');
8178 html = html.replace('<div role="button"', '<div role="button" aria-expanded="' + expandedAttr + '"');
8180 if (!html.includes("data-cms-folder-edit-btn")) {
8181 if (html.includes('class="dbx-cms-lng-badges"')) {
8182 html = html.replace(/<span class="dbx-cms-lng-badges"/, editBtn + '<span class="dbx-cms-lng-badges"');
8184 html = html.replace(/(<span class="dbx-cms-tree-meta">)([\s\S]*?)(<\/span>)/, '$1$2' + editBtn + '$3');
8188 if (children.length) {
8189 html += `<div class="dbx-cms-tree-children"${collapsed ? " hidden" : ""}>`;
8190 children.forEach(child => {
8191 if (!search || hasSearchMatch(child)) html += renderNode(child);
8197 const rights = type === "folder" && node._rights ? `<span class="dbx-cms-rights" data-cms-folder-edit title="Ordnerrechte bearbeiten">${escapeHtml(node._rights)}</span>` : "";
8198 const lngBadges = node._lng_badges ? String(node._lng_badges) : "";
8199 const meta = `${rights}${editBtn}${lngBadges}`;
8200 const title = type === "page"
8201 ? `<span class="dbx-cms-page-id">(${escapeHtml(node._id)})</span> ${escapeHtml(node._title)}`
8202 : escapeHtml(node._title);
8205 if (!search || hasSearchMatch(node)) {
8206 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}"` : ""}>`;
8208 html += `<span class="dbx-cms-tree-icons"><i class="bi ${icon}"></i>`;
8209 html += `</span><span class="dbx-cms-tree-label">${title}</span><span class="dbx-cms-tree-meta">${meta}</span>`;
8213 if (children.length) {
8214 html += `<div class="dbx-cms-tree-children"${collapsed ? " hidden" : ""}>`;
8215 children.forEach(child => {
8216 if (!search || hasSearchMatch(child)) html += renderNode(child);
8224 if (!s.tree.length) {
8225 box.innerHTML = '<div class="dbx-cms-empty">Keine Content-Struktur vorhanden.</div>';
8229 box.innerHTML = s.tree.map(renderNode).join("");
8231 const firstMatch = qs(box, ".dbx-cms-tree-row.is-search-id-match") || qs(box, ".dbx-cms-tree-row.is-search-match");
8233 requestAnimationFrame(() => {
8234 firstMatch.scrollIntoView({ block: "center", inline: "nearest" });
8240 function toggleTreeFolder(root, row, forceCollapsed) {
8242 const id = Number(row.getAttribute("data-id") || 0);
8243 if (!id || row.getAttribute("data-type") !== "folder") return;
8244 const collapsed = typeof forceCollapsed === "boolean"
8246 : !isFolderCollapsed(root, id, false);
8247 setFolderCollapsed(root, id, collapsed);
8251 function loadTree(root, cfg) {
8252 const s = state(root);
8253 const url = cfgUrl(cfg, "tree");
8254 const box = qs(root, "[data-cms-tree]");
8256 if (box) box.innerHTML = '<div class="dbx-cms-empty">Tree-URL fehlt.</div>';
8257 return Promise.resolve();
8259 if (box) box.innerHTML = '<div class="dbx-cms-empty">Tree wird geladen...</div>';
8261 return fetchJson(apiUrl(url, cmsLngParams(root)))
8263 s.tree = Array.isArray(data.nodes) ? data.nodes : [];
8264 s.flat = Array.isArray(data.flat) ? data.flat : [];
8267 if (!s.selectionRestored) {
8268 s.selectionRestored = true;
8269 if (isViewMode(cfg)) {
8270 const requestedPage = Number(cfg && cfg.cid ? cfg.cid : 0) || 0;
8271 if (requestedPage > 0) {
8272 if (root.getAttribute("data-cms-initial-page-loaded") === "1") {
8273 const node = s.flat.find(n => n._type === "page" && Number(n._id) === requestedPage) || {};
8274 s.page = { id: requestedPage, title: node._title || "" };
8275 setSelectedPage(root, requestedPage);
8276 setSelectedType(root, "page");
8277 updateViewPageTitle(root, node._title || "");
8278 root.removeAttribute("data-cms-initial-page-loaded");
8279 revealTreeSelection(root);
8282 return loadViewPage(root, cfg, requestedPage);
8284 if (s.selectedPage > 0) {
8285 return loadViewPage(root, cfg, s.selectedPage);
8287 const firstViewPage = s.flat.find(n => n._type === "page");
8288 if (firstViewPage) return loadViewPage(root, cfg, firstViewPage._id);
8291 const requestedEditPage = Number(cfg && cfg.cid ? cfg.cid : 0) || 0;
8292 if (requestedEditPage > 0) {
8293 s.selectedPage = requestedEditPage;
8294 s.selectedType = "page";
8295 setSelectedPage(root, requestedEditPage);
8296 setSelectedType(root, "page");
8297 return loadPage(root, cfg, requestedEditPage).then(() => revealTreeSelection(root));
8299 if (s.selectedType === "folder" && s.selectedFolder) {
8300 const folder = findNode(root, "folder", s.selectedFolder);
8302 showFolderEditor(root, folder);
8303 loadMedia(root, cfg);
8304 revealTreeSelection(root);
8308 if (s.selectedPage > 0) {
8309 return loadPage(root, cfg, s.selectedPage);
8311 const firstPage = s.flat.find(n => n._type === "page");
8312 if (firstPage) return loadPage(root, cfg, firstPage._id);
8316 dbx.error("[cms] tree load failed", err);
8318 box.innerHTML = '<div class="dbx-cms-empty">Tree konnte nicht geladen werden.</div>';
8320 status(root, "Tree konnte nicht geladen werden.", "error");
8324 function setSelectValues(select, value) {
8325 if (!select) return;
8326 const values = String(value || "").split(",").map(v => v.trim()).filter(Boolean);
8327 Array.from(select.options).forEach(option => { option.selected = values.includes(option.value); });
8328 syncCmsSelect(select);
8329 select.dispatchEvent(new Event("change", { bubbles: true }));
8332 function getSelectValues(select) {
8333 if (!select) return "";
8334 return Array.from(select.selectedOptions).map(option => option.value).join(",");
8337 function setFolderField(root, name, value) {
8338 const el = qs(root, `[data-cms-folder-field="${name}"]`);
8340 el.value = value == null ? "" : String(value);
8342 if (name === "hero_image_id") renderHeroPreview(root);
8343 if (name === "seo_image_id") renderSeoPreview(root);
8346 function getFolderField(root, name) {
8347 const el = qs(root, `[data-cms-folder-field="${name}"]`);
8348 return el ? el.value : "";
8351 function findNode(root, type, id) {
8352 const list = state(root).flat || [];
8353 return list.find(node => node._type === type && Number(node._id) === Number(id)) || null;
8356 function buildParentOptions(root, currentId, selectedParent) {
8357 const s = state(root);
8358 const select = qs(root, '[data-cms-folder-field="parent_id"]');
8359 if (!select) return;
8361 const forbidden = new Set();
8362 function markForbidden(node) {
8363 if (!node || node._type !== "folder") return;
8364 forbidden.add(Number(node._id));
8365 (node._children || []).forEach(markForbidden);
8367 if (currentId > 0) markForbidden(findNode(root, "folder", currentId));
8369 let html = '<option value="0">Root / erste Ebene</option>';
8370 function walk(nodes, depth) {
8371 (nodes || []).forEach(node => {
8372 if (node._type !== "folder") return;
8373 const id = Number(node._id || 0);
8374 if (!forbidden.has(id)) {
8375 const pad = depth > 0 ? Array(depth + 1).join("-- ") : "";
8376 html += `<option value="${escapeHtml(id)}">${escapeHtml(pad + (node._title || "Ordner " + id))}</option>`;
8378 walk(node._children || [], depth + 1);
8381 walk(s.tree || [], 0);
8382 select.innerHTML = html;
8383 select.value = String(selectedParent || 0);
8384 syncCmsSelect(select);
8387 function showFolderEditor(root, folder, anchor) {
8388 const panel = qs(root, "[data-cms-folder-editor]");
8389 const title = qs(root, "[data-cms-folder-title]");
8390 const rights = qs(root, "[data-cms-folder-rights]");
8391 const s = state(root);
8393 s.folder = folder || null;
8394 if (!panel || !folder) return;
8396 s.heroPreviewRow = null;
8397 root.classList.add("is-folder-editing");
8398 updateHeaderActionTooltips(root);
8400 const id = Number(folder._id || folder.id || 0);
8401 const parentId = Number(folder._parent ?? folder.parent_id ?? s.selectedFolder ?? 0);
8402 const name = folder._title || folder.name || "";
8403 const template = folder._template || folder.template || "";
8404 const folderRights = folder._rights || folder.group_read || (parentId > 0 ? "parent" : "*");
8405 const settingFields = ["hero_template", "hero_image_id", "hero_margin_top", "hero_height", "hero_variant", "hero_sticky", "hero_scroll_layer"];
8406 updateCurrentSelectionTitle(root, "folder", id, name);
8408 panel.hidden = false;
8409 panel.classList.toggle("is-new", id <= 0);
8410 if (title) title.textContent = id > 0 ? "Ordner bearbeiten" : "Neuen Ordner anlegen";
8411 setFolderField(root, "id", id);
8412 setFolderField(root, "name", name);
8413 buildParentOptions(root, id, parentId);
8414 setFolderField(root, "template", template);
8415 setSelectValues(rights, folderRights);
8416 settingFields.forEach(key => {
8417 const value = folder["_" + key] || folder[key] || "parent";
8418 setFolderField(root, key, value);
8419 setField(root, key, value);
8421 renderHeroPreview(root);
8422 const deleteButton = qs(panel, "[data-cms-folder-delete]");
8423 if (deleteButton) deleteButton.hidden = id <= 0;
8425 panel.style.removeProperty("left");
8426 panel.style.removeProperty("top");
8428 if (typeof window.multiselect2 === "function") {
8429 window.multiselect2(rights && (rights.id || rights.name) ? (rights.id || rights.name) : "group_read");
8432 const first = qs(panel, '[data-cms-folder-field="name"]');
8433 if (first) window.setTimeout(() => first.focus(), 20);
8436 function hideFolderEditor(root) {
8437 const panel = qs(root, "[data-cms-folder-editor]");
8438 if (panel) panel.hidden = true;
8439 root.classList.remove("is-folder-editing");
8440 updateHeaderActionTooltips(root);
8441 const pageTitle = getField(root, "title");
8442 updateCurrentSelectionTitle(root, "page", getField(root, "id"), pageTitle);
8445 function saveFolder(root, cfg) {
8446 const s = state(root);
8447 const folder = s.folder;
8448 const url = cfgUrl(cfg, "savefolder");
8449 if (s.saving) return Promise.resolve();
8450 if (!folder || !url) {
8451 status(root, "Bitte erst einen Ordner im Tree waehlen.", "error");
8455 const id = Number(getFolderField(root, "id") || 0);
8456 const name = getFolderField(root, "name").trim();
8458 status(root, "Bitte eine Ordner-Bezeichnung eintragen.", "error");
8462 const rights = getSelectValues(qs(root, "[data-cms-folder-rights]"));
8463 setSaving(root, true);
8464 return fetchJson(apiUrl(url, cmsLngParams(root)), {
8466 headers: { "Content-Type": "application/json" },
8467 body: JSON.stringify({
8470 parent_id: Number(getFolderField(root, "parent_id") || 0),
8471 template: getFolderField(root, "template"),
8473 hero_template: getField(root, "hero_template") || getFolderField(root, "hero_template") || "parent",
8474 hero_image_id: getField(root, "hero_image_id") || getFolderField(root, "hero_image_id") || "parent",
8475 hero_margin_top: getField(root, "hero_margin_top") || getFolderField(root, "hero_margin_top") || "parent",
8476 hero_height: getField(root, "hero_height") || getFolderField(root, "hero_height") || "parent",
8477 hero_variant: getField(root, "hero_variant") || getFolderField(root, "hero_variant") || "parent",
8478 hero_sticky: getField(root, "hero_sticky") || getFolderField(root, "hero_sticky") || "parent",
8479 hero_scroll_layer: getField(root, "hero_scroll_layer") || getFolderField(root, "hero_scroll_layer") || "parent",
8482 if (!data || !data.ok) throw new Error("folder save failed");
8483 const synced = Number(data.lng_synced || 0);
8484 const saveStatus = formatLngSaveStatus(
8485 synced > 0 ? "Ordner gespeichert." : "Ordner gespeichert.",
8488 status(root, saveStatus.text, saveStatus.type);
8489 clearDirtyAfterSave(root);
8490 setSelectedFolder(root, data.id || id || 0);
8491 setSelectedType(root, "folder");
8492 return loadTree(root, cfg).then(() => {
8493 const fresh = findNode(root, "folder", data.id || id);
8495 const mediaRows = (s.mediaRows || []).slice();
8496 const heroPreviewRow = s.heroPreviewRow || null;
8497 showFolderEditor(root, fresh);
8498 s.mediaRows = mediaRows;
8499 s.heroPreviewRow = heroPreviewRow;
8500 renderHeroPreview(root);
8502 revealTreeSelection(root);
8504 maybeOpenLngProvisionAfterCreate(root, cfg, data);
8507 dbx.error("[cms] folder save failed", err);
8508 status(root, "Ordner konnte nicht gespeichert werden.", "error");
8510 setSaving(root, false);
8511 clearCmsLoading(root);
8515 function deleteFolder(root, cfg) {
8516 const id = Number(getFolderField(root, "id") || 0);
8517 const name = getFolderField(root, "name") || "Ordner";
8518 const url = cfgUrl(cfg, "deletefolder");
8520 status(root, "Nur gespeicherte Ordner koennen geloescht werden.", "error");
8524 return openLngDeleteDialog(root, cfg, "folder", id, name).catch(err => {
8525 const msg = err && err.message ? err.message : "Ordner konnte nicht geloescht werden.";
8526 dbx.error("[cms] folder delete failed", err);
8527 status(root, msg, "error");
8531 function moveNode(root, cfg, type, id, targetFolder, position) {
8532 const url = cfgUrl(cfg, "movenode");
8533 if (!url || !type || !id || targetFolder < 0) return Promise.resolve();
8538 target_folder: targetFolder
8540 if (position && position.before_id) payload.before_id = position.before_id;
8541 if (position && position.after_id) payload.after_id = position.after_id;
8543 return fetchJson(apiUrl(url), {
8545 headers: { "Content-Type": "application/json" },
8546 body: JSON.stringify(payload)
8548 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "move failed");
8549 status(root, "Tree-Eintrag verschoben.", "success");
8550 return loadTree(root, cfg);
8552 dbx.error("[cms] move node failed", err);
8553 status(root, err && err.message ? err.message : "Tree-Eintrag konnte nicht verschoben werden.", "error");
8557 function syncSelectTitle(el) {
8558 if (!el || el.tagName !== "SELECT") return;
8559 const selected = el.selectedOptions && el.selectedOptions.length ? el.selectedOptions[0] : null;
8560 el.title = selected ? selected.textContent.trim() : "";
8563 function selectedOptionText(select) {
8564 const selected = select && select.selectedOptions && select.selectedOptions.length ? select.selectedOptions[0] : null;
8565 return selected ? selected.textContent.trim() : "";
8568 function syncCmsSelect(select) {
8569 syncSelectTitle(select);
8570 if (select && typeof select.__dbxCmsSelectRender === "function") {
8571 select.__dbxCmsSelectRender();
8575 function syncDetailsToggleIcon(details) {
8576 const icon = qs(details, ".dbx-cms-toggle-icon:not(.dbx-cms-toggle-icon-open):not(.dbx-cms-toggle-icon-closed)");
8578 icon.classList.toggle("bi-chevron-down", !!details.open);
8579 icon.classList.toggle("bi-chevron-right", !details.open);
8581 const openIcon = qs(details, ".dbx-cms-toggle-icon-open");
8582 const closedIcon = qs(details, ".dbx-cms-toggle-icon-closed");
8583 if (openIcon) openIcon.style.display = details.open ? "inline-block" : "none";
8584 if (closedIcon) closedIcon.style.display = details.open ? "none" : "inline-block";
8587 function initDetailsToggles(root) {
8588 const saved = dbx.uiGet ? dbx.uiGet(LIB, PANEL_UI_ID, "openPanels", {}) : {};
8589 const openPanels = (saved && typeof saved === "object" && !Array.isArray(saved)) ? saved : {};
8590 qsa(root, ".dbx-cms-page-panel, .dbx-cms-settings-panel").forEach((details, index) => {
8591 const key = details.getAttribute("data-cms-ui-state") || ("panel-" + index);
8592 details.setAttribute("data-cms-ui-state", key);
8593 if (Object.prototype.hasOwnProperty.call(openPanels, key)) {
8594 details.open = !!openPanels[key];
8596 syncDetailsToggleIcon(details);
8597 if (details.__dbxCmsToggleReady) return;
8598 details.__dbxCmsToggleReady = true;
8599 details.addEventListener("toggle", () => {
8600 syncDetailsToggleIcon(details);
8601 const state = dbx.uiGet ? dbx.uiGet(LIB, PANEL_UI_ID, "openPanels", {}) : {};
8602 const next = (state && typeof state === "object" && !Array.isArray(state)) ? state : {};
8603 next[key] = !!details.open;
8604 if (dbx.uiSet) dbx.uiSet(LIB, PANEL_UI_ID, "openPanels", next);
8609 function closeCmsSelects(root, except) {
8610 qsa(root, ".dbx-cms-select.is-open").forEach(wrapper => {
8611 if (wrapper === except) return;
8612 wrapper.classList.remove("is-open");
8613 const button = qs(wrapper, ".dbx-cms-select-control");
8614 const menu = qs(wrapper, ".dbx-cms-select-menu");
8615 if (button) button.setAttribute("aria-expanded", "false");
8616 if (menu) menu.hidden = true;
8620 function buildCmsSelect(root, select) {
8621 if (!select || select.multiple || select.dataset.dbxCmsSelectReady === "1") return;
8623 select.dataset.dbxCmsSelectReady = "1";
8624 select.classList.add("dbx-cms-select-source");
8626 const wrapper = document.createElement("div");
8627 wrapper.className = "dbx-cms-select";
8629 const button = document.createElement("button");
8630 button.type = "button";
8631 button.className = "dbx-cms-select-control";
8632 button.setAttribute("aria-haspopup", "listbox");
8633 button.setAttribute("aria-expanded", "false");
8635 const value = document.createElement("span");
8636 value.className = "dbx-cms-select-value";
8638 const icon = document.createElement("i");
8639 icon.className = "bi bi-chevron-down";
8640 icon.setAttribute("aria-hidden", "true");
8642 const menu = document.createElement("div");
8643 menu.className = "dbx-cms-select-menu";
8644 menu.setAttribute("role", "listbox");
8647 button.appendChild(value);
8648 button.appendChild(icon);
8649 wrapper.appendChild(button);
8650 wrapper.appendChild(menu);
8651 select.insertAdjacentElement("afterend", wrapper);
8654 closeCmsSelects(root, wrapper);
8655 wrapper.classList.add("is-open");
8656 button.setAttribute("aria-expanded", "true");
8657 menu.hidden = false;
8662 wrapper.classList.remove("is-open");
8663 button.setAttribute("aria-expanded", "false");
8668 const text = selectedOptionText(select) || "Auswahl";
8669 value.textContent = text;
8670 button.title = text;
8671 button.disabled = select.disabled;
8672 menu.innerHTML = "";
8674 Array.from(select.options).forEach(option => {
8675 if (option.disabled) return;
8676 const row = document.createElement("button");
8677 row.type = "button";
8678 row.className = "dbx-cms-select-option";
8679 row.dataset.value = option.value;
8680 row.setAttribute("role", "option");
8681 row.setAttribute("aria-selected", option.selected ? "true" : "false");
8682 if (option.selected) row.classList.add("is-selected");
8684 const label = document.createElement("span");
8685 label.textContent = option.textContent.trim();
8686 const check = document.createElement("i");
8687 check.className = option.selected ? "bi bi-check2" : "bi";
8688 check.setAttribute("aria-hidden", "true");
8689 row.appendChild(label);
8690 row.appendChild(check);
8692 row.addEventListener("click", event => {
8693 event.preventDefault();
8694 event.stopPropagation();
8695 select.value = option.value;
8696 syncSelectTitle(select);
8697 select.dispatchEvent(new Event("input", { bubbles: true }));
8698 select.dispatchEvent(new Event("change", { bubbles: true }));
8704 menu.appendChild(row);
8707 syncSelectTitle(select);
8710 button.addEventListener("click", event => {
8711 event.preventDefault();
8712 event.stopPropagation();
8713 if (wrapper.classList.contains("is-open")) close();
8716 button.addEventListener("keydown", event => {
8717 if (event.key === "Escape") {
8721 if (event.key === "Enter" || event.key === " " || event.key === "ArrowDown") {
8722 event.preventDefault();
8726 select.addEventListener("change", render);
8727 select.__dbxCmsSelectRender = render;
8731 function initCmsSelects(root) {
8732 qsa(root, "select[data-cms-field]:not([multiple]), select[data-cms-folder-field]:not([multiple])").forEach(select => {
8733 buildCmsSelect(root, select);
8737 function setField(root, name, value) {
8738 const el = qs(root, `[data-cms-field="${name}"]`);
8741 const values = String(value == null ? "" : value).split(",").map(v => v.trim()).filter(Boolean);
8742 Array.from(el.options).forEach(opt => { opt.selected = values.includes(opt.value); });
8746 el.value = value == null ? "" : String(value);
8750 function getField(root, name) {
8751 const el = qs(root, `[data-cms-field="${name}"]`);
8754 return Array.from(el.selectedOptions).map(opt => opt.value).join(",");
8759 function loadViewPage(root, cfg, id, opt) {
8761 const s = state(root);
8762 const url = cfgUrl(cfg, "viewpage");
8763 const box = qs(root, "[data-cms-content-view]");
8764 if (!url || !id) return Promise.resolve();
8765 if (box) box.innerHTML = '<div class="dbx-cms-empty">Seite wird geladen...</div>';
8767 return fetchJson(apiUrl(url, { id }), { footerRuntime: opt.footerRuntime || "" })
8769 if (!data || !data.ok) throw new Error("bad response");
8770 const pageId = Number(data.id || id);
8771 s.page = { id: pageId, title: data.title || "" };
8772 setSelectedPage(root, pageId);
8773 setSelectedType(root, "page");
8774 if (box) box.innerHTML = data.html || '<div class="dbx-cms-empty">Keine Ansicht vorhanden.</div>';
8775 updateViewPageTitle(root, data.title || "");
8776 revealTreeSelection(root);
8779 dbx.error("[cms] content view load failed", err);
8780 if (box) box.innerHTML = '<div class="dbx-cms-empty">Seite konnte nicht geladen werden.</div>';
8781 status(root, "Seite konnte nicht geladen werden.", "error");
8785 function loadPage(root, cfg, id) {
8786 const s = state(root);
8787 const url = cfgUrl(cfg, "page");
8788 const pageId = Number(id || 0);
8789 if (!url || pageId <= 0) return Promise.resolve();
8791 const loadSeq = Number(s.pageLoadSeq || 0) + 1;
8792 s.pageLoadSeq = loadSeq;
8795 return fetchJson(apiUrl(url, Object.assign({ id: pageId }, cmsLngParams(root))))
8797 if (loadSeq !== s.pageLoadSeq) return;
8798 if (!data || !data.ok) throw new Error("bad response");
8799 const row = data.row || {};
8801 hideFolderEditor(root);
8803 s.heroPreviewRow = data.hero_preview_media || null;
8804 s.heroParentPreviewRow = data.hero_parent_preview_media || null;
8805 s.seoPreviewRow = data.seo_preview_media || null;
8806 setSelectedPage(root, row.id || pageId);
8807 setSelectedFolder(root, row.folder || 0);
8808 setSelectedType(root, "page");
8810 ["id", "folder", "title", "permalink", "description", "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 => {
8811 setField(root, key, row[key] || "");
8815 setEditorHtml(root, row.content || "");
8817 dbx.error("[cms] editor update failed", err);
8820 updateCurrentSelectionTitle(root, "page", row.id || pageId, row.title || "Unbenannte Seite");
8822 revealTreeSelection(root);
8823 renderMedia(root, data.media || []);
8824 renderSeoPreview(root);
8825 setDirty(root, false);
8829 if (loadSeq !== s.pageLoadSeq) return;
8831 dbx.error("[cms] page load failed", err);
8832 status(root, "Seite konnte nicht geladen werden.", "error");
8836 function selectTreePage(root, cfg, id) {
8837 const pageId = Number(id || 0);
8838 if (!pageId) return Promise.resolve();
8839 status(root, "Seite wird geladen...", "info");
8840 closeTreePanel(root);
8841 return loadPage(root, cfg, pageId);
8844 function selectTreeFolder(root, cfg, row, options) {
8845 if (!row || !root.contains(row) || row.getAttribute("data-type") !== "folder") return;
8846 const id = Number(row.getAttribute("data-id") || 0);
8848 setSelectedFolder(root, id);
8849 setSelectedType(root, "folder");
8851 if (!options || !options.silent) {
8852 status(root, "Ordner gewaehlt.", "info");
8856 function openFolderEditorFromRow(root, cfg, row) {
8857 if (!row || !root.contains(row) || row.getAttribute("data-type") !== "folder") return;
8858 const id = Number(row.getAttribute("data-id") || 0);
8859 const folder = findNode(root, "folder", id);
8860 if (!folder) return;
8861 setSelectedFolder(root, id);
8862 setSelectedType(root, "folder");
8863 showFolderEditor(root, folder, row);
8864 loadMedia(root, cfg);
8865 revealTreeSelection(root);
8866 status(root, "Ordner bearbeiten.", "info");
8867 closeTreePanel(root);
8870 function activateTreeRow(root, cfg, row) {
8871 if (!row || !root.contains(row)) return;
8872 const type = row.getAttribute("data-type");
8873 const id = Number(row.getAttribute("data-id") || 0);
8874 const stamp = String(type || "") + ":" + String(id || 0);
8875 const s = state(root);
8876 const now = Date.now();
8877 if (s.lastTreeActivate === stamp && now - Number(s.lastTreeActivateAt || 0) < 350) {
8880 s.lastTreeActivate = stamp;
8881 s.lastTreeActivateAt = now;
8883 if (isViewMode(cfg)) {
8884 if (type === "page") {
8885 loadViewPage(root, cfg, id, { footerRuntime: "visible" }).finally(() => {
8886 applyTreePanelState(root, true);
8887 forceCollapseTreePanel(root);
8888 if (dbx.uiSet) dbx.uiSet(LIB, PANEL_UI_ID, "treeCollapsed", true);
8893 if (type === "folder") {
8894 selectTreeFolder(root, cfg, row, { silent: true });
8897 if (type === "page") {
8898 selectTreePage(root, cfg, id);
8902 function loadMedia(root, cfg) {
8903 const url = cfgUrl(cfg, "media");
8904 const pageId = Number(getField(root, "id") || 0);
8905 const folderId = root.classList.contains("is-folder-editing") ? Number(getFolderField(root, "id") || 0) : 0;
8906 if (!url || (!pageId && !folderId)) return Promise.resolve();
8908 const params = folderId > 0 ? { folder_id: folderId, usage: 1 } : { content_id: pageId, usage: 1 };
8909 return fetchJson(apiUrl(url, params))
8911 if (!data || !data.ok) throw new Error("bad response");
8912 renderMedia(root, data.rows || []);
8915 dbx.warn("[cms] media refresh failed", err);
8919 function reloadCms(root, cfg) {
8920 const s = state(root);
8921 const wasDirty = !!s.dirty;
8922 const selectedType = root.classList.contains("is-folder-editing") || s.selectedType === "folder"
8925 const folderId = selectedType === "folder"
8926 ? Number(getFolderField(root, "id") || s.selectedFolder || 0)
8928 const pageId = selectedType === "page"
8929 ? Number(getField(root, "id") || s.selectedPage || 0)
8931 const finish = () => {
8932 setDirty(root, false);
8936 ? "Neu geladen. Nicht gespeicherte Änderungen wurden verworfen."
8937 : "CMS neu geladen.",
8942 status(root, "CMS wird neu geladen...", "info");
8943 s.selectionRestored = true;
8945 return loadTree(root, cfg).then(() => {
8946 if (selectedType === "folder" && folderId > 0) {
8947 const folder = findNode(root, "folder", folderId);
8949 setSelectedFolder(root, folderId);
8950 setSelectedType(root, "folder");
8951 showFolderEditor(root, folder);
8952 revealTreeSelection(root);
8953 return loadMedia(root, cfg).then(finish);
8957 const currentPageId = pageId > 0 && s.flat.some(node => (
8958 node._type === "page" && Number(node._id) === pageId
8960 const firstPage = currentPageId ? null : s.flat.find(node => node._type === "page");
8961 const reloadPageId = currentPageId || Number(firstPage && firstPage._id || 0);
8963 if (reloadPageId > 0) {
8964 return loadPage(root, cfg, reloadPageId).then(finish);
8967 hideFolderEditor(root);
8968 renderMedia(root, []);
8971 dbx.error("[cms] reload failed", err);
8972 status(root, "CMS konnte nicht neu geladen werden.", "error");
8976 function collectPage(root) {
8977 const html = serializeCmsMarkers(getEditorHtml(root));
8978 setField(root, "content", html);
8981 id: Number(getField(root, "id") || 0),
8982 folder: Number(getField(root, "folder") || 0),
8983 title: getField(root, "title"),
8984 permalink: getField(root, "permalink"),
8985 description: getField(root, "description"),
8986 template: getField(root, "template"),
8987 hero_template: getField(root, "hero_template") || "parent",
8988 hero_image_id: getField(root, "hero_image_id") || "parent",
8989 hero_margin_top: getField(root, "hero_margin_top") || "parent",
8990 hero_height: getField(root, "hero_height") || "parent",
8991 hero_variant: getField(root, "hero_variant") || "parent",
8992 hero_sticky: getField(root, "hero_sticky") || "parent",
8993 hero_scroll_layer: getField(root, "hero_scroll_layer") || "parent",
8994 gallery_template: "image-gallery",
8995 gallery_visible_count: "3",
8996 gallery_image_size: getField(root, "gallery_image_size") || "original",
8997 gallery_lightbox_width: getField(root, "gallery_lightbox_width") || "100vw",
8998 gallery_overflow: getField(root, "gallery_overflow") || "grid",
8999 gallery_click_behavior: getField(root, "gallery_click_behavior") || "lightbox",
9000 activ: getField(root, "activ"),
9002 inline_media_ids: collectInlineMediaIdsFromEditor(root)
9006 function collectInlineMediaIdsFromEditor(root) {
9007 const surface = editorSurface(root);
9008 const ids = new Set();
9009 const registerId = (value) => {
9010 const id = Number(value || 0);
9011 if (id > 0) ids.add(id);
9013 const registerNode = (node) => {
9014 if (!node || !node.getAttribute) return;
9015 registerId(node.getAttribute("data-cms-media-id"));
9016 const src = String(node.getAttribute("src") || "");
9017 const match = src.match(/dbx_mid=([0-9]+)/i);
9018 if (match) registerId(match[1]);
9022 qsa(surface, ".dbx-cms-inline-media[data-cms-media-id], figure.dbx-cms-inline-video-block[data-cms-media-id]").forEach(wrap => {
9023 if (!inlineMediaWrapperHasContent(wrap)) return;
9024 if (qs(wrap, "img[src*='dbx_mid=']")) return;
9027 qsa(surface, "img[data-cms-media-id], video[data-cms-media-id], iframe[data-cms-media-id]").forEach(registerNode);
9028 qsa(surface, "img[src*='dbx_mid=']").forEach(registerNode);
9031 return Array.from(ids).filter(Boolean);
9034 function inlineMediaIds(html) {
9035 const ids = new Set();
9036 String(html || "").replace(/dbx_mid=([0-9]+)/gi, (_, id) => {
9037 ids.add(Number(id));
9040 String(html || "").replace(/data-cms-media-id=["']?([0-9]+)/gi, (_, id) => {
9041 ids.add(Number(id));
9044 return Array.from(ids).filter(Boolean);
9047 function removeInlineMediaFromEditor(root, id) {
9048 id = Number(id || 0);
9049 if (!id) return false;
9051 const html = getEditorHtml(root);
9052 const doc = new DOMParser().parseFromString('<div data-root>' + String(html || "") + '</div>', "text/html");
9053 const wrap = doc.querySelector("[data-root]");
9054 if (!wrap) return false;
9056 let changed = false;
9057 const mediaNodes = Array.from(wrap.querySelectorAll("[data-cms-media-id], img, video, iframe, source"));
9058 mediaNodes.forEach(node => {
9059 if (!node || !node.getAttribute) return;
9061 const dataId = Number(node.getAttribute("data-cms-media-id") || 0);
9062 const src = node.getAttribute("src") || node.getAttribute("href") || "";
9063 const srcMatch = new RegExp("(?:dbx_mid=|data-cms-media-id=)" + id + "(?:[^0-9]|$)", "i").test(src);
9064 if (dataId !== id && !srcMatch) return;
9066 const target = node.closest(".dbx-cms-inline-media, .dbx-cms-inline-media-missing-wrap, figure") || node;
9067 if (target && target.parentNode) {
9068 target.parentNode.removeChild(target);
9073 if (!changed) return false;
9075 setEditorHtml(root, wrap.innerHTML);
9080 function savePage(root, cfg) {
9081 const url = cfgUrl(cfg, "save");
9082 const s = state(root);
9083 if (!url || s.saving) return Promise.resolve();
9084 let saveCommitted = false;
9085 let committedStatus = "Seite gespeichert.";
9087 setSaving(root, true);
9089 return fetchJson(apiUrl(url, cmsLngParams(root)), {
9091 headers: { "Content-Type": "application/json" },
9092 body: JSON.stringify(collectPage(root)),
9095 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "save failed");
9096 saveCommitted = true;
9097 const row = data.row || {};
9098 if (row && row.id) {
9100 suppressDirtyFor(root, 250);
9101 ["id", "folder", "title", "permalink", "description", "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 => {
9102 if (row[key] !== undefined) setField(root, key, row[key] || "");
9104 setSelectedPage(root, row.id || data.id || 0);
9105 setSelectedFolder(root, row.folder || 0);
9106 setSelectedType(root, "page");
9108 updateCurrentSelectionTitle(root, "page", row.id || data.id || 0, row.title || "Unbenannte Seite");
9109 if (row.content !== undefined) {
9111 setEditorHtml(root, row.content || "");
9113 dbx.warn("[cms] editor sync after save failed", err);
9118 s.heroPreviewRow = data.hero_preview_media || s.heroPreviewRow || null;
9119 s.heroParentPreviewRow = data.hero_parent_preview_media || s.heroParentPreviewRow || null;
9120 s.seoPreviewRow = data.seo_preview_media || s.seoPreviewRow || null;
9121 if (Array.isArray(data.media)) renderMedia(root, data.media);
9122 renderHeroPreview(root);
9123 renderSeoPreview(root);
9124 const saveStatus = formatLngSaveStatus("Seite gespeichert.", data);
9125 committedStatus = saveStatus.text;
9126 status(root, saveStatus.text, saveStatus.type);
9127 clearDirtyAfterSave(root);
9128 clearCmsLoading(root);
9129 return loadMedia(root, cfg)
9131 dbx.warn("[cms] media refresh after save failed", err);
9133 .then(() => loadTree(root, cfg))
9135 dbx.warn("[cms] refresh after save failed", err);
9136 status(root, "Seite gespeichert. Ansicht konnte nicht aktualisiert werden.", "success");
9139 revealTreeSelection(root);
9140 maybeOpenLngProvisionAfterCreate(root, cfg, data);
9145 if (saveCommitted) {
9146 dbx.warn("[cms] post-save refresh failed", err);
9147 status(root, committedStatus + " Ansicht konnte nicht vollstaendig aktualisiert werden.", "success");
9148 clearDirtyAfterSave(root);
9150 dbx.error("[cms] save failed", err);
9151 status(root, err && err.message ? err.message : "Seite konnte nicht gespeichert werden.", "error");
9154 setSaving(root, false);
9155 clearCmsLoading(root);
9159 function deletePage(root, cfg) {
9160 const id = Number(getField(root, "id") || 0);
9161 const title = getField(root, "title") || "Seite";
9162 const url = cfgUrl(cfg, "deletepage");
9164 status(root, "Bitte erst eine Seite auswaehlen.", "error");
9165 return Promise.resolve();
9168 return openLngDeleteDialog(root, cfg, "page", id, title).catch(err => {
9169 dbx.error("[cms] page delete failed", err);
9170 status(root, err && err.message ? err.message : "Seite konnte nicht geloescht werden.", "error");
9174 function renderMedia(root, rows) {
9175 const boxes = qsa(root, "[data-cms-media]");
9176 if (!boxes.length) return;
9177 const s = state(root);
9178 if (Array.isArray(rows)) s.mediaRows = rows.slice();
9179 rows = s.mediaRows || [];
9180 renderHeroPreview(root);
9181 renderSeoPreview(root);
9183 syncUploadSlot(root);
9184 const prefixes = { hero: "h", gallery: "g", inline: "i", shop: "s" };
9185 const mediaFilter = String(qs(root, "[data-cms-media-filter]")?.value || state(root).mediaFilter || "all");
9187 boxes.forEach(box => {
9188 const boxSlot = String(box.getAttribute("data-cms-media") || "all");
9189 const visible = (Array.isArray(rows) ? rows : []).filter(row => {
9190 const slot = String(row.slot || "").trim();
9191 if (boxSlot === "gallery" || boxSlot === "inline" || boxSlot === "shop") {
9192 if (slot !== boxSlot) return false;
9193 } else if (boxSlot === "all" && mediaFilter !== "all") {
9194 if (slot !== mediaFilter) return false;
9196 if (slot === "hero" && boxSlot !== "all") return false;
9200 if (!visible.length) {
9201 const empty = boxSlot === "gallery"
9202 ? "Keine Medien in der Gallery."
9203 : (boxSlot === "inline"
9204 ? "Keine Medien im Text."
9205 : (mediaFilter === "hero" ? "Kein Hero-Medium zugeordnet." : "Keine Medien in diesem Bereich."));
9206 box.innerHTML = `<div class="dbx-cms-empty">${empty}</div>`;
9210 const counters = {};
9211 box.innerHTML = visible.map(row => {
9212 const preview = mediaPreviewHtml(row);
9213 const canEmbed = !!row.url;
9214 const slot = String(row.slot || "inline");
9215 counters[slot] = (counters[slot] || 0) + 1;
9216 const badge = (prefixes[slot] || "m") + counters[slot];
9217 const originLabel = mediaOriginLabel(row);
9218 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 || "")}">
9219 <span class="dbx-cms-media-preview"><span class="dbx-cms-media-badge">${escapeHtml(badge)}</span>${preview}</span>
9220 <span class="dbx-cms-media-meta">
9221 <span class="dbx-cms-media-name">${escapeHtml(row.title || row.file_name || "Medium")}</span>
9222 <span class="dbx-cms-media-slot">${escapeHtml(originLabel)}</span>
9224 <span class="dbx-cms-media-actions">
9225 ${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>' : ''}
9226 <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>
9230 setupMediaLazyImages(box);
9234 function removeMedia(root, cfg, id, usageId, slot) {
9235 const url = cfgUrl(cfg, "removemedia");
9236 if (!url || (!id && !usageId)) return Promise.resolve();
9237 const pageId = Number(getField(root, "id") || 0);
9238 const folderId = root.classList.contains("is-folder-editing")
9239 ? Number(getFolderField(root, "id") || 0)
9240 : Number(getField(root, "folder") || 0);
9242 id: Number(id || 0),
9243 usage_id: Number(usageId || 0),
9245 folder_id: folderId || 0,
9248 return fetchJson(apiUrl(url), {
9250 headers: { "Content-Type": "application/json" },
9251 body: JSON.stringify(payload)
9254 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "remove failed");
9255 if (slot === "inline") removeInlineMediaFromEditor(root, id);
9256 if (Array.isArray(data.media || data.rows)) renderMedia(root, data.media || data.rows);
9257 status(root, "Medien-Zuordnung entfernt.", "success");
9258 return Array.isArray(data.media || data.rows) ? Promise.resolve(data) : loadMedia(root, cfg);
9261 dbx.error("[cms] media remove failed", err);
9262 status(root, "Medien-Zuordnung konnte nicht entfernt werden.", "error");
9266 function deleteMedia(root, cfg, id) {
9267 const url = cfgUrl(cfg, "deletemedia");
9268 if (!url || !id) return Promise.resolve();
9270 return fetchJson(apiUrl(url, { id }), {
9272 headers: { "Content-Type": "application/json" },
9273 body: JSON.stringify({ id: Number(id) })
9275 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "delete failed");
9276 status(root, "Mediendatei geloescht.", "success");
9277 return loadMedia(root, cfg);
9279 dbx.error("[cms] media delete failed", err);
9280 status(root, "Mediendatei konnte nicht geloescht werden.", "error");
9284 function autoRatioValue(changed, other, originalWidth, originalHeight) {
9285 const value = Number(changed?.value || 0);
9286 if (!changed || !other || !value || !originalWidth || !originalHeight) return;
9287 if (changed.hasAttribute("data-cms-media-edit-width")) {
9288 other.value = Math.max(1, Math.round(value * originalHeight / originalWidth));
9290 other.value = Math.max(1, Math.round(value * originalWidth / originalHeight));
9294 function mediaEditNeedsRebuild(modal) {
9295 return !modal || !!qs(modal, ".dbx-cms-media-edit-actions");
9298 function fitMediaEditDialog(modal) {
9299 const maxW = Math.min(1380, Math.floor(window.innerWidth * 0.96));
9300 const maxH = Math.min(1010, Math.floor(window.innerHeight * 0.94));
9301 const width = Math.min(760, maxW);
9302 const height = Math.min(640, maxH);
9303 modal.style.width = width + "px";
9304 modal.style.height = height + "px";
9305 modal.style.maxWidth = maxW + "px";
9306 modal.style.maxHeight = maxH + "px";
9309 function ensureMediaEditDialog(root) {
9310 const s = state(root);
9311 let modal = s.mediaEditDialog || qs(root, "[data-cms-media-edit]");
9312 if (modal && !document.documentElement.contains(modal)) {
9314 s.mediaEditDialog = null;
9316 if (mediaEditNeedsRebuild(modal)) {
9317 if (modal) modal.remove();
9319 s.mediaEditDialog = null;
9322 if (modal.parentNode !== document.body) document.body.appendChild(modal);
9323 modal.__dbxCmsRoot = root;
9324 s.mediaEditDialog = modal;
9327 modal = document.createElement("div");
9328 modal.className = "dbx-cms-media-edit";
9329 modal.setAttribute("data-cms-media-edit", "1");
9330 modal.__dbxCmsRoot = root;
9331 modal.hidden = true;
9333 <div class="dbx-cms-media-edit-head">
9334 <strong><i class="bi bi-crop"></i> Bild bearbeiten</strong>
9335 <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>
9337 <div class="dbx-cms-media-edit-body">
9338 <div class="dbx-cms-media-edit-preview" data-cms-media-edit-preview>
9339 <div class="dbx-cms-media-edit-stage" data-cms-media-edit-stage>
9340 <img src="" alt="" draggable="false" data-cms-media-edit-image>
9341 <span class="dbx-cms-media-edit-selection" data-cms-media-edit-selection hidden>
9342 <span class="dbx-cms-media-edit-selection-size" data-cms-media-edit-selection-size></span>
9345 <span class="dbx-cms-media-edit-info" data-cms-media-edit-info>X 0 px · Y 0 px · 0 x 0 px</span>
9347 <div class="dbx-cms-media-edit-grid">
9348 <label>X <input type="number" class="form-control form-control-sm" data-cms-media-edit-x value="0"></label>
9349 <label>Y <input type="number" class="form-control form-control-sm" data-cms-media-edit-y value="0"></label>
9350 <label>Breite <input type="number" class="form-control form-control-sm" data-cms-media-edit-width></label>
9351 <label>Hoehe <input type="number" class="form-control form-control-sm" data-cms-media-edit-height></label>
9352 <label class="dbx-cms-media-edit-ratio"><input type="checkbox" data-cms-media-edit-ratio checked> Ratio Resize</label>
9353 <div class="dbx-cms-media-edit-side-actions">
9354 <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>
9355 <button type="button" class="btn btn-primary btn-sm" data-cms-media-edit-crop><i class="bi bi-crop"></i><span>Zuschneiden</span></button>
9356 <span class="dbx-cms-media-edit-side-separator" aria-hidden="true"></span>
9357 <span class="dbx-cms-media-edit-hint">
9358 <span>Mit der Maus den gewuenschten Bildausschnitt waehlen.</span>
9359 <button type="button" class="btn btn-danger btn-sm" data-cms-media-edit-crop-apply>
9360 <i class="bi bi-crop"></i><span>Ausschnitt uebernehmen</span>
9366 document.body.appendChild(modal);
9367 s.mediaEditDialog = modal;
9371 function mediaEditImageSize(modal) {
9372 const img = qs(modal, "[data-cms-media-edit-image]");
9373 const row = modal.__dbxCmsEditRow || {};
9375 width: Number(img?.naturalWidth || row.width || 0),
9376 height: Number(img?.naturalHeight || row.height || 0)
9380 function clampMediaCrop(value, min, max) {
9381 return Math.max(min, Math.min(max, value));
9384 function renderMediaCropSelection(modal) {
9385 const preview = qs(modal, "[data-cms-media-edit-preview]");
9386 const stage = qs(modal, "[data-cms-media-edit-stage]");
9387 const img = qs(modal, "[data-cms-media-edit-image]");
9388 const selection = qs(modal, "[data-cms-media-edit-selection]");
9389 const info = qs(modal, "[data-cms-media-edit-info]");
9390 if (!preview || !stage || !img || !selection || !img.complete) {
9391 if (info) info.textContent = "X 0 px · Y 0 px · 0 x 0 px";
9395 const natural = mediaEditImageSize(modal);
9396 const imageRect = img.getBoundingClientRect();
9397 const stageRect = stage.getBoundingClientRect();
9398 if (!natural.width || !natural.height || !imageRect.width || !imageRect.height) return;
9400 const x = clampMediaCrop(Number(qs(modal, "[data-cms-media-edit-x]")?.value || 0), 0, natural.width - 1);
9401 const y = clampMediaCrop(Number(qs(modal, "[data-cms-media-edit-y]")?.value || 0), 0, natural.height - 1);
9402 const width = clampMediaCrop(Number(qs(modal, "[data-cms-media-edit-width]")?.value || natural.width), 1, natural.width - x);
9403 const height = clampMediaCrop(Number(qs(modal, "[data-cms-media-edit-height]")?.value || natural.height), 1, natural.height - y);
9404 const scaleX = imageRect.width / natural.width;
9405 const scaleY = imageRect.height / natural.height;
9406 const infoText = "X " + Math.round(x) + " px · Y " + Math.round(y) + " px · "
9407 + Math.round(width) + " x " + Math.round(height) + " px";
9408 if (info) info.textContent = infoText;
9410 selection.style.left = (imageRect.left - stageRect.left + x * scaleX) + "px";
9411 selection.style.top = (imageRect.top - stageRect.top + y * scaleY) + "px";
9412 selection.style.width = (width * scaleX) + "px";
9413 selection.style.height = (height * scaleY) + "px";
9415 const size = qs(selection, "[data-cms-media-edit-selection-size]");
9416 const isFullFrame = x === 0 && y === 0 && width >= natural.width && height >= natural.height;
9417 if (!modal.__dbxCmsCropActive && isFullFrame) {
9418 selection.hidden = true;
9419 if (size) size.textContent = "";
9422 selection.hidden = false;
9424 if (size) size.textContent = Math.round(width) + " × " + Math.round(height);
9427 function setMediaCropFields(modal, crop) {
9428 modal.__dbxCmsCropActive = true;
9429 const natural = mediaEditImageSize(modal);
9430 if (!natural.width || !natural.height) return;
9431 const x = clampMediaCrop(Math.round(Math.min(crop.x1, crop.x2)), 0, natural.width - 1);
9432 const y = clampMediaCrop(Math.round(Math.min(crop.y1, crop.y2)), 0, natural.height - 1);
9433 const right = clampMediaCrop(Math.round(Math.max(crop.x1, crop.x2)), x + 1, natural.width);
9434 const bottom = clampMediaCrop(Math.round(Math.max(crop.y1, crop.y2)), y + 1, natural.height);
9435 const width = qs(modal, "[data-cms-media-edit-width]");
9436 const height = qs(modal, "[data-cms-media-edit-height]");
9437 const inputX = qs(modal, "[data-cms-media-edit-x]");
9438 const inputY = qs(modal, "[data-cms-media-edit-y]");
9439 if (inputX) inputX.value = x;
9440 if (inputY) inputY.value = y;
9441 if (width) width.value = right - x;
9442 if (height) height.value = bottom - y;
9443 renderMediaCropSelection(modal);
9446 function mediaCropPoint(modal, event) {
9447 const img = qs(modal, "[data-cms-media-edit-image]");
9448 const natural = mediaEditImageSize(modal);
9449 if (!img || !natural.width || !natural.height) return null;
9450 const rect = img.getBoundingClientRect();
9451 if (!rect.width || !rect.height) return null;
9453 x: clampMediaCrop((event.clientX - rect.left) * natural.width / rect.width, 0, natural.width),
9454 y: clampMediaCrop((event.clientY - rect.top) * natural.height / rect.height, 0, natural.height),
9455 inside: event.clientX >= rect.left && event.clientX <= rect.right
9456 && event.clientY >= rect.top && event.clientY <= rect.bottom
9460 function mediaEditPayload(modal, action) {
9461 const row = modal.__dbxCmsEditRow || {};
9464 id: Number(row.id || 0),
9465 width: Number(qs(modal, "[data-cms-media-edit-width]")?.value || 0),
9466 height: Number(qs(modal, "[data-cms-media-edit-height]")?.value || 0),
9467 ratio: qs(modal, "[data-cms-media-edit-ratio]")?.checked ? 1 : 0,
9468 x: Number(qs(modal, "[data-cms-media-edit-x]")?.value || 0),
9469 y: Number(qs(modal, "[data-cms-media-edit-y]")?.value || 0)
9473 function resetMediaEditSelection(modal) {
9474 const x = qs(modal, "[data-cms-media-edit-x]");
9475 const y = qs(modal, "[data-cms-media-edit-y]");
9476 const w = qs(modal, "[data-cms-media-edit-width]");
9477 const h = qs(modal, "[data-cms-media-edit-height]");
9478 const selection = qs(modal, "[data-cms-media-edit-selection]");
9479 const natural = mediaEditImageSize(modal);
9480 modal.__dbxCmsCropActive = false;
9481 if (x) x.value = "0";
9482 if (y) y.value = "0";
9483 if (w && natural.width) w.value = natural.width;
9484 if (h && natural.height) h.value = natural.height;
9485 if (selection) selection.hidden = true;
9486 renderMediaCropSelection(modal);
9489 function previewMediaCrop(root, modal) {
9490 const img = qs(modal, "[data-cms-media-edit-image]");
9491 const selection = qs(modal, "[data-cms-media-edit-selection]");
9492 const payload = mediaEditPayload(modal, "crop");
9493 const natural = mediaEditImageSize(modal);
9495 status(root, "Kein Bild ausgewaehlt.", "error");
9498 if (!modal.__dbxCmsCropActive) {
9499 status(root, "Bitte zuerst einen Bildausschnitt waehlen.", "error");
9502 if (!img || !img.complete || !natural.width || !natural.height || payload.width < 1 || payload.height < 1) {
9503 status(root, "Bitte einen gueltigen Ausschnitt waehlen.", "error");
9506 const x = clampMediaCrop(Math.round(payload.x), 0, natural.width - 1);
9507 const y = clampMediaCrop(Math.round(payload.y), 0, natural.height - 1);
9508 const width = clampMediaCrop(Math.round(payload.width), 1, natural.width - x);
9509 const height = clampMediaCrop(Math.round(payload.height), 1, natural.height - y);
9510 const canvas = document.createElement("canvas");
9511 canvas.width = width;
9512 canvas.height = height;
9513 const ctx = canvas.getContext("2d");
9515 status(root, "Vorschau konnte nicht erstellt werden.", "error");
9519 ctx.drawImage(img, x, y, width, height, 0, 0, width, height);
9521 dbx.error("[cms] crop preview failed", err);
9522 status(root, "Vorschau konnte nicht erstellt werden.", "error");
9525 const previous = modal.__dbxCmsPendingCrop || { x: 0, y: 0 };
9526 modal.__dbxCmsPendingCrop = {
9528 x: Math.round(Number(previous.x || 0) + x),
9529 y: Math.round(Number(previous.y || 0) + y),
9533 img.onload = () => {
9534 resetMediaEditSelection(modal);
9535 fitMediaEditDialog(modal);
9537 img.src = canvas.toDataURL("image/png");
9538 if (selection) selection.hidden = true;
9539 status(root, "Ausschnitt als Vorschau erstellt. Mit Ausschnitt uebernehmen speichern.", "info");
9543 function refreshMediaEditPreview(modal, row) {
9544 if (!modal || !row) return;
9545 const img = qs(modal, "[data-cms-media-edit-image]");
9546 const w = qs(modal, "[data-cms-media-edit-width]");
9547 const h = qs(modal, "[data-cms-media-edit-height]");
9548 const x = qs(modal, "[data-cms-media-edit-x]");
9549 const y = qs(modal, "[data-cms-media-edit-y]");
9550 const selection = qs(modal, "[data-cms-media-edit-selection]");
9551 modal.__dbxCmsEditRow = row;
9552 modal.__dbxCmsPendingCrop = null;
9553 modal.__dbxCmsCropActive = false;
9554 if (x) x.value = "0";
9555 if (y) y.value = "0";
9556 if (selection) selection.hidden = true;
9557 const baseUrl = row.url || row.thumb_url || "";
9558 const url = baseUrl ? apiUrl(baseUrl) : "";
9559 if (w) w.value = row.width || "";
9560 if (h) h.value = row.height || "";
9561 if (!img || !url) return;
9562 img.onload = () => {
9563 const natural = mediaEditImageSize(modal);
9564 if (w && natural.width) w.value = natural.width;
9565 if (h && natural.height) h.value = natural.height;
9566 if (selection) selection.hidden = true;
9567 fitMediaEditDialog(modal);
9568 renderMediaCropSelection(modal);
9573 function reopenMediaBrowserAfterEdit(root, browser) {
9574 if (!browser || browser.hidden) return;
9575 openMediaBrowser(browser.__dbxCmsRoot || root, browserCfg(browser), {
9576 mode: browser.__dbxCmsMediaMode || "editor",
9577 slot: browser.__dbxCmsAssignSlot || currentMediaSlot(root),
9578 mediaFolder: browser.__dbxCmsMediaFolder || "",
9579 formDataExtra: browser.__dbxCmsFormDataExtra || null,
9580 afterAssign: browser.__dbxCmsAfterAssign
9584 function commitMediaEditAction(root, cfg, modal, action, options) {
9585 options = options || {};
9586 let payload = mediaEditPayload(modal, action);
9588 status(root, "Kein Bild ausgewaehlt.", "error");
9589 return Promise.resolve(false);
9591 if (action === "resize" && modal.__dbxCmsPendingCrop) {
9592 status(root, "Bitte den Ausschnitt zuerst uebernehmen oder den Dialog neu oeffnen.", "warning");
9593 return Promise.resolve(false);
9595 if (action === "crop" && modal.__dbxCmsPendingCrop) {
9596 payload = Object.assign({}, payload, {
9597 x: Number(modal.__dbxCmsPendingCrop.x || 0),
9598 y: Number(modal.__dbxCmsPendingCrop.y || 0),
9599 width: Number(modal.__dbxCmsPendingCrop.width || 0),
9600 height: Number(modal.__dbxCmsPendingCrop.height || 0)
9603 if (action === "crop" && !modal.__dbxCmsPendingCrop && !modal.__dbxCmsCropActive) {
9604 status(root, "Bitte zuerst einen Bildausschnitt waehlen.", "error");
9605 return Promise.resolve(false);
9607 if (action === "crop" && (payload.width < 1 || payload.height < 1)) {
9608 status(root, "Bitte einen gueltigen Ausschnitt waehlen.", "error");
9609 return Promise.resolve(false);
9611 const successMsg = action === "crop"
9612 ? "Bild zugeschnitten."
9613 : "Bild bearbeitet.";
9614 return editMedia(root, cfg, payload, {
9619 if (!data || !data.ok) return false;
9620 const updated = Array.isArray(data.rows) && data.rows[0] ? data.rows[0] : null;
9621 if (!updated) return false;
9622 const browserModal = qs(document, "[data-cms-media-browser]");
9623 if (browserModal) patchMediaBrowserRow(browserModal, updated);
9624 loadMedia(root, cfg);
9625 if (options.closeAfter) {
9626 modal.hidden = true;
9627 reopenMediaBrowserAfterEdit(root, qs(document, "[data-cms-media-browser]"));
9630 refreshMediaEditPreview(modal, updated);
9635 function bindMediaEditEvents(root, modal) {
9636 if (!modal || modal.__dbxCmsEventsBound) return;
9637 modal.__dbxCmsEventsBound = true;
9639 // Direkt am Dialog binden. Der Medienbrowser lebt in einem openWin-Fenster
9640 // und weitere UI-Layer koennen die delegierten Root-Events abfangen.
9641 modal.addEventListener("click", e => {
9642 const close = closestElement(e.target, "[data-cms-media-edit-close]");
9643 if (close && modal.contains(close)) {
9645 e.stopPropagation();
9646 modal.hidden = true;
9650 const resize = closestElement(e.target, "[data-cms-media-edit-resize]");
9651 const cropApply = closestElement(e.target, "[data-cms-media-edit-crop-apply]");
9652 const cropSave = closestElement(e.target, "[data-cms-media-edit-crop]");
9653 const cfg = modal.__dbxCmsCfg || {};
9655 if (cropApply && modal.contains(cropApply)) {
9657 e.stopPropagation();
9658 commitMediaEditAction(root, cfg, modal, "crop", { closeAfter: false });
9662 if (cropSave && modal.contains(cropSave)) {
9664 e.stopPropagation();
9665 previewMediaCrop(root, modal);
9669 if (resize && modal.contains(resize)) {
9671 e.stopPropagation();
9672 commitMediaEditAction(root, cfg, modal, "resize", { closeAfter: false });
9676 modal.addEventListener("input", e => {
9677 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]");
9678 if (!editInput || !modal.contains(editInput)) return;
9679 modal.__dbxCmsCropActive = true;
9680 if (editInput.hasAttribute("data-cms-media-edit-x") || editInput.hasAttribute("data-cms-media-edit-y")) {
9681 renderMediaCropSelection(modal);
9684 const ratio = qs(modal, "[data-cms-media-edit-ratio]");
9685 if (ratio && ratio.checked && !modal.__dbxCmsCropSelecting) {
9686 const row = modal.__dbxCmsEditRow || {};
9687 const w = qs(modal, "[data-cms-media-edit-width]");
9688 const h = qs(modal, "[data-cms-media-edit-height]");
9689 autoRatioValue(editInput, editInput === w ? h : w, Number(row.width || 0), Number(row.height || 0));
9691 renderMediaCropSelection(modal);
9694 const preview = qs(modal, "[data-cms-media-edit-preview]");
9696 preview.addEventListener("pointerdown", e => {
9697 if (e.button !== undefined && e.button !== 0) return;
9698 const point = mediaCropPoint(modal, e);
9699 if (!point || !point.inside) return;
9701 e.stopPropagation();
9702 modal.__dbxCmsCropSelecting = {
9703 pointerId: e.pointerId,
9709 if (preview.setPointerCapture && e.pointerId !== undefined) {
9710 preview.setPointerCapture(e.pointerId);
9712 setMediaCropFields(modal, modal.__dbxCmsCropSelecting);
9715 preview.addEventListener("pointermove", e => {
9716 const crop = modal.__dbxCmsCropSelecting;
9717 if (!crop || (e.pointerId !== undefined && crop.pointerId !== e.pointerId)) return;
9718 const point = mediaCropPoint(modal, e);
9723 setMediaCropFields(modal, crop);
9726 const finishSelection = e => {
9727 const crop = modal.__dbxCmsCropSelecting;
9728 if (!crop || (e.pointerId !== undefined && crop.pointerId !== e.pointerId)) return;
9729 const point = mediaCropPoint(modal, e);
9733 setMediaCropFields(modal, crop);
9735 if (preview.releasePointerCapture && e.pointerId !== undefined && preview.hasPointerCapture?.(e.pointerId)) {
9736 preview.releasePointerCapture(e.pointerId);
9738 modal.__dbxCmsCropSelecting = null;
9740 preview.addEventListener("pointerup", finishSelection);
9741 preview.addEventListener("pointercancel", finishSelection);
9744 window.addEventListener("resize", () => {
9745 if (!modal.hidden) {
9746 fitMediaEditDialog(modal);
9747 renderMediaCropSelection(modal);
9752 function openMediaEdit(root, cfg, row) {
9753 if (!row || !canEditImage(row)) {
9754 status(root, "Nur Rasterbilder koennen bearbeitet werden.", "error");
9757 const modal = ensureMediaEditDialog(root);
9758 bindMediaEditEvents(root, modal);
9759 // Der Medienbrowser kann aus einem CMS-openWin heraus ein weiteres openWin oeffnen.
9760 // Der Bildeditor liegt deshalb als Body-Layer ausserhalb des CMS-Fenster-Stacking-Contexts.
9761 const windowLevels = qsa(document, ".dbx-window")
9762 .map(win => Number(window.getComputedStyle(win).zIndex || 0))
9763 .filter(Number.isFinite);
9764 modal.style.zIndex = String(Math.max(3200, ...windowLevels) + 10);
9765 modal.__dbxCmsCfg = cfg || {};
9766 modal.__dbxCmsEditRow = row;
9767 modal.__dbxCmsPendingCrop = null;
9768 modal.__dbxCmsCropActive = false;
9769 const img = qs(modal, "[data-cms-media-edit-image]");
9770 const w = qs(modal, "[data-cms-media-edit-width]");
9771 const h = qs(modal, "[data-cms-media-edit-height]");
9772 const x = qs(modal, "[data-cms-media-edit-x]");
9773 const y = qs(modal, "[data-cms-media-edit-y]");
9774 const ratio = qs(modal, "[data-cms-media-edit-ratio]");
9776 img.onload = () => {
9777 const natural = mediaEditImageSize(modal);
9778 if (w && natural.width) w.value = natural.width;
9779 if (h && natural.height) h.value = natural.height;
9780 fitMediaEditDialog(modal);
9781 renderMediaCropSelection(modal);
9783 img.src = apiUrl(row.url || row.thumb_url || "");
9785 if (w) w.value = row.width || "";
9786 if (h) h.value = row.height || "";
9787 if (x) x.value = "0";
9788 if (y) y.value = "0";
9789 if (ratio) ratio.checked = true;
9790 modal.hidden = false;
9791 if (img && img.complete) {
9792 fitMediaEditDialog(modal);
9793 renderMediaCropSelection(modal);
9797 function editMedia(root, cfg, payload, options) {
9798 options = options || {};
9799 const url = cfgUrl(cfg, "editmedia");
9801 status(root, "Medienbearbeitung ist nicht konfiguriert.", "error");
9802 return Promise.resolve();
9804 return fetchJson(apiUrl(url), {
9806 headers: { "Content-Type": "application/json" },
9807 body: JSON.stringify(payload || {})
9809 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "edit failed");
9810 if (options.silent !== true) status(root, options.successMsg || "Bild bearbeitet.", "success");
9811 if (options.reload === false) return data;
9812 return loadMedia(root, cfg).then(() => data);
9814 dbx.error("[cms] media edit failed", err);
9815 if (options.silent !== true) status(root, err && err.message ? err.message : "Bild konnte nicht bearbeitet werden.", "error");
9820 function mediaRowsForResize(root, scope, modal) {
9822 if (scope === "all") {
9823 return mediaBrowserAllRows(modal).filter(canEditImage);
9825 if (scope === "visible") {
9826 return mediaBrowserRows(modal).filter(canEditImage);
9828 return selectedMediaBrowserImageRows(modal);
9831 const rows = state(root).mediaRows || [];
9832 if (scope === "all") {
9833 return rows.filter(canEditImage);
9835 if (scope === "visible") {
9836 const visibleIds = qsa(root, ".dbx-cms-media-item").map(el => Number(el.getAttribute("data-media-id") || 0)).filter(Boolean);
9837 return rows.filter(row => visibleIds.includes(Number(row.id || 0)) && canEditImage(row));
9839 const ids = qsa(root, "[data-cms-media-select]:checked").map(el => Number(el.value || 0)).filter(Boolean);
9840 return rows.filter(row => ids.includes(Number(row.id || 0)) && canEditImage(row));
9843 function bulkResizeMedia(root, cfg, scope, modal) {
9844 scope = scope || "selected";
9845 const rows = mediaRowsForResize(root, scope, modal);
9846 const ids = rows.map(row => Number(row.id || 0)).filter(Boolean);
9847 const host = batchControlHost(modal) || modal || root;
9848 const width = Number(qs(host, "[data-cms-bulk-resize-width]")?.value || 0);
9849 const height = Number(qs(host, "[data-cms-bulk-resize-height]")?.value || 0);
9850 const ratio = qs(host, "[data-cms-bulk-resize-ratio]")?.checked !== false;
9852 status(root, scope === "selected" ? "Bitte erst Bilder auswaehlen." : "Keine bearbeitbaren Bilder gefunden.", "error");
9855 if (!width && !height) {
9856 status(root, "Bitte Breite oder Hoehe fuer Resize eintragen.", "error");
9859 let chain = Promise.resolve({ ok: 0, failed: 0 });
9861 chain = chain.then(result => editMedia(root, cfg, {
9866 ratio: ratio ? 1 : 0
9871 if (ok) result.ok++;
9872 else result.failed++;
9873 status(root, "Resize laeuft: " + result.ok + " von " + ids.length + " Bildern bearbeitet.", "info");
9878 chain.then(result => {
9880 status(root, "Keine Bilder konnten resized werden.", "error");
9883 status(root, result.failed ? (result.ok + " Bilder resized, " + result.failed + " fehlgeschlagen.") : (result.ok + " Bilder resized."), result.failed ? "error" : "success");
9884 loadMedia(root, cfg);
9885 if (!modal || modal.hidden) return;
9886 openMediaBrowser(modal.__dbxCmsRoot || root, browserCfg(modal), {
9887 mode: modal.__dbxCmsMediaMode || "editor",
9888 slot: modal.__dbxCmsAssignSlot || currentMediaSlot(root),
9889 mediaFolder: modal.__dbxCmsMediaFolder || "",
9890 formDataExtra: modal.__dbxCmsFormDataExtra || null,
9891 afterAssign: modal.__dbxCmsAfterAssign
9896 function assignMedia(root, cfg, row, slot) {
9897 const url = cfgUrl(cfg, "assignmedia");
9898 const isFolderEditing = root.classList.contains("is-folder-editing");
9899 let pageId = Number(getField(root, "id") || 0);
9900 const folderId = isFolderEditing
9901 ? Number(getFolderField(root, "id") || 0)
9902 : Number(getField(root, "folder") || 0);
9903 const mediaId = Number(row && row.id || 0);
9904 const targetSlot = slot || currentMediaSlot(root);
9905 if (isFolderEditing && targetSlot === "hero") pageId = 0;
9906 if (!url || !mediaId || (!pageId && !(folderId && targetSlot === "hero"))) {
9907 status(root, "Bitte erst eine Seite und ein Bild waehlen.", "error");
9908 return Promise.resolve();
9911 return fetchJson(apiUrl(url), {
9913 headers: { "Content-Type": "application/json" },
9914 body: JSON.stringify({
9917 folder_id: folderId,
9921 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "assign failed");
9922 status(root, "Bild der Seite zugeordnet.", "success");
9923 const assignedRow = mediaRowWithUsage(data.row || row, data.usage || {}, targetSlot);
9924 return loadMedia(root, cfg).then(() => {
9925 upsertLocalMediaRow(root, assignedRow);
9929 dbx.error("[cms] media assign failed", err);
9930 status(root, "Bild konnte nicht zugeordnet werden.", "error");
9935 function saveMediaOrder(root, cfg, list) {
9936 const url = cfgUrl(cfg, "sortmedia");
9937 const pageId = Number(getField(root, "id") || 0);
9938 if (!url || !pageId) return Promise.resolve();
9939 const scope = list || root;
9940 const ids = qsa(scope, ".dbx-cms-media-item").map(item => Number(item.getAttribute("data-usage-id") || item.getAttribute("data-media-id") || 0)).filter(Boolean);
9941 if (!ids.length) return Promise.resolve();
9942 const slot = String((list && list.getAttribute("data-cms-media")) || "");
9944 return fetchJson(apiUrl(url), {
9946 headers: { "Content-Type": "application/json" },
9947 body: JSON.stringify({
9949 slot: slot || "all",
9953 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "sort failed");
9954 status(root, "Medien sortiert.", "success");
9955 return loadMedia(root, cfg);
9957 dbx.error("[cms] media sort failed", err);
9958 status(root, "Medien konnten nicht sortiert werden.", "error");
9962 function setMediaSlot(root, cfg, id, slot) {
9963 const url = cfgUrl(cfg, "setmediaslot");
9964 const pageId = Number(getField(root, "id") || 0);
9965 if (!url || !id || !slot) return Promise.resolve();
9966 const item = qs(root, `.dbx-cms-media-item[data-media-id="${String(id).replace(/"/g, '\\"')}"]`);
9967 const usageId = Number(item?.getAttribute("data-usage-id") || 0);
9969 return fetchJson(apiUrl(url), {
9971 headers: { "Content-Type": "application/json" },
9972 body: JSON.stringify({
9979 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "slot failed");
9980 status(root, "Medium zugeordnet.", "success");
9981 const updatedRow = data.row ? mediaRowWithUsage(data.row, data.usage || {}, slot) : null;
9982 return loadMedia(root, cfg).then(() => {
9983 if (updatedRow) upsertLocalMediaRow(root, updatedRow);
9987 dbx.error("[cms] media slot failed", err);
9988 status(root, "Medium konnte nicht zugeordnet werden.", "error");
9989 return loadMedia(root, cfg).then(() => null);
9991 clearCmsLoading(root);
9992 window.setTimeout(() => clearCmsLoading(root), 50);
9996 function addExternalVideo(root, cfg, form, options) {
9997 options = options || {};
9998 const url = cfgUrl(cfg, "externalvideo");
9999 const externalUrl = String(qs(form, 'input[name="url"]')?.value || "").trim();
10000 const isFolderEditing = !!closestElement(form, "[data-cms-folder-panel]");
10001 const pageId = isFolderEditing ? 0 : Number(getField(root, "id") || 0);
10002 const folderId = isFolderEditing
10003 ? Number(getFolderField(root, "id") || 0)
10004 : Number(getField(root, "folder") || 0);
10005 const slotInput = qs(form, 'input[name="slot"]');
10006 const slot = options.slot || (slotInput && slotInput.value ? slotInput.value : currentMediaSlot(root));
10008 status(root, "Externe Videos sind nicht konfiguriert.", "error");
10009 return Promise.resolve(null);
10011 if (!externalUrl) {
10012 status(root, "Bitte eine YouTube-URL eintragen.", "error");
10013 return Promise.resolve(null);
10015 if (!pageId && !(folderId && slot === "hero")) {
10016 status(root, "Bitte erst eine Seite speichern/auswaehlen.", "error");
10017 return Promise.resolve(null);
10020 return fetchJson(apiUrl(url), {
10022 headers: { "Content-Type": "application/json" },
10023 body: JSON.stringify({
10024 provider: "youtube",
10026 content_id: pageId,
10027 folder_id: folderId || 0,
10029 media_folder: "youtube"
10032 if (!data || !data.ok || !data.row) throw new Error(data && data.msg ? data.msg : "external video failed");
10033 status(root, "Externes Video hinzugefuegt.", "success");
10034 const assignedRow = mediaRowWithUsage(data.row, data.usage || {}, slot);
10035 if (options.insertExternal) insertMediaRow(root, assignedRow);
10037 return loadMedia(root, cfg).then(() => {
10038 upsertLocalMediaRow(root, assignedRow);
10039 if (typeof options.afterExternal === "function") options.afterExternal(Object.assign({}, data, { row: assignedRow }));
10040 return Object.assign({}, data, { row: assignedRow });
10043 dbx.error("[cms] external video failed", err);
10044 status(root, err && err.message ? err.message : "Externes Video konnte nicht hinzugefuegt werden.", "error");
10049 function uploadMedia(root, cfg, form, options) {
10050 options = options || {};
10051 const url = cfgUrl(cfg, "upload");
10052 const moduleUploadUrl = String(url || "").indexOf("modul_images_upload") >= 0;
10053 options.moduleUpload = moduleUploadUrl;
10054 const isFolderEditing = root.classList.contains("is-folder-editing");
10055 let pageId = Number(getField(root, "id") || 0);
10056 const folderId = isFolderEditing
10057 ? Number(getFolderField(root, "id") || 0)
10058 : Number(getField(root, "folder") || 0);
10059 const slotInput = qs(form, 'input[name="slot"]');
10060 const slot = slotInput && slotInput.value ? slotInput.value : currentMediaSlot(root);
10061 if (isFolderEditing && slot === "hero") pageId = 0;
10062 const browserModal = state(root).mediaBrowser;
10063 const browserOpen = browserModal && !browserModal.hidden;
10065 status(root, "Upload ist nicht konfiguriert.", "error");
10068 if (!options.pickMode && !options.moduleUpload && !browserOpen && !pageId && !(folderId && slot === "hero")) {
10069 status(root, "Bitte erst eine Seite speichern/auswaehlen.", "error");
10072 const files = selectedUploadFiles(form);
10073 if (!files.length) {
10074 status(root, "Bitte zuerst eine Datei auswaehlen oder in die Upload-Zone ziehen.", "error");
10075 updateUploadLabel(form);
10078 const selectedUploadFolder = qs(form, "[data-cms-upload-folder]")?.value || "";
10079 const maxUpload = Number(cfg && cfg.uploadmax || 0);
10080 if (maxUpload > 0) {
10081 const tooLarge = files.find(file => Number(file.size || 0) > maxUpload);
10083 status(root, "Datei ist zu gross: " + tooLarge.name + " (" + formatBytes(tooLarge.size) + "). Erlaubt sind maximal " + formatBytes(maxUpload) + ".", "error");
10088 const uploadOne = function (file) {
10089 const body = new FormData(form);
10090 body.delete("file");
10091 body.append("file", file, file.name);
10092 if (!options.moduleUpload) {
10093 body.set("content_id", pageId);
10094 body.set("folder_id", folderId || "0");
10096 const uploadFolderSelect = qs(form, "[data-cms-upload-folder]");
10097 if (!options.moduleUpload) {
10098 if (String(file.type || "").startsWith("video/")) {
10099 const videoFolder = firstMediaFolderOption(uploadFolderSelect, "videos") || firstMediaFolderOption(uploadFolderSelect, "video");
10100 if (videoFolder && !/^(videos|video)(\/|$)/.test(String(body.get("media_folder") || ""))) {
10101 body.set("media_folder", videoFolder);
10103 } else if (String(file.type || "").startsWith("image/")) {
10104 const imageFolder = firstMediaFolderOption(uploadFolderSelect, "img/");
10105 if (imageFolder && String(body.get("media_folder") || "").indexOf("img/") !== 0) {
10106 body.set("media_folder", imageFolder);
10110 if (form.hasAttribute("data-cms-upload")) {
10111 body.set("slot", slot);
10113 Object.keys(options.formDataExtra || {}).forEach(key => {
10114 if (options.formDataExtra[key] != null) {
10115 body.set(key, options.formDataExtra[key]);
10119 return fetchJson(apiUrl(url), {
10124 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "upload failed");
10125 data.upload_folder = String(body.get("media_folder") || "");
10130 files.reduce((chain, file) => {
10131 return chain.then(results => uploadOne(file).then(data => {
10132 results.push(data);
10135 }, Promise.resolve([]))
10137 const rows = data.map(item => item && item.row ? mediaRowWithUsage(item.row, item.usage || {}, slot) : null).filter(Boolean);
10138 const moduleUpload = data.some(item => item && Array.isArray(item.items));
10139 const uploadMsg = rows.length > 1
10140 ? rows.length + " Medien hochgeladen."
10141 : (moduleUpload ? "Modulbild gespeichert." : "Medium hochgeladen.");
10142 status(root, uploadMsg, "success");
10143 if (options.insertUploaded) rows.forEach(row => insertMediaRow(root, row));
10145 const uploadFolderAfterReset = qs(form, "[data-cms-upload-folder]");
10146 const refreshUploadFolder = data.find(item => item && item.upload_folder)?.upload_folder
10147 || (rows[0] && rows[0].media_folder)
10148 || selectedUploadFolder
10150 if (uploadFolderAfterReset && refreshUploadFolder && Array.from(uploadFolderAfterReset.options).some(option => option.value === refreshUploadFolder)) {
10151 uploadFolderAfterReset.value = refreshUploadFolder;
10153 updateUploadLabel(form);
10154 const modal = state(root).mediaBrowser;
10155 const browserOpen = modal && !modal.hidden;
10157 if (moduleUpload && options.pickMode) {
10158 reload = Promise.resolve();
10159 } else if (browserOpen) {
10160 reload = Promise.resolve();
10162 reload = loadMedia(root, cfg);
10164 reload.then(() => rows.forEach(row => upsertLocalMediaRow(root, row)));
10165 if (typeof options.afterUpload === "function") {
10166 reload.then(() => options.afterUpload({
10169 row: rows[0] || null,
10172 uploadFolder: refreshUploadFolder
10178 dbx.error("[cms] upload failed", err);
10179 const message = err && err.message ? String(err.message) : "";
10180 const uploadErr = message === "Decoding failed"
10181 ? "Upload fehlgeschlagen. Die Serverantwort konnte nicht gelesen werden; bitte Upload-Limit und Dateigroesse pruefen."
10182 : (message || "Upload fehlgeschlagen.");
10183 status(root, uploadErr, "error");
10186 clearCmsLoading(root);
10190 function insertMarker(root, marker, label) {
10191 const editor = qs(root, "[data-cms-editor]");
10192 if (!editor) return;
10193 if (marker === "dbx:split") marker = "dbx:col2";
10194 insertEditorMarkerElement(root, marker, label);
10197 function bind(root, cfg) {
10198 initDetailsToggles(root);
10199 initCmsSelects(root);
10200 bindAdminTreeOutsideClose(root, cfg || {});
10201 bindViewTreeHover(root, cfg || {});
10202 bindCmsKeyboardShortcuts(root, cfg || {});
10203 updateHeaderActionTooltips(root);
10205 if (!root.__dbxCmsMediaProcessEvents && dbx.event && typeof dbx.event.on === "function") {
10206 root.__dbxCmsMediaProcessEvents = true;
10207 dbx.event.on("process:after", data => {
10208 const proc = data && data.root;
10209 if (!proc || !proc.getAttribute) return;
10210 const modal = closestElement(proc, "[data-cms-media-browser]");
10211 if (!modal || !root.contains(modal)) return;
10212 if (proc.getAttribute("data-process-status") !== "finished" || proc.__dbxCmsFinishedHandled) return;
10213 proc.__dbxCmsFinishedHandled = true;
10214 status(root, "Medienwartung abgeschlossen.", "success");
10215 openMediaBrowser(root, cfg, {
10216 mode: modal.__dbxCmsMediaMode || "editor",
10217 slot: modal.__dbxCmsAssignSlot || currentMediaSlot(root),
10218 mediaFolder: modal.__dbxCmsMediaFolder || "",
10219 formDataExtra: modal.__dbxCmsFormDataExtra || null,
10220 afterAssign: modal.__dbxCmsAfterAssign
10225 if (!root.__dbxCmsTreePointerBound) {
10226 root.__dbxCmsTreePointerBound = true;
10227 let treePress = null;
10228 root.addEventListener("pointerdown", e => {
10229 if (e.button !== 0) return;
10230 const row = closestElement(e.target, ".dbx-cms-tree-row");
10231 if (!row || !root.contains(row) || closestElement(e.target, "[data-cms-tree-toggle]") || closestElement(e.target, "[data-cms-folder-edit-btn]")) return;
10232 treePress = { row, x: e.clientX, y: e.clientY, pointerId: e.pointerId, dragIntent: false };
10233 root.__dbxCmsTreePress = treePress;
10235 root.addEventListener("pointermove", e => {
10236 if (!treePress || treePress.pointerId !== e.pointerId) return;
10237 if (Math.abs(e.clientX - treePress.x) > 8 || Math.abs(e.clientY - treePress.y) > 8) {
10238 treePress.dragIntent = true;
10241 root.addEventListener("pointerup", e => {
10242 if (e.button !== 0 || !treePress || treePress.pointerId !== e.pointerId) return;
10243 const start = treePress;
10245 root.__dbxCmsTreePress = null;
10246 const row = closestElement(e.target, ".dbx-cms-tree-row");
10247 if (!row || row !== start.row || row.classList.contains("is-dragging")) return;
10248 if (Math.abs(e.clientX - start.x) > 8 || Math.abs(e.clientY - start.y) > 8) return;
10249 activateTreeRow(root, cfg, row);
10251 root.addEventListener("pointercancel", e => {
10252 if (!treePress || treePress.pointerId !== e.pointerId) return;
10254 root.__dbxCmsTreePress = null;
10258 root.addEventListener("click", e => {
10259 if (!closestElement(e.target, ".dbx-cms-select")) {
10260 closeCmsSelects(root);
10263 const row = closestElement(e.target, ".dbx-cms-tree-row");
10264 if (row && root.contains(row)) {
10265 const editBtn = closestElement(e.target, "[data-cms-folder-edit-btn]");
10266 if (editBtn && root.contains(editBtn)) {
10267 e.preventDefault();
10268 e.stopPropagation();
10269 openFolderEditorFromRow(root, cfg, row);
10272 const rightsEdit = closestElement(e.target, "[data-cms-folder-edit]");
10273 if (rightsEdit && root.contains(rightsEdit) && row.getAttribute("data-type") === "folder") {
10274 e.preventDefault();
10275 e.stopPropagation();
10276 openFolderEditorFromRow(root, cfg, row);
10279 const toggle = closestElement(e.target, "[data-cms-tree-toggle]");
10280 if (toggle && root.contains(toggle)) {
10281 e.preventDefault();
10282 e.stopPropagation();
10283 toggleTreeFolder(root, row);
10286 if (row.getAttribute("data-type") === "folder") {
10287 selectTreeFolder(root, cfg, row);
10290 activateTreeRow(root, cfg, row);
10294 const folderClose = closestElement(e.target, "[data-cms-folder-close]");
10295 if (folderClose && root.contains(folderClose)) {
10296 hideFolderEditor(root);
10300 const action = closestElement(e.target, "[data-cms-action]");
10301 if (action && root.contains(action)) {
10302 e.preventDefault();
10303 e.stopPropagation();
10304 if (e.stopImmediatePropagation) e.stopImmediatePropagation();
10306 const name = action.getAttribute("data-cms-action");
10307 if (name === "toggle-tree-panel") toggleTreePanel(root);
10308 if (name === "toggle-right-panel") toggleRightPanel(root, action);
10309 if (name === "save") saveCurrentCms(root, cfg);
10310 if (name === "save-settings") saveCurrentCms(root, cfg);
10311 if (name === "delete") deleteCurrentCms(root, cfg);
10312 if (name === "delete-page") deletePage(root, cfg);
10313 if (name === "save-folder") saveFolder(root, cfg);
10314 if (name === "delete-folder") deleteFolder(root, cfg);
10315 if (name === "reload") reloadCms(root, cfg);
10316 if (name === "open-admin") openContentAdmin(root);
10317 if (name === "duplicate-page") duplicateCurrentPage(root, cfg);
10318 if (name === "new-page") {
10319 const folder = state(root).selectedFolder || Number(getField(root, "folder") || 0);
10320 fetchJson(apiUrl(cfgUrl(cfg, "newpage"), { folder })).then(data => {
10321 if (data && data.ok) {
10322 return loadTree(root, cfg).then(() => loadPage(root, cfg, data.id)).then(() => {
10323 maybeOpenLngProvisionAfterCreate(root, cfg, data);
10326 throw new Error(data && data.msg ? data.msg : "new page failed");
10328 dbx.error("[cms] new page failed", err);
10329 status(root, err && err.message ? err.message : "Seite konnte nicht angelegt werden.", "error");
10332 if (name === "new-folder") {
10333 const parent = state(root).selectedFolder || Number(getField(root, "folder") || 0) || 0;
10334 status(root, "Bezeichnung fuer neuen Ordner eintragen und speichern.", "info");
10335 showFolderEditor(root, {
10339 _rights: parent > 0 ? "parent" : "*",
10343 if (name === "preview") {
10346 if (name === "lng-provision") {
10347 openLngProvisionDialog(root, cfg);
10349 if (name === "lng-reset-sync") {
10350 resetLngSync(root, cfg);
10352 if (name === "assign-media") {
10353 openMediaBrowser(root, cfg, { mode: "assign", slot: action.getAttribute("data-cms-slot") || currentMediaSlot(root) });
10355 if (name === "bulk-resize-media") {
10356 bulkResizeMedia(root, cfg, action.getAttribute("data-cms-resize-scope") || "selected");
10358 if (name === "assign-hero-media") {
10359 openMediaBrowser(root, cfg, {
10364 if (!row || !row.id) return;
10365 state(root).heroPreviewRow = row;
10366 setField(root, "hero_image_id", row.id);
10367 if (!getField(root, "hero_template") || getField(root, "hero_template") === "parent" || getField(root, "hero_template") === "none") {
10368 setField(root, "hero_template", "image-hero");
10371 renderHeroPreview(root);
10373 status(root, "Hero-Bild ausgewaehlt.", "success");
10377 if (name === "assign-seo-media") {
10378 openMediaBrowser(root, cfg, {
10382 if (!row || !row.id) return;
10383 state(root).seoPreviewRow = row;
10384 setField(root, "seo_image_id", row.id);
10386 renderSeoPreview(root);
10387 status(root, "OG-Bild ausgewaehlt.", "success");
10391 if (name === "clear-seo-media") {
10392 state(root).seoPreviewRow = null;
10393 setField(root, "seo_image_id", 0);
10395 renderSeoPreview(root);
10396 status(root, "OG-Bild entfernt.", "success");
10401 const browserClose = closestElement(e.target, "[data-cms-media-browser-close]");
10402 if (browserClose && root.contains(browserClose)) {
10403 const modal = closestElement(browserClose, "[data-cms-media-browser]");
10404 if (modal) modal.hidden = true;
10405 clearCmsLoading(root);
10409 const browserMaintenance = closestElement(e.target, "[data-cms-media-maintenance]");
10410 if (browserMaintenance && root.contains(browserMaintenance)) {
10411 const batchPanel = closestElement(browserMaintenance, "[data-cms-media-batch-window]");
10412 const browserModal = batchPanel && batchPanel.__dbxCmsBrowserModal
10413 ? batchPanel.__dbxCmsBrowserModal
10414 : closestElement(browserMaintenance, "[data-cms-media-browser]");
10415 renderMediaMaintenanceHome(root, cfg, browserModal, batchPanel || null);
10419 const processStart = closestElement(e.target, "[data-cms-media-process-start]");
10420 if (processStart && root.contains(processStart)) {
10421 const batchPanel = closestElement(processStart, "[data-cms-media-batch-window]");
10422 const browserModal = batchPanel && batchPanel.__dbxCmsBrowserModal
10423 ? batchPanel.__dbxCmsBrowserModal
10424 : closestElement(processStart, "[data-cms-media-browser]");
10425 startMediaMaintenance(root, cfg, browserModal, batchPanel || null);
10429 const unusedAction = closestElement(e.target, "[data-cms-media-unused-action]");
10430 if (unusedAction && root.contains(unusedAction)) {
10431 const browserModal = closestElement(unusedAction, "[data-cms-media-browser]");
10432 executeUnusedMediaMaintenance(root, cfg, browserModal, unusedAction.getAttribute("data-cms-media-unused-action"));
10436 const processClose = closestElement(e.target, "[data-cms-media-process-close]");
10437 if (processClose && root.contains(processClose)) {
10438 const panel = closestElement(processClose, "[data-cms-media-process-panel]");
10440 panel.hidden = true;
10441 panel.innerHTML = "";
10443 const modal = closestElement(processClose, "[data-cms-media-browser]");
10444 if (modal) modal.classList.remove("is-process-open");
10445 clearCmsLoading(root);
10449 const browserConfirm = closestElement(e.target, "[data-cms-media-browser-confirm]");
10450 if (browserConfirm && root.contains(browserConfirm)) {
10451 const modal = closestElement(browserConfirm, "[data-cms-media-browser]");
10452 if (modal && modal.__dbxCmsMediaMode === "pick") {
10453 confirmPickMediaBrowser(root, modal);
10456 const slot = modal && modal.__dbxCmsAssignSlot || currentMediaSlot(root);
10457 const rows = modal ? selectedMediaBrowserRows(modal) : [];
10458 if (!rows.length) {
10459 status(root, "Bitte mindestens ein Bild auswaehlen.", "error");
10463 let chain = Promise.resolve();
10464 rows.forEach(row => {
10465 chain = chain.then(() => assignMedia(root, cfg, row, slot).then(assignedRow => {
10466 if (!assignedRow) return;
10467 if (slot === "inline") {
10468 insertMediaRow(root, assignedRow);
10469 setLocalMediaSlot(root, assignedRow.id, "inline");
10471 if (modal && typeof modal.__dbxCmsAfterAssign === "function") {
10472 modal.__dbxCmsAfterAssign(assignedRow);
10478 modal.hidden = true;
10479 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.close === "function") {
10480 dbx.openWin.close(modal.__dbxCmsWindowId);
10483 clearCmsLoading(root);
10484 status(root, "Auswahl uebernommen.", "success");
10489 const browserDelete = closestElement(e.target, "[data-cms-media-browser-delete]");
10490 if (browserDelete && root.contains(browserDelete)) {
10491 const item = closestElement(browserDelete, "[data-cms-media-browser-item]");
10492 const modal = closestElement(browserDelete, "[data-cms-media-browser]");
10493 const mode = modal && modal.__dbxCmsMediaMode === "assign" ? "assign" : "editor";
10494 deleteMedia(root, cfg, Number(item?.getAttribute("data-media-id") || 0))
10495 .then(() => openMediaBrowser(root, cfg, { mode }));
10499 const browserEdit = closestElement(e.target, "[data-cms-media-browser-edit]");
10500 if (browserEdit && root.contains(browserEdit)) {
10501 const item = closestElement(browserEdit, "[data-cms-media-browser-item]");
10502 openMediaEdit(root, cfg, mediaRowFromItem(item));
10506 const browserPick = closestElement(e.target, "[data-cms-media-browser-pick]");
10507 if (browserPick && root.contains(browserPick)) {
10508 const item = closestElement(browserPick, "[data-cms-media-browser-item]") || browserPick;
10509 const mediaRow = mediaRowFromItem(item);
10510 const modal = closestElement(browserPick, "[data-cms-media-browser]");
10511 const mode = modal && modal.__dbxCmsMediaMode ? modal.__dbxCmsMediaMode : "editor";
10512 if (mode === "pick" || mode === "assign") {
10513 if (modal) toggleMediaBrowserSelection(modal, item);
10516 assignMedia(root, cfg, mediaRow, "inline").then(assignedRow => {
10517 if (!assignedRow) return;
10518 insertMediaRow(root, assignedRow);
10519 setLocalMediaSlot(root, assignedRow.id, "inline");
10520 if (modal) modal.hidden = true;
10521 clearCmsLoading(root);
10526 const cmd = closestElement(e.target, "[data-cms-cmd]");
10527 if (cmd && root.contains(cmd)) {
10528 execEditorCommand(root, cmd.getAttribute("data-cms-cmd"));
10532 const marker = closestElement(e.target, "[data-cms-marker]");
10533 if (marker && root.contains(marker)) {
10534 insertMarker(root, marker.getAttribute("data-cms-marker"));
10538 const mediaRemove = closestElement(e.target, "[data-cms-media-remove]");
10539 if (mediaRemove && root.contains(mediaRemove)) {
10540 const item = closestElement(mediaRemove, ".dbx-cms-media-item");
10541 const id = Number(item?.getAttribute("data-media-id") || 0);
10542 const usageId = Number(item?.getAttribute("data-usage-id") || 0);
10543 const slot = String(item?.getAttribute("data-media-slot") || "");
10544 removeMedia(root, cfg, id, usageId, slot);
10548 const mediaEdit = closestElement(e.target, "[data-cms-media-edit-one]");
10549 if (mediaEdit && root.contains(mediaEdit)) {
10550 const item = closestElement(mediaEdit, ".dbx-cms-media-item");
10551 openMediaEdit(root, cfg, mediaRowFromItem(item));
10555 const toolsToggle = closestElement(e.target, "[data-cms-media-tools-toggle]");
10556 if (toolsToggle && root.contains(toolsToggle)) {
10557 const menu = qs(root, "[data-cms-media-tools-menu]");
10558 if (menu) menu.hidden = !menu.hidden;
10562 if (!closestElement(e.target, ".dbx-cms-media-tools")) {
10563 const menu = qs(root, "[data-cms-media-tools-menu]");
10564 if (menu) menu.hidden = true;
10567 const bulkResize = closestElement(e.target, "[data-cms-action='bulk-resize-media']");
10568 if (bulkResize && root.contains(bulkResize)) {
10569 bulkResizeMedia(root, cfg, bulkResize.getAttribute("data-cms-resize-scope") || "selected");
10573 const editModal = closestElement(e.target, "[data-cms-media-edit]");
10574 if (editModal && root.contains(editModal)) {
10575 if (closestElement(e.target, "[data-cms-media-edit-close]")) {
10576 editModal.hidden = true;
10579 const resize = closestElement(e.target, "[data-cms-media-edit-resize]");
10580 const cropApply = closestElement(e.target, "[data-cms-media-edit-crop-apply]");
10581 const cropSave = closestElement(e.target, "[data-cms-media-edit-crop]");
10583 commitMediaEditAction(root, cfg, editModal, "crop", { closeAfter: false });
10587 previewMediaCrop(root, editModal);
10591 commitMediaEditAction(root, cfg, editModal, "resize", { closeAfter: false });
10596 const videoOptionsModal = closestElement(e.target, "[data-cms-video-options]");
10597 if (videoOptionsModal && root.contains(videoOptionsModal)) {
10598 if (closestElement(e.target, "[data-cms-video-options-close]")) {
10599 closeInlineVideoOptionsWindow(videoOptionsModal);
10602 if (closestElement(e.target, "[data-cms-video-options-apply]")) {
10603 applyInlineVideoOptions(root, videoOptionsModal);
10608 const inlineVideoOptionsOpen = closestElement(e.target, "[data-cms-inline-video-options-open]");
10609 if (inlineVideoOptionsOpen && root.contains(inlineVideoOptionsOpen)) {
10610 const video = inlineVideoTarget(root, inlineVideoOptionsOpen);
10612 e.preventDefault();
10613 e.stopPropagation();
10614 openInlineVideoOptions(root, video);
10619 const inlineVideoPlay = inlineVideoEventTarget(root, e);
10620 if (inlineVideoPlay && root.contains(inlineVideoPlay) && !closestElement(e.target, ".dbx-cms-context-menu")) {
10621 if (!closestElement(e.target, "[data-cms-inline-video-options-open]")
10622 && (closestElement(e.target, ".dbx-cms-inline-video-play") || !inlineVideoPlay.querySelector(".dbx-cms-inline-video-player, iframe, video"))) {
10623 e.preventDefault();
10624 e.stopPropagation();
10625 playInlineVideoBlock(root, inlineVideoPlay);
10628 if (!closestElement(e.target, "[data-cms-inline-video-options-open]") && !isInlineVideoResizeHandleEvent(inlineVideoPlay, e)) {
10629 e.preventDefault();
10630 e.stopPropagation();
10631 openInlineVideoOptions(root, inlineVideoPlay);
10636 const mediaInsert = closestElement(e.target, "[data-cms-media-insert]");
10637 if (mediaInsert && root.contains(mediaInsert)) {
10638 const item = closestElement(mediaInsert, ".dbx-cms-media-item");
10639 const url = item?.getAttribute("data-url") || "";
10640 if (url) insertMarker(root, "media:" + url);
10643 const mediaEmbed = closestElement(e.target, "[data-cms-media-embed]");
10644 if (mediaEmbed && root.contains(mediaEmbed)) {
10645 const item = closestElement(mediaEmbed, ".dbx-cms-media-item");
10646 const mediaRow = mediaRowFromItem(item);
10647 insertMediaRow(root, mediaRow);
10648 setLocalMediaSlot(root, mediaRow.id, "inline");
10653 const search = qs(root, "[data-cms-search]");
10655 bindTreeSearchClear(root, search);
10656 search.addEventListener("input", () => renderTree(root));
10659 qsa(root, "[data-cms-upload], [data-cms-hero-upload]").forEach(upload => {
10660 if (upload.__dbxCmsUploadBound) return;
10661 upload.__dbxCmsUploadBound = true;
10662 upload.addEventListener("submit", e => {
10663 e.preventDefault();
10664 if (upload.hasAttribute("data-cms-hero-upload")) {
10665 uploadMedia(root, cfg, upload, {
10666 afterUpload(data) {
10667 if (!data || !data.row || !data.row.id) return;
10668 state(root).heroPreviewRow = data.row;
10669 setField(root, "hero_image_id", data.row.id);
10670 if (!getField(root, "hero_template") || getField(root, "hero_template") === "parent" || getField(root, "hero_template") === "none") {
10671 setField(root, "hero_template", "image-hero");
10674 renderHeroPreview(root);
10675 status(root, "Hero-Bild hochgeladen.", "success");
10680 uploadMedia(root, cfg, upload);
10682 upload.addEventListener("change", () => updateUploadLabel(upload));
10685 qsa(root, "[data-cms-external-video]").forEach(form => {
10686 if (form.__dbxCmsExternalVideoBound) return;
10687 form.__dbxCmsExternalVideoBound = true;
10688 form.addEventListener("submit", e => {
10689 e.preventDefault();
10690 addExternalVideo(root, cfg, form);
10694 root.addEventListener("keydown", e => {
10695 const row = closestElement(e.target, ".dbx-cms-tree-row");
10696 if (!row || !root.contains(row) || row.getAttribute("data-type") !== "folder") return;
10697 if (e.key === "ArrowLeft") {
10698 e.preventDefault();
10699 toggleTreeFolder(root, row, true);
10700 } else if (e.key === "ArrowRight") {
10701 e.preventDefault();
10702 toggleTreeFolder(root, row, false);
10706 root.addEventListener("change", e => {
10707 const uploadForm = closestElement(e.target, "[data-cms-upload], [data-cms-hero-upload], [data-cms-browser-upload]");
10708 if (uploadForm && root.contains(uploadForm)) updateUploadLabel(uploadForm);
10709 const mediaFilter = closestElement(e.target, "[data-cms-media-filter]");
10710 if (mediaFilter && root.contains(mediaFilter)) {
10711 state(root).mediaFilter = mediaFilter.value || "all";
10712 syncUploadSlot(root);
10715 const ratioInput = closestElement(e.target, "[data-cms-bulk-resize-ratio]");
10716 if (ratioInput && root.contains(ratioInput)) {
10717 status(root, ratioInput.checked ? "Resize behaelt das Seitenverhaeltnis." : "Resize nutzt exakte Breite und Hoehe.", "info");
10719 const heroTemplate = closestElement(e.target, '[data-cms-field="hero_template"], [data-cms-folder-field="hero_template"]');
10720 if (heroTemplate && root.contains(heroTemplate)) {
10721 applyHeroTemplateChoice(root, heroTemplate);
10725 root.addEventListener("input", e => {
10726 const editInput = closestElement(e.target, "[data-cms-media-edit-width], [data-cms-media-edit-height]");
10727 if (editInput && root.contains(editInput)) {
10728 const modal = closestElement(editInput, "[data-cms-media-edit]");
10729 const ratio = qs(modal, "[data-cms-media-edit-ratio]");
10730 if (!modal || !ratio || !ratio.checked) return;
10731 const row = modal.__dbxCmsEditRow || {};
10732 const w = qs(modal, "[data-cms-media-edit-width]");
10733 const h = qs(modal, "[data-cms-media-edit-height]");
10734 autoRatioValue(editInput, editInput === w ? h : w, Number(row.width || 0), Number(row.height || 0));
10738 root.addEventListener("dragover", e => {
10739 const dropzone = closestElement(e.target, "[data-cms-dropzone]");
10740 if (!dropzone || !root.contains(dropzone)) return;
10741 e.preventDefault();
10742 dropzone.classList.add("is-dragover");
10745 root.addEventListener("dragleave", e => {
10746 const dropzone = closestElement(e.target, "[data-cms-dropzone]");
10747 if (!dropzone || !root.contains(dropzone)) return;
10748 dropzone.classList.remove("is-dragover");
10751 root.addEventListener("drop", e => {
10752 const dropzone = closestElement(e.target, "[data-cms-dropzone]");
10753 if (!dropzone || !root.contains(dropzone)) return;
10754 e.preventDefault();
10755 dropzone.classList.remove("is-dragover");
10756 const form = closestElement(dropzone, "form");
10757 const files = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length ? e.dataTransfer.files : null;
10758 if (!form || !files || !files.length) return;
10759 if (!setUploadFiles(form, files)) {
10760 status(root, "Datei bitte ueber die Dateiauswahl waehlen.", "error");
10764 qsa(root, "[data-cms-field]").forEach(el => {
10765 if (el.__dbxCmsDirtyBound) return;
10766 el.__dbxCmsDirtyBound = true;
10768 el.addEventListener("input", () => markDirty(root));
10769 el.addEventListener("change", () => {
10771 if (el.getAttribute("data-cms-field") === "hero_template") applyHeroTemplateChoice(root, el);
10776 root.addEventListener("dragstart", e => {
10777 const media = closestElement(e.target, ".dbx-cms-media-item");
10778 if (media && root.contains(media)) {
10779 e.dataTransfer.effectAllowed = "move";
10780 e.dataTransfer.setData("application/x-dbx-cms-media", media.getAttribute("data-media-id") || "");
10781 media.classList.add("is-dragging");
10785 const row = closestElement(e.target, ".dbx-cms-tree-row");
10786 if (!row || !root.contains(row)) return;
10787 const treePress = root.__dbxCmsTreePress;
10788 root.__dbxCmsTreeDrag = treePress && treePress.row === row ? {
10792 dragIntent: treePress.dragIntent,
10795 e.dataTransfer.effectAllowed = "move";
10796 e.dataTransfer.setData("application/x-dbx-cms-node", JSON.stringify({
10797 type: row.getAttribute("data-type"),
10798 id: Number(row.getAttribute("data-id") || 0)
10800 row.classList.add("is-dragging");
10803 root.addEventListener("dragend", e => {
10804 clearEditorDropMarks(root);
10805 state(root).dragMarker = null;
10806 state(root).pointerDragMarker = null;
10808 const media = closestElement(e.target, ".dbx-cms-media-item");
10809 if (media) media.classList.remove("is-dragging");
10810 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"));
10812 const row = closestElement(e.target, ".dbx-cms-tree-row");
10813 if (row) row.classList.remove("is-dragging");
10814 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 => {
10815 el.classList.remove("is-drop-target", "is-drop-before", "is-drop-after");
10818 const treeDrag = root.__dbxCmsTreeDrag;
10819 root.__dbxCmsTreeDrag = null;
10820 if (row && treeDrag && treeDrag.row === row && !treeDrag.dropped && !treeDrag.dragIntent) {
10821 const moved = Math.abs((e.clientX || treeDrag.x) - treeDrag.x) > 8 || Math.abs((e.clientY || treeDrag.y) - treeDrag.y) > 8;
10822 if (!moved) activateTreeRow(root, cfg, row);
10826 root.addEventListener("dragover", e => {
10827 const media = closestElement(e.target, ".dbx-cms-media-item");
10828 if (media && root.contains(media) && Array.from(e.dataTransfer.types || []).includes("application/x-dbx-cms-media")) {
10829 e.preventDefault();
10830 e.dataTransfer.dropEffect = "move";
10831 qsa(root, ".dbx-cms-media-item.is-drop-before,.dbx-cms-media-item.is-drop-after").forEach(el => {
10832 if (el !== media) el.classList.remove("is-drop-before", "is-drop-after");
10834 const rect = media.getBoundingClientRect();
10835 const before = e.clientY < rect.top + rect.height / 2;
10836 media.classList.toggle("is-drop-before", before);
10837 media.classList.toggle("is-drop-after", !before);
10841 const row = closestElement(e.target, ".dbx-cms-tree-row");
10842 if (!row || !root.contains(row)) return;
10843 const type = row.getAttribute("data-type");
10844 const hasData = Array.from(e.dataTransfer.types || []).includes("application/x-dbx-cms-node");
10845 if (!hasData) return;
10848 data = JSON.parse(e.dataTransfer.getData("application/x-dbx-cms-node") || "{}");
10850 if (!data || !data.type || !data.id) return;
10851 if (root.__dbxCmsTreeDrag) root.__dbxCmsTreeDrag.dragIntent = true;
10852 if (data.type === "folder" && type !== "folder") return;
10854 e.preventDefault();
10855 e.dataTransfer.dropEffect = "move";
10856 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 => {
10857 if (el !== row) el.classList.remove("is-drop-target", "is-drop-before", "is-drop-after");
10860 const rect = row.getBoundingClientRect();
10861 const y = rect.height ? (e.clientY - rect.top) / rect.height : 0.5;
10862 if (type === "page") {
10863 const before = y < 0.5;
10864 row.classList.toggle("is-drop-before", before);
10865 row.classList.toggle("is-drop-after", !before);
10866 } else if (data.type === "folder") {
10867 const before = y < 0.28;
10868 const after = y > 0.72;
10869 row.classList.toggle("is-drop-before", before);
10870 row.classList.toggle("is-drop-after", after);
10871 row.classList.toggle("is-drop-target", !before && !after);
10873 row.classList.add("is-drop-target");
10877 root.addEventListener("dragleave", e => {
10878 const surface = editorSurface(root);
10879 if (surface && !surface.contains(e.relatedTarget)) {
10880 clearEditorDropMarks(root);
10883 const media = closestElement(e.target, ".dbx-cms-media-item");
10884 if (media && !media.contains(e.relatedTarget)) media.classList.remove("is-drop-before", "is-drop-after");
10886 const row = closestElement(e.target, ".dbx-cms-tree-row");
10887 if (row && !row.contains(e.relatedTarget)) row.classList.remove("is-drop-target", "is-drop-before", "is-drop-after");
10890 root.addEventListener("drop", e => {
10891 const media = closestElement(e.target, ".dbx-cms-media-item");
10892 if (media && root.contains(media) && Array.from(e.dataTransfer.types || []).includes("application/x-dbx-cms-media")) {
10893 e.preventDefault();
10894 const fromId = e.dataTransfer.getData("application/x-dbx-cms-media");
10895 const from = qsa(root, ".dbx-cms-media-item").find(item => item.getAttribute("data-media-id") === fromId);
10896 if (!from || from === media) return;
10897 if (from.parentElement !== media.parentElement) return;
10898 const rect = media.getBoundingClientRect();
10899 const before = e.clientY < rect.top + rect.height / 2;
10900 media.classList.remove("is-drop-before", "is-drop-after");
10901 if (before) media.parentElement.insertBefore(from, media);
10902 else media.parentElement.insertBefore(from, media.nextSibling);
10903 saveMediaOrder(root, cfg, media.parentElement);
10907 const row = closestElement(e.target, ".dbx-cms-tree-row");
10908 if (!row || !root.contains(row)) return;
10909 e.preventDefault();
10910 if (root.__dbxCmsTreeDrag) {
10911 root.__dbxCmsTreeDrag.dropped = true;
10912 root.__dbxCmsTreeDrag.dragIntent = true;
10914 row.classList.remove("is-drop-target", "is-drop-before", "is-drop-after");
10918 data = JSON.parse(e.dataTransfer.getData("application/x-dbx-cms-node") || "{}");
10920 if (!data || !data.type || !data.id) return;
10922 const targetType = row.getAttribute("data-type");
10923 const targetId = Number(row.getAttribute("data-id") || 0);
10924 let targetFolder = targetType === "folder" ? targetId : Number(row.getAttribute("data-folder") || 0);
10925 const position = {};
10927 if (targetType === "folder" && data.type === "folder") {
10928 const rect = row.getBoundingClientRect();
10929 const y = rect.height ? (e.clientY - rect.top) / rect.height : 0.5;
10930 if (y < 0.28 || y > 0.72) {
10931 targetFolder = Number(row.getAttribute("data-folder") || 0);
10932 if (y < 0.28) position.before_id = targetId;
10933 else position.after_id = targetId;
10935 } else if (targetType === "page" && data.type === "page") {
10936 const rect = row.getBoundingClientRect();
10937 if (e.clientY < rect.top + rect.height / 2) position.before_id = targetId;
10938 else position.after_id = targetId;
10939 } else if (targetType !== "folder") {
10943 if (Number(data.id) === targetId && (position.before_id || position.after_id)) {
10947 if (data.type === "folder" && Number(data.id) === targetFolder) {
10948 status(root, "Ordner kann nicht in sich selbst verschoben werden.", "error");
10952 moveNode(root, cfg, data.type, Number(data.id), targetFolder, position);
10956 bindGlobalCmsEditorEvents();
10957 bindInlineVideoOptionsDocumentEvents();
10959 function bindTreeRuntimeEnhancements(root) {
10960 qsa(root || document, ".dbx-cms[data-dbx]").forEach(el => {
10961 const cfg = cmsConfig(el);
10962 bindAdminTreeOutsideClose(el, cfg || {});
10963 bindViewTreeHover(el, cfg || {});
10967 const cmsFeature = {
10971 ["css", "design", "c-cms.css"],
10972 ["css", "design", "c-form.css"],
10973 ["css", "design", "c-grid.css"]
10976 ["js", "lib", "ajax.js"],
10977 ["js", "lib", "form.js"]
10981 if (!el || el.__dbxCmsReady) return;
10982 el.__dbxCmsReady = true;
10983 const initialCid = Number(cfg && cfg.cid ? cfg.cid : 0) || 0;
10984 const initialFid = Number(cfg && cfg.fid ? cfg.fid : 0) || 0;
10985 if (initialFid > 0) {
10986 state(el).selectedFolder = initialFid;
10987 state(el).selectedPage = 0;
10988 state(el).selectedType = "folder";
10989 } else if (initialCid > 0) {
10990 state(el).selectedPage = initialCid;
10991 state(el).selectedType = "page";
10993 bindStickyHeaderOffset(el);
10994 bindTreeFlyoutPosition(el);
10995 initTreePanelState(el, cfg || {});
10996 initRightPanelState(el);
10997 bindTreeRuntimeEnhancements(el);
10998 if (!isViewMode(cfg || {})) {
10999 ensureJodit().then(ok => {
11001 initEditor(el, cfg || {});
11003 dbx.warn("[cms] Jodit not loaded, fallback contenteditable active");
11008 bind(el, cfg || {});
11010 dbx.error("[cms] bind failed", err);
11012 loadTree(el, cfg || {});
11016 qsa(root || document, ".dbx-cms[data-dbx]").forEach(el => {
11017 if (el.__dbxCmsReady) return;
11018 const cfgList = dbx.parseData(el.getAttribute("data-dbx"));
11019 const cfg = cfgList.find(item => item.lib === LIB) || {};
11020 this.init(el, cfg);
11025 dbx.cmsMediaBrowser = {
11026 open: openMediaBrowser
11029 dbx.feature.register(LIB, cmsFeature);
11031})(window, document);