From 711620fa8893fbf35ce7d1dc74e77f4c0305fa4b Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Sun, 12 Apr 2026 08:29:02 +0200 Subject: [PATCH 01/16] 6871: Started cleanup of template code --- assets/shared/templates/image-text.jsx | 25 +++++++++++-------- .../templates/image-text/image-text.scss | 7 +++++- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/assets/shared/templates/image-text.jsx b/assets/shared/templates/image-text.jsx index 6f6b33ba..75eb36e9 100644 --- a/assets/shared/templates/image-text.jsx +++ b/assets/shared/templates/image-text.jsx @@ -170,10 +170,14 @@ function ImageText({ slide, content, run, slideDone, executionId }) { } }, [slide]); - const startTheShow = () => { + const clearImageTimeout = () => { if (imageTimeoutRef.current) { clearTimeout(imageTimeoutRef.current); } + }; + + const startTheShow = () => { + clearImageTimeout(); const currentImages = imagesRef.current; @@ -191,24 +195,23 @@ function ImageText({ slide, content, run, slideDone, executionId }) { } else { setCurrentImage(undefined); } - }, [images]); - /** Setup slide run function. */ - const slideExecution = new BaseSlideExecution(slide, slideDone); + }, [images]); useEffect(() => { if (run) { startTheShow(); + + const slideExecution = new BaseSlideExecution(slide, slideDone); slideExecution.start(duration); - } - return function cleanup() { - slideExecution.stop(); + return () => { + slideExecution.stop(); + clearImageTimeout(); + }; + } - if (imageTimeoutRef.current) { - clearTimeout(imageTimeoutRef.current); - } - }; + return clearImageTimeout; }, [run]); return ( diff --git a/assets/shared/templates/image-text/image-text.scss b/assets/shared/templates/image-text/image-text.scss index a8662728..25260613 100644 --- a/assets/shared/templates/image-text/image-text.scss +++ b/assets/shared/templates/image-text/image-text.scss @@ -21,6 +21,8 @@ height: 100%; width: 100%; overflow: hidden; + isolation: isolate; + position: relative; display: flex; flex-direction: row; flex-wrap: nowrap; @@ -31,6 +33,8 @@ font-size: var(--font-size-base); .box { + position: relative; + z-index: 1; padding: 2%; order: 0; flex: 1 0 auto; @@ -143,7 +147,7 @@ } .background-image { - z-index: -1; + z-index: 0; position: absolute; background-size: cover; background-position: center; @@ -169,6 +173,7 @@ .logo { position: absolute; + z-index: 1; width: 10%; } From eba8b136f33b529d2cc43f7cf1cf489acfeaa595 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Sun, 12 Apr 2026 08:51:07 +0200 Subject: [PATCH 02/16] 6871: Finished image-text cleanup --- assets/shared/templates/image-text.jsx | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/assets/shared/templates/image-text.jsx b/assets/shared/templates/image-text.jsx index 75eb36e9..a9ddf9b7 100644 --- a/assets/shared/templates/image-text.jsx +++ b/assets/shared/templates/image-text.jsx @@ -43,7 +43,9 @@ function renderSlide(slide, run, slideDone) { function ImageText({ slide, content, run, slideDone, executionId }) { const imageTimeoutRef = useRef(); const imagesRef = useRef([]); + const durationRef = useRef(); const [images, setImages] = useState([]); + imagesRef.current = images; const [currentImage, setCurrentImage] = useState(); const logo = slide?.theme?.logo; const { @@ -78,16 +80,16 @@ function ImageText({ slide, content, run, slideDone, executionId }) { halfSize, fontSize, shadow, - } = content || {}; + } = content; let boxClasses = "box"; // Styling objects - const rootStyle = {}; const imageTextStyle = {}; // Content from content const { title, text, textColor, boxColor, duration = 15000 } = content; + durationRef.current = duration; const sanitizedText = DOMPurify.sanitize(text); @@ -95,7 +97,7 @@ function ImageText({ slide, content, run, slideDone, executionId }) { const displaySeparator = separator && !reversed; // Set background image. - if (!(images?.length > 0)) { + if (images.length === 0) { boxClasses = `${boxClasses} full-screen`; } @@ -141,7 +143,7 @@ function ImageText({ slide, content, run, slideDone, executionId }) { if (newIndex < currentImages.length - 1) { imageTimeoutRef.current = setTimeout( () => changeImage(newIndex + 1), - duration / currentImages.length, + durationRef.current / currentImages.length, ); } } @@ -160,15 +162,12 @@ function ImageText({ slide, content, run, slideDone, executionId }) { nodeRef: createRef(), })); - imagesRef.current = newImages; - setImages(newImages); } else { - imagesRef.current = []; setImages([]); } } - }, [slide]); + }, [slide, content.image]); const clearImageTimeout = () => { if (imageTimeoutRef.current) { @@ -212,11 +211,11 @@ function ImageText({ slide, content, run, slideDone, executionId }) { } return clearImageTimeout; - }, [run]); + }, [run, slide, slideDone, duration]); return ( <> -
+
{currentImage && (
Date: Sun, 12 Apr 2026 09:06:34 +0200 Subject: [PATCH 03/16] 6871: Fixed BaseSlideExecution instantiation issue --- .gitignore | 2 ++ .../custom-templates-example/custom-template-example.jsx | 3 +-- assets/shared/templates/book-review.jsx | 2 +- assets/shared/templates/brnd.jsx | 2 +- assets/shared/templates/calendar.jsx | 2 +- assets/shared/templates/contacts.jsx | 2 +- assets/shared/templates/iframe.jsx | 2 +- assets/shared/templates/table.jsx | 8 +++++--- assets/shared/templates/travel.jsx | 2 +- assets/shared/templates/vimeo-player.jsx | 2 +- 10 files changed, 15 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index 6a75d646..ecee3e2f 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,5 @@ phpstan.neon ###> vincentlanglet/twig-cs-fixer ### /.twig-cs-fixer.cache ###< vincentlanglet/twig-cs-fixer ### + +.claude/ diff --git a/assets/shared/custom-templates-example/custom-template-example.jsx b/assets/shared/custom-templates-example/custom-template-example.jsx index 0ea8a45f..57bafe4e 100644 --- a/assets/shared/custom-templates-example/custom-template-example.jsx +++ b/assets/shared/custom-templates-example/custom-template-example.jsx @@ -57,9 +57,8 @@ function CustomTemplateExample({ const { duration = 15000 } = content; const { title = "Default title" } = content; - const slideExecution = new BaseSlideExecution(slide, slideDone); - useEffect(() => { + const slideExecution = new BaseSlideExecution(slide, slideDone); if (run) { slideExecution.start(duration); } diff --git a/assets/shared/templates/book-review.jsx b/assets/shared/templates/book-review.jsx index bfc70f43..364cc3fd 100644 --- a/assets/shared/templates/book-review.jsx +++ b/assets/shared/templates/book-review.jsx @@ -62,8 +62,8 @@ function BookReview({ slide, content, run, slideDone, executionId }) { : ""; /** Setup slide run function. */ - const slideExecution = new BaseSlideExecution(slide, slideDone); useEffect(() => { + const slideExecution = new BaseSlideExecution(slide, slideDone); if (run) { slideExecution.start(duration); } diff --git a/assets/shared/templates/brnd.jsx b/assets/shared/templates/brnd.jsx index cb84d768..7a3994d6 100644 --- a/assets/shared/templates/brnd.jsx +++ b/assets/shared/templates/brnd.jsx @@ -65,8 +65,8 @@ function Brnd({ slide, content, run, slideDone, executionId }) { } /** Setup slide run function. */ - const slideExecution = new BaseSlideExecution(slide, slideDone); useEffect(() => { + const slideExecution = new BaseSlideExecution(slide, slideDone); if (run) { slideExecution.start(duration); } diff --git a/assets/shared/templates/calendar.jsx b/assets/shared/templates/calendar.jsx index df3e2883..a99b2140 100644 --- a/assets/shared/templates/calendar.jsx +++ b/assets/shared/templates/calendar.jsx @@ -68,8 +68,8 @@ function Calendar({ slide, content, run, slideDone, executionId }) { } /** Setup slide run function. */ - const slideExecution = new BaseSlideExecution(slide, slideDone); useEffect(() => { + const slideExecution = new BaseSlideExecution(slide, slideDone); if (run) { slideExecution.start(duration); } diff --git a/assets/shared/templates/contacts.jsx b/assets/shared/templates/contacts.jsx index e7ef621c..44a2f4a0 100644 --- a/assets/shared/templates/contacts.jsx +++ b/assets/shared/templates/contacts.jsx @@ -73,8 +73,8 @@ function Contacts({ slide, content, run, slideDone, executionId }) { }, []); /** Setup slide run function. */ - const slideExecution = new BaseSlideExecution(slide, slideDone); useEffect(() => { + const slideExecution = new BaseSlideExecution(slide, slideDone); if (run) { slideExecution.start(duration); } diff --git a/assets/shared/templates/iframe.jsx b/assets/shared/templates/iframe.jsx index 862c41de..ef2bb0ba 100644 --- a/assets/shared/templates/iframe.jsx +++ b/assets/shared/templates/iframe.jsx @@ -39,8 +39,8 @@ function IFrame({ slide, content, run, slideDone, executionId }) { const { source, duration = 15000 } = content; /** Setup slide run function. */ - const slideExecution = new BaseSlideExecution(slide, slideDone); useEffect(() => { + const slideExecution = new BaseSlideExecution(slide, slideDone); if (run) { slideExecution.start(duration); } diff --git a/assets/shared/templates/table.jsx b/assets/shared/templates/table.jsx index 6931da61..53920590 100644 --- a/assets/shared/templates/table.jsx +++ b/assets/shared/templates/table.jsx @@ -69,13 +69,15 @@ function Table({ slide, content, run, slideDone, executionId }) { } /** Setup slide run function. */ - const slideExecution = new BaseSlideExecution(slide, slideDone); useEffect(() => { + const slideExecution = new BaseSlideExecution(slide, slideDone); if (run) { slideExecution.start(duration); - } else { - slideExecution.stop(); } + + return function cleanup() { + slideExecution.stop(); + }; }, [run]); let gridStyle; diff --git a/assets/shared/templates/travel.jsx b/assets/shared/templates/travel.jsx index a11d6eda..bd018e0d 100644 --- a/assets/shared/templates/travel.jsx +++ b/assets/shared/templates/travel.jsx @@ -110,8 +110,8 @@ function Travel({ } // Setup slide run function - const slideExecution = new BaseSlideExecution(slide, slideDone); useEffect(() => { + const slideExecution = new BaseSlideExecution(slide, slideDone); if (run) { slideExecution.start(duration); } diff --git a/assets/shared/templates/vimeo-player.jsx b/assets/shared/templates/vimeo-player.jsx index 9538d81d..bf5e27ce 100644 --- a/assets/shared/templates/vimeo-player.jsx +++ b/assets/shared/templates/vimeo-player.jsx @@ -41,8 +41,8 @@ function VimeoPlayer({ slide, content, run, slideDone, executionId }) { const { vimeoid, duration = 15000, mediaContain } = content; /** Setup slide run function. */ - const slideExecution = new BaseSlideExecution(slide, slideDone); useEffect(() => { + const slideExecution = new BaseSlideExecution(slide, slideDone); if (run) { slideExecution.start(duration); } From 6d7194882aa8226a60f66c6c4d35121797733ad4 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Sun, 12 Apr 2026 09:32:33 +0200 Subject: [PATCH 04/16] 6871: Changed to use hook for BaseSlideExecution --- README.md | 2 +- .../custom-template-example.jsx | 14 +------ .../slide-utils/base-slide-execution.js | 18 ++++++-- .../slide-utils/useBaseSlideExecution.js | 41 +++++++++++++++++++ assets/shared/templates/book-review.jsx | 15 +------ assets/shared/templates/brnd.jsx | 14 +------ assets/shared/templates/calendar.jsx | 20 ++------- .../templates/calendar/calendar-multiple.jsx | 2 +- assets/shared/templates/contacts.jsx | 14 +------ assets/shared/templates/iframe.jsx | 15 +------ assets/shared/templates/image-text.jsx | 15 +++---- assets/shared/templates/table.jsx | 15 +------ assets/shared/templates/travel.jsx | 14 +------ assets/shared/templates/vimeo-player.jsx | 15 +------ 14 files changed, 82 insertions(+), 132 deletions(-) create mode 100644 assets/shared/slide-utils/useBaseSlideExecution.js diff --git a/README.md b/README.md index 379c63fa..032e6a93 100644 --- a/README.md +++ b/README.md @@ -838,7 +838,7 @@ For an example of a custom template see `assets/shared/custom-templates-example/ The slide is responsible for signaling that it is done executing. This is done by calling the slideDone() function. If the slide should just run for X milliseconds then you can use the -BaseSlideExecution class to handle this. See the example for this approach. +`useBaseSlideExecution` hook to handle this. See the example for this approach. ##### Admin Form diff --git a/assets/shared/custom-templates-example/custom-template-example.jsx b/assets/shared/custom-templates-example/custom-template-example.jsx index 57bafe4e..3e313222 100644 --- a/assets/shared/custom-templates-example/custom-template-example.jsx +++ b/assets/shared/custom-templates-example/custom-template-example.jsx @@ -1,6 +1,5 @@ -import { useEffect } from "react"; import templateConfig from "./custom-template-example.json"; -import BaseSlideExecution from "../slide-utils/base-slide-execution.js"; +import useBaseSlideExecution from "../slide-utils/useBaseSlideExecution.js"; import { ThemeStyles } from "../slide-utils/slide-util.jsx"; /** @@ -57,16 +56,7 @@ function CustomTemplateExample({ const { duration = 15000 } = content; const { title = "Default title" } = content; - useEffect(() => { - const slideExecution = new BaseSlideExecution(slide, slideDone); - if (run) { - slideExecution.start(duration); - } - - return function cleanup() { - slideExecution.stop(); - }; - }, [run]); + useBaseSlideExecution({ slide, run, slideDone, duration }); return ( <> diff --git a/assets/shared/slide-utils/base-slide-execution.js b/assets/shared/slide-utils/base-slide-execution.js index cbcdda4a..d59fb1b4 100644 --- a/assets/shared/slide-utils/base-slide-execution.js +++ b/assets/shared/slide-utils/base-slide-execution.js @@ -34,14 +34,24 @@ class BaseSlideExecution { clearTimeout(this.slideTimeout); } - // Wait duration when call slideDone. + const safeDuration = + Number.isFinite(duration) && duration > 0 ? duration : 15000; + + // Wait duration then call slideDone. this.slideTimeout = setTimeout(() => { - this.slideDone(this.slide); + if (typeof this.slideDone === "function") { + this.slideDone(this.slide); + } this.slideTimeout = null; - }, duration); + }, safeDuration); } - /** Stops execution timeout. */ + /** + * Stops execution timeout. + * + * Does not call slideDone — this is intentional for cleanup-on-unmount + * scenarios where the slide was cancelled, not completed. + */ stop() { if (this.slideTimeout !== null) { clearTimeout(this.slideTimeout); diff --git a/assets/shared/slide-utils/useBaseSlideExecution.js b/assets/shared/slide-utils/useBaseSlideExecution.js new file mode 100644 index 00000000..80ce0b8c --- /dev/null +++ b/assets/shared/slide-utils/useBaseSlideExecution.js @@ -0,0 +1,41 @@ +import { useEffect, useRef } from "react"; +import BaseSlideExecution from "./base-slide-execution.js"; + +/** + * Hook to manage slide execution lifecycle. + * + * Uses refs for slide, slideDone, and duration to avoid stale closures + * while keeping [run] as the only dependency that triggers the timer. + * + * @param {object} options + * @param {object} options.slide The slide object. + * @param {boolean} options.run Whether the slide should run. + * @param {Function} options.slideDone Callback when slide finishes. + * @param {number} options.duration Duration in ms. + */ +function useBaseSlideExecution({ slide, run, slideDone, duration }) { + const slideRef = useRef(slide); + const slideDoneRef = useRef(slideDone); + const durationRef = useRef(duration); + + slideRef.current = slide; + slideDoneRef.current = slideDone; + durationRef.current = duration; + + useEffect(() => { + const slideExecution = new BaseSlideExecution( + slideRef.current, + (s) => slideDoneRef.current(s), + ); + + if (run) { + slideExecution.start(durationRef.current); + } + + return function cleanup() { + slideExecution.stop(); + }; + }, [run]); +} + +export default useBaseSlideExecution; diff --git a/assets/shared/templates/book-review.jsx b/assets/shared/templates/book-review.jsx index 364cc3fd..37c32400 100644 --- a/assets/shared/templates/book-review.jsx +++ b/assets/shared/templates/book-review.jsx @@ -1,7 +1,6 @@ -import { useEffect } from "react"; import parse from "html-react-parser"; import DOMPurify from "dompurify"; -import BaseSlideExecution from "../slide-utils/base-slide-execution.js"; +import useBaseSlideExecution from "../slide-utils/useBaseSlideExecution.js"; import { getFirstMediaUrlFromField, ThemeStyles, @@ -61,17 +60,7 @@ function BookReview({ slide, content, run, slideDone, executionId }) { ? { backgroundImage: `url("${bookImageUrl}")` } : ""; - /** Setup slide run function. */ - useEffect(() => { - const slideExecution = new BaseSlideExecution(slide, slideDone); - if (run) { - slideExecution.start(duration); - } - - return function cleanup() { - slideExecution.stop(); - }; - }, [run]); + useBaseSlideExecution({ slide, run, slideDone, duration }); return ( <> diff --git a/assets/shared/templates/brnd.jsx b/assets/shared/templates/brnd.jsx index 7a3994d6..eea76142 100644 --- a/assets/shared/templates/brnd.jsx +++ b/assets/shared/templates/brnd.jsx @@ -2,7 +2,7 @@ import React, { useEffect, Fragment, useState } from "react"; import dayjs from "dayjs"; import localizedFormat from "dayjs/plugin/localizedFormat"; import { FormattedMessage, IntlProvider } from "react-intl"; -import BaseSlideExecution from "../slide-utils/base-slide-execution"; +import useBaseSlideExecution from "../slide-utils/useBaseSlideExecution.js"; import da from "./brnd/lang/da.json"; import { getFirstMediaUrlFromField, @@ -64,17 +64,7 @@ function Brnd({ slide, content, run, slideDone, executionId }) { rootStyle["--bg-image"] = `url("${imageUrl}")`; } - /** Setup slide run function. */ - useEffect(() => { - const slideExecution = new BaseSlideExecution(slide, slideDone); - if (run) { - slideExecution.start(duration); - } - - return function cleanup() { - slideExecution.stop(); - }; - }, [run]); + useBaseSlideExecution({ slide, run, slideDone, duration }); /** Imports language strings, sets localized formats. */ useEffect(() => { diff --git a/assets/shared/templates/calendar.jsx b/assets/shared/templates/calendar.jsx index a99b2140..727245fa 100644 --- a/assets/shared/templates/calendar.jsx +++ b/assets/shared/templates/calendar.jsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import dayjs from "dayjs"; import localizedFormat from "dayjs/plugin/localizedFormat"; import { FormattedMessage, IntlProvider } from "react-intl"; -import BaseSlideExecution from "../slide-utils/base-slide-execution.js"; +import useBaseSlideExecution from "../slide-utils/useBaseSlideExecution.js"; import da from "./calendar/lang/da.json"; import { getFirstMediaUrlFromField, @@ -48,7 +48,7 @@ function renderSlide(slide, run, slideDone) { * @returns {JSX.Element} The component. */ function Calendar({ slide, content, run, slideDone, executionId }) { - const [translations, setTranslations] = useState(); + const [translations, setTranslations] = useState(da); const { layout = "multiple", @@ -67,23 +67,11 @@ function Calendar({ slide, content, run, slideDone, executionId }) { rootStyle["--bg-image"] = `url("${imageUrl}")`; } - /** Setup slide run function. */ - useEffect(() => { - const slideExecution = new BaseSlideExecution(slide, slideDone); - if (run) { - slideExecution.start(duration); - } + useBaseSlideExecution({ slide, run, slideDone, duration }); - return function cleanup() { - slideExecution.stop(); - }; - }, [run]); - - /** Imports language strings, sets localized formats. */ + /** Sets localized formats. */ useEffect(() => { dayjs.extend(localizedFormat); - - setTranslations(da); }, []); const getTitle = (eventTitle) => { diff --git a/assets/shared/templates/calendar/calendar-multiple.jsx b/assets/shared/templates/calendar/calendar-multiple.jsx index d1ceb5d9..b23c8bdf 100644 --- a/assets/shared/templates/calendar/calendar-multiple.jsx +++ b/assets/shared/templates/calendar/calendar-multiple.jsx @@ -70,7 +70,7 @@ function CalendarMultiple({ return e.endTime > now.unix() && startDate.date() === now.date(); }) - .sort((a, b) => a - b); + .sort((a, b) => a.startTime - b.startTime); }; useEffect(() => { diff --git a/assets/shared/templates/contacts.jsx b/assets/shared/templates/contacts.jsx index 44a2f4a0..ac3148cd 100644 --- a/assets/shared/templates/contacts.jsx +++ b/assets/shared/templates/contacts.jsx @@ -1,7 +1,7 @@ import { useState, useEffect } from "react"; import { IntlProvider, FormattedMessage } from "react-intl"; import styled from "styled-components"; -import BaseSlideExecution from "../slide-utils/base-slide-execution.js"; +import useBaseSlideExecution from "../slide-utils/useBaseSlideExecution.js"; import da from "./contacts/lang/da.json"; import { getFirstMediaUrlFromField, @@ -72,17 +72,7 @@ function Contacts({ slide, content, run, slideDone, executionId }) { setTranslations(da); }, []); - /** Setup slide run function. */ - useEffect(() => { - const slideExecution = new BaseSlideExecution(slide, slideDone); - if (run) { - slideExecution.start(duration); - } - - return function cleanup() { - slideExecution.stop(); - }; - }, [run]); + useBaseSlideExecution({ slide, run, slideDone, duration }); return ( diff --git a/assets/shared/templates/iframe.jsx b/assets/shared/templates/iframe.jsx index ef2bb0ba..c6285298 100644 --- a/assets/shared/templates/iframe.jsx +++ b/assets/shared/templates/iframe.jsx @@ -1,5 +1,4 @@ -import { useEffect } from "react"; -import BaseSlideExecution from "../slide-utils/base-slide-execution.js"; +import useBaseSlideExecution from "../slide-utils/useBaseSlideExecution.js"; import { ThemeStyles } from "../slide-utils/slide-util.jsx"; import "../slide-utils/global-styles.css"; import templateConfig from "./iframe.json"; @@ -38,17 +37,7 @@ function renderSlide(slide, run, slideDone) { function IFrame({ slide, content, run, slideDone, executionId }) { const { source, duration = 15000 } = content; - /** Setup slide run function. */ - useEffect(() => { - const slideExecution = new BaseSlideExecution(slide, slideDone); - if (run) { - slideExecution.start(duration); - } - - return function cleanup() { - slideExecution.stop(); - }; - }, [run]); + useBaseSlideExecution({ slide, run, slideDone, duration }); return ( <> diff --git a/assets/shared/templates/image-text.jsx b/assets/shared/templates/image-text.jsx index a9ddf9b7..ecbcec16 100644 --- a/assets/shared/templates/image-text.jsx +++ b/assets/shared/templates/image-text.jsx @@ -2,7 +2,7 @@ import { createRef, useEffect, useRef, useState } from "react"; import parse from "html-react-parser"; import DOMPurify from "dompurify"; import { CSSTransition, TransitionGroup } from "react-transition-group"; -import BaseSlideExecution from "../slide-utils/base-slide-execution.js"; +import useBaseSlideExecution from "../slide-utils/useBaseSlideExecution.js"; import { getAllMediaUrlsFromField, ThemeStyles, @@ -197,21 +197,16 @@ function ImageText({ slide, content, run, slideDone, executionId }) { }, [images]); + useBaseSlideExecution({ slide, run, slideDone, duration }); + useEffect(() => { if (run) { startTheShow(); - - const slideExecution = new BaseSlideExecution(slide, slideDone); - slideExecution.start(duration); - - return () => { - slideExecution.stop(); - clearImageTimeout(); - }; + return clearImageTimeout; } return clearImageTimeout; - }, [run, slide, slideDone, duration]); + }, [run]); return ( <> diff --git a/assets/shared/templates/table.jsx b/assets/shared/templates/table.jsx index 53920590..a6481c67 100644 --- a/assets/shared/templates/table.jsx +++ b/assets/shared/templates/table.jsx @@ -1,6 +1,5 @@ -import { useEffect } from "react"; import styled from "styled-components"; -import BaseSlideExecution from "../slide-utils/base-slide-execution.js"; +import useBaseSlideExecution from "../slide-utils/useBaseSlideExecution.js"; import { getFirstMediaUrlFromField, ThemeStyles, @@ -68,17 +67,7 @@ function Table({ slide, content, run, slideDone, executionId }) { rootStyle.backgroundImage = `url("${backgroundImageUrl}")`; } - /** Setup slide run function. */ - useEffect(() => { - const slideExecution = new BaseSlideExecution(slide, slideDone); - if (run) { - slideExecution.start(duration); - } - - return function cleanup() { - slideExecution.stop(); - }; - }, [run]); + useBaseSlideExecution({ slide, run, slideDone, duration }); let gridStyle; if (header) { diff --git a/assets/shared/templates/travel.jsx b/assets/shared/templates/travel.jsx index bd018e0d..5eb68f93 100644 --- a/assets/shared/templates/travel.jsx +++ b/assets/shared/templates/travel.jsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import DOMPurify from "dompurify"; import parse from "html-react-parser"; import { IntlProvider, FormattedMessage } from "react-intl"; -import BaseSlideExecution from "../slide-utils/base-slide-execution.js"; +import useBaseSlideExecution from "../slide-utils/useBaseSlideExecution.js"; import { getFirstMediaUrlFromField, ThemeStyles, @@ -109,17 +109,7 @@ function Travel({ iFrameClass = "iframe grow"; } - // Setup slide run function - useEffect(() => { - const slideExecution = new BaseSlideExecution(slide, slideDone); - if (run) { - slideExecution.start(duration); - } - - return function cleanup() { - slideExecution.stop(); - }; - }, [run]); + useBaseSlideExecution({ slide, run, slideDone, duration }); // Create url useEffect(() => { diff --git a/assets/shared/templates/vimeo-player.jsx b/assets/shared/templates/vimeo-player.jsx index bf5e27ce..d2f88255 100644 --- a/assets/shared/templates/vimeo-player.jsx +++ b/assets/shared/templates/vimeo-player.jsx @@ -1,6 +1,5 @@ -import { useEffect } from "react"; import Vimeo from "@u-wave/react-vimeo"; // eslint-disable-line import/no-unresolved -import BaseSlideExecution from "../slide-utils/base-slide-execution.js"; +import useBaseSlideExecution from "../slide-utils/useBaseSlideExecution.js"; import { ThemeStyles } from "../slide-utils/slide-util.jsx"; import "../slide-utils/global-styles.css"; import "./vimeo-player/vimeo-player.scss"; @@ -40,17 +39,7 @@ function renderSlide(slide, run, slideDone) { function VimeoPlayer({ slide, content, run, slideDone, executionId }) { const { vimeoid, duration = 15000, mediaContain } = content; - /** Setup slide run function. */ - useEffect(() => { - const slideExecution = new BaseSlideExecution(slide, slideDone); - if (run) { - slideExecution.start(duration); - } - - return function cleanup() { - slideExecution.stop(); - }; - }, [run]); + useBaseSlideExecution({ slide, run, slideDone, duration }); return ( <> From 8f403a5469a23c309c441208c8a2c8783644e901 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Sun, 12 Apr 2026 09:40:46 +0200 Subject: [PATCH 05/16] 6871: Added test for useBaseSlideExecution --- Taskfile.yml | 2 +- .../template/template-slide-execution.spec.js | 47 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 assets/tests/template/template-slide-execution.spec.js diff --git a/Taskfile.yml b/Taskfile.yml index d0bd361a..348c480a 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -202,7 +202,7 @@ tasks: test:frontend-local: desc: "Runs frontend tests from the local machine." cmds: - - BASE_URL="https://display.local.itkdev.dk" npx playwright test + - BASE_URL="https://display.local.itkdev.dk" npx playwright test {{.CLI_ARGS}} test:frontend-local-ui: desc: "Runs frontend tests from the local machine in UI mode." diff --git a/assets/tests/template/template-slide-execution.spec.js b/assets/tests/template/template-slide-execution.spec.js new file mode 100644 index 00000000..718bd565 --- /dev/null +++ b/assets/tests/template/template-slide-execution.spec.js @@ -0,0 +1,47 @@ +import { test, expect } from "@playwright/test"; + +test.describe("useBaseSlideExecution hook", () => { + test("Should call slideDone after duration expires", async ({ page }) => { + // book-review-0 has duration: 5000 and uses useBaseSlideExecution. + const consoleDone = page.waitForEvent("console", { + predicate: (msg) => msg.text() === "slide done", + timeout: 10000, + }); + + await page.goto("/template/book-review-0"); + await expect(page.locator(".template-book-review")).toBeVisible(); + + const start = Date.now(); + await consoleDone; + const elapsed = Date.now() - start; + + // Duration is 5000ms. Allow a margin for browser/timer variance. + expect(elapsed).toBeGreaterThan(4000); + expect(elapsed).toBeLessThan(8000); + }); + + test("Should not call slideDone if slide is removed before duration", async ({ + page, + }) => { + // Navigate to a slide, then navigate away before duration (5000ms) expires. + let slideDoneCalled = false; + + page.on("console", (msg) => { + if (msg.text() === "slide done") { + slideDoneCalled = true; + } + }); + + await page.goto("/template/book-review-0"); + await expect(page.locator(".template-book-review")).toBeVisible(); + + // Navigate away after 1s, well before the 5s duration. + await page.waitForTimeout(1000); + await page.goto("/template"); + + // Wait a bit past when the timer would have fired. + await page.waitForTimeout(6000); + + expect(slideDoneCalled).toBe(false); + }); +}); From 15e83c1036ee1b3b9edd7bc2b519c43c5537d6e6 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Sun, 12 Apr 2026 10:13:06 +0200 Subject: [PATCH 06/16] 6871: Introduced hook for slide execution where multiple entries are iterated before transitioning to next slide --- .../useMultipleEntrySlideExecution.js | 79 ++++++++++++++ assets/shared/templates/rss.jsx | 103 ++++++++---------- 2 files changed, 122 insertions(+), 60 deletions(-) create mode 100644 assets/shared/slide-utils/useMultipleEntrySlideExecution.js diff --git a/assets/shared/slide-utils/useMultipleEntrySlideExecution.js b/assets/shared/slide-utils/useMultipleEntrySlideExecution.js new file mode 100644 index 00000000..995996a5 --- /dev/null +++ b/assets/shared/slide-utils/useMultipleEntrySlideExecution.js @@ -0,0 +1,79 @@ +import { useEffect, useRef, useState } from "react"; + +/** + * Hook to manage slide execution for templates that cycle through + * multiple entries (RSS feeds, news feeds, slideshows, etc.). + * + * Uses refs to avoid stale closures while keeping [run] as the + * only dependency that triggers the cycling. + * + * @param {object} options + * @param {Array} options.entries Array of entries to cycle through. + * @param {boolean} options.run Whether the slide should run. + * @param {object} options.slide The slide object. + * @param {Function} options.slideDone Callback when cycling completes. + * @param {number} options.entryDuration Duration per entry in ms. + */ +function useMultipleEntrySlideExecution({ + entries, + run, + slide, + slideDone, + entryDuration, +}) { + const [entryIndex, setEntryIndex] = useState(0); + const [currentEntry, setCurrentEntry] = useState(null); + + // Refs to avoid stale closures (same pattern as useBaseSlideExecution) + const slideRef = useRef(slide); + const slideDoneRef = useRef(slideDone); + const entriesRef = useRef(entries); + const entryDurationRef = useRef(entryDuration); + + slideRef.current = slide; + slideDoneRef.current = slideDone; + entriesRef.current = entries; + entryDurationRef.current = entryDuration; + + useEffect(() => { + if (!run || !entriesRef.current?.length) return; + + let timeoutId = null; + let stopped = false; + + const showEntry = (index) => { + if (stopped) return; + + if (index >= entriesRef.current.length) { + slideDoneRef.current(slideRef.current); + return; + } + + setEntryIndex(index); + setCurrentEntry(entriesRef.current[index]); + + const safeDuration = + Number.isFinite(entryDurationRef.current) && + entryDurationRef.current > 0 + ? entryDurationRef.current + : 15000; + + timeoutId = setTimeout(() => { + showEntry(index + 1); + }, safeDuration); + }; + + showEntry(0); + + return () => { + stopped = true; + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + }; + }, [run]); + + return { currentEntry, entryIndex }; +} + +export default useMultipleEntrySlideExecution; diff --git a/assets/shared/templates/rss.jsx b/assets/shared/templates/rss.jsx index f65d6242..52bf89dd 100644 --- a/assets/shared/templates/rss.jsx +++ b/assets/shared/templates/rss.jsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect } from "react"; import dayjs from "dayjs"; import localeDa from "dayjs/locale/da"; import localizedFormat from "dayjs/plugin/localizedFormat"; @@ -8,6 +8,7 @@ import { ThemeStyles, } from "../slide-utils/slide-util.jsx"; import GlobalStyles from "../slide-utils/GlobalStyles.js"; +import useMultipleEntrySlideExecution from "../slide-utils/useMultipleEntrySlideExecution.js"; import "./rss/rss.scss"; import templateConfig from "./rss.json"; @@ -31,6 +32,16 @@ function renderSlide(slide, run, slideDone) { ); } +/** + * Capitalize the datestring, as it starts with the weekday. + * + * @param {string} s The string to capitalize. + * @returns {string} The capitalized string. + */ +const capitalize = (s) => { + return s.charAt(0).toUpperCase() + s.slice(1); +}; + /** * RSS component. * @@ -43,62 +54,38 @@ function renderSlide(slide, run, slideDone) { * @returns {JSX.Element} The component. */ function RSS({ slide, content, run, slideDone, executionId }) { - const [entryIndex, setEntryIndex] = useState(0); - const [currentEntry, setCurrentEntry] = useState(null); - const timeoutRef = useRef(null); - - if (!slide?.feed) { - return ""; - } - const { fontSize = "m", image, mediaContain } = content; - const { feedData = [], feed = {} } = slide; + const { feedData = [], feed = {} } = slide ?? {}; const { configuration = {} } = feed; const { entryDuration = 10, numberOfEntries = 5 } = configuration; - const rootStyle = {}; - const feedLength = Math.min(numberOfEntries, feedData?.entries?.length ?? 0); - const imageUrl = getFirstMediaUrlFromField(slide.mediaData, image); - - // Set background image. - if (imageUrl) { - rootStyle.backgroundImage = `url("${imageUrl}")`; - } + const feedEntries = feedData?.entries?.slice(0, numberOfEntries) ?? []; - /** - * Capitalize the datestring, as it starts with the weekday. - * - * @param {string} s The string to capitalize. - * @returns {string} The capitalized string. - */ - const capitalize = (s) => { - return s.charAt(0).toUpperCase() + s.slice(1); - }; - - const entryDone = (index) => { - const nextIndex = index + 1; - - if (nextIndex >= feedLength) { - slideDone(slide); - } else { - setEntryIndex(nextIndex); - setCurrentEntry(feedData?.entries[nextIndex]); - timeoutRef.current = setTimeout(() => { - entryDone(nextIndex); - }, entryDuration * 1000); - } - }; + const { currentEntry, entryIndex } = useMultipleEntrySlideExecution({ + entries: feedEntries, + run, + slide, + slideDone, + entryDuration: entryDuration * 1000, + }); /** Sets localized formats (dayjs) */ useEffect(() => { dayjs.extend(localizedFormat); }, []); - useEffect(() => { - if (run) { - entryDone(-1); - } - }, [run]); + const imageUrl = getFirstMediaUrlFromField(slide?.mediaData, image); + + const rootStyle = {}; + + // Set background image. + if (imageUrl) { + rootStyle.backgroundImage = `url("${imageUrl}")`; + } + + if (!slide?.feed) { + return null; + } return ( <> @@ -109,27 +96,23 @@ function RSS({ slide, content, run, slideDone, executionId }) { style={rootStyle} > - {currentEntry && ( - <> - {currentEntry.lastModified && ( - - {capitalize( - dayjs(currentEntry.lastModified) - .locale(localeDa) - .format("LLLL"), - )} - + {currentEntry?.lastModified && ( + + {capitalize( + dayjs(currentEntry.lastModified) + .locale(localeDa) + .format("LLLL"), )} - + )} {slide?.feedData?.title} - {slide?.feed.configuration.showFeedProgress && ( + {slide?.feed?.configuration?.showFeedProgress && ( - {feedLength > 0 && ( + {feedEntries.length > 0 && ( - {entryIndex + 1} / {feedLength} + {entryIndex + 1} / {feedEntries.length} )} From 97d0f444af2e4a1d7340a97f61d0c0438ed3a8d2 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Sun, 12 Apr 2026 10:25:17 +0200 Subject: [PATCH 07/16] 6871: Changed to use useMultipleEntrySlideExecution --- assets/shared/templates/instagram-feed.jsx | 55 ++++++-------- assets/shared/templates/news-feed.jsx | 88 ++++++---------------- 2 files changed, 49 insertions(+), 94 deletions(-) diff --git a/assets/shared/templates/instagram-feed.jsx b/assets/shared/templates/instagram-feed.jsx index 2c3c1497..2e8c121b 100644 --- a/assets/shared/templates/instagram-feed.jsx +++ b/assets/shared/templates/instagram-feed.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import dayjs from "dayjs"; import localeDa from "dayjs/locale/da"; import relativeTime from "dayjs/plugin/relativeTime"; @@ -8,6 +8,7 @@ import DOMPurify from "dompurify"; import Shape from "./instagram-feed/shape.svg"; import InstagramLogo from "./instagram-feed/instagram-logo.svg"; import { ThemeStyles } from "../slide-utils/slide-util.jsx"; +import useMultipleEntrySlideExecution from "../slide-utils/useMultipleEntrySlideExecution.js"; import "../slide-utils/global-styles.css"; import "./instagram-feed/instagram-feed.scss"; import templateConfig from "./instagram-feed.json"; @@ -47,11 +48,10 @@ function InstagramFeed({ slide, content, run, slideDone, executionId }) { dayjs.extend(localizedFormat); dayjs.extend(relativeTime); - const [currentPost, setCurrentPost] = useState(null); - // Animation const [show, setShow] = useState(true); const animationDuration = 1500; + const fallbackRef = useRef(null); const { feedData = [] } = slide; const { hashtagText, orientation, imageWidth = null, mediaContain } = content; @@ -63,44 +63,39 @@ function InstagramFeed({ slide, content, run, slideDone, executionId }) { const { maxEntries = 5 } = content; const maxEntriesToShow = Number.isInteger(maxEntries) ? maxEntries : 5; + const feedEntries = feedData?.slice(0, maxEntriesToShow) ?? []; + + const { currentEntry: currentPost } = useMultipleEntrySlideExecution({ + entries: feedEntries, + run, + slide, + slideDone, + entryDuration: duration, + }); - /** Setup feed entry switch and animation, if there is more than one post. */ + // Trigger fade-out animation before entry changes. useEffect(() => { - const timer = setTimeout(() => { - const currentIndex = feedData.indexOf(currentPost); - const nextIndex = - (currentIndex + 1) % Math.min(feedData.length, maxEntriesToShow); - - if (nextIndex === 0) { - slideDone(slide); - } else { - setCurrentPost(feedData[nextIndex]); - setShow(true); - } - }, duration); + if (!currentPost) return; + setShow(true); const animationTimer = setTimeout(() => { setShow(false); }, duration - animationDuration); - return function cleanup() { - if (timer !== null) { - clearInterval(timer); - } - if (animationTimer !== null) { - clearInterval(animationTimer); - } - }; + return () => clearTimeout(animationTimer); }, [currentPost]); + // If no content, wait 1 second and continue to next slide. useEffect(() => { - if (run) { - if (feedData?.length > 0) { - setCurrentPost(feedData[0]); - } else { - setTimeout(() => slideDone(slide), 1000); - } + if (run && feedEntries.length === 0) { + fallbackRef.current = setTimeout(() => slideDone(slide), 1000); } + + return () => { + if (fallbackRef.current) { + clearTimeout(fallbackRef.current); + } + }; }, [run]); const getSanitizedMarkup = (textMarkup) => { diff --git a/assets/shared/templates/news-feed.jsx b/assets/shared/templates/news-feed.jsx index 0fb6fe19..338a14b5 100644 --- a/assets/shared/templates/news-feed.jsx +++ b/assets/shared/templates/news-feed.jsx @@ -8,6 +8,7 @@ import { getFirstMediaUrlFromField, ThemeStyles, } from "../slide-utils/slide-util.jsx"; +import useMultipleEntrySlideExecution from "../slide-utils/useMultipleEntrySlideExecution.js"; import "../slide-utils/global-styles.css"; import "./news-feed/news-feed.scss"; import templateConfig from "./news-feed.json"; @@ -47,12 +48,8 @@ function NewsFeed({ slide, content, run, slideDone, executionId }) { dayjs.extend(localizedFormat); dayjs.extend(relativeTime); - const [currentPost, setCurrentPost] = useState(null); - const [posts, setPosts] = useState([]); const [qr, setQr] = useState(null); - const transitionRef = useRef(null); - - const timerRef = useRef(); + const fallbackRef = useRef(null); const { feedData = [], mediaData = {} } = slide; const { @@ -63,77 +60,40 @@ function NewsFeed({ slide, content, run, slideDone, executionId }) { } = content; const fallbackImageUrl = getFirstMediaUrlFromField(mediaData, fallbackImage); + const feedEntries = feedData?.entries ?? []; - const duration = entryDuration * 1000; + const { currentEntry: currentPost } = useMultipleEntrySlideExecution({ + entries: feedEntries, + run, + slide, + slideDone, + entryDuration: entryDuration * 1000, + }); - // Setup feed entry switch, if there is more than one post. + // Generate QR code for current post link. useEffect(() => { - if (currentPost) { - timerRef.current = setTimeout(() => { - const currentIndex = posts.indexOf(currentPost); - const nextIndex = (currentIndex + 1) % posts.length; - - if (nextIndex === 0) { - slideDone(slide); - } else { - setCurrentPost(posts[nextIndex]); - } - }, duration); - - if (!currentPost?.link) { - setQr(null); - } else { - QRCode.toDataURL(currentPost.link, { - margin: 0, - color: { - dark: "#000000", - light: "#ffffff00", - }, - }).then((data) => { - setQr(data); - }); - } + if (!currentPost?.link) { + setQr(null); + return; } - - return function cleanup() { - if (timerRef?.current) { - clearInterval(timerRef.current); - } - }; + QRCode.toDataURL(currentPost.link, { + margin: 0, + color: { dark: "#000000", light: "#ffffff00" }, + }).then((data) => setQr(data)); }, [currentPost]); + // If no content, wait 5 seconds and continue to next slide. useEffect(() => { - if (posts.length > 0) { - setCurrentPost(posts[0]); - } - }, [posts]); - - useEffect(() => { - if (feedData?.entries?.length > 0) { - setPosts(feedData.entries); - } else if (!transitionRef.current) { - // If no content, wait 5 seconds and continue to next slide. - transitionRef.current = setTimeout(() => { - slideDone(slide); - }, 5000); - } - }, [feedData]); - - useEffect(() => { - if (run) { - if (posts?.length > 0) { - setCurrentPost(posts[0]); - } + if (run && feedEntries.length === 0) { + fallbackRef.current = setTimeout(() => slideDone(slide), 5000); } - }, [run]); - useEffect(() => { return () => { - if (transitionRef.current) { - clearInterval(transitionRef.current); + if (fallbackRef.current) { + clearTimeout(fallbackRef.current); } }; - }, []); + }, [run]); const getImageUrl = (post) => { let imageUrl = fallbackImageUrl ?? null; From c09d8ca8a1890e67b28570d3616cb5dda764aacb Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Sun, 12 Apr 2026 10:54:27 +0200 Subject: [PATCH 08/16] 6871: Transition to use hook --- assets/shared/templates/poster.jsx | 93 +++++-------- assets/shared/templates/slideshow.jsx | 145 +++++++++------------ assets/template/fixtures/slide-fixtures.js | 53 ++++++++ 3 files changed, 147 insertions(+), 144 deletions(-) diff --git a/assets/shared/templates/poster.jsx b/assets/shared/templates/poster.jsx index 46501f4e..564c9d02 100644 --- a/assets/shared/templates/poster.jsx +++ b/assets/shared/templates/poster.jsx @@ -5,6 +5,7 @@ import localizedFormat from "dayjs/plugin/localizedFormat"; import { IntlProvider, FormattedMessage } from "react-intl"; import da from "./poster/lang/da.json"; import { ThemeStyles } from "../slide-utils/slide-util.jsx"; +import useMultipleEntrySlideExecution from "../slide-utils/useMultipleEntrySlideExecution.js"; import "../slide-utils/global-styles.css"; import "./poster/poster.scss"; import templateConfig from "./poster.json"; @@ -42,11 +43,8 @@ function renderSlide(slide, run, slideDone) { */ function Poster({ slide, content, run, slideDone, executionId }) { const [translations, setTranslations] = useState({}); - const [currentEvent, setCurrentEvent] = useState(null); - const [currentIndex, setCurrentIndex] = useState(null); const [show, setShow] = useState(true); - const timerRef = useRef(null); - const animationTimerRef = useRef(null); + const fallbackRef = useRef(null); const logo = slide?.theme?.logo; const { showLogo, mediaContain } = content; @@ -58,6 +56,16 @@ function Poster({ slide, content, run, slideDone, executionId }) { const animationDuration = 500; const { duration = 15000 } = content; // default 15s. + const feedEntries = feedData ?? []; + + const { currentEntry: currentEvent } = useMultipleEntrySlideExecution({ + entries: feedEntries, + run, + slide, + slideDone, + entryDuration: duration, + }); + // Props from currentEvent. const { endDate, @@ -121,75 +129,38 @@ function Poster({ slide, content, run, slideDone, executionId }) { ); }; + // Trigger fade-out animation before entry changes. useEffect(() => { - if (currentEvent) { - setShow(true); - } - }, [currentEvent]); - - // Setup feed entry switch and animation, if there is more than one post. - useEffect(() => { - if (currentIndex === null) { - return; - } - - setCurrentEvent(feedData[currentIndex]); - - const nextIndex = (currentIndex + 1) % feedData.length; + if (!currentEvent) return; - if (nextIndex > 0) { - if (animationTimerRef?.current) { - clearInterval(animationTimerRef.current); - } + setShow(true); + const animationTimer = setTimeout( + () => { + setShow(false); + }, + duration - animationDuration + 50, + ); - animationTimerRef.current = setTimeout( - () => { - setShow(false); - }, - duration - animationDuration + 50, - ); - } + return () => clearTimeout(animationTimer); + }, [currentEvent]); - if (timerRef?.current) { - clearInterval(timerRef.current); + // If no content, wait 1 second and continue to next slide. + useEffect(() => { + if (run && feedEntries.length === 0) { + fallbackRef.current = setTimeout(() => slideDone(slide), 1000); } - timerRef.current = setTimeout(() => { - if (nextIndex === 0) { - slideDone(slide); - } else { - setCurrentIndex(nextIndex); - } - }, duration); - }, [currentIndex]); - - useEffect(() => { - if (run) { - if (feedData?.length > 0) { - setCurrentIndex(0); - } else { - setTimeout(() => slideDone(slide), 1000); + return () => { + if (fallbackRef.current) { + clearTimeout(fallbackRef.current); } - } else { - setCurrentEvent(null); - setCurrentIndex(null); - } + }; }, [run]); - // Imports language strings, sets localized formats and sets timer. + // Imports language strings and sets localized formats. useEffect(() => { dayjs.extend(localizedFormat); - setTranslations(da); - - return function cleanup() { - if (timerRef?.current) { - clearInterval(timerRef.current); - } - if (animationTimerRef?.current) { - clearInterval(animationTimerRef.current); - } - }; }, []); return ( diff --git a/assets/shared/templates/slideshow.jsx b/assets/shared/templates/slideshow.jsx index 2e6b1023..4ac88223 100644 --- a/assets/shared/templates/slideshow.jsx +++ b/assets/shared/templates/slideshow.jsx @@ -3,6 +3,7 @@ import { getAllMediaUrlsFromField, ThemeStyles, } from "../slide-utils/slide-util.jsx"; +import useMultipleEntrySlideExecution from "../slide-utils/useMultipleEntrySlideExecution.js"; import "../slide-utils/global-styles.css"; import "./slideshow/slideshow.scss"; import templateConfig from "./slideshow.json"; @@ -52,15 +53,17 @@ function Slideshow({ slide, content, run, slideDone, executionId }) { const imageDurationInMilliseconds = imageDuration * 1000; - const [index, setIndex] = useState(0); const [fade, setFade] = useState(false); const [animationIndex, setAnimationIndex] = useState(0); + const [animationKeyframesCurrent, setAnimationKeyframesCurrent] = useState(""); + const [animationKeyframesNext, setAnimationKeyframesNext] = useState(""); + const fallbackRef = useRef(null); const fadeEnabled = transition === "fade"; const fadeDuration = 1000; const fadeSafeMargin = 50; - const animationName = "animationForImage"; + const getAnimationName = (i) => `animationForImage-${executionId}-${i % 2}`; const animationDuration = imageDurationInMilliseconds + (fadeEnabled ? fadeDuration * 2 : 0); @@ -78,8 +81,13 @@ function Slideshow({ slide, content, run, slideDone, executionId }) { logoClasses.push(logoPosition); } - const timeoutRef = useRef(null); - const fadeRef = useRef(null); + const { entryIndex: index } = useMultipleEntrySlideExecution({ + entries: imageUrls, + run, + slide, + slideDone, + entryDuration: imageDurationInMilliseconds, + }); /** * A random function to simplify the code where random is used @@ -98,12 +106,12 @@ function Slideshow({ slide, content, run, slideDone, executionId }) { * @param {string} transform The transform. * @returns {string} The animation. */ - function createAnimation(grow, transform = "50% 50%") { + function createAnimation(name, grow, transform = "50% 50%") { const transformOrigin = transform; const startSize = grow ? 1 : 1.2; const finishSize = grow ? 1.2 : 1; - return `@keyframes ${animationName} { + return `@keyframes ${name} { 0% { transform: scale(${startSize}); transform-origin: ${transformOrigin}; @@ -129,7 +137,7 @@ function Slideshow({ slide, content, run, slideDone, executionId }) { * @param {string} animationType The animation type. * @returns {string | null} The current animation. */ - function getCurrentAnimation(animationType) { + function getCurrentAnimation(name, animationType) { const animationTypes = [ "zoom-in-middle", "zoom-out-middle", @@ -140,15 +148,16 @@ function Slideshow({ slide, content, run, slideDone, executionId }) { const randomPercent = `${random(100) + 1}% ${random(100) + 1}%`; switch (animationType) { case "zoom-in-middle": - return createAnimation(true); + return createAnimation(name, true); case "zoom-out-middle": - return createAnimation(false); + return createAnimation(name, false); case "zoom-in-random": - return createAnimation(true, randomPercent); + return createAnimation(name, true, randomPercent); case "zoom-out-random": - return createAnimation(false, randomPercent); + return createAnimation(name, false, randomPercent); case "random": return getCurrentAnimation( + name, animationTypes[random(animationTypes.length)], ); default: @@ -157,104 +166,67 @@ function Slideshow({ slide, content, run, slideDone, executionId }) { } // Get image style for the given image url. - const getImageStyle = (imageUrl, enableAnimation, localAnimationDuration) => { + const getImageStyle = ( + imageUrl, + imageIndex, + enableAnimation, + localAnimationDuration, + ) => { const imageStyle = { backgroundImage: `url(${imageUrl})`, }; if (enableAnimation) { - imageStyle.animation = `${animationName} ${localAnimationDuration}ms`; + imageStyle.animation = `${getAnimationName(imageIndex)} ${localAnimationDuration}ms`; } return imageStyle; }; + // If there are no images in slide, wait for 2s before continuing to avoid crashes. useEffect(() => { - // Setup animation - if (animation) { - // Adds the animation to the stylesheet. because there is an element of random, we cannot have it in the .scss file. - const styleSheet = document.styleSheets[0]; - const currentAnimation = getCurrentAnimation(animation); - if (currentAnimation !== null) { - styleSheet.insertRule( - getCurrentAnimation(animation), - styleSheet.cssRules.length, - ); - } + if (run && imageUrls.length === 0) { + fallbackRef.current = setTimeout(() => slideDone(slide), 2000); } return () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - if (fadeRef.current) { - clearTimeout(fadeRef.current); + if (fallbackRef.current) { + clearTimeout(fallbackRef.current); } }; - }, []); - - // Setup image progress. - useEffect(() => { - if (run) { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - if (fadeRef.current) { - clearTimeout(fadeRef.current); - } - - if (imageUrls.length === 0) { - // If there are no images in slide, wait for 2s before continuing to avoid crashes. - setTimeout(() => { - slideDone(slide); - }, 2000); - } else { - setFade(false); - setIndex(0); - setAnimationIndex(0); - } - } }, [run]); + // Regenerate animation keyframes and trigger fade for each image. + // Pre-start the scale animation on the next image during the fade so + // the zoom is already in progress when the image becomes visible. useEffect(() => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); + setAnimationIndex(index); + setFade(false); + + if (animation) { + const keyframes = + getCurrentAnimation(getAnimationName(index), animation) ?? ""; + setAnimationKeyframesCurrent(keyframes); } - timeoutRef.current = setTimeout(() => { - let newIndex = index + 1; + if (!fadeEnabled) return; - if (newIndex === imageUrls.length) { - newIndex = 0; - } + const fadeTimer = setTimeout(() => { + setFade(true); - if (newIndex !== 0) { - setAnimationIndex(newIndex); - } - - if (fadeEnabled && newIndex !== 0) { - // Fade to next image. - setFade(true); + const nextIndex = index + 1; + if (nextIndex < imageUrls.length) { + setAnimationIndex(nextIndex); - if (fadeRef.current) { - clearTimeout(fadeRef.current); + if (animation) { + const nextKeyframes = + getCurrentAnimation(getAnimationName(nextIndex), animation) ?? ""; + setAnimationKeyframesNext(nextKeyframes); } - - fadeRef.current = setTimeout(() => { - setFade(false); - - if (newIndex === 0) { - slideDone(slide); - } else { - setIndex(newIndex); - } - }, fadeDuration - fadeSafeMargin); - } else if (newIndex === 0) { - slideDone(slide); - } else { - setIndex(newIndex); } - }, imageDurationInMilliseconds); + }, imageDurationInMilliseconds - fadeDuration + fadeSafeMargin); + + return () => clearTimeout(fadeTimer); }, [index]); return ( @@ -301,6 +273,7 @@ function Slideshow({ slide, content, run, slideDone, executionId }) {
+ {(animationKeyframesCurrent || animationKeyframesNext) && ( + + )} ); diff --git a/assets/template/fixtures/slide-fixtures.js b/assets/template/fixtures/slide-fixtures.js index 55691a47..9ec62833 100644 --- a/assets/template/fixtures/slide-fixtures.js +++ b/assets/template/fixtures/slide-fixtures.js @@ -2023,6 +2023,59 @@ const slideFixtures = [ animation: "none", }, }, + { + id: "slideshow-3", + templateData: { + id: "01FP2SNSC9VXD10ZKXQR819NS9", + }, + themeFile: null, + theme: { + logo: { + assets: { + uri: "/fixtures/template/images/mountain1.jpeg", + }, + }, + }, + mediaData: { + "/v1/media/00000000000000000000000001": { + assets: { + uri: "/fixtures/template/images/mountain1.jpeg", + }, + }, + "/v1/media/00000000000000000000000002": { + assets: { + uri: "/fixtures/template/images/mountain2.jpeg", + }, + }, + "/v1/media/00000000000000000000000003": { + assets: { + uri: "/fixtures/template/images/mountain3.jpeg", + }, + }, + "/v1/media/00000000000000000000000004": { + assets: { + uri: "/fixtures/template/images/mountain4.jpeg", + }, + }, + }, + // Disable dark mode for slide. + darkModeEnabled: false, + content: { + imageDuration: 5, + images: [ + "/v1/media/00000000000000000000000001", + "/v1/media/00000000000000000000000002", + "/v1/media/00000000000000000000000003", + "/v1/media/00000000000000000000000004", + ], + showLogo: true, + logoSize: "l", + mediaContain: true, + logoPosition: "logo-position-top-right", + transition: "fade", + animation: "random", + }, + }, { id: "table-0", templateData: { From 771aeeed93079354314d2c556c8df2522f607738 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Sun, 12 Apr 2026 11:13:07 +0200 Subject: [PATCH 09/16] 6871: Fixed slideshow animation reset issues --- assets/shared/templates/slideshow.jsx | 33 ++++++++++++++++++++------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/assets/shared/templates/slideshow.jsx b/assets/shared/templates/slideshow.jsx index 4ac88223..73db1545 100644 --- a/assets/shared/templates/slideshow.jsx +++ b/assets/shared/templates/slideshow.jsx @@ -55,9 +55,12 @@ function Slideshow({ slide, content, run, slideDone, executionId }) { const [fade, setFade] = useState(false); const [animationIndex, setAnimationIndex] = useState(0); - const [animationKeyframesCurrent, setAnimationKeyframesCurrent] = useState(""); - const [animationKeyframesNext, setAnimationKeyframesNext] = useState(""); + // Two stable keyframe slots keyed by index % 2 (matching animation names). + // A slot only updates when its animation genuinely needs new keyframes, + // preventing unnecessary )} From 65c5a9135394e744f9ad453f2654ec80e120c4d8 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Sun, 12 Apr 2026 11:19:17 +0200 Subject: [PATCH 10/16] 6871: Fixed fixture --- assets/template/fixtures/slide-fixtures.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/template/fixtures/slide-fixtures.js b/assets/template/fixtures/slide-fixtures.js index 9ec62833..728af238 100644 --- a/assets/template/fixtures/slide-fixtures.js +++ b/assets/template/fixtures/slide-fixtures.js @@ -2024,7 +2024,7 @@ const slideFixtures = [ }, }, { - id: "slideshow-3", + id: "slideshow-3-random", templateData: { id: "01FP2SNSC9VXD10ZKXQR819NS9", }, @@ -2070,7 +2070,7 @@ const slideFixtures = [ ], showLogo: true, logoSize: "l", - mediaContain: true, + mediaContain: false, logoPosition: "logo-position-top-right", transition: "fade", animation: "random", From d5986daf263dc9c7bf526fcd993472ff87241587 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:45:16 +0200 Subject: [PATCH 11/16] 7258: Fixed paths where slideDown might not be called --- assets/shared/templates/rss.jsx | 16 +++++++++++++++- assets/shared/templates/video.jsx | 6 ++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/assets/shared/templates/rss.jsx b/assets/shared/templates/rss.jsx index 52bf89dd..52069feb 100644 --- a/assets/shared/templates/rss.jsx +++ b/assets/shared/templates/rss.jsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import dayjs from "dayjs"; import localeDa from "dayjs/locale/da"; import localizedFormat from "dayjs/plugin/localizedFormat"; @@ -58,6 +58,7 @@ function RSS({ slide, content, run, slideDone, executionId }) { const { feedData = [], feed = {} } = slide ?? {}; const { configuration = {} } = feed; const { entryDuration = 10, numberOfEntries = 5 } = configuration; + const fallbackRef = useRef(null); const feedEntries = feedData?.entries?.slice(0, numberOfEntries) ?? []; @@ -74,6 +75,19 @@ function RSS({ slide, content, run, slideDone, executionId }) { dayjs.extend(localizedFormat); }, []); + // If no content, wait 1 second and continue to next slide. + useEffect(() => { + if (run && feedEntries.length === 0) { + fallbackRef.current = setTimeout(() => slideDone(slide), 1000); + } + + return () => { + if (fallbackRef.current) { + clearTimeout(fallbackRef.current); + } + }; + }, [run]); + const imageUrl = getFirstMediaUrlFromField(slide?.mediaData, image); const rootStyle = {}; diff --git a/assets/shared/templates/video.jsx b/assets/shared/templates/video.jsx index ffb43879..3553ea43 100644 --- a/assets/shared/templates/video.jsx +++ b/assets/shared/templates/video.jsx @@ -53,6 +53,11 @@ function Video({ slide, content, run, slideDone, executionId }) { useEffect(() => { if (run) { + if (videoUrls.length === 0) { + slideDone(slide); + return; + } + videoRef?.current?.load(); videoRef?.current?.addEventListener("ended", onEnded); videoRef?.current?.addEventListener("error", onError); @@ -71,6 +76,7 @@ function Video({ slide, content, run, slideDone, executionId }) { if (videoRef?.current) { videoRef.current.controls = true; } + slideDone(slide); }); } } From f53ab04f3e9fa8e7cd043e1a7f9cf50c1e3437f5 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:55:18 +0200 Subject: [PATCH 12/16] 7258: Fixed video paths where slideDone might not be called --- assets/shared/templates/video.jsx | 78 +++++++++++++++++++------------ 1 file changed, 47 insertions(+), 31 deletions(-) diff --git a/assets/shared/templates/video.jsx b/assets/shared/templates/video.jsx index 3553ea43..8b0693d1 100644 --- a/assets/shared/templates/video.jsx +++ b/assets/shared/templates/video.jsx @@ -41,49 +41,65 @@ function renderSlide(slide, run, slideDone) { function Video({ slide, content, run, slideDone, executionId }) { const videoUrls = getAllMediaUrlsFromField(slide.mediaData, content.video); const videoRef = useRef(); + const doneRef = useRef(false); const { sound, mediaContain } = content; - const onEnded = () => { - slideDone(slide); - }; - - const onError = () => { - slideDone(slide); + const finish = () => { + if (!doneRef.current) { + doneRef.current = true; + slideDone(slide); + } }; useEffect(() => { - if (run) { - if (videoUrls.length === 0) { - slideDone(slide); - return; - } + if (!run) return; - videoRef?.current?.load(); - videoRef?.current?.addEventListener("ended", onEnded); - videoRef?.current?.addEventListener("error", onError); - videoRef.current.muted = true; + doneRef.current = false; - if (sound) { - videoRef.current.muted = false; - } + if (videoUrls.length === 0) { + finish(); + return; + } + + const video = videoRef.current; + if (!video) { + finish(); + return; + } - const promise = videoRef.current.play(); - - if (promise !== undefined) { - promise - .then(() => {}) - .catch(() => { - if (videoRef?.current) { - videoRef.current.controls = true; - } - slideDone(slide); - }); + let guardTimeout = null; + + const onLoadedMetadata = () => { + if (Number.isFinite(video.duration) && video.duration > 0) { + // Allow 10% extra time for buffering delays. + const guardMs = video.duration * 1.1 * 1000; + guardTimeout = setTimeout(finish, guardMs); } + }; + + video.addEventListener("ended", finish); + video.addEventListener("error", finish); + video.addEventListener("loadedmetadata", onLoadedMetadata); + + video.load(); + video.muted = !sound; + + const promise = video.play(); + + if (promise !== undefined) { + promise.then(() => {}).catch(() => { + video.controls = true; + finish(); + }); } return () => { - videoRef?.current?.removeEventListener("ended", onEnded); - videoRef?.current?.removeEventListener("error", onError); + video.removeEventListener("ended", finish); + video.removeEventListener("error", finish); + video.removeEventListener("loadedmetadata", onLoadedMetadata); + if (guardTimeout !== null) { + clearTimeout(guardTimeout); + } }; }, [run]); From fa293af5cb87dd4ee7540c57842654bbadb97324 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:07:38 +0200 Subject: [PATCH 13/16] 7258: Fixed fixture --- assets/template/fixtures/slide-fixtures.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/template/fixtures/slide-fixtures.js b/assets/template/fixtures/slide-fixtures.js index 728af238..f40776b4 100644 --- a/assets/template/fixtures/slide-fixtures.js +++ b/assets/template/fixtures/slide-fixtures.js @@ -1795,7 +1795,7 @@ const slideFixtures = [ mediaData: { "/v1/media/00000000000000000000000001": { assets: { - uri: "/fixtures/template/mountain1.jpeg", + uri: "/fixtures/template/images/mountain1.jpeg", }, }, }, From 0658a2c47119b93d11c0a593efb2671b3ca3996c Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:42:28 +0200 Subject: [PATCH 14/16] 7258: Only apply fade where there are more entries to transition to --- CHANGELOG.md | 6 ++++++ assets/shared/templates/slideshow.jsx | 3 +-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3a3b015..4d0b19a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,12 @@ All notable changes to this project will be documented in this file. - Optimized release data fetching. - Optimized list loading. - Removed fixture length check from test. +- Introduced two hooks (useBaseSlideExecution, useMultipleEntrySlideExecution) that are used in the templates in place + of BaseSlideExecution in fixed duration slides and manual iteration over entries in slides that iterate through + elements before calling slideDone. +- Fixed issue with Slideshow animations that would be locked to one type. +- Fixed sorting in calendar "multiple" layout. +- Fixed video progression issues. ### NB! Prior to 3.x the project was split into separate repositories diff --git a/assets/shared/templates/slideshow.jsx b/assets/shared/templates/slideshow.jsx index 73db1545..a88d3e15 100644 --- a/assets/shared/templates/slideshow.jsx +++ b/assets/shared/templates/slideshow.jsx @@ -228,10 +228,9 @@ function Slideshow({ slide, content, run, slideDone, executionId }) { if (!fadeEnabled) return; const fadeTimer = setTimeout(() => { - setFade(true); - const nextIndex = index + 1; if (nextIndex < imageUrls.length) { + setFade(true); setAnimationIndex(nextIndex); if (animation) { From 12f425c47af6e1be151da635f871852b4c6a454d Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:46:25 +0200 Subject: [PATCH 15/16] 7258: Moved base-slide-execution into hook instead of as a separate file --- .../slide-utils/base-slide-execution.js | 63 ------------------- .../slide-utils/useBaseSlideExecution.js | 21 +++---- 2 files changed, 10 insertions(+), 74 deletions(-) delete mode 100644 assets/shared/slide-utils/base-slide-execution.js diff --git a/assets/shared/slide-utils/base-slide-execution.js b/assets/shared/slide-utils/base-slide-execution.js deleted file mode 100644 index d59fb1b4..00000000 --- a/assets/shared/slide-utils/base-slide-execution.js +++ /dev/null @@ -1,63 +0,0 @@ -/** - * BaseSlideExecution. - * - * Slide runs for duration then calls slideDone(). - */ -class BaseSlideExecution { - // Function to call when the slide is done executing. - slideDone; - - // Slide that should be run. - slide; - - // Slide timeout. - slideTimeout = null; - - /** - * Constructor. - * - * @param {object} slide The slide to execute. - * @param {Function} slideDone The function to invoke when execution is done. - */ - constructor(slide, slideDone) { - this.slide = slide; - this.slideDone = slideDone; - } - - /** - * Start execution of slide. - * - * @param {number} duration Slide duration in milliseconds. - */ - start(duration) { - if (this.slideTimeout !== null) { - clearTimeout(this.slideTimeout); - } - - const safeDuration = - Number.isFinite(duration) && duration > 0 ? duration : 15000; - - // Wait duration then call slideDone. - this.slideTimeout = setTimeout(() => { - if (typeof this.slideDone === "function") { - this.slideDone(this.slide); - } - this.slideTimeout = null; - }, safeDuration); - } - - /** - * Stops execution timeout. - * - * Does not call slideDone — this is intentional for cleanup-on-unmount - * scenarios where the slide was cancelled, not completed. - */ - stop() { - if (this.slideTimeout !== null) { - clearTimeout(this.slideTimeout); - this.slideTimeout = null; - } - } -} - -export default BaseSlideExecution; diff --git a/assets/shared/slide-utils/useBaseSlideExecution.js b/assets/shared/slide-utils/useBaseSlideExecution.js index 80ce0b8c..855604e0 100644 --- a/assets/shared/slide-utils/useBaseSlideExecution.js +++ b/assets/shared/slide-utils/useBaseSlideExecution.js @@ -1,5 +1,4 @@ import { useEffect, useRef } from "react"; -import BaseSlideExecution from "./base-slide-execution.js"; /** * Hook to manage slide execution lifecycle. @@ -23,18 +22,18 @@ function useBaseSlideExecution({ slide, run, slideDone, duration }) { durationRef.current = duration; useEffect(() => { - const slideExecution = new BaseSlideExecution( - slideRef.current, - (s) => slideDoneRef.current(s), - ); + if (!run) return; - if (run) { - slideExecution.start(durationRef.current); - } + const safeDuration = + Number.isFinite(durationRef.current) && durationRef.current > 0 + ? durationRef.current + : 15000; - return function cleanup() { - slideExecution.stop(); - }; + const timeoutId = setTimeout(() => { + slideDoneRef.current(slideRef.current); + }, safeDuration); + + return () => clearTimeout(timeoutId); }, [run]); } From 522d02673d1de677f78e96af8ecaeaf9124ca7f5 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:47:36 +0200 Subject: [PATCH 16/16] 7258: Applied coding standards --- assets/shared/templates/image-text.jsx | 1 - assets/shared/templates/slideshow.jsx | 32 +++++++++++++++----------- assets/shared/templates/video.jsx | 10 ++++---- 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/assets/shared/templates/image-text.jsx b/assets/shared/templates/image-text.jsx index ecbcec16..3d9ec992 100644 --- a/assets/shared/templates/image-text.jsx +++ b/assets/shared/templates/image-text.jsx @@ -194,7 +194,6 @@ function ImageText({ slide, content, run, slideDone, executionId }) { } else { setCurrentImage(undefined); } - }, [images]); useBaseSlideExecution({ slide, run, slideDone, duration }); diff --git a/assets/shared/templates/slideshow.jsx b/assets/shared/templates/slideshow.jsx index a88d3e15..541f9091 100644 --- a/assets/shared/templates/slideshow.jsx +++ b/assets/shared/templates/slideshow.jsx @@ -221,26 +221,30 @@ function Slideshow({ slide, content, run, slideDone, executionId }) { preparedNextKeyframesRef.current = null; const keyframes = prepared ?? - (getCurrentAnimation(getAnimationName(index), animation) ?? ""); + getCurrentAnimation(getAnimationName(index), animation) ?? + ""; updateKeyframeSlot(index, keyframes); } if (!fadeEnabled) return; - const fadeTimer = setTimeout(() => { - const nextIndex = index + 1; - if (nextIndex < imageUrls.length) { - setFade(true); - setAnimationIndex(nextIndex); - - if (animation) { - const nextKeyframes = - getCurrentAnimation(getAnimationName(nextIndex), animation) ?? ""; - preparedNextKeyframesRef.current = nextKeyframes; - updateKeyframeSlot(nextIndex, nextKeyframes); + const fadeTimer = setTimeout( + () => { + const nextIndex = index + 1; + if (nextIndex < imageUrls.length) { + setFade(true); + setAnimationIndex(nextIndex); + + if (animation) { + const nextKeyframes = + getCurrentAnimation(getAnimationName(nextIndex), animation) ?? ""; + preparedNextKeyframesRef.current = nextKeyframes; + updateKeyframeSlot(nextIndex, nextKeyframes); + } } - } - }, imageDurationInMilliseconds - fadeDuration + fadeSafeMargin); + }, + imageDurationInMilliseconds - fadeDuration + fadeSafeMargin, + ); return () => clearTimeout(fadeTimer); }, [index]); diff --git a/assets/shared/templates/video.jsx b/assets/shared/templates/video.jsx index 8b0693d1..44984b4f 100644 --- a/assets/shared/templates/video.jsx +++ b/assets/shared/templates/video.jsx @@ -87,10 +87,12 @@ function Video({ slide, content, run, slideDone, executionId }) { const promise = video.play(); if (promise !== undefined) { - promise.then(() => {}).catch(() => { - video.controls = true; - finish(); - }); + promise + .then(() => {}) + .catch(() => { + video.controls = true; + finish(); + }); } return () => {