diff --git a/packages/perseus-core/src/index.ts b/packages/perseus-core/src/index.ts index c34af77712..814106257f 100644 --- a/packages/perseus-core/src/index.ts +++ b/packages/perseus-core/src/index.ts @@ -17,3 +17,5 @@ export {PerseusError} from "./error/perseus-error"; export * from "./data-schema"; export {pluck, mapObject} from "./utils/objective_"; +export {default as Util} from "./utils/util"; +export type {GridDimensions, Position} from "./utils/util"; diff --git a/packages/perseus-core/src/utils/util.ts b/packages/perseus-core/src/utils/util.ts new file mode 100644 index 0000000000..d80bb4e5b4 --- /dev/null +++ b/packages/perseus-core/src/utils/util.ts @@ -0,0 +1,688 @@ +import _ from "underscore"; + +import type {Range} from "../data-schema"; + +type WordPosition = { + start: number; + end: number; +}; + +type WordAndPosition = { + string: string; + pos: WordPosition; +}; + +type RNG = () => number; + +export type ParsedValue = { + value: number; + exact: boolean; +}; + +// TODO: dedupe this with Coord in interactive2/types.js +type Coordinates = [number, number]; + +export type GridDimensions = { + scale: number; + tickStep: number; + unityLabel: boolean; +}; + +type QueryParams = { + [param: string]: string; +}; + +export type Position = { + top: number; + left: number; +}; + +type TouchHandlers = { + pointerDown: boolean; + currentTouchIdentifier: string | null | undefined; +}; + +let supportsPassive = false; + +const nestedMap = function ( + children: T | ReadonlyArray, + func: (arg1: T) => M, + context: unknown, +): M | ReadonlyArray { + if (Array.isArray(children)) { + // @ts-expect-error - TS2322 - Type '(M | readonly M[])[]' is not assignable to type 'M | readonly M[]'. + return _.map(children, function (child) { + // @ts-expect-error - TS2554 - Expected 3 arguments, but got 2. + return nestedMap(child, func); + }); + } + // TODO(LEMS-2656): remove TS suppression + // @ts-expect-error: T | ReadonlyArray is not assignable to T + return func.call(context, children); +}; + +/** + * Used to compare equality of two input paths, which are represented as + * arrays of strings. + */ +function inputPathsEqual( + a?: ReadonlyArray | null, + b?: ReadonlyArray | null, +): boolean { + if (a == null || b == null) { + return (a == null) === (b == null); + } + + return ( + a.length === b.length && + a.every((item, index) => { + return b[index] === item; + }) + ); +} + +const rWidgetRule = /^\[\[\u2603 (([a-z-]+) ([0-9]+))\]\]/; +const rTypeFromWidgetId = /^([a-z-]+) ([0-9]+)$/; + +const rWidgetParts = new RegExp(rWidgetRule.source + "$"); +const snowman = "\u2603"; + +const seededRNG: (seed: number) => RNG = function (seed: number): RNG { + let randomSeed = seed; + + return function () { + // Robert Jenkins' 32 bit integer hash function. + let seed = randomSeed; + seed = (seed + 0x7ed55d16 + (seed << 12)) & 0xffffffff; + seed = (seed ^ 0xc761c23c ^ (seed >>> 19)) & 0xffffffff; + seed = (seed + 0x165667b1 + (seed << 5)) & 0xffffffff; + seed = ((seed + 0xd3a2646c) ^ (seed << 9)) & 0xffffffff; + seed = (seed + 0xfd7046c5 + (seed << 3)) & 0xffffffff; + seed = (seed ^ 0xb55a4f09 ^ (seed >>> 16)) & 0xffffffff; + return (randomSeed = seed & 0xfffffff) / 0x10000000; + }; +}; + +// Shuffle an array using a given random seed or function. +// If `ensurePermuted` is true, the input and ouput are guaranteed to be +// distinct permutations. +function shuffle( + array: ReadonlyArray, + randomSeed: number | RNG, + ensurePermuted = false, +): ReadonlyArray { + // Always return a copy of the input array + const shuffled = _.clone(array); + + // Handle edge cases (input array is empty or uniform) + if ( + !shuffled.length || + _.all(shuffled, function (value) { + return _.isEqual(value, shuffled[0]); + }) + ) { + return shuffled; + } + + let random; + if (typeof randomSeed === "function") { + random = randomSeed; + } else { + random = seededRNG(randomSeed); + } + + do { + // Fischer-Yates shuffle + for (let top = shuffled.length; top > 0; top--) { + const newEnd = Math.floor(random() * top); + const temp = shuffled[newEnd]; + + // @ts-expect-error - TS2542 - Index signature in type 'readonly T[]' only permits reading. + shuffled[newEnd] = shuffled[top - 1]; + // @ts-expect-error - TS2542 - Index signature in type 'readonly T[]' only permits reading. + shuffled[top - 1] = temp; + } + } while (ensurePermuted && _.isEqual(array, shuffled)); + + return shuffled; +} + +/** + * TODO(somewhatabstract, FEI-3463): + * Drop this custom split thing. + */ +// In IE8, split doesn't work right. Implement it ourselves. +const split: (str: string, r: RegExp) => ReadonlyArray = "x".split( + /(.)/g, +).length + ? function (str: string, r) { + return str.split(r); + } + : function (str: string, r: RegExp) { + // Based on Steven Levithan's MIT-licensed split, available at + // http://blog.stevenlevithan.com/archives/cross-browser-split + const output = []; + let lastIndex = (r.lastIndex = 0); + let match; + + while ((match = r.exec(str))) { + const m = match; + // @ts-expect-error - TS2345 - Argument of type 'string' is not assignable to parameter of type 'never'. + output.push(str.slice(lastIndex, m.index)); + // @ts-expect-error - TS2345 - Argument of type 'any' is not assignable to parameter of type 'never'. + output.push(...m.slice(1)); + lastIndex = m.index + m[0].length; + } + + // @ts-expect-error - TS2345 - Argument of type 'string' is not assignable to parameter of type 'never'. + output.push(str.slice(lastIndex)); + return output; + }; + +function stringArrayOfSize(size: number): ReadonlyArray { + return _(size).times(function () { + return ""; + }); +} + +/** + * For a graph's x or y dimension, given the tick step, + * the ranges extent (e.g. [-10, 10]), the pixel dimension constraint, + * and the grid step, return a bunch of configurations for that dimension. + * + * Example: + * gridDimensionConfig(10, [-50, 50], 400, 5) + * + * Returns: { + * scale: 4, + * snap: 2.5, + * tickStep: 2, + * unityLabel: true + * }; + */ +function gridDimensionConfig( + absTickStep: number, + extent: Coordinates, + dimensionConstraint: number, + gridStep: number, +): GridDimensions { + const scale = scaleFromExtent(extent, dimensionConstraint); + const stepPx = absTickStep * scale; + const unityLabel = stepPx > 30; + return { + scale: scale, + tickStep: absTickStep / gridStep, + unityLabel: unityLabel, + }; +} +/** + * Given the range, step, and boxSize, calculate the reasonable gridStep. + * Used for when one was not given explicitly. + * + * Example: + * getGridStep([[-10, 10], [-10, 10]], [1, 1], 340) + * + * Returns: [1, 1] + * + * TODO(somewhatabstract, FEI-3464): Consolidate query string parsing functions. + */ +function getGridStep( + range: [Coordinates, Coordinates], + step: Coordinates, + boxSize: number, +): Coordinates { + // @ts-expect-error - TS2322 - Type '(number | null | undefined)[]' is not assignable to type 'Coordinates'. + return _(2).times(function (i) { + const scale = scaleFromExtent(range[i], boxSize); + const gridStep = gridStepFromTickStep(step[i], scale); + return gridStep; + }); +} + +function snapStepFromGridStep(gridStep: [number, number]): [number, number] { + return [gridStep[0] / 2, gridStep[1] / 2]; +} + +/** + * Given the tickStep and the graph's scale, find a + * grid step. + * Example: + * gridStepFromTickStep(200, 0.2) // returns 100 + */ +function gridStepFromTickStep( + tickStep: number, + scale: number, +): number | null | undefined { + const tickWidth = tickStep * scale; + const x = tickStep; + const y = Math.pow(10, Math.floor(Math.log(x) / Math.LN10)); + const leadingDigit = Math.floor(x / y); + if (tickWidth < 25) { + return tickStep; + } + if (tickWidth < 50) { + if (leadingDigit === 5) { + return tickStep; + } + return tickStep / 2; + } + if (leadingDigit === 1) { + return tickStep / 2; + } + if (leadingDigit === 2) { + return tickStep / 4; + } + if (leadingDigit === 5) { + return tickStep / 5; + } +} + +/** + * Given the range and a dimension, come up with the appropriate + * scale. + * Example: + * scaleFromExtent([-25, 25], 500) // returns 10 + */ +function scaleFromExtent( + extent: Coordinates, + dimensionConstraint: number, +): number { + const span = extent[1] - extent[0]; + const scale = dimensionConstraint / span; + return scale; +} + +/** + * Return a reasonable tick step given extent and dimension. + * (extent is [begin, end] of the domain.) + * Example: + * tickStepFromExtent([-10, 10], 300) // returns 2 + */ +function tickStepFromExtent( + extent: Coordinates, + dimensionConstraint: number, +): number { + const span = extent[1] - extent[0]; + + let tickFactor; + // If single number digits + if (15 < span && span <= 20) { + tickFactor = 23; + + // triple digit or decimal + } else if (span > 100 || span < 5) { + tickFactor = 10; + + // double digit + } else { + tickFactor = 16; + } + const constraintFactor = dimensionConstraint / 500; + const desiredNumTicks = tickFactor * constraintFactor; + return tickStepFromNumTicks(span, desiredNumTicks); +} + +/** + * Find a good tick step for the desired number of ticks in the range + * Modified from d3.scale.linear: d3_scale_linearTickRange. + * Thanks, mbostock! + * Example: + * tickStepFromNumTicks(50, 6) // returns 10 + */ +function tickStepFromNumTicks(span: number, numTicks: number): number { + let step = Math.pow(10, Math.floor(Math.log(span / numTicks) / Math.LN10)); + const err = (numTicks / span) * step; + + // Filter ticks to get closer to the desired count. + if (err <= 0.15) { + step *= 10; + } else if (err <= 0.35) { + step *= 5; + } else if (err <= 0.75) { + step *= 2; + } + + // Round start and stop values to step interval. + return step; +} + +const constrainTickStep = (step: number, range: Range): number => { + const span = range[1] - range[0]; + const numTicks = span / step; + if (numTicks <= 10) { + // Will displays fine on mobile + return step; + } + if (numTicks <= 20) { + // Will be crowded on mobile, so hide every other tick + return step * 2; + } + // Fallback in case we somehow have more than 20 ticks + // Note: This shouldn't happen due to GraphSettings.validStep + return tickStepFromNumTicks(span, 10); +}; + +/** + * Constrain tick steps intended for desktop size graphs + * to something more suitable for mobile size graphs. + * Specifically, we aim for 10 or fewer ticks per graph axis. + */ +function constrainedTickStepsFromTickSteps( + tickSteps: [number, number], + ranges: [Range, Range], +): Coordinates { + return [ + constrainTickStep(tickSteps[0], ranges[0]), + constrainTickStep(tickSteps[1], ranges[1]), + ]; +} + +/** + * Approximate equality on numbers and primitives. + */ +function eq(x: T, y: T): boolean { + if (typeof x === "number" && typeof y === "number") { + return Math.abs(x - y) < 1e-9; + } + return x === y; +} + +/** + * Deep approximate equality on primitives, numbers, arrays, and objects. + * Recursive. + */ +function deepEq(x: T, y: T): boolean { + if (Array.isArray(x) && Array.isArray(y)) { + if (x.length !== y.length) { + return false; + } + for (let i = 0; i < x.length; i++) { + if (!deepEq(x[i], y[i])) { + return false; + } + } + return true; + } + if (Array.isArray(x) || Array.isArray(y)) { + return false; + } + if (typeof x === "function" && typeof y === "function") { + return eq(x, y); + } + if (typeof x === "function" || typeof y === "function") { + return false; + } + if (typeof x === "object" && typeof y === "object" && !!x && !!y) { + return ( + x === y || + (_.all(x, function (v, k) { + // @ts-expect-error - TS2536 - Type 'CollectionKey' cannot be used to index type 'T'. + return deepEq(y[k], v); + }) && + _.all(y, function (v, k) { + // @ts-expect-error - TS2536 - Type 'CollectionKey' cannot be used to index type 'T'. + return deepEq(x[k], v); + })) + ); + } + if ((typeof x === "object" && !!x) || (typeof y === "object" && !!y)) { + return false; + } + return eq(x, y); +} + +/** + * Query String Parser + * + * Original from: + * http://stackoverflow.com/questions/901115/get-querystring-values-in-javascript/2880929#2880929 + */ +function parseQueryString(query: string): QueryParams { + // TODO(jangmi, CP-3340): Use withLocation to access SSR safe location. + // eslint-disable-next-line no-restricted-syntax + query = query || window.location.search.substring(1); + const urlParams: Record = {}; + // Regex for replacing addition symbol with a space + const a = /\+/g; + const r = /([^&=]+)=?([^&]*)/g; + const d = function (s) { + return decodeURIComponent(s.replace(a, " ")); + }; + + let e; + while ((e = r.exec(query))) { + const m = e; + urlParams[d(m[1])] = d(m[2]); + } + + return urlParams; +} + +/** + * Query string adder + * Works for URLs without #. + * Original from: + * http://stackoverflow.com/questions/5999118/add-or-update-query-string-parameter + */ +function updateQueryString(uri: string, key: string, value: string): string { + value = encodeURIComponent(value); + const re = new RegExp("([?&])" + key + "=.*?(&|$)", "i"); + const separator = uri.indexOf("?") !== -1 ? "&" : "?"; + if (uri.match(re)) { + return uri.replace(re, "$1" + key + "=" + value + "$2"); + } + return uri + separator + key + "=" + value; +} + +/** + * A more strict encodeURIComponent that escapes `()'!`s + * Especially useful for creating URLs that are embeddable in markdown + * + * Adapted from + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent + * This function and the above original available under the + * CC-BY-SA 2.5 license. + */ +function strongEncodeURIComponent(str: string): string { + return ( + encodeURIComponent(str) + // Note that although RFC3986 reserves "!", RFC5987 does not, + // so we do not need to escape it + .replace(/['()!]/g, window.escape) // i.e., %27 %28 %29 + .replace(/\*/g, "%2A") + ); +} + +/* + * The touchHandlers are used to track the current state of the touch + * event, such as whether or not the user is currently pressed down (either + * through touch or mouse) on the screen. + */ +const touchHandlers: TouchHandlers = { + pointerDown: false, + currentTouchIdentifier: null, +}; + +function resetTouchHandlers() { + Object.assign(touchHandlers, { + pointerDown: false, + currentTouchIdentifier: null, + }); +} + +/** + * Extracts the location of a touch or mouse event, allowing you to pass + * in a "mouseup", "mousedown", or "mousemove" event and receive the + * correct coordinates. Shouldn't be used with "vmouse" events. + */ +function extractPointerLocation(event: any): Position | null | undefined { + let touchOrEvent; + + if (touchHandlers.pointerDown) { + // Look for the touch matching the one we're tracking; ignore others + if (touchHandlers.currentTouchIdentifier != null) { + const len = event.changedTouches ? event.changedTouches.length : 0; + for (let i = 0; i < len; i++) { + if ( + event.changedTouches[i].identifier === + touchHandlers.currentTouchIdentifier + ) { + touchOrEvent = event.changedTouches[i]; + } + } + } else { + touchOrEvent = event; + } + + const isEndish = + event.type === "touchend" || event.type === "touchcancel"; + if (touchOrEvent && isEndish) { + touchHandlers.pointerDown = false; + touchHandlers.currentTouchIdentifier = null; + } + } else { + // touchstart or mousedown + touchHandlers.pointerDown = true; + if (event.changedTouches) { + touchOrEvent = event.changedTouches[0]; + touchHandlers.currentTouchIdentifier = touchOrEvent.identifier; + } else { + touchOrEvent = event; + } + } + + if (touchOrEvent) { + return { + left: touchOrEvent.pageX, + top: touchOrEvent.pageY, + }; + } +} + +// Older browsers don't support passive events and so we need to feature- +// detect them and do event subscription differently for them. +// See: orderer.jsx +const supportsPassiveEvents: () => boolean = () => { + // Test via a getter in the options object to see if the passive + // property is accessed + try { + const opts = Object.defineProperty({}, "passive", { + get: function () { + supportsPassive = true; + }, + }); + // @ts-expect-error - TS2769 - No overload matches this call. + window.addEventListener("testPassive", null, opts); + // @ts-expect-error - TS2769 - No overload matches this call. + window.removeEventListener("testPassive", null, opts); + } catch { + // Intentionally left empty! + } + + return supportsPassive; +}; + +/** + * Gets the word right before where the textarea cursor is + * + * @param {Element} textarea - The textarea DOM element + * @return {JSON} - An object with the word and its starting and ending positions in the textarea + */ +function getWordBeforeCursor(textarea: HTMLTextAreaElement): WordAndPosition { + const text = textarea.value; + + const endPos = textarea.selectionStart - 1; + const startPos = + Math.max( + text.lastIndexOf("\n", endPos), + text.lastIndexOf(" ", endPos), + ) + 1; + + return { + string: text.substring(startPos, endPos + 1), + pos: { + start: startPos, + end: endPos, + }, + }; +} + +/** + * Moves the textarea cursor at the specified position + * + * @param {Element} textarea - The textarea DOM element + * @param {int} pos - The position where the cursor will be moved + */ +function moveCursor(textarea: HTMLTextAreaElement, pos: number): void { + textarea.selectionStart = pos; + textarea.selectionEnd = pos; +} + +const textarea = { + getWordBeforeCursor, + moveCursor, +} as const; + +/** + * Many of our labels are automatically converted into math mode without + * the dollar signs. Unfortunately, this makes them untranslatable! This + * helper function removes the math mode symbols from a string if we want + * to translate it but don't need the extra dollar signs. + */ +const unescapeMathMode: (label: string) => string = (label) => + label.startsWith("$") && label.endsWith("$") ? label.slice(1, -1) : label; + +const random: RNG = seededRNG(new Date().getTime() & 0xffffffff); + +// TODO(benchristel): in the future, we may want to make deepClone work for +// Record as well. Currently, it only does arrays. +type Cloneable = + | null + | undefined + | boolean + | string + | number + | Cloneable[] + | readonly Cloneable[]; +function deepClone(obj: T): T { + if (Array.isArray(obj)) { + return obj.map(deepClone) as T; + } + return obj; +} + +const Util = { + inputPathsEqual, + nestedMap, + rWidgetRule, + rTypeFromWidgetId, + rWidgetParts, + snowman, + seededRNG, + shuffle, + split, + stringArrayOfSize, + gridDimensionConfig, + getGridStep, + snapStepFromGridStep, + scaleFromExtent, + tickStepFromExtent, + gridStepFromTickStep, + tickStepFromNumTicks, + constrainedTickStepsFromTickSteps, + eq, + deepEq, + parseQueryString, + updateQueryString, + strongEncodeURIComponent, + touchHandlers, + resetTouchHandlers, + extractPointerLocation, + supportsPassiveEvents, + textarea, + unescapeMathMode, + random, + deepClone, +} as const; + +export default Util; diff --git a/packages/perseus-editor/src/components/graph-settings.tsx b/packages/perseus-editor/src/components/graph-settings.tsx index 4ae48751f5..2b7a43e9ed 100644 --- a/packages/perseus-editor/src/components/graph-settings.tsx +++ b/packages/perseus-editor/src/components/graph-settings.tsx @@ -8,8 +8,9 @@ import { interactiveSizes, Changeable, Dependencies, - Util, + Util as PerseusUtil, } from "@khanacademy/perseus"; +import {Util} from "@khanacademy/perseus-core"; import {Checkbox} from "@khanacademy/wonder-blocks-form"; import * as React from "react"; import ReactDOM from "react-dom"; @@ -201,7 +202,7 @@ class GraphSettings extends React.Component { // @ts-expect-error - TS2531 - Object is possibly 'null'. | TS2339 - Property 'value' does not exist on type 'Element | Text'. const url = ReactDOM.findDOMNode(this.refs["bg-url"]).value; // eslint-disable-line react/no-string-refs if (url) { - Util.getImageSize(url, (width, height) => { + PerseusUtil.getImageSize(url, (width, height) => { if (this._isMounted) { setUrl(url, width, height); } diff --git a/packages/perseus-editor/src/editor.tsx b/packages/perseus-editor/src/editor.tsx index 13a66f2a17..0e7d0c2990 100644 --- a/packages/perseus-editor/src/editor.tsx +++ b/packages/perseus-editor/src/editor.tsx @@ -3,10 +3,10 @@ import { preprocessTex, Log, PerseusMarkdown, - Util, Widgets, + Util as PerseusUtil, } from "@khanacademy/perseus"; -import {Errors, PerseusError} from "@khanacademy/perseus-core"; +import {Util, Errors, PerseusError} from "@khanacademy/perseus-core"; import $ from "jquery"; // eslint-disable-next-line import/no-unresolved, import/no-extraneous-dependencies import katex from "katex"; @@ -312,7 +312,7 @@ class Editor extends React.Component { // TODO(jack): Q promises would make this nicer and only // fire once. _.each(newImageUrls, (url) => { - Util.getImageSize(url, (width, height) => { + PerseusUtil.getImageSize(url, (width, height) => { // We keep modifying the same image object rather than a new // copy from this.props because all changes here are additive. // Maintaining old changes isn't strictly necessary if diff --git a/packages/perseus-editor/src/widgets/interaction-editor/interaction-editor.tsx b/packages/perseus-editor/src/widgets/interaction-editor/interaction-editor.tsx index 1b6f03ef32..a0f6369adf 100644 --- a/packages/perseus-editor/src/widgets/interaction-editor/interaction-editor.tsx +++ b/packages/perseus-editor/src/widgets/interaction-editor/interaction-editor.tsx @@ -1,11 +1,7 @@ /* eslint-disable @khanacademy/ts-no-error-suppressions */ /* eslint-disable @babel/no-invalid-this, react/no-unsafe */ -import { - Changeable, - Dependencies, - EditorJsonify, - Util, -} from "@khanacademy/perseus"; +import {Changeable, Dependencies, EditorJsonify} from "@khanacademy/perseus"; +import {Util} from "@khanacademy/perseus-core"; import * as React from "react"; import _ from "underscore"; diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor/components/interactive-graph-settings.tsx b/packages/perseus-editor/src/widgets/interactive-graph-editor/components/interactive-graph-settings.tsx index 858493450f..581ee34dfe 100644 --- a/packages/perseus-editor/src/widgets/interactive-graph-editor/components/interactive-graph-settings.tsx +++ b/packages/perseus-editor/src/widgets/interactive-graph-editor/components/interactive-graph-settings.tsx @@ -6,8 +6,9 @@ import { components, interactiveSizes, Changeable, - Util, + Util as PerseusUtil, } from "@khanacademy/perseus"; +import {Util} from "@khanacademy/perseus-core"; import Banner from "@khanacademy/wonder-blocks-banner"; import {View} from "@khanacademy/wonder-blocks-core"; import {Checkbox} from "@khanacademy/wonder-blocks-form"; @@ -205,7 +206,7 @@ class InteractiveGraphSettings extends React.Component { const url = this.bgUrlRef.current?.value; if (url) { - Util.getImageSize(url, (width, height) => { + PerseusUtil.getImageSize(url, (width, height) => { if (this._isMounted) { setUrl(url, width, height); } diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor/interactive-graph-editor.tsx b/packages/perseus-editor/src/widgets/interactive-graph-editor/interactive-graph-editor.tsx index 60a2bd958a..3386674073 100644 --- a/packages/perseus-editor/src/widgets/interactive-graph-editor/interactive-graph-editor.tsx +++ b/packages/perseus-editor/src/widgets/interactive-graph-editor/interactive-graph-editor.tsx @@ -5,8 +5,8 @@ import { getInteractiveBoxFromSizeClass, InteractiveGraphWidget, interactiveSizes, - Util, } from "@khanacademy/perseus"; +import {Util} from "@khanacademy/perseus-core"; import {View} from "@khanacademy/wonder-blocks-core"; import {OptionItem, SingleSelect} from "@khanacademy/wonder-blocks-dropdown"; import {Checkbox} from "@khanacademy/wonder-blocks-form"; diff --git a/packages/perseus-editor/src/widgets/table-editor.tsx b/packages/perseus-editor/src/widgets/table-editor.tsx index 431c11f444..9f39aff45d 100644 --- a/packages/perseus-editor/src/widgets/table-editor.tsx +++ b/packages/perseus-editor/src/widgets/table-editor.tsx @@ -1,4 +1,5 @@ -import {components, TableWidget, Util} from "@khanacademy/perseus"; +import {components, TableWidget} from "@khanacademy/perseus"; +import {Util} from "@khanacademy/perseus-core"; import PropTypes from "prop-types"; import * as React from "react"; import _ from "underscore"; diff --git a/packages/perseus/src/__tests__/util.test.ts b/packages/perseus/src/__tests__/util.test.ts index e3d31b49f0..bdde7286b0 100644 --- a/packages/perseus/src/__tests__/util.test.ts +++ b/packages/perseus/src/__tests__/util.test.ts @@ -1,4 +1,4 @@ -import Util from "../util"; +import {Util} from "@khanacademy/perseus-core"; describe("#constrainedTickStepsFromTickSteps", () => { it("should not changes the tick steps if there are fewer than (or exactly) 10 steps", () => { diff --git a/packages/perseus/src/article-renderer.tsx b/packages/perseus/src/article-renderer.tsx index cb8cf98fcf..915d756f03 100644 --- a/packages/perseus/src/article-renderer.tsx +++ b/packages/perseus/src/article-renderer.tsx @@ -3,6 +3,7 @@ * composed of multiple (Renderer) sections concatenated together. */ +import {Util} from "@khanacademy/perseus-core"; import * as PerseusLinter from "@khanacademy/perseus-linter"; import classNames from "classnames"; import * as React from "react"; @@ -12,7 +13,6 @@ import {DependenciesContext, getDependencies} from "./dependencies"; import JiptParagraphs from "./jipt-paragraphs"; import {ClassNames as ApiClassNames, ApiOptions} from "./perseus-api"; import Renderer from "./renderer"; -import Util from "./util"; import type {PerseusDependenciesV2, SharedRendererProps} from "./types"; import type {KeypadAPI} from "@khanacademy/math-input"; diff --git a/packages/perseus/src/components/graph.tsx b/packages/perseus/src/components/graph.tsx index 47e107e9b7..f1006a7493 100644 --- a/packages/perseus/src/components/graph.tsx +++ b/packages/perseus/src/components/graph.tsx @@ -1,20 +1,22 @@ /* eslint-disable @khanacademy/ts-no-error-suppressions */ /* eslint-disable react/no-unsafe */ import {vector as kvector} from "@khanacademy/kmath"; +import {Util} from "@khanacademy/perseus-core"; import $ from "jquery"; import * as React from "react"; import _ from "underscore"; import AssetContext from "../asset-context"; import {interactiveSizes} from "../styles/constants"; -import Util from "../util"; import GraphUtils from "../util/graph-utils"; import SvgImage from "./svg-image"; import type {Coord} from "../interactive2/types"; -import type {GridDimensions} from "../util"; -import type {PerseusImageBackground} from "@khanacademy/perseus-core"; +import type { + GridDimensions, + PerseusImageBackground, +} from "@khanacademy/perseus-core"; const defaultBackgroundImage = { url: null, diff --git a/packages/perseus/src/components/graphie-classes.ts b/packages/perseus/src/components/graphie-classes.ts index 350237f25d..2026900c1f 100644 --- a/packages/perseus/src/components/graphie-classes.ts +++ b/packages/perseus/src/components/graphie-classes.ts @@ -1,10 +1,8 @@ /* eslint-disable @babel/no-invalid-this */ -import {Errors, PerseusError} from "@khanacademy/perseus-core"; +import {Errors, PerseusError, Util} from "@khanacademy/perseus-core"; import _ from "underscore"; -import Util from "../util"; - const nestedMap = Util.nestedMap; const deepEq = Util.deepEq; diff --git a/packages/perseus/src/components/graphie.tsx b/packages/perseus/src/components/graphie.tsx index cbc30b4bfc..7324199a85 100644 --- a/packages/perseus/src/components/graphie.tsx +++ b/packages/perseus/src/components/graphie.tsx @@ -1,11 +1,10 @@ -import {Errors} from "@khanacademy/perseus-core"; +import {Errors, Util} from "@khanacademy/perseus-core"; import $ from "jquery"; import * as React from "react"; import _ from "underscore"; import InteractiveUtil from "../interactive2/interactive-util"; import {Log} from "../logging/log"; -import Util from "../util"; import GraphUtils from "../util/graph-utils"; import {Graphie as GraphieDrawingContext} from "../util/graphie"; diff --git a/packages/perseus/src/components/sortable.tsx b/packages/perseus/src/components/sortable.tsx index d169d463d3..0e29cce28c 100644 --- a/packages/perseus/src/components/sortable.tsx +++ b/packages/perseus/src/components/sortable.tsx @@ -1,5 +1,6 @@ /* eslint-disable @khanacademy/ts-no-error-suppressions */ /* eslint-disable @babel/no-invalid-this, react/no-unsafe */ +import {Util} from "@khanacademy/perseus-core"; import * as PerseusLinter from "@khanacademy/perseus-linter"; import {CircularSpinner} from "@khanacademy/wonder-blocks-progress-spinner"; import {StyleSheet, css} from "aphrodite"; @@ -11,11 +12,10 @@ import _ from "underscore"; import {getDependencies} from "../dependencies"; import {ClassNames as ApiClassNames} from "../perseus-api"; import Renderer from "../renderer"; -import Util from "../util"; import {PerseusI18nContext} from "./i18n-context"; -import type {Position} from "../util"; +import type {Position} from "@khanacademy/perseus-core"; import type {LinterContextProps} from "@khanacademy/perseus-linter"; type Layout = "horizontal" | "vertical"; diff --git a/packages/perseus/src/renderer.tsx b/packages/perseus/src/renderer.tsx index 84b7c8183a..20e0785777 100644 --- a/packages/perseus/src/renderer.tsx +++ b/packages/perseus/src/renderer.tsx @@ -1,6 +1,6 @@ /* eslint-disable @khanacademy/ts-no-error-suppressions */ /* eslint-disable react/no-unsafe */ -import {Errors, PerseusError, mapObject} from "@khanacademy/perseus-core"; +import {Errors, PerseusError, mapObject, Util} from "@khanacademy/perseus-core"; import * as PerseusLinter from "@khanacademy/perseus-linter"; import {entries} from "@khanacademy/wonder-stuff-core"; import classNames from "classnames"; @@ -30,7 +30,6 @@ import { scoreWidgetsFunctional, } from "./renderer-util"; import TranslationLinter from "./translation-linter"; -import Util from "./util"; import {flattenScores} from "./util/scoring"; import preprocessTex from "./util/tex-preprocess"; import WidgetContainer from "./widget-container"; diff --git a/packages/perseus/src/server-item-renderer.tsx b/packages/perseus/src/server-item-renderer.tsx index ba066aa03c..e2f095b4a6 100644 --- a/packages/perseus/src/server-item-renderer.tsx +++ b/packages/perseus/src/server-item-renderer.tsx @@ -1,4 +1,5 @@ /* eslint-disable @khanacademy/ts-no-error-suppressions */ +import {Util} from "@khanacademy/perseus-core"; import * as PerseusLinter from "@khanacademy/perseus-linter"; import {StyleSheet, css} from "aphrodite"; /** @@ -19,7 +20,6 @@ import HintsRenderer from "./hints-renderer"; import LoadingContext from "./loading-context"; import {ApiOptions} from "./perseus-api"; import Renderer from "./renderer"; -import Util from "./util"; import type { FocusPath, diff --git a/packages/perseus/src/util.ts b/packages/perseus/src/util.ts index d1f823a169..53493c42d6 100644 --- a/packages/perseus/src/util.ts +++ b/packages/perseus/src/util.ts @@ -17,173 +17,11 @@ type WordAndPosition = { pos: WordPosition; }; -type RNG = () => number; - export type ParsedValue = { value: number; exact: boolean; }; -// TODO: dedupe this with Coord in interactive2/types.js -type Coordinates = [number, number]; - -export type GridDimensions = { - scale: number; - tickStep: number; - unityLabel: boolean; -}; - -type QueryParams = { - [param: string]: string; -}; - -export type Position = { - top: number; - left: number; -}; - -type TouchHandlers = { - pointerDown: boolean; - currentTouchIdentifier: string | null | undefined; -}; - -let supportsPassive = false; - -const nestedMap = function ( - children: T | ReadonlyArray, - func: (arg1: T) => M, - context: unknown, -): M | ReadonlyArray { - if (Array.isArray(children)) { - // @ts-expect-error - TS2322 - Type '(M | readonly M[])[]' is not assignable to type 'M | readonly M[]'. - return _.map(children, function (child) { - // @ts-expect-error - TS2554 - Expected 3 arguments, but got 2. - return nestedMap(child, func); - }); - } - // TODO(LEMS-2656): remove TS suppression - // @ts-expect-error: T | ReadonlyArray is not assignable to T - return func.call(context, children); -}; - -/** - * Used to compare equality of two input paths, which are represented as - * arrays of strings. - */ -function inputPathsEqual( - a?: ReadonlyArray | null, - b?: ReadonlyArray | null, -): boolean { - if (a == null || b == null) { - return (a == null) === (b == null); - } - - return ( - a.length === b.length && - a.every((item, index) => { - return b[index] === item; - }) - ); -} - -const rWidgetRule = /^\[\[\u2603 (([a-z-]+) ([0-9]+))\]\]/; -const rTypeFromWidgetId = /^([a-z-]+) ([0-9]+)$/; - -const rWidgetParts = new RegExp(rWidgetRule.source + "$"); -const snowman = "\u2603"; - -const seededRNG: (seed: number) => RNG = function (seed: number): RNG { - let randomSeed = seed; - - return function () { - // Robert Jenkins' 32 bit integer hash function. - let seed = randomSeed; - seed = (seed + 0x7ed55d16 + (seed << 12)) & 0xffffffff; - seed = (seed ^ 0xc761c23c ^ (seed >>> 19)) & 0xffffffff; - seed = (seed + 0x165667b1 + (seed << 5)) & 0xffffffff; - seed = ((seed + 0xd3a2646c) ^ (seed << 9)) & 0xffffffff; - seed = (seed + 0xfd7046c5 + (seed << 3)) & 0xffffffff; - seed = (seed ^ 0xb55a4f09 ^ (seed >>> 16)) & 0xffffffff; - return (randomSeed = seed & 0xfffffff) / 0x10000000; - }; -}; - -// Shuffle an array using a given random seed or function. -// If `ensurePermuted` is true, the input and ouput are guaranteed to be -// distinct permutations. -function shuffle( - array: ReadonlyArray, - randomSeed: number | RNG, - ensurePermuted = false, -): ReadonlyArray { - // Always return a copy of the input array - const shuffled = _.clone(array); - - // Handle edge cases (input array is empty or uniform) - if ( - !shuffled.length || - _.all(shuffled, function (value) { - return _.isEqual(value, shuffled[0]); - }) - ) { - return shuffled; - } - - let random; - if (typeof randomSeed === "function") { - random = randomSeed; - } else { - random = seededRNG(randomSeed); - } - - do { - // Fischer-Yates shuffle - for (let top = shuffled.length; top > 0; top--) { - const newEnd = Math.floor(random() * top); - const temp = shuffled[newEnd]; - - // @ts-expect-error - TS2542 - Index signature in type 'readonly T[]' only permits reading. - shuffled[newEnd] = shuffled[top - 1]; - // @ts-expect-error - TS2542 - Index signature in type 'readonly T[]' only permits reading. - shuffled[top - 1] = temp; - } - } while (ensurePermuted && _.isEqual(array, shuffled)); - - return shuffled; -} - -/** - * TODO(somewhatabstract, FEI-3463): - * Drop this custom split thing. - */ -// In IE8, split doesn't work right. Implement it ourselves. -const split: (str: string, r: RegExp) => ReadonlyArray = "x".split( - /(.)/g, -).length - ? function (str: string, r) { - return str.split(r); - } - : function (str: string, r: RegExp) { - // Based on Steven Levithan's MIT-licensed split, available at - // http://blog.stevenlevithan.com/archives/cross-browser-split - const output = []; - let lastIndex = (r.lastIndex = 0); - let match; - - while ((match = r.exec(str))) { - const m = match; - // @ts-expect-error - TS2345 - Argument of type 'string' is not assignable to parameter of type 'never'. - output.push(str.slice(lastIndex, m.index)); - // @ts-expect-error - TS2345 - Argument of type 'any' is not assignable to parameter of type 'never'. - output.push(...m.slice(1)); - lastIndex = m.index + m[0].length; - } - - // @ts-expect-error - TS2345 - Argument of type 'string' is not assignable to parameter of type 'never'. - output.push(str.slice(lastIndex)); - return output; - }; - /** * Return the first valid interpretation of 'text' as a number, in the form * {value: 2.3, exact: true}. @@ -210,409 +48,6 @@ function firstNumericalParse( return first; } -function stringArrayOfSize(size: number): ReadonlyArray { - return _(size).times(function () { - return ""; - }); -} - -/** - * For a graph's x or y dimension, given the tick step, - * the ranges extent (e.g. [-10, 10]), the pixel dimension constraint, - * and the grid step, return a bunch of configurations for that dimension. - * - * Example: - * gridDimensionConfig(10, [-50, 50], 400, 5) - * - * Returns: { - * scale: 4, - * snap: 2.5, - * tickStep: 2, - * unityLabel: true - * }; - */ -function gridDimensionConfig( - absTickStep: number, - extent: Coordinates, - dimensionConstraint: number, - gridStep: number, -): GridDimensions { - const scale = scaleFromExtent(extent, dimensionConstraint); - const stepPx = absTickStep * scale; - const unityLabel = stepPx > 30; - return { - scale: scale, - tickStep: absTickStep / gridStep, - unityLabel: unityLabel, - }; -} -/** - * Given the range, step, and boxSize, calculate the reasonable gridStep. - * Used for when one was not given explicitly. - * - * Example: - * getGridStep([[-10, 10], [-10, 10]], [1, 1], 340) - * - * Returns: [1, 1] - * - * TODO(somewhatabstract, FEI-3464): Consolidate query string parsing functions. - */ -function getGridStep( - range: [Coordinates, Coordinates], - step: Coordinates, - boxSize: number, -): Coordinates { - // @ts-expect-error - TS2322 - Type '(number | null | undefined)[]' is not assignable to type 'Coordinates'. - return _(2).times(function (i) { - const scale = scaleFromExtent(range[i], boxSize); - const gridStep = gridStepFromTickStep(step[i], scale); - return gridStep; - }); -} - -function snapStepFromGridStep(gridStep: [number, number]): [number, number] { - return [gridStep[0] / 2, gridStep[1] / 2]; -} - -/** - * Given the tickStep and the graph's scale, find a - * grid step. - * Example: - * gridStepFromTickStep(200, 0.2) // returns 100 - */ -function gridStepFromTickStep( - tickStep: number, - scale: number, -): number | null | undefined { - const tickWidth = tickStep * scale; - const x = tickStep; - const y = Math.pow(10, Math.floor(Math.log(x) / Math.LN10)); - const leadingDigit = Math.floor(x / y); - if (tickWidth < 25) { - return tickStep; - } - if (tickWidth < 50) { - if (leadingDigit === 5) { - return tickStep; - } - return tickStep / 2; - } - if (leadingDigit === 1) { - return tickStep / 2; - } - if (leadingDigit === 2) { - return tickStep / 4; - } - if (leadingDigit === 5) { - return tickStep / 5; - } -} - -/** - * Given the range and a dimension, come up with the appropriate - * scale. - * Example: - * scaleFromExtent([-25, 25], 500) // returns 10 - */ -function scaleFromExtent( - extent: Coordinates, - dimensionConstraint: number, -): number { - const span = extent[1] - extent[0]; - const scale = dimensionConstraint / span; - return scale; -} - -/** - * Return a reasonable tick step given extent and dimension. - * (extent is [begin, end] of the domain.) - * Example: - * tickStepFromExtent([-10, 10], 300) // returns 2 - */ -function tickStepFromExtent( - extent: Coordinates, - dimensionConstraint: number, -): number { - const span = extent[1] - extent[0]; - - let tickFactor; - // If single number digits - if (15 < span && span <= 20) { - tickFactor = 23; - - // triple digit or decimal - } else if (span > 100 || span < 5) { - tickFactor = 10; - - // double digit - } else { - tickFactor = 16; - } - const constraintFactor = dimensionConstraint / 500; - const desiredNumTicks = tickFactor * constraintFactor; - return tickStepFromNumTicks(span, desiredNumTicks); -} - -/** - * Find a good tick step for the desired number of ticks in the range - * Modified from d3.scale.linear: d3_scale_linearTickRange. - * Thanks, mbostock! - * Example: - * tickStepFromNumTicks(50, 6) // returns 10 - */ -function tickStepFromNumTicks(span: number, numTicks: number): number { - let step = Math.pow(10, Math.floor(Math.log(span / numTicks) / Math.LN10)); - const err = (numTicks / span) * step; - - // Filter ticks to get closer to the desired count. - if (err <= 0.15) { - step *= 10; - } else if (err <= 0.35) { - step *= 5; - } else if (err <= 0.75) { - step *= 2; - } - - // Round start and stop values to step interval. - return step; -} - -const constrainTickStep = (step: number, range: Range): number => { - const span = range[1] - range[0]; - const numTicks = span / step; - if (numTicks <= 10) { - // Will displays fine on mobile - return step; - } - if (numTicks <= 20) { - // Will be crowded on mobile, so hide every other tick - return step * 2; - } - // Fallback in case we somehow have more than 20 ticks - // Note: This shouldn't happen due to GraphSettings.validStep - return tickStepFromNumTicks(span, 10); -}; - -/** - * Constrain tick steps intended for desktop size graphs - * to something more suitable for mobile size graphs. - * Specifically, we aim for 10 or fewer ticks per graph axis. - */ -function constrainedTickStepsFromTickSteps( - tickSteps: [number, number], - ranges: [Range, Range], -): Coordinates { - return [ - constrainTickStep(tickSteps[0], ranges[0]), - constrainTickStep(tickSteps[1], ranges[1]), - ]; -} - -/** - * Approximate equality on numbers and primitives. - */ -function eq(x: T, y: T): boolean { - if (typeof x === "number" && typeof y === "number") { - return Math.abs(x - y) < 1e-9; - } - return x === y; -} - -/** - * Deep approximate equality on primitives, numbers, arrays, and objects. - * Recursive. - */ -function deepEq(x: T, y: T): boolean { - if (Array.isArray(x) && Array.isArray(y)) { - if (x.length !== y.length) { - return false; - } - for (let i = 0; i < x.length; i++) { - if (!deepEq(x[i], y[i])) { - return false; - } - } - return true; - } - if (Array.isArray(x) || Array.isArray(y)) { - return false; - } - if (typeof x === "function" && typeof y === "function") { - return eq(x, y); - } - if (typeof x === "function" || typeof y === "function") { - return false; - } - if (typeof x === "object" && typeof y === "object" && !!x && !!y) { - return ( - x === y || - (_.all(x, function (v, k) { - // @ts-expect-error - TS2536 - Type 'CollectionKey' cannot be used to index type 'T'. - return deepEq(y[k], v); - }) && - _.all(y, function (v, k) { - // @ts-expect-error - TS2536 - Type 'CollectionKey' cannot be used to index type 'T'. - return deepEq(x[k], v); - })) - ); - } - if ((typeof x === "object" && !!x) || (typeof y === "object" && !!y)) { - return false; - } - return eq(x, y); -} - -/** - * Query String Parser - * - * Original from: - * http://stackoverflow.com/questions/901115/get-querystring-values-in-javascript/2880929#2880929 - */ -function parseQueryString(query: string): QueryParams { - // TODO(jangmi, CP-3340): Use withLocation to access SSR safe location. - // eslint-disable-next-line no-restricted-syntax - query = query || window.location.search.substring(1); - const urlParams: Record = {}; - // Regex for replacing addition symbol with a space - const a = /\+/g; - const r = /([^&=]+)=?([^&]*)/g; - const d = function (s) { - return decodeURIComponent(s.replace(a, " ")); - }; - - let e; - while ((e = r.exec(query))) { - const m = e; - urlParams[d(m[1])] = d(m[2]); - } - - return urlParams; -} - -/** - * Query string adder - * Works for URLs without #. - * Original from: - * http://stackoverflow.com/questions/5999118/add-or-update-query-string-parameter - */ -function updateQueryString(uri: string, key: string, value: string): string { - value = encodeURIComponent(value); - const re = new RegExp("([?&])" + key + "=.*?(&|$)", "i"); - const separator = uri.indexOf("?") !== -1 ? "&" : "?"; - if (uri.match(re)) { - return uri.replace(re, "$1" + key + "=" + value + "$2"); - } - return uri + separator + key + "=" + value; -} - -/** - * A more strict encodeURIComponent that escapes `()'!`s - * Especially useful for creating URLs that are embeddable in markdown - * - * Adapted from - * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent - * This function and the above original available under the - * CC-BY-SA 2.5 license. - */ -function strongEncodeURIComponent(str: string): string { - return ( - encodeURIComponent(str) - // Note that although RFC3986 reserves "!", RFC5987 does not, - // so we do not need to escape it - .replace(/['()!]/g, window.escape) // i.e., %27 %28 %29 - .replace(/\*/g, "%2A") - ); -} - -/* - * The touchHandlers are used to track the current state of the touch - * event, such as whether or not the user is currently pressed down (either - * through touch or mouse) on the screen. - */ -const touchHandlers: TouchHandlers = { - pointerDown: false, - currentTouchIdentifier: null, -}; - -function resetTouchHandlers() { - Object.assign(touchHandlers, { - pointerDown: false, - currentTouchIdentifier: null, - }); -} - -/** - * Extracts the location of a touch or mouse event, allowing you to pass - * in a "mouseup", "mousedown", or "mousemove" event and receive the - * correct coordinates. Shouldn't be used with "vmouse" events. - */ -function extractPointerLocation(event: any): Position | null | undefined { - let touchOrEvent; - - if (touchHandlers.pointerDown) { - // Look for the touch matching the one we're tracking; ignore others - if (touchHandlers.currentTouchIdentifier != null) { - const len = event.changedTouches ? event.changedTouches.length : 0; - for (let i = 0; i < len; i++) { - if ( - event.changedTouches[i].identifier === - touchHandlers.currentTouchIdentifier - ) { - touchOrEvent = event.changedTouches[i]; - } - } - } else { - touchOrEvent = event; - } - - const isEndish = - event.type === "touchend" || event.type === "touchcancel"; - if (touchOrEvent && isEndish) { - touchHandlers.pointerDown = false; - touchHandlers.currentTouchIdentifier = null; - } - } else { - // touchstart or mousedown - touchHandlers.pointerDown = true; - if (event.changedTouches) { - touchOrEvent = event.changedTouches[0]; - touchHandlers.currentTouchIdentifier = touchOrEvent.identifier; - } else { - touchOrEvent = event; - } - } - - if (touchOrEvent) { - return { - left: touchOrEvent.pageX, - top: touchOrEvent.pageY, - }; - } -} - -// Older browsers don't support passive events and so we need to feature- -// detect them and do event subscription differently for them. -// See: orderer.jsx -const supportsPassiveEvents: () => boolean = () => { - // Test via a getter in the options object to see if the passive - // property is accessed - try { - const opts = Object.defineProperty({}, "passive", { - get: function () { - supportsPassive = true; - }, - }); - // @ts-expect-error - TS2769 - No overload matches this call. - window.addEventListener("testPassive", null, opts); - // @ts-expect-error - TS2769 - No overload matches this call. - window.removeEventListener("testPassive", null, opts); - } catch { - // Intentionally left empty! - } - - return supportsPassive; -}; - /** * Pass this function as the touchstart for an element to * avoid sending the touch to the mobile scratchpad @@ -621,37 +56,6 @@ function captureScratchpadTouchStart(e: React.TouchEvent) { e.stopPropagation(); } -function getImageSize( - url: string, - callback: (width: number, height: number) => void, -): void { - const img = new Image(); - img.onload = function () { - // IE 11 seems to have problems calculating the heights of svgs - // if they're not in the DOM. To solve this, we add the element to - // the dom, wait for a rerender, and use `.clientWidth` and - // `.clientHeight`. I think we could also solve the problem by - // adding the image to the document before setting the src, but then - // the experience would be worse for other browsers. - // TODO(scottgrant): This is correctly calculating the width of SVG - // images in browsers, but incorrectly saving the width of what may - // be a smaller viewport when using the editor, and reusing that - // width in a full-screen article. - if (img.width === 0 && img.height === 0) { - document.body?.appendChild(img); - // TODO(scottgrant): Remove this use of _.defer. - _.defer(function () { - callback(img.clientWidth, img.clientHeight); - document.body?.removeChild(img); - }); - } else { - callback(img.width, img.height); - } - }; - - img.src = GraphieUtil.getRealImageUrl(url); -} - /** * Gets the word right before where the textarea cursor is * @@ -693,65 +97,41 @@ const textarea = { moveCursor, } as const; -/** - * Many of our labels are automatically converted into math mode without - * the dollar signs. Unfortunately, this makes them untranslatable! This - * helper function removes the math mode symbols from a string if we want - * to translate it but don't need the extra dollar signs. - */ -const unescapeMathMode: (label: string) => string = (label) => - label.startsWith("$") && label.endsWith("$") ? label.slice(1, -1) : label; - -const random: RNG = seededRNG(new Date().getTime() & 0xffffffff); +function getImageSize( + url: string, + callback: (width: number, height: number) => void, +): void { + const img = new Image(); + img.onload = function () { + // IE 11 seems to have problems calculating the heights of svgs + // if they're not in the DOM. To solve this, we add the element to + // the dom, wait for a rerender, and use `.clientWidth` and + // `.clientHeight`. I think we could also solve the problem by + // adding the image to the document before setting the src, but then + // the experience would be worse for other browsers. + // TODO(scottgrant): This is correctly calculating the width of SVG + // images in browsers, but incorrectly saving the width of what may + // be a smaller viewport when using the editor, and reusing that + // width in a full-screen article. + if (img.width === 0 && img.height === 0) { + document.body?.appendChild(img); + // TODO(scottgrant): Remove this use of _.defer. + _.defer(function () { + callback(img.clientWidth, img.clientHeight); + document.body?.removeChild(img); + }); + } else { + callback(img.width, img.height); + } + }; -// TODO(benchristel): in the future, we may want to make deepClone work for -// Record as well. Currently, it only does arrays. -type Cloneable = - | null - | undefined - | boolean - | string - | number - | Cloneable[] - | readonly Cloneable[]; -function deepClone(obj: T): T { - if (Array.isArray(obj)) { - return obj.map(deepClone) as T; - } - return obj; + img.src = Util.getRealImageUrl(url); } const Util = { - inputPathsEqual, - nestedMap, - rWidgetRule, - rTypeFromWidgetId, - rWidgetParts, - snowman, - seededRNG, - shuffle, - split, firstNumericalParse, - stringArrayOfSize, - gridDimensionConfig, - getGridStep, - snapStepFromGridStep, - scaleFromExtent, - tickStepFromExtent, - gridStepFromTickStep, - tickStepFromNumTicks, - constrainedTickStepsFromTickSteps, - eq, - deepEq, - parseQueryString, - updateQueryString, - strongEncodeURIComponent, - touchHandlers, - resetTouchHandlers, - extractPointerLocation, - supportsPassiveEvents, - captureScratchpadTouchStart, getImageSize, + captureScratchpadTouchStart, getImageSizeModern: GraphieUtil.getImageSizeModern, getRealImageUrl: GraphieUtil.getRealImageUrl, isLabeledSVG: GraphieUtil.isLabeledSVG, @@ -759,9 +139,6 @@ const Util = { getSvgUrl: GraphieUtil.getSvgUrl, getDataUrl: GraphieUtil.getDataUrl, textarea, - unescapeMathMode, - random, - deepClone, } as const; export default Util; diff --git a/packages/perseus/src/util/geometry.ts b/packages/perseus/src/util/geometry.ts index ab7eacc0fe..b28f0b6e7d 100644 --- a/packages/perseus/src/util/geometry.ts +++ b/packages/perseus/src/util/geometry.ts @@ -3,10 +3,9 @@ */ import {number as knumber, point as kpoint, sum} from "@khanacademy/kmath"; +import {Util} from "@khanacademy/perseus-core"; import _ from "underscore"; -import Util from "../util"; - import type {Coord, Line} from "../interactive2/types"; const {eq, deepEq} = Util; diff --git a/packages/perseus/src/util/is-real-json-parse.ts b/packages/perseus/src/util/is-real-json-parse.ts index 548f17518a..fa1e7ec5b0 100644 --- a/packages/perseus/src/util/is-real-json-parse.ts +++ b/packages/perseus/src/util/is-real-json-parse.ts @@ -1,4 +1,4 @@ -import Util from "../util"; +import {Util} from "@khanacademy/perseus-core"; const deepEq = Util.deepEq; diff --git a/packages/perseus/src/widgets/categorizer/categorizer.tsx b/packages/perseus/src/widgets/categorizer/categorizer.tsx index 4d18004162..4c4ce2eeae 100644 --- a/packages/perseus/src/widgets/categorizer/categorizer.tsx +++ b/packages/perseus/src/widgets/categorizer/categorizer.tsx @@ -1,4 +1,5 @@ /* eslint-disable @khanacademy/ts-no-error-suppressions */ +import {Util} from "@khanacademy/perseus-core"; import {linterContextDefault} from "@khanacademy/perseus-linter"; import {StyleSheet, css} from "aphrodite"; import classNames from "classnames"; @@ -13,7 +14,6 @@ import {ClassNames as ApiClassNames} from "../../perseus-api"; import Renderer from "../../renderer"; import mediaQueries from "../../styles/media-queries"; import sharedStyles from "../../styles/shared"; -import Util from "../../util"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/categorizer/categorizer-ai-utils"; import scoreCategorizer from "./score-categorizer"; diff --git a/packages/perseus/src/widgets/cs-program/cs-program.tsx b/packages/perseus/src/widgets/cs-program/cs-program.tsx index c4c1755106..4f600e052f 100644 --- a/packages/perseus/src/widgets/cs-program/cs-program.tsx +++ b/packages/perseus/src/widgets/cs-program/cs-program.tsx @@ -2,6 +2,7 @@ * This widget is for embedding Khan Academy CS programs. */ +import {Util} from "@khanacademy/perseus-core"; import {StyleSheet, css} from "aphrodite"; import $ from "jquery"; import * as React from "react"; @@ -10,7 +11,6 @@ import _ from "underscore"; import {getDependencies} from "../../dependencies"; import * as Changeable from "../../mixins/changeable"; import {articleMaxWidthInPx} from "../../styles/constants"; -import Util from "../../util"; import {isFileProtocol} from "../../util/mobile-native-utils"; import {toAbsoluteUrl} from "../../util/url-utils"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/cs-program/cs-program-ai-utils"; diff --git a/packages/perseus/src/widgets/grapher/grapher.tsx b/packages/perseus/src/widgets/grapher/grapher.tsx index 94db2c0887..486ca59a17 100644 --- a/packages/perseus/src/widgets/grapher/grapher.tsx +++ b/packages/perseus/src/widgets/grapher/grapher.tsx @@ -3,6 +3,7 @@ import { vector as kvector, point as kpoint, } from "@khanacademy/kmath"; +import {Util} from "@khanacademy/perseus-core"; import * as React from "react"; import _ from "underscore"; @@ -13,7 +14,6 @@ import Interactive2 from "../../interactive2"; import WrappedLine from "../../interactive2/wrapped-line"; import * as Changeable from "../../mixins/changeable"; import {interactiveSizes} from "../../styles/constants"; -import Util from "../../util"; import KhanColors from "../../util/colors"; import {getInteractiveBoxFromSizeClass} from "../../util/sizing-utils"; /* Graphie and relevant components. */ @@ -34,13 +34,13 @@ import { import type {Coord, Line} from "../../interactive2/types"; import type {ChangeableProps} from "../../mixins/changeable"; import type {Widget, WidgetExports, WidgetProps} from "../../types"; -import type {GridDimensions} from "../../util"; import type { PerseusGrapherRubric, PerseusGrapherUserInput, } from "../../validation.types"; import type {GrapherPromptJSON} from "../../widget-ai-utils/grapher/grapher-ai-utils"; import type { + GridDimensions, MarkingsType, PerseusGrapherWidgetOptions, } from "@khanacademy/perseus-core"; diff --git a/packages/perseus/src/widgets/grapher/util.tsx b/packages/perseus/src/widgets/grapher/util.tsx index ed7c6af887..88bb5ffc24 100644 --- a/packages/perseus/src/widgets/grapher/util.tsx +++ b/packages/perseus/src/widgets/grapher/util.tsx @@ -1,10 +1,10 @@ import {point as kpoint} from "@khanacademy/kmath"; +import {Util} from "@khanacademy/perseus-core"; import * as React from "react"; import _ from "underscore"; import Graphie from "../../components/graphie"; import {getDependencies} from "../../dependencies"; -import Util from "../../util"; import type { Coords, diff --git a/packages/perseus/src/widgets/iframe/iframe.tsx b/packages/perseus/src/widgets/iframe/iframe.tsx index 67ce0d4f32..6aa1b8b4e7 100644 --- a/packages/perseus/src/widgets/iframe/iframe.tsx +++ b/packages/perseus/src/widgets/iframe/iframe.tsx @@ -7,13 +7,13 @@ * but could also be used for embedding viz's hosted elsewhere. */ +import {Util} from "@khanacademy/perseus-core"; import $ from "jquery"; import * as React from "react"; import _ from "underscore"; import {getDependencies} from "../../dependencies"; import * as Changeable from "../../mixins/changeable"; -import Util from "../../util"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/iframe/iframe-ai-utils"; import {scoreIframe} from "./score-iframe"; diff --git a/packages/perseus/src/widgets/interaction/interaction.tsx b/packages/perseus/src/widgets/interaction/interaction.tsx index e7eaed7e09..7dfdddbac5 100644 --- a/packages/perseus/src/widgets/interaction/interaction.tsx +++ b/packages/perseus/src/widgets/interaction/interaction.tsx @@ -2,12 +2,12 @@ /* eslint-disable @babel/no-invalid-this, react/no-unsafe */ import * as KAS from "@khanacademy/kas"; import {vector as kvector} from "@khanacademy/kmath"; +import {Util} from "@khanacademy/perseus-core"; import * as React from "react"; import _ from "underscore"; import Graphie from "../../components/graphie"; import * as Changeable from "../../mixins/changeable"; -import Util from "../../util"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/interaction/interaction-ai-utils"; import scoreNoop from "../__shared__/score-noop"; diff --git a/packages/perseus/src/widgets/interactive-graph.tsx b/packages/perseus/src/widgets/interactive-graph.tsx index ee412b3c94..1d5054e6fa 100644 --- a/packages/perseus/src/widgets/interactive-graph.tsx +++ b/packages/perseus/src/widgets/interactive-graph.tsx @@ -1,6 +1,6 @@ /* eslint-disable @babel/no-invalid-this, react/no-unsafe, react/sort-comp */ import {number as knumber, point as kpoint} from "@khanacademy/kmath"; -import {Errors, PerseusError} from "@khanacademy/perseus-core"; +import {Errors, PerseusError, Util} from "@khanacademy/perseus-core"; import $ from "jquery"; import debounce from "lodash.debounce"; import * as React from "react"; @@ -10,7 +10,6 @@ import Graph from "../components/graph"; import {PerseusI18nContext} from "../components/i18n-context"; import Interactive2 from "../interactive2"; import WrappedLine from "../interactive2/wrapped-line"; -import Util from "../util"; import KhanColors from "../util/colors"; import { angleMeasures, diff --git a/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.ts b/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.ts index 5b5e6974c7..9f15590ee3 100644 --- a/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.ts +++ b/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.ts @@ -1,9 +1,9 @@ import {vector as kvector} from "@khanacademy/kmath"; +import {Util} from "@khanacademy/perseus-core"; import {UnreachableCaseError} from "@khanacademy/wonder-stuff-core"; import {vec} from "mafs"; import _ from "underscore"; -import Util from "../../../util"; import { angleMeasures, ccw, diff --git a/packages/perseus/src/widgets/interactive-graphs/score-interactive-graph.ts b/packages/perseus/src/widgets/interactive-graphs/score-interactive-graph.ts index 0525f2add5..539a1e7a44 100644 --- a/packages/perseus/src/widgets/interactive-graphs/score-interactive-graph.ts +++ b/packages/perseus/src/widgets/interactive-graphs/score-interactive-graph.ts @@ -1,7 +1,7 @@ import {number as knumber} from "@khanacademy/kmath"; +import {Util} from "@khanacademy/perseus-core"; import _ from "underscore"; -import Util from "../../util"; import { canonicalSineCoefficients, collinear, diff --git a/packages/perseus/src/widgets/matcher/matcher.tsx b/packages/perseus/src/widgets/matcher/matcher.tsx index 15f56e8e09..641b760968 100644 --- a/packages/perseus/src/widgets/matcher/matcher.tsx +++ b/packages/perseus/src/widgets/matcher/matcher.tsx @@ -1,3 +1,4 @@ +import {Util} from "@khanacademy/perseus-core"; import {linterContextDefault} from "@khanacademy/perseus-linter"; import {CircularSpinner} from "@khanacademy/wonder-blocks-progress-spinner"; import {StyleSheet, css} from "aphrodite"; @@ -8,7 +9,6 @@ import {PerseusI18nContext} from "../../components/i18n-context"; import Sortable from "../../components/sortable"; import {getDependencies} from "../../dependencies"; import Renderer from "../../renderer"; -import Util from "../../util"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/matcher/matcher-ai-utils"; import scoreMatcher from "./score-matcher"; diff --git a/packages/perseus/src/widgets/matrix/matrix.tsx b/packages/perseus/src/widgets/matrix/matrix.tsx index da8674812c..a7823be1b4 100644 --- a/packages/perseus/src/widgets/matrix/matrix.tsx +++ b/packages/perseus/src/widgets/matrix/matrix.tsx @@ -1,3 +1,4 @@ +import {Util} from "@khanacademy/perseus-core"; import {linterContextDefault} from "@khanacademy/perseus-linter"; import {StyleSheet} from "aphrodite"; import classNames from "classnames"; @@ -12,7 +13,6 @@ import TextInput from "../../components/text-input"; import InteractiveUtil from "../../interactive2/interactive-util"; import {ApiOptions} from "../../perseus-api"; import Renderer from "../../renderer"; -import Util from "../../util"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/matrix/matrix-ai-utils"; import scoreMatrix from "./score-matrix"; diff --git a/packages/perseus/src/widgets/orderer/orderer.tsx b/packages/perseus/src/widgets/orderer/orderer.tsx index 2936da8a0e..7750a49be4 100644 --- a/packages/perseus/src/widgets/orderer/orderer.tsx +++ b/packages/perseus/src/widgets/orderer/orderer.tsx @@ -1,6 +1,6 @@ /* eslint-disable @khanacademy/ts-no-error-suppressions */ /* eslint-disable @babel/no-invalid-this, react/no-unsafe */ -import {Errors} from "@khanacademy/perseus-core"; +import {Errors, Util} from "@khanacademy/perseus-core"; import {linterContextDefault} from "@khanacademy/perseus-linter"; import $ from "jquery"; import * as React from "react"; @@ -11,7 +11,6 @@ import {PerseusI18nContext} from "../../components/i18n-context"; import {Log} from "../../logging/log"; import {ClassNames as ApiClassNames} from "../../perseus-api"; import Renderer from "../../renderer"; -import Util from "../../util"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/orderer/orderer-ai-utils"; import {scoreOrderer} from "./score-orderer"; diff --git a/packages/perseus/src/widgets/plotter/score-plotter.ts b/packages/perseus/src/widgets/plotter/score-plotter.ts index 41674387e9..ddaca7365d 100644 --- a/packages/perseus/src/widgets/plotter/score-plotter.ts +++ b/packages/perseus/src/widgets/plotter/score-plotter.ts @@ -1,4 +1,4 @@ -import Util from "../../util"; +import {Util} from "@khanacademy/perseus-core"; import validatePlotter from "./validate-plotter"; diff --git a/packages/perseus/src/widgets/plotter/validate-plotter.ts b/packages/perseus/src/widgets/plotter/validate-plotter.ts index 60cf5b2b00..31300eadd6 100644 --- a/packages/perseus/src/widgets/plotter/validate-plotter.ts +++ b/packages/perseus/src/widgets/plotter/validate-plotter.ts @@ -1,4 +1,4 @@ -import Util from "../../util"; +import {Util} from "@khanacademy/perseus-core"; import type {ValidationResult} from "../../types"; import type { diff --git a/packages/perseus/src/widgets/radio/radio-component.tsx b/packages/perseus/src/widgets/radio/radio-component.tsx index 7683f4db31..202bfa3618 100644 --- a/packages/perseus/src/widgets/radio/radio-component.tsx +++ b/packages/perseus/src/widgets/radio/radio-component.tsx @@ -1,9 +1,9 @@ +import {Util} from "@khanacademy/perseus-core"; import {linterContextDefault} from "@khanacademy/perseus-linter"; import * as React from "react"; import {PerseusI18nContext} from "../../components/i18n-context"; import Renderer from "../../renderer"; -import Util from "../../util"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/radio/radio-ai-utils"; import PassageRef from "../passage-ref/passage-ref"; diff --git a/packages/perseus/src/widgets/radio/radio.ts b/packages/perseus/src/widgets/radio/radio.ts index 25778f91a5..9dd9630b96 100644 --- a/packages/perseus/src/widgets/radio/radio.ts +++ b/packages/perseus/src/widgets/radio/radio.ts @@ -1,7 +1,6 @@ +import {Util} from "@khanacademy/perseus-core"; import _ from "underscore"; -import Util from "../../util"; - import Radio from "./radio-component"; import scoreRadio from "./score-radio"; diff --git a/packages/perseus/src/widgets/sorter/score-sorter.ts b/packages/perseus/src/widgets/sorter/score-sorter.ts index f86579d3e9..bdfba1eae1 100644 --- a/packages/perseus/src/widgets/sorter/score-sorter.ts +++ b/packages/perseus/src/widgets/sorter/score-sorter.ts @@ -1,4 +1,4 @@ -import Util from "../../util"; +import {Util} from "@khanacademy/perseus-core"; import validateSorter from "./validate-sorter"; diff --git a/packages/perseus/src/widgets/sorter/sorter.tsx b/packages/perseus/src/widgets/sorter/sorter.tsx index b50cd8750d..b2480d3624 100644 --- a/packages/perseus/src/widgets/sorter/sorter.tsx +++ b/packages/perseus/src/widgets/sorter/sorter.tsx @@ -1,8 +1,8 @@ +import {Util} from "@khanacademy/perseus-core"; import {linterContextDefault} from "@khanacademy/perseus-linter"; import * as React from "react"; import Sortable from "../../components/sortable"; -import Util from "../../util"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/sorter/sorter-ai-utils"; import scoreSorter from "./score-sorter"; diff --git a/packages/perseus/src/widgets/table/table.tsx b/packages/perseus/src/widgets/table/table.tsx index 6b2fd35b1c..5043ba37ca 100644 --- a/packages/perseus/src/widgets/table/table.tsx +++ b/packages/perseus/src/widgets/table/table.tsx @@ -1,3 +1,4 @@ +import {Util} from "@khanacademy/perseus-core"; import {linterContextDefault} from "@khanacademy/perseus-linter"; import * as React from "react"; import ReactDOM from "react-dom"; @@ -8,7 +9,6 @@ import SimpleKeypadInput from "../../components/simple-keypad-input"; import InteractiveUtil from "../../interactive2/interactive-util"; import {ApiOptions} from "../../perseus-api"; import Renderer from "../../renderer"; -import Util from "../../util"; import scoreTable from "./score-table";