Release v1.5.10

This commit is contained in:
2026-07-26 12:32:01 +01:00
parent b5fbf79af7
commit 07ec17ad91
24 changed files with 1113 additions and 352 deletions
+381 -25
View File
@@ -58,6 +58,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
var openPreviewPopupButton = document.getElementById('open-preview-popup');
var editorInstances = new Map();
var submitting = false;
var uploadMaxBytes = 100 * 1024 * 1024;
var uploadMaxLabel = '100 MB';
var previewRenderFrame = 0;
var previewPopupWindow = null;
var previewPopupRenderFrame = 0;
@@ -773,17 +775,308 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
});
}
function getRegionUploadZone(input) {
return input ? input.closest('[data-region-upload-zone]') : null;
}
function getRegionUploadHiddenValue(input) {
var regionId = input && input.name ? input.name.replace(/^region_image_/, '') : '';
var card = regionId ? templateFields.querySelector('[data-region-id="' + regionId + '"]') : null;
var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
return hidden ? hidden.value : '';
}
function getRegionUploadProgressNode(zone) {
return zone ? zone.querySelector('[data-region-upload-progress]') : null;
}
function getRegionUploadProgressBar(zone) {
return zone ? zone.querySelector('[data-region-upload-progress-bar]') : null;
}
function getRegionUploadProgressText(zone) {
return zone ? zone.querySelector('[data-region-upload-progress-text]') : null;
}
function setRegionUploadProgress(zone, percent, text, isIndeterminate) {
var progress = getRegionUploadProgressNode(zone);
var bar = getRegionUploadProgressBar(zone);
var progressText = getRegionUploadProgressText(zone);
var normalizedPercent = Math.max(0, Math.min(100, Math.round(Number(percent || 0))));
if (!progress || !bar) {
return;
}
if (progressText) {
progressText.textContent = text || 'Uploading...';
}
progress.hidden = false;
bar.style.width = (isIndeterminate ? 100 : normalizedPercent) + '%';
bar.textContent = isIndeterminate ? 'Uploading...' : normalizedPercent + '%';
bar.setAttribute('aria-valuenow', String(isIndeterminate ? 100 : normalizedPercent));
bar.classList.toggle('progress-bar-striped', Boolean(isIndeterminate));
bar.classList.toggle('progress-bar-animated', Boolean(isIndeterminate));
}
function setRegionUploadState(input, isUploading, percent, text, isIndeterminate) {
var zone = getRegionUploadZone(input);
var progress = getRegionUploadProgressNode(zone);
if (!zone || !progress) {
return;
}
zone.classList.toggle('is-uploading', Boolean(isUploading));
zone.setAttribute('aria-busy', isUploading ? 'true' : 'false');
input.disabled = Boolean(isUploading);
if (isUploading) {
setRegionUploadProgress(zone, percent, text, isIndeterminate);
return;
}
progress.hidden = true;
if (input) {
input.disabled = false;
}
}
function isRegionImageFileWithinLimit(file) {
return Boolean(file) && Number(file.size || 0) <= uploadMaxBytes;
}
function rejectOversizeRegionImage(input, file) {
var zone = getRegionUploadZone(input);
var hiddenValue = getRegionUploadHiddenValue(input);
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 = '';
if (input.dataset.previewUrl) {
URL.revokeObjectURL(input.dataset.previewUrl);
delete input.dataset.previewUrl;
}
renderImageRegionPreview(zone ? zone.closest('[data-region-id]') : null, hiddenValue);
if (file) {
return false;
}
return false;
}
function setRegionImageInputFile(input, file) {
if (!input || !file) {
return;
}
if (!isRegionImageFileWithinLimit(file)) {
rejectOversizeRegionImage(input, file);
return;
}
var dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
input.files = dataTransfer.files;
input.dispatchEvent(new Event('change', { bubbles: true }));
}
function uploadRegionImageFile(input, file) {
if (!input || !file) {
return Promise.resolve(null);
}
if (!isRegionImageFileWithinLimit(file)) {
rejectOversizeRegionImage(input, file);
return Promise.resolve(null);
}
var regionId = input.name.replace(/^region_image_/, '');
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
var zone = getRegionUploadZone(input);
var uploadToken = String(Date.now()) + ':' + Math.random().toString(16).slice(2);
var previousUploadedPath = String(input.dataset.uploadedPath || '').trim();
var previousNeedsCleanup = String(input.dataset.uploadedNeedsCleanup || '') === '1';
input.dataset.uploadToken = uploadToken;
setRegionUploadState(input, true, 0, 'Uploading...', true);
return new Promise(function (resolve, reject) {
var xhr = new XMLHttpRequest();
var formData = new FormData();
formData.append('file', file, file.name || 'upload');
xhr.open('POST', '/slides/uploads', true);
xhr.responseType = 'text';
xhr.withCredentials = true;
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.setRequestHeader('Accept', 'application/json, text/plain, */*');
xhr.upload.onprogress = function (event) {
if (input.dataset.uploadToken !== uploadToken) {
return;
}
if (!event || !event.lengthComputable || !event.total) {
setRegionUploadProgress(zone, 100, 'Uploading...', true);
return;
}
var percent = Math.round((event.loaded / event.total) * 100);
setRegionUploadProgress(zone, percent, 'Uploading ' + percent + '%', false);
};
xhr.onload = function () {
if (input.dataset.uploadToken !== uploadToken) {
resolve(null);
return;
}
var response = {
ok: xhr.status >= 200 && xhr.status < 300,
status: xhr.status,
statusText: xhr.statusText,
responseText: xhr.responseText || ''
};
if (!response.ok) {
var error = new Error(response.responseText || 'Unable to upload image.');
error.status = response.status;
error.responseText = response.responseText;
reject(error);
return;
}
var payload = {};
try {
payload = JSON.parse(response.responseText || '{}') || {};
} catch (_error) {
payload = {};
}
var uploadedPath = String(payload.path || '').trim();
if (!uploadedPath) {
reject(new Error('Unable to upload image.'));
return;
}
if (input.dataset.previewUrl) {
URL.revokeObjectURL(input.dataset.previewUrl);
delete input.dataset.previewUrl;
}
if (hidden) {
hidden.value = uploadedPath;
}
input.dataset.uploadedPath = uploadedPath;
input.dataset.uploadedNeedsCleanup = '1';
input.value = '';
input.setCustomValidity('');
renderImageRegionPreview(card, uploadedPath);
templateSelectorLock.markEdited();
requestPreviewRender();
if (previousNeedsCleanup && previousUploadedPath && previousUploadedPath !== uploadedPath) {
queueUploadCleanup([previousUploadedPath]);
}
setRegionUploadState(input, false);
resolve(payload);
};
xhr.onerror = function () {
if (input.dataset.uploadToken !== uploadToken) {
resolve(null);
return;
}
reject(new Error('Unable to upload image.'));
};
xhr.onabort = function () {
if (input.dataset.uploadToken !== uploadToken) {
resolve(null);
return;
}
reject(new Error('Unable to upload image.'));
};
xhr.onloadend = function () {
if (input.dataset.uploadToken === uploadToken) {
delete input.dataset.uploadToken;
setRegionUploadState(input, false);
}
};
xhr.send(formData);
}).catch(function (error) {
if (input.dataset.uploadToken === uploadToken) {
delete input.dataset.uploadToken;
setRegionUploadState(input, false);
}
throw error;
});
}
function getPendingUploadCleanupPaths() {
return getRegionImageInputs().map(function (input) {
if (!input || String(input.dataset.uploadedNeedsCleanup || '') !== '1') {
return null;
}
return String(input.dataset.uploadedPath || '').trim();
}).filter(Boolean);
}
function clearPendingUploadCleanupPaths() {
getRegionImageInputs().forEach(function (input) {
if (!input) {
return;
}
delete input.dataset.uploadedNeedsCleanup;
});
}
function queueUploadCleanup(uploadPaths) {
var normalizedPaths = Array.from(new Set((uploadPaths || []).map(function (value) {
return String(value || '').trim();
}).filter(Boolean)));
if (!normalizedPaths.length) {
return;
}
var payload = JSON.stringify({ uploadPaths: normalizedPaths });
var url = '/slides/uploads/cleanup';
if (navigator.sendBeacon) {
try {
navigator.sendBeacon(url, new Blob([payload], { type: 'application/json' }));
return;
} catch (_error) {
// Fall back to fetch below.
}
}
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: payload,
credentials: 'same-origin',
keepalive: true
}).catch(function () {
// Best-effort cleanup only.
});
}
function renderImageRegionPreview(card, value) {
if (!card) {
return;
@@ -799,6 +1092,16 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No image</div>';
}
function getRegionImageInputs() {
return Array.prototype.slice.call(templateFields.querySelectorAll('input[type="file"][name^="region_image_"]'));
}
function hasPendingRegionImageUpload() {
return getRegionImageInputs().some(function (input) {
return Boolean(input && input.files && input.files.length);
});
}
function getRegionBox(region, canvasWidth, canvasHeight) {
return {
left: Math.max(0, Math.round(Number(region.x || 0))),
@@ -822,23 +1125,11 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
'<meta charset="utf-8" />' +
'<meta name="viewport" content="width=device-width, initial-scale=1" />' +
'<title>Slide preview</title>' +
'<style>' +
'html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#111;color:#fff;font-family:Arial,sans-serif;}' +
'#popup-preview-stage{position:relative;width:100%;height:100%;overflow:hidden;background:#111;}' +
'#popup-preview-canvas{position:absolute;top:0;left:0;transform-origin:top left;will-change:transform;}' +
'#popup-preview-canvas,#popup-preview-stage{box-sizing:border-box;}' +
'.slide-preview-background,.slide-preview-overlay,.slide-preview-empty{position:absolute;inset:0;}' +
'.slide-preview-background{width:100%;height:100%;object-fit:contain;display:block;z-index:1;}' +
'.slide-preview-overlay{z-index:2;}' +
'.slide-preview-empty{display:flex;align-items:center;justify-content:center;padding:1rem;text-align:center;color:rgba(255,255,255,0.75);z-index:0;}' +
'.slide-preview-region{position:absolute;overflow:hidden;box-sizing:border-box;}' +
'.slide-preview-image{width:100%;height:100%;object-fit:contain;display:block;}' +
'.slide-preview-placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;color:rgba(255,255,255,0.65);background:rgba(255,255,255,0.06);font-size:0.9rem;}' +
'</style>' +
'<link rel="stylesheet" href="/assets/css/theme-custom.css" />' +
'</head>' +
'<body>' +
'<div id="popup-preview-stage">' +
'<div id="popup-preview-canvas"></div>' +
'<body class="slide-preview-popup-body">' +
'<div class="slide-preview-popup-stage" id="popup-preview-stage">' +
'<div class="slide-preview-popup-canvas" id="popup-preview-canvas"></div>' +
'</div>' +
'</body>' +
'</html>';
@@ -1329,10 +1620,19 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
'<div class="col-12 col-md-8 d-flex flex-column">' +
'<label class="slide-image-region-upload-zone" data-region-upload-zone="' + region.id + '" for="region_image_' + region.id + '">' +
'<input type="file" id="region_image_' + region.id + '" name="region_image_' + region.id + '" class="visually-hidden" accept="image/*" data-slide-image-cropper-region-ratio="' + escapeHtml(regionRatio) + '" data-slide-image-cropper-region-ratio-label="Region" />' +
'<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">' +
'<strong>Drop an image here or click to upload</strong>' +
'<span>PNG, JPG, GIF, or WebP</span>' +
'<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">' +
'<strong>Drop an image here or click to upload</strong>' +
'<span>PNG, JPG, GIF, or WebP</span>' +
'<span class="slide-image-region-upload-zone-limit">Max ' + uploadMaxLabel + ' per file</span>' +
'</span>' +
'</span>' +
'<span class="slide-image-region-upload-zone-progress" data-region-upload-progress hidden>' +
'<span class="slide-image-region-upload-zone-progress-label" data-region-upload-progress-text>Uploading...</span>' +
'<div class="progress slide-image-region-upload-zone-progress-bar" role="progressbar" aria-label="Image upload progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">' +
'<div class="progress-bar bg-danger progress-bar-striped progress-bar-animated" data-region-upload-progress-bar style="width:0%">0%</div>' +
'</div>' +
'</span>' +
'</label>' +
'</div>' +
@@ -1494,19 +1794,52 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
templateFields.querySelectorAll('input[type="file"][name^="region_image_"]').forEach(function (input) {
input.addEventListener('change', function () {
var regionId = input.name.replace(/^region_image_/, '');
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
var file = input.files && input.files.length ? input.files[0] : null;
if (file && !isRegionImageFileWithinLimit(file)) {
if (input.dataset.previewUrl) {
URL.revokeObjectURL(input.dataset.previewUrl);
delete input.dataset.previewUrl;
}
input.value = '';
if (typeof window.showToast === 'function') {
window.showToast('File must be ' + uploadMaxLabel + ' or smaller.', 'warning');
} else {
window.alert('File must be ' + uploadMaxLabel + ' or smaller.');
}
renderImageRegionPreview(card, hidden ? hidden.value : '');
return;
}
if (input.dataset.previewUrl) {
URL.revokeObjectURL(input.dataset.previewUrl);
delete input.dataset.previewUrl;
}
if (input.files && input.files.length) {
input.dataset.previewUrl = URL.createObjectURL(input.files[0]);
if (file) {
input.dataset.previewUrl = URL.createObjectURL(file);
}
var regionId = input.name.replace(/^region_image_/, '');
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
renderImageRegionPreview(card, input.dataset.previewUrl || (hidden ? hidden.value : ''));
templateSelectorLock.markEdited();
requestPreviewRender();
if (file) {
uploadRegionImageFile(input, file).catch(function (error) {
if (input.dataset.previewUrl) {
URL.revokeObjectURL(input.dataset.previewUrl);
delete input.dataset.previewUrl;
}
input.value = '';
if (typeof showToast === 'function') {
showToast(error && error.message ? error.message : 'Unable to upload image.', 'danger');
} else {
window.alert(error && error.message ? error.message : 'Unable to upload image.');
}
renderImageRegionPreview(card, hidden ? hidden.value : '');
requestPreviewRender();
});
}
});
});
templateFields.querySelectorAll('[data-region-upload-zone]').forEach(function (zone) {
@@ -1535,6 +1868,11 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
return;
}
if (!isRegionImageFileWithinLimit(file)) {
rejectOversizeRegionImage(input, file);
return;
}
setRegionImageInputFile(input, file);
});
});
@@ -1544,13 +1882,19 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
var input = card && card.querySelector('input[type="file"][name="region_image_' + regionId + '"]');
var uploadPath = input ? String(input.dataset.uploadedPath || '').trim() : '';
if (input && input.dataset.previewUrl) {
URL.revokeObjectURL(input.dataset.previewUrl);
delete input.dataset.previewUrl;
}
if (input && input.dataset.uploadedNeedsCleanup === '1' && uploadPath) {
queueUploadCleanup([uploadPath]);
}
if (input) {
input.value = '';
delete input.dataset.uploadedPath;
delete input.dataset.uploadedNeedsCleanup;
}
if (hidden) {
hidden.value = '';
@@ -1567,13 +1911,19 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
var input = card && card.querySelector('input[type="file"][name="region_image_' + regionId + '"]');
var uploadPath = input ? String(input.dataset.uploadedPath || '').trim() : '';
if (input && input.dataset.previewUrl) {
URL.revokeObjectURL(input.dataset.previewUrl);
delete input.dataset.previewUrl;
}
if (input && input.dataset.uploadedNeedsCleanup === '1' && uploadPath) {
queueUploadCleanup([uploadPath]);
}
if (input) {
input.value = '';
delete input.dataset.uploadedPath;
delete input.dataset.uploadedNeedsCleanup;
}
if (hidden) {
hidden.value = '';
@@ -1656,6 +2006,9 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
});
submitting = true;
try {
if (hasPendingRegionImageUpload()) {
throw new Error('Wait for the image upload to finish before saving.');
}
await Promise.all(saves);
var formData = new FormData(slideForm);
if (event.submitter && event.submitter.name) {
@@ -1686,6 +2039,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
if (isCreateForm) {
try {
if (response.url) {
clearPendingUploadCleanupPaths();
window.location.replace(response.url);
return;
}
@@ -1711,6 +2065,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
showToast(savedMessage || 'Saved slide.', 'success');
slideForm.dataset.dirty = 'false';
clearPendingUploadCleanupPaths();
} catch (error) {
if (typeof showToast === 'function') {
var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger';
@@ -1761,6 +2116,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
window.addEventListener('resize', requestPreviewRender);
window.addEventListener('resize', requestSidebarSync);
window.addEventListener('beforeunload', function () {
queueUploadCleanup(getPendingUploadCleanupPaths());
if (previewPopupWindow && !previewPopupWindow.closed) {
previewPopupWindow.close();
}