@khanacademy/perseus 19.1.4 → 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,17 +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
 
18
- import type {Coord} from "./types";
21
+ import type {Constraint, ConstraintCallbacks, Coord} from "./types";
19
22
  import type {Graphie} from "../util/graphie";
20
23
 
21
24
  const normalizeOptions = InteractiveUtil.normalizeOptions;
22
25
 
23
26
  const assert = InteractiveUtil.assert;
24
27
 
25
- // Default "props" and "state". Both are added to this.state and
26
- // receive magic getter methods (this.isHovering() etc).
28
+ // Default "props" and "state". Both are added to this.state.
27
29
  // However, properties in DEFAULT_PROPS are updated on `modify()`,
28
30
  // while those in DEFAULT_STATE persist and are not updated.
29
31
  // Things that the user might want to change should be on "props",
@@ -44,48 +46,51 @@ export interface State {
44
46
  isHovering?: boolean;
45
47
  isMouseOver?: boolean;
46
48
  isDragging?: boolean;
47
- // TODO(benchristel): improve types
48
- mouseTarget?: unknown | null;
49
- // TODO(benchristel): improve types
50
- cursor: unknown | null;
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;
51
57
  id: string;
52
- add: (() => void)[];
53
- modify: (() => void)[];
54
- draw: (() => void)[];
55
- remove: (() => void)[];
56
- onMoveStart: ((position: Coord) => void)[];
57
- onMove: ((end: Coord, start: Coord) => void)[];
58
- onMoveEnd: ((end: Coord, start: Coord) => void)[];
59
- onClick: ((position: Coord, start: Coord) => void)[];
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[];
60
72
  }
61
73
 
