Release 1.5.4

This commit is contained in:
2026-07-25 17:22:21 +01:00
parent 0e89892c94
commit c18f597068
9 changed files with 831 additions and 110 deletions
+1 -1
View File
@@ -1054,7 +1054,7 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
gap: 0;
min-height: 3.25rem;
}
+156 -13
View File
@@ -773,6 +773,32 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
});
}
function setRegionImageInputFile(input, file) {
if (!input || !file) {
return;
}
var dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
input.files = dataTransfer.files;
input.dispatchEvent(new Event('change', { bubbles: true }));
}
function renderImageRegionPreview(card, value) {
if (!card) {
return;
}
var previewBox = card.querySelector('.slide-image-region-preview-box');
if (!previewBox) {
return;
}
previewBox.innerHTML = value
? '<img class="slide-image-region-preview" src="' + escapeHtml(value) + '" alt="Current image preview" />'
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No image</div>';
}
function getRegionBox(region, canvasWidth, canvasHeight) {
return {
left: Math.max(0, Math.round(Number(region.x || 0))),
@@ -1118,7 +1144,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
'<span class="chip">Text</span>' +
'</div>' +
'</div>' +
'<div class="card-body p-3 d-grid gap-3">' +
'<div class="card-body p-3 d-grid">' +
'<div class="ckeditor-holder" data-region-id="' + region.id + '">' +
'<textarea class="ckeditor-source" rows="10">' + escapeHtml(current) + '</textarea>' +
'</div>' +
@@ -1148,7 +1174,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
'<span class="chip">RSS</span>' +
'</div>' +
'</div>' +
'<div class="card-body p-3 d-grid gap-3">' +
'<div class="card-body p-3 d-grid">' +
'<div class="ckeditor-holder" data-region-id="' + region.id + '">' +
'<textarea class="ckeditor-source" rows="10">' + escapeHtml(current.value !== undefined ? current.value : '') + '</textarea>' +
'</div>' +
@@ -1193,7 +1219,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
'<span class="chip">API</span>' +
'</div>' +
'</div>' +
'<div class="card-body p-3 d-grid gap-3">' +
'<div class="card-body p-3 d-grid">' +
'<div class="ckeditor-holder" data-region-id="' + region.id + '">' +
'<textarea class="ckeditor-source" rows="10">' + escapeHtml(current.value !== undefined ? current.value : '') + '</textarea>' +
'</div>' +
@@ -1226,7 +1252,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
'<strong>' + escapeHtml(region.label) + '</strong>' +
'<span class="chip">Webpage</span>' +
'</div>' +
'<div class="card-body p-3 d-grid gap-3">' +
'<div class="card-body p-3 d-grid">' +
'<label style="display:block;">Webpage URL' +
'<input type="url" name="region_webpage_' + region.id + '" class="form-control" value="' + escapeHtml(current) + '" placeholder="https://example.com" />' +
'</label>' +
@@ -1245,7 +1271,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
'<strong>' + escapeHtml(region.label) + '</strong>' +
'<span class="chip">RTMP</span>' +
'</div>' +
'<div class="card-body p-3 d-grid gap-3">' +
'<div class="card-body p-3 d-grid">' +
'<label style="display:block;">RTMP URL' +
'<input type="url" name="region_rtmp_' + region.id + '" class="form-control" value="' + escapeHtml(url) + '" placeholder="rtmp://example.com/live/stream" />' +
'</label>' +
@@ -1266,7 +1292,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
'<strong>' + escapeHtml(region.label) + '</strong>' +
'<span class="chip">HTML</span>' +
'</div>' +
'<div class="card-body p-3 d-grid gap-3">' +
'<div class="card-body p-3 d-grid">' +
'<label style="display:block;">HTML content' +
'<textarea name="region_html_' + region.id + '" class="form-control" rows="10" placeholder="<div>Hello</div>">' + escapeHtml(current) + '</textarea>' +
'</label>' +
@@ -1275,22 +1301,53 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
'</div>';
}
function reduceAspectRatio(width, height) {
var a = Math.max(1, Math.round(Number(width || 0) || 1));
var b = Math.max(1, Math.round(Number(height || 0) || 1));
while (b !== 0) {
var remainder = a % b;
a = b;
b = remainder;
}
var divisor = Math.max(1, a);
return Math.max(1, Math.round(Number(width || 0) || 1) / divisor) + ':' + Math.max(1, Math.round(Number(height || 0) || 1) / divisor);
}
function renderImageRegion(region) {
var current = getCurrentRegionValue(region);
var regionWidth = Math.max(1, Math.round(Number(region && region.width ? region.width : 0) || 1));
var regionHeight = Math.max(1, Math.round(Number(region && region.height ? region.height : 0) || 1));
var regionRatio = reduceAspectRatio(regionWidth, regionHeight);
return '' +
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
'<div class="card-header template-field-head">' +
'<strong>' + escapeHtml(region.label) + '</strong>' +
'<span class="chip">Image</span>' +
'</div>' +
'<div class="card-body p-3 d-grid gap-3">' +
'<label style="display:block;">Image file' +
'<input type="file" name="region_image_' + region.id + '" class="form-control" accept="image/*" />' +
'</label>' +
'<div class="card-body p-3 d-grid">' +
'<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 + '" 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>' +
'</label>' +
'</div>' +
'<div class="col-12 col-md-4">' +
'<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">' +
'<img class="slide-image-region-preview" src="' + escapeHtml(current) + '" alt="Current image preview" />' +
'<span class="slide-image-region-preview-remove" aria-hidden="true"><i class="bi bi-trash3" aria-hidden="true"></i></span>' +
'</div>'
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No image</div>') +
'</div>' +
'</div>' +
'</div>' +
'<input type="hidden" name="existing_region_image_' + region.id + '" value="' + escapeHtml(current) + '" />' +
(current
? '<div class="muted slide-image-file">Current: <a href="' + escapeHtml(current) + '" target="_blank" rel="noreferrer">' + escapeHtml(current) + '</a></div>'
: '<div class="muted slide-image-file">No image selected.</div>') +
'</div>' +
'</div>';
}
@@ -1444,10 +1501,96 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
if (input.files && input.files.length) {
input.dataset.previewUrl = URL.createObjectURL(input.files[0]);
}
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();
});
});
templateFields.querySelectorAll('[data-region-upload-zone]').forEach(function (zone) {
var regionId = zone.getAttribute('data-region-upload-zone');
var input = zone.querySelector('input[type="file"][name="region_image_' + regionId + '"]');
if (!input) {
return;
}
zone.addEventListener('dragover', function (event) {
event.preventDefault();
zone.classList.add('is-dragover');
});
zone.addEventListener('dragleave', function () {
zone.classList.remove('is-dragover');
});
zone.addEventListener('drop', function (event) {
event.preventDefault();
zone.classList.remove('is-dragover');
var files = event.dataTransfer && event.dataTransfer.files ? event.dataTransfer.files : null;
var file = files && files.length ? files[0] : null;
if (!file || !/^image\//i.test(file.type || '')) {
return;
}
setRegionImageInputFile(input, file);
});
});
templateFields.querySelectorAll('button[data-remove-region-image]').forEach(function (button) {
button.addEventListener('click', function () {
var regionId = button.getAttribute('data-remove-region-image');
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 + '"]');
if (input && input.dataset.previewUrl) {
URL.revokeObjectURL(input.dataset.previewUrl);
delete input.dataset.previewUrl;
}
if (input) {
input.value = '';
}
if (hidden) {
hidden.value = '';
}
renderImageRegionPreview(card, '');
templateSelectorLock.markEdited();
requestPreviewRender();
});
});
templateFields.querySelectorAll('.slide-image-region-preview-shell').forEach(function (shell) {
shell.addEventListener('click', function () {
var regionId = shell.getAttribute('data-remove-region-image');
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 + '"]');
if (input && input.dataset.previewUrl) {
URL.revokeObjectURL(input.dataset.previewUrl);
delete input.dataset.previewUrl;
}
if (input) {
input.value = '';
}
if (hidden) {
hidden.value = '';
}
renderImageRegionPreview(card, '');
templateSelectorLock.markEdited();
requestPreviewRender();
});
shell.addEventListener('keydown', function (event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
shell.click();
}
});
});
templateFields.querySelectorAll('select[name^="region_rss_feed_id_"], input[name^="region_rss_item_number_"], select[name^="region_api_source_id_"], input[name^="region_api_item_number_"]').forEach(function (input) {
input.addEventListener('input', function () {
var regionId = input.name.replace(/^region_(?:rss_feed_id|rss_item_number|api_source_id|api_item_number)_/, '');
@@ -0,0 +1,415 @@
(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');
if (!templateFields || !modal || !image || !status) {
return;
}
var cropper = null;
var currentInput = null;
var currentFile = null;
var currentObjectUrl = '';
var flipX = 1;
var flipY = 1;
var currentAspectRatio = NaN;
var currentRegionAspectRatio = NaN;
var currentRegionAspectRatioLabel = 'Region';
var listenersAttached = false;
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 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();
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 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 () {
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);
setStatus('Use the toolbar to crop, rotate, or flip the image before applying it.');
}
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';
setButtonState(true);
setStatus('Loading image editor...');
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 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 commitCrop() {
if (!currentInput || !currentFile) {
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) {
var input = event.target;
if (!input || !input.matches || !input.matches('input[type="file"][name^="region_image_"]')) {
return;
}
if (input.dataset.cropperBypass === '1') {
return;
}
var file = input.files && input.files[0];
if (!file) {
return;
}
if (!/^image\//i.test(file.type || '')) {
input.value = '';
return;
}
event.stopImmediatePropagation();
event.stopPropagation();
input.value = '';
if (!window.Cropper) {
currentInput = input;
currentFile = file;
commitCrop();
return;
}
openEditor(input, file);
}, true);
modal.addEventListener('click', function (event) {
var button = event.target && event.target.closest ? event.target.closest('[data-slide-image-cropper-action]') : null;
if (!button) {
return;
}
var action = button.getAttribute('data-slide-image-cropper-action');
if (action === 'apply') {
commitCrop();
return;
}
if (action === 'ratio') {
applyAspectRatio(button.getAttribute('data-slide-image-cropper-ratio') || 'free');
return;
}
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');
return;
}
if (action === 'reset') {
resetImage();
return;
}
});
}());