Add template animation editor

This commit is contained in:
2026-08-03 17:27:42 +01:00
parent e95928f31c
commit ed8d51580e
35 changed files with 3173 additions and 44 deletions
+1
View File
@@ -160,6 +160,7 @@ function registerPartials(Handlebars, viewsRoot) {
Handlebars.registerPartial('playlists/form', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'form.hbs'), 'utf8'));
Handlebars.registerPartial('signage/playlists/slide-row', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'slide-row.hbs'), 'utf8'));
Handlebars.registerPartial('signage/playlists/schedule-rule-card', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'schedule-rule-card.hbs'), 'utf8'));
Handlebars.registerPartial('signage/templates/animation-advanced-modal', fs.readFileSync(path.join(viewsRoot, 'signage', 'templates', 'animation-advanced-modal.hbs'), 'utf8'));
Handlebars.registerPartial('data-sources/api-sources/form', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'api-sources', 'form.hbs'), 'utf8'));
Handlebars.registerPartial('data-sources/rss-feeds/form', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'rss-feeds', 'form.hbs'), 'utf8'));
}
+102 -14
View File
@@ -190,6 +190,82 @@
max-width: 14rem;
}
.template-preview-card .card-body {
gap: 0.75rem 1rem;
}
.template-preview-card.is-previewing {
border-color: rgba(var(--bs-primary-rgb), 0.45);
}
.template-preview-card.is-previewing .card-header {
position: relative;
}
.template-preview-card.is-previewing .card-title {
position: relative;
padding-right: 6.25rem;
}
.template-preview-card.is-previewing .card-title::after {
content: ' previewing';
position: absolute;
top: 50%;
left: calc(100% + 0.35rem);
color: var(--bs-primary);
font-size: 0.8em;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
transform: translateY(-50%);
}
.template-preview-card .btn-group {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem;
}
.template-preview-card .btn-group .btn {
width: 100%;
}
.template-preview-card[hidden] {
display: none !important;
}
.template-designer-form--previewing .designer-stage {
box-shadow: none !important;
overflow: hidden;
}
.template-designer-form--previewing .designer-overlay {
cursor: progress;
}
.template-designer-form--previewing .designer-rect {
transition: none !important;
}
.template-designer-form--previewing .resize-handle {
display: none;
}
.template-designer-form--previewing .region-item,
.template-designer-form--previewing .template-details-card,
.template-designer-form--previewing .template-options-card,
.template-designer-form--previewing .region-info-card {
opacity: 0.84;
}
.template-designer-form--previewing .region-item [disabled],
.template-designer-form--previewing .template-details-card [disabled],
.template-designer-form--previewing .template-options-card [disabled],
.template-designer-form--previewing .region-info-card [disabled] {
cursor: not-allowed;
}
.announcement-icon-picker {
position: relative;
}
@@ -1401,7 +1477,7 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
position: relative;
width: 100%;
min-height: 18rem;
/* overflow: hidden; */
overflow: visible;
border: 1px solid var(--bs-border-color);
border-radius: 0;
background: var(--bs-body-bg);
@@ -1431,11 +1507,16 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
.designer-overlay {
z-index: 2;
cursor: default;
--designer-overlay-scale: 1;
}
.designer-rect {
position: absolute;
border: 2px solid rgba(13, 110, 253, 0.95);
overflow: visible;
z-index: 3;
border: 0;
outline: 2px solid rgba(13, 110, 253, 0.95);
outline-offset: -2px;
background: rgba(13, 110, 253, 0.12);
box-sizing: border-box;
border-radius: 0;
@@ -1445,20 +1526,20 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
}
.designer-rect.selected {
border-color: rgba(25, 135, 84, 1);
outline-color: rgba(25, 135, 84, 1);
background: rgba(25, 135, 84, 0.14);
}
.designer-rect-label {
position: absolute;
left: 0.4rem;
top: 0.35rem;
left: calc(6px * var(--designer-overlay-scale, 1));
top: calc(6px * var(--designer-overlay-scale, 1));
max-width: 100%;
padding: 0.15rem 0.45rem;
padding: calc(2px * var(--designer-overlay-scale, 1)) calc(7px * var(--designer-overlay-scale, 1));
border-radius: 999px;
background: rgba(33, 37, 41, 0.92);
color: #fff;
font-size: 0.75rem;
font-size: calc(0.75rem * var(--designer-overlay-scale, 1));
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
@@ -1471,8 +1552,10 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
.resize-handle {
position: absolute;
width: 0.8rem;
height: 0.8rem;
z-index: 4;
width: 12px;
height: 12px;
box-sizing: border-box;
border-radius: 999px;
border: 2px solid #fff;
background: rgba(13, 110, 253, 1);
@@ -1482,28 +1565,28 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
.resize-handle.nw {
left: 0;
top: 0;
transform: translate(-50%, -50%);
transform: translate(-40%, -40%);
cursor: nwse-resize;
}
.resize-handle.ne {
right: 0;
top: 0;
transform: translate(50%, -50%);
transform: translate(40%, -40%);
cursor: nesw-resize;
}
.resize-handle.sw {
left: 0;
bottom: 0;
transform: translate(-50%, 50%);
transform: translate(-40%, 40%);
cursor: nesw-resize;
}
.resize-handle.se {
right: 0;
bottom: 0;
transform: translate(50%, 50%);
transform: translate(40%, 40%);
cursor: nwse-resize;
}
@@ -1641,6 +1724,10 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.region-field-grid--animation {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.region-field-grid > label {
display: grid;
gap: 0.35rem;
@@ -1877,7 +1964,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
.region-field-grid--identity,
.region-field-grid--position,
.region-field-grid--size {
.region-field-grid--size,
.region-field-grid--animation {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,203 @@
(function (root, factory) {
var presets = factory();
if (typeof module === 'object' && module.exports) {
module.exports = presets;
}
if (root) {
root.templateAnimationPresets = presets;
}
})(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : this), function () {
function option(value, label) {
return { value: value, label: label };
}
function formatLabel(value) {
return String(value || '')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/\b\w/g, function (letter) {
return letter.toUpperCase();
});
}
function buildAllValues(groups, extraValues) {
var values = [];
var seen = Object.create(null);
function addEntry(entry) {
if (!entry || typeof entry !== 'object' || !entry.value || seen[entry.value]) {
return;
}
seen[entry.value] = true;
values.push(entry.value);
}
Object.keys(groups).forEach(function (groupName) {
(groups[groupName] || []).forEach(function (entry) {
if (Array.isArray(entry)) {
entry.forEach(addEntry);
return;
}
addEntry(entry);
});
});
(extraValues || []).forEach(function (value) {
if (!value || seen[value]) {
return;
}
seen[value] = true;
values.push(value);
});
return values;
}
function buildLookup(values) {
var lookup = Object.create(null);
values.forEach(function (value) {
var key = String(value || '').trim().toLowerCase();
if (key && !lookup[key]) {
lookup[key] = value;
}
});
return lookup;
}
function normalizeValue(value, lookup) {
var raw = String(value || '').trim();
if (!raw) {
return 'none';
}
return lookup[raw.toLowerCase()] || 'none';
}
function splitAnimateCssValues(values) {
var groups = {
intro: [],
outro: [],
attentionSeekers: []
};
(values || []).forEach(function (value) {
if (!value || value === 'none') {
return;
}
if (value.indexOf('In') !== -1) {
groups.intro.push(value);
return;
}
if (value.indexOf('Out') !== -1) {
groups.outro.push(value);
return;
}
groups.attentionSeekers.push(value);
});
return groups;
}
function buildCuratedBasic(values) {
return [option('none', 'None')].concat(values.map(function (value) {
return option(value, formatLabel(value));
}));
}
var animateCssValues = [
'none',
'bounce', 'flash', 'pulse', 'rubberBand', 'shakeX', 'shakeY', 'headShake', 'swing', 'tada', 'wobble', 'jello', 'heartBeat',
'backInDown', 'backInLeft', 'backInRight', 'backInUp',
'backOutDown', 'backOutLeft', 'backOutRight', 'backOutUp',
'bounceIn', 'bounceInDown', 'bounceInLeft', 'bounceInRight', 'bounceInUp',
'bounceOut', 'bounceOutDown', 'bounceOutLeft', 'bounceOutRight', 'bounceOutUp',
'fadeIn', 'fadeInDown', 'fadeInDownBig', 'fadeInLeft', 'fadeInLeftBig', 'fadeInRight', 'fadeInRightBig', 'fadeInUp', 'fadeInUpBig', 'fadeInTopLeft', 'fadeInTopRight', 'fadeInBottomLeft', 'fadeInBottomRight',
'fadeOut', 'fadeOutDown', 'fadeOutDownBig', 'fadeOutLeft', 'fadeOutLeftBig', 'fadeOutRight', 'fadeOutRightBig', 'fadeOutUp', 'fadeOutUpBig', 'fadeOutTopLeft', 'fadeOutTopRight', 'fadeOutBottomLeft', 'fadeOutBottomRight',
'flip', 'flipInX', 'flipInY', 'flipOutX', 'flipOutY',
'lightSpeedInLeft', 'lightSpeedInRight', 'lightSpeedOutLeft', 'lightSpeedOutRight',
'rotateIn', 'rotateInDownLeft', 'rotateInDownRight', 'rotateInUpLeft', 'rotateInUpRight',
'rotateOut', 'rotateOutDownLeft', 'rotateOutDownRight', 'rotateOutUpLeft', 'rotateOutUpRight',
'hinge', 'jackInTheBox', 'rollIn', 'rollOut',
'zoomIn', 'zoomInDown', 'zoomInLeft', 'zoomInRight', 'zoomInUp',
'zoomOut', 'zoomOutDown', 'zoomOutLeft', 'zoomOutRight', 'zoomOutUp',
'slideInDown', 'slideInLeft', 'slideInRight', 'slideInUp',
'slideOutDown', 'slideOutLeft', 'slideOutRight', 'slideOutUp'
];
var splitValues = splitAnimateCssValues(animateCssValues);
var basic = {
intro: [
option('none', 'None'),
option('fadeIn', formatLabel('fadeIn')),
option('fadeInDown', formatLabel('fadeInDown')),
option('fadeInLeft', formatLabel('fadeInLeft')),
option('fadeInRight', formatLabel('fadeInRight')),
option('fadeInUp', formatLabel('fadeInUp')),
option('zoomIn', formatLabel('zoomIn')),
option('zoomInDown', formatLabel('zoomInDown')),
option('zoomInLeft', formatLabel('zoomInLeft')),
option('zoomInRight', formatLabel('zoomInRight')),
option('zoomInUp', formatLabel('zoomInUp')),
option('slideInDown', formatLabel('slideInDown')),
option('slideInLeft', formatLabel('slideInLeft')),
option('slideInRight', formatLabel('slideInRight')),
option('slideInUp', formatLabel('slideInUp'))
],
outro: [
option('none', 'None'),
option('fadeOut', formatLabel('fadeOut')),
option('fadeOutDown', formatLabel('fadeOutDown')),
option('fadeOutLeft', formatLabel('fadeOutLeft')),
option('fadeOutRight', formatLabel('fadeOutRight')),
option('fadeOutUp', formatLabel('fadeOutUp')),
option('zoomOut', formatLabel('zoomOut')),
option('zoomOutDown', formatLabel('zoomOutDown')),
option('zoomOutLeft', formatLabel('zoomOutLeft')),
option('zoomOutRight', formatLabel('zoomOutRight')),
option('zoomOutUp', formatLabel('zoomOutUp')),
option('slideOutDown', formatLabel('slideOutDown')),
option('slideOutLeft', formatLabel('slideOutLeft')),
option('slideOutRight', formatLabel('slideOutRight')),
option('slideOutUp', formatLabel('slideOutUp'))
],
attentionSeekers: buildCuratedBasic(['flash', 'pulse', 'rubberBand', 'shakeX', 'shakeY', 'swing', 'tada', 'wobble', 'jello', 'heartBeat', 'bounce'])
};
var advanced = {
intro: buildCuratedBasic(splitValues.intro),
outro: buildCuratedBasic(splitValues.outro),
attentionSeekers: buildCuratedBasic(splitValues.attentionSeekers)
};
var lookup = buildLookup(buildAllValues({ basic: [basic.intro, basic.outro, basic.attentionSeekers], advanced: [advanced.intro, advanced.outro, advanced.attentionSeekers] }));
function normalize(value) {
return normalizeValue(value, lookup);
}
function isAnimateCssPreset(value) {
return animateCssValues.indexOf(normalizeValue(value, buildLookup(animateCssValues))) !== -1;
}
return {
basic: basic,
advanced: advanced,
animateCssValues: animateCssValues,
allValues: buildAllValues({ basic: [basic.intro, basic.outro, basic.attentionSeekers], advanced: [advanced.intro, advanced.outro, advanced.attentionSeekers] }),
normalize: normalize,
isAnimateCssPreset: isAnimateCssPreset
};
});
@@ -82,6 +82,174 @@
return String(card.querySelector('[name="region_name[]"]').value || '').trim();
}
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 getAnimationPresetValues() {
var presets = window.templateAnimationPresets || {};
if (Array.isArray(presets.allValues) && presets.allValues.length) {
return presets.allValues;
}
return [
'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'
];
}
function getAnimationPresetMap() {
var lookup = {};
getAnimationPresetValues().forEach(function (value) {
lookup[value] = true;
});
return lookup;
}
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);
}
return normalized;
}
function normalizePreviewAnimation(value) {
if (window.templateAnimationPresets && typeof window.templateAnimationPresets.normalize === 'function') {
return window.templateAnimationPresets.normalize(value);
}
var allowed = getAnimationPresetMap();
var raw = String(value || '').trim().toLowerCase();
return allowed[raw] ? raw : 'none';
}
function 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.round(Number(value.iterations)) : null;
normalized.easing = String(value.easing || '').trim();
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,
easing: '',
fill_mode: ''
};
}
function 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 formatAnimationConfig(value) {
return JSON.stringify(normalizeAnimationConfig(value), null, 2);
}
function syncRegionIdentity(card, value) {
var next = String(value || '').trim();
card.querySelector('[name="region_name[]"]').value = next;
@@ -96,6 +264,7 @@
label: name,
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),
@@ -114,6 +283,7 @@
}
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); }
@@ -171,6 +341,10 @@
syncRegionIdentity: syncRegionIdentity,
readCard: readCard,
writeCard: writeCard,
normalizeAnimationConfig: normalizeAnimationConfig,
formatAnimationConfig: formatAnimationConfig,
getAnimationConfig: getAnimationConfig,
writeAnimationConfig: writeAnimationConfig,
clampRegion: clampRegion,
getOverlayRect: getOverlayRect,
toCanvasPoint: toCanvasPoint,
@@ -20,6 +20,10 @@
: (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');
@@ -36,6 +40,7 @@
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');
@@ -43,7 +48,20 @@
var regionUsageSet = new Set();
var draft = null;
var selectedIndex = -1;
var activeAnimationCard = null;
var overlayRenderFrame = 0;
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) || '[]');
@@ -218,6 +236,7 @@
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),
@@ -226,6 +245,582 @@
};
}
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 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('active', previewState.active && activeMode === entry.mode);
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));
}
}
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.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 = step.duration_ms === null || step.duration_ms === undefined || Number(step.duration_ms) <= 0 ? '1000' : String(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); }
}
});
}
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 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 = readNumberField(durationInput ? durationInput.value : '') || 1000;
nextStep.delay_ms = readNumberField(delayInput ? delayInput.value : '') || 0;
if (stepName === 'loop') {
var iterationsInput = getAnimationModalField(stepName, 'iterations');
nextStep.iterations = 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);
@@ -240,6 +835,7 @@
}
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); }
@@ -401,6 +997,7 @@
if (regionLockRatioInput) {
regionLockRatioInput.value = normalizeLockRatio(region.lock_ratio);
}
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);
@@ -452,6 +1049,9 @@
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 () {
syncRegionIdentity(card, nameInput.value);
updateRegionLabel(card);
@@ -459,6 +1059,28 @@
renderRegionSidebar();
renderOverlay();
});
Array.prototype.forEach.call(animationPresetInputs, function (input) {
input.addEventListener('change', function () {
setAnimationPreset(card, input.getAttribute('data-animation-preset'), input.value);
requestOverlayRender();
});
});
Array.prototype.forEach.call(animationResetButtons, function (button) {
button.addEventListener('click', function (event) {
event.preventDefault();
setAnimationPreset(card, button.getAttribute('data-animation-reset-button'), 'none');
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 () {
updateRegionLockBadge(card);
@@ -536,28 +1158,49 @@
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="left:' + box.left + 'px;top:' + box.top + 'px;width:' + box.width + 'px;height:' + box.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>';
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 = getOverlayRect();
var size = getCanvasSize();
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:' + ((draftBox.x / size.width) * rect.width) + 'px;top:' + ((draftBox.y / size.height) * rect.height) + 'px;width:' + ((draftBox.width / size.width) * rect.width) + 'px;height:' + ((draftBox.height / size.height) * rect.height) + 'px;"></div>';
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;
}
@@ -575,6 +1218,10 @@
}
function setSelected(index) {
if (previewState.active) {
return;
}
var cards = getCards();
if (!cards.length) {
selectedIndex = -1;
@@ -588,6 +1235,10 @@
}
function addRegion(region) {
if (previewState.active) {
return;
}
var hint = regionList.querySelector('.muted');
if (hint) {
hint.remove();
@@ -613,6 +1264,7 @@
region_key: name,
label: name,
region_type: type,
animation_json: normalizeAnimationConfig('{}'),
x: 80,
y: 80,
width: size.width,
@@ -639,6 +1291,10 @@
}
function startDraw(event) {
if (previewState.active) {
return;
}
var start = toCanvasPoint(event);
draft = { start: start, end: start };
renderOverlay();
@@ -664,6 +1320,10 @@
}
function startMove(index, event) {
if (previewState.active) {
return;
}
var startPoint = toCanvasPoint(event);
var startRegion = readCard(cardAt(index));
function moveHandler(moveEvent) {
@@ -687,6 +1347,10 @@
}
function resizeFromHandle(index, dir, event) {
if (previewState.active) {
return;
}
var startPoint = toCanvasPoint(event);
var startRegion = readCard(cardAt(index));
var lockRatio = normalizeLockRatio(startRegion.lock_ratio);
@@ -824,6 +1488,31 @@
backgroundEmpty.style.display = 'block';
});
}
if (animationAdvancedModal) {
animationAdvancedModal.addEventListener('hidden.bs.modal', function () {
activeAnimationCard = null;
});
var animationModalApplyButton = animationAdvancedModal.querySelector('[data-animation-modal-apply]');
var animationModalResetButton = animationAdvancedModal.querySelector('[data-animation-modal-reset]');
if (animationModalApplyButton) {
animationModalApplyButton.addEventListener('click', function () {
saveAnimationModal();
if (window.pulseModal) {
window.pulseModal.hide(animationAdvancedModal);
}
});
}
if (animationModalResetButton) {
animationModalResetButton.addEventListener('click', function () {
if (!activeAnimationCard) {
return;
}
writeAnimationConfig(activeAnimationCard, normalizeAnimationConfig('{}'));
populateAnimationModal(activeAnimationCard);
requestOverlayRender();
});
}
}
if (backgroundColorInput) {
backgroundColorInput.addEventListener('input', updateStageBackgroundColor);
}
@@ -853,6 +1542,11 @@
});
if (templateForm) {
templateForm.addEventListener('formdata', function (event) {
if (previewState.active) {
event.preventDefault();
return;
}
if (!validateRegionNames()) {
event.preventDefault();
return;
@@ -868,6 +1562,10 @@
});
templateForm.addEventListener('submit', function () {
if (previewState.active) {
return;
}
if (!validateRegionNames()) {
return;
}
@@ -882,6 +1580,36 @@
});
}
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();
File diff suppressed because one or more lines are too long
+4 -4
View File
@@ -501,8 +501,8 @@ module.exports = function registerContentRoutes(app, deps) {
for (let i = 0; i < payload.regions.length; i += 1) {
const region = payload.regions[i];
await pool.query(
'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[result.insertId, region.region_key, region.region_type, region.label, region.lock_ratio, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, animation_json, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[result.insertId, region.region_key, region.region_type, region.label, region.lock_ratio, JSON.stringify(region.animation_json || {}), region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
);
}
await syncPlaylistUploadsOnChange({
@@ -563,8 +563,8 @@ module.exports = function registerContentRoutes(app, deps) {
for (let i = 0; i < payload.regions.length; i += 1) {
const region = payload.regions[i];
await pool.query(
'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[template.id, region.region_key, region.region_type, region.label, region.lock_ratio, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, animation_json, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[template.id, region.region_key, region.region_type, region.label, region.lock_ratio, JSON.stringify(region.animation_json || {}), region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
);
}
await syncPlaylistUploadsOnChange({
@@ -1,6 +1,7 @@
// Shared template form view-model builder.
const { getRegionEditorScripts } = require('../../../lib/region-scripts');
const animationPresets = require('../../../public/js/templates/animation-presets');
function buildDefaultTemplate() {
return {
@@ -61,7 +62,8 @@ function buildTemplateFormViewModel(template, message, canvasSizes, currentUser,
cancelUrl: '/templates',
deleteUrl: isEdit && current.id ? '/templates/' + current.id + '/delete' : '',
canvasSizes: canvasSizes || [],
scripts: ['js/lib/modal.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion), ['js/templates/template-designer-utils.js?v=' + assetVersion, 'js/templates/template-designer.js?v=' + assetVersion])
animationPresets: animationPresets,
scripts: ['js/lib/modal.js?v=' + assetVersion, 'js/templates/animation-presets.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion), ['js/templates/template-designer-utils.js?v=' + assetVersion, 'js/templates/template-designer.js?v=' + assetVersion])
};
}
+1
View File
@@ -11,6 +11,7 @@
<link rel="stylesheet" href="/assets/adminlte/bootstrap-icons/css/bootstrap-icons.min.css" />
<link rel="stylesheet" href="/assets/adminlte/css/adminlte.min.css" />
<link rel="stylesheet" href="/assets/css/theme-custom.css?v={{appVersion}}" />
<link rel="stylesheet" href="/assets/vendor/animate.css/animate.min.css?v={{appVersion}}" />
{{#if stylesheets.length}}
{{#each stylesheets}}
<link rel="stylesheet" href="{{assetHref this}}" />
@@ -0,0 +1,100 @@
{{#> modal-shell modalId="animation-advanced-modal" modalLabelId="animation-advanced-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="animation-advanced-modal-label" data-animation-modal-title>Advanced animation settings</h2>
<p class="text-body-secondary mb-0">Fine-tune the selected region animation JSON without editing raw data.</p>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body d-grid gap-4">
<div class="alert alert-secondary mb-0">Changes here are saved into the region animation JSON and stay in sync with the preset dropdowns.</div>
<div class="row g-3">
<div class="col-12 col-lg-4">
<div class="card border-secondary-subtle">
<div class="card-header bg-transparent">
<strong class="text-capitalize">Intro</strong>
</div>
<div class="card-body d-grid gap-3 align-content-start">
<label class="d-grid gap-1">
Preset
<select class="form-select" data-animation-modal-step="intro" data-animation-modal-field="preset">
{{#each animationPresets.advanced.intro}}
<option value="{{value}}">{{label}}</option>
{{/each}}
</select>
</label>
<label class="d-grid gap-1">
Duration ms
<input class="form-control" type="number" min="0" step="1" data-animation-modal-step="intro" data-animation-modal-field="duration_ms" placeholder="1000" />
</label>
<label class="d-grid gap-1">
Delay ms
<input class="form-control" type="number" min="0" step="1" data-animation-modal-step="intro" data-animation-modal-field="delay_ms" placeholder="0" />
</label>
</div>
</div>
</div>
<div class="col-12 col-lg-4">
<div class="card border-secondary-subtle">
<div class="card-header bg-transparent">
<strong class="text-capitalize">Outro</strong>
</div>
<div class="card-body d-grid gap-3 align-content-start">
<label class="d-grid gap-1">
Preset
<select class="form-select" data-animation-modal-step="outro" data-animation-modal-field="preset">
{{#each animationPresets.advanced.outro}}
<option value="{{value}}">{{label}}</option>
{{/each}}
</select>
</label>
<label class="d-grid gap-1">
Duration ms
<input class="form-control" type="number" min="0" step="1" data-animation-modal-step="outro" data-animation-modal-field="duration_ms" placeholder="1000" />
</label>
<label class="d-grid gap-1">
Delay ms
<input class="form-control" type="number" min="0" step="1" data-animation-modal-step="outro" data-animation-modal-field="delay_ms" placeholder="0" />
</label>
</div>
</div>
</div>
<div class="col-12 col-lg-4">
<div class="card border-secondary-subtle">
<div class="card-header bg-transparent">
<strong class="text-capitalize">Attention seekers</strong>
</div>
<div class="card-body d-grid gap-3 align-content-start">
<label class="d-grid gap-1">
Preset
<select class="form-select" data-animation-modal-step="loop" data-animation-modal-field="preset">
{{#each animationPresets.advanced.attentionSeekers}}
<option value="{{value}}">{{label}}</option>
{{/each}}
</select>
</label>
<label class="d-grid gap-1">
Duration ms
<input class="form-control" type="number" min="0" step="1" data-animation-modal-step="loop" data-animation-modal-field="duration_ms" placeholder="1000" />
</label>
<label class="d-grid gap-1">
Delay ms
<input class="form-control" type="number" min="0" step="1" data-animation-modal-step="loop" data-animation-modal-field="delay_ms" placeholder="0" />
</label>
<label class="d-grid gap-1">
Repeat
<input class="form-control" type="number" min="1" step="1" data-animation-modal-step="loop" data-animation-modal-field="iterations" placeholder="1" />
</label>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-outline-secondary" data-animation-modal-reset>Reset to defaults</button>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" data-animation-modal-apply>Apply changes</button>
</div>
</div>
{{/modal-shell}}
+56 -2
View File
@@ -31,10 +31,17 @@
</div>
<div class="template-designer-sidebar">
<div class="card card-outline card-primary admin-form-card">
<div class="card-body d-grid">
<div class="card-body d-grid gap-3 pb-0">
<div class="btn-group" role="group" aria-label="Template actions">
{{{saveActionButtons formId="template-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
</div>
<div class="d-grid gap-2 template-preview-card" id="template-preview-card" hidden>
<div class="btn-group flex-wrap gap-0" role="group" aria-label="Template animation preview controls">
<button type="button" class="btn btn-outline-secondary" id="template-preview-intro" data-preview-control data-preview-action="intro" aria-pressed="false">Intro</button>
<button type="button" class="btn btn-outline-secondary" id="template-preview-out" data-preview-control data-preview-action="out" aria-pressed="false">Outro</button>
<button type="button" class="btn btn-outline-secondary" id="template-preview-continuous" data-preview-control data-preview-action="continuous" aria-pressed="false">Attention</button>
</div>
</div>
</div>
</div>
<div class="card card-outline card-secondary admin-form-card region-info-card mb-3">
@@ -148,10 +155,57 @@
<label>Width<input class="form-control" type="number" name="region_width[]" value="300" required /></label>
<label>Height<input class="form-control" type="number" name="region_height[]" value="120" required /></label>
<label>Lock ratio<input class="form-control" type="text" name="region_lock_ratio[]" value="" placeholder="16:9 or 5:4" /></label>
<label>Remove<button type="button" class="btn btn-sm btn-outline-danger w-100" data-region-remove-button{{#if isEdit}} disabled{{/if}}>Remove</button></label>
<label>Remove<button type="button" class="btn btn-outline-danger w-100" data-region-remove-button{{#if isEdit}} disabled{{/if}}>Remove</button></label>
</div>
<div class="region-field-grid region-field-grid--animation">
<div class="d-grid gap-1">
<label>
Intro
<select class="form-select" data-animation-preset="intro">
{{#each animationPresets.basic.intro}}
<option value="{{value}}">{{label}}</option>
{{/each}}
</select>
</label>
<button type="button" class="btn btn-outline-secondary w-100" data-animation-reset-button="intro" hidden>Reset to basic</button>
</div>
<div class="d-grid gap-1">
<label>
Outro
<select class="form-select" data-animation-preset="outro">
{{#each animationPresets.basic.outro}}
<option value="{{value}}">{{label}}</option>
{{/each}}
</select>
</label>
<button type="button" class="btn btn-outline-secondary w-100" data-animation-reset-button="outro" hidden>Reset to basic</button>
</div>
</div>
<div class="region-field-grid region-field-grid--animation">
<div class="d-grid gap-1">
<label>
Attention seekers
<select class="form-select" data-animation-preset="loop">
{{#each animationPresets.basic.attentionSeekers}}
<option value="{{value}}">{{label}}</option>
{{/each}}
</select>
</label>
<button type="button" class="btn btn-outline-secondary w-100" data-animation-reset-button="loop" hidden>Reset to basic</button>
</div>
<div class="d-grid gap-1">
<label>
Advanced
<button type="button" class="btn btn-outline-secondary w-100" data-animation-advanced-button>Advanced</button>
</label>
</div>
</div>
<textarea class="visually-hidden" name="region_animation_json[]" data-animation-json-input rows="1"></textarea>
</div>
</div>
</div>
</template>
{{> signage/templates/animation-advanced-modal}}
<textarea id="template-editor-data" hidden>{{json template.regions}}</textarea>