From 12ea1292a3553271ac9fbae3e2ccd223e80e2f00 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Sun, 9 Aug 2026 09:56:50 +0600 Subject: [PATCH 1/2] ref(mixedimage): refactor crop stack and document #31 Restructure crop processor and manager JS without UX changes. Add README crop docs, Keep a Changelog format, and lexicon updates. Fix MIME/extension alignment and crop window lifecycle bugs. Refs #31 --- README.md | 137 ++++++--- .../mixedimage/js/mgr/mixedimage.js | 237 +++++++++------ core/components/mixedimage/docs/changelog.txt | 285 +++++++++++------- .../mixedimage/lexicon/en/default.inc.php | 8 +- .../mixedimage/lexicon/ru/default.inc.php | 8 +- .../mixedimage/processors/file/crop.class.php | 106 +++++-- 6 files changed, 501 insertions(+), 280 deletions(-) diff --git a/README.md b/README.md index 5cc4d5a..63eb0be 100644 --- a/README.md +++ b/README.md @@ -1,84 +1,121 @@ # mixedimage -mixedImage - modx custom TV [сhangelog](core/components/mixedimage/docs/changelog.txt) - -![mixedImage](mixedimage2.png) +mixedImage — кастомный TV для MODX. [Changelog](core/components/mixedimage/docs/changelog.txt) +![mixedImage](mixedimage2.png) ## Требования + - PHP >= 7.0 -- MODX >= 2.8 +- MODX >= 2.8 + +## Что делает + +Добавляет тип TV **Смешанная загрузка файла / Mixed Image Input**. + +Вы загружаете файл через менеджер MODX, с компьютера, по URL или обрезаете уже загруженное изображение. Поле рассчитано на картинки. Другие типы файлов тоже проходят, если их разрешает сайт. Для не-изображений отключите превью, иначе под полем будет битая миниатюра. --------------------------------------- +## Настройки TV -Добавляет новый тип TV поля: **Смешанная загрузка файла/Mixed Image Input** +**Путь сохранения** — папка относительно корня медиа-источника. Если у TV свой источник с `assets/images/`, а в пути указали `uploads/`, итоговая папка будет `assets/images/uploads/`. -Данный тип поля позволяет прикреплять изображения к ресурсам используя стандартный менеджер файлов, либо напрямую с компьютера пользователя, минуя все остальные шаги. -Предназначен для загрузки изображений, но может загружать и другие типы файлов, разрешенные для загрузки в системных настройках сайта, но в данном случае необходимо отключить показ миниатюр, чтобы не было битой картинки. +**Удаление файла** — при «Да» очистка поля удаляет файл с диска. -#### При создании TV такого типа можно указать следующие настройки: +**Префикс имени файла** — строка в начале имени. Пример: `{rand}_` -**- Путь сохранения** - папку в которую будут загружаться картинки с компьютера пользователя (ВАЖНО: при указании источника файлов отличным от стандартного, этот путь будет браться с учетом папки источника файлов) -*Например вы создали новый источник файлов IMAGES и указали ему путь assets/images/ (в настройках источника файлов), а в поле Путь сохранения указали папку uploads/ - итоговая папка загрузки файлов будет такой: assets/images/uploads/* +Плейсхолдеры префикса: -**- Удаление файла** - если выставлено "Да", то при очистке поля файл физически удаляется с сервера +- `{id}` — ID ресурса +- `{pid}` — ID родителя +- `{alias}` — алиас ресурса +- `{palias}` — алиас родителя +- `{tid}` — ID TV +- `{uid}` — ID пользователя +- `{rand}` — случайная строка (длина в системной настройке) +- `{t}` — timestamp +- `{y}`, `{m}`, `{d}`, `{h}`, `{i}`, `{s}` — компоненты даты и времени -**- Префикс имени файла** - добавляет данную строку в начало наименования файла, можно использовать различные плейсхолдеры. Пример: {rand}_ +**Принимаемые типы MIME** — дополнительный список через запятую. Пример: `image/jpeg, image/png, application/pdf` -* {id} - ID ресурса -* {pid} - ID ресурса родителя -* {alias} - Алиас ресурса -* {palias} - Алиас ресурса родителя -* {tid} - ID доп. поля (tv) -* {uid} - ID юзера -* {rand} - Случайная строка *(количество символов указывается в системных настройках)* -* {t} - Время в формате timestamp -* {y} - Год -* {m} - Месяц -* {d} - День -* {h} - Час -* {i} - Минута -* {s} - Секунда +**Использовать префикс как имя файла** — имя файла полностью берётся из префикса, без исходного имени загрузки. -**- Принимаемые типы MIME** - указывает какие типы файлов можно загружать (по умолчанию те что указаны в системных настройках modx) -*Например: image/jpeg, image/png, application/pdf* +**Показать превью** — миниатюра или видеоплеер под полем (по умолчанию включено). -**- Использовать префикс как имя файла** - позволяет не обращать внимания на пользовательское название файла и полностью заменить его на значение из поля **Префикс имени файла** +**Показывать значение TV** — путь к файлу под полем (по умолчанию включено). -**- Показать изображение** - выводить под полем изображение миниатюры или нет *(по умолчанию выводит)*. +**Параметры для ресайза изображений** — параметры phpThumb при выводе. + +Примеры: + +``` +w=200&h=200&zc=1 +fltr=wmt|Hello|60|C|ff0000| +fltr=wmi|/assets/wt.png|C| +``` -**- Показывать значение TV** - выводить под полем путь к загруженному файлу или нет *(по умолчанию выводит)* +**Список кнопок (triggerlist)** — кнопки через запятую: `clear`, `manager`, `pc`, `url`, `crop`. По умолчанию: `clear,manager,pc`. -**- Параметры для ресайза изображений** - параметры для modphpthumb. +## Обрезка изображения (Crop) -*Примеры:
-w=200&h=200&zc=1
-для наложения ватермарка:
-fltr=wmt|Hello|60|C|ff0000|
-fltr=wmi|/assets/wt.png|C|
* +Добавьте `crop` в **triggerlist**, например: `clear,manager,pc,crop`. ------- +В блоке **Crop** в настройках TV: +| Опция | Назначение | +| --- | --- | +| `crop_ratio` | Пропорции рамки: `1/1`, `16/9`, `450/500` | +| `crop_width` | Ширина результата в px | +| `crop_height` | Высота результата в px | +| `crop_suffix` | Суффикс имени файла. Пусто — перезапись. `time()` — новый файл с `_timestamp` | +| `crop_options` | Доп. опции CropperJS: `zoomOnWheel: false, viewMode: 1` | -###Системные настройки +Пустой суффикс перезаписывает исходный файл. `time()` каждый раз создаёт новое имя. Если суффикс уже есть в имени, повторно не добавляется. -**- mixedimage.random_lenght** - Длина строки для плейсхолдера {rand} +### Событие OnMixedImageCrop + +После сохранения обрезанного файла вызывается системное событие `OnMixedImageCrop`: + +- `image` — абсолютный путь к файлу на диске +- `tvId` — ID TV + +Пример плагина с водяным знаком после crop: + +```php +event->name) { + case 'OnMixedImageCrop': + if (!$image) { + return; + } + + $watermark = $modx->runSnippet('phpthumbon', [ + 'input' => $image, + 'options' => 'fltr=wmi|/assets/template/images/watermark.png|C|100', + ]); + + rename(MODX_BASE_PATH . $watermark, $image); + break; +} + +return; +``` -**- mixedimage.translit** - Транслитерация файлов, позволяет заменять кириллицу на латиницу в названии файла во избежание возможных проблем на некоторых хостингах. Настройка работает только при установленном дополнении "translit" +## Системные настройки -**- mixedimage.check_resid** - Загружать только при редактировании. Пока ресурс не будет сохранен, файл не получится загрузить. Рекомендуется оставить включенным эту настройку. Иначе могут возникнуть проблемы при использовании плейсхолдеров {alias} и {palias} - у несохранных ресурсов они будут возвращать пустые значения. +**mixedimage.random_lenght** — длина строки для `{rand}`. +**mixedimage.translit** — транслит имён файлов. Нужен установленный пакет translit. --------------------------------------- +**mixedimage.check_resid** — загрузка только при редактировании сохранённого ресурса. Рекомендуется оставить включённым: у несохранённых ресурсов `{alias}` и `{palias}` пустые. -### Пример работы с MIGX +## MIGX -* Создаnm TV (например migx_img) с типом **Смешанная загрузка файла**, не назначая никакому шаблону, настроить согласно текущему проекту (указать источник файлов или папку) -* Создать TV (например slider) с типом **migx** и в конфигурации (Вкладки формы) для поля с изображением прописать такие строки для каждой необходимой картинки +1. Создайте TV `migx_img` с типом **Смешанная загрузка файла**. Не назначайте шаблону. Укажите источник или путь. +2. Создайте TV `slider` с типом **migx**. В конфигурации формы для полей изображений: ```json - {"field":"image","caption":"Изображение 1","inputTV":"migx_img"}, - {"field":"image2","caption":"Изображение 2","inputTV":"migx_img"} +{"field":"image","caption":"Изображение 1","inputTV":"migx_img"}, +{"field":"image2","caption":"Изображение 2","inputTV":"migx_img"} ``` -И тогда при добавления новой строки в migx будут поля выбора файла согласно типу migx_img +В новой строке MIGX появятся поля загрузки по настройкам `migx_img`. diff --git a/assets/components/mixedimage/js/mgr/mixedimage.js b/assets/components/mixedimage/js/mgr/mixedimage.js index 967d477..883dc14 100644 --- a/assets/components/mixedimage/js/mgr/mixedimage.js +++ b/assets/components/mixedimage/js/mgr/mixedimage.js @@ -35,8 +35,8 @@ Ext.extend(mixedimage.panel, Ext.Container, { if (config.value?.trim()) { const lastSlashIndex = config.value.lastIndexOf('/'); - config.openPath = lastSlashIndex !== -1 - ? config.value.substring(0, lastSlashIndex) + config.openPath = lastSlashIndex !== -1 + ? config.value.substring(0, lastSlashIndex) : ''; } @@ -388,7 +388,7 @@ mixedimage.windowCrop = function (config) { , autoHeight: false , baseParams: this.getBaseParams(config) }); - mixedimage.window.superclass.constructor.call(this, config); + mixedimage.windowCrop.superclass.constructor.call(this, config); }; @@ -422,6 +422,119 @@ Ext.extend(mixedimage.windowCrop, MODx.Window, { Ext.reg('mixedimage-window-editimage', mixedimage.windowCrop); +mixedimage.parseCropOptionValue = function (value) { + if (value === undefined || value === null) { + return value; + } + + value = String(value).trim(); + + if (value === 'true') { + return true; + } + if (value === 'false') { + return false; + } + if (value === 'null') { + return null; + } + if (value !== '' && !isNaN(value)) { + return Number(value); + } + + return value; +}; + +mixedimage.parseCropOptions = function (optionsString) { + if (!optionsString) { + return {}; + } + + var options = {}; + + optionsString.split(',').forEach(function (pair) { + var parts = pair.split(':'); + if (parts.length < 2) { + return; + } + + var key = parts.shift().trim(); + var value = parts.join(':').trim(); + + if (key) { + options[key] = mixedimage.parseCropOptionValue(value); + } + }); + + return options; +}; + +mixedimage.getSourceMime = function (filePath) { + var ext = (filePath.split('.').pop() || '').toLowerCase(); + var map = { + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + gif: 'image/gif', + webp: 'image/webp' + }; + + return map[ext] || 'image/png'; +}; + +mixedimage.buildCropperOptions = function (field, dataWidthEl, dataHeightEl) { + var ratio = ''; + if (field.crop_ratio) { + var ratioParts = String(field.crop_ratio).split('/'); + if (ratioParts.length === 2 && ratioParts[1] != 0) { + ratio = ratioParts[0] / ratioParts[1]; + } + } + + var defaults = { + aspectRatio: ratio, + minCanvasWidth: 300, + minCropBoxWidth: field.crop_width, + minCropBoxHeight: field.crop_height, + zoomOnWheel: false, + viewMode: 1, + crop: function (e) { + var data = e.detail; + dataHeightEl.innerText = Math.round(data.height); + dataWidthEl.innerText = Math.round(data.width); + } + }; + + return Ext.apply(defaults, mixedimage.parseCropOptions(field.crop_options)); +}; + +mixedimage.saveCroppedBlob = function (field, blob) { + var reader = new FileReader(); + reader.onloadend = function () { + Ext.Ajax.request({ + url: MODx.config.assets_url + 'components/mixedimage/connector.php', + params: { + file: reader.result, + action: 'file/crop', + ctx_path: field.ctx_path, + value: field.value, + source: field.source, + suffix: field.crop_suffix, + tvId: field.tvId + }, + success: function (response) { + field.setValueInput(response.responseText); + MODx.fireResourceFormChange(); + }, + failure: function () { + MODx.msg.alert('Error', _('mixedimage.err_crop_save')); + } + }); + }; + reader.readAsDataURL(blob); +}; + + ////////////////////////////////////////////////////////// @@ -524,19 +637,19 @@ Ext.extend(mixedimage.trigger, Ext.form.TriggerField, { listeners: { success: { fn: function () { - this.setValue(''); + this.setValue(''); this.fireEvent('change', this); MODx.msg.alert('Success', _('mixedimage.success_removed')); }, scope: this } } - }); + }); } else { - this.setValue(''); + this.setValue(''); this.fireEvent('change', this); } - } + } , getExtension: function (value) { var ext = value.split('.').pop(); var isVideo = false; @@ -585,10 +698,9 @@ Ext.extend(mixedimage.trigger, Ext.form.TriggerField, { this.window.show(e.target); } , editImage: function (field, e) { + var triggerField = this; if (!this.windowCrop) { - var cropper; - var winHeight = window.innerHeight - 100; var winWidth = window.innerWidth - 100; @@ -607,108 +719,53 @@ Ext.extend(mixedimage.trigger, Ext.form.TriggerField, { text: _('mixedimage.button_crop'), cls: 'primary-button', handler: function () { - - canvas = cropper.getCroppedCanvas({ - width: field.crop_width, - height: field.crop_height, + var win = triggerField.windowCrop; + var mime = mixedimage.getSourceMime(triggerField.value); + var canvas = win.cropper.getCroppedCanvas({ + width: triggerField.crop_width, + height: triggerField.crop_height, maxWidth: 1000, - maxHeight: 1000, + maxHeight: 1000 }); canvas.toBlob(function (blob) { - url = URL.createObjectURL(blob); - var reader = new FileReader(); - reader.readAsDataURL(blob); - reader.onloadend = function () { - var base64data = reader.result; - - Ext.Ajax.request({ - url: MODx.config.assets_url + 'components/mixedimage/connector.php' - , params: { - file: base64data - , action: 'file/crop' - , ctx_path: field.ctx_path - , value: field.value - , source: field.source - , suffix: field.crop_suffix - , tvId: field.tvId - } - , success: function (data) { - field.setValueInput(data.responseText); - MODx.fireResourceFormChange(); - } - , failure: function (data) { - MODx.msg.alert('Error on cropped'); - } - }); - }; - }); - - this.windowCrop.hide(); + mixedimage.saveCroppedBlob(triggerField, blob); + }, mime); + win.hide(); } - , scope: this }, { text: _('cancel'), handler: function () { - this.windowCrop.hide(); + triggerField.windowCrop.hide(); } - , scope: this } ] , width: winWidth , height: winHeight , listeners: { - success: { - fn: function (data) { + show: function (win) { + var imageEl = document.getElementById('image-' + win.window.id); + var dataHeightEl = document.getElementById('crop-dataHeight-' + win.window.id); + var dataWidthEl = document.getElementById('crop-dataWidth-' + win.window.id); - }, scope: this - } - , failure: function (fp, o) { - MODx.msg.alert('Error', o.result.message); - } - , show: function (fp, o) { - var image = document.getElementById("image-" + fp.window.id); - var dataHeight = document.getElementById("crop-dataHeight-" + fp.window.id); - var dataWidth = document.getElementById("crop-dataWidth-" + fp.window.id); - var ratio = ''; - - if (field.crop_ratio) { - var ratioArray = (field.crop_ratio).split('/'); - ratio = ratioArray[0] / ratioArray[1]; - } - - const crop_options_default = { - aspectRatio: ratio, - minCanvasWidth: 300, - minCropBoxWidth: field.crop_width, - minCropBoxHeight: field.crop_height, - zoomOnWheel: false, - viewMode: 1, - crop(e) { - var data = e.detail; - dataHeight.innerText = Math.round(data.height); - dataWidth.innerText = Math.round(data.width); - }, - } + imageEl.src = '/' + win.window.ctx_path + win.window.value; - let crop_options_config = {} - - if (field.crop_options) { - crop_options_config = Object.fromEntries(field.crop_options.split(',').map(i => i.split(':'))); + if (win.cropper) { + win.cropper.destroy(); } - const crop_options = { - ...crop_options_default, - ...crop_options_config - } - - cropper = new Cropper(image, crop_options); + win.cropper = new Cropper( + imageEl, + mixedimage.buildCropperOptions(win.window, dataWidthEl, dataHeightEl) + ); } - , hide: function () { - cropper.destroy(); - cropper = null; + , hide: function (win) { + if (win.cropper) { + win.cropper.destroy(); + win.cropper = null; + } } } }); diff --git a/core/components/mixedimage/docs/changelog.txt b/core/components/mixedimage/docs/changelog.txt index 44f88e3..cc670a0 100644 --- a/core/components/mixedimage/docs/changelog.txt +++ b/core/components/mixedimage/docs/changelog.txt @@ -1,115 +1,170 @@ --------------------- - -Extra: mixedImage - Custom TV - --------------------- -### 2.3.0 -* Fix clearing value on open browser select https://github.com/webinmd/mixedimage/issues/39 -* Fix open path https://github.com/webinmd/mixedimage/issues/40 -* Fix parsing placeholders {id} and {pid} https://github.com/webinmd/mixedimage/issues/42 - - --------------------- -### 2.2.0 -* Crop image -* Fix checking mime type By [webnitros] https://github.com/webnitros -* Fix get tv id in migx field By [webnitros] -* Changed download functionBy [webnitros] - - --------------------- -### 2.1.0 -* Refactoring -* Support MODx 3.0 -* CSS Fix -* Drag from tree - --------------------- -# Version 2.0.9-beta -:: Fix Access Denied in MODx 2.8.0 - --------------------- -# Version 2.0.8-beta -:: Fix MIGX CMP - --------------------- -# Version 2.0.7-beta -:: Add video preview - --------------------- -# Version 2.0.6-beta --- By DeNomad -:: Add drag&drop - --------------------- -# Version 2.0.5-beta -:: Bugfix - --------------------- -# Version 2.0.4-beta -:: Fix url upload on migx - --------------------- -# Version 2.0.3-beta -:: Fix css for modx-resource-main-right position -:: Fix js bug on url upload - --------------------- -# Version 2.0.2-beta -:: Add download by external url -:: Move removing file in modx processor -:: Control buttons for each tv field -:: Fix css - --------------------- -# Version 2.0.1-beta --- By SintezCode -:: Rewrite extjs part for extending and overriding by another components. -:: Triggers now may configured by triggerConfig. -:: Triggers handlers may extending or overriding. -:: Input width now depended by triggers count and its width. -:: In css now not needed triggers width and positions -:: Add ability to set custom upload path by another components or by extending or overriding -:: Uploader now send all resource-panel fields in processor and may used to get upload path by placeholders or in snippet - --------------------- -# Version 2.0.0-beta -:: Change TV view -:: Fix upload for unsaved resource -:: Added file deletion - --------------------- -# Version 1.1.6-beta -:: Fixed plugin event - --------------------- -# Version 1.1.5-beta -:: Fixed media path on ajax request -:: Fixed undefined in manager lexicon - --------------------- -# Version 1.1.4-beta -:: Fixed {alias} - --------------------- -# Version 1.1.3-beta -:: Fixed for media source path in context - --------------------- -# Version 1.1.2-beta -:: Fixed for media source path (2) - --------------------- -# Version 1.1.1-beta -:: legiero: Fixed css -:: Fixed russian filename - --------------------- -# Version 1.1.0-beta - :: Add resize options - :: Fixed for MIGX - :: Fixed for media source path - --------------------- -# Version 1.0.0-beta - :: initial release +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Документация crop и события `OnMixedImageCrop` в README. + +### Changed +- Рефакторинг crop-процессора и JS-хелперов CropperJS без смены UX редактора. + +### Fixed +- Согласование MIME blob и расширения файла при обрезке. +- Повторное открытие окна crop обновляет изображение. +- Парсинг `crop_options` с типами bool/number/null. + +## [2.3.0-pl] - 2026-02-09 + +### Fixed +- Очистка значения при выборе в browser (#39). +- Корректный `openPath` (#40). +- Парсинг плейсхолдеров `{id}` и `{pid}` (#42). + +## [2.2.0-pl] - 2023-04-21 + +### Added +- Crop изображения (CropperJS, TV-опции, процессор `file/crop`, событие `OnMixedImageCrop`). +- Диалог подтверждения при удалении файла. + +### Fixed +- Проверка MIME (by webnitros). +- Получение TV id в MIGX (by webnitros). + +### Changed +- Функция загрузки файла (by webnitros). + +## [2.1.0-pl] - 2022-02-23 + +### Added +- Drag файлов из дерева ресурсов. + +### Changed +- Рефакторинг компонента. +- Поддержка MODX 3.0. + +### Fixed +- CSS. + +## [2.0.9-beta] - 2020-10-30 + +### Fixed +- Access Denied в MODX 2.8.0. + +## [2.0.8-beta] - 2019-09-10 + +### Fixed +- MIGX CMP (#8). + +## [2.0.7-beta] - 2019-06-20 + +### Added +- Превью видео в админке и при загрузке по URL. + +## [2.0.6-beta] - 2019-04-04 + +### Added +- Drag and drop (by DeNomad). + +### Fixed +- Инициализация drag&drop внутри MIGX. + +## [2.0.5-beta] - 2019-03-29 + +### Fixed +- Bugfix при обновлении с версии 2.0.0. + +## [2.0.4-beta] - 2019-03-07 + +### Fixed +- Загрузка по URL в MIGX. + +## [2.0.3-pl] - 2019-03-05 + +### Fixed +- CSS для `modx-resource-main-right`. +- JS-баг при загрузке по URL. + +## [2.0.2-beta] - 2019-03-01 + +### Added +- Загрузка по внешнему URL. +- Настраиваемые кнопки-триггеры для каждого TV. + +### Changed +- Удаление файла перенесено в processor. + +### Fixed +- CSS. + +## [2.0.1-beta] - 2018-06-11 + +### Added +- Кастомный путь загрузки через расширение компонента. +- Отправка полей resource-panel в processor для плейсхолдеров пути. + +### Changed +- Переписан ExtJS-слой для расширения и переопределения (by SintezCode). +- Ширина поля зависит от числа триггеров. +- Триггеры настраиваются через `triggerConfig`. + +## [2.0.0-beta] - 2018-01-08 + +### Added +- Физическое удаление файла при очистке поля. + +### Changed +- Новый вид TV-поля. + +### Fixed +- Загрузка для несохранённого ресурса. + +## [1.1.6-beta] - 2018-01-05 + +### Fixed +- Событие плагина. + +## [1.1.5-beta] - 2017-12-01 + +### Fixed +- Путь медиа в AJAX-запросе. +- Undefined в lexicon менеджера. + +## [1.1.4-beta] - 2017-03-16 + +### Fixed +- Плейсхолдер `{alias}`. + +## [1.1.3-beta] - 2017-03-01 + +### Fixed +- Путь media source в контексте. + +## [1.1.2-beta] - 2017-02-20 + +### Fixed +- Путь media source (2). + +## [1.1.1-beta] - 2017-02-10 + +### Fixed +- CSS (by legiero). +- Русские имена файлов. + +## [1.1.0-beta] - 2017-02-01 + +### Added +- Параметры ресайза изображений. + +### Fixed +- MIGX. +- Путь media source. + +## [1.0.0-beta] - 2017-01-05 + +### Added +- Первый релиз mixedImage TV. diff --git a/core/components/mixedimage/lexicon/en/default.inc.php b/core/components/mixedimage/lexicon/en/default.inc.php index cb62d8e..5a33f1f 100644 --- a/core/components/mixedimage/lexicon/en/default.inc.php +++ b/core/components/mixedimage/lexicon/en/default.inc.php @@ -40,6 +40,10 @@ $_lang['mixedimage.err_save_resource'] = 'Before adding new items, you need to save this resource!'; $_lang['mixedimage.err_file_mime'] = 'mixedImage: wrong file MIME type'; $_lang['mixedimage.error_remove'] = 'mixedImage removing (view console)'; +$_lang['mixedimage.err_crop_invalid_data'] = 'mixedImage: invalid crop image data'; +$_lang['mixedimage.err_crop_value_ns'] = 'mixedImage: source file value is missing'; +$_lang['mixedimage.err_crop_write_failed'] = 'mixedImage: failed to save cropped image'; +$_lang['mixedimage.err_crop_save'] = 'Error saving cropped image'; // Settings $_lang['setting_mixedimage.translit'] = 'Translit'; @@ -65,7 +69,7 @@ $_lang['mixedimage.trigger_btn_file_desktop'] = 'From computer'; $_lang['mixedimage.trigger_btn_file_url'] = 'From external url'; -$_lang['mixedimage.triggerlist_desc'] = 'Specify which of the trigger buttons will be displayed when rendering the field (clear,manager,pc,url)'; +$_lang['mixedimage.triggerlist_desc'] = 'Specify which of the trigger buttons will be displayed when rendering the field (clear,manager,pc,url,crop)'; // url $_lang['mixedimage.window_url'] = 'Provide a link'; $_lang['mixedimage.link'] = 'Link'; @@ -80,4 +84,4 @@ $_lang['mixedimage.image_crop_title'] = 'Edit image'; $_lang['mixedimage.button_crop'] = 'Crop'; $_lang['mixedimage.crop_options'] = 'Additional options'; -$_lang['mixedimage.crop_options_desc'] = 'Example: zoomOnWheel: false, viewMode: 1
Full list of options'; +$_lang['mixedimage.crop_options_desc'] = 'Format: key: value, key2: value2. Values true, false, null and numbers are parsed automatically. Example: zoomOnWheel: false, viewMode: 1
Full CropperJS options list'; diff --git a/core/components/mixedimage/lexicon/ru/default.inc.php b/core/components/mixedimage/lexicon/ru/default.inc.php index a6a11ab..9cb1ef1 100644 --- a/core/components/mixedimage/lexicon/ru/default.inc.php +++ b/core/components/mixedimage/lexicon/ru/default.inc.php @@ -39,6 +39,10 @@ $_lang['mixedimage.error_remove'] = 'Ошибка при удалении (смотрите console)'; $_lang['mixedimage.err_file_ns'] = 'Ошибка: файл не был загружен'; $_lang['mixedimage.err_save_resource'] = 'Перед добавлением новых элементов, вам необходимо сохранить этот ресурс!'; +$_lang['mixedimage.err_crop_invalid_data'] = 'mixedImage: некорректные данные изображения для обрезки'; +$_lang['mixedimage.err_crop_value_ns'] = 'mixedImage: не указан исходный файл для обрезки'; +$_lang['mixedimage.err_crop_write_failed'] = 'mixedImage: не удалось сохранить обрезанное изображение'; +$_lang['mixedimage.err_crop_save'] = 'Ошибка при сохранении обрезанного изображения'; // Settings $_lang['setting_mixedimage.translit'] = 'Транслитерация файлов'; @@ -62,7 +66,7 @@ $_lang['mixedimage.trigger_btn_file_desktop'] = 'С компьютера'; $_lang['mixedimage.trigger_btn_file_url'] = 'С URL'; -$_lang['mixedimage.triggerlist_desc'] = 'Укажите какие из кнопок-триггеров будут выводиться при рендере поля, через запятую (clear,manager,pc,url)'; +$_lang['mixedimage.triggerlist_desc'] = 'Укажите какие из кнопок-триггеров будут выводиться при рендере поля, через запятую (clear,manager,pc,url,crop)'; // url $_lang['mixedimage.window_url'] = 'Укажите ссылку'; @@ -78,4 +82,4 @@ $_lang['mixedimage.image_crop_title'] = 'Редактировать изображение'; $_lang['mixedimage.button_crop'] = 'Обрезать'; $_lang['mixedimage.crop_options'] = 'Дополнительные опции'; -$_lang['mixedimage.crop_options_desc'] = 'Пример: zoomOnWheel: false, viewMode: 1
Полный список опций'; +$_lang['mixedimage.crop_options_desc'] = 'Формат: key: value, key2: value2. Значения true, false, null и числа распознаются автоматически. Пример: zoomOnWheel: false, viewMode: 1
Полный список опций CropperJS'; diff --git a/core/components/mixedimage/processors/file/crop.class.php b/core/components/mixedimage/processors/file/crop.class.php index 2e5ecd6..91e66a5 100644 --- a/core/components/mixedimage/processors/file/crop.class.php +++ b/core/components/mixedimage/processors/file/crop.class.php @@ -8,6 +8,12 @@ class mixedimageCropProcessor extends modProcessor { + private const MIME_EXTENSIONS = [ + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/gif' => 'gif', + 'image/webp' => 'webp', + ]; public function initialize() { @@ -15,38 +21,96 @@ public function initialize() return true; } + public function getLanguageTopics() + { + return ['mixedimage:default']; + } public function process() { - $data = $this->properties['file']; - $suffix = $this->properties['suffix']; - $old_value = $this->properties['value']; - $image_array_1 = explode(";", $data); - $image_array_2 = explode(",", $image_array_1[1]); - $data = base64_decode($image_array_2[1]); - - $name_array = explode(".", $old_value); - $fileinfo = pathinfo($old_value); + $oldValue = (string)$this->getProperty('value', ''); + if ($oldValue === '') { + return $this->failure($this->modx->lexicon('mixedimage.err_crop_value_ns')); + } - if (mb_strlen($suffix) > 0) { - if ($suffix == 'time()') { - $suffix = "_" . time(); - } elseif (stripos($name_array[0], $suffix) !== false) { - $suffix = ''; - } + $decoded = $this->decodeDataUri((string)$this->getProperty('file', '')); + if ($decoded === null) { + return $this->failure($this->modx->lexicon('mixedimage.err_crop_invalid_data')); } - $image_new = $name_array[0] . $suffix . '.' . $fileinfo['extension']; - $image_cropped = MODX_BASE_PATH . $this->properties['ctx_path'] . $image_new; + $fileInfo = pathinfo($oldValue); + $extension = $this->resolveExtension($fileInfo, $decoded['mime']); + $suffix = $this->resolveSuffix( + (string)$this->getProperty('suffix', ''), + $fileInfo['filename'] ?? pathinfo($oldValue, PATHINFO_FILENAME) + ); - file_put_contents($image_cropped, $data); + $relativePath = $this->buildRelativePath($fileInfo, $suffix, $extension); + $absolutePath = MODX_BASE_PATH . $this->getProperty('ctx_path', '') . $relativePath; + + if (@file_put_contents($absolutePath, $decoded['data']) === false) { + return $this->failure($this->modx->lexicon('mixedimage.err_crop_write_failed')); + } $this->modx->invokeEvent('OnMixedImageCrop', [ - 'image' => $image_cropped, - 'tvId' => $this->properties['tvId'] + 'image' => $absolutePath, + 'tvId' => $this->getProperty('tvId'), ]); - return $image_new; + return $relativePath; + } + + private function decodeDataUri($dataUri) + { + if (!preg_match('#^data:([^;]+);base64,(.+)$#s', $dataUri, $matches)) { + return null; + } + + $binary = base64_decode($matches[2], true); + if ($binary === false) { + return null; + } + + return [ + 'mime' => strtolower(trim($matches[1])), + 'data' => $binary, + ]; + } + + private function resolveExtension(array $fileInfo, $mime) + { + if (!empty($fileInfo['extension'])) { + return strtolower($fileInfo['extension']); + } + + return self::MIME_EXTENSIONS[$mime] ?? 'png'; + } + + private function resolveSuffix($suffix, $basename) + { + if (mb_strlen($suffix) === 0) { + return ''; + } + + if ($suffix === 'time()') { + return '_' . time(); + } + + if (stripos($basename, $suffix) !== false) { + return ''; + } + + return $suffix; + } + + private function buildRelativePath(array $fileInfo, $suffix, $extension) + { + $dirname = (!empty($fileInfo['dirname']) && $fileInfo['dirname'] !== '.') + ? $fileInfo['dirname'] . '/' + : ''; + $filename = $fileInfo['filename'] ?? ''; + + return $dirname . $filename . $suffix . '.' . $extension; } } From a3f72a93f3e4ec72194925f6ff5a5bbbce7660e5 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Sun, 9 Aug 2026 10:18:20 +0600 Subject: [PATCH 2/2] fix(mixedimage): parse crop connector JSON and defer window close Use result.message from MODX response, add HTTP_MODAUTH, coerce crop dimensions to integers, and hide crop window only after successful save. --- .../mixedimage/js/mgr/mixedimage.js | 34 +++++++++++++------ .../mixedimage/processors/file/crop.class.php | 2 +- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/assets/components/mixedimage/js/mgr/mixedimage.js b/assets/components/mixedimage/js/mgr/mixedimage.js index 883dc14..8158928 100644 --- a/assets/components/mixedimage/js/mgr/mixedimage.js +++ b/assets/components/mixedimage/js/mgr/mixedimage.js @@ -494,8 +494,8 @@ mixedimage.buildCropperOptions = function (field, dataWidthEl, dataHeightEl) { var defaults = { aspectRatio: ratio, minCanvasWidth: 300, - minCropBoxWidth: field.crop_width, - minCropBoxHeight: field.crop_height, + minCropBoxWidth: parseInt(field.crop_width, 10) || 0, + minCropBoxHeight: parseInt(field.crop_height, 10) || 0, zoomOnWheel: false, viewMode: 1, crop: function (e) { @@ -508,7 +508,7 @@ mixedimage.buildCropperOptions = function (field, dataWidthEl, dataHeightEl) { return Ext.apply(defaults, mixedimage.parseCropOptions(field.crop_options)); }; -mixedimage.saveCroppedBlob = function (field, blob) { +mixedimage.saveCroppedBlob = function (field, blob, onComplete) { var reader = new FileReader(); reader.onloadend = function () { Ext.Ajax.request({ @@ -517,14 +517,26 @@ mixedimage.saveCroppedBlob = function (field, blob) { file: reader.result, action: 'file/crop', ctx_path: field.ctx_path, - value: field.value, + value: field.getValue(), source: field.source, suffix: field.crop_suffix, - tvId: field.tvId + tvId: field.tvId, + HTTP_MODAUTH: MODx.siteId }, success: function (response) { - field.setValueInput(response.responseText); + var result = Ext.util.JSON.decode(response.responseText); + + if (!result || !result.success) { + MODx.msg.alert('Error', (result && result.message) || _('mixedimage.err_crop_save')); + return; + } + + field.setValueInput(result.message); MODx.fireResourceFormChange(); + + if (typeof onComplete === 'function') { + onComplete(); + } }, failure: function () { MODx.msg.alert('Error', _('mixedimage.err_crop_save')); @@ -722,17 +734,17 @@ Ext.extend(mixedimage.trigger, Ext.form.TriggerField, { var win = triggerField.windowCrop; var mime = mixedimage.getSourceMime(triggerField.value); var canvas = win.cropper.getCroppedCanvas({ - width: triggerField.crop_width, - height: triggerField.crop_height, + width: parseInt(triggerField.crop_width, 10) || undefined, + height: parseInt(triggerField.crop_height, 10) || undefined, maxWidth: 1000, maxHeight: 1000 }); canvas.toBlob(function (blob) { - mixedimage.saveCroppedBlob(triggerField, blob); + mixedimage.saveCroppedBlob(triggerField, blob, function () { + win.hide(); + }); }, mime); - - win.hide(); } }, { diff --git a/core/components/mixedimage/processors/file/crop.class.php b/core/components/mixedimage/processors/file/crop.class.php index 91e66a5..2ec350a 100644 --- a/core/components/mixedimage/processors/file/crop.class.php +++ b/core/components/mixedimage/processors/file/crop.class.php @@ -57,7 +57,7 @@ public function process() 'tvId' => $this->getProperty('tvId'), ]); - return $relativePath; + return $this->success($relativePath); } private function decodeDataUri($dataUri)