Release 2.21.0: non-destructive media and scoped player sync
This commit is contained in:
1 parent
24df245ea1
commit
7cff39dc76
35 files changed
+2009
-949
No files matched your search
@@ -3321,6 +3321,41 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
justify-items: end;
|
||||
}
|
||||
|
||||
.slide-image-region-preview-tools {
|
||||
display: grid;
|
||||
grid-template-columns: 2.5rem minmax(0, 1fr);
|
||||
min-height: 176px;
|
||||
background: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
.slide-image-region-preview-tools > .slide-image-region-preview-box {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.slide-image-region-crop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--bs-border-color);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 1.125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slide-image-region-crop:hover:not(:disabled) {
|
||||
background: var(--bs-secondary-bg);
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.slide-image-region-crop:focus-visible {
|
||||
outline: 2px solid var(--bs-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.slide-image-region-preview-shell {
|
||||
position: relative;
|
||||
display: block;
|
||||
|
||||
@@ -26,10 +26,19 @@
|
||||
return '<img class="slide-preview-image" src="' + escapeHtml(src) + '" alt="" style="width:100%;height:100%;object-fit:contain;display:block;" />';
|
||||
}
|
||||
|
||||
function renderCropButton(region) {
|
||||
return '<button type="button" class="slide-image-region-crop" data-edit-image-crop="' + region.id + '" data-region-key="' + escapeHtml(region.region_key) + '" data-region-width="' + escapeHtml(region.width) + '" data-region-height="' + escapeHtml(region.height) + '" data-region-ratio="' + escapeHtml(region.width + ':' + region.height) + '" title="Edit crop" aria-label="Edit crop"><i class="bi bi-crop" aria-hidden="true"></i></button>';
|
||||
}
|
||||
|
||||
function renderFramingControls(region, value) {
|
||||
var current = value || {};
|
||||
return '<input type="hidden" name="region_image_original_' + region.id + '" value="' + escapeHtml(current.original_value || current.value || '') + '">' +
|
||||
'<input type="hidden" name="region_image_crop_' + region.id + '" value="' + escapeHtml(current.crop ? JSON.stringify(current.crop) : '') + '">';
|
||||
}
|
||||
|
||||
function renderEditorCard(context) {
|
||||
var region = context.region;
|
||||
var current = String(context.current || '');
|
||||
var regionRatio = String(context.regionRatio || '1:1');
|
||||
var uploadConfig = context.uploadConfig || {};
|
||||
var uploadMaxLabel = String(uploadConfig.limitLabel || context.uploadMaxLabel || '100 MB');
|
||||
var uploadAccept = Array.isArray(uploadConfig.accept) ? uploadConfig.accept.join(', ') : String(uploadConfig.accept || 'image/png, image/jpeg, image/gif, image/webp, image/svg+xml');
|
||||
@@ -42,7 +51,7 @@
|
||||
'<div class="row g-3 align-items-start">' +
|
||||
'<div class="col-12 col-md-8 d-flex flex-column">' +
|
||||
'<label class="slide-image-region-upload-zone" data-region-upload-zone="' + region.id + '" data-media-picker data-media-picker-type="image" data-media-picker-hidden="existing_region_image_' + region.id + '" for="region_image_' + region.id + '">' +
|
||||
'<input type="file" id="region_image_' + region.id + '" name="region_image_' + region.id + '" class="visually-hidden" accept="' + escapeHtml(uploadAccept) + '" data-slide-image-cropper-region-ratio="' + escapeHtml(regionRatio) + '" data-slide-image-cropper-region-ratio-label="Region" />' +
|
||||
'<input type="file" id="region_image_' + region.id + '" name="region_image_' + region.id + '" class="visually-hidden" accept="' + escapeHtml(uploadAccept) + '" />' +
|
||||
'<span class="slide-image-region-upload-zone-content">' +
|
||||
'<span class="slide-image-region-upload-zone-icon"><i class="bi bi-cloud-arrow-up" aria-hidden="true"></i></span>' +
|
||||
'<span class="slide-image-region-upload-zone-copy">' +
|
||||
@@ -60,6 +69,8 @@
|
||||
'</label>' +
|
||||
'</div>' +
|
||||
'<div class="col-12 col-md-4">' +
|
||||
'<div class="slide-image-region-preview-tools">' +
|
||||
renderCropButton(region) +
|
||||
'<div class="slide-image-region-preview-box">' +
|
||||
(current
|
||||
? '<div class="slide-image-region-preview-shell" data-remove-region-image="' + region.id + '" role="button" tabindex="0" aria-label="Remove image">' +
|
||||
@@ -68,13 +79,14 @@
|
||||
'</div>'
|
||||
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No image</div>') +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<input type="hidden" name="existing_region_image_' + region.id + '" value="' + escapeHtml(current) + '" />'
|
||||
'<input type="hidden" name="existing_region_image_' + region.id + '" value="' + escapeHtml(current) + '" />' + renderFramingControls(region, context.imageContent)
|
||||
});
|
||||
}
|
||||
|
||||
function buildEditorCardContext(context) {
|
||||
function buildEditorCardContext(context, existingContent) {
|
||||
var defaultConfig = context && context.uploadConfig ? context.uploadConfig : {};
|
||||
var configuredTypes = Array.isArray(context && context.uploadMimeTypes)
|
||||
? context.uploadMimeTypes.filter(function (mimeType) { return String(mimeType || '').indexOf('image/') === 0; })
|
||||
@@ -92,6 +104,7 @@
|
||||
return {
|
||||
region: context.region,
|
||||
current: String(context.current || ''),
|
||||
imageContent: (existingContent || {})[context.region.region_key] || {},
|
||||
regionRatio: String(context.regionRatio || '1:1'),
|
||||
uploadConfig: {
|
||||
accept: accept,
|
||||
@@ -109,12 +122,47 @@
|
||||
var imageHidden = card && card.querySelector ? card.querySelector('input[type="hidden"][name="existing_region_image_' + region.id + '"]') : null;
|
||||
var imageFileInput = card && card.querySelector ? card.querySelector('input[type="file"][name="region_image_' + region.id + '"]') : null;
|
||||
|
||||
var original = card && card.querySelector ? card.querySelector('[name="region_image_original_' + region.id + '"]') : null;
|
||||
var crop = card && card.querySelector ? card.querySelector('[name="region_image_crop_' + region.id + '"]') : null;
|
||||
var cropData = current.crop || null;
|
||||
try { if (crop) cropData = JSON.parse(crop.value || 'null'); } catch (_error) { cropData = null; }
|
||||
return {
|
||||
value: imageFileInput && imageFileInput.dataset.previewUrl ? imageFileInput.dataset.previewUrl : (imageHidden ? imageHidden.value : (current && current.value !== undefined ? current.value : '')),
|
||||
original_value: original ? original.value : current.original_value || current.value || '',
|
||||
crop: cropData,
|
||||
existingContent: existingContent || {}
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined' && document.addEventListener) {
|
||||
document.addEventListener('change', function (event) {
|
||||
var input = event.target;
|
||||
if (!input.name || input.name.indexOf('existing_region_image_') !== 0 || input.dataset.applyingCrop === '1') return;
|
||||
var card = input.closest('[data-region-id]');
|
||||
if (!card) return;
|
||||
var regionId = input.name.slice('existing_region_image_'.length);
|
||||
var original = card.querySelector('[name="region_image_original_' + regionId + '"]');
|
||||
var crop = card.querySelector('[name="region_image_crop_' + regionId + '"]');
|
||||
if (original) original.value = input.value;
|
||||
if (crop) crop.value = '';
|
||||
if (input.value) input.dataset.cropSelectionPending = '1';
|
||||
else delete input.dataset.cropSelectionPending;
|
||||
if (!input.value) return;
|
||||
var selectedValue = input.value;
|
||||
var openCropper = function () {
|
||||
if (!card.isConnected || input.value !== selectedValue) return;
|
||||
var button = card.querySelector('[data-edit-image-crop]');
|
||||
if (button && !button.disabled) button.click();
|
||||
};
|
||||
var picker = document.getElementById('media-picker-modal');
|
||||
if (picker && picker.classList.contains('show')) {
|
||||
picker.addEventListener('hidden.bs.modal', openCropper, { once: true });
|
||||
} else {
|
||||
window.setTimeout(openCropper, 0);
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
|
||||
function handleMediaInputChange(context) {
|
||||
if (!context || !context.file || typeof context.uploadFile !== 'function') {
|
||||
return false;
|
||||
|
||||
@@ -35,17 +35,7 @@ const uploadInput = document.querySelector('[data-media-library-upload]');
|
||||
const uploadZone = document.querySelector('[data-media-library-upload-zone]');
|
||||
const uploadName = document.querySelector('[data-media-library-upload-name]');
|
||||
const uploadModal = document.getElementById('media-library-upload-modal');
|
||||
const cropModal = document.getElementById('media-library-crop-modal');
|
||||
const cropImage = cropModal && cropModal.querySelector('#media-library-crop-image');
|
||||
const cropApply = cropModal && cropModal.querySelector('[data-media-library-crop-apply]');
|
||||
const cropCancel = cropModal && cropModal.querySelector('[data-media-library-crop-cancel]');
|
||||
const cropStatus = cropModal && cropModal.querySelector('#media-library-crop-status');
|
||||
const cropFrame = cropModal && cropModal.querySelector('.slide-image-cropper-frame');
|
||||
let cropper = null;
|
||||
let cropObjectUrl = '';
|
||||
let cropSourceFile = null;
|
||||
let cropFlipX = 1;
|
||||
let cropFlipY = 1;
|
||||
let uploadInProgress = false;
|
||||
|
||||
function getColumnsPerRow() {
|
||||
return window.innerWidth >= 1200 ? 6 : window.innerWidth >= 992 ? 3 : window.innerWidth >= 576 ? 2 : 1;
|
||||
@@ -73,28 +63,6 @@ if (addMediaButton && uploadModal) {
|
||||
});
|
||||
}
|
||||
|
||||
function clearCropper() {
|
||||
if (cropper) {
|
||||
cropper.destroy();
|
||||
cropper = null;
|
||||
}
|
||||
if (cropObjectUrl) {
|
||||
URL.revokeObjectURL(cropObjectUrl);
|
||||
cropObjectUrl = '';
|
||||
}
|
||||
cropSourceFile = null;
|
||||
cropFlipX = 1;
|
||||
cropFlipY = 1;
|
||||
if (cropStatus) cropStatus.textContent = '';
|
||||
if (cropFrame) cropFrame.classList.remove('is-loading');
|
||||
}
|
||||
|
||||
function setCropperActionState(disabled) {
|
||||
if (!cropModal) return;
|
||||
cropModal.querySelectorAll('[data-media-library-crop-action]').forEach(function (button) {
|
||||
button.disabled = Boolean(disabled);
|
||||
});
|
||||
}
|
||||
function appendGalleryAsset(asset) {
|
||||
if (!gallery || !asset) return;
|
||||
const column = document.createElement('div');
|
||||
@@ -178,52 +146,9 @@ function setCropperActionState(disabled) {
|
||||
column.querySelector('.media-gallery-card').classList.toggle('media-gallery-card-selected', selectedAssetIds.has(String(asset.id || '')));
|
||||
}
|
||||
|
||||
function applyCropRatio(value) {
|
||||
if (!cropper) return;
|
||||
cropper.setAspectRatio(value === 'free' ? NaN : Number(value.split(':')[0]) / Number(value.split(':')[1]));
|
||||
cropModal.querySelectorAll('[data-media-library-crop-action="ratio"]').forEach(function (button) {
|
||||
const active = button.dataset.mediaLibraryCropRatio === value;
|
||||
button.classList.toggle('active', active);
|
||||
button.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
function initMediaCropper() {
|
||||
if (!cropSourceFile || !cropImage || !window.Cropper || cropper) return;
|
||||
if (cropFrame) cropFrame.classList.add('is-loading');
|
||||
cropper = new window.Cropper(cropImage, {
|
||||
aspectRatio: NaN,
|
||||
autoCropArea: 1,
|
||||
background: false,
|
||||
dragMode: 'move',
|
||||
initialAspectRatio: NaN,
|
||||
movable: true,
|
||||
responsive: true,
|
||||
rotatable: true,
|
||||
scalable: true,
|
||||
viewMode: 1,
|
||||
zoomOnTouch: true,
|
||||
zoomOnWheel: true,
|
||||
ready: function () {
|
||||
if (cropFrame) cropFrame.classList.remove('is-loading');
|
||||
if (cropper && cropper.container) {
|
||||
cropper.container.style.width = '100%';
|
||||
cropper.container.style.height = '560px';
|
||||
cropper.container.style.maxHeight = '70vh';
|
||||
}
|
||||
const containerData = cropper.getContainerData();
|
||||
const imageData = cropper.getImageData();
|
||||
const fitRatio = Math.min(Number(containerData.width || 0) / Number(imageData.naturalWidth || 1), Number(containerData.height || 0) / Number(imageData.naturalHeight || 1), 1);
|
||||
if (fitRatio > 0) cropper.zoomTo(fitRatio);
|
||||
applyCropRatio('free');
|
||||
setCropperActionState(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setUploadFile(file, shouldCrop) {
|
||||
if (!uploadInput || !file) {
|
||||
return;
|
||||
function setUploadFile(file) {
|
||||
if (!uploadInput || uploadInput.disabled || !file) {
|
||||
return false;
|
||||
}
|
||||
const allowedTypes = String(uploadInput.accept || '').split(',').map(function (value) { return value.trim().toLowerCase(); }).filter(Boolean);
|
||||
const fileType = String(file.type || '').toLowerCase();
|
||||
@@ -232,12 +157,12 @@ function setUploadFile(file, shouldCrop) {
|
||||
if (!matchesType) {
|
||||
window.PulseUpload.showMessage('This media type is disabled in Media Uploads settings.');
|
||||
uploadInput.value = '';
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (maxBytes && file.size > maxBytes) {
|
||||
window.PulseUpload.showMessage('This file is larger than the current upload limit of ' + window.PulseUpload.formatUploadLimit(maxBytes) + '.');
|
||||
uploadInput.value = '';
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const transfer = new DataTransfer();
|
||||
@@ -247,15 +172,7 @@ function setUploadFile(file, shouldCrop) {
|
||||
uploadName.textContent = file.name;
|
||||
}
|
||||
|
||||
if (shouldCrop && fileType.indexOf('image/') === 0 && cropModal && cropImage && window.Cropper) {
|
||||
clearCropper();
|
||||
setCropperActionState(true);
|
||||
cropSourceFile = file;
|
||||
cropObjectUrl = URL.createObjectURL(file);
|
||||
cropImage.src = cropObjectUrl;
|
||||
if (cropFrame) cropFrame.classList.add('is-loading');
|
||||
showModal(cropModal);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function submitUpload() {
|
||||
@@ -272,11 +189,8 @@ function submitUpload() {
|
||||
if (uploadInput) {
|
||||
uploadInput.addEventListener('change', function () {
|
||||
const file = uploadInput.files && uploadInput.files[0];
|
||||
if (file) {
|
||||
setUploadFile(file, true);
|
||||
if (String(file.type || '').toLowerCase().indexOf('image/') !== 0 || !(cropModal && cropImage && window.Cropper)) {
|
||||
submitUpload();
|
||||
}
|
||||
if (file && setUploadFile(file)) {
|
||||
submitUpload();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -293,91 +207,91 @@ if (uploadZone) {
|
||||
event.preventDefault();
|
||||
uploadZone.classList.remove('is-dragover');
|
||||
const file = event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0];
|
||||
if (file) {
|
||||
setUploadFile(file, true);
|
||||
if (String(file.type || '').toLowerCase().indexOf('image/') !== 0 || !(cropModal && cropImage && window.Cropper)) {
|
||||
submitUpload();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (cropApply) {
|
||||
cropApply.addEventListener('click', function () {
|
||||
if (!cropper || !cropSourceFile || !uploadInput) {
|
||||
hideModal(cropModal);
|
||||
return;
|
||||
}
|
||||
const outputType = cropSourceFile.type === 'image/jpeg' || cropSourceFile.type === 'image/webp' ? cropSourceFile.type : 'image/png';
|
||||
cropper.getCroppedCanvas().toBlob(function (blob) {
|
||||
if (!blob) {
|
||||
window.PulseUpload.showMessage('Unable to crop this image.');
|
||||
return;
|
||||
}
|
||||
const extension = outputType === 'image/jpeg' ? '.jpg' : outputType === 'image/webp' ? '.webp' : '.png';
|
||||
const croppedFile = new File([blob], cropSourceFile.name.replace(/\.[^.]+$/, '') + extension, { type: outputType, lastModified: Date.now() });
|
||||
clearCropper();
|
||||
setUploadFile(croppedFile, false);
|
||||
hideModal(cropModal);
|
||||
if (file && setUploadFile(file)) {
|
||||
submitUpload();
|
||||
}, outputType, 0.92);
|
||||
});
|
||||
}
|
||||
|
||||
if (cropCancel) {
|
||||
cropCancel.addEventListener('click', function () {
|
||||
clearCropper();
|
||||
if (uploadInput) uploadInput.value = '';
|
||||
if (uploadName) uploadName.textContent = 'No file selected';
|
||||
hideModal(cropModal);
|
||||
});
|
||||
}
|
||||
|
||||
if (cropModal) {
|
||||
cropModal.addEventListener('click', function (event) {
|
||||
const button = event.target && event.target.closest ? event.target.closest('[data-media-library-crop-action]') : null;
|
||||
if (!button || !cropper) return;
|
||||
const action = button.dataset.mediaLibraryCropAction;
|
||||
if (action === 'rotate-left') cropper.rotate(-90);
|
||||
if (action === 'rotate-right') cropper.rotate(90);
|
||||
if (action === 'flip-horizontal') {
|
||||
cropFlipX *= -1;
|
||||
cropper.scaleX(cropFlipX);
|
||||
}
|
||||
if (action === 'flip-vertical') {
|
||||
cropFlipY *= -1;
|
||||
cropper.scaleY(cropFlipY);
|
||||
}
|
||||
if (action === 'ratio') applyCropRatio(button.dataset.mediaLibraryCropRatio || 'free');
|
||||
if (action === 'reset') {
|
||||
cropper.reset();
|
||||
cropFlipX = 1;
|
||||
cropFlipY = 1;
|
||||
applyCropRatio('free');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (cropModal) {
|
||||
cropModal.addEventListener('shown.bs.modal', function () {
|
||||
if (cropSourceFile && cropImage && window.Cropper && !cropper) {
|
||||
initMediaCropper();
|
||||
}
|
||||
});
|
||||
cropModal.addEventListener('hidden.bs.modal', function () {
|
||||
clearCropper();
|
||||
});
|
||||
function setUploadProgress(percent, message, indeterminate) {
|
||||
if (!uploadZone) return;
|
||||
const progress = uploadZone.querySelector('[data-media-library-upload-progress]');
|
||||
const label = uploadZone.querySelector('[data-media-library-upload-progress-text]');
|
||||
const bar = uploadZone.querySelector('[data-media-library-upload-progress-bar]');
|
||||
const fill = uploadZone.querySelector('[data-media-library-upload-progress-fill]');
|
||||
uploadZone.classList.toggle('is-uploading', uploadInProgress);
|
||||
uploadZone.setAttribute('aria-busy', uploadInProgress ? 'true' : 'false');
|
||||
if (progress) progress.hidden = !uploadInProgress;
|
||||
if (label) label.textContent = message;
|
||||
if (bar) {
|
||||
if (indeterminate) bar.removeAttribute('aria-valuenow');
|
||||
else bar.setAttribute('aria-valuenow', String(percent));
|
||||
}
|
||||
if (fill) {
|
||||
fill.style.width = percent + '%';
|
||||
fill.textContent = indeterminate ? '' : percent + '%';
|
||||
fill.classList.toggle('progress-bar-animated', indeterminate);
|
||||
}
|
||||
}
|
||||
|
||||
if (uploadForm) {
|
||||
uploadForm.addEventListener('submit', function (event) {
|
||||
event.preventDefault();
|
||||
if (uploadInProgress) return;
|
||||
if (uploadInput && !uploadInput.files.length) {
|
||||
event.preventDefault();
|
||||
window.PulseUpload.showMessage('Choose an image or video to upload.');
|
||||
return;
|
||||
}
|
||||
const formData = new FormData(uploadForm);
|
||||
const xhr = new XMLHttpRequest();
|
||||
const buttons = uploadModal ? Array.from(uploadModal.querySelectorAll('button')).map(button => ({ button, disabled: button.disabled })) : [];
|
||||
uploadInProgress = true;
|
||||
if (uploadInput) uploadInput.disabled = true;
|
||||
buttons.forEach(state => { state.button.disabled = true; });
|
||||
setUploadProgress(0, 'Uploading...', true);
|
||||
|
||||
function finish() {
|
||||
uploadInProgress = false;
|
||||
if (uploadInput) uploadInput.disabled = false;
|
||||
buttons.forEach(state => { state.button.disabled = state.disabled; });
|
||||
setUploadProgress(0, '', false);
|
||||
}
|
||||
function fail(message) {
|
||||
finish();
|
||||
window.PulseUpload.showMessage(message);
|
||||
}
|
||||
xhr.open('POST', uploadForm.action, true);
|
||||
xhr.withCredentials = true;
|
||||
xhr.timeout = 300000;
|
||||
xhr.setRequestHeader('Accept', 'application/json');
|
||||
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||
xhr.upload.onprogress = function (event) {
|
||||
if (!event.lengthComputable || !event.total) { setUploadProgress(100, 'Uploading...', true); return; }
|
||||
const percent = Math.min(100, Math.round(event.loaded / event.total * 100));
|
||||
setUploadProgress(percent, percent === 100 ? 'Processing...' : 'Uploading ' + percent + '%', percent === 100);
|
||||
};
|
||||
xhr.upload.onload = function () { setUploadProgress(100, 'Processing...', true); };
|
||||
xhr.onload = async function () {
|
||||
let result;
|
||||
try { result = JSON.parse(xhr.responseText || '{}'); } catch (_error) { fail('Unable to upload media. Check your sign-in status and retry.'); return; }
|
||||
if (xhr.status < 200 || xhr.status >= 300 || !result.success) { fail(result.error || 'Unable to upload media.'); return; }
|
||||
finish();
|
||||
if (uploadInput) uploadInput.value = '';
|
||||
hideModal(uploadModal);
|
||||
if (typeof window.showToast === 'function') window.showToast('Media uploaded.', 'success');
|
||||
await refreshGallery();
|
||||
};
|
||||
xhr.onerror = function () { fail('Unable to upload media. Check your connection and retry.'); };
|
||||
xhr.onabort = function () { fail('Upload cancelled.'); };
|
||||
xhr.ontimeout = function () { fail('Upload timed out. Please try again.'); };
|
||||
try { xhr.send(formData); } catch (_error) { fail('Unable to upload media. Please try again.'); }
|
||||
});
|
||||
}
|
||||
|
||||
if (uploadModal) {
|
||||
uploadModal.addEventListener('hide.bs.modal', function (event) { if (uploadInProgress) event.preventDefault(); });
|
||||
}
|
||||
|
||||
function getGalleryFilters() {
|
||||
const params = new URLSearchParams();
|
||||
params.set('search', String(search && search.value || '').trim());
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
(function (root, factory) {
|
||||
var api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
if (root) root.pulseImageCrop = api;
|
||||
}(typeof window !== 'undefined' ? window : null, function () {
|
||||
function dimension(value, fallback) {
|
||||
var number = Number(value);
|
||||
return Number.isFinite(number) && number > 0 ? number : fallback;
|
||||
}
|
||||
|
||||
function outputSize(crop, region, canvas) {
|
||||
var width = dimension(crop && crop.width, 1);
|
||||
var height = dimension(crop && crop.height, 1);
|
||||
var maximumWidth = Math.min(dimension(region && region.width, width), dimension(canvas && canvas.width, width));
|
||||
var maximumHeight = Math.min(dimension(region && region.height, height), dimension(canvas && canvas.height, height));
|
||||
var scale = Math.min(1, maximumWidth / width, maximumHeight / height);
|
||||
return { width: Math.max(1, Math.floor(width * scale)), height: Math.max(1, Math.floor(height * scale)) };
|
||||
}
|
||||
|
||||
function isFullImage(crop, image) {
|
||||
if (!crop || !image || !(image.naturalWidth > 0) || !(image.naturalHeight > 0)) return false;
|
||||
return Math.abs(Number(crop.x) || 0) < 0.01 && Math.abs(Number(crop.y) || 0) < 0.01 &&
|
||||
Math.abs(Number(crop.width) - image.naturalWidth) < 0.01 && Math.abs(Number(crop.height) - image.naturalHeight) < 0.01 &&
|
||||
Math.abs((Number(crop.rotate) || 0) % 360) < 0.01 &&
|
||||
(crop.scaleX === undefined || crop.scaleX === 1) && (crop.scaleY === undefined || crop.scaleY === 1);
|
||||
}
|
||||
|
||||
return { outputSize: outputSize, isFullImage: isFullImage };
|
||||
}));
|
||||
@@ -1,581 +1,284 @@
|
||||
// Slide image cropper modal wiring and file replacement helpers.
|
||||
|
||||
(function () {
|
||||
var templateFields = document.getElementById('template-fields');
|
||||
var modal = document.getElementById('slide-image-cropper-modal');
|
||||
var image = document.getElementById('slide-image-cropper-image');
|
||||
var status = document.getElementById('slide-image-cropper-status');
|
||||
var modal;
|
||||
var cropper;
|
||||
var active;
|
||||
var busy = false;
|
||||
var loader;
|
||||
var draftCopies = new Map();
|
||||
|
||||
if (!templateFields || !modal || !image || !status) {
|
||||
return;
|
||||
function cleanupCopies(paths) {
|
||||
var unique = Array.from(new Set(paths));
|
||||
if (!unique.length) return;
|
||||
fetch('/slides/uploads/cleanup', {
|
||||
method: 'POST', credentials: 'same-origin', keepalive: true,
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ uploadPaths: unique })
|
||||
}).catch(function () {});
|
||||
}
|
||||
|
||||
var cropper = null;
|
||||
var currentInput = null;
|
||||
var currentFile = null;
|
||||
var currentObjectUrl = '';
|
||||
var cropperFrame = modal.querySelector('.slide-image-cropper-frame');
|
||||
var flipX = 1;
|
||||
var flipY = 1;
|
||||
var currentAspectRatio = NaN;
|
||||
var currentRegionAspectRatio = NaN;
|
||||
var currentRegionAspectRatioLabel = 'Region';
|
||||
var listenersAttached = false;
|
||||
var uploadMaxBytes = 100 * 1024 * 1024;
|
||||
var uploadMaxLabel = '100 MB';
|
||||
var editorDataElement = document.getElementById('slide-editor-data');
|
||||
try {
|
||||
var editorData = JSON.parse(editorDataElement && (editorDataElement.value || editorDataElement.textContent) || '{}') || {};
|
||||
uploadMaxBytes = Number(editorData.uploadLimits && editorData.uploadLimits.imageMaxBytes) || uploadMaxBytes;
|
||||
var megabytes = uploadMaxBytes / 1024 / 1024;
|
||||
uploadMaxLabel = Number.isInteger(megabytes) ? String(megabytes) + ' MB' : megabytes.toFixed(2).replace(/0+$/, '').replace(/\.$/, '') + ' MB';
|
||||
} catch (_error) {
|
||||
function trackCopy(input, path) {
|
||||
var previous = draftCopies.get(input) || [];
|
||||
draftCopies.set(input, [path]);
|
||||
cleanupCopies(previous.filter(function (value) { return value !== path; }));
|
||||
}
|
||||
|
||||
function getRegionId(input) {
|
||||
var match = String(input && input.name || '').match(/^region_image_(\d+)$/);
|
||||
return match ? match[1] : '';
|
||||
}
|
||||
|
||||
function getExistingInput(regionId) {
|
||||
if (!regionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return document.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
|
||||
}
|
||||
|
||||
function setStatus(message) {
|
||||
status.textContent = String(message || '');
|
||||
}
|
||||
|
||||
function setCropperLoading(loading) {
|
||||
if (cropperFrame) {
|
||||
cropperFrame.classList.toggle('is-loading', Boolean(loading));
|
||||
}
|
||||
}
|
||||
|
||||
function showModal() {
|
||||
if (window.pulseModal && typeof window.pulseModal.show === 'function') {
|
||||
window.pulseModal.show(modal);
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.bootstrap && window.bootstrap.Modal) {
|
||||
window.bootstrap.Modal.getOrCreateInstance(modal).show();
|
||||
}
|
||||
}
|
||||
|
||||
function hideModal() {
|
||||
if (window.pulseModal && typeof window.pulseModal.hide === 'function') {
|
||||
window.pulseModal.hide(modal);
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.bootstrap && window.bootstrap.Modal) {
|
||||
window.bootstrap.Modal.getOrCreateInstance(modal).hide();
|
||||
}
|
||||
}
|
||||
|
||||
function destroyCropper() {
|
||||
if (cropper) {
|
||||
cropper.destroy();
|
||||
cropper = null;
|
||||
}
|
||||
}
|
||||
|
||||
function revokeObjectUrl() {
|
||||
if (currentObjectUrl) {
|
||||
URL.revokeObjectURL(currentObjectUrl);
|
||||
currentObjectUrl = '';
|
||||
}
|
||||
}
|
||||
|
||||
function resetModalState() {
|
||||
destroyCropper();
|
||||
revokeObjectUrl();
|
||||
setCropperLoading(false);
|
||||
currentInput = null;
|
||||
currentFile = null;
|
||||
flipX = 1;
|
||||
flipY = 1;
|
||||
image.removeAttribute('src');
|
||||
image.alt = 'Selected image to crop';
|
||||
setStatus('');
|
||||
}
|
||||
|
||||
function setButtonState(disabled) {
|
||||
modal.querySelectorAll('[data-slide-image-cropper-action]').forEach(function (button) {
|
||||
button.disabled = Boolean(disabled) && button.getAttribute('data-slide-image-cropper-action') !== 'reset';
|
||||
});
|
||||
}
|
||||
|
||||
function ratioLabelForValue(value) {
|
||||
if (value === 'free') {
|
||||
return 'free';
|
||||
}
|
||||
|
||||
if (value === 'region') {
|
||||
return 'region';
|
||||
}
|
||||
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function parseAspectRatio(value) {
|
||||
var parts = String(value || '').trim().split(/[:/]/);
|
||||
var width = Number(parts[0]);
|
||||
var height = Number(parts[1]);
|
||||
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||
return NaN;
|
||||
}
|
||||
|
||||
return width / height;
|
||||
}
|
||||
|
||||
function isSvgFile(file) {
|
||||
if (!file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return String(file.type || '').toLowerCase() === 'image/svg+xml' || /\.svg$/i.test(String(file.name || ''));
|
||||
}
|
||||
|
||||
function isGifFile(file) {
|
||||
if (!file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return String(file.type || '').toLowerCase() === 'image/gif' || /\.gif$/i.test(String(file.name || ''));
|
||||
}
|
||||
|
||||
function isSpecialRasterizationFile(file) {
|
||||
return isSvgFile(file) || isGifFile(file);
|
||||
}
|
||||
|
||||
function getSpecialFileWarning(file) {
|
||||
if (isGifFile(file)) {
|
||||
return 'GIF selected. If you crop, rotate, or flip this image, it will be rasterized and the animation will be lost. Leave the full image selected to keep the original GIF.';
|
||||
}
|
||||
|
||||
return 'SVG selected. If you crop, rotate, or flip this image, it will be rasterized. Leave the full image selected to keep the original SVG.';
|
||||
}
|
||||
|
||||
function isOriginalSpecialSelection() {
|
||||
if (!cropper || !currentFile || !isSpecialRasterizationFile(currentFile) || typeof cropper.getData !== 'function' || typeof cropper.getImageData !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
var cropData = cropper.getData(true) || {};
|
||||
var imageData = cropper.getImageData() || {};
|
||||
var width = Number(imageData.naturalWidth || 0);
|
||||
var height = Number(imageData.naturalHeight || 0);
|
||||
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Math.abs(Number(cropData.x || 0)) < 0.5 &&
|
||||
Math.abs(Number(cropData.y || 0)) < 0.5 &&
|
||||
Math.abs(Number(cropData.width || 0) - width) < 0.5 &&
|
||||
Math.abs(Number(cropData.height || 0) - height) < 0.5 &&
|
||||
Math.abs(Number(cropData.rotate || 0)) < 0.5 &&
|
||||
Number(cropData.scaleX || 1) === 1 &&
|
||||
Number(cropData.scaleY || 1) === 1;
|
||||
}
|
||||
|
||||
function getRasterizedFileName(sourceName) {
|
||||
var name = String(sourceName || '').trim();
|
||||
|
||||
if (!name) {
|
||||
return 'slide-region-image.png';
|
||||
}
|
||||
|
||||
if (/\.svg$/i.test(name)) {
|
||||
return name.replace(/\.svg$/i, '.png');
|
||||
}
|
||||
|
||||
if (/\.[a-z0-9]+$/i.test(name)) {
|
||||
return name.replace(/\.[a-z0-9]+$/i, '.png');
|
||||
}
|
||||
|
||||
return name + '.png';
|
||||
}
|
||||
|
||||
function setActiveRatioButton(value) {
|
||||
var targetValue = ratioLabelForValue(value);
|
||||
modal.querySelectorAll('[data-slide-image-cropper-action="ratio"]').forEach(function (button) {
|
||||
var buttonValue = ratioLabelForValue(button.getAttribute('data-slide-image-cropper-ratio'));
|
||||
var isActive = buttonValue === targetValue;
|
||||
button.classList.toggle('active', isActive);
|
||||
button.setAttribute('aria-pressed', isActive ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
function applyAspectRatio(value) {
|
||||
if (!cropper) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentAspectRatio = value;
|
||||
if (value === 'free') {
|
||||
cropper.setAspectRatio(NaN);
|
||||
setActiveRatioButton('free');
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === 'region') {
|
||||
if (!Number.isFinite(currentRegionAspectRatio) || currentRegionAspectRatio <= 0) {
|
||||
cropper.setAspectRatio(NaN);
|
||||
setActiveRatioButton('free');
|
||||
return;
|
||||
}
|
||||
|
||||
cropper.setAspectRatio(currentRegionAspectRatio);
|
||||
setActiveRatioButton('region');
|
||||
return;
|
||||
}
|
||||
|
||||
var parts = String(value || '').split(':');
|
||||
var width = Number(parts[0]);
|
||||
var height = Number(parts[1]);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
cropper.setAspectRatio(width / height);
|
||||
setActiveRatioButton(value);
|
||||
}
|
||||
|
||||
function initCropper() {
|
||||
if (!currentInput || !currentFile || !window.Cropper) {
|
||||
return;
|
||||
}
|
||||
|
||||
destroyCropper();
|
||||
cropper = new window.Cropper(image, {
|
||||
aspectRatio: NaN,
|
||||
autoCropArea: 1,
|
||||
background: false,
|
||||
dragMode: 'move',
|
||||
initialAspectRatio: NaN,
|
||||
movable: true,
|
||||
responsive: true,
|
||||
rotatable: true,
|
||||
scalable: true,
|
||||
viewMode: 1,
|
||||
zoomOnTouch: true,
|
||||
zoomOnWheel: true,
|
||||
ready: function () {
|
||||
setCropperLoading(false);
|
||||
|
||||
if (cropper && cropper.container) {
|
||||
cropper.container.style.width = '100%';
|
||||
cropper.container.style.height = '560px';
|
||||
cropper.container.style.maxHeight = '70vh';
|
||||
}
|
||||
|
||||
var containerData = cropper.getContainerData();
|
||||
var imageData = cropper.getImageData();
|
||||
var fitRatio = Math.min(
|
||||
Number(containerData.width || 0) / Number(imageData.naturalWidth || 1),
|
||||
Number(containerData.height || 0) / Number(imageData.naturalHeight || 1),
|
||||
1
|
||||
);
|
||||
|
||||
if (Number.isFinite(fitRatio) && fitRatio > 0) {
|
||||
cropper.zoomTo(fitRatio);
|
||||
} else {
|
||||
cropper.reset();
|
||||
}
|
||||
|
||||
applyAspectRatio(currentAspectRatio === undefined ? 'free' : currentAspectRatio);
|
||||
}
|
||||
});
|
||||
|
||||
setButtonState(false);
|
||||
setActiveRatioButton(currentAspectRatio === undefined ? 'free' : currentAspectRatio);
|
||||
|
||||
if (isSpecialRasterizationFile(currentFile)) {
|
||||
setStatus(getSpecialFileWarning(currentFile));
|
||||
}
|
||||
}
|
||||
|
||||
function openEditor(input, file) {
|
||||
currentInput = input;
|
||||
currentFile = file;
|
||||
flipX = 1;
|
||||
flipY = 1;
|
||||
currentAspectRatio = 'free';
|
||||
currentRegionAspectRatio = parseAspectRatio(input && input.dataset && input.dataset.slideImageCropperRegionRatio);
|
||||
currentRegionAspectRatioLabel = String(input && input.dataset && input.dataset.slideImageCropperRegionRatioLabel || 'Region').trim() || 'Region';
|
||||
setCropperLoading(true);
|
||||
setButtonState(true);
|
||||
setStatus(isSpecialRasterizationFile(file) ? getSpecialFileWarning(file) : '');
|
||||
|
||||
modal.querySelectorAll('[data-slide-image-cropper-action="ratio"][data-slide-image-cropper-ratio="region"]').forEach(function (button) {
|
||||
button.title = currentRegionAspectRatioLabel ? 'Region ratio ' + currentRegionAspectRatioLabel : 'Region ratio';
|
||||
});
|
||||
|
||||
revokeObjectUrl();
|
||||
currentObjectUrl = URL.createObjectURL(file);
|
||||
image.alt = file.name || 'Selected image to crop';
|
||||
image.src = currentObjectUrl;
|
||||
|
||||
if (!listenersAttached) {
|
||||
listenersAttached = true;
|
||||
modal.addEventListener('shown.bs.modal', function () {
|
||||
if (currentInput && currentFile) {
|
||||
initCropper();
|
||||
}
|
||||
});
|
||||
modal.addEventListener('hidden.bs.modal', function () {
|
||||
resetModalState();
|
||||
});
|
||||
}
|
||||
|
||||
showModal();
|
||||
}
|
||||
|
||||
function setInputFile(input, file) {
|
||||
var dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
input.files = dataTransfer.files;
|
||||
}
|
||||
|
||||
function rejectOversizeFile(input) {
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window.showToast === 'function') {
|
||||
window.showToast('File must be ' + uploadMaxLabel + ' or smaller.', 'warning');
|
||||
} else {
|
||||
window.alert('File must be ' + uploadMaxLabel + ' or smaller.');
|
||||
}
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function finalizeCropFile(file) {
|
||||
if (!currentInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
var input = currentInput;
|
||||
var existing = getExistingInput(getRegionId(input));
|
||||
|
||||
input.dataset.cropperBypass = '1';
|
||||
setInputFile(input, file);
|
||||
if (existing) {
|
||||
existing.value = '';
|
||||
}
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
window.setTimeout(function () {
|
||||
delete input.dataset.cropperBypass;
|
||||
}, 0);
|
||||
hideModal();
|
||||
}
|
||||
|
||||
function finalizeCurrentSvgSelection() {
|
||||
if (!currentFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isOriginalSpecialSelection()) {
|
||||
finalizeCropFile(currentFile);
|
||||
return;
|
||||
}
|
||||
|
||||
setButtonState(true);
|
||||
setStatus('Creating a rasterized image from the crop...');
|
||||
|
||||
var canvas = cropper && typeof cropper.getCroppedCanvas === 'function' ? cropper.getCroppedCanvas({
|
||||
fillColor: 'transparent',
|
||||
imageSmoothingEnabled: true,
|
||||
imageSmoothingQuality: 'high'
|
||||
}) : null;
|
||||
|
||||
if (!canvas) {
|
||||
setStatus('Unable to create the cropped image.');
|
||||
setButtonState(false);
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.toBlob(function (blob) {
|
||||
if (!blob) {
|
||||
setStatus('Unable to create the cropped image.');
|
||||
setButtonState(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var finalFile = new File([blob], getRasterizedFileName(currentFile.name), {
|
||||
lastModified: Date.now(),
|
||||
type: blob.type || 'image/png'
|
||||
});
|
||||
|
||||
finalizeCropFile(finalFile);
|
||||
}, 'image/png');
|
||||
}
|
||||
|
||||
function commitCrop() {
|
||||
if (!currentInput || !currentFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSpecialRasterizationFile(currentFile)) {
|
||||
if (!cropper) {
|
||||
finalizeCropFile(currentFile);
|
||||
return;
|
||||
}
|
||||
|
||||
finalizeCurrentSvgSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cropper) {
|
||||
finalizeCropFile(currentFile);
|
||||
return;
|
||||
}
|
||||
|
||||
setButtonState(true);
|
||||
setStatus('Creating the cropped image...');
|
||||
|
||||
var canvas = cropper.getCroppedCanvas({
|
||||
fillColor: 'transparent',
|
||||
imageSmoothingEnabled: true,
|
||||
imageSmoothingQuality: 'high'
|
||||
});
|
||||
|
||||
if (!canvas) {
|
||||
setStatus('Unable to create the cropped image.');
|
||||
setButtonState(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var outputType = /^image\/(jpeg|jpg|webp|png)$/i.test(currentFile.type || '') ? currentFile.type : 'image/png';
|
||||
canvas.toBlob(function (blob) {
|
||||
if (!blob) {
|
||||
setStatus('Unable to create the cropped image.');
|
||||
setButtonState(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var finalFile = new File([blob], currentFile.name || 'slide-region-image.png', {
|
||||
lastModified: Date.now(),
|
||||
type: blob.type || outputType
|
||||
});
|
||||
|
||||
finalizeCropFile(finalFile);
|
||||
}, outputType, outputType.indexOf('jpeg') !== -1 ? 0.92 : undefined);
|
||||
}
|
||||
|
||||
function rotateImage(direction) {
|
||||
if (cropper) {
|
||||
cropper.rotate(direction);
|
||||
}
|
||||
}
|
||||
|
||||
function flipImage(axis) {
|
||||
if (!cropper) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (axis === 'x') {
|
||||
flipX *= -1;
|
||||
cropper.scaleX(flipX);
|
||||
return;
|
||||
}
|
||||
|
||||
flipY *= -1;
|
||||
cropper.scaleY(flipY);
|
||||
}
|
||||
|
||||
function resetImage() {
|
||||
if (!cropper) {
|
||||
return;
|
||||
}
|
||||
|
||||
cropper.reset();
|
||||
flipX = 1;
|
||||
flipY = 1;
|
||||
}
|
||||
|
||||
templateFields.addEventListener('change', function (event) {
|
||||
document.addEventListener('change', function (event) {
|
||||
var input = event.target;
|
||||
if (!input || !input.matches || !input.matches('input[type="file"][name^="region_image_"]')) {
|
||||
return;
|
||||
}
|
||||
if (!input.name || input.name.indexOf('existing_region_image_') !== 0 || input.dataset.applyingCrop === '1') return;
|
||||
var copies = draftCopies.get(input) || [];
|
||||
cleanupCopies(copies.filter(function (path) { return path !== input.value; }));
|
||||
if (copies.indexOf(input.value) !== -1) draftCopies.set(input, [input.value]);
|
||||
else draftCopies.delete(input);
|
||||
});
|
||||
|
||||
if (input.dataset.cropperBypass === '1') {
|
||||
return;
|
||||
}
|
||||
window.addEventListener('pagehide', function () {
|
||||
var paths = [];
|
||||
draftCopies.forEach(function (copies) { paths = paths.concat(copies); });
|
||||
cleanupCopies(paths);
|
||||
});
|
||||
|
||||
var file = input.files && input.files[0];
|
||||
if (!file) {
|
||||
return;
|
||||
function discardInitialSelection() {
|
||||
if (!active || !active.initialSelection || active.applied || active.current.value !== active.previousValue) return;
|
||||
cleanupCopies([active.original]);
|
||||
if (active.fileInput && active.fileInput.dataset.uploadedPath === active.original) {
|
||||
active.fileInput.value = '';
|
||||
delete active.fileInput.dataset.uploadedPath;
|
||||
delete active.fileInput.dataset.uploadedNeedsCleanup;
|
||||
}
|
||||
active.originalInput.value = '';
|
||||
active.cropInput.value = '';
|
||||
active.current.value = '';
|
||||
delete active.current.dataset.cropSelectionPending;
|
||||
active.current.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
var preview = active.card.querySelector('.slide-image-region-preview-box');
|
||||
if (preview) preview.innerHTML = '<div class="slide-image-region-preview slide-image-region-preview-empty">No image</div>';
|
||||
}
|
||||
|
||||
if (Number(file.size || 0) > uploadMaxBytes) {
|
||||
event.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
rejectOversizeFile(input);
|
||||
return;
|
||||
}
|
||||
function loadCropper() {
|
||||
if (window.Cropper) return Promise.resolve();
|
||||
if (!loader) loader = new Promise(function (resolve, reject) {
|
||||
var css = document.createElement('link');
|
||||
css.rel = 'stylesheet';
|
||||
css.href = '/assets/vendor/cropperjs/cropper.min.css';
|
||||
document.head.appendChild(css);
|
||||
var script = document.createElement('script');
|
||||
script.src = '/assets/vendor/cropperjs/cropper.min.js';
|
||||
script.onload = resolve;
|
||||
script.onerror = function () { loader = null; reject(new Error('Unable to load the image editor.')); };
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
return loader;
|
||||
}
|
||||
|
||||
if (!/^image\//i.test(file.type || '') && !/\.svg$/i.test(String(file.name || ''))) {
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
function status(message) {
|
||||
modal.querySelector('[data-crop-status]').textContent = message || '';
|
||||
}
|
||||
|
||||
event.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
input.value = '';
|
||||
function setBusy(value) {
|
||||
busy = value;
|
||||
modal.querySelectorAll('button').forEach(function (button) { button.disabled = value; });
|
||||
}
|
||||
|
||||
if (!window.Cropper) {
|
||||
currentInput = input;
|
||||
currentFile = file;
|
||||
commitCrop();
|
||||
return;
|
||||
}
|
||||
function setLoading(value) {
|
||||
var frame = modal.querySelector('.slide-image-cropper-frame');
|
||||
frame.classList.toggle('is-loading', value);
|
||||
frame.setAttribute('aria-busy', value ? 'true' : 'false');
|
||||
frame.querySelector('.slide-image-cropper-loading-overlay').setAttribute('aria-hidden', value ? 'false' : 'true');
|
||||
}
|
||||
|
||||
openEditor(input, file);
|
||||
}, true);
|
||||
function createModal() {
|
||||
if (modal) return;
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'slide-image-cropper-modal';
|
||||
modal.className = 'modal fade';
|
||||
modal.tabIndex = -1;
|
||||
modal.setAttribute('aria-labelledby', 'slide-image-cropper-title');
|
||||
modal.setAttribute('data-bs-backdrop', 'static');
|
||||
modal.setAttribute('data-bs-keyboard', 'false');
|
||||
var tools = [
|
||||
{ label: 'Rotate', buttons: [['rotate-left', 'arrow-counterclockwise', 'Rotate left'], ['rotate-right', 'arrow-clockwise', 'Rotate right']] },
|
||||
{ label: 'Flip', buttons: [['flip-x', 'symmetry-vertical', 'Flip horizontal'], ['flip-y', 'symmetry-horizontal', 'Flip vertical']] }
|
||||
].map(function (group) {
|
||||
return '<div class="btn-group btn-group-sm" role="group" aria-label="' + group.label + '">' + group.buttons.map(function (tool) {
|
||||
return '<button type="button" class="btn btn-outline-secondary" data-crop-action="' + tool[0] + '"><i class="bi bi-' + tool[1] + '" aria-hidden="true"></i> ' + tool[2] + '</button>';
|
||||
}).join('') + '</div>';
|
||||
}).join('');
|
||||
var ratios = ['Free', '1:1', '4:3', '16:9', 'Region'].map(function (ratio) {
|
||||
return '<button type="button" class="btn btn-outline-secondary btn-sm" data-crop-ratio="' + ratio + '" aria-pressed="false">' + ratio + '</button>';
|
||||
}).join('');
|
||||
modal.innerHTML = '<div class="modal-dialog modal-dialog-centered modal-xl modal-dialog-scrollable"><div class="modal-content">' +
|
||||
'<div class="modal-header"><h2 class="modal-title fs-5" id="slide-image-cropper-title">Edit image</h2><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button></div>' +
|
||||
'<div class="modal-body d-grid gap-3"><div class="slide-image-cropper-frame is-loading" aria-busy="true"><img data-crop-image alt="Original image to crop"><div class="slide-image-cropper-loading-overlay" aria-hidden="false"><div class="spinner-border text-primary slide-image-cropper-loading-spinner" role="status"><span class="visually-hidden">Loading image editor</span></div></div></div>' +
|
||||
'<div class="d-flex flex-wrap gap-2" role="toolbar" aria-label="Image editing controls">' + tools + '<div class="btn-group btn-group-sm" role="group" aria-label="Crop ratio">' + ratios + '</div><button type="button" class="btn btn-outline-danger btn-sm" data-crop-action="reset"><i class="bi bi-arrow-repeat" aria-hidden="true"></i> Reset</button></div>' +
|
||||
'<p class="small text-body-secondary mb-0" data-crop-status role="status" aria-live="polite"></p></div>' +
|
||||
'<div class="modal-footer"><button type="button" class="btn btn-primary" data-crop-apply>Apply crop</button><button type="button" class="btn btn-warning" data-bs-dismiss="modal">Cancel</button></div></div></div>';
|
||||
document.body.appendChild(modal);
|
||||
modal.addEventListener('shown.bs.modal', function () {
|
||||
if (!active) return;
|
||||
cropper = new window.Cropper(modal.querySelector('[data-crop-image]'), {
|
||||
viewMode: 1, autoCropArea: 1, background: false, responsive: true, dragMode: 'move',
|
||||
ready: function () {
|
||||
if (active.crop) {
|
||||
cropper.rotateTo(active.crop.rotate || 0);
|
||||
cropper.scale(active.crop.scaleX || 1, active.crop.scaleY || 1);
|
||||
cropper.setCropBoxData({ width: 1, height: 1 });
|
||||
var container = cropper.getContainerData();
|
||||
var canvas = cropper.getCanvasData();
|
||||
cropper.zoomTo(Math.min(container.width / canvas.naturalWidth, container.height / canvas.naturalHeight));
|
||||
cropper.setData(active.crop);
|
||||
}
|
||||
setLoading(false);
|
||||
setBusy(false);
|
||||
if (/\.(?:svg|gif)(?:[?#]|$)/i.test(active.original)) status('Applying a crop creates a PNG copy. The original stays unchanged.');
|
||||
else status('');
|
||||
}
|
||||
});
|
||||
});
|
||||
modal.addEventListener('hide.bs.modal', function (event) {
|
||||
if (busy) event.preventDefault();
|
||||
else modal.dataset.cropClosing = '1';
|
||||
});
|
||||
modal.addEventListener('hidden.bs.modal', function () {
|
||||
discardInitialSelection();
|
||||
if (cropper) cropper.destroy();
|
||||
cropper = null;
|
||||
active = null;
|
||||
setLoading(false);
|
||||
modal.querySelector('[data-crop-image]').removeAttribute('src');
|
||||
delete modal.dataset.cropClosing;
|
||||
});
|
||||
modal.querySelector('[data-crop-image]').addEventListener('error', function () {
|
||||
setLoading(false);
|
||||
setBusy(false);
|
||||
modal.querySelector('[data-crop-apply]').disabled = true;
|
||||
status('Unable to load the original image.');
|
||||
});
|
||||
modal.addEventListener('click', handleAction);
|
||||
}
|
||||
|
||||
modal.addEventListener('click', function (event) {
|
||||
var button = event.target && event.target.closest ? event.target.closest('[data-slide-image-cropper-action]') : null;
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
function setSelection(value, crop) {
|
||||
active.applied = true;
|
||||
delete active.current.dataset.cropSelectionPending;
|
||||
var copies = draftCopies.get(active.current) || [];
|
||||
cleanupCopies(copies.filter(function (path) { return path !== value; }));
|
||||
if (copies.indexOf(value) === -1) draftCopies.delete(active.current);
|
||||
active.originalInput.value = active.original;
|
||||
active.cropInput.value = crop ? JSON.stringify(crop) : '';
|
||||
active.current.dataset.applyingCrop = '1';
|
||||
active.current.value = value;
|
||||
active.current.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
delete active.current.dataset.applyingCrop;
|
||||
var preview = active.card.querySelector('.slide-image-region-preview');
|
||||
if (preview && preview.tagName === 'IMG') preview.src = value;
|
||||
window.pulseModal.hide(modal);
|
||||
}
|
||||
|
||||
var action = button.getAttribute('data-slide-image-cropper-action');
|
||||
if (action === 'apply') {
|
||||
commitCrop();
|
||||
async function applyCrop() {
|
||||
if (!active || !cropper || busy) return;
|
||||
var selection = active;
|
||||
var crop = cropper.getData();
|
||||
if (window.pulseImageCrop.isFullImage(crop, cropper.getImageData())) {
|
||||
setSelection(selection.original, null);
|
||||
return;
|
||||
}
|
||||
if (action === 'ratio') {
|
||||
applyAspectRatio(button.getAttribute('data-slide-image-cropper-ratio') || 'free');
|
||||
return;
|
||||
setBusy(true);
|
||||
status('Saving cropped copy...');
|
||||
try {
|
||||
var size = window.pulseImageCrop.outputSize(crop, selection.bounds, selection.canvas);
|
||||
var canvas = cropper.getCroppedCanvas({ width: size.width, height: size.height, maxWidth: size.width, maxHeight: size.height, imageSmoothingEnabled: true, imageSmoothingQuality: 'high' });
|
||||
if (!canvas || !canvas.width || !canvas.height) throw new Error('Unable to create the cropped image.');
|
||||
var outputType = /\.jpe?g(?:[?#]|$)/i.test(selection.original) ? 'image/jpeg' : /\.webp(?:[?#]|$)/i.test(selection.original) ? 'image/webp' : 'image/png';
|
||||
var extension = outputType === 'image/jpeg' ? '.jpg' : outputType === 'image/webp' ? '.webp' : '.png';
|
||||
var blob = await new Promise(function (resolve) { canvas.toBlob(resolve, outputType, 0.92); });
|
||||
if (!blob) throw new Error('Unable to create the cropped image.');
|
||||
var form = new FormData();
|
||||
form.append('file', blob, 'slide-image-crop' + extension);
|
||||
var response = await fetch('/slides/uploads', { method: 'POST', credentials: 'same-origin', headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, body: form });
|
||||
var payload = await response.json();
|
||||
if (!response.ok || !payload.path) throw new Error(payload.error || 'Unable to save the cropped copy.');
|
||||
if (!selection.card.isConnected || selection.current.value !== selection.previousValue) {
|
||||
cleanupCopies([payload.path]);
|
||||
throw new Error('The selected image changed. Open the crop editor again.');
|
||||
}
|
||||
trackCopy(selection.current, payload.path);
|
||||
setBusy(false);
|
||||
setSelection(payload.path, crop);
|
||||
} catch (error) {
|
||||
setBusy(false);
|
||||
status(error.message || 'Unable to save the cropped copy.');
|
||||
}
|
||||
if (action === 'rotate-left') {
|
||||
rotateImage(-90);
|
||||
return;
|
||||
}
|
||||
if (action === 'rotate-right') {
|
||||
rotateImage(90);
|
||||
return;
|
||||
}
|
||||
if (action === 'flip-horizontal') {
|
||||
flipImage('x');
|
||||
return;
|
||||
}
|
||||
if (action === 'flip-vertical') {
|
||||
flipImage('y');
|
||||
}
|
||||
|
||||
function handleAction(event) {
|
||||
if (busy || !active) return;
|
||||
if (event.target.closest('[data-crop-apply]')) { applyCrop(); return; }
|
||||
if (!cropper) return;
|
||||
var ratio = event.target.closest('[data-crop-ratio]');
|
||||
if (ratio) {
|
||||
var label = ratio.dataset.cropRatio;
|
||||
var parts = String(label === 'Region' ? active.ratio : label).split(':');
|
||||
var aspect = Number(parts[0]) / Number(parts[1]);
|
||||
cropper.setAspectRatio(Number.isFinite(aspect) && aspect > 0 ? aspect : NaN);
|
||||
modal.querySelectorAll('[data-crop-ratio]').forEach(function (button) {
|
||||
button.classList.toggle('active', button === ratio);
|
||||
button.setAttribute('aria-pressed', button === ratio ? 'true' : 'false');
|
||||
});
|
||||
return;
|
||||
}
|
||||
var button = event.target.closest('[data-crop-action]');
|
||||
if (!button) return;
|
||||
var action = button.dataset.cropAction;
|
||||
if (action === 'rotate-left') cropper.rotate(-90);
|
||||
if (action === 'rotate-right') cropper.rotate(90);
|
||||
if (action === 'flip-x') cropper.scaleX(-(cropper.getData().scaleX || 1));
|
||||
if (action === 'flip-y') cropper.scaleY(-(cropper.getData().scaleY || 1));
|
||||
if (action === 'reset') {
|
||||
resetImage();
|
||||
cropper.reset();
|
||||
modal.querySelector('[data-crop-ratio="Free"]').click();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', async function (event) {
|
||||
var button = event.target.closest && event.target.closest('[data-edit-image-crop]');
|
||||
if (!button || busy) return;
|
||||
if (modal && modal.dataset.cropClosing === '1') {
|
||||
modal.addEventListener('hidden.bs.modal', function () { if (button.isConnected) button.click(); }, { once: true });
|
||||
return;
|
||||
}
|
||||
var card = button.closest('[data-region-id]');
|
||||
var regionId = button.dataset.editImageCrop;
|
||||
var current = card.querySelector('[name="existing_region_image_' + regionId + '"]');
|
||||
if (!current || !current.value) {
|
||||
if (window.showToast) window.showToast('Choose an image first.', 'warning');
|
||||
return;
|
||||
}
|
||||
button.disabled = true;
|
||||
try {
|
||||
await loadCropper();
|
||||
createModal();
|
||||
var originalInput = card.querySelector('[name="region_image_original_' + regionId + '"]');
|
||||
var cropInput = card.querySelector('[name="region_image_crop_' + regionId + '"]');
|
||||
var crop = null;
|
||||
try { crop = JSON.parse(cropInput.value || 'null'); } catch (_error) {}
|
||||
var previewRegion = Array.prototype.find.call(document.querySelectorAll('.slide-preview-region'), function (region) { return region.dataset.regionKey === button.dataset.regionKey; });
|
||||
var previewCanvas = document.getElementById('slide-preview-canvas');
|
||||
var bounds = { width: previewRegion ? parseFloat(previewRegion.style.width) : Number(button.dataset.regionWidth), height: previewRegion ? parseFloat(previewRegion.style.height) : Number(button.dataset.regionHeight) };
|
||||
var canvas = { width: previewCanvas ? parseFloat(previewCanvas.style.width) : bounds.width, height: previewCanvas ? parseFloat(previewCanvas.style.height) : bounds.height };
|
||||
active = { card: card, current: current, originalInput: originalInput, cropInput: cropInput, original: originalInput.value || current.value, crop: crop, bounds: bounds, canvas: canvas, ratio: bounds.width + ':' + bounds.height, previousValue: current.value, initialSelection: current.dataset.cropSelectionPending === '1', applied: false, fileInput: card.querySelector('input[type="file"][name="region_image_' + regionId + '"]') };
|
||||
modal.querySelectorAll('[data-crop-ratio]').forEach(function (ratio) {
|
||||
var free = ratio.dataset.cropRatio === 'Free';
|
||||
ratio.classList.toggle('active', free);
|
||||
ratio.setAttribute('aria-pressed', free ? 'true' : 'false');
|
||||
});
|
||||
setLoading(true);
|
||||
modal.querySelector('[data-crop-image]').src = active.original;
|
||||
status('Loading image...');
|
||||
setBusy(true);
|
||||
window.pulseModal.show(modal);
|
||||
} catch (error) {
|
||||
if (window.showToast) window.showToast(error.message, 'danger');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
}());
|
||||
Reference in new issue
Block a user