Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f77bd9f
[image-replace-zoom-with-modal] Replace zoom service with modal
nishasy Oct 31, 2025
364335c
[image-replace-zoom-with-modal] docs(changeset): [Image] | (DX) | Rem…
nishasy Oct 31, 2025
1abdf85
[image-replace-zoom-with-modal] Remove zoom service
nishasy Oct 31, 2025
70f30c6
[image-replace-zoom-with-modal] Update snapshots
nishasy Oct 31, 2025
f463acf
[image-replace-zoom-with-modal] Remove cypress test since modal can b…
nishasy Oct 31, 2025
4505f9e
[image-replace-zoom-with-modal] add unit tests
nishasy Oct 31, 2025
1716d23
[image-replace-zoom-with-modal] Merge branch 'main' into image-replac…
nishasy Nov 12, 2025
e9715a3
[image-replace-zoom-with-modal] style comments
nishasy Nov 12, 2025
0db2ecc
[image-replace-zoom-with-modal] Use components as components rather t…
nishasy Nov 12, 2025
656a40d
[image-replace-zoom-with-modal] Allow zoomed image to touches edges o…
nishasy Nov 12, 2025
b91e0e5
[image-replace-zoom-with-modal] Make required
nishasy Nov 13, 2025
c4abc1a
[image-replace-zoom-with-modal] remove unused styles
nishasy Nov 13, 2025
6fda1ca
[image-replace-zoom-with-modal] Add a test for zoom behavior within e…
nishasy Nov 13, 2025
fc6876d
[image-replace-zoom-with-modal] I think this is a minor change
nishasy Nov 13, 2025
0648b69
[image-replace-zoom-with-modal] Move comment
nishasy Nov 14, 2025
4cd5f80
[image-replace-zoom-with-modal] Merge branch 'main' into image-replac…
nishasy Nov 14, 2025
aa8d559
[image-replace-zoom-with-modal] Merge branch 'main' into image-replac…
nishasy Nov 20, 2025
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/tall-bees-raise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/perseus": minor
---

