Skip to content
Draft
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/giant-frogs-explode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/perseus": patch
---

Include the CSSZoomFactor in the calculations for the scrollable view
18 changes: 15 additions & 3 deletions packages/perseus/src/components/scrollable-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import caretRightIcon from "@phosphor-icons/core/regular/caret-right.svg";
import * as React from "react";
import {useEffect, useRef, useState} from "react";

import {getCSSZoomFactor} from "../util";

import {usePerseusI18n} from "./i18n-context";
import styles from "./scrollable-view.module.css";

Expand Down Expand Up @@ -154,8 +156,17 @@ function ScrollableArea({
const newIsRtl =
getComputedStyle(containerRef.current).direction === "rtl";

// Compensate for CSS zoom applied by the parent app (e.g. mobile font scaling).
// Under CSS zoom, scrollWidth can be reported in visual (zoomed) pixels while
// clientWidth and scrollLeft remain in CSS pixels, causing a false-positive
// overflow detection. Dividing scrollWidth by the zoom factor normalizes it
// to CSS pixels so the comparison is apples-to-apples.
const zoomFactor = getCSSZoomFactor(containerRef.current);
const adjustedScrollWidth = scrollWidth / zoomFactor;

// Only consider content scrollable if there's a meaningful amount to scroll
const newIsScrollable = scrollWidth > clientWidth + scrollableThreshold;
const newIsScrollable =
adjustedScrollWidth > clientWidth + scrollableThreshold;
setIsScrollable(newIsScrollable);

// In RTL mode, scrollLeft values work differently (can be negative)
Expand All @@ -169,11 +180,12 @@ function ScrollableArea({
newCanScrollStart = scrollLeft < -scrollableThreshold;
newCanScrollEnd =
Math.abs(scrollLeft) <
scrollWidth - clientWidth - scrollableThreshold;
adjustedScrollWidth - clientWidth - scrollableThreshold;
} else {
newCanScrollStart = scrollLeft > scrollableThreshold;
newCanScrollEnd =
scrollLeft + clientWidth < scrollWidth - scrollableThreshold;
scrollLeft + clientWidth <
adjustedScrollWidth - scrollableThreshold;
}

// Update global registry
Expand Down
40 changes: 40 additions & 0 deletions packages/perseus/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,45 @@ const textarea = {
const unescapeMathMode: (label: string) => string = (label) =>
label.startsWith("$") && label.endsWith("$") ? label.slice(1, -1) : label;

/**
* 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;
}

const Util = {
inputPathsEqual,
nestedMap,
Expand Down Expand Up @@ -585,6 +624,7 @@ const Util = {
getDataUrl: getDataUrl,
textarea,
unescapeMathMode,
getCSSZoomFactor,
} as const;

export default Util;
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";
import {actions} from "../reducer/interactive-graph-action";
import useGraphConfig from "../reducer/use-graph-config";
import {getCSSZoomFactor} from "../utils";

import {MovablePoint} from "./components/movable-point";
import {srFormatNumber} from "./screenreader-text";
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";
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 {MovablePoint} from "./components/movable-point";
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";
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 @@ -237,42 +237,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;
}
Loading