Release v2.5.6

This commit is contained in:
2026-08-05 19:38:16 +01:00
parent a8ef35b287
commit 9f831f04bc
161 changed files with 8487 additions and 1295 deletions
+301 -52
View File
@@ -50,6 +50,8 @@
var selectedIndex = -1;
var activeAnimationCard = null;
var overlayRenderFrame = 0;
var stageResizeObserver = null;
var regionNameValidationToastShown = false;
var previewState = {
active: false,
mode: '',
@@ -192,6 +194,40 @@
card.querySelector('[name="region_label[]"]').value = next;
}
function normalizeRegionNamePrefix(regionType) {
var prefix = String(regionType || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
return prefix || 'region';
}
function buildDefaultRegionName(regionType, index) {
return normalizeRegionNamePrefix(regionType) + '_' + index;
}
function getNextDefaultRegionName(regionType) {
var normalizedType = normalizeRegionNamePrefix(regionType);
var nextIndex = getCards().reduce(function (count, card) {
var currentType = normalizeRegionNamePrefix(card.querySelector('[name="region_type[]"]').value || '');
if (currentType !== normalizedType) {
return count;
}
var currentName = getRegionName(card);
var expectedPrefix = normalizedType + '_';
if (currentName.indexOf(expectedPrefix) !== 0) {
return count;
}
var suffix = Number(currentName.slice(expectedPrefix.length));
if (!Number.isFinite(suffix) || suffix < 1) {
return count;
}
return Math.max(count, suffix);
}, 0) + 1;
return buildDefaultRegionName(normalizedType, nextIndex);
}
function validateRegionNames() {
var cards = getCards();
var names = {};
@@ -230,6 +266,17 @@
return !hasDuplicate;
}
function notifyRegionNameValidationError(message) {
if (regionNameValidationToastShown) {
return;
}
regionNameValidationToastShown = true;
if (typeof showToast === 'function') {
showToast(message || 'Region names must be unique on this template.', 'warning');
}
}
function readCard(card) {
return utils.readCard ? utils.readCard(card) : {
region_key: getRegionName(card),
@@ -304,6 +351,19 @@
});
}
function updatePreviewButtonIcon(button, iconClass) {
if (!button) {
return;
}
var icon = button.querySelector('[data-preview-icon]');
if (!icon) {
return;
}
icon.className = 'bi ' + iconClass + ' me-1';
}
function updatePreviewControlState() {
var activeMode = previewState.active ? previewState.mode : '';
@@ -315,7 +375,9 @@
var available = hasPreviewAnimationForMode(entry.mode);
var disabled = !available || (previewState.active && activeMode !== entry.mode);
entry.button.disabled = disabled;
entry.button.classList.toggle('active', previewState.active && activeMode === entry.mode);
entry.button.classList.toggle('btn-outline-secondary', disabled);
entry.button.classList.toggle('btn-info', !disabled);
updatePreviewButtonIcon(entry.button, previewState.active && activeMode === entry.mode ? 'bi-stop-fill' : 'bi-play-fill');
entry.button.setAttribute('aria-pressed', previewState.active && activeMode === entry.mode ? 'true' : 'false');
});
}
@@ -380,6 +442,8 @@
}
}
var MAX_ANIMATION_REPEAT_COUNT = 999;
function getPreviewAnimationStepTimingMs(step, mode, animateCssMode) {
var normalizedMode = String(mode || '').trim().toLowerCase();
var durationMs = Number(step && step.duration_ms);
@@ -416,7 +480,9 @@
normalized.preset = normalizePreviewAnimation(value.preset !== undefined ? value.preset : fallbackPreset || 'none');
normalized.duration_ms = Number.isFinite(Number(value.duration_ms)) && Number(value.duration_ms) > 0 ? Math.round(Number(value.duration_ms)) : null;
normalized.delay_ms = Number.isFinite(Number(value.delay_ms)) && Number(value.delay_ms) >= 0 ? Math.round(Number(value.delay_ms)) : null;
normalized.iterations = Number.isFinite(Number(value.iterations)) && Number(value.iterations) > 0 ? Math.round(Number(value.iterations)) : null;
normalized.iterations = Number.isFinite(Number(value.iterations)) && Number(value.iterations) > 0
? Math.min(MAX_ANIMATION_REPEAT_COUNT, Math.max(1, Math.round(Number(value.iterations))))
: null;
normalized.fill_mode = String(value.fill_mode || '').trim();
return normalized;
}
@@ -599,11 +665,11 @@
var delayInput = getAnimationModalField(stepName, 'delay_ms');
if (presetInput) { presetInput.value = step.preset; }
if (durationInput) { durationInput.value = step.duration_ms === null || step.duration_ms === undefined || Number(step.duration_ms) <= 0 ? '1000' : String(step.duration_ms); }
if (durationInput) { durationInput.value = String(getAnimationDurationPercent(step.duration_ms)); }
if (delayInput) { delayInput.value = step.delay_ms === null || step.delay_ms === undefined ? '0' : String(step.delay_ms); }
if (stepName === 'loop') {
var iterationsInput = getAnimationModalField(stepName, 'iterations');
if (iterationsInput) { iterationsInput.value = step.iterations === null || step.iterations === undefined ? '1' : String(step.iterations); }
if (iterationsInput) { iterationsInput.value = step.iterations === null || step.iterations === undefined ? '1' : String(Math.min(MAX_ANIMATION_REPEAT_COUNT, Math.max(1, Number(step.iterations) || 1))); }
}
});
}
@@ -617,6 +683,44 @@
return Number.isFinite(next) ? Math.round(next) : null;
}
function clampAnimationModalField(field) {
if (!field || !field.matches) {
return;
}
var isLoopIterationsField = field.matches('[data-animation-modal-step="loop"][data-animation-modal-field="iterations"]');
var isDurationField = field.matches('[data-animation-modal-field="duration_ms"]');
if (!isLoopIterationsField && !isDurationField) {
return;
}
var fieldValue = readNumberField(field.value);
if (fieldValue === null) {
return;
}
var normalizedValue = isLoopIterationsField
? Math.min(MAX_ANIMATION_REPEAT_COUNT, Math.max(1, fieldValue))
: Math.min(200, Math.max(1, fieldValue));
if (String(normalizedValue) !== String(field.value)) {
field.value = String(normalizedValue);
}
}
function getAnimationDurationPercent(durationMs) {
var value = Number(durationMs);
if (!Number.isFinite(value) || value <= 0) {
return 100;
}
return Math.min(200, Math.max(1, Math.round(value / 10)));
}
function getAnimationDurationMs(durationPercent) {
var percent = Math.min(200, Math.max(1, readNumberField(durationPercent) || 100));
return percent * 10;
}
function readAnimationModalConfig() {
if (!animationAdvancedModal) {
return null;
@@ -631,11 +735,11 @@
var nextStep = Object.assign({}, config[stepName] || {});
nextStep.preset = presetInput ? presetInput.value : nextStep.preset;
nextStep.duration_ms = readNumberField(durationInput ? durationInput.value : '') || 1000;
nextStep.duration_ms = getAnimationDurationMs(durationInput ? durationInput.value : '');
nextStep.delay_ms = readNumberField(delayInput ? delayInput.value : '') || 0;
if (stepName === 'loop') {
var iterationsInput = getAnimationModalField(stepName, 'iterations');
nextStep.iterations = readNumberField(iterationsInput ? iterationsInput.value : '') || 1;
nextStep.iterations = Math.min(MAX_ANIMATION_REPEAT_COUNT, Math.max(1, readNumberField(iterationsInput ? iterationsInput.value : '') || 1));
}
config[stepName] = normalizeAnimationStep(nextStep, 'none');
@@ -892,6 +996,104 @@
canvasHeightInput.value = Math.max(1, Number(option.dataset.height || canvasHeightInput.value || 1080));
updateAspectRatio();
updateCanvasSizeSummary();
getCards().forEach(function (card) {
normalizeRegionCard(card, 'width');
});
}
function fitRegionToCanvas(region, aspect) {
var size = getCanvasSize();
var minSize = 12;
var next = {
x: Math.round(Number(region && region.x !== undefined ? region.x : 0)),
y: Math.round(Number(region && region.y !== undefined ? region.y : 0)),
width: Math.max(minSize, Math.round(Number(region && region.width !== undefined ? region.width : minSize))),
height: Math.max(minSize, Math.round(Number(region && region.height !== undefined ? region.height : minSize)))
};
if (aspect && Number.isFinite(aspect.ratio) && aspect.ratio > 0) {
if (next.width > size.width || next.height > size.height) {
var canvasRatio = size.width / Math.max(1, size.height);
if (canvasRatio >= aspect.ratio) {
next.height = Math.max(minSize, Math.min(size.height, next.height, Math.round(size.height)));
next.width = Math.max(minSize, Math.round(next.height * aspect.ratio));
if (next.width > size.width) {
next.width = size.width;
next.height = Math.max(minSize, Math.round(next.width / aspect.ratio));
}
} else {
next.width = Math.max(minSize, Math.min(size.width, next.width, Math.round(size.width)));
next.height = Math.max(minSize, Math.round(next.width / aspect.ratio));
if (next.height > size.height) {
next.height = size.height;
next.width = Math.max(minSize, Math.round(next.height * aspect.ratio));
}
}
}
} else {
if (next.width > size.width) {
next.width = size.width;
next.x = 0;
}
if (next.height > size.height) {
next.height = size.height;
next.y = 0;
}
}
if (next.x + next.width > size.width) {
next.x = size.width - next.width;
}
if (next.y + next.height > size.height) {
next.y = size.height - next.height;
}
next.x = clamp(next.x, 0, Math.max(0, size.width - next.width));
next.y = clamp(next.y, 0, Math.max(0, size.height - next.height));
return next;
}
function normalizeRegionCard(card, changedField) {
if (!card) {
return null;
}
var current = readCard(card);
var next = {
x: current.x,
y: current.y,
width: Math.max(12, Math.round(current.width || 0)),
height: Math.max(12, Math.round(current.height || 0))
};
var lockRatio = normalizeLockRatio(current.lock_ratio);
var aspect = lockRatio ? parseLockRatio(lockRatio) : null;
var size = getCanvasSize();
if (aspect) {
if (changedField === 'height') {
next.width = Math.max(12, Math.round(next.height * aspect.ratio));
} else {
next.height = Math.max(12, Math.round(next.width / aspect.ratio));
}
if (next.width > size.width || next.height > size.height) {
var scale = Math.min(size.width / Math.max(1, next.width), size.height / Math.max(1, next.height), 1);
if (scale < 1) {
next.width = Math.max(12, Math.round(next.width * scale));
next.height = Math.max(12, Math.round(next.height * scale));
if (changedField === 'height') {
next.width = Math.max(12, Math.round(next.height * aspect.ratio));
} else {
next.height = Math.max(12, Math.round(next.width / aspect.ratio));
}
}
}
}
next = fitRegionToCanvas(next, aspect);
writeCard(card, next);
return next;
}
function updateBackgroundPreview(file) {
@@ -932,6 +1134,25 @@
lockChip.textContent = 'Locked ' + lockRatio;
}
function updateRegionLockRatioState(card) {
var lockRatioInput = card.querySelector('[name="region_lock_ratio[]"]');
var regionTypeInput = card.querySelector('[name="region_type[]"]');
if (!lockRatioInput || !regionTypeInput) {
return;
}
var isQrCode = String(regionTypeInput.value || '').trim() === 'qr-code';
lockRatioInput.disabled = isQrCode;
lockRatioInput.setAttribute('aria-disabled', isQrCode ? 'true' : 'false');
if (isQrCode) {
lockRatioInput.value = '1:1';
lockRatioInput.title = 'QR code regions are fixed to 1:1.';
} else {
lockRatioInput.removeAttribute('title');
}
}
function getRegionLockRatio(card) {
return normalizeLockRatio(card.querySelector('[name="region_lock_ratio[]"]').value);
}
@@ -941,22 +1162,10 @@
}
function syncLockedDimensions(card, changedField) {
var lockRatio = getRegionLockRatio(card);
var aspect = lockRatio ? parseLockRatio(lockRatio) : null;
if (!aspect) {
return;
}
var widthInput = card.querySelector('[name="region_width[]"]');
var heightInput = card.querySelector('[name="region_height[]"]');
var width = Math.max(1, Number(widthInput.value || 0));
var height = Math.max(1, Number(heightInput.value || 0));
if (changedField === 'width') {
heightInput.value = Math.max(1, Math.round(width / aspect.ratio));
} else if (changedField === 'height') {
widthInput.value = Math.max(1, Math.round(height * aspect.ratio));
}
normalizeRegionCard(card, changedField);
updateRegionLockBadge(card);
markTemplateFormDirty();
requestOverlayRender();
}
function getRegionChipLabel(regionType) {
@@ -964,7 +1173,7 @@
return window.pulseRegionTypes.getRegionChipLabel(regionType);
}
return regionType === 'image' ? 'Image' : regionType === 'video' ? 'Video' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : regionType === 'rtmp' ? 'RTMP' : regionType === 'rss' ? 'RSS' : 'Text';
return 'Text';
}
function populateRegionCard(card, region) {
@@ -995,7 +1204,7 @@
}
var regionLockRatioInput = card.querySelector('[name="region_lock_ratio[]"]');
if (regionLockRatioInput) {
regionLockRatioInput.value = normalizeLockRatio(region.lock_ratio);
regionLockRatioInput.value = normalizeLockRatio(region.lock_ratio || (String(region.region_type || '').trim() === 'qr-code' ? '1:1' : ''));
}
writeAnimationConfig(card, region.animation_json);
card.querySelector('[name="region_x[]"]').value = valueOrDefault(region.x, 80);
@@ -1003,6 +1212,7 @@
card.querySelector('[name="region_z[]"]').value = valueOrDefault(region.z_index, 1);
card.querySelector('[name="region_width[]"]').value = valueOrDefault(region.width, 300);
card.querySelector('[name="region_height[]"]').value = valueOrDefault(region.height, 120);
updateRegionLockRatioState(card);
updateRegionLockBadge(card);
}
@@ -1053,6 +1263,7 @@
var animationResetButtons = card.querySelectorAll('[data-animation-reset-button]');
var animationAdvancedButton = card.querySelector('[data-animation-advanced-button]');
nameInput.addEventListener('input', function () {
regionNameValidationToastShown = false;
syncRegionIdentity(card, nameInput.value);
updateRegionLabel(card);
validateRegionNames();
@@ -1062,6 +1273,7 @@
Array.prototype.forEach.call(animationPresetInputs, function (input) {
input.addEventListener('change', function () {
setAnimationPreset(card, input.getAttribute('data-animation-preset'), input.value);
markTemplateFormDirty();
requestOverlayRender();
});
});
@@ -1069,6 +1281,7 @@
button.addEventListener('click', function (event) {
event.preventDefault();
setAnimationPreset(card, button.getAttribute('data-animation-reset-button'), 'none');
markTemplateFormDirty();
requestOverlayRender();
});
});
@@ -1083,26 +1296,17 @@
}
if (lockRatioInput) {
lockRatioInput.addEventListener('input', function () {
updateRegionLockBadge(card);
requestOverlayRender();
syncLockedDimensions(card, 'width');
});
}
if (widthInput) {
widthInput.addEventListener('input', function () {
if (isRegionLocked(card)) {
syncLockedDimensions(card, 'width');
updateRegionLockBadge(card);
}
requestOverlayRender();
syncLockedDimensions(card, 'width');
});
}
if (heightInput) {
heightInput.addEventListener('input', function () {
if (isRegionLocked(card)) {
syncLockedDimensions(card, 'height');
updateRegionLockBadge(card);
}
requestOverlayRender();
syncLockedDimensions(card, 'height');
});
}
card.addEventListener('click', function (event) {
@@ -1114,6 +1318,7 @@
card.querySelector('[data-region-remove-button]').addEventListener('click', function (event) {
event.preventDefault();
card.remove();
markTemplateFormDirty();
if (!getCards().length) {
selectedIndex = -1;
} else if (selectedIndex >= getCards().length) {
@@ -1217,6 +1422,29 @@
renderOverlay();
}
function observeStageResize() {
if (!stage) {
return;
}
if (window.ResizeObserver) {
stageResizeObserver = new window.ResizeObserver(function () {
requestOverlayRender();
});
stageResizeObserver.observe(stage);
return;
}
window.addEventListener('resize', requestOverlayRender);
}
function markTemplateFormDirty() {
if (!templateForm) {
return;
}
templateForm.dataset.dirty = 'true';
}
function setSelected(index) {
if (previewState.active) {
return;
@@ -1246,6 +1474,7 @@
var card = makeRegionCard(region);
setRegionRemovalState(card);
regionList.appendChild(card);
markTemplateFormDirty();
setSelected(getCards().length - 1);
}
@@ -1257,9 +1486,9 @@
}
function createDefaultRegion(type) {
var count = getCards().length + 1;
var name = 'region_' + count;
var size = getDefaultRegionSize(type, '');
var name = getNextDefaultRegionName(type);
var lockRatio = String(type || '').trim() === 'qr-code' ? '1:1' : '';
var size = getDefaultRegionSize(type, lockRatio);
return {
region_key: name,
label: name,
@@ -1269,7 +1498,7 @@
y: 80,
width: size.width,
height: size.height,
lock_ratio: '',
lock_ratio: lockRatio,
z_index: 1
};
}
@@ -1307,8 +1536,9 @@
var width = Math.abs(draft.end.x - draft.start.x);
var height = Math.abs(draft.end.y - draft.start.y);
if (width >= 8 && height >= 8) {
var region = clampRegion({ x: Math.min(draft.start.x, draft.end.x), y: Math.min(draft.start.y, draft.end.y), width: width, height: height });
addRegion({ region_key: 'region_' + (getCards().length + 1), label: 'Region ' + (getCards().length + 1), region_type: 'text', x: region.x, y: region.y, width: region.width, height: region.height, z_index: 1 });
var region = fitRegionToCanvas({ x: Math.min(draft.start.x, draft.end.x), y: Math.min(draft.start.y, draft.end.y), width: width, height: height });
var name = getNextDefaultRegionName('text');
addRegion({ region_key: name, label: name, region_type: 'text', x: region.x, y: region.y, width: region.width, height: region.height, z_index: 1 });
}
draft = null;
requestOverlayRender();
@@ -1336,6 +1566,7 @@
var nextX = clamp(startRegion.x + dx, 0, maxX);
var nextY = clamp(startRegion.y + dy, 0, maxY);
writeCard(cardAt(index), { x: nextX, y: nextY, width: startRegion.width, height: startRegion.height });
markTemplateFormDirty();
requestOverlayRender();
}
function upHandler() {
@@ -1439,8 +1670,9 @@
}
if (next.width < 12) { if (dir.indexOf('w') !== -1) { next.x -= 12 - next.width; } next.width = 12; }
if (next.height < 12) { if (dir.indexOf('n') !== -1) { next.y -= 12 - next.height; } next.height = 12; }
next = clampRegion(next);
next = fitRegionToCanvas(next, aspect);
writeCard(cardAt(index), { x: next.x, y: next.y, width: next.width, height: next.height });
markTemplateFormDirty();
requestOverlayRender();
}
function upHandler() {
@@ -1492,11 +1724,19 @@
animationAdvancedModal.addEventListener('hidden.bs.modal', function () {
activeAnimationCard = null;
});
animationAdvancedModal.addEventListener('input', function (event) {
clampAnimationModalField(event.target);
markTemplateFormDirty();
});
animationAdvancedModal.addEventListener('change', function () {
markTemplateFormDirty();
});
var animationModalApplyButton = animationAdvancedModal.querySelector('[data-animation-modal-apply]');
var animationModalResetButton = animationAdvancedModal.querySelector('[data-animation-modal-reset]');
if (animationModalApplyButton) {
animationModalApplyButton.addEventListener('click', function () {
saveAnimationModal();
markTemplateFormDirty();
if (window.pulseModal) {
window.pulseModal.hide(animationAdvancedModal);
}
@@ -1508,6 +1748,7 @@
return;
}
writeAnimationConfig(activeAnimationCard, normalizeAnimationConfig('{}'));
markTemplateFormDirty();
populateAnimationModal(activeAnimationCard);
requestOverlayRender();
});
@@ -1541,6 +1782,19 @@
setSelected(-1);
});
if (templateForm) {
templateForm.addEventListener('invalid', function (event) {
var target = event.target;
if (!target || !target.validationMessage || !target.validationMessage.match(/Region names must be unique on this template\.|Region name is required\./)) {
return;
}
if (target.closest && !target.closest('.region-item')) {
return;
}
notifyRegionNameValidationError(target.validationMessage);
}, true);
templateForm.addEventListener('formdata', function (event) {
if (previewState.active) {
event.preventDefault();
@@ -1552,16 +1806,13 @@
return;
}
getCards().forEach(function (card) {
if (isRegionLocked(card)) {
syncLockedDimensions(card, 'width');
updateRegionLockBadge(card);
}
normalizeRegionCard(card, 'width');
});
syncCanvasSizeSelection();
event.formData.set('regions_json', JSON.stringify(getCards().map(readCard)));
});
templateForm.addEventListener('submit', function () {
templateForm.addEventListener('submit', function (event) {
if (previewState.active) {
return;
}
@@ -1570,10 +1821,7 @@
return;
}
getCards().forEach(function (card) {
if (isRegionLocked(card)) {
syncLockedDimensions(card, 'width');
updateRegionLockBadge(card);
}
normalizeRegionCard(card, 'width');
});
syncCanvasSizeSelection();
regionsJsonInput.value = JSON.stringify(getCards().map(readCard));
@@ -1613,5 +1861,6 @@
renderRegionList(existingRegions);
syncCanvasSizeSelection();
updateStageBackgroundColor();
observeStageResize();
render();
})();