Skip to content
Open
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
6c4d657
[catjohnson/lems-3735] Initial implementation for pause and play.
catandthemachines Mar 17, 2026
cc54bc1
[catjohnson/lems-3735] Fixing frame issues on load.
catandthemachines Mar 17, 2026
e8a3007
[catjohnson/lems-3735] Remove console log.
catandthemachines Mar 17, 2026
47446b6
[catjohnson/lems-3735] Adding tests
catandthemachines Mar 17, 2026
aa13292
[catjohnson/lems-3735] Fixing linting errors.
catandthemachines Mar 18, 2026
8800384
[catjohnson/lems-3735] Fixed a few more linting issues.
catandthemachines Mar 18, 2026
0726379
[catjohnson/lems-3735] docs(changeset): [Image] New pause and play fu…
catandthemachines Mar 18, 2026
9c11caa
[catjohnson/lems-3735] Update snapshots.
catandthemachines Mar 18, 2026
c85801a
[catjohnson/lems-3735] Adding gif image looping mechanism.
catandthemachines Mar 18, 2026
0e0e21e
[catjohnson/lems-3735] Saving and storing gifImageElement.
catandthemachines Mar 18, 2026
e6573d5
[catjohnson/lems-3735] Adding a few more tests.
catandthemachines Mar 19, 2026
3587e31
[catjohnson/lems-3735] Merge branch 'main' into catjohnson/lems-3735
catandthemachines Mar 24, 2026
af67d85
[catjohnson/lems-3735] Updating gif implementation.
catandthemachines Mar 25, 2026
0b118d5
[catjohnson/lems-3735] update comment.
catandthemachines Mar 25, 2026
1ba3f6f
[catjohnson/lems-3735] Hiding the canvas element from screen readers.
catandthemachines Mar 25, 2026
2d3c622
[catjohnson/lems-3735] Fixing gif loading edgecase.
catandthemachines Mar 25, 2026
3c3ce41
[catjohnson/lems-3735] Merge branch 'main' into catjohnson/lems-3735
catandthemachines Mar 25, 2026
3004972
[catjohnson/lems-3735] A few tweeks.
catandthemachines Mar 25, 2026
f57d979
[catjohnson/lems-3735] Fixing comment.
catandthemachines Mar 25, 2026
517aa6c
[catjohnson/lems-3735] Updating the name of the gif ref.
catandthemachines Mar 25, 2026
d141d86
[catjohnson/lems-3735] Fixing staggered gif playing in zoomed view.
catandthemachines Mar 25, 2026
363b955
[catjohnson/lems-3735] Current Implementation for using gifuct-js to …
catandthemachines Mar 30, 2026
56e321a
[catjohnson/lems-3735] Undoing testid for loading spinner. Can come b…
catandthemachines Mar 30, 2026
962c3bf
[catjohnson/lems-3735] Removing unused image loader properties.
catandthemachines Mar 30, 2026
cdce7dd
[catjohnson/lems-3735] Updating and removing more stuff.
catandthemachines Mar 30, 2026
aa280bb
[catjohnson/lems-3735] Removing zoom from gif images.
catandthemachines Mar 30, 2026
5759436
[catjohnson/lems-3735] Moving GifLogic to it's own file.
catandthemachines Mar 30, 2026
8417f85
[catjohnson/lems-3735] changing logic around pausing and looping.
catandthemachines Mar 30, 2026
3cacf72
[catjohnson/lems-3735] Removing unnecessary test utility additions.
catandthemachines Mar 30, 2026
62f5aa7
[catjohnson/lems-3735] Update tests.
catandthemachines Mar 31, 2026
e90dc8a
[catjohnson/lems-3735] Cleaning up tests.
catandthemachines Mar 31, 2026
81005e0
[catjohnson/lems-3735] Updating the name of the baseCanvas to better …
catandthemachines Mar 31, 2026
6b8cc27
[catjohnson/lems-3735] Improving gif tests.
catandthemachines Mar 31, 2026
5adb76c
[catjohnson/lems-3735] fixing all the canvas scalling to be cleaner a…
catandthemachines Mar 31, 2026
c2881a2
[catjohnson/lems-3735] Changing onPause, to onLoop. Much more aligned…
catandthemachines Apr 1, 2026
1995ff2
[catjohnson/lems-3735] Updating comment to highlight where this duel …
catandthemachines Apr 1, 2026
b3ac72e
[catjohnson/lems-3735] Improving restart function.
catandthemachines Apr 1, 2026
b03c395
[catjohnson/lems-3735] Merge branch 'main' into catjohnson/lems-3735
catandthemachines Apr 6, 2026
75a9672
[catjohnson/lems-3735] Cleaning up a few lines.
catandthemachines Apr 6, 2026
16a9832
[catjohnson/lems-3735] Updating variable names and comments.
catandthemachines Apr 6, 2026
b9082ca
[catjohnson/lems-3735] Fixing -1 for no frames yet and trying to tamp…
catandthemachines Apr 6, 2026
feaa027
[catjohnson/lems-3735] Adding onload functionality.
catandthemachines Apr 7, 2026
de190f5
[catjohnson/lems-3735] A few fixes.
catandthemachines Apr 7, 2026
a09f12a
[catjohnson/lems-3735] removing cate statement.
catandthemachines Apr 7, 2026
2fc1031
[catjohnson/lems-3735] Fixing some tests.
catandthemachines Apr 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/olive-cameras-change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/perseus": patch
---

