From cad4baa4f14c415bbad205e99fb7e909ed89636d Mon Sep 17 00:00:00 2001 From: Nisha Yerunkar Date: Thu, 12 Feb 2026 15:08:56 -0800 Subject: [PATCH 1/2] [scale-zoomed-graphies] [Image] | (UX) | Scale up SVG images to fill screen when zoomed Currently, the zoomed view of an image will show it at its original/natural size. It was implemented this way so that the images are at their largest size without compromising quality, but that doesn't apply to SVGs. SVGs can be scaled up without quality drop. In this PR: - Scale up SVGs to fit the whole screen on zoom - Do not scale up non-SVGs on zoom - keep them at their natural size Issue: https://khanacademy.atlassian.net/browse/LEMS-3740 Test plan: `pnpm jest packages/perseus/src/widgets/image/image.test.ts` Storybook - `/?path=/story/widgets-image-widget-demo--graphie-image` - `/?path=/story/widgets-image-widget-demo--image` --- .../src/components/fixed-to-responsive.tsx | 4 + packages/perseus/src/components/svg-image.tsx | 6 ++ .../src/components/zoom-image-button.tsx | 14 +++- .../src/components/zoomed-image-view.tsx | 62 +++++++++++---- .../perseus/src/widgets/image/image.test.ts | 78 +++++++++++++++++++ 5 files changed, 148 insertions(+), 16 deletions(-) diff --git a/packages/perseus/src/components/fixed-to-responsive.tsx b/packages/perseus/src/components/fixed-to-responsive.tsx index 712c2bb4a8a..c63cb66e2cd 100644 --- a/packages/perseus/src/components/fixed-to-responsive.tsx +++ b/packages/perseus/src/components/fixed-to-responsive.tsx @@ -26,6 +26,10 @@ type Props = { children: React.ReactNode; className?: string; constrainHeight?: boolean; + /** + * When the content is at least as wide as the viewport (i.e. mobile), + * allow the content to fill the entire viewport. + */ allowFullBleed?: boolean; }; diff --git a/packages/perseus/src/components/svg-image.tsx b/packages/perseus/src/components/svg-image.tsx index 5d178867d78..763cd3581ed 100644 --- a/packages/perseus/src/components/svg-image.tsx +++ b/packages/perseus/src/components/svg-image.tsx @@ -479,6 +479,9 @@ class SvgImage extends React.Component { imgSrc={imageSrc} width={width} height={height} + // Keep non-SVG images at their original + // size in zoom/focus mode. + allowScaleUp={false} /> )} @@ -568,6 +571,9 @@ class SvgImage extends React.Component { imgSrc={imageUrl} width={width} height={height} + // Allow SVG images to scale up to fill + // the viewport in zoom/focus mode. + allowScaleUp={true} /> )} diff --git a/packages/perseus/src/components/zoom-image-button.tsx b/packages/perseus/src/components/zoom-image-button.tsx index ab29c1fdc88..6983d21bca2 100644 --- a/packages/perseus/src/components/zoom-image-button.tsx +++ b/packages/perseus/src/components/zoom-image-button.tsx @@ -10,9 +10,20 @@ type Props = { imgSrc: string; width: number; height: number; + /** + * When true, scale up to fill viewport (SVGs/Graphies). + * When false, only scale to their original size. + */ + allowScaleUp?: boolean; }; -export const ZoomImageButton = ({imgElement, imgSrc, width, height}: Props) => { +export const ZoomImageButton = ({ + imgElement, + imgSrc, + width, + height, + allowScaleUp = false, +}: Props) => { const i18n = usePerseusI18n(); // Check for "Command + Click" or "Control + Click" to open the image @@ -37,6 +48,7 @@ export const ZoomImageButton = ({imgElement, imgSrc, width, height}: Props) => { imgElement={imgElement} width={width} height={height} + allowScaleUp={allowScaleUp} onClose={closeModal} /> )} diff --git a/packages/perseus/src/components/zoomed-image-view.tsx b/packages/perseus/src/components/zoomed-image-view.tsx index d9b1dd6d3d1..f96b85a32ac 100644 --- a/packages/perseus/src/components/zoomed-image-view.tsx +++ b/packages/perseus/src/components/zoomed-image-view.tsx @@ -15,6 +15,11 @@ type Props = { imgElement: React.ReactNode; width: number; height: number; + /** + * When true, scale up to fill viewport (SVGs/Graphies). + * When false, only scale to their original size. + */ + allowScaleUp?: boolean; onClose: () => void; }; @@ -22,6 +27,7 @@ export const ZoomedImageView = ({ imgElement, width, height, + allowScaleUp = false, onClose, }: Props) => { const i18n = usePerseusI18n(); @@ -30,17 +36,15 @@ export const ZoomedImageView = ({ const maxWidth = window.innerWidth - WB_MODAL_PADDING_TOTAL; const maxHeight = window.innerHeight - WB_MODAL_PADDING_TOTAL; - // Figure out the scale for the width and height, and use it to determine - // which dimension to use for the final size. const scaleWidth = maxWidth / width; const scaleHeight = maxHeight / height; - // Choose the smaller of the two so that the image fits inside - // the window - no scrolling. - const scale = Math.min(scaleWidth, scaleHeight, 1); + // When allowScaleUp is false (e.g. photos), cap at 1 so we never scale up. + const scale = allowScaleUp + ? Math.min(scaleWidth, scaleHeight) + : Math.min(scaleWidth, scaleHeight, 1); - // Calculate the final dimensions, constraine by the window size. - const constrainedWidth = width * scale; - const constrainedHeight = height * scale; + const displayWidth = width * scale; + const displayHeight = height * scale; return ( - - {imgElement} - + {allowScaleUp ? ( +
+ + {imgElement} + +
+ ) : ( + + {imgElement} + + )} )} diff --git a/packages/perseus/src/widgets/image/image.test.ts b/packages/perseus/src/widgets/image/image.test.ts index dfd369056ac..cb85fb0f5e5 100644 --- a/packages/perseus/src/widgets/image/image.test.ts +++ b/packages/perseus/src/widgets/image/image.test.ts @@ -555,6 +555,84 @@ describe.each([[true], [false]])("image widget - isMobile(%j)", (isMobile) => { // Assert expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); + + it("does not scale up normal images (e.g. photos) when zoomed", async () => { + // Arrange - normal image (jpg) uses allowScaleUp: false + const imageQuestion = generateTestPerseusRenderer({ + content: "[[☃ image 1]]", + widgets: { + "image 1": generateImageWidget({ + options: generateImageOptions({ + // Non-SVG image + backgroundImage: earthMoonImage, + }), + }), + }, + }); + renderQuestion(imageQuestion, apiOptions); + + // Act - open the zoom modal + const button = screen.getByRole("button", { + name: "Zoom image.", + }); + await userEvent.click(button); + + // Assert - no CSS transform scale wrapper (image stays at original size) + const dialog = screen.getByRole("dialog"); + expect(dialog).toBeVisible(); + const scaleWrappers = Array.from( + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + dialog.querySelectorAll("div"), + ).filter((el: Element) => { + const style = el.getAttribute("style"); + return ( + style?.includes("transform") && style?.includes("scale(") + ); + }); + expect(scaleWrappers).toHaveLength(0); + }); + + it("scales up Graphie/SVG images when zoomed", async () => { + // Arrange - Graphie image uses allowScaleUp: true + const imageQuestion = generateTestPerseusRenderer({ + content: "[[☃ image 1]]", + widgets: { + "image 1": generateImageWidget({ + options: generateImageOptions({ + // SVG image + backgroundImage: graphieImage, + }), + }), + }, + }); + renderQuestion(imageQuestion, apiOptions); + act(() => { + jest.runAllTimers(); + }); + + // Act - open the zoom modal + const button = screen.getByRole("button", { + name: "Zoom image.", + }); + await userEvent.click(button); + + // Assert - zoom uses transform scale so graph + labels scale together. + // When allowScaleUp is true, ZoomedImageView wraps content in a div with + // inline transform (scale(...)). + const dialog = screen.getByRole("dialog"); + expect(dialog).toBeVisible(); + const scaleWrapper = Array.from( + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + dialog.querySelectorAll("div"), + ).find((el: Element) => { + const style = el.getAttribute("style"); + return ( + style?.includes("transform") && style?.includes("scale(") + ); + }); + expect(scaleWrapper).toBeDefined(); + expect(scaleWrapper).toBeInTheDocument(); + }); }); describe("upgrade-image-widget feature flag", () => { From 42a40e70106a9bbb22fa89372ecdc6cf3dfc1fce Mon Sep 17 00:00:00 2001 From: Nisha Yerunkar Date: Thu, 12 Feb 2026 15:16:04 -0800 Subject: [PATCH 2/2] [scale-zoomed-graphies] docs(changeset): [Image] | (UX) | Scale up SVG images to fill screen when zoomed --- .changeset/olive-ties-sneeze.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/olive-ties-sneeze.md diff --git a/.changeset/olive-ties-sneeze.md b/.changeset/olive-ties-sneeze.md new file mode 100644 index 00000000000..b74f1c94e81 --- /dev/null +++ b/.changeset/olive-ties-sneeze.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +[Image] | (UX) | Scale up SVG images to fill screen when zoomed