@khanacademy/perseus 19.1.3 → 19.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,27 +13,19 @@ import {point as kpoint} from "@khanacademy/kmath";
13
13
  import $ from "jquery";
14
14
  import _ from "underscore";
15
15
 
16
+ import {Errors} from "../logging/log";
17
+ import {PerseusError} from "../perseus-error";
18
+
16
19
  import InteractiveUtil from "./interactive-util";
17
20
 
21
+ import type {Constraint, ConstraintCallbacks, Coord} from "./types";
22
+ import type {Graphie} from "../util/graphie";
23
+
18
24
  const normalizeOptions = InteractiveUtil.normalizeOptions;
19
25
 
20
26
  const assert = InteractiveUtil.assert;
21
27
 
22
- // state parameters that should be converted into an array of
23
- // functions
24
- const FUNCTION_ARRAY_OPTIONS = [
25
- "add",
26
- "modify",
27
- "draw",
28
- "remove",
29
- "onMoveStart",
30
- "onMove",
31
- "onMoveEnd",
32
- "onClick",
33
- ];
34
-
35
- // Default "props" and "state". Both are added to this.state and
36
- // receive magic getter methods (this.isHovering() etc).
28
+ // Default "props" and "state". Both are added to this.state.
37
29
  // However, properties in DEFAULT_PROPS are updated on `modify()`,
38
30
  // while those in DEFAULT_STATE persist and are not updated.
39
31
  // Things that the user might want to change should be on "props",
@@ -49,120 +41,78 @@ const DEFAULT_STATE = {
49
41
  mouseTarget: null,
50
42
  } as const;
51
43
 
