diff --git a/.changeset/zoomable-font-scale.md b/.changeset/zoomable-font-scale.md new file mode 100644 index 0000000000..ca6af360b4 --- /dev/null +++ b/.changeset/zoomable-font-scale.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +Fix table (and block math) text not scaling with the device font scale on mobile: Zoomable now fits content to the zoom-adjusted container width instead of cancelling out the CSS zoom applied for font enlargement diff --git a/packages/perseus/src/components/__docs__/zoomable.stories.tsx b/packages/perseus/src/components/__docs__/zoomable.stories.tsx index abfe6af184..cc801ea0c9 100644 --- a/packages/perseus/src/components/__docs__/zoomable.stories.tsx +++ b/packages/perseus/src/components/__docs__/zoomable.stories.tsx @@ -49,3 +49,52 @@ export const ZoomableExample: Story = { ), }, }; + +/** + * The mobile apps honor the device font scale by applying CSS `zoom` to + * the page. Zoomable fits content to the zoom-adjusted width, so the + * fitted content's visual size grows with the font scale instead of being + * scaled back down to its unzoomed size (LEMS-3885). The table below + * should render with visibly larger text than the same story without the + * zoomed container. + */ +export const UnderAncestorCSSZoom: Story = { + args: { + // Measure the actual table rather than the default story's + // hard-coded bounds. + computeChildBounds: undefined, + children: ( + + + + + + + + + + + + + + + + + + + + + + + +
PlanetDiameter (km)Distance from sun (AU)Orbital period (years)
Mercury4,8790.390.24
Neptune49,24430.1164.8
+ ), + }, + decorators: [ + (StoryComponent) => ( +
+ +
+ ), + ], +}; diff --git a/packages/perseus/src/components/__tests__/zoomable.test.tsx b/packages/perseus/src/components/__tests__/zoomable.test.tsx index 21292f52d0..42402190da 100644 --- a/packages/perseus/src/components/__tests__/zoomable.test.tsx +++ b/packages/perseus/src/components/__tests__/zoomable.test.tsx @@ -330,6 +330,77 @@ describe("Zoomable", () => { }); }); + describe("under ancestor CSS zoom (device font scale)", () => { + // The mobile apps enlarge text by applying CSS `zoom` to . + // Zoomable must fit content to the zoom-adjusted width, otherwise + // its fit-to-width scale exactly cancels the font scaling + // (LEMS-3885). + + const mockBodyZoom = (zoom: string) => { + const realGetComputedStyle = window.getComputedStyle; + jest.spyOn(window, "getComputedStyle").mockImplementation( + (el: Element) => { + const style = realGetComputedStyle(el); + if (el === document.body) { + Object.defineProperty(style, "zoom", {value: zoom}); + } + return style; + }, + ); + }; + + it("scales to the zoom-adjusted width when the child overflows it", () => { + // Arrange + mockBodyZoom("1.5"); + const {container} = render( + + Some zoomable text + , + ); + + // eslint-disable-next-line testing-library/no-node-access, no-restricted-syntax + const rootNode = container.firstElementChild as HTMLElement; + mockSize(rootNode, {width: 400, height: 100}); + mockSize(screen.getByText("Some zoomable text"), { + width: 800, + height: 200, + }); + + // Act + act(() => jest.runAllTimers()); + + // Assert + // scale = (400 * 1.5) / 801, instead of 400 / 801 unzoomed + const scale = 600 / 801; + expect(rootNode.style.transform).toBe(`scale(${scale}, ${scale})`); + expect(rootNode.style.height).toBe(`${Math.ceil(scale * 201)}px`); + }); + + it("does not scale down when the child fits the zoom-adjusted width", () => { + // Arrange + mockBodyZoom("1.5"); + const {container} = render( + + Some zoomable text + , + ); + + // eslint-disable-next-line testing-library/no-node-access, no-restricted-syntax + const rootNode = container.firstElementChild as HTMLElement; + mockSize(rootNode, {width: 400, height: 100}); + mockSize(screen.getByText("Some zoomable text"), { + width: 500, + height: 200, + }); + + // Act + act(() => jest.runAllTimers()); + + // Assert + expect(rootNode.style.transform).toBe("scale(1, 1)"); + }); + }); + describe("child node mutations", () => { let computeChildBounds; let componentContainer; diff --git a/packages/perseus/src/components/zoomable.tsx b/packages/perseus/src/components/zoomable.tsx index f3fb84d034..777f07f1db 100644 --- a/packages/perseus/src/components/zoomable.tsx +++ b/packages/perseus/src/components/zoomable.tsx @@ -6,6 +6,8 @@ import * as React from "react"; import ReactDOM from "react-dom"; +import {getCSSZoomFactor} from "../util/css-zoom-utils"; + type Bounds = { width: number; height: number; @@ -220,8 +222,15 @@ class Zoomable extends React.Component { const childWidth = childBounds.width + 1; const childHeight = childBounds.height + 1; - if (childWidth > parentBounds.width) { - const scale = parentBounds.width / childWidth; + // Fit to the zoom-adjusted width so the fitted content's visual + // size grows with the device font scale instead of being scaled + // back down to its unzoomed size. Overflow at the enlarged size is + // handled by the parent's overflowX scrolling and tap-to-zoom. + const availableWidth = + parentBounds.width * getCSSZoomFactor(this._node); + + if (childWidth > availableWidth) { + const scale = availableWidth / childWidth; this.setState({ scale, diff --git a/packages/perseus/src/util/css-zoom-utils.test.ts b/packages/perseus/src/util/css-zoom-utils.test.ts new file mode 100644 index 0000000000..aeb46222a4 --- /dev/null +++ b/packages/perseus/src/util/css-zoom-utils.test.ts @@ -0,0 +1,74 @@ +import {getCSSZoomFactor} from "./css-zoom-utils"; + +describe("getCSSZoomFactor", () => { + const mockZoomByElement = (zoomByElement: Map) => { + jest.spyOn(window, "getComputedStyle").mockImplementation( + (el: Element) => + // eslint-disable-next-line no-restricted-syntax -- partial mock of CSSStyleDeclaration; only `zoom` is read + ({zoom: zoomByElement.get(el) ?? ""}) as CSSStyleDeclaration, + ); + }; + + it("returns 1 when no element in the tree has zoom applied", () => { + // Arrange + const node = document.createElement("div"); + document.body.appendChild(node); + + // Act + const zoomFactor = getCSSZoomFactor(node); + + // Assert + expect(zoomFactor).toBe(1); + }); + + it("returns the zoom applied to an ancestor", () => { + // Arrange + const node = document.createElement("div"); + document.body.appendChild(node); + mockZoomByElement(new Map([[document.body, "1.5"]])); + + // Act + const zoomFactor = getCSSZoomFactor(node); + + // Assert + expect(zoomFactor).toBe(1.5); + }); + + it("multiplies zoom values across the element and its ancestors", () => { + // Arrange + const parent = document.createElement("div"); + const node = document.createElement("div"); + parent.appendChild(node); + document.body.appendChild(parent); + mockZoomByElement( + new Map([ + [node, "2"], + [parent, "1.5"], + ]), + ); + + // Act + const zoomFactor = getCSSZoomFactor(node); + + // Assert + expect(zoomFactor).toBe(3); + }); + + it("ignores 'normal' and non-numeric zoom values", () => { + // Arrange + const node = document.createElement("div"); + document.body.appendChild(node); + mockZoomByElement( + new Map([ + [node, "normal"], + [document.body, "bogus"], + ]), + ); + + // Act + const zoomFactor = getCSSZoomFactor(node); + + // Assert + expect(zoomFactor).toBe(1); + }); +}); diff --git a/packages/perseus/src/util/css-zoom-utils.ts b/packages/perseus/src/util/css-zoom-utils.ts new file mode 100644 index 0000000000..90d5878204 --- /dev/null +++ b/packages/perseus/src/util/css-zoom-utils.ts @@ -0,0 +1,40 @@ +/** + * Gets the effective CSS zoom factor applied to an element or any of its ancestors. + * This is used to compensate for the mobile font scaling zoom applied to the body + * or exercise content via the fontScale query parameter. + * + * On mobile, the parent application may apply CSS zoom to accommodate device font + * size settings. This zoom affects coordinate calculations for click/touch events, + * as both clientX/clientY and getBoundingClientRect() return zoomed values, but + * the SVG coordinate system expects unzoomed pixel values. It also shrinks the + * widths reported by offsetWidth for viewport-constrained containers, which + * fit-to-width logic (e.g. Zoomable) must account for. + * + * Note: We calculate the cumulative zoom by traversing the DOM tree rather than + * targeting specific elements to avoid coupling Perseus to parent application + * implementation details (e.g., specific class names or DOM hierarchy). + * + * @param element - The DOM element to check for CSS zoom + * @returns The cumulative zoom factor (e.g., 1.5 for 150% zoom, 1.0 for no zoom) + */ +export function getCSSZoomFactor(element: Element): number { + let zoomFactor = 1; + let currentElement: Element | null = element; + + // Traverse up the DOM tree to accumulate all zoom values + while (currentElement) { + const computedStyle = window.getComputedStyle(currentElement); + const zoom = computedStyle.zoom; + + if (zoom && zoom !== "normal") { + const zoomValue = parseFloat(zoom); + if (!isNaN(zoomValue)) { + zoomFactor *= zoomValue; + } + } + + currentElement = currentElement.parentElement; + } + + return zoomFactor; +} diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/point.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/point.tsx index 08b89be768..7f1403dfdb 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/point.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/point.tsx @@ -1,9 +1,9 @@ import {useTimeout} from "@khanacademy/wonder-blocks-timing"; import * as React from "react"; +import {getCSSZoomFactor} from "../../../util/css-zoom-utils"; import {actions} from "../reducer/interactive-graph-action"; import useGraphConfig from "../reducer/use-graph-config"; -import {getCSSZoomFactor} from "../utils"; import { buildPointAriaLabel, diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx index ec8e8f1f29..b95661e340 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx @@ -9,6 +9,7 @@ import { usePerseusI18n, type I18nContextType, } from "../../../components/i18n-context"; +import {getCSSZoomFactor} from "../../../util/css-zoom-utils"; import {snap} from "../math"; import {isInBound} from "../math/box"; import {actions} from "../reducer/interactive-graph-action"; @@ -17,7 +18,7 @@ import { calculateSideSnap, } from "../reducer/interactive-graph-reducer"; import useGraphConfig from "../reducer/use-graph-config"; -import {bound, getCSSZoomFactor, TARGET_SIZE} from "../utils"; +import {bound, TARGET_SIZE} from "../utils"; import {PolygonAngle} from "./components/angle-indicators"; import { diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/use-draggable.ts b/packages/perseus/src/widgets/interactive-graphs/graphs/use-draggable.ts index ed810c1c87..9dff5bf193 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/use-draggable.ts +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/use-draggable.ts @@ -3,9 +3,9 @@ import {useTransformContext, vec} from "mafs"; import * as React from "react"; import invariant from "tiny-invariant"; +import {getCSSZoomFactor} from "../../../util/css-zoom-utils"; import {X, Y} from "../math"; import useGraphConfig from "../reducer/use-graph-config"; -import {getCSSZoomFactor} from "../utils"; import type {RefObject} from "react"; diff --git a/packages/perseus/src/widgets/interactive-graphs/utils.ts b/packages/perseus/src/widgets/interactive-graphs/utils.ts index 46bb86323f..0bbd91aa89 100644 --- a/packages/perseus/src/widgets/interactive-graphs/utils.ts +++ b/packages/perseus/src/widgets/interactive-graphs/utils.ts @@ -269,42 +269,3 @@ export const calculateNestedSVGCoords = ( viewboxY, }; }; - -/** - * Gets the effective CSS zoom factor applied to an element or any of its ancestors. - * This is used to compensate for the mobile font scaling zoom applied to the body - * or exercise content via the fontScale query parameter. - * - * On mobile, the parent application may apply CSS zoom to accommodate device font - * size settings. This zoom affects coordinate calculations for click/touch events, - * as both clientX/clientY and getBoundingClientRect() return zoomed values, but - * the SVG coordinate system expects unzoomed pixel values. - * - * Note: We calculate the cumulative zoom by traversing the DOM tree rather than - * targeting specific elements to avoid coupling Perseus to parent application - * implementation details (e.g., specific class names or DOM hierarchy). - * - * @param element - The DOM element to check for CSS zoom - * @returns The cumulative zoom factor (e.g., 1.5 for 150% zoom, 1.0 for no zoom) - */ -export function getCSSZoomFactor(element: Element): number { - let zoomFactor = 1; - let currentElement: Element | null = element; - - // Traverse up the DOM tree to accumulate all zoom values - while (currentElement) { - const computedStyle = window.getComputedStyle(currentElement); - const zoom = computedStyle.zoom; - - if (zoom && zoom !== "normal") { - const zoomValue = parseFloat(zoom); - if (!isNaN(zoomValue)) { - zoomFactor *= zoomValue; - } - } - - currentElement = currentElement.parentElement; - } - - return zoomFactor; -}