From f2ca180a7a1a4b4253761cb4e048e83f2f1a8bdd Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Sat, 27 Jun 2026 07:26:46 +0200 Subject: [PATCH 01/11] add drag and drop for sections --- .../resources/manager/js/manager-inject.js | 1 - .../manager/js/modules/filebrowser.d.ts | 2 +- .../manager/js/modules/form/utils.d.ts | 4 +- .../manager/js/modules/localization.d.ts | 2 +- .../manager/manager.message.handlers.js | 5 +- .../js/modules/manager/toolbar-icons.d.ts | 1 + .../js/modules/manager/toolbar-icons.js | 5 + .../js/modules/manager/toolbar.inject.js | 148 +++++++- .../manager/js/modules/preview.history.d.ts | 2 +- .../manager/js/modules/preview.utils.d.ts | 2 +- .../resources/manager/js/modules/state.js | 1 - .../manager/js/modules/ui-state.d.ts | 4 +- .../resources/manager/public/manager-login.js | 1 - .../src/main/ts/dist/js/manager-inject.js | 1 - .../main/ts/dist/js/modules/filebrowser.d.ts | 2 +- .../main/ts/dist/js/modules/form/utils.d.ts | 4 +- .../main/ts/dist/js/modules/localization.d.ts | 2 +- .../manager/manager.message.handlers.js | 5 +- .../js/modules/manager/toolbar-icons.d.ts | 1 + .../dist/js/modules/manager/toolbar-icons.js | 5 + .../dist/js/modules/manager/toolbar.inject.js | 148 +++++++- .../ts/dist/js/modules/preview.history.d.ts | 2 +- .../ts/dist/js/modules/preview.utils.d.ts | 2 +- .../src/main/ts/dist/js/modules/state.js | 1 - .../src/main/ts/dist/js/modules/ui-state.d.ts | 4 +- .../src/main/ts/dist/public/manager-login.js | 1 - .../plans/2026-06-26-drag-drop-sections.md | 318 ++++++++++++++++++ .../2026-06-26-drag-drop-sections-design.md | 92 +++++ .../manager/manager.message.handlers.ts | 6 +- .../src/js/modules/manager/toolbar-icons.ts | 6 + .../src/js/modules/manager/toolbar.inject.ts | 174 +++++++++- .../demo/content/index.asection.other.md | 2 +- .../hosts/demo/content/index.asection.test.md | 2 +- .../demo/content/index.asection.test1.md | 2 +- test-server/themes/demo/templates/start.html | 11 +- 35 files changed, 937 insertions(+), 32 deletions(-) create mode 100644 modules/ui-module/src/main/ts/docs/superpowers/plans/2026-06-26-drag-drop-sections.md create mode 100644 modules/ui-module/src/main/ts/docs/superpowers/specs/2026-06-26-drag-drop-sections-design.md diff --git a/modules/ui-module/src/main/resources/manager/js/manager-inject.js b/modules/ui-module/src/main/resources/manager/js/manager-inject.js index 2b11d2b1d..75083adbc 100644 --- a/modules/ui-module/src/main/resources/manager/js/manager-inject.js +++ b/modules/ui-module/src/main/resources/manager/js/manager-inject.js @@ -1,4 +1,3 @@ -"use strict"; /*- * #%L * UI Module diff --git a/modules/ui-module/src/main/resources/manager/js/modules/filebrowser.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/filebrowser.d.ts index 0869c773e..0c529bf7f 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/filebrowser.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/filebrowser.d.ts @@ -20,6 +20,6 @@ */ export function openFileBrowser(optionsParam: any): Promise; export namespace state { - let options: null; + let options: any; let currentFolder: string; } diff --git a/modules/ui-module/src/main/resources/manager/js/modules/form/utils.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/form/utils.d.ts index 23eeb2258..7d3d89dcb 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/form/utils.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/form/utils.d.ts @@ -20,7 +20,7 @@ */ declare const createID: () => string; declare const utcToLocalDateTimeInputValue: (utcString: string) => string; -declare function getUTCDateTimeFromInput(inputElement: HTMLInputElement): string | null; +declare function getUTCDateTimeFromInput(inputElement: HTMLInputElement): string; declare function utcToLocalDateInputValue(utcString: string): string; -declare function getUTCDateFromInput(inputElement: HTMLInputElement): string | null; +declare function getUTCDateFromInput(inputElement: HTMLInputElement): string; export { createID, utcToLocalDateTimeInputValue, getUTCDateTimeFromInput, utcToLocalDateInputValue, getUTCDateFromInput }; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/localization.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/localization.d.ts index e91051759..b1e393873 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/localization.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/localization.d.ts @@ -21,7 +21,7 @@ export function localizeUi(): Promise; export namespace i18n { let _locale: any; - let _cache: null; + let _cache: any; /** * Loads and merges remote localizations with defaults. */ diff --git a/modules/ui-module/src/main/resources/manager/js/modules/manager/manager.message.handlers.js b/modules/ui-module/src/main/resources/manager/js/modules/manager/manager.message.handlers.js index 44176fd74..81752a6f4 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/manager/manager.message.handlers.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/manager/manager.message.handlers.js @@ -21,7 +21,7 @@ import { executeScriptAction } from '@cms/js/manager-globals.js'; import frameMessenger from '@cms/modules/frameMessenger.js'; import { getPreviewFrame, getPreviewUrl } from '@cms/modules/preview.utils.js'; -import { getContentNode } from '@cms/modules/rpc/rpc-content.js'; +import { getContentNode, setMetaBatch } from '@cms/modules/rpc/rpc-content.js'; const executeImageForm = (payload) => { const cmd = { "module": window.manager.baseUrl + "/actions/media/edit-media-form", @@ -190,5 +190,8 @@ const initMessageHandlers = () => { var previewFrame = getPreviewFrame(); frameMessenger.send(previewFrame.contentWindow, message); }); + frameMessenger.on('sort-sections', async (payload) => { + await setMetaBatch({ updates: payload.updates }); + }); }; export { initMessageHandlers }; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar-icons.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar-icons.d.ts index 92761a64f..3ae76b89a 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar-icons.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar-icons.d.ts @@ -27,3 +27,4 @@ export declare const IMAGE_ICON = "\n\n \n \n\n"; export declare const SECTION_UNPUBLISHED_ICON = "\n\n \n \n\n"; export declare const MEDIA_CROP_ICON = "\n\n \n\n"; +export declare const MOVE_ICON = "\n\n \n\n"; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar-icons.js b/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar-icons.js index 715aa464c..b172e748e 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar-icons.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar-icons.js @@ -68,3 +68,8 @@ export const MEDIA_CROP_ICON = ` `; +export const MOVE_ICON = ` + + + +`; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar.inject.js b/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar.inject.js index b3d8c6743..06d292a64 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar.inject.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar.inject.js @@ -19,7 +19,7 @@ * #L% */ import frameMessenger from "@cms/modules/frameMessenger.js"; -import { EDIT_ATTRIBUTES_ICON, EDIT_PAGE_ICON, SECTION_ADD_ICON, SECTION_DELETE_ICON, SECTION_SORT_ICON, SECTION_UNPUBLISHED_ICON } from "@cms/modules/manager/toolbar-icons"; +import { EDIT_ATTRIBUTES_ICON, EDIT_PAGE_ICON, MOVE_ICON, SECTION_ADD_ICON, SECTION_DELETE_ICON, SECTION_SORT_ICON, SECTION_UNPUBLISHED_ICON } from "@cms/modules/manager/toolbar-icons"; const addSection = (event) => { var toolbar = event.target.closest('[data-cms-toolbar]'); var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar || '{}'); @@ -115,6 +115,145 @@ const editAttributes = (event) => { */ frameMessenger.send(window.parent, command); }; +const initDragDrop = (container) => { + const draggableItems = Array.from(container.querySelectorAll(':scope > .cms-ui-editable-sections')); + if (draggableItems.length === 0) { + return; + } + let draggedEl = null; + let placeholder = null; + draggableItems.forEach((item) => { + item.setAttribute('draggable', 'false'); + const itemToolbar = item.querySelector('.cms-ui-toolbar'); + if (itemToolbar) { + const handle = document.createElement('button'); + handle.setAttribute('data-cms-drag-handle', ''); + handle.setAttribute('title', 'Drag to reorder'); + handle.innerHTML = MOVE_ICON; + handle.style.cursor = 'grab'; + handle.addEventListener('mousedown', () => { + item.setAttribute('draggable', 'true'); + }); + itemToolbar.appendChild(handle); + } + item.addEventListener('dragstart', (e) => { + draggedEl = item; + e.dataTransfer?.setData('text/plain', ''); + placeholder = document.createElement('div'); + placeholder.setAttribute('data-cms-drag-placeholder', ''); + const cs = getComputedStyle(item); + placeholder.style.width = item.offsetWidth + 'px'; + placeholder.style.height = item.offsetHeight + 'px'; + placeholder.style.margin = cs.margin; + placeholder.style.border = '2px dashed #aaa'; + placeholder.style.boxSizing = 'border-box'; + placeholder.style.opacity = '0.5'; + placeholder.style.flexShrink = cs.flexShrink; + placeholder.style.flexGrow = cs.flexGrow; + placeholder.style.flexBasis = cs.flexBasis; + requestAnimationFrame(() => { + if (draggedEl && placeholder) { + container.insertBefore(placeholder, draggedEl); + draggedEl.style.display = 'none'; + } + }); + }); + item.addEventListener('dragend', () => { + if (draggedEl) { + draggedEl.style.display = ''; + draggedEl.setAttribute('draggable', 'false'); + } + placeholder?.remove(); + placeholder = null; + draggedEl = null; + }); + }); + container.addEventListener('dragover', (e) => { + e.preventDefault(); + if (!draggedEl || !placeholder) + return; + const siblings = Array.from(container.querySelectorAll(':scope > .cms-ui-editable-sections')).filter(el => el !== draggedEl); + if (siblings.length === 0) + return; + const centers = siblings.map(el => { + const r = el.getBoundingClientRect(); + return { el, cx: r.left + r.width / 2, cy: r.top + r.height / 2 }; + }); + // Build n+1 gap points for n siblings. + // Middle gaps: midpoint between consecutive element centers. + // Edge gaps: extrapolate from the first/last inter-element direction so + // the "before first" and "after last" zones are symmetric with the rest. + const gaps = []; + if (centers.length === 1) { + const r = siblings[0].getBoundingClientRect(); + gaps.push({ x: r.left, y: r.top + r.height / 2, before: siblings[0] }); + gaps.push({ x: r.right, y: r.top + r.height / 2, before: null }); + } + else { + const dx0 = centers[1].cx - centers[0].cx; + const dy0 = centers[1].cy - centers[0].cy; + gaps.push({ x: centers[0].cx - dx0 / 2, y: centers[0].cy - dy0 / 2, before: centers[0].el }); + for (let i = 1; i < centers.length; i++) { + gaps.push({ + x: (centers[i - 1].cx + centers[i].cx) / 2, + y: (centers[i - 1].cy + centers[i].cy) / 2, + before: centers[i].el + }); + } + const last = centers.length - 1; + const dxL = centers[last].cx - centers[last - 1].cx; + const dyL = centers[last].cy - centers[last - 1].cy; + gaps.push({ x: centers[last].cx + dxL / 2, y: centers[last].cy + dyL / 2, before: null }); + } + let bestDist = Infinity; + let bestBefore = undefined; + for (const gap of gaps) { + const dx = e.clientX - gap.x; + const dy = e.clientY - gap.y; + const dist = dx * dx + dy * dy; + if (dist < bestDist) { + bestDist = dist; + bestBefore = gap.before; + } + } + if (bestBefore === undefined) + return; + if (bestBefore === null) { + container.appendChild(placeholder); + } + else { + container.insertBefore(placeholder, bestBefore); + } + }); + container.addEventListener('drop', (e) => { + e.preventDefault(); + if (!draggedEl || !placeholder) + return; + container.insertBefore(draggedEl, placeholder); + placeholder.remove(); + placeholder = null; + draggedEl.style.display = ''; + draggedEl.setAttribute('draggable', 'false'); + const items = Array.from(container.querySelectorAll(':scope > .cms-ui-editable-sections')); + const updates = items.map((el, index) => { + const toolbarData = el.dataset.cmsToolbar ? JSON.parse(el.dataset.cmsToolbar) : {}; + return { + uri: toolbarData.uri, + meta: { + 'layout.order': { + type: 'number', + value: index + } + } + }; + }).filter(u => u.uri); + draggedEl = null; + frameMessenger.send(window.parent, { + type: 'sort-sections', + payload: { updates } + }); + }); +}; export const initToolbar = (container) => { var toolbarDefinition = JSON.parse(container.dataset.cmsToolbar || '{}'); if (!toolbarDefinition.actions) { @@ -184,6 +323,13 @@ export const initToolbar = (container) => { button.addEventListener('click', deleteSection); toolbar.appendChild(button); } + else if (action === "dragSectionEntries") { + // Kein Button — DnD wird nach dem ersten Render-Frame initialisiert, + // damit alle sectionEntry-Toolbars bereits im DOM sind. + requestAnimationFrame(() => { + initDragDrop(container); + }); + } }); if (toolbarDefinition.type === "sectionEntry") { const button = document.createElement('button'); diff --git a/modules/ui-module/src/main/resources/manager/js/modules/preview.history.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/preview.history.d.ts index 6cfeb16ad..520ce2957 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/preview.history.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/preview.history.d.ts @@ -22,6 +22,6 @@ export namespace PreviewHistory { export { init }; export { navigatePreview }; } -declare function init(defaultUrl?: null): void; +declare function init(defaultUrl?: any): void; declare function navigatePreview(url: any, usePush?: boolean): void; export {}; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/preview.utils.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/preview.utils.d.ts index 20c850ee6..e7c2a28ba 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/preview.utils.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/preview.utils.d.ts @@ -23,4 +23,4 @@ export function deActivatePreviewOverlay(): void; export function getPreviewUrl(): any; export function reloadPreview(): void; export function loadPreview(url: any): void; -export function getPreviewFrame(): HTMLElement | null; +export function getPreviewFrame(): HTMLElement; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/state.js b/modules/ui-module/src/main/resources/manager/js/modules/state.js index a0988236f..7274c0b66 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/state.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/state.js @@ -1,4 +1,3 @@ -"use strict"; /*- * #%L * UI Module diff --git a/modules/ui-module/src/main/resources/manager/js/modules/ui-state.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/ui-state.d.ts index 30e36cd4d..65018962d 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/ui-state.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/ui-state.d.ts @@ -20,11 +20,11 @@ */ export namespace UIStateManager { function setTabState(key: any, value: any): void; - function getTabState(key: any, defaultValue?: null): any; + function getTabState(key: any, defaultValue?: any): any; function setLocale(locale: any): void; function getLocale(): any; function removeTabState(key: any): void; function setAuthToken(token: any): void; - function getAuthToken(): string | null; + function getAuthToken(): string; function clearAuthToken(): void; } diff --git a/modules/ui-module/src/main/resources/manager/public/manager-login.js b/modules/ui-module/src/main/resources/manager/public/manager-login.js index 24aa077ae..6f21eee99 100644 --- a/modules/ui-module/src/main/resources/manager/public/manager-login.js +++ b/modules/ui-module/src/main/resources/manager/public/manager-login.js @@ -1,4 +1,3 @@ -"use strict"; /*- * #%L * UI Module diff --git a/modules/ui-module/src/main/ts/dist/js/manager-inject.js b/modules/ui-module/src/main/ts/dist/js/manager-inject.js index 2b11d2b1d..75083adbc 100644 --- a/modules/ui-module/src/main/ts/dist/js/manager-inject.js +++ b/modules/ui-module/src/main/ts/dist/js/manager-inject.js @@ -1,4 +1,3 @@ -"use strict"; /*- * #%L * UI Module diff --git a/modules/ui-module/src/main/ts/dist/js/modules/filebrowser.d.ts b/modules/ui-module/src/main/ts/dist/js/modules/filebrowser.d.ts index e10842a8d..34d9f9cb9 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/filebrowser.d.ts +++ b/modules/ui-module/src/main/ts/dist/js/modules/filebrowser.d.ts @@ -1,5 +1,5 @@ export function openFileBrowser(optionsParam: any): Promise; export namespace state { - let options: null; + let options: any; let currentFolder: string; } diff --git a/modules/ui-module/src/main/ts/dist/js/modules/form/utils.d.ts b/modules/ui-module/src/main/ts/dist/js/modules/form/utils.d.ts index 525b147b7..79c944de7 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/form/utils.d.ts +++ b/modules/ui-module/src/main/ts/dist/js/modules/form/utils.d.ts @@ -1,6 +1,6 @@ declare const createID: () => string; declare const utcToLocalDateTimeInputValue: (utcString: string) => string; -declare function getUTCDateTimeFromInput(inputElement: HTMLInputElement): string | null; +declare function getUTCDateTimeFromInput(inputElement: HTMLInputElement): string; declare function utcToLocalDateInputValue(utcString: string): string; -declare function getUTCDateFromInput(inputElement: HTMLInputElement): string | null; +declare function getUTCDateFromInput(inputElement: HTMLInputElement): string; export { createID, utcToLocalDateTimeInputValue, getUTCDateTimeFromInput, utcToLocalDateInputValue, getUTCDateFromInput }; diff --git a/modules/ui-module/src/main/ts/dist/js/modules/localization.d.ts b/modules/ui-module/src/main/ts/dist/js/modules/localization.d.ts index 9eeed5a15..0b22efabb 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/localization.d.ts +++ b/modules/ui-module/src/main/ts/dist/js/modules/localization.d.ts @@ -1,7 +1,7 @@ export function localizeUi(): Promise; export namespace i18n { let _locale: any; - let _cache: null; + let _cache: any; /** * Loads and merges remote localizations with defaults. */ diff --git a/modules/ui-module/src/main/ts/dist/js/modules/manager/manager.message.handlers.js b/modules/ui-module/src/main/ts/dist/js/modules/manager/manager.message.handlers.js index 44176fd74..81752a6f4 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/manager/manager.message.handlers.js +++ b/modules/ui-module/src/main/ts/dist/js/modules/manager/manager.message.handlers.js @@ -21,7 +21,7 @@ import { executeScriptAction } from '@cms/js/manager-globals.js'; import frameMessenger from '@cms/modules/frameMessenger.js'; import { getPreviewFrame, getPreviewUrl } from '@cms/modules/preview.utils.js'; -import { getContentNode } from '@cms/modules/rpc/rpc-content.js'; +import { getContentNode, setMetaBatch } from '@cms/modules/rpc/rpc-content.js'; const executeImageForm = (payload) => { const cmd = { "module": window.manager.baseUrl + "/actions/media/edit-media-form", @@ -190,5 +190,8 @@ const initMessageHandlers = () => { var previewFrame = getPreviewFrame(); frameMessenger.send(previewFrame.contentWindow, message); }); + frameMessenger.on('sort-sections', async (payload) => { + await setMetaBatch({ updates: payload.updates }); + }); }; export { initMessageHandlers }; diff --git a/modules/ui-module/src/main/ts/dist/js/modules/manager/toolbar-icons.d.ts b/modules/ui-module/src/main/ts/dist/js/modules/manager/toolbar-icons.d.ts index 37904af59..570269ca5 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/manager/toolbar-icons.d.ts +++ b/modules/ui-module/src/main/ts/dist/js/modules/manager/toolbar-icons.d.ts @@ -7,3 +7,4 @@ export declare const IMAGE_ICON = "\n\n \n \n\n"; export declare const SECTION_UNPUBLISHED_ICON = "\n\n \n \n\n"; export declare const MEDIA_CROP_ICON = "\n\n \n\n"; +export declare const MOVE_ICON = "\n\n \n\n"; diff --git a/modules/ui-module/src/main/ts/dist/js/modules/manager/toolbar-icons.js b/modules/ui-module/src/main/ts/dist/js/modules/manager/toolbar-icons.js index 715aa464c..b172e748e 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/manager/toolbar-icons.js +++ b/modules/ui-module/src/main/ts/dist/js/modules/manager/toolbar-icons.js @@ -68,3 +68,8 @@ export const MEDIA_CROP_ICON = ` `; +export const MOVE_ICON = ` + + + +`; diff --git a/modules/ui-module/src/main/ts/dist/js/modules/manager/toolbar.inject.js b/modules/ui-module/src/main/ts/dist/js/modules/manager/toolbar.inject.js index b3d8c6743..06d292a64 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/manager/toolbar.inject.js +++ b/modules/ui-module/src/main/ts/dist/js/modules/manager/toolbar.inject.js @@ -19,7 +19,7 @@ * #L% */ import frameMessenger from "@cms/modules/frameMessenger.js"; -import { EDIT_ATTRIBUTES_ICON, EDIT_PAGE_ICON, SECTION_ADD_ICON, SECTION_DELETE_ICON, SECTION_SORT_ICON, SECTION_UNPUBLISHED_ICON } from "@cms/modules/manager/toolbar-icons"; +import { EDIT_ATTRIBUTES_ICON, EDIT_PAGE_ICON, MOVE_ICON, SECTION_ADD_ICON, SECTION_DELETE_ICON, SECTION_SORT_ICON, SECTION_UNPUBLISHED_ICON } from "@cms/modules/manager/toolbar-icons"; const addSection = (event) => { var toolbar = event.target.closest('[data-cms-toolbar]'); var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar || '{}'); @@ -115,6 +115,145 @@ const editAttributes = (event) => { */ frameMessenger.send(window.parent, command); }; +const initDragDrop = (container) => { + const draggableItems = Array.from(container.querySelectorAll(':scope > .cms-ui-editable-sections')); + if (draggableItems.length === 0) { + return; + } + let draggedEl = null; + let placeholder = null; + draggableItems.forEach((item) => { + item.setAttribute('draggable', 'false'); + const itemToolbar = item.querySelector('.cms-ui-toolbar'); + if (itemToolbar) { + const handle = document.createElement('button'); + handle.setAttribute('data-cms-drag-handle', ''); + handle.setAttribute('title', 'Drag to reorder'); + handle.innerHTML = MOVE_ICON; + handle.style.cursor = 'grab'; + handle.addEventListener('mousedown', () => { + item.setAttribute('draggable', 'true'); + }); + itemToolbar.appendChild(handle); + } + item.addEventListener('dragstart', (e) => { + draggedEl = item; + e.dataTransfer?.setData('text/plain', ''); + placeholder = document.createElement('div'); + placeholder.setAttribute('data-cms-drag-placeholder', ''); + const cs = getComputedStyle(item); + placeholder.style.width = item.offsetWidth + 'px'; + placeholder.style.height = item.offsetHeight + 'px'; + placeholder.style.margin = cs.margin; + placeholder.style.border = '2px dashed #aaa'; + placeholder.style.boxSizing = 'border-box'; + placeholder.style.opacity = '0.5'; + placeholder.style.flexShrink = cs.flexShrink; + placeholder.style.flexGrow = cs.flexGrow; + placeholder.style.flexBasis = cs.flexBasis; + requestAnimationFrame(() => { + if (draggedEl && placeholder) { + container.insertBefore(placeholder, draggedEl); + draggedEl.style.display = 'none'; + } + }); + }); + item.addEventListener('dragend', () => { + if (draggedEl) { + draggedEl.style.display = ''; + draggedEl.setAttribute('draggable', 'false'); + } + placeholder?.remove(); + placeholder = null; + draggedEl = null; + }); + }); + container.addEventListener('dragover', (e) => { + e.preventDefault(); + if (!draggedEl || !placeholder) + return; + const siblings = Array.from(container.querySelectorAll(':scope > .cms-ui-editable-sections')).filter(el => el !== draggedEl); + if (siblings.length === 0) + return; + const centers = siblings.map(el => { + const r = el.getBoundingClientRect(); + return { el, cx: r.left + r.width / 2, cy: r.top + r.height / 2 }; + }); + // Build n+1 gap points for n siblings. + // Middle gaps: midpoint between consecutive element centers. + // Edge gaps: extrapolate from the first/last inter-element direction so + // the "before first" and "after last" zones are symmetric with the rest. + const gaps = []; + if (centers.length === 1) { + const r = siblings[0].getBoundingClientRect(); + gaps.push({ x: r.left, y: r.top + r.height / 2, before: siblings[0] }); + gaps.push({ x: r.right, y: r.top + r.height / 2, before: null }); + } + else { + const dx0 = centers[1].cx - centers[0].cx; + const dy0 = centers[1].cy - centers[0].cy; + gaps.push({ x: centers[0].cx - dx0 / 2, y: centers[0].cy - dy0 / 2, before: centers[0].el }); + for (let i = 1; i < centers.length; i++) { + gaps.push({ + x: (centers[i - 1].cx + centers[i].cx) / 2, + y: (centers[i - 1].cy + centers[i].cy) / 2, + before: centers[i].el + }); + } + const last = centers.length - 1; + const dxL = centers[last].cx - centers[last - 1].cx; + const dyL = centers[last].cy - centers[last - 1].cy; + gaps.push({ x: centers[last].cx + dxL / 2, y: centers[last].cy + dyL / 2, before: null }); + } + let bestDist = Infinity; + let bestBefore = undefined; + for (const gap of gaps) { + const dx = e.clientX - gap.x; + const dy = e.clientY - gap.y; + const dist = dx * dx + dy * dy; + if (dist < bestDist) { + bestDist = dist; + bestBefore = gap.before; + } + } + if (bestBefore === undefined) + return; + if (bestBefore === null) { + container.appendChild(placeholder); + } + else { + container.insertBefore(placeholder, bestBefore); + } + }); + container.addEventListener('drop', (e) => { + e.preventDefault(); + if (!draggedEl || !placeholder) + return; + container.insertBefore(draggedEl, placeholder); + placeholder.remove(); + placeholder = null; + draggedEl.style.display = ''; + draggedEl.setAttribute('draggable', 'false'); + const items = Array.from(container.querySelectorAll(':scope > .cms-ui-editable-sections')); + const updates = items.map((el, index) => { + const toolbarData = el.dataset.cmsToolbar ? JSON.parse(el.dataset.cmsToolbar) : {}; + return { + uri: toolbarData.uri, + meta: { + 'layout.order': { + type: 'number', + value: index + } + } + }; + }).filter(u => u.uri); + draggedEl = null; + frameMessenger.send(window.parent, { + type: 'sort-sections', + payload: { updates } + }); + }); +}; export const initToolbar = (container) => { var toolbarDefinition = JSON.parse(container.dataset.cmsToolbar || '{}'); if (!toolbarDefinition.actions) { @@ -184,6 +323,13 @@ export const initToolbar = (container) => { button.addEventListener('click', deleteSection); toolbar.appendChild(button); } + else if (action === "dragSectionEntries") { + // Kein Button — DnD wird nach dem ersten Render-Frame initialisiert, + // damit alle sectionEntry-Toolbars bereits im DOM sind. + requestAnimationFrame(() => { + initDragDrop(container); + }); + } }); if (toolbarDefinition.type === "sectionEntry") { const button = document.createElement('button'); diff --git a/modules/ui-module/src/main/ts/dist/js/modules/preview.history.d.ts b/modules/ui-module/src/main/ts/dist/js/modules/preview.history.d.ts index 19f953414..02a1aa102 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/preview.history.d.ts +++ b/modules/ui-module/src/main/ts/dist/js/modules/preview.history.d.ts @@ -2,6 +2,6 @@ export namespace PreviewHistory { export { init }; export { navigatePreview }; } -declare function init(defaultUrl?: null): void; +declare function init(defaultUrl?: any): void; declare function navigatePreview(url: any, usePush?: boolean): void; export {}; diff --git a/modules/ui-module/src/main/ts/dist/js/modules/preview.utils.d.ts b/modules/ui-module/src/main/ts/dist/js/modules/preview.utils.d.ts index 24beb8ecc..f2f167f34 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/preview.utils.d.ts +++ b/modules/ui-module/src/main/ts/dist/js/modules/preview.utils.d.ts @@ -3,4 +3,4 @@ export function deActivatePreviewOverlay(): void; export function getPreviewUrl(): any; export function reloadPreview(): void; export function loadPreview(url: any): void; -export function getPreviewFrame(): HTMLElement | null; +export function getPreviewFrame(): HTMLElement; diff --git a/modules/ui-module/src/main/ts/dist/js/modules/state.js b/modules/ui-module/src/main/ts/dist/js/modules/state.js index a0988236f..7274c0b66 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/state.js +++ b/modules/ui-module/src/main/ts/dist/js/modules/state.js @@ -1,4 +1,3 @@ -"use strict"; /*- * #%L * UI Module diff --git a/modules/ui-module/src/main/ts/dist/js/modules/ui-state.d.ts b/modules/ui-module/src/main/ts/dist/js/modules/ui-state.d.ts index d064b5d45..cf3faa9ae 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/ui-state.d.ts +++ b/modules/ui-module/src/main/ts/dist/js/modules/ui-state.d.ts @@ -1,10 +1,10 @@ export namespace UIStateManager { function setTabState(key: any, value: any): void; - function getTabState(key: any, defaultValue?: null): any; + function getTabState(key: any, defaultValue?: any): any; function setLocale(locale: any): void; function getLocale(): any; function removeTabState(key: any): void; function setAuthToken(token: any): void; - function getAuthToken(): string | null; + function getAuthToken(): string; function clearAuthToken(): void; } diff --git a/modules/ui-module/src/main/ts/dist/public/manager-login.js b/modules/ui-module/src/main/ts/dist/public/manager-login.js index 24aa077ae..6f21eee99 100644 --- a/modules/ui-module/src/main/ts/dist/public/manager-login.js +++ b/modules/ui-module/src/main/ts/dist/public/manager-login.js @@ -1,4 +1,3 @@ -"use strict"; /*- * #%L * UI Module diff --git a/modules/ui-module/src/main/ts/docs/superpowers/plans/2026-06-26-drag-drop-sections.md b/modules/ui-module/src/main/ts/docs/superpowers/plans/2026-06-26-drag-drop-sections.md new file mode 100644 index 000000000..8a91cbeea --- /dev/null +++ b/modules/ui-module/src/main/ts/docs/superpowers/plans/2026-06-26-drag-drop-sections.md @@ -0,0 +1,318 @@ +# Drag & Drop Section Sorting Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Sections im CMS-Preview per nativem HTML5 Drag & Drop umsortieren, mit Auto-Save on Drop und ohne Seiten-Reload. + +**Architecture:** Ein neuer Action-Typ `"dragSectionEntries"` wird in `toolbar.inject.ts` verarbeitet: wenn er erkannt wird, initialisiert `initDragDrop()` natives DnD auf dem section-Container und injiziert Move-Handles in alle Kind-Elemente vom Typ `sectionEntry`. Nach dem Drop sendet der Preview-Frame per `frameMessenger` ein `"sort-sections"`-Kommando mit den neuen Index-Werten; ein neuer Handler im Manager-Frame ruft direkt `setMetaBatch()` auf. + +**Tech Stack:** TypeScript, natives HTML5 Drag & Drop API, frameMessenger (bestehendes Messaging-System), `setMetaBatch` aus `rpc-content.ts` + +## Global Constraints + +- Nur Desktop-Browser (Chrome, Firefox, Safari, Edge aktuell) — kein Touch-Support +- Kein Seiten-Reload nach dem Drop (`reloadPreview()` wird nicht aufgerufen) +- Keine externen Libraries (kein SortableJS o.ä.) +- Layout-agnostisch: funktioniert für Flex-Row, Flex-Column und Grid +- `"orderSectionEntries"` (Modal) bleibt unverändert und weiterhin nutzbar +- Build-Command: `npm run build` (ruft `tsc` auf) im Verzeichnis `src/main/ts` + +--- + +## File Map + +| Datei | Aktion | Verantwortung | +|---|---|---| +| `src/js/modules/manager/toolbar.inject.ts` | Modify | Neuer `"dragSectionEntries"` Branch + `initDragDrop()`-Funktion | +| `src/js/modules/manager/manager.message.handlers.ts` | Modify | Neuer `"sort-sections"`-Handler mit `setMetaBatch` | + +--- + +## Task 1: `sort-sections` Message Handler im Manager-Frame + +**Files:** +- Modify: `src/js/modules/manager/manager.message.handlers.ts` + +**Interfaces:** +- Consumes: `setMetaBatch` aus `@cms/modules/rpc/rpc-content.js` (bereits exportiert) +- Consumes: `frameMessenger.on(type, callback)` (bereits im File vorhanden) +- Produces: Handler reagiert auf `{ type: "sort-sections", payload: { updates: Array<{ uri: string, meta: { "layout.order": { type: "number", value: number } } }> } }` + +- [ ] **Step 1: Import `setMetaBatch` hinzufügen** + +In `manager.message.handlers.ts`, Zeile 24 (nach dem letzten Import), folgende Zeile einfügen: + +```typescript +import { getContentNode, setMetaBatch } from '@cms/modules/rpc/rpc-content.js'; +``` + +Achtung: Der bestehende Import von `getContentNode` in Zeile 24 muss dabei ersetzt werden: + +```typescript +// vorher (Zeile 24): +import { getContentNode } from '@cms/modules/rpc/rpc-content.js'; + +// nachher: +import { getContentNode, setMetaBatch } from '@cms/modules/rpc/rpc-content.js'; +``` + +- [ ] **Step 2: Handler nach dem `getContentNode`-Handler einfügen** + +Nach dem `frameMessenger.on('getContentNode', ...)` Block (Zeile 184-196), vor der schließenden `}` von `initMessageHandlers`, folgenden Block einfügen: + +```typescript + frameMessenger.on('sort-sections', async (payload: any) => { + await setMetaBatch({ updates: payload.updates }); + }); +``` + +- [ ] **Step 3: Build ausführen und auf Fehler prüfen** + +```bash +cd /pfad/zu/src/main/ts && npm run build +``` + +Erwartetes Ergebnis: Build erfolgreich, keine TypeScript-Fehler. + +- [ ] **Step 4: Manuell verifizieren** + +Prüfen dass im kompilierten Output `manager.message.handlers.js` der neue Handler vorhanden ist: + +```bash +grep -n "sort-sections" src/js/modules/manager/manager.message.handlers.js +``` + +Erwartetes Ergebnis: Zeile mit `sort-sections` gefunden. + +--- + +## Task 2: `initDragDrop()` und `"dragSectionEntries"` in `toolbar.inject.ts` + +**Files:** +- Modify: `src/js/modules/manager/toolbar.inject.ts` + +**Interfaces:** +- Consumes: `MOVE_ICON` aus `@cms/modules/manager/toolbar-icons` (bereits im File importierbar, muss zum Import hinzugefügt werden) +- Consumes: `frameMessenger` aus `@cms/modules/frameMessenger.js` (bereits importiert, Zeile 21) +- Produces: Funktion `initDragDrop(container: HTMLElement, sectionName: string): void` +- Produces: Neuer `else if (action === "dragSectionEntries")` Branch in `initToolbar()` + +**Voraussetzung aus Task 1:** Handler für `"sort-sections"` muss im Manager registriert sein (Task 1 abgeschlossen). + +### Wie sectionEntry-Kinder gefunden werden + +`initToolbar()` wird für jeden Container aufgerufen, der `[data-cms-toolbar]` hat. Für `sectionEntry`-Elemente setzt es `container.classList.add("cms-ui-editable-sections")`. `initDragDrop()` sucht daher im **Parent** des section-Containers nach allen direkten Kinder-Elementen mit der Klasse `cms-ui-editable-sections`: + +``` +section-Container (hat "dragSectionEntries" Action) + └── Kind-Element 1 [data-cms-toolbar type="sectionEntry"] → hat Klasse cms-ui-editable-sections + └── Kind-Element 2 [data-cms-toolbar type="sectionEntry"] → hat Klasse cms-ui-editable-sections +``` + +Da `initDragDrop` via `requestAnimationFrame` aufgerufen wird, sind alle `initToolbar`-Aufrufe für die Kinder bereits abgeschlossen. + +### Layout-agnostische Insert-Position + +``` +für jedes draggable-Kind k (außer dem gezogenen Element): + rect = k.getBoundingClientRect() + dx = event.clientX - (rect.left + rect.width / 2) + dy = event.clientY - (rect.top + rect.height / 2) + distance = Math.sqrt(dx*dx + dy*dy) + +nächstes Element = Kind mit minimalem distance +wenn Cursor vor der Mitte des nächsten Elements (dy < 0 ODER (dy === 0 UND dx < 0)) + → insertBefore(dragged, nearest) +sonst + → insertBefore(dragged, nearest.nextSibling) // = insertAfter +``` + +- [ ] **Step 1: `MOVE_ICON` zum Import hinzufügen** + +Zeile 22 in `toolbar.inject.ts` anpassen: + +```typescript +// vorher: +import { EDIT_ATTRIBUTES_ICON, EDIT_PAGE_ICON, SECTION_ADD_ICON, SECTION_DELETE_ICON, SECTION_SORT_ICON, SECTION_UNPUBLISHED_ICON } from "@cms/modules/manager/toolbar-icons"; + +// nachher: +import { EDIT_ATTRIBUTES_ICON, EDIT_PAGE_ICON, MOVE_ICON, SECTION_ADD_ICON, SECTION_DELETE_ICON, SECTION_SORT_ICON, SECTION_UNPUBLISHED_ICON } from "@cms/modules/manager/toolbar-icons"; +``` + +- [ ] **Step 2: `initDragDrop()` Funktion vor `initToolbar` einfügen** + +Direkt vor `export const initToolbar = ...` (Zeile 136) folgende Funktion einfügen: + +```typescript +const initDragDrop = (container: HTMLElement, sectionName: string) => { + const draggableItems = Array.from( + container.querySelectorAll(':scope > .cms-ui-editable-sections') + ); + + if (draggableItems.length === 0) { + return; + } + + let draggedEl: HTMLElement | null = null; + + draggableItems.forEach((item) => { + item.setAttribute('draggable', 'true'); + + // Move-Handle in die Toolbar des Items injizieren + const itemToolbar = item.querySelector('.cms-ui-toolbar'); + if (itemToolbar) { + const handle = document.createElement('button'); + handle.setAttribute('data-cms-drag-handle', ''); + handle.setAttribute('title', 'Drag to reorder'); + handle.innerHTML = MOVE_ICON; + handle.style.cursor = 'grab'; + // mousedown/mouseup auf dem Handle steuert das draggable-Attribut, + // damit nur das Handle das Ziehen auslöst + handle.addEventListener('mousedown', () => { + item.setAttribute('draggable', 'true'); + }); + itemToolbar.appendChild(handle); + } + + item.addEventListener('dragstart', (e: DragEvent) => { + draggedEl = item; + item.style.opacity = '0.4'; + e.dataTransfer?.setData('text/plain', ''); + }); + + item.addEventListener('dragend', () => { + item.style.opacity = ''; + draggedEl = null; + }); + }); + + container.addEventListener('dragover', (e: DragEvent) => { + e.preventDefault(); + if (!draggedEl) return; + + const siblings = Array.from( + container.querySelectorAll(':scope > .cms-ui-editable-sections') + ).filter(el => el !== draggedEl); + + if (siblings.length === 0) return; + + let nearest: HTMLElement = siblings[0]; + let nearestDist = Infinity; + + siblings.forEach(el => { + const rect = el.getBoundingClientRect(); + const dx = e.clientX - (rect.left + rect.width / 2); + const dy = e.clientY - (rect.top + rect.height / 2); + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist < nearestDist) { + nearestDist = dist; + nearest = el; + } + }); + + const rect = nearest.getBoundingClientRect(); + const dy = e.clientY - (rect.top + rect.height / 2); + const dx = e.clientX - (rect.left + rect.width / 2); + const before = dy < 0 || (dy === 0 && dx < 0); + + if (before) { + container.insertBefore(draggedEl, nearest); + } else { + container.insertBefore(draggedEl, nearest.nextSibling); + } + }); + + container.addEventListener('drop', async (e: DragEvent) => { + e.preventDefault(); + if (!draggedEl) return; + + const items = Array.from( + container.querySelectorAll(':scope > .cms-ui-editable-sections') + ); + + const updates = items.map((el, index) => { + const toolbarData = el.dataset.cmsToolbar ? JSON.parse(el.dataset.cmsToolbar) : {}; + return { + uri: toolbarData.uri, + meta: { + 'layout.order': { + type: 'number', + value: index + } + } + }; + }).filter(u => u.uri); + + frameMessenger.send(window.parent, { + type: 'sort-sections', + payload: { updates } + }); + }); +}; +``` + +- [ ] **Step 3: `"dragSectionEntries"` Branch in `initToolbar()` hinzufügen** + +In der `toolbarDefinition.actions.forEach`-Schleife (nach dem `else if (action === "deleteSectionEntry")` Block, ca. Zeile 207) folgenden Block einfügen: + +```typescript + } else if (action === "dragSectionEntries") { + // Kein Button — DnD wird nach dem ersten Render-Frame initialisiert, + // damit alle sectionEntry-Toolbars bereits im DOM sind. + const sectionName = toolbarDefinition.section || ''; + requestAnimationFrame(() => { + initDragDrop(container, sectionName); + }); + } +``` + +- [ ] **Step 4: Build ausführen** + +```bash +cd /pfad/zu/src/main/ts && npm run build +``` + +Erwartetes Ergebnis: Build erfolgreich, keine TypeScript-Fehler. + +- [ ] **Step 5: Manuell verifizieren — Handle-Injektion** + +Im kompilierten Output prüfen: + +```bash +grep -n "dragSectionEntries\|initDragDrop\|sort-sections" src/js/modules/manager/toolbar.inject.js +``` + +Erwartetes Ergebnis: Alle drei Strings gefunden. + +--- + +## Task 3: Manuelle End-to-End-Verifikation + +Kein automatisierter Test möglich (DnD erfordert Browser-Interaktion). Manuelle Schritte: + +- [ ] **Step 1: Template mit `"dragSectionEntries"` konfigurieren** + +In einem Test-Template die section-Toolbar auf den neuen Action-Typ umstellen: + +```html +{{ ext.ui.toolbar("asection", "section", ["addSectionEntry", "dragSectionEntries"], { "section": "asection"}) | raw }} +``` + +- [ ] **Step 2: Vorschau öffnen und Toolbar-Hover prüfen** + +Im CMS-Manager die Seite mit der konfigurierten Section aufrufen. Beim Hovern über ein sectionEntry-Element muss in dessen Toolbar das Move-Icon (`MOVE_ICON` — Kreuz-Pfeile) erscheinen. + +- [ ] **Step 3: Drag & Drop testen** + +Ein sectionEntry-Element am Move-Handle greifen und an eine andere Position ziehen. Beim Loslassen muss: +1. Das Element an der neuen Position im DOM verbleiben +2. Kein Seiten-Reload stattfinden +3. In den Browser DevTools (Network) ein RPC-Request `meta.set.batch` mit den aktualisierten `layout.order`-Werten sichtbar sein + +- [ ] **Step 4: Persistenz prüfen** + +Seite neu laden — die Sections müssen in der neuen Reihenfolge erscheinen (entsprechend der gespeicherten `layout.order`-Werte). + +- [ ] **Step 5: Rückwärtskompatibilität prüfen** + +Eine Section mit `"orderSectionEntries"` (Modal) in der Toolbar öffnen und sicherstellen, dass der Sortier-Dialog weiterhin funktioniert. diff --git a/modules/ui-module/src/main/ts/docs/superpowers/specs/2026-06-26-drag-drop-sections-design.md b/modules/ui-module/src/main/ts/docs/superpowers/specs/2026-06-26-drag-drop-sections-design.md new file mode 100644 index 000000000..dd6428e3b --- /dev/null +++ b/modules/ui-module/src/main/ts/docs/superpowers/specs/2026-06-26-drag-drop-sections-design.md @@ -0,0 +1,92 @@ +# Drag & Drop Section Sorting — Design Spec + +**Date:** 2026-06-26 +**Branch:** e2e_example_module + +## Overview + +Sections im CMS-Preview sollen per Drag & Drop direkt umsortierbar sein. Die Konfiguration erfolgt an der section-Container-Toolbar via neuem Action-Typ `"dragSectionEntries"`, parallel zum bestehenden `"orderSectionEntries"` (Modal). Nach dem Drop wird automatisch gespeichert, ohne die Preview-Seite neu zu laden. + +## Anforderungen + +- Desktop-Browser: Chrome, Firefox, Safari, Edge (aktuell) +- Touch-Support: nicht erforderlich +- Layout-agnostisch: funktioniert für horizontale (Flex-Row, Grid) und vertikale (Flex-Column) Section-Layouts +- Auto-Save on Drop: kein expliziter Speichern-Button, kein Seiten-Reload +- Rückwärtskompatibel: `"orderSectionEntries"` (Modal) bleibt unverändert + +## Konfiguration + +Der Entwickler wählt in seinem Template einen der beiden Action-Typen — oder beide: + +```html +{{/* Nur Drag & Drop */}} +{{ ext.ui.toolbar("asection", "section", ["addSectionEntry", "dragSectionEntries"], { "section": "asection"}) | raw }} + +{{/* Nur Modal */}} +{{ ext.ui.toolbar("asection", "section", ["addSectionEntry", "orderSectionEntries"], { "section": "asection"}) | raw }} +``` + +Die sectionEntry-Toolbar bleibt unverändert: +```html +{{ ext.ui.toolbar(node.uri, "sectionEntry", ["editContent", "editAttributes", "deleteSectionEntry"], {"uri": node.uri, "form": "attributes"}) | raw }} +``` + +## Architektur & Datenfluss + +``` +Preview-Frame (toolbar.inject.ts) + 1. initToolbar() erkennt "dragSectionEntries" auf section-Container + 2. requestAnimationFrame(() => initDragDrop(container, sectionName)) + — stellt sicher, dass alle sectionEntry-Toolbars bereits initialisiert sind + 3. Für jedes sectionEntry-Kind-Element: + - draggable="true" setzen + - MOVE_ICON-Handle als Button in dessen Toolbar injizieren + 4. Container: dragstart / dragover / drop Events registrieren + 5. onDrop: URIs in neuer Reihenfolge aus dem DOM lesen → updates[] aufbauen + 6. frameMessenger.send(window.parent, { + type: "sort-sections", + payload: { updates: [{ uri, meta: { "layout.order": { type: "number", value: index } } }] } + }) + +Manager-Frame (manager.message.handlers.ts) + 7. Neuer Handler frameMessenger.on("sort-sections", payload => ...) + 8. setMetaBatch({ updates: payload.updates }) direkt aufrufen + 9. Kein reloadPreview(), kein Modal +``` + +## Layout-agnostische Drop-Ziel-Erkennung + +Beim `dragover` wird für jedes sectionEntry-Kind der Abstand zwischen Mausposition und Element-Mitte berechnet (pythagoreisch). Das Element mit dem kleinsten Abstand ist das Ziel. Der Cursor-Vergleich zur Mitte bestimmt `insertBefore` vs. `insertAfter`: + +``` +für jedes sectionEntry-Kind k: + rect = k.getBoundingClientRect() + dx = event.clientX - (rect.left + rect.width / 2) + dy = event.clientY - (rect.top + rect.height / 2) + distance = Math.sqrt(dx*dx + dy*dy) + +nächstes Element = Kind mit minimalem distance +insertBefore wenn Cursor vor der Mitte (dx < 0 || dy < 0), sonst insertAfter +``` + +Das funktioniert ohne Annahmen über das CSS-Layout. + +## Betroffene Dateien + +| Datei | Änderung | +|---|---| +| `src/js/modules/manager/toolbar.inject.ts` | neuer `else if (action === "dragSectionEntries")` Block; `initDragDrop()`-Funktion; Import von `MOVE_ICON` | +| `src/js/modules/manager/manager.message.handlers.ts` | neuer `frameMessenger.on("sort-sections", ...)` Handler mit direktem `setMetaBatch`-Aufruf; Import von `setMetaBatch` | +| `src/js/modules/manager/toolbar-icons.ts` | keine Änderung (`MOVE_ICON` existiert bereits) | + +## Was nicht geändert wird + +- `edit-sections.js` — bleibt unverändert +- `toolbar-icons.ts` — `MOVE_ICON` ist bereits vorhanden +- sectionEntry-Toolbar-Konfiguration — keine neuen Actions erforderlich +- Preview-Reload nach Drop — bewusst weggelassen + +## Offene Punkte / Entscheidungen + +- Das MOVE_ICON-Handle wird vom section-Container-Init in die sectionEntry-Toolbar injiziert. Das setzt voraus, dass sectionEntry-Kinder mit `[data-cms-toolbar]` und `data-cms-type="sectionEntry"` (oder äquivalent) auffindbar sind. Die exakte Selektor-Logik wird in der Implementierung anhand des DOM-Outputs von `initToolbar` für sectionEntry-Elemente verifiziert. diff --git a/modules/ui-module/src/main/ts/src/js/modules/manager/manager.message.handlers.ts b/modules/ui-module/src/main/ts/src/js/modules/manager/manager.message.handlers.ts index 4a05f65f6..e5077745e 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/manager/manager.message.handlers.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/manager/manager.message.handlers.ts @@ -21,7 +21,7 @@ import { executeScriptAction } from '@cms/js/manager-globals.js'; import frameMessenger from '@cms/modules/frameMessenger.js'; import { getPreviewFrame, getPreviewUrl } from '@cms/modules/preview.utils.js'; -import { getContentNode } from '@cms/modules/rpc/rpc-content.js'; +import { getContentNode, setMetaBatch } from '@cms/modules/rpc/rpc-content.js'; const executeImageForm = (payload: any) => { const cmd: any = { @@ -194,6 +194,10 @@ const initMessageHandlers = () => { var previewFrame = getPreviewFrame() as any frameMessenger.send(previewFrame.contentWindow, message); }); + + frameMessenger.on('sort-sections', async (payload: any) => { + await setMetaBatch({ updates: payload.updates }); + }); } export { initMessageHandlers }; diff --git a/modules/ui-module/src/main/ts/src/js/modules/manager/toolbar-icons.ts b/modules/ui-module/src/main/ts/src/js/modules/manager/toolbar-icons.ts index 25458cb7b..a36b81508 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/manager/toolbar-icons.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/manager/toolbar-icons.ts @@ -76,3 +76,9 @@ export const MEDIA_CROP_ICON = ` `; + +export const MOVE_ICON = ` + + + +`; diff --git a/modules/ui-module/src/main/ts/src/js/modules/manager/toolbar.inject.ts b/modules/ui-module/src/main/ts/src/js/modules/manager/toolbar.inject.ts index c76eb5b34..ef227e4e0 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/manager/toolbar.inject.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/manager/toolbar.inject.ts @@ -19,7 +19,7 @@ * #L% */ import frameMessenger from "@cms/modules/frameMessenger.js"; -import { EDIT_ATTRIBUTES_ICON, EDIT_PAGE_ICON, SECTION_ADD_ICON, SECTION_DELETE_ICON, SECTION_SORT_ICON, SECTION_UNPUBLISHED_ICON } from "@cms/modules/manager/toolbar-icons"; +import { EDIT_ATTRIBUTES_ICON, EDIT_PAGE_ICON, MOVE_ICON, SECTION_ADD_ICON, SECTION_DELETE_ICON, SECTION_SORT_ICON, SECTION_UNPUBLISHED_ICON } from "@cms/modules/manager/toolbar-icons"; const addSection = (event : Event) => { var toolbar = (event.target as HTMLElement).closest('[data-cms-toolbar]') as HTMLElement; @@ -133,6 +133,172 @@ const editAttributes = (event: Event) => { } +const initDragDrop = (container: HTMLElement) => { + const draggableItems = Array.from( + container.querySelectorAll(':scope > .cms-ui-editable-sections') + ); + + if (draggableItems.length === 0) { + return; + } + + let draggedEl: HTMLElement | null = null; + let placeholder: HTMLElement | null = null; + + draggableItems.forEach((item) => { + item.setAttribute('draggable', 'false'); + + const itemToolbar = item.querySelector('.cms-ui-toolbar'); + if (itemToolbar) { + const handle = document.createElement('button'); + handle.setAttribute('data-cms-drag-handle', ''); + handle.setAttribute('title', 'Drag to reorder'); + handle.innerHTML = MOVE_ICON; + handle.style.cursor = 'grab'; + handle.addEventListener('mousedown', () => { + item.setAttribute('draggable', 'true'); + }); + itemToolbar.appendChild(handle); + } + + item.addEventListener('dragstart', (e: DragEvent) => { + draggedEl = item; + e.dataTransfer?.setData('text/plain', ''); + + placeholder = document.createElement('div'); + placeholder.setAttribute('data-cms-drag-placeholder', ''); + const cs = getComputedStyle(item); + placeholder.style.width = item.offsetWidth + 'px'; + placeholder.style.height = item.offsetHeight + 'px'; + placeholder.style.margin = cs.margin; + placeholder.style.border = '2px dashed #aaa'; + placeholder.style.boxSizing = 'border-box'; + placeholder.style.opacity = '0.5'; + placeholder.style.flexShrink = cs.flexShrink; + placeholder.style.flexGrow = cs.flexGrow; + placeholder.style.flexBasis = cs.flexBasis; + + requestAnimationFrame(() => { + if (draggedEl && placeholder) { + container.insertBefore(placeholder, draggedEl); + draggedEl.style.display = 'none'; + } + }); + }); + + item.addEventListener('dragend', () => { + if (draggedEl) { + draggedEl.style.display = ''; + draggedEl.setAttribute('draggable', 'false'); + } + placeholder?.remove(); + placeholder = null; + draggedEl = null; + }); + }); + + container.addEventListener('dragover', (e: DragEvent) => { + e.preventDefault(); + if (!draggedEl || !placeholder) return; + + const siblings = Array.from( + container.querySelectorAll(':scope > .cms-ui-editable-sections') + ).filter(el => el !== draggedEl); + + if (siblings.length === 0) return; + + const centers = siblings.map(el => { + const r = el.getBoundingClientRect(); + return { el, cx: r.left + r.width / 2, cy: r.top + r.height / 2 }; + }); + + // Build n+1 gap points for n siblings. + // Middle gaps: midpoint between consecutive element centers. + // Edge gaps: extrapolate from the first/last inter-element direction so + // the "before first" and "after last" zones are symmetric with the rest. + const gaps: Array<{ x: number; y: number; before: HTMLElement | null }> = []; + + if (centers.length === 1) { + const r = siblings[0].getBoundingClientRect(); + gaps.push({ x: r.left, y: r.top + r.height / 2, before: siblings[0] }); + gaps.push({ x: r.right, y: r.top + r.height / 2, before: null }); + } else { + const dx0 = centers[1].cx - centers[0].cx; + const dy0 = centers[1].cy - centers[0].cy; + gaps.push({ x: centers[0].cx - dx0 / 2, y: centers[0].cy - dy0 / 2, before: centers[0].el }); + + for (let i = 1; i < centers.length; i++) { + gaps.push({ + x: (centers[i - 1].cx + centers[i].cx) / 2, + y: (centers[i - 1].cy + centers[i].cy) / 2, + before: centers[i].el + }); + } + + const last = centers.length - 1; + const dxL = centers[last].cx - centers[last - 1].cx; + const dyL = centers[last].cy - centers[last - 1].cy; + gaps.push({ x: centers[last].cx + dxL / 2, y: centers[last].cy + dyL / 2, before: null }); + } + + let bestDist = Infinity; + let bestBefore: HTMLElement | null | undefined = undefined; + + for (const gap of gaps) { + const dx = e.clientX - gap.x; + const dy = e.clientY - gap.y; + const dist = dx * dx + dy * dy; + if (dist < bestDist) { + bestDist = dist; + bestBefore = gap.before; + } + } + + if (bestBefore === undefined) return; + + if (bestBefore === null) { + container.appendChild(placeholder); + } else { + container.insertBefore(placeholder, bestBefore); + } + }); + + container.addEventListener('drop', (e: DragEvent) => { + e.preventDefault(); + if (!draggedEl || !placeholder) return; + + container.insertBefore(draggedEl, placeholder); + placeholder.remove(); + placeholder = null; + draggedEl.style.display = ''; + draggedEl.setAttribute('draggable', 'false'); + + const items = Array.from( + container.querySelectorAll(':scope > .cms-ui-editable-sections') + ); + + const updates = items.map((el, index) => { + const toolbarData = el.dataset.cmsToolbar ? JSON.parse(el.dataset.cmsToolbar) : {}; + return { + uri: toolbarData.uri, + meta: { + 'layout.order': { + type: 'number', + value: index + } + } + }; + }).filter(u => u.uri); + + draggedEl = null; + + frameMessenger.send(window.parent, { + type: 'sort-sections', + payload: { updates } + }); + }); +}; + export const initToolbar = (container: HTMLElement) => { var toolbarDefinition = JSON.parse(container.dataset.cmsToolbar || '{}'); @@ -205,6 +371,12 @@ export const initToolbar = (container: HTMLElement) => { button.addEventListener('click', deleteSection); toolbar.appendChild(button); + } else if (action === "dragSectionEntries") { + // Kein Button — DnD wird nach dem ersten Render-Frame initialisiert, + // damit alle sectionEntry-Toolbars bereits im DOM sind. + requestAnimationFrame(() => { + initDragDrop(container); + }); } }) diff --git a/test-server/hosts/demo/content/index.asection.other.md b/test-server/hosts/demo/content/index.asection.other.md index 726e0bb14..001e602c8 100644 --- a/test-server/hosts/demo/content/index.asection.other.md +++ b/test-server/hosts/demo/content/index.asection.other.md @@ -1,7 +1,7 @@ --- template: section.html layout: - order: 4 + order: 3 parent: text: another parent text description: another description diff --git a/test-server/hosts/demo/content/index.asection.test.md b/test-server/hosts/demo/content/index.asection.test.md index 17926442b..ae8464584 100644 --- a/test-server/hosts/demo/content/index.asection.test.md +++ b/test-server/hosts/demo/content/index.asection.test.md @@ -2,7 +2,7 @@ template: section.html description: sec descriptione layout: - order: 3 + order: 2 parent: text: sec parent text published: false diff --git a/test-server/hosts/demo/content/index.asection.test1.md b/test-server/hosts/demo/content/index.asection.test1.md index b5d2ad7b0..ab2f615f9 100644 --- a/test-server/hosts/demo/content/index.asection.test1.md +++ b/test-server/hosts/demo/content/index.asection.test1.md @@ -2,7 +2,7 @@ template: section.html description: test23 layout: - order: 2 + order: 1 published: false parent: text: '' diff --git a/test-server/themes/demo/templates/start.html b/test-server/themes/demo/templates/start.html index e31823c2e..e8b0d6a58 100644 --- a/test-server/themes/demo/templates/start.html +++ b/test-server/themes/demo/templates/start.html @@ -66,7 +66,7 @@

template function test

{% if node.sections.containsKey('asection') %} {% for item in node.sections.get('asection') %} @@ -97,6 +97,15 @@

Template component content test

---
+ +
+

Template function with node as parameter

+
+ --- + {{ ext.node({'node': node}) | raw }} + --- +
+
{{ cms.hooks({'hook': 'theme/template/footer'}) | raw }} From a21abb5c7dd8e8082e8a6a80a4bde8982974fe4f Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Sat, 27 Jun 2026 07:30:50 +0200 Subject: [PATCH 02/11] update drop logic --- .../js/modules/manager/toolbar.inject.js | 55 ++++------------- .../dist/js/modules/manager/toolbar.inject.js | 55 ++++------------- .../src/js/modules/manager/toolbar.inject.ts | 60 ++++--------------- .../hosts/demo/content/index.asection.bla.md | 2 +- .../demo/content/index.asection.test1.md | 2 +- 5 files changed, 41 insertions(+), 133 deletions(-) diff --git a/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar.inject.js b/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar.inject.js index 06d292a64..9ebe5f343 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar.inject.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar.inject.js @@ -175,54 +175,25 @@ const initDragDrop = (container) => { const siblings = Array.from(container.querySelectorAll(':scope > .cms-ui-editable-sections')).filter(el => el !== draggedEl); if (siblings.length === 0) return; - const centers = siblings.map(el => { + // Reading-order insertion: scan siblings in DOM order and find the first + // one the cursor "comes before" — either because the cursor is above its + // row, or on the same row and to the left of its horizontal midpoint. + let insertBeforeEl = null; + for (const el of siblings) { const r = el.getBoundingClientRect(); - return { el, cx: r.left + r.width / 2, cy: r.top + r.height / 2 }; - }); - // Build n+1 gap points for n siblings. - // Middle gaps: midpoint between consecutive element centers. - // Edge gaps: extrapolate from the first/last inter-element direction so - // the "before first" and "after last" zones are symmetric with the rest. - const gaps = []; - if (centers.length === 1) { - const r = siblings[0].getBoundingClientRect(); - gaps.push({ x: r.left, y: r.top + r.height / 2, before: siblings[0] }); - gaps.push({ x: r.right, y: r.top + r.height / 2, before: null }); - } - else { - const dx0 = centers[1].cx - centers[0].cx; - const dy0 = centers[1].cy - centers[0].cy; - gaps.push({ x: centers[0].cx - dx0 / 2, y: centers[0].cy - dy0 / 2, before: centers[0].el }); - for (let i = 1; i < centers.length; i++) { - gaps.push({ - x: (centers[i - 1].cx + centers[i].cx) / 2, - y: (centers[i - 1].cy + centers[i].cy) / 2, - before: centers[i].el - }); + const aboveRow = e.clientY < r.top; + const sameRow = e.clientY >= r.top && e.clientY <= r.bottom; + const leftHalf = e.clientX < r.left + r.width / 2; + if (aboveRow || (sameRow && leftHalf)) { + insertBeforeEl = el; + break; } - const last = centers.length - 1; - const dxL = centers[last].cx - centers[last - 1].cx; - const dyL = centers[last].cy - centers[last - 1].cy; - gaps.push({ x: centers[last].cx + dxL / 2, y: centers[last].cy + dyL / 2, before: null }); } - let bestDist = Infinity; - let bestBefore = undefined; - for (const gap of gaps) { - const dx = e.clientX - gap.x; - const dy = e.clientY - gap.y; - const dist = dx * dx + dy * dy; - if (dist < bestDist) { - bestDist = dist; - bestBefore = gap.before; - } - } - if (bestBefore === undefined) - return; - if (bestBefore === null) { + if (insertBeforeEl === null) { container.appendChild(placeholder); } else { - container.insertBefore(placeholder, bestBefore); + container.insertBefore(placeholder, insertBeforeEl); } }); container.addEventListener('drop', (e) => { diff --git a/modules/ui-module/src/main/ts/dist/js/modules/manager/toolbar.inject.js b/modules/ui-module/src/main/ts/dist/js/modules/manager/toolbar.inject.js index 06d292a64..9ebe5f343 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/manager/toolbar.inject.js +++ b/modules/ui-module/src/main/ts/dist/js/modules/manager/toolbar.inject.js @@ -175,54 +175,25 @@ const initDragDrop = (container) => { const siblings = Array.from(container.querySelectorAll(':scope > .cms-ui-editable-sections')).filter(el => el !== draggedEl); if (siblings.length === 0) return; - const centers = siblings.map(el => { + // Reading-order insertion: scan siblings in DOM order and find the first + // one the cursor "comes before" — either because the cursor is above its + // row, or on the same row and to the left of its horizontal midpoint. + let insertBeforeEl = null; + for (const el of siblings) { const r = el.getBoundingClientRect(); - return { el, cx: r.left + r.width / 2, cy: r.top + r.height / 2 }; - }); - // Build n+1 gap points for n siblings. - // Middle gaps: midpoint between consecutive element centers. - // Edge gaps: extrapolate from the first/last inter-element direction so - // the "before first" and "after last" zones are symmetric with the rest. - const gaps = []; - if (centers.length === 1) { - const r = siblings[0].getBoundingClientRect(); - gaps.push({ x: r.left, y: r.top + r.height / 2, before: siblings[0] }); - gaps.push({ x: r.right, y: r.top + r.height / 2, before: null }); - } - else { - const dx0 = centers[1].cx - centers[0].cx; - const dy0 = centers[1].cy - centers[0].cy; - gaps.push({ x: centers[0].cx - dx0 / 2, y: centers[0].cy - dy0 / 2, before: centers[0].el }); - for (let i = 1; i < centers.length; i++) { - gaps.push({ - x: (centers[i - 1].cx + centers[i].cx) / 2, - y: (centers[i - 1].cy + centers[i].cy) / 2, - before: centers[i].el - }); + const aboveRow = e.clientY < r.top; + const sameRow = e.clientY >= r.top && e.clientY <= r.bottom; + const leftHalf = e.clientX < r.left + r.width / 2; + if (aboveRow || (sameRow && leftHalf)) { + insertBeforeEl = el; + break; } - const last = centers.length - 1; - const dxL = centers[last].cx - centers[last - 1].cx; - const dyL = centers[last].cy - centers[last - 1].cy; - gaps.push({ x: centers[last].cx + dxL / 2, y: centers[last].cy + dyL / 2, before: null }); } - let bestDist = Infinity; - let bestBefore = undefined; - for (const gap of gaps) { - const dx = e.clientX - gap.x; - const dy = e.clientY - gap.y; - const dist = dx * dx + dy * dy; - if (dist < bestDist) { - bestDist = dist; - bestBefore = gap.before; - } - } - if (bestBefore === undefined) - return; - if (bestBefore === null) { + if (insertBeforeEl === null) { container.appendChild(placeholder); } else { - container.insertBefore(placeholder, bestBefore); + container.insertBefore(placeholder, insertBeforeEl); } }); container.addEventListener('drop', (e) => { diff --git a/modules/ui-module/src/main/ts/src/js/modules/manager/toolbar.inject.ts b/modules/ui-module/src/main/ts/src/js/modules/manager/toolbar.inject.ts index ef227e4e0..8dd66699c 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/manager/toolbar.inject.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/manager/toolbar.inject.ts @@ -207,59 +207,25 @@ const initDragDrop = (container: HTMLElement) => { if (siblings.length === 0) return; - const centers = siblings.map(el => { + // Reading-order insertion: scan siblings in DOM order and find the first + // one the cursor "comes before" — either because the cursor is above its + // row, or on the same row and to the left of its horizontal midpoint. + let insertBeforeEl: HTMLElement | null = null; + for (const el of siblings) { const r = el.getBoundingClientRect(); - return { el, cx: r.left + r.width / 2, cy: r.top + r.height / 2 }; - }); - - // Build n+1 gap points for n siblings. - // Middle gaps: midpoint between consecutive element centers. - // Edge gaps: extrapolate from the first/last inter-element direction so - // the "before first" and "after last" zones are symmetric with the rest. - const gaps: Array<{ x: number; y: number; before: HTMLElement | null }> = []; - - if (centers.length === 1) { - const r = siblings[0].getBoundingClientRect(); - gaps.push({ x: r.left, y: r.top + r.height / 2, before: siblings[0] }); - gaps.push({ x: r.right, y: r.top + r.height / 2, before: null }); - } else { - const dx0 = centers[1].cx - centers[0].cx; - const dy0 = centers[1].cy - centers[0].cy; - gaps.push({ x: centers[0].cx - dx0 / 2, y: centers[0].cy - dy0 / 2, before: centers[0].el }); - - for (let i = 1; i < centers.length; i++) { - gaps.push({ - x: (centers[i - 1].cx + centers[i].cx) / 2, - y: (centers[i - 1].cy + centers[i].cy) / 2, - before: centers[i].el - }); - } - - const last = centers.length - 1; - const dxL = centers[last].cx - centers[last - 1].cx; - const dyL = centers[last].cy - centers[last - 1].cy; - gaps.push({ x: centers[last].cx + dxL / 2, y: centers[last].cy + dyL / 2, before: null }); - } - - let bestDist = Infinity; - let bestBefore: HTMLElement | null | undefined = undefined; - - for (const gap of gaps) { - const dx = e.clientX - gap.x; - const dy = e.clientY - gap.y; - const dist = dx * dx + dy * dy; - if (dist < bestDist) { - bestDist = dist; - bestBefore = gap.before; + const aboveRow = e.clientY < r.top; + const sameRow = e.clientY >= r.top && e.clientY <= r.bottom; + const leftHalf = e.clientX < r.left + r.width / 2; + if (aboveRow || (sameRow && leftHalf)) { + insertBeforeEl = el; + break; } } - if (bestBefore === undefined) return; - - if (bestBefore === null) { + if (insertBeforeEl === null) { container.appendChild(placeholder); } else { - container.insertBefore(placeholder, bestBefore); + container.insertBefore(placeholder, insertBeforeEl); } }); diff --git a/test-server/hosts/demo/content/index.asection.bla.md b/test-server/hosts/demo/content/index.asection.bla.md index fba0b7f06..15ff3cfd7 100644 --- a/test-server/hosts/demo/content/index.asection.bla.md +++ b/test-server/hosts/demo/content/index.asection.bla.md @@ -7,7 +7,7 @@ description: my new descritpion for that awesome section-lol parent: text: text for a section layout: - order: 0 + order: 1 media_url: compass-7592444_1920.jpg published: true object: diff --git a/test-server/hosts/demo/content/index.asection.test1.md b/test-server/hosts/demo/content/index.asection.test1.md index ab2f615f9..abb98289e 100644 --- a/test-server/hosts/demo/content/index.asection.test1.md +++ b/test-server/hosts/demo/content/index.asection.test1.md @@ -2,7 +2,7 @@ template: section.html description: test23 layout: - order: 1 + order: 0 published: false parent: text: '' From ee03fd8df8b892da526f0e9e77e86fcd8574dd56 Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Sat, 27 Jun 2026 07:47:37 +0200 Subject: [PATCH 03/11] add vertical test for drag and drop --- .../js/modules/manager/toolbar.inject.js | 25 +++++++--- .../dist/js/modules/manager/toolbar.inject.js | 25 +++++++--- .../src/js/modules/manager/toolbar.inject.ts | 25 +++++++--- .../hosts/demo/content/index.asection.bla.md | 2 +- .../demo/content/index.asection.other.md | 2 +- .../hosts/demo/content/index.asection.test.md | 2 +- .../demo/content/index.asection.test1.md | 2 +- .../hosts/demo/content/index.vsection.bla.md | 31 ++++++++++++ .../demo/content/index.vsection.other.md | 20 ++++++++ test-server/themes/demo/assets/style.css | 12 +++++ .../themes/demo/extensions/theme.manager.js | 47 +++++++++++++++++++ .../themes/demo/templates/section_v.html | 36 ++++++++++++++ test-server/themes/demo/templates/start.html | 11 +++++ 13 files changed, 218 insertions(+), 22 deletions(-) create mode 100644 test-server/hosts/demo/content/index.vsection.bla.md create mode 100644 test-server/hosts/demo/content/index.vsection.other.md create mode 100644 test-server/themes/demo/templates/section_v.html diff --git a/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar.inject.js b/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar.inject.js index 9ebe5f343..7f7a4a9b2 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar.inject.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar.inject.js @@ -175,16 +175,29 @@ const initDragDrop = (container) => { const siblings = Array.from(container.querySelectorAll(':scope > .cms-ui-editable-sections')).filter(el => el !== draggedEl); if (siblings.length === 0) return; - // Reading-order insertion: scan siblings in DOM order and find the first - // one the cursor "comes before" — either because the cursor is above its - // row, or on the same row and to the left of its horizontal midpoint. + const containerWidth = container.getBoundingClientRect().width; let insertBeforeEl = null; for (const el of siblings) { const r = el.getBoundingClientRect(); const aboveRow = e.clientY < r.top; - const sameRow = e.clientY >= r.top && e.clientY <= r.bottom; - const leftHalf = e.clientX < r.left + r.width / 2; - if (aboveRow || (sameRow && leftHalf)) { + const belowRow = e.clientY > r.bottom; + const sameRow = !aboveRow && !belowRow; + let placeBefore; + if (aboveRow) { + placeBefore = true; + } + else if (belowRow) { + placeBefore = false; + } + else if (r.width >= containerWidth * 0.9) { + // Full-width element (vertical layout): top/bottom half decides + placeBefore = e.clientY < r.top + r.height / 2; + } + else { + // Partial-width element (horizontal/wrap layout): left/right half decides + placeBefore = sameRow && e.clientX < r.left + r.width / 2; + } + if (placeBefore) { insertBeforeEl = el; break; } diff --git a/modules/ui-module/src/main/ts/dist/js/modules/manager/toolbar.inject.js b/modules/ui-module/src/main/ts/dist/js/modules/manager/toolbar.inject.js index 9ebe5f343..7f7a4a9b2 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/manager/toolbar.inject.js +++ b/modules/ui-module/src/main/ts/dist/js/modules/manager/toolbar.inject.js @@ -175,16 +175,29 @@ const initDragDrop = (container) => { const siblings = Array.from(container.querySelectorAll(':scope > .cms-ui-editable-sections')).filter(el => el !== draggedEl); if (siblings.length === 0) return; - // Reading-order insertion: scan siblings in DOM order and find the first - // one the cursor "comes before" — either because the cursor is above its - // row, or on the same row and to the left of its horizontal midpoint. + const containerWidth = container.getBoundingClientRect().width; let insertBeforeEl = null; for (const el of siblings) { const r = el.getBoundingClientRect(); const aboveRow = e.clientY < r.top; - const sameRow = e.clientY >= r.top && e.clientY <= r.bottom; - const leftHalf = e.clientX < r.left + r.width / 2; - if (aboveRow || (sameRow && leftHalf)) { + const belowRow = e.clientY > r.bottom; + const sameRow = !aboveRow && !belowRow; + let placeBefore; + if (aboveRow) { + placeBefore = true; + } + else if (belowRow) { + placeBefore = false; + } + else if (r.width >= containerWidth * 0.9) { + // Full-width element (vertical layout): top/bottom half decides + placeBefore = e.clientY < r.top + r.height / 2; + } + else { + // Partial-width element (horizontal/wrap layout): left/right half decides + placeBefore = sameRow && e.clientX < r.left + r.width / 2; + } + if (placeBefore) { insertBeforeEl = el; break; } diff --git a/modules/ui-module/src/main/ts/src/js/modules/manager/toolbar.inject.ts b/modules/ui-module/src/main/ts/src/js/modules/manager/toolbar.inject.ts index 8dd66699c..cd14fbda5 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/manager/toolbar.inject.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/manager/toolbar.inject.ts @@ -207,16 +207,29 @@ const initDragDrop = (container: HTMLElement) => { if (siblings.length === 0) return; - // Reading-order insertion: scan siblings in DOM order and find the first - // one the cursor "comes before" — either because the cursor is above its - // row, or on the same row and to the left of its horizontal midpoint. + const containerWidth = container.getBoundingClientRect().width; + let insertBeforeEl: HTMLElement | null = null; for (const el of siblings) { const r = el.getBoundingClientRect(); const aboveRow = e.clientY < r.top; - const sameRow = e.clientY >= r.top && e.clientY <= r.bottom; - const leftHalf = e.clientX < r.left + r.width / 2; - if (aboveRow || (sameRow && leftHalf)) { + const belowRow = e.clientY > r.bottom; + const sameRow = !aboveRow && !belowRow; + + let placeBefore: boolean; + if (aboveRow) { + placeBefore = true; + } else if (belowRow) { + placeBefore = false; + } else if (r.width >= containerWidth * 0.9) { + // Full-width element (vertical layout): top/bottom half decides + placeBefore = e.clientY < r.top + r.height / 2; + } else { + // Partial-width element (horizontal/wrap layout): left/right half decides + placeBefore = sameRow && e.clientX < r.left + r.width / 2; + } + + if (placeBefore) { insertBeforeEl = el; break; } diff --git a/test-server/hosts/demo/content/index.asection.bla.md b/test-server/hosts/demo/content/index.asection.bla.md index 15ff3cfd7..1d8ce7dcc 100644 --- a/test-server/hosts/demo/content/index.asection.bla.md +++ b/test-server/hosts/demo/content/index.asection.bla.md @@ -7,7 +7,7 @@ description: my new descritpion for that awesome section-lol parent: text: text for a section layout: - order: 1 + order: 3 media_url: compass-7592444_1920.jpg published: true object: diff --git a/test-server/hosts/demo/content/index.asection.other.md b/test-server/hosts/demo/content/index.asection.other.md index 001e602c8..032fda528 100644 --- a/test-server/hosts/demo/content/index.asection.other.md +++ b/test-server/hosts/demo/content/index.asection.other.md @@ -1,7 +1,7 @@ --- template: section.html layout: - order: 3 + order: 0 parent: text: another parent text description: another description diff --git a/test-server/hosts/demo/content/index.asection.test.md b/test-server/hosts/demo/content/index.asection.test.md index ae8464584..45799d0c7 100644 --- a/test-server/hosts/demo/content/index.asection.test.md +++ b/test-server/hosts/demo/content/index.asection.test.md @@ -2,7 +2,7 @@ template: section.html description: sec descriptione layout: - order: 2 + order: 1 parent: text: sec parent text published: false diff --git a/test-server/hosts/demo/content/index.asection.test1.md b/test-server/hosts/demo/content/index.asection.test1.md index abb98289e..b5d2ad7b0 100644 --- a/test-server/hosts/demo/content/index.asection.test1.md +++ b/test-server/hosts/demo/content/index.asection.test1.md @@ -2,7 +2,7 @@ template: section.html description: test23 layout: - order: 0 + order: 2 published: false parent: text: '' diff --git a/test-server/hosts/demo/content/index.vsection.bla.md b/test-server/hosts/demo/content/index.vsection.bla.md new file mode 100644 index 000000000..4f6d1c68e --- /dev/null +++ b/test-server/hosts/demo/content/index.vsection.bla.md @@ -0,0 +1,31 @@ +--- +title: Startseite +template: section_v.html +search: + index: false +description: my new descritpion for that awesome section-lol +parent: + text: text for a section +layout: + order: 3 +media_url: compass-7592444_1920.jpg +published: true +object: + values: + - title: section 1-1 + description: in section 1 + features: search + - title: New Item + description: asdads + features: export +about: its all about content +unpublish_date: null +publish_date: null +about1: more content more better +--- + +# This is a section: bla + +And no i can edit the content + +blabladdddsd asd diff --git a/test-server/hosts/demo/content/index.vsection.other.md b/test-server/hosts/demo/content/index.vsection.other.md new file mode 100644 index 000000000..73528ea8f --- /dev/null +++ b/test-server/hosts/demo/content/index.vsection.other.md @@ -0,0 +1,20 @@ +--- +template: section_v.html +layout: + order: 0 +parent: + text: another parent text +description: another description +published: false +about: "thats the about text \nand we also support **multilines**\n\nhere" +unpublish_date: null +publish_date: null +object: + values: + - title: platz 1 + description: tolles ding + features: search +title: '' +--- + +another section diff --git a/test-server/themes/demo/assets/style.css b/test-server/themes/demo/assets/style.css index 0197f56fa..dfec0d660 100644 --- a/test-server/themes/demo/assets/style.css +++ b/test-server/themes/demo/assets/style.css @@ -24,6 +24,18 @@ body { padding: 1rem; } +.sections_v { + display: flex; + flex-direction: column; + gap: 20px; +} + +.section_v { + box-sizing: border-box; + background: #f0f0f0; + padding: 1rem; +} + .language-buttons { display: flex; gap: 0.5rem; diff --git a/test-server/themes/demo/extensions/theme.manager.js b/test-server/themes/demo/extensions/theme.manager.js index 8e54305ef..5dcc0cda6 100644 --- a/test-server/themes/demo/extensions/theme.manager.js +++ b/test-server/themes/demo/extensions/theme.manager.js @@ -215,6 +215,53 @@ $hooks.registerFilter("manager/contentTypes/register", (contentTypes) => { } } }); + contentTypes.registerSectionEntryTemplate({ + section: "vsection", + name: "SectionEntryTemplate", + template: "section_v.html", + forms: { + attributes: { + fields: [ + TitleField, + DescriptionField, + PublishDateField, + UnPublishDateField, + { + type: "list", + name: "object.values", + title: "Object list", + options: { + nameField: "title" + } + }, + { + type: "text", + name: "parent.text", + title: "Parent Text" + }, + { + type: "markdown", + name: "about", + title: "About" + }, + { + type: "divider", + title: "Additional Information" + }, + { + type: "markdown", + name: "about1", + title: "About1" + }, + { + type: "markdown", + name: "about2", + title: "About2" + } + ] + } + } + }); /* global definition if ListItemTypes diff --git a/test-server/themes/demo/templates/section_v.html b/test-server/themes/demo/templates/section_v.html new file mode 100644 index 000000000..e6d91f532 --- /dev/null +++ b/test-server/themes/demo/templates/section_v.html @@ -0,0 +1,36 @@ +
+
+ {{ node.content | default('your content here') | raw}} +
+ + +
+
+ {{ node.meta.getOrDefault('description', 'description here')}} +
+
+ {{ node.meta.getOrDefault('parent.text', 'Parent.Text here')}} +
+
+ {{ cms.markdown.render(node.meta.getOrDefault('about', '--about--')) | raw}} +
+
+ {{ cms.markdown.render(node.meta.getOrDefault('about1', '--about1--')) | raw}} +
+
+
+ {% if node.meta.containsKey('media_url') %} + {% assign media = cms.mediaService.get(node.meta.media_url) %} + {{ media.meta.alt }} + {% endif %} +
+
+ {% if node.meta.containsKey('object.values') %} + {% for item in node.meta.get("object.values") %} + {{ item.title }} - {{ item.description }}
+ {% endfor %} + {% endif %} +
+
\ No newline at end of file diff --git a/test-server/themes/demo/templates/start.html b/test-server/themes/demo/templates/start.html index e8b0d6a58..47ca9d1c8 100644 --- a/test-server/themes/demo/templates/start.html +++ b/test-server/themes/demo/templates/start.html @@ -74,6 +74,17 @@

template function test

{% endfor %} {% endif %} + +

vertical test

+
+ {% if node.sections.containsKey('vsection') %} + {% for item in node.sections.get('vsection') %} + {{ item.content() | raw }} + {% endfor %} + {% endif %} +

Test Node references

From 831de08a432673ee9519a052942251005921fd6a Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Sat, 27 Jun 2026 07:48:17 +0200 Subject: [PATCH 04/11] update spec --- .../superpowers/specs/2026-06-26-drag-drop-sections-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/ui-module/src/main/ts/docs/superpowers/specs/2026-06-26-drag-drop-sections-design.md b/modules/ui-module/src/main/ts/docs/superpowers/specs/2026-06-26-drag-drop-sections-design.md index dd6428e3b..4675b9e7c 100644 --- a/modules/ui-module/src/main/ts/docs/superpowers/specs/2026-06-26-drag-drop-sections-design.md +++ b/modules/ui-module/src/main/ts/docs/superpowers/specs/2026-06-26-drag-drop-sections-design.md @@ -1,7 +1,7 @@ # Drag & Drop Section Sorting — Design Spec **Date:** 2026-06-26 -**Branch:** e2e_example_module +**Branch:** main ## Overview From 4420f87c827672cb71b34e414bd73dd9fcf23eea Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Sat, 27 Jun 2026 07:51:13 +0200 Subject: [PATCH 05/11] demo projec --- test-server/hosts/demo/content/index.vsection.bla.md | 2 +- test-server/hosts/demo/content/index.vsection.other.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test-server/hosts/demo/content/index.vsection.bla.md b/test-server/hosts/demo/content/index.vsection.bla.md index 4f6d1c68e..bfa649722 100644 --- a/test-server/hosts/demo/content/index.vsection.bla.md +++ b/test-server/hosts/demo/content/index.vsection.bla.md @@ -7,7 +7,7 @@ description: my new descritpion for that awesome section-lol parent: text: text for a section layout: - order: 3 + order: 0 media_url: compass-7592444_1920.jpg published: true object: diff --git a/test-server/hosts/demo/content/index.vsection.other.md b/test-server/hosts/demo/content/index.vsection.other.md index 73528ea8f..b806e8ee1 100644 --- a/test-server/hosts/demo/content/index.vsection.other.md +++ b/test-server/hosts/demo/content/index.vsection.other.md @@ -1,7 +1,7 @@ --- template: section_v.html layout: - order: 0 + order: 1 parent: text: another parent text description: another description From 3ee7553bfa3ab41f1120e3bb2ee5238352c44fa8 Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Sat, 27 Jun 2026 13:09:17 +0200 Subject: [PATCH 06/11] update some drag and drop handlings --- .../resources/manager/js/manager-inject.js | 1 + .../manager/js/modules/filebrowser.d.ts | 2 +- .../manager/js/modules/form/utils.d.ts | 4 +- .../manager/js/modules/localization.d.ts | 2 +- .../js/modules/manager/toolbar.inject.js | 188 +++++++++++----- .../manager/js/modules/preview.history.d.ts | 2 +- .../manager/js/modules/preview.utils.d.ts | 2 +- .../resources/manager/js/modules/state.js | 1 + .../manager/js/modules/ui-state.d.ts | 4 +- .../resources/manager/public/manager-login.js | 1 + .../src/main/ts/dist/js/manager-inject.js | 1 + .../main/ts/dist/js/modules/filebrowser.d.ts | 2 +- .../main/ts/dist/js/modules/form/utils.d.ts | 4 +- .../main/ts/dist/js/modules/localization.d.ts | 2 +- .../dist/js/modules/manager/toolbar.inject.js | 188 +++++++++++----- .../ts/dist/js/modules/preview.history.d.ts | 2 +- .../ts/dist/js/modules/preview.utils.d.ts | 2 +- .../src/main/ts/dist/js/modules/state.js | 1 + .../src/main/ts/dist/js/modules/ui-state.d.ts | 4 +- .../src/main/ts/dist/public/manager-login.js | 1 + .../src/js/modules/manager/toolbar.inject.ts | 205 ++++++++++++------ .../hosts/demo/content/index.asection.test.md | 2 +- .../demo/content/index.asection.test1.md | 2 +- .../hosts/demo/content/index.vsection.bla.md | 3 +- .../demo/content/index.vsection.other.md | 2 +- 25 files changed, 430 insertions(+), 198 deletions(-) diff --git a/modules/ui-module/src/main/resources/manager/js/manager-inject.js b/modules/ui-module/src/main/resources/manager/js/manager-inject.js index 75083adbc..2b11d2b1d 100644 --- a/modules/ui-module/src/main/resources/manager/js/manager-inject.js +++ b/modules/ui-module/src/main/resources/manager/js/manager-inject.js @@ -1,3 +1,4 @@ +"use strict"; /*- * #%L * UI Module diff --git a/modules/ui-module/src/main/resources/manager/js/modules/filebrowser.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/filebrowser.d.ts index 0c529bf7f..0869c773e 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/filebrowser.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/filebrowser.d.ts @@ -20,6 +20,6 @@ */ export function openFileBrowser(optionsParam: any): Promise; export namespace state { - let options: any; + let options: null; let currentFolder: string; } diff --git a/modules/ui-module/src/main/resources/manager/js/modules/form/utils.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/form/utils.d.ts index 7d3d89dcb..23eeb2258 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/form/utils.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/form/utils.d.ts @@ -20,7 +20,7 @@ */ declare const createID: () => string; declare const utcToLocalDateTimeInputValue: (utcString: string) => string; -declare function getUTCDateTimeFromInput(inputElement: HTMLInputElement): string; +declare function getUTCDateTimeFromInput(inputElement: HTMLInputElement): string | null; declare function utcToLocalDateInputValue(utcString: string): string; -declare function getUTCDateFromInput(inputElement: HTMLInputElement): string; +declare function getUTCDateFromInput(inputElement: HTMLInputElement): string | null; export { createID, utcToLocalDateTimeInputValue, getUTCDateTimeFromInput, utcToLocalDateInputValue, getUTCDateFromInput }; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/localization.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/localization.d.ts index b1e393873..e91051759 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/localization.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/localization.d.ts @@ -21,7 +21,7 @@ export function localizeUi(): Promise; export namespace i18n { let _locale: any; - let _cache: any; + let _cache: null; /** * Loads and merges remote localizations with defaults. */ diff --git a/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar.inject.js b/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar.inject.js index 7f7a4a9b2..69d945979 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar.inject.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar.inject.js @@ -116,41 +116,140 @@ const editAttributes = (event) => { frameMessenger.send(window.parent, command); }; const initDragDrop = (container) => { + if (container.dataset.cmsDragDropInitialized === 'true') { + return; + } const draggableItems = Array.from(container.querySelectorAll(':scope > .cms-ui-editable-sections')); if (draggableItems.length === 0) { return; } + container.dataset.cmsDragDropInitialized = 'true'; let draggedEl = null; let placeholder = null; + let dragItems = []; + let pendingDragPosition = null; + let dragOverFrame = 0; + const createPlaceholder = (item) => { + const nextPlaceholder = document.createElement('div'); + nextPlaceholder.setAttribute('data-cms-drag-placeholder', ''); + const cs = getComputedStyle(item); + nextPlaceholder.style.width = item.offsetWidth + 'px'; + nextPlaceholder.style.height = item.offsetHeight + 'px'; + nextPlaceholder.style.margin = cs.margin; + nextPlaceholder.style.border = '2px dashed #aaa'; + nextPlaceholder.style.boxSizing = 'border-box'; + nextPlaceholder.style.opacity = '0.5'; + nextPlaceholder.style.flexShrink = cs.flexShrink; + nextPlaceholder.style.flexGrow = cs.flexGrow; + nextPlaceholder.style.flexBasis = cs.flexBasis; + return nextPlaceholder; + }; + const resetDragState = () => { + if (dragOverFrame) { + cancelAnimationFrame(dragOverFrame); + dragOverFrame = 0; + } + if (draggedEl) { + draggedEl.style.display = ''; + draggedEl.setAttribute('draggable', 'false'); + } + placeholder?.remove(); + placeholder = null; + draggedEl = null; + dragItems = []; + pendingDragPosition = null; + }; + const getInsertBeforeElement = (position) => { + if (!draggedEl) { + return null; + } + const siblings = dragItems.filter(el => el !== draggedEl); + if (siblings.length === 0) { + return null; + } + const containerWidth = container.getBoundingClientRect().width; + for (const el of siblings) { + const r = el.getBoundingClientRect(); + const aboveRow = position.clientY < r.top; + const belowRow = position.clientY > r.bottom; + const sameRow = !aboveRow && !belowRow; + let placeBefore; + if (aboveRow) { + placeBefore = true; + } + else if (belowRow) { + placeBefore = false; + } + else if (r.width >= containerWidth * 0.9) { + // Full-width element (vertical layout): top/bottom half decides + placeBefore = position.clientY < r.top + r.height / 2; + } + else { + // Partial-width element (horizontal/wrap layout): left/right half decides + placeBefore = sameRow && position.clientX < r.left + r.width / 2; + } + if (placeBefore) { + return el; + } + } + return null; + }; + const updatePlaceholderPosition = () => { + dragOverFrame = 0; + if (!placeholder || !pendingDragPosition) { + return; + } + const insertBeforeEl = getInsertBeforeElement(pendingDragPosition); + if (insertBeforeEl === null) { + if (placeholder.nextElementSibling !== null) { + container.appendChild(placeholder); + } + return; + } + if (placeholder.nextElementSibling !== insertBeforeEl) { + container.insertBefore(placeholder, insertBeforeEl); + } + }; draggableItems.forEach((item) => { + if (item.dataset.cmsDragDropItemInitialized === 'true') { + return; + } + item.dataset.cmsDragDropItemInitialized = 'true'; item.setAttribute('draggable', 'false'); const itemToolbar = item.querySelector('.cms-ui-toolbar'); - if (itemToolbar) { + if (itemToolbar && !itemToolbar.querySelector('[data-cms-drag-handle]')) { const handle = document.createElement('button'); + handle.setAttribute('type', 'button'); handle.setAttribute('data-cms-drag-handle', ''); handle.setAttribute('title', 'Drag to reorder'); + handle.setAttribute('aria-label', 'Drag to reorder'); handle.innerHTML = MOVE_ICON; handle.style.cursor = 'grab'; - handle.addEventListener('mousedown', () => { + handle.addEventListener('mousedown', (e) => { + if (e.button !== 0) { + return; + } item.setAttribute('draggable', 'true'); + document.addEventListener('mouseup', () => { + if (!draggedEl) { + item.setAttribute('draggable', 'false'); + } + }, { once: true }); }); itemToolbar.appendChild(handle); } item.addEventListener('dragstart', (e) => { + if (item.getAttribute('draggable') !== 'true') { + e.preventDefault(); + return; + } draggedEl = item; + dragItems = Array.from(container.querySelectorAll(':scope > .cms-ui-editable-sections')); e.dataTransfer?.setData('text/plain', ''); - placeholder = document.createElement('div'); - placeholder.setAttribute('data-cms-drag-placeholder', ''); - const cs = getComputedStyle(item); - placeholder.style.width = item.offsetWidth + 'px'; - placeholder.style.height = item.offsetHeight + 'px'; - placeholder.style.margin = cs.margin; - placeholder.style.border = '2px dashed #aaa'; - placeholder.style.boxSizing = 'border-box'; - placeholder.style.opacity = '0.5'; - placeholder.style.flexShrink = cs.flexShrink; - placeholder.style.flexGrow = cs.flexGrow; - placeholder.style.flexBasis = cs.flexBasis; + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + } + placeholder = createPlaceholder(item); requestAnimationFrame(() => { if (draggedEl && placeholder) { container.insertBefore(placeholder, draggedEl); @@ -159,65 +258,36 @@ const initDragDrop = (container) => { }); }); item.addEventListener('dragend', () => { - if (draggedEl) { - draggedEl.style.display = ''; - draggedEl.setAttribute('draggable', 'false'); - } - placeholder?.remove(); - placeholder = null; - draggedEl = null; + resetDragState(); }); }); container.addEventListener('dragover', (e) => { e.preventDefault(); if (!draggedEl || !placeholder) return; - const siblings = Array.from(container.querySelectorAll(':scope > .cms-ui-editable-sections')).filter(el => el !== draggedEl); - if (siblings.length === 0) - return; - const containerWidth = container.getBoundingClientRect().width; - let insertBeforeEl = null; - for (const el of siblings) { - const r = el.getBoundingClientRect(); - const aboveRow = e.clientY < r.top; - const belowRow = e.clientY > r.bottom; - const sameRow = !aboveRow && !belowRow; - let placeBefore; - if (aboveRow) { - placeBefore = true; - } - else if (belowRow) { - placeBefore = false; - } - else if (r.width >= containerWidth * 0.9) { - // Full-width element (vertical layout): top/bottom half decides - placeBefore = e.clientY < r.top + r.height / 2; - } - else { - // Partial-width element (horizontal/wrap layout): left/right half decides - placeBefore = sameRow && e.clientX < r.left + r.width / 2; - } - if (placeBefore) { - insertBeforeEl = el; - break; - } - } - if (insertBeforeEl === null) { - container.appendChild(placeholder); - } - else { - container.insertBefore(placeholder, insertBeforeEl); + pendingDragPosition = { + clientX: e.clientX, + clientY: e.clientY + }; + if (!dragOverFrame) { + dragOverFrame = requestAnimationFrame(updatePlaceholderPosition); } }); container.addEventListener('drop', (e) => { e.preventDefault(); if (!draggedEl || !placeholder) return; + if (dragOverFrame) { + cancelAnimationFrame(dragOverFrame); + dragOverFrame = 0; + updatePlaceholderPosition(); + } + const droppedEl = draggedEl; container.insertBefore(draggedEl, placeholder); placeholder.remove(); placeholder = null; - draggedEl.style.display = ''; - draggedEl.setAttribute('draggable', 'false'); + droppedEl.style.display = ''; + droppedEl.setAttribute('draggable', 'false'); const items = Array.from(container.querySelectorAll(':scope > .cms-ui-editable-sections')); const updates = items.map((el, index) => { const toolbarData = el.dataset.cmsToolbar ? JSON.parse(el.dataset.cmsToolbar) : {}; @@ -232,6 +302,8 @@ const initDragDrop = (container) => { }; }).filter(u => u.uri); draggedEl = null; + dragItems = []; + pendingDragPosition = null; frameMessenger.send(window.parent, { type: 'sort-sections', payload: { updates } diff --git a/modules/ui-module/src/main/resources/manager/js/modules/preview.history.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/preview.history.d.ts index 520ce2957..6cfeb16ad 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/preview.history.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/preview.history.d.ts @@ -22,6 +22,6 @@ export namespace PreviewHistory { export { init }; export { navigatePreview }; } -declare function init(defaultUrl?: any): void; +declare function init(defaultUrl?: null): void; declare function navigatePreview(url: any, usePush?: boolean): void; export {}; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/preview.utils.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/preview.utils.d.ts index e7c2a28ba..20c850ee6 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/preview.utils.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/preview.utils.d.ts @@ -23,4 +23,4 @@ export function deActivatePreviewOverlay(): void; export function getPreviewUrl(): any; export function reloadPreview(): void; export function loadPreview(url: any): void; -export function getPreviewFrame(): HTMLElement; +export function getPreviewFrame(): HTMLElement | null; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/state.js b/modules/ui-module/src/main/resources/manager/js/modules/state.js index 7274c0b66..a0988236f 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/state.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/state.js @@ -1,3 +1,4 @@ +"use strict"; /*- * #%L * UI Module diff --git a/modules/ui-module/src/main/resources/manager/js/modules/ui-state.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/ui-state.d.ts index 65018962d..30e36cd4d 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/ui-state.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/ui-state.d.ts @@ -20,11 +20,11 @@ */ export namespace UIStateManager { function setTabState(key: any, value: any): void; - function getTabState(key: any, defaultValue?: any): any; + function getTabState(key: any, defaultValue?: null): any; function setLocale(locale: any): void; function getLocale(): any; function removeTabState(key: any): void; function setAuthToken(token: any): void; - function getAuthToken(): string; + function getAuthToken(): string | null; function clearAuthToken(): void; } diff --git a/modules/ui-module/src/main/resources/manager/public/manager-login.js b/modules/ui-module/src/main/resources/manager/public/manager-login.js index 6f21eee99..24aa077ae 100644 --- a/modules/ui-module/src/main/resources/manager/public/manager-login.js +++ b/modules/ui-module/src/main/resources/manager/public/manager-login.js @@ -1,3 +1,4 @@ +"use strict"; /*- * #%L * UI Module diff --git a/modules/ui-module/src/main/ts/dist/js/manager-inject.js b/modules/ui-module/src/main/ts/dist/js/manager-inject.js index 75083adbc..2b11d2b1d 100644 --- a/modules/ui-module/src/main/ts/dist/js/manager-inject.js +++ b/modules/ui-module/src/main/ts/dist/js/manager-inject.js @@ -1,3 +1,4 @@ +"use strict"; /*- * #%L * UI Module diff --git a/modules/ui-module/src/main/ts/dist/js/modules/filebrowser.d.ts b/modules/ui-module/src/main/ts/dist/js/modules/filebrowser.d.ts index 34d9f9cb9..e10842a8d 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/filebrowser.d.ts +++ b/modules/ui-module/src/main/ts/dist/js/modules/filebrowser.d.ts @@ -1,5 +1,5 @@ export function openFileBrowser(optionsParam: any): Promise; export namespace state { - let options: any; + let options: null; let currentFolder: string; } diff --git a/modules/ui-module/src/main/ts/dist/js/modules/form/utils.d.ts b/modules/ui-module/src/main/ts/dist/js/modules/form/utils.d.ts index 79c944de7..525b147b7 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/form/utils.d.ts +++ b/modules/ui-module/src/main/ts/dist/js/modules/form/utils.d.ts @@ -1,6 +1,6 @@ declare const createID: () => string; declare const utcToLocalDateTimeInputValue: (utcString: string) => string; -declare function getUTCDateTimeFromInput(inputElement: HTMLInputElement): string; +declare function getUTCDateTimeFromInput(inputElement: HTMLInputElement): string | null; declare function utcToLocalDateInputValue(utcString: string): string; -declare function getUTCDateFromInput(inputElement: HTMLInputElement): string; +declare function getUTCDateFromInput(inputElement: HTMLInputElement): string | null; export { createID, utcToLocalDateTimeInputValue, getUTCDateTimeFromInput, utcToLocalDateInputValue, getUTCDateFromInput }; diff --git a/modules/ui-module/src/main/ts/dist/js/modules/localization.d.ts b/modules/ui-module/src/main/ts/dist/js/modules/localization.d.ts index 0b22efabb..9eeed5a15 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/localization.d.ts +++ b/modules/ui-module/src/main/ts/dist/js/modules/localization.d.ts @@ -1,7 +1,7 @@ export function localizeUi(): Promise; export namespace i18n { let _locale: any; - let _cache: any; + let _cache: null; /** * Loads and merges remote localizations with defaults. */ diff --git a/modules/ui-module/src/main/ts/dist/js/modules/manager/toolbar.inject.js b/modules/ui-module/src/main/ts/dist/js/modules/manager/toolbar.inject.js index 7f7a4a9b2..69d945979 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/manager/toolbar.inject.js +++ b/modules/ui-module/src/main/ts/dist/js/modules/manager/toolbar.inject.js @@ -116,41 +116,140 @@ const editAttributes = (event) => { frameMessenger.send(window.parent, command); }; const initDragDrop = (container) => { + if (container.dataset.cmsDragDropInitialized === 'true') { + return; + } const draggableItems = Array.from(container.querySelectorAll(':scope > .cms-ui-editable-sections')); if (draggableItems.length === 0) { return; } + container.dataset.cmsDragDropInitialized = 'true'; let draggedEl = null; let placeholder = null; + let dragItems = []; + let pendingDragPosition = null; + let dragOverFrame = 0; + const createPlaceholder = (item) => { + const nextPlaceholder = document.createElement('div'); + nextPlaceholder.setAttribute('data-cms-drag-placeholder', ''); + const cs = getComputedStyle(item); + nextPlaceholder.style.width = item.offsetWidth + 'px'; + nextPlaceholder.style.height = item.offsetHeight + 'px'; + nextPlaceholder.style.margin = cs.margin; + nextPlaceholder.style.border = '2px dashed #aaa'; + nextPlaceholder.style.boxSizing = 'border-box'; + nextPlaceholder.style.opacity = '0.5'; + nextPlaceholder.style.flexShrink = cs.flexShrink; + nextPlaceholder.style.flexGrow = cs.flexGrow; + nextPlaceholder.style.flexBasis = cs.flexBasis; + return nextPlaceholder; + }; + const resetDragState = () => { + if (dragOverFrame) { + cancelAnimationFrame(dragOverFrame); + dragOverFrame = 0; + } + if (draggedEl) { + draggedEl.style.display = ''; + draggedEl.setAttribute('draggable', 'false'); + } + placeholder?.remove(); + placeholder = null; + draggedEl = null; + dragItems = []; + pendingDragPosition = null; + }; + const getInsertBeforeElement = (position) => { + if (!draggedEl) { + return null; + } + const siblings = dragItems.filter(el => el !== draggedEl); + if (siblings.length === 0) { + return null; + } + const containerWidth = container.getBoundingClientRect().width; + for (const el of siblings) { + const r = el.getBoundingClientRect(); + const aboveRow = position.clientY < r.top; + const belowRow = position.clientY > r.bottom; + const sameRow = !aboveRow && !belowRow; + let placeBefore; + if (aboveRow) { + placeBefore = true; + } + else if (belowRow) { + placeBefore = false; + } + else if (r.width >= containerWidth * 0.9) { + // Full-width element (vertical layout): top/bottom half decides + placeBefore = position.clientY < r.top + r.height / 2; + } + else { + // Partial-width element (horizontal/wrap layout): left/right half decides + placeBefore = sameRow && position.clientX < r.left + r.width / 2; + } + if (placeBefore) { + return el; + } + } + return null; + }; + const updatePlaceholderPosition = () => { + dragOverFrame = 0; + if (!placeholder || !pendingDragPosition) { + return; + } + const insertBeforeEl = getInsertBeforeElement(pendingDragPosition); + if (insertBeforeEl === null) { + if (placeholder.nextElementSibling !== null) { + container.appendChild(placeholder); + } + return; + } + if (placeholder.nextElementSibling !== insertBeforeEl) { + container.insertBefore(placeholder, insertBeforeEl); + } + }; draggableItems.forEach((item) => { + if (item.dataset.cmsDragDropItemInitialized === 'true') { + return; + } + item.dataset.cmsDragDropItemInitialized = 'true'; item.setAttribute('draggable', 'false'); const itemToolbar = item.querySelector('.cms-ui-toolbar'); - if (itemToolbar) { + if (itemToolbar && !itemToolbar.querySelector('[data-cms-drag-handle]')) { const handle = document.createElement('button'); + handle.setAttribute('type', 'button'); handle.setAttribute('data-cms-drag-handle', ''); handle.setAttribute('title', 'Drag to reorder'); + handle.setAttribute('aria-label', 'Drag to reorder'); handle.innerHTML = MOVE_ICON; handle.style.cursor = 'grab'; - handle.addEventListener('mousedown', () => { + handle.addEventListener('mousedown', (e) => { + if (e.button !== 0) { + return; + } item.setAttribute('draggable', 'true'); + document.addEventListener('mouseup', () => { + if (!draggedEl) { + item.setAttribute('draggable', 'false'); + } + }, { once: true }); }); itemToolbar.appendChild(handle); } item.addEventListener('dragstart', (e) => { + if (item.getAttribute('draggable') !== 'true') { + e.preventDefault(); + return; + } draggedEl = item; + dragItems = Array.from(container.querySelectorAll(':scope > .cms-ui-editable-sections')); e.dataTransfer?.setData('text/plain', ''); - placeholder = document.createElement('div'); - placeholder.setAttribute('data-cms-drag-placeholder', ''); - const cs = getComputedStyle(item); - placeholder.style.width = item.offsetWidth + 'px'; - placeholder.style.height = item.offsetHeight + 'px'; - placeholder.style.margin = cs.margin; - placeholder.style.border = '2px dashed #aaa'; - placeholder.style.boxSizing = 'border-box'; - placeholder.style.opacity = '0.5'; - placeholder.style.flexShrink = cs.flexShrink; - placeholder.style.flexGrow = cs.flexGrow; - placeholder.style.flexBasis = cs.flexBasis; + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + } + placeholder = createPlaceholder(item); requestAnimationFrame(() => { if (draggedEl && placeholder) { container.insertBefore(placeholder, draggedEl); @@ -159,65 +258,36 @@ const initDragDrop = (container) => { }); }); item.addEventListener('dragend', () => { - if (draggedEl) { - draggedEl.style.display = ''; - draggedEl.setAttribute('draggable', 'false'); - } - placeholder?.remove(); - placeholder = null; - draggedEl = null; + resetDragState(); }); }); container.addEventListener('dragover', (e) => { e.preventDefault(); if (!draggedEl || !placeholder) return; - const siblings = Array.from(container.querySelectorAll(':scope > .cms-ui-editable-sections')).filter(el => el !== draggedEl); - if (siblings.length === 0) - return; - const containerWidth = container.getBoundingClientRect().width; - let insertBeforeEl = null; - for (const el of siblings) { - const r = el.getBoundingClientRect(); - const aboveRow = e.clientY < r.top; - const belowRow = e.clientY > r.bottom; - const sameRow = !aboveRow && !belowRow; - let placeBefore; - if (aboveRow) { - placeBefore = true; - } - else if (belowRow) { - placeBefore = false; - } - else if (r.width >= containerWidth * 0.9) { - // Full-width element (vertical layout): top/bottom half decides - placeBefore = e.clientY < r.top + r.height / 2; - } - else { - // Partial-width element (horizontal/wrap layout): left/right half decides - placeBefore = sameRow && e.clientX < r.left + r.width / 2; - } - if (placeBefore) { - insertBeforeEl = el; - break; - } - } - if (insertBeforeEl === null) { - container.appendChild(placeholder); - } - else { - container.insertBefore(placeholder, insertBeforeEl); + pendingDragPosition = { + clientX: e.clientX, + clientY: e.clientY + }; + if (!dragOverFrame) { + dragOverFrame = requestAnimationFrame(updatePlaceholderPosition); } }); container.addEventListener('drop', (e) => { e.preventDefault(); if (!draggedEl || !placeholder) return; + if (dragOverFrame) { + cancelAnimationFrame(dragOverFrame); + dragOverFrame = 0; + updatePlaceholderPosition(); + } + const droppedEl = draggedEl; container.insertBefore(draggedEl, placeholder); placeholder.remove(); placeholder = null; - draggedEl.style.display = ''; - draggedEl.setAttribute('draggable', 'false'); + droppedEl.style.display = ''; + droppedEl.setAttribute('draggable', 'false'); const items = Array.from(container.querySelectorAll(':scope > .cms-ui-editable-sections')); const updates = items.map((el, index) => { const toolbarData = el.dataset.cmsToolbar ? JSON.parse(el.dataset.cmsToolbar) : {}; @@ -232,6 +302,8 @@ const initDragDrop = (container) => { }; }).filter(u => u.uri); draggedEl = null; + dragItems = []; + pendingDragPosition = null; frameMessenger.send(window.parent, { type: 'sort-sections', payload: { updates } diff --git a/modules/ui-module/src/main/ts/dist/js/modules/preview.history.d.ts b/modules/ui-module/src/main/ts/dist/js/modules/preview.history.d.ts index 02a1aa102..19f953414 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/preview.history.d.ts +++ b/modules/ui-module/src/main/ts/dist/js/modules/preview.history.d.ts @@ -2,6 +2,6 @@ export namespace PreviewHistory { export { init }; export { navigatePreview }; } -declare function init(defaultUrl?: any): void; +declare function init(defaultUrl?: null): void; declare function navigatePreview(url: any, usePush?: boolean): void; export {}; diff --git a/modules/ui-module/src/main/ts/dist/js/modules/preview.utils.d.ts b/modules/ui-module/src/main/ts/dist/js/modules/preview.utils.d.ts index f2f167f34..24beb8ecc 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/preview.utils.d.ts +++ b/modules/ui-module/src/main/ts/dist/js/modules/preview.utils.d.ts @@ -3,4 +3,4 @@ export function deActivatePreviewOverlay(): void; export function getPreviewUrl(): any; export function reloadPreview(): void; export function loadPreview(url: any): void; -export function getPreviewFrame(): HTMLElement; +export function getPreviewFrame(): HTMLElement | null; diff --git a/modules/ui-module/src/main/ts/dist/js/modules/state.js b/modules/ui-module/src/main/ts/dist/js/modules/state.js index 7274c0b66..a0988236f 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/state.js +++ b/modules/ui-module/src/main/ts/dist/js/modules/state.js @@ -1,3 +1,4 @@ +"use strict"; /*- * #%L * UI Module diff --git a/modules/ui-module/src/main/ts/dist/js/modules/ui-state.d.ts b/modules/ui-module/src/main/ts/dist/js/modules/ui-state.d.ts index cf3faa9ae..d064b5d45 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/ui-state.d.ts +++ b/modules/ui-module/src/main/ts/dist/js/modules/ui-state.d.ts @@ -1,10 +1,10 @@ export namespace UIStateManager { function setTabState(key: any, value: any): void; - function getTabState(key: any, defaultValue?: any): any; + function getTabState(key: any, defaultValue?: null): any; function setLocale(locale: any): void; function getLocale(): any; function removeTabState(key: any): void; function setAuthToken(token: any): void; - function getAuthToken(): string; + function getAuthToken(): string | null; function clearAuthToken(): void; } diff --git a/modules/ui-module/src/main/ts/dist/public/manager-login.js b/modules/ui-module/src/main/ts/dist/public/manager-login.js index 6f21eee99..24aa077ae 100644 --- a/modules/ui-module/src/main/ts/dist/public/manager-login.js +++ b/modules/ui-module/src/main/ts/dist/public/manager-login.js @@ -1,3 +1,4 @@ +"use strict"; /*- * #%L * UI Module diff --git a/modules/ui-module/src/main/ts/src/js/modules/manager/toolbar.inject.ts b/modules/ui-module/src/main/ts/src/js/modules/manager/toolbar.inject.ts index cd14fbda5..84fee0fe4 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/manager/toolbar.inject.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/manager/toolbar.inject.ts @@ -134,6 +134,10 @@ const editAttributes = (event: Event) => { const initDragDrop = (container: HTMLElement) => { + if (container.dataset.cmsDragDropInitialized === 'true') { + return; + } + const draggableItems = Array.from( container.querySelectorAll(':scope > .cms-ui-editable-sections') ); @@ -141,42 +145,150 @@ const initDragDrop = (container: HTMLElement) => { if (draggableItems.length === 0) { return; } + container.dataset.cmsDragDropInitialized = 'true'; let draggedEl: HTMLElement | null = null; let placeholder: HTMLElement | null = null; + let dragItems: HTMLElement[] = []; + let pendingDragPosition: { clientX: number; clientY: number } | null = null; + let dragOverFrame = 0; + + const createPlaceholder = (item: HTMLElement) => { + const nextPlaceholder = document.createElement('div'); + nextPlaceholder.setAttribute('data-cms-drag-placeholder', ''); + const cs = getComputedStyle(item); + nextPlaceholder.style.width = item.offsetWidth + 'px'; + nextPlaceholder.style.height = item.offsetHeight + 'px'; + nextPlaceholder.style.margin = cs.margin; + nextPlaceholder.style.border = '2px dashed #aaa'; + nextPlaceholder.style.boxSizing = 'border-box'; + nextPlaceholder.style.opacity = '0.5'; + nextPlaceholder.style.flexShrink = cs.flexShrink; + nextPlaceholder.style.flexGrow = cs.flexGrow; + nextPlaceholder.style.flexBasis = cs.flexBasis; + return nextPlaceholder; + }; + + const resetDragState = () => { + if (dragOverFrame) { + cancelAnimationFrame(dragOverFrame); + dragOverFrame = 0; + } + if (draggedEl) { + draggedEl.style.display = ''; + draggedEl.setAttribute('draggable', 'false'); + } + placeholder?.remove(); + placeholder = null; + draggedEl = null; + dragItems = []; + pendingDragPosition = null; + }; + + const getInsertBeforeElement = (position: { clientX: number; clientY: number }) => { + if (!draggedEl) { + return null; + } + + const siblings = dragItems.filter(el => el !== draggedEl); + if (siblings.length === 0) { + return null; + } + + const containerWidth = container.getBoundingClientRect().width; + + for (const el of siblings) { + const r = el.getBoundingClientRect(); + const aboveRow = position.clientY < r.top; + const belowRow = position.clientY > r.bottom; + const sameRow = !aboveRow && !belowRow; + + let placeBefore: boolean; + if (aboveRow) { + placeBefore = true; + } else if (belowRow) { + placeBefore = false; + } else if (r.width >= containerWidth * 0.9) { + // Full-width element (vertical layout): top/bottom half decides + placeBefore = position.clientY < r.top + r.height / 2; + } else { + // Partial-width element (horizontal/wrap layout): left/right half decides + placeBefore = sameRow && position.clientX < r.left + r.width / 2; + } + + if (placeBefore) { + return el; + } + } + + return null; + }; + + const updatePlaceholderPosition = () => { + dragOverFrame = 0; + if (!placeholder || !pendingDragPosition) { + return; + } + + const insertBeforeEl = getInsertBeforeElement(pendingDragPosition); + if (insertBeforeEl === null) { + if (placeholder.nextElementSibling !== null) { + container.appendChild(placeholder); + } + return; + } + + if (placeholder.nextElementSibling !== insertBeforeEl) { + container.insertBefore(placeholder, insertBeforeEl); + } + }; draggableItems.forEach((item) => { + if (item.dataset.cmsDragDropItemInitialized === 'true') { + return; + } + item.dataset.cmsDragDropItemInitialized = 'true'; item.setAttribute('draggable', 'false'); const itemToolbar = item.querySelector('.cms-ui-toolbar'); - if (itemToolbar) { + if (itemToolbar && !itemToolbar.querySelector('[data-cms-drag-handle]')) { const handle = document.createElement('button'); + handle.setAttribute('type', 'button'); handle.setAttribute('data-cms-drag-handle', ''); handle.setAttribute('title', 'Drag to reorder'); + handle.setAttribute('aria-label', 'Drag to reorder'); handle.innerHTML = MOVE_ICON; handle.style.cursor = 'grab'; - handle.addEventListener('mousedown', () => { + handle.addEventListener('mousedown', (e: MouseEvent) => { + if (e.button !== 0) { + return; + } item.setAttribute('draggable', 'true'); + document.addEventListener('mouseup', () => { + if (!draggedEl) { + item.setAttribute('draggable', 'false'); + } + }, { once: true }); }); itemToolbar.appendChild(handle); } item.addEventListener('dragstart', (e: DragEvent) => { + if (item.getAttribute('draggable') !== 'true') { + e.preventDefault(); + return; + } + draggedEl = item; + dragItems = Array.from( + container.querySelectorAll(':scope > .cms-ui-editable-sections') + ); e.dataTransfer?.setData('text/plain', ''); + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + } - placeholder = document.createElement('div'); - placeholder.setAttribute('data-cms-drag-placeholder', ''); - const cs = getComputedStyle(item); - placeholder.style.width = item.offsetWidth + 'px'; - placeholder.style.height = item.offsetHeight + 'px'; - placeholder.style.margin = cs.margin; - placeholder.style.border = '2px dashed #aaa'; - placeholder.style.boxSizing = 'border-box'; - placeholder.style.opacity = '0.5'; - placeholder.style.flexShrink = cs.flexShrink; - placeholder.style.flexGrow = cs.flexGrow; - placeholder.style.flexBasis = cs.flexBasis; + placeholder = createPlaceholder(item); requestAnimationFrame(() => { if (draggedEl && placeholder) { @@ -187,13 +299,7 @@ const initDragDrop = (container: HTMLElement) => { }); item.addEventListener('dragend', () => { - if (draggedEl) { - draggedEl.style.display = ''; - draggedEl.setAttribute('draggable', 'false'); - } - placeholder?.remove(); - placeholder = null; - draggedEl = null; + resetDragState(); }); }); @@ -201,44 +307,12 @@ const initDragDrop = (container: HTMLElement) => { e.preventDefault(); if (!draggedEl || !placeholder) return; - const siblings = Array.from( - container.querySelectorAll(':scope > .cms-ui-editable-sections') - ).filter(el => el !== draggedEl); - - if (siblings.length === 0) return; - - const containerWidth = container.getBoundingClientRect().width; - - let insertBeforeEl: HTMLElement | null = null; - for (const el of siblings) { - const r = el.getBoundingClientRect(); - const aboveRow = e.clientY < r.top; - const belowRow = e.clientY > r.bottom; - const sameRow = !aboveRow && !belowRow; - - let placeBefore: boolean; - if (aboveRow) { - placeBefore = true; - } else if (belowRow) { - placeBefore = false; - } else if (r.width >= containerWidth * 0.9) { - // Full-width element (vertical layout): top/bottom half decides - placeBefore = e.clientY < r.top + r.height / 2; - } else { - // Partial-width element (horizontal/wrap layout): left/right half decides - placeBefore = sameRow && e.clientX < r.left + r.width / 2; - } - - if (placeBefore) { - insertBeforeEl = el; - break; - } - } - - if (insertBeforeEl === null) { - container.appendChild(placeholder); - } else { - container.insertBefore(placeholder, insertBeforeEl); + pendingDragPosition = { + clientX: e.clientX, + clientY: e.clientY + }; + if (!dragOverFrame) { + dragOverFrame = requestAnimationFrame(updatePlaceholderPosition); } }); @@ -246,11 +320,18 @@ const initDragDrop = (container: HTMLElement) => { e.preventDefault(); if (!draggedEl || !placeholder) return; + if (dragOverFrame) { + cancelAnimationFrame(dragOverFrame); + dragOverFrame = 0; + updatePlaceholderPosition(); + } + + const droppedEl = draggedEl; container.insertBefore(draggedEl, placeholder); placeholder.remove(); placeholder = null; - draggedEl.style.display = ''; - draggedEl.setAttribute('draggable', 'false'); + droppedEl.style.display = ''; + droppedEl.setAttribute('draggable', 'false'); const items = Array.from( container.querySelectorAll(':scope > .cms-ui-editable-sections') @@ -270,6 +351,8 @@ const initDragDrop = (container: HTMLElement) => { }).filter(u => u.uri); draggedEl = null; + dragItems = []; + pendingDragPosition = null; frameMessenger.send(window.parent, { type: 'sort-sections', diff --git a/test-server/hosts/demo/content/index.asection.test.md b/test-server/hosts/demo/content/index.asection.test.md index 45799d0c7..ae8464584 100644 --- a/test-server/hosts/demo/content/index.asection.test.md +++ b/test-server/hosts/demo/content/index.asection.test.md @@ -2,7 +2,7 @@ template: section.html description: sec descriptione layout: - order: 1 + order: 2 parent: text: sec parent text published: false diff --git a/test-server/hosts/demo/content/index.asection.test1.md b/test-server/hosts/demo/content/index.asection.test1.md index b5d2ad7b0..ab2f615f9 100644 --- a/test-server/hosts/demo/content/index.asection.test1.md +++ b/test-server/hosts/demo/content/index.asection.test1.md @@ -2,7 +2,7 @@ template: section.html description: test23 layout: - order: 2 + order: 1 published: false parent: text: '' diff --git a/test-server/hosts/demo/content/index.vsection.bla.md b/test-server/hosts/demo/content/index.vsection.bla.md index bfa649722..7bdd62ab6 100644 --- a/test-server/hosts/demo/content/index.vsection.bla.md +++ b/test-server/hosts/demo/content/index.vsection.bla.md @@ -7,8 +7,7 @@ description: my new descritpion for that awesome section-lol parent: text: text for a section layout: - order: 0 -media_url: compass-7592444_1920.jpg + order: 1 published: true object: values: diff --git a/test-server/hosts/demo/content/index.vsection.other.md b/test-server/hosts/demo/content/index.vsection.other.md index b806e8ee1..73528ea8f 100644 --- a/test-server/hosts/demo/content/index.vsection.other.md +++ b/test-server/hosts/demo/content/index.vsection.other.md @@ -1,7 +1,7 @@ --- template: section_v.html layout: - order: 1 + order: 0 parent: text: another parent text description: another description From 7b87045cfa2e054a8391d5ef166235f9811c3905 Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Tue, 30 Jun 2026 21:26:30 +0200 Subject: [PATCH 07/11] update drop handling --- .../js/modules/manager/toolbar.inject.js | 52 +++++++++++-------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar.inject.js b/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar.inject.js index 69d945979..c62df5278 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar.inject.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar.inject.js @@ -139,6 +139,7 @@ const initDragDrop = (container) => { nextPlaceholder.style.border = '2px dashed #aaa'; nextPlaceholder.style.boxSizing = 'border-box'; nextPlaceholder.style.opacity = '0.5'; + nextPlaceholder.style.pointerEvents = 'none'; nextPlaceholder.style.flexShrink = cs.flexShrink; nextPlaceholder.style.flexGrow = cs.flexGrow; nextPlaceholder.style.flexBasis = cs.flexBasis; @@ -159,36 +160,43 @@ const initDragDrop = (container) => { dragItems = []; pendingDragPosition = null; }; + const getDirectChildSectionEntry = (element) => { + const item = element?.closest('.cms-ui-editable-sections'); + if (!item || item.parentElement !== container || item === draggedEl) { + return null; + } + return item; + }; + const isPointInsideElement = (position, element) => { + const r = element.getBoundingClientRect(); + return position.clientX >= r.left + && position.clientX <= r.right + && position.clientY >= r.top + && position.clientY <= r.bottom; + }; const getInsertBeforeElement = (position) => { - if (!draggedEl) { + if (!draggedEl || !placeholder) { return null; } + const targetItem = getDirectChildSectionEntry(document.elementFromPoint(position.clientX, position.clientY)); + if (targetItem) { + const draggedIndex = dragItems.indexOf(draggedEl); + const targetIndex = dragItems.indexOf(targetItem); + if (draggedIndex > -1 && targetIndex > -1 && draggedIndex < targetIndex) { + return targetItem.nextElementSibling; + } + return targetItem; + } + if (isPointInsideElement(position, placeholder)) { + return placeholder.nextElementSibling; + } const siblings = dragItems.filter(el => el !== draggedEl); if (siblings.length === 0) { return null; } - const containerWidth = container.getBoundingClientRect().width; for (const el of siblings) { const r = el.getBoundingClientRect(); - const aboveRow = position.clientY < r.top; - const belowRow = position.clientY > r.bottom; - const sameRow = !aboveRow && !belowRow; - let placeBefore; - if (aboveRow) { - placeBefore = true; - } - else if (belowRow) { - placeBefore = false; - } - else if (r.width >= containerWidth * 0.9) { - // Full-width element (vertical layout): top/bottom half decides - placeBefore = position.clientY < r.top + r.height / 2; - } - else { - // Partial-width element (horizontal/wrap layout): left/right half decides - placeBefore = sameRow && position.clientX < r.left + r.width / 2; - } - if (placeBefore) { + if (position.clientY < r.top + r.height / 2) { return el; } } @@ -206,7 +214,7 @@ const initDragDrop = (container) => { } return; } - if (placeholder.nextElementSibling !== insertBeforeEl) { + if (insertBeforeEl !== placeholder && placeholder.nextElementSibling !== insertBeforeEl) { container.insertBefore(placeholder, insertBeforeEl); } }; From 6d1979d85b807b0b34edca79208a15ef9bc58db6 Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Tue, 30 Jun 2026 21:26:34 +0200 Subject: [PATCH 08/11] update drop handling --- .../js/modules/manager/toolbar.inject.js | 35 ++++++------------- .../hosts/demo/content/index.asection.bla.md | 2 +- .../demo/content/index.asection.other.md | 2 +- .../hosts/demo/content/index.asection.test.md | 2 +- .../demo/content/index.asection.test1.md | 2 +- 5 files changed, 14 insertions(+), 29 deletions(-) diff --git a/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar.inject.js b/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar.inject.js index c62df5278..b137a68f6 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar.inject.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar.inject.js @@ -129,6 +129,7 @@ const initDragDrop = (container) => { let dragItems = []; let pendingDragPosition = null; let dragOverFrame = 0; + const keepPlaceholderPosition = Symbol('keepPlaceholderPosition'); const createPlaceholder = (item) => { const nextPlaceholder = document.createElement('div'); nextPlaceholder.setAttribute('data-cms-drag-placeholder', ''); @@ -167,40 +168,21 @@ const initDragDrop = (container) => { } return item; }; - const isPointInsideElement = (position, element) => { - const r = element.getBoundingClientRect(); - return position.clientX >= r.left - && position.clientX <= r.right - && position.clientY >= r.top - && position.clientY <= r.bottom; - }; const getInsertBeforeElement = (position) => { if (!draggedEl || !placeholder) { - return null; + return keepPlaceholderPosition; } const targetItem = getDirectChildSectionEntry(document.elementFromPoint(position.clientX, position.clientY)); if (targetItem) { - const draggedIndex = dragItems.indexOf(draggedEl); - const targetIndex = dragItems.indexOf(targetItem); - if (draggedIndex > -1 && targetIndex > -1 && draggedIndex < targetIndex) { + const children = Array.from(container.children); + const placeholderIndex = children.indexOf(placeholder); + const targetIndex = children.indexOf(targetItem); + if (placeholderIndex > -1 && targetIndex > -1 && placeholderIndex < targetIndex) { return targetItem.nextElementSibling; } return targetItem; } - if (isPointInsideElement(position, placeholder)) { - return placeholder.nextElementSibling; - } - const siblings = dragItems.filter(el => el !== draggedEl); - if (siblings.length === 0) { - return null; - } - for (const el of siblings) { - const r = el.getBoundingClientRect(); - if (position.clientY < r.top + r.height / 2) { - return el; - } - } - return null; + return keepPlaceholderPosition; }; const updatePlaceholderPosition = () => { dragOverFrame = 0; @@ -208,6 +190,9 @@ const initDragDrop = (container) => { return; } const insertBeforeEl = getInsertBeforeElement(pendingDragPosition); + if (insertBeforeEl === keepPlaceholderPosition) { + return; + } if (insertBeforeEl === null) { if (placeholder.nextElementSibling !== null) { container.appendChild(placeholder); diff --git a/test-server/hosts/demo/content/index.asection.bla.md b/test-server/hosts/demo/content/index.asection.bla.md index 1d8ce7dcc..17e48c628 100644 --- a/test-server/hosts/demo/content/index.asection.bla.md +++ b/test-server/hosts/demo/content/index.asection.bla.md @@ -7,7 +7,7 @@ description: my new descritpion for that awesome section-lol parent: text: text for a section layout: - order: 3 + order: 2 media_url: compass-7592444_1920.jpg published: true object: diff --git a/test-server/hosts/demo/content/index.asection.other.md b/test-server/hosts/demo/content/index.asection.other.md index 032fda528..1aaba3e1c 100644 --- a/test-server/hosts/demo/content/index.asection.other.md +++ b/test-server/hosts/demo/content/index.asection.other.md @@ -1,7 +1,7 @@ --- template: section.html layout: - order: 0 + order: 1 parent: text: another parent text description: another description diff --git a/test-server/hosts/demo/content/index.asection.test.md b/test-server/hosts/demo/content/index.asection.test.md index ae8464584..633edf9a9 100644 --- a/test-server/hosts/demo/content/index.asection.test.md +++ b/test-server/hosts/demo/content/index.asection.test.md @@ -2,7 +2,7 @@ template: section.html description: sec descriptione layout: - order: 2 + order: 0 parent: text: sec parent text published: false diff --git a/test-server/hosts/demo/content/index.asection.test1.md b/test-server/hosts/demo/content/index.asection.test1.md index ab2f615f9..16e5e151b 100644 --- a/test-server/hosts/demo/content/index.asection.test1.md +++ b/test-server/hosts/demo/content/index.asection.test1.md @@ -2,7 +2,7 @@ template: section.html description: test23 layout: - order: 1 + order: 3 published: false parent: text: '' From 050439575241c093f0654da87495dd5826179185 Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Tue, 30 Jun 2026 21:30:58 +0200 Subject: [PATCH 09/11] update drop handling --- .../dist/js/modules/manager/toolbar.inject.js | 53 ++- .../plans/2026-06-26-drag-drop-sections.md | 318 ------------------ .../2026-06-26-drag-drop-sections-design.md | 92 ----- modules/ui-module/src/main/ts/package.json | 2 +- .../src/js/modules/manager/toolbar.inject.ts | 54 ++- 5 files changed, 49 insertions(+), 470 deletions(-) delete mode 100644 modules/ui-module/src/main/ts/docs/superpowers/plans/2026-06-26-drag-drop-sections.md delete mode 100644 modules/ui-module/src/main/ts/docs/superpowers/specs/2026-06-26-drag-drop-sections-design.md diff --git a/modules/ui-module/src/main/ts/dist/js/modules/manager/toolbar.inject.js b/modules/ui-module/src/main/ts/dist/js/modules/manager/toolbar.inject.js index 69d945979..b137a68f6 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/manager/toolbar.inject.js +++ b/modules/ui-module/src/main/ts/dist/js/modules/manager/toolbar.inject.js @@ -129,6 +129,7 @@ const initDragDrop = (container) => { let dragItems = []; let pendingDragPosition = null; let dragOverFrame = 0; + const keepPlaceholderPosition = Symbol('keepPlaceholderPosition'); const createPlaceholder = (item) => { const nextPlaceholder = document.createElement('div'); nextPlaceholder.setAttribute('data-cms-drag-placeholder', ''); @@ -139,6 +140,7 @@ const initDragDrop = (container) => { nextPlaceholder.style.border = '2px dashed #aaa'; nextPlaceholder.style.boxSizing = 'border-box'; nextPlaceholder.style.opacity = '0.5'; + nextPlaceholder.style.pointerEvents = 'none'; nextPlaceholder.style.flexShrink = cs.flexShrink; nextPlaceholder.style.flexGrow = cs.flexGrow; nextPlaceholder.style.flexBasis = cs.flexBasis; @@ -159,40 +161,28 @@ const initDragDrop = (container) => { dragItems = []; pendingDragPosition = null; }; - const getInsertBeforeElement = (position) => { - if (!draggedEl) { + const getDirectChildSectionEntry = (element) => { + const item = element?.closest('.cms-ui-editable-sections'); + if (!item || item.parentElement !== container || item === draggedEl) { return null; } - const siblings = dragItems.filter(el => el !== draggedEl); - if (siblings.length === 0) { - return null; + return item; + }; + const getInsertBeforeElement = (position) => { + if (!draggedEl || !placeholder) { + return keepPlaceholderPosition; } - const containerWidth = container.getBoundingClientRect().width; - for (const el of siblings) { - const r = el.getBoundingClientRect(); - const aboveRow = position.clientY < r.top; - const belowRow = position.clientY > r.bottom; - const sameRow = !aboveRow && !belowRow; - let placeBefore; - if (aboveRow) { - placeBefore = true; - } - else if (belowRow) { - placeBefore = false; - } - else if (r.width >= containerWidth * 0.9) { - // Full-width element (vertical layout): top/bottom half decides - placeBefore = position.clientY < r.top + r.height / 2; - } - else { - // Partial-width element (horizontal/wrap layout): left/right half decides - placeBefore = sameRow && position.clientX < r.left + r.width / 2; - } - if (placeBefore) { - return el; + const targetItem = getDirectChildSectionEntry(document.elementFromPoint(position.clientX, position.clientY)); + if (targetItem) { + const children = Array.from(container.children); + const placeholderIndex = children.indexOf(placeholder); + const targetIndex = children.indexOf(targetItem); + if (placeholderIndex > -1 && targetIndex > -1 && placeholderIndex < targetIndex) { + return targetItem.nextElementSibling; } + return targetItem; } - return null; + return keepPlaceholderPosition; }; const updatePlaceholderPosition = () => { dragOverFrame = 0; @@ -200,13 +190,16 @@ const initDragDrop = (container) => { return; } const insertBeforeEl = getInsertBeforeElement(pendingDragPosition); + if (insertBeforeEl === keepPlaceholderPosition) { + return; + } if (insertBeforeEl === null) { if (placeholder.nextElementSibling !== null) { container.appendChild(placeholder); } return; } - if (placeholder.nextElementSibling !== insertBeforeEl) { + if (insertBeforeEl !== placeholder && placeholder.nextElementSibling !== insertBeforeEl) { container.insertBefore(placeholder, insertBeforeEl); } }; diff --git a/modules/ui-module/src/main/ts/docs/superpowers/plans/2026-06-26-drag-drop-sections.md b/modules/ui-module/src/main/ts/docs/superpowers/plans/2026-06-26-drag-drop-sections.md deleted file mode 100644 index 8a91cbeea..000000000 --- a/modules/ui-module/src/main/ts/docs/superpowers/plans/2026-06-26-drag-drop-sections.md +++ /dev/null @@ -1,318 +0,0 @@ -# Drag & Drop Section Sorting Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Sections im CMS-Preview per nativem HTML5 Drag & Drop umsortieren, mit Auto-Save on Drop und ohne Seiten-Reload. - -**Architecture:** Ein neuer Action-Typ `"dragSectionEntries"` wird in `toolbar.inject.ts` verarbeitet: wenn er erkannt wird, initialisiert `initDragDrop()` natives DnD auf dem section-Container und injiziert Move-Handles in alle Kind-Elemente vom Typ `sectionEntry`. Nach dem Drop sendet der Preview-Frame per `frameMessenger` ein `"sort-sections"`-Kommando mit den neuen Index-Werten; ein neuer Handler im Manager-Frame ruft direkt `setMetaBatch()` auf. - -**Tech Stack:** TypeScript, natives HTML5 Drag & Drop API, frameMessenger (bestehendes Messaging-System), `setMetaBatch` aus `rpc-content.ts` - -## Global Constraints - -- Nur Desktop-Browser (Chrome, Firefox, Safari, Edge aktuell) — kein Touch-Support -- Kein Seiten-Reload nach dem Drop (`reloadPreview()` wird nicht aufgerufen) -- Keine externen Libraries (kein SortableJS o.ä.) -- Layout-agnostisch: funktioniert für Flex-Row, Flex-Column und Grid -- `"orderSectionEntries"` (Modal) bleibt unverändert und weiterhin nutzbar -- Build-Command: `npm run build` (ruft `tsc` auf) im Verzeichnis `src/main/ts` - ---- - -## File Map - -| Datei | Aktion | Verantwortung | -|---|---|---| -| `src/js/modules/manager/toolbar.inject.ts` | Modify | Neuer `"dragSectionEntries"` Branch + `initDragDrop()`-Funktion | -| `src/js/modules/manager/manager.message.handlers.ts` | Modify | Neuer `"sort-sections"`-Handler mit `setMetaBatch` | - ---- - -## Task 1: `sort-sections` Message Handler im Manager-Frame - -**Files:** -- Modify: `src/js/modules/manager/manager.message.handlers.ts` - -**Interfaces:** -- Consumes: `setMetaBatch` aus `@cms/modules/rpc/rpc-content.js` (bereits exportiert) -- Consumes: `frameMessenger.on(type, callback)` (bereits im File vorhanden) -- Produces: Handler reagiert auf `{ type: "sort-sections", payload: { updates: Array<{ uri: string, meta: { "layout.order": { type: "number", value: number } } }> } }` - -- [ ] **Step 1: Import `setMetaBatch` hinzufügen** - -In `manager.message.handlers.ts`, Zeile 24 (nach dem letzten Import), folgende Zeile einfügen: - -```typescript -import { getContentNode, setMetaBatch } from '@cms/modules/rpc/rpc-content.js'; -``` - -Achtung: Der bestehende Import von `getContentNode` in Zeile 24 muss dabei ersetzt werden: - -```typescript -// vorher (Zeile 24): -import { getContentNode } from '@cms/modules/rpc/rpc-content.js'; - -// nachher: -import { getContentNode, setMetaBatch } from '@cms/modules/rpc/rpc-content.js'; -``` - -- [ ] **Step 2: Handler nach dem `getContentNode`-Handler einfügen** - -Nach dem `frameMessenger.on('getContentNode', ...)` Block (Zeile 184-196), vor der schließenden `}` von `initMessageHandlers`, folgenden Block einfügen: - -```typescript - frameMessenger.on('sort-sections', async (payload: any) => { - await setMetaBatch({ updates: payload.updates }); - }); -``` - -- [ ] **Step 3: Build ausführen und auf Fehler prüfen** - -```bash -cd /pfad/zu/src/main/ts && npm run build -``` - -Erwartetes Ergebnis: Build erfolgreich, keine TypeScript-Fehler. - -- [ ] **Step 4: Manuell verifizieren** - -Prüfen dass im kompilierten Output `manager.message.handlers.js` der neue Handler vorhanden ist: - -```bash -grep -n "sort-sections" src/js/modules/manager/manager.message.handlers.js -``` - -Erwartetes Ergebnis: Zeile mit `sort-sections` gefunden. - ---- - -## Task 2: `initDragDrop()` und `"dragSectionEntries"` in `toolbar.inject.ts` - -**Files:** -- Modify: `src/js/modules/manager/toolbar.inject.ts` - -**Interfaces:** -- Consumes: `MOVE_ICON` aus `@cms/modules/manager/toolbar-icons` (bereits im File importierbar, muss zum Import hinzugefügt werden) -- Consumes: `frameMessenger` aus `@cms/modules/frameMessenger.js` (bereits importiert, Zeile 21) -- Produces: Funktion `initDragDrop(container: HTMLElement, sectionName: string): void` -- Produces: Neuer `else if (action === "dragSectionEntries")` Branch in `initToolbar()` - -**Voraussetzung aus Task 1:** Handler für `"sort-sections"` muss im Manager registriert sein (Task 1 abgeschlossen). - -### Wie sectionEntry-Kinder gefunden werden - -`initToolbar()` wird für jeden Container aufgerufen, der `[data-cms-toolbar]` hat. Für `sectionEntry`-Elemente setzt es `container.classList.add("cms-ui-editable-sections")`. `initDragDrop()` sucht daher im **Parent** des section-Containers nach allen direkten Kinder-Elementen mit der Klasse `cms-ui-editable-sections`: - -``` -section-Container (hat "dragSectionEntries" Action) - └── Kind-Element 1 [data-cms-toolbar type="sectionEntry"] → hat Klasse cms-ui-editable-sections - └── Kind-Element 2 [data-cms-toolbar type="sectionEntry"] → hat Klasse cms-ui-editable-sections -``` - -Da `initDragDrop` via `requestAnimationFrame` aufgerufen wird, sind alle `initToolbar`-Aufrufe für die Kinder bereits abgeschlossen. - -### Layout-agnostische Insert-Position - -``` -für jedes draggable-Kind k (außer dem gezogenen Element): - rect = k.getBoundingClientRect() - dx = event.clientX - (rect.left + rect.width / 2) - dy = event.clientY - (rect.top + rect.height / 2) - distance = Math.sqrt(dx*dx + dy*dy) - -nächstes Element = Kind mit minimalem distance -wenn Cursor vor der Mitte des nächsten Elements (dy < 0 ODER (dy === 0 UND dx < 0)) - → insertBefore(dragged, nearest) -sonst - → insertBefore(dragged, nearest.nextSibling) // = insertAfter -``` - -- [ ] **Step 1: `MOVE_ICON` zum Import hinzufügen** - -Zeile 22 in `toolbar.inject.ts` anpassen: - -```typescript -// vorher: -import { EDIT_ATTRIBUTES_ICON, EDIT_PAGE_ICON, SECTION_ADD_ICON, SECTION_DELETE_ICON, SECTION_SORT_ICON, SECTION_UNPUBLISHED_ICON } from "@cms/modules/manager/toolbar-icons"; - -// nachher: -import { EDIT_ATTRIBUTES_ICON, EDIT_PAGE_ICON, MOVE_ICON, SECTION_ADD_ICON, SECTION_DELETE_ICON, SECTION_SORT_ICON, SECTION_UNPUBLISHED_ICON } from "@cms/modules/manager/toolbar-icons"; -``` - -- [ ] **Step 2: `initDragDrop()` Funktion vor `initToolbar` einfügen** - -Direkt vor `export const initToolbar = ...` (Zeile 136) folgende Funktion einfügen: - -```typescript -const initDragDrop = (container: HTMLElement, sectionName: string) => { - const draggableItems = Array.from( - container.querySelectorAll(':scope > .cms-ui-editable-sections') - ); - - if (draggableItems.length === 0) { - return; - } - - let draggedEl: HTMLElement | null = null; - - draggableItems.forEach((item) => { - item.setAttribute('draggable', 'true'); - - // Move-Handle in die Toolbar des Items injizieren - const itemToolbar = item.querySelector('.cms-ui-toolbar'); - if (itemToolbar) { - const handle = document.createElement('button'); - handle.setAttribute('data-cms-drag-handle', ''); - handle.setAttribute('title', 'Drag to reorder'); - handle.innerHTML = MOVE_ICON; - handle.style.cursor = 'grab'; - // mousedown/mouseup auf dem Handle steuert das draggable-Attribut, - // damit nur das Handle das Ziehen auslöst - handle.addEventListener('mousedown', () => { - item.setAttribute('draggable', 'true'); - }); - itemToolbar.appendChild(handle); - } - - item.addEventListener('dragstart', (e: DragEvent) => { - draggedEl = item; - item.style.opacity = '0.4'; - e.dataTransfer?.setData('text/plain', ''); - }); - - item.addEventListener('dragend', () => { - item.style.opacity = ''; - draggedEl = null; - }); - }); - - container.addEventListener('dragover', (e: DragEvent) => { - e.preventDefault(); - if (!draggedEl) return; - - const siblings = Array.from( - container.querySelectorAll(':scope > .cms-ui-editable-sections') - ).filter(el => el !== draggedEl); - - if (siblings.length === 0) return; - - let nearest: HTMLElement = siblings[0]; - let nearestDist = Infinity; - - siblings.forEach(el => { - const rect = el.getBoundingClientRect(); - const dx = e.clientX - (rect.left + rect.width / 2); - const dy = e.clientY - (rect.top + rect.height / 2); - const dist = Math.sqrt(dx * dx + dy * dy); - if (dist < nearestDist) { - nearestDist = dist; - nearest = el; - } - }); - - const rect = nearest.getBoundingClientRect(); - const dy = e.clientY - (rect.top + rect.height / 2); - const dx = e.clientX - (rect.left + rect.width / 2); - const before = dy < 0 || (dy === 0 && dx < 0); - - if (before) { - container.insertBefore(draggedEl, nearest); - } else { - container.insertBefore(draggedEl, nearest.nextSibling); - } - }); - - container.addEventListener('drop', async (e: DragEvent) => { - e.preventDefault(); - if (!draggedEl) return; - - const items = Array.from( - container.querySelectorAll(':scope > .cms-ui-editable-sections') - ); - - const updates = items.map((el, index) => { - const toolbarData = el.dataset.cmsToolbar ? JSON.parse(el.dataset.cmsToolbar) : {}; - return { - uri: toolbarData.uri, - meta: { - 'layout.order': { - type: 'number', - value: index - } - } - }; - }).filter(u => u.uri); - - frameMessenger.send(window.parent, { - type: 'sort-sections', - payload: { updates } - }); - }); -}; -``` - -- [ ] **Step 3: `"dragSectionEntries"` Branch in `initToolbar()` hinzufügen** - -In der `toolbarDefinition.actions.forEach`-Schleife (nach dem `else if (action === "deleteSectionEntry")` Block, ca. Zeile 207) folgenden Block einfügen: - -```typescript - } else if (action === "dragSectionEntries") { - // Kein Button — DnD wird nach dem ersten Render-Frame initialisiert, - // damit alle sectionEntry-Toolbars bereits im DOM sind. - const sectionName = toolbarDefinition.section || ''; - requestAnimationFrame(() => { - initDragDrop(container, sectionName); - }); - } -``` - -- [ ] **Step 4: Build ausführen** - -```bash -cd /pfad/zu/src/main/ts && npm run build -``` - -Erwartetes Ergebnis: Build erfolgreich, keine TypeScript-Fehler. - -- [ ] **Step 5: Manuell verifizieren — Handle-Injektion** - -Im kompilierten Output prüfen: - -```bash -grep -n "dragSectionEntries\|initDragDrop\|sort-sections" src/js/modules/manager/toolbar.inject.js -``` - -Erwartetes Ergebnis: Alle drei Strings gefunden. - ---- - -## Task 3: Manuelle End-to-End-Verifikation - -Kein automatisierter Test möglich (DnD erfordert Browser-Interaktion). Manuelle Schritte: - -- [ ] **Step 1: Template mit `"dragSectionEntries"` konfigurieren** - -In einem Test-Template die section-Toolbar auf den neuen Action-Typ umstellen: - -```html -{{ ext.ui.toolbar("asection", "section", ["addSectionEntry", "dragSectionEntries"], { "section": "asection"}) | raw }} -``` - -- [ ] **Step 2: Vorschau öffnen und Toolbar-Hover prüfen** - -Im CMS-Manager die Seite mit der konfigurierten Section aufrufen. Beim Hovern über ein sectionEntry-Element muss in dessen Toolbar das Move-Icon (`MOVE_ICON` — Kreuz-Pfeile) erscheinen. - -- [ ] **Step 3: Drag & Drop testen** - -Ein sectionEntry-Element am Move-Handle greifen und an eine andere Position ziehen. Beim Loslassen muss: -1. Das Element an der neuen Position im DOM verbleiben -2. Kein Seiten-Reload stattfinden -3. In den Browser DevTools (Network) ein RPC-Request `meta.set.batch` mit den aktualisierten `layout.order`-Werten sichtbar sein - -- [ ] **Step 4: Persistenz prüfen** - -Seite neu laden — die Sections müssen in der neuen Reihenfolge erscheinen (entsprechend der gespeicherten `layout.order`-Werte). - -- [ ] **Step 5: Rückwärtskompatibilität prüfen** - -Eine Section mit `"orderSectionEntries"` (Modal) in der Toolbar öffnen und sicherstellen, dass der Sortier-Dialog weiterhin funktioniert. diff --git a/modules/ui-module/src/main/ts/docs/superpowers/specs/2026-06-26-drag-drop-sections-design.md b/modules/ui-module/src/main/ts/docs/superpowers/specs/2026-06-26-drag-drop-sections-design.md deleted file mode 100644 index 4675b9e7c..000000000 --- a/modules/ui-module/src/main/ts/docs/superpowers/specs/2026-06-26-drag-drop-sections-design.md +++ /dev/null @@ -1,92 +0,0 @@ -# Drag & Drop Section Sorting — Design Spec - -**Date:** 2026-06-26 -**Branch:** main - -## Overview - -Sections im CMS-Preview sollen per Drag & Drop direkt umsortierbar sein. Die Konfiguration erfolgt an der section-Container-Toolbar via neuem Action-Typ `"dragSectionEntries"`, parallel zum bestehenden `"orderSectionEntries"` (Modal). Nach dem Drop wird automatisch gespeichert, ohne die Preview-Seite neu zu laden. - -## Anforderungen - -- Desktop-Browser: Chrome, Firefox, Safari, Edge (aktuell) -- Touch-Support: nicht erforderlich -- Layout-agnostisch: funktioniert für horizontale (Flex-Row, Grid) und vertikale (Flex-Column) Section-Layouts -- Auto-Save on Drop: kein expliziter Speichern-Button, kein Seiten-Reload -- Rückwärtskompatibel: `"orderSectionEntries"` (Modal) bleibt unverändert - -## Konfiguration - -Der Entwickler wählt in seinem Template einen der beiden Action-Typen — oder beide: - -```html -{{/* Nur Drag & Drop */}} -{{ ext.ui.toolbar("asection", "section", ["addSectionEntry", "dragSectionEntries"], { "section": "asection"}) | raw }} - -{{/* Nur Modal */}} -{{ ext.ui.toolbar("asection", "section", ["addSectionEntry", "orderSectionEntries"], { "section": "asection"}) | raw }} -``` - -Die sectionEntry-Toolbar bleibt unverändert: -```html -{{ ext.ui.toolbar(node.uri, "sectionEntry", ["editContent", "editAttributes", "deleteSectionEntry"], {"uri": node.uri, "form": "attributes"}) | raw }} -``` - -## Architektur & Datenfluss - -``` -Preview-Frame (toolbar.inject.ts) - 1. initToolbar() erkennt "dragSectionEntries" auf section-Container - 2. requestAnimationFrame(() => initDragDrop(container, sectionName)) - — stellt sicher, dass alle sectionEntry-Toolbars bereits initialisiert sind - 3. Für jedes sectionEntry-Kind-Element: - - draggable="true" setzen - - MOVE_ICON-Handle als Button in dessen Toolbar injizieren - 4. Container: dragstart / dragover / drop Events registrieren - 5. onDrop: URIs in neuer Reihenfolge aus dem DOM lesen → updates[] aufbauen - 6. frameMessenger.send(window.parent, { - type: "sort-sections", - payload: { updates: [{ uri, meta: { "layout.order": { type: "number", value: index } } }] } - }) - -Manager-Frame (manager.message.handlers.ts) - 7. Neuer Handler frameMessenger.on("sort-sections", payload => ...) - 8. setMetaBatch({ updates: payload.updates }) direkt aufrufen - 9. Kein reloadPreview(), kein Modal -``` - -## Layout-agnostische Drop-Ziel-Erkennung - -Beim `dragover` wird für jedes sectionEntry-Kind der Abstand zwischen Mausposition und Element-Mitte berechnet (pythagoreisch). Das Element mit dem kleinsten Abstand ist das Ziel. Der Cursor-Vergleich zur Mitte bestimmt `insertBefore` vs. `insertAfter`: - -``` -für jedes sectionEntry-Kind k: - rect = k.getBoundingClientRect() - dx = event.clientX - (rect.left + rect.width / 2) - dy = event.clientY - (rect.top + rect.height / 2) - distance = Math.sqrt(dx*dx + dy*dy) - -nächstes Element = Kind mit minimalem distance -insertBefore wenn Cursor vor der Mitte (dx < 0 || dy < 0), sonst insertAfter -``` - -Das funktioniert ohne Annahmen über das CSS-Layout. - -## Betroffene Dateien - -| Datei | Änderung | -|---|---| -| `src/js/modules/manager/toolbar.inject.ts` | neuer `else if (action === "dragSectionEntries")` Block; `initDragDrop()`-Funktion; Import von `MOVE_ICON` | -| `src/js/modules/manager/manager.message.handlers.ts` | neuer `frameMessenger.on("sort-sections", ...)` Handler mit direktem `setMetaBatch`-Aufruf; Import von `setMetaBatch` | -| `src/js/modules/manager/toolbar-icons.ts` | keine Änderung (`MOVE_ICON` existiert bereits) | - -## Was nicht geändert wird - -- `edit-sections.js` — bleibt unverändert -- `toolbar-icons.ts` — `MOVE_ICON` ist bereits vorhanden -- sectionEntry-Toolbar-Konfiguration — keine neuen Actions erforderlich -- Preview-Reload nach Drop — bewusst weggelassen - -## Offene Punkte / Entscheidungen - -- Das MOVE_ICON-Handle wird vom section-Container-Init in die sectionEntry-Toolbar injiziert. Das setzt voraus, dass sectionEntry-Kinder mit `[data-cms-toolbar]` und `data-cms-type="sectionEntry"` (oder äquivalent) auffindbar sind. Die exakte Selektor-Logik wird in der Implementierung anhand des DOM-Outputs von `initToolbar` für sectionEntry-Elemente verifiziert. diff --git a/modules/ui-module/src/main/ts/package.json b/modules/ui-module/src/main/ts/package.json index 601d608d7..b01d59552 100644 --- a/modules/ui-module/src/main/ts/package.json +++ b/modules/ui-module/src/main/ts/package.json @@ -1,7 +1,7 @@ { "name": "condation-cms-ui", "author": "CondationCMS", - "version": "0.2.0", + "version": "0.3.0", "scripts": { "build": "tsc" }, diff --git a/modules/ui-module/src/main/ts/src/js/modules/manager/toolbar.inject.ts b/modules/ui-module/src/main/ts/src/js/modules/manager/toolbar.inject.ts index 84fee0fe4..a015b698c 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/manager/toolbar.inject.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/manager/toolbar.inject.ts @@ -152,6 +152,7 @@ const initDragDrop = (container: HTMLElement) => { let dragItems: HTMLElement[] = []; let pendingDragPosition: { clientX: number; clientY: number } | null = null; let dragOverFrame = 0; + const keepPlaceholderPosition = Symbol('keepPlaceholderPosition'); const createPlaceholder = (item: HTMLElement) => { const nextPlaceholder = document.createElement('div'); @@ -163,6 +164,7 @@ const initDragDrop = (container: HTMLElement) => { nextPlaceholder.style.border = '2px dashed #aaa'; nextPlaceholder.style.boxSizing = 'border-box'; nextPlaceholder.style.opacity = '0.5'; + nextPlaceholder.style.pointerEvents = 'none'; nextPlaceholder.style.flexShrink = cs.flexShrink; nextPlaceholder.style.flexGrow = cs.flexGrow; nextPlaceholder.style.flexBasis = cs.flexBasis; @@ -185,43 +187,33 @@ const initDragDrop = (container: HTMLElement) => { pendingDragPosition = null; }; - const getInsertBeforeElement = (position: { clientX: number; clientY: number }) => { - if (!draggedEl) { + const getDirectChildSectionEntry = (element: Element | null) => { + const item = element?.closest('.cms-ui-editable-sections'); + if (!item || item.parentElement !== container || item === draggedEl) { return null; } + return item; + }; - const siblings = dragItems.filter(el => el !== draggedEl); - if (siblings.length === 0) { - return null; + const getInsertBeforeElement = (position: { clientX: number; clientY: number }): Element | null | typeof keepPlaceholderPosition => { + if (!draggedEl || !placeholder) { + return keepPlaceholderPosition; } - const containerWidth = container.getBoundingClientRect().width; - - for (const el of siblings) { - const r = el.getBoundingClientRect(); - const aboveRow = position.clientY < r.top; - const belowRow = position.clientY > r.bottom; - const sameRow = !aboveRow && !belowRow; - - let placeBefore: boolean; - if (aboveRow) { - placeBefore = true; - } else if (belowRow) { - placeBefore = false; - } else if (r.width >= containerWidth * 0.9) { - // Full-width element (vertical layout): top/bottom half decides - placeBefore = position.clientY < r.top + r.height / 2; - } else { - // Partial-width element (horizontal/wrap layout): left/right half decides - placeBefore = sameRow && position.clientX < r.left + r.width / 2; - } + const targetItem = getDirectChildSectionEntry(document.elementFromPoint(position.clientX, position.clientY)); + if (targetItem) { + const children = Array.from(container.children); + const placeholderIndex = children.indexOf(placeholder); + const targetIndex = children.indexOf(targetItem); - if (placeBefore) { - return el; + if (placeholderIndex > -1 && targetIndex > -1 && placeholderIndex < targetIndex) { + return targetItem.nextElementSibling; } + + return targetItem; } - return null; + return keepPlaceholderPosition; }; const updatePlaceholderPosition = () => { @@ -231,6 +223,10 @@ const initDragDrop = (container: HTMLElement) => { } const insertBeforeEl = getInsertBeforeElement(pendingDragPosition); + if (insertBeforeEl === keepPlaceholderPosition) { + return; + } + if (insertBeforeEl === null) { if (placeholder.nextElementSibling !== null) { container.appendChild(placeholder); @@ -238,7 +234,7 @@ const initDragDrop = (container: HTMLElement) => { return; } - if (placeholder.nextElementSibling !== insertBeforeEl) { + if (insertBeforeEl !== placeholder && placeholder.nextElementSibling !== insertBeforeEl) { container.insertBefore(placeholder, insertBeforeEl); } }; From 920050d46c0603e8e4c71999525d411101a305f4 Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Tue, 30 Jun 2026 21:37:12 +0200 Subject: [PATCH 10/11] update test data --- test-server/hosts/demo/content/index.asection.bla.md | 2 +- test-server/hosts/demo/content/index.asection.other.md | 2 +- test-server/hosts/demo/content/index.asection.test.md | 2 +- test-server/hosts/demo/content/index.asection.test1.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test-server/hosts/demo/content/index.asection.bla.md b/test-server/hosts/demo/content/index.asection.bla.md index 17e48c628..fba0b7f06 100644 --- a/test-server/hosts/demo/content/index.asection.bla.md +++ b/test-server/hosts/demo/content/index.asection.bla.md @@ -7,7 +7,7 @@ description: my new descritpion for that awesome section-lol parent: text: text for a section layout: - order: 2 + order: 0 media_url: compass-7592444_1920.jpg published: true object: diff --git a/test-server/hosts/demo/content/index.asection.other.md b/test-server/hosts/demo/content/index.asection.other.md index 1aaba3e1c..001e602c8 100644 --- a/test-server/hosts/demo/content/index.asection.other.md +++ b/test-server/hosts/demo/content/index.asection.other.md @@ -1,7 +1,7 @@ --- template: section.html layout: - order: 1 + order: 3 parent: text: another parent text description: another description diff --git a/test-server/hosts/demo/content/index.asection.test.md b/test-server/hosts/demo/content/index.asection.test.md index 633edf9a9..ae8464584 100644 --- a/test-server/hosts/demo/content/index.asection.test.md +++ b/test-server/hosts/demo/content/index.asection.test.md @@ -2,7 +2,7 @@ template: section.html description: sec descriptione layout: - order: 0 + order: 2 parent: text: sec parent text published: false diff --git a/test-server/hosts/demo/content/index.asection.test1.md b/test-server/hosts/demo/content/index.asection.test1.md index 16e5e151b..ab2f615f9 100644 --- a/test-server/hosts/demo/content/index.asection.test1.md +++ b/test-server/hosts/demo/content/index.asection.test1.md @@ -2,7 +2,7 @@ template: section.html description: test23 layout: - order: 3 + order: 1 published: false parent: text: '' From 05f14ce53e543c78e94c3f86ad7fdff6da5d4820 Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Wed, 1 Jul 2026 11:59:01 +0200 Subject: [PATCH 11/11] avoid multiple preview parameters in url --- .../com/condation/cms/api/utils/HTTPUtil.java | 59 ++++++++++++++----- 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/cms-api/src/main/java/com/condation/cms/api/utils/HTTPUtil.java b/cms-api/src/main/java/com/condation/cms/api/utils/HTTPUtil.java index 21d3cf5f6..953ef4389 100644 --- a/cms-api/src/main/java/com/condation/cms/api/utils/HTTPUtil.java +++ b/cms-api/src/main/java/com/condation/cms/api/utils/HTTPUtil.java @@ -66,7 +66,7 @@ public static String appendPreviewParameter(String url, final FeatureContainer f return url + fragment; } - + /** * Adds the context according to the siteproperties and the preview to an * url @@ -77,33 +77,60 @@ public static String appendPreviewParameter(String url, final FeatureContainer f */ public static String modifyUrl(String url, final FeatureContainer featureContainer) { - // is external url - if (url.startsWith("http") || url.startsWith("https")) { + // Externe URL + if (url.startsWith("http://") || url.startsWith("https://")) { return url; } - // Fragment (#...) abtrennen, falls vorhanden + // Fragment (#...) abtrennen String fragment = ""; int fragmentIndex = url.indexOf('#'); if (fragmentIndex >= 0) { - fragment = url.substring(fragmentIndex); // inkl. # + fragment = url.substring(fragmentIndex); url = url.substring(0, fragmentIndex); } - url = prependContext(url, featureContainer.get(SitePropertiesFeature.class).siteProperties()); + url = prependContext( + url, + featureContainer.get(SitePropertiesFeature.class).siteProperties() + ); + + if (featureContainer.has(IsPreviewFeature.class) + && !hasQueryParameter(url, "preview")) { - if (featureContainer.has(IsPreviewFeature.class)) { var feature = featureContainer.get(IsPreviewFeature.class); - if (url.contains("?")) { - url += "&preview=" + feature.mode().getValue(); - } else { - url += "?preview=" + feature.mode().getValue(); - } + + url += url.contains("?") + ? "&preview=" + feature.mode().getValue() + : "?preview=" + feature.mode().getValue(); } return url + fragment; } + private static boolean hasQueryParameter(String url, String parameterName) { + int queryStart = url.indexOf('?'); + + if (queryStart < 0) { + return false; + } + + String query = url.substring(queryStart + 1); + + for (String parameter : query.split("&")) { + int equalsIndex = parameter.indexOf('='); + String name = equalsIndex >= 0 + ? parameter.substring(0, equalsIndex) + : parameter; + + if (parameterName.equals(name)) { + return true; + } + } + + return false; + } + /** * Adds the context according to the siteproperties to an url * @@ -126,10 +153,10 @@ public static String prependContext(String url, final SiteProperties sitePropert url = contextPath + url; } - if (!url.startsWith("/")) { - url = "/" + url; - } - + if (!url.startsWith("/")) { + url = "/" + url; + } + return url; }