Release 1.5.4
This commit is contained in:
@@ -163,6 +163,7 @@ async function start() {
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json());
|
||||
app.use('/assets', express.static(ASSET_DIR));
|
||||
app.use('/assets/vendor/cropperjs', express.static(path.join(__dirname, '..', 'node_modules', 'cropperjs', 'dist')));
|
||||
app.use('/media', express.static(MEDIA_DIR));
|
||||
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
||||
fs.mkdirSync(THUMBNAILS_DIR, { recursive: true });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}());
|
||||
@@ -38,7 +38,7 @@
|
||||
<td data-label="Role">
|
||||
<div class="user-cell">
|
||||
<div>
|
||||
<strong>{{name}}</strong>
|
||||
{{name}}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
</div>
|
||||
|
||||
<div class="slide-editor-shell">
|
||||
<link rel="stylesheet" href="/assets/vendor/cropperjs/cropper.min.css" />
|
||||
<form method="post" action="{{action}}" enctype="multipart/form-data" id="slide-form" class="slide-form-stack" data-async-save-close-url="/slides" data-async-save-new-url="/slides/new">
|
||||
<div class="card card-outline card-primary admin-form-card slide-template-card">
|
||||
<div class="card-header">
|
||||
@@ -35,7 +36,7 @@
|
||||
|
||||
<div class="slide-editor-sidebar">
|
||||
<div class="card card-outline card-primary admin-form-card mb-3">
|
||||
<div class="card-body d-grid gap-2">
|
||||
<div class="card-body d-grid">
|
||||
<div class="btn-group template-actions-group slide-actions-group" role="group" aria-label="Slide actions">
|
||||
{{{saveActionButtons formId="slide-form" saveLabel=saveLabel saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}}
|
||||
<a class="btn btn-warning" href="/slides" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||
@@ -67,10 +68,216 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#> modal-shell modalId="slide-image-cropper-modal" modalLabelId="slide-image-cropper-modal-label" modalDialogClass="modal-dialog-centered modal-xl modal-dialog-scrollable" modalBackdropStatic=true modalKeyboardDisabled=true}}
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h2 class="modal-title fs-5" id="slide-image-cropper-modal-label">Edit image</h2>
|
||||
</div>
|
||||
<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" style="width:100%;height:560px;max-height:70vh;overflow:hidden;">
|
||||
<img id="slide-image-cropper-image" alt="Selected image to crop" />
|
||||
</div>
|
||||
<div class="btn-toolbar flex-wrap gap-2 slide-image-cropper-toolbar" role="toolbar" aria-label="Image editing controls">
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="Rotate">
|
||||
<button type="button" class="btn btn-outline-secondary" data-slide-image-cropper-action="rotate-left"><i class="bi bi-arrow-counterclockwise" aria-hidden="true"></i> Rotate left</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-slide-image-cropper-action="rotate-right"><i class="bi bi-arrow-clockwise" aria-hidden="true"></i> Rotate right</button>
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="Flip">
|
||||
<button type="button" class="btn btn-outline-secondary" data-slide-image-cropper-action="flip-horizontal"><i class="bi bi-symmetry-vertical" aria-hidden="true"></i> Flip horizontal</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-slide-image-cropper-action="flip-vertical"><i class="bi bi-symmetry-horizontal" aria-hidden="true"></i> Flip vertical</button>
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="Crop ratio presets">
|
||||
<button type="button" class="btn btn-outline-secondary" data-slide-image-cropper-action="ratio" data-slide-image-cropper-ratio="free">Free</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-slide-image-cropper-action="ratio" data-slide-image-cropper-ratio="1:1">1:1</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-slide-image-cropper-action="ratio" data-slide-image-cropper-ratio="4:3">4:3</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-slide-image-cropper-action="ratio" data-slide-image-cropper-ratio="16:9">16:9</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-slide-image-cropper-action="ratio" data-slide-image-cropper-ratio="region">Region</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" data-slide-image-cropper-action="reset"><i class="bi bi-arrow-repeat" aria-hidden="true"></i> Reset</button>
|
||||
</div>
|
||||
<p class="small text-body-secondary mb-0" id="slide-image-cropper-status"></p>
|
||||
</div>
|
||||
<div class="modal-footer justify-content-end">
|
||||
<div class="btn-group" role="group" aria-label="Crop actions">
|
||||
<button type="button" class="btn btn-primary" data-slide-image-cropper-action="apply">Apply crop</button>
|
||||
<button type="button" class="btn btn-warning" data-bs-dismiss="modal">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
{{/modal-shell}}
|
||||
|
||||
<style>
|
||||
.slide-image-cropper-frame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 560px;
|
||||
min-height: 360px;
|
||||
max-height: 70vh;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: var(--bs-body-bg);
|
||||
}
|
||||
|
||||
.slide-image-cropper-frame img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 65vh;
|
||||
}
|
||||
|
||||
.slide-image-cropper-frame > .cropper-container {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
max-width: 100% !important;
|
||||
max-height: 100% !important;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.slide-image-cropper-frame > .cropper-container img {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
.slide-image-cropper-frame .cropper-container,
|
||||
.slide-image-cropper-frame .cropper-canvas,
|
||||
.slide-image-cropper-frame .cropper-wrap-box,
|
||||
.slide-image-cropper-frame .cropper-crop-box,
|
||||
.slide-image-cropper-frame .cropper-view-box,
|
||||
.slide-image-cropper-frame .cropper-face {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.slide-image-cropper-frame .cropper-wrap-box,
|
||||
.slide-image-cropper-frame .cropper-crop-box {
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
.slide-image-cropper-toolbar .btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.slide-image-region-preview-box {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
justify-items: end;
|
||||
}
|
||||
|
||||
.slide-image-region-preview-shell {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 288px;
|
||||
height: 176px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slide-image-region-preview-shell:focus-visible,
|
||||
.slide-image-region-preview-shell:hover .slide-image-region-preview-remove,
|
||||
.slide-image-region-preview-shell:focus-visible .slide-image-region-preview-remove {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
|
||||
.slide-image-region-preview-remove {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(33, 37, 41, 0.82);
|
||||
color: #fff;
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0.92);
|
||||
transition: opacity 0.15s ease, transform 0.15s ease, background-color 0.15s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slide-image-region-preview-shell:hover .slide-image-region-preview-remove,
|
||||
.slide-image-region-preview-shell:focus-visible .slide-image-region-preview-remove {
|
||||
background: rgba(var(--bs-danger-rgb), 0.92);
|
||||
}
|
||||
|
||||
.slide-image-region-preview-remove i {
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.slide-image-region-upload-zone {
|
||||
flex: 1 1 auto;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
min-height: 176px;
|
||||
padding: 1rem 1.1rem;
|
||||
border: 1px dashed var(--bs-border-color);
|
||||
border-radius: 0;
|
||||
background: var(--bs-tertiary-bg);
|
||||
color: var(--bs-body-color);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.slide-image-region-upload-zone:hover,
|
||||
.slide-image-region-upload-zone:focus-within,
|
||||
.slide-image-region-upload-zone.is-dragover {
|
||||
border-color: var(--bs-primary);
|
||||
background: var(--bs-secondary-bg);
|
||||
box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.12);
|
||||
}
|
||||
|
||||
.slide-image-region-upload-zone-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
font-size: 1.25rem;
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.slide-image-region-upload-zone-copy {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.slide-image-region-upload-zone-copy span {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.slide-image-region-preview {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 288px;
|
||||
height: 176px;
|
||||
object-fit: contain;
|
||||
border-radius: 0;
|
||||
background: var(--bs-tertiary-bg);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.slide-image-region-preview-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
{{#if isEdit}}
|
||||
<form id="delete-slide-form" method="post" action="/slides/{{slide.id}}/delete" data-confirm-message="Delete this slide?"></form>
|
||||
{{/if}}
|
||||
|
||||
<textarea id="slide-editor-data" hidden>{{json slideEditorData}}</textarea>
|
||||
<script type="module" src="/assets/js/slides/slide-form.js?v=20260725"></script>
|
||||
<script src="/assets/vendor/cropperjs/cropper.min.js"></script>
|
||||
<script type="module" src="/assets/js/slides/slide-form.js?v=20260725.7"></script>
|
||||
<script type="module" src="/assets/js/slides/slide-image-cropper.js?v=20260725.4"></script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user