Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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/zoomable-font-scale.md
Original file line number Diff line number Diff line change
@@ -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
49 changes: 49 additions & 0 deletions packages/perseus/src/components/__docs__/zoomable.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: (
<table>
<thead>
<tr>
<th>Planet</th>
<th>Diameter (km)</th>
<th>Distance from sun (AU)</th>
<th>Orbital period (years)</th>
</tr>
</thead>
<tbody>
<tr>
<td>Mercury</td>
<td>4,879</td>
<td>0.39</td>
<td>0.24</td>
</tr>
<tr>
<td>Neptune</td>
<td>49,244</td>
<td>30.1</td>
<td>164.8</td>
</tr>
</tbody>
</table>
),
},
decorators: [
(StoryComponent) => (
<div style={{zoom: 1.6, width: 320, overflowX: "auto"}}>
<StoryComponent />
</div>
),
],
};
71 changes: 71 additions & 0 deletions packages/perseus/src/components/__tests__/zoomable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,77 @@ describe("Zoomable", () => {
});
});

describe("under ancestor CSS zoom (device font scale)", () => {
// The mobile apps enlarge text by applying CSS `zoom` to <body>.
// 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(
<Zoomable>
<span>Some zoomable text</span>
</Zoomable>,
);

// 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(
<Zoomable>
<span>Some zoomable text</span>
</Zoomable>,
);

// 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;
Expand Down
13 changes: 11 additions & 2 deletions packages/perseus/src/components/zoomable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -220,8 +222,15 @@ class Zoomable extends React.Component<Props, State> {
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,
Expand Down
74 changes: 74 additions & 0 deletions packages/perseus/src/util/css-zoom-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import {getCSSZoomFactor} from "./css-zoom-utils";

describe("getCSSZoomFactor", () => {
const mockZoomByElement = (zoomByElement: Map<Element, string>) => {
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);
});
});
40 changes: 40 additions & 0 deletions packages/perseus/src/util/css-zoom-utils.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
39 changes: 0 additions & 39 deletions packages/perseus/src/widgets/interactive-graphs/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}