Skip to content

Commit 131d5e6

Browse files
Include zoom level in unlimted interactive graph calculations (#3221)
## Summary: This PR fixes a bug related to our Mobile Application and our Interactive Graph Widgets. The issue was that we're adding a zoom style in frontend based on the user's fontScale, in order to help support accessibility features on iOS. Unfortunately, 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. As a result, iOS users with increased fontSizes would find the position of points they add/drag to be inaccurate to their touch position. In order to resolve that, I've added logic to calculate the cumulative zoom level. I chose to do it cumulatively, rather than finding a specific element, to help prevent future regressions. It seemed plausible that class names or hierarchy could be changed at some point in the future, and the cumulative approach allows us to remain platform agnostic. The calculations seemed fairly negligible, but I'm happy to adjust if anyone has any concerns about it! Issue: LEMS-3258 ## Test plan: - Manual Testing using fontScale parameter on Desktop - Manual Testing with Mobile Applications (Both Android and iOS, at both normal and enhanced zooms) - Existing tests pass Author: SonicScrewdriver Reviewers: SonicScrewdriver, mark-fitzgerald Required Reviewers: Approved By: mark-fitzgerald Checks: ⏭️ 1 check has been skipped, ✅ 10 checks were successful Pull Request URL: #3221
1 parent 304c071 commit 131d5e6

File tree

5 files changed

+66
-6
lines changed

5 files changed

+66
-6
lines changed

.changeset/mighty-pants-retire.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@khanacademy/perseus": minor
3+
---
4+
5+
Bugfix to account for frontend's application of zoom to support fontScaling on mobile.

packages/perseus/src/widgets/interactive-graphs/graphs/point.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as React from "react";
33

44
import {actions} from "../reducer/interactive-graph-action";
55
import useGraphConfig from "../reducer/use-graph-config";
6+
import {getCSSZoomFactor} from "../utils";
67

78
import {MovablePoint} from "./components/movable-point";
89
import {srFormatNumber} from "./screenreader-text";
@@ -135,9 +136,11 @@ function UnlimitedPointGraph(statefulProps: StatefulProps) {
135136

136137
const elementRect =
137138
event.currentTarget.getBoundingClientRect();
139+
const zoomFactor = getCSSZoomFactor(event.currentTarget);
138140

139-
const x = event.clientX - elementRect.x;
140-
const y = event.clientY - elementRect.y;
141+
// Compensate for CSS zoom applied by mobile font scaling
142+
const x = (event.clientX - elementRect.x) / zoomFactor;
143+
const y = (event.clientY - elementRect.y) / zoomFactor;
141144

142145
const graphCoordinates = pixelsToVectors(
143146
[[x, y]],

packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
calculateSideSnap,
1818
} from "../reducer/interactive-graph-reducer";
1919
import useGraphConfig from "../reducer/use-graph-config";
20-
import {bound, TARGET_SIZE} from "../utils";
20+
import {bound, getCSSZoomFactor, TARGET_SIZE} from "../utils";
2121

2222
import {PolygonAngle} from "./components/angle-indicators";
2323
import {MovablePoint} from "./components/movable-point";
@@ -513,9 +513,11 @@ const UnlimitedPolygonGraph = (statefulProps: StatefulProps) => {
513513

514514
const elementRect =
515515
event.currentTarget.getBoundingClientRect();
516+
const zoomFactor = getCSSZoomFactor(event.currentTarget);
516517

517-
const x = event.clientX - elementRect.x;
518-
const y = event.clientY - elementRect.y;
518+
// Compensate for CSS zoom applied by mobile font scaling
519+
const x = (event.clientX - elementRect.x) / zoomFactor;
520+
const y = (event.clientY - elementRect.y) / zoomFactor;
519521

520522
const graphCoordinates = pixelsToVectors(
521523
[[x, y]],

packages/perseus/src/widgets/interactive-graphs/graphs/use-draggable.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import invariant from "tiny-invariant";
55

66
import {X, Y} from "../math";
77
import useGraphConfig from "../reducer/use-graph-config";
8+
import {getCSSZoomFactor} from "../utils";
89

910
import type {RefObject} from "react";
1011

@@ -171,8 +172,18 @@ export function useDraggable(args: Params): DragState {
171172
return;
172173
}
173174

174-
const movement = vec.transform(
175+
// Compensate for CSS zoom applied by mobile font scaling
176+
// The pixelMovement from the drag library is in zoomed coordinates
177+
const zoomFactor = target.current
178+
? getCSSZoomFactor(target.current)
179+
: 1;
180+
const unzoomedPixelMovement = vec.scale(
175181
pixelMovement,
182+
1 / zoomFactor,
183+
);
184+
185+
const movement = vec.transform(
186+
unzoomedPixelMovement,
176187
inverseViewTransform,
177188
);
178189
onMove(

packages/perseus/src/widgets/interactive-graphs/utils.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,3 +237,42 @@ export const calculateNestedSVGCoords = (
237237
viewboxY,
238238
};
239239
};
240+
241+
/**
242+
* Gets the effective CSS zoom factor applied to an element or any of its ancestors.
243+
* This is used to compensate for the mobile font scaling zoom applied to the body
244+
* or exercise content via the fontScale query parameter.
245+
*
246+
* On mobile, the parent application may apply CSS zoom to accommodate device font
247+
* size settings. This zoom affects coordinate calculations for click/touch events,
248+
* as both clientX/clientY and getBoundingClientRect() return zoomed values, but
249+
* the SVG coordinate system expects unzoomed pixel values.
250+
*
251+
* Note: We calculate the cumulative zoom by traversing the DOM tree rather than
252+
* targeting specific elements to avoid coupling Perseus to parent application
253+
* implementation details (e.g., specific class names or DOM hierarchy).
254+
*
255+
* @param element - The DOM element to check for CSS zoom
256+
* @returns The cumulative zoom factor (e.g., 1.5 for 150% zoom, 1.0 for no zoom)
257+
*/
258+
export function getCSSZoomFactor(element: Element): number {
259+
let zoomFactor = 1;
260+
let currentElement: Element | null = element;
261+
262+
// Traverse up the DOM tree to accumulate all zoom values
263+
while (currentElement) {
264+
const computedStyle = window.getComputedStyle(currentElement);
265+
const zoom = computedStyle.zoom;
266+
267+
if (zoom && zoom !== "normal") {
268+
const zoomValue = parseFloat(zoom);
269+
if (!isNaN(zoomValue)) {
270+
zoomFactor *= zoomValue;
271+
}
272+
}
273+
274+
currentElement = currentElement.parentElement;
275+
}
276+
277+
return zoomFactor;
278+
}

0 commit comments

Comments
 (0)