diff --git a/.changeset/olive-ties-sneeze.md b/.changeset/olive-ties-sneeze.md new file mode 100644 index 0000000000..b74f1c94e8 --- /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 diff --git a/packages/perseus/src/components/fixed-to-responsive.tsx b/packages/perseus/src/components/fixed-to-responsive.tsx index 712c2bb4a8..c63cb66e2c 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 5d178867d7..763cd3581e 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 ab29c1fdc8..6983d21bca 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 d9b1dd6d3d..f96b85a32a 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 dfd369056a..cb85fb0f5e 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", () => {