@khanacademy/perseus-editor 0.0.1 → 0.1.2

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.
Files changed (127) hide show
  1. package/.babelrc.js +1 -1
  2. package/CHANGELOG.md +26 -0
  3. package/dist/es/index.css +1020 -0
  4. package/dist/es/index.js +9 -1
  5. package/dist/es/index.js.map +1 -1
  6. package/dist/index.css +1020 -0
  7. package/dist/index.js +9 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/strings.js +6 -1
  10. package/package.json +16 -4
  11. package/src/README.md +7 -0
  12. package/src/__stories__/editor.stories.js +33 -0
  13. package/src/__stories__/section-control-button.stories.jsx +27 -0
  14. package/src/__testdata__/input-number_testdata.js +28 -0
  15. package/src/__tests__/editor_test.jsx +123 -0
  16. package/src/__tests__/i18n_test.jsx +585 -0
  17. package/src/__tests__/traversal_test.jsx +378 -0
  18. package/src/all-editors.js +85 -0
  19. package/src/all-editors_test.js +17 -0
  20. package/src/all-widgets.js +10 -0
  21. package/src/article-editor.jsx +437 -0
  22. package/src/components/__stories__/blur-input.stories.jsx +24 -0
  23. package/src/components/blur-input.jsx +62 -0
  24. package/src/components/checkbox.jsx +194 -0
  25. package/src/components/device-framer.jsx +91 -0
  26. package/src/components/drag-target.jsx +88 -0
  27. package/src/components/dropdown-option.jsx +318 -0
  28. package/src/components/form-wrapped-text-field.jsx +248 -0
  29. package/src/components/graph-settings.jsx +631 -0
  30. package/src/components/hover-behavior.jsx +178 -0
  31. package/src/components/json-editor.jsx +121 -0
  32. package/src/components/link.jsx +173 -0
  33. package/src/components/simple-button.jsx +86 -0
  34. package/src/components/sortable.jsx +186 -0
  35. package/src/components/util.js +39 -0
  36. package/src/components/viewport-resizer.jsx +73 -0
  37. package/src/diffs/__stories__/perseus-diff-wrapper.jsx +8 -0
  38. package/src/diffs/__stories__/structured-item-diff.stories.jsx +855 -0
  39. package/src/diffs/__stories__/tags-diff.stories.jsx +38 -0
  40. package/src/diffs/__stories__/text-diff.stories.jsx +37 -0
  41. package/src/diffs/__tests__/split-diff_test.js +34 -0
  42. package/src/diffs/__tests__/string-array-diff_test.js +74 -0
  43. package/src/diffs/__tests__/structured-item-diff_test.js +432 -0
  44. package/src/diffs/__tests__/widget-diff-performer_test.js +44 -0
  45. package/src/diffs/article-diff.jsx +70 -0
  46. package/src/diffs/item-diff.jsx +77 -0
  47. package/src/diffs/renderer-diff.jsx +143 -0
  48. package/src/diffs/split-diff.jsx +30 -0
  49. package/src/diffs/string-array-diff.jsx +75 -0
  50. package/src/diffs/structured-item-diff.jsx +269 -0
  51. package/src/diffs/tags-diff.jsx +50 -0
  52. package/src/diffs/text-diff.jsx +194 -0
  53. package/src/diffs/widget-diff-performer.jsx +74 -0
  54. package/src/diffs/widget-diff.jsx +291 -0
  55. package/src/editor-page.jsx +351 -0
  56. package/src/editor.jsx +1364 -0
  57. package/src/hint-editor.jsx +447 -0
  58. package/src/i18n.jsx +139 -0
  59. package/src/iframe-content-renderer.jsx +201 -0
  60. package/src/index.js +27 -8
  61. package/src/item-editor.jsx +159 -0
  62. package/src/item-extras-editor.jsx +150 -0
  63. package/src/katex-error-view.jsx +87 -0
  64. package/src/multirenderer-editor.jsx +1074 -0
  65. package/src/section-control-button.jsx +53 -0
  66. package/src/stateful-article-editor.jsx +176 -0
  67. package/src/stateful-editor-page.jsx +90 -0
  68. package/src/styles/perseus-editor.less +1339 -0
  69. package/src/testing-widgets.js +12 -0
  70. package/src/util/register-all-widgets-and-editors-for-testing.js +15 -0
  71. package/src/util/register-all-widgets-and-editors-for-testing_test.js +18 -0
  72. package/src/widgets/__stories__/label-image-editor.stories.jsx +97 -0
  73. package/src/widgets/categorizer-editor.jsx +103 -0
  74. package/src/widgets/cs-program-editor.jsx +258 -0
  75. package/src/widgets/definition-editor.jsx +75 -0
  76. package/src/widgets/dropdown-editor.jsx +201 -0
  77. package/src/widgets/example-graphie-widget-editor.jsx +70 -0
  78. package/src/widgets/example-graphie-widget.jsx +175 -0
  79. package/src/widgets/example-widget-editor.jsx +60 -0
  80. package/src/widgets/example-widget.jsx +153 -0
  81. package/src/widgets/explanation-editor.jsx +89 -0
  82. package/src/widgets/expression-editor.jsx +560 -0
  83. package/src/widgets/graded-group-editor.jsx +162 -0
  84. package/src/widgets/graded-group-set-editor.jsx +101 -0
  85. package/src/widgets/grapher-editor.jsx +154 -0
  86. package/src/widgets/group-editor.jsx +83 -0
  87. package/src/widgets/iframe-editor.jsx +200 -0
  88. package/src/widgets/image-editor.jsx +326 -0
  89. package/src/widgets/input-number-editor.jsx +269 -0
  90. package/src/widgets/interaction/constraint-editor.jsx +147 -0
  91. package/src/widgets/interaction-editor.jsx +1389 -0
  92. package/src/widgets/interactive-graph-editor.jsx +322 -0
  93. package/src/widgets/label-image/__stories__/answer-choices.stories.jsx +81 -0
  94. package/src/widgets/label-image/__stories__/behavior.stories.jsx +32 -0
  95. package/src/widgets/label-image/__stories__/marker.stories.jsx +76 -0
  96. package/src/widgets/label-image/__stories__/question-markers.stories.jsx +106 -0
  97. package/src/widgets/label-image/__stories__/select-image.stories.jsx +60 -0
  98. package/src/widgets/label-image/answer-choices.jsx +231 -0
  99. package/src/widgets/label-image/behavior.jsx +92 -0
  100. package/src/widgets/label-image/marker.jsx +292 -0
  101. package/src/widgets/label-image/question-markers.jsx +181 -0
  102. package/src/widgets/label-image/select-image.jsx +86 -0
  103. package/src/widgets/label-image-editor.jsx +257 -0
  104. package/src/widgets/lights-puzzle-editor.jsx +267 -0
  105. package/src/widgets/matcher-editor.jsx +147 -0
  106. package/src/widgets/matrix-editor.jsx +155 -0
  107. package/src/widgets/measurer-editor.jsx +245 -0
  108. package/src/widgets/molecule-editor.jsx +63 -0
  109. package/src/widgets/number-line-editor.jsx +498 -0
  110. package/src/widgets/numeric-input-editor.jsx +554 -0
  111. package/src/widgets/orderer-editor.jsx +166 -0
  112. package/src/widgets/passage-editor.jsx +115 -0
  113. package/src/widgets/passage-ref-editor.jsx +77 -0
  114. package/src/widgets/passage-ref-target-editor.jsx +47 -0
  115. package/src/widgets/plotter-editor.jsx +537 -0
  116. package/src/widgets/radio/editor.jsx +398 -0
  117. package/src/widgets/reaction-diagram-editor.jsx +127 -0
  118. package/src/widgets/sequence-editor.jsx +205 -0
  119. package/src/widgets/simple-markdown-tester-editor.jsx +84 -0
  120. package/src/widgets/simple-markdown-tester.jsx +91 -0
  121. package/src/widgets/simulator-editor.jsx +144 -0
  122. package/src/widgets/sorter-editor.jsx +100 -0
  123. package/src/widgets/table-editor.jsx +164 -0
  124. package/src/widgets/transformer-editor.jsx +1006 -0
  125. package/src/widgets/unit-editor.jsx +293 -0
  126. package/src/widgets/video-editor.jsx +74 -0
  127. package/src/__tests__/index.test.js +0 -15
