From 554678b3c46f05342f66d7129b7da15277922e7f Mon Sep 17 00:00:00 2001 From: Mark Rapson Date: Mon, 26 Oct 2026 22:41:07 +0000 Subject: [PATCH] Release 2.21.2: fix animation timing --- CHANGELOG.md | 6 +++ build/package.player.json | 2 +- build/package.web.json | 2 +- package-lock.json | 4 +- package.json | 2 +- src/player/public/js/player-page-animation.js | 10 ++-- .../js/shared/region-animation-controls.js | 16 +++++- .../js/slides/slide-animation-editor.js | 11 +++-- .../public/js/templates/template-designer.js | 49 +++++-------------- .../templates/animation-advanced-fields.hbs | 2 +- test/player-page-playback.test.js | 46 +++++++++++++++++ test/region-layout.test.js | 28 +++++++++-- test/templates.test.js | 27 +++++++++- 13 files changed, 151 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 383ebe0..9bfafa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## 2.21.2 - 2026-10-26 + +### Fixed + +- Animation delays now work in slide and template previews and player playback. Outro timing is configured as time before transition, preview-only region staggering is removed, and slide animation controls enforce the same advanced limits as templates. + ## 2.21.1 - 2026-10-26 ### Fixed diff --git a/build/package.player.json b/build/package.player.json index 0250328..0b033e3 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-player", - "version": "2.21.1", + "version": "2.21.2", "private": false, "description": "Pulse Signage player application bundle", "engines": { diff --git a/build/package.web.json b/build/package.web.json index 4c1de97..437227e 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-web", - "version": "2.21.1", + "version": "2.21.2", "private": false, "description": "Pulse Signage web and bridge application bundle", "engines": { diff --git a/package-lock.json b/package-lock.json index 1ae161a..7a8badc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-signage", - "version": "2.21.1", + "version": "2.21.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "2.21.1", + "version": "2.21.2", "dependencies": { "@better-auth/passkey": "^1.7.13", "animate.css": "^4.1.1", diff --git a/package.json b/package.json index 79ca5f8..aae49fa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.21.1", + "version": "2.21.2", "private": false, "description": "Pulse Signage application with MySQL and media storage", "engines": { diff --git a/src/player/public/js/player-page-animation.js b/src/player/public/js/player-page-animation.js index b762609..085e93b 100644 --- a/src/player/public/js/player-page-animation.js +++ b/src/player/public/js/player-page-animation.js @@ -144,6 +144,7 @@ function clearRegionAnimationClasses(root) { element.style.removeProperty('--animate-duration'); element.style.removeProperty('--animate-delay'); element.style.removeProperty('--animate-repeat'); + element.style.removeProperty('animation-delay'); }); } @@ -161,13 +162,16 @@ function applyAnimationStep(element, step, phase) { return; } - element.classList.add('animate__animated', 'animate__' + preset); element.style.setProperty('--animate-duration', String(Math.max(1, Number(step.duration_ms || 0) || 1000)) + 'ms'); - if (Number(step.delay_ms || 0) > 0) { - element.style.setProperty('--animate-delay', String(Math.max(0, Number(step.delay_ms || 0))) + 'ms'); + var delayMs = phase === 'outro' ? 0 : Math.max(0, Number(step.delay_ms || 0)); + if (delayMs > 0) { + element.style.setProperty('--animate-delay', String(delayMs) + 'ms'); + element.style.setProperty('animation-delay', String(delayMs) + 'ms'); } else { element.style.removeProperty('--animate-delay'); + element.style.removeProperty('animation-delay'); } + element.classList.add('animate__animated', 'animate__' + preset); if (phase === 'loop') { var repeatCount = Number(step.iterations); diff --git a/src/web/public/js/shared/region-animation-controls.js b/src/web/public/js/shared/region-animation-controls.js index 6fff2db..2e75703 100644 --- a/src/web/public/js/shared/region-animation-controls.js +++ b/src/web/public/js/shared/region-animation-controls.js @@ -76,8 +76,22 @@ }); return normalize(config); } + function clampAdvancedField(input) { + if (!input || !input.dataset) return; + var field = input.dataset.animationModalField; + var value = String(input.value || '').trim(); + if (!value) return; + var number = Number(value); + if (!Number.isFinite(number)) return; + var normalized; + if (field === 'duration_ms') normalized = Math.min(200, Math.max(1, Math.round(number))); + else if (field === 'iterations' && input.dataset.animationModalStep === 'loop') normalized = Math.min(999, Math.max(1, Math.round(number))); + else return; + if (String(normalized) !== String(input.value)) input.value = String(normalized); + } window.pulseRegionAnimationControls = { read: read, write: write, setPreset: setPreset, bind: bind, - populateAdvanced: populateAdvanced, readAdvanced: readAdvanced + populateAdvanced: populateAdvanced, readAdvanced: readAdvanced, + clampAdvancedField: clampAdvancedField }; })(); \ No newline at end of file diff --git a/src/web/public/js/slides/slide-animation-editor.js b/src/web/public/js/slides/slide-animation-editor.js index 0e87cf1..f1dba06 100644 --- a/src/web/public/js/slides/slide-animation-editor.js +++ b/src/web/public/js/slides/slide-animation-editor.js @@ -141,7 +141,7 @@ export function createSlideAnimationEditor(options) { element.className.split(/\s+/).forEach(function (className) { if (className.indexOf('animate__') === 0) element.classList.remove(className); }); - ['--animate-duration', '--animate-delay', '--animate-repeat'].forEach(function (property) { element.style.removeProperty(property); }); + ['--animate-duration', '--animate-delay', '--animate-repeat', 'animation-delay'].forEach(function (property) { element.style.removeProperty(property); }); }); updatePreviewButtons(); } @@ -168,16 +168,18 @@ export function createSlideAnimationEditor(options) { previewMode = mode; var token = previewToken; var longest = 0; - getPreviewElements().forEach(function (element, index) { + getPreviewElements().forEach(function (element) { var config = window.templateDesignerUtils.normalizeAnimationConfig(getConfig(element.dataset.regionKey)); var step = config[mode] || {}; var preset = window.templateAnimationPresets.normalize(step.preset); if (preset === 'none') return; - var delay = Math.max(0, Number(step.delay_ms) || 0) + index * 90; + var configuredDelay = mode === 'outro' ? 0 : Math.max(0, Number(step.delay_ms) || 0); + var delay = configuredDelay; var duration = Math.max(1, Number(step.duration_ms) || 1000); var iterations = mode === 'loop' ? Math.max(1, Number(step.iterations) || 1) : 1; element.style.setProperty('--animate-duration', duration + 'ms'); element.style.setProperty('--animate-delay', delay + 'ms'); + element.style.setProperty('animation-delay', delay + 'ms'); element.style.setProperty('--animate-repeat', String(iterations)); element.classList.remove('animate__animated', 'animate__' + preset, 'animate__repeat-1'); void element.offsetWidth; @@ -266,6 +268,9 @@ export function createSlideAnimationEditor(options) { advanced.addEventListener('shown.bs.modal', function () { advanced.querySelector('[data-animation-modal-field]').focus(); }); + advanced.addEventListener('input', function (event) { + controls.clampAdvancedField(event.target); + }); advanced.addEventListener('hidden.bs.modal', function () { window.pulseModal.getOrCreate(advanced)._scrollBar = advancedScrollBar; advancedScrollBar = null; diff --git a/src/web/public/js/templates/template-designer.js b/src/web/public/js/templates/template-designer.js index d4b9982..e635615 100644 --- a/src/web/public/js/templates/template-designer.js +++ b/src/web/public/js/templates/template-designer.js @@ -363,6 +363,7 @@ rect.style.removeProperty('--animate-duration'); rect.style.removeProperty('--animate-delay'); rect.style.removeProperty('--animate-repeat'); + rect.style.removeProperty('animation-delay'); rect.style.opacity = ''; rect.style.transform = ''; rect.style.filter = ''; @@ -442,15 +443,20 @@ var durationMs = Number(step && step.duration_ms); var delayMs = Number(step && step.delay_ms); var repeatCount = Number(step && step.iterations); + if (mode === 'out') delayMs = 0; 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.removeProperty('animation-delay'); 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.style.setProperty('animation-delay', Math.max(0, delayMs) + 'ms'); + } else { + rect.style.removeProperty('animation-delay'); } rect.classList.add('animate__animated', animateClass); if (isAttentionSeeker) { @@ -475,6 +481,9 @@ if (!Number.isFinite(delayMs) || delayMs < 0) { delayMs = 0; } + if (normalizedMode === 'out') { + delayMs = 0; + } if (!Number.isFinite(iterations) || iterations < 1) { iterations = 1; } @@ -595,39 +604,6 @@ window.pulseRegionAnimationControls.populateAdvanced(animationAdvancedModal, config); } - 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 readAnimationModalConfig() { if (!animationAdvancedModal) { return null; @@ -732,7 +708,6 @@ 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)); @@ -740,11 +715,11 @@ previewState.animations = []; - rects.forEach(function (rect, index) { + rects.forEach(function (rect) { var region = regions && regions[index] ? regions[index] : null; var step = getPreviewRegionAnimationStep(region, mode); var preset = normalizePreviewAnimation(step && step.preset); - var delay = index * stagger; + var delay = mode === 'out' ? 0 : Math.max(0, Number(step && step.delay_ms) || 0); var animation = null; var animateCssMode = isAnimateCssPreset(preset); var timingMs = delay + getPreviewAnimationStepTimingMs(step, mode, animateCssMode); @@ -1785,7 +1760,7 @@ activeAnimationCard = null; }); animationAdvancedModal.addEventListener('input', function (event) { - clampAnimationModalField(event.target); + window.pulseRegionAnimationControls.clampAdvancedField(event.target); markTemplateFormDirty(); }); animationAdvancedModal.addEventListener('change', function () { diff --git a/src/web/views/signage/templates/animation-advanced-fields.hbs b/src/web/views/signage/templates/animation-advanced-fields.hbs index 51a9cf4..ec103aa 100644 --- a/src/web/views/signage/templates/animation-advanced-fields.hbs +++ b/src/web/views/signage/templates/animation-advanced-fields.hbs @@ -15,7 +15,7 @@
- +
diff --git a/test/player-page-playback.test.js b/test/player-page-playback.test.js index cf20803..cf6ab57 100644 --- a/test/player-page-playback.test.js +++ b/test/player-page-playback.test.js @@ -1181,4 +1181,50 @@ test('playlist removal of the current slide queues without rerendering it immedi assert.equal(JSON.stringify(sandbox.slides), JSON.stringify([{ id: 1, duration_seconds: 12 }, { id: 2, duration_seconds: 12 }])); assert.equal(calls.showCurrent, 0); assert.equal(calls.logDebug.some((entry) => entry.includes('applying on next slide transition')), true); +}); + +test('player applies animation delays and schedules outro triggers to finish at slide advance', () => { + const animationPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-animation.js'); + const transitionPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-transition.js'); + const values = {}; + const classes = new Set(); + const config = JSON.stringify({ + intro: { preset: 'fadeIn', duration_ms: 600, delay_ms: 250 }, + outro: { preset: 'fadeOut', duration_ms: 700, delay_ms: 400 } + }); + const element = { + dataset: {}, + isConnected: true, + style: { + setProperty(name, value) { values[name] = value; }, + removeProperty(name) { delete values[name]; } + }, + classList: { + add(...names) { names.forEach(name => classes.add(name)); }, + remove(...names) { names.forEach(name => classes.delete(name)); }, + [Symbol.iterator]() { return classes[Symbol.iterator](); } + }, + getAttribute(name) { return name === 'data-animation-json' ? config : null; }, + addEventListener() {}, + removeEventListener() {}, + querySelectorAll() { return []; } + }; + const root = { querySelectorAll() { return [element]; } }; + const timers = []; + const sandbox = { + window: null, + app: { querySelectorAll() { return [root]; }, firstElementChild: root }, + setTimeout(callback, delay) { timers.push({ callback, delay }); return timers.length; }, + clearTimeout() {} + }; + sandbox.window = sandbox; + vm.runInNewContext(fs.readFileSync(animationPath, 'utf8') + '\n' + fs.readFileSync(transitionPath, 'utf8'), sandbox); + + sandbox.playRegionAnimation(element, 'intro'); + assert.equal(values['animation-delay'], '250ms'); + + sandbox.scheduleSlideOutro(5000); + assert.equal(timers[0].delay, 3900); + timers[0].callback(); + assert.equal(values['animation-delay'], undefined); }); \ No newline at end of file diff --git a/test/region-layout.test.js b/test/region-layout.test.js index 4e5c6d0..7f0a157 100644 --- a/test/region-layout.test.js +++ b/test/region-layout.test.js @@ -322,6 +322,12 @@ test('shared animation controls round-trip advanced settings with template norma assert.equal(clamped.intro.duration_ms, 2000); assert.equal(clamped.intro.delay_ms, 0); assert.equal(clamped.loop.iterations, 999); + fields[1].value = '999'; + fields[4].value = '2000'; + controls.clampAdvancedField(fields[1]); + controls.clampAdvancedField(fields[4]); + assert.equal(fields[1].value, '200'); + assert.equal(fields[4].value, '999'); }); test('slide advanced popup applies changes, cancels drafts and preserves the parent animation draft', function () { @@ -330,7 +336,8 @@ test('slide advanced popup applies changes, cancels drafts and preserves the par addEventListener(type, handler) { this.handlers[type] = handler; }, querySelectorAll() { return []; }, replaceChildren() {}, appendChild() {}, focus() {}, setAttribute() {}, removeAttribute() {}, - style: { setProperty() {}, removeProperty() {} }, classList: { add() {}, remove() {}, toggle() {} } }; + style: { values: {}, setProperty(name, value) { this.values[name] = value; }, removeProperty(name) { delete this.values[name]; } }, + classList: { add() {}, remove() {}, toggle() {} } }; } const ids = Object.fromEntries([ 'slide-animation-modal', 'adjust-slide-animations', 'slide-animation-region', @@ -352,9 +359,12 @@ test('slide advanced popup applies changes, cancels drafts and preserves the par add(...classNames) { readonlyPreviewRegion.className += ' ' + classNames.join(' '); }, remove(...classNames) { readonlyPreviewRegion.className = readonlyPreviewRegion.className.split(/\s+/).filter(name => !classNames.includes(name)).join(' '); } }; + const secondPreviewRegion = element(); + secondPreviewRegion.className = 'slide-preview-region'; + secondPreviewRegion.dataset.regionKey = 'fixed_brand_second'; advanced.querySelectorAll = () => fields; ids['slide-animation-modal'].querySelectorAll = selector => selector === '[data-slide-animation-preview]' ? animationPreviewButtons : []; - ids['slide-animation-preview'].querySelectorAll = () => [readonlyPreviewRegion]; + ids['slide-animation-preview'].querySelectorAll = () => [readonlyPreviewRegion, secondPreviewRegion]; advanced.querySelector = selector => selector === '[data-animation-modal-apply]' ? advancedApply : selector === '[data-animation-modal-reset]' ? advancedReset : element(); const hidden = { value: '{}' }; @@ -399,17 +409,29 @@ test('slide advanced popup applies changes, cancels drafts and preserves the par context.createSlideAnimationEditor({ stage: { parentNode: { insertBefore() {} }, nextSibling: null }, getTemplate() { return { regions: [ { region_key: 'hero', animation_json: { intro: { preset: 'bounceIn' }, outro: { preset: 'fadeOut' } } }, - { region_key: 'fixed_brand', read_only: true, animation_json: { loop: { preset: 'pulse', duration_ms: 200, iterations: 1 } } } + { region_key: 'fixed_brand', read_only: true, animation_json: { + outro: { preset: 'fadeOut', duration_ms: 200, delay_ms: 500 }, + loop: { preset: 'pulse', duration_ms: 200, delay_ms: 240, iterations: 1 } + } }, + { region_key: 'fixed_brand_second', read_only: true, animation_json: { loop: { preset: 'pulse', duration_ms: 200, iterations: 1 } } } ] }; }, getAnimationOverrides() { return {}; }, requestPreviewRender() {}, markEdited() {}, setAnimationOverrides(value) { applied = value; } }); ids['slide-animation-region'].value = 'hero'; ids['adjust-slide-animations'].handlers.click(); + fields[1].value = '999'; + ids['animation-advanced-modal'].handlers.input({ target: fields[1] }); + assert.equal(fields[1].value, '200'); assert.equal(animationPreviewButtons[2].disabled, false); animationPreviewButtons[2].handlers.click(); assert.match(readonlyPreviewRegion.className, /animate__pulse/); + assert.equal(readonlyPreviewRegion.style.values['animation-delay'], '240ms'); + assert.equal(secondPreviewRegion.style.values['animation-delay'], '0ms'); animationPreviewButtons[2].handlers.click(); + animationPreviewButtons[1].handlers.click(); + assert.equal(readonlyPreviewRegion.style.values['animation-delay'], '0ms'); + animationPreviewButtons[1].handlers.click(); const resetRegion = ids['slide-animation-reset-region']; const resetAll = ids['slide-animation-reset-all']; assert.equal(resetRegion.disabled, true); diff --git a/test/templates.test.js b/test/templates.test.js index 5be0380..5c71b03 100644 --- a/test/templates.test.js +++ b/test/templates.test.js @@ -338,7 +338,7 @@ test('template animation controls mark the form dirty', () => { assert.match(script, /animationPresetInputs[\s\S]*markTemplateFormDirty\(\);/); assert.ok(script.includes("animationAdvancedModal.addEventListener('input'")); - assert.ok(script.includes('clampAnimationModalField(event.target);')); + assert.ok(script.includes('window.pulseRegionAnimationControls.clampAdvancedField(event.target);')); assert.ok(script.includes("animationAdvancedModal.addEventListener('change'")); assert.ok(script.includes("animationModalApplyButton.addEventListener('click', function () {")); assert.ok(script.includes("saveAnimationModal();")); @@ -355,6 +355,7 @@ test('template animation durations use percent in the advanced modal', () => { assert.match(modal, /signage\/templates\/animation-advanced-fields/); assert.match(modal, /data-animation-modal-title>Advanced animations:/); + assert.match(advancedFields, /Finish before transition \(ms\).*data-animation-modal-step="outro"/); assert.ok(modal.indexOf('data-animation-modal-apply') < modal.lastIndexOf('>Cancel')); assert.match(modal, /class="btn btn-warning" data-bs-dismiss="modal">Cancel<\/button>/); assert.ok(script.includes("title.textContent = 'Advanced animations: ' + (label ? label.textContent : 'Region');")); @@ -584,4 +585,28 @@ test('template designer restores region default content and read-only state from type: 'html', value: '
Template-owned
' }); assert.equal(fields['[data-region-read-only]'].checked, true); +}); + +test('template animation preview applies configured delays to the CSS animation property', () => { + const script = fs.readFileSync(require.resolve('../src/web/public/js/templates/template-designer.js'), 'utf8'); + assert.doesNotMatch(script, /var stagger = 90/); + assert.match(script, /var delay = mode === 'out' \? 0 : Math\.max\(0, Number\(step && step\.delay_ms\) \|\| 0\);/); + const match = script.match(/ function applyAnimateCssPreview\([^]*?\n }/); + assert.ok(match); + const context = vm.createContext({}); + vm.runInContext(match[0] + '\nthis.applyAnimateCssPreview = applyAnimateCssPreview;', context); + const values = {}; + const rect = { + classList: { add() {}, remove() {} }, + style: { + setProperty(name, value) { values[name] = value; }, + removeProperty(name) { delete values[name]; } + } + }; + + context.applyAnimateCssPreview(rect, 'fadeIn', { duration_ms: 1000, delay_ms: 425 }, 'intro'); + + assert.equal(values['animation-delay'], '425ms'); + context.applyAnimateCssPreview(rect, 'fadeOut', { duration_ms: 1000, delay_ms: 425 }, 'out'); + assert.equal(values['animation-delay'], undefined); }); \ No newline at end of file