From 95a7b03dc644bf16ffacc94dcaa2dcdeb37cf05d Mon Sep 17 00:00:00 2001 From: Leon Schmitt Date: Tue, 23 Dec 2025 17:36:31 +0100 Subject: [PATCH 01/63] feat: Integrate new Collage Designer and replace old Generator This commit introduces a new, dedicated Collage Designer, replacing the legacy collage generator. The goal is to provide a more flexible and robust platform for designing collages and also be able to move all collage settings in this designer in the future, laying the groundwork for future enhancements and a better user experience. Key changes include: - **Removal of Legacy Collage Generator:** The old generator's entry points and related files have been removed or redirected. (Note: A dedicated cleanup commit for all generator-related files is planned for the future.) - **New Collage Designer Structure:** A dedicated directory and file structure for the new Collage Designer (`admin/collage-designer/`) have been established to encapsulate its logic and assets. - **Initial Collage Designer Layout:** The foundational HTML/CSS layout for the Collage Designer has been created, providing a basic UI for future design capabilities. - **Unified Configuration Rendering:** The `renderConfigManager` was introduced and integrated into `inputAdmin.php` to standardize the rendering of configuration options across the admin panel, improving consistency and maintainability. - **Theme Renderer Adaptation:** The theme renderer has been updated to utilize the new, general `renderConfigManager`, ensuring that theme-specific configurations are also processed through the unified rendering system. - **Enhanced Admin Panel Navigation:** - The "Collage Designer" button in the admin panel now directly navigates to the new designer within the same tab, providing a smoother user flow. - **Optimized Admin Settings Save Logic:** - A centralized `saveAdminSettings()` JavaScript function has been implemented for handling API requests to save admin configurations. - This function uses Promises for asynchronous handling and allows configurable behavior (e.g., page reload) via options. - An `isDirty` check (based on `$('#save-admin-btn').hasClass('isDirty')`) ensures that settings are only saved if actual changes have occurred, preventing unnecessary API calls and improving responsiveness. - The "Collage Designer" button now conditionally saves admin settings before navigation *only if* there are pending changes, otherwise, it navigates directly. - The main "Save" button in the admin panel retains its original behavior of reloading the page after saving. Change-Id: Iecdac17ee959247347cfef99f687e1e93431d55d --- admin/collage-designer/assets/css | 0 admin/collage-designer/assets/js/designer.js | 0 .../components/design-selector.php | 54 ++ .../components/element-settings-panel.php | 113 +++ .../components/general-settings.php | 128 +++ .../components/image-placeholders-manager.php | 29 + .../components/placeholder-settings.php | 60 ++ .../components/preview-canvas.php | 36 + .../components/text-fields-manager.php | 18 + .../includes/CollageManager.php | 97 +++ .../includes/CollageRenderer.php | 0 admin/collage-designer/index.php | 156 ++++ admin/generator/index.php | 770 ------------------ assets/js/admin/buttons.js | 161 +++- lib/configsetup.inc.php | 8 +- src/Utility/AdminInput.php | 144 +++- test/collage.php | 2 +- 17 files changed, 964 insertions(+), 812 deletions(-) create mode 100644 admin/collage-designer/assets/css create mode 100644 admin/collage-designer/assets/js/designer.js create mode 100644 admin/collage-designer/components/design-selector.php create mode 100644 admin/collage-designer/components/element-settings-panel.php create mode 100644 admin/collage-designer/components/general-settings.php create mode 100644 admin/collage-designer/components/image-placeholders-manager.php create mode 100644 admin/collage-designer/components/placeholder-settings.php create mode 100644 admin/collage-designer/components/preview-canvas.php create mode 100644 admin/collage-designer/components/text-fields-manager.php create mode 100644 admin/collage-designer/includes/CollageManager.php create mode 100644 admin/collage-designer/includes/CollageRenderer.php create mode 100644 admin/collage-designer/index.php diff --git a/admin/collage-designer/assets/css b/admin/collage-designer/assets/css new file mode 100644 index 000000000..e69de29bb diff --git a/admin/collage-designer/assets/js/designer.js b/admin/collage-designer/assets/js/designer.js new file mode 100644 index 000000000..e69de29bb diff --git a/admin/collage-designer/components/design-selector.php b/admin/collage-designer/components/design-selector.php new file mode 100644 index 000000000..7ef06251d --- /dev/null +++ b/admin/collage-designer/components/design-selector.php @@ -0,0 +1,54 @@ + + ' . $languageService->translate('collage_choose_new_design') . ' + + + +'; + +// --- Preparing the $configManagerSetting array (structure only, with real values later) --- +$configManagerSetting = [ + 'name_input_id' => 'collage-designer-name', + 'name_input_placeholder' => 'collage_name_placeholder', // Language key for placeholder + 'select_id' => 'collage-select', + 'select_label_headline' => $languageService->translate('manage_collage_designs'), // Headline for this section + 'select_options_html' => $optionsHtml, + 'current_name_hidden_field_name' => 'collage[current_design]', // Name of the hidden field + 'current_name_hidden_field_value' => $currentDesign, + + 'save_btn_id' => 'collage-save-btn', + 'save_btn_title_label_key' => 'collage_save', // Language key for the title + 'save_btn_onclick' => 'adminCollageSave();', // JavaScript function for saving (to be implemented later) + 'save_btn_icon_class' => 'fa fa-save', + + 'load_btn_id' => 'collage-load-btn', + 'load_btn_title_label_key' => 'collage_load', + 'load_btn_onclick' => 'adminCollageLoad();', // JavaScript function for loading (to be implemented later) + 'load_btn_icon_class' => 'fa fa-download', + + 'delete_btn_id' => 'collage-delete-btn', + 'delete_btn_title_label_key' => 'collage_delete', + 'delete_btn_onclick' => 'adminCollageDelete();', // JavaScript function for deleting (to be implemented later) + 'delete_btn_icon_class' => 'fa fa-trash', +]; +?> + +
+ + +
+ + + diff --git a/admin/collage-designer/components/element-settings-panel.php b/admin/collage-designer/components/element-settings-panel.php new file mode 100644 index 000000000..120b5895a --- /dev/null +++ b/admin/collage-designer/components/element-settings-panel.php @@ -0,0 +1,113 @@ + + + diff --git a/admin/collage-designer/components/general-settings.php b/admin/collage-designer/components/general-settings.php new file mode 100644 index 000000000..2938fe6f2 --- /dev/null +++ b/admin/collage-designer/components/general-settings.php @@ -0,0 +1,128 @@ + + +
+ + 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' + ) + ?> +
+
+
diff --git a/admin/collage-designer/components/image-placeholders-manager.php b/admin/collage-designer/components/image-placeholders-manager.php new file mode 100644 index 000000000..d7aa0171c --- /dev/null +++ b/admin/collage-designer/components/image-placeholders-manager.php @@ -0,0 +1,29 @@ + + +
+ + Manage Image Placeholders + +
+ + + +
+
+ + +
+
diff --git a/admin/collage-designer/components/placeholder-settings.php b/admin/collage-designer/components/placeholder-settings.php new file mode 100644 index 000000000..85ba94426 --- /dev/null +++ b/admin/collage-designer/components/placeholder-settings.php @@ -0,0 +1,60 @@ + + +
+ + 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', // This max will need to be dynamic based on the number of actual image placeholders + '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' + ) + ?> +
+
+
diff --git a/admin/collage-designer/components/preview-canvas.php b/admin/collage-designer/components/preview-canvas.php new file mode 100644 index 000000000..3ff0a1d38 --- /dev/null +++ b/admin/collage-designer/components/preview-canvas.php @@ -0,0 +1,36 @@ + +
+
+
+ +
+ + + +
"; + } + ?> +
+ +
+
+ + +
+
+
+
+
+
+
+ diff --git a/admin/collage-designer/components/text-fields-manager.php b/admin/collage-designer/components/text-fields-manager.php new file mode 100644 index 000000000..216498125 --- /dev/null +++ b/admin/collage-designer/components/text-fields-manager.php @@ -0,0 +1,18 @@ + + +
+ + Manage Text Fields + +
+ +

No text fields added yet.

