diff --git a/.changeset/olive-cameras-change.md b/.changeset/olive-cameras-change.md new file mode 100644 index 00000000000..d57d9e479fd --- /dev/null +++ b/.changeset/olive-cameras-change.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +[Image] New pause and play functionality for image gifs. diff --git a/packages/perseus/package.json b/packages/perseus/package.json index 81923365fc5..033b330011e 100644 --- a/packages/perseus/package.json +++ b/packages/perseus/package.json @@ -51,6 +51,7 @@ "@khanacademy/pure-markdown": "workspace:*", "@khanacademy/simple-markdown": "workspace:*", "@use-gesture/react": "^10.2.27", + "gifuct-js": "^2.1.2", "mafs": "0.19.0", "tiny-invariant": "catalog:prodDeps", "uuid": "^10.0.0" @@ -64,8 +65,8 @@ "@khanacademy/wonder-blocks-data": "catalog:devDeps", "@khanacademy/wonder-blocks-dropdown": "catalog:devDeps", "@khanacademy/wonder-blocks-form": "catalog:devDeps", - "@khanacademy/wonder-blocks-icon-button": "catalog:devDeps", "@khanacademy/wonder-blocks-icon": "catalog:devDeps", + "@khanacademy/wonder-blocks-icon-button": "catalog:devDeps", "@khanacademy/wonder-blocks-labeled-field": "catalog:devDeps", "@khanacademy/wonder-blocks-layout": "catalog:devDeps", "@khanacademy/wonder-blocks-link": "catalog:devDeps", diff --git a/packages/perseus/src/components/__tests__/gif-image.test.tsx b/packages/perseus/src/components/__tests__/gif-image.test.tsx new file mode 100644 index 00000000000..51bc5fa85e3 --- /dev/null +++ b/packages/perseus/src/components/__tests__/gif-image.test.tsx @@ -0,0 +1,301 @@ +import {act, render, screen, waitFor} from "@testing-library/react"; +import {parseGIF, decompressFrames} from "gifuct-js"; +import * as React from "react"; + +import GifImage from "../gif-image"; + +jest.mock("gifuct-js"); + +const GIF_SRC = "https://cdn.kastatic.org/test.gif"; + +// A minimal fake frame from gifuct-js with a 50ms delay. +const fakeFrame = { + patch: new Uint8ClampedArray(4), // 1x1 RGBA + delay: 50, + dims: {width: 1, height: 1, top: 0, left: 0}, + disposalType: 0, +}; + +describe("GifImage", () => { + beforeEach(() => { + // Make gifuct-js return two fake frames (100ms total loop). + (parseGIF as jest.Mock).mockReturnValue({}); + (decompressFrames as jest.Mock).mockReturnValue([fakeFrame, fakeFrame]); + // jsdom doesn't implement canvas getContext or ImageData. + jest.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({ + putImageData: jest.fn(), + clearRect: jest.fn(), + drawImage: jest.fn(), + imageSmoothingEnabled: true, + } as Partial as CanvasRenderingContext2D); + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + }), + ) as jest.Mock; + // @ts-expect-error - jsdom doesn't have ImageData + global.ImageData = class ImageData { + data: Uint8ClampedArray; + width: number; + height: number; + constructor( + data: Uint8ClampedArray, + width: number, + height: number, + ) { + this.data = data; + this.width = width; + this.height = height; + } + }; + }); + + it("renders a canvas when paused", () => { + // Arrange, Act + render( + , + ); + + // Assert + expect(screen.getByTestId("gif-canvas")).toBeInTheDocument(); + }); + + it("renders a canvas when playing", () => { + // Arrange, Act + render( + , + ); + + // Assert + expect(screen.getByTestId("gif-canvas")).toBeInTheDocument(); + }); + + describe("loop completion", () => { + it("calls onLoop after all frames have been rendered", async () => { + // Arrange + const onLoop = jest.fn(); + render( + , + ); + + // Wait for the fetch → decode promise chain to complete. + await waitFor(() => { + expect(decompressFrames).toHaveBeenCalled(); + }); + + // Act — advance enough for both 50ms frames to render. + // RAF fires at ~16ms intervals, so we need enough ticks + // for two frames plus the initial timestamp capture. + act(() => { + jest.advanceTimersByTime(200); + }); + + // Assert + expect(onLoop).toHaveBeenCalledTimes(1); + }); + + it("does not call onLoop again after pausing", async () => { + // Arrange + const onLoop = jest.fn(); + const {rerender} = render( + , + ); + + await waitFor(() => { + expect(decompressFrames).toHaveBeenCalled(); + }); + act(() => { + jest.advanceTimersByTime(200); + }); + expect(onLoop).toHaveBeenCalledTimes(1); + + // Act - pause the GIF + rerender( + , + ); + act(() => { + jest.advanceTimersByTime(200); + }); + + // Assert - onLoop should not have been called again + expect(onLoop).toHaveBeenCalledTimes(1); + }); + }); + + it("resets to frame 0 after loop completes and is replayed", async () => { + // Arrange — play through one full loop + const onLoop = jest.fn(); + const {rerender} = render( + , + ); + + await waitFor(() => { + expect(decompressFrames).toHaveBeenCalled(); + }); + act(() => { + jest.advanceTimersByTime(200); + }); + expect(onLoop).toHaveBeenCalledTimes(1); + + // Act — sync parent state to paused, then play again + rerender( + , + ); + rerender( + , + ); + + // Advance enough for both frames to render from frame 0. + act(() => { + jest.advanceTimersByTime(200); + }); + // The loop completed a second time — onLoop fires again, + // confirming it played through both frames from the start. + expect(onLoop).toHaveBeenCalledTimes(2); + }); + + it("renders the hidden base canvas", () => { + // Arrange, Act + render( + , + ); + + // Assert + const patchCanvas = screen.getByTestId("gif-hidden-canvas"); + expect(patchCanvas).toBeInTheDocument(); + expect(patchCanvas).not.toBeVisible(); + }); + + it("decodes gif frames on mount", async () => { + // Arrange, Act + render( + , + ); + + // Assert — wait for the async decode chain to complete. + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith(GIF_SRC); + }); + expect(parseGIF).toHaveBeenCalled(); + expect(decompressFrames).toHaveBeenCalled(); + }); + + it("re-decodes frames when src changes", async () => { + // Arrange + const {rerender} = render( + , + ); + + await waitFor(() => { + expect(decompressFrames).toHaveBeenCalled(); + }); + + // Act — change the src + const newSrc = "https://cdn.kastatic.org/other.gif"; + rerender( + , + ); + + // Assert — wait for the second decode to complete. + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith(newSrc); + }); + }); +}); diff --git a/packages/perseus/src/components/__tests__/svg-image.test.tsx b/packages/perseus/src/components/__tests__/svg-image.test.tsx index baa0bda842f..729e08d27ba 100644 --- a/packages/perseus/src/components/__tests__/svg-image.test.tsx +++ b/packages/perseus/src/components/__tests__/svg-image.test.tsx @@ -1,4 +1,4 @@ -import {act, render} from "@testing-library/react"; +import {act, render, screen} from "@testing-library/react"; import * as React from "react"; import * as Dependencies from "../../dependencies"; @@ -145,10 +145,9 @@ describe("SvgImage", () => { }); // Assert - // eslint-disable-next-line testing-library/no-node-access - expect(document.getElementsByTagName("img")[0].src).toEqual( - "https://www.khanacademy.org/my-test-img.png", - ); + expect( + screen.getByRole("img", {name: "png image"}).src, + ).toEqual("https://www.khanacademy.org/my-test-img.png"); }); describe("Graphie label scaling", () => { diff --git a/packages/perseus/src/components/gif-image.tsx b/packages/perseus/src/components/gif-image.tsx new file mode 100644 index 00000000000..477b403a084 --- /dev/null +++ b/packages/perseus/src/components/gif-image.tsx @@ -0,0 +1,336 @@ +import {parseGIF, decompressFrames} from "gifuct-js"; +import * as React from "react"; + +import type {ParsedFrame} from "gifuct-js"; + +/** + * Fetches a GIF and decodes it into individual frames using gifuct-js. + * Returns the parsed frames array, which includes per-frame pixel data, + * delay, dimensions, and disposal type. + */ +async function decodeGifFrames(src: string): Promise { + const res = await fetch(src); + if (!res.ok) { + return []; + } + + const buffer = await res.arrayBuffer(); + const gif = parseGIF(buffer); + return decompressFrames(gif, true); +} + +type Props = { + src: string; + alt: string; + width?: number; + height?: number; + scale: number; + isPlaying: boolean; + /** + * Called when the GIF finishes one full loop. + */ + onLoop: () => void; + /** + * Called once GIF frames are decoded and the first frame is drawn. + */ + onLoad?: () => void; +}; + +/** + * Renders a GIF using canvas-based frame-by-frame playback. + * + * Uses two canvases: + * - A display canvas (visible) composited and scaled to the display size + * - A hidden canvas (hidden) used to convert per-frame ImageData into a + * drawable source for proper alpha compositing via drawImage + * + * This is similar to the approach taken by gifuct-js's own demo: + * https://github.com/matt-way/gifuct-js/blob/master/demo/demo.js + * + * Why are we taking this approach? Because browsers don't natively + * support pausing/resuming GIF animations, and we need that for our GIF + * images. By decoding the GIF into frames and controlling the playback + * via requestAnimationFrame, we can: + * - "Pause" GIFs (i.e. show a static image). + * - "Play" GIFs (i.e. animate). + * - Detect when we have looped the animation. + */ +const GifImage = (props: Props) => { + const {src, alt, width, height, scale, isPlaying, onLoop, onLoad} = props; + + // Decoded GIF frames from gifuct-js + const framesRef = React.useRef([]); + // The display canvas shown to the user (composited and scaled) + const canvasRef = React.useRef(null); + // A hidden canvas used to convert per-frame ImageData into + // a drawable source for compositing onto the display canvas + const hiddenCanvasRef = React.useRef(null); + // The ID for the gif animation + const animationIdRef = React.useRef(null); + const currentFrameIndexRef = React.useRef(0); + // It tracks when the last frame was drawn so we can compare against + // the current timestamp to know if enough time has passed for the + // current frame's delay. GIF frames have variable delays (e.g., + // frame 1 might be 50ms, frame 2 might be 100ms). + const lastFrameTimeRef = React.useRef(null); + // Keep a ref to the latest props so the animation loop (which runs + // outside of React's render cycle) can read current values without + // stale closures. + const latestPropsRef = React.useRef({isPlaying, onLoop, onLoad}); + latestPropsRef.current = {isPlaying, onLoop, onLoad}; + + // Draw a single frame's patch directly onto the display canvas, + // using the hidden canvas to convert raw pixel data into a + // drawable source with proper alpha compositing. + const drawPatch = React.useCallback((index: number) => { + const frames = framesRef.current; + const hiddenCtx = hiddenCanvasRef.current?.getContext("2d"); + const displayCtx = canvasRef.current?.getContext("2d"); + if ( + !hiddenCtx || + !hiddenCanvasRef.current || + !displayCtx || + !canvasRef.current || + frames.length === 0 + ) { + return; + } + + const frame = frames[index]; + const {dims} = frame; + + // Handle disposal of the previous frame. + if (index > 0) { + const prev = frames[index - 1]; + if (prev.disposalType === 2) { + // Restore to background — clear the previous frame's area. + displayCtx.clearRect( + prev.dims.left, + prev.dims.top, + prev.dims.width, + prev.dims.height, + ); + } + } + + // Write the raw patch pixels onto the hidden canvas, then + // composite onto the display canvas using drawImage (which + // respects alpha blending, unlike putImageData which + // overwrites pixels — including transparent ones). + hiddenCanvasRef.current.width = dims.width; + hiddenCanvasRef.current.height = dims.height; + const imageData = new ImageData( + new Uint8ClampedArray(frame.patch), + dims.width, + dims.height, + ); + hiddenCtx.putImageData(imageData, 0, 0); + + displayCtx.drawImage(hiddenCanvasRef.current, dims.left, dims.top); + }, []); + + // Draw a specific frame without starting playback. Draws frames + // 0 through the target index so partial patches build up correctly. + const drawFrame = React.useCallback( + (index = 0) => { + const frames = framesRef.current; + if ( + frames.length === 0 || + !canvasRef.current || + !hiddenCanvasRef.current + ) { + return; + } + const targetIndex = Math.min(index, frames.length - 1); + currentFrameIndexRef.current = targetIndex; + + // Size the canvas buffer to the native GIF resolution. + // Display scaling is handled by CSS width/height on the + // canvas element, so no manual scaling is needed here. + const {width: nativeWidth, height: nativeHeight} = frames[0].dims; + canvasRef.current.width = nativeWidth; + canvasRef.current.height = nativeHeight; + + // Draw frames 0 through target so partial patches composite. + for (let i = 0; i <= targetIndex; i++) { + drawPatch(i); + } + }, + [drawPatch], + ); + + // Cancel the animation loop. The canvas retains the last drawn frame. + const pause = React.useCallback(() => { + if (animationIdRef.current !== null) { + cancelAnimationFrame(animationIdRef.current); + animationIdRef.current = null; + } + }, []); + + // The requestAnimationFrame callback that drives GIF playback. + const animate = React.useCallback( + (timestamp: number) => { + const frames = framesRef.current; + if (frames.length === 0) { + return; + } + + // On the first callback, record the baseline timestamp and + // draw the current frame immediately so there's no delay + // before the first visible frame. + if (lastFrameTimeRef.current === null) { + lastFrameTimeRef.current = timestamp; + drawPatch(currentFrameIndexRef.current); + } + + const frame = frames[currentFrameIndexRef.current]; + // GIF spec: delay of 0 is treated as 10ms by most decoders. + const delay = frame.delay <= 0 ? 10 : frame.delay; + + if (timestamp - lastFrameTimeRef.current >= delay) { + // Advance to the next frame. + currentFrameIndexRef.current++; + + if (currentFrameIndexRef.current >= frames.length) { + // Loop complete — notify parent. + currentFrameIndexRef.current = 0; + animationIdRef.current = null; + latestPropsRef.current.onLoop(); + return; + } + + drawPatch(currentFrameIndexRef.current); + // Advance by the scheduled delay rather than snapping to + // the current timestamp. This prevents timing drift when + // RAF callbacks fire late. + lastFrameTimeRef.current = lastFrameTimeRef.current + delay; + } + + animationIdRef.current = requestAnimationFrame(animate); + }, + [drawPatch], + ); + + // Start the requestAnimationFrame loop from the current frame. + const play = React.useCallback(() => { + if (framesRef.current.length === 0) { + return; + } + // Reset to null so the first RAF callback records the baseline + // timestamp and draws the current frame immediately. + lastFrameTimeRef.current = null; + animationIdRef.current = requestAnimationFrame(animate); + }, [animate]); + + // Reset to frame 0. + const restart = React.useCallback(() => { + pause(); + // Render frame 0 immediately so the canvas is never blank + // between clearing and the first RAF callback. + drawFrame(0); + }, [pause, drawFrame]); + + // Load and decode GIF frames on mount and when src changes. + React.useEffect(() => { + let mounted = true; + + decodeGifFrames(src).then((frames) => { + if (!mounted) { + return; + } + framesRef.current = frames; + + // Show the first frame on the canvas. + drawFrame(0); + latestPropsRef.current.onLoad?.(); + + if (latestPropsRef.current.isPlaying) { + play(); + } + }); + + return () => { + mounted = false; + pause(); + framesRef.current = []; + }; + // Only re-run when src changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [src]); + + // Handle play/pause transitions. + const prevIsPlayingRef = React.useRef(isPlaying); + React.useEffect(() => { + const wasPlaying = prevIsPlayingRef.current; + prevIsPlayingRef.current = isPlaying; + + if (isPlaying && !wasPlaying) { + // Resume playback from the current frame. + play(); + } + + if (!isPlaying && wasPlaying) { + // When transitioning to paused, stop the animation loop. + // If the loop completed (frame index reset to 0), show + // frame 0. Otherwise it was a manual pause — keep the + // current frame. + if (currentFrameIndexRef.current === 0) { + restart(); + } else { + pause(); + } + } + }, [isPlaying, restart, play, pause, drawFrame]); + + // Callback ref for the display canvas. Renders the first frame + // immediately when mounted so there's no flash of empty canvas. + const setCanvasRef = React.useCallback( + (canvas: HTMLCanvasElement | null) => { + canvasRef.current = canvas; + if (canvas && framesRef.current.length > 0) { + drawFrame(0); + } + }, + [drawFrame], + ); + + return ( + <> + {/* Two canvases are needed because there is no canvas API + that writes raw pixel data (ImageData) with alpha + compositing. putImageData overwrites pixels directly + (including transparent ones), while drawImage composites + properly. So we putImageData onto the hidden + canvas, then drawImage it onto this display canvas. */} + + {/* Display canvas: the visible, composited GIF output + scaled to the display dimensions. Each frame is drawn + here via drawImage, which respects alpha blending. */} + + {/* Base canvas: a hidden canvas that acts as a bridge + between putImageData (the only way to write raw RGBA + frame data) and drawImage (which composites with alpha). + Each frame's pixel data is written here first, then + drawn onto the display canvas above. */} + + + ); +}; + +export default GifImage; diff --git a/packages/perseus/src/components/svg-image.tsx b/packages/perseus/src/components/svg-image.tsx index fe54d59b4ae..c7f08889bb4 100644 --- a/packages/perseus/src/components/svg-image.tsx +++ b/packages/perseus/src/components/svg-image.tsx @@ -10,6 +10,7 @@ import Util from "../util"; import {loadGraphie} from "../util/graphie-utils"; import FixedToResponsive from "./fixed-to-responsive"; +import GifImage from "./gif-image"; import Graphie from "./graphie"; import {PerseusI18nContext} from "./i18n-context"; import ImageLoader from "./image-loader"; @@ -97,6 +98,18 @@ export type Props = { * If not, it defaults to a no-op. */ setAssetStatus: (assetKey: string, loaded: boolean) => void; + /** + * When provided, enables GIF play/pause support. + * + * - `undefined`: no GIF controls (default, no extra DOM) + * - `true`: GIF is playing. + * - `false`: GIF is paused. + */ + isGifPlaying?: boolean; + /** + * Called when the GIF completes one full loop. + */ + onGifLoop?: () => void; }; type DefaultProps = { @@ -457,15 +470,38 @@ class SvgImage extends React.Component { if (!Util.isLabeledSVG(imageSrc)) { // Responsive non-Graphie images if (responsive) { + // When gif controls are active (isGifPlaying is defined), wrap + // ImageLoader in a div so we can querySelector the for + // canvas capture. The canvas is a non-first child of + // FixedToResponsive, so it gets position:absolute coverage via + // the .fixed-to-responsive > :not(:first-child) CSS rule. + const isGifControlled = this.props.isGifPlaying !== undefined; + const imageContent = ( <> - - {extraGraphie} + {!isGifControlled && ( + <> + + {extraGraphie} + + )} + {isGifControlled && ( + {})} + onLoad={this.onImageLoad} + /> + )} ); diff --git a/packages/perseus/src/widgets/image/components/explore-image-modal-content.tsx b/packages/perseus/src/widgets/image/components/explore-image-modal-content.tsx index 792125ea566..aa98585c6a7 100644 --- a/packages/perseus/src/widgets/image/components/explore-image-modal-content.tsx +++ b/packages/perseus/src/widgets/image/components/explore-image-modal-content.tsx @@ -30,9 +30,8 @@ export default function ExploreImageModalContent({ labels, range, zoomSize, - isGifPlaying, - setIsGifPlaying, }: Props) { + const [isGifPlaying, setIsGifPlaying] = React.useState(false); const context = React.useContext(PerseusI18nContext); if (!backgroundImage.url) { @@ -117,6 +116,16 @@ export default function ExploreImageModalContent({ constrainHeight={apiOptions.isMobile} allowFullBleed={apiOptions.isMobile} setAssetStatus={setAssetStatus} + isGifPlaying={ + gifControlsFF && imageIsGif + ? isGifPlaying + : undefined + } + onGifLoop={ + gifControlsFF && imageIsGif + ? () => setIsGifPlaying(false) + : undefined + } /> )} diff --git a/packages/perseus/src/widgets/image/components/explore-image-modal.test.tsx b/packages/perseus/src/widgets/image/components/explore-image-modal.test.tsx index 6e4eb265fd2..78c4d06d98b 100644 --- a/packages/perseus/src/widgets/image/components/explore-image-modal.test.tsx +++ b/packages/perseus/src/widgets/image/components/explore-image-modal.test.tsx @@ -76,6 +76,16 @@ describe("ExploreImageModal", () => { ); unmockImageLoading = mockImageLoading(); + + // GifImage (rendered via SvgImage when gif controls are active) + // calls fetch() to decode GIF frames. jsdom doesn't provide + // fetch, so we stub it here. + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + }), + ) as jest.Mock; }); afterEach(() => { @@ -264,16 +274,19 @@ describe("ExploreImageModal", () => { renderModal({ ...defaultProps, backgroundImage: gifImage, - isGifPlaying: true, apiOptions: apiOptionsWithGifControls, }); - // Act - const pauseButton = screen.getByRole("button", { - name: "Pause Animation", + // Act — the modal starts paused, so click Play first + const playButton = screen.getByRole("button", { + name: "Play Animation", }); + await userEvent.click(playButton); // Assert + const pauseButton = screen.getByRole("button", { + name: "Pause Animation", + }); expect(pauseButton).toBeVisible(); }); @@ -297,44 +310,44 @@ describe("ExploreImageModal", () => { it("should toggle the gif playing state when the play button is clicked", async () => { // Arrange - const toggleGifPlaying = jest.fn(); renderModal({ ...defaultProps, backgroundImage: gifImage, - isGifPlaying: false, - setIsGifPlaying: toggleGifPlaying, apiOptions: apiOptionsWithGifControls, }); - // Act + // Act — modal starts paused, click Play const playButton = screen.getByRole("button", { name: "Play Animation", }); await userEvent.click(playButton); - // Assert - expect(toggleGifPlaying).toHaveBeenCalledWith(true); + // Assert — should now show the Pause button + expect( + screen.getByRole("button", {name: "Pause Animation"}), + ).toBeVisible(); }); it("should toggle the gif playing state when the pause button is clicked", async () => { // Arrange - const toggleGifPlaying = jest.fn(); renderModal({ ...defaultProps, backgroundImage: gifImage, - isGifPlaying: true, - setIsGifPlaying: toggleGifPlaying, apiOptions: apiOptionsWithGifControls, }); - // Act - const pauseButton = screen.getByRole("button", { - name: "Pause Animation", - }); - await userEvent.click(pauseButton); - - // Assert - expect(toggleGifPlaying).toHaveBeenCalledWith(false); + // Act — click Play then Pause + await userEvent.click( + screen.getByRole("button", {name: "Play Animation"}), + ); + await userEvent.click( + screen.getByRole("button", {name: "Pause Animation"}), + ); + + // Assert — should now show the Play button again + expect( + screen.getByRole("button", {name: "Play Animation"}), + ).toBeVisible(); }); }); diff --git a/packages/perseus/src/widgets/image/image.test.ts b/packages/perseus/src/widgets/image/image.test.ts index be9f17826d6..f1b0546b679 100644 --- a/packages/perseus/src/widgets/image/image.test.ts +++ b/packages/perseus/src/widgets/image/image.test.ts @@ -54,6 +54,16 @@ describe.each([[true], [false]])("image widget - isMobile(%j)", (isMobile) => { ); unmockImageLoading = mockImageLoading(); + + // GifImage (rendered via SvgImage when gif controls are active) + // calls fetch() to decode GIF frames. jsdom doesn't provide + // fetch, so we stub it here. + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + }), + ) as jest.Mock; }); afterEach(() => { @@ -512,6 +522,29 @@ describe.each([[true], [false]])("image widget - isMobile(%j)", (isMobile) => { expect(button).not.toBeInTheDocument(); }); + it("does not render a zoom image button for gif images", () => { + // Arrange + const gifImageQuestion = generateTestPerseusRenderer({ + content: "[[☃ image 1]]", + widgets: { + "image 1": generateImageWidget({ + options: generateImageOptions({ + backgroundImage: gifImage, + }), + }), + }, + }); + + // Act + renderQuestion(gifImageQuestion, apiOptionsWithGifControlsFlag); + + // Assert + const zoomButton = screen.queryByRole("button", { + name: "Zoom image.", + }); + expect(zoomButton).not.toBeInTheDocument(); + }); + it("does not render a zoom image button for images without sizes", () => { // Arrange const imageQuestion = generateTestPerseusRenderer({ @@ -1029,6 +1062,27 @@ describe.each([[true], [false]])("image widget - isMobile(%j)", (isMobile) => { }); expect(playButtonAgain).toBeVisible(); }); + + it("does not render a canvas overlay when the feature flag is disabled", () => { + // Arrange, Act + const gifImageQuestion = generateTestPerseusRenderer({ + content: "[[☃ image 1]]", + widgets: { + "image 1": generateImageWidget({ + options: generateImageOptions({ + backgroundImage: gifImage, + }), + }), + }, + }); + renderQuestion(gifImageQuestion, apiOptions); + act(() => { + jest.runAllTimers(); + }); + + // Assert + expect(screen.queryByTestId("gif-canvas")).not.toBeInTheDocument(); + }); }); describe("flags", () => { diff --git a/packages/perseus/src/widgets/image/image.tsx b/packages/perseus/src/widgets/image/image.tsx index feef6c4922e..994c89fbf32 100644 --- a/packages/perseus/src/widgets/image/image.tsx +++ b/packages/perseus/src/widgets/image/image.tsx @@ -63,7 +63,7 @@ export const ImageComponent = (props: ImageWidgetProps) => { }); }); - // TODO(LEMS-3912): Remove this effect afte we turn on and remove the + // TODO(LEMS-3912): Remove this effect after we turn on and remove the // image-widget-upgrade-scale feature flag. React.useEffect(() => { // Reset the flag for this effect run @@ -102,6 +102,12 @@ export const ImageComponent = (props: ImageWidgetProps) => { }; }, [backgroundImage.url, backgroundImage.width, backgroundImage.height]); + // If the backgroundImage.url changes (likely in the editor preview) + // we will stop any gif from playing. + React.useEffect(() => { + setIsGifPlaying(false); + }, [backgroundImage.url]); + if (!backgroundImage.url) { return null; } @@ -136,9 +142,18 @@ export const ImageComponent = (props: ImageWidgetProps) => { zoomToFullSizeOnMobile={apiOptions.isMobile} constrainHeight={apiOptions.isMobile} allowFullBleed={apiOptions.isMobile} - allowZoom={!decorative} + // Only allow zooming if the image is not decorative and not a GIF. + allowZoom={!decorative && !imageIsGif} alt={decorative || caption === alt ? "" : alt} setAssetStatus={setAssetStatus} + isGifPlaying={ + gifControlsFF && imageIsGif ? isGifPlaying : undefined + } + onGifLoop={ + gifControlsFF && imageIsGif + ? () => setIsGifPlaying(false) + : undefined + } /> )} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5fe55c01a4e..3bc1c7f5907 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -603,6 +603,9 @@ importers: '@use-gesture/react': specifier: ^10.2.27 version: 10.3.0(react@18.2.0) + gifuct-js: + specifier: ^2.1.2 + version: 2.1.2 mafs: specifier: 0.19.0 version: 0.19.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -5649,6 +5652,9 @@ packages: getpass@0.1.7: resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} + gifuct-js@2.1.2: + resolution: {integrity: sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -6550,6 +6556,9 @@ packages: jquery@3.6.0: resolution: {integrity: sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw==} + js-binary-schema-parser@2.0.3: + resolution: {integrity: sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -15255,6 +15264,10 @@ snapshots: dependencies: assert-plus: 1.0.0 + gifuct-js@2.1.2: + dependencies: + js-binary-schema-parser: 2.0.3 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -16442,6 +16455,8 @@ snapshots: jquery@3.6.0: {} + js-binary-schema-parser@2.0.3: {} + js-tokens@4.0.0: {} js-yaml@3.14.1: