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; } 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..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 @@ -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,194 @@ 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 keepPlaceholderPosition = Symbol('keepPlaceholderPosition'); + 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.pointerEvents = 'none'; + 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 getDirectChildSectionEntry = (element) => { + const item = element?.closest('.cms-ui-editable-sections'); + if (!item || item.parentElement !== container || item === draggedEl) { + return null; + } + return item; + }; + const getInsertBeforeElement = (position) => { + if (!draggedEl || !placeholder) { + return keepPlaceholderPosition; + } + 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 keepPlaceholderPosition; + }; + const updatePlaceholderPosition = () => { + dragOverFrame = 0; + if (!placeholder || !pendingDragPosition) { + return; + } + const insertBeforeEl = getInsertBeforeElement(pendingDragPosition); + if (insertBeforeEl === keepPlaceholderPosition) { + return; + } + if (insertBeforeEl === null) { + if (placeholder.nextElementSibling !== null) { + container.appendChild(placeholder); + } + return; + } + if (insertBeforeEl !== placeholder && 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 && !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', (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', ''); + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + } + placeholder = createPlaceholder(item); + requestAnimationFrame(() => { + if (draggedEl && placeholder) { + container.insertBefore(placeholder, draggedEl); + draggedEl.style.display = 'none'; + } + }); + }); + item.addEventListener('dragend', () => { + resetDragState(); + }); + }); + container.addEventListener('dragover', (e) => { + e.preventDefault(); + if (!draggedEl || !placeholder) + return; + 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; + 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) : {}; + return { + uri: toolbarData.uri, + meta: { + 'layout.order': { + type: 'number', + value: index + } + } + }; + }).filter(u => u.uri); + draggedEl = null; + dragItems = []; + pendingDragPosition = 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 +372,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/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..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 @@ -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,194 @@ 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 keepPlaceholderPosition = Symbol('keepPlaceholderPosition'); + 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.pointerEvents = 'none'; + 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 getDirectChildSectionEntry = (element) => { + const item = element?.closest('.cms-ui-editable-sections'); + if (!item || item.parentElement !== container || item === draggedEl) { + return null; + } + return item; + }; + const getInsertBeforeElement = (position) => { + if (!draggedEl || !placeholder) { + return keepPlaceholderPosition; + } + 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 keepPlaceholderPosition; + }; + const updatePlaceholderPosition = () => { + dragOverFrame = 0; + if (!placeholder || !pendingDragPosition) { + return; + } + const insertBeforeEl = getInsertBeforeElement(pendingDragPosition); + if (insertBeforeEl === keepPlaceholderPosition) { + return; + } + if (insertBeforeEl === null) { + if (placeholder.nextElementSibling !== null) { + container.appendChild(placeholder); + } + return; + } + if (insertBeforeEl !== placeholder && 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 && !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', (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', ''); + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + } + placeholder = createPlaceholder(item); + requestAnimationFrame(() => { + if (draggedEl && placeholder) { + container.insertBefore(placeholder, draggedEl); + draggedEl.style.display = 'none'; + } + }); + }); + item.addEventListener('dragend', () => { + resetDragState(); + }); + }); + container.addEventListener('dragover', (e) => { + e.preventDefault(); + if (!draggedEl || !placeholder) + return; + 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; + 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) : {}; + return { + uri: toolbarData.uri, + meta: { + 'layout.order': { + type: 'number', + value: index + } + } + }; + }).filter(u => u.uri); + draggedEl = null; + dragItems = []; + pendingDragPosition = 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 +372,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/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/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..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 @@ -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,230 @@ 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') + ); + + 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 keepPlaceholderPosition = Symbol('keepPlaceholderPosition'); + + 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.pointerEvents = 'none'; + 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 getDirectChildSectionEntry = (element: Element | null) => { + const item = element?.closest('.cms-ui-editable-sections'); + if (!item || item.parentElement !== container || item === draggedEl) { + return null; + } + return item; + }; + + const getInsertBeforeElement = (position: { clientX: number; clientY: number }): Element | null | typeof keepPlaceholderPosition => { + if (!draggedEl || !placeholder) { + return keepPlaceholderPosition; + } + + 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 keepPlaceholderPosition; + }; + + const updatePlaceholderPosition = () => { + dragOverFrame = 0; + if (!placeholder || !pendingDragPosition) { + return; + } + + const insertBeforeEl = getInsertBeforeElement(pendingDragPosition); + if (insertBeforeEl === keepPlaceholderPosition) { + return; + } + + if (insertBeforeEl === null) { + if (placeholder.nextElementSibling !== null) { + container.appendChild(placeholder); + } + return; + } + + if (insertBeforeEl !== placeholder && 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 && !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', (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 = createPlaceholder(item); + + requestAnimationFrame(() => { + if (draggedEl && placeholder) { + container.insertBefore(placeholder, draggedEl); + draggedEl.style.display = 'none'; + } + }); + }); + + item.addEventListener('dragend', () => { + resetDragState(); + }); + }); + + container.addEventListener('dragover', (e: DragEvent) => { + e.preventDefault(); + if (!draggedEl || !placeholder) return; + + pendingDragPosition = { + clientX: e.clientX, + clientY: e.clientY + }; + if (!dragOverFrame) { + dragOverFrame = requestAnimationFrame(updatePlaceholderPosition); + } + }); + + container.addEventListener('drop', (e: DragEvent) => { + e.preventDefault(); + if (!draggedEl || !placeholder) return; + + if (dragOverFrame) { + cancelAnimationFrame(dragOverFrame); + dragOverFrame = 0; + updatePlaceholderPosition(); + } + + const droppedEl = draggedEl; + container.insertBefore(draggedEl, placeholder); + placeholder.remove(); + placeholder = null; + 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) : {}; + return { + uri: toolbarData.uri, + meta: { + 'layout.order': { + type: 'number', + value: index + } + } + }; + }).filter(u => u.uri); + + draggedEl = null; + dragItems = []; + pendingDragPosition = null; + + frameMessenger.send(window.parent, { + type: 'sort-sections', + payload: { updates } + }); + }); +}; + export const initToolbar = (container: HTMLElement) => { var toolbarDefinition = JSON.parse(container.dataset.cmsToolbar || '{}'); @@ -205,6 +429,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/hosts/demo/content/index.vsection.bla.md b/test-server/hosts/demo/content/index.vsection.bla.md new file mode 100644 index 000000000..7bdd62ab6 --- /dev/null +++ b/test-server/hosts/demo/content/index.vsection.bla.md @@ -0,0 +1,30 @@ +--- +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: 1 +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 9745cf892..47ca9d1c8 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') %} @@ -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