diff --git a/admin/collage-designer/assets/icons/rotate-ccw.svg b/admin/collage-designer/assets/icons/rotate-ccw.svg new file mode 100644 index 000000000..ade5dc426 --- /dev/null +++ b/admin/collage-designer/assets/icons/rotate-ccw.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/collage-designer/assets/icons/trash.svg b/admin/collage-designer/assets/icons/trash.svg new file mode 100644 index 000000000..55650bd44 --- /dev/null +++ b/admin/collage-designer/assets/icons/trash.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/collage-designer/assets/js/collage-designer-elemntSetPnl.js b/admin/collage-designer/assets/js/collage-designer-elemntSetPnl.js new file mode 100644 index 000000000..10040b542 --- /dev/null +++ b/admin/collage-designer/assets/js/collage-designer-elemntSetPnl.js @@ -0,0 +1,269 @@ +// admin/collage-designer/assets/js/collage-designer-elemntSetPnl.js + +document.addEventListener('DOMContentLoaded', () => { + // Basic check to ensure main designer variables/functions are available + // These are expected to be globally exposed by collage-designer.js + if (typeof window.collageCanvas === 'undefined' || typeof window.drawCanvas === 'undefined' || + typeof window.collageElements === 'undefined' || + typeof window.saveState === 'undefined' || typeof window.deleteSelectedElements === 'undefined' + ) { + console.error('collage-designer-elemntSetPnl.js: Dependent main designer variables/functions not found. Ensure collage-designer.js is loaded first and exposes necessary variables globally.'); + return; + } + + + // --- DOM Elements (Defined once at load) --- + const elementSettingsPanel = document.getElementById('element_settings_panel'); + const noElementSelectedMessage = document.getElementById('no_element_selected_message'); + const imageSpecificSettingsPanel = document.getElementById('image_specific_settings_panel'); + const textSpecificSettingsPanel = document.getElementById('text_specific_settings_panel'); + + // All other panel input elements should be defined here as well, + // or accessed dynamically inside updateElementSettingsPanel to avoid many global const. + // For now, let's keep them here as they are accessed in setupPanelEventListeners + + const elementXPosition = document.getElementById('element_x_position'); + const elementXPositionSlider = document.getElementById('element_x_position_slider'); + const elementYPosition = document.getElementById('element_y_position'); + const elementYPositionSlider = document.getElementById('element_y_position_slider'); + const elementWidth = document.getElementById('element_width'); + const elementWidthSlider = document.getElementById('element_width_slider'); + const elementHeight = document.getElementById('element_height'); + const elementHeightSlider = document.getElementById('element_height_slider'); + const elementRotation = document.getElementById('element_rotation'); + const elementRotationSlider = document.getElementById('element_rotation_slider'); + + //================================================================================= + // --- Element Settings Panel Management --- + //================================================================================= + + /** + * Clamps a percentage value between 0 and 100. + * @param {number} value The value to clamp. + * @returns {number} The clamped value. + */ + function clampPercentage(value) { + return Math.max(0, Math.min(100, value)); + } + + /** + * Clamps a rotation value between -180 and 180 degrees. + * @param {number} value The value to clamp. + * @returns {number} The clamped value. + */ + function clampRotation(value) { + return Math.max(-180, Math.min(180, value)); + } + + /** + * Applies a new value to an element property, respecting aspect ratio lock if active. + * This function encapsulates the common logic for updating x, y, width, height, rotation. + * @param {string} prop The property name (e.g., 'width', 'height', 'x', 'y', 'rotation'). + * @param {number} rawValue The raw numerical value from the input/slider. + * @param {string} [dimension=''] The canvas dimension relevant for percentage conversion ('width' or 'height'). + */ + function applyPanelValueToElement(prop, rawValue, dimension = '') { + if (!window.activeElement) return; + + let value = parseFloat(rawValue); + if (isNaN(value)) value = 0; + + if (prop === 'rotation') { + value = clampRotation(value); + window.activeElement[prop] = value; + } else { + const canvasDimension = (dimension === 'width') ? window.collageCanvas.width : window.collageCanvas.height; + value = clampPercentage(value); // Clamp percentage value (0-100) + let newDimensionValuePx = (value / 100) * canvasDimension; // Convert to pixel value + + if ((prop === 'width' || prop === 'height') && window.globalLockAspectRatio) { + // Use the current aspect ratio of the element for locking + const originalAspectRatio = window.activeElement.width / window.activeElement.height; + + if (prop === 'width') { + window.activeElement.width = newDimensionValuePx; + window.activeElement.height = window.activeElement.width / originalAspectRatio; + } else { // prop === 'height' + window.activeElement.height = newDimensionValuePx; + window.activeElement.width = window.activeElement.height * originalAspectRatio; + } + + // Clamp both dimensions to canvas limits (pixel values) + window.activeElement.width = Math.max(0.1 * window.collageCanvas.width / 100, Math.min(window.collageCanvas.width, window.activeElement.width)); + window.activeElement.height = Math.max(0.1 * window.collageCanvas.height / 100, Math.min(window.collageCanvas.height, window.activeElement.height)); + + } else { + // Normal behavior without aspect ratio lock + // Ensure minimum size for width/height when not locked (value is already clamped percentage 0-100) + if ((prop === 'width' || prop === 'height') && value <= 0) { + newDimensionValuePx = (0.1 / 100) * canvasDimension; // Convert 0.1% to pixel + } + window.activeElement[prop] = newDimensionValuePx; + } + } + window.drawCanvas(); // Draw after applying value + } + + /** + * Updates the element settings panel based on the current selection. + * Deactivates the panel if no or multiple elements are selected. + * Activates and populates the panel if exactly one element is selected. + */ + window.updateElementSettingsPanel = function() { + const selectedElements = window.collageElements.filter(el => el.isSelected); + + // Find all interactive elements within the panel (inputs, sliders, buttons) + const interactiveElements = elementSettingsPanel.querySelectorAll( + 'input:not([type="checkbox"]), textarea, select, button' // Exclude checkboxes if they should always be active for "lock aspect ratio" etc. + ); + + if (selectedElements.length === 1 && window.activeElement) { + // Activate panel + elementSettingsPanel.classList.remove('opacity-50', 'pointer-events-none'); + interactiveElements.forEach(el => el.disabled = false); + if (noElementSelectedMessage) noElementSelectedMessage.classList.add('hidden'); // Check for existence + + const activeEl = window.activeElement; + + // Update basic info + document.getElementById('selected_element_type_display').textContent = photoboothTools.getTranslation(selectedElements[0].type); + document.getElementById('selected_element_id_display').textContent = `ID: ${activeEl.id}`; + + const canvasWidth = window.collageCanvas.width; + const canvasHeight = window.collageCanvas.height; + + // Convert pixel values to percentage for display + const xPercent = (activeEl.x / canvasWidth) * 100; + const yPercent = (activeEl.y / canvasHeight) * 100; + const widthPercent = (activeEl.width / canvasWidth) * 100; + const heightPercent = (activeEl.height / canvasHeight) * 100; + + // Update X position + if (elementXPosition) elementXPosition.value = xPercent.toFixed(1); + if (elementXPositionSlider) elementXPositionSlider.value = xPercent.toFixed(1); + + // Update Y position + if (elementYPosition) elementYPosition.value = yPercent.toFixed(1); + if (elementYPositionSlider) elementYPositionSlider.value = yPercent.toFixed(1); + + // Update Width + if (elementWidth) elementWidth.value = widthPercent.toFixed(1); + if (elementWidthSlider) elementWidthSlider.value = widthPercent.toFixed(1); + + // Update Height + if (elementHeight) elementHeight.value = heightPercent.toFixed(1); + if (elementHeightSlider) elementHeightSlider.value = heightPercent.toFixed(1); + + // Update Rotation + if (elementRotation) elementRotation.value = activeEl.rotation.toFixed(0); + if (elementRotationSlider) elementRotationSlider.value = activeEl.rotation.toFixed(0); + + // --- update specific panels and show / hide them --- + if (activeEl.type === 'image') { + if (window.updateImageSettingsPanel) window.updateImageSettingsPanel(); + if (imageSpecificSettingsPanel) imageSpecificSettingsPanel.classList.remove('hidden'); // Jetzt sollte es gehen! + if (textSpecificSettingsPanel) textSpecificSettingsPanel.classList.add('hidden'); + } else if (activeEl.type === 'text') { + if (window.updateTextSettingsPanel) window.updateTextSettingsPanel(); + if (textSpecificSettingsPanel) textSpecificSettingsPanel.classList.remove('hidden'); + if (imageSpecificSettingsPanel) imageSpecificSettingsPanel.classList.add('hidden'); + } else { + // Typ unbekannt oder kein spezifisches Panel + if (imageSpecificSettingsPanel) imageSpecificSettingsPanel.classList.add('hidden'); + if (textSpecificSettingsPanel) textSpecificSettingsPanel.classList.add('hidden'); + } + + } else { + // none or multiple elements selected + elementSettingsPanel.classList.add('opacity-50', 'pointer-events-none'); + interactiveElements.forEach(el => el.disabled = true); + + // Clear basic info + document.getElementById('selected_element_type_display').textContent = ''; + document.getElementById('selected_element_id_display').textContent = 'ID: '; + + // hide all specific panels + if (noElementSelectedMessage) noElementSelectedMessage.classList.remove('hidden'); // Check for existence + if (imageSpecificSettingsPanel) imageSpecificSettingsPanel.classList.add('hidden'); + if (textSpecificSettingsPanel) textSpecificSettingsPanel.classList.add('hidden'); + } + }; + + /** + * Sets up event listeners for the element settings panel inputs. + */ + function setupPanelEventListeners() { + const inputs = [ + { id: 'element_x_position', sliderId: 'element_x_position_slider', prop: 'x', dimension: 'width' }, + { id: 'element_y_position', sliderId: 'element_y_position_slider', prop: 'y', dimension: 'height' }, + { id: 'element_width', sliderId: 'element_width_slider', prop: 'width', dimension: 'width' }, + { id: 'element_height', sliderId: 'element_height_slider', prop: 'height', dimension: 'height' }, + { id: 'element_rotation', sliderId: 'element_rotation_slider', prop: 'rotation' } + ]; + + inputs.forEach(({ id, sliderId, prop, dimension }) => { + const numberInput = document.getElementById(id); + const sliderInput = document.getElementById(sliderId); + + let isChanging = false; // Flag to prevent redundant saveState calls during continuous slider drag + + if (numberInput) { + numberInput.addEventListener('change', (event) => { + if (!window.activeElement) return; + window.saveState(); // Save state on change for number input + + applyPanelValueToElement(prop, event.target.value, dimension); + + /// Update corresponding slider + if (sliderInput) { + sliderInput.value = (prop === 'rotation') ? window.activeElement[prop].toFixed(0) : + ((window.activeElement[prop] / ((dimension === 'width') ? window.collageCanvas.width : window.collageCanvas.height)) * 100).toFixed(1); + } + + window.updateElementSettingsPanel(); // Re-populate to ensure consistency and handle potential rounding + }); + } + + if (sliderInput) { + sliderInput.addEventListener('mousedown', () => { + if (!window.activeElement) return; + window.saveState(); // Save state at the start of slider drag + isChanging = true; + }); + + sliderInput.addEventListener('input', (event) => { + if (!window.activeElement || !isChanging) return; // Only update if actively dragging + + applyPanelValueToElement(prop, event.target.value, dimension); + + // Update corresponding number input + if (numberInput) { + numberInput.value = (prop === 'rotation') ? window.activeElement[prop].toFixed(0) : + ((window.activeElement[prop] / ((dimension === 'width') ? window.collageCanvas.width : window.collageCanvas.height)) * 100).toFixed(1); + } + }); + + sliderInput.addEventListener('mouseup', () => { + if (!window.activeElement) return; + isChanging = false; + // A final draw and panel update ensures consistency after drag ends + window.drawCanvas(); + window.updateElementSettingsPanel(); // Re-populate to fix minor floating point inaccuracies + }); + } + }); + + // Event listener for the delete button inside the panel + const deleteElementBtn = document.getElementById('panelDeleteElementBtn'); + if (deleteElementBtn) { + deleteElementBtn.addEventListener('click', () => { + if (window.activeElement) { + window.deleteSelectedElements(); + } + }); + } + } + + // Initialize panel event listeners once the DOM is ready + setupPanelEventListeners(); +}); diff --git a/admin/collage-designer/assets/js/collage-designer-generalSet.js b/admin/collage-designer/assets/js/collage-designer-generalSet.js new file mode 100644 index 000000000..162349595 --- /dev/null +++ b/admin/collage-designer/assets/js/collage-designer-generalSet.js @@ -0,0 +1,168 @@ +// admin/collage-designer/assets/js/collage-designer-generalSet.js + +document.addEventListener('DOMContentLoaded', () => { + // Basic check to ensure main designer variables/functions are available + if (typeof window.collageCanvas === 'undefined' || typeof window.drawCanvas === 'undefined' || + typeof window.collageElements === 'undefined' || + typeof window.saveState === 'undefined' || typeof window.globalLockAspectRatio === 'undefined' || + typeof window.collageCanvasWrapper === 'undefined' + ) { + console.error('collage-designer-generalSet.js: Dependent main designer variables/functions not found. Ensure collage-designer.js is loaded first and exposes necessary variables globally.'); + return; + } + + const canvasWidthInput = document.querySelector('input[name="final_width"]'); + const canvasHeightInput = document.querySelector('input[name="final_height"]'); + const backgroundColorInput = document.querySelector('input[name="background_color"]'); // the hidden Input, which saves the background-color + const showframeCheckbox = document.getElementById('show_frame'); + const backgroundImage = document.getElementById('background_image'); + const backgroundImageSelectorParent = backgroundImage.closest('.adminImageSelection'); + const backgroundImagePreviewElement = backgroundImageSelectorParent.querySelector('.adminImageSelection-preview'); + const backgroundImageTextElement = backgroundImageSelectorParent.querySelector('.adminImageSelection-text'); + const frameImage = document.getElementById('frame_image'); + const frameImageSelectorParent = frameImage.closest('.adminImageSelection'); + const frameImagePreviewElement = frameImageSelectorParent.querySelector('.adminImageSelection-preview'); + const frameImageTextElement = frameImageSelectorParent.querySelector('.adminImageSelection-text'); + + // Define minimum and maximum canvas dimensions + const MIN_CANVAS_DIMENSION = 100; // e.g., 100px minimum for width and height + const MAX_CANVAS_DIMENSION = 4000; // e.g., 4000px maximum for width and height + + + // Debounced version of saveState to prevent excessive calls during rapid input changes + const debouncedSaveState = window.debounce(window.saveState, 500); + + /** + * Updates the settings panel input values to reflect the current state of the collage canvas. + */ + window.updateGeneralSettingsPanel = function() { + // Background Image + if(window.backgroundImage){ + backgroundImage.value = window.backgroundImage; + backgroundImageTextElement.textContent = window.backgroundImage; + backgroundImagePreviewElement.src = '../../' + window.backgroundImage; + backgroundImagePreviewElement.parentElement.classList.remove('hidden'); + } else { + backgroundImage.value = ''; + backgroundImageTextElement.textContent = ''; + backgroundImagePreviewElement.parentElement.classList.add('hidden'); + } + + // global Frame Image + if(window.globalFrameImage){ + frameImage.value = window.globalFrameImage; + frameImageTextElement.textContent = window.globalFrameImage; + //frameImagePreviewElement.src = '../../' + window.globalFrameImage; BUG: preview not working yet + frameImagePreviewElement.parentElement.classList.remove('hidden'); + } else { + frameImage.value = ''; + frameImageTextElement.textContent = ''; + frameImagePreviewElement.parentElement.classList.add('hidden'); + } + + // show frame checkbox + showframeCheckbox.checked = window.showGlobalFrameImage; + + // Background color + backgroundColorInput.value = window.backgroundColor; + + // Canvas dimensions + canvasWidthInput.value = window.collageCanvas.width; + canvasHeightInput.value = window.collageCanvas.height; + + // Update wrapper aspect ratio + if (window.collageCanvas.width > 0 && window.collageCanvas.height > 0) { + window.collageCanvasWrapper.style.aspectRatio = `${window.collageCanvas.width} / ${window.collageCanvas.height}`; + } + } + + // Event listener for width input + canvasWidthInput.addEventListener('input', (e) => { + let newWidth = parseInt(e.target.value, 10); + if (isNaN(newWidth) || newWidth <= 0) { + return; // Ignore invalid input + } + // Apply min/max constraints + newWidth = Math.max(MIN_CANVAS_DIMENSION, Math.min(MAX_CANVAS_DIMENSION, newWidth)); + + if (window.globalLockAspectRatio) { + const currentAspectRatio = window.collageCanvas.width / window.collageCanvas.height; + const newHeight = Math.round(newWidth / currentAspectRatio); // Calculate new height based on new width and current aspect ratio + window.collageCanvas.width = newWidth; + window.collageCanvas.height = newHeight; + canvasHeightInput.value = newHeight; // Update companion input field + } else { + window.collageCanvas.width = newWidth; + } + + // Update wrapper aspect ratio after dimension changes + if (window.collageCanvas.width > 0 && window.collageCanvas.height > 0) { + window.collageCanvasWrapper.style.aspectRatio = `${window.collageCanvas.width} / ${window.collageCanvas.height}`; + } + + debouncedSaveState(); // Use debouncedSaveState for numeric inputs + window.drawCanvas(); // Re-draw canvas with new dimensions + }); + + // Event listener for height input + canvasHeightInput.addEventListener('input', (e) => { + let newHeight = parseInt(e.target.value, 10); + if (isNaN(newHeight) || newHeight <= 0) { + return; // Ignore invalid input + } + // Apply min/max constraints + newHeight = Math.max(MIN_CANVAS_DIMENSION, Math.min(MAX_CANVAS_DIMENSION, newHeight)); + + if (window.globalLockAspectRatio) { + const currentAspectRatio = window.collageCanvas.width / window.collageCanvas.height; + const newWidth = Math.round(newHeight * currentAspectRatio); // Calculate new width based on new height and current aspect ratio + window.collageCanvas.width = newWidth; + window.collageCanvas.height = newHeight; + canvasWidthInput.value = newWidth; // Update companion input field + } else { + window.collageCanvas.height = newHeight; + } + + // Update wrapper aspect ratio after dimension changes + if (window.collageCanvas.width > 0 && window.collageCanvas.height > 0) { + window.collageCanvasWrapper.style.aspectRatio = `${window.collageCanvas.width} / ${window.collageCanvas.height}`; + } + + debouncedSaveState(); // Use debouncedSaveState for numeric inputs + window.drawCanvas(); // Re-draw canvas with new dimensions + }); + + // Event listener for background color input + backgroundColorInput.addEventListener('input', (e) => { + const newColor = e.target.value; + window.backgroundColor = newColor; + debouncedSaveState(); + window.drawCanvas(); + }); + + // Event listener for show frame checkbox + showframeCheckbox.addEventListener('change', (e) => { + const isChecked = e.target.checked; + window.showGlobalFrameImage = isChecked; + window.saveState(); + window.drawCanvas(); + }); + + backgroundImage.addEventListener('change', (e) => { + const filePath = e.target.value; + window.backgroundImage = filePath; + window.saveState(); + window.drawCanvas(); + updateGeneralSettingsPanel(); + }); + + frameImage.addEventListener('change', (e) => { + const filePath = e.target.value; + window.globalFrameImage = filePath; + window.saveState(); + window.drawCanvas(); + updateGeneralSettingsPanel(); + }); + + updateGeneralSettingsPanel(); +}); \ No newline at end of file diff --git a/admin/collage-designer/assets/js/collage-designer-history.js b/admin/collage-designer/assets/js/collage-designer-history.js new file mode 100644 index 000000000..8c43bc592 --- /dev/null +++ b/admin/collage-designer/assets/js/collage-designer-history.js @@ -0,0 +1,298 @@ +// admin/collage-designer/assets/js/collage-designer-history.js + +document.addEventListener('DOMContentLoaded', () => { + //================================================================================= + // --- History Functions --- + //================================================================================= + + // --- Undo/Redo History --- + let undoStack = []; + let redoStack = []; + const MAX_HISTORY_SIZE = 50; // Limit the history to prevent excessive memory usage + + //================================================================================= + // --- update Buttons --- + //================================================================================= + + /** + * Updates the enabled/disabled state of the Undo/Redo buttons. + */ + window.updateUndoRedoButtonStates = function() { + const undoBtn = document.getElementById('undoBtn'); + const redoBtn = document.getElementById('redoBtn'); + + if (undoBtn) undoBtn.disabled = undoStack.length <= 1; // Always need at least 1 state to undo from + if (redoBtn) redoBtn.disabled = redoStack.length === 0; + } + + /** + * updates the enabled/disabled state of the Remove Button. + */ + window.updateRemoveButtonState = function() { + const removeBtn = document.getElementById('removeBtn'); + if (removeBtn) { + const selectedElementsCount = window.collageElements.filter(el => el.isSelected).length; + removeBtn.disabled = selectedElementsCount === 0; // Deaktiviert, wenn nichts ausgewählt ist + } + }; + + //================================================================================= + // --- Undo/Redo Functionality --- + //================================================================================= + + /** + * Creates a snapshot of the current state of all collage elements. + * Only stores properties that can change (x, y, width, height, rotation, isSelected). + * @returns {Array} A deep copy of the relevant element states. + */ + window.createSnapshot = function() { + const elementSnapshots = window.collageElements.map(el => { + const snapshotEl = { + id: el.id, + x: el.x, + y: el.y, + width: el.width, + height: el.height, + rotation: el.rotation, + isSelected: el.isSelected, + type: el.type // Crucial: save element type + }; + + switch (el.type) { + case 'image': + snapshotEl.imageSrc = el.image ? el.image.src : null; + snapshotEl.src = el.src || null; + snapshotEl.originalLayoutDataIndex = el.originalLayoutDataIndex; + snapshotEl.show_frame = el.show_frame; + break; + case 'text': + snapshotEl.content = el.content; + snapshotEl.font_family = el.font_family; + snapshotEl.font_color = el.font_color; + snapshotEl.font_size = el.font_size; + snapshotEl.text_horizontal_align = el.text_horizontal_align; + snapshotEl.text_vertical_align = el.text_vertical_align; + snapshotEl.font_bold = el.font_bold; + snapshotEl.font_italic = el.font_italic; + snapshotEl.font_underline = el.font_underline; + break; + } + return snapshotEl; + }); + + // Also snapshot global settings + const globalSettings = { + canvasHeight: window.collageCanvas.height, // number + canvasWidth: window.collageCanvas.width, // number + backgroundImage: window.backgroundImage, // path + backgroundColor: window.backgroundColor, // solor as string + showGlobalFrameImage: window.showGlobalFrameImage, // boolean + globalFrameImage: window.globalFrameImage // path + }; + + // Der gesamte Snapshot enthält nun Elemente und globale Einstellungen + return { + elements: elementSnapshots, + globalSettings: globalSettings + }; + } + + /** + * Restores the state of collage elements and global settings from a given snapshot. + * @param {Array} snapshot The snapshot to restore. + */ + window.restoreSnapshot = function(snapshot) { + // Clear current selection + window.collageElements.forEach(el => el.isSelected = false); + window.activeElement = null; + + // 1. Restore global settings first + const restoredGlobalSettings = snapshot.globalSettings; + if (restoredGlobalSettings) { + window.collageCanvas.height = restoredGlobalSettings.canvasHeight; + window.collageCanvas.width = restoredGlobalSettings.canvasWidth; + window.backgroundImage = restoredGlobalSettings.backgroundImage; + window.backgroundColor = restoredGlobalSettings.backgroundColor; + window.showGlobalFrameImage = restoredGlobalSettings.showGlobalFrameImage; + window.globalFrameImage = restoredGlobalSettings.globalFrameImage; + } + + // Create a new array for the elements, incorporating changes + const newCollageElements = []; + + // 2. Update existing elements and add elements from snapshot that are new to current state + snapshot.elements.forEach(snapEl => { + const currentEl = window.collageElements.find(el => el.id === snapEl.id); + if (currentEl) { + // Element exists, update its properties + currentEl.x = snapEl.x; + currentEl.y = snapEl.y; + currentEl.width = snapEl.width; + currentEl.height = snapEl.height; + currentEl.rotation = snapEl.rotation; + currentEl.isSelected = snapEl.isSelected; + currentEl.type = snapEl.type; // Ensure type is restored + + switch (snapEl.type) { + case 'image': + if (snapEl.imageSrc !== (currentEl.image ? currentEl.image.src : null)) { + const newImage = new Image(); + newImage.crossOrigin = "anonymous"; + newImage.src = snapEl.imageSrc; + newImage.onload = window.drawCanvas; + newImage.onerror = () => { console.error(`Failed to load restored image: ${newImage.src}`); window.drawCanvas(); }; + currentEl.image = newImage; + } + currentEl.src = snapEl.src; + currentEl.originalLayoutDataIndex = snapEl.originalLayoutDataIndex; + currentEl.show_frame = snapEl.show_frame; + break; + case 'text': + currentEl.content = snapEl.content; + currentEl.font_family = snapEl.font_family; + currentEl.font_color = snapEl.font_color; + currentEl.font_size = snapEl.font_size; + currentEl.text_horizontal_align = snapEl.text_horizontal_align; + currentEl.text_vertical_align = snapEl.text_vertical_align; + currentEl.font_bold = snapEl.font_bold; + currentEl.font_italic = snapEl.font_italic; + currentEl.font_underline = snapEl.font_underline; + break; + } + newCollageElements.push(currentEl); + } else { + // Element exists in snapshot but not in current window.collageElements, so it was "added" + let recreatedData = {}; + let recreatedImage = null; + + switch (snapEl.type) { + case 'image': + recreatedImage = new Image(); + recreatedImage.crossOrigin = "anonymous"; + recreatedImage.src = snapEl.imageSrc || window.phpFallbackImageUrl; + recreatedImage.onload = window.drawCanvas; + recreatedImage.onerror = () => { console.error(`Failed to load recreated image: ${recreatedImage.src}`); window.drawCanvas(); }; + recreatedData = { + image: recreatedImage, + src: snapEl.src, + originalLayoutDataIndex: snapEl.originalLayoutDataIndex !== undefined ? snapEl.originalLayoutDataIndex : -1, + show_frame: snapEl.show_frame + }; + break; + case 'text': + recreatedData = { + content: snapEl.content, + font_family: snapEl.font_family, + font_color: snapEl.font_color, + font_size: snapEl.font_size, + text_horizontal_align: snapEl.text_horizontal_align, + text_vertical_align: snapEl.text_vertical_align, + font_bold: snapEl.font_bold, + font_italic: snapEl.font_italic, + font_underline: snapEl.font_underline + }; + break; + } + + const recreatedElement = new window.CollageElement( + snapEl.id, + snapEl.x, + snapEl.y, + snapEl.width, + snapEl.height, + snapEl.rotation, + snapEl.type, // Pass the type + recreatedData // Pass the type-specific data + ); + recreatedElement.isSelected = snapEl.isSelected; + newCollageElements.push(recreatedElement); + } + }); + + // 3. Elements that are in window.collageElements but NOT in the snapshot should be removed. + // By creating newCollageElements based only on the snapshot, this is implicitly handled. + // We just need to replace the global array. + window.collageElements = newCollageElements; // Replace the old array with the new one + + // 4. Update activeElement based on the restored selection + const selected = window.collageElements.filter(el => el.isSelected); + if (selected.length === 1) { + window.activeElement = selected[0]; + } else if (selected.length > 1) { + // If multiple elements were selected, the activeElement should be one of them. + // We might try to restore the original activeElement if its ID is among the selected ones. + if (window.activeElement && selected.some(el => el.id === window.activeElement.id)) { + // Keep activeElement if it's still selected + } else { + window.activeElement = selected[0]; // Otherwise, pick the first selected + } + } else { + window.activeElement = null; // No selection + } + window.drawCanvas(); // Initial draw of the restored state + window.updateUndoRedoButtonStates(); // Update button states after restore + window.updateGeneralSettingsPanel(); // Update settings panel to reflect restored state + }; + + /** + * Saves the current state to the undoStack and clears the redoStack. + */ + window.saveState = function() { + const currentState = window.createSnapshot(); + // Only save if the current state is different from the last state + // This prevents saving redundant states from continuous actions like dragging. + // For continuous actions, the state is saved ONCE at mousedown, + // and then the final state is saved on mouseup. + if (undoStack.length > 0) { + const lastState = undoStack[undoStack.length - 1]; + // Simple comparison: check if stringified versions are different + // For complex objects, a deep comparison function would be better. + if (JSON.stringify(currentState) === JSON.stringify(lastState)) { + return; // State hasn't changed meaningfully + } + } + + undoStack.push(currentState); + if (undoStack.length > MAX_HISTORY_SIZE) { + undoStack.shift(); // Remove the oldest state + } + redoStack = []; // Any new action clears the redo stack + window.updateUndoRedoButtonStates(); + } + + //================================================================================= + // --- Event Listeners --- + //================================================================================= + // Undo/Redo Buttons + document.getElementById('undoBtn').addEventListener('click', () => { + if (undoStack.length > 1) { // Need at least the initial state and one action to undo + const currentState = undoStack.pop(); // Remove current state from undo stack + redoStack.push(currentState); // Push it to redo stack + window.restoreSnapshot(undoStack[undoStack.length - 1]); // Load the previous state + } + }); + + document.getElementById('redoBtn').addEventListener('click', () => { + if (redoStack.length > 0) { + const nextState = redoStack.pop(); // Get next state from redo stack + undoStack.push(nextState); // Push it back to undo stack + window.restoreSnapshot(nextState); // Load this state + } + }); + + // --- Keyboard Shortcuts for Undo/Redo --- + document.addEventListener('keydown', (event) => { + // Check for Ctrl (Windows/Linux) or Cmd (macOS) key + const isCtrlCmd = event.ctrlKey || event.metaKey; + + if (isCtrlCmd) { + if (event.key === 'z' || event.key === 'Z') { + event.preventDefault(); // Prevent default browser undo (e.g., in text fields) + document.getElementById('undoBtn').click(); // Simulate click on undo button + } else if (event.key === 'y' || event.key === 'Y') { + event.preventDefault(); // Prevent default browser redo + document.getElementById('redoBtn').click(); // Simulate click on redo button + } + } + }); +}); \ No newline at end of file diff --git a/admin/collage-designer/assets/js/collage-designer-imgSetPnl.js b/admin/collage-designer/assets/js/collage-designer-imgSetPnl.js new file mode 100644 index 000000000..fed947b41 --- /dev/null +++ b/admin/collage-designer/assets/js/collage-designer-imgSetPnl.js @@ -0,0 +1,288 @@ +// admin/collage-designer/assets/js/collage-designer-imgSetPnl.js + +document.addEventListener('DOMContentLoaded', () => { + // Basic check to ensure main designer variables/functions are available + if (typeof window.collageCanvas === 'undefined' || typeof window.drawCanvas === 'undefined' || + typeof window.collageElements === 'undefined' || + typeof window.saveState === 'undefined' || typeof window.globalLockAspectRatio === 'undefined' + ) { + console.error('collage-designer-imgSetPnl.js: Dependent main designer variables/functions not found. Ensure collage-designer.js is loaded first and exposes necessary variables globally.'); + return; + } + + const imageSettingsPanel = document.getElementById('image_specific_settings_panel'); + const aspectRatioPresetSelect = document.getElementById('image_aspect_ratio_preset'); + const customAspectRatioInputsDiv = document.getElementById('custom_aspect_ratio_inputs'); + const customRatioXSlider = document.getElementById('custom_ratio_x_slider'); + const customRatioXInput = document.getElementById('custom_ratio_x'); + const customRatioYSlider = document.getElementById('custom_ratio_y_slider'); + const customRatioYInput = document.getElementById('custom_ratio_y'); + const applyAspectRatioBtn = document.getElementById('apply_aspect_ratio_btn'); + const showFrameCheckbox = document.getElementById('picture_show_frame_current'); + const removePlaceholderImageBtn = document.getElementById('remove_placeholder_image_btn'); + const placeholderPath = document.getElementById('placeholder_path'); + const placeholderSelectorParent = placeholderPath.closest('.adminImageSelection'); + const placeholderPreviewElement = placeholderSelectorParent.querySelector('.adminImageSelection-preview'); + const placeholderTextElement = placeholderSelectorParent.querySelector('.adminImageSelection-text'); + + // Store the last custom ratio values locally. Initialized to a common aspect ratio. + let lastCustomRatioX = 16; + let lastCustomRatioY = 9; + + // A flag to indicate if 'custom' was explicitly selected by the user. + // This prevents updateImageSettingsPanel from overriding user's 'custom' choice. + let userSelectedCustom = false; + + // Store the ID of the last active image element + let lastActiveImageElementId = null; + + /** + * Updates the visibility of the "Apply Aspect Ratio" button. + */ + function updateApplyButtonVisibility() { + if (applyAspectRatioBtn) { + applyAspectRatioBtn.classList.toggle('hidden', aspectRatioPresetSelect.value !== 'custom'); + } + } + + /** + * Updates the image-specific settings panel based on the currently active element. + * This function is expected to be called by the main updateElementSettingsPanel. + * It only updates the UI elements within this panel. + */ + window.updateImageSettingsPanel = function() { + if (!window.activeElement || window.activeElement.type !== 'image') { + imageSettingsPanel.classList.add('hidden'); + return; + } + + imageSettingsPanel.classList.remove('hidden'); + document.getElementById('selected_image_element_id_display').textContent = `ID: ${window.activeElement.id}`; + + // --- IMPORTANT: Reset userSelectedCustom flag if a new image element is selected --- + if (window.activeElement.id !== lastActiveImageElementId) { + userSelectedCustom = false; // Reset if a different image is now active + lastActiveImageElementId = window.activeElement.id; + } + + const currentWidth = window.activeElement.width; + const currentHeight = window.activeElement.height; + const currentAspectRatio = currentWidth / currentHeight; + + let presetMatchesCurrentElement = false; + let matchedPresetValue = ''; + + // Check if current AR matches any preset + for (const option of aspectRatioPresetSelect.options) { + if (option.value !== 'original' && option.value !== 'custom') { + const [ratioW, ratioH] = option.value.split(':').map(Number); + if (ratioH !== 0 && Math.abs((ratioW / ratioH) - currentAspectRatio) < 0.001) { + matchedPresetValue = option.value; + presetMatchesCurrentElement = true; + break; + } + } + } + + // --- Logic for setting the dropdown value --- + if (userSelectedCustom) { + // If user explicitly chose 'custom', respect that choice. + aspectRatioPresetSelect.value = 'custom'; + } else if (presetMatchesCurrentElement) { + // If no explicit 'custom' choice, but element matches a preset, select that preset. + aspectRatioPresetSelect.value = matchedPresetValue; + } else { + // If no explicit 'custom' choice and no preset matches, default to 'custom'. + aspectRatioPresetSelect.value = 'custom'; + } + + // --- Update Custom Aspect Ratio Inputs (based on dropdown value) --- + if (aspectRatioPresetSelect.value === 'custom') { + customAspectRatioInputsDiv.classList.remove('hidden'); + + // Load last user-entered custom values. If custom was never used for this session, + // lastCustomRatioX/Y will be their default (16:9). + customRatioXInput.value = lastCustomRatioX; + customRatioYInput.value = lastCustomRatioY; + customRatioXSlider.value = lastCustomRatioX; + customRatioYSlider.value = lastCustomRatioY; + } else { + customAspectRatioInputsDiv.classList.add('hidden'); + } + + updateApplyButtonVisibility(); // Update button visibility based on selection + + // --- Update Frame checkbox --- + if (showFrameCheckbox) { + showFrameCheckbox.checked = window.activeElement.show_frame === true; + } + + if(window.activeElement.src){ + if(removePlaceholderImageBtn){ + removePlaceholderImageBtn.parentElement.classList.remove('hidden'); + } + placeholderPath.value = window.activeElement.src; + placeholderTextElement.textContent = window.activeElement.src; + placeholderPreviewElement.src = '../../' + window.activeElement.src; + placeholderPreviewElement.parentElement.classList.remove('hidden'); + } else { + if(removePlaceholderImageBtn){ + removePlaceholderImageBtn.parentElement.classList.add('hidden'); + } + placeholderPath.value = ''; + placeholderTextElement.textContent = ''; + placeholderPreviewElement.parentElement.classList.add('hidden'); + } + }; + + /** + * Applies a chosen aspect ratio to the active element. + * This function centralizes the logic for aspect ratio changes. + * @param {string} type 'preset', 'original', or 'custom' + * @param {number} [ratioW] Custom ratio width component (optional, for preset/custom) + * @param {number} [ratioH] Custom ratio height component (optional, for preset/custom) + */ + function applyAspectRatio(type, ratioW, ratioH) { + if (!window.activeElement || window.activeElement.type !== 'image') return; + + window.saveState(); // Save state BEFORE applying changes + + let newAspectRatio; + + if (type === 'original') { + const imgElement = window.activeElement.image; + if (imgElement && imgElement.naturalWidth && imgElement.naturalHeight) { + newAspectRatio = imgElement.naturalWidth / imgElement.naturalHeight; + } else { + console.warn('Original image dimensions not available for aspect ratio reset. Using current ratio as fallback.'); + newAspectRatio = window.activeElement.width / window.activeElement.height; // Fallback to current ratio + } + } else if ((type === 'preset' || type === 'custom') && ratioH !== 0 && ratioW > 0 && ratioH > 0) { + newAspectRatio = ratioW / ratioH; + } else { + console.warn('Invalid aspect ratio parameters for application.'); + return; + } + + // Apply new aspect ratio while maintaining current width + const newHeight = window.activeElement.width / newAspectRatio; + window.activeElement.height = newHeight; + window.globalLockAspectRatio = true; // Lock aspect ratio after applying any ratio + + window.drawCanvas(); // Draw after applying value + window.updateElementSettingsPanel(); // Re-populate all panels to reflect height/width changes + } + + /** + * Event listeners for the Aspect Ratio section. + */ + function setupAspectRatioEventListeners() { + // Preset select changed + aspectRatioPresetSelect.addEventListener('change', () => { + if (!window.activeElement || window.activeElement.type !== 'image') return; + + const selectedValue = aspectRatioPresetSelect.value; + userSelectedCustom = (selectedValue === 'custom'); // Set flag based on user's choice + + customAspectRatioInputsDiv.classList.toggle('hidden', !userSelectedCustom); + updateApplyButtonVisibility(); // Update button visibility immediately + + if (userSelectedCustom) { + // When switching TO custom, populate with last *user-entered* custom values. + // updateImageSettingsPanel will handle loading lastCustomRatioX/Y into inputs. + window.updateImageSettingsPanel(); + } else { + // For 'original' or presets, immediately apply the ratio. + if (selectedValue === 'original') { + applyAspectRatio('original'); + } else { // Preset values like '1:1', '4:3' + const [ratioW, ratioH] = selectedValue.split(':').map(Number); + if (!isNaN(ratioW) && !isNaN(ratioH) && ratioH !== 0) { + applyAspectRatio('preset', ratioW, ratioH); + } + } + } + }); + + // Custom ratio sliders and inputs synchronization + [ + { slider: customRatioXSlider, input: customRatioXInput, prop: 'x' }, + { slider: customRatioYSlider, input: customRatioYInput, prop: 'y' } + ].forEach(({ slider, input, prop }) => { + slider.addEventListener('input', () => { + input.value = slider.value; + // Store last custom value when user interacts + if (prop === 'x') lastCustomRatioX = parseInt(slider.value); + if (prop === 'y') lastCustomRatioY = parseInt(slider.value); + }); + input.addEventListener('input', () => { + let val = parseInt(input.value); + if (isNaN(val) || val < parseInt(slider.min)) val = parseInt(slider.min); + if (val > parseInt(slider.max)) val = parseInt(slider.max); + input.value = val; + slider.value = val; + // Store last custom value when user interacts + if (prop === 'x') lastCustomRatioX = val; + if (prop === 'y') lastCustomRatioY = val; + }); + }); + + // Apply Custom Aspect Ratio Button + applyAspectRatioBtn.addEventListener('click', () => { + if (!window.activeElement || window.activeElement.type !== 'image' || aspectRatioPresetSelect.value !== 'custom') return; + + const ratioW = parseInt(customRatioXInput.value); + const ratioH = parseInt(customRatioYInput.value); + + if (isNaN(ratioW) || isNaN(ratioH) || ratioH === 0 || ratioW <= 0 || ratioH <= 0) { + console.warn('Invalid custom aspect ratio values.'); + return; + } + applyAspectRatio('custom', ratioW, ratioH); + }); + } + + /** + * Event listener for the Frame checkbox. + */ + if (showFrameCheckbox) { + showFrameCheckbox.addEventListener('change', () => { + if (!window.activeElement || window.activeElement.type !== 'image') return; + + window.saveState(); // Save state BEFORE applying changes + window.activeElement.show_frame = showFrameCheckbox.checked; + window.drawCanvas(); // Draw after applying value + window.updateElementSettingsPanel(); // Update general panel to reflect UI changes (if any) + }); + } + + /** + * Sets up event listeners for the placeholder selector. + */ + if (placeholderPath) { + placeholderPath.addEventListener('change', (e) => { + const filePath = e.target.value; // The new file path is in the input's value + window.saveState(); // Save state BEFORE applying changes + window.activeElement.src = filePath; // Update the active element's SRC if needed + window.updateImageSettingsPanel(); // Update the panel to reflect removal + window.drawCanvas(); // Redraw canvas to reflect any changes + }); + } + + + /** + * Sets up event listeners for the remove placeholder button. + */ + if (removePlaceholderImageBtn) { + removePlaceholderImageBtn.addEventListener('click', () => { + window.saveState(); // Save state BEFORE applying changes + window.activeElement.src = ''; // Clear the active element's source + window.updateImageSettingsPanel(); // Update the panel to reflect removal + window.drawCanvas(); // Redraw canvas to reflect any changes + }); + } + + + // Initialize event listeners + setupAspectRatioEventListeners(); +}); \ No newline at end of file diff --git a/admin/collage-designer/assets/js/collage-designer-mouseEvents.js b/admin/collage-designer/assets/js/collage-designer-mouseEvents.js new file mode 100644 index 000000000..7a6209204 --- /dev/null +++ b/admin/collage-designer/assets/js/collage-designer-mouseEvents.js @@ -0,0 +1,555 @@ +// admin/collage-designer/assets/js/collage-designer-mouseEvents.js + +document.addEventListener('DOMContentLoaded', () => { + //================================================================================= + // --- Local State Variables for Mouse Events --- + //================================================================================= + let isDragging = false; + let dragStartX, dragStartY; + let elementStartX, elementStartY; + + let isResizing = false; + let resizeHandle = null; + let initialElementWidth, initialElementHeight; + let initialElementX, initialElementY; + + let isRotating = false; + let rotationStartAngle = 0; + let initialElementRotation = 0; + + + //================================================================================= + // --- Mouse Event Handlers --- + //================================================================================= + + // Helper function to get mouse position relative to canvas + function getMousePos(event) { + const rect = window.collageCanvas.getBoundingClientRect(); + const scaleX = window.collageCanvas.width / rect.width; + const scaleY = window.collageCanvas.height / rect.height; + return { + x: (event.clientX - rect.left) * scaleX, + y: (event.clientY - rect.top) * scaleY + }; + } + + // --- Helper function for transforming mouse coordinates to element's local space --- + window.getLocalMouseCoordinates = function(globalX, globalY, element) { + let effectiveRotation = element.rotation; + if (element.type === 'image') {effectiveRotation = 0;} // Images are treated as unrotated for handle hit detection + + if (effectiveRotation === 0) { + return { x: globalX - element.x, y: globalY - element.y }; + } + + // Calculate element's center (pivot for rotation) + const centerX = element.x + element.width / 2; + const centerY = element.y + element.height / 2; + + // 1. Translate global mouse point so element's center becomes (0,0) + const translatedX = globalX - centerX; + const translatedY = globalY - centerY; + + // 2. Inverse rotate the translated point + const angleRad = element.rotation * Math.PI / 180; // Negative angle for inverse rotation + const inverselyRotatedX = translatedX * Math.cos(angleRad) - translatedY * Math.sin(angleRad); + const inverselyRotatedY = translatedX * Math.sin(angleRad) + translatedY * Math.cos(angleRad); + + // 3. Translate the point back so element's top-left becomes (0,0) for local checks + // This makes the transformed mouse coordinates relative to the element's top-left corner + const localX = inverselyRotatedX + element.width / 2; + const localY = inverselyRotatedY + element.height / 2; + + return { x: localX, y: localY }; + } + + // --- Helper function for point-in-rotated-rectangle test --- + window.isPointInRect = function(pointX, pointY, rectX, rectY, rectWidth, rectHeight) { + return pointX >= rectX && pointX <= rectX + rectWidth && + pointY >= rectY && pointY <= rectY + rectHeight; + } + + // --- Helper function for point-in-circle test --- + function isPointInCircle(pointX, pointY, centerX, centerY, radius) { + const dist = Math.sqrt(Math.pow(pointX - centerX, 2) + Math.pow(pointY - centerY, 2)); + return dist <= radius; + } + + + //--------------------------------------------------------------------------------- + function handleMouseDown(event) { + const globalMouse = getMousePos(event); + + + // Reset interaction flags + isResizing = false; + isRotating = false; + isDragging = false; + + const selectedElementsCount = window.collageElements.filter(el => el.isSelected).length; + + // Check if there's an active element to interact with its handles + if (window.activeElement) { + // Transform global mouse coordinates into the local (unrotated) space of the active element + const transformedMouse = getLocalMouseCoordinates(globalMouse.x, globalMouse.y, window.activeElement); + + // 1. Check for hits on handles first (they are always on top) + // Check delete handle + if (window.currentHandles.delete) { + if (isPointInCircle(transformedMouse.x, transformedMouse.y, + window.currentHandles.delete.handleLocalX, window.currentHandles.delete.handleLocalY, + window.currentHandles.delete.radius)) { + + console.log('Delete handle hit!'); + + event.preventDefault(); // prevent that the click executes other interactions + window.deleteSelectedElements(); // remove active element + return; + } + } + + // Check rotate handle + if (window.currentHandles.rotate) { + if (isPointInCircle(transformedMouse.x, transformedMouse.y, + window.currentHandles.rotate.handleLocalX, window.currentHandles.rotate.handleLocalY, + window.currentHandles.rotate.radius)) { + window.selectedHandle = { ...window.currentHandles.rotate, element: window.activeElement }; + + console.log('Rotate handle hit!'); + + isRotating = true; + window.saveState(); // Save state at start of rotation + + // The rotationStartAngle needs to be calculated in the global coordinate system initially + // because the mouse movement is global. + const elementCenterX = window.activeElement.x + window.activeElement.width / 2; + const elementCenterY = window.activeElement.y + window.activeElement.height / 2; + rotationStartAngle = Math.atan2(globalMouse.y - elementCenterY, globalMouse.x - elementCenterX); + initialElementRotation = window.activeElement.rotation; // Store active element's rotation as start reference + window.collageCanvas.style.cursor = window.selectedHandle.cursor; + return; + } + } + + // Check resize handles + let resizeXY; + if (selectedElementsCount > 1) { + const boundingBox = window.getSelectionBoundingBox(); + resizeXY = { x: globalMouse.x - boundingBox.x, y: globalMouse.y - boundingBox.y }; + } else { + resizeXY = transformedMouse; + } + + for (const handle of window.currentHandles.resize) { + if (isPointInRect(resizeXY.x, resizeXY.y, handle.handleLocalX, handle.handleLocalY, handle.handleWidth, handle.handleHeight)) { + window.selectedHandle = { ...handle, element: window.activeElement }; + + console.log('Resize handle hit:', handle.type); + + isResizing = true; + window.saveState(); // Save state at start of resizing + resizeHandle = handle.type; + dragStartX = globalMouse.x; + dragStartY = globalMouse.y; + // Store initial state for resizing relative to the group/element + if (window.activeElement) { + initialElementWidth = window.activeElement.width; + initialElementHeight = window.activeElement.height; + initialElementX = window.activeElement.x; + initialElementY = window.activeElement.y; + } else { // Fallback for group resize (will be set from window.getSelectionBoundingBox in handleMouseMove) + const groupBoundingBox = window.getSelectionBoundingBox(); + if (groupBoundingBox) { + initialElementWidth = groupBoundingBox.width; + initialElementHeight = groupBoundingBox.height; + initialElementX = groupBoundingBox.x; + initialElementY = groupBoundingBox.y; + } + } + window.collageCanvas.style.cursor = window.selectedHandle.cursor; + return; // Handle hit + } + } + } + + // --- 2. If no handles hit, check for hits on elements themselves (for dragging/selection) --- + let clickedOnElement = false; + let elementClicked = null; + + // Find the topmost element clicked + for (let i = window.collageElements.length - 1; i >= 0; i--) { + const element = window.collageElements[i]; + if (element.isHit(globalMouse.x, globalMouse.y)) { + elementClicked = element; + clickedOnElement = true; + break; + } + } + + const wasElementSelectedBeforeClick = clickedOnElement ? elementClicked.isSelected : false; + + if (clickedOnElement) { + + // If Ctrl/Cmd is pressed, toggle selection for the clicked element + if (event.ctrlKey || event.metaKey) { + elementClicked.isSelected = !elementClicked.isSelected; + if (elementClicked.isSelected) { + window.activeElement = elementClicked; // Set active element to the one just selected + } else if (window.activeElement === elementClicked) { + // If we deselected the active element, find another selected one to be active, or null + window.activeElement = window.collageElements.find(el => el.isSelected) || null; + } + } else { + // Single selection: deselect all others, then select this one + if (window.activeElement !== elementClicked || !elementClicked.isSelected) { + window.collageElements.forEach(el => el.isSelected = false); + } + elementClicked.isSelected = true; + window.activeElement = elementClicked; // The clicked element becomes the active one + } + + if (wasElementSelectedBeforeClick) { + isDragging = true; + window.saveState(); // Save state at start of dragging + dragStartX = globalMouse.x; + dragStartY = globalMouse.y; + + // elementStartX/Y for dragging needs to be the initial position of the active element + // or the top-left of the group bounding box for consistent dragging behavior. + if (selectedElementsCount > 1) { // If multiple elements, drag the group + const groupBoundingBox = window.getSelectionBoundingBox(); + if (groupBoundingBox) { + elementStartX = groupBoundingBox.x; + elementStartY = groupBoundingBox.y; + } + } else if (window.activeElement) { // Single element drag + elementStartX = window.activeElement.x; + elementStartY = window.activeElement.y; + } + } + window.collageCanvas.style.cursor = window.activeElement ? 'grabbing' : 'default'; + } else { + // No element was clicked + if (!event.ctrlKey && !event.metaKey) { + window.collageElements.forEach(el => el.isSelected = false); + window.activeElement = null; + } + } + + window.drawCanvas(); + } + + //--------------------------------------------------------------------------------- + function handleMouseMove(event) { + const globalMouse = getMousePos(event); // Mouse coordinates relative to canvas + + // --- Cursor hover for handles (adjust for group vs. single) --- + let cursorChanged = false; + if (!isDragging && !isResizing && !isRotating) { // Only change cursor if no interaction is active + const selectedElements = window.collageElements.filter(el => el.isSelected); + const selectedElementsCount = selectedElements.length; + + // --- Determine the mouse coordinates for handle hit testing (similar to handleMouseDown) --- + let mouseForHandleHitX, mouseForHandleHitY; // Mouse coordinates in the local space of the handle's "container" + let handleContainerTarget = null; // Either activeElement or groupBoundingBox for reference + + if (selectedElementsCount === 1 && window.activeElement) { + // Single element selection: Handles are relative to the activeElement. + // Transform global mouse into the activeElement's local, unrotated space. + const transformedMouse = getLocalMouseCoordinates(globalMouse.x, globalMouse.y, window.activeElement); + mouseForHandleHitX = transformedMouse.x; + mouseForHandleHitY = transformedMouse.y; + handleContainerTarget = window.activeElement; + } else if (selectedElementsCount > 1) { + // Group selection: Handles are relative to the groupBoundingBox. + // The groupBoundingBox is always axis-aligned. + // We need mouse coordinates relative to the top-left of the groupBoundingBox. + const groupBoundingBox = window.getSelectionBoundingBox(); + if (groupBoundingBox) { + mouseForHandleHitX = globalMouse.x - groupBoundingBox.x; + mouseForHandleHitY = globalMouse.y - groupBoundingBox.y; + handleContainerTarget = groupBoundingBox; + } + } + + if (handleContainerTarget) { + // 1. Check delete handle (ONLY FOR SINGLE ACTIVE ELEMENT) + if (selectedElementsCount === 1 && window.currentHandles.delete) { + if (isPointInCircle(mouseForHandleHitX, mouseForHandleHitY, + window.currentHandles.delete.handleLocalX, window.currentHandles.delete.handleLocalY, + window.currentHandles.delete.radius)) { + window.collageCanvas.style.cursor = window.DELETE_CURSOR_URL; + cursorChanged = true; + } + } + + // 2. Check rotate handle (ONLY FOR SINGLE ACTIVE ELEMENT) + if (!cursorChanged && selectedElementsCount === 1 && window.currentHandles.rotate) { // Only check if no other cursor changed + if (isPointInCircle(mouseForHandleHitX, mouseForHandleHitY, + window.currentHandles.rotate.handleLocalX, window.currentHandles.rotate.handleLocalY, + window.currentHandles.rotate.radius)) { + window.collageCanvas.style.cursor = window.ROTATION_CURSOR_URL; + cursorChanged = true; + } + } + + // 3. Check resize handles (for single or multiple selection) + if (!cursorChanged && window.currentHandles.resize.length > 0) { // Only check if no other cursor changed + for (const handle of window.currentHandles.resize) { + if (isPointInRect(mouseForHandleHitX, mouseForHandleHitY, + handle.handleLocalX, handle.handleLocalY, handle.handleWidth, handle.handleHeight)) { + window.collageCanvas.style.cursor = handle.cursor; + cursorChanged = true; + break; + } + } + } + } // End if (handleContainerTarget) + + // 4. Check if hovering over the selection box itself (for dragging) + if (!cursorChanged) { + let overSelectedElement = false; + for (let i = window.collageElements.length - 1; i >= 0; i--) { // Iterate over selected elements + const element = window.collageElements[i]; + if (element.isHit(globalMouse.x, globalMouse.y)) { // isHit handles the internal transformation + overSelectedElement = true; + break; + } + } + if (overSelectedElement) { + window.collageCanvas.style.cursor = 'grab'; + cursorChanged = true; + } + } + + } // End if (!isDragging && !isResizing && !isRotating) + + // If no specific cursor was set by handle or selection hover, revert to default. + if (!cursorChanged && !isDragging && !isResizing && !isRotating) { + window.collageCanvas.style.cursor = 'default'; + } + + + // --- Rotation Logic (now applies to all selected elements, driven by activeElement's handle) --- + if (isRotating && window.activeElement) { + const selected = window.collageElements.filter(el => el.isSelected); + if (selected.length === 0) { // Should not happen if activeElement is set and selected + isRotating = false; + return; + } + + const activeElementCenterX = window.activeElement.x + window.activeElement.width / 2; + const activeElementCenterY = window.activeElement.y + window.activeElement.height / 2; + + const currentAngle = Math.atan2(globalMouse.y - activeElementCenterY, globalMouse.x - activeElementCenterX); + let angleDiff = (rotationStartAngle - currentAngle) * 180 / Math.PI; + + if (event.shiftKey) { + angleDiff = Math.round(angleDiff / 20) * 30; + } else { + angleDiff = Math.round(angleDiff); + } + + selected.forEach(element => { + element.rotation = (element.rotation + angleDiff % 360 + 360) % 360; + }); + + // Update rotationStartAngle for the next mousemove step + rotationStartAngle = currentAngle; + + window.drawCanvas(); + return; + } + + // --- Scaling Logic --- + if (isResizing) { + const selectedElements = window.collageElements.filter(el => el.isSelected); + if (selectedElements.length === 0) { + isResizing = false; + return; + } + + // initialBoundingBox: represents the initial bounding box of the target (single element or group) + // These are the values stored in handleMouseDown in initialElementX/Y/Width/Height. + const initialBoundingBox = { + x: initialElementX, y: initialElementY, + width: initialElementWidth, height: initialElementHeight + }; + + const mouseStart = { x: dragStartX, y: dragStartY }; + + const applyAspectRatioLock = event.shiftKey || window.globalLockAspectRatio; + + // anchorpoint (opposite corner of the handle) + let anchorX, anchorY; + switch (resizeHandle) { + case 'top-left': anchorX = initialBoundingBox.x + initialBoundingBox.width; anchorY = initialBoundingBox.y + initialBoundingBox.height; break; + case 'top-right': anchorX = initialBoundingBox.x; anchorY = initialBoundingBox.y + initialBoundingBox.height; break; + case 'bottom-left': anchorX = initialBoundingBox.x + initialBoundingBox.width; anchorY = initialBoundingBox.y; break; + case 'bottom-right': anchorX = initialBoundingBox.x; anchorY = initialBoundingBox.y; break; + } + + // Calculate current mouse movement since drag started + const deltaX = globalMouse.x - mouseStart.x; + const deltaY = globalMouse.y - mouseStart.y; + + // Adjust delta values based on the handle for correct direction + let effectiveDeltaX = resizeHandle.includes('left') ? -deltaX : deltaX; + let effectiveDeltaY = resizeHandle.includes('top') ? -deltaY : deltaY; + + let finalWidth, finalHeight; + + const aspectRatioToUse = applyAspectRatioLock && initialBoundingBox.height !== 0 ? + initialBoundingBox.width / initialBoundingBox.height : + null; + + if (aspectRatioToUse !== null) { // Proportional scaling + // When AR lock is active, maintain the determined aspectRatioToUse. + + // Assume the dominant movement controls the dimension. + if (Math.abs(effectiveDeltaX) > Math.abs(effectiveDeltaY)) { + finalWidth = initialBoundingBox.width + effectiveDeltaX; + finalHeight = finalWidth / aspectRatioToUse; + } else { + finalHeight = initialBoundingBox.height + effectiveDeltaY; + finalWidth = finalHeight * aspectRatioToUse; + } + + } else { // Free scaling + finalWidth = initialBoundingBox.width + effectiveDeltaX; + finalHeight = initialBoundingBox.height + effectiveDeltaY; + } + + // --- Apply minimum size restriction --- + const MIN_SIZE_PX = 100; + + if (finalWidth < MIN_SIZE_PX) { + finalWidth = MIN_SIZE_PX; + if (aspectRatioToUse !== null) { // Only adjust if Aspect Ratio Lock is active + finalHeight = MIN_SIZE_PX / aspectRatioToUse; + } + } + if (finalHeight < MIN_SIZE_PX) { + finalHeight = MIN_SIZE_PX; + if (aspectRatioToUse !== null) { // Only adjust if Aspect Ratio Lock is active + finalWidth = MIN_SIZE_PX * aspectRatioToUse; + } + } + + // Calculate new top-left corner of the bounding box based on anchor point and new dimensions + let newBoundingBoxX, newBoundingBoxY; + switch (resizeHandle) { + case 'top-left': + newBoundingBoxX = anchorX - finalWidth; + newBoundingBoxY = anchorY - finalHeight; + break; + case 'top-right': + newBoundingBoxX = anchorX; + newBoundingBoxY = anchorY - finalHeight; + break; + case 'bottom-left': + newBoundingBoxX = anchorX - finalWidth; + newBoundingBoxY = anchorY; + break; + case 'bottom-right': + newBoundingBoxX = anchorX; + newBoundingBoxY = anchorY; + break; + } + + // Calculate overall displacement and scaling for each selected element + const displacementX = newBoundingBoxX - initialBoundingBox.x; + const displacementY = newBoundingBoxY - initialBoundingBox.y; + + let scaleFactorX, scaleFactorY; + if (aspectRatioToUse !== null) { // Proportional scaling + // When AR lock is active, use a single scaling factor for both dimensions. + // This ensures each element within the selection scales uniformly, retaining its own AR. + scaleFactorX = finalWidth / initialBoundingBox.width; + scaleFactorY = scaleFactorX; // Both dimensions scale with the SAME factor + } else { + // For free scaling, use potentially different scaling factors for width and height. + scaleFactorX = finalWidth / initialBoundingBox.width; + scaleFactorY = finalHeight / initialBoundingBox.height; + } + + // Apply transformation on each selected element + selectedElements.forEach(element => { + // Position of the element relative to the BoundingBox's initial top-left corner + const relativeXToBoundingBox = element.x - initialBoundingBox.x; + const relativeYToBoundingBox = element.y - initialBoundingBox.y; + + // Scale relative position and dimensions + element.x = newBoundingBoxX + relativeXToBoundingBox * scaleFactorX; + element.y = newBoundingBoxY + relativeYToBoundingBox * scaleFactorY; + + element.width = element.width * scaleFactorX; + element.height = element.height * scaleFactorY; + // console.log("handleMouseMove: Element ID:", element.id, "new dimensions:", element.width, "x", element.height, "AR:", element.width / element.height); + }); + + // Update dragStartX/Y to current mouse position for continuous resizing + dragStartX = globalMouse.x; + dragStartY = globalMouse.y; + + window.drawCanvas(); + return; + } + + // --- Dragging Logic (now applies to all selected elements) --- + if (isDragging) { + const selected = window.collageElements.filter(el => el.isSelected); + if (selected.length === 0) { + isDragging = false; + return; + } + const dx = globalMouse.x - dragStartX; + const dy = globalMouse.y - dragStartY; + + selected.forEach(element => { + element.x += dx; + element.y += dy; + }); + + // Update dragStartX/Y to current mouse position for continuous resizing + dragStartX = globalMouse.x; + dragStartY = globalMouse.y; + + window.drawCanvas(); + return; + } + } + + //--------------------------------------------------------------------------------- + function handleMouseUp(event) { + if (isDragging) { + isDragging = false; + } + if (isResizing) { + isResizing = false; + } + if (isRotating) { + isRotating = false; + } + + // Restore default cursor: grab if over a selected element, default otherwise. + let overSelectable = false; + const mouse = getMousePos(event); + for (let i = window.collageElements.length - 1; i >= 0; i--) { + const element = window.collageElements[i]; + if (element.isHit(mouse.x, mouse.y)) { + overSelectable = true; + break; + } + } + window.collageCanvas.style.cursor = overSelectable ? 'grab' : 'default'; + } + + //================================================================================= + // --- Event Listeners --- + //================================================================================= + window.collageCanvas.addEventListener('mousedown', handleMouseDown); + window.collageCanvas.addEventListener('mousemove', handleMouseMove); + window.collageCanvas.addEventListener('mouseup', handleMouseUp); + window.collageCanvas.addEventListener('mouseout', handleMouseUp); // End interaction if mouse leaves canvas +}); \ No newline at end of file diff --git a/admin/collage-designer/assets/js/collage-designer-tools.js b/admin/collage-designer/assets/js/collage-designer-tools.js new file mode 100644 index 000000000..1a0c565c2 --- /dev/null +++ b/admin/collage-designer/assets/js/collage-designer-tools.js @@ -0,0 +1,439 @@ +// admin/collage-designer/assets/js/collage-designer-tools.js + +document.addEventListener('DOMContentLoaded', () => { + // Check if main designer variables/functions are available + if (typeof window.collageCanvas === 'undefined' || typeof window.drawCanvas === 'undefined' || + typeof window.collageElements === 'undefined' || typeof window.activeElement === 'undefined' || + typeof window.CollageElement === 'undefined' || typeof window.createSnapshot === 'undefined' || + typeof window.restoreSnapshot === 'undefined' || typeof window.saveState === 'undefined' || + typeof window.phpFallbackImageUrl === 'undefined' || typeof window.fetchDemoImageUrls === 'undefined' + ) { + console.error('collage-designer-tools.js: Dependent main designer variables/functions not found. Ensure collage-designer.js is loaded first and exposes necessary variables globally.'); + return; + } + + //================================================================================= + // --- Helper Functions --- + //================================================================================= + + /** + * Gets the canvas dimensions. + * @returns {{width: number, height: number}} + */ + function getCanvasDimensions() { + return { + width: window.collageCanvas.width, + height: window.collageCanvas.height + }; + } + + + //================================================================================= + // --- z-order functions --- + //================================================================================= + + /** + * Moves selected elements within the window.collageElements array to change their Z-order. + * + * @param {string} direction 'front', 'back', 'forward', 'backward' + */ + window.changeZOrder = function(direction) { + const selectedElements = window.collageElements.filter(el => el.isSelected); + if (selectedElements.length === 0) { + return; // Nothing selected to move + } + + window.saveState(); // Save state before modifying Z-order + + // Create a copy of the current elements array + let currentElements = [...window.collageElements]; + + // Filter out selected elements from their current positions + const nonSelectedElements = currentElements.filter(el => !selectedElements.includes(el)); + + if (direction === 'front') { + // All selected elements to the very end of the array + window.collageElements = [...nonSelectedElements, ...selectedElements]; + } else if (direction === 'back') { + // All selected elements to the very beginning of the array + window.collageElements = [...selectedElements, ...nonSelectedElements]; + } else if (direction === 'forward') { + // Move selected elements one position forward (towards the end) + // Iterate from the back to allow moving elements without affecting earlier indices in the same loop + for (let i = window.collageElements.length - 1; i >= 0; i--) { + const element = window.collageElements[i]; + if (selectedElements.includes(element)) { + const currentIndex = i; + // If the element is not the last one in the array + // AND the element directly after it is NOT also selected (to preserve internal order) + if (currentIndex < window.collageElements.length - 1 && !selectedElements.includes(window.collageElements[currentIndex + 1])) { + // Swap with the element directly after it + [window.collageElements[currentIndex], window.collageElements[currentIndex + 1]] = + [window.collageElements[currentIndex + 1], window.collageElements[currentIndex]]; + } + } + } + } else if (direction === 'backward') { + // Move selected elements one position backward (towards the beginning) + // Iterate from the front to allow moving elements without affecting later indices in the same loop + for (let i = 0; i < window.collageElements.length; i++) { + const element = window.collageElements[i]; + if (selectedElements.includes(element)) { + const currentIndex = i; + // If the element is not the first one in the array + // AND the element directly before it is NOT also selected (to preserve internal order) + if (currentIndex > 0 && !selectedElements.includes(window.collageElements[currentIndex - 1])) { + // Swap with the element directly before it + [window.collageElements[currentIndex], window.collageElements[currentIndex - 1]] = + [window.collageElements[currentIndex - 1], window.collageElements[currentIndex]]; + } + } + } + } + + window.drawCanvas(); // Redraw canvas to reflect new Z-order + window.updateLayerButtonStates(); // Update button states + }; + + //================================================================================= + // --- Alignment Functions --- + //================================================================================= + + // --- Horizontal Alignment --- + + // Align selected elements to the left edge (Canvas / Leftmost of Group / Active Element's left) + document.getElementById('alignLeftBtn').addEventListener('click', () => { + window.saveState(); // Save state for undo functionality + const elementsToAlign = window.getSelectedElements(); + if (elementsToAlign.length === 0) { + console.log('No elements selected for left alignment.'); + return; + } + + let targetX; + if (elementsToAlign.length === 1) { + // Align single element to canvas left + targetX = 0; + } else { + // Multiple elements: align to active element's left, or leftmost of group + if (window.activeElement && window.activeElement.isSelected) { + // Align to active element's left edge + targetX = window.activeElement.x; + } else { + // Align to the leftmost edge among selected elements + targetX = Math.min(...elementsToAlign.map(el => el.x)); + } + } + elementsToAlign.forEach(element => { + element.x = targetX; + }); + window.drawCanvas(); + }); + + // Align selected elements to the horizontal center (Canvas / Active Element's center) + document.getElementById('alignCenterHBtn').addEventListener('click', () => { + window.saveState(); // Save state for undo functionality + const elementsToAlign = window.getSelectedElements(); + if (elementsToAlign.length === 0) { + console.log('No elements selected for horizontal center alignment.'); + return; + } + + let targetCenterX; + if (elementsToAlign.length === 1) { + // Align single element to canvas horizontal center + targetCenterX = getCanvasDimensions().width / 2; + } else { + // Multiple elements: align to active element's horizontal center + if (window.activeElement && window.activeElement.isSelected) { + targetCenterX = window.activeElement.x + window.activeElement.width / 2; + } else { + // Fallback: If no active element, align to the center of the bounding box of selected elements + const minX = Math.min(...elementsToAlign.map(el => el.x)); + const maxX = Math.max(...elementsToAlign.map(el => el.x + el.width)); + targetCenterX = minX + (maxX - minX) / 2; + } + } + elementsToAlign.forEach(element => { + element.x = targetCenterX - element.width / 2; + }); + window.drawCanvas(); + }); + + // Align selected elements to the right edge (Canvas / Rightmost of Group / Active Element's right) + document.getElementById('alignRightBtn').addEventListener('click', () => { + window.saveState(); // Save state for undo functionality + const elementsToAlign = window.getSelectedElements(); + if (elementsToAlign.length === 0) { + console.log('No elements selected for right alignment.'); + return; + } + + let targetRightX; + if (elementsToAlign.length === 1) { + // Align single element to canvas right + targetRightX = getCanvasDimensions().width; + } else { + // Multiple elements: align to active element's right, or rightmost of group + if (window.activeElement && window.activeElement.isSelected) { + targetRightX = window.activeElement.x + window.activeElement.width; + } else { + // Align to the rightmost edge among selected elements + targetRightX = Math.max(...elementsToAlign.map(el => el.x + el.width)); + } + } + elementsToAlign.forEach(element => { + element.x = targetRightX - element.width; + }); + window.drawCanvas(); + }); + + // --- Vertical Alignment --- + + // Align selected elements to the top edge (Canvas / Topmost of Group / Active Element's top) + document.getElementById('alignTopBtn').addEventListener('click', () => { + window.saveState(); // Save state for undo functionality + const elementsToAlign = window.getSelectedElements(); + if (elementsToAlign.length === 0) { + console.log('No elements selected for top alignment.'); + return; + } + + let targetY; + if (elementsToAlign.length === 1) { + // Align single element to canvas top + targetY = 0; + } else { + // Multiple elements: align to active element's top, or topmost of group + if (window.activeElement && window.activeElement.isSelected) { + targetY = window.activeElement.y; + } else { + // Align to the topmost edge among selected elements + targetY = Math.min(...elementsToAlign.map(el => el.y)); + } + } + elementsToAlign.forEach(element => { + element.y = targetY; + }); + window.drawCanvas(); + }); + + // Align selected elements to the vertical middle (Canvas / Active Element's middle) + document.getElementById('alignMiddleVBtn').addEventListener('click', () => { + window.saveState(); // Save state for undo functionality + const elementsToAlign = window.getSelectedElements(); + if (elementsToAlign.length === 0) { + console.log('No elements selected for vertical middle alignment.'); + return; + } + + let targetCenterY; + if (elementsToAlign.length === 1) { + // Align single element to canvas vertical middle + targetCenterY = getCanvasDimensions().height / 2; + } else { + // Multiple elements: align to active element's vertical middle + if (window.activeElement && window.activeElement.isSelected) { + targetCenterY = window.activeElement.y + window.activeElement.height / 2; + } else { + // Fallback: If no active element, align to the center of the bounding box of selected elements + const minY = Math.min(...elementsToAlign.map(el => el.y)); + const maxY = Math.max(...elementsToAlign.map(el => el.y + el.height)); + targetCenterY = minY + (maxY - minY) / 2; + } + } + elementsToAlign.forEach(element => { + element.y = targetCenterY - element.height / 2; + }); + window.drawCanvas(); + }); + + // Align selected elements to the bottom edge (Canvas / Bottommost of Group / Active Element's bottom) + document.getElementById('alignBottomBtn').addEventListener('click', () => { + window.saveState(); // Save state for undo functionality + const elementsToAlign = window.getSelectedElements(); + if (elementsToAlign.length === 0) { + console.log('No elements selected for bottom alignment.'); + return; + } + + let targetBottomY; + if (elementsToAlign.length === 1) { + // Align single element to canvas bottom + targetBottomY = getCanvasDimensions().height; + } else { + // Multiple elements: align to active element's bottom, or bottommost of group + if (window.activeElement && window.activeElement.isSelected) { + targetBottomY = window.activeElement.y + window.activeElement.height; + } else { + // Align to the bottommost edge among selected elements + targetBottomY = Math.max(...elementsToAlign.map(el => el.y + el.height)); + } + } + elementsToAlign.forEach(element => { + element.y = targetBottomY - element.height; + }); + window.drawCanvas(); + }); + + //================================================================================= + // --- Distribution Functions --- + //================================================================================= + + // Distribute selected elements horizontally + document.getElementById('distributeHBtn').addEventListener('click', () => { + window.saveState(); // Save state for undo functionality + let elementsToDistribute = window.getSelectedElements(); + + if (elementsToDistribute.length < 3) { + console.log('Select at least 3 elements for horizontal distribution.'); + return; + } + + // Sort elements by their x-coordinate to ensure proper distribution order + elementsToDistribute.sort((a, b) => a.x - b.x); + + const firstElement = elementsToDistribute[0]; + const lastElement = elementsToDistribute[elementsToDistribute.length - 1]; + + // Determine the total width of all elements combined + const totalElementsWidth = elementsToDistribute.reduce((sum, el) => sum + el.width, 0); + + // Determine the total available space between the first and last element's outer edges + const availableSpace = (lastElement.x + lastElement.width) - firstElement.x; + + // Calculate the space to be distributed between elements + // This is the total space minus the space occupied by the elements themselves + const spaceBetweenElements = availableSpace - totalElementsWidth; + + // Calculate the actual gap size that needs to be inserted between each element + // There are (n-1) gaps for n elements + const numGaps = elementsToDistribute.length - 1; + if (numGaps <= 0) { // Should not happen with length < 3 check, but for safety + window.drawCanvas(); + return; + } + const uniformGap = spaceBetweenElements / numGaps; + + // Apply new positions + let currentX = firstElement.x; // Start from the first element's x-position + elementsToDistribute.forEach((element, index) => { + if (index === 0) { + // First element stays at its sorted position (x) + element.x = firstElement.x; + } else { + // Position subsequent elements based on the previous element's width and the uniform gap + currentX += elementsToDistribute[index - 1].width + uniformGap; + element.x = currentX; + } + }); + + window.drawCanvas(); + }); + + // Distribute selected elements vertically + document.getElementById('distributeVBtn').addEventListener('click', () => { + window.saveState(); // Save state for undo functionality + let elementsToDistribute = window.getSelectedElements(); + + if (elementsToDistribute.length < 3) { + console.log('Select at least 3 elements for vertical distribution.'); + return; + } + + // Sort elements by their y-coordinate + elementsToDistribute.sort((a, b) => a.y - b.y); + + const firstElement = elementsToDistribute[0]; + const lastElement = elementsToDistribute[elementsToDistribute.length - 1]; + + // Determine the total height of all elements combined + const totalElementsHeight = elementsToDistribute.reduce((sum, el) => sum + el.height, 0); + + // Determine the total available space between the first and last element's outer edges + const availableSpace = (lastElement.y + lastElement.height) - firstElement.y; + + // Calculate the space to be distributed between elements + const spaceBetweenElements = availableSpace - totalElementsHeight; + + // Calculate the actual gap size + const numGaps = elementsToDistribute.length - 1; + if (numGaps <= 0) { + window.drawCanvas(); + return; + } + const uniformGap = spaceBetweenElements / numGaps; + + // Apply new positions + let currentY = firstElement.y; // Start from the first element's y-position + elementsToDistribute.forEach((element, index) => { + if (index === 0) { + // First element stays at its sorted position (y) + element.y = firstElement.y; + } else { + // Position subsequent elements based on the previous element's height and the uniform gap + currentY += elementsToDistribute[index - 1].height + uniformGap; + element.y = currentY; + } + }); + + window.drawCanvas(); + }); + + //================================================================================= + // --- Event Listeners --- + //================================================================================= + + // show / hide element outlines + document.getElementById('showElmntOutlineBtn').addEventListener('click', () => { + window.globalShowElementOutlines = !window.globalShowElementOutlines; // toggle + window.drawCanvas(); + }); + + // add / remove buttons + document.getElementById('addImg').addEventListener('click', () => { + // When clicking the button, add a new element + window.addNewElement(); + }); + document.getElementById('addTxt').addEventListener('click', () => { + // When clicking the button, add a new element + window.addNewElement(undefined, undefined, undefined, undefined, undefined, type = 'text'); + }); + // Remove Button + document.getElementById('removeBtn').addEventListener('click', () => { + window.deleteSelectedElements(); + }); + + // --- Keyboard Shortcut for Delete --- + document.addEventListener('keydown', (event) => { + if (event.key === 'Delete') { + const selectedElementsCount = window.collageElements.filter(el => el.isSelected).length; + if (selectedElementsCount > 0) { + event.preventDefault(); // prevents default browser behavior (e.g., navigating back in browser) + window.deleteSelectedElements(); // Delete the selected elements + } + } + }); + + document.getElementById('lockAspectRatioBtn').addEventListener('click', () => { + window.globalLockAspectRatio = !window.globalLockAspectRatio; // toggle + window.updateAspectRatioLockButtonState(); + }); + + //================================================================================= + + // --- Layering Buttons --- + document.getElementById('sendToBackBtn').addEventListener('click', () => { + window.changeZOrder('back'); + }); + document.getElementById('sendBackwardBtn').addEventListener('click', () => { + window.changeZOrder('backward'); + }); + document.getElementById('bringForwardBtn').addEventListener('click', () => { + window.changeZOrder('forward'); + }); + document.getElementById('bringToFrontBtn').addEventListener('click', () => { + window.changeZOrder('front'); + }); + //================================================================================= +}); diff --git a/admin/collage-designer/assets/js/collage-designer-txtSetPnl.js b/admin/collage-designer/assets/js/collage-designer-txtSetPnl.js new file mode 100644 index 000000000..7f7fd3243 --- /dev/null +++ b/admin/collage-designer/assets/js/collage-designer-txtSetPnl.js @@ -0,0 +1,226 @@ +// admin/collage-designer/assets/js/collage-designer-txtSetPnl.js + +document.addEventListener('DOMContentLoaded', () => { + // Basic check to ensure main designer variables/functions are available + if (typeof window.collageCanvas === 'undefined' || typeof window.drawCanvas === 'undefined' || + typeof window.collageElements === 'undefined' || typeof window.activeElement === 'undefined' || + typeof window.CollageElement === 'undefined' || typeof window.createSnapshot === 'undefined' || + typeof window.restoreSnapshot === 'undefined' || typeof window.saveState === 'undefined' || + typeof window.getSelectedElements === 'undefined' + ) { + console.error('collage-designer-txtSetPnl.js: Dependent main designer variables/functions not found. Ensure collage-designer.js is loaded first and exposes necessary variables globally.'); + return; + } + + // --- References to the Text Settings Panel and its elements --- + const text_specific_settings_panel = document.getElementById('text_specific_settings_panel'); + const selectedTextElementIdDisplay = document.getElementById('selected_text_element_id_display'); + + // References to the individual buttons for updating their state (e.g., active class) + const txtIncrBtn = document.getElementById('txtIncr'); + const txtDecrBtn = document.getElementById('txtDecr'); + const txtBoldBtn = document.getElementById('txtBold'); + const txtItalicBtn = document.getElementById('txtIalic'); + const txtUnderlineBtn = document.getElementById('txtUnderline'); + const txtAlignLeftBtn = document.getElementById('txtAlignLeft'); + const txtAlignHorCenterBtn = document.getElementById('txtAlignHorCenter'); + const txtAlignRightBtn = document.getElementById('txtAlignRight'); + const txtAlignVerTopBtn = document.getElementById('txtAlignVerTop'); + const txtAlignVerCenterBtn = document.getElementById('txtAlignVerCenter'); + const txtAlignVerBottomBtn = document.getElementById('txtAlignVerBottom'); + const active_txt_element_content = document.querySelector('input[name="active_txt_element_content"]'); + + const fontHiddenInput = document.querySelector('input[name="textoncollage[font]"]'); // the hidden Input, which saves the font-path + const fontPreviewTextElement = fontHiddenInput ? fontHiddenInput.closest('.adminFontSelection')?.querySelector('.adminFontSelection-text') : null; + const fontPreviewImageElement = fontHiddenInput ? fontHiddenInput.closest('.adminFontSelection')?.querySelector('.adminFontSelection-preview') : null; // preview_img + const fontColorInput = document.querySelector('input[name="textoncollage[font_color]"]'); // the hidden Input, which saves the font-path + + // Helper to toggle active class for buttons + function toggleButtonActiveState(button, isActive) { + if (button) { + button.classList.toggle('active', isActive); + button.classList.toggle('btn-primary', isActive); + button.classList.toggle('btn-outline-primary', !isActive); + } + } + + /** + * Updates the text-specific settings panel based on the currently active element. + * This function is expected to be called by a main updateElementSettingsPanel. + * It only updates the UI elements within this panel. + */ + window.updateTextSettingsPanel = function() { + if (!window.activeElement || window.activeElement.type !== 'text') { + text_specific_settings_panel.classList.add('hidden'); + return; + } + active_txt_element_content.value = window.activeElement.content; + + text_specific_settings_panel.classList.remove('hidden'); + selectedTextElementIdDisplay.textContent = `ID: ${window.activeElement.id}`; + //electedTextElementIdDisplay.classList.remove('hidden'); // Show ID if text element is active + + // Update Button States based on activeElement properties + toggleButtonActiveState(txtBoldBtn, window.activeElement.font_bold); + toggleButtonActiveState(txtItalicBtn, window.activeElement.font_italic); + toggleButtonActiveState(txtUnderlineBtn, window.activeElement.font_underline); + + // Update Horizontal Alignment Buttons + toggleButtonActiveState(txtAlignLeftBtn, window.activeElement.text_horizontal_align === 'left'); + toggleButtonActiveState(txtAlignHorCenterBtn, window.activeElement.text_horizontal_align === 'center'); + toggleButtonActiveState(txtAlignRightBtn, window.activeElement.text_horizontal_align === 'right'); + + // Update Vertical Alignment Buttons + toggleButtonActiveState(txtAlignVerTopBtn, window.activeElement.text_vertical_align === 'top'); + toggleButtonActiveState(txtAlignVerCenterBtn, window.activeElement.text_vertical_align === 'center'); + toggleButtonActiveState(txtAlignVerBottomBtn, window.activeElement.text_vertical_align === 'bottom'); + + if (fontHiddenInput) { + fontHiddenInput.value = window.activeElement.font_family; + //TODO Font Preview Update via API(?) + + } + if (fontColorInput) { + fontColorInput.value = window.activeElement.font_color; + } + }; + + //================================================================================= + // --- Text Functions --- + //================================================================================= + + // Increase Font Size + txtIncrBtn.addEventListener('click', () => { + window.saveState(); // Save state for undo functionality + const selectedElements = window.getSelectedElements().filter(el => el.type === 'text'); + selectedElements.forEach(element => { + element.font_size += 1; // Increase font size by 1 unit + }); + window.drawCanvas(); + }); + + // Decrease Font Size + txtDecrBtn.addEventListener('click', () => { + window.saveState(); // Save state for undo functionality + const selectedElements = window.getSelectedElements().filter(el => el.type === 'text'); + selectedElements.forEach(element => { + if (element.font_size > 1) { // Prevent font size from going below 1 + element.font_size -= 1; // Decrease font size by 1 unit + } + }); + window.drawCanvas(); + }); + + // Toggle Bold + txtBoldBtn.addEventListener('click', () => { + window.saveState(); // Save state for undo functionality + const selectedElements = window.getSelectedElements().filter(el => el.type === 'text'); + selectedElements.forEach(element => { + element.font_bold = !element.font_bold; // Toggle bold + }); + window.drawCanvas(); + }); + + // Toggle Italic + txtItalicBtn.addEventListener('click', () => { + window.saveState(); // Save state for undo functionality + const selectedElements = window.getSelectedElements().filter(el => el.type === 'text'); + selectedElements.forEach(element => { + element.font_italic = !element.font_italic; // Toggle italic + }); + window.drawCanvas(); + }); + + // Toggle Underline + txtUnderlineBtn.addEventListener('click', () => { + window.saveState(); // Save state for undo functionality + const selectedElements = window.getSelectedElements().filter(el => el.type === 'text'); + selectedElements.forEach(element => { + element.font_underline = !element.font_underline; // Toggle underline + }); + window.drawCanvas(); + }); + + // Align Text Left + txtAlignLeftBtn.addEventListener('click', () => { + window.saveState(); // Save state for undo functionality + const selectedElements = window.getSelectedElements().filter(el => el.type === 'text'); + selectedElements.forEach(element => { + element.text_horizontal_align = 'left'; // Set text alignment to left + }); + window.drawCanvas(); + }); + + // Align Text Center + txtAlignHorCenterBtn.addEventListener('click', () => { + window.saveState(); // Save state for undo functionality + const selectedElements = window.getSelectedElements().filter(el => el.type === 'text'); + selectedElements.forEach(element => { + element.text_horizontal_align = 'center'; // Set text alignment to center + }); + window.drawCanvas(); + }); + + // Align Text Right + txtAlignRightBtn.addEventListener('click', () => { + window.saveState(); // Save state for undo functionality + const selectedElements = window.getSelectedElements().filter(el => el.type === 'text'); + selectedElements.forEach(element => { + element.text_horizontal_align = 'right'; // Set text alignment to right + }); + window.drawCanvas(); + }); + + // Align Text Vertical Top + txtAlignVerTopBtn.addEventListener('click', () => { + window.saveState(); // Save state for undo functionality + const selectedElements = window.getSelectedElements().filter(el => el.type === 'text'); + selectedElements.forEach(element => { + element.text_vertical_align = 'top'; // Set text vertical alignment to top + }); + window.drawCanvas(); + }); + + // Align Text Vertical Center + txtAlignVerCenterBtn.addEventListener('click', () => { + window.saveState(); // Save state for undo functionality + const selectedElements = window.getSelectedElements().filter(el => el.type === 'text'); + selectedElements.forEach(element => { + element.text_vertical_align = 'center'; // Set text vertical alignment to center + }); + window.drawCanvas(); + }); + + // Align Text Vertical Bottom + txtAlignVerBottomBtn.addEventListener('click', () => { + window.saveState(); // Save state for undo functionality + const selectedElements = window.getSelectedElements().filter(el => el.type === 'text'); + selectedElements.forEach(element => { + element.text_vertical_align = 'bottom'; // Set text vertical alignment to bottom + }); + window.drawCanvas(); + }); + + // Font Selector + fontHiddenInput.addEventListener('change', (e) => { + window.saveState(); + window.activeElement.font_family = e.target.value; + window.updateImageSettingsPanel(); + window.drawCanvas(); + }); + + // Color Selector + fontColorInput.addEventListener('change', (e) => { + window.saveState(); + window.activeElement.font_color = e.target.value; + window.updateImageSettingsPanel(); + window.drawCanvas(); + }); + + // Content Input + active_txt_element_content.addEventListener('input', (e) => { + window.saveState(); + window.activeElement.content = e.target.value; + window.drawCanvas(); + }); +}); \ No newline at end of file diff --git a/admin/collage-designer/assets/js/collage-designer-utils.js b/admin/collage-designer/assets/js/collage-designer-utils.js new file mode 100644 index 000000000..c08e4334a --- /dev/null +++ b/admin/collage-designer/assets/js/collage-designer-utils.js @@ -0,0 +1,621 @@ +// admin/collage-designer/assets/js/collage-designer-utils.js + +//================================================================================= +// --- Utility Functions --- +//================================================================================= + + /** + * debounce function to limit the rate of function calls + * + * @param {Function} func The function to debounce. + * @param {number} delay The debounce delay in milliseconds. + * @returns {Function} The debounced function. + */ + window.debounce = function(func, delay) { + let timeout; + return function(...args) { + const context = this; + clearTimeout(timeout); + timeout = setTimeout(() => func.apply(context, args), delay); + }; + } + + //================================================================================= + // --- Element Management Functions --- + //================================================================================= + + /** + * Adds a new placeholder element to the canvas. + * + * @param {number} x Optional x-coordinate. Defaults to center of canvas if not provided. + * @param {number} y Optional y-coordinate. Defaults to center of canvas if not provided. + * @param {number} width Optional width. Defaults to a standard size. + * @param {number} height Optional height. Defaults to a standard size. + * @param {number} rotation Optional rotation. Defaults to 0. + * @param {string} type Type of the element ('image', 'text', etc.). Defaults to 'image'. + * @param {object} data Additional data specific to the element type. + * @returns {CollageElement} The newly created element. + */ + window.addNewElement = async function(x, y, width, height, rotation = 0, type = 'image', data = {}) { // Added type and data + window.saveState(); + + const canvasWidth = window.collageCanvas.width; + const canvasHeight = window.collageCanvas.height; + + const defaultImageWidth = canvasWidth / 2; + const defaultImageHeight = canvasHeight / 2; + const defaultTextWidth = canvasWidth / 4; + const defaultTextHeight = canvasHeight / 8; + + const offset = window.collageElements.filter(el => el.originalLayoutDataIndex === -1).length * 10; + + let finalX = x !== undefined ? x : (canvasWidth / 2) - ((type === 'text' ? defaultTextWidth : defaultImageWidth) / 2) + offset; + let finalY = y !== undefined ? y : (canvasHeight / 2) - ((type === 'text' ? defaultTextHeight : defaultImageHeight) / 2) + offset; + + const newId = `element-${type}-${Date.now()}-${Math.floor(Math.random() * 1000)}`; + + // Type-specific data preparation + let elementData = { ...data }; // Copy any passed data + + switch (type) { + case 'image': + let imageUrl = null; + try { + const fetchedUrls = await window.fetchDemoImageUrls(1); + imageUrl = fetchedUrls[0]; + } catch (error) { + console.error('Could not fetch demo image for new element, using fallback.', error); + imageUrl = window.phpFallbackImageUrl; + } + const img = new Image(); + img.crossOrigin = "anonymous"; + img.src = imageUrl; + elementData.image = img; + elementData.originalLayoutDataIndex = -1; // Indicates it's an added element + elementData.show_frame = false; // Default for new image element + break; + case 'text': + elementData.content = elementData.content || photoboothTools.getTranslation('new_text_element'); // New translation key + elementData.font_family = elementData.font_family || 'resources/fonts/GreatVibes-Regular.ttf'; // Default or from global settings + elementData.font_color = elementData.font_color || '#000000'; + elementData.font_size = elementData.font_size !== undefined ? elementData.font_size : 5; // Default font size + elementData.font_bold = elementData.font_bold || false; + elementData.font_italic = elementData.font_italic || false; + elementData.font_underline = elementData.font_underline || false; + elementData.originalLayoutDataIndex = -1; // Indicates it's an added element + break; + // Add more cases for other types as needed + } + + const newElement = new CollageElement( + newId, + finalX, + finalY, + width !== undefined ? width : (type === 'text' ? defaultTextWidth : defaultImageWidth), + height !== undefined ? height : (type === 'text' ? defaultTextHeight : defaultImageHeight), + rotation, + type, // Pass the element type + elementData // Pass the prepared data object + ); + + window.collageElements.push(newElement); + + window.collageElements.forEach(el => el.isSelected = false); + newElement.isSelected = true; + window.activeElement = newElement; + + window.drawCanvas(); + + // For images, we need to redraw after loading + if (type === 'image' && newElement.image) { + newElement.image.onload = () => { + window.drawCanvas(); + window.saveState(); + }; + newElement.image.onerror = () => { + console.error(`Failed to load image for element ${newId}: ${newElement.image.src}`); + window.drawCanvas(); + window.saveState(); + }; + } else { + window.saveState(); // Save state immediately for text/other elements + } + + return newElement; + }; + + /** + * removes all selected Collage-Elementes from the canvas + */ + window.deleteSelectedElements = function() { + const selectedElements = window.collageElements.filter(el => el.isSelected); + if (selectedElements.length === 0) { + return; // nothing to remove + } + + window.saveState(); + + // removes selected elements from the main array + window.collageElements = window.collageElements.filter(el => !el.isSelected); + + // resets activeElement, if it was deleted + if (window.activeElement && !window.collageElements.includes(window.activeElement)) { + window.activeElement = null; + } + + window.drawCanvas(); + window.updateUndoRedoButtonStates(); + }; + + /** + * Gets currently selected elements from all known element arrays + * + * @returns {Array} An array of selected elements + */ + window.getSelectedElements = function() { + const selected = []; + // Add elements from window.collageElements (your image boxes) + window.collageElements.forEach(el => { + if (el.isSelected) selected.push(el); + }); + return selected; + } + + /** + * function to update the collage elements based on the current layout data. + * @param {Array} loadedImagesArray + * @returns + */ + window.updateCollageElements = function(loadedImagesArray) { + window.collageElements = []; + const canvasWidth = window.collageCanvas.width; + const canvasHeight = window.collageCanvas.height; + + // Check if currentLayout has the new 'elements' array + if (!window.currentLayout.elements || !Array.isArray(window.currentLayout.elements) || window.currentLayout.elements.length === 0) { + console.warn('Current layout does not contain a valid "elements" array in the new JSON format. No elements will be loaded.'); + // If no elements in new format, ensure existing window.collageElements is empty and redraw. + window.collageElements = []; + return; + } + + let imagePlaceholderCount = 0; // To correctly map demo images to image elements + + // Update genral settings based on currentLayout + window.backgroundImage = window.currentLayout.background_image || null; + window.backgroundColor = window.currentLayout.background_color || '#ffffff'; + window.showGlobalFrameImage = window.currentLayout.show_global_frame_image || false; + window.globalFrameImage = window.currentLayout.global_frame_image || null; + + window.currentLayout.elements.forEach((elementData) => { + // Parse x, y, width, height, rotation - assuming they might still contain 'x'/'y' placeholders or be strings + const x = eval(String(elementData.x).replace(/x/g, canvasWidth).replace(/y/g, canvasHeight)); + const y = eval(String(elementData.y).replace(/x/g, canvasWidth).replace(/y/g, canvasHeight)); + const width = eval(String(elementData.width).replace(/x/g, canvasWidth).replace(/y/g, canvasHeight)); + const height = eval(String(elementData.height).replace(/x/g, canvasWidth).replace(/y/g, canvasHeight)); + const rotation = parseFloat(elementData.rotation || 0); + + // Prepare data object for CollageElement constructor + let data = {}; + + switch (elementData.type) { + case 'image': + // Assign a demo image to the placeholder for display in the designer + // We cycle through loadedImagesArray for each image element found in the JSON + const demoImageIndex = imagePlaceholderCount % loadedImagesArray.length; + const imgElement = loadedImagesArray[demoImageIndex]; + + data = { + image: imgElement, // Assign the loaded demo image + src: elementData.src || null, // Original source from JSON (can be null) + originalLayoutDataIndex: imagePlaceholderCount, // Use this for consistent demo image assignment + show_frame: elementData.show_frame || false // Using show_frame from new JSON + }; + imagePlaceholderCount++; // Increment for the next image element + break; + + case 'text': + data = { + content: elementData.content || '', + font_family: elementData.font_family || 'Arial', + font_color: elementData.font_color || '#000000', + font_size: elementData.font_size !== undefined ? parseFloat(elementData.font_size) : 2, // Ensure number + text_horizontal_align: elementData.text_horizontal_align || 'center', + text_vertical_align: elementData.text_vertical_align || 'center' + }; + break; + + // Add cases for other types (e.g., 'shape', 'background') if they also exist in your JSON + default: + console.warn(`Attempted to load unsupported element type from JSON: ${elementData.type}`); + return; // Skip unsupported elements + } + + const element = new CollageElement( + elementData.id || `element-${elementData.type}-${Date.now()}-${Math.floor(Math.random() * 1000)}`, // Ensure ID is present or generated + x, y, width, height, rotation, + elementData.type, + data + ); + window.collageElements.push(element); + }); + } + + /** + * Calculates the bounding box for all currently selected elements. + * + * @returns {{x: number, y: number, width: number, height: number}|null} The bounding box or null if no elements are selected. + */ + window.getSelectionBoundingBox = function() { + const selectedElements = window.collageElements.filter(el => el.isSelected); + if (selectedElements.length === 0) { + return null; + } + + let minX = Infinity, minY = Infinity; + let maxX = -Infinity, maxY = -Infinity; + + selectedElements.forEach(el => { + minX = Math.min(minX, el.x); + minY = Math.min(minY, el.y); + maxX = Math.max(maxX, el.x + el.width); + maxY = Math.max(maxY, el.y + el.height); + }); + + return { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY + }; + } + + + //================================================================================= + // --- Img functions --- + //================================================================================= + + /** + * Global Function to fetch Demo Image URLs + * + * @param {int} count + * @returns imgURLs Array of image URLs + */ + window.fetchDemoImageUrls = async function(count = 1) { + try { + const response = await fetch(`../../api/demo-images.php?count=${count}`); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const imageUrls = await response.json(); + if (!Array.isArray(imageUrls) || imageUrls.length === 0) { + console.warn('fetchDemoImageUrls: Received empty or invalid image URLs from backend, using fallback.'); + return [window.phpFallbackImageUrl]; + } + return imageUrls; + } catch (error) { + console.error('Failed to fetch demo images:', error); + // Return a generic fallback URL in case of error + return [window.phpFallbackImageUrl]; + } + }; + + /** + * Prepares a rotated image on a temporary canvas and scales/crops it to fit target dimensions + * + * @param {img} backgroundImg + * @param {int} degrees + * @param {int} targetWidth + * @param {int} targetHeight + * @returns + */ + window.prepareRotatedImage = function(backgroundImg, degrees, targetWidth, targetHeight) { + const tempCanvas = document.createElement('canvas'); + const tempCtx = tempCanvas.getContext('2d'); + const canvasRotationDegrees = -degrees; + const imgWidth = backgroundImg.width; + const imgHeight = backgroundImg.height; + const absCos = Math.abs(Math.cos(canvasRotationDegrees * Math.PI / 180)); + const absSin = Math.abs(Math.sin(canvasRotationDegrees * Math.PI / 180)); + const rotatedBoundingWidth = imgWidth * absCos + imgHeight * absSin; + const rotatedBoundingHeight = imgWidth * absSin + imgHeight * absCos; + tempCanvas.width = rotatedBoundingWidth; + tempCanvas.height = rotatedBoundingHeight; + tempCtx.save(); + tempCtx.translate(rotatedBoundingWidth / 2, rotatedBoundingHeight / 2); + tempCtx.rotate(canvasRotationDegrees * Math.PI / 180); + tempCtx.drawImage(backgroundImg, -imgWidth / 2, -imgHeight / 2, imgWidth, imgHeight); + tempCtx.restore(); + + const finalCanvas = document.createElement('canvas'); + const finalCtx = finalCanvas.getContext('2d'); + finalCanvas.width = targetWidth; + finalCanvas.height = targetHeight; + const rotatedImgAspectRatio = tempCanvas.width / tempCanvas.height; + const targetAspectRatio = targetWidth / targetHeight; + let drawX, drawY, drawWidth, drawHeight; + if (rotatedImgAspectRatio > targetAspectRatio) { + drawWidth = targetWidth; + drawHeight = targetWidth / rotatedImgAspectRatio; + } else { + drawHeight = targetHeight; + drawWidth = targetHeight * rotatedImgAspectRatio; + } + drawX = (targetWidth - drawWidth) / 2; + drawY = (targetHeight - drawHeight) / 2; + finalCtx.drawImage(tempCanvas, 0, 0, tempCanvas.width, tempCanvas.height, drawX, drawY, drawWidth, drawHeight); + return finalCanvas; + } + + /** + * Function to combine two images into one canvas. + * @param {img} backgroundImg + * @param {img} frontImg + * @param {int} width + * @param {int} height + * @returns + */ + window.combineImages = function(backgroundImg, frontImg, width, height) { + const tempCanvas = document.createElement('canvas'); + const tempCtx = tempCanvas.getContext('2d'); + + tempCanvas.width = width; + tempCanvas.height = height; + + // 1. draw first image (full size) + // This must use the same scaling and cropping logic as in the `else` branch of your `drawCanvas` + // for unrotated images, so that the image fits correctly within the frame. + const imgAspectRatio = backgroundImg.width / backgroundImg.height; + const boxAspectRatio = width / height; // Box is the tempCanvas + let sx, sy, sWidth, sHeight; // Source in original image + + if (imgAspectRatio > boxAspectRatio) { + sHeight = backgroundImg.height; + sWidth = sHeight * boxAspectRatio; + sx = (backgroundImg.width - sWidth) / 2; + sy = 0; + } else { + sWidth = backgroundImg.width; + sHeight = sWidth / boxAspectRatio; + sx = 0; + sy = (backgroundImg.height - sHeight) / 2; + } + tempCtx.drawImage(backgroundImg, sx, sy, sWidth, sHeight, 0, 0, width, height); + + // 2. draw second image over the first image + if (frontImg && frontImg.complete) { + tempCtx.drawImage(frontImg, 0, 0, width, height); + } + return tempCanvas; + } + + window.setupCanvasDimensions = function() { + const { width, height, aspect_ratio } = window.currentLayout; + if (!width || !height || !aspect_ratio) { + console.warn('Layout missing width, height, or aspect_ratio. Using default 3:2.'); + window.collageCanvasWrapper.style.aspectRatio = `3 / 2`; + window.collageCanvas.width = 900; + window.collageCanvas.height = 600; + return; + } + window.collageCanvas.width = parseInt(width, 10); + window.collageCanvas.height = parseInt(height, 10); + window.collageCanvasWrapper.style.aspectRatio = aspect_ratio.replace(':', ' / '); + } + + /** + * Loads the global frame image based on the configuration. + * + * @returns {Image|null} The loaded Image object or null if not defined or failed to load. + */ + window.loadFrameImg = function() { + const frameId = 'global_frame'; + + // 'config'-object is globally available, because api/settings.php is loaded via a '; +?> diff --git a/admin/collage-designer/includes/CollageManager.php b/admin/collage-designer/includes/CollageManager.php new file mode 100644 index 000000000..9955c0144 --- /dev/null +++ b/admin/collage-designer/includes/CollageManager.php @@ -0,0 +1,98 @@ +designsPath = PathUtility::getAbsolutePath('private/collages/'); + $this->indexFile = $this->designsPath . 'designs_index.json'; + $this->ensureDesignsDirectoryExists(); + } + + private function ensureDesignsDirectoryExists(): void + { + if (!is_dir($this->designsPath)) { + mkdir($this->designsPath, 0777, true); // Erstelle Verzeichnis, wenn nicht vorhanden + } + if (!file_exists($this->indexFile)) { + file_put_contents($this->indexFile, json_encode([])); // Leere Index-Datei erstellen + } + } + + public function getAvailableDesigns(): array + { + if (!file_exists($this->indexFile)) { + return []; + } + $content = file_get_contents($this->indexFile); + return json_decode($content, true) ?: []; + } + + public function loadDesign(string $filename): ?array + { + $filePath = $this->designsPath . $filename; + if (file_exists($filePath)) { + return json_decode(file_get_contents($filePath), true); + } + return null; + } + + public function saveDesign(string $name, array $data, ?string $originalFilename = null): string + { + $designs = $this->getAvailableDesigns(); + $filename = $originalFilename ?: $this->generateUniqueFilename($name); + $filePath = $this->designsPath . $filename; + + file_put_contents($filePath, json_encode($data, JSON_PRETTY_PRINT)); + + // Update index + $found = false; + foreach ($designs as &$design) { + if ($design['filename'] === $filename) { + $design['name'] = $name; + $found = true; + break; + } + } + if (!$found) { + $designs[] = ['name' => $name, 'filename' => $filename]; + } + file_put_contents($this->indexFile, json_encode($designs, JSON_PRETTY_PRINT)); + + return $filename; + } + + public function deleteDesign(string $filename): bool + { + $filePath = $this->designsPath . $filename; + if (file_exists($filePath)) { + unlink($filePath); + + // Update index + $designs = $this->getAvailableDesigns(); + $designs = array_filter($designs, fn ($design) => $design['filename'] !== $filename); + file_put_contents($this->indexFile, json_encode(array_values($designs), JSON_PRETTY_PRINT)); // array_values um Indizes zurückzusetzen + + return true; + } + return false; + } + + private function generateUniqueFilename(string $name): string + { + $base = strtolower(preg_replace('/[^a-z0-9]/', '-', $name)); + $filename = $base . '.json'; + $counter = 1; + while (file_exists($this->designsPath . $filename)) { + $filename = $base . '-' . $counter++ . '.json'; + } + return $filename; + } +} diff --git a/admin/collage-designer/index.php b/admin/collage-designer/index.php new file mode 100644 index 000000000..f5908e6d7 --- /dev/null +++ b/admin/collage-designer/index.php @@ -0,0 +1,167 @@ +getDefaultConfiguration(); + +// Initial loading of a collage layout or empty design +$currentLayout = $config['collage']['layout']; // künftig direkt in js verfübar durch skript siehe unten (footer.scripts.php) +//TEST: +$currentLayout = 'private/collage/landscape/1+2-1'; +// +$currentLayoutData = null; +if ($currentLayout) { + $currentLayoutData = CollageLayoutScanner::getLayoutData($currentLayout); +} else { + // Fallback: load a standard layout or an empty layout, if none is configured + $currentLayoutData = CollageLayoutScanner::getLayoutData('private/collages/1+3-1'); +} +// convert JSON to JavaScript-Variable +echo ''; + +// ============================================================= +// Standard Admin Panel Head & Body +// ============================================================= +$pageTitle = 'Collage Designer - ' . ApplicationService::getInstance()->getTitle(); +include PathUtility::getAbsolutePath('admin/components/head.admin.php'); +include PathUtility::getAbsolutePath('admin/helper/index.php'); // Contains e.g. getMenuBtn + +?> + +
+
+
+ +
+
+ +
+
+ translate('collage_designer_title') ?> +
+
+ + +
+
+ +
+ + +
+ +
+
+ + translate('element_settings_panel') ?> + +
+
+ +
+ + + +
+ + +
+ +
+ + +
+ + + + +
+ +
+ +
+ + +
+ +
+ +
+ + +
+
+
+
+ +
+
+
+
+ +getUrl('admin/collage-designer/assets/js/collage-designer-utils.js') . '">'; // Utility JS +echo ''; // History management JS +echo ''; // Mouse events handling JS +// Specific JS files for different panels and tools +echo ''; // Main JS +echo ''; // Tools JS +echo ''; // Element Settings Panel JS +echo ''; // Image Settings Panel JS +echo ''; // Text Settings Panel JS +echo ''; // General Settings JS + +// Optional: Specific toasts/messages depending on PHP processing +if (isset($_SESSION['designer_message'])) { + echo ''; + unset($_SESSION['designer_message']); +} +include PathUtility::getAbsolutePath('admin/components/footer.admin.php'); +?> diff --git a/admin/generator/index.php b/admin/generator/index.php index c8689560f..e69de29bb 100644 --- a/admin/generator/index.php +++ b/admin/generator/index.php @@ -1,770 +0,0 @@ -getTitle(); -include PathUtility::getAbsolutePath('admin/components/head.admin.php'); -include PathUtility::getAbsolutePath('admin/helper/index.php'); - -$collageConfigFilePath = PathUtility::getAbsolutePath('private/collage.json'); -$collageJson = ''; -$permitSubmit = true; -$enableWriteMessage = ''; -$startPreloaded = false; -if (file_exists($collageConfigFilePath)) { - $collageJson = json_decode((string)file_get_contents($collageConfigFilePath), true); - if (!is_writable($collageConfigFilePath)) { - $permitSubmit = false; - $enableWriteMessage = $languageService->translate('collage:generator:please_enable_write'); - } -} - -$demoImages = ImageUtility::getDemoImages(8); - -$newConfiguration = ''; -if (isset($_POST['new-configuration'])) { - $newConfiguration = $_POST['new-configuration']; - $newConfig = $config; - - $fp = fopen($collageConfigFilePath, 'w'); - if ($fp) { - fwrite($fp, $newConfiguration); - fclose($fp); - if ($config['collage']['layout'] === 'collage.json') { - $collageJson = json_decode($newConfiguration); - $startPreloaded = true; - $arrayCollageJson = (array) $collageJson; - - if (array_key_exists('layout', $arrayCollageJson)) { - $newConfig['collage']['limit'] = count($arrayCollageJson['layout']); - } else { - $newConfig['collage']['limit'] = count($arrayCollageJson); - } - if (array_key_exists('placeholder', $arrayCollageJson)) { - $newConfig['collage']['placeholder'] = $arrayCollageJson['placeholder']; - } - if (array_key_exists('placeholderposition', $arrayCollageJson)) { - $newConfig['collage']['placeholderposition'] = $arrayCollageJson['placeholderposition']; - } - if (array_key_exists('placeholderpath', $arrayCollageJson)) { - $newConfig['collage']['placeholderpath'] = $arrayCollageJson['placeholderpath']; - } - // If there is a collage placeholder whithin the correct range (0 < placeholderposition <= collage limit), we need to decrease the collage limit by 1 - if ($newConfig['collage']['placeholder']) { - $collagePlaceholderPosition = (int) $newConfig['collage']['placeholderposition']; - if ($collagePlaceholderPosition > 0 && $collagePlaceholderPosition <= $newConfig['collage']['limit']) { - $newConfig['collage']['limit'] = $newConfig['collage']['limit'] - 1; - } else { - $newConfig['collage']['placeholder'] = false; - $warning = true; - } - } - try { - $configurationService->update($newConfig); - } catch (\Exception $exception) { - $warning = true; - } - } - } else { - $error = true; - } - - $success = !($error || $warning); -} - -$font_paths = [ - PathUtility::getAbsolutePath('resources/fonts'), - PathUtility::getAbsolutePath('private/fonts') -]; - -$font_family_options = []; - -$font_styles = ''; - -?> - -
- - - -
-
-
- Collage Layout Generator -
-
-
-
- - - - - - - -
-
- -
-
- -
-
- - translate('general') ?> - -
-
-
- 'background_color', - 'value' => '#FFFFFF', - 'placeholder' => 'background color', - 'attributes' => ['data-trigger' => 'general'] - ], - 'collage:collage_background_color' - ) -?> -
-
- 'generator-background', - 'value' => '', - 'paths' => [ - PathUtility::getAbsolutePath('resources/img/background'), - PathUtility::getAbsolutePath('private/images/background'), - ], - 'attributes' => ['data-trigger' => 'general'] - ], - 'collage:collage_background' - ) -?> -
-
- 'generator-frame', - 'value' => '', - 'paths' => [ - PathUtility::getAbsolutePath('resources/img/frames'), - PathUtility::getAbsolutePath('private/images/frames'), - ], - 'attributes' => ['data-trigger' => 'general'] - ], - 'collage:collage_frame' - ) -?> -
-
- 'number', - 'name' => 'final_width', - 'value' => '1500', - 'placeholder' => 'collage width', - 'attributes' => ['data-trigger' => 'general'] - ], - 'collage:generator:final_width' - ) -?> -
-
- 'number', - 'name' => 'final_height', - 'value' => '1000', - 'placeholder' => 'collage height', - 'attributes' => ['data-trigger' => 'general'] - ], - 'collage:generator:final_height' - ) -?> -
-
- 'select', - 'name' => 'apply_frame', - 'options' => [ - 'off' => 'Off', - 'always' => 'Always', - 'once' => 'Once', - ], - 'value' => 'once', - 'attributes' => ['data-trigger' => 'general'] - ], - 'collage:collage_take_frame' - ) -?> -
-
- 'show-background', - 'value' => 'false', - 'attributes' => ['data-trigger' => 'general'] - ], - 'collage:generator:show_background' - ) -?> -
-
- 'show-frame', - 'value' => 'false', - 'attributes' => ['data-trigger' => 'general'] - ], - 'collage:generator:show_frame' - ) -?> -
-
-
- - translate('collage:generator:placeholder_settings') ?> - -
-
-
- 'enable_placeholder_image', - 'value' => 'false', - 'attributes' => ['data-trigger' => 'general'] - ], - 'collage:collage_placeholder' - ) -?> -
-
- 'number', - 'name' => 'placeholder_image_position', - 'value' => '1', - 'placeholder' => 'placehoder image position', - 'attributes' => [ - 'min' => '1', - 'max' => '8', - 'data-trigger' => 'general' - ] - ], - 'collage:collage_placeholderposition' - ) -?> -
-
- 'placeholder_image', - 'value' => '', - 'paths' => [ - PathUtility::getAbsolutePath('resources/img/demo'), - PathUtility::getAbsolutePath('private/images/placeholder'), - ], - 'attributes' => ['data-trigger' => 'general'] - ], - 'choose_placeholder' - ) -?> -
-
-
- - translate('text_settings') ?> - -
-
-
- 'text_enabled', - 'value' => 'false', - 'attributes' => ['data-trigger' => 'general'] - ], - 'collage:textoncollage_enabled' - ) -?> -
-
- 'text_font_family', - 'value' => '', - 'paths' => [ - PathUtility::getAbsolutePath('resources/fonts'), - PathUtility::getAbsolutePath('private/fonts'), - ], - 'attributes' => ['data-trigger' => 'general'] - ], - 'collage:textoncollage_font' - ) -?> -
-
- 'text_font_color', - 'value' => '#000000', - 'placeholder' => 'text font color', - 'attributes' => ['data-trigger' => 'general'] - ], - 'collage:textoncollage_font_color' -) -?> -
-
- 'number', - 'name' => 'text_font_size', - 'value' => '50', - 'placeholder' => 'text font size', - 'attributes' => ['data-trigger' => 'general'] - ], - 'collage:textoncollage_font_size' - ) -?> -
-
- 'text', - 'name' => 'text_line_1', - 'value' => 'Photobooth', - 'placeholder' => 'text line 1', - 'attributes' => ['data-trigger' => 'general'] - ], - 'collage:textoncollage_line1' - ) -?> -
-
- 'text', - 'name' => 'text_line_2', - 'value' => 'we love', - 'placeholder' => 'text line 2', - 'attributes' => ['data-trigger' => 'general'] - ], - 'collage:textoncollage_line2' - ) -?> -
-
- 'text', - 'name' => 'text_line_3', - 'value' => 'OpenSource', - 'placeholder' => 'text line 3', - 'attributes' => ['data-trigger' => 'general'] - ], - 'collage:textoncollage_line3' - ) -?> -
-
- 'number', - 'name' => 'text_line_space', - 'value' => '90', - 'placeholder' => 'text line space', - 'attributes' => ['data-trigger' => 'general'] - ], - 'collage:textoncollage_linespace' - ) -?> -
-
- 'number', - 'name' => 'text_location_x', - 'value' => '1470', - 'placeholder' => 'text location x', - 'attributes' => ['data-trigger' => 'general'] - ], - 'collage:textoncollage_locationx' - ) -?> -
-
- 'number', - 'name' => 'text_location_y', - 'value' => '250', - 'placeholder' => 'text location y', - 'attributes' => ['data-trigger' => 'general'] - ], - 'collage:textoncollage_locationy' - ) -?> -
-
- 'number', - 'name' => 'text_rotation', - 'value' => '0', - 'unit' => 'degrees', - 'range_min' => '-180', - 'range_max' => '180', - 'range_step' => '5', - 'placeholder' => 'degrees', - 'attributes' => ['data-trigger' => 'general'] - ], - 'collage:textoncollage_rotation' - ) -?> -
-
-
-
-
-
-
- -
- -
- 'text', - 'name' => 'picture-x-position-' . $i, - 'value' => rand(100, 500), - 'placeholder' => 'x position', - 'attributes' => ['data-prop' => 'left', 'data-trigger' => 'image'] - ], - 'collage:generator:x_position' - ) - ?> -
-
- 'text', - 'name' => 'picture-y-position-' . $i, - 'value' => rand(100, 500), - 'placeholder' => 'y position', - 'attributes' => ['data-prop' => 'top', 'data-trigger' => 'image'] - ], - 'collage:generator:y_position' - ) - ?> -
-
- 'text', - 'name' => 'picture-width-' . $i, - 'value' => 'x*0.5', - 'placeholder' => $languageService->translate('image_width'), - 'attributes' => ['data-prop' => 'width', 'data-trigger' => 'image'] - ], - 'collage:generator:image_width' - ) - ?> -
-
- 'text', - 'name' => 'picture-height-' . $i, - 'value' => 'y*0.5', - 'placeholder' => $languageService->translate('image_height'), - 'attributes' => ['data-prop' => 'height', 'data-trigger' => 'image'] - ], - 'collage:generator:image_height' - ) - ?> -
-
- 'number', - 'name' => 'picture-rotation-' . $i, - 'value' => '0', - 'unit' => 'degrees', - 'range_min' => '-180', - 'range_max' => '180', - 'range_step' => '1', - 'placeholder' => 'degrees', - 'attributes' => ['data-prop' => 'transform', 'data-trigger' => 'image'] - ], - 'collage:generator:image_rotation' - ) - ?> -
-
- 'picture-show-frame-' . $i, - 'value' => 'false', - 'attributes' => ['data-prop' => 'single_frame', 'data-trigger' => 'image'] - ], - 'collage:generator:show_single_frame' - ) - ?> -
-
- -
-
- -
-
-
-
-
-
- -
- - - -
"; -} -?> -
- -
-
-
-
-
-
-
-
-
-
-
- - -
-
-
-
- -
-
-
- -getUrl('resources/js/admin/generator.js') . '">'; - -if ($success) { - echo ''; -} -if ($error !== false) { - echo ''; -} -if ($warning) { - echo ''; -} - -include PathUtility::getAbsolutePath('admin/components/footer.admin.php'); - -?> diff --git a/admin/helper/backBtn.php b/admin/helper/backBtn.php new file mode 100644 index 000000000..0c86b8009 --- /dev/null +++ b/admin/helper/backBtn.php @@ -0,0 +1,37 @@ +'; + $targetAttribute = $newTab ? '_blank' : '_self'; + + return ' + + ' . $iconElement . ' + ' . $languageService->translate($label) . ' + + '; +} diff --git a/admin/helper/index.php b/admin/helper/index.php index fdde65a8d..0f9a31ff6 100644 --- a/admin/helper/index.php +++ b/admin/helper/index.php @@ -6,3 +6,4 @@ include PathUtility::getAbsolutePath('admin/helper/hiddenElement.php'); include PathUtility::getAbsolutePath('admin/helper/menuBtn.php'); include PathUtility::getAbsolutePath('admin/helper/toast.php'); +include PathUtility::getAbsolutePath('admin/helper/backBtn.php'); diff --git a/api/admin.php b/api/admin.php index 798eba21a..5b4bc0936 100644 --- a/api/admin.php +++ b/api/admin.php @@ -279,6 +279,7 @@ } // Collage json config + // TODO: check still working after merge? $newConfig['collage']['limit'] = $newConfig['collage']['limit'] ?? $defaultConfig['collage']['limit']; if ($newConfig['collage']['enabled']) { $limitData = Collage::calculateLimit($newConfig['collage'], $logger); diff --git a/api/demo-images.php b/api/demo-images.php new file mode 100644 index 000000000..58e8e2d62 --- /dev/null +++ b/api/demo-images.php @@ -0,0 +1,30 @@ + PathUtility::getPublicPath($img), $demoImages); + +// Return image paths as JSON +echo json_encode($publicImagePaths); + +// Terminate script execution to ensure nothing extra is outputted +exit; diff --git a/assets/js/admin/buttons.js b/assets/js/admin/buttons.js index b8b608aee..75ee320c2 100644 --- a/assets/js/admin/buttons.js +++ b/assets/js/admin/buttons.js @@ -1,5 +1,112 @@ /* eslint n/no-unsupported-features/node-builtins: "off" */ /* globals photoboothTools shellCommand csrf */ + +/** + * Saves the admin settings via the API. + * Displays a loader during the saving process. + * + * @param {object} [options] - Configuration options for the save operation. + * @param {boolean} [options.reloadOnSuccess=false] - If true, reloads the page on successful save. + * @param {boolean} [options.reloadOnError=true] - If true, reloads the page on save failure. + * @returns {Promise} A Promise that resolves with the API response data on success, + * or rejects with an Error object on failure. + */ +function saveAdminSettings(options = {}) { + if (!hasPendingAdminChanges()) { + photoboothTools.console.logDev('No changes detected in admin settings. Save operation skipped.'); + return Promise.resolve({ skipped: true }); + } + + const defaultOptions = { + reloadOnSuccess: true, + reloadOnError: false + }; + const currentOptions = { ...defaultOptions, ...options }; // Merge default with provided options + + // Show loader + $('.pageLoader').addClass('isActive'); + $('.pageLoader').find('label').html(photoboothTools.getTranslation('saving')); + + const data = new FormData(document.querySelector('form')); + data.append('type', 'config'); + if (typeof csrf !== 'undefined') { + data.append(csrf.key, csrf.token); + } + + return fetch('../api/admin.php', { + method: 'POST', + body: data + }) + .then((response) => { + // Hide loader after the fetch request completes, regardless of success or failure + $('.pageLoader').removeClass('isActive'); + + if (!response.ok) { + // If the HTTP response is not OK (e.g., 404, 500), throw an error + return response + .json() + .then((errorData) => { + const errorMessage = errorData.message || `HTTP error! status: ${response.status}`; + photoboothTools.console.logDev(errorMessage); + throw new Error(errorMessage); + }) + .catch(() => { + // Handle cases where response is not JSON or parsing fails + const errorMessage = `HTTP error! status: ${response.status}`; + photoboothTools.console.logDev(errorMessage); + throw new Error(errorMessage); + }); + } + return response.json(); // Parse JSON from the response + }) + .then((responseData) => { + // Process the JSON response data + if (responseData.status === 'success') { + // After successful save, if the form was dirty, reset it to clean state. + $('#save-admin-btn').removeClass('isDirty'); + // Also, update the initial serialized state to the newly saved state + // to correctly detect future changes without a full page reload. + $('form').data('initialSerialized', $('form').serialize()); + if (currentOptions.reloadOnSuccess) { + window.location.reload(); + // We return a pending Promise here, as reload will prevent subsequent .then() from running + // eslint-disable-next-line no-empty-function + return new Promise(() => {}); + } + return responseData; // Saving successful, resolve with response data + } else { + // API returned a non-success status, but HTTP fetch was successful + const errorMessage = responseData.message || 'Saving failed with API error'; + photoboothTools.console.logDev(errorMessage); + throw new Error(errorMessage); // Reject with a specific error + } + }) + .catch((error) => { + // Catch any errors during the fetch, JSON parsing, or from API non-success status + photoboothTools.console.logDev('Error during admin settings save:', error); + + // Ensure loader is hidden in case of unexpected errors (already done above, but good safeguard) + $('.pageLoader').removeClass('isActive'); + + if (currentOptions.reloadOnError) { + window.location.reload(); + // We return a pending Promise here, as reload will prevent subsequent .catch() from running + // eslint-disable-next-line no-empty-function + return new Promise(() => {}); + } + throw error; // Re-throw the error to be caught by the calling handlers + }); +} + +/** + * Checks if the admin settings form has pending changes that need to be saved. + * Relies on the 'isDirty' class being added to the #save-admin-btn by the form change listener. + * + * @returns {boolean} True if there are unsaved changes (i.e., the save button has 'isDirty' class), false otherwise. + */ +function hasPendingAdminChanges() { + return $('#save-admin-btn').hasClass('isDirty'); +} $(function () { // Highlight save button on form changes const $saveButton = $('#save-admin-btn'); @@ -56,31 +163,17 @@ $(function () { $('#save-admin-btn').on('click', function (e) { e.preventDefault(); - // show loader - $('.pageLoader').addClass('isActive'); - $('.pageLoader').find('label').html(photoboothTools.getTranslation('saving')); - - const data = new FormData(document.querySelector('form')); - data.append('type', 'config'); - if (typeof csrf !== 'undefined') { - data.append(csrf.key, csrf.token); - } - - fetch('../api/admin.php', { - method: 'POST', - body: data - }) - .then((response) => response.json()) - .then((data) => { - if (data.status === 'success') { - window.location.reload(); - } else { - photoboothTools.console.logDev(data.message); - window.location.reload(); - } + // The admin save button should always reload the page on success or failure + saveAdminSettings({ reloadOnSuccess: true, reloadOnError: true }) + .then(() => { + // This block will theoretically not be reached due to reloadOnSuccess, + // but is kept for structural completeness if options change. + console.log('Admin settings saved successfully via button (page reloaded).'); }) .catch((error) => { - photoboothTools.console.logDev('Error:', error); + // This block will theoretically not be reached due to reloadOnError, + // but is kept for structural completeness if options change. + console.error('Failed to save admin settings via button (page reloaded):', error); }); }); @@ -90,9 +183,28 @@ $(function () { return false; }); - $('#layout-generator').on('click', function (ev) { + $('#collage-designer').on('click', function (ev) { ev.preventDefault(); - window.open('../admin/generator'); + + saveAdminSettings({ reloadOnSuccess: false, reloadOnError: false }) // No reload here + .then(() => { + // Saving successful: Navigate to the Collage Designer + photoboothTools.console.logDev('Admin settings saved successfully, navigating to Collage Designer.'); + const designerUrl = '../admin/collage-designer/'; + const currentHash = window.location.hash ? window.location.hash.substring(1) : ''; + let targetUrl = designerUrl; + if (currentHash) { + targetUrl += '?from=' + currentHash; + } + window.location.href = targetUrl; + }) + .catch((error) => { + // Saving failed: Handle error (e.g., display a toast, do not navigate) + console.error('Failed to save admin settings before navigating to Collage Designer:', error); + photoboothTools.console.logDev('Saving failed, not navigating to Collage Designer.', error); + // Optional: photoboothTools.openToast(photoboothTools.getTranslation('saving_failed_before_designer'), 'error', 5000); + // We do not navigate to the designer if saving fails. + }); return false; }); diff --git a/lib/configsetup.inc.php b/lib/configsetup.inc.php index 3fc36d8f0..703c8c4b8 100644 --- a/lib/configsetup.inc.php +++ b/lib/configsetup.inc.php @@ -1,6 +1,5 @@ 'select', 'name' => 'collage[layout]', 'data-theme-field' => 'true', - 'placeholder' => $defaultConfig['collage']['layout'], - 'options' => CollageLayoutEnum::cases(), - 'value' => $config['collage']['layout'], + 'options_html' => CollageLayoutScanner::getLayoutSelectOptionsHtml($config['collage']['layout'] ?? $defaultConfig['collage']['layout']), ], 'collage_allow_selection' => [ 'view' => 'advanced', @@ -1017,12 +1015,12 @@ ], 'value' => $config['collage']['orientation'], ], - 'layout_generator' => [ + 'collage_designer' => [ 'view' => 'expert', 'type' => 'button', - 'placeholder' => 'layout_generator', - 'name' => 'LAYOUTGENERATOR', - 'value' => 'layout-generator', + 'placeholder' => 'collage-designer', + 'name' => 'COLLAGEDESIGNER', + 'value' => 'collage-designer', ], 'collage_dashedline_color' => [ 'view' => 'advanced', diff --git a/resources/lang/de.json b/resources/lang/de.json index d3a6b54ad..99941dc04 100644 --- a/resources/lang/de.json +++ b/resources/lang/de.json @@ -39,6 +39,7 @@ "choose_font": "Schriftart auswählen", "choose_frame": "Rahmen auswählen", "choose_image": "Bild auswählen", + "choose_layouts": "Layouts auswählen", "choose_placeholder": "Platzhalter auswählen", "choose_video": "Video auswählen", "chroma_needs_background": "Bitte wählen Sie zuerst einen Hintergrund!", @@ -49,6 +50,9 @@ "click_element": "Klick", "close": "Schließen", "collage": "Collage", + "collage-designer": "Collage-Designer öffnen", + "collage-designer:lock_aspect_ratio": "Seitenverhältnis sperren", + "collage-designer:show_single_frame": "Einzelnen Rahmen anzeigen", "collage:collage_allow_selection": "Layout-Auswahl erlauben", "collage:collage_background": "Hintergrund", "collage:collage_background_color": "Hintergrundfarbe der Collage", @@ -56,6 +60,7 @@ "collage:collage_continuous": "Collage ohne Unterbrechung aufnehmen", "collage:collage_continuous_time": "Einzelne Bilder bei Aufnahme einer Collage ohne Unterbrechung anzeigen für:", "collage:collage_dashedline_color": "Farbe der Trennlinie beim 2x4 Collage-Layout", + "collage:collage_designer": "Collage-Designer", "collage:collage_enabled": "Foto-Collage erlauben", "collage:collage_frame": "Rahmen", "collage:collage_keep_single_images": "Einzelbilder der Collage zur Galerie hinzufügen", @@ -104,6 +109,11 @@ "collage:textoncollage_locationy": "Y-Koordinate", "collage:textoncollage_rotation": "Textdrehung", "collageTest": "Foto-Collage testen", + "collage_choose_new_design": "Neues Design wählen", + "collage_designer_title": "Collage-Designer", + "collage_load": "Laden", + "collage_name_placeholder": "Design-Name", + "collage_select_min_two_layouts": "Wähle mindestens zwei Layouts, um die Auswahl zu aktivieren.", "commands": "Befehle", "commands:exiftool_cmd": "EXIFtool-Befehl", "commands:nodebin_cmd": "Dateipfad für node.js", @@ -119,6 +129,7 @@ "commands:take_collage_cmd": "Befehl um ein Collage-Bild aufzunehmen", "commands:take_picture_cmd": "Befehl zur Bildaufnahme", "commands:take_video_cmd": "Befehl zur Videoaufnahme", + "community_layouts": "Community-Layouts", "confirm": "bestätigen", "current_version": "Installiert:", "currentPhpVersion": "Aktuelle PHP-Version:", @@ -130,6 +141,8 @@ "custom:get_request_custom": "GET-Anfrage bei Countdown für die benutzerdefinierte Aktion", "custom:icons_take_custom": "Icon für benutzerdefinierte Aktion", "custom:take_custom_cmd": "Befehl zum Auslösen über die benutzerdefinierte Aktion", + "custom_aspect_ratio": "Benutzerdefiniertes Seitenverhältnis", + "custom_layouts": "Eigene Layouts", "database_rebuild": "Neuaufbau", "debugpanel": "Debug Panel", "debugpanel:autorefresh": "Automatisch aktualisieren", @@ -304,6 +317,7 @@ "healthStatus": "Allgemeinzustand", "hideFrame": "Rahmen verbergen", "home": "Startseite", + "image": "Bild", "image_height": "Bildhöhe", "image_rotation": "Bilddrehung", "image_width": "Bildbreite", @@ -323,7 +337,9 @@ "keying:keying_size": "Größe für Chroma-Keying", "keying:keying_variant": "Keying-Algorithmus", "keyingerror": "Chroma-Keying nicht möglich!", + "landscape": "Landscape", "layout_generator": "Collage Layout-Generator öffnen", + "load": "Laden", "login_invalid": "Benutzername oder Passwort ungültig!", "login_password": "Passwort", "login_pin_request": "Bitte geben Sie Ihre PIN ein.", @@ -362,6 +378,7 @@ "mailError": "Fehler beim Senden der E-Mail", "mailSaved": "E-Mail-Adresse erfolgreich gespeichert", "mailSent": "E-Mail gesendet", + "manage_collage_designs": "Collage-Designs verwalten", "manual:authentication:login_enabled": "Wenn diese Option aktiviert ist, werden Benutzername und Passwort benötigt, um auf die Administrationsseite und/oder den Startbildschirm zuzugreifen (abhängig von Ihren Einstellungen).", "manual:authentication:login_keypad": "PIN für numerischen Login. (0-9) muss aus 4 Ziffern bestehen, andernfalls wird der numerische Login nicht aktiviert.", "manual:authentication:login_password": "Geben Sie Ihr Passwort für die Anmeldung ein. Bitte beachten Sie: Nach dem Speichern wird Ihr Passwort nur als Hash im Admin-Panel sichtbar. Um sich einzuloggen, geben Sie das Passwort ein, das Sie gesetzt haben. Geben Sie nicht den Hash ein.", @@ -902,7 +919,8 @@ "pictures:textonpicture_locationx": "X-Koordinaten", "pictures:textonpicture_locationy": "Y-Koordinaten", "pictures:textonpicture_rotation": "Textdrehung", - "pictureTest": "Bildtest", + "portrait": "Portrait", + "position": "Position", "power": "Power", "power:reboot_button": "Neustart", "power:shutdown_button": "Herunterfahren", @@ -1061,6 +1079,8 @@ "sound:sound_fallback_enabled": "Fallback-Töne aktivieren", "sound:sound_test": "Test", "sound:sound_voice": "Stimme", + "square": "Quadratisch", + "standard_layouts": "Standard-Layouts", "startPreview": "Vorschau starten", "stopPreview": "Vorschau beenden", "success": "Erfolgreich!", diff --git a/resources/lang/en.json b/resources/lang/en.json index 474d84d88..bf936d40d 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -28,6 +28,8 @@ "available_version": "Available:", "back": "Back", "bootconfig": "config.txt", + "Bring Forward": "Bring Forward", + "Bring to Front": "Bring to Front", "busy": "Processing ...", "busyCollage": "Processing collage ...", "busyVideo": "Processing video ...", @@ -47,10 +49,18 @@ "chromaCapture": "Chroma Capture", "chromaInfoBefore": "Please choose a background to take a picture", "chromaPreviewTest": "Chroma preview test", + "Classic_Photo": "Classic photo", "click_element": "Click", "close": "Close", "collage": "Collage", + "collage_choose_new_design": "Choose a new design", + "collage_designer_title": "Collage Designer", + "collage_load": "Load", + "collage_name_placeholder": "Design Name", "collage_select_min_two_layouts": "Choose at least two layouts to enable layout selection.", + "collage-designer": "Open Collage Designer", + "collage-designer:lock_aspect_ratio": "Lock aspect ratio", + "collage-designer:show_single_frame": "Show single frame", "collage:collage_allow_selection": "Allow layout selection", "collage:collage_background": "Background", "collage:collage_background_color": "Collage background color", @@ -58,6 +68,7 @@ "collage:collage_continuous": "Take collage without interruption", "collage:collage_continuous_time": "Show single images on continuous collage for:", "collage:collage_dashedline_color": "Cutting line color on 2x4 Collage layouts", + "collage:collage_designer": "Collage designer", "collage:collage_enabled": "Allow photo collage", "collage:collage_frame": "Frame", "collage:collage_keep_single_images": "Add single images from collage to gallery", @@ -72,27 +83,6 @@ "collage:collage_polaroid_effect": "Polaroid effect", "collage:collage_polaroid_rotation": "Polaroid picture rotation", "collage:collage_take_frame": "Take collage with frame", - "collage:generator:add_image": "Add image", - "collage:generator:configuration_saved": "Configuration saved", - "collage:generator:configuration_saving_error": "Error during configuration saving", - "collage:generator:final_height": "Collage height", - "collage:generator:final_width": "Collage width", - "collage:generator:general_settings": "General settings", - "collage:generator:image_height": "Image height", - "collage:generator:image_rotation": "Image rotation", - "collage:generator:image_width": "Image width", - "collage:generator:load_current_configuration": "Load current configuration", - "collage:generator:placeholder_settings": "Placeholder settings", - "collage:generator:please_enable_write": "In order to save the collage.json file automatically you need to enable the write on that file.", - "collage:generator:portrait": "Portrait", - "collage:generator:rotate_after_creation": "Rotate after creation", - "collage:generator:save_config_manually": "The json is saved but please go to the admin panel and save the configuration", - "collage:generator:show_background": "Show background", - "collage:generator:show_frame": "Show frame", - "collage:generator:show_single_frame": "Toggle frame", - "collage:generator:text_settings": "Text settings", - "collage:generator:x_position": "X position", - "collage:generator:y_position": "Y position", "collage:layout_generator": "Collage layout generator", "collage:textoncollage_enabled": "Text on collage", "collage:textoncollage_font": "Font", @@ -121,10 +111,13 @@ "commands:take_collage_cmd": "Take collage image command", "commands:take_picture_cmd": "Take picture command", "commands:take_video_cmd": "Command to take a video", + "community_layouts": "Community Layouts", "confirm": "confirm", "current_version": "Installed:", "currentPhpVersion": "Current PHP version:", "custom": "Custom", + "custom_aspect_ratio": "Custom aspect ratio", + "custom_layouts": "Custom Layouts", "custom:custom_btn_text": "Custom button text", "custom:custom_cntdwn_time": "Custom action countdown timer:", "custom:custom_enabled": "Allow custom photo action", @@ -260,6 +253,7 @@ "gallery:pswp_tapAction": "Action while tap on Gallery viewport content (excluding buttons)", "gallery:pswp_zoomEl": "Show PhotoSwipe zoom button", "general": "General", + "general_placeholder_settings_title": "Placeholder settings", "general:adminpanel_experimental_settings": "Show Experimental Settings", "general:adminpanel_view": "Admin Options", "general:database_enabled": "Use database for images", @@ -306,6 +300,7 @@ "healthStatus": "Health Status", "hideFrame": "Hide Frames", "home": "Home", + "image": "Image", "image_height": "Image height", "image_rotation": "Image rotation", "image_width": "Image width", @@ -325,7 +320,9 @@ "keying:keying_size": "Chromakeying size", "keying:keying_variant": "Keying algorithm", "keyingerror": "Chroma keying not possible!", + "landscape": "Landscape Layouts", "layout_generator": "Open layout generator", + "load": "Load", "login_invalid": "Username or password is invalid!", "login_password": "Password", "login_pin_request": "Please enter your PIN.", @@ -364,6 +361,7 @@ "mailError": "Error sending e-mail", "mailSaved": "E-mail address saved successfully", "mailSent": "E-mail sent", + "manage_collage_designs": "Manage Collage Designs", "manual:authentication:login_enabled": "If enabled, a username and password will be needed to access the adminpage and/or start screen (depending on your setup).", "manual:authentication:login_keypad": "Pincode for numeric login. (0-9) Must be 4 digits, otherwise numeric login will not be enabled.", "manual:authentication:login_password": "Define your password used for login. Please note: after saving your password will only be visible as a hash inside adminpanel. For login don't enter the hash, enter the password you have set.", @@ -905,10 +903,12 @@ "pictures:textonpicture_locationy": "Y Coordinate", "pictures:textonpicture_rotation": "Text rotation", "pictureTest": "Picture test", + "portrait": "Portrait Layouts", "power": "Power", "power:reboot_button": "Reboot", "power:shutdown_button": "Shutdown", "preview": "Live preview", + "preview_title": "Preview", "preview:preview_asBackground": "Use preview from device cam as background", "preview:preview_camera_mode": "Camera facing mode", "preview:preview_camTakesPic": "Capture screenshot (preview \"from device cam\" only)", @@ -1041,6 +1041,8 @@ "selectCollageLayout": "Select collage layout:", "selectFilter": "Image filter", "send": "Send", + "Send Backward": "Send Backward", + "Send to Back": "Send to Back", "serverprocesses": "Processes", "share": "Share", "shareMessage": "Look at this photo! %s", @@ -1063,6 +1065,10 @@ "sound:sound_fallback_enabled": "Enable fallback sounds", "sound:sound_test": "Test", "sound:sound_voice": "Voice", + "square": "Square Layouts", + "Square": "Square", + "Standard": "Standard", + "standard_layouts": "Standard Layouts", "startPreview": "Start Preview", "stopPreview": "Stop Preview", "success": "Successful!", @@ -1194,6 +1200,10 @@ "video:video_gif": "Video as GIF", "video:video_qr": "Show video QR code", "viewer_photo_title": "Your photo", - "viewer_video_fallback": "Your browser can’t play this video.", - "wait_message": "Please wait..." + "viewer_video_fallback": "Your browser cant play this video.", + "wait_message": "Please wait...", + "Widescreen": "Widescreen", + "width": "Width", + "x_position": "X position", + "y_position": "Y position" } diff --git a/src/Collage.php b/src/Collage.php index 5e1ed5ff0..12f3b4915 100644 --- a/src/Collage.php +++ b/src/Collage.php @@ -9,6 +9,7 @@ use Photobooth\Utility\ImageUtility; use Photobooth\Utility\PathUtility; use Psr\Log\LoggerInterface; +use Photobooth\Utility\CollageLayoutScanner; class Collage { @@ -140,18 +141,20 @@ public static function createCollage(array $config, array $srcImagePaths, string self::$pictureOrientation = $c->collageOrientation; - $collageConfigFilePath = self::getCollageConfigPath($c->collageLayout, self::$pictureOrientation); + $layoutPath = CollageLayoutScanner::getCollageConfigPath($c->collageLayout); //needed? + $collageJson = CollageLayoutScanner::getLayoutData($c->collageLayout); // Save the original admin setting for text on collage $adminTextOnCollageEnabled = $c->textOnCollageEnabled; - if ($collageConfigFilePath !== null) { - $collageJson = json_decode((string)file_get_contents($collageConfigFilePath), true); + if (!empty($collageJson)) { if (is_array($collageJson)) { if (isset($collageJson['layout']) && !empty($collageJson['layout'])) { $layoutConfigArray = $collageJson['layout']; + self::$drawDashedLine = str_starts_with($collageJson['id'], '2x'); + if (isset($collageJson['background_color']) && !empty($collageJson['background_color'])) { $c->collageBackgroundColor = $collageJson['background_color']; } @@ -296,8 +299,6 @@ public static function createCollage(array $config, array $srcImagePaths, string } else { $layoutConfigArray = $collageJson; } - } else { - return false; } } @@ -407,7 +408,7 @@ public static function createCollage(array $config, array $srcImagePaths, string unset($imageResource); } - if (strpos($c->collageLayout, '2x') === 0) { + if (self::$drawDashedLine) { $editImages = array_merge($editImages, $editImages); } diff --git a/src/Configuration/Section/CollageConfiguration.php b/src/Configuration/Section/CollageConfiguration.php index 4299ddbed..22f0ab7eb 100644 --- a/src/Configuration/Section/CollageConfiguration.php +++ b/src/Configuration/Section/CollageConfiguration.php @@ -2,7 +2,7 @@ namespace Photobooth\Configuration\Section; -use Photobooth\Enum\CollageLayoutEnum; +use Photobooth\Utility\CollageLayoutScanner; use Symfony\Component\Config\Definition\Builder\NodeDefinition; use Symfony\Component\Config\Definition\Builder\TreeBuilder; @@ -56,16 +56,17 @@ public static function getNode(): NodeDefinition ->values(['landscape', 'portrait']) ->defaultValue('landscape') ->end() - ->enumNode('layout') - ->values(CollageLayoutEnum::cases()) - ->defaultValue(CollageLayoutEnum::TWO_PLUS_TWO_2) - ->beforeNormalization() - ->always(function ($value) { - if (is_string($value)) { - $value = CollageLayoutEnum::from($value); + ->scalarNode('layout') + ->defaultValue('template/collage/landscape/1+2-1') + ->validate() + ->ifTrue(function ($value) { + if (!is_string($value) || empty($value)) { + return true; } - return $value; + $absolutePath = CollageLayoutScanner::getCollageConfigPath($value); + return $absolutePath === null; }) + ->thenInvalid('The collage layout "%s" does not exist or is invalid.') ->end() ->end() ->booleanNode('allow_selection')->defaultValue(false)->end() diff --git a/src/Enum/CollageLayoutEnum.php b/src/Enum/CollageLayoutEnum.php deleted file mode 100644 index 8bcff8772..000000000 --- a/src/Enum/CollageLayoutEnum.php +++ /dev/null @@ -1,45 +0,0 @@ - '2+2 Layout (Option 1)', - self::TWO_PLUS_TWO_2 => '2+2 Layout (Option 2)', - self::ONE_PLUS_THREE_1 => '1+3 Layout (Option 1)', - self::ONE_PLUS_THREE_2 => '1+3 Layout (Option 2)', - self::THREE_PLUS_ONE_1 => '3+1 Layout', - self::ONE_PLUS_TWO_1 => '1+2 Layout', - self::TWO_PLUS_ONE_1 => '2+1 Layout', - self::TWO_X_FOUR_1 => '2x4 Layout (Option 1)', - self::TWO_X_FOUR_2 => '2x4 Layout (Option 2)', - self::TWO_X_FOUR_3 => '2x4 Layout (Option 3)', - self::TWO_X_FOUR_4 => '2x4 Layout (Option 4)', - self::TWO_X_THREE_1 => '2x3 Layout (Option 1)', - self::TWO_X_THREE_2 => '2x3 Layout (Option 2)', - self::COLLAGE_JSON => 'Collage JSON Configuration', - }; - } -} diff --git a/src/Factory/CollageConfigFactory.php b/src/Factory/CollageConfigFactory.php index c4fb8df04..308dbcf97 100644 --- a/src/Factory/CollageConfigFactory.php +++ b/src/Factory/CollageConfigFactory.php @@ -3,7 +3,6 @@ namespace Photobooth\Factory; use Photobooth\Dto\CollageConfig; -use Photobooth\Enum\CollageLayoutEnum; use Photobooth\Utility\PathUtility; class CollageConfigFactory @@ -11,9 +10,7 @@ class CollageConfigFactory public static function fromConfig(array $config): CollageConfig { $collageConfig = new CollageConfig(); - $collageConfig->collageLayout = $config['collage']['layout'] instanceof CollageLayoutEnum - ? $config['collage']['layout']->value - : (string) $config['collage']['layout']; + $collageConfig->collageLayout = (string) $config['collage']['layout']; $collageConfig->collageOrientation = $config['collage']['orientation']; $collageConfig->collageBackgroundColor = $config['collage']['background_color']; $collageConfig->collageFrame = $config['collage']['frame']; diff --git a/src/Service/ConfigurationService.php b/src/Service/ConfigurationService.php index f504dfe71..1a67845cd 100644 --- a/src/Service/ConfigurationService.php +++ b/src/Service/ConfigurationService.php @@ -128,6 +128,36 @@ protected function addDefaults(array $config): array protected function processMigration(array $config): array { + $legacyCollage = 'collage.json'; + $legacyCollageDir = PathUtility::getAbsolutePath('private/'); + $communityCollageDir = PathUtility::getAbsolutePath('private/collage/community/'); + $communityCollage = $communityCollageDir . DIRECTORY_SEPARATOR . $legacyCollage; + $relativCommunityCollageDir = PathUtility::getRelativePath($communityCollage); + + if (is_file($legacyCollageDir . DIRECTORY_SEPARATOR . $legacyCollage)) { + if (!is_dir($communityCollageDir) && !mkdir($communityCollageDir, 0777, true) && !is_dir($communityCollageDir)) { + throw new \RuntimeException(sprintf('Directory "%s" could not be created.', $communityCollageDir)); + } + + if (!is_file($communityCollageDir . DIRECTORY_SEPARATOR . $legacyCollage)) { + rename($legacyCollageDir . DIRECTORY_SEPARATOR . $legacyCollage, $communityCollageDir . DIRECTORY_SEPARATOR . $legacyCollage); + } + + if (isset($config['collage']['layout'])) { + $config['collage']['layout'] = $communityCollageDir . DIRECTORY_SEPARATOR . $legacyCollage; + } + } + + //TODO: depend on how multiple custom layouts are handled and stored in the future, this migration may need to be adjusted + // if (isset($config['collage']['layouts_enabled']) && is_array($config['collage']['layouts_enabled'])) { + // $config['collage']['layouts_enabled'] = array_map( + // static function ($value) use ($legacyCollage, $relativCommunityCollageDir) { + // return $value == $legacyCollage ? $relativCommunityCollageDir : $value; + // }, + // $config['collage']['layouts_enabled'] + // ); + // } + // Normalize legacy paths that may contain absolute URLs or subfolder prefixes (e.g. /photobooth/) $baseUrl = PathUtility::getBaseUrl(); $hostBase = ''; diff --git a/src/Service/ThemeService.php b/src/Service/ThemeService.php index 818d9a132..41be06796 100644 --- a/src/Service/ThemeService.php +++ b/src/Service/ThemeService.php @@ -393,7 +393,7 @@ private function collectAssetCandidates(array $theme): array } } - $projectRelative = PathUtility::toProjectRelative($absolute); + $projectRelative = PathUtility::getRelativePath($absolute); // Skip generated assets living in resources/ per export policy if (str_starts_with($projectRelative, 'resources/')) { diff --git a/src/Utility/AdminInput.php b/src/Utility/AdminInput.php index f02e37a2f..e7a32b1f6 100644 --- a/src/Utility/AdminInput.php +++ b/src/Utility/AdminInput.php @@ -212,27 +212,32 @@ public static function renderSelect(array $setting, string $label): string $className = $setting['type'] === 'multi-select' ? 'min-h-[30px] h-32 resize-y ' : ''; $className .= 'w-full h-10 border-2 border-solid border-gray-300 focus:border-brand-1 rounded-md px-2 mt-auto'; $settingName = $setting['name'] . '' . ($setting['type'] === 'multi-select' ? '[]' : ''); - $options = ''; + $optionsHtmlContent = ''; // Renamed $options to $optionsHtmlContent for clarity when injecting generated HTML $attributes = self::buildAttributes($setting); - foreach ($setting['options'] as $value => $option) { - $optionLabel = $option; - $optionValue = $value; - if ($option instanceof \BackedEnum) { - $optionLabel = ($option instanceof LabelInterface) ? $option->label() : $option->name; - $optionValue = $option; - } + if (isset($setting['options_html'])) { + // If 'options_html' is provided, directly inject it (it's already pre-rendered HTML) + $optionsHtmlContent .= $setting['options_html']; + } else { + foreach ($setting['options'] ?? [] as $value => $option) { // Added ?? [] to ensure it's iterable + $optionLabel = $option; + $optionValue = $value; + if ($option instanceof \BackedEnum) { + $optionLabel = ($option instanceof LabelInterface) ? $option->label() : $option->name; + $optionValue = $option; + } - $selected = ''; - if ((is_array($setting['value']) && in_array($optionValue, $setting['value'])) || $optionValue === $setting['value']) { - $selected = ' selected="selected"'; - } - $styles = ''; - if ($settingName === 'text_font_family') { - $styles = 'style="font-family:' . $optionLabel . '"'; + $selected = ''; + if ((is_array($setting['value']) && in_array($optionValue, $setting['value'])) || $optionValue === $setting['value']) { + $selected = ' selected="selected"'; + } + $styles = ''; + if ($settingName === 'text_font_family') { + $styles = 'style="font-family:' . $optionLabel . '"'; + } + $optionsHtmlContent .= ''; } - $options .= ''; } return self::renderHeadline($label) . ' @@ -242,7 +247,7 @@ class="' . $className . '" ' . ($setting['type'] === 'multi-select' ? ' multiple="multiple"' : '') . ' ' . $attributes . ' > - ' . $options . ' + ' . $optionsHtmlContent . ' '; } @@ -466,46 +471,145 @@ public static function renderTheme(array $setting, string $label): string $themeNames = array_keys($themes); sort($themeNames); - $options = ' + $optionsHtml = ' '; foreach ($themeNames as $name) { - $options .= ''; + $selected = ($name === $currentTheme) ? ' selected="selected"' : ''; + $optionsHtml .= ''; } + // Prepare the settings array for renderConfigManager + $configManagerSetting = [ + 'name_input_id' => 'theme-name', + 'name_input_placeholder' => 'theme_name_placeholder', // Language key + 'select_id' => 'theme-select', + 'select_label_headline' => $label, // Use the provided label for the headline + 'select_options_html' => $optionsHtml, + 'current_name_hidden_field_name' => 'theme[current]', + 'current_name_hidden_field_value' => $currentTheme, + + 'save_btn_id' => 'theme-save-btn', + 'save_btn_title_label_key' => 'theme_save', // Language key for title + 'save_btn_onclick' => 'adminThemeSave();', + + 'load_btn_id' => 'theme-load-btn', + 'load_btn_title_label_key' => 'theme_load', // Language key for title + 'load_btn_onclick' => 'adminThemeLoad();', + + 'delete_btn_id' => 'theme-delete-btn', + 'delete_btn_title_label_key' => 'theme_delete', // Language key for title + 'delete_btn_onclick' => 'adminThemeDelete();', + ]; + + return self::renderConfigManager($configManagerSetting); + } + + /** + * Renders a generic configuration management UI component. + * This includes a dropdown for selection, an input for naming, and buttons for save, load, and delete. + * The structure and button styling are derived from the theme management UI. + * + * @param array $setting Configuration array for the component. Expected keys (with defaults): + * - 'name_input_id': HTML ID for the name input field. + * - 'name_input_placeholder': Placeholder text for the name input field (language key). + * - 'select_id': HTML ID for the select dropdown. + * - 'select_label_headline': Headline label for the select dropdown (language key). + * - 'select_options_html': HTML string for the ', + 'current_name_hidden_field_name' => 'config[current]', + 'current_name_hidden_field_value' => '', + + 'save_btn_id' => 'config-save-btn', + 'save_btn_title_label_key' => 'Save', // Language key expected for title + 'save_btn_onclick' => 'saveConfig();', + 'save_btn_classes' => 'bg-brand-1 text-white border-brand-1 hover:bg-content-1 hover:text-brand-1', + 'save_btn_icon_class' => 'fa fa-save', + + 'load_btn_id' => 'config-load-btn', + 'load_btn_title_label_key' => 'Load', // Language key expected for title + 'load_btn_onclick' => 'loadConfig();', + 'load_btn_classes' => 'bg-content-1 text-brand-1 border-brand-1 hover:bg-brand-1 hover:text-white', + 'load_btn_icon_class' => 'fa fa-refresh', + + 'delete_btn_id' => 'config-delete-btn', + 'delete_btn_title_label_key' => 'Delete', // Language key expected for title + 'delete_btn_onclick' => 'deleteConfig();', + 'delete_btn_classes' => 'bg-content-1 text-red-600 border-red-600 hover:bg-red-600 hover:text-white', + 'delete_btn_icon_class' => 'fa fa-trash', + ], $setting); + + // Define common base classes for the icon buttons, which are not customizable per button + $iconButtonBaseClasses = 'h-8 w-8 flex items-center justify-center rounded-full transition text-[10px] font-bold border border-solid'; + return ' - ' . self::renderHeadline($label) . ' + ' . self::renderHeadline($setting['select_label_headline']) . '
@@ -527,20 +631,22 @@ class="h-8 w-8 flex items-center justify-center rounded-full bg-content-1 text-b
@@ -734,6 +840,67 @@ class="adminList-remove bg-red-500 text-white px-3 rounded-md" return $html; } + /* Renders a standardized "Back" button for admin sub-pages. + * It determines the appropriate return URL based on URL parameters and HTTP_REFERER, + * and uses styling consistent with other admin buttons. + * + * @param array $attributes Additional attributes for the tag of the button. + * Commonly used for positioning classes like ['class' => 'absolute top-8 left-4 z-10 w-auto h-auto']. + * @param string|null $defaultBackPath Optional path to return to if no specific origin is found. Defaults to 'admin'. + * @return string The HTML for the "Back" button. + */ + public static function renderBackButton(array $attributes = [], ?string $defaultBackPath = null): string + { + $languageService = LanguageService::getInstance(); + global $config; // Access global config for icons + + $backToOriginLink = PathUtility::getPublicPath($defaultBackPath ?? 'admin'); // Default: back to admin panel + + // 'from' GET parameter + if (isset($_GET['from']) && !empty($_GET['from'])) { + $fromParam = htmlspecialchars($_GET['from']); + // If 'from' parameter looks like an admin hash segment (e.g., 'collage', 'gallery') + if (strpos($fromParam, '/') === false && strpos($fromParam, '#') === false) { + $backToOriginLink = PathUtility::getPublicPath('admin') . '#' . $fromParam; + } else { + // If 'from' contains a slash or hash, it might be a more complex path. + // For security, ensure it's still within our application's public path. + $publicPath = PathUtility::getPublicPath(); + if (strpos($fromParam, $publicPath) === 0) { // Simple check if it starts with our public path + $backToOriginLink = $fromParam; + } else { + // If 'from' is suspicious, fallback to default admin path. + $backToOriginLink = PathUtility::getPublicPath('admin'); + } + } + } + + // Define default classes for the button, + $finalClasses = 'btn bg-brand-1'; // Adjust this to your actual standard button classes + + if (isset($attributes['class'])) { + $finalClasses .= ' ' . $attributes['class']; + unset($attributes['class']); // Remove 'class' to avoid duplication in $finalAttrs string + } + + $finalAttrs = ''; + foreach ($attributes as $key => $value) { + $finalAttrs .= ' ' . htmlspecialchars($key) . '="' . htmlspecialchars($value) . '"'; + } + + $iconHtml = $config['icons']['back'] ?? 'fa fa-arrow-left'; // Fallback icon + $iconHtml = ''; + + $translatedText = $languageService->translate('back'); + + return << + {$iconHtml} + {$translatedText} + + HTML; + } + public static function renderToggleButtonGroupModal(array $setting, string $label): string { $languageService = LanguageService::getInstance(); diff --git a/src/Utility/CollageLayoutScanner.php b/src/Utility/CollageLayoutScanner.php new file mode 100644 index 000000000..6f9470898 --- /dev/null +++ b/src/Utility/CollageLayoutScanner.php @@ -0,0 +1,286 @@ + ['Portrait-Layouts' => [...]], 'Eigene Layouts' => ['Community-Layouts' => [...]]] + */ + public static function scanLayouts(): array + { + $layoutFiles = []; + + // Define the main base directories for grouping (e.g., 'template', 'private') + // Use simple keys ('template', 'private') for logical grouping, map to actual paths. + $mainBaseDirs = [ + 'template' => 'template/collage', // Standard layouts path + 'private' => 'private/collage', // User-defined/community layouts path + ]; + + foreach ($mainBaseDirs as $mainGroupKey => $baseDirRelativePath) { + $absoluteBaseDir = PathUtility::getAbsolutePath($baseDirRelativePath); + + // Initialize the main group key in $layoutFiles early + $layoutFiles[$mainGroupKey] = []; + + // Ensure the base directory exists, create if it's a 'private' one and missing + if (!is_dir($absoluteBaseDir)) { + if ($mainGroupKey === 'private') { + try { + mkdir($absoluteBaseDir, 0777, true); + } catch (\Exception $e) { + error_log('CollageLayoutScanner: Failed to create base directory: ' . $absoluteBaseDir . ' - ' . $e->getMessage()); + continue; + } + } else { + continue; // Skip if 'template' base dir doesn't exist (expected to be present) + } + } + + // --- Scan subdirectories for specific groups (e.g., 'portrait', 'landscape', 'community') --- + $subDirNames = ['portrait', 'landscape', 'community']; // Extend as needed + + foreach ($subDirNames as $subGroupName) { + $subDirPath = $absoluteBaseDir . DIRECTORY_SEPARATOR . $subGroupName; + + // Ensure the subdirectory exists, create if it's in a 'private' context and missing + if (!is_dir($subDirPath)) { + if ($mainGroupKey === 'private') { + try { + mkdir($subDirPath, 0777, true); + } catch (\Exception $e) { + error_log('CollageLayoutScanner: Failed to create subdirectory: ' . $subDirPath . ' - ' . $e->getMessage()); + continue; + } + } else { + continue; // Skip if 'template' subdir doesn't exist (expected to be present) + } + } + + // If directory exists (or was created), scan it + // Pass the mainGroupKey AND the subGroupName to build the nested structure + self::scanDirectory($subDirPath, $layoutFiles[$mainGroupKey], $subGroupName, $mainGroupKey); + } + } + + return self::groupAndTranslateLayouts($layoutFiles); + } + + /** + * Scans a given directory for JSON files and extracts relevant layout data. + * + * @param string $directory The absolute path to the directory. + * @param array $layoutFiles Reference to the array to store found layouts for the current main group. + * @param string $subGroupKey The key for the subgroup (e.g., 'landscape', 'community', 'square'). + * @param string $mainGroupKey The key for the main group (e.g., 'template', 'private'). + */ + private static function scanDirectory(string $directory, array &$layoutFiles, string $subGroupKey, string $mainGroupKey): void + { + $files = glob($directory . DIRECTORY_SEPARATOR . '*.json'); + if ($files === false) { + return; + } + + foreach ($files as $filePath) { + $fileContent = file_get_contents($filePath); + if ($fileContent === false) { + error_log('CollageLayoutScanner: Could not read file: ' . $filePath); + continue; + } + + $layoutConfig = json_decode($fileContent, true); + if (json_last_error() !== JSON_ERROR_NONE || !is_array($layoutConfig)) { + error_log('CollageLayoutScanner: Malformed JSON in file: ' . $filePath); + continue; + } + + $layoutId = basename($filePath, '.json'); + + $layoutName = $layoutConfig['name'] ?? $layoutId; + + $refFilePath = $mainGroupKey . '/collage/' . $subGroupKey . '/' . $layoutId; + + // Group by the provided $subGroupKey within the main group + // $layoutFiles is passed by reference and already represents $layoutFiles[$mainGroupKey] from scanLayouts + $layoutFiles[$subGroupKey][$layoutId] = [ + 'id' => $layoutId, + 'name' => $layoutName, + 'description' => $layoutConfig['description'] ?? '', + 'author' => $layoutConfig['author'] ?? 'Unknown', + 'ref_file_path' => $refFilePath, + 'aspect_ratio' => $layoutConfig['aspect_ratio'] ?? '', + 'width' => $layoutConfig['width'] ?? '', + 'height' => $layoutConfig['height'] ?? '', + ]; + } + } + + /** + * Groups and translates the found layouts for display without explicit sorting. + * + * @param array $rawLayoutFiles The raw array of found layouts, grouped by main group and subgroup key. + * @return array The grouped layouts, with translated group titles. + */ + private static function groupAndTranslateLayouts(array $rawLayoutFiles): array + { + $groupedLayouts = []; + $languageService = LanguageService::getInstance(); + + // Define a desired order and translation keys for the main groups (template, private) + $mainGroupTranslationKeys = [ + 'template' => 'standard_layouts', // e.g., "Standard Layouts" + 'private' => 'custom_layouts', // e.g., "Eigene Layouts" + ]; + + // Define a desired order and translation keys for the subgroups (portrait, landscape, community) + $subGroupTranslationKeys = [ + 'portrait' => 'portrait', + 'landscape' => 'landscape', + 'community' => 'community_layouts', + // Add other subdir names here + ]; + + foreach ($mainGroupTranslationKeys as $mainGroupKey => $mainTransKey) { + $translatedMainGroupTitle = $languageService->translate($mainTransKey); + $groupedLayouts[$translatedMainGroupTitle] = []; // Initialize main group + + if (isset($rawLayoutFiles[$mainGroupKey])) { + foreach ($subGroupTranslationKeys as $subGroupKey => $subTransKey) { + if (isset($rawLayoutFiles[$mainGroupKey][$subGroupKey])) { + $translatedSubGroupTitle = $languageService->translate($subTransKey); + // Add directly, no sorting + $groupedLayouts[$translatedMainGroupTitle][$translatedSubGroupTitle] = $rawLayoutFiles[$mainGroupKey][$subGroupKey]; + } + } + // Handle any subgroups not explicitly defined in $subGroupTranslationKeys (e.g., new custom folder) + foreach ($rawLayoutFiles[$mainGroupKey] as $subGroupKey => $layouts) { + if (!array_key_exists($subGroupKey, $subGroupTranslationKeys)) { + $translatedSubGroupTitle = $languageService->translate($subGroupKey); // Try to translate, fallback to key + $groupedLayouts[$translatedMainGroupTitle][$translatedSubGroupTitle] = $layouts; + } + } + } + } + + return $groupedLayouts; + } + + /** + * Retrieves full layout data by its logical reference path. + * This method loads the actual JSON content from the file. + * + * @param string $logicalReferencePath The unique logical path (e.g., "template/portrait/my-layout-id"). + * @return array|null The complete layout data array including the 'layout' content, or null if not found/invalid. + */ + public static function getLayoutData(string $logicalReferencePath): ?array + { + $AbsFilePath = self::getCollageConfigPath($logicalReferencePath); + + if ($AbsFilePath === null) { + return null; + } + + $fileContent = file_get_contents($AbsFilePath); + + if ($fileContent === false) { + error_log('CollageLayoutScanner: Could not read file: ' . $AbsFilePath); + return []; + } + + $layoutConfig = json_decode($fileContent, true); + if (json_last_error() !== JSON_ERROR_NONE || !is_array($layoutConfig)) { + error_log('CollageLayoutScanner: Malformed JSON in file: ' . $AbsFilePath); + return []; + } + + $layoutId = basename($AbsFilePath, '.json'); + + $layoutName = $layoutConfig['name'] ?? $layoutId; + + $layoutData = [ + 'id' => $layoutId, + 'name' => $layoutName, + 'ref_file_path' => $logicalReferencePath, + ]; + + $layoutData = array_merge($layoutConfig, $layoutData); + + return $layoutData; + } + + /** + * Scans for collage layouts and returns them formatted for an HTML select dropdown. + * Each option's value will be the ref_file_path and its label the display name. + * + * @param string|null $currentSelectedPath The currently selected ref_file_path to mark an option as 'selected'. + * @return string HTML string of elements. + */ + public static function getLayoutSelectOptionsHtml(?string $currentSelectedPath = null): string + { + $designes = self::scanLayouts(); + + $optionsHtml = ''; + + foreach ($designes as $mainGroupTitle => $subGroups) { + $optionsHtml .= ''; + + // Sort subgroups by their translated titles to ensure consistent order + ksort($subGroups); + + foreach ($subGroups as $subGroupTitle => $layouts) { + // Add a disabled option as a heading for the subgroup, if not empty + if (!empty($subGroupTitle)) { + $optionsHtml .= ''; + } + + // Sort the layouts within the subgroup by their name + uasort($layouts, function ($a, $b) { + return strcmp($a['name'] ?? $a['id'], $b['name'] ?? $b['id']); + }); + + foreach ($layouts as $layoutId => $layoutData) { + $selected = ($layoutData['ref_file_path'] === $currentSelectedPath) ? ' selected="selected"' : ''; + + $displayName = htmlspecialchars($layoutData['name'] ?? $layoutData['id'] ?? '', ENT_QUOTES); + + $optionsHtml .= ''; + } + } + $optionsHtml .= ''; + } + return $optionsHtml; + } + + /** + * Helper to get the absolute path for a logical collage layout reference path. + * Checks if the file exists and returns the path or null. + * This method is also used for validation in CollageConfiguration. + * + * @param string $logicalReferencePath e.g., 'template/collage/landscape/1+2-1' + * @return string|null Absolute file path to the JSON file, or null if not found. + */ + public static function getCollageConfigPath(string $logicalReferencePath): ?string + { + // Add the .json extension + $fullPathWithExtension = $logicalReferencePath . '.json'; + + // Let PathUtility build the absolute path + $absolutePath = PathUtility::getAbsolutePath($fullPathWithExtension); + + if (file_exists($absolutePath)) { + return $absolutePath; + } + + // Log, falls die Datei nicht gefunden wird, hilfreich für Debugging + error_log('DEBUG: CollageLayoutScanner::getCollageConfigPath - Layout JSON file not found at: ' . $absolutePath); + return null; + } +} diff --git a/src/Utility/PathUtility.php b/src/Utility/PathUtility.php index 49945806a..806a48f1b 100644 --- a/src/Utility/PathUtility.php +++ b/src/Utility/PathUtility.php @@ -180,7 +180,7 @@ public static function fixFilePath(string $path): string * - Absolute paths outside the project are returned as-is. * - Relative paths are normalized (slashes fixed) and returned. */ - public static function toProjectRelative(string $path): string + public static function getRelativePath(string $path): string { if (self::isUrl($path)) { return $path; @@ -196,6 +196,15 @@ public static function toProjectRelative(string $path): string return $normalized; } + /** + * @return string + * @deprecated Use getRelativePath() instead. + */ + public static function toProjectRelative(string $path): string + { + return self::getRelativePath($path); + } + /** * Resolves a file path or URL to a readable absolute filesystem path. * diff --git a/test/collage.php b/test/collage.php index f6b19caaa..2eae71f3f 100644 --- a/test/collage.php +++ b/test/collage.php @@ -101,7 +101,7 @@
diff --git a/test/remote-storage-template.php b/test/remote-storage-template.php index fe2069b12..d433383a2 100644 --- a/test/remote-storage-template.php +++ b/test/remote-storage-template.php @@ -1,13 +1,13 @@ getConfiguration(); $languageService = LanguageService::getInstance();