62
- const Movable = function (graphie: Graphie, options: any): void {
63
- // @ts-expect-error - TS2683 - 'this' implicitly has type 'any' because it does not have a type annotation.
64
- _.extend(this, {
65
- graphie,
66
- state: {
67
- // Set here because this must be unique for each instance
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 = {
68
84
  id: _.uniqueId("movable"),
69
- },
70
- });
71
-
72
- // We only set DEFAULT_STATE once, here
73
- // @ts-expect-error - TS2683 - 'this' implicitly has type 'any' because it does not have a type annotation.
74
- this.modify(_.extend({}, DEFAULT_STATE, options));
75
- };
76
-
77
- InteractiveUtil.createGettersFor(
78
- Movable,
79
- _.extend({}, DEFAULT_PROPS, DEFAULT_STATE),
80
- );
81
- InteractiveUtil.addMovableHelperMethodsTo(Movable);
82
-
83
- _.extend(Movable.prototype, {
84
- cloneState: function (): State {
85
- return {...this.state};
86
- },
85
+ };
86
+ this.modify({...DEFAULT_STATE, ...options});
87
+ }
87
88
 
88
- _createDefaultState: function (): State {
89
+ modify(options: Options) {
90
+ this.update({...this._createDefaultState(), ...options});
91
+ }
92
+
93
+ _createDefaultState() {
89
94
  return {
90
95
  id: this.state.id,
91
96
  add: [],
@@ -101,73 +106,13 @@ _.extend(Movable.prototype, {
101
106
  // be persistent, and updated appropriately in modify()
102
107
  ...DEFAULT_PROPS,
103
108
  };
104
- },
105
-
106
- /**
107
- * Resets the object to its state as if it were constructed with
108
- * `options` originally. The only state maintained is `state.id`
109
- *
110
- * Analogous to React.js's replaceProps
111
- */
112
- modify: function (options: Partial<State>) {
113
- this.update({...this._createDefaultState(), ...options});
114
- },
115
-
116
- /**
117
- * Simulates a mouse grab event on the movable object.
118
- */
119
- grab: function (coord: Coord) {
120
- assert(kpoint.is(coord));
121
- const self = this;
122
- const graphie = self.graphie;
123
- const state: State = self.state;
109
+ }
124
110
 
125
- state.isHovering = true;
126
- state.isDragging = true;
127
- graphie.isDragging = true;
128
-
129
- const startMouseCoord = coord;
130
- let prevMouseCoord = startMouseCoord;
131
- self._fireEvent(state.onMoveStart, startMouseCoord, startMouseCoord);
132
-
133
- const moveHandler = function (e: any) {
134
- e.preventDefault();
111
+ update(options: Options) {
112
+ const graphie = this.graphie;
135
113
 
136
- const mouseCoord = graphie.getMouseCoord(e);
137
- self._fireEvent(state.onMove, mouseCoord, prevMouseCoord);
138
- self.draw();
139
- prevMouseCoord = mouseCoord;
140
- };
141
-
142
- const upHandler = function (e: any) {
143
- $(document).unbind("vmousemove", moveHandler);
144
- $(document).unbind("vmouseup", upHandler);
145
- if (state.isHovering) {
146
- self._fireEvent(state.onClick, prevMouseCoord, startMouseCoord);
147
- }
148
- state.isHovering = self.state.isMouseOver;
149
- state.isDragging = false;
150
- graphie.isDragging = false;
151
- self._fireEvent(state.onMoveEnd, prevMouseCoord, startMouseCoord);
152
- self.draw();
153
- };
154
-
155
- $(document).bind("vmousemove", moveHandler);
156
- $(document).bind("vmouseup", upHandler);
157
- },
158
-
159
- /**
160
- * Adjusts constructor parameters without changing previous settings
161
- * for any option not specified
162
- *
163
- * Analogous to React.js's setProps
164
- */
165
- update: function (options: State) {
166
- const self = this;
167
- const graphie = self.graphie;
168
-
169
- const prevState = self.cloneState();
170
- const state = _.extend(self.state, normalizeOptions(options));
114
+ const prevState = this.cloneState();
115
+ const state = Object.assign(this.state, normalizeOptions(options));
171
116
 
172
117
  // the invisible shape in front of the point that gets mouse events
173
118
  if (state.mouseTarget && !prevState.mouseTarget) {
@@ -181,25 +126,25 @@ _.extend(Movable.prototype, {
181
126
  const isMouse = !("ontouchstart" in window);
182
127
 
183
128
  if (isMouse) {
184
- $mouseTarget.on("vmouseover", function () {
129
+ $mouseTarget.on("vmouseover", () => {
185
130
  state.isMouseOver = true;
186
131
  if (!graphie.isDragging) {
187
132
  state.isHovering = true;
188
133
  }
189
- if (self.state.added) {
134
+ if (this.state.added) {
190
135
  // Avoid drawing if the point has been removed
191
- self.draw();
136
+ this.draw();
192
137
  }
193
138
  });
194
139
 
195
- $mouseTarget.on("vmouseout", function () {
140
+ $mouseTarget.on("vmouseout", () => {
196
141
  state.isMouseOver = false;
197
142
  if (!state.isDragging) {
198
143
  state.isHovering = false;
199
144
  }
200
- if (self.state.added) {
145
+ if (this.state.added) {
201
146
  // Avoid drawing if the point has been removed
202
- self.draw();
147
+ this.draw();
203
148
  }
204
149
  });
205
150
  }
@@ -214,14 +159,14 @@ _.extend(Movable.prototype, {
214
159
  {passive: false},
215
160
  );
216
161
 
217
- $mouseTarget.on("vmousedown", function (e) {
162
+ $mouseTarget.on("vmousedown", (e) => {
218
163
  if (e.which !== 0 && e.which !== 1) {
219
164
  return;
220
165
  }
221
166
  e.preventDefault();
222
167
 
223
168
  const mouseCoord = graphie.getMouseCoord(e);
224
- self.grab(mouseCoord);
169
+ this.grab(mouseCoord);
225
170
  });
226
171
  }
227
172
 
@@ -239,41 +184,254 @@ _.extend(Movable.prototype, {
239
184
 
240
185
  // Trigger an add event if this hasn't been added before
241
186
  if (!state.added) {
242
- 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(), {});
243
191
  state.added = true;
244
192
 
245
193
  // Update the state for `added` and in case the add event
246
194
  // changed it
247
- self.prevState = self.cloneState();
195
+ this.prevState = this.cloneState();
248
196
  }
249
197
 
250
198
  // Trigger a modify event
251
- self._fireEvent(state.modify, self.cloneState(), self.prevState);
252
- },
199
+ this._fireEvent(state.modify, this.cloneState(), this.prevState);
200
+ }
253
201
 
254
- remove: function () {
255
- this.state.added = false;
256
- this._fireEvent(this.state.remove);
257
- if (this.state.mouseTarget) {
258
- $(this.state.mouseTarget).off();
259
- this.state.mouseTarget.remove();
260
- 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;
215
+ }
216
+ for (const listener of listeners) {
217
+ listener.call(this, ...args);
261
218
  }
262
- },
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
+ }
263
326
 
264
327
  // Change z-order to back
265
- toBack: function () {
328
+ toBack() {
266
329
  if (this.state.mouseTarget) {
267
330
  this.state.mouseTarget.toBack();
268
331
  }
269
- },
332
+ }
270
333
 
271
334
  // Change z-order to front
272
- toFront: function () {
335
+ toFront() {
273
336
  if (this.state.mouseTarget) {
274
337
  this.state.mouseTarget.toFront();
275
338
  }
276
- },
277
- });
339
+ }
278
340
 
279
- export default Movable;
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
+ }
434
+
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");
@@ -1401,6 +1406,11 @@ export class Graphie {
1401
1406
 
1402
1407
  return processed;
1403
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
+ }
1404
1414
  }
1405
1415
 
1406
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