[Image] New pause and play functionality for image gifs.
3 changes: 2 additions & 1 deletion packages/perseus/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
Expand Down
301 changes: 301 additions & 0 deletions packages/perseus/src/components/__tests__/gif-image.test.tsx
Original file line number Diff line number Diff line change
@@ -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<CanvasRenderingContext2D> 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(
<GifImage
src={GIF_SRC}
alt="test gif"
width={500}
height={285}
scale={1}
isPlaying={false}
onPause={jest.fn()}
/>,
);

// Assert
expect(screen.getByTestId("gif-canvas")).toBeInTheDocument();
});

it("renders a canvas when playing", () => {
// Arrange, Act
render(
<GifImage
src={GIF_SRC}
alt="test gif"
width={500}
height={285}
scale={1}
isPlaying={true}
onPause={jest.fn()}
/>,
);

// Assert
expect(screen.getByTestId("gif-canvas")).toBeInTheDocument();
});

describe("auto-pause", () => {
it("calls onPause after all frames have been rendered", async () => {
// Arrange
const onPause = jest.fn();
render(
<GifImage
src={GIF_SRC}
alt="test gif"
width={500}
height={285}
scale={1}
isPlaying={true}
onPause={onPause}
/>,
);

// 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(onPause).toHaveBeenCalledTimes(1);
});

it("does not call onPause again after pausing", async () => {
// Arrange
const onPause = jest.fn();
const {rerender} = render(
<GifImage
src={GIF_SRC}
alt="test gif"
width={500}
height={285}
scale={1}
isPlaying={true}
onPause={onPause}
/>,
);

await waitFor(() => {
expect(decompressFrames).toHaveBeenCalled();
});
act(() => {
jest.advanceTimersByTime(200);
});
expect(onPause).toHaveBeenCalledTimes(1);

// Act - pause the GIF
rerender(
<GifImage
src={GIF_SRC}
alt="test gif"
width={500}
height={285}
scale={1}
isPlaying={false}
onPause={onPause}
/>,
);
act(() => {
jest.advanceTimersByTime(200);
});

// Assert - onPause should not have been called again
expect(onPause).toHaveBeenCalledTimes(1);
});
});

it("resets to frame 0 after loop completes and is replayed", async () => {
// Arrange — play through one full loop
const onPause = jest.fn();
const {rerender} = render(
<GifImage
src={GIF_SRC}
alt="test gif"
width={500}
height={285}
scale={1}
isPlaying={true}
onPause={onPause}
/>,
);

await waitFor(() => {
expect(decompressFrames).toHaveBeenCalled();
});
act(() => {
jest.advanceTimersByTime(200);
});
expect(onPause).toHaveBeenCalledTimes(1);

// Act — sync parent state to paused, then play again
rerender(
<GifImage
src={GIF_SRC}
alt="test gif"
width={500}
height={285}
scale={1}
isPlaying={false}
onPause={onPause}
/>,
);
rerender(
<GifImage
src={GIF_SRC}
alt="test gif"
width={500}
height={285}
scale={1}
isPlaying={true}
onPause={onPause}
/>,
);

// Advance enough for both frames to render from frame 0.
act(() => {
jest.advanceTimersByTime(200);
});
// The loop completed a second time — onPause fires again,
// confirming it played through both frames from the start.
expect(onPause).toHaveBeenCalledTimes(2);
});

it("renders the hidden base canvas", () => {
// Arrange, Act
render(
<GifImage
src={GIF_SRC}
alt="test gif"
width={500}
height={285}
scale={1}
isPlaying={false}
onPause={jest.fn()}
/>,
);

// Assert
const patchCanvas = screen.getByTestId("gif-base-canvas");
expect(patchCanvas).toBeInTheDocument();
expect(patchCanvas).not.toBeVisible();
});

it("decodes gif frames on mount", async () => {
// Arrange, Act
render(
<GifImage
src={GIF_SRC}
alt="test gif"
width={500}
height={285}
scale={1}
isPlaying={false}
onPause={jest.fn()}
/>,
);

// 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(
<GifImage
src={GIF_SRC}
alt="test gif"
width={500}
height={285}
scale={1}
isPlaying={false}
onPause={jest.fn()}
/>,
);

await waitFor(() => {
expect(decompressFrames).toHaveBeenCalled();
});

// Act — change the src
const newSrc = "https://cdn.kastatic.org/other.gif";
rerender(
<GifImage
src={newSrc}
alt="test gif"
width={500}
height={285}
scale={1}
isPlaying={false}
onPause={jest.fn()}
/>,
);

// Assert — wait for the second decode to complete.
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(newSrc);
});
});
});
9 changes: 4 additions & 5 deletions packages/perseus/src/components/__tests__/svg-image.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<HTMLImageElement>("img", {name: "png image"}).src,
).toEqual("https://www.khanacademy.org/my-test-img.png");
});

describe("Graphie label scaling", () => {
Expand Down
Loading
Loading