@@ -0,0 +1,1006 @@
1
+ /* eslint-disable camelcase, react/sort-comp */
2
+ // @flow
3
+ import {
4
+ line as kline,
5
+ number as knumber,
6
+ point as kpoint,
7
+ ray as kray,
8
+ vector as kvector,
9
+ } from "@khanacademy/kmath";
10
+ import {
11
+ components,
12
+ ApiOptions,
13
+ Errors,
14
+ KhanColors,
15
+ PerseusError,
16
+ TransformerWidget,
17
+ Util,
18
+ } from "@khanacademy/perseus";
19
+ import * as React from "react";
20
+ import _ from "underscore";
21
+
22
+ import GraphSettings from "../components/graph-settings.jsx";
23
+
24
+ const {Graph, InfoTip, PropCheckBox} = components;
25
+ const Transformer = TransformerWidget.widget;
26
+ const {deepEq, getGridStep} = Util;
27
+
28
+ function arraySum(array) {
29
+ return _.reduce(
30
+ array,
31
+ function (memo, arg) {
32
+ return memo + arg;
33
+ },
34
+ 0,
35
+ );
36
+ }
37
+
38
+ const defaultBackgroundImage = {
39
+ url: null,
40
+ };
41
+
42
+ /* Scales a distance from the default range of
43
+ * [-10, 10] to a given props.range pair
44
+ *
45
+ * Used for sizing various transformation tools
46
+ * (rotation handle, dilation circle)
47
+ */
48
+ function scaleToRange(dist, range) {
49
+ const spreadX = range[0][1] - range[0][0];
50
+ const spreadY = range[1][1] - range[1][0];
51
+
52
+ return (dist * Math.max(spreadX, spreadY)) / 20;
53
+ }
54
+
55
+ function orderInsensitiveCoordsEqual(coords1, coords2) {
56
+ coords1 = _.clone(coords1).sort(kpoint.compare);
57
+ coords2 = _.clone(coords2).sort(kpoint.compare);
58
+ return _.all(
59
+ _.map(coords1, function (coord1, i) {
60
+ const coord2 = coords2[i];
61
+ return kpoint.equal(coord1, coord2);
62
+ }),
63
+ );
64
+ }
65
+
66
+ const defaultGraphProps = function (setProps, boxSize) {
67
+ setProps = setProps || {};
68
+ const labels = setProps.labels || ["x", "y"];
69
+ const range = setProps.range || [
70
+ [-10, 10],
71
+ [-10, 10],
72
+ ];
73
+ const step = setProps.step || [1, 1];
74
+ const gridStep = setProps.gridStep || getGridStep(range, step, boxSize);
75
+ return {
76
+ box: [boxSize, boxSize],
77
+ labels: labels,
78
+ range: range,
79
+ step: step,
80
+ gridStep: gridStep,
81
+ valid: true,
82
+ backgroundImage: defaultBackgroundImage,
83
+ markings: "grid",
84
+ showProtractor: false,
85
+ };
86
+ };
87
+
88
+ const defaultTransformerProps = {
89
+ apiOptions: ApiOptions.defaults,
90
+ gradeEmpty: false,
91
+ graphMode: "interactive",
92
+ listMode: "dynamic",
93
+ graph: {},
94
+ tools: {
95
+ translation: {
96
+ enabled: true,
97
+ required: false,
98
+ constraints: {},
99
+ },
100
+ rotation: {
101
+ enabled: true,
102
+ required: false,
103
+ constraints: {
104
+ fixed: false,
105
+ },
106
+ coord: [1, 6],
107
+ },
108
+ reflection: {
109
+ enabled: true,
110
+ required: false,
111
+ constraints: {
112
+ fixed: false,
113
+ },
114
+ coords: [
115
+ [2, -4],
116
+ [2, 2],
117
+ ],
118
+ },
119
+ dilation: {
120
+ enabled: true,
121
+ required: false,
122
+ constraints: {
123
+ fixed: false,
124
+ },
125
+ coord: [6, 6],
126
+ },
127
+ },
128
+ drawSolutionShape: true,
129
+ starting: {
130
+ shape: {
131
+ type: "polygon-3",
132
+ coords: [
133
+ [2, 2],
134
+ [2, 6],
135
+ [7, 2],
136
+ ],
137
+ },
138
+ transformations: [],
139
+ },
140
+ correct: {
141
+ shape: {
142
+ type: "polygon-3",
143
+ coords: [
144
+ [2, 2],
145
+ [2, 6],
146
+ [7, 2],
147
+ ],
148
+ },
149
+ transformations: [],
150
+ },
151
+ };
152
+
153
+ class ToolSettings extends React.Component<$FlowFixMe> {
154
+ static defaultProps = {
155
+ allowFixed: true,
156
+ };
157
+
158
+ render(): React.Node {
159
+ return (
160
+ <div>
161
+ {this.props.name}:{" "}
162
+ <PropCheckBox
163
+ label="enabled:"
164
+ enabled={this.props.settings.enabled}
165
+ onChange={this.props.onChange}
166
+ />{" "}
167
+ {this.props.settings.enabled && (
168
+ <PropCheckBox
169
+ label="required:"
170
+ required={this.props.settings.required}
171
+ onChange={this.props.onChange}
172
+ />
173
+ )}
174
+ {this.props.settings.enabled && (
175
+ <InfoTip>
176
+ 'Required' will only grade the answer as correct if the
177
+ student has used at least one such transformation.
178
+ </InfoTip>
179
+ )}{" "}
180
+ {this.props.allowFixed && this.props.settings.enabled && (
181
+ <PropCheckBox
182
+ label="fixed:"
183
+ fixed={this.props.settings.constraints.fixed}
184
+ onChange={this.changeConstraints}
185
+ />
186
+ )}
187
+ {this.props.allowFixed && this.props.settings.enabled && (
188
+ <InfoTip>
189
+ Enable 'fixed' to prevent the student from repositioning
190
+ the tool. The tool will appear in the position at which
191
+ it is placed in the editor below.
192
+ </InfoTip>
193
+ )}
194
+ </div>
195
+ );
196
+ }
197
+
198
+ changeConstraints = (changed) => {
199
+ const newConstraints = _.extend({}, this.props.constraints, changed);
200
+ this.props.onChange({
201
+ constraints: newConstraints,
202
+ });
203
+ };
204
+ }
205
+
206
+ class TransformationExplorerSettings extends React.Component<$FlowFixMe> {
207
+ render(): React.Node {
208
+ return (
209
+ <div className="transformer-settings">
210
+ <div>
211
+ {" "}
212
+ Mode:{" "}
213
+ <select value={this.getMode()} onChange={this.changeMode}>
214
+ <option value="interactive,dynamic">
215
+ {" "}
216
+ Exploration with text{" "}
217
+ </option>
218
+ <option value="interactive,static">
219
+ {" "}
220
+ Exploration without text{" "}
221
+ </option>
222
+ <option value="dynamic,interactive">
223
+ {" "}
224
+ Formal with movement{" "}
225
+ </option>
226
+ <option value="static,interactive">
227
+ {" "}
228
+ Formal without movement{" "}
229
+ </option>
230
+ </select>
231
+ <InfoTip>
232
+ <ul>
233
+ <li>
234
+ <b>Exploration:</b> Students create
235
+ transformations with tools on the graph.{" "}
236
+ </li>
237
+ <li>
238
+ <b>Formal with movement:</b> Students specify
239
+ transformations mathematically in the
240
+ transformation list. Graph shows the results of
241
+ these transformations.{" "}
242
+ </li>
243
+ <li>
244
+ <b>Formal without movement:</b> Students specify
245
+ transformations mathematically in the
246
+ transformation list. Graph does not update.{" "}
247
+ </li>
248
+ </ul>
249
+ </InfoTip>
250
+ </div>
251
+ <ToolSettings
252
+ name="Translations"
253
+ settings={this.props.tools.translation}
254
+ allowFixed={false}
255
+ onChange={this.changeHandlerFor("translation")}
256
+ />
257
+ <ToolSettings
258
+ name="Rotations"
259
+ settings={this.props.tools.rotation}
260
+ onChange={this.changeHandlerFor("rotation")}
261
+ />
262
+ <ToolSettings
263
+ name="Reflections"
264
+ settings={this.props.tools.reflection}
265
+ onChange={this.changeHandlerFor("reflection")}
266
+ />
267
+ <ToolSettings
268
+ name="Dilations"
269
+ settings={this.props.tools.dilation}
270
+ onChange={this.changeHandlerFor("dilation")}
271
+ />
272
+ <PropCheckBox
273
+ label="Draw Solution:"
274
+ drawSolutionShape={this.props.drawSolutionShape}
275
+ onChange={this.props.onChange}
276
+ />
277
+ </div>
278
+ );
279
+ }
280
+
281
+ getMode = () => {
282
+ return this.props.graphMode + "," + this.props.listMode;
283
+ };
284
+
285
+ changeMode = (e) => {
286
+ const selected = e.target.value;
287
+ const modes = selected.split(",");
288
+
289
+ this.props.onChange({
290
+ graphMode: modes[0],
291
+ listMode: modes[1],
292
+ });
293
+ };
294
+
295
+ changeHandlerFor = (toolName) => {
296
+ return (change) => {
297
+ const newTools = _.clone(this.props.tools);
298
+ newTools[toolName] = _.extend(
299
+ {},
300
+ this.props.tools[toolName],
301
+ change,
302
+ );
303
+
304
+ this.props.onChange({
305
+ tools: newTools,
306
+ });
307
+ };
308
+ };
309
+ }
310
+
311
+ const ShapeTypes: $FlowFixMe = {
312
+ getPointCountForType: function (type) {
313
+ const splitType = type.split("-");
314
+ if (splitType[0] === "polygon") {
315
+ return splitType[1] || 3;
316
+ }
317
+ if (splitType[0] === "line" || splitType[0] === "lineSegment") {
318
+ return 2;
319
+ }
320
+ if (splitType[0] === "angle") {
321
+ return 3;
322
+ }
323
+ if (splitType[0] === "circle") {
324
+ return 2;
325
+ }
326
+ if (splitType[0] === "point") {
327
+ return 1;
328
+ }
329
+ },
330
+
331
+ addMovableShape: function (graphie, options) {
332
+ if (options.editable && options.translatable) {
333
+ throw new PerseusError(
334
+ "It doesn't make sense to have a movable shape " +
335
+ "where you can stretch the points and translate them " +
336
+ "simultaneously.",
337
+ Errors.InvalidInput,
338
+ {metadata: {options}},
339
+ );
340
+ }
341
+
342
+ const points = _.map(options.shape.coords, function (coord) {
343
+ let isMoving = false;
344
+ let previousCoord = coord;
345
+
346
+ const onMove = function (x, y) {
347
+ if (!isMoving) {
348
+ previousCoord = currentPoint.coord;
349
+ isMoving = true;
350
+ }
351
+
352
+ let moveVector = kvector.subtract([x, y], currentPoint.coord);
353
+
354
+ // Translate from (x, y) semantics to (dX, dY) semantics
355
+ // This is more useful for translations on multiple points,
356
+ // where we care about how the points moved, not where any
357
+ // individual point ended up
358
+ if (options.onMove) {
359
+ moveVector = options.onMove(moveVector[0], moveVector[1]);
360
+ }
361
+
362
+ // Perform a translation on all points in this shape when
363
+ // any point moves
364
+ if (options.translatable) {
365
+ _.each(points, function (point) {
366
+ // The point itself will be updated by the
367
+ // movablePoint class, so only translate the other
368
+ // points
369
+ if (point !== currentPoint) {
370
+ point.setCoord(
371
+ kvector.add(point.coord, moveVector),
372
+ );
373
+ }
374
+ });
375
+ }
376
+
377
+ // Update our shape and our currentPoint
378
+ // Without this, some shapes (circles, angles) appear
379
+ // "bouncy" as they are updated with currentPoint at the
380
+ // current mouse coordinate (oldCoord), rather than newCoord
381
+ const oldCoord = currentPoint.coord;
382
+ const newCoord = kvector.add(currentPoint.coord, moveVector);
383
+ // Temporarily change our coordinate so that
384
+ // shape.update() sees the new coordinate
385
+ currentPoint.coord = newCoord;
386
+ shape.update();
387
+ // ...But don't break onMove, which assumes it
388
+ // is the only thing changing our coord
389
+ currentPoint.coord = oldCoord;
390
+ return newCoord;
391
+ };
392
+
393
+ const onMoveEnd = function () {
394
+ // onMove isn't guaranteed to be called before onMoveEnd, so
395
+ // we have to take into account that we may not have moved and
396
+ // set previousCoord.
397
+ if (options.onMoveEnd && isMoving) {
398
+ isMoving = false;
399
+ // We don't use the supplied x and y parameters here
400
+ // because MovablePoint's onMoveEnd semantics suck.
401
+ // It returns the mouseX, mouseY without processing them
402
+ // through onMove, leaving us with weird fractional moves
403
+ const change = kvector.subtract(
404
+ currentPoint.coord,
405
+ previousCoord,
406
+ );
407
+ options.onMoveEnd(change[0], change[1]);
408
+ }
409
+ shape.update();
410
+ };
411
+
412
+ const currentPoint = graphie.addMovablePoint({
413
+ coord: coord,
414
+ normalStyle: options.normalPointStyle,
415
+ highlightStyle: options.highlightPointStyle,
416
+ constraints: {
417
+ fixed: !options.translatable && !options.editable,
418
+ },
419
+ visible: options.showPoints,
420
+ snapX: (options.snap && options.snap[0]) || 0,
421
+ snapY: (options.snap && options.snap[1]) || 0,
422
+ bounded: false, // Don't bound it when placing it on the graph
423
+ onMove: onMove,
424
+ onMoveEnd: onMoveEnd,
425
+ });
426
+
427
+ // Bound it when moving
428
+ // We can't set this earlier, because doing so would mean any
429
+ // points outside of the graph would be moved into a moved into
430
+ // a position that doesn't preserve the shape
431
+ currentPoint.bounded = true;
432
+
433
+ return currentPoint;
434
+ });
435
+
436
+ const shape = ShapeTypes.addShape(graphie, options, points);
437
+ const removeShapeWithoutPoints = shape.remove;
438
+ shape.remove = function () {
439
+ removeShapeWithoutPoints.apply(shape);
440
+ _.invoke(points, "remove");
441
+ };
442
+ return shape;
443
+ },
444
+
445
+ addShape: function (graphie, options, points) {
446
+ points = points || options.shape.coords;
447
+
448
+ const types = ShapeTypes._typesOf(options.shape);
449
+ const typeOptions =
450
+ options.shape.options || ShapeTypes.defaultOptions(types);
451
+
452
+ const shapes = ShapeTypes._mapTypes(
453
+ types,
454
+ points,
455
+ function (type, points, i) {
456
+ const shapeOptions = _.extend({}, options, typeOptions[i]);
457
+ return ShapeTypes._addType(graphie, type, points, shapeOptions);
458
+ },
459
+ );
460
+
461
+ const updateFuncs = _.filter(_.pluck(shapes, "update"), _.identity);
462
+ const update = function () {
463
+ _.invoke(updateFuncs, "call");
464
+ };
465
+
466
+ const removeFuncs = _.filter(_.pluck(shapes, "remove"), _.identity);
467
+ const remove = function () {
468
+ _.invoke(removeFuncs, "call");
469
+ };
470
+
471
+ const getOptions = function () {
472
+ return _.map(shapes, function (shape) {
473
+ if (shape.getOptions) {
474
+ return shape.getOptions();
475
+ }
476
+ return {};
477
+ });
478
+ };
479
+
480
+ const toJSON = function () {
481
+ const coords = _.map(points, function (pt) {
482
+ if (_.isArray(pt)) {
483
+ return pt;
484
+ }
485
+ return pt.coord;
486
+ });
487
+ return {
488
+ type: types,
489
+ coords: coords,
490
+ options: getOptions(),
491
+ };
492
+ };
493
+
494
+ return {
495
+ type: types,
496
+ points: points,
497
+ update: update,
498
+ remove: remove,
499
+ toJSON: toJSON,
500
+ getOptions: getOptions,
501
+ };
502
+ },
503
+
504
+ equal: function (shape1, shape2) {
505
+ const types1 = ShapeTypes._typesOf(shape1);
506
+ const types2 = ShapeTypes._typesOf(shape2);
507
+ if (types1.length !== types2.length) {
508
+ return false;
509
+ }
510
+ const shapes1 = ShapeTypes._mapTypes(
511
+ types1,
512
+ shape1.coords,
513
+ ShapeTypes._combine,
514
+ );
515
+ const shapes2 = ShapeTypes._mapTypes(
516
+ types2,
517
+ shape2.coords,
518
+ ShapeTypes._combine,
519
+ );
520
+ return _.all(
521
+ _.map(shapes1, function (partialShape1, i) {
522
+ const partialShape2 = shapes2[i];
523
+ if (partialShape1.type !== partialShape2.type) {
524
+ return false;
525
+ }
526
+ return ShapeTypes._forType(partialShape1.type).equal(
527
+ partialShape1.coords,
528
+ partialShape2.coords,
529
+ );
530
+ }),
531
+ );
532
+ },
533
+
534
+ _typesOf: function (shape) {
535
+ let types = shape.type;
536
+ if (!_.isArray(types)) {
537
+ types = [types];
538
+ }
539
+ return _.map(types, function (type) {
540
+ if (type === "polygon") {
541
+ return "polygon-3";
542
+ }
543
+ return type;
544
+ });
545
+ },
546
+
547
+ defaultOptions: function (types) {
548
+ return _.map(types, function (type) {
549
+ const typeDefaultOptions = ShapeTypes._forType(type).defaultOptions;
550
+ return _.extend({}, typeDefaultOptions);
551
+ });
552
+ },
553
+
554
+ _forType: function (type) {
555
+ const baseType = type.split("-")[0];
556
+ return ShapeTypes[baseType];
557
+ },
558
+
559
+ _mapTypes: function (types, points, func, context) {
560
+ return _.map(types, function (type, i) {
561
+ const pointCount = ShapeTypes.getPointCountForType(type);
562
+ const currentPoints = _.first(points, pointCount);
563
+ points = _.rest(points, pointCount);
564
+ return func.call(context, type, currentPoints, i);
565
+ });
566
+ },
567
+
568
+ _addType: function (graphie, type, points, options) {
569
+ const lineCoords = _.isArray(points[0])
570
+ ? {
571
+ coordA: points[0],
572
+ coordZ: points[1],
573
+ }
574
+ : {
575
+ pointA: points[0],
576
+ pointZ: points[1],
577
+ };
578
+
579
+ type = type.split("-")[0];
580
+ if (type === "polygon") {
581
+ const polygon = graphie.addMovablePolygon(
582
+ _.extend({}, options, {
583
+ fixed: !options.editable,
584
+ snapX: (options.snap && options.snap[0]) || 0,
585
+ snapY: (options.snap && options.snap[1]) || 0,
586
+ points: points,
587
+ constrainToGraph: false,
588
+ }),
589
+ );
590
+ return {
591
+ update: polygon.transform.bind(polygon),
592
+ remove: polygon.remove.bind(polygon),
593
+ };
594
+ }
595
+ if (type === "line" || type === "lineSegment") {
596
+ const line = graphie.addMovableLineSegment(
597
+ _.extend({}, options, lineCoords, {
598
+ movePointsWithLine: true,
599
+ fixed: true,
600
+ constraints: {
601
+ fixed: true,
602
+ },
603
+ extendLine: type === "line",
604
+ }),
605
+ );
606
+
607
+ // TODO(jack): Hide points on uneditable lines when translation
608
+ // is a vector.
609
+ // We can't just remove the points yet, because they are the
610
+ // translation handle for the line.
611
+ return {
612
+ update: line.transform.bind(line, true),
613
+ remove: line.remove.bind(line),
614
+ };
615
+ }
616
+ if (type === "angle") {
617
+ // If this angle is editable, we want to be able to make angles
618
+ // both larger and smaller than 180 degrees.
619
+ // If this angle is not editable, it should always maintain
620
+ // it's angle measure, even if it is reflected (causing the
621
+ // clockwise-ness of the points to change)
622
+ const shouldChangeReflexivity = options.editable ? null : false;
623
+
624
+ const angle = graphie.addMovableAngle({
625
+ angleLabel: "$deg0",
626
+ fixed: true,
627
+ points: points,
628
+ normalStyle: options.normalStyle,
629
+ reflex: options.reflex,
630
+ });
631
+
632
+ // Hide non-vertex points on uneditable angles
633
+ if (!_.isArray(points[0]) && !options.editable) {
634
+ points[0].remove();
635
+ points[2].remove();
636
+ }
637
+ return {
638
+ update: angle.update.bind(angle, shouldChangeReflexivity),
639
+ remove: angle.remove.bind(angle),
640
+ getOptions: function () {
641
+ return {
642
+ reflex: angle.isReflex(),
643
+ };
644
+ },
645
+ };
646
+ }
647
+ if (type === "circle") {
648
+ let perimeter = {
649
+ // temporary object for the first removal
650
+ remove: _.identity,
651
+ };
652
+ const redrawPerim = function () {
653
+ const coord0 = points[0].coord || points[0];
654
+ const coord1 = points[1].coord || points[1];
655
+ const radius = kpoint.distanceToPoint(coord0, coord1);
656
+ perimeter.remove();
657
+ perimeter = graphie.circle(
658
+ coord0,
659
+ radius,
660
+ _.extend(
661
+ {
662
+ stroke: KhanColors.DYNAMIC,
663
+ "stroke-width": 2,
664
+ },
665
+ options.normalStyle,
666
+ ),
667
+ );
668
+ };
669
+
670
+ redrawPerim();
671
+ if (points[1].remove && !options.editable) {
672
+ points[1].remove();
673
+ }
674
+
675
+ return {
676
+ update: redrawPerim,
677
+ remove: function () {
678
+ // Not _.bind because the remove function changes
679
+ // when the perimeter is redrawn
680
+ perimeter.remove();
681
+ },
682
+ };
683
+ }
684
+ if (type === "point") {
685
+ // do nothing
686
+ return {
687
+ update: null,
688
+ remove: null,
689
+ };
690
+ }
691
+ throw new PerseusError(
692
+ "Invalid shape type " + type,
693
+ Errors.InvalidInput,
694
+ );
695
+ },
696
+
697
+ _combine: function (type, coords) {
698
+ return {
699
+ type: type,
700
+ coords: coords,
701
+ };
702
+ },
703
+
704
+ polygon: {
705
+ equal: orderInsensitiveCoordsEqual,
706
+ },
707
+
708
+ line: {
709
+ equal: kline.equal,
710
+ },
711
+
712
+ lineSegment: {
713
+ equal: orderInsensitiveCoordsEqual,
714
+ },
715
+
716
+ angle: {
717
+ equal: function (points1, points2) {
718
+ if (!kpoint.equal(points1[1], points2[1])) {
719
+ return false;
720
+ }
721
+
722
+ const line1_0 = [points1[1], points1[0]];
723
+ const line1_2 = [points1[1], points1[2]];
724
+ const line2_0 = [points2[1], points2[0]];
725
+ const line2_2 = [points2[1], points2[2]];
726
+
727
+ const equalUnflipped =
728
+ kray.equal(line1_0, line2_0) && kray.equal(line1_2, line2_2);
729
+ const equalFlipped =
730
+ kray.equal(line1_0, line2_2) && kray.equal(line1_2, line2_0);
731
+
732
+ return equalUnflipped || equalFlipped;
733
+ },
734
+
735
+ defaultOptions: {
736
+ reflex: false,
737
+ },
738
+ },
739
+
740
+ circle: {
741
+ equal: function (points1, points2) {
742
+ const radius1 = kpoint.distanceToPoint(points1[0], points1[1]);
743
+ const radius2 = kpoint.distanceToPoint(points2[0], points2[1]);
744
+ return (
745
+ kpoint.equal(points1[0], points2[0]) &&
746
+ knumber.equal(radius1, radius2)
747
+ );
748
+ },
749
+ },
750
+
751
+ point: {
752
+ equal: kpoint.equal,
753
+ },
754
+ };
755
+
756
+ class TransformationsShapeEditor extends React.Component<$FlowFixMe> {
757
+ render(): React.Node {
758
+ return (
759
+ <div>
760
+ <Graph
761
+ // eslint-disable-next-line react/no-string-refs
762
+ ref="graph"
763
+ box={this.props.graph.box}
764
+ range={this.props.graph.range}
765
+ labels={this.props.graph.labels}
766
+ step={this.props.graph.step}
767
+ gridStep={this.props.graph.gridStep}
768
+ markings={this.props.graph.markings}
769
+ backgroundImage={this.props.graph.backgroundImage}
770
+ onGraphieUpdated={this.setupGraphie}
771
+ />
772
+ <select
773
+ key="type-select"
774
+ value={this.getTypeString(this.props.shape.type)}
775
+ onChange={this.changeType}
776
+ >
777
+ <option value="polygon-3">Triangle</option>
778
+ <option value="polygon-4">Quadrilateral</option>
779
+ <option value="polygon-5">Pentagon</option>
780
+ <option value="polygon-6">Hexagon</option>
781
+ <option value="line">Line</option>
782
+ <option value="line,line">2 lines</option>
783
+ <option value="lineSegment">Line segment</option>
784
+ <option value="lineSegment,lineSegment">
785
+ {" "}
786
+ 2 line segments{" "}
787
+ </option>
788
+ <option value="angle">Angle</option>
789
+ <option value="circle">Circle</option>
790
+ </select>
791
+ </div>
792
+ );
793
+ }
794
+
795
+ /* Return the option string for a given type */
796
+ getTypeString = (type) => {
797
+ if (_.isArray(type)) {
798
+ return _.map(type, this.getTypeString).join(",");
799
+ }
800
+ if (type === "polygon") {
801
+ return "polygon-" + this.props.shape.coords.length;
802
+ }
803
+ return type;
804
+ };
805
+
806
+ /* Change the type on the window event e
807
+ *
808
+ * e.target.value is the new type string
809
+ */
810
+ changeType = (e) => {
811
+ const types = String(e.target.value).split(",");
812
+ const pointCount = arraySum(
813
+ _.map(types, ShapeTypes.getPointCountForType),
814
+ );
815
+
816
+ // eslint-disable-next-line react/no-string-refs
817
+ const radius = scaleToRange(4, this.refs.graph.props.range);
818
+ const offset = (1 / 2 - 1 / pointCount) * 180;
819
+ const coords = _.times(pointCount, function (i) {
820
+ return kpoint.rotateDeg(
821
+ [radius, 0],
822
+ (360 * i) / pointCount + offset,
823
+ );
824
+ });
825
+
826
+ this.props.onChange({
827
+ shape: {
828
+ type: types,
829
+ coords: coords,
830
+ options: ShapeTypes.defaultOptions(types),
831
+ },
832
+ });
833
+ };
834
+
835
+ componentDidMount() {
836
+ // eslint-disable-next-line react/no-string-refs
837
+ this.setupGraphie(this.refs.graph.graphie());
838
+ }
839
+
840
+ componentDidUpdate(prevProps) {
841
+ if (!deepEq(prevProps.shape, this.props.shape)) {
842
+ // eslint-disable-next-line react/no-string-refs
843
+ this.refs.graph.reset();
844
+ }
845
+ }
846
+
847
+ updateCoords = () => {
848
+ this.props.onChange({
849
+ // $FlowFixMe[prop-missing]
850
+ shape: this.shape.toJSON(),
851
+ });
852
+ };
853
+
854
+ setupGraphie = (graphie) => {
855
+ // $FlowFixMe[prop-missing]
856
+ this.shape = ShapeTypes.addMovableShape(graphie, {
857
+ editable: true,
858
+ snap: graphie.snap,
859
+ shape: this.props.shape,
860
+ onMoveEnd: this.updateCoords,
861
+ });
862
+ };
863
+ }
864
+
865
+ class TransformerEditor extends React.Component<$FlowFixMe> {
866
+ // TODO (jack): These should be refactored into a nice object at the top
867
+ // so that we don't have all this duplication
868
+ static defaultProps: $FlowFixMe = defaultTransformerProps;
869
+
870
+ static widgetName: string = "transformer";
871
+
872
+ render(): React.Node {
873
+ // Fill in any missing value in this.props.graph
874
+ // this can happen because the graph json doesn't include
875
+ // box, for example
876
+ const graph = _.extend(
877
+ defaultGraphProps(this.props.graph, 340),
878
+ this.props.graph,
879
+ );
880
+
881
+ return (
882
+ <div>
883
+ <div>
884
+ <PropCheckBox
885
+ label="Grade empty answers as wrong:"
886
+ gradeEmpty={this.props.gradeEmpty}
887
+ onChange={this.props.onChange}
888
+ />
889
+ <InfoTip>
890
+ <p>
891
+ We generally do not grade empty answers. This
892
+ usually works well, but sometimes can result in
893
+ giving away part of an answer in a multi-part
894
+ question.
895
+ </p>
896
+ <p>
897
+ If this is a multi-part question (there is another
898
+ widget), you probably want to enable this option.
899
+ Otherwise, you should leave it disabled.
900
+ </p>
901
+ <p>Confused? Talk to Elizabeth.</p>
902
+ </InfoTip>
903
+ </div>
904
+ <div>Graph settings:</div>
905
+ <GraphSettings
906
+ box={graph.box}
907
+ labels={graph.labels}
908
+ range={graph.range}
909
+ step={graph.step}
910
+ gridStep={graph.gridStep}
911
+ valid={graph.valid}
912
+ backgroundImage={graph.backgroundImage}
913
+ markings={graph.markings}
914
+ showProtractor={graph.showProtractor}
915
+ onChange={this.changeGraph}
916
+ />
917
+ <div>Transformation settings:</div>
918
+ <TransformationExplorerSettings
919
+ // eslint-disable-next-line react/no-string-refs
920
+ ref="transformationSettings"
921
+ graphMode={this.props.graphMode}
922
+ listMode={this.props.listMode}
923
+ tools={this.props.tools}
924
+ drawSolutionShape={this.props.drawSolutionShape}
925
+ onChange={this.props.onChange}
926
+ />
927
+ <div>Starting location:</div>
928
+ <TransformationsShapeEditor
929
+ // eslint-disable-next-line react/no-string-refs
930
+ ref="shapeEditor"
931
+ graph={graph}
932
+ shape={this.props.starting.shape}
933
+ onChange={this.changeStarting}
934
+ setDrawingAreaAvailable={
935
+ this.props.apiOptions.setDrawingAreaAvailable
936
+ }
937
+ />
938
+ <div>Solution transformations:</div>
939
+ <Transformer
940
+ // eslint-disable-next-line react/no-string-refs
941
+ ref="explorer"
942
+ graph={graph}
943
+ graphMode={this.props.graphMode}
944
+ listMode={this.props.listMode}
945
+ gradeEmpty={this.props.gradeEmpty}
946
+ tools={this.props.tools}
947
+ drawSolutionShape={this.props.drawSolutionShape}
948
+ starting={this.props.starting}
949
+ correct={this.props.starting}
950
+ transformations={this.props.correct.transformations}
951
+ onChange={this.changeTransformer}
952
+ trackInteraction={() => {}}
953
+ />
954
+ </div>
955
+ );
956
+ }
957
+
958
+ // propagate a props change on our graph settings to
959
+ // this.props.graph
960
+ changeGraph: ($FlowFixMe, $FlowFixMe) => void = (
961
+ graphChanges,
962
+ callback,
963
+ ) => {
964
+ const newGraph = _.extend({}, this.props.graph, graphChanges);
965
+ this.props.onChange(
966
+ {
967
+ graph: newGraph,
968
+ },
969
+ callback,
970
+ );
971
+ };
972
+
973
+ // propagate a props change on our starting graph to
974
+ // this.props.starting
975
+ changeStarting: ($FlowFixMe) => void = (startingChanges) => {
976
+ const newStarting = _.extend({}, this.props.starting, startingChanges);
977
+ this.props.onChange({
978
+ starting: newStarting,
979
+ });
980
+ };
981
+
982
+ // propagate a transformations change onto correct.transformations
983
+ changeTransformer: ($FlowFixMe, $FlowFixMe) => void = (
984
+ changes,
985
+ callback,
986
+ ) => {
987
+ if (changes.transformations) {
988
+ changes.correct = {
989
+ ...this.props.correct,
990
+ transformations: changes.transformations,
991
+ };
992
+ delete changes.transformations;
993
+ }
994
+ this.props.onChange(changes, callback);
995
+ };
996
+
997
+ serialize: () => $FlowFixMe = () => {
998
+ // eslint-disable-next-line react/no-string-refs
999
+ const json = this.refs.explorer.getEditorJSON();
1000
+ json.correct = json.answer;
1001
+ delete json.answer;
1002
+ return json;
1003
+ };
1004
+ }
1005
+
1006
+ export default TransformerEditor;