52
- const Movable = function (graphie: any, options: any): void {
53
- // @ts-expect-error - TS2683 - 'this' implicitly has type 'any' because it does not have a type annotation.
54
- _.extend(this, {
55
- graphie: graphie,
56
- state: {
57
- // Set here because this must be unique for each instance
44
+ export interface State {
45
+ added?: boolean;
46
+ isHovering?: boolean;
47
+ isMouseOver?: boolean;
48
+ isDragging?: boolean;
49
+ mouseTarget?: {
50
+ toFront(): void;
51
+ toBack(): void;
52
+ remove(): void;
53
+ getMouseTarget(): Element;
54
+ } | null;
55
+ // TODO(benchristel): I don't think cursor is used
56
+ cursor?: null;
57
+ id: string;
58
+ add?: (() => void)[];
59
+ // TODO(benchristel): I don't see any evidence that implementers of
60
+ // modify do anything with the second argument. Remove it?
61
+ modify?: ((state: State, prevState?: State) => void)[];
62
+ draw?: ((state: State, prevState?: State) => void)[];
63
+ remove?: (() => void)[];
64
+ // TODO(benchristel): callers pass the position twice to onMoveStart.
65
+ // But I don't see any implementers expecting two arguments. Remove the
66
+ // second argument.
67
+ onMoveStart?: ((position: Coord, positionAgain: Coord) => void)[];
68
+ onMove?: ((end: Coord, start: Coord) => void)[];
69
+ onMoveEnd?: ((end: Coord, start: Coord) => void)[];
70
+ onClick?: ((position: Coord, start: Coord) => void)[];
71
+ constraints?: Constraint[];
72
+ }
73
+
74
+ export class Movable<Options extends Record<string, any>> {
75
+ graphie: Graphie;
76
+ state: State;
77
+ prevState: State | undefined;
78
+ // TODO(benchristel): delete _listenerMap; it's unused
79
+ _listenerMap: Record<string, number> = {};
80
+
81
+ constructor(graphie: Graphie, options: Options) {
82
+ this.graphie = graphie;
83
+ this.state = {
58
84
  id: _.uniqueId("movable"),
59
- },
60
- });
61
-
62
- // We only set DEFAULT_STATE once, here
63
- // @ts-expect-error - TS2683 - 'this' implicitly has type 'any' because it does not have a type annotation.
64
- this.modify(_.extend({}, DEFAULT_STATE, options));
65
- };
66
-
67
- InteractiveUtil.createGettersFor(
68
- Movable,
69
- _.extend({}, DEFAULT_PROPS, DEFAULT_STATE),
70
- );
71
- InteractiveUtil.addMovableHelperMethodsTo(Movable);
72
-
73
- _.extend(Movable.prototype, {
74
- cloneState: function () {
75
- return _.clone(this.state);
76
- },
77
-
78
- _createDefaultState: function () {
79
- return _.extend(
80
- {
81
- id: this.state.id,
82
- add: [],
83
- modify: [],
84
- draw: [],
85
- remove: [],
86
- onMoveStart: [],
87
- onMove: [],
88
- onMoveEnd: [],
89
- onClick: [],
90
-
91
- // We only update props here, because we want things on state to
92
- // be persistent, and updated appropriately in modify()
93
- },
94
- DEFAULT_PROPS,
95
- );
96
- },
97
-
98
- /**
99
- * Resets the object to its state as if it were constructed with
100
- * `options` originally. The only state maintained is `state.id`
101
- *
102
- * Analogous to React.js's replaceProps
103
- */
104
- modify: function (options) {
105
- this.update(_.extend({}, this._createDefaultState(), options));
106
- },
107
-
108
- /**
109
- * Simulates a mouse grab event on the movable object.
110
- */
111
- grab: function (coord) {
112
- assert(kpoint.is(coord));
113
- const self = this;
114
- const graphie = self.graphie;
115
- const state = self.state;
116
-
117
- state.isHovering = true;
118
- state.isDragging = true;
119
- graphie.isDragging = true;
120
-
121
- const startMouseCoord = coord;
122
- let prevMouseCoord = startMouseCoord;
123
- self._fireEvent(state.onMoveStart, startMouseCoord, startMouseCoord);
124
-
125
- const moveHandler = function (e: any) {
126
- e.preventDefault();
127
-
128
- const mouseCoord = graphie.getMouseCoord(e);
129
- self._fireEvent(state.onMove, mouseCoord, prevMouseCoord);
130
- self.draw();
131
- prevMouseCoord = mouseCoord;
132
85
  };
133
-
134
- const upHandler = function (e: any) {
135
- $(document).unbind("vmousemove", moveHandler);
136
- $(document).unbind("vmouseup", upHandler);
137
- if (state.isHovering) {
138
- self._fireEvent(state.onClick, prevMouseCoord, startMouseCoord);
139
- }
140
- state.isHovering = self.state.isMouseOver;
141
- state.isDragging = false;
142
- graphie.isDragging = false;
143
- self._fireEvent(state.onMoveEnd, prevMouseCoord, startMouseCoord);
144
- self.draw();
86
+ this.modify({...DEFAULT_STATE, ...options});
87
+ }
88
+
89
+ modify(options: Options) {
90
+ this.update({...this._createDefaultState(), ...options});
91
+ }
92
+
93
+ _createDefaultState() {
94
+ return {
95
+ id: this.state.id,
96
+ add: [],
97
+ modify: [],
98
+ draw: [],
99
+ remove: [],
100
+ onMoveStart: [],
101
+ onMove: [],
102
+ onMoveEnd: [],
103
+ onClick: [],
104
+
105
+ // We only update props here, because we want things on state to
106
+ // be persistent, and updated appropriately in modify()
107
+ ...DEFAULT_PROPS,
145
108
  };
109
+ }
146
110
 
147
- $(document).bind("vmousemove", moveHandler);
148
- $(document).bind("vmouseup", upHandler);
149
- },
111
+ update(options: Options) {
112
+ const graphie = this.graphie;
150
113
 
151
- /**
152
- * Adjusts constructor parameters without changing previous settings
153
- * for any option not specified
154
- *
155
- * Analogous to React.js's setProps
156
- */
157
- update: function (options) {
158
- const self = this;
159
- const graphie = self.graphie;
160
-
161
- const prevState = self.cloneState();
162
- const state = _.extend(
163
- self.state,
164
- normalizeOptions(FUNCTION_ARRAY_OPTIONS, options),
165
- );
114
+ const prevState = this.cloneState();
115
+ const state = Object.assign(this.state, normalizeOptions(options));
166
116
 
167
117
  // the invisible shape in front of the point that gets mouse events
168
118
  if (state.mouseTarget && !prevState.mouseTarget) {
@@ -176,25 +126,25 @@ _.extend(Movable.prototype, {
176
126
  const isMouse = !("ontouchstart" in window);
177
127
 
178
128
  if (isMouse) {
179
- $mouseTarget.on("vmouseover", function () {
129
+ $mouseTarget.on("vmouseover", () => {
180
130
  state.isMouseOver = true;
181
131
  if (!graphie.isDragging) {
182
132
  state.isHovering = true;
183
133
  }
184
- if (self.state.added) {
134
+ if (this.state.added) {
185
135
  // Avoid drawing if the point has been removed
186
- self.draw();
136
+ this.draw();
187
137
  }
188
138
  });
189
139
 
190
- $mouseTarget.on("vmouseout", function () {
140
+ $mouseTarget.on("vmouseout", () => {
191
141
  state.isMouseOver = false;
192
142
  if (!state.isDragging) {
193
143
  state.isHovering = false;
194
144
  }
195
- if (self.state.added) {
145
+ if (this.state.added) {
196
146
  // Avoid drawing if the point has been removed
197
- self.draw();
147
+ this.draw();
198
148
  }
199
149
  });
200
150
  }
@@ -209,14 +159,14 @@ _.extend(Movable.prototype, {
209
159
  {passive: false},
210
160
  );
211
161
 
212
- $mouseTarget.on("vmousedown", function (e) {
162
+ $mouseTarget.on("vmousedown", (e) => {
213
163
  if (e.which !== 0 && e.which !== 1) {
214
164
  return;
215
165
  }
216
166
  e.preventDefault();
217
167
 
218
168
  const mouseCoord = graphie.getMouseCoord(e);
219
- self.grab(mouseCoord);
169
+ this.grab(mouseCoord);
220
170
  });
221
171
  }
222
172
 
@@ -234,41 +184,254 @@ _.extend(Movable.prototype, {
234
184
 
235
185
  // Trigger an add event if this hasn't been added before
236
186
  if (!state.added) {
237
- self._fireEvent(state.modify, self.cloneState(), {});
187
+ // TODO(benchristel): No one seems to handle the modify event. Can
188
+ // we delete this?
189
+ // @ts-expect-error - TS2345: Argument of type '{}' is not assignable to parameter of type 'State'.
190
+ this._fireEvent(state.modify, this.cloneState(), {});
238
191
  state.added = true;
239
192
 
240
193
  // Update the state for `added` and in case the add event
241
194
  // changed it
242
- self.prevState = self.cloneState();
195
+ this.prevState = this.cloneState();
243
196
  }
244
197
 
245
198
  // Trigger a modify event
246
- self._fireEvent(state.modify, self.cloneState(), self.prevState);
247
- },
199
+ this._fireEvent(state.modify, this.cloneState(), this.prevState);
200
+ }
248
201
 
249
- remove: function () {
250
- this.state.added = false;
251
- this._fireEvent(this.state.remove);
252
- if (this.state.mouseTarget) {
253
- $(this.state.mouseTarget).off();
254
- this.state.mouseTarget.remove();
255
- this.state.mouseTarget = null;
202
+ cloneState(): State {
203
+ return {...this.state};
204
+ }
205
+
206
+ /**
207
+ * Fire an onSomething type event to all functions in listeners
208
+ */
209
+ _fireEvent<F extends (...args: any[]) => any>(
210
+ listeners: F[] | undefined,
211
+ ...args: Parameters<F>
212
+ ) {
213
+ if (listeners == null) {
214
+ return;
256
215
  }
257
- },
216
+ for (const listener of listeners) {
217
+ listener.call(this, ...args);
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Call all draw functions, and update our prevState for the next
223
+ * draw function
224
+ */
225
+ draw() {
226
+ const currState = this.cloneState();
227
+ this._fireEvent(this.state.draw, currState, this.prevState);
228
+ this.prevState = currState;
229
+ }
230
+
231
+ /**
232
+ * Simulates a mouse grab event on the movable object.
233
+ */
234
+ grab(coord: Coord) {
235
+ assert(kpoint.is(coord));
236
+ const graphie = this.graphie;
237
+ const state: State = this.state;
238
+
239
+ state.isHovering = true;
240
+ state.isDragging = true;
241
+ graphie.isDragging = true;
242
+
243
+ const startMouseCoord = coord;
244
+ let prevMouseCoord = startMouseCoord;
245
+ this._fireEvent(state.onMoveStart, startMouseCoord, startMouseCoord);
246
+
247
+ const moveHandler = (
248
+ e: Readonly<{
249
+ pageX?: number;
250
+ pageY?: number;
251
+ preventDefault(): void;
252
+ }>,
253
+ ) => {
254
+ e.preventDefault();
255
+
256
+ const mouseCoord = graphie.getMouseCoord(e);
257
+ this._fireEvent(state.onMove, mouseCoord, prevMouseCoord);
258
+ this.draw();
259
+ prevMouseCoord = mouseCoord;
260
+ };
261
+
262
+ const upHandler = () => {
263
+ $(document).unbind("vmousemove", moveHandler);
264
+ $(document).unbind("vmouseup", upHandler);
265
+ if (state.isHovering) {
266
+ this._fireEvent(state.onClick, prevMouseCoord, startMouseCoord);
267
+ }
268
+ state.isHovering = this.state.isMouseOver;
269
+ state.isDragging = false;
270
+ graphie.isDragging = false;
271
+ this._fireEvent(state.onMoveEnd, prevMouseCoord, startMouseCoord);
272
+ this.draw();
273
+ };
274
+
275
+ $(document).bind("vmousemove", moveHandler);
276
+ $(document).bind("vmouseup", upHandler);
277
+ }
278
+
279
+ _applyConstraints(
280
+ current: Coord,
281
+ previous: Coord,
282
+ extraOptions?: ConstraintCallbacks,
283
+ ) {
284
+ let skipRemaining = false;
285
+
286
+ return (this.state.constraints ?? []).reduce(
287
+ (memo: Coord | false, constraint) => {
288
+ // A move that has been cancelled won't be propagated to later
289
+ // constraints calls
290
+ if (memo === false) {
291
+ return false;
292
+ }
293
+
294
+ if (skipRemaining) {
295
+ return memo;
296
+ }
297
+
298
+ const result = constraint.call(this, memo, previous, {
299
+ onSkipRemaining: () => {
300
+ skipRemaining = true;
301
+ },
302
+ ...extraOptions,
303
+ });
304
+
305
+ if (result === false) {
306
+ // Returning false cancels the move
307
+ return false;
308
+ }
309
+ if (kpoint.is(result, 2)) {
310
+ // Returning a coord from constraints overrides the move
311
+ return result;
312
+ }
313
+ if (result === true || result == null) {
314
+ // Returning true or undefined allow the move to occur
315
+ return memo;
316
+ }
317
+ // Anything else is an error
318
+ throw new PerseusError(
319
+ "Constraint returned invalid result: " + result,
320
+ Errors.Internal,
321
+ );
322
+ },
323
+ current,
324
+ );
325
+ }
258
326
 
259
327
  // Change z-order to back
260
- toBack: function () {
328
+ toBack() {
261
329
  if (this.state.mouseTarget) {
262
330
  this.state.mouseTarget.toBack();
263
331
  }
264
- },
332
+ }
265
333
 
266
334
  // Change z-order to front
267
- toFront: function () {
335
+ toFront() {
268
336
  if (this.state.mouseTarget) {
269
337
  this.state.mouseTarget.toFront();
270
338
  }
271
- },
272
- });
339
+ }
340
+
341
+ /**
342
+ * Add a listener to any event: startMove, constraints, onMove, onMoveEnd,
343
+ * etc. If a listener is already bound to the given eventName and id, then
344
+ * it is overwritten by func.
345
+ *
346
+ * eventName: the string name of the event to listen to. one of:
347
+ * "onMoveStart", "onMove", "onMoveEnd", "draw", "remove"
348
+ *
349
+ * id: a string id that can be used to remove this event at a later time
350
+ * note: adding multiple listeners with the same id is undefined behavior
351
+ *
352
+ * func: the function to call when the event happens, which is called
353
+ * with the event's standard parameters [usually (coord, prevCoord) or
354
+ * (state, prevState)]
355
+ */
356
+ listen(eventName: string, id: string, func: () => unknown) {
357
+ this._listenerMap = this._listenerMap || {};
358
+
359
+ // If there's an existing handler, replace it by using its index in
360
+ // `this.state[eventName]`; otherwise, add this handler to the end
361
+ const key = getKey(eventName, id);
362
+ const index = (this._listenerMap[key] =
363
+ this._listenerMap[key] || this.state[eventName].length);
364
+ this.state[eventName][index] = func;
365
+ }
366
+
367
+ /**
368
+ * Remove a previously added listener, by the id specified in the
369
+ * corresponding listen() call
370
+ *
371
+ * If the given id has not been registered already, this is a no-op
372
+ */
373
+ unlisten(eventName: string, id: string) {
374
+ this._listenerMap = this._listenerMap || {};
375
+
376
+ const key = getKey(eventName, id);
377
+ const index = this._listenerMap[key];
378
+ if (index !== undefined) {
379
+ // Remove handler from list of event handlers and listenerMap
380
+ this.state[eventName].splice(index, 1);
381
+ delete this._listenerMap[key];
382
+
383
+ // Re-index existing events: if they occur after `index`, decrement
384
+ const keys = _.keys(this._listenerMap);
385
+ keys.forEach((key) => {
386
+ if (
387
+ getEventName(key) === eventName &&
388
+ this._listenerMap[key] > index
389
+ ) {
390
+ this._listenerMap[key]--;
391
+ }
392
+ });
393
+ }
394
+ }
395
+
396
+ remove() {
397
+ this.state.added = false;
398
+ this._fireEvent(this.state.remove);
399
+ if (this.state.mouseTarget) {
400
+ $(this.state.mouseTarget).off();
401
+ this.state.mouseTarget.remove();
402
+ this.state.mouseTarget = null;
403
+ }
404
+ }
405
+
406
+ cursor() {
407
+ return this.state.cursor;
408
+ }
409
+
410
+ added() {
411
+ return this.state.added;
412
+ }
413
+
414
+ isHovering() {
415
+ return this.state.isHovering;
416
+ }
417
+
418
+ isMouseOver() {
419
+ return this.state.isMouseOver;
420
+ }
421
+
422
+ isDragging() {
423
+ return this.state.isDragging;
424
+ }
425
+
426
+ mouseTarget() {
427
+ return this.state.mouseTarget;
428
+ }
429
+ }
430
+
431
+ function getKey(eventName: string, id: string): string {
432
+ return eventName + ":" + id;
433
+ }
273
434
 
274
- export default Movable;
435
+ function getEventName(key: string): string {
436
+ return key.split(":")[0];
437
+ }
@@ -1,2 +1,17 @@
1
+ import type {Movable} from "./movable";
2
+
1
3
  export type Coord = [number, number]; // x, y
2
4
  export type Line = [Coord, Coord]; // start, end
5
+ export type Constraint = (
6
+ this: Movable<any>,
7
+ current: Coord | boolean,
8
+ previous: Coord,
9
+ options: ConstraintCallbacks,
10
+ ) => Coord | boolean | undefined;
11
+
12
+ // TODO(benchristel): these callbacks feel overcomplicated. Can we
13
+ // get rid of them?
14
+ export type ConstraintCallbacks = {
15
+ onSkipRemaining(): void;
16
+ onOutOfBounds?: () => void;
17
+ };
@@ -1,4 +1,4 @@
1
- import Movable from "./interactive2/movable";
1
+ import {Movable} from "./interactive2/movable";
2
2
  import MovableLine from "./interactive2/movable-line";
3
3
  import MovablePoint from "./interactive2/movable-point";
4
4
  import MovablePolygon from "./interactive2/movable-polygon";
@@ -111,6 +111,11 @@ export class Graphie {
111
111
  xpixels?: number;
112
112
  ypixels?: number;
113
113
 
114
+ // set by Movables
115
+ // TODO(benchristel): consider putting this state on the Movable instead of
116
+ // on the Graphie
117
+ isDragging?: boolean;
118
+
114
119
  constructor(el: Element) {
115
120
  this.el = el;
116
121
  $(el).css("position", "relative");
@@ -121,13 +126,15 @@ export class Graphie {
121
126
  }
122
127
 
123
128
  init(options: {
124
- range?: [Interval, Interval];
129
+ range: [Interval, Interval];
125
130
  scale?: number | Coord;
126
131
  isMobile?: boolean;
127
132
  }) {
128
133
  let scale = options.scale || [40, 40];
129
134
  scale = typeof scale === "number" ? [scale, scale] : scale;
130
135
 
136
+ // TODO(jeremy): This can be removed once we're confident that callers
137
+ // obey the Typescript types.
131
138
  if (options.range == null) {
132
139
  throw new PerseusError(
133
140
  "range should be specified in graph init",
@@ -1399,6 +1406,11 @@ export class Graphie {
1399
1406
 
1400
1407
  return processed;
1401
1408
  }
1409
+
1410
+ // This is a stub that's overridden in interactive.ts
1411
+ getMouseCoord(event: Readonly<{pageX?: number; pageY?: number}>): Coord {
1412
+ throw new Error("getMouseCoord is a stub, and is not implemented");
1413
+ }
1402
1414
  }
1403
1415
 
1404
1416
  const labelDirections = {
@@ -198,7 +198,7 @@ _.extend(GraphUtils.Graphie.prototype, {
198
198
  /**
199
199
  * Get mouse coordinates in pixels
200
200
  */
201
- getMousePx: function (event) {
201
+ getMousePx: function (event: Readonly<{pageX?: number; pageY?: number}>) {
202
202
  const graphie = this;
203
203
 
204
204
  const mouseX =
@@ -214,7 +214,9 @@ _.extend(GraphUtils.Graphie.prototype, {
214
214
  /**
215
215
  * Get mouse coordinates in graph coordinates
216
216
  */
217
- getMouseCoord: function (event): Coord {
217
+ getMouseCoord: function (
218
+ event: Readonly<{pageX?: number; pageY?: number}>,
219
+ ): Coord {
218
220
  return this.unscalePoint(this.getMousePx(event));
219
221
  },
220
222