Files
pulse-signage/src/web/public/js/templates/template-designer.js
T
2026-08-05 19:38:16 +01:00

1867 lines
67 KiB
JavaScript

// Template designer bootstrap and form wiring.
(function () {
var dataElement = document.getElementById('template-editor-data');
if (!dataElement) {
return;
}
var utils = window.templateDesignerUtils || {};
var templateData = {};
try {
templateData = JSON.parse(dataElement.getAttribute('data-json') || dataElement.textContent || '{}') || {};
} catch (_error) {
templateData = {};
}
var existingRegions = Array.isArray(templateData.regions)
? templateData.regions
: (Array.isArray(templateData) ? templateData : []);
var stage = document.getElementById('designer-stage');
var overlay = document.getElementById('designer-overlay');
var previewCard = document.getElementById('template-preview-card');
var previewIntroButton = document.getElementById('template-preview-intro');
var previewOutButton = document.getElementById('template-preview-out');
var previewContinuousButton = document.getElementById('template-preview-continuous');
var regionList = document.getElementById('region-list');
var regionSelect = document.getElementById('region-select');
var canvasSizeSelect = document.getElementById('canvas-size-select');
var canvasSizeIdInput = document.getElementById('canvas-size-id');
var canvasSizeSummary = document.getElementById('canvas-size-summary');
var canvasWidthInput = document.getElementById('canvas-width');
var canvasHeightInput = document.getElementById('canvas-height');
var backgroundInput = document.getElementById('background-image');
var backgroundColorInput = document.getElementById('background-color');
var backgroundPreview = document.getElementById('background-preview');
var backgroundEmpty = document.getElementById('background-empty');
var removeBackgroundButton = document.getElementById('remove-background-image');
var removeBackgroundFlag = document.getElementById('remove-background-image-flag');
var addRegionButton = document.getElementById('add-region-button');
var regionAddModal = document.getElementById('region-add-modal');
var regionAddOptions = document.getElementById('region-add-options');
var animationAdvancedModal = document.getElementById('animation-advanced-modal');
var regionCardTemplate = document.getElementById('region-card-template');
var regionsJsonInput = document.getElementById('regions-json');
var templateForm = document.getElementById('template-form');
var templateRegionUsageElement = document.getElementById('template-region-usage');
var regionUsageSet = new Set();
var draft = null;
var selectedIndex = -1;
var activeAnimationCard = null;
var overlayRenderFrame = 0;
var stageResizeObserver = null;
var regionNameValidationToastShown = false;
var previewState = {
active: false,
mode: '',
stopTimer: 0,
token: 0,
animations: []
};
var previewButtons = [
{ button: previewIntroButton, mode: 'intro' },
{ button: previewOutButton, mode: 'out' },
{ button: previewContinuousButton, mode: 'continuous' }
];
try {
var regionUsageData = JSON.parse((templateRegionUsageElement && templateRegionUsageElement.textContent) || '[]');
if (Array.isArray(regionUsageData)) {
regionUsageData.forEach(function (regionKey) {
var normalized = String(regionKey || '').trim();
if (normalized) {
regionUsageSet.add(normalized);
}
});
}
} catch (_error) {
regionUsageSet = new Set();
}
function escapeHtml(value) {
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
}
function valueOrDefault(value, fallback) {
return utils.valueOrDefault ? utils.valueOrDefault(value, fallback) : (value === undefined || value === null || value === '' ? fallback : value);
}
function getCanvasSize() {
return {
width: Math.max(1, Number(canvasWidthInput.value || 1920)),
height: Math.max(1, Number(canvasHeightInput.value || 1080))
};
}
function clamp(value, min, max) {
return utils.clamp ? utils.clamp(value, min, max) : Math.max(min, Math.min(max, value));
}
function normalizeLockRatio(value) {
return utils.normalizeLockRatio ? utils.normalizeLockRatio(value) : String(value || '').trim().replace(/\s+/g, '');
}
function parseLockRatio(value) {
if (utils.parseLockRatio) {
return utils.parseLockRatio(value);
}
var normalized = normalizeLockRatio(value);
if (!normalized || !/^\d+:\d+$/.test(normalized)) {
return null;
}
var parts = normalized.split(':');
var width = Number(parts[0]);
var height = Number(parts[1]);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return null;
}
return { label: normalized, ratio: width / height };
}
function getDefaultRegionSize(regionType, lockRatio) {
if (utils.getDefaultRegionSize) {
return utils.getDefaultRegionSize(regionType, lockRatio);
}
var ratio = parseLockRatio(lockRatio);
var locked = false;
if (ratio) {
if (ratio.ratio >= 1) {
var baseWidth = locked ? 420 : 300;
return { width: Math.max(12, Math.round(baseWidth)), height: Math.max(12, Math.round(baseWidth / ratio.ratio)) };
}
var baseHeight = locked ? 300 : 240;
return { width: Math.max(12, Math.round(baseHeight * ratio.ratio)), height: Math.max(12, Math.round(baseHeight)) };
}
return { width: locked ? 420 : 300, height: locked ? 240 : 120 };
}
function getRegionTypeEntries() {
if (window.pulseRegionTypes && typeof window.pulseRegionTypes.list === 'function') {
return window.pulseRegionTypes.list();
}
return ['api', 'html', 'image', 'rss', 'rtmp', 'text', 'time-date', 'video', 'webpage'].map(function (type) {
return { type: type, definition: window.pulseRegionTypes && typeof window.pulseRegionTypes.get === 'function' ? window.pulseRegionTypes.get(type) || {} : {} };
});
}
function formatRegionTypeLabel(regionType) {
return String(regionType || '')
.trim()
.replace(/(^|[_-])(\w)/g, function (_match, _prefix, letter) {
return String(letter || '').toUpperCase();
}) || 'Region';
}
function renderAddRegionOptions() {
if (!regionAddOptions) {
return;
}
regionAddOptions.innerHTML = getRegionTypeEntries().map(function (entry) {
var type = String(entry && entry.type ? entry.type : '').trim();
if (!type) {
return '';
}
var definition = entry.definition || {};
var label = definition.label || formatRegionTypeLabel(type);
return '<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="' + escapeHtml(type) + '">' + escapeHtml(label) + '</button>';
}).join('');
}
function getCards() {
return Array.prototype.slice.call(regionList.querySelectorAll('.region-item'));
}
function cardAt(index) {
return getCards()[index] || null;
}
function getRegionName(card) {
return utils.getRegionName ? utils.getRegionName(card) : String(card.querySelector('[name="region_name[]"]').value || '').trim();
}
function syncRegionIdentity(card, value) {
if (utils.syncRegionIdentity) {
utils.syncRegionIdentity(card, value);
return;
}
var next = String(value || '').trim();
card.querySelector('[name="region_name[]"]').value = next;
card.querySelector('[name="region_key[]"]').value = next;
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 = {};
var hasDuplicate = false;
cards.forEach(function (card) {
var input = card.querySelector('[name="region_name[]"]');
if (!input) {
return;
}
var normalized = String(input.value || '').trim().toLowerCase();
if (!normalized) {
input.setCustomValidity('Region name is required.');
return;
}
if (!names[normalized]) {
names[normalized] = [];
}
names[normalized].push(input);
});
Object.keys(names).forEach(function (key) {
var inputs = names[key];
if (inputs.length > 1) {
hasDuplicate = true;
inputs.forEach(function (input) {
input.setCustomValidity('Region names must be unique on this template.');
});
} else {
inputs[0].setCustomValidity('');
}
});
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),
label: getRegionName(card),
region_type: card.querySelector('[name="region_type[]"]').value,
lock_ratio: normalizeLockRatio(card.querySelector('[name="region_lock_ratio[]"]').value),
animation_json: getAnimationConfig(card),
x: Number(card.querySelector('[name="region_x[]"]').value || 0),
y: Number(card.querySelector('[name="region_y[]"]').value || 0),
width: Number(card.querySelector('[name="region_width[]"]').value || 0),
height: Number(card.querySelector('[name="region_height[]"]').value || 0),
z_index: Number(card.querySelector('[name="region_z[]"]').value || 0)
};
}
function hasAnyAnimationCards() {
return getCards().some(function (card) {
var hiddenAnimationInput = card.querySelector('[name="region_animation_json[]"]');
var animationConfig = normalizeAnimationConfig(hiddenAnimationInput ? hiddenAnimationInput.value || '{}' : '{}');
return animationConfig && ['intro', 'outro', 'loop'].some(function (stepName) {
var step = animationConfig[stepName] || {};
return normalizePreviewAnimation(step.preset) !== 'none';
});
});
}
function hasPreviewAnimationForMode(mode) {
var previewMode = String(mode || '').trim().toLowerCase();
if (!previewMode) {
return false;
}
return getCards().some(function (card) {
var step = getPreviewRegionAnimationStep(readCard(card), previewMode);
return normalizePreviewAnimation(step && step.preset) !== 'none';
});
}
function getPreviewRects() {
if (!overlay) {
return [];
}
return Array.prototype.slice.call(overlay.querySelectorAll('.designer-rect'));
}
function clearPreviewAnimations() {
previewState.animations.forEach(function (animation) {
if (animation && typeof animation.cancel === 'function') {
animation.cancel();
}
});
previewState.animations = [];
getPreviewRects().forEach(function (rect) {
rect.classList.remove('animate__animated', 'animate__infinite');
rect.classList.remove('animate__repeat-1', 'animate__repeat-2', 'animate__repeat-3');
if (window.templateAnimationPresets && Array.isArray(window.templateAnimationPresets.animateCssValues)) {
window.templateAnimationPresets.animateCssValues.forEach(function (value) {
if (value && value !== 'none') {
rect.classList.remove('animate__' + value);
}
});
}
rect.style.removeProperty('--animate-duration');
rect.style.removeProperty('--animate-delay');
rect.style.removeProperty('--animate-repeat');
rect.style.opacity = '';
rect.style.transform = '';
rect.style.filter = '';
rect.style.willChange = '';
});
}
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 : '';
previewButtons.forEach(function (entry) {
if (!entry.button) {
return;
}
var available = hasPreviewAnimationForMode(entry.mode);
var disabled = !available || (previewState.active && activeMode !== entry.mode);
entry.button.disabled = disabled;
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');
});
}
function normalizePreviewAnimation(value) {
if (window.templateAnimationPresets && typeof window.templateAnimationPresets.normalize === 'function') {
return window.templateAnimationPresets.normalize(value);
}
var allowed = {};
var presets = window.templateAnimationPresets || {};
var values = Array.isArray(presets.allValues) && presets.allValues.length ? presets.allValues : [
'none', 'fadeIn', 'fadeInDown', 'fadeInLeft', 'fadeInRight', 'fadeInUp', 'zoomIn', 'zoomInDown',
'zoomInLeft', 'zoomInRight', 'zoomInUp', 'slideInDown', 'slideInLeft', 'slideInRight', 'slideInUp',
'fadeOut', 'fadeOutDown', 'fadeOutLeft', 'fadeOutRight', 'fadeOutUp', 'zoomOut', 'zoomOutDown',
'zoomOutLeft', 'zoomOutRight', 'zoomOutUp', 'slideOutDown', 'slideOutLeft', 'slideOutRight', 'slideOutUp',
'backInDown', 'backInLeft', 'backInRight', 'backInUp', 'backOutDown', 'backOutLeft', 'backOutRight', 'backOutUp',
'bounceIn', 'bounceInDown', 'bounceInLeft', 'bounceInRight', 'bounceInUp', 'bounceOut', 'bounceOutDown', 'bounceOutLeft', 'bounceOutRight', 'bounceOutUp',
'flip', 'flipInX', 'flipInY', 'flipOutX', 'flipOutY', 'lightSpeedInLeft', 'lightSpeedInRight', 'lightSpeedOutLeft', 'lightSpeedOutRight',
'rotateIn', 'rotateInDownLeft', 'rotateInDownRight', 'rotateInUpLeft', 'rotateInUpRight', 'rotateOut', 'rotateOutDownLeft', 'rotateOutDownRight', 'rotateOutUpLeft', 'rotateOutUpRight',
'hinge', 'jackInTheBox', 'rollIn', 'rollOut', 'flash', 'pulse', 'rubberBand', 'shakeX', 'shakeY', 'headShake', 'swing', 'tada', 'wobble', 'jello', 'heartBeat', 'bounce'
];
values.forEach(function (valueName) {
allowed[valueName] = true;
});
var raw = String(value || '').trim().toLowerCase();
return allowed[raw] ? raw : 'none';
}
function isAnimateCssPreset(value) {
var presets = window.templateAnimationPresets || {};
if (Array.isArray(presets.animateCssValues) && presets.animateCssValues.length) {
return presets.animateCssValues.indexOf(normalizePreviewAnimation(value)) !== -1;
}
return false;
}
function applyAnimateCssPreview(rect, preset, step, mode) {
var animateClass = 'animate__' + preset;
var durationMs = Number(step && step.duration_ms);
var delayMs = Number(step && step.delay_ms);
var repeatCount = Number(step && step.iterations);
var isAttentionSeeker = preset === 'bounce' || preset === 'flash' || preset === 'pulse' || preset === 'rubberBand' || preset === 'shakeX' || preset === 'shakeY' || preset === 'headShake' || preset === 'swing' || preset === 'tada' || preset === 'wobble' || preset === 'jello' || preset === 'heartBeat';
rect.classList.remove('animate__animated', 'animate__infinite');
rect.style.removeProperty('--animate-duration');
rect.style.removeProperty('--animate-delay');
rect.style.removeProperty('--animate-repeat');
rect.style.setProperty('--animate-duration', Math.max(0, Number.isFinite(durationMs) && durationMs > 0 ? durationMs : 1000) + 'ms');
if (Number.isFinite(delayMs) && delayMs > 0) {
rect.style.setProperty('--animate-delay', Math.max(0, delayMs) + 'ms');
}
rect.classList.add('animate__animated', animateClass);
if (isAttentionSeeker) {
var normalizedRepeatCount = Math.max(1, Number.isFinite(repeatCount) && repeatCount > 0 ? repeatCount : 1);
rect.classList.remove('animate__repeat-1', 'animate__repeat-2', 'animate__repeat-3');
rect.classList.add('animate__repeat-1');
rect.style.setProperty('--animate-repeat', String(normalizedRepeatCount));
}
}
var MAX_ANIMATION_REPEAT_COUNT = 999;
function getPreviewAnimationStepTimingMs(step, mode, animateCssMode) {
var normalizedMode = String(mode || '').trim().toLowerCase();
var durationMs = Number(step && step.duration_ms);
var delayMs = Number(step && step.delay_ms);
var iterations = Number(step && step.iterations);
if (!Number.isFinite(durationMs) || durationMs <= 0) {
durationMs = animateCssMode ? 1000 : 900;
}
if (!Number.isFinite(delayMs) || delayMs < 0) {
delayMs = 0;
}
if (!Number.isFinite(iterations) || iterations < 1) {
iterations = 1;
}
if (normalizedMode === 'continuous') {
return delayMs + (durationMs * iterations);
}
return delayMs + durationMs;
}
function normalizeAnimationStep(value, fallbackPreset) {
if (utils.normalizeAnimationStep) {
return utils.normalizeAnimationStep(value, fallbackPreset);
}
if (value && typeof value === 'object' && !Array.isArray(value)) {
var normalized = {};
Object.keys(value).forEach(function (key) {
normalized[key] = value[key];
});
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.min(MAX_ANIMATION_REPEAT_COUNT, Math.max(1, Math.round(Number(value.iterations))))
: null;
normalized.fill_mode = String(value.fill_mode || '').trim();
return normalized;
}
return {
preset: normalizePreviewAnimation(typeof value === 'string' ? value : fallbackPreset || 'none'),
duration_ms: null,
delay_ms: null,
iterations: null,
fill_mode: ''
};
}
function normalizeAnimationConfig(value) {
if (utils.normalizeAnimationConfig) {
return utils.normalizeAnimationConfig(value);
}
var raw = value;
if (typeof raw === 'string') {
var text = String(raw || '').trim();
if (!text) {
raw = null;
} else {
try {
raw = JSON.parse(text);
} catch (_error) {
raw = null;
}
}
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
raw = {};
}
return {
intro: normalizeAnimationStep(raw.intro, 'none'),
outro: normalizeAnimationStep(raw.outro !== undefined ? raw.outro : raw.out, 'none'),
loop: normalizeAnimationStep(raw.loop, 'none')
};
}
function getPreviewRegionAnimationStep(region, mode) {
if (!region) {
return normalizeAnimationStep({}, 'none');
}
var animationConfig = normalizeAnimationConfig(region.animation_json !== undefined ? region.animation_json : region);
if (mode === 'intro') {
return animationConfig.intro;
}
if (mode === 'out') {
return animationConfig.outro;
}
return animationConfig.loop;
}
function getAnimationConfig(card) {
var hiddenAnimationInput = card.querySelector('[name="region_animation_json[]"]');
var animationConfig = normalizeAnimationConfig(hiddenAnimationInput ? hiddenAnimationInput.value || '{}' : '{}');
var introPresetInput = card.querySelector('[data-animation-preset="intro"]');
var outroPresetInput = card.querySelector('[data-animation-preset="outro"]');
var loopPresetInput = card.querySelector('[data-animation-preset="loop"]');
if (introPresetInput && introPresetInput.dataset.animationPresetCustom !== '1' && introPresetInput.selectedIndex >= 0) {
animationConfig.intro.preset = normalizePreviewAnimation(introPresetInput.value);
}
if (outroPresetInput && outroPresetInput.dataset.animationPresetCustom !== '1' && outroPresetInput.selectedIndex >= 0) {
animationConfig.outro.preset = normalizePreviewAnimation(outroPresetInput.value);
}
if (loopPresetInput && loopPresetInput.dataset.animationPresetCustom !== '1' && loopPresetInput.selectedIndex >= 0) {
animationConfig.loop.preset = normalizePreviewAnimation(loopPresetInput.value);
}
return animationConfig;
}
function syncAnimationPresetCustomState(selectInput, resetButton, preset) {
if (!selectInput) {
return;
}
var normalizedPreset = normalizePreviewAnimation(preset);
var hasMatchingOption = Array.prototype.some.call(selectInput.options || [], function (option) {
return option && option.value === normalizedPreset;
});
if (hasMatchingOption) {
delete selectInput.dataset.animationPresetCustom;
selectInput.hidden = false;
if (resetButton) {
resetButton.hidden = true;
}
} else {
selectInput.dataset.animationPresetCustom = '1';
selectInput.hidden = true;
if (resetButton) {
resetButton.hidden = false;
resetButton.textContent = 'Reset to basic';
}
}
}
function writeAnimationConfig(card, animationConfig) {
var normalized = normalizeAnimationConfig(animationConfig);
var hiddenAnimationInput = card.querySelector('[name="region_animation_json[]"]');
var introPresetInput = card.querySelector('[data-animation-preset="intro"]');
var outroPresetInput = card.querySelector('[data-animation-preset="outro"]');
var loopPresetInput = card.querySelector('[data-animation-preset="loop"]');
var introResetButton = card.querySelector('[data-animation-reset-button="intro"]');
var outroResetButton = card.querySelector('[data-animation-reset-button="outro"]');
var loopResetButton = card.querySelector('[data-animation-reset-button="loop"]');
if (hiddenAnimationInput) {
hiddenAnimationInput.value = JSON.stringify(normalized, null, 2);
}
if (introPresetInput) {
introPresetInput.value = normalized.intro.preset;
syncAnimationPresetCustomState(introPresetInput, introResetButton, normalized.intro.preset);
}
if (outroPresetInput) {
outroPresetInput.value = normalized.outro.preset;
syncAnimationPresetCustomState(outroPresetInput, outroResetButton, normalized.outro.preset);
}
if (loopPresetInput) {
loopPresetInput.value = normalized.loop.preset;
syncAnimationPresetCustomState(loopPresetInput, loopResetButton, normalized.loop.preset);
}
updatePreviewCardVisibility();
return normalized;
}
function setAnimationPreset(card, stepName, preset) {
var config = getAnimationConfig(card);
if (config[stepName]) {
config[stepName].preset = normalizePreviewAnimation(preset);
config[stepName].duration_ms = null;
config[stepName].delay_ms = null;
config[stepName].iterations = null;
config[stepName].easing = '';
config[stepName].fill_mode = '';
writeAnimationConfig(card, config);
if (activeAnimationCard === card) {
populateAnimationModal(card);
}
}
}
function getAnimationModalField(stepName, fieldName) {
if (!animationAdvancedModal) {
return null;
}
return animationAdvancedModal.querySelector('[data-animation-modal-step="' + stepName + '"][data-animation-modal-field="' + fieldName + '"]');
}
function populateAnimationModal(card) {
if (!animationAdvancedModal || !card) {
return;
}
var title = animationAdvancedModal.querySelector('[data-animation-modal-title]');
var label = card.querySelector('[data-region-title]');
var config = getAnimationConfig(card);
if (title) {
title.textContent = (label ? label.textContent : 'Region') + ' animation settings';
}
['intro', 'outro', 'loop'].forEach(function (stepName) {
var step = config[stepName] || normalizeAnimationStep({}, 'none');
var presetInput = getAnimationModalField(stepName, 'preset');
var durationInput = getAnimationModalField(stepName, 'duration_ms');
var delayInput = getAnimationModalField(stepName, 'delay_ms');
if (presetInput) { presetInput.value = step.preset; }
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(Math.min(MAX_ANIMATION_REPEAT_COUNT, Math.max(1, Number(step.iterations) || 1))); }
}
});
}
function readNumberField(value) {
var raw = String(value || '').trim();
if (!raw) {
return null;
}
var next = Number(raw);
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;
}
var config = getAnimationConfig(activeAnimationCard) || { intro: {}, outro: {}, loop: {} };
['intro', 'outro', 'loop'].forEach(function (stepName) {
var presetInput = getAnimationModalField(stepName, 'preset');
var durationInput = getAnimationModalField(stepName, 'duration_ms');
var delayInput = getAnimationModalField(stepName, 'delay_ms');
var nextStep = Object.assign({}, config[stepName] || {});
nextStep.preset = presetInput ? presetInput.value : nextStep.preset;
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 = Math.min(MAX_ANIMATION_REPEAT_COUNT, Math.max(1, readNumberField(iterationsInput ? iterationsInput.value : '') || 1));
}
config[stepName] = normalizeAnimationStep(nextStep, 'none');
});
return config;
}
function saveAnimationModal() {
if (!activeAnimationCard) {
return;
}
var config = readAnimationModalConfig();
if (!config) {
return;
}
writeAnimationConfig(activeAnimationCard, config);
renderOverlay();
}
function setPreviewControlState(active) {
updatePreviewControlState();
}
function updatePreviewCardVisibility() {
if (!previewCard) {
return;
}
previewCard.hidden = !hasAnyAnimationCards();
}
function setTemplateEditorLocked(locked) {
if (templateForm) {
templateForm.classList.toggle('template-designer-form--previewing', locked);
}
if (stage) {
stage.classList.toggle('is-previewing', locked);
}
if (previewCard) {
previewCard.classList.toggle('is-previewing', locked);
}
if (overlay) {
overlay.style.pointerEvents = locked ? 'none' : '';
}
var controls = templateForm ? templateForm.querySelectorAll('input, select, textarea, button') : [];
Array.prototype.forEach.call(controls, function (control) {
if (!control) {
return;
}
if (control.matches && control.matches('[data-preview-control]')) {
return;
}
if (locked) {
if (!control.hasAttribute('data-preview-disabled')) {
control.setAttribute('data-preview-disabled', control.disabled ? '1' : '0');
}
control.disabled = true;
} else if (control.hasAttribute('data-preview-disabled')) {
control.disabled = control.getAttribute('data-preview-disabled') === '1';
control.removeAttribute('data-preview-disabled');
}
});
setPreviewControlState(locked);
}
function stopTemplatePreview(skipRender) {
if (previewState.stopTimer) {
window.clearTimeout(previewState.stopTimer);
previewState.stopTimer = 0;
}
previewState.token += 1;
previewState.active = false;
previewState.mode = '';
clearPreviewAnimations();
setTemplateEditorLocked(false);
if (!skipRender) {
renderOverlay();
}
}
function schedulePreviewStop(delayMs, token) {
if (previewState.stopTimer) {
window.clearTimeout(previewState.stopTimer);
}
previewState.stopTimer = window.setTimeout(function () {
if (token !== previewState.token) {
return;
}
stopTemplatePreview(false);
}, Math.max(0, Number(delayMs || 0)));
}
function animatePreviewRects(mode, regions) {
var rects = getPreviewRects();
var token = previewState.token;
var stagger = 90;
var duration = 900;
var overlayRect = getOverlayRect();
var canvasMovement = Math.max(12, Math.round(overlayRect.height * 0.02));
var longestTimingMs = 0;
previewState.animations = [];
rects.forEach(function (rect, index) {
var region = regions && regions[index] ? regions[index] : null;
var step = getPreviewRegionAnimationStep(region, mode);
var preset = normalizePreviewAnimation(step && step.preset);
var delay = index * stagger;
var animation = null;
var animateCssMode = isAnimateCssPreset(preset);
var timingMs = delay + getPreviewAnimationStepTimingMs(step, mode, animateCssMode);
rect.style.willChange = 'transform, opacity, filter';
if (timingMs > longestTimingMs) {
longestTimingMs = timingMs;
}
if (preset === 'none') {
return;
}
if (animateCssMode) {
applyAnimateCssPreview(rect, preset, step, mode);
return;
}
if (mode === 'intro') {
animation = rect.animate([
{ opacity: 0, transform: 'translate3d(0, ' + canvasMovement + 'px, 0) scale(0.96)', filter: 'blur(1px)' },
{ opacity: 1, transform: 'translate3d(0, 0, 0) scale(1)', filter: 'blur(0)' }
], {
duration: duration,
delay: delay,
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
fill: 'both'
});
} else if (mode === 'out') {
animation = rect.animate([
{ opacity: 1, transform: 'translate3d(0, 0, 0) scale(1)', filter: 'blur(0)' },
{ opacity: 0, transform: 'translate3d(0, -' + canvasMovement + 'px, 0) scale(0.96)', filter: 'blur(1px)' }
], {
duration: duration,
delay: delay,
easing: 'cubic-bezier(0.4, 0, 1, 1)',
fill: 'both'
});
}
if (animation) {
previewState.animations.push(animation);
}
});
schedulePreviewStop(longestTimingMs + 140, token);
}
function startTemplatePreview(mode) {
var previewMode = String(mode || '').trim().toLowerCase();
if (!previewMode) {
return;
}
var previewRegions = getCards().map(readCard);
stopTemplatePreview(true);
previewState.active = true;
previewState.mode = previewMode;
previewState.token += 1;
setTemplateEditorLocked(true);
if (!getPreviewRects().length) {
schedulePreviewStop(120, previewState.token);
return;
}
animatePreviewRects(previewMode, previewRegions);
}
function writeCard(card, values) {
if (utils.writeCard) {
utils.writeCard(card, values);
return;
}
if (values.region_name !== undefined) {
syncRegionIdentity(card, values.region_name);
} else if (values.region_key !== undefined) {
syncRegionIdentity(card, values.region_key);
} else if (values.label !== undefined) {
syncRegionIdentity(card, values.label);
}
if (values.region_type !== undefined) { card.querySelector('[name="region_type[]"]').value = values.region_type; }
if (values.lock_ratio !== undefined) { card.querySelector('[name="region_lock_ratio[]"]').value = normalizeLockRatio(values.lock_ratio); }
if (values.animation_json !== undefined) { writeAnimationConfig(card, values.animation_json); }
if (values.x !== undefined) { card.querySelector('[name="region_x[]"]').value = Math.round(values.x); }
if (values.y !== undefined) { card.querySelector('[name="region_y[]"]').value = Math.round(values.y); }
if (values.width !== undefined) { card.querySelector('[name="region_width[]"]').value = Math.round(values.width); }
if (values.height !== undefined) { card.querySelector('[name="region_height[]"]').value = Math.round(values.height); }
if (values.z_index !== undefined) { card.querySelector('[name="region_z[]"]').value = Math.round(values.z_index); }
}
function getOverlayRect() {
return utils.getOverlayRect ? utils.getOverlayRect(overlay) : overlay.getBoundingClientRect();
}
function toCanvasPoint(event) {
return utils.toCanvasPoint ? utils.toCanvasPoint(event, overlay, getCanvasSize()) : {
x: 0,
y: 0
};
}
function canvasRectToPixels(region) {
return utils.canvasRectToPixels ? utils.canvasRectToPixels(region, overlay, getCanvasSize()) : {
left: 0,
top: 0,
width: 0,
height: 0
};
}
function updateAspectRatio() {
var size = getCanvasSize();
stage.style.aspectRatio = size.width + ' / ' + size.height;
}
function updateCanvasSizeSummary() {
var option = canvasSizeSelect.options[canvasSizeSelect.selectedIndex];
canvasSizeSummary.textContent = option ? option.textContent : '';
}
function updateCanvasSizeLock() {
var lockOnExistingTemplate = canvasSizeSelect.dataset.lockOnExistingTemplate === 'true';
var locked = lockOnExistingTemplate && getCards().length > 0;
canvasSizeSelect.disabled = locked;
canvasSizeSummary.classList.toggle('is-locked', locked);
}
function syncCanvasSizeSelection() {
var option = canvasSizeSelect.options[canvasSizeSelect.selectedIndex];
if (!option) {
return;
}
if (canvasSizeIdInput) {
canvasSizeIdInput.value = option.value;
}
canvasWidthInput.value = Math.max(1, Number(option.dataset.width || canvasWidthInput.value || 1920));
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) {
if (!file) {
return;
}
if (removeBackgroundFlag) {
removeBackgroundFlag.checked = false;
}
var reader = new FileReader();
reader.onload = function () {
backgroundPreview.src = reader.result;
backgroundPreview.style.display = 'block';
backgroundEmpty.style.display = 'none';
};
reader.readAsDataURL(file);
}
function updateStageBackgroundColor() {
if (!stage) {
return;
}
stage.style.backgroundColor = backgroundColorInput && backgroundColorInput.value ? backgroundColorInput.value : '#111111';
}
function updateRegionLockBadge(card) {
var lockChip = card.querySelector('[data-region-lock-chip]');
if (!lockChip) {
return;
}
var lockRatio = normalizeLockRatio(card.querySelector('[name="region_lock_ratio[]"]').value);
if (!lockRatio) {
lockChip.hidden = true;
lockChip.textContent = '';
return;
}
lockChip.hidden = false;
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);
}
function isRegionLocked(card) {
return Boolean(getRegionLockRatio(card));
}
function syncLockedDimensions(card, changedField) {
normalizeRegionCard(card, changedField);
updateRegionLockBadge(card);
markTemplateFormDirty();
requestOverlayRender();
}
function getRegionChipLabel(regionType) {
if (window.pulseRegionTypes && typeof window.pulseRegionTypes.getRegionChipLabel === 'function') {
return window.pulseRegionTypes.getRegionChipLabel(regionType);
}
return 'Text';
}
function populateRegionCard(card, region) {
var chip = card.querySelector('[data-region-chip]');
var title = card.querySelector('[data-region-title]');
var nameInput = card.querySelector('[name="region_name[]"]');
var regionTypeInput = card.querySelector('[name="region_type[]"]');
var regionKeyInput = card.querySelector('[name="region_key[]"]');
var regionLabelInput = card.querySelector('[name="region_label[]"]');
if (title) {
title.textContent = region.label || region.region_key || 'Region';
}
if (chip) {
chip.textContent = getRegionChipLabel(region.region_type);
}
if (nameInput) {
nameInput.value = region.region_key || region.label || '';
}
if (regionTypeInput) {
regionTypeInput.value = region.region_type || 'text';
}
if (regionKeyInput) {
regionKeyInput.value = region.region_key || region.label || '';
}
if (regionLabelInput) {
regionLabelInput.value = region.label || region.region_key || '';
}
var regionLockRatioInput = card.querySelector('[name="region_lock_ratio[]"]');
if (regionLockRatioInput) {
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);
card.querySelector('[name="region_y[]"]').value = valueOrDefault(region.y, 80);
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);
}
function setRegionRemovalState(card) {
var removeButton = card.querySelector('[data-region-remove-button]');
if (!removeButton) {
return;
}
var regionKey = String(card.dataset.regionKey || getRegionName(card) || '').trim();
var regionDeletionLocked = regionUsageSet.has(regionKey);
removeButton.disabled = regionDeletionLocked;
if (regionDeletionLocked) {
removeButton.title = 'Regions cannot be deleted while this template is used by slides.';
} else {
removeButton.removeAttribute('title');
}
}
function updateRegionLabel(card) {
var label = getRegionName(card) || 'Region';
var cards = getCards();
var index = cards.indexOf(card);
var title = card.querySelector('.template-field-head strong');
if (title) {
title.textContent = label;
}
if (index >= 0 && regionSelect.options[index]) {
regionSelect.options[index].textContent = label;
}
}
function makeRegionCard(region) {
var card;
if (regionCardTemplate && regionCardTemplate.content) {
card = regionCardTemplate.content.firstElementChild.cloneNode(true);
} else {
card = document.createElement('div');
card.className = 'card card-outline card-secondary admin-form-card region-item mb-3';
}
populateRegionCard(card, region);
card.dataset.regionKey = String(region.region_key || region.label || getRegionName(card) || '').trim();
setRegionRemovalState(card);
var nameInput = card.querySelector('[name="region_name[]"]');
var lockRatioInput = card.querySelector('[name="region_lock_ratio[]"]');
var widthInput = card.querySelector('[name="region_width[]"]');
var heightInput = card.querySelector('[name="region_height[]"]');
var animationPresetInputs = card.querySelectorAll('[data-animation-preset]');
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();
renderRegionSidebar();
renderOverlay();
});
Array.prototype.forEach.call(animationPresetInputs, function (input) {
input.addEventListener('change', function () {
setAnimationPreset(card, input.getAttribute('data-animation-preset'), input.value);
markTemplateFormDirty();
requestOverlayRender();
});
});
Array.prototype.forEach.call(animationResetButtons, function (button) {
button.addEventListener('click', function (event) {
event.preventDefault();
setAnimationPreset(card, button.getAttribute('data-animation-reset-button'), 'none');
markTemplateFormDirty();
requestOverlayRender();
});
});
if (animationAdvancedButton && animationAdvancedModal) {
animationAdvancedButton.addEventListener('click', function () {
activeAnimationCard = card;
populateAnimationModal(card);
if (window.pulseModal) {
window.pulseModal.show(animationAdvancedModal);
}
});
}
if (lockRatioInput) {
lockRatioInput.addEventListener('input', function () {
syncLockedDimensions(card, 'width');
});
}
if (widthInput) {
widthInput.addEventListener('input', function () {
syncLockedDimensions(card, 'width');
});
}
if (heightInput) {
heightInput.addEventListener('input', function () {
syncLockedDimensions(card, 'height');
});
}
card.addEventListener('click', function (event) {
if (event.target && event.target.closest && event.target.closest('[data-region-remove-button]')) {
return;
}
setSelected(getCards().indexOf(card));
});
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) {
selectedIndex = getCards().length - 1;
}
renderRegionSidebar();
renderOverlay();
});
return card;
}
function renderRegionList(initialRegions) {
regionList.innerHTML = '';
initialRegions.forEach(function (region) {
regionList.appendChild(makeRegionCard(region));
});
}
function renderRegionSidebar() {
var cards = getCards();
regionSelect.innerHTML = '';
if (!cards.length) {
regionSelect.disabled = true;
regionList.innerHTML = '';
updateCanvasSizeLock();
return;
}
regionSelect.disabled = false;
if (selectedIndex < 0 || selectedIndex >= cards.length) {
selectedIndex = 0;
}
cards.forEach(function (card, index) {
var option = document.createElement('option');
option.value = String(index);
option.textContent = getRegionName(card) || ('Region ' + (index + 1));
if (index === selectedIndex) {
option.selected = true;
}
regionSelect.appendChild(option);
card.hidden = index !== selectedIndex;
});
regionSelect.value = String(selectedIndex);
updateCanvasSizeLock();
validateRegionNames();
updatePreviewCardVisibility();
}
function renderOverlay() {
if (previewState.active) {
return;
}
var cards = getCards();
var selectedCard = selectedIndex >= 0 ? cards[selectedIndex] : null;
var selectedNow = selectedCard ? cards.indexOf(selectedCard) : -1;
var regions = cards.map(readCard);
var canvasSize = getCanvasSize();
var overlayRect = getOverlayRect();
var overlayScale = overlayRect.width / Math.max(1, canvasSize.width);
overlay.style.setProperty('--designer-overlay-scale', String(overlayScale));
regionsJsonInput.value = JSON.stringify(regions);
overlay.innerHTML = regions.map(function (region, index) {
var box = canvasRectToPixels(region);
var renderedBox = {
left: Math.round(box.left),
top: Math.round(box.top),
width: Math.round(box.width),
height: Math.round(box.height)
};
var selected = index === selectedNow ? ' selected' : '';
return '<div class="designer-rect' + selected + '" data-index="' + index + '" style="--designer-overlay-scale:1;left:' + renderedBox.left + 'px;top:' + renderedBox.top + 'px;width:' + renderedBox.width + 'px;height:' + renderedBox.height + 'px;"><div class="designer-rect-label">' + escapeHtml(region.label || region.region_key || 'Region') + '</div><span class="resize-handle nw" data-dir="nw"></span><span class="resize-handle ne" data-dir="ne"></span><span class="resize-handle sw" data-dir="sw"></span><span class="resize-handle se" data-dir="se"></span></div>';
}).join('');
if (draft) {
var rect = overlayRect;
var size = canvasSize;
var draftBox = { x: Math.min(draft.start.x, draft.end.x), y: Math.min(draft.start.y, draft.end.y), width: Math.abs(draft.end.x - draft.start.x), height: Math.abs(draft.end.y - draft.start.y) };
overlay.innerHTML += '<div class="designer-rect designer-draft" style="left:' + Math.round((draftBox.x / size.width) * rect.width) + 'px;top:' + Math.round((draftBox.y / size.height) * rect.height) + 'px;width:' + Math.round((draftBox.width / size.width) * rect.width) + 'px;height:' + Math.round((draftBox.height / size.height) * rect.height) + 'px;"></div>';
}
updatePreviewControlState();
}
function requestOverlayRender() {
if (previewState.active) {
return;
}
if (overlayRenderFrame) {
return;
}
overlayRenderFrame = window.requestAnimationFrame(function () {
overlayRenderFrame = 0;
renderOverlay();
});
}
function render() {
updateAspectRatio();
renderRegionSidebar();
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;
}
var cards = getCards();
if (!cards.length) {
selectedIndex = -1;
} else if (index < 0) {
selectedIndex = 0;
} else {
selectedIndex = clamp(index, 0, cards.length - 1);
}
renderRegionSidebar();
renderOverlay();
}
function addRegion(region) {
if (previewState.active) {
return;
}
var hint = regionList.querySelector('.muted');
if (hint) {
hint.remove();
}
var card = makeRegionCard(region);
setRegionRemovalState(card);
regionList.appendChild(card);
markTemplateFormDirty();
setSelected(getCards().length - 1);
}
function openAddRegionModal() {
if (!window.pulseModal) {
return;
}
window.pulseModal.show(regionAddModal);
}
function createDefaultRegion(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,
region_type: type,
animation_json: normalizeAnimationConfig('{}'),
x: 80,
y: 80,
width: size.width,
height: size.height,
lock_ratio: lockRatio,
z_index: 1
};
}
function clampRegion(region) {
var size = getCanvasSize();
var minSize = 12;
var x = clamp(region.x, 0, size.width - minSize);
var y = clamp(region.y, 0, size.height - minSize);
var width = Math.max(minSize, region.width);
var height = Math.max(minSize, region.height);
if (x + width > size.width) {
width = size.width - x;
}
if (y + height > size.height) {
height = size.height - y;
}
return { x: Math.round(x), y: Math.round(y), width: Math.round(Math.max(minSize, width)), height: Math.round(Math.max(minSize, height)) };
}
function startDraw(event) {
if (previewState.active) {
return;
}
var start = toCanvasPoint(event);
draft = { start: start, end: start };
renderOverlay();
function moveHandler(moveEvent) {
draft.end = toCanvasPoint(moveEvent);
requestOverlayRender();
}
function upHandler(upEvent) {
draft.end = toCanvasPoint(upEvent);
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 = 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();
document.removeEventListener('mousemove', moveHandler);
document.removeEventListener('mouseup', upHandler);
}
document.addEventListener('mousemove', moveHandler);
document.addEventListener('mouseup', upHandler);
}
function startMove(index, event) {
if (previewState.active) {
return;
}
var startPoint = toCanvasPoint(event);
var startRegion = readCard(cardAt(index));
function moveHandler(moveEvent) {
var currentPoint = toCanvasPoint(moveEvent);
var dx = currentPoint.x - startPoint.x;
var dy = currentPoint.y - startPoint.y;
var size = getCanvasSize();
var maxX = Math.max(0, size.width - startRegion.width);
var maxY = Math.max(0, size.height - startRegion.height);
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() {
document.removeEventListener('mousemove', moveHandler);
document.removeEventListener('mouseup', upHandler);
}
document.addEventListener('mousemove', moveHandler);
document.addEventListener('mouseup', upHandler);
}
function resizeFromHandle(index, dir, event) {
if (previewState.active) {
return;
}
var startPoint = toCanvasPoint(event);
var startRegion = readCard(cardAt(index));
var lockRatio = normalizeLockRatio(startRegion.lock_ratio);
var aspect = lockRatio ? parseLockRatio(lockRatio) : null;
var canvasSize = getCanvasSize();
var minSize = 12;
function clampResizeDelta(dx, dy) {
if (dir.indexOf('w') !== -1) {
dx = clamp(dx, -startRegion.x, startRegion.width - minSize);
}
if (dir.indexOf('e') !== -1) {
dx = clamp(dx, minSize - startRegion.width, canvasSize.width - startRegion.x - startRegion.width);
}
if (dir.indexOf('n') !== -1) {
dy = clamp(dy, -startRegion.y, startRegion.height - minSize);
}
if (dir.indexOf('s') !== -1) {
dy = clamp(dy, minSize - startRegion.height, canvasSize.height - startRegion.y - startRegion.height);
}
return { dx: dx, dy: dy };
}
function fitFromWidth(width) {
var nextWidth = Math.max(12, Math.round(width));
return {
width: nextWidth,
height: aspect ? Math.max(12, Math.round(nextWidth / aspect.ratio)) : startRegion.height
};
}
function fitFromHeight(height) {
var nextHeight = Math.max(12, Math.round(height));
return {
width: aspect ? Math.max(12, Math.round(nextHeight * aspect.ratio)) : startRegion.width,
height: nextHeight
};
}
function moveHandler(moveEvent) {
var currentPoint = toCanvasPoint(moveEvent);
var dx = currentPoint.x - startPoint.x;
var dy = currentPoint.y - startPoint.y;
var constrainedDelta = clampResizeDelta(dx, dy);
dx = constrainedDelta.dx;
dy = constrainedDelta.dy;
var next = { x: startRegion.x, y: startRegion.y, width: startRegion.width, height: startRegion.height };
if (aspect) {
if (dir === 'e') {
var eastSize = fitFromWidth(startRegion.width + dx);
next.width = eastSize.width;
next.height = eastSize.height;
next.y = startRegion.y + Math.round((startRegion.height - next.height) / 2);
} else if (dir === 'w') {
var westSize = fitFromWidth(startRegion.width - dx);
next.width = westSize.width;
next.height = westSize.height;
next.x = startRegion.x + startRegion.width - next.width;
next.y = startRegion.y + Math.round((startRegion.height - next.height) / 2);
} else if (dir === 'n') {
var northSize = fitFromHeight(startRegion.height - dy);
next.width = northSize.width;
next.height = northSize.height;
next.x = startRegion.x + Math.round((startRegion.width - next.width) / 2);
next.y = startRegion.y + startRegion.height - next.height;
} else if (dir === 's') {
var southSize = fitFromHeight(startRegion.height + dy);
next.width = southSize.width;
next.height = southSize.height;
next.x = startRegion.x + Math.round((startRegion.width - next.width) / 2);
} else {
var useWidth = Math.abs(dx) >= Math.abs(dy * aspect.ratio);
var cornerSize = useWidth ? fitFromWidth(dir.indexOf('w') !== -1 ? startRegion.width - dx : startRegion.width + dx) : fitFromHeight(dir.indexOf('n') !== -1 ? startRegion.height - dy : startRegion.height + dy);
next.width = cornerSize.width;
next.height = cornerSize.height;
if (dir.indexOf('w') !== -1) { next.x = startRegion.x + startRegion.width - next.width; }
if (dir.indexOf('n') !== -1) { next.y = startRegion.y + startRegion.height - next.height; }
if (dir.indexOf('e') !== -1) { next.x = startRegion.x; }
if (dir.indexOf('s') !== -1) { next.y = startRegion.y; }
}
} else {
if (dir.indexOf('w') !== -1) { next.x = startRegion.x + dx; next.width = startRegion.width - dx; }
if (dir.indexOf('e') !== -1) { next.width = startRegion.width + dx; }
if (dir.indexOf('n') !== -1) { next.y = startRegion.y + dy; next.height = startRegion.height - dy; }
if (dir.indexOf('s') !== -1) { next.height = startRegion.height + dy; }
}
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 = fitRegionToCanvas(next, aspect);
writeCard(cardAt(index), { x: next.x, y: next.y, width: next.width, height: next.height });
markTemplateFormDirty();
requestOverlayRender();
}
function upHandler() {
document.removeEventListener('mousemove', moveHandler);
document.removeEventListener('mouseup', upHandler);
}
document.addEventListener('mousemove', moveHandler);
document.addEventListener('mouseup', upHandler);
}
if (addRegionButton && regionAddModal) {
addRegionButton.addEventListener('click', function () {
renderAddRegionOptions();
openAddRegionModal();
});
if (regionAddOptions) {
regionAddOptions.addEventListener('click', function (event) {
var button = event.target && event.target.closest ? event.target.closest('[data-add-region-type]') : null;
if (!button) {
return;
}
var regionType = button.getAttribute('data-add-region-type');
addRegion(createDefaultRegion(regionType));
if (window.pulseModal) {
window.pulseModal.hide(regionAddModal);
}
});
}
renderAddRegionOptions();
}
backgroundInput.addEventListener('change', function () {
var file = backgroundInput.files && backgroundInput.files[0];
if (file) {
updateBackgroundPreview(file);
}
});
if (removeBackgroundButton && removeBackgroundFlag) {
removeBackgroundButton.addEventListener('click', function () {
removeBackgroundFlag.checked = true;
backgroundInput.value = '';
backgroundPreview.removeAttribute('src');
backgroundPreview.style.display = 'none';
backgroundEmpty.style.display = 'block';
});
}
if (animationAdvancedModal) {
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);
}
});
}
if (animationModalResetButton) {
animationModalResetButton.addEventListener('click', function () {
if (!activeAnimationCard) {
return;
}
writeAnimationConfig(activeAnimationCard, normalizeAnimationConfig('{}'));
markTemplateFormDirty();
populateAnimationModal(activeAnimationCard);
requestOverlayRender();
});
}
}
if (backgroundColorInput) {
backgroundColorInput.addEventListener('input', updateStageBackgroundColor);
}
canvasSizeSelect.addEventListener('change', function () { syncCanvasSizeSelection(); render(); });
canvasWidthInput.addEventListener('input', render);
canvasHeightInput.addEventListener('input', render);
regionSelect.addEventListener('change', function () { setSelected(Number(regionSelect.value || 0)); });
overlay.addEventListener('mousedown', function (event) {
var rect = event.target.closest('.designer-rect');
if (rect) {
var index = Number(rect.getAttribute('data-index'));
var handle = event.target.closest('.resize-handle');
event.preventDefault();
setSelected(index);
if (handle) {
resizeFromHandle(index, handle.getAttribute('data-dir'), event);
} else {
startMove(index, event);
}
return;
}
if (event.target !== overlay && !event.target.classList.contains('designer-overlay')) {
return;
}
event.preventDefault();
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();
return;
}
if (!validateRegionNames()) {
event.preventDefault();
return;
}
getCards().forEach(function (card) {
normalizeRegionCard(card, 'width');
});
syncCanvasSizeSelection();
event.formData.set('regions_json', JSON.stringify(getCards().map(readCard)));
});
templateForm.addEventListener('submit', function (event) {
if (previewState.active) {
return;
}
if (!validateRegionNames()) {
return;
}
getCards().forEach(function (card) {
normalizeRegionCard(card, 'width');
});
syncCanvasSizeSelection();
regionsJsonInput.value = JSON.stringify(getCards().map(readCard));
});
}
if (previewIntroButton) {
previewIntroButton.addEventListener('click', function () {
if (previewState.active && previewState.mode === 'intro') {
stopTemplatePreview(false);
return;
}
startTemplatePreview('intro');
});
}
if (previewOutButton) {
previewOutButton.addEventListener('click', function () {
if (previewState.active && previewState.mode === 'out') {
stopTemplatePreview(false);
return;
}
startTemplatePreview('out');
});
}
if (previewContinuousButton) {
previewContinuousButton.addEventListener('click', function () {
if (previewState.active && previewState.mode === 'continuous') {
stopTemplatePreview(false);
return;
}
startTemplatePreview('continuous');
});
}
renderRegionList(existingRegions);
syncCanvasSizeSelection();
updateStageBackgroundColor();
observeStageResize();
render();
})();