+
+
+ +
+
diff --git a/admin/collage-designer/includes/CollageManager.php b/admin/collage-designer/includes/CollageManager.php new file mode 100644 index 000000000..380a88449 --- /dev/null +++ b/admin/collage-designer/includes/CollageManager.php @@ -0,0 +1,97 @@ +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/includes/CollageRenderer.php b/admin/collage-designer/includes/CollageRenderer.php new file mode 100644 index 000000000..e69de29bb diff --git a/admin/collage-designer/index.php b/admin/collage-designer/index.php new file mode 100644 index 000000000..79e4cba0c --- /dev/null +++ b/admin/collage-designer/index.php @@ -0,0 +1,156 @@ +'; +$font_family_options = []; +foreach ($font_paths as $path) { + try { + $files = FontUtility::getFontsFromPath($path, false); + $files = array_map(fn ($file): string => PathUtility::getPublicPath($file), $files); + if (count($files) > 0) { + foreach ($files as $name => $path) { + $font_styles .= '@font-face { font-family: "' . $name . '"; src: url(' . $path . ') format("truetype"); }'; + $font_family_options[$path] = $name; + } + } + } catch (\Exception $e) { /* Handle error or log */ } +} +$font_styles .= ''; + +// Optional: Initial loading of a default collage design or empty design +// This could later be controlled by the CollageManager +$initialCollageJson = '{"general": {"final_width": 1500, "final_height": 1000}, "elements": []}'; // Minimal JSON + +// ============================================================= +// 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_title') ?> + + +
+ + +
+ + translate('preview_title') ?> + + +
+
+ + +
+ + translate('general_placeholder_settings_title') ?> + + +
+ +
+ + +
+
+
+
+ +
+
+
+
+ +getUrl('admin/collage-designer/assets/js/designer.js') . '">'; // Your main 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/assets/js/admin/buttons.js b/assets/js/admin/buttons.js index b8b608aee..bd5ece7cd 100644 --- a/assets/js/admin/buttons.js +++ b/assets/js/admin/buttons.js @@ -1,5 +1,98 @@ /* 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 = {}) { + 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'); + + 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 + 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 + 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,6 +149,15 @@ $(function () { $('#save-admin-btn').on('click', function (e) { e.preventDefault(); + if (!hasPendingAdminChanges()) { + //TODO: move this to saveAdminSettings + + // console.log('No pending changes to save. Save button clicked, but form is clean.'); + // Optional: Display a toast message like "Nothing to save." + // photoboothTools.openToast(photoboothTools.getTranslation('no_changes_to_save'), 'info', 3000); + return; // Exit if no changes + } + // show loader $('.pageLoader').addClass('isActive'); $('.pageLoader').find('label').html(photoboothTools.getTranslation('saving')); @@ -66,21 +168,17 @@ $(function () { 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 +188,42 @@ $(function () { return false; }); - $('#layout-generator').on('click', function (ev) { + $('#collage-designer').on('click', function (ev) { ev.preventDefault(); - window.open('../admin/generator'); + + if (!hasPendingAdminChanges()) { + //TODO: move this to saveAdminSettings + + console.log('No pending changes detected. Navigating directly to Collage Designer.'); + // If no changes, directly navigate without saving + 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; + return; // Exit after navigation + } + + // If there are pending changes, save them first + saveAdminSettings({ reloadOnSuccess: false, reloadOnError: false }) // No reload here + .then(() => { + // Saving successful: Navigate to the 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); + // 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..91990c888 100644 --- a/lib/configsetup.inc.php +++ b/lib/configsetup.inc.php @@ -1017,12 +1017,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/src/Utility/AdminInput.php b/src/Utility/AdminInput.php index f02e37a2f..4c167b815 100644 --- a/src/Utility/AdminInput.php +++ b/src/Utility/AdminInput.php @@ -466,46 +466,144 @@ 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 +625,22 @@ class="h-8 w-8 flex items-center justify-center rounded-full bg-content-1 text-b
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 @@
From 5f4696e3ae73c297f7ab1aca47a428067c259ea6 Mon Sep 17 00:00:00 2001 From: Leon Schmitt Date: Wed, 24 Dec 2025 18:22:06 +0100 Subject: [PATCH 02/63] feat: Introduce dedicated Back button component and admin input renderer New back button functionality in a dedicated helper file (`admin/helper/backBtn.php`). This new component provides a robust "Back" button experience by leveraging the browser's `history.back()` JavaScript API, ensuring navigation to preceding page in the browser history, including hash fragments. Additionally, an `AdminInput` renderer was created. Key changes include: - **New File:** `admin/helper/backBtn.php` containing the `getBackBtn` function. - ** Back Button Logic:** The `getBackBtn` function uses `href="javascript:history.back()"` and `onclick="event.preventDefault(); history.back();"` for reliable client-side navigation. - **Admin Input Renderer:** Implemented a new renderer for the back button, allowing it to be easily added to admin forms and layouts. Change-Id: I79e2d12f3c5f6e4dfebf5c1025f74a2fc8b48c69 --- .../components/element-settings-panel.php | 41 ++--- .../components/general-settings.php | 141 +++++++++--------- .../components/image-placeholders-manager.php | 9 +- .../components/placeholder-settings.php | 59 ++++---- .../components/preview-canvas.php | 3 +- .../components/text-fields-manager.php | 1 + .../includes/CollageManager.php | 3 +- admin/collage-designer/index.php | 48 +++--- admin/helper/backBtn.php | 37 +++++ admin/helper/index.php | 1 + assets/js/admin/buttons.js | 105 ++++++------- src/Utility/AdminInput.php | 63 +++++++- test/remote-storage-template.php | 4 +- 13 files changed, 316 insertions(+), 199 deletions(-) create mode 100644 admin/helper/backBtn.php diff --git a/admin/collage-designer/components/element-settings-panel.php b/admin/collage-designer/components/element-settings-panel.php index 120b5895a..cf9b8cd10 100644 --- a/admin/collage-designer/components/element-settings-panel.php +++ b/admin/collage-designer/components/element-settings-panel.php @@ -3,6 +3,7 @@ use Photobooth\Utility\AdminInput; use Photobooth\Utility\PathUtility; + ?>
'text_font_color_current', - 'value' => '#000000', - 'placeholder' => 'text font color', - 'attributes' => ['data-trigger' => 'current_text_element', 'id' => 'text_font_color_current', 'data-setting-prop' => 'font_color'] - ], - 'collage:textoncollage_font_color' // Keep original language key - ) - ?> + AdminInput::renderColor( + [ + 'name' => 'text_font_color_current', + 'value' => '#000000', + 'placeholder' => 'text font color', + 'attributes' => ['data-trigger' => 'current_text_element', 'id' => 'text_font_color_current', 'data-setting-prop' => 'font_color'] + ], + 'collage:textoncollage_font_color' // Keep original language key + ) +?>
@@ -94,15 +95,15 @@
'picture_show_frame_current', - 'value' => 'false', - 'attributes' => ['data-trigger' => 'current_image_element', 'id' => 'picture_show_frame_current', 'data-setting-prop' => 'show_frame'] - ], - 'collage:generator:show_single_frame' // Keep original language key - ) - ?> + AdminInput::renderCheckbox( + [ + 'name' => 'picture_show_frame_current', + 'value' => 'false', + 'attributes' => ['data-trigger' => 'current_image_element', 'id' => 'picture_show_frame_current', 'data-setting-prop' => 'show_frame'] + ], + 'collage:generator:show_single_frame' // Keep original language key + ) +?>
diff --git a/admin/collage-designer/components/general-settings.php b/admin/collage-designer/components/general-settings.php index 2938fe6f2..5b9d7cdcd 100644 --- a/admin/collage-designer/components/general-settings.php +++ b/admin/collage-designer/components/general-settings.php @@ -3,6 +3,7 @@ use Photobooth\Utility\AdminInput; use Photobooth\Utility\PathUtility; + ?>
@@ -21,7 +22,7 @@ ], 'collage:collage_background_color' ) - ?> +?>
+?>
'generator-frame', - 'value' => '', - 'paths' => [ - PathUtility::getAbsolutePath('resources/img/frames'), - PathUtility::getAbsolutePath('private/images/frames'), - ], - 'attributes' => ['data-trigger' => 'general'] - ], - 'collage:collage_frame' - ) - ?> + AdminInput::renderImageSelect( + [ + 'name' => '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' - ) - ?> + AdminInput::renderInput( + [ + 'type' => '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' - ) - ?> + AdminInput::renderInput( + [ + 'type' => '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' - ) - ?> + AdminInput::renderSelect( + [ + 'type' => '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' - ) - ?> + AdminInput::renderCheckbox( + [ + 'name' => 'show-background', + 'value' => 'false', + 'attributes' => ['data-trigger' => 'general'] + ], + 'collage:generator:show_background' + ) +?>
'show-frame', - 'value' => 'false', - 'attributes' => ['data-trigger' => 'general'] - ], - 'collage:generator:show_frame' - ) - ?> + AdminInput::renderCheckbox( + [ + 'name' => 'show-frame', + 'value' => 'false', + 'attributes' => ['data-trigger' => 'general'] + ], + 'collage:generator:show_frame' + ) +?>
diff --git a/admin/collage-designer/components/image-placeholders-manager.php b/admin/collage-designer/components/image-placeholders-manager.php index d7aa0171c..6adbd1f13 100644 --- a/admin/collage-designer/components/image-placeholders-manager.php +++ b/admin/collage-designer/components/image-placeholders-manager.php @@ -3,6 +3,7 @@ use Photobooth\Utility\AdminInput; use Photobooth\Utility\PathUtility; + ?>
@@ -16,14 +17,14 @@ // In a multi-design scenario, this will be handled by JS loading the design. $initialDisplay = ($i < $config['collage']['limit']) ? 'block' : 'hidden'; // Example based on old config ?> -
- +
diff --git a/admin/collage-designer/components/placeholder-settings.php b/admin/collage-designer/components/placeholder-settings.php index 85ba94426..e420a4e70 100644 --- a/admin/collage-designer/components/placeholder-settings.php +++ b/admin/collage-designer/components/placeholder-settings.php @@ -3,6 +3,7 @@ use Photobooth\Utility\AdminInput; use Photobooth\Utility\PathUtility; + ?>
@@ -20,41 +21,41 @@ ], 'collage:collage_placeholder' ) - ?> +?>
'number', - 'name' => 'placeholder_image_position', - 'value' => '1', - 'placeholder' => 'placehoder image position', - 'attributes' => [ - 'min' => '1', - 'max' => '8', // This max will need to be dynamic based on the number of actual image placeholders - 'data-trigger' => 'general' - ] - ], - 'collage:collage_placeholderposition' - ) - ?> + AdminInput::renderInput( + [ + 'type' => 'number', + 'name' => 'placeholder_image_position', + 'value' => '1', + 'placeholder' => 'placehoder image position', + 'attributes' => [ + 'min' => '1', + 'max' => '8', // This max will need to be dynamic based on the number of actual image placeholders + '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' - ) - ?> + AdminInput::renderImageSelect( + [ + 'name' => 'placeholder_image', + 'value' => '', + 'paths' => [ + PathUtility::getAbsolutePath('resources/img/demo'), + PathUtility::getAbsolutePath('private/images/placeholder'), + ], + 'attributes' => ['data-trigger' => 'general'] + ], + 'choose_placeholder' + ) +?>
diff --git a/admin/collage-designer/components/preview-canvas.php b/admin/collage-designer/components/preview-canvas.php index 3ff0a1d38..7250263fe 100644 --- a/admin/collage-designer/components/preview-canvas.php +++ b/admin/collage-designer/components/preview-canvas.php @@ -2,6 +2,7 @@ // admin/collage-designer/components/preview-canvas.php use Photobooth\Utility\PathUtility; + ?>
@@ -19,7 +20,7 @@
"; } - ?> +?>
diff --git a/admin/collage-designer/components/text-fields-manager.php b/admin/collage-designer/components/text-fields-manager.php index 216498125..7a57012bd 100644 --- a/admin/collage-designer/components/text-fields-manager.php +++ b/admin/collage-designer/components/text-fields-manager.php @@ -2,6 +2,7 @@ // admin/collage-designer/components/text-fields-manager.php use Photobooth\Utility\AdminInput; + ?>
diff --git a/admin/collage-designer/includes/CollageManager.php b/admin/collage-designer/includes/CollageManager.php index 380a88449..9955c0144 100644 --- a/admin/collage-designer/includes/CollageManager.php +++ b/admin/collage-designer/includes/CollageManager.php @@ -1,4 +1,5 @@ getAvailableDesigns(); - $designs = array_filter($designs, fn($design) => $design['filename'] !== $filename); + $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; diff --git a/admin/collage-designer/index.php b/admin/collage-designer/index.php index 79e4cba0c..734e4e58b 100644 --- a/admin/collage-designer/index.php +++ b/admin/collage-designer/index.php @@ -47,7 +47,8 @@ $font_family_options[$path] = $name; } } - } catch (\Exception $e) { /* Handle error or log */ } + } catch (\Exception $e) { // Handle error or log + } } $font_styles .= ''; @@ -74,17 +75,22 @@
-
- translate('collage_designer_title') ?> + +
+
+ +
+
+ translate('collage_designer_title') ?> +
- -
+
+?>
@@ -95,11 +101,11 @@ translate('element_settings_title') ?> + // Include components relevant to element-specific adjustments + include 'components/element-settings-panel.php'; // Dynamic settings for active element +include 'components/text-fields-manager.php'; // Text fields management +include 'components/image-placeholders-manager.php'; // Image placeholders management +?>
@@ -107,7 +113,7 @@ translate('preview_title') ?> - +
@@ -117,9 +123,9 @@ translate('general_placeholder_settings_title') ?> +include 'components/general-settings.php'; // General settings +include 'components/placeholder-settings.php'; // Placeholder settings +?>
@@ -133,12 +139,12 @@
+echo getMenuBtn(PathUtility::getPublicPath('admin'), 'admin_panel', $config['icons']['admin']); +echo getMenuBtn(PathUtility::getPublicPath('test/collage.php'), 'collageTest', $config['icons']['take_collage'], true); +if (isset($_SESSION['auth']) && $_SESSION['auth'] === true) { + echo getMenuBtn(PathUtility::getPublicPath('login/logout.php'), 'logout', $config['icons']['logout']); +} +?>
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/assets/js/admin/buttons.js b/assets/js/admin/buttons.js index bd5ece7cd..62f4f64f7 100644 --- a/assets/js/admin/buttons.js +++ b/assets/js/admin/buttons.js @@ -29,60 +29,65 @@ function saveAdminSettings(options = {}) { 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}`; + .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); - }); - } - 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 - return new Promise(() => {}); + throw new Error(errorMessage); // Reject with a specific error } - 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); + }) + .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'); + // 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 - return new Promise(() => {}); - } - throw error; // Re-throw the error to be caught by the calling handlers - }); + 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. diff --git a/src/Utility/AdminInput.php b/src/Utility/AdminInput.php index 4c167b815..e64588026 100644 --- a/src/Utility/AdminInput.php +++ b/src/Utility/AdminInput.php @@ -569,7 +569,7 @@ public static function renderConfigManager(array $setting): string ], $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"; + $iconButtonBaseClasses = 'h-8 w-8 flex items-center justify-center rounded-full transition text-[10px] font-bold border border-solid'; return ' ' . self::renderHeadline($setting['select_label_headline']) . ' @@ -834,6 +834,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/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(); From cf9bab18ae9fd8a7f7fdfa94665bc18cd8f7fac5 Mon Sep 17 00:00:00 2001 From: Leon Schmitt Date: Wed, 24 Dec 2025 18:34:42 +0100 Subject: [PATCH 03/63] fixed formatting and removed "from" tag from collage-desginer Button Change-Id: I487e4a1a755d2f2a10ef1f47d7e1aa2d1cef3772 --- admin/collage-designer/index.php | 14 +++++++------- assets/js/admin/buttons.js | 8 +------- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/admin/collage-designer/index.php b/admin/collage-designer/index.php index 734e4e58b..f5e7f65ba 100644 --- a/admin/collage-designer/index.php +++ b/admin/collage-designer/index.php @@ -88,8 +88,8 @@
-
@@ -100,9 +100,9 @@ translate('element_settings_title') ?> - @@ -122,7 +122,7 @@ translate('general_placeholder_settings_title') ?> - @@ -138,7 +138,7 @@
- Date: Sat, 27 Dec 2025 16:34:18 +0100 Subject: [PATCH 04/63] feat: Implement grouped collage layout selection Key changes include: - **new CollageLayoutScanner:** new scanner to dynamically scan all available json-designs. - **Nested Grouping:** Collage layouts grouped into two levels (e.g., "Standard Layouts" > "Portrait Layouts", "Custom Layouts" > "Community Layouts") for better organization and clarity. - **Dynamic Directory Creation:** The scanner automatically creates missing base (`private/collage`) and subdirectories (`private/collage/portrait`, `private/collage/community`) if they don't exist. - **Robust Layout Naming:** Each layout is guaranteed to have a display name, using the `name` field from its JSON configuration or falling back to the filename (`layoutId`) if `name` is missing. - **changes Collage template:** added `name` field --- .../components/design-selector.php | 54 ++++-- resources/lang/en.json | 34 ++-- src/Utility/AdminInput.php | 3 +- src/Utility/CollageLayoutScanner.php | 167 ++++++++++++++++++ 4 files changed, 222 insertions(+), 36 deletions(-) create mode 100644 src/Utility/CollageLayoutScanner.php diff --git a/admin/collage-designer/components/design-selector.php b/admin/collage-designer/components/design-selector.php index 7ef06251d..44c82c44d 100644 --- a/admin/collage-designer/components/design-selector.php +++ b/admin/collage-designer/components/design-selector.php @@ -1,22 +1,53 @@ - ' . $languageService->translate('collage_choose_new_design') . ' - - - -'; + '; + +foreach ($designes as $mainGroupTitle => $subGroups) { // Iteriere über Hauptgruppen (z.B. "Standard Layouts", "Custom Layouts") + $optionsHtml .= ''; + + // Sortiere die Untergruppen nach ihren übersetzten Titeln, um eine konsistente Reihenfolge zu gewährleisten + // Hier ist eine einfache Sortierung nach Key (dem übersetzten Namen) + ksort($subGroups); + + foreach ($subGroups as $subGroupTitle => $layouts) { // Iteriere über Untergruppen (z.B. "Portrait Layouts", "Community Layouts") + // Füge eine (deaktivierte) Option als Überschrift für die Untergruppe hinzu + if (!empty($subGroupTitle)) { + $optionsHtml .= ''; + } + + // Sortiere die Layouts innerhalb der Untergruppe nach ihrem Namen + uasort($layouts, function($a, $b) { + return strcmp($a['name'] ?? $a['id'], $b['name'] ?? $b['id']); + }); + + foreach ($layouts as $layoutId => $layoutData) { // Jetzt sind hier die tatsächlichen Layout-Daten + $selected = ($layoutId === $currentDesign) ? ' selected="selected"' : ''; + + // Verwende den Null-Coalescing-Operator für "name", um Deprecated-Warnungen zu vermeiden + // und den "id" als Fallback, falls "name" fehlen sollte (was der Scanner bereits beheben sollte) + $displayName = htmlspecialchars($layoutData['name'] ?? $layoutData['id'] ?? '', ENT_QUOTES); + + $optionsHtml .= ''; + } + } + $optionsHtml .= ''; +} // --- Preparing the $configManagerSetting array (structure only, with real values later) --- $configManagerSetting = [ @@ -31,17 +62,14 @@ 'save_btn_id' => 'collage-save-btn', 'save_btn_title_label_key' => 'collage_save', // Language key for the title 'save_btn_onclick' => 'adminCollageSave();', // JavaScript function for saving (to be implemented later) - 'save_btn_icon_class' => 'fa fa-save', 'load_btn_id' => 'collage-load-btn', 'load_btn_title_label_key' => 'collage_load', 'load_btn_onclick' => 'adminCollageLoad();', // JavaScript function for loading (to be implemented later) - 'load_btn_icon_class' => 'fa fa-download', 'delete_btn_id' => 'collage-delete-btn', 'delete_btn_title_label_key' => 'collage_delete', 'delete_btn_onclick' => 'adminCollageDelete();', // JavaScript function for deleting (to be implemented later) - 'delete_btn_icon_class' => 'fa fa-trash', ]; ?> diff --git a/resources/lang/en.json b/resources/lang/en.json index 474d84d88..e1863a1f3 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -72,27 +72,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", @@ -1195,5 +1174,16 @@ "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..." + "wait_message": "Please wait...", + + "standard_layouts": "Standard Layouts", + "custom_layouts": "Custom Layouts", + "portrait": "Portrait Layouts", + "landscape": "Landscape Layouts", + "square": "Square Layouts", + "community_layouts": "Community Layouts", + "collage_choose_new_design": "Choose a new design", + "collage_name_placeholder": "Design Name", + "manage_collage_designs": "Manage Collage Designs", + "load": "Load" } diff --git a/src/Utility/AdminInput.php b/src/Utility/AdminInput.php index e64588026..ba909a8b5 100644 --- a/src/Utility/AdminInput.php +++ b/src/Utility/AdminInput.php @@ -502,7 +502,8 @@ public static function renderTheme(array $setting, string $label): string return self::renderConfigManager($configManagerSetting); } - /* Renders a generic configuration management UI component. + /** + * 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. * diff --git a/src/Utility/CollageLayoutScanner.php b/src/Utility/CollageLayoutScanner.php new file mode 100644 index 000000000..36252ea69 --- /dev/null +++ b/src/Utility/CollageLayoutScanner.php @@ -0,0 +1,167 @@ + ['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); + } + } + + 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'). + */ + private static function scanDirectory(string $directory, array &$layoutFiles, string $subGroupKey): void + { + $files = glob($directory . DIRECTORY_SEPARATOR . '*.json'); + 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; + + // 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'] ?? '', + 'file_path' => $filePath, + 'author' => $layoutConfig['author'] ?? 'Unknown', + '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; + } +} \ No newline at end of file From 695f85153795ebf41ec885f46cbee9043c844ce5 Mon Sep 17 00:00:00 2001 From: Leon Schmitt Date: Sun, 28 Dec 2025 22:27:49 +0100 Subject: [PATCH 05/63] collage-desinger: small fixes --- admin/collage-designer/assets/css | 0 admin/collage-designer/assets/js/designer.js | 0 .../components/design-selector.php | 21 +++++++++---------- 3 files changed, 10 insertions(+), 11 deletions(-) delete mode 100644 admin/collage-designer/assets/css delete mode 100644 admin/collage-designer/assets/js/designer.js diff --git a/admin/collage-designer/assets/css b/admin/collage-designer/assets/css deleted file mode 100644 index e69de29bb..000000000 diff --git a/admin/collage-designer/assets/js/designer.js b/admin/collage-designer/assets/js/designer.js deleted file mode 100644 index e69de29bb..000000000 diff --git a/admin/collage-designer/components/design-selector.php b/admin/collage-designer/components/design-selector.php index 44c82c44d..722d73d16 100644 --- a/admin/collage-designer/components/design-selector.php +++ b/admin/collage-designer/components/design-selector.php @@ -2,7 +2,6 @@ use Photobooth\Utility\AdminInput; use Photobooth\Service\LanguageService; -use Photobooth\Enum\CollageLayoutEnum; use Photobooth\Utility\CollageLayoutScanner; $languageService = LanguageService::getInstance(); @@ -16,33 +15,33 @@ ' . $languageService->translate('collage_choose_new_design') . ' '; -foreach ($designes as $mainGroupTitle => $subGroups) { // Iteriere über Hauptgruppen (z.B. "Standard Layouts", "Custom Layouts") +foreach ($designes as $mainGroupTitle => $subGroups) { // Iterate over main groups (e.g., "Standard Layouts", "Custom Layouts") $optionsHtml .= ''; - // Sortiere die Untergruppen nach ihren übersetzten Titeln, um eine konsistente Reihenfolge zu gewährleisten - // Hier ist eine einfache Sortierung nach Key (dem übersetzten Namen) + // Sort subgroups by their translated titles to ensure consistent order + // This is a simple key sort (by the translated name) ksort($subGroups); - foreach ($subGroups as $subGroupTitle => $layouts) { // Iteriere über Untergruppen (z.B. "Portrait Layouts", "Community Layouts") - // Füge eine (deaktivierte) Option als Überschrift für die Untergruppe hinzu + foreach ($subGroups as $subGroupTitle => $layouts) { // Iterate over subgroups (e.g., "Portrait Layouts", "Community Layouts") + // Add a disabled option as a heading for the subgroup if (!empty($subGroupTitle)) { $optionsHtml .= ''; } - // Sortiere die Layouts innerhalb der Untergruppe nach ihrem Namen + // 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) { // Jetzt sind hier die tatsächlichen Layout-Daten + foreach ($layouts as $layoutId => $layoutData) { // Now these are the actual layout data $selected = ($layoutId === $currentDesign) ? ' selected="selected"' : ''; - // Verwende den Null-Coalescing-Operator für "name", um Deprecated-Warnungen zu vermeiden - // und den "id" als Fallback, falls "name" fehlen sollte (was der Scanner bereits beheben sollte) + // Use the null coalescing operator for "name" to avoid Deprecated warnings + // and use "id" as a fallback if "name" should be missing (which the scanner should already handle) $displayName = htmlspecialchars($layoutData['name'] ?? $layoutData['id'] ?? '', ENT_QUOTES); $optionsHtml .= ''; } } From b3de08f3c79648989da9c265bb4f3de4662ed79e Mon Sep 17 00:00:00 2001 From: Leon Schmitt Date: Sun, 28 Dec 2025 23:46:49 +0100 Subject: [PATCH 06/63] feat(collage): Adapt layout selection and limit calculation for new designer Key changes include: - **Adapted Layout Selection:** The collage layout selection mechanism has been updated to align with the new designer's data structure. This means `config['collage']['layout']` now stores the relative path to the layout's JSON configuration file instead of an enum value. - **Persistent Collage Configuration:** The calculation of the collage limit and other related settings (e.g., placeholder properties) are now always saved to the main configuration, regardless of whether the collage feature is currently enabled. This ensures that the configuration is consistently up-to-date and prevents issues where stale limit values were used, leading to "Undefined array key" errors. - **Removed `CollageLayoutEnum`:** The `CollageLayoutEnum` and all its references have been deprecated and removed, simplifying the code and eliminating unnecessary abstraction now that layout paths are directly used. - **Enhanced `AdminInput::renderSelect`:** The `renderSelect` method in `AdminInput` now supports providing an HTML string as an option list, allowing for more flexible and dynamic dropdown constructions (e.g., with `optgroup` elements). - **Extended `CollageLayoutScanner`:** The `CollageLayoutScanner` class has been extended with new utility methods: - `getLayoutData()`: To retrieve detailed data for a specific layout. - `getLayoutSelectOptionsHtml()`: To generate the formatted HTML options for the select dropdown, including `optgroup` for better organization. - `getCollageConfigPath()`: To correctly determine the full file path for a given collage layout. --- .../components/design-selector.php | 37 +----- api/admin.php | 7 +- assets/js/admin/buttons.js | 6 +- lib/configsetup.inc.php | 6 +- src/Collage.php | 11 +- .../Section/CollageConfiguration.php | 19 +-- src/Enum/CollageLayoutEnum.php | 45 ------- src/Factory/CollageConfigFactory.php | 5 +- src/Utility/AdminInput.php | 39 +++--- src/Utility/CollageLayoutScanner.php | 123 +++++++++++++++++- 10 files changed, 170 insertions(+), 128 deletions(-) delete mode 100644 src/Enum/CollageLayoutEnum.php diff --git a/admin/collage-designer/components/design-selector.php b/admin/collage-designer/components/design-selector.php index 722d73d16..190cb0804 100644 --- a/admin/collage-designer/components/design-selector.php +++ b/admin/collage-designer/components/design-selector.php @@ -6,47 +6,14 @@ $languageService = LanguageService::getInstance(); -$currentDesign = ''; - -$designes = CollageLayoutScanner::scanLayouts(); +$currentDesign = $config['collage']['layout']; $optionsHtml = ' '; -foreach ($designes as $mainGroupTitle => $subGroups) { // Iterate over main groups (e.g., "Standard Layouts", "Custom Layouts") - $optionsHtml .= ''; - - // Sort subgroups by their translated titles to ensure consistent order - // This is a simple key sort (by the translated name) - ksort($subGroups); - - foreach ($subGroups as $subGroupTitle => $layouts) { // Iterate over subgroups (e.g., "Portrait Layouts", "Community Layouts") - // Add a disabled option as a heading for the subgroup - 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) { // Now these are the actual layout data - $selected = ($layoutId === $currentDesign) ? ' selected="selected"' : ''; - - // Use the null coalescing operator for "name" to avoid Deprecated warnings - // and use "id" as a fallback if "name" should be missing (which the scanner should already handle) - $displayName = htmlspecialchars($layoutData['name'] ?? $layoutData['id'] ?? '', ENT_QUOTES); - - $optionsHtml .= ''; - } - } - $optionsHtml .= ''; -} +$optionsHtml .= CollageLayoutScanner::getLayoutSelectOptionsHtml($currentDesign); // --- Preparing the $configManagerSetting array (structure only, with real values later) --- $configManagerSetting = [ diff --git a/api/admin.php b/api/admin.php index 798eba21a..6cfc70319 100644 --- a/api/admin.php +++ b/api/admin.php @@ -20,6 +20,7 @@ use Photobooth\Utility\PathUtility; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Finder\Finder; +use Photobooth\Utility\CollageLayoutScanner; header('Content-Type: application/json'); $loggerService = LoggerService::getInstance(); @@ -279,14 +280,14 @@ } // 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); $newConfig['collage']['limit'] = $limitData['limit']; $newConfig['collage']['placeholder'] = $limitData['placeholderEnabled']; - if ($newConfig['collage']['limit'] < 1) { - $newConfig['collage']['enabled'] = false; - } + if ($newConfig['collage']['limit'] < 1) { + $newConfig['collage']['enabled'] = false; } if ($newConfig['picture']['take_frame'] && $newConfig['picture']['frame'] === '') { diff --git a/assets/js/admin/buttons.js b/assets/js/admin/buttons.js index a4d5b95c5..2a955844e 100644 --- a/assets/js/admin/buttons.js +++ b/assets/js/admin/buttons.js @@ -1,7 +1,8 @@ /* eslint n/no-unsupported-features/node-builtins: "off" */ /* globals photoboothTools shellCommand csrf */ -/* Saves the admin settings via the API. +/** + * Saves the admin settings via the API. * Displays a loader during the saving process. * * @param {object} [options] - Configuration options for the save operation. @@ -89,7 +90,8 @@ function saveAdminSettings(options = {}) { }); } -/* Checks if the admin settings form has pending changes that need to be saved. +/** + * 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. diff --git a/lib/configsetup.inc.php b/lib/configsetup.inc.php index 91990c888..8045c8927 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', diff --git a/src/Collage.php b/src/Collage.php index 5e1ed5ff0..e6da724b9 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']; } @@ -407,7 +410,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..0e9b0c070 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/Utility/AdminInput.php b/src/Utility/AdminInput.php index ba909a8b5..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 . ' '; } diff --git a/src/Utility/CollageLayoutScanner.php b/src/Utility/CollageLayoutScanner.php index 36252ea69..ef387a62b 100644 --- a/src/Utility/CollageLayoutScanner.php +++ b/src/Utility/CollageLayoutScanner.php @@ -66,7 +66,7 @@ public static function scanLayouts(): array // 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); + self::scanDirectory($subDirPath, $layoutFiles[$mainGroupKey], $subGroupName, $mainGroupKey); } } @@ -79,8 +79,9 @@ public static function scanLayouts(): array * @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): void + private static function scanDirectory(string $directory, array &$layoutFiles, string $subGroupKey, string $mainGroupKey): void { $files = glob($directory . DIRECTORY_SEPARATOR . '*.json'); foreach ($files as $filePath) { @@ -98,7 +99,9 @@ private static function scanDirectory(string $directory, array &$layoutFiles, st $layoutId = basename($filePath, '.json'); - $layoutName = $layoutConfig['name'] ?? $layoutId; + $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 @@ -106,8 +109,8 @@ private static function scanDirectory(string $directory, array &$layoutFiles, st 'id' => $layoutId, 'name' => $layoutName, 'description' => $layoutConfig['description'] ?? '', - 'file_path' => $filePath, 'author' => $layoutConfig['author'] ?? 'Unknown', + 'ref_file_path' => $refFilePath, 'aspect_ratio' => $layoutConfig['aspect_ratio'] ?? '', 'width' => $layoutConfig['width'] ?? '', 'height' => $layoutConfig['height'] ?? '', @@ -164,4 +167,114 @@ private static function groupAndTranslateLayouts(array $rawLayoutFiles): array return $groupedLayouts; } -} \ No newline at end of file + + /** + * 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); + + $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; + } +} From 9fd884b2ca28a3e698087d69ba44d4ad33bf9045 Mon Sep 17 00:00:00 2001 From: reloxx13 Date: Tue, 30 Dec 2025 17:20:05 +0100 Subject: [PATCH 07/63] fix rebase missin brace, fix cs and ci --- api/admin.php | 5 +- lib/configsetup.inc.php | 2 +- src/Collage.php | 2 - .../Section/CollageConfiguration.php | 4 +- src/Utility/CollageLayoutScanner.php | 52 +++++++++++-------- 5 files changed, 35 insertions(+), 30 deletions(-) diff --git a/api/admin.php b/api/admin.php index 6cfc70319..1e7712513 100644 --- a/api/admin.php +++ b/api/admin.php @@ -286,8 +286,9 @@ $limitData = Collage::calculateLimit($newConfig['collage'], $logger); $newConfig['collage']['limit'] = $limitData['limit']; $newConfig['collage']['placeholder'] = $limitData['placeholderEnabled']; - if ($newConfig['collage']['limit'] < 1) { - $newConfig['collage']['enabled'] = false; + if ($newConfig['collage']['limit'] < 1) { + $newConfig['collage']['enabled'] = false; + } } if ($newConfig['picture']['take_frame'] && $newConfig['picture']['frame'] === '') { diff --git a/lib/configsetup.inc.php b/lib/configsetup.inc.php index 8045c8927..703c8c4b8 100644 --- a/lib/configsetup.inc.php +++ b/lib/configsetup.inc.php @@ -985,7 +985,7 @@ 'type' => 'select', 'name' => 'collage[layout]', 'data-theme-field' => 'true', - 'options_html' => CollageLayoutScanner::getLayoutSelectOptionsHtml($config['collage']['layout'] ?? $defaultConfig['collage']['layout']), + 'options_html' => CollageLayoutScanner::getLayoutSelectOptionsHtml($config['collage']['layout'] ?? $defaultConfig['collage']['layout']), ], 'collage_allow_selection' => [ 'view' => 'advanced', diff --git a/src/Collage.php b/src/Collage.php index e6da724b9..12f3b4915 100644 --- a/src/Collage.php +++ b/src/Collage.php @@ -299,8 +299,6 @@ public static function createCollage(array $config, array $srcImagePaths, string } else { $layoutConfigArray = $collageJson; } - } else { - return false; } } diff --git a/src/Configuration/Section/CollageConfiguration.php b/src/Configuration/Section/CollageConfiguration.php index 0e9b0c070..22f0ab7eb 100644 --- a/src/Configuration/Section/CollageConfiguration.php +++ b/src/Configuration/Section/CollageConfiguration.php @@ -61,9 +61,9 @@ public static function getNode(): NodeDefinition ->validate() ->ifTrue(function ($value) { if (!is_string($value) || empty($value)) { - return true; + return true; } - $absolutePath = CollageLayoutScanner::getCollageConfigPath($value); + $absolutePath = CollageLayoutScanner::getCollageConfigPath($value); return $absolutePath === null; }) ->thenInvalid('The collage layout "%s" does not exist or is invalid.') diff --git a/src/Utility/CollageLayoutScanner.php b/src/Utility/CollageLayoutScanner.php index ef387a62b..6f9470898 100644 --- a/src/Utility/CollageLayoutScanner.php +++ b/src/Utility/CollageLayoutScanner.php @@ -2,7 +2,6 @@ namespace Photobooth\Utility; -use Photobooth\Utility\PathUtility; use Photobooth\Service\LanguageService; class CollageLayoutScanner @@ -29,15 +28,15 @@ public static function scanLayouts(): array // 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') { + if ($mainGroupKey === 'private') { try { mkdir($absoluteBaseDir, 0777, true); } catch (\Exception $e) { error_log('CollageLayoutScanner: Failed to create base directory: ' . $absoluteBaseDir . ' - ' . $e->getMessage()); - continue; + continue; } } else { continue; // Skip if 'template' base dir doesn't exist (expected to be present) @@ -52,18 +51,18 @@ public static function scanLayouts(): array // Ensure the subdirectory exists, create if it's in a 'private' context and missing if (!is_dir($subDirPath)) { - if ($mainGroupKey === 'private') { + if ($mainGroupKey === 'private') { try { mkdir($subDirPath, 0777, true); } catch (\Exception $e) { error_log('CollageLayoutScanner: Failed to create subdirectory: ' . $subDirPath . ' - ' . $e->getMessage()); - continue; + 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); @@ -84,6 +83,10 @@ public static function scanLayouts(): array 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) { @@ -97,8 +100,8 @@ private static function scanDirectory(string $directory, array &$layoutFiles, st continue; } - $layoutId = basename($filePath, '.json'); - + $layoutId = basename($filePath, '.json'); + $layoutName = $layoutConfig['name'] ?? $layoutId; $refFilePath = $mainGroupKey . '/collage/' . $subGroupKey . '/' . $layoutId; @@ -108,9 +111,9 @@ private static function scanDirectory(string $directory, array &$layoutFiles, st $layoutFiles[$subGroupKey][$layoutId] = [ 'id' => $layoutId, 'name' => $layoutName, - 'description' => $layoutConfig['description'] ?? '', + 'description' => $layoutConfig['description'] ?? '', 'author' => $layoutConfig['author'] ?? 'Unknown', - 'ref_file_path' => $refFilePath, + 'ref_file_path' => $refFilePath, 'aspect_ratio' => $layoutConfig['aspect_ratio'] ?? '', 'width' => $layoutConfig['width'] ?? '', 'height' => $layoutConfig['height'] ?? '', @@ -164,7 +167,7 @@ private static function groupAndTranslateLayouts(array $rawLayoutFiles): array } } } - + return $groupedLayouts; } @@ -179,6 +182,10 @@ public static function getLayoutData(string $logicalReferencePath): ?array { $AbsFilePath = self::getCollageConfigPath($logicalReferencePath); + if ($AbsFilePath === null) { + return null; + } + $fileContent = file_get_contents($AbsFilePath); if ($fileContent === false) { @@ -192,17 +199,16 @@ public static function getLayoutData(string $logicalReferencePath): ?array return []; } - $layoutId = basename($AbsFilePath, '.json'); - - $layoutName = $layoutConfig['name'] ?? $layoutId; + $layoutId = basename($AbsFilePath, '.json'); + + $layoutName = $layoutConfig['name'] ?? $layoutId; - $layoutData = [ 'id' => $layoutId, 'name' => $layoutName, - 'ref_file_path' => $logicalReferencePath, + 'ref_file_path' => $logicalReferencePath, ]; - + $layoutData = array_merge($layoutConfig, $layoutData); return $layoutData; @@ -223,7 +229,7 @@ public static function getLayoutSelectOptionsHtml(?string $currentSelectedPath = foreach ($designes as $mainGroupTitle => $subGroups) { $optionsHtml .= ''; - + // Sort subgroups by their translated titles to ensure consistent order ksort($subGroups); @@ -234,13 +240,13 @@ public static function getLayoutSelectOptionsHtml(?string $currentSelectedPath = } // Sort the layouts within the subgroup by their name - uasort($layouts, function($a, $b) { + 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 .= '
+ + getUrl('admin/collage-designer/assets/collage-designer.js') . '">'; // Your main JS From 9cfac375c909d0cd049a2b6f7902f868301cd2cf Mon Sep 17 00:00:00 2001 From: Leon Schmitt Date: Wed, 31 Dec 2025 16:15:56 +0100 Subject: [PATCH 13/63] collage-designer: added alaignment tools --- .../components/general-tools-panel.php | 30 +++++++++++++++++++ admin/collage-designer/index.php | 26 +++++++++++----- 2 files changed, 49 insertions(+), 7 deletions(-) create mode 100644 admin/collage-designer/components/general-tools-panel.php diff --git a/admin/collage-designer/components/general-tools-panel.php b/admin/collage-designer/components/general-tools-panel.php new file mode 100644 index 000000000..c4efd4bcb --- /dev/null +++ b/admin/collage-designer/components/general-tools-panel.php @@ -0,0 +1,30 @@ + +
+ + + + + + + + + + + +
diff --git a/admin/collage-designer/index.php b/admin/collage-designer/index.php index dc37ea7c9..189cb2a08 100644 --- a/admin/collage-designer/index.php +++ b/admin/collage-designer/index.php @@ -123,13 +123,25 @@ ?>
- -
- - translate('preview_title') ?> - - -
+ + +
+ + +
+ +
+ + +
+ + translate('preview_title') ?> + + +
+ +
+
From d7a4f9110c3af12f8d99a7e588eef0ff9d242e61 Mon Sep 17 00:00:00 2001 From: Leon Schmitt Date: Wed, 31 Dec 2025 16:19:57 +0100 Subject: [PATCH 14/63] collage-desginer: implementation of alaignment tools --- .../assets/collage-designer.js | 715 ------------------ .../assets/js/collage-designer-tools.js | 270 +++++++ .../assets/js/collage-designer.js | 589 +++++++++++++++ admin/collage-designer/index.php | 3 +- 4 files changed, 861 insertions(+), 716 deletions(-) delete mode 100644 admin/collage-designer/assets/collage-designer.js create mode 100644 admin/collage-designer/assets/js/collage-designer-tools.js create mode 100644 admin/collage-designer/assets/js/collage-designer.js diff --git a/admin/collage-designer/assets/collage-designer.js b/admin/collage-designer/assets/collage-designer.js deleted file mode 100644 index 9c4be3a41..000000000 --- a/admin/collage-designer/assets/collage-designer.js +++ /dev/null @@ -1,715 +0,0 @@ -// admin/collage-designer/assets/collage-designer.js - -document.addEventListener('DOMContentLoaded', () => { - console.log('Collage Designer JS loaded.'); - - const collageCanvas = document.getElementById('collageCanvas'); - const collageCanvasWrapper = document.getElementById('collageCanvasWrapper'); - const loadingOverlay = document.getElementById('loadingOverlay'); - - const BASE_URL = typeof window.AppBaseUrl !== 'undefined' ? window.AppBaseUrl : './'; - - // --- Utility Functions for Loading Overlay --- - function showLoadingOverlay() { - if (loadingOverlay) { - loadingOverlay.style.display = 'flex'; // Use flex to center content - } - } - - function hideLoadingOverlay() { - if (loadingOverlay) { - loadingOverlay.style.display = 'none'; - } - } - // End of Utility Functions for Loading Overlay --- - - if (!collageCanvas || !collageCanvasWrapper || !initialCollageLayout || !initialDemoImagePaths) { - console.error('Required elements or data not found for collage designer.'); - return; - } - - const ctx = collageCanvas.getContext('2d'); - if (!ctx) { - console.error('Failed to get 2D rendering context for canvas.'); - return; - } - - // --- Configuration Constants --- - const BORDER_COLOR = '#000000'; // Color for drawing layout box borders - const BORDER_WIDTH = 2; // Width of layout box borders - const SELECTION_COLOR = 'rgba(0, 123, 255, 0.7)'; // Color for selected element border - - // Configuration for Resizing Handles --- - const HANDLE_SIZE = 10; // Size of the square handles - const HANDLE_COLOR = '#FFFFFF'; // Color of the handle fill - const HANDLE_STROKE_COLOR = SELECTION_COLOR; // Border color of the handle - const HANDLE_BORDER_WIDTH = 2; // Border width of the handle - // End: Configuration for Resizing Handles --- - - // Configuration for Rotation Handle --- - const ROTATION_HANDLE_SIZE = 16; // Size of the rotation handle (e.g., diameter of a circle) - const ROTATION_HANDLE_OFFSET = 20; // Distance from the top center of the element box - const ROTATION_HANDLE_COLOR = '#FFFFFF'; - const ROTATION_HANDLE_STROKE_COLOR = SELECTION_COLOR; - const ROTATION_HANDLE_ICON = '\u21BA'; // Unicode for a counter-clockwise arrow (↺) or use another icon - const ROTATION_HANDLE_ICON_FONT_SIZE = '12px Arial'; - const ROTATION_CURSOR_RELATIVE_PATH = 'assets/icons/rotate-cw.svg'; - const ROTATION_CURSOR_URL = `url("${BASE_URL}${ROTATION_CURSOR_RELATIVE_PATH}") 12 12, auto`; - // End: Configuration for Rotation Handle --- - - let currentLayout = initialCollageLayout; - let demoImagePaths = initialDemoImagePaths; - let loadedImages = []; // Cache for loaded demo images - - // --- Interactive Elements Management --- - let collageElements = []; // Array to hold instances of CollageElement - let activeElement = null; // The currently selected/dragged element - let isDragging = false; - let dragStartX, dragStartY; // Mouse position where drag started - let elementStartX, elementStartY; // Element position when drag started - - // Variables for Resizing --- - let isResizing = false; - let resizeHandle = null; // Stores which handle is being dragged ('top-left', 'bottom-right' etc.) - let initialElementWidth, initialElementHeight; // Original dimensions when resizing started - let initialElementX, initialElementY; // Original position when resizing started - // End: Variables for Resizing --- - - // Variables for Rotation --- - let isRotating = false; - let rotationStartAngle = 0; // Angle from element center to mouse start when rotation began - let initialElementRotation = 0; // Original rotation of the element when rotation began - // End: Variables for Rotation --- - - /** - * Represents an interactive element (e.g., a picture box) on the collage canvas. - */ - class CollageElement { - constructor(id, x, y, width, height, rotation, originalLayoutDataIndex) { - this.id = id; - this.x = x; - this.y = y; - this.width = width; - this.height = height; - this.rotation = rotation; // in degrees - this.originalLayoutDataIndex = originalLayoutDataIndex; // Reference to its original position in layout array - this.image = null; // Reference to the loaded image for this element - } - - /** - * Checks if a point (mouseX, mouseY) is inside this element. - * The hit test refers to the UNROTATED bounding box, - * because the interaction is with the fixed "slot" in the layout. - * @param {number} mouseX - * @param {number} mouseY - * @returns {boolean} - */ - isHit(mouseX, mouseY) { - // Hit test against the unrotated bounding box of the element. - return mouseX >= this.x && mouseX <= this.x + this.width && - mouseY >= this.y && mouseY <= this.y + this.height; - } - } - - /** - * Helper function to rotate an image and then fit it into a target size (object-fit: contain). - * This simulates the backend's behavior of rotating the image *before* placing it into a fixed box. - * @param {Image} originalImage The original loaded image. - * @param {number} degrees Rotation angle in degrees. - * @param {number} targetWidth Desired width of the final (rotated & contained) image. - * @param {number} targetHeight Desired height of the final (rotated & contained) image. - * @returns {HTMLCanvasElement} A new canvas element containing the rotated and contained image. - */ - function prepareRotatedImage(originalImage, degrees, targetWidth, targetHeight) { - const tempCanvas = document.createElement('canvas'); - const tempCtx = tempCanvas.getContext('2d'); - - // GD's imagerotate usually rotates counter-clockwise for positive degrees. - // Canvas's ctx.rotate rotates clockwise for positive radians. - // To achieve the same visual as GD's typical counter-clockwise rotation with positive values, - // we negate the degrees for Canvas's clockwise rotation. - const canvasRotationDegrees = -degrees; - - // Calculate dimensions of the temporary canvas needed to hold the *full* rotated image without cropping. - // This is the largest bounding box that would contain the rotated image. - const imgWidth = originalImage.width; - const imgHeight = originalImage.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; - - // Translate and rotate context to draw the image correctly centered on tempCanvas - tempCtx.save(); - tempCtx.translate(rotatedBoundingWidth / 2, rotatedBoundingHeight / 2); // Move origin to center of bounding box - tempCtx.rotate(canvasRotationDegrees * Math.PI / 180); // Rotate around this center - tempCtx.drawImage(originalImage, -imgWidth / 2, -imgHeight / 2, imgWidth, imgHeight); // Draw image centered at new origin - tempCtx.restore(); - - // Now, tempCanvas contains the fully rotated image (possibly with empty space if not 90/180/270 degree rotation). - // We now need to scale this rotated image from tempCanvas to fit targetWidth/targetHeight using object-fit: contain. - const finalCanvas = document.createElement('canvas'); - const finalCtx = finalCanvas.getContext('2d'); - finalCanvas.width = targetWidth; - finalCanvas.height = targetHeight; - - // --- NEW LOGIC: object-fit: contain --- - // Scale the rotated image so it fits COMPLETELY within the target box, - // without cropping any part of it. This might create empty space within the target box. - - const rotatedImgAspectRatio = tempCanvas.width / tempCanvas.height; // Actual aspect ratio of the rotated image on tempCanvas - const targetAspectRatio = targetWidth / targetHeight; - - let drawX, drawY, drawWidth, drawHeight; // Target position and size on finalCanvas - - if (rotatedImgAspectRatio > targetAspectRatio) { - // Rotated image is wider relative to its height than the target box. - // Scale based on the width of the target box. - drawWidth = targetWidth; - drawHeight = targetWidth / rotatedImgAspectRatio; - } else { - // Rotated image is taller relative to its width than the target box (or has the same aspect ratio). - // Scale based on the height of the target box. - drawHeight = targetHeight; - drawWidth = targetHeight * rotatedImgAspectRatio; - } - - // Center the scaled image within the target box (finalCanvas) - drawX = (targetWidth - drawWidth) / 2; - drawY = (targetHeight - drawHeight) / 2; - - // Draw the complete rotated image (from tempCanvas) into the calculated size and position on finalCanvas - finalCtx.drawImage(tempCanvas, 0, 0, tempCanvas.width, tempCanvas.height, drawX, drawY, drawWidth, drawHeight); - // --- END NEW LOGIC --- - - return finalCanvas; - } - - /** - * Sets the canvas dimensions and wrapper aspect ratio based on the layout data. - */ - function setupCanvasDimensions() { - const { width, height, aspect_ratio } = currentLayout; - - if (!width || !height || !aspect_ratio) { - console.warn('Layout missing width, height, or aspect_ratio. Using default 3:2.'); - // Default values for robustness - collageCanvasWrapper.style.aspectRatio = `3 / 2`; - collageCanvas.width = 900; - collageCanvas.height = 600; - return; - } - - collageCanvas.width = parseInt(width, 10); - collageCanvas.height = parseInt(height, 10); - collageCanvasWrapper.style.aspectRatio = aspect_ratio.replace(':', ' / '); - } - - /** - * Loads demo images into an array for drawing. - * @returns {Promise} A promise that resolves when all images are loaded. - */ - function loadDemoImages() { - showLoadingOverlay(); - const imagePromises = demoImagePaths.map((path, index) => { - return new Promise((resolve, reject) => { - const img = new Image(); - img.onload = () => { - loadedImages[index] = img; - resolve(); - }; - img.onerror = () => { - console.warn(`Failed to load image: ${path}. Using placeholder.`); - loadedImages[index] = null; - resolve(); - }; - img.src = path; - }); - }); - - return Promise.all(imagePromises).finally(() => { - hideLoadingOverlay(); - }); - } - - /** - * Updates the collageElements array based on the currentLayout. - * This creates our interactive "handles" for each picture box. - */ - function updateCollageElements() { - collageElements = []; // Clear existing elements - const canvasWidth = collageCanvas.width; - const canvasHeight = collageCanvas.height; - - if (!currentLayout.layout || currentLayout.layout.length === 0) { - return; - } - - currentLayout.layout.forEach((boxCoords, index) => { - // Layout data structure is [xExpr, yExpr, widthExpr, heightExpr, rotationDegreesExpr] - // We ensure rotationDegreesExpr exists before accessing - const [xExpr, yExpr, widthExpr, heightExpr, rotationDegreesExpr = '0'] = boxCoords; - - // Evaluate expressions to get pixel values - const x = eval(xExpr.replace(/x/g, canvasWidth).replace(/y/g, canvasHeight)); - const y = eval(yExpr.replace(/x/g, canvasWidth).replace(/y/g, canvasHeight)); - const width = eval(widthExpr.replace(/x/g, canvasWidth).replace(/y/g, canvasHeight)); - const height = eval(heightExpr.replace(/x/g, canvasWidth).replace(/y/g, canvasHeight)); - const rotation = parseFloat(rotationDegreesExpr); // Parse rotation from layout - - const element = new CollageElement( - `element-${index}`, // Unique ID for the element - x, y, width, height, rotation, - index // Store original index in layout array - ); - // Assign a demo image to the element for drawing - const demoImageIndex = index % loadedImages.length; - element.image = loadedImages[demoImageIndex]; - - collageElements.push(element); - }); - } - - /** - * Draws the collage layout and fills picture boxes with demo images. - * Also draws selection border for active element. - */ - function drawCollage() { - ctx.clearRect(0, 0, collageCanvas.width, collageCanvas.height); // Clear canvas - - collageElements.forEach((element) => { - const { x, y, width, height, rotation, image } = element; - - // --- LOGIC FOR IMAGE RENDERING --- - if (image) { - if (rotation !== 0) { - // If rotation is defined in the layout (not 0), - // we prepare the rotated and scaled image. - // prepareRotatedImage returns an HTMLCanvasElement, - // which can then be drawn just like an image. - const preparedImageCanvas = prepareRotatedImage(image, rotation, width, height); - ctx.drawImage(preparedImageCanvas, x, y, width, height); - } else { - // No rotation, so just draw the original image with object-fit: cover logic. - // (Note: This is still 'cover' for unrotated images. If you also want 'contain' for unrotated, - // this block would need adjustment or a call to a more general prepare function.) - const imgAspectRatio = image.width / image.height; - const boxAspectRatio = width / height; - - let sx, sy, sWidth, sHeight; // Source rectangle on the original image - - if (imgAspectRatio > boxAspectRatio) { - // Image is wider than the box, crop sides - sHeight = image.height; - sWidth = sHeight * boxAspectRatio; - sx = (image.width - sWidth) / 2; - sy = 0; - } else { - // Image is taller than the box, crop top/bottom - sWidth = image.width; - sHeight = sWidth / boxAspectRatio; - sx = 0; - sy = (image.height - sHeight) / 2; - } - // Draw the cropped original image into the static (x,y,width,height) box - ctx.drawImage(image, sx, sy, sWidth, sHeight, x, y, width, height); - } - } else { - // Draw placeholder if no image is available - ctx.fillStyle = '#CCCCCC'; - ctx.fillRect(x, y, width, height); - - ctx.fillStyle = '#666666'; - ctx.font = `${Math.min(width, height) * 0.1}px Arial`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(`Image ${element.originalLayoutDataIndex + 1}`, x + width / 2, y + height / 2); - } - - // The selection border is ALWAYS drawn around the UNROTATED BOX, - // as interaction is with the "slot" in the layout. - ctx.strokeStyle = (element === activeElement) ? SELECTION_COLOR : BORDER_COLOR; - ctx.lineWidth = BORDER_WIDTH; - ctx.strokeRect(x, y, width, height); - - // --- Draw Resizing Handles if element is active --- - if (element === activeElement) { - // Define handle positions (corners) - const handles = [ - { x: x, y: y, cursor: 'nwse-resize', name: 'top-left' }, - { x: x + width, y: y, cursor: 'nesw-resize', name: 'top-right' }, - { x: x, y: y + height, cursor: 'nesw-resize', name: 'bottom-left' }, - { x: x + width, y: y + height, cursor: 'nwse-resize', name: 'bottom-right' } - ]; - - handles.forEach(handle => { - ctx.fillStyle = HANDLE_COLOR; - ctx.strokeStyle = HANDLE_STROKE_COLOR; - ctx.lineWidth = HANDLE_BORDER_WIDTH; - // Draw a square handle centered at the calculated position - ctx.fillRect(handle.x - HANDLE_SIZE / 2, handle.y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE); - ctx.strokeRect(handle.x - HANDLE_SIZE / 2, handle.y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE); - }); - - // --- Draw Rotation Handle --- - const rotationHandleX = x + width / 2; // Center top of the box - const rotationHandleY = y - ROTATION_HANDLE_OFFSET; // Offset upwards - - ctx.beginPath(); - ctx.arc(rotationHandleX, rotationHandleY, ROTATION_HANDLE_SIZE / 2, 0, Math.PI * 2); // Circle - ctx.fillStyle = ROTATION_HANDLE_COLOR; - ctx.fill(); - ctx.strokeStyle = ROTATION_HANDLE_STROKE_COLOR; - ctx.lineWidth = HANDLE_BORDER_WIDTH; - ctx.stroke(); - - // Draw the rotation icon (optional, using unicode character) - ctx.fillStyle = ROTATION_HANDLE_STROKE_COLOR; - ctx.font = ROTATION_HANDLE_ICON_FONT_SIZE; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(ROTATION_HANDLE_ICON, rotationHandleX, rotationHandleY); - // --- END: Draw Rotation Handle --- - } - // --- END: Draw Resizing Handles --- - // --- END LOGIC --- - }); - } - - /** - * Helper to get mouse coordinates relative to the canvas. - * @param {MouseEvent} event - * @returns {{x: number, y: number}} - */ - function getMousePos(event) { - const rect = collageCanvas.getBoundingClientRect(); - // Scale mouse coordinates to canvas coordinates (canvas resolution vs. display size) - const scaleX = collageCanvas.width / rect.width; - const scaleY = collageCanvas.height / rect.height; - return { - x: (event.clientX - rect.left) * scaleX, - y: (event.clientY - rect.top) * scaleY - }; - } - - /** - * Event handler for mouse down. - * @param {MouseEvent} event - */ - function handleMouseDown(event) { - const mouse = getMousePos(event); - - // --- Check for Rotation Handle hit FIRST --- - if (activeElement) { - const rotationHandleX = activeElement.x + activeElement.width / 2; - const rotationHandleY = activeElement.y - ROTATION_HANDLE_OFFSET; - - // Calculate distance from mouse to center of rotation handle - const dist = Math.sqrt( - Math.pow(mouse.x - rotationHandleX, 2) + - Math.pow(mouse.y - rotationHandleY, 2) - ); - - if (dist <= ROTATION_HANDLE_SIZE / 2) { // Mouse hit the rotation handle - isRotating = true; - // Calculate the angle from the element's center to the mouse position - const elementCenterX = activeElement.x + activeElement.width / 2; - const elementCenterY = activeElement.y + activeElement.height / 2; - rotationStartAngle = Math.atan2(mouse.y - elementCenterY, mouse.x - elementCenterX); - initialElementRotation = activeElement.rotation; - collageCanvas.style.cursor = ROTATION_CURSOR_URL; // Or a specific rotate cursor - drawCollage(); - return; // Rotation handle clicked, don't proceed further - } - } - // --- END: Check for Rotation Handle hit --- - - // Check for handle hit FIRST, if an element is active - if (activeElement) { - const handles = [ - { x: activeElement.x, y: activeElement.y, name: 'top-left' }, - { x: activeElement.x + activeElement.width, y: activeElement.y, name: 'top-right' }, - { x: activeElement.x, y: activeElement.y + activeElement.height, name: 'bottom-left' }, - { x: activeElement.x + activeElement.width, y: activeElement.y + activeElement.height, name: 'bottom-right' } - ]; - - for (const handle of handles) { - // Check if mouse is within the handle's clickable area - if (mouse.x >= handle.x - HANDLE_SIZE / 2 && mouse.x <= handle.x + HANDLE_SIZE / 2 && - mouse.y >= handle.y - HANDLE_SIZE / 2 && mouse.y <= handle.y + HANDLE_SIZE / 2) { - - isResizing = true; - resizeHandle = handle.name; - dragStartX = mouse.x; - dragStartY = mouse.y; - initialElementWidth = activeElement.width; - initialElementHeight = activeElement.height; - initialElementX = activeElement.x; - initialElementY = activeElement.y; - - // Set cursor based on handle - switch(resizeHandle) { - case 'top-left': - case 'bottom-right': - collageCanvas.style.cursor = 'nwse-resize'; - break; - case 'top-right': - case 'bottom-left': - collageCanvas.style.cursor = 'nesw-resize'; - break; - } - drawCollage(); // Redraw to potentially update cursor visually if needed (though browser does this) - return; // Handle clicked, don't proceed to drag logic - } - } - } - - // If no handle was clicked, proceed with element dragging logic - // Iterate elements in reverse order to select topmost - for (let i = collageElements.length - 1; i >= 0; i--) { - const element = collageElements[i]; - if (element.isHit(mouse.x, mouse.y)) { - activeElement = element; - isDragging = true; - dragStartX = mouse.x; - dragStartY = mouse.y; - elementStartX = element.x; - elementStartY = element.y; - collageCanvas.style.cursor = 'grabbing'; // Change cursor - drawCollage(); // Redraw to show selection border and handles - return; // Only select one element - } - } - activeElement = null; // Deselect if no element was hit - drawCollage(); // Redraw to remove selection border and handles - } - - /** - * Event handler for mouse move. - * @param {MouseEvent} event - */ - function handleMouseMove(event) { - const mouse = getMousePos(event); - - // --- Cursor hover for handles --- - // --- Cursor hover for Rotation Handle --- - let cursorChanged = false; - if (activeElement && !isDragging && !isResizing && !isRotating) { - const rotationHandleX = activeElement.x + activeElement.width / 2; - const rotationHandleY = activeElement.y - ROTATION_HANDLE_OFFSET; - const dist = Math.sqrt( - Math.pow(mouse.x - rotationHandleX, 2) + - Math.pow(mouse.y - rotationHandleY, 2) - ); - if (dist <= ROTATION_HANDLE_SIZE / 2) { - collageCanvas.style.cursor = ROTATION_CURSOR_URL; // Or specific 'ew-resize' / 'grabbing' for rotation - cursorChanged = true; - } - } - // --- Cursor hover for scale Handle --- - if (activeElement && !isDragging && !isResizing) { // Only change cursor if not dragging/resizing - const handles = [ - { x: activeElement.x, y: activeElement.y, cursor: 'nwse-resize', name: 'top-left' }, - { x: activeElement.x + activeElement.width, y: activeElement.y, cursor: 'nesw-resize', name: 'top-right' }, - { x: activeElement.x, y: activeElement.y + activeElement.height, cursor: 'nesw-resize', name: 'bottom-left' }, - { x: activeElement.x + activeElement.width, y: activeElement.y + activeElement.height, cursor: 'nwse-resize', name: 'bottom-right' } - ]; - for (const handle of handles) { - if (mouse.x >= handle.x - HANDLE_SIZE / 2 && mouse.x <= handle.x + HANDLE_SIZE / 2 && - mouse.y >= handle.y - HANDLE_SIZE / 2 && mouse.y <= handle.y + HANDLE_SIZE / 2) { - collageCanvas.style.cursor = handle.cursor; - cursorChanged = true; - break; - } - } - } - if (!cursorChanged && !isDragging && !isResizing && !isRotating) { - // Restore default cursor if not over a handle and not dragging/resizing - collageCanvas.style.cursor = 'grab'; // Default cursor for draggable elements - } - // --- END: Cursor hover for handles --- - - // --- Rotation Logic --- - if (isRotating && activeElement) { - const elementCenterX = activeElement.x + activeElement.width / 2; - const elementCenterY = activeElement.y + activeElement.height / 2; - - // Calculate current angle from element center to mouse position - const currentAngle = Math.atan2(mouse.y - elementCenterY, mouse.x - elementCenterX); - - // Calculate the difference in angle since rotation started - let angleDiff = (rotationStartAngle - currentAngle) * 180 / Math.PI; // Convert to degrees - - // Apply rotation (snap to 15-degree increments if Shift is pressed) - let newRotation = initialElementRotation + angleDiff; - if (event.shiftKey) { - newRotation = Math.round(newRotation / 15) * 15; - } - // Normalize rotation to be within 0-360 degrees if desired, or allow negative - activeElement.rotation = (newRotation % 360 + 360) % 360; - - drawCollage(); - return; // Rotation is active, don't proceed to resizing/dragging - } - // --- END: Rotation Logic --- - - // --- Scaling Logic --- - if (isResizing && activeElement) { - const dx = mouse.x - dragStartX; - const dy = mouse.y - dragStartY; - - let newWidth = initialElementWidth; - let newHeight = initialElementHeight; - let newX = initialElementX; - let newY = initialElementY; - - const aspectRatio = initialElementWidth / initialElementHeight; - - switch (resizeHandle) { - case 'top-left': - newWidth = initialElementWidth - dx; - newHeight = initialElementHeight - dy; - if (event.shiftKey) { // Maintain aspect ratio - if (Math.abs(dx) > Math.abs(dy)) { // Adjust based on larger movement - newHeight = newWidth / aspectRatio; - } else { - newWidth = newHeight * aspectRatio; - } - } - newX = initialElementX + (initialElementWidth - newWidth); - newY = initialElementY + (initialElementHeight - newHeight); - break; - case 'top-right': - newWidth = initialElementWidth + dx; - newHeight = initialElementHeight - dy; - if (event.shiftKey) { // Maintain aspect ratio - if (Math.abs(dx) > Math.abs(dy)) { - newHeight = newWidth / aspectRatio; - } else { - newWidth = newHeight * aspectRatio; - } - } - newY = initialElementY + (initialElementHeight - newHeight); - break; - case 'bottom-left': - newWidth = initialElementWidth - dx; - newHeight = initialElementHeight + dy; - if (event.shiftKey) { // Maintain aspect ratio - if (Math.abs(dx) > Math.abs(dy)) { - newHeight = newWidth / aspectRatio; - } else { - newWidth = newHeight * aspectRatio; - } - } - newX = initialElementX + (initialElementWidth - newWidth); - break; - case 'bottom-right': - newWidth = initialElementWidth + dx; - newHeight = initialElementHeight + dy; - if (event.shiftKey) { // Maintain aspect ratio - if (Math.abs(dx) > Math.abs(dy)) { - newHeight = newWidth / aspectRatio; - } else { - newWidth = newHeight * aspectRatio; - } - } - break; - } - - // Apply minimum size constraint - const MIN_SIZE = 20; // Example minimum size - if (newWidth < MIN_SIZE) { - newWidth = MIN_SIZE; - if (event.shiftKey) newHeight = newWidth / aspectRatio; - // Recalculate X if resizing from left - if (resizeHandle.includes('left')) newX = initialElementX + (initialElementWidth - newWidth); - } - if (newHeight < MIN_SIZE) { - newHeight = MIN_SIZE; - if (event.shiftKey) newWidth = newHeight * aspectRatio; - // Recalculate Y if resizing from top - if (resizeHandle.includes('top')) newY = initialElementY + (initialElementHeight - newHeight); - } - - activeElement.x = newX; - activeElement.y = newY; - activeElement.width = newWidth; - activeElement.height = newHeight; - - drawCollage(); - return; // Resizing, so don't proceed to drag logic - } - // --- END: Scaling Logic --- - - // --- Dragging Logic --- - if (isDragging && activeElement) { - const dx = mouse.x - dragStartX; - const dy = mouse.y - dragStartY; - - activeElement.x = elementStartX + dx; - activeElement.y = elementStartY + dy; - - drawCollage(); - return; // Dragging, so done - } - // --- END: Dragging Logic --- - - // If neither resizing nor dragging, just update cursor based on hover - // (This part is handled by the new cursor hover block at the beginning) - } - - /** - * Event handler for mouse up. - * @param {MouseEvent} event - */ - function handleMouseUp(event) { - if (isDragging) { - isDragging = false; - // TODO: Update the currentLayout.layout array with the new element position - // (Only if changes should persist, for now internal `activeElement.x/y` are updated). - } - if (isResizing) { - isResizing = false; - // TODO: Update the currentLayout.layout array with the new element size/position - // (Only if changes should persist, for now internal `activeElement.width/height/x/y` are updated). - } - if (isRotating) { - isRotating = false; - // TODO: Update currentLayout.layout for rotation - } - - // Restore default cursor, but first check if still over an element that could be grabbed - if (activeElement) { - collageCanvas.style.cursor = 'grab'; // Restore grab cursor if an element is active - } else { - collageCanvas.style.cursor = 'default'; // Or default if nothing is active - } - // No need to deselect activeElement here, as it might still be selected for further interaction - // If you want to deselect after every drag/resize, move 'activeElement = null;' here. - drawCollage(); // Final redraw - } - - /** - * Initializes the designer. - */ - async function initDesigner() { - setupCanvasDimensions(); - await loadDemoImages(); - updateCollageElements(); // Initialize interactive elements - drawCollage(); // Initial draw - } - - // --- Event Listeners --- - collageCanvas.addEventListener('mousedown', handleMouseDown); - collageCanvas.addEventListener('mousemove', handleMouseMove); - collageCanvas.addEventListener('mouseup', handleMouseUp); - collageCanvas.addEventListener('mouseout', handleMouseUp); // End drag if mouse leaves canvas - - // Initial setup - initDesigner(); -}); \ 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..b3ed44dd5 --- /dev/null +++ b/admin/collage-designer/assets/js/collage-designer-tools.js @@ -0,0 +1,270 @@ +// 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') { + 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 currently selected elements from all known element arrays. + * @returns {Array} An array of selected elements. + */ + function getSelectedElements() { + const selected = []; + // Add elements from window.collageElements (your image boxes) + if (window.collageElements) { + window.collageElements.forEach(el => { + if (el.isSelected) selected.push(el); + }); + } + // Add elements from other global arrays if they exist and are selected + if (window.textFields) { // Assuming textFields have an isSelected property + window.textFields.forEach(tf => { + if (tf.isSelected) selected.push(tf); + }); + } + if (window.imagePlaceholders) { // Assuming imagePlaceholders (if different from collageElements) have an isSelected property + window.imagePlaceholders.forEach(ip => { + if (ip.isSelected) selected.push(ip); + }); + } + return selected; + } + + /** + * Gets the canvas dimensions. + * @returns {{width: number, height: number}} + */ + function getCanvasDimensions() { + return { + width: window.collageCanvas.width, + height: window.collageCanvas.height + }; + } + + // --- Alignment Functions --- + + // General alignment logic function to avoid repetition + function applyAlignment(property, targetValueFn, useActiveElementAsReference = false) { + const canvas = getCanvasDimensions(); + let elementsToAlign = getSelectedElements(); + + if (elementsToAlign.length === 0) { + console.log(`No elements selected for ${property} alignment.`); + return; + } + + let referenceValue; + if (elementsToAlign.length === 1 || !useActiveElementAsReference) { + // Single element or multi-selection without specific active element reference: align to canvas + referenceValue = targetValueFn(canvas, elementsToAlign); + } else { + // Multiple elements selected AND activeElement exists: align to activeElement + if (!window.activeElement || !window.activeElement.isSelected) { + console.log('Multiple elements selected, but no valid active element to use as reference for alignment.'); + return; + } + referenceValue = targetValueFn(window.activeElement, elementsToAlign); + } + + // Apply alignment + elementsToAlign.forEach(element => { + // This assumes `property` is 'x' or 'y' and `width` or `height` are available on element. + // Adjust `element[property] = ...` based on specific alignment logic. + // For example, for 'alignLeft', `element.x = referenceValue`. + // For 'alignCenterH', `element.x = referenceValue - element.width / 2`. + // The `targetValueFn` should return the target X or Y coordinate based on the reference. + element[property] = referenceValue - (property === 'x' ? element.width / 2 : element.height / 2); // Default center adjustment + if (property === 'x' && elementsToAlign.length > 1 && !useActiveElementAsReference) { + // If aligning multiple elements to the leftmost of the group, referenceValue is the leftmost X. + // Each element's X should then be set to this reference X. + element[property] = referenceValue; + } + if (property === 'y' && elementsToAlign.length > 1 && !useActiveElementAsReference) { + // Similar for vertical alignment to topmost of the group. + element[property] = referenceValue; + } + }); + + window.drawCanvas(); // Redraw after changing position + } + + // --- Horizontal Alignment --- + + // Align selected elements to the left edge (Canvas / Leftmost of Group / Active Element's left) + document.getElementById('alignLeftBtn').addEventListener('click', () => { + const elementsToAlign = 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', () => { + const elementsToAlign = 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', () => { + const elementsToAlign = 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', () => { + const elementsToAlign = 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', () => { + const elementsToAlign = 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', () => { + const elementsToAlign = 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 buttons will be implemented later, as they are more complex. + // document.getElementById('distributeHBtn').addEventListener('click', () => { /* ... */ }); + // document.getElementById('distributeVBtn').addEventListener('click', () => { /* ... */ }); +}); \ No newline at end of file diff --git a/admin/collage-designer/assets/js/collage-designer.js b/admin/collage-designer/assets/js/collage-designer.js new file mode 100644 index 000000000..449a6ce3d --- /dev/null +++ b/admin/collage-designer/assets/js/collage-designer.js @@ -0,0 +1,589 @@ +// admin/collage-designer/assets/js/collage-designer.js + +document.addEventListener('DOMContentLoaded', () => { + console.log('Collage Designer JS loaded.'); + + // --- Global Variables (exposed via window for external scripts) --- + window.collageCanvas = document.getElementById('collageCanvas'); + window.collageCanvasWrapper = document.getElementById('collageCanvasWrapper'); + window.loadingOverlay = document.getElementById('loadingOverlay'); + + window.ctx = window.collageCanvas.getContext('2d'); + window.collageElements = []; + window.activeElement = null; // Represents the *single* element currently being interacted with (dragged, resized, rotated) + + window.textFields = []; + window.imagePlaceholders = []; + + // --- Local Variables (not exposed globally) --- + const BASE_URL = typeof window.AppBaseUrl !== 'undefined' ? window.AppBaseUrl : './'; + + let currentLayout = initialCollageLayout; + let demoImagePaths = initialDemoImagePaths; + let loadedImages = []; + + 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; + + // --- Utility Functions for Loading Overlay --- + function showLoadingOverlay() { + if (window.loadingOverlay) { + window.loadingOverlay.style.display = 'flex'; + } + } + + function hideLoadingOverlay() { + if (window.loadingOverlay) { + window.loadingOverlay.style.display = 'none'; + } + } + + if (!window.collageCanvas || !window.collageCanvasWrapper || !initialCollageLayout || !initialDemoImagePaths) { + console.error('Required elements or data not found for collage designer.'); + return; + } + + if (!window.ctx) { + console.error('Failed to get 2D rendering context for canvas.'); + return; + } + + // --- Configuration Constants --- + const BORDER_COLOR = '#000000'; + const BORDER_WIDTH = 2; + const SELECTION_COLOR = 'rgba(0, 123, 255, 0.7)'; + + const HANDLE_SIZE = 10; + const HANDLE_COLOR = '#FFFFFF'; + const HANDLE_STROKE_COLOR = SELECTION_COLOR; + const HANDLE_BORDER_WIDTH = 2; + + const ROTATION_HANDLE_SIZE = 16; + const ROTATION_HANDLE_OFFSET = 20; + const ROTATION_HANDLE_COLOR = '#FFFFFF'; + const ROTATION_HANDLE_STROKE_COLOR = SELECTION_COLOR; + const ROTATION_HANDLE_ICON = '\u21BA'; + const ROTATION_HANDLE_ICON_FONT_SIZE = '12px Arial'; + const ROTATION_CURSOR_RELATIVE_PATH = 'assets/icons/rotate-cw.svg'; + const ROTATION_CURSOR_URL = `url("${BASE_URL}${ROTATION_CURSOR_RELATIVE_PATH}") 12 12, auto`; + + class CollageElement { + constructor(id, x, y, width, height, rotation, originalLayoutDataIndex) { + this.id = id; + this.x = x; + this.y = y; + this.width = width; + this.height = height; + this.rotation = rotation; + this.originalLayoutDataIndex = originalLayoutDataIndex; + this.image = null; + this.isSelected = false; // Tracks if element is part of a selection (multi or single) + } + + isHit(mouseX, mouseY) { + return mouseX >= this.x && mouseX <= this.x + this.width && + mouseY >= this.y && mouseY <= this.y + this.height; + } + } + + function prepareRotatedImage(originalImage, degrees, targetWidth, targetHeight) { + const tempCanvas = document.createElement('canvas'); + const tempCtx = tempCanvas.getContext('2d'); + const canvasRotationDegrees = -degrees; + const imgWidth = originalImage.width; + const imgHeight = originalImage.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(originalImage, -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 setupCanvasDimensions() { + const { width, height, aspect_ratio } = 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(':', ' / '); + } + + function loadDemoImages() { + showLoadingOverlay(); + const imagePromises = demoImagePaths.map((path, index) => { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + loadedImages[index] = img; + resolve(); + }; + img.onerror = () => { + console.warn(`Failed to load image: ${path}. Using placeholder.`); + loadedImages[index] = null; + resolve(); + }; + img.src = path; + }); + }); + return Promise.all(imagePromises).finally(() => { + hideLoadingOverlay(); + }); + } + + function updateCollageElements() { + window.collageElements = []; + const canvasWidth = window.collageCanvas.width; + const canvasHeight = window.collageCanvas.height; + if (!currentLayout.layout || currentLayout.layout.length === 0) { + return; + } + currentLayout.layout.forEach((boxCoords, index) => { + const [xExpr, yExpr, widthExpr, heightExpr, rotationDegreesExpr = '0'] = boxCoords; + const x = eval(xExpr.replace(/x/g, canvasWidth).replace(/y/g, canvasHeight)); + const y = eval(yExpr.replace(/x/g, canvasWidth).replace(/y/g, canvasHeight)); + const width = eval(widthExpr.replace(/x/g, canvasWidth).replace(/y/g, canvasHeight)); + const height = eval(heightExpr.replace(/x/g, canvasWidth).replace(/y/g, canvasHeight)); + const rotation = parseFloat(rotationDegreesExpr); + const element = new CollageElement( + `element-${index}`, + x, y, width, height, rotation, + index + ); + const demoImageIndex = index % loadedImages.length; + element.image = loadedImages[demoImageIndex]; + window.collageElements.push(element); + }); + } + + window.drawCanvas = function() { + window.ctx.clearRect(0, 0, window.collageCanvas.width, window.collageCanvas.height); + + window.collageElements.forEach((element) => { + const { x, y, width, height, rotation, image } = element; + + if (image) { + if (rotation !== 0) { + const preparedImageCanvas = prepareRotatedImage(image, rotation, width, height); + window.ctx.drawImage(preparedImageCanvas, x, y, width, height); + } else { + const imgAspectRatio = image.width / image.height; + const boxAspectRatio = width / height; + let sx, sy, sWidth, sHeight; + if (imgAspectRatio > boxAspectRatio) { + sHeight = image.height; + sWidth = sHeight * boxAspectRatio; + sx = (image.width - sWidth) / 2; + sy = 0; + } else { + sWidth = image.width; + sHeight = sWidth / boxAspectRatio; + sx = 0; + sy = (image.height - sHeight) / 2; + } + window.ctx.drawImage(image, sx, sy, sWidth, sHeight, x, y, width, height); + } + } else { + window.ctx.fillStyle = '#CCCCCC'; + window.ctx.fillRect(x, y, width, height); + window.ctx.fillStyle = '#666666'; + window.ctx.font = `${Math.min(width, height) * 0.1}px Arial`; + window.ctx.textAlign = 'center'; + window.ctx.textBaseline = 'middle'; + window.ctx.fillText(`Image ${element.originalLayoutDataIndex + 1}`, x + width / 2, y + height / 2); + } + + // Draw selection border for ALL selected elements + if (element.isSelected) { // <--- KORREKTUR: Selection border für alle isSelected Elemente + window.ctx.strokeStyle = SELECTION_COLOR; + window.ctx.lineWidth = BORDER_WIDTH; + window.ctx.strokeRect(x, y, width, height); + } else { + // Only draw default border if not selected + window.ctx.strokeStyle = BORDER_COLOR; + window.ctx.lineWidth = BORDER_WIDTH; + window.ctx.strokeRect(x, y, width, height); + } + + // --- Draw Resizing Handles and Rotation Handle ONLY FOR THE ACTIVE ELEMENT --- + if (element === window.activeElement) { // <--- KORREKTUR: Handles nur für activeElement + const handles = [ + { x: x, y: y, cursor: 'nwse-resize', name: 'top-left' }, + { x: x + width, y: y, cursor: 'nesw-resize', name: 'top-right' }, + { x: x, y: y + height, cursor: 'nesw-resize', name: 'bottom-left' }, + { x: x + width, y: y + height, cursor: 'nwse-resize', name: 'bottom-right' } + ]; + handles.forEach(handle => { + window.ctx.fillStyle = HANDLE_COLOR; + window.ctx.strokeStyle = HANDLE_STROKE_COLOR; + window.ctx.lineWidth = HANDLE_BORDER_WIDTH; + window.ctx.fillRect(handle.x - HANDLE_SIZE / 2, handle.y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE); + window.ctx.strokeRect(handle.x - HANDLE_SIZE / 2, handle.y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE); + }); + + const rotationHandleX = x + width / 2; + const rotationHandleY = y - ROTATION_HANDLE_OFFSET; + window.ctx.beginPath(); + window.ctx.arc(rotationHandleX, rotationHandleY, ROTATION_HANDLE_SIZE / 2, 0, Math.PI * 2); + window.ctx.fillStyle = ROTATION_HANDLE_COLOR; + window.ctx.fill(); + window.ctx.strokeStyle = ROTATION_HANDLE_STROKE_COLOR; + window.ctx.lineWidth = HANDLE_BORDER_WIDTH; + window.ctx.stroke(); + window.ctx.fillStyle = ROTATION_HANDLE_STROKE_COLOR; + window.ctx.font = ROTATION_HANDLE_ICON_FONT_SIZE; + window.ctx.textAlign = 'center'; + window.ctx.textBaseline = 'middle'; + window.ctx.fillText(ROTATION_HANDLE_ICON, rotationHandleX, rotationHandleY); + } + }); + }; + + 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 + }; + } + + function handleMouseDown(event) { + const mouse = getMousePos(event); + + // Reset interaction flags + isResizing = false; + isRotating = false; + isDragging = false; + + // Save current active element before potentially changing it + const prevActiveElement = window.activeElement; + + // --- Check for Rotation Handle hit FIRST --- + if (prevActiveElement && prevActiveElement.isSelected) { // Check if it's selected and active + const rotationHandleX = prevActiveElement.x + prevActiveElement.width / 2; + const rotationHandleY = prevActiveElement.y - ROTATION_HANDLE_OFFSET; + const dist = Math.sqrt( + Math.pow(mouse.x - rotationHandleX, 2) + + Math.pow(mouse.y - rotationHandleY, 2) + ); + if (dist <= ROTATION_HANDLE_SIZE / 2) { + isRotating = true; + const elementCenterX = prevActiveElement.x + prevActiveElement.width / 2; + const elementCenterY = prevActiveElement.y + prevActiveElement.height / 2; + rotationStartAngle = Math.atan2(mouse.y - elementCenterY, mouse.x - elementCenterX); + initialElementRotation = prevActiveElement.rotation; + window.collageCanvas.style.cursor = ROTATION_CURSOR_URL; + window.drawCanvas(); + return; // Rotation handle clicked, don't proceed further + } + } + + // Check for handle hit SECOND (Resizing Handles) + if (prevActiveElement && prevActiveElement.isSelected) { // Check if it's selected and active + const handles = [ + { x: prevActiveElement.x, y: prevActiveElement.y, name: 'top-left' }, + { x: prevActiveElement.x + prevActiveElement.width, y: prevActiveElement.y, name: 'top-right' }, + { x: prevActiveElement.x, y: prevActiveElement.y + prevActiveElement.height, name: 'bottom-left' }, + { x: prevActiveElement.x + prevActiveElement.width, y: prevActiveElement.y + prevActiveElement.height, name: 'bottom-right' } + ]; + + for (const handle of handles) { + if (mouse.x >= handle.x - HANDLE_SIZE / 2 && mouse.x <= handle.x + HANDLE_SIZE / 2 && + mouse.y >= handle.y - HANDLE_SIZE / 2 && mouse.y <= handle.y + HANDLE_SIZE / 2) { + + isResizing = true; + resizeHandle = handle.name; + dragStartX = mouse.x; + dragStartY = mouse.y; + initialElementWidth = prevActiveElement.width; + initialElementHeight = prevActiveElement.height; + initialElementX = prevActiveElement.x; + initialElementY = prevActiveElement.y; + + switch(resizeHandle) { + case 'top-left': case 'bottom-right': window.collageCanvas.style.cursor = 'nwse-resize'; break; + case 'top-right': case 'bottom-left': window.collageCanvas.style.cursor = 'nesw-resize'; break; + } + window.drawCanvas(); + return; // Handle clicked, don't proceed to drag logic + } + } + } + + // --- Handle Clicks on Elements for Selection/Dragging --- + 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(mouse.x, mouse.y)) { + elementClicked = element; + clickedOnElement = true; + break; + } + } + + 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) { // Only deselect if a different element is clicked + window.collageElements.forEach(el => el.isSelected = false); + } + elementClicked.isSelected = true; + window.activeElement = elementClicked; // The clicked element becomes the active one + } + isDragging = true; + dragStartX = mouse.x; + dragStartY = mouse.y; + elementStartX = window.activeElement.x; + elementStartY = window.activeElement.y; + window.collageCanvas.style.cursor = 'grabbing'; + } else { + // No element was clicked + if (!event.ctrlKey && !event.metaKey) { // If no multi-selection key, deselect all + window.collageElements.forEach(el => el.isSelected = false); + window.activeElement = null; + } + } + window.drawCanvas(); // Redraw to reflect selection/deselection and active element handles + } + + function handleMouseMove(event) { + const mouse = getMousePos(event); + + // --- Cursor hover for handles --- + let cursorChanged = false; + if (!isDragging && !isResizing && !isRotating) { // Only change cursor if not interacting + // Check rotation handle hover + if (window.activeElement && window.activeElement.isSelected) { // Check active element for handles + const rotationHandleX = window.activeElement.x + window.activeElement.width / 2; + const rotationHandleY = window.activeElement.y - ROTATION_HANDLE_OFFSET; + const dist = Math.sqrt( + Math.pow(mouse.x - rotationHandleX, 2) + + Math.pow(mouse.y - rotationHandleY, 2) + ); + if (dist <= ROTATION_HANDLE_SIZE / 2) { + window.collageCanvas.style.cursor = ROTATION_CURSOR_URL; + cursorChanged = true; + } + } + + // Check resize handles hover + if (window.activeElement && window.activeElement.isSelected && !cursorChanged) { // Check active element for handles + const handles = [ + { x: window.activeElement.x, y: window.activeElement.y, cursor: 'nwse-resize', name: 'top-left' }, + { x: window.activeElement.x + window.activeElement.width, y: window.activeElement.y, cursor: 'nesw-resize', name: 'top-right' }, + { x: window.activeElement.x, y: window.activeElement.y + window.activeElement.height, cursor: 'nesw-resize', name: 'bottom-left' }, + { x: window.activeElement.x + window.activeElement.width, y: window.activeElement.y + window.activeElement.height, cursor: 'nwse-resize', name: 'bottom-right' } + ]; + for (const handle of handles) { + if (mouse.x >= handle.x - HANDLE_SIZE / 2 && mouse.x <= handle.x + HANDLE_SIZE / 2 && + mouse.y >= handle.y - HANDLE_SIZE / 2 && mouse.y <= handle.y + HANDLE_SIZE / 2) { + window.collageCanvas.style.cursor = handle.cursor; + cursorChanged = true; + break; + } + } + } + } + + if (!cursorChanged && !isDragging && !isResizing && !isRotating) { + // Default cursor: If over a selected element (which is not active), show 'grab'. Otherwise 'default'. + let overSelectable = false; + 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'; + } + + // --- Rotation Logic --- + if (isRotating && window.activeElement) { + const elementCenterX = window.activeElement.x + window.activeElement.width / 2; + const elementCenterY = window.activeElement.y + window.activeElement.height / 2; + const currentAngle = Math.atan2(mouse.y - elementCenterY, mouse.x - elementCenterX); + let angleDiff = (rotationStartAngle - currentAngle) * 180 / Math.PI; + let newRotation = initialElementRotation + angleDiff; + if (event.shiftKey) { + newRotation = Math.round(newRotation / 15) * 15; + } + window.activeElement.rotation = (newRotation % 360 + 360) % 360; + window.drawCanvas(); + return; + } + + // --- Scaling Logic --- + if (isResizing && window.activeElement) { + const dx = mouse.x - dragStartX; + const dy = mouse.y - dragStartY; + + let newWidth = initialElementWidth; + let newHeight = initialElementHeight; + let newX = initialElementX; + let newY = initialElementY; + + const aspectRatio = initialElementWidth / initialElementHeight; + + switch (resizeHandle) { + case 'top-left': + newWidth = initialElementWidth - dx; + newHeight = initialElementHeight - dy; + if (event.shiftKey) { + if (Math.abs(dx) > Math.abs(dy)) { newHeight = newWidth / aspectRatio; } else { newWidth = newHeight * aspectRatio; } + } + newX = initialElementX + (initialElementWidth - newWidth); + newY = initialElementY + (initialElementHeight - newHeight); + break; + case 'top-right': + newWidth = initialElementWidth + dx; + newHeight = initialElementHeight - dy; + if (event.shiftKey) { + if (Math.abs(dx) > Math.abs(dy)) { newHeight = newWidth / aspectRatio; } else { newWidth = newHeight * aspectRatio; } + } + newY = initialElementY + (initialElementHeight - newHeight); + break; + case 'bottom-left': + newWidth = initialElementWidth - dx; + newHeight = initialElementHeight + dy; + if (event.shiftKey) { + if (Math.abs(dx) > Math.abs(dy)) { newHeight = newWidth / aspectRatio; } else { newWidth = newHeight * aspectRatio; } + } + newX = initialElementX + (initialElementWidth - newWidth); + break; + case 'bottom-right': + newWidth = initialElementWidth + dx; + newHeight = initialElementHeight + dy; + if (event.shiftKey) { + if (Math.abs(dx) > Math.abs(dy)) { newHeight = newWidth / aspectRatio; } else { newWidth = newHeight * aspectRatio; } + } + break; + } + + const MIN_SIZE = 20; + if (newWidth < MIN_SIZE) { + newWidth = MIN_SIZE; + if (event.shiftKey) newHeight = newWidth / aspectRatio; + if (resizeHandle.includes('left')) newX = initialElementX + (initialElementWidth - newWidth); + } + if (newHeight < MIN_SIZE) { + newHeight = MIN_SIZE; + if (event.shiftKey) newWidth = newHeight * aspectRatio; + if (resizeHandle.includes('top')) newY = initialElementY + (initialElementHeight - newHeight); + } + + window.activeElement.x = newX; + window.activeElement.y = newY; + window.activeElement.width = newWidth; + window.activeElement.height = newHeight; + + window.drawCanvas(); + return; + } + + // --- Dragging Logic --- + if (isDragging && window.activeElement) { + const dx = mouse.x - dragStartX; + const dy = mouse.y - dragStartY; + + window.activeElement.x = elementStartX + dx; + window.activeElement.y = elementStartY + dy; + + 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'; + window.drawCanvas(); + } + + async function initDesigner() { + setupCanvasDimensions(); + await loadDemoImages(); + updateCollageElements(); + window.drawCanvas(); + } + + // --- 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 + + initDesigner(); +}); \ No newline at end of file diff --git a/admin/collage-designer/index.php b/admin/collage-designer/index.php index 189cb2a08..4b447903a 100644 --- a/admin/collage-designer/index.php +++ b/admin/collage-designer/index.php @@ -183,7 +183,8 @@ getUrl('admin/collage-designer/assets/collage-designer.js') . '">'; // Your main JS +echo ''; // Your main JS +echo ''; // Tools JS // Optional: Specific toasts/messages depending on PHP processing if (isset($_SESSION['designer_message'])) { echo ''; From dd7f02752ab1cf24b12348025bb18b5f86e8afa1 Mon Sep 17 00:00:00 2001 From: Leon Schmitt Date: Wed, 31 Dec 2025 18:05:12 +0100 Subject: [PATCH 15/63] collage-designer: added grouped moving, rotation and scaling. - removed unproportional scaling (didn't work reliable) --- .../assets/js/collage-designer.js | 485 +++++++++++++----- 1 file changed, 353 insertions(+), 132 deletions(-) diff --git a/admin/collage-designer/assets/js/collage-designer.js b/admin/collage-designer/assets/js/collage-designer.js index 449a6ce3d..4cebe4d77 100644 --- a/admin/collage-designer/assets/js/collage-designer.js +++ b/admin/collage-designer/assets/js/collage-designer.js @@ -195,6 +195,34 @@ document.addEventListener('DOMContentLoaded', () => { }); } + /** + * 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. + */ + function getSelectionBoundingBox() { + 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 + }; + } + window.drawCanvas = function() { window.ctx.clearRect(0, 0, window.collageCanvas.width, window.collageCanvas.height); @@ -233,7 +261,7 @@ document.addEventListener('DOMContentLoaded', () => { } // Draw selection border for ALL selected elements - if (element.isSelected) { // <--- KORREKTUR: Selection border für alle isSelected Elemente + if (element.isSelected) { window.ctx.strokeStyle = SELECTION_COLOR; window.ctx.lineWidth = BORDER_WIDTH; window.ctx.strokeRect(x, y, width, height); @@ -243,39 +271,74 @@ document.addEventListener('DOMContentLoaded', () => { window.ctx.lineWidth = BORDER_WIDTH; window.ctx.strokeRect(x, y, width, height); } + }); - // --- Draw Resizing Handles and Rotation Handle ONLY FOR THE ACTIVE ELEMENT --- - if (element === window.activeElement) { // <--- KORREKTUR: Handles nur für activeElement - const handles = [ - { x: x, y: y, cursor: 'nwse-resize', name: 'top-left' }, - { x: x + width, y: y, cursor: 'nesw-resize', name: 'top-right' }, - { x: x, y: y + height, cursor: 'nesw-resize', name: 'bottom-left' }, - { x: x + width, y: y + height, cursor: 'nwse-resize', name: 'bottom-right' } - ]; - handles.forEach(handle => { - window.ctx.fillStyle = HANDLE_COLOR; - window.ctx.strokeStyle = HANDLE_STROKE_COLOR; - window.ctx.lineWidth = HANDLE_BORDER_WIDTH; - window.ctx.fillRect(handle.x - HANDLE_SIZE / 2, handle.y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE); - window.ctx.strokeRect(handle.x - HANDLE_SIZE / 2, handle.y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE); - }); - - const rotationHandleX = x + width / 2; - const rotationHandleY = y - ROTATION_HANDLE_OFFSET; - window.ctx.beginPath(); - window.ctx.arc(rotationHandleX, rotationHandleY, ROTATION_HANDLE_SIZE / 2, 0, Math.PI * 2); - window.ctx.fillStyle = ROTATION_HANDLE_COLOR; - window.ctx.fill(); - window.ctx.strokeStyle = ROTATION_HANDLE_STROKE_COLOR; - window.ctx.lineWidth = HANDLE_BORDER_WIDTH; - window.ctx.stroke(); - window.ctx.fillStyle = ROTATION_HANDLE_STROKE_COLOR; - window.ctx.font = ROTATION_HANDLE_ICON_FONT_SIZE; - window.ctx.textAlign = 'center'; - window.ctx.textBaseline = 'middle'; - window.ctx.fillText(ROTATION_HANDLE_ICON, rotationHandleX, rotationHandleY); + // --- Draw Resizing Handles for active element OR group bounding box --- + // --- Draw Rotation Handle ONLY FOR THE ACTIVE ELEMENT --- + const selectedElementsCount = window.collageElements.filter(el => el.isSelected).length; + + let targetForHandles = null; // Either activeElement or groupBoundingBox for resizing + let targetX = 0, targetY = 0, targetWidth = 0, targetHeight = 0; + + if (selectedElementsCount === 1 && window.activeElement && window.activeElement.isSelected) { + targetForHandles = window.activeElement; + targetX = targetForHandles.x; + targetY = targetForHandles.y; + targetWidth = targetForHandles.width; + targetHeight = targetForHandles.height; + } else if (selectedElementsCount > 1) { + targetForHandles = getSelectionBoundingBox(); // This is for resizing the group + if (targetForHandles) { + targetX = targetForHandles.x; + targetY = targetForHandles.y; + targetWidth = targetForHandles.width; + targetHeight = targetForHandles.height; } - }); + } + + // Draw Resizing Handles + if (targetForHandles && targetWidth > 0 && targetHeight > 0) { // Check for valid dimensions + // Optional: Draw a dashed border around the group bounding box if multiple elements are selected + if (selectedElementsCount > 1) { + window.ctx.strokeStyle = SELECTION_COLOR; + window.ctx.lineWidth = BORDER_WIDTH; + window.ctx.setLineDash([5, 5]); // Dashed line + window.ctx.strokeRect(targetX, targetY, targetWidth, targetHeight); + window.ctx.setLineDash([]); // Reset to solid line + } + + const handles = [ + { x: targetX, y: targetY, cursor: 'nwse-resize', name: 'top-left' }, + { x: targetX + targetWidth, y: targetY, cursor: 'nesw-resize', name: 'top-right' }, + { x: targetX, y: targetY + targetHeight, cursor: 'nesw-resize', name: 'bottom-left' }, + { x: targetX + targetWidth, y: targetY + targetHeight, cursor: 'nwse-resize', name: 'bottom-right' } + ]; + handles.forEach(handle => { + window.ctx.fillStyle = HANDLE_COLOR; + window.ctx.strokeStyle = HANDLE_STROKE_COLOR; + window.ctx.lineWidth = HANDLE_BORDER_WIDTH; + window.ctx.fillRect(handle.x - HANDLE_SIZE / 2, handle.y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE); + window.ctx.strokeRect(handle.x - HANDLE_SIZE / 2, handle.y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE); + }); + } + + // Draw Rotation Handle ONLY FOR THE ACTIVE ELEMENT + if (window.activeElement && window.activeElement.isSelected) { // Check if it's selected and active + const rotationHandleX = window.activeElement.x + window.activeElement.width / 2; + const rotationHandleY = window.activeElement.y - ROTATION_HANDLE_OFFSET; + window.ctx.beginPath(); + window.ctx.arc(rotationHandleX, rotationHandleY, ROTATION_HANDLE_SIZE / 2, 0, Math.PI * 2); + window.ctx.fillStyle = ROTATION_HANDLE_COLOR; + window.ctx.fill(); + window.ctx.strokeStyle = ROTATION_HANDLE_STROKE_COLOR; + window.ctx.lineWidth = HANDLE_BORDER_WIDTH; + window.ctx.stroke(); + window.ctx.fillStyle = ROTATION_HANDLE_STROKE_COLOR; + window.ctx.font = ROTATION_HANDLE_ICON_FONT_SIZE; + window.ctx.textAlign = 'center'; + window.ctx.textBaseline = 'middle'; + window.ctx.fillText(ROTATION_HANDLE_ICON, rotationHandleX, rotationHandleY); + } }; function getMousePos(event) { @@ -296,36 +359,58 @@ document.addEventListener('DOMContentLoaded', () => { isRotating = false; isDragging = false; - // Save current active element before potentially changing it - const prevActiveElement = window.activeElement; + const selectedElementsCount = window.collageElements.filter(el => el.isSelected).length; + + let currentInteractionTarget = null; // The object or bounding box currently being interacted with via handles + let currentTargetX = 0, currentTargetY = 0, currentTargetWidth = 0, currentTargetHeight = 0; + + // Determine the target for handle interaction (single active element or group bounding box) + if (selectedElementsCount === 1 && window.activeElement && window.activeElement.isSelected) { + currentInteractionTarget = window.activeElement; + currentTargetX = currentInteractionTarget.x; + currentTargetY = currentInteractionTarget.y; + currentTargetWidth = currentInteractionTarget.width; + currentTargetHeight = currentInteractionTarget.height; + } else if (selectedElementsCount > 1) { + currentInteractionTarget = getSelectionBoundingBox(); // This is for resizing the group + if (currentInteractionTarget) { + currentTargetX = currentInteractionTarget.x; + currentTargetY = currentInteractionTarget.y; + currentTargetWidth = currentInteractionTarget.width; + currentTargetHeight = currentInteractionTarget.height; + } + } // --- Check for Rotation Handle hit FIRST --- - if (prevActiveElement && prevActiveElement.isSelected) { // Check if it's selected and active - const rotationHandleX = prevActiveElement.x + prevActiveElement.width / 2; - const rotationHandleY = prevActiveElement.y - ROTATION_HANDLE_OFFSET; + // Rotation handle is ALWAYS on the activeElement, even if multiple are selected. + if (window.activeElement && window.activeElement.isSelected) { + const rotationHandleX = window.activeElement.x + window.activeElement.width / 2; + const rotationHandleY = window.activeElement.y - ROTATION_HANDLE_OFFSET; const dist = Math.sqrt( Math.pow(mouse.x - rotationHandleX, 2) + Math.pow(mouse.y - rotationHandleY, 2) ); + if (dist <= ROTATION_HANDLE_SIZE / 2) { isRotating = true; - const elementCenterX = prevActiveElement.x + prevActiveElement.width / 2; - const elementCenterY = prevActiveElement.y + prevActiveElement.height / 2; + const elementCenterX = window.activeElement.x + window.activeElement.width / 2; + const elementCenterY = window.activeElement.y + window.activeElement.height / 2; rotationStartAngle = Math.atan2(mouse.y - elementCenterY, mouse.x - elementCenterX); - initialElementRotation = prevActiveElement.rotation; + initialElementRotation = window.activeElement.rotation; // Store active element's rotation as start reference window.collageCanvas.style.cursor = ROTATION_CURSOR_URL; window.drawCanvas(); return; // Rotation handle clicked, don't proceed further } } - // Check for handle hit SECOND (Resizing Handles) - if (prevActiveElement && prevActiveElement.isSelected) { // Check if it's selected and active + // --- Check for Resize Handle hit SECOND --- + // Handles are on activeElement (if single selected) or group bounding box (if multiple selected) + if (currentInteractionTarget && currentTargetWidth > 0 && currentTargetHeight > 0) { // Check for valid dimensions to prevent errors const handles = [ - { x: prevActiveElement.x, y: prevActiveElement.y, name: 'top-left' }, - { x: prevActiveElement.x + prevActiveElement.width, y: prevActiveElement.y, name: 'top-right' }, - { x: prevActiveElement.x, y: prevActiveElement.y + prevActiveElement.height, name: 'bottom-left' }, - { x: prevActiveElement.x + prevActiveElement.width, y: prevActiveElement.y + prevActiveElement.height, name: 'bottom-right' } + { x: currentTargetX, y: currentTargetY, name: 'top-left' }, + { x: currentTargetX + currentTargetWidth, y: currentTargetY, name: 'top-right' }, + { x: currentTargetX, y: currentTargetY + currentTargetHeight, name: 'bottom-left' }, + { x: currentTargetX + currentTargetWidth, y: currentTargetY + currentTargetHeight, name: 'bottom-right' } ]; for (const handle of handles) { @@ -336,12 +421,13 @@ document.addEventListener('DOMContentLoaded', () => { resizeHandle = handle.name; dragStartX = mouse.x; dragStartY = mouse.y; - initialElementWidth = prevActiveElement.width; - initialElementHeight = prevActiveElement.height; - initialElementX = prevActiveElement.x; - initialElementY = prevActiveElement.y; - - switch(resizeHandle) { + // Store initial state for resizing relative to the group/element + initialElementWidth = currentTargetWidth; + initialElementHeight = currentTargetHeight; + initialElementX = currentTargetX; + initialElementY = currentTargetY; + + switch(resizeHandle) { case 'top-left': case 'bottom-right': window.collageCanvas.style.cursor = 'nwse-resize'; break; case 'top-right': case 'bottom-left': window.collageCanvas.style.cursor = 'nesw-resize'; break; } @@ -351,7 +437,7 @@ document.addEventListener('DOMContentLoaded', () => { } } - // --- Handle Clicks on Elements for Selection/Dragging --- + // --- Handle Clicks on Elements for Selection/Dragging (if no handles hit) --- let clickedOnElement = false; let elementClicked = null; @@ -377,36 +463,68 @@ document.addEventListener('DOMContentLoaded', () => { } } else { // Single selection: deselect all others, then select this one - if (window.activeElement !== elementClicked) { // Only deselect if a different element is clicked - window.collageElements.forEach(el => el.isSelected = false); + 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 } + isDragging = true; dragStartX = mouse.x; dragStartY = mouse.y; - elementStartX = window.activeElement.x; - elementStartY = window.activeElement.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 = 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 = 'grabbing'; } else { // No element was clicked - if (!event.ctrlKey && !event.metaKey) { // If no multi-selection key, deselect all + if (!event.ctrlKey && !event.metaKey) { window.collageElements.forEach(el => el.isSelected = false); window.activeElement = null; } } - window.drawCanvas(); // Redraw to reflect selection/deselection and active element handles + window.drawCanvas(); } function handleMouseMove(event) { const mouse = getMousePos(event); - // --- Cursor hover for handles --- + // --- Cursor hover for handles (adjust for group vs. single) --- let cursorChanged = false; if (!isDragging && !isResizing && !isRotating) { // Only change cursor if not interacting - // Check rotation handle hover - if (window.activeElement && window.activeElement.isSelected) { // Check active element for handles + const selectedElementsCount = window.collageElements.filter(el => el.isSelected).length; + let currentTargetForHover = null; + let currentTargetX = 0, currentTargetY = 0, currentTargetWidth = 0, currentTargetHeight = 0; + + // Determine target for hover (single active element or group bounding box) + if (selectedElementsCount === 1 && window.activeElement && window.activeElement.isSelected) { + currentTargetForHover = window.activeElement; + currentTargetX = currentTargetForHover.x; + currentTargetY = currentTargetForHover.y; + currentTargetWidth = currentTargetForHover.width; + currentTargetHeight = currentTargetForHover.height; + } else if (selectedElementsCount > 1) { + currentTargetForHover = getSelectionBoundingBox(); + if (currentTargetForHover) { + currentTargetX = currentTargetForHover.x; + currentTargetY = currentTargetForHover.y; + currentTargetWidth = currentTargetForHover.width; + currentTargetHeight = currentTargetForHover.height; + } + } + + // Check rotation handle hover (ALWAYS on activeElement) + if (window.activeElement && window.activeElement.isSelected) { const rotationHandleX = window.activeElement.x + window.activeElement.width / 2; const rotationHandleY = window.activeElement.y - ROTATION_HANDLE_OFFSET; const dist = Math.sqrt( @@ -419,13 +537,13 @@ document.addEventListener('DOMContentLoaded', () => { } } - // Check resize handles hover - if (window.activeElement && window.activeElement.isSelected && !cursorChanged) { // Check active element for handles + // Check resize handles hover (on currentTargetForHover if it exists and not already hovering rotation handle) + if (currentTargetForHover && !cursorChanged && currentTargetForHover.width > 0 && currentTargetForHover.height > 0) { const handles = [ - { x: window.activeElement.x, y: window.activeElement.y, cursor: 'nwse-resize', name: 'top-left' }, - { x: window.activeElement.x + window.activeElement.width, y: window.activeElement.y, cursor: 'nesw-resize', name: 'top-right' }, - { x: window.activeElement.x, y: window.activeElement.y + window.activeElement.height, cursor: 'nesw-resize', name: 'bottom-left' }, - { x: window.activeElement.x + window.activeElement.width, y: window.activeElement.y + window.activeElement.height, cursor: 'nwse-resize', name: 'bottom-right' } + { x: currentTargetX, y: currentTargetY, cursor: 'nwse-resize', name: 'top-left' }, + { x: currentTargetX + currentTargetWidth, y: currentTargetY, cursor: 'nesw-resize', name: 'top-right' }, + { x: currentTargetX, y: currentTargetY + currentTargetHeight, cursor: 'nesw-resize', name: 'bottom-left' }, + { x: currentTargetX + currentTargetWidth, y: currentTargetY + currentTargetHeight, cursor: 'nwse-resize', name: 'bottom-right' } ]; for (const handle of handles) { if (mouse.x >= handle.x - HANDLE_SIZE / 2 && mouse.x <= handle.x + HANDLE_SIZE / 2 && @@ -451,96 +569,199 @@ document.addEventListener('DOMContentLoaded', () => { window.collageCanvas.style.cursor = overSelectable ? 'grab' : 'default'; } - // --- Rotation Logic --- + // --- Rotation Logic (now applies to all selected elements, driven by activeElement's handle) --- if (isRotating && window.activeElement) { - const elementCenterX = window.activeElement.x + window.activeElement.width / 2; - const elementCenterY = window.activeElement.y + window.activeElement.height / 2; - const currentAngle = Math.atan2(mouse.y - elementCenterY, mouse.x - elementCenterX); + 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(mouse.y - activeElementCenterY, mouse.x - activeElementCenterX); let angleDiff = (rotationStartAngle - currentAngle) * 180 / Math.PI; - let newRotation = initialElementRotation + angleDiff; + if (event.shiftKey) { - newRotation = Math.round(newRotation / 15) * 15; + angleDiff = Math.round(angleDiff / 20) * 30; } - window.activeElement.rotation = (newRotation % 360 + 360) % 360; + + 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 && window.activeElement) { - const dx = mouse.x - dragStartX; - const dy = mouse.y - dragStartY; + // --- Scaling Logic (UNIFIED for single and group, with correct Shift behavior) --- + 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 + }; - let newWidth = initialElementWidth; - let newHeight = initialElementHeight; - let newX = initialElementX; - let newY = initialElementY; + const mouseCurrent = mouse; // current mouse position + const mouseStart = { x: dragStartX, y: dragStartY }; // mouse position at the start of resize - const aspectRatio = initialElementWidth / initialElementHeight; + const initialAspectRatio = initialBoundingBox.width / initialBoundingBox.height; + // anchorpoint (opposite corner of the handle) + let anchorX, anchorY; switch (resizeHandle) { - case 'top-left': - newWidth = initialElementWidth - dx; - newHeight = initialElementHeight - dy; - if (event.shiftKey) { - if (Math.abs(dx) > Math.abs(dy)) { newHeight = newWidth / aspectRatio; } else { newWidth = newHeight * aspectRatio; } - } - newX = initialElementX + (initialElementWidth - newWidth); - newY = initialElementY + (initialElementHeight - newHeight); - break; - case 'top-right': - newWidth = initialElementWidth + dx; - newHeight = initialElementHeight - dy; - if (event.shiftKey) { - if (Math.abs(dx) > Math.abs(dy)) { newHeight = newWidth / aspectRatio; } else { newWidth = newHeight * aspectRatio; } - } - newY = initialElementY + (initialElementHeight - newHeight); - break; - case 'bottom-left': - newWidth = initialElementWidth - dx; - newHeight = initialElementHeight + dy; - if (event.shiftKey) { - if (Math.abs(dx) > Math.abs(dy)) { newHeight = newWidth / aspectRatio; } else { newWidth = newHeight * aspectRatio; } - } - newX = initialElementX + (initialElementWidth - newWidth); - break; - case 'bottom-right': - newWidth = initialElementWidth + dx; - newHeight = initialElementHeight + dy; - if (event.shiftKey) { - if (Math.abs(dx) > Math.abs(dy)) { newHeight = newWidth / aspectRatio; } else { newWidth = newHeight * aspectRatio; } - } - break; + 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; } + let finalWidth, finalHeight, finalX, finalY; + + //if (event.shiftKey) { //not working properly for unproportional resize yet + // scale proportionally from the anchor point, based on the dominant axis. + + // Current distance of the mouse from the anchor point + const currentRelX = mouseCurrent.x - anchorX; + const currentRelY = mouseCurrent.y - anchorY; + + // Original distance of the mouse from the anchor point (as reference for Delta) + const startRelX = mouseStart.x - anchorX; + const startRelY = mouseStart.y - anchorY; + + // Calculation of the "Delta" change in X and Y relative to the anchor point + // This reflects the movement of the handle + const deltaMovementX = currentRelX - startRelX; + const deltaMovementY = currentRelY - startRelY; + + // Calculate the new width/height, if it were scaled proportionally from the start + // We use initialBoundingBox to maintain the original aspect ratio + let potentialNewWidth = initialBoundingBox.width + (resizeHandle.includes('left') ? -deltaMovementX : deltaMovementX); + let potentialNewHeight = initialBoundingBox.height + (resizeHandle.includes('top') ? -deltaMovementY : deltaMovementY); + + // Determine the effective scaling factor based on the dominant axis + // (the axis, which was scaled proportionally the furthest) + let scaleFactorFromWidth = potentialNewWidth / initialBoundingBox.width; + let scaleFactorFromHeight = potentialNewHeight / initialBoundingBox.height; + + let effectiveScaleFactor; + if (Math.abs(scaleFactorFromWidth) > Math.abs(scaleFactorFromHeight)) { + effectiveScaleFactor = scaleFactorFromWidth; + } else { + effectiveScaleFactor = scaleFactorFromHeight; + } + + // Apply the effective scaling factor to the original dimensions + finalWidth = initialBoundingBox.width * effectiveScaleFactor; + finalHeight = initialBoundingBox.height * effectiveScaleFactor; + + // Calculate the final position based on anchor point and new proportional dimensions + finalX = anchorX; + finalY = anchorY; + + if (resizeHandle.includes('left')) finalX = anchorX - finalWidth; + if (resizeHandle.includes('top')) finalY = anchorY - finalHeight; + + /*} else { + // no shift: scale unproportional. + // The dx/dy values are the total mouse movement since the start of the click. + // finalWidth/Height are directly calculated from initialBoundingBox + dx/dy. + finalWidth = initialBoundingBox.width + (resizeHandle.includes('left') ? -dx : dx); + finalHeight = initialBoundingBox.height + (resizeHandle.includes('top') ? -dy : dy); + + // finalX/Y are directly calculated from initialBoundingBox + dx/dy. + finalX = initialBoundingBox.x; + finalY = initialBoundingBox.y; + + if (resizeHandle.includes('left')) finalX = initialBoundingBox.x + dx; + if (resizeHandle.includes('top')) finalY = initialBoundingBox.y + dy; + }*/ + + // --- apply minimum size restriction --- const MIN_SIZE = 20; - if (newWidth < MIN_SIZE) { - newWidth = MIN_SIZE; - if (event.shiftKey) newHeight = newWidth / aspectRatio; - if (resizeHandle.includes('left')) newX = initialElementX + (initialElementWidth - newWidth); + + if (finalWidth < MIN_SIZE) { + finalWidth = MIN_SIZE; + if (event.shiftKey) finalHeight = MIN_SIZE / initialAspectRatio; // while shift: height proportional adjust } - if (newHeight < MIN_SIZE) { - newHeight = MIN_SIZE; - if (event.shiftKey) newWidth = newHeight * aspectRatio; - if (resizeHandle.includes('top')) newY = initialElementY + (initialElementHeight - newHeight); + if (finalHeight < MIN_SIZE) { + finalHeight = MIN_SIZE; + if (event.shiftKey) finalWidth = MIN_SIZE * initialAspectRatio; // while shift: width proportional adjust } - window.activeElement.x = newX; - window.activeElement.y = newY; - window.activeElement.width = newWidth; - window.activeElement.height = newHeight; + // position after resizing, to avoid jumps + if (resizeHandle.includes('left') && finalWidth === MIN_SIZE && initialBoundingBox.width > MIN_SIZE) { + finalX = anchorX - MIN_SIZE; + } + if (resizeHandle.includes('top') && finalHeight === MIN_SIZE && initialBoundingBox.height > MIN_SIZE) { + finalY = anchorY - MIN_SIZE; + } + + // to avoid errors, if finalWidth/Height become 0 (shouldn't happen due to MIN_SIZE) + if (finalWidth === 0) finalWidth = 1; + if (finalHeight === 0) finalHeight = 1; + + + // scale factors from initial bounding box to final bounding box + const scaleX = finalWidth / initialBoundingBox.width; + const scaleY = finalHeight / initialBoundingBox.height; + + // apply transformation on each selected element + selectedElements.forEach(element => { + // position of the element relative to the anchor point of the initial bounding box + // This is crucial for the "sticky" behavior + const relativeXToAnchor = element.x - anchorX; + const relativeYToAnchor = element.y - anchorY; + + // Scale relative position + const newRelativeXToAnchor = relativeXToAnchor * scaleX; + const newRelativeYToAnchor = relativeYToAnchor * scaleY; + + // new Position and Dimensions of the element + element.x = anchorX + newRelativeXToAnchor; + element.y = anchorY + newRelativeYToAnchor; + element.width = element.width * scaleX; + element.height = element.height * scaleY; + }); + + // dragStartX/Y for continuous resizing update + dragStartX = mouseCurrent.x; + dragStartY = mouseCurrent.y; window.drawCanvas(); return; } - // --- Dragging Logic --- - if (isDragging && window.activeElement) { + // --- 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 = mouse.x - dragStartX; const dy = mouse.y - dragStartY; - window.activeElement.x = elementStartX + dx; - window.activeElement.y = elementStartY + dy; + selected.forEach(element => { + element.x += dx; + element.y += dy; + }); + + // dragStartX/Y for continuous dragging without accumulation update + dragStartX = mouse.x; + dragStartY = mouse.y; window.drawCanvas(); return; From 4ad4a7b235ea596d612c0a4c330bc90ae0f95506 Mon Sep 17 00:00:00 2001 From: Leon Schmitt Date: Wed, 31 Dec 2025 18:19:35 +0100 Subject: [PATCH 16/63] collage-designer: added functions for distribution buttons --- .../assets/js/collage-designer-tools.js | 102 +++++++++++++++++- admin/collage-designer/index.php | 2 +- 2 files changed, 100 insertions(+), 4 deletions(-) diff --git a/admin/collage-designer/assets/js/collage-designer-tools.js b/admin/collage-designer/assets/js/collage-designer-tools.js index b3ed44dd5..c7476009b 100644 --- a/admin/collage-designer/assets/js/collage-designer-tools.js +++ b/admin/collage-designer/assets/js/collage-designer-tools.js @@ -264,7 +264,103 @@ document.addEventListener('DOMContentLoaded', () => { window.drawCanvas(); }); - // Distribution buttons will be implemented later, as they are more complex. - // document.getElementById('distributeHBtn').addEventListener('click', () => { /* ... */ }); - // document.getElementById('distributeVBtn').addEventListener('click', () => { /* ... */ }); + // --- Distribution Functions --- + + // Distribute selected elements horizontally + document.getElementById('distributeHBtn').addEventListener('click', () => { + let elementsToDistribute = 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', () => { + let elementsToDistribute = 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(); + }); }); \ No newline at end of file diff --git a/admin/collage-designer/index.php b/admin/collage-designer/index.php index 4b447903a..2c0073f40 100644 --- a/admin/collage-designer/index.php +++ b/admin/collage-designer/index.php @@ -129,7 +129,7 @@
- +
From f93659b5a1f16feb6e5f7de2b8b3cf2c636b30c4 Mon Sep 17 00:00:00 2001 From: Leon Schmitt Date: Wed, 31 Dec 2025 18:51:39 +0100 Subject: [PATCH 17/63] collage-designer: added Undo/Redo Buttons and functionality --- .../assets/js/collage-designer-tools.js | 8 ++ .../assets/js/collage-designer.js | 126 +++++++++++++++++- .../components/general-tools-panel.php | 9 ++ 3 files changed, 142 insertions(+), 1 deletion(-) diff --git a/admin/collage-designer/assets/js/collage-designer-tools.js b/admin/collage-designer/assets/js/collage-designer-tools.js index c7476009b..8bbcda8e5 100644 --- a/admin/collage-designer/assets/js/collage-designer-tools.js +++ b/admin/collage-designer/assets/js/collage-designer-tools.js @@ -97,6 +97,7 @@ document.addEventListener('DOMContentLoaded', () => { // 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 = getSelectedElements(); if (elementsToAlign.length === 0) { console.log('No elements selected for left alignment.'); @@ -125,6 +126,7 @@ document.addEventListener('DOMContentLoaded', () => { // 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 = getSelectedElements(); if (elementsToAlign.length === 0) { console.log('No elements selected for horizontal center alignment.'); @@ -154,6 +156,7 @@ document.addEventListener('DOMContentLoaded', () => { // 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 = getSelectedElements(); if (elementsToAlign.length === 0) { console.log('No elements selected for right alignment.'); @@ -183,6 +186,7 @@ document.addEventListener('DOMContentLoaded', () => { // 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 = getSelectedElements(); if (elementsToAlign.length === 0) { console.log('No elements selected for top alignment.'); @@ -210,6 +214,7 @@ document.addEventListener('DOMContentLoaded', () => { // 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 = getSelectedElements(); if (elementsToAlign.length === 0) { console.log('No elements selected for vertical middle alignment.'); @@ -239,6 +244,7 @@ document.addEventListener('DOMContentLoaded', () => { // 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 = getSelectedElements(); if (elementsToAlign.length === 0) { console.log('No elements selected for bottom alignment.'); @@ -268,6 +274,7 @@ document.addEventListener('DOMContentLoaded', () => { // Distribute selected elements horizontally document.getElementById('distributeHBtn').addEventListener('click', () => { + window.saveState(); // Save state for undo functionality let elementsToDistribute = getSelectedElements(); if (elementsToDistribute.length < 3) { @@ -318,6 +325,7 @@ document.addEventListener('DOMContentLoaded', () => { // Distribute selected elements vertically document.getElementById('distributeVBtn').addEventListener('click', () => { + window.saveState(); // Save state for undo functionality let elementsToDistribute = getSelectedElements(); if (elementsToDistribute.length < 3) { diff --git a/admin/collage-designer/assets/js/collage-designer.js b/admin/collage-designer/assets/js/collage-designer.js index 4cebe4d77..215fba5d3 100644 --- a/admin/collage-designer/assets/js/collage-designer.js +++ b/admin/collage-designer/assets/js/collage-designer.js @@ -35,6 +35,11 @@ document.addEventListener('DOMContentLoaded', () => { let rotationStartAngle = 0; let initialElementRotation = 0; + // --- Undo/Redo History --- + let undoStack = []; + let redoStack = []; + const MAX_HISTORY_SIZE = 50; // Limit the history to prevent excessive memory usage + // --- Utility Functions for Loading Overlay --- function showLoadingOverlay() { if (window.loadingOverlay) { @@ -223,6 +228,93 @@ document.addEventListener('DOMContentLoaded', () => { }; } + // --- Undo/Redo Functions --- + + /** + * 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. + */ + function createSnapshot() { + return window.collageElements.map(el => ({ + id: el.id, // Keep ID for matching + x: el.x, + y: el.y, + width: el.width, + height: el.height, + rotation: el.rotation, + isSelected: el.isSelected, + // image and originalLayoutDataIndex do not change, no need to store + })); + } + + /** + * Restores the state of collage elements from a given snapshot. + * @param {Array} snapshot The snapshot to restore. + */ + function restoreSnapshot(snapshot) { + // Clear current selection + window.collageElements.forEach(el => el.isSelected = false); + window.activeElement = null; + + snapshot.forEach(snapEl => { + const currentEl = window.collageElements.find(el => el.id === snapEl.id); + if (currentEl) { + currentEl.x = snapEl.x; + currentEl.y = snapEl.y; + currentEl.width = snapEl.width; + currentEl.height = snapEl.height; + currentEl.rotation = snapEl.rotation; + currentEl.isSelected = snapEl.isSelected; // Restore selection state too + if (snapEl.isSelected) { // If an element was selected in the snapshot, make it active if it's the only one + if (snapshot.filter(s => s.isSelected).length === 1) { + window.activeElement = currentEl; + } else if (window.activeElement && window.activeElement.id === currentEl.id) { + // If multiple selected, try to restore the active element + window.activeElement = currentEl; + } + } + } + }); + } + + /** + * Saves the current state to the undoStack and clears the redoStack. + */ + window.saveState = function() { + const currentState = 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(); + } + + /** + * 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; + } + window.drawCanvas = function() { window.ctx.clearRect(0, 0, window.collageCanvas.width, window.collageCanvas.height); @@ -354,6 +446,8 @@ document.addEventListener('DOMContentLoaded', () => { function handleMouseDown(event) { const mouse = getMousePos(event); + const prevSelectedState = createSnapshot(); // create Snapshot before Snapshot for possible selection changes + // Reset interaction flags isResizing = false; isRotating = false; @@ -493,6 +587,11 @@ document.addEventListener('DOMContentLoaded', () => { window.activeElement = null; } } + + // Save state if there was any change in selection + if (isRotating || isResizing || isDragging || JSON.stringify(prevSelectedState) !== JSON.stringify(createSnapshot())) { + window.saveState(); + } window.drawCanvas(); } @@ -806,5 +905,30 @@ document.addEventListener('DOMContentLoaded', () => { window.collageCanvas.addEventListener('mouseup', handleMouseUp); window.collageCanvas.addEventListener('mouseout', handleMouseUp); // End interaction if mouse leaves canvas - initDesigner(); + // 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 + restoreSnapshot(undoStack[undoStack.length - 1]); // Load the previous state + window.drawCanvas(); + updateUndoRedoButtonStates(); + } + }); + + 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 + restoreSnapshot(nextState); // Load this state + window.drawCanvas(); + window.updateUndoRedoButtonStates(); + } + }); + + // Initialize the designer and save the very first state + initDesigner().then(() => { + window.saveState(); // Save initial state after everything is loaded + window.updateUndoRedoButtonStates(); // Update button states based on initial stack + }); }); \ No newline at end of file diff --git a/admin/collage-designer/components/general-tools-panel.php b/admin/collage-designer/components/general-tools-panel.php index c4efd4bcb..c5854756c 100644 --- a/admin/collage-designer/components/general-tools-panel.php +++ b/admin/collage-designer/components/general-tools-panel.php @@ -1,5 +1,14 @@
+ + + + +