[Image] | (DX) | Remove ZoomService in favor of WB Modal
1 change: 1 addition & 0 deletions packages/perseus-editor/src/diffs/text-diff.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ class ImageDiffSide extends React.Component<any> {
<SvgImage
src={entry.value}
title={entry.value}
allowZoom={false}
/>
</div>
</div>
Expand Down
12 changes: 10 additions & 2 deletions packages/perseus-editor/src/diffs/widget-diff.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,11 @@ class ImageWidgetDiff extends React.Component<any> {
})}
>
{/* @ts-expect-error - TS2741 - Property 'alt' is missing in type '{ src: any; title: any; }' but required in type 'Pick<Readonly<Props> & Readonly<{ children?: ReactNode; }>, "children" | "height" | "width" | "title" | "alt" | "trackInteraction" | "preloader" | "allowFullBleed" | "extraGraphie" | "overrideAriaHidden">'. */}
<SvgImage src={beforeSrc} title={beforeSrc} />
<SvgImage
src={beforeSrc}
title={beforeSrc}
allowZoom={false}
/>
</div>
)}
</div>
Expand All @@ -243,7 +247,11 @@ class ImageWidgetDiff extends React.Component<any> {
})}
>
{/* @ts-expect-error - TS2741 - Property 'alt' is missing in type '{ src: any; title: any; }' but required in type 'Pick<Readonly<Props> & Readonly<{ children?: ReactNode; }>, "children" | "height" | "width" | "title" | "alt" | "trackInteraction" | "preloader" | "allowFullBleed" | "extraGraphie" | "overrideAriaHidden">'. */}
<SvgImage src={afterSrc} title={afterSrc} />
<SvgImage
src={afterSrc}
title={afterSrc}
allowZoom={false}
/>
</div>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ export default function ImageSettings({
<SvgImage
src={backgroundImage.url}
alt={`Preview: ${alt || "No alt text"}`}
// No need to allow zooming within the editor.
allowZoom={false}
/>
}
styles={wbFieldStyles}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,8 @@ export const RadioOptionContentAndImageEditor = (props: Props) => {
<SvgImage
src={image.url}
alt={`Preview: ${image.altText ?? "No alt text"}`}
// No need to allow zooming within the editor.
allowZoom={false}
/>
<RadioImageEditor
initialImageUrl={image.url}
Expand Down
32 changes: 27 additions & 5 deletions packages/perseus/src/components/__tests__/svg-image.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ describe("SvgImage", () => {

// Act
const {container} = render(
<SvgImage src="http://localhost/sample.png" alt="png image" />,
<SvgImage
src="http://localhost/sample.png"
alt="png image"
allowZoom={false}
/>,
);

// Assert
Expand All @@ -54,7 +58,11 @@ describe("SvgImage", () => {

// Act
const {container} = render(
<SvgImage src="http://localhost/sample.png" alt="png image" />,
<SvgImage
src="http://localhost/sample.png"
alt="png image"
allowZoom={false}
/>,
);

act(() => {
Expand All @@ -73,7 +81,11 @@ describe("SvgImage", () => {

// Act
const {container} = render(
<SvgImage src={typicalCase.url} alt="svg image" />,
<SvgImage
src={typicalCase.url}
alt="svg image"
allowZoom={false}
/>,
);

act(() => {
Expand All @@ -93,7 +105,11 @@ describe("SvgImage", () => {

// Act
const {container} = render(
<SvgImage src={typicalCase.url} alt="svg image" />,
<SvgImage
src={typicalCase.url}
alt="svg image"
allowZoom={false}
/>,
);

act(() => {
Expand All @@ -114,7 +130,13 @@ describe("SvgImage", () => {
});

// Act
render(<SvgImage src="http://localhost/sample.png" alt="png image" />);
render(
<SvgImage
src="http://localhost/sample.png"
alt="png image"
allowZoom={false}
/>,
);

act(() => {
jest.runAllTimers();
Expand Down
37 changes: 10 additions & 27 deletions packages/perseus/src/components/image-loader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
/* eslint-disable jsx-a11y/alt-text, react/no-unsafe */
// TODO(scottgrant): Enable the alt-text eslint rule above.

import Clickable from "@khanacademy/wonder-blocks-clickable";
import * as React from "react";

import {type Dimensions, type PerseusDependenciesV2} from "../types";

import {withDependencies} from "./with-dependencies";
import {ZoomImageButton} from "./zoom-image-button";

const Status = {
PENDING: "pending",
Expand All @@ -21,14 +21,13 @@ export type ImageProps = {
title?: string;
["aria-hidden"]?: boolean;
tabIndex?: number;
onClick?: (e: React.SyntheticEvent) => void;
clickAriaLabel?: string;
style?: Dimensions;
};

type Props = {
children?: React.ReactNode;
imgProps: ImageProps;
allowZoom: boolean;
onError?: (event: Event) => void;
onLoad?: (event: Event) => void;
// When the DOM updates to replace the preloader with the image, or
Expand Down Expand Up @@ -140,12 +139,6 @@ class ImageLoader extends React.Component<Props, State> {
renderImg: () => React.ReactElement<React.ComponentProps<"img">> = () => {
const {src, imgProps, forwardedRef} = this.props;
// Destructure to exclude props that shouldn't be on the <img> element
const {
// Don't pass onClick or clickAriaLabel to the <img> element
onClick,
clickAriaLabel,
...otherImgProps
} = imgProps;

const imgElement = (
<img
Expand Down Expand Up @@ -173,33 +166,23 @@ class ImageLoader extends React.Component<Props, State> {
width: "100%",
}),
}}
{...otherImgProps}
{...imgProps}
/>
);

if (!onClick) {
if (!this.props.allowZoom) {
return imgElement;
}

return (
<>
{imgElement}
<Clickable
aria-label={clickAriaLabel}
onClick={onClick}
style={{
// Overlay the button over the image.
position: "absolute",
width: this.props.imgProps.style?.width ?? "100%",
height: this.props.imgProps.style?.height ?? "100%",
overflow: "hidden",
cursor: "zoom-in",
}}
>
{() => {
return <React.Fragment />;
}}
</Clickable>
<ZoomImageButton
imgElement={imgElement}
imgSrc={src}
width={imgProps?.style?.width}
height={imgProps?.style?.height}
/>
</>
);
};
Expand Down
50 changes: 8 additions & 42 deletions packages/perseus/src/components/svg-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import _ from "underscore";
import {getDependencies} from "../dependencies";
import Util from "../util";
import {loadGraphie} from "../util/graphie-utils";
import * as Zoom from "../zoom";

import FixedToResponsive from "./fixed-to-responsive";
import Graphie from "./graphie";
Expand Down Expand Up @@ -48,7 +47,7 @@ function defaultPreloader(dimensions: Dimensions) {

type Props = {
allowFullBleed?: boolean;
allowZoom?: boolean;
allowZoom: boolean;
alt: string;
constrainHeight?: boolean;
extraGraphie?: {
Expand Down Expand Up @@ -378,40 +377,6 @@ class SvgImage extends React.Component<Props, State> {
return parseFloat(value) || null;
}

_handleZoomClick: (e: React.SyntheticEvent) => void = (
e: React.SyntheticEvent,
) => {
// Don't attempt to zoom if the image ref isn't available or the
// image hasn't finished loading yet
if (!this.imageRef.current || !this.state.imageLoaded) {
return;
}

e.stopPropagation();
e.preventDefault();

// Pass the image ref and the clicked element to the zoom service.
// The image is the target element to zoom into, and the clicked
// element will be refocused after exiting the zoom view.
Zoom.ZoomService.handleZoomClick(
this.imageRef,
this.props.zoomToFullSizeOnMobile,
{
clickedElement: e.currentTarget as HTMLElement,
// Pass the translated string from i18n context
zoomedImageAriaLabel:
this.context.strings.imageResetZoomAriaLabel,
// Specify if the meta or ctrl key is being pressed.
// The zoom service uses this to determine if the image should
// be opened in a new tab when clicked.
metaKey: (e as React.KeyboardEvent).metaKey || false,
ctrlKey: (e as React.KeyboardEvent).ctrlKey || false,
},
);

this.props.trackInteraction?.();
};

handleUpdate: (status: string) => void = (status: string) => {
this.props.onUpdate();
// NOTE: Labeled SVG images use this.onImageLoad to set imageLoaded
Expand Down Expand Up @@ -485,12 +450,6 @@ class SvgImage extends React.Component<Props, State> {
// Just use a normal image if a normal image is provided
if (!Util.isLabeledSVG(imageSrc)) {
if (responsive) {
if (this.props.allowZoom) {
imageProps.onClick = this._handleZoomClick;
imageProps.clickAriaLabel =
this.context.strings.imageZoomAriaLabel;
}

return (
<FixedToResponsive
className="svg-image"
Expand All @@ -503,6 +462,7 @@ class SvgImage extends React.Component<Props, State> {
}
>
<ImageLoader
allowZoom={this.props.allowZoom}
forwardedRef={this.imageRef}
src={imageSrc}
imgProps={imageProps}
Expand All @@ -517,6 +477,8 @@ class SvgImage extends React.Component<Props, State> {
return (
<ImageLoader
src={imageSrc}
// Don't allow zooming on non-responsive images
allowZoom={false}
preloader={preloader}
imgProps={imageProps}
onUpdate={this.handleUpdate}
Expand Down Expand Up @@ -577,6 +539,8 @@ class SvgImage extends React.Component<Props, State> {
>
<ImageLoader
src={imageUrl}
// Don't allow zooming on Graphie images (yet)
allowZoom={false}
onLoad={this.onImageLoad}
onUpdate={this.handleUpdate}
preloader={preloader}
Expand All @@ -592,6 +556,8 @@ class SvgImage extends React.Component<Props, State> {
<div className="unresponsive-svg-image" style={dimensions}>
<ImageLoader
src={imageUrl}
// Don't allow zooming on non-responsive images
allowZoom={false}
onLoad={this.onImageLoad}
onUpdate={this.handleUpdate}
preloader={preloader}
Expand Down
50 changes: 50 additions & 0 deletions packages/perseus/src/components/zoom-image-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import Clickable from "@khanacademy/wonder-blocks-clickable";
import {ModalLauncher} from "@khanacademy/wonder-blocks-modal";
import * as React from "react";

import {usePerseusI18n} from "./i18n-context";
import {ZoomedImageView} from "./zoomed-image-view";

type Props = {
imgElement: React.ReactNode;
imgSrc: string;
width?: number;
height?: number;
};

export const ZoomImageButton = ({imgElement, imgSrc, width, height}: Props) => {
const i18n = usePerseusI18n();

return (
<ModalLauncher
modal={({closeModal}) => (
<ZoomedImageView
imgElement={imgElement}
imgSrc={imgSrc}
onClose={closeModal}
/>
)}
>
{({openModal}) => (
<Clickable
aria-label={i18n.strings.imageZoomAriaLabel}
onClick={openModal}
// TODO(LEMS-3686): Use CSS modules after Wonder Blocks
// supports it instead of inline styles.
style={{
// Overlay the button over the image.
position: "absolute",
width: width ?? "100%",
height: height,
overflow: "hidden",
cursor: "zoom-in",
}}
>
{() => {
return <React.Fragment />;
}}
</Clickable>
)}
</ModalLauncher>
);
};
14 changes: 14 additions & 0 deletions packages/perseus/src/components/zoomed-image-view.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
.contentWrapper {
/* Undo the ModalContent padding so that the image takes up the whole modal. */
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for these comments!

margin-block: calc(-1 * var(--wb-c-modal-panel-layout-gap-default));
margin-inline: calc(-1 * var(--wb-c-modal-panel-layout-gap-default));
/* Remove inline spacing that creates a 1px gap below images */
line-height: 0;
}

@media (max-width: 767px) {
.contentWrapper {
/* Smaller screens have a smaller inline gap */
margin-inline: calc(-1 * var(--wb-c-modal-panel-layout-gap-small));
}
}
Loading
Loading