@khanacademy/perseus 0.0.1 → 0.1.0

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 (503) hide show
  1. package/.babelrc.js +1 -1
  2. package/CHANGELOG.md +7 -0
  3. package/dist/es/index.js +15 -1
  4. package/dist/es/index.js.map +1 -1
  5. package/dist/index.css +11934 -0
  6. package/dist/index.js +15 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/strings.js +148 -1
  9. package/package.json +40 -4
  10. package/src/README.md +7 -0
  11. package/src/__stories__/item-renderer.stories.jsx +27 -0
  12. package/src/__stories__/server-item-renderer.stories.jsx +27 -0
  13. package/src/__testdata__/item-renderer_testdata.js +161 -0
  14. package/src/__testdata__/renderer_testdata.js +123 -0
  15. package/src/__tests__/__snapshots__/item-renderer_test.jsx.snap +233 -0
  16. package/src/__tests__/__snapshots__/perseus-markdown_test.jsx.snap +516 -0
  17. package/src/__tests__/__snapshots__/pure-markdown_test.js.snap +113 -0
  18. package/src/__tests__/__snapshots__/renderer_test.jsx.snap +576 -0
  19. package/src/__tests__/__snapshots__/server-item-renderer_test.jsx.snap +306 -0
  20. package/src/__tests__/a11y_test.jsx +304 -0
  21. package/src/__tests__/item-renderer_test.jsx +892 -0
  22. package/src/__tests__/mock-asset-loading-widget.jsx +57 -0
  23. package/src/__tests__/mock-widget.jsx +35 -0
  24. package/src/__tests__/perseus-markdown_test.jsx +747 -0
  25. package/src/__tests__/pure-markdown_test.js +552 -0
  26. package/src/__tests__/renderability_test.jsx +481 -0
  27. package/src/__tests__/renderer-api_test.jsx +333 -0
  28. package/src/__tests__/renderer_test.jsx +1792 -0
  29. package/src/__tests__/server-item-renderer_test.jsx +516 -0
  30. package/src/__tests__/test-items/image-item.js +25 -0
  31. package/src/__tests__/test-items/input-number-1-item.js +27 -0
  32. package/src/__tests__/test-items/input-number-2-item.js +39 -0
  33. package/src/__tests__/test-items/table-item.js +30 -0
  34. package/src/__tests__/tex-wrangler_test.js +83 -0
  35. package/src/__tests__/util_test.js +41 -0
  36. package/src/a11y.js +46 -0
  37. package/src/all-widgets.js +9 -0
  38. package/src/article-renderer.jsx +215 -0
  39. package/src/asset-context.js +30 -0
  40. package/src/basic-widgets.js +13 -0
  41. package/src/components/__stories__/button-group.stories.jsx +61 -0
  42. package/src/components/__stories__/fixed-to-responsive.stories.jsx +132 -0
  43. package/src/components/__stories__/graph.stories.jsx +27 -0
  44. package/src/components/__stories__/graphie.stories.jsx +28 -0
  45. package/src/components/__stories__/hud.stories.jsx +36 -0
  46. package/src/components/__stories__/icon.stories.jsx +56 -0
  47. package/src/components/__stories__/image-loader.stories.jsx +60 -0
  48. package/src/components/__stories__/info-tip.stories.jsx +33 -0
  49. package/src/components/__stories__/inline-icon.stories.jsx +39 -0
  50. package/src/components/__stories__/input-with-examples.stories.jsx +51 -0
  51. package/src/components/__stories__/lint.stories.jsx +40 -0
  52. package/src/components/__stories__/math-input.stories.jsx +29 -0
  53. package/src/components/__stories__/math-output.stories.jsx +24 -0
  54. package/src/components/__stories__/multi-button-group.stories.jsx +69 -0
  55. package/src/components/__stories__/number-input.stories.jsx +42 -0
  56. package/src/components/__stories__/possible-answers.stories.jsx +22 -0
  57. package/src/components/__stories__/prop-check-box.stories.jsx +62 -0
  58. package/src/components/__stories__/range-input.stories.jsx +28 -0
  59. package/src/components/__stories__/simple-keypad-input.stories.jsx +28 -0
  60. package/src/components/__stories__/sortable.stories.jsx +73 -0
  61. package/src/components/__stories__/stub-tag-editor.stories.jsx +44 -0
  62. package/src/components/__stories__/svg-image.stories.jsx +60 -0
  63. package/src/components/__stories__/tex.stories.jsx +18 -0
  64. package/src/components/__stories__/text-buttons.stories.jsx +51 -0
  65. package/src/components/__stories__/text-input.stories.jsx +34 -0
  66. package/src/components/__stories__/text-list-editor.stories.jsx +33 -0
  67. package/src/components/__stories__/zoomable-tex.stories.jsx +39 -0
  68. package/src/components/__stories__/zoomable.stories.jsx +40 -0
  69. package/src/components/__testdata__/sorter_testdata.js +20 -0
  70. package/src/components/__tests__/__snapshots__/sortable_test.jsx.snap +79 -0
  71. package/src/components/__tests__/__snapshots__/sorter_test.jsx.snap +297 -0
  72. package/src/components/__tests__/number-input_test.jsx +112 -0
  73. package/src/components/__tests__/sortable_test.jsx +234 -0
  74. package/src/components/__tests__/sorter_test.jsx +111 -0
  75. package/src/components/__tests__/zoomable_test.jsx +388 -0
  76. package/src/components/button-group.jsx +131 -0
  77. package/src/components/constants.js +15 -0
  78. package/src/components/fixed-to-responsive.jsx +163 -0
  79. package/src/components/graph.jsx +414 -0
  80. package/src/components/graphie-classes.jsx +148 -0
  81. package/src/components/graphie-movables.jsx +342 -0
  82. package/src/components/graphie.jsx +353 -0
  83. package/src/components/highlighting/highlightable-content.jsx +245 -0
  84. package/src/components/highlighting/highlights.js +208 -0
  85. package/src/components/highlighting/ranges.js +299 -0
  86. package/src/components/highlighting/types.js +73 -0
  87. package/src/components/highlighting/ui/highlight-renderer.jsx +179 -0
  88. package/src/components/highlighting/ui/highlight-set-renderer.jsx +190 -0
  89. package/src/components/highlighting/ui/highlight-tooltip.jsx +153 -0
  90. package/src/components/highlighting/ui/highlighting-ui.jsx +163 -0
  91. package/src/components/highlighting/ui/selection-tracker.jsx +195 -0
  92. package/src/components/highlighting/ui/types.js +21 -0
  93. package/src/components/highlighting/ui/util.js +166 -0
  94. package/src/components/highlighting/word-indexer.jsx +155 -0
  95. package/src/components/hud.jsx +179 -0
  96. package/src/components/icon.jsx +149 -0
  97. package/src/components/image-loader.jsx +175 -0
  98. package/src/components/info-tip/info-tip.jsx +149 -0
  99. package/src/components/info-tip.jsx +38 -0
  100. package/src/components/inline-icon.jsx +81 -0
  101. package/src/components/input-with-examples.jsx +231 -0
  102. package/src/components/lint.jsx +456 -0
  103. package/src/components/math-input.jsx +282 -0
  104. package/src/components/math-output.jsx +128 -0
  105. package/src/components/multi-button-group.jsx +137 -0
  106. package/src/components/new-tooltip/README.md +110 -0
  107. package/src/components/new-tooltip/__stories__/flexible-position.stories.jsx +437 -0
  108. package/src/components/new-tooltip/__stories__/new-tooltip.stories.jsx +665 -0
  109. package/src/components/new-tooltip/__stories__/tooltip-arrow.stories.jsx +70 -0
  110. package/src/components/new-tooltip/__stories__/tooltip-bubble.stories.jsx +98 -0
  111. package/src/components/new-tooltip/__tests__/style-constants_test.js +8 -0
  112. package/src/components/new-tooltip/__tests__/util_test.js +47 -0
  113. package/src/components/new-tooltip/flexible-position.jsx +182 -0
  114. package/src/components/new-tooltip/new-tooltip.jsx +399 -0
  115. package/src/components/new-tooltip/style-constants.js +13 -0
  116. package/src/components/new-tooltip/tooltip-arrow.jsx +184 -0
  117. package/src/components/new-tooltip/tooltip-bubble.jsx +223 -0
  118. package/src/components/new-tooltip/tooltip-portal.jsx +101 -0
  119. package/src/components/new-tooltip/tooltip-positioner.jsx +529 -0
  120. package/src/components/new-tooltip/types.js +183 -0
  121. package/src/components/new-tooltip/util.js +27 -0
  122. package/src/components/number-input.jsx +253 -0
  123. package/src/components/possible-answers.jsx +36 -0
  124. package/src/components/prop-check-box.jsx +78 -0
  125. package/src/components/range-input.jsx +61 -0
  126. package/src/components/simple-keypad-input.jsx +91 -0
  127. package/src/components/sortable.jsx +872 -0
  128. package/src/components/stub-tag-editor.jsx +53 -0
  129. package/src/components/svg-image.jsx +762 -0
  130. package/src/components/tex-buttons.jsx +322 -0
  131. package/src/components/tex.jsx +70 -0
  132. package/src/components/text-input.jsx +82 -0
  133. package/src/components/text-list-editor.jsx +157 -0
  134. package/src/components/tooltip.jsx +395 -0
  135. package/src/components/visibility-observer/__stories__/visibility-observer.stories.jsx +140 -0
  136. package/src/components/visibility-observer/get-scroll-parent.js +45 -0
  137. package/src/components/visibility-observer/visibility-observer.js +109 -0
  138. package/src/components/visibility-observer/visibility-observer.jsx +123 -0
  139. package/src/components/zoomable-tex.jsx +70 -0
  140. package/src/components/zoomable.jsx +304 -0
  141. package/src/components.js +17 -0
  142. package/src/dependencies.js +22 -0
  143. package/src/error-boundary.jsx +48 -0
  144. package/src/extra-widgets.js +75 -0
  145. package/src/gorgon/README +40 -0
  146. package/src/gorgon/__tests__/matcher_test.js +497 -0
  147. package/src/gorgon/__tests__/rule_test.js +101 -0
  148. package/src/gorgon/__tests__/rules_test.js +487 -0
  149. package/src/gorgon/__tests__/selector-parser_test.js +52 -0
  150. package/src/gorgon/__tests__/tree-transformer_test.js +446 -0
  151. package/src/gorgon/gorgon.js +290 -0
  152. package/src/gorgon/proptypes.js +29 -0
  153. package/src/gorgon/rule.js +413 -0
  154. package/src/gorgon/rules/absolute-url.js +24 -0
  155. package/src/gorgon/rules/all-rules.js +72 -0
  156. package/src/gorgon/rules/blockquoted-math.js +10 -0
  157. package/src/gorgon/rules/blockquoted-widget.js +10 -0
  158. package/src/gorgon/rules/double-spacing-after-terminal.js +12 -0
  159. package/src/gorgon/rules/extra-content-spacing.js +12 -0
  160. package/src/gorgon/rules/heading-level-1.js +14 -0
  161. package/src/gorgon/rules/heading-level-skip.js +20 -0
  162. package/src/gorgon/rules/heading-sentence-case.js +11 -0
  163. package/src/gorgon/rules/heading-title-case.js +63 -0
  164. package/src/gorgon/rules/image-alt-text.js +21 -0
  165. package/src/gorgon/rules/image-in-table.js +10 -0
  166. package/src/gorgon/rules/image-spaces-around-urls.js +35 -0
  167. package/src/gorgon/rules/image-widget.js +50 -0
  168. package/src/gorgon/rules/link-click-here.js +11 -0
  169. package/src/gorgon/rules/lint-utils.js +48 -0
  170. package/src/gorgon/rules/long-paragraph.js +14 -0
  171. package/src/gorgon/rules/math-adjacent.js +10 -0
  172. package/src/gorgon/rules/math-align-extra-break.js +11 -0
  173. package/src/gorgon/rules/math-align-linebreaks.js +43 -0
  174. package/src/gorgon/rules/math-empty.js +10 -0
  175. package/src/gorgon/rules/math-font-size.js +12 -0
  176. package/src/gorgon/rules/math-frac.js +10 -0
  177. package/src/gorgon/rules/math-nested.js +11 -0
  178. package/src/gorgon/rules/math-starts-with-space.js +12 -0
  179. package/src/gorgon/rules/math-text-empty.js +10 -0
  180. package/src/gorgon/rules/math-without-dollars.js +14 -0
  181. package/src/gorgon/rules/nested-lists.js +11 -0
  182. package/src/gorgon/rules/profanity.js +10 -0
  183. package/src/gorgon/rules/table-missing-cells.js +20 -0
  184. package/src/gorgon/rules/unbalanced-code-delimiters.js +14 -0
  185. package/src/gorgon/rules/unescaped-dollar.js +10 -0
  186. package/src/gorgon/rules/widget-in-table.js +10 -0
  187. package/src/gorgon/selector.js +506 -0
  188. package/src/gorgon/tree-transformer.js +588 -0
  189. package/src/hint-renderer.jsx +159 -0
  190. package/src/hints-renderer.jsx +359 -0
  191. package/src/icon-paths.js +120 -0
  192. package/src/index.js +149 -2
  193. package/src/init.js +49 -0
  194. package/src/interaction-tracker.js +60 -0
  195. package/src/interactive2/__tests__/movable-mock.js +48 -0
  196. package/src/interactive2/__tests__/movable-point_test.js +146 -0
  197. package/src/interactive2/interactive-util.js +152 -0
  198. package/src/interactive2/movable-helper-methods.js +160 -0
  199. package/src/interactive2/movable-line-options.js +392 -0
  200. package/src/interactive2/movable-line.js +322 -0
  201. package/src/interactive2/movable-point-options.js +190 -0
  202. package/src/interactive2/movable-point.jsx +604 -0
  203. package/src/interactive2/movable-polygon-options.js +351 -0
  204. package/src/interactive2/movable-polygon.js +367 -0
  205. package/src/interactive2/movable.js +273 -0
  206. package/src/interactive2/objective_.js +79 -0
  207. package/src/interactive2/types.js +3 -0
  208. package/src/interactive2/wrapped-defaults.js +86 -0
  209. package/src/interactive2/wrapped-ellipse.js +87 -0
  210. package/src/interactive2/wrapped-line.js +109 -0
  211. package/src/interactive2/wrapped-path.js +47 -0
  212. package/src/interactive2.js +91 -0
  213. package/src/item-renderer.jsx +518 -0
  214. package/src/item-version.js +12 -0
  215. package/src/jipt-paragraphs.js +44 -0
  216. package/src/jquery.mobile.vmouse.js +545 -0
  217. package/src/keypad-context.js +33 -0
  218. package/src/loading-context.js +20 -0
  219. package/src/logging/log.js +107 -0
  220. package/src/logging/log_test.js +41 -0
  221. package/src/mixins/changeable.jsx +115 -0
  222. package/src/mixins/editor-jsonify.jsx +13 -0
  223. package/src/mixins/provide-keypad.jsx +131 -0
  224. package/src/mixins/widget-jsonify-deprecated.jsx +23 -0
  225. package/src/mixins/widget-prop-blacklist.js +36 -0
  226. package/src/multi-items/__stories__/multi-renderer.stories.js +88 -0
  227. package/src/multi-items/__testdata__/multi-renderer_testdata.js +174 -0
  228. package/src/multi-items/__tests__/__snapshots__/multi-renderer_test.js.snap +994 -0
  229. package/src/multi-items/__tests__/items_test.js +245 -0
  230. package/src/multi-items/__tests__/multi-renderer_test.js +903 -0
  231. package/src/multi-items/__tests__/prop-type-builders_test.js +134 -0
  232. package/src/multi-items/__tests__/shapes_test.js +44 -0
  233. package/src/multi-items/__tests__/trees_test.js +316 -0
  234. package/src/multi-items/item-types.js +55 -0
  235. package/src/multi-items/items.js +176 -0
  236. package/src/multi-items/multi-renderer.jsx +542 -0
  237. package/src/multi-items/prop-type-builders.js +105 -0
  238. package/src/multi-items/shape-types.js +47 -0
  239. package/src/multi-items/shapes.js +53 -0
  240. package/src/multi-items/tree-types.js +42 -0
  241. package/src/multi-items/trees.js +345 -0
  242. package/src/multi-items.js +76 -0
  243. package/src/not-gorgon.js +129 -0
  244. package/src/perseus-api.jsx +270 -0
  245. package/src/perseus-env.js +26 -0
  246. package/src/perseus-error.js +19 -0
  247. package/src/perseus-markdown.jsx +434 -0
  248. package/src/perseus-types.js +1597 -0
  249. package/src/pure-markdown.js +346 -0
  250. package/src/question-paragraph.jsx +30 -0
  251. package/src/renderability.jsx +108 -0
  252. package/src/renderer.jsx +1931 -0
  253. package/src/server-item-renderer.jsx +467 -0
  254. package/src/sigfigs.jsx +492 -0
  255. package/src/styles/articles.less +748 -0
  256. package/src/styles/constants.js +119 -0
  257. package/src/styles/global-constants.js +350 -0
  258. package/src/styles/global-styles.js +307 -0
  259. package/src/styles/khan-exercise.css +1304 -0
  260. package/src/styles/media-queries.js +48 -0
  261. package/src/styles/perseus-renderer.less +1008 -0
  262. package/src/styles/shared.js +132 -0
  263. package/src/styles/util.less +47 -0
  264. package/src/styles/variables.boostrap.less +301 -0
  265. package/src/styles/variables.less +68 -0
  266. package/src/styles/variables.shared.less +356 -0
  267. package/src/styles/widgets/categorizer.less +39 -0
  268. package/src/styles/widgets/definition.less +86 -0
  269. package/src/styles/widgets/dropdown.less +3 -0
  270. package/src/styles/widgets/expression.less +147 -0
  271. package/src/styles/widgets/graded-group.less +36 -0
  272. package/src/styles/widgets/grapher.less +11 -0
  273. package/src/styles/widgets/group.less +19 -0
  274. package/src/styles/widgets/image.less +29 -0
  275. package/src/styles/widgets/interactive-graph.less +61 -0
  276. package/src/styles/widgets/label-image.less +41 -0
  277. package/src/styles/widgets/matcher.less +8 -0
  278. package/src/styles/widgets/matrix.less +120 -0
  279. package/src/styles/widgets/measurer.less +20 -0
  280. package/src/styles/widgets/orderer.less +97 -0
  281. package/src/styles/widgets/passage.less +151 -0
  282. package/src/styles/widgets/plotter.less +33 -0
  283. package/src/styles/widgets/radio.less +52 -0
  284. package/src/styles/widgets/reaction-diagram.less +35 -0
  285. package/src/styles/widgets/sequence.less +5 -0
  286. package/src/styles/widgets/sortable.less +127 -0
  287. package/src/styles/widgets/table.less +56 -0
  288. package/src/styles/widgets/transformer.less +28 -0
  289. package/src/styles/widgets/unit.less +14 -0
  290. package/src/tex-wrangler.js +176 -0
  291. package/src/traversal.jsx +141 -0
  292. package/src/types.js +538 -0
  293. package/src/util/a11y.js +24 -0
  294. package/src/util/answer-types.js +862 -0
  295. package/src/util/answer-types_test.js +115 -0
  296. package/src/util/colors.js +94 -0
  297. package/src/util/fix-passage-refs.jsx +122 -0
  298. package/src/util/graph-utils.js +16 -0
  299. package/src/util/graphie.js +1585 -0
  300. package/src/util/interactive.js +4071 -0
  301. package/src/util/katex-preprocess.js +16 -0
  302. package/src/util/math.js +296 -0
  303. package/src/util/mobile-native-utils.js +14 -0
  304. package/src/util/mobile-native-utils_test.js +24 -0
  305. package/src/util/register-all-widgets-for-testing.js +12 -0
  306. package/src/util/scroll-utils.js +162 -0
  307. package/src/util/sizing-utils.js +46 -0
  308. package/src/util/tex.js +203 -0
  309. package/src/util/url-utils.js +36 -0
  310. package/src/util.js +990 -0
  311. package/src/version.js +11 -0
  312. package/src/widget-container.jsx +174 -0
  313. package/src/widgets/__stories__/categorizer.stories.jsx +15 -0
  314. package/src/widgets/__stories__/cs-program.stories.jsx +15 -0
  315. package/src/widgets/__stories__/definition.stories.jsx +38 -0
  316. package/src/widgets/__stories__/dropdown.stories.jsx +15 -0
  317. package/src/widgets/__stories__/explanation.stories.jsx +19 -0
  318. package/src/widgets/__stories__/expression.stories.jsx +26 -0
  319. package/src/widgets/__stories__/graded-group-set.stories.jsx +32 -0
  320. package/src/widgets/__stories__/graded-group.stories.jsx +32 -0
  321. package/src/widgets/__stories__/grapher.stories.jsx +47 -0
  322. package/src/widgets/__stories__/group.stories.jsx +15 -0
  323. package/src/widgets/__stories__/iframe.stories.jsx +15 -0
  324. package/src/widgets/__stories__/image.stories.jsx +62 -0
  325. package/src/widgets/__stories__/input-number.stories.jsx +90 -0
  326. package/src/widgets/__stories__/interaction.stories.jsx +15 -0
  327. package/src/widgets/__stories__/interactive-graph.stories.jsx +61 -0
  328. package/src/widgets/__stories__/matcher.stories.jsx +15 -0
  329. package/src/widgets/__stories__/matrix.stories.jsx +15 -0
  330. package/src/widgets/__stories__/number-line.stories.jsx +15 -0
  331. package/src/widgets/__stories__/numeric-input.stories.jsx +19 -0
  332. package/src/widgets/__stories__/orderer.stories.jsx +15 -0
  333. package/src/widgets/__stories__/passage-ref.stories.jsx +19 -0
  334. package/src/widgets/__stories__/passage.stories.jsx +19 -0
  335. package/src/widgets/__stories__/radio.stories.jsx +82 -0
  336. package/src/widgets/__stories__/sorter.stories.jsx +15 -0
  337. package/src/widgets/__stories__/transformer.stories.jsx +15 -0
  338. package/src/widgets/__stories__/video.stories.jsx +15 -0
  339. package/src/widgets/__testdata__/categorizer_testdata.js +50 -0
  340. package/src/widgets/__testdata__/cs-program_testdata.js +28 -0
  341. package/src/widgets/__testdata__/dropdown_testdata.js +34 -0
  342. package/src/widgets/__testdata__/explanation_testdata.js +56 -0
  343. package/src/widgets/__testdata__/expression_testdata.js +95 -0
  344. package/src/widgets/__testdata__/graded-group-set_testdata.js +358 -0
  345. package/src/widgets/__testdata__/graded-group_testdata.js +79 -0
  346. package/src/widgets/__testdata__/grapher_testdata.js +376 -0
  347. package/src/widgets/__testdata__/group_testdata.js +156 -0
  348. package/src/widgets/__testdata__/iframe_testdata.js +36 -0
  349. package/src/widgets/__testdata__/image_testdata.js +38 -0
  350. package/src/widgets/__testdata__/input-number_testdata.js +73 -0
  351. package/src/widgets/__testdata__/interaction_testdata.js +264 -0
  352. package/src/widgets/__testdata__/interactive-graph_testdata.js +646 -0
  353. package/src/widgets/__testdata__/matcher_testdata.js +34 -0
  354. package/src/widgets/__testdata__/matrix_testdata.js +29 -0
  355. package/src/widgets/__testdata__/number-line_testdata.js +31 -0
  356. package/src/widgets/__testdata__/numeric-input_testdata.js +266 -0
  357. package/src/widgets/__testdata__/orderer_testdata.js +58 -0
  358. package/src/widgets/__testdata__/passage-ref_testdata.js +41 -0
  359. package/src/widgets/__testdata__/passage_testdata.js +71 -0
  360. package/src/widgets/__testdata__/radio_testdata.js +210 -0
  361. package/src/widgets/__testdata__/sorter_testdata.js +20 -0
  362. package/src/widgets/__testdata__/transformer_testdata.js +109 -0
  363. package/src/widgets/__testdata__/video_testdata.js +22 -0
  364. package/src/widgets/__tests__/__snapshots__/categorizer_test.jsx.snap +928 -0
  365. package/src/widgets/__tests__/__snapshots__/cs-program_test.jsx.snap +75 -0
  366. package/src/widgets/__tests__/__snapshots__/definition_test.js.snap +99 -0
  367. package/src/widgets/__tests__/__snapshots__/dropdown_test.jsx.snap +143 -0
  368. package/src/widgets/__tests__/__snapshots__/explanation_test.js.snap +473 -0
  369. package/src/widgets/__tests__/__snapshots__/expression_test.jsx.snap +38 -0
  370. package/src/widgets/__tests__/__snapshots__/graded-group-set-jipt_test.jsx.snap +754 -0
  371. package/src/widgets/__tests__/__snapshots__/graded-group-set_test.jsx.snap +310 -0
  372. package/src/widgets/__tests__/__snapshots__/graded-group_test.jsx.snap +863 -0
  373. package/src/widgets/__tests__/__snapshots__/grapher_test.jsx.snap +2880 -0
  374. package/src/widgets/__tests__/__snapshots__/group_test.jsx.snap +1154 -0
  375. package/src/widgets/__tests__/__snapshots__/iframe_test.jsx.snap +69 -0
  376. package/src/widgets/__tests__/__snapshots__/image_test.jsx.snap +388 -0
  377. package/src/widgets/__tests__/__snapshots__/input-number_test.jsx.snap +978 -0
  378. package/src/widgets/__tests__/__snapshots__/interaction_test.jsx.snap +311 -0
  379. package/src/widgets/__tests__/__snapshots__/interactive-graph_test.jsx.snap +2915 -0
  380. package/src/widgets/__tests__/__snapshots__/matcher_test.jsx.snap +1039 -0
  381. package/src/widgets/__tests__/__snapshots__/matrix_test.jsx.snap +450 -0
  382. package/src/widgets/__tests__/__snapshots__/number-line_test.jsx.snap +888 -0
  383. package/src/widgets/__tests__/__snapshots__/numeric-input_test.jsx.snap +930 -0
  384. package/src/widgets/__tests__/__snapshots__/orderer_test.jsx.snap +275 -0
  385. package/src/widgets/__tests__/__snapshots__/passage-ref_test.jsx.snap +446 -0
  386. package/src/widgets/__tests__/__snapshots__/passage_test.jsx.snap +1988 -0
  387. package/src/widgets/__tests__/__snapshots__/radio_test.jsx.snap +7107 -0
  388. package/src/widgets/__tests__/__snapshots__/video_test.jsx.snap +163 -0
  389. package/src/widgets/__tests__/categorizer_test.jsx +205 -0
  390. package/src/widgets/__tests__/cs-program_test.jsx +45 -0
  391. package/src/widgets/__tests__/definition_test.js +152 -0
  392. package/src/widgets/__tests__/dropdown_test.jsx +106 -0
  393. package/src/widgets/__tests__/explanation_test.js +199 -0
  394. package/src/widgets/__tests__/expression_test.jsx +439 -0
  395. package/src/widgets/__tests__/graded-group-set-jipt_test.jsx +33 -0
  396. package/src/widgets/__tests__/graded-group-set_test.jsx +270 -0
  397. package/src/widgets/__tests__/graded-group_test.jsx +233 -0
  398. package/src/widgets/__tests__/grapher.cypress.jsx +486 -0
  399. package/src/widgets/__tests__/grapher_test.jsx +35 -0
  400. package/src/widgets/__tests__/group_test.jsx +512 -0
  401. package/src/widgets/__tests__/iframe_test.jsx +47 -0
  402. package/src/widgets/__tests__/image_test.jsx +65 -0
  403. package/src/widgets/__tests__/input-number_test.jsx +360 -0
  404. package/src/widgets/__tests__/interaction_test.jsx +33 -0
  405. package/src/widgets/__tests__/interactive-graph.cypress.jsx +697 -0
  406. package/src/widgets/__tests__/interactive-graph_test.jsx +105 -0
  407. package/src/widgets/__tests__/matcher_test.jsx +157 -0
  408. package/src/widgets/__tests__/matrix_test.jsx +84 -0
  409. package/src/widgets/__tests__/number-line_test.jsx +81 -0
  410. package/src/widgets/__tests__/numeric-input_test.jsx +574 -0
  411. package/src/widgets/__tests__/orderer_test.jsx +75 -0
  412. package/src/widgets/__tests__/passage-ref_test.jsx +166 -0
  413. package/src/widgets/__tests__/passage_test.jsx +133 -0
  414. package/src/widgets/__tests__/radio/base-radio_test.jsx +82 -0
  415. package/src/widgets/__tests__/radio_test.jsx +651 -0
  416. package/src/widgets/__tests__/renderQuestion.jsx +65 -0
  417. package/src/widgets/__tests__/unit_test.jsx +144 -0
  418. package/src/widgets/__tests__/video_test.jsx +55 -0
  419. package/src/widgets/categorizer.jsx +338 -0
  420. package/src/widgets/cs-program.jsx +228 -0
  421. package/src/widgets/definition.jsx +141 -0
  422. package/src/widgets/dropdown.jsx +143 -0
  423. package/src/widgets/explanation.jsx +315 -0
  424. package/src/widgets/expression.jsx +654 -0
  425. package/src/widgets/graded-group-answer-bar.jsx +152 -0
  426. package/src/widgets/graded-group-set.jsx +285 -0
  427. package/src/widgets/graded-group.jsx +448 -0
  428. package/src/widgets/grapher/util.jsx +819 -0
  429. package/src/widgets/grapher.jsx +658 -0
  430. package/src/widgets/group.jsx +213 -0
  431. package/src/widgets/iframe.jsx +199 -0
  432. package/src/widgets/image.jsx +309 -0
  433. package/src/widgets/input-number.jsx +410 -0
  434. package/src/widgets/interaction/arrow-picker.jsx +39 -0
  435. package/src/widgets/interaction/color-picker.jsx +69 -0
  436. package/src/widgets/interaction/dash-picker.jsx +46 -0
  437. package/src/widgets/interaction/element-container.jsx +97 -0
  438. package/src/widgets/interaction.jsx +742 -0
  439. package/src/widgets/interactive-graph.jsx +2821 -0
  440. package/src/widgets/label-image/__stories__/answer-choices.stories.jsx +93 -0
  441. package/src/widgets/label-image/__stories__/marker.stories.jsx +172 -0
  442. package/src/widgets/label-image/answer-choices.jsx +311 -0
  443. package/src/widgets/label-image/marker.jsx +365 -0
  444. package/src/widgets/label-image/types.js +33 -0
  445. package/src/widgets/label-image.jsx +841 -0
  446. package/src/widgets/label-image_test.js +840 -0
  447. package/src/widgets/lights-puzzle.jsx +356 -0
  448. package/src/widgets/matcher.jsx +271 -0
  449. package/src/widgets/matrix.jsx +615 -0
  450. package/src/widgets/measurer.jsx +215 -0
  451. package/src/widgets/molecule/__tests__/layout_test.jsx +159 -0
  452. package/src/widgets/molecule/__tests__/parser_test.jsx +142 -0
  453. package/src/widgets/molecule/molecule-drawing.jsx +175 -0
  454. package/src/widgets/molecule/molecule-layout.jsx +367 -0
  455. package/src/widgets/molecule/smiles-parser.jsx +300 -0
  456. package/src/widgets/molecule.jsx +172 -0
  457. package/src/widgets/number-line.jsx +858 -0
  458. package/src/widgets/numeric-input.jsx +622 -0
  459. package/src/widgets/orderer.jsx +753 -0
  460. package/src/widgets/passage/__tests__/passage-markdown_test.jsx +382 -0
  461. package/src/widgets/passage/get-line-height-for-node.js +10 -0
  462. package/src/widgets/passage/passage-markdown.jsx +416 -0
  463. package/src/widgets/passage-ref-target.jsx +70 -0
  464. package/src/widgets/passage-ref.jsx +198 -0
  465. package/src/widgets/passage.jsx +601 -0
  466. package/src/widgets/plotter.jsx +1198 -0
  467. package/src/widgets/radio/__stories__/base-radio.stories.jsx +40 -0
  468. package/src/widgets/radio/__stories__/choice-icon.stories.jsx +157 -0
  469. package/src/widgets/radio/__stories__/choice-none-above.stories.jsx +26 -0
  470. package/src/widgets/radio/__stories__/cross-out-button.stories.jsx +64 -0
  471. package/src/widgets/radio/__stories__/cross-out-menu.stories.jsx +59 -0
  472. package/src/widgets/radio/__stories__/cross-out-tooltip.stories.jsx +59 -0
  473. package/src/widgets/radio/__stories__/focus-ring.stories.jsx +38 -0
  474. package/src/widgets/radio/__stories__/option-status.stories.jsx +118 -0
  475. package/src/widgets/radio/__stories__/toggleable-radio-button.stories.jsx +28 -0
  476. package/src/widgets/radio/base-radio.jsx +642 -0
  477. package/src/widgets/radio/choice-icon.jsx +468 -0
  478. package/src/widgets/radio/choice-none-above.jsx +54 -0
  479. package/src/widgets/radio/choice.jsx +692 -0
  480. package/src/widgets/radio/choice.jsx.fixture.js +64 -0
  481. package/src/widgets/radio/cross-out-button.jsx +136 -0
  482. package/src/widgets/radio/cross-out-menu-wrapper.jsx +492 -0
  483. package/src/widgets/radio/cross-out-tooltip.jsx +129 -0
  484. package/src/widgets/radio/focus-ring.jsx +51 -0
  485. package/src/widgets/radio/option-status.jsx +89 -0
  486. package/src/widgets/radio/toggleable-radio-button.jsx +133 -0
  487. package/src/widgets/radio/util.js +32 -0
  488. package/src/widgets/radio/util_test.js +19 -0
  489. package/src/widgets/radio/widget.jsx +470 -0
  490. package/src/widgets/radio.jsx +156 -0
  491. package/src/widgets/reaction-diagram.jsx +136 -0
  492. package/src/widgets/sequence.jsx +140 -0
  493. package/src/widgets/simulator.jsx +808 -0
  494. package/src/widgets/sorter.jsx +146 -0
  495. package/src/widgets/table.jsx +395 -0
  496. package/src/widgets/transformer.jsx +2870 -0
  497. package/src/widgets/unit.jsx +371 -0
  498. package/src/widgets/video-transcript-link.jsx +84 -0
  499. package/src/widgets/video-transcript-link_test.js +211 -0
  500. package/src/widgets/video.jsx +131 -0
  501. package/src/widgets.js +431 -0
  502. package/src/zoom.js +539 -0
  503. package/src/__tests__/index.test.js +0 -14
@@ -0,0 +1,2870 @@
1
+ /* eslint-disable @babel/no-invalid-this, camelcase, react/no-did-update-set-state, react/sort-comp */
2
+ /*
3
+ NOTE(Pereus stabilize project): This widget is no-longer-used as of January
4
+ 2022. It's not present in US content but is still present in some
5
+ international content so it can't be removed quite yet. As soon as it's
6
+ verified to have been removed from international content this all needs
7
+ to get deleted. In the meantime- do not maintain this code.
8
+
9
+ ref: https://khanacademy.slack.com/archives/CJDRXTGQ7/p1641857400011600
10
+ ref: https://khanacademy.slack.com/archives/C2RFQGYKU/p1596160975061500?thread_ts=1596129758.040000&cid=C2RFQGYKU
11
+ */
12
+ // @flow
13
+ import * as i18n from "@khanacademy/wonder-blocks-i18n";
14
+ import createReactClass from "create-react-class";
15
+ import $ from "jquery";
16
+ import {
17
+ number as knumber,
18
+ vector as kvector,
19
+ point as kpoint,
20
+ ray as kray,
21
+ line as kline,
22
+ } from "kmath";
23
+ import * as React from "react";
24
+ import ReactDOM from "react-dom";
25
+ import _ from "underscore";
26
+
27
+ import Graph from "../components/graph.jsx";
28
+ import InlineIcon from "../components/inline-icon.jsx";
29
+ import MathOutput from "../components/math-output.jsx";
30
+ import NumberInput from "../components/number-input.jsx";
31
+ import SimpleKeypadInput from "../components/simple-keypad-input.jsx";
32
+ import {getDependencies} from "../dependencies.js";
33
+ import {iconPlus, iconUndo} from "../icon-paths.js";
34
+ import InteractiveUtil from "../interactive2/interactive-util.js";
35
+ import {Errors} from "../logging/log.js";
36
+ import {ApiOptions} from "../perseus-api.jsx";
37
+ import {PerseusError} from "../perseus-error.js";
38
+ import Util from "../util.js";
39
+ import KhanColors from "../util/colors.js";
40
+ import KhanMath from "../util/math.js";
41
+
42
+ import type {Coord} from "../interactive2/types.js";
43
+ import type {
44
+ PerseusTransformerWidgetOptions,
45
+ PerseusTransformerTransformation as TransformerTransformation,
46
+ DilationTransformation,
47
+ ReflectionTransformation,
48
+ RotationTransformation,
49
+ TranslationTransformation,
50
+ } from "../perseus-types.js";
51
+ import type {Path, PerseusScore, WidgetExports, WidgetProps} from "../types.js";
52
+
53
+ const {deepEq, getGridStep, captureScratchpadTouchStart} = Util;
54
+ const {assert} = InteractiveUtil;
55
+
56
+ const ROTATE_SNAP_DEGREES = 15;
57
+
58
+ const DEGREE_SIGN = "\u00B0";
59
+ const RENDER_TRANSFORM_DELAY_IN_MS = 300;
60
+ const ROTATE_HANDLE_DIST = 1.5;
61
+ const REFLECT_ROTATE_HANDLE_DIST = 2;
62
+ const REFLECT_BUTTON_SIZE = 1;
63
+ const defaultBoxSize = 400;
64
+ const defaultBackgroundImage = {
65
+ url: null,
66
+ };
67
+
68
+ /* Does a pluck on keys inside objects in an object
69
+ *
70
+ * Ex:
71
+ * tools = {
72
+ * translation: {
73
+ * enabled: true
74
+ * },
75
+ * rotation: {
76
+ * enabled: false
77
+ * }
78
+ * };
79
+ * pluckObject(tools, "enabled") returns {
80
+ * translation: true
81
+ * rotation: false
82
+ * }
83
+ */
84
+ function pluckObject(object, subKey) {
85
+ return _.object(
86
+ _.map(object, function (value, key) {
87
+ return [key, value[subKey]];
88
+ }),
89
+ );
90
+ }
91
+
92
+ const defaultGraphProps = function (setProps, boxSize) {
93
+ setProps = setProps || {};
94
+ const labels = setProps.labels || ["x", "y"];
95
+ const range = setProps.range || [
96
+ [-10, 10],
97
+ [-10, 10],
98
+ ];
99
+ const step = setProps.step || [1, 1];
100
+ const gridStep = setProps.gridStep || getGridStep(range, step, boxSize);
101
+ return {
102
+ box: [boxSize, boxSize],
103
+ labels: labels,
104
+ range: range,
105
+ step: step,
106
+ gridStep: gridStep,
107
+ valid: true,
108
+ backgroundImage: defaultBackgroundImage,
109
+ markings: "grid",
110
+ showProtractor: false,
111
+ };
112
+ };
113
+
114
+ const defaultTransformerProps = {
115
+ apiOptions: ApiOptions.defaults,
116
+ gradeEmpty: false,
117
+ graphMode: "interactive",
118
+ listMode: "dynamic",
119
+ graph: {},
120
+ tools: {
121
+ translation: {
122
+ enabled: true,
123
+ required: false,
124
+ constraints: {},
125
+ },
126
+ rotation: {
127
+ enabled: true,
128
+ required: false,
129
+ constraints: {
130
+ fixed: false,
131
+ },
132
+ coord: [1, 6],
133
+ },
134
+ reflection: {
135
+ enabled: true,
136
+ required: false,
137
+ constraints: {
138
+ fixed: false,
139
+ },
140
+ coords: [
141
+ [2, -4],
142
+ [2, 2],
143
+ ],
144
+ },
145
+ dilation: {
146
+ enabled: true,
147
+ required: false,
148
+ constraints: {
149
+ fixed: false,
150
+ },
151
+ coord: [6, 6],
152
+ },
153
+ },
154
+ drawSolutionShape: true,
155
+ starting: {
156
+ shape: {
157
+ type: "polygon-3",
158
+ coords: [
159
+ [2, 2],
160
+ [2, 6],
161
+ [7, 2],
162
+ ],
163
+ },
164
+ transformations: [],
165
+ },
166
+ correct: {
167
+ shape: {
168
+ type: "polygon-3",
169
+ coords: [
170
+ [2, 2],
171
+ [2, 6],
172
+ [7, 2],
173
+ ],
174
+ },
175
+ transformations: [],
176
+ },
177
+ };
178
+
179
+ function colorForTool(tool) {
180
+ return tool.constraints.fixed ? KhanColors.DYNAMIC : KhanColors.INTERACTIVE;
181
+ }
182
+
183
+ /* Scales a distance from the default range of
184
+ * [-10, 10] to a given props.range pair
185
+ *
186
+ * Used for sizing various transformation tools
187
+ * (rotation handle, dilation circle)
188
+ */
189
+ function scaleToRange(dist, range) {
190
+ const spreadX = range[0][1] - range[0][0];
191
+ const spreadY = range[1][1] - range[1][0];
192
+
193
+ return (dist * Math.max(spreadX, spreadY)) / 20;
194
+ }
195
+
196
+ function dilatePointFromCenter(point, dilationCenter, scale) {
197
+ const pv = kvector.subtract(point, dilationCenter);
198
+ const pvScaled = kvector.scale(pv, scale);
199
+ const transformedPoint = kvector.add(dilationCenter, pvScaled);
200
+ return transformedPoint;
201
+ }
202
+
203
+ // TODO(jack): i18nize this
204
+ function stringFromDecimal(number) {
205
+ return String(KhanMath.roundTo(9, number));
206
+ }
207
+
208
+ function stringFromFraction(number) {
209
+ const frac = KhanMath.toFraction(number, knumber.DEFAULT_TOLERANCE);
210
+ if (frac[1] === 1) {
211
+ return stringFromDecimal(number);
212
+ }
213
+ return stringFromDecimal(frac[0]) + "/" + stringFromDecimal(frac[1]);
214
+ }
215
+
216
+ function texFromPoint(point) {
217
+ const {TeX} = getDependencies();
218
+
219
+ return [
220
+ <TeX key="(">(</TeX>,
221
+ stringFromDecimal(point[0]),
222
+ <TeX key=",">{", {}"}</TeX>,
223
+ stringFromDecimal(point[1]),
224
+ <TeX key=")">)</TeX>,
225
+ ];
226
+ }
227
+
228
+ function texFromVector(vector) {
229
+ const {TeX} = getDependencies();
230
+
231
+ return [
232
+ <TeX key="<">{i18n.doNotTranslate("\\langle")}</TeX>,
233
+ stringFromDecimal(vector[0]),
234
+ <TeX key=",">{", {}"}</TeX>,
235
+ stringFromDecimal(vector[1]),
236
+ <TeX key=">">{i18n.doNotTranslate("\\rangle")}</TeX>,
237
+ ];
238
+ }
239
+
240
+ function texFromAngleDeg(angleDeg) {
241
+ return stringFromDecimal(angleDeg) + DEGREE_SIGN;
242
+ }
243
+
244
+ function orderInsensitiveCoordsEqual(coords1, coords2) {
245
+ coords1 = _.clone(coords1).sort(kpoint.compare);
246
+ coords2 = _.clone(coords2).sort(kpoint.compare);
247
+ return _.all(
248
+ _.map(coords1, function (coord1, i) {
249
+ const coord2 = coords2[i];
250
+ return kpoint.equal(coord1, coord2);
251
+ }),
252
+ );
253
+ }
254
+
255
+ const inputComponentForApiOptions = (apiOptions) => {
256
+ if (apiOptions.customKeypad) {
257
+ return SimpleKeypadInput;
258
+ }
259
+ if (apiOptions.staticRender) {
260
+ return MathOutput;
261
+ }
262
+ return NumberInput;
263
+ };
264
+
265
+ /* Perform operations on raw transform objects */
266
+ const TransformOps = {
267
+ apply: function (transform: TransformerTransformation) {
268
+ // Any transformation with empty text boxes is a no-op until
269
+ // filled out (these show up as nulls in transform.vector/line/etc).
270
+ // TODO (jack): Merge this just into reflections now that other
271
+ // transforms are always valid (after merging transformation
272
+ // collapsing, which may use isValid)
273
+ // $FlowFixMe[prop-missing]
274
+ // $FlowFixMe[incompatible-call]
275
+ if (!Transformations[transform.type].isValid(transform)) {
276
+ return _.identity; // do not transform the coord
277
+ }
278
+ // $FlowFixMe[prop-missing]
279
+ // $FlowFixMe[incompatible-call]
280
+ return Transformations[transform.type].apply(transform);
281
+ },
282
+
283
+ append: function (transformList, newTransform) {
284
+ // Append newTransform to transformList, and collapse the last
285
+ // two transforms if they are collapsable
286
+ const results = TransformOps._appendAndCollapseLastTwo(
287
+ transformList,
288
+ newTransform,
289
+ );
290
+ // Collapse any no-ops at the end of the transformation list
291
+ return TransformOps._collapseFinalNoOps(results);
292
+ },
293
+
294
+ _collapseFinalNoOps: function (transforms) {
295
+ // Collapse no-op transformations at the end of the list
296
+ if (transforms.length && TransformOps.isNoOp(_.last(transforms))) {
297
+ return _.initial(transforms);
298
+ }
299
+ return transforms;
300
+ },
301
+
302
+ _appendAndCollapseLastTwo: function (transformList, newTransform) {
303
+ if (!transformList.length) {
304
+ return [newTransform];
305
+ }
306
+ const collapsed = TransformOps.collapse(
307
+ _.last(transformList),
308
+ newTransform,
309
+ );
310
+ return _.initial(transformList).concat(collapsed);
311
+ },
312
+
313
+ isNoOp: function (transform) {
314
+ return Transformations[transform.type].isNoOp(transform);
315
+ },
316
+
317
+ collapse: function (transform1, transform2) {
318
+ // We can only collapse transforms that have the same type
319
+ if (transform1.type !== transform2.type) {
320
+ return [transform1, transform2];
321
+ }
322
+
323
+ // Clicking the button again removes empty transformations
324
+ if (
325
+ TransformOps.isEmpty(transform1) &&
326
+ TransformOps.isEmpty(transform2)
327
+ ) {
328
+ return [];
329
+ }
330
+
331
+ // Don't collapse invalid transformations otherwise
332
+ if (
333
+ !TransformOps.isValid(transform1) ||
334
+ !TransformOps.isValid(transform2)
335
+ ) {
336
+ return [transform1, transform2];
337
+ }
338
+
339
+ return TransformOps._collapseValidMonotypedTransforms(
340
+ transform1,
341
+ transform2,
342
+ );
343
+ },
344
+
345
+ isValid: function (transform: TransformerTransformation): boolean {
346
+ // $FlowFixMe[prop-missing]
347
+ // $FlowFixMe[incompatible-call]
348
+ return Transformations[transform.type].isValid(transform);
349
+ },
350
+
351
+ isEmpty: function (transform: TransformerTransformation): boolean {
352
+ // $FlowFixMe[prop-missing]
353
+ // $FlowFixMe[incompatible-call]
354
+ return Transformations[transform.type].isEmpty(transform);
355
+ },
356
+
357
+ _collapseValidMonotypedTransforms: function (transform1, transform2) {
358
+ let collapsed = Transformations[transform1.type].collapse(
359
+ transform1,
360
+ transform2,
361
+ );
362
+ if (collapsed) {
363
+ // Force all answers into an array
364
+ if (!_.isArray(collapsed)) {
365
+ collapsed = [collapsed];
366
+ }
367
+ // Add types to all transforms in the answer
368
+ _.each(collapsed, function (transform) {
369
+ transform.type = transform1.type;
370
+ });
371
+ return collapsed;
372
+ }
373
+ // These transforms can't be collapsed together
374
+ return [transform1, transform2];
375
+ },
376
+
377
+ toTeX: function (transform) {
378
+ return Transformations[transform.type].toTeX(transform);
379
+ },
380
+
381
+ /* A react representation of this transform object */
382
+ ListItem: createReactClass({
383
+ displayName: "ListItem",
384
+
385
+ render: function () {
386
+ if (this.props.mode === "dynamic") {
387
+ return <div>{TransformOps.toTeX(this.props.transform)}</div>;
388
+ }
389
+ if (this.props.mode === "interactive") {
390
+ const TransformClass =
391
+ Transformations[this.props.transform.type].Input;
392
+ return (
393
+ <TransformClass
394
+ // eslint-disable-next-line react/no-string-refs
395
+ ref="transform"
396
+ onChange={this.handleChange}
397
+ onFocus={this.props.onFocus}
398
+ onBlur={this.props.onBlur}
399
+ keypadElement={this.props.keypadElement}
400
+ apiOptions={this.props.apiOptions}
401
+ {...this.props.transform}
402
+ />
403
+ );
404
+ }
405
+ throw new PerseusError(
406
+ "Invalid mode: " + this.props.mode,
407
+ Errors.InvalidInput,
408
+ );
409
+ },
410
+
411
+ value: function () {
412
+ if (this.props.mode === "interactive") {
413
+ return _.extend(
414
+ {
415
+ type: this.props.transform.type,
416
+ },
417
+ // eslint-disable-next-line react/no-string-refs
418
+ this.refs.transform.value(),
419
+ );
420
+ }
421
+ return this.props.transform;
422
+ },
423
+
424
+ handleChange: _.debounce(function (callback) {
425
+ this.props.onChange(this.value(), callback);
426
+ }, RENDER_TRANSFORM_DELAY_IN_MS),
427
+
428
+ /* InputPath API: depending on the API call, this could involve simply
429
+ * navigating to the right ref and calling the function on that
430
+ * component, or threading the call down and returning the result. */
431
+ _getComponentAtPath: function (path) {
432
+ // eslint-disable-next-line react/no-string-refs
433
+ const transform = this.refs.transform;
434
+ const ref = _.head(path);
435
+ return transform.refs[ref];
436
+ },
437
+
438
+ focus: function () {
439
+ // eslint-disable-next-line react/no-string-refs
440
+ const transform = this.refs.transform;
441
+ const path = _.head(transform.getInputPaths());
442
+ if (path) {
443
+ this.focusInputPath(path);
444
+ }
445
+ },
446
+
447
+ focusInputPath: function (path) {
448
+ this._getComponentAtPath(path).focus();
449
+ },
450
+
451
+ blurInputPath: function (path) {
452
+ this._getComponentAtPath(path).blur();
453
+ },
454
+
455
+ getDOMNodeForPath: function (path) {
456
+ return ReactDOM.findDOMNode(this._getComponentAtPath(path));
457
+ },
458
+
459
+ getGrammarTypeForPath: function (path) {
460
+ return "number";
461
+ },
462
+
463
+ setInputValue: function (path, value: string | number, cb) {
464
+ // `value` comes in as a string on mobile, but we need a number
465
+ // We let through the empty string so that "Clear" works -- in
466
+ // that case, the transformer widget will just act as if there is
467
+ // no input, which is what we want.
468
+ if (value.length) {
469
+ value = parseFloat(value);
470
+ if (isNaN(value)) {
471
+ return;
472
+ }
473
+ }
474
+ // eslint-disable-next-line react/no-string-refs
475
+ this.refs.transform.setInputValue(path, value, cb);
476
+ },
477
+
478
+ getInputPaths: function () {
479
+ // If we're in dynamic mode, then the list items are made up of
480
+ // static text.
481
+ if (this.props.mode === "dynamic") {
482
+ return [];
483
+ }
484
+ // eslint-disable-next-line react/no-string-refs
485
+ return this.refs.transform.getInputPaths();
486
+ },
487
+ }),
488
+ };
489
+
490
+ const Transformations = {
491
+ translation: {
492
+ // I18N: As in the command, "Translate the polygon"
493
+ verbName: i18n._("Translate"),
494
+ nounName: i18n._("Translation"),
495
+ lowerNounName: i18n._("translation"),
496
+ apply: function (transform: TranslationTransformation) {
497
+ return function (coord) {
498
+ return kvector.add(coord, transform.vector);
499
+ };
500
+ },
501
+ isValid: function (transform: TranslationTransformation) {
502
+ return (
503
+ _.isFinite(transform.vector[0]) &&
504
+ _.isFinite(transform.vector[1])
505
+ );
506
+ },
507
+ isEmpty: function (transform: TranslationTransformation) {
508
+ return transform.vector[0] === null && transform.vector[1] === null;
509
+ },
510
+ isNoOp: function (transform) {
511
+ return kvector.equal(transform.vector, [0, 0]);
512
+ },
513
+ collapse: function (
514
+ transform1: TranslationTransformation,
515
+ transform2: TranslationTransformation,
516
+ ) {
517
+ return {
518
+ vector: kvector.add(transform1.vector, transform2.vector),
519
+ };
520
+ },
521
+ toTeX: function (transform: TranslationTransformation) {
522
+ // I18N: As in the command, "Translation by <3, 1>"
523
+ return i18n.$_("Translation by %(vector)s", {
524
+ vector: texFromVector(transform.vector),
525
+ });
526
+ },
527
+ Input: class extends React.Component<$FlowFixMe, $FlowFixMe> {
528
+ state = {
529
+ vector: this.props.vector || [null, null],
530
+ };
531
+
532
+ componentDidUpdate(prevProps) {
533
+ if (!deepEq(this.props, prevProps)) {
534
+ this.setState({vector: this.props.vector});
535
+ }
536
+ }
537
+
538
+ render(): React.Node {
539
+ const InputComponent = inputComponentForApiOptions(
540
+ this.props.apiOptions,
541
+ );
542
+ const {TeX} = getDependencies();
543
+
544
+ const vector = [
545
+ <TeX key="<">{i18n.doNotTranslate("\\langle")}</TeX>,
546
+ <InputComponent
547
+ key="vec_x"
548
+ // eslint-disable-next-line react/no-string-refs
549
+ ref="x"
550
+ placeholder={0}
551
+ value={this.state.vector[0]}
552
+ useArrowKeys={true}
553
+ onChange={(val0) => {
554
+ const val1 = this.state.vector[1];
555
+ this.setState({vector: [val0, val1]}, () => {
556
+ this.props.onChange();
557
+ });
558
+ }}
559
+ onFocus={_.partial(this.props.onFocus, "x")}
560
+ onBlur={_.partial(this.props.onBlur, "x")}
561
+ keypadElement={this.props.keypadElement}
562
+ />,
563
+ <TeX key=",">{", {}"}</TeX>,
564
+ <InputComponent
565
+ key="vec_y"
566
+ // eslint-disable-next-line react/no-string-refs
567
+ ref="y"
568
+ placeholder={0}
569
+ value={this.state.vector[1]}
570
+ useArrowKeys={true}
571
+ onChange={(val1) => {
572
+ const val0 = this.state.vector[0];
573
+ this.setState({vector: [val0, val1]}, () => {
574
+ this.props.onChange();
575
+ });
576
+ }}
577
+ onFocus={_.partial(this.props.onFocus, "y")}
578
+ onBlur={_.partial(this.props.onBlur, "y")}
579
+ keypadElement={this.props.keypadElement}
580
+ />,
581
+ <TeX key=">">{i18n.doNotTranslate("\\rangle")}</TeX>,
582
+ ];
583
+ return (
584
+ <div>
585
+ {i18n.$_("Translation by %(vector)s", {vector: vector})}
586
+ </div>
587
+ );
588
+ }
589
+
590
+ value = () => {
591
+ // eslint-disable-next-line react/no-string-refs
592
+ const x = this.refs.x.getValue();
593
+ // eslint-disable-next-line react/no-string-refs
594
+ const y = this.refs.y.getValue();
595
+ return {
596
+ vector: [x, y],
597
+ };
598
+ };
599
+
600
+ /* InputPath API */
601
+ setInputValue = (path, value, cb) => {
602
+ const id = _.first(path);
603
+ const vector = _.clone(this.state.vector);
604
+ if (id === "x") {
605
+ vector[0] = value;
606
+ } else if (id === "y") {
607
+ vector[1] = value;
608
+ }
609
+ this.setState({vector: vector}, () => {
610
+ this.props.onChange(cb);
611
+ });
612
+ };
613
+
614
+ getInputPaths = () => {
615
+ return [["x"], ["y"]];
616
+ };
617
+ },
618
+ },
619
+
620
+ rotation: {
621
+ // I18N: As in the command, "Rotate the polygon"
622
+ verbName: i18n._("Rotate"),
623
+ nounName: i18n._("Rotation"),
624
+ lowerNounName: i18n._("rotation"),
625
+ apply: function (transform: RotationTransformation) {
626
+ return function (coord) {
627
+ return kpoint.rotateDeg(
628
+ coord,
629
+ transform.angleDeg,
630
+ transform.center,
631
+ );
632
+ };
633
+ },
634
+ isValid: function (transform: RotationTransformation) {
635
+ return (
636
+ _.isFinite(transform.angleDeg) &&
637
+ _.isFinite(transform.center[0]) &&
638
+ _.isFinite(transform.center[1])
639
+ );
640
+ },
641
+ isEmpty: function (transform: RotationTransformation) {
642
+ return (
643
+ transform.angleDeg === null &&
644
+ transform.center[0] === null &&
645
+ transform.center[1] === null
646
+ );
647
+ },
648
+ isNoOp: function (transform: RotationTransformation) {
649
+ return knumber.equal(transform.angleDeg, 0);
650
+ },
651
+ collapse: function (
652
+ transform1: RotationTransformation,
653
+ transform2: RotationTransformation,
654
+ ) {
655
+ if (!kpoint.equal(transform1.center, transform2.center)) {
656
+ return false;
657
+ }
658
+ return {
659
+ center: transform1.center,
660
+ angleDeg: transform1.angleDeg + transform2.angleDeg,
661
+ };
662
+ },
663
+ toTeX: function (transform: RotationTransformation) {
664
+ return i18n.$_("Rotation by %(degrees)s about %(point)s", {
665
+ degrees: texFromAngleDeg(transform.angleDeg),
666
+ point: texFromPoint(transform.center),
667
+ });
668
+ },
669
+ Input: class extends React.Component<$FlowFixMe, $FlowFixMe> {
670
+ state = {
671
+ center: this.props.center || [null, null],
672
+ angleDeg: this.props.angleDeg || null,
673
+ };
674
+
675
+ componentDidUpdate(prevProps) {
676
+ if (!deepEq(this.props, prevProps)) {
677
+ this.setState({
678
+ center: this.props.center,
679
+ angleDeg: this.props.angleDeg,
680
+ });
681
+ }
682
+ }
683
+
684
+ render(): React.Node {
685
+ const InputComponent = inputComponentForApiOptions(
686
+ this.props.apiOptions,
687
+ );
688
+ const {TeX} = getDependencies();
689
+
690
+ const point = [
691
+ <TeX key="(">(</TeX>,
692
+ <InputComponent
693
+ key="X"
694
+ // eslint-disable-next-line react/no-string-refs
695
+ ref="centerX"
696
+ placeholder={0}
697
+ value={this.state.center[0]}
698
+ useArrowKeys={true}
699
+ onChange={(val0) => {
700
+ const val1 = this.state.center[1];
701
+ this.setState({center: [val0, val1]}, () => {
702
+ this.props.onChange();
703
+ });
704
+ }}
705
+ onFocus={_.partial(this.props.onFocus, "centerX")}
706
+ onBlur={_.partial(this.props.onBlur, "centerX")}
707
+ keypadElement={this.props.keypadElement}
708
+ />,
709
+ <TeX key=",">{", {}"}</TeX>,
710
+ <InputComponent
711
+ key="Y"
712
+ // eslint-disable-next-line react/no-string-refs
713
+ ref="centerY"
714
+ placeholder={0}
715
+ value={this.state.center[1]}
716
+ useArrowKeys={true}
717
+ onChange={(val1) => {
718
+ const val0 = this.state.center[0];
719
+ this.setState({center: [val0, val1]}, () => {
720
+ this.props.onChange();
721
+ });
722
+ }}
723
+ onFocus={_.partial(this.props.onFocus, "centerY")}
724
+ onBlur={_.partial(this.props.onBlur, "centerY")}
725
+ keypadElement={this.props.keypadElement}
726
+ />,
727
+ <TeX key=")">)</TeX>,
728
+ ];
729
+ const degrees = [
730
+ <InputComponent
731
+ key="deg"
732
+ // eslint-disable-next-line react/no-string-refs
733
+ ref="angleDeg"
734
+ placeholder={0}
735
+ value={this.state.angleDeg}
736
+ useArrowKeys={true}
737
+ onChange={(val) => {
738
+ this.setState({angleDeg: val}, () => {
739
+ this.props.onChange();
740
+ });
741
+ }}
742
+ onFocus={_.partial(this.props.onFocus, "angleDeg")}
743
+ onBlur={_.partial(this.props.onBlur, "angleDeg")}
744
+ keypadElement={this.props.keypadElement}
745
+ />,
746
+ DEGREE_SIGN,
747
+ ];
748
+ // I18N: %(point)s must come before %(degrees)s in this phrase
749
+ const text = i18n.$_(
750
+ "Rotation about %(point)s by %(degrees)s",
751
+ {
752
+ point,
753
+ degrees,
754
+ },
755
+ );
756
+
757
+ return <div>{text}</div>;
758
+ }
759
+
760
+ value = () => {
761
+ // eslint-disable-next-line react/no-string-refs
762
+ const angleDeg = this.refs.angleDeg.getValue();
763
+ // eslint-disable-next-line react/no-string-refs
764
+ const centerX = this.refs.centerX.getValue();
765
+ // eslint-disable-next-line react/no-string-refs
766
+ const centerY = this.refs.centerY.getValue();
767
+ return {
768
+ angleDeg: angleDeg,
769
+ center: [centerX, centerY],
770
+ };
771
+ };
772
+
773
+ /* InputPath API */
774
+ setInputValue = (path, value, cb) => {
775
+ const id = _.first(path);
776
+ let angleDeg = _.clone(this.state.angleDeg);
777
+ const center = _.clone(this.state.center);
778
+ if (id === "angleDeg") {
779
+ angleDeg = value;
780
+ } else if (id === "centerX") {
781
+ center[0] = value;
782
+ } else if (id === "centerY") {
783
+ center[1] = value;
784
+ }
785
+ this.setState({angleDeg: angleDeg, center: center}, () => {
786
+ this.props.onChange(cb);
787
+ });
788
+ };
789
+
790
+ getInputPaths = () => {
791
+ return [["centerX"], ["centerY"], ["angleDeg"]];
792
+ };
793
+ },
794
+ },
795
+
796
+ reflection: {
797
+ // I18N: As in the command, "Reflect the polygon"
798
+ verbName: i18n._("Reflect"),
799
+ nounName: i18n._("Reflection"),
800
+ lowerNounName: i18n._("reflection"),
801
+ apply: function (transform: ReflectionTransformation) {
802
+ return function (coord) {
803
+ return kpoint.reflectOverLine(coord, transform.line);
804
+ };
805
+ },
806
+ isValid: function (transform: ReflectionTransformation) {
807
+ // A bit hacky, but we'll also define reflecting over a
808
+ // single point as a no-op, to avoid NaN fun.
809
+ return (
810
+ _.all(_.flatten(transform.line), _.isFinite) &&
811
+ !kpoint.equal(transform.line[0], transform.line[1])
812
+ );
813
+ },
814
+ isEmpty: function (transform: ReflectionTransformation) {
815
+ return _.all(_.flatten(transform.line), _.isNull);
816
+ },
817
+ isNoOp: function (transform: ReflectionTransformation) {
818
+ // Invalid transforms are implicitly no-ops, so we don't
819
+ // have to catch that case here.
820
+ return false;
821
+ },
822
+ collapse: function (
823
+ transform1: ReflectionTransformation,
824
+ transform2: ReflectionTransformation,
825
+ ) {
826
+ if (!kline.equal(transform1.line, transform2.line)) {
827
+ return false;
828
+ }
829
+ return [];
830
+ },
831
+ toTeX: function (transform) {
832
+ const point1 = transform.line[0];
833
+ const point2 = transform.line[1];
834
+ return i18n.$_(
835
+ "Reflection over the line from %(point1)s to %(point2)s",
836
+ {
837
+ point1: texFromPoint(point1),
838
+ point2: texFromPoint(point2),
839
+ },
840
+ );
841
+ },
842
+ Input: class extends React.Component<$FlowFixMe, $FlowFixMe> {
843
+ state = {
844
+ line: this.props.line || [
845
+ [null, null],
846
+ [null, null],
847
+ ],
848
+ };
849
+
850
+ componentDidUpdate(prevProps) {
851
+ if (!deepEq(this.props, prevProps)) {
852
+ this.setState({line: this.props.line});
853
+ }
854
+ }
855
+
856
+ render(): React.Node {
857
+ const InputComponent = inputComponentForApiOptions(
858
+ this.props.apiOptions,
859
+ );
860
+ const {TeX} = getDependencies();
861
+
862
+ const point1 = [
863
+ <TeX key="(">(</TeX>,
864
+ <InputComponent
865
+ key="x1"
866
+ // eslint-disable-next-line react/no-string-refs
867
+ ref="x1"
868
+ value={this.state.line[0][0]}
869
+ useArrowKeys={true}
870
+ // eslint-disable-next-line react/jsx-no-bind
871
+ onChange={this.changePoint.bind(this, 0, 0)}
872
+ onFocus={_.partial(this.props.onFocus, "x1")}
873
+ onBlur={_.partial(this.props.onBlur, "x1")}
874
+ keypadElement={this.props.keypadElement}
875
+ />,
876
+ <TeX key=",">{", {}"}</TeX>,
877
+ <InputComponent
878
+ key="y1"
879
+ // eslint-disable-next-line react/no-string-refs
880
+ ref="y1"
881
+ value={this.state.line[0][1]}
882
+ useArrowKeys={true}
883
+ // eslint-disable-next-line react/jsx-no-bind
884
+ onChange={this.changePoint.bind(this, 0, 1)}
885
+ onFocus={_.partial(this.props.onFocus, "y1")}
886
+ onBlur={_.partial(this.props.onBlur, "y1")}
887
+ keypadElement={this.props.keypadElement}
888
+ />,
889
+ <TeX key=")">)</TeX>,
890
+ ];
891
+ const point2 = [
892
+ <TeX key="(">(</TeX>,
893
+ <InputComponent
894
+ key="x2"
895
+ // eslint-disable-next-line react/no-string-refs
896
+ ref="x2"
897
+ value={this.state.line[1][0]}
898
+ useArrowKeys={true}
899
+ // eslint-disable-next-line react/jsx-no-bind
900
+ onChange={this.changePoint.bind(this, 1, 0)}
901
+ onFocus={_.partial(this.props.onFocus, "x2")}
902
+ onBlur={_.partial(this.props.onBlur, "x2")}
903
+ keypadElement={this.props.keypadElement}
904
+ />,
905
+ <TeX key=",">{", {}"}</TeX>,
906
+ <InputComponent
907
+ key="y2"
908
+ // eslint-disable-next-line react/no-string-refs
909
+ ref="y2"
910
+ value={this.state.line[1][1]}
911
+ useArrowKeys={true}
912
+ // eslint-disable-next-line react/jsx-no-bind
913
+ onChange={this.changePoint.bind(this, 1, 1)}
914
+ onFocus={_.partial(this.props.onFocus, "y2")}
915
+ onBlur={_.partial(this.props.onBlur, "y2")}
916
+ keypadElement={this.props.keypadElement}
917
+ />,
918
+ <TeX key=")">)</TeX>,
919
+ ];
920
+ return (
921
+ <div>
922
+ {i18n.$_(
923
+ "Reflection over the line from " +
924
+ "%(point1)s to %(point2)s",
925
+ {point1, point2},
926
+ )}
927
+ </div>
928
+ );
929
+ }
930
+
931
+ changePoint = (i, j, val, cb) => {
932
+ const line = _.map(this.state.line, _.clone);
933
+ line[i][j] = val;
934
+ this.setState({line: line}, () => {
935
+ this.props.onChange(cb);
936
+ });
937
+ };
938
+
939
+ value = () => {
940
+ // eslint-disable-next-line react/no-string-refs
941
+ const x1 = this.refs.x1.getValue();
942
+ // eslint-disable-next-line react/no-string-refs
943
+ const y1 = this.refs.y1.getValue();
944
+ // eslint-disable-next-line react/no-string-refs
945
+ const x2 = this.refs.x2.getValue();
946
+ // eslint-disable-next-line react/no-string-refs
947
+ const y2 = this.refs.y2.getValue();
948
+ return {
949
+ line: [
950
+ [x1, y1],
951
+ [x2, y2],
952
+ ],
953
+ };
954
+ };
955
+
956
+ /* InputPath API */
957
+ setInputValue = (path, value, cb) => {
958
+ const id = _.first(path);
959
+ let j;
960
+ if (id[0] === "x") {
961
+ j = 0;
962
+ } else if (id[0] === "y") {
963
+ j = 1;
964
+ }
965
+ let i;
966
+ if (id[1] === "1") {
967
+ i = 0;
968
+ } else if (id[1] === "2") {
969
+ i = 1;
970
+ }
971
+ this.changePoint(i, j, value, cb);
972
+ };
973
+
974
+ getInputPaths = () => {
975
+ return [["x1"], ["y1"], ["x2"], ["y2"]];
976
+ };
977
+ },
978
+ },
979
+
980
+ dilation: {
981
+ // I18N: As in the command, "Dilate the polygon"
982
+ verbName: i18n._("Dilate"),
983
+ nounName: i18n._("Dilation"),
984
+ lowerNounName: i18n._("dilation"),
985
+ apply: function (transform: DilationTransformation) {
986
+ return function (coord) {
987
+ return dilatePointFromCenter(
988
+ coord,
989
+ transform.center,
990
+ transform.scale,
991
+ );
992
+ };
993
+ },
994
+ isValid: function (transform: DilationTransformation) {
995
+ return (
996
+ _.isFinite(transform.scale) &&
997
+ _.isFinite(transform.center[0]) &&
998
+ _.isFinite(transform.center[1])
999
+ );
1000
+ },
1001
+ isEmpty: function (transform: DilationTransformation) {
1002
+ return (
1003
+ transform.scale === null &&
1004
+ transform.center[0] === null &&
1005
+ transform.center[1] === null
1006
+ );
1007
+ },
1008
+ isNoOp: function (transform: DilationTransformation) {
1009
+ return knumber.equal(transform.scale, 1);
1010
+ },
1011
+ collapse: function (
1012
+ transform1: DilationTransformation,
1013
+ transform2: DilationTransformation,
1014
+ ) {
1015
+ if (!kpoint.equal(transform1.center, transform2.center)) {
1016
+ return false;
1017
+ }
1018
+ return {
1019
+ center: transform1.center,
1020
+ scale: transform1.scale * transform2.scale,
1021
+ };
1022
+ },
1023
+ toTeX: function (transform: DilationTransformation) {
1024
+ const scaleString = stringFromFraction(transform.scale);
1025
+ return i18n.$_("Dilation of scale %(scale)s about %(point)s", {
1026
+ scale: scaleString,
1027
+ point: texFromPoint(transform.center),
1028
+ });
1029
+ },
1030
+ Input: class extends React.Component<$FlowFixMe, $FlowFixMe> {
1031
+ state = {
1032
+ center: this.props.center || [null, null],
1033
+ scale: this.props.scale || null,
1034
+ };
1035
+
1036
+ componentDidUpdate(prevProps) {
1037
+ if (!deepEq(this.props, prevProps)) {
1038
+ this.setState({
1039
+ center: this.props.center,
1040
+ scale: this.props.scale,
1041
+ });
1042
+ }
1043
+ }
1044
+
1045
+ render(): React.Node {
1046
+ const InputComponent = inputComponentForApiOptions(
1047
+ this.props.apiOptions,
1048
+ );
1049
+ const {TeX} = getDependencies();
1050
+
1051
+ const point = [
1052
+ <TeX key="(">(</TeX>,
1053
+ <InputComponent
1054
+ key="point_x"
1055
+ // eslint-disable-next-line react/no-string-refs
1056
+ ref="x"
1057
+ placeholder={0}
1058
+ value={this.state.center[0]}
1059
+ useArrowKeys={true}
1060
+ onChange={(val0) => {
1061
+ const val1 = this.state.center[1];
1062
+ this.setState({center: [val0, val1]}, () => {
1063
+ this.props.onChange();
1064
+ });
1065
+ }}
1066
+ onFocus={_.partial(this.props.onFocus, "x")}
1067
+ onBlur={_.partial(this.props.onBlur, "x")}
1068
+ keypadElement={this.props.keypadElement}
1069
+ />,
1070
+ <TeX key=",">{", {}"}</TeX>,
1071
+ <InputComponent
1072
+ key="point_y"
1073
+ // eslint-disable-next-line react/no-string-refs
1074
+ ref="y"
1075
+ placeholder={0}
1076
+ value={this.state.center[1]}
1077
+ useArrowKeys={true}
1078
+ onChange={(val1) => {
1079
+ const val0 = this.state.center[0];
1080
+ this.setState({center: [val0, val1]}, () => {
1081
+ this.props.onChange();
1082
+ });
1083
+ }}
1084
+ onFocus={_.partial(this.props.onFocus, "y")}
1085
+ onBlur={_.partial(this.props.onBlur, "y")}
1086
+ keypadElement={this.props.keypadElement}
1087
+ />,
1088
+ <TeX key=")">)</TeX>,
1089
+ ];
1090
+ const scale = (
1091
+ <InputComponent
1092
+ // eslint-disable-next-line react/no-string-refs
1093
+ ref="scale"
1094
+ placeholder={1}
1095
+ value={this.state.scale}
1096
+ useArrowKeys={true}
1097
+ onChange={(val) => {
1098
+ this.setState({scale: val}, () => {
1099
+ this.props.onChange();
1100
+ });
1101
+ }}
1102
+ onFocus={_.partial(this.props.onFocus, "scale")}
1103
+ onBlur={_.partial(this.props.onBlur, "scale")}
1104
+ keypadElement={this.props.keypadElement}
1105
+ />
1106
+ );
1107
+ return (
1108
+ <div>
1109
+ {i18n.$_("Dilation about %(point)s by %(scale)s", {
1110
+ point,
1111
+ scale,
1112
+ })}
1113
+ </div>
1114
+ );
1115
+ }
1116
+
1117
+ value = () => {
1118
+ // eslint-disable-next-line react/no-string-refs
1119
+ const scale = this.refs.scale.getValue();
1120
+ // eslint-disable-next-line react/no-string-refs
1121
+ const x = this.refs.x.getValue();
1122
+ // eslint-disable-next-line react/no-string-refs
1123
+ const y = this.refs.y.getValue();
1124
+ return {
1125
+ scale: scale,
1126
+ center: [x, y],
1127
+ };
1128
+ };
1129
+
1130
+ /* InputPath API */
1131
+ setInputValue = (path, value, cb) => {
1132
+ const id = _.first(path);
1133
+ let scale = this.state.scale;
1134
+ const center = _.clone(this.state.center);
1135
+ if (id === "x") {
1136
+ center[0] = value;
1137
+ } else if (id === "y") {
1138
+ center[1] = value;
1139
+ } else if (id === "scale") {
1140
+ scale = value;
1141
+ }
1142
+ this.setState({scale: scale, center: center}, () => {
1143
+ this.props.onChange(cb);
1144
+ });
1145
+ };
1146
+
1147
+ getInputPaths = () => {
1148
+ return [["x"], ["y"], ["scale"]];
1149
+ };
1150
+ },
1151
+ },
1152
+ };
1153
+
1154
+ /* Various functions to deal with different shape types */
1155
+ const ShapeTypes = {
1156
+ getPointCountForType: function (type) {
1157
+ const splitType = type.split("-");
1158
+ if (splitType[0] === "polygon") {
1159
+ return splitType[1] || 3;
1160
+ }
1161
+ if (splitType[0] === "line" || splitType[0] === "lineSegment") {
1162
+ return 2;
1163
+ }
1164
+ if (splitType[0] === "angle") {
1165
+ return 3;
1166
+ }
1167
+ if (splitType[0] === "circle") {
1168
+ return 2;
1169
+ }
1170
+ if (splitType[0] === "point") {
1171
+ return 1;
1172
+ }
1173
+ },
1174
+
1175
+ addMovableShape: function (graphie, options) {
1176
+ if (options.editable && options.translatable) {
1177
+ throw new PerseusError(
1178
+ "It doesn't make sense to have a movable shape " +
1179
+ "where you can stretch the points and translate them " +
1180
+ "simultaneously. options: " +
1181
+ JSON.stringify(options),
1182
+ Errors.InvalidInput,
1183
+ );
1184
+ }
1185
+
1186
+ const points = _.map(options.shape.coords, function (coord) {
1187
+ let isMoving = false;
1188
+ let previousCoord = coord;
1189
+
1190
+ const onMove = function (x, y) {
1191
+ if (!isMoving) {
1192
+ previousCoord = currentPoint.coord;
1193
+ isMoving = true;
1194
+ }
1195
+
1196
+ let moveVector = kvector.subtract([x, y], currentPoint.coord);
1197
+
1198
+ // Translate from (x, y) semantics to (dX, dY) semantics
1199
+ // This is more useful for translations on multiple points,
1200
+ // where we care about how the points moved, not where any
1201
+ // individual point ended up
1202
+ if (options.onMove) {
1203
+ moveVector = options.onMove(moveVector[0], moveVector[1]);
1204
+ }
1205
+
1206
+ // Perform a translation on all points in this shape when
1207
+ // any point moves
1208
+ if (options.translatable) {
1209
+ _.each(points, function (point) {
1210
+ // The point itself will be updated by the
1211
+ // movablePoint class, so only translate the other
1212
+ // points
1213
+ if (point !== currentPoint) {
1214
+ point.setCoord(
1215
+ kvector.add(point.coord, moveVector),
1216
+ );
1217
+ }
1218
+ });
1219
+ }
1220
+
1221
+ // Update our shape and our currentPoint
1222
+ // Without this, some shapes (circles, angles) appear
1223
+ // "bouncy" as they are updated with currentPoint at the
1224
+ // current mouse coordinate (oldCoord), rather than newCoord
1225
+ const oldCoord = currentPoint.coord;
1226
+ const newCoord = kvector.add(currentPoint.coord, moveVector);
1227
+ // Temporarily change our coordinate so that
1228
+ // shape.update() sees the new coordinate
1229
+ currentPoint.coord = newCoord;
1230
+ shape.update();
1231
+ // ...But don't break onMove, which assumes it
1232
+ // is the only thing changing our coord
1233
+ currentPoint.coord = oldCoord;
1234
+ return newCoord;
1235
+ };
1236
+
1237
+ const onMoveEnd = function () {
1238
+ // onMove isn't guaranteed to be called before onMoveEnd, so
1239
+ // we have to take into account that we may not have moved and
1240
+ // set previousCoord.
1241
+ // $FlowFixMe[prop-missing]
1242
+ if (options.onMoveEnd && isMoving) {
1243
+ isMoving = false;
1244
+ // We don't use the supplied x and y parameters here
1245
+ // because MovablePoint's onMoveEnd semantics suck.
1246
+ // It returns the mouseX, mouseY without processing them
1247
+ // through onMove, leaving us with weird fractional moves
1248
+ const change = kvector.subtract(
1249
+ currentPoint.coord,
1250
+ previousCoord,
1251
+ );
1252
+ options.onMoveEnd(change[0], change[1]);
1253
+ }
1254
+ shape.update();
1255
+ };
1256
+
1257
+ const currentPoint = graphie.addMovablePoint({
1258
+ coord: coord,
1259
+ normalStyle: options.normalPointStyle,
1260
+ highlightStyle: options.highlightPointStyle,
1261
+ constraints: {
1262
+ fixed: !options.translatable && !options.editable,
1263
+ },
1264
+ visible: options.showPoints,
1265
+ // $FlowFixMe[prop-missing]
1266
+ snapX: (options.snap && options.snap[0]) || 0,
1267
+ // $FlowFixMe[prop-missing]
1268
+ snapY: (options.snap && options.snap[1]) || 0,
1269
+ bounded: false, // Don't bound it when placing it on the graph
1270
+ onMove: onMove,
1271
+ onMoveEnd: onMoveEnd,
1272
+ });
1273
+
1274
+ // Bound it when moving
1275
+ // We can't set this earlier, because doing so would mean any
1276
+ // points outside of the graph would be moved into a moved into
1277
+ // a position that doesn't preserve the shape
1278
+ currentPoint.bounded = true;
1279
+
1280
+ return currentPoint;
1281
+ });
1282
+
1283
+ const shape = ShapeTypes.addShape(graphie, options, points);
1284
+ const removeShapeWithoutPoints = shape.remove;
1285
+ shape.remove = function () {
1286
+ removeShapeWithoutPoints.apply(shape);
1287
+ _.invoke(points, "remove");
1288
+ };
1289
+ return shape;
1290
+ },
1291
+
1292
+ addShape: function (graphie, options, points) {
1293
+ points = points || options.shape.coords;
1294
+
1295
+ const types = ShapeTypes._typesOf(options.shape);
1296
+ const typeOptions =
1297
+ options.shape.options || ShapeTypes.defaultOptions(types);
1298
+
1299
+ const shapes = ShapeTypes._mapTypes(
1300
+ types,
1301
+ points,
1302
+ function (type, points, i) {
1303
+ const shapeOptions = _.extend({}, options, typeOptions[i]);
1304
+ return ShapeTypes._addType(graphie, type, points, shapeOptions);
1305
+ },
1306
+ );
1307
+
1308
+ const updateFuncs = _.filter(_.pluck(shapes, "update"), _.identity);
1309
+ const update = function () {
1310
+ _.invoke(updateFuncs, "call");
1311
+ };
1312
+
1313
+ const removeFuncs = _.filter(_.pluck(shapes, "remove"), _.identity);
1314
+ const remove = function () {
1315
+ _.invoke(removeFuncs, "call");
1316
+ };
1317
+
1318
+ const getOptions = function () {
1319
+ return _.map(shapes, function (shape) {
1320
+ if (shape.getOptions) {
1321
+ return shape.getOptions();
1322
+ }
1323
+ return {};
1324
+ });
1325
+ };
1326
+
1327
+ const toJSON = function () {
1328
+ const coords = _.map(points, function (pt) {
1329
+ if (_.isArray(pt)) {
1330
+ return pt;
1331
+ }
1332
+ return pt.coord;
1333
+ });
1334
+ return {
1335
+ type: types,
1336
+ coords: coords,
1337
+ options: getOptions(),
1338
+ };
1339
+ };
1340
+
1341
+ return {
1342
+ type: types,
1343
+ points: points,
1344
+ update: update,
1345
+ remove: remove,
1346
+ toJSON: toJSON,
1347
+ getOptions: getOptions,
1348
+ };
1349
+ },
1350
+
1351
+ equal: function (shape1, shape2) {
1352
+ const types1 = ShapeTypes._typesOf(shape1);
1353
+ const types2 = ShapeTypes._typesOf(shape2);
1354
+ if (types1.length !== types2.length) {
1355
+ return false;
1356
+ }
1357
+ const shapes1 = ShapeTypes._mapTypes(
1358
+ types1,
1359
+ shape1.coords,
1360
+ ShapeTypes._combine,
1361
+ );
1362
+ const shapes2 = ShapeTypes._mapTypes(
1363
+ types2,
1364
+ shape2.coords,
1365
+ ShapeTypes._combine,
1366
+ );
1367
+ return _.all(
1368
+ _.map(shapes1, function (partialShape1, i) {
1369
+ const partialShape2 = shapes2[i];
1370
+ if (partialShape1.type !== partialShape2.type) {
1371
+ return false;
1372
+ }
1373
+ return ShapeTypes._forType(partialShape1.type).equal(
1374
+ partialShape1.coords,
1375
+ partialShape2.coords,
1376
+ );
1377
+ }),
1378
+ );
1379
+ },
1380
+
1381
+ _typesOf: function (shape) {
1382
+ let types = shape.type;
1383
+ if (!_.isArray(types)) {
1384
+ types = [types];
1385
+ }
1386
+ return _.map(types, function (type) {
1387
+ if (type === "polygon") {
1388
+ return "polygon-3";
1389
+ }
1390
+ return type;
1391
+ });
1392
+ },
1393
+
1394
+ defaultOptions: function (types) {
1395
+ return _.map(types, function (type) {
1396
+ const typeDefaultOptions = ShapeTypes._forType(type).defaultOptions;
1397
+ return _.extend({}, typeDefaultOptions);
1398
+ });
1399
+ },
1400
+
1401
+ _forType: function (type) {
1402
+ const baseType = type.split("-")[0];
1403
+ return ShapeTypes[baseType];
1404
+ },
1405
+
1406
+ _mapTypes: function (types, points, func, context) {
1407
+ return _.map(types, function (type, i) {
1408
+ const pointCount = ShapeTypes.getPointCountForType(type);
1409
+ const currentPoints = _.first(points, pointCount);
1410
+ points = _.rest(points, pointCount);
1411
+ // $FlowFixMe[extra-arg]
1412
+ return func.call(context, type, currentPoints, i);
1413
+ });
1414
+ },
1415
+
1416
+ _addType: function (graphie, type, points, options) {
1417
+ const lineCoords = _.isArray(points[0])
1418
+ ? {
1419
+ coordA: points[0],
1420
+ coordZ: points[1],
1421
+ }
1422
+ : {
1423
+ pointA: points[0],
1424
+ pointZ: points[1],
1425
+ };
1426
+
1427
+ type = type.split("-")[0];
1428
+ if (type === "polygon") {
1429
+ const polygon = graphie.addMovablePolygon(
1430
+ _.extend({}, options, {
1431
+ fixed: !options.editable,
1432
+ snapX: (options.snap && options.snap[0]) || 0,
1433
+ snapY: (options.snap && options.snap[1]) || 0,
1434
+ points: points,
1435
+ constrainToGraph: false,
1436
+ }),
1437
+ );
1438
+ return {
1439
+ update: polygon.transform.bind(polygon),
1440
+ remove: polygon.remove.bind(polygon),
1441
+ };
1442
+ }
1443
+ if (type === "line" || type === "lineSegment") {
1444
+ const line = graphie.addMovableLineSegment(
1445
+ _.extend({}, options, lineCoords, {
1446
+ movePointsWithLine: true,
1447
+ fixed: true,
1448
+ constraints: {
1449
+ fixed: true,
1450
+ },
1451
+ extendLine: type === "line",
1452
+ }),
1453
+ );
1454
+
1455
+ // TODO(jack): Hide points on uneditable lines when translation
1456
+ // is a vector.
1457
+ // We can't just remove the points yet, because they are the
1458
+ // translation handle for the line.
1459
+ return {
1460
+ update: line.transform.bind(line, true),
1461
+ remove: line.remove.bind(line),
1462
+ };
1463
+ }
1464
+ if (type === "angle") {
1465
+ // If this angle is editable, we want to be able to make angles
1466
+ // both larger and smaller than 180 degrees.
1467
+ // If this angle is not editable, it should always maintain
1468
+ // it's angle measure, even if it is reflected (causing the
1469
+ // clockwise-ness of the points to change)
1470
+ const shouldChangeReflexivity = options.editable ? null : false;
1471
+
1472
+ const angle = graphie.addMovableAngle({
1473
+ angleLabel: "$deg0",
1474
+ fixed: true,
1475
+ points: points,
1476
+ normalStyle: options.normalStyle,
1477
+ reflex: options.reflex,
1478
+ });
1479
+
1480
+ // Hide non-vertex points on uneditable angles
1481
+ if (!_.isArray(points[0]) && !options.editable) {
1482
+ points[0].remove();
1483
+ points[2].remove();
1484
+ }
1485
+ return {
1486
+ update: angle.update.bind(angle, shouldChangeReflexivity),
1487
+ remove: angle.remove.bind(angle),
1488
+ getOptions: function () {
1489
+ return {
1490
+ reflex: angle.isReflex(),
1491
+ };
1492
+ },
1493
+ };
1494
+ }
1495
+ if (type === "circle") {
1496
+ let perimeter = {
1497
+ // temporary object for the first removal
1498
+ remove: _.identity,
1499
+ };
1500
+ const redrawPerim = function () {
1501
+ const coord0 = points[0].coord || points[0];
1502
+ const coord1 = points[1].coord || points[1];
1503
+ const radius = kpoint.distanceToPoint(coord0, coord1);
1504
+ perimeter.remove();
1505
+ perimeter = graphie.circle(
1506
+ coord0,
1507
+ radius,
1508
+ _.extend(
1509
+ {
1510
+ stroke: KhanColors.DYNAMIC,
1511
+ "stroke-width": 2,
1512
+ },
1513
+ options.normalStyle,
1514
+ ),
1515
+ );
1516
+ };
1517
+
1518
+ redrawPerim();
1519
+ if (points[1].remove && !options.editable) {
1520
+ points[1].remove();
1521
+ }
1522
+
1523
+ return {
1524
+ update: redrawPerim,
1525
+ remove: function () {
1526
+ // Not _.bind because the remove function changes
1527
+ // when the perimeter is redrawn
1528
+ perimeter.remove();
1529
+ },
1530
+ };
1531
+ }
1532
+ if (type === "point") {
1533
+ // do nothing
1534
+ return {
1535
+ update: null,
1536
+ remove: null,
1537
+ };
1538
+ }
1539
+ throw new PerseusError(
1540
+ "Invalid shape type " + type,
1541
+ Errors.InvalidInput,
1542
+ );
1543
+ },
1544
+
1545
+ _combine: function (type, coords) {
1546
+ return {
1547
+ type: type,
1548
+ coords: coords,
1549
+ };
1550
+ },
1551
+
1552
+ polygon: {
1553
+ equal: orderInsensitiveCoordsEqual,
1554
+ },
1555
+
1556
+ line: {
1557
+ equal: kline.equal,
1558
+ },
1559
+
1560
+ lineSegment: {
1561
+ equal: orderInsensitiveCoordsEqual,
1562
+ },
1563
+
1564
+ angle: {
1565
+ equal: function (points1, points2) {
1566
+ if (!kpoint.equal(points1[1], points2[1])) {
1567
+ return false;
1568
+ }
1569
+
1570
+ const line1_0 = [points1[1], points1[0]];
1571
+ const line1_2 = [points1[1], points1[2]];
1572
+ const line2_0 = [points2[1], points2[0]];
1573
+ const line2_2 = [points2[1], points2[2]];
1574
+
1575
+ const equalUnflipped =
1576
+ kray.equal(line1_0, line2_0) && kray.equal(line1_2, line2_2);
1577
+ const equalFlipped =
1578
+ kray.equal(line1_0, line2_2) && kray.equal(line1_2, line2_0);
1579
+
1580
+ return equalUnflipped || equalFlipped;
1581
+ },
1582
+
1583
+ defaultOptions: {
1584
+ reflex: false,
1585
+ },
1586
+ },
1587
+
1588
+ circle: {
1589
+ equal: function (points1, points2) {
1590
+ const radius1 = kpoint.distanceToPoint(points1[0], points1[1]);
1591
+ const radius2 = kpoint.distanceToPoint(points2[0], points2[1]);
1592
+ return (
1593
+ kpoint.equal(points1[0], points2[0]) &&
1594
+ knumber.equal(radius1, radius2)
1595
+ );
1596
+ },
1597
+ },
1598
+
1599
+ point: {
1600
+ equal: kpoint.equal,
1601
+ },
1602
+ };
1603
+
1604
+ const TransformationListItem = TransformOps.ListItem;
1605
+
1606
+ class TransformationList extends React.Component<$FlowFixMe, $FlowFixMe> {
1607
+ render(): React.Node {
1608
+ if (this.props.mode === "static") {
1609
+ return <span />; // don't render anything
1610
+ }
1611
+
1612
+ const transformationList = _.map(
1613
+ this.props.transformations,
1614
+ function (transform, i) {
1615
+ return (
1616
+ <TransformationListItem
1617
+ ref={"transformation" + i}
1618
+ key={"transformation" + i}
1619
+ transform={transform}
1620
+ mode={this.props.mode}
1621
+ onChange={this.handleChange}
1622
+ onFocus={_.partial(this.props.onFocus, "" + i)}
1623
+ onBlur={_.partial(this.props.onBlur, "" + i)}
1624
+ keypadElement={this.props.keypadElement}
1625
+ apiOptions={this.props.apiOptions}
1626
+ />
1627
+ );
1628
+ },
1629
+ this,
1630
+ );
1631
+
1632
+ return (
1633
+ <div className="perseus-transformation-list">
1634
+ {transformationList}
1635
+ </div>
1636
+ );
1637
+ }
1638
+
1639
+ _transformationRefs = () => {
1640
+ return _.times(this.props.transformations.length, (i) => {
1641
+ // eslint-disable-next-line react/no-string-refs
1642
+ return this.refs["transformation" + i];
1643
+ });
1644
+ };
1645
+
1646
+ value = () => {
1647
+ return _.invoke(this._transformationRefs(), "value");
1648
+ };
1649
+
1650
+ handleChange = (changed, callback) => {
1651
+ this.props.onChange(this.value(), callback);
1652
+ };
1653
+
1654
+ focusLast = () => {
1655
+ const transformationRefs = this._transformationRefs();
1656
+ if (transformationRefs.length !== 0) {
1657
+ _.last(transformationRefs).focus();
1658
+ }
1659
+ };
1660
+ }
1661
+
1662
+ class ToolButton extends React.Component<$FlowFixMe, $FlowFixMe> {
1663
+ render(): React.Node {
1664
+ const classes = this.props.toggled
1665
+ ? "simple-button exercise-orange toggled highlighted-tool-button"
1666
+ : "simple-button";
1667
+
1668
+ return (
1669
+ <button
1670
+ type="button"
1671
+ className={classes}
1672
+ disabled={this.props.disabled}
1673
+ onClick={this.props.onClick}
1674
+ onTouchStart={captureScratchpadTouchStart}
1675
+ >
1676
+ {this.props.children}
1677
+ </button>
1678
+ );
1679
+ }
1680
+ }
1681
+
1682
+ class ToolsBar extends React.Component<$FlowFixMe, $FlowFixMe> {
1683
+ state = {
1684
+ selected: null,
1685
+ };
1686
+
1687
+ render(): React.Node {
1688
+ const tools = _.map(
1689
+ Transformations,
1690
+ function (tool, type) {
1691
+ if (this.props.enabled[type]) {
1692
+ return (
1693
+ <ToolButton
1694
+ key={type}
1695
+ disabled={this.props.apiOptions.readOnly}
1696
+ toggled={this.state.selected === type}
1697
+ // eslint-disable-next-line react/jsx-no-bind
1698
+ onClick={this.changeSelected.bind(this, type)}
1699
+ >
1700
+ {tool.verbName}
1701
+ </ToolButton>
1702
+ );
1703
+ }
1704
+ },
1705
+ this,
1706
+ );
1707
+
1708
+ return (
1709
+ <div className="transformer-tools-bar">
1710
+ <span className="simple-button-group">{tools}</span>
1711
+ <button
1712
+ className="transformer-undo-button simple-button"
1713
+ type="button"
1714
+ disabled={this.props.apiOptions.readOnly}
1715
+ onClick={this.props.onUndoClick}
1716
+ onTouchStart={captureScratchpadTouchStart}
1717
+ >
1718
+ <InlineIcon {...iconUndo} />
1719
+ {" " + i18n._("Undo")}
1720
+ </button>
1721
+ <div className="clear" />
1722
+ </div>
1723
+ );
1724
+ }
1725
+
1726
+ changeSelected = (tool) => {
1727
+ this.props.removeTool(this.state.selected);
1728
+
1729
+ if (!tool || tool === this.state.selected) {
1730
+ this.setState({
1731
+ selected: null,
1732
+ });
1733
+ } else {
1734
+ this.props.addTool(tool);
1735
+ this.setState({
1736
+ selected: tool,
1737
+ });
1738
+ }
1739
+ };
1740
+ }
1741
+
1742
+ class AddTransformBar extends React.Component<$FlowFixMe, $FlowFixMe> {
1743
+ render(): React.Node {
1744
+ const tools = _.map(
1745
+ Transformations,
1746
+ function (tool, type) {
1747
+ if (this.props.enabled[type]) {
1748
+ return (
1749
+ <ToolButton
1750
+ key={type}
1751
+ toggled={false}
1752
+ disabled={this.props.apiOptions.readOnly}
1753
+ // eslint-disable-next-line react/jsx-no-bind
1754
+ onClick={this.changeSelected.bind(this, type)}
1755
+ >
1756
+ <InlineIcon {...iconPlus} /> {tool.nounName}
1757
+ </ToolButton>
1758
+ );
1759
+ }
1760
+ },
1761
+ this,
1762
+ );
1763
+
1764
+ return (
1765
+ <div className="transformer-tools-bar">
1766
+ {tools}
1767
+ <button
1768
+ className="transformer-undo-button simple-button"
1769
+ type="button"
1770
+ onClick={this.props.onUndoClick}
1771
+ disabled={this.props.apiOptions.readOnly}
1772
+ onTouchStart={captureScratchpadTouchStart}
1773
+ >
1774
+ <InlineIcon {...iconUndo} />
1775
+ {" " + i18n._("Undo")}
1776
+ </button>
1777
+ <div className="clear" />
1778
+ </div>
1779
+ );
1780
+ }
1781
+
1782
+ changeSelected = (tool) => {
1783
+ if (tool) {
1784
+ this.props.addTool(tool);
1785
+ }
1786
+ };
1787
+ }
1788
+
1789
+ type Graphie = $FlowFixMe;
1790
+ type Tool = $FlowFixMe;
1791
+ type ToolCleanup = $FlowFixMe;
1792
+
1793
+ type Rubric = PerseusTransformerWidgetOptions;
1794
+ type RenderProps = PerseusTransformerWidgetOptions; // there's no transform in the exports
1795
+ type UserInput = $FlowFixMe;
1796
+ type Props = {|
1797
+ ...WidgetProps<RenderProps, Rubric>,
1798
+ transformations: $ReadOnlyArray<TransformerTransformation>,
1799
+ keypadElement: $FlowFixMe,
1800
+ |};
1801
+
1802
+ class Transformer extends React.Component<Props> {
1803
+ tools: {|[string]: Tool|};
1804
+ currentTool: ?Tool;
1805
+ transformations: $ReadOnlyArray<TransformerTransformation>;
1806
+ shape: ?$FlowFixMe; // This is not the same as TransformerShape
1807
+ dilationCircle: ?$FlowFixMe;
1808
+ rotatePoint: ?$FlowFixMe;
1809
+ rotateHandle: ?$FlowFixMe;
1810
+
1811
+ static defaultProps: $FlowFixMe = _.defaults(
1812
+ {
1813
+ transformations: [],
1814
+ },
1815
+ defaultTransformerProps,
1816
+ );
1817
+
1818
+ render(): React.Node {
1819
+ // Fill in any missing value in this.props.graph
1820
+ // this can happen because the graph json doesn't include
1821
+ // box, for example
1822
+ const graph = _.extend(
1823
+ defaultGraphProps(this.props.graph, defaultBoxSize),
1824
+ this.props.graph,
1825
+ );
1826
+
1827
+ const interactiveToolsMode = this.props.graphMode === "interactive";
1828
+
1829
+ const ToolsBarClass = interactiveToolsMode ? ToolsBar : AddTransformBar;
1830
+
1831
+ // This style is applied inline because it is dependent on the
1832
+ // size of the graph as set by the graph.box prop, and this also
1833
+ // lets us specify it in the same place the graph's width is
1834
+ // specified.
1835
+ const toolsBar = (
1836
+ <div style={{width: graph.box[0]}}>
1837
+ <ToolsBarClass
1838
+ // eslint-disable-next-line react/no-string-refs
1839
+ ref="toolsBar"
1840
+ enabled={pluckObject(this.props.tools, "enabled")}
1841
+ apiOptions={this.props.apiOptions}
1842
+ addTool={this.addTool}
1843
+ removeTool={this.removeTool}
1844
+ onUndoClick={this.handleUndoClick}
1845
+ />
1846
+ </div>
1847
+ );
1848
+
1849
+ return (
1850
+ <div className={"perseus-widget " + "perseus-widget-transformer"}>
1851
+ <Graph
1852
+ // eslint-disable-next-line react/no-string-refs
1853
+ ref="graph"
1854
+ box={graph.box}
1855
+ range={graph.range}
1856
+ labels={graph.labels}
1857
+ step={graph.step}
1858
+ gridStep={graph.gridStep}
1859
+ markings={graph.markings}
1860
+ backgroundImage={graph.backgroundImage}
1861
+ showProtractor={graph.showProtractor}
1862
+ onGraphieUpdated={this.setupGraphie}
1863
+ setDrawingAreaAvailable={
1864
+ this.props.apiOptions.setDrawingAreaAvailable
1865
+ }
1866
+ />
1867
+
1868
+ {!interactiveToolsMode && i18n._("Add transformations below:")}
1869
+
1870
+ {this.props.graphMode === "static" && [
1871
+ <br key="static-br" />,
1872
+ <em key="static-nomove">
1873
+ {" "}
1874
+ {i18n._(
1875
+ "Note: For this question, the shape will not move.",
1876
+ )}{" "}
1877
+ </em>,
1878
+ ]}
1879
+
1880
+ {interactiveToolsMode && toolsBar}
1881
+
1882
+ <TransformationList
1883
+ // eslint-disable-next-line react/no-string-refs
1884
+ ref="transformationList"
1885
+ mode={this.props.listMode}
1886
+ transformations={this.props.transformations}
1887
+ onChange={this.setTransformationProps}
1888
+ onFocus={this._handleFocus}
1889
+ onBlur={this._handleBlur}
1890
+ keypadElement={this.props.keypadElement}
1891
+ apiOptions={this.props.apiOptions}
1892
+ />
1893
+
1894
+ {!interactiveToolsMode && toolsBar}
1895
+ </div>
1896
+ );
1897
+ }
1898
+
1899
+ componentDidMount() {
1900
+ this.setupGraphie(this.graphie());
1901
+ }
1902
+
1903
+ componentDidUpdate(prevProps: Props) {
1904
+ if (this.shouldSetupGraphie(this.props, prevProps)) {
1905
+ // eslint-disable-next-line react/no-string-refs
1906
+ this.refs.graph.reset();
1907
+ } else if (!deepEq(this.props.transformations, this.transformations)) {
1908
+ this.setTransformations(this.props.transformations);
1909
+ }
1910
+ }
1911
+
1912
+ shouldSetupGraphie: (Props, Props) => boolean = (nextProps, prevProps) => {
1913
+ if (!deepEq(prevProps.starting, nextProps.starting)) {
1914
+ return true;
1915
+ }
1916
+ if (prevProps.graphMode !== nextProps.graphMode) {
1917
+ return true;
1918
+ }
1919
+ if (prevProps.listMode !== nextProps.listMode) {
1920
+ return true;
1921
+ }
1922
+ if (prevProps.drawSolutionShape !== nextProps.drawSolutionShape) {
1923
+ return true;
1924
+ }
1925
+ if (
1926
+ nextProps.drawSolutionShape &&
1927
+ !deepEq(prevProps.correct.shape, nextProps.correct.shape)
1928
+ ) {
1929
+ return true;
1930
+ }
1931
+ if (!deepEq(this.tools, nextProps.tools)) {
1932
+ return true;
1933
+ }
1934
+ return false;
1935
+ };
1936
+
1937
+ graphie: () => Graphie = () => {
1938
+ // eslint-disable-next-line react/no-string-refs
1939
+ return this.refs.graph.graphie();
1940
+ };
1941
+
1942
+ setupGraphie: (Graphie) => void = (graphie) => {
1943
+ // A background image of our solution:
1944
+ if (
1945
+ this.props.drawSolutionShape &&
1946
+ this.props.correct.shape &&
1947
+ this.props.correct.shape.coords
1948
+ ) {
1949
+ ShapeTypes.addShape(graphie, {
1950
+ fixed: true,
1951
+ shape: this.props.correct.shape,
1952
+ normalStyle: {
1953
+ stroke: KhanColors.GRAY,
1954
+ "stroke-dasharray": "",
1955
+ "stroke-width": 2,
1956
+ },
1957
+ });
1958
+ }
1959
+
1960
+ this.currentTool = null;
1961
+ // eslint-disable-next-line react/no-string-refs
1962
+ this.refs.toolsBar.changeSelected(null);
1963
+ this.addTransformerShape(
1964
+ this.props.starting.shape,
1965
+ /* translatable */ false,
1966
+ );
1967
+ this.setTransformations(this.props.transformations);
1968
+
1969
+ // Save a copy of our tools so that we can check future
1970
+ // this.props.tools changes against them
1971
+ // This seems weird, but gives us an easy way to tell whether
1972
+ // props changes were self-inflicted (for which a graphie reset
1973
+ // is not required, and is in fact a bad idea right now because
1974
+ // of resetting the size of the dilation tool).
1975
+ // TODO (jack): A deepClone method would be nice here
1976
+ this.tools = {
1977
+ translation: _.clone(this.props.tools.translation),
1978
+ rotation: _.clone(this.props.tools.rotation),
1979
+ reflection: _.clone(this.props.tools.reflection),
1980
+ dilation: _.clone(this.props.tools.dilation),
1981
+ };
1982
+ };
1983
+
1984
+ /* Applies all transformations in `transformations`
1985
+ * to the starting shape, and updates this.transformations
1986
+ * to reflect this
1987
+ *
1988
+ * Usually called with this.props.transformations
1989
+ */
1990
+ setTransformations: ($ReadOnlyArray<TransformerTransformation>) => void = (
1991
+ transformations,
1992
+ ) => {
1993
+ this.resetCoords();
1994
+ this.transformations = _.clone(transformations);
1995
+ _.each(this.transformations, this.applyTransform);
1996
+ };
1997
+
1998
+ // the polygon that we transform
1999
+ addTransformerShape: ($FlowFixMe, boolean) => void = (
2000
+ shape,
2001
+ translatable,
2002
+ ) => {
2003
+ const self = this;
2004
+ const graphie = this.graphie();
2005
+
2006
+ this.shape = ShapeTypes.addMovableShape(graphie, {
2007
+ shape: shape,
2008
+ editable: false,
2009
+ showPoints: this.props.graphMode !== "static",
2010
+ translatable: translatable,
2011
+ onMove: function (dX, dY) {
2012
+ dX = KhanMath.roundToNearest(graphie.snap[0], dX);
2013
+ dY = KhanMath.roundToNearest(graphie.snap[1], dY);
2014
+ // NOTE(kevinb): object is missing .constraints property
2015
+ // $FlowFixMe[prop-missing]
2016
+ self.addTransform({
2017
+ type: "translation",
2018
+ vector: [dX, dY],
2019
+ });
2020
+ return [dX, dY];
2021
+ },
2022
+ normalPointStyle: {
2023
+ fill: translatable
2024
+ ? KhanColors.INTERACTIVE
2025
+ : KhanColors.DYNAMIC,
2026
+ stroke: translatable
2027
+ ? KhanColors.INTERACTIVE
2028
+ : KhanColors.DYNAMIC,
2029
+ },
2030
+ highlightPointStyle: {
2031
+ fill: KhanColors.INTERACTING,
2032
+ stroke: KhanColors.INTERACTING,
2033
+ },
2034
+ });
2035
+ };
2036
+
2037
+ addTool: (TransformerTransformation["type"]) => void = (toolId) => {
2038
+ const self = this;
2039
+
2040
+ if (this.props.graphMode === "interactive") {
2041
+ if (toolId === "translation") {
2042
+ this.currentTool = this.addTranslationTool();
2043
+ } else if (toolId === "rotation") {
2044
+ this.currentTool = this.addRotationTool();
2045
+ } else if (toolId === "reflection") {
2046
+ this.currentTool = this.addReflectionTool();
2047
+ } else if (toolId === "dilation") {
2048
+ this.currentTool = this.addDilationTool();
2049
+ } else {
2050
+ throw new PerseusError(
2051
+ "Invalid tool id: " + toolId,
2052
+ Errors.InvalidInput,
2053
+ );
2054
+ }
2055
+ } else {
2056
+ let transform: TransformerTransformation;
2057
+ if (toolId === "translation") {
2058
+ // NOTE(kevinb): object is missing .constraints property
2059
+ // $FlowFixMe[prop-missing]
2060
+ transform = {
2061
+ type: "translation",
2062
+ // NOTE(kevib): these should be numbers
2063
+ // $FlowFixMe[incompatible-type]
2064
+ vector: [null, null],
2065
+ };
2066
+ } else if (toolId === "rotation") {
2067
+ // NOTE(kevinb): object is missing .constraints property
2068
+ // $FlowFixMe[prop-missing]
2069
+ transform = {
2070
+ type: "rotation",
2071
+ // NOTE(kevib): these should be numbers
2072
+ // $FlowFixMe[incompatible-type]
2073
+ center: [null, null],
2074
+ // NOTE(kevib): this should be a number
2075
+ // $FlowFixMe[incompatible-type]
2076
+ angleDeg: null,
2077
+ };
2078
+ } else if (toolId === "reflection") {
2079
+ // Reflections with nulls in them won't be applied until
2080
+ // fills in the blanks
2081
+ transform = {
2082
+ type: "reflection",
2083
+ line: [
2084
+ // NOTE(kevib): these should be numbers
2085
+ // $FlowFixMe[incompatible-type]
2086
+ [null, null],
2087
+ // NOTE(kevib): these should be numbers
2088
+ // $FlowFixMe[incompatible-type]
2089
+ [null, null],
2090
+ ],
2091
+ };
2092
+ } else if (toolId === "dilation") {
2093
+ // NOTE(kevinb): object is missing .constraints property
2094
+ // $FlowFixMe[prop-missing]
2095
+ transform = {
2096
+ type: "dilation",
2097
+ // NOTE(kevib): these should be numbers
2098
+ // $FlowFixMe[incompatible-type]
2099
+ center: [null, null],
2100
+ // NOTE(kevib): this should be a number
2101
+ // $FlowFixMe[incompatible-type]
2102
+ scale: null,
2103
+ };
2104
+ } else {
2105
+ throw new PerseusError(
2106
+ "Invalid tool id: " + toolId,
2107
+ Errors.InvalidInput,
2108
+ );
2109
+ }
2110
+
2111
+ // $FlowFixMe[prop-missing]
2112
+ this.doTransform(transform, function () {
2113
+ self.refs.transformationList.focusLast();
2114
+ });
2115
+ }
2116
+ };
2117
+
2118
+ removeTool: (string) => void = (toolId) => {
2119
+ if (this.currentTool) {
2120
+ this.currentTool.remove();
2121
+ }
2122
+ this.currentTool = null;
2123
+ };
2124
+
2125
+ addTranslationTool: () => $FlowFixMe = () => {
2126
+ const self = this;
2127
+ this.shape?.remove();
2128
+ this.addTransformerShape(this.shape?.toJSON(), /* translatable */ true);
2129
+
2130
+ return {
2131
+ remove: function () {
2132
+ self.shape?.remove();
2133
+ self.addTransformerShape(
2134
+ self.shape?.toJSON(),
2135
+ /* translatable */ false,
2136
+ );
2137
+ },
2138
+ };
2139
+ };
2140
+
2141
+ // Snaps a coord to this.graphie()'s snap
2142
+ snapCoord: (Coord) => Coord = (coord) => {
2143
+ const graphie = this.graphie();
2144
+ return _.map(coord, function (val, dim) {
2145
+ return KhanMath.roundToNearest(graphie.snap[dim], val);
2146
+ });
2147
+ };
2148
+
2149
+ // Normalize the coords into something that fits the new 45 degree
2150
+ // reflection line.
2151
+ normalizeReflectionCoords: ([Coord, Coord]) => [Coord, Coord] = (
2152
+ messyCoords,
2153
+ ) => {
2154
+ const midpoint = this.snapCoord(kline.midpoint(messyCoords));
2155
+ const origDirectionPolar = kvector.polarDegFromCart(
2156
+ kvector.subtract(messyCoords[0], messyCoords[1]),
2157
+ );
2158
+ const directionPolar = [
2159
+ 1,
2160
+ KhanMath.roundToNearest(45, origDirectionPolar[1]),
2161
+ ];
2162
+ const direction = kvector.cartFromPolarDeg(directionPolar);
2163
+ const coords = _.map(
2164
+ [-1, 1],
2165
+ function (directionCoefficient) {
2166
+ const coord = kvector.add(
2167
+ midpoint,
2168
+ kvector.scale(
2169
+ direction,
2170
+ directionCoefficient *
2171
+ this.scaleToCurrentRange(
2172
+ REFLECT_ROTATE_HANDLE_DIST,
2173
+ ),
2174
+ ),
2175
+ );
2176
+ return this.snapCoord(coord);
2177
+ },
2178
+ this,
2179
+ );
2180
+ return coords;
2181
+ };
2182
+
2183
+ addReflectionTool: () => ToolCleanup = () => {
2184
+ const options = this.props.tools.reflection;
2185
+ if (!options.enabled) {
2186
+ return;
2187
+ }
2188
+ const self = this;
2189
+ // eslint-disable-next-line react/no-string-refs
2190
+ const graphie = this.refs.graph.graphie();
2191
+
2192
+ const updateReflectionTool = function () {
2193
+ self.changeTool("reflection", {
2194
+ coords: _.pluck(reflectPoints, "coord"),
2195
+ });
2196
+ };
2197
+
2198
+ const coords = this.normalizeReflectionCoords(options.coords);
2199
+
2200
+ // The points defining the line of reflection; hidden from the
2201
+ // user.
2202
+ const reflectPoints = _.map(
2203
+ coords,
2204
+ function (coord) {
2205
+ return graphie.addMovablePoint({
2206
+ coord: coord,
2207
+ visible: false,
2208
+ });
2209
+ },
2210
+ this,
2211
+ );
2212
+
2213
+ // the line of reflection
2214
+ // TODO(jack): graphie.style here is a hack to prevent the dashed
2215
+ // style from leaking into the rest of the shapes. Remove when
2216
+ // graphie.addMovableLineSegment doesn't leak styles anymore.
2217
+ let reflectLine;
2218
+ const normalColor = colorForTool(options);
2219
+ graphie.style({}, function () {
2220
+ reflectLine = graphie.addMovableLineSegment({
2221
+ fixed: options.constraints.fixed,
2222
+ constraints: options.constraints,
2223
+ pointA: reflectPoints[0],
2224
+ pointZ: reflectPoints[1],
2225
+ snapX: graphie.snap[0],
2226
+ snapY: graphie.snap[1],
2227
+ extendLine: true,
2228
+ normalStyle: {
2229
+ stroke: normalColor,
2230
+ "stroke-width": 2,
2231
+ "stroke-dasharray": "- ",
2232
+ },
2233
+ highlightStyle: {
2234
+ stroke: KhanColors.INTERACTING,
2235
+ "stroke-width": 2,
2236
+ "stroke-dasharray": "- ", // TODO(jack) solid doesn't
2237
+ // work here, but would be
2238
+ // nicer
2239
+ },
2240
+ movePointsWithLine: true,
2241
+ onMoveEnd: updateReflectionTool,
2242
+ });
2243
+ });
2244
+
2245
+ // the "button" point in the center of the line of reflection
2246
+ const reflectButton = graphie.addReflectButton({
2247
+ fixed: options.constraints.fixed,
2248
+ line: reflectLine,
2249
+ size: this.scaleToCurrentRange(REFLECT_BUTTON_SIZE),
2250
+ onClick: function () {
2251
+ self.doTransform({
2252
+ type: "reflection",
2253
+ line: _.pluck(reflectPoints, "coord"),
2254
+ });
2255
+ if (reflectRotateHandle) {
2256
+ // flip the rotation handle
2257
+ reflectRotateHandle.setCoord(
2258
+ kvector.add(
2259
+ reflectButton.coord,
2260
+ kvector.subtract(
2261
+ reflectButton.coord,
2262
+ reflectRotateHandle.coord,
2263
+ ),
2264
+ ),
2265
+ );
2266
+ reflectRotateHandle.update();
2267
+ }
2268
+ },
2269
+ normalStyle: {
2270
+ stroke: normalColor,
2271
+ "stroke-width": 2,
2272
+ fill: normalColor,
2273
+ },
2274
+ highlightStyle: {
2275
+ stroke: KhanColors.INTERACTING,
2276
+ "stroke-width": 3,
2277
+ fill: KhanColors.INTERACTING,
2278
+ },
2279
+ onMoveEnd: updateReflectionTool,
2280
+ });
2281
+
2282
+ let reflectRotateHandle = null;
2283
+ if (!options.constraints.fixed) {
2284
+ // The rotation handle for rotating the line of reflection
2285
+ const initRotateHandleAngle =
2286
+ kvector.polarDegFromCart(
2287
+ kvector.subtract(
2288
+ reflectPoints[1].coord,
2289
+ reflectPoints[0].coord,
2290
+ ),
2291
+ )[1] + 90; // 90 degrees off of the line
2292
+ reflectRotateHandle = graphie.addRotateHandle({
2293
+ center: reflectButton,
2294
+ radius: this.scaleToCurrentRange(REFLECT_ROTATE_HANDLE_DIST),
2295
+ angleDeg: initRotateHandleAngle,
2296
+ width: this.scaleToCurrentRange(0.24),
2297
+ hoverWidth: this.scaleToCurrentRange(0.4),
2298
+ lengthAngle: 17,
2299
+ onMove: function (newAngle) {
2300
+ return KhanMath.roundToNearest(45, newAngle);
2301
+ },
2302
+ onMoveEnd: updateReflectionTool,
2303
+ });
2304
+ }
2305
+
2306
+ // Move the reflectButton and reflectRotateHandle with the line
2307
+ $(reflectLine).on("move", function () {
2308
+ reflectButton.update();
2309
+ $(reflectButton).trigger("move"); // update the rotation handle,
2310
+ // which watches for this in util/interactive.js.
2311
+ });
2312
+
2313
+ // Update the line and reflect button when the reflectRotateHandle is
2314
+ // rotated
2315
+ if (reflectRotateHandle) {
2316
+ $(reflectRotateHandle).on("move", function () {
2317
+ const rotateHandleApprox = self.snapCoord(
2318
+ // $FlowFixMe[incompatible-use]
2319
+ reflectRotateHandle.coord,
2320
+ );
2321
+
2322
+ const rotateVector = kvector.subtract(
2323
+ rotateHandleApprox,
2324
+ reflectButton.coord,
2325
+ );
2326
+
2327
+ const flipped = reflectButton.isFlipped() ? 1 : 0;
2328
+ reflectPoints[flipped].setCoord(
2329
+ kvector.add(
2330
+ reflectButton.coord,
2331
+ kvector.rotateDeg(rotateVector, 90),
2332
+ ),
2333
+ );
2334
+ reflectPoints[1 - flipped].setCoord(
2335
+ kvector.add(
2336
+ reflectButton.coord,
2337
+ kvector.rotateDeg(rotateVector, -90),
2338
+ ),
2339
+ );
2340
+
2341
+ reflectLine.transform(true);
2342
+ reflectButton.update();
2343
+ });
2344
+ }
2345
+
2346
+ return {
2347
+ remove: function () {
2348
+ reflectButton.remove();
2349
+ if (reflectRotateHandle) {
2350
+ reflectRotateHandle.remove();
2351
+ }
2352
+ reflectLine.remove();
2353
+ reflectPoints[0].remove();
2354
+ reflectPoints[1].remove();
2355
+ },
2356
+ };
2357
+ };
2358
+
2359
+ /* Scales a distance from the default range of
2360
+ * [-10, 10] to the current this.props.graph.range
2361
+ *
2362
+ * Used for sizing various transformation tools
2363
+ * (rotation handle, dilation circle)
2364
+ */
2365
+ scaleToCurrentRange: (number) => number = (dist) => {
2366
+ // eslint-disable-next-line react/no-string-refs
2367
+ return scaleToRange(dist, this.refs.graph.props.range);
2368
+ };
2369
+
2370
+ addRotationTool: () => ToolCleanup = () => {
2371
+ const options = this.props.tools.rotation;
2372
+ if (!options.enabled) {
2373
+ return;
2374
+ }
2375
+ const self = this;
2376
+ // eslint-disable-next-line react/no-string-refs
2377
+ const graphie = this.refs.graph.graphie();
2378
+
2379
+ const pointColor = colorForTool(options);
2380
+ // The center of our rotation, which can be moved to change the
2381
+ // center of rotation
2382
+ this.rotatePoint = graphie.addMovablePoint({
2383
+ constraints: options.constraints,
2384
+ coord: options.coord,
2385
+ snapX: graphie.snap[0],
2386
+ snapY: graphie.snap[1],
2387
+ normalStyle: {
2388
+ // ugh, this seems to be a global and
2389
+ "stroke-dasharray": "", // is set to dash above
2390
+ stroke: pointColor,
2391
+ fill: pointColor,
2392
+ },
2393
+ highlightStyle: {
2394
+ "stroke-dasharray": "",
2395
+ stroke: KhanColors.INTERACTING,
2396
+ fill: KhanColors.INTERACTING,
2397
+ },
2398
+ });
2399
+
2400
+ // The point that we move around the center of rotation to actually
2401
+ // cause rotations
2402
+ this.rotateHandle = graphie.addRotateHandle({
2403
+ center: this.rotatePoint,
2404
+ radius: this.scaleToCurrentRange(ROTATE_HANDLE_DIST),
2405
+ width: this.scaleToCurrentRange(0.24),
2406
+ hoverWidth: this.scaleToCurrentRange(0.4),
2407
+ onMove: function (newAngle, oldAngle) {
2408
+ const transform = self.getRotationTransformFromAngle(
2409
+ // $FlowFixMe[incompatible-use]
2410
+ self.rotatePoint.coord,
2411
+ newAngle - oldAngle,
2412
+ );
2413
+
2414
+ // Rotate polygon with rotateHandle
2415
+ self.doTransform(transform);
2416
+
2417
+ // $FlowFixMe[prop-missing]
2418
+ return oldAngle + transform.angleDeg;
2419
+ },
2420
+ });
2421
+
2422
+ // Update tools.rotation.coord
2423
+ // $FlowFixMe[incompatible-use]
2424
+ this.rotatePoint.onMoveEnd = (x, y) => {
2425
+ self.changeTool("rotation", {
2426
+ coord: [x, y],
2427
+ });
2428
+ };
2429
+
2430
+ return {
2431
+ remove: function () {
2432
+ self.rotateHandle?.remove();
2433
+ self.rotatePoint?.remove();
2434
+ },
2435
+ };
2436
+ };
2437
+
2438
+ addDilationTool: () => ToolCleanup = () => {
2439
+ const options = this.props.tools.dilation;
2440
+ if (!options.enabled) {
2441
+ return;
2442
+ }
2443
+ const self = this;
2444
+ // eslint-disable-next-line react/no-string-refs
2445
+ const graphie = this.refs.graph.graphie();
2446
+
2447
+ const pointColor = colorForTool(options);
2448
+ // the circle for causing dilation transforms
2449
+ self.dilationCircle = graphie.addCircleGraph({
2450
+ centerConstraints: options.constraints,
2451
+ center: options.coord,
2452
+ radius: self.scaleToCurrentRange(2),
2453
+ snapX: graphie.snap[0],
2454
+ snapY: graphie.snap[1],
2455
+ minRadius: self.scaleToCurrentRange(1),
2456
+ snapRadius: self.scaleToCurrentRange(0.5),
2457
+ onResize: function (newRadius, oldRadius) {
2458
+ // NOTE(kevinb): object is missing .constraints property
2459
+ // $FlowFixMe[prop-missing]
2460
+ self.doTransform({
2461
+ type: "dilation",
2462
+ // NOTE(kevinb): It's hard to convince that this.dilationCircle
2463
+ // will still be defined here.
2464
+ // $FlowFixMe[incompatible-use]
2465
+ center: self.dilationCircle.centerPoint.coord,
2466
+ scale: newRadius / oldRadius,
2467
+ });
2468
+ },
2469
+ circleNormalStyle: {
2470
+ stroke: pointColor,
2471
+ "stroke-width": 2,
2472
+ "stroke-dasharray": "- ",
2473
+ "fill-opacity": 0,
2474
+ },
2475
+ circleHighlightStyle: {
2476
+ stroke: KhanColors.INTERACTING,
2477
+ "stroke-width": 2,
2478
+ "stroke-dasharray": "",
2479
+ fill: KhanColors.INTERACTING,
2480
+ "fill-opacity": 0.05,
2481
+ },
2482
+ centerNormalStyle: {
2483
+ stroke: pointColor,
2484
+ fill: pointColor,
2485
+ "stroke-width": 2,
2486
+ "stroke-dasharray": "",
2487
+ },
2488
+ centerHighlightStyle: {
2489
+ stroke: pointColor,
2490
+ fill: pointColor,
2491
+ "stroke-width": 2,
2492
+ "stroke-dasharray": "",
2493
+ },
2494
+ });
2495
+
2496
+ // $FlowFixMe[incompatible-use]
2497
+ const origOnMoveEnd = this.dilationCircle.centerPoint.onMoveEnd;
2498
+ // $FlowFixMe[incompatible-use]
2499
+ this.dilationCircle.centerPoint.onMoveEnd = (...args) => {
2500
+ if (origOnMoveEnd) {
2501
+ origOnMoveEnd(args);
2502
+ }
2503
+ self.changeTool("dilation", {
2504
+ // $FlowFixMe[incompatible-use]
2505
+ coord: self.dilationCircle.centerPoint.coord,
2506
+ });
2507
+ };
2508
+
2509
+ return {
2510
+ remove: function () {
2511
+ self.dilationCircle?.remove();
2512
+ },
2513
+ };
2514
+ };
2515
+
2516
+ // returns a transformation object representing a rotation
2517
+ // rounds the angle to the nearest 15 degrees
2518
+ getRotationTransformFromAngle: (
2519
+ Coord,
2520
+ number,
2521
+ ) => TransformerTransformation = (center, angleChanged) => {
2522
+ angleChanged = (angleChanged + 360) % 360;
2523
+ if (angleChanged > 180) {
2524
+ angleChanged -= 360;
2525
+ }
2526
+ const roundedAngle =
2527
+ Math.round(angleChanged / ROTATE_SNAP_DEGREES) *
2528
+ ROTATE_SNAP_DEGREES;
2529
+
2530
+ // NOTE(kevinb): return object is missing .constraints property
2531
+ // $FlowFixMe[prop-missing]
2532
+ return {
2533
+ type: "rotation",
2534
+ center: center,
2535
+ angleDeg: roundedAngle,
2536
+ };
2537
+ };
2538
+
2539
+ // apply and save a transform
2540
+ doTransform: (TransformerTransformation, ?($FlowFixMe) => mixed) => void = (
2541
+ transform,
2542
+ callback,
2543
+ ) => {
2544
+ this.applyTransform(transform);
2545
+ this.addTransform(transform, callback);
2546
+ };
2547
+
2548
+ // apply a transform to our polygon (without modifying our transformation
2549
+ // list)
2550
+ applyTransform: (TransformerTransformation) => void = (transform) => {
2551
+ if (this.props.graphMode !== "static") {
2552
+ const transformFunc = TransformOps.apply(transform);
2553
+ // $FlowFixMe[incompatible-call]
2554
+ this.applyCoordTransformation(transformFunc);
2555
+ }
2556
+ };
2557
+
2558
+ // transform our polygon by transforming each point using a given function
2559
+ applyCoordTransformation: (TransformerTransformation) => void = (
2560
+ pointTransform,
2561
+ ) => {
2562
+ // $FlowFixMe[incompatible-use]
2563
+ _.each(this.shape.points, function (point) {
2564
+ // $FlowFixMe[prop-missing]
2565
+ const newCoord = pointTransform(point.coord);
2566
+ point.setCoord(newCoord);
2567
+ });
2568
+ this.shape?.update();
2569
+ };
2570
+
2571
+ resetCoords: () => void = () => {
2572
+ const startCoords = this.props.starting.shape.coords;
2573
+ // $FlowFixMe[incompatible-use]
2574
+ _.each(this.shape.points, function (point, i) {
2575
+ point.setCoord(startCoords[i]);
2576
+ });
2577
+ this.shape?.update();
2578
+ };
2579
+
2580
+ // Remove the last transformation
2581
+ handleUndoClick: () => void = () => {
2582
+ // eslint-disable-next-line react/no-string-refs
2583
+ this.refs.toolsBar.changeSelected(null);
2584
+ if (this.props.transformations.length) {
2585
+ this.props.onChange({
2586
+ transformations: _.initial(this.props.transformations),
2587
+ });
2588
+ }
2589
+ };
2590
+
2591
+ setTransformationProps: (
2592
+ $ReadOnlyArray<TransformerTransformation>,
2593
+ ($FlowFixMe) => mixed,
2594
+ ) => void = (newTransfomationList, callback) => {
2595
+ this.props.onChange(
2596
+ {
2597
+ transformations: newTransfomationList,
2598
+ },
2599
+ callback,
2600
+ );
2601
+ };
2602
+
2603
+ // add a transformation to our props list of transformation
2604
+ addTransform: (TransformerTransformation, ?($FlowFixMe) => mixed) => void =
2605
+ (transform, callback) => {
2606
+ this.transformations = TransformOps.append(
2607
+ this.transformations,
2608
+ transform,
2609
+ );
2610
+ this.props.onChange(
2611
+ {
2612
+ transformations: _.clone(this.transformations),
2613
+ },
2614
+ callback,
2615
+ );
2616
+ };
2617
+
2618
+ changeTool: (Tool, $FlowFixMe) => void = (tool, changes) => {
2619
+ const newTools = _.clone(this.props.tools);
2620
+ newTools[tool] = _.extend({}, this.props.tools[tool], changes);
2621
+ this.tools[tool] = _.clone(newTools[tool]);
2622
+ this.props.onChange({
2623
+ tools: newTools,
2624
+ });
2625
+ this.props.trackInteraction();
2626
+ };
2627
+
2628
+ simpleValidate: (Rubric) => PerseusScore = (rubric) => {
2629
+ return Transformer.validate(this.getUserInput(), rubric);
2630
+ };
2631
+
2632
+ /**
2633
+ * Calculate where the coordinates would be if they were
2634
+ * moved, even if we're in formal mode with no movement
2635
+ * (and thus the actual movablepoints may not have moved
2636
+ */
2637
+ getCoords: () => Coord = () => {
2638
+ const startCoords = this.props.starting.shape.coords;
2639
+ const transforms = this.props.transformations;
2640
+ return _.reduce(
2641
+ transforms,
2642
+ function (coords, transform) {
2643
+ return _.map(coords, TransformOps.apply(transform));
2644
+ },
2645
+ startCoords,
2646
+ );
2647
+ };
2648
+
2649
+ getEditorJSON: () => $FlowFixMe = () => {
2650
+ const json = _.pick(
2651
+ this.props,
2652
+ "grading",
2653
+ "starting",
2654
+ "graphMode",
2655
+ "listMode",
2656
+ "tools",
2657
+ "drawSolutionShape",
2658
+ "gradeEmpty",
2659
+ );
2660
+ // eslint-disable-next-line react/no-string-refs
2661
+ json.graph = this.refs.graph.toJSON();
2662
+ json.version = 1.2; // Give us some safety to change the format
2663
+ // when we realize that I wrote
2664
+ // a horrible json spec for this widget
2665
+
2666
+ json.answer = this.getUserInput();
2667
+ return json;
2668
+ };
2669
+
2670
+ getUserInput: UserInput = () => {
2671
+ return {
2672
+ transformations: this.props.transformations,
2673
+ // This doesn't call this.shape.toJSON() because that doesn't
2674
+ // handle coordinates in formal mode without movement, since
2675
+ // the movablepoints never move
2676
+ shape: {
2677
+ // $FlowFixMe[incompatible-use]
2678
+ type: this.shape.type,
2679
+ coords: this.getCoords(),
2680
+ options: this.shape?.getOptions(),
2681
+ },
2682
+ };
2683
+ };
2684
+
2685
+ /* InputPath API */
2686
+
2687
+ _handleFocus: (...$ReadOnlyArray<string>) => void = (...path) => {
2688
+ this.props.onFocus(path);
2689
+ };
2690
+
2691
+ _handleBlur: (...$ReadOnlyArray<string>) => void = (...path) => {
2692
+ this.props.onBlur(path);
2693
+ };
2694
+
2695
+ _getTransformationForID: (string) => $FlowFixMe = (transformationID) => {
2696
+ // Returns the 'transformation' component corresponding to a given ID
2697
+ const refPath = [
2698
+ "transformationList",
2699
+ "transformation" + transformationID,
2700
+ ];
2701
+
2702
+ // Follow the path of references
2703
+ let component = this;
2704
+ _.each(refPath, (ref) => {
2705
+ component = component.refs[ref];
2706
+ });
2707
+ return component;
2708
+ };
2709
+
2710
+ getInputPaths: () => $ReadOnlyArray<Path> = () => {
2711
+ // If we're in static mode, then there is no transformation list, and,
2712
+ // as a result, no input paths.
2713
+ if (this.props.listMode === "static") {
2714
+ return [];
2715
+ }
2716
+
2717
+ let inputPaths = [];
2718
+ _.each(this.props.transformations, (transformation, i) => {
2719
+ transformation = this._getTransformationForID(i);
2720
+ const innerPaths = transformation.getInputPaths();
2721
+ const fullPaths = _.map(innerPaths, (innerPath) => {
2722
+ return ["" + i].concat(innerPath);
2723
+ });
2724
+ inputPaths = inputPaths.concat(fullPaths);
2725
+ });
2726
+ return inputPaths;
2727
+ };
2728
+
2729
+ _passToInner: (
2730
+ string,
2731
+ $ReadOnlyArray<string>,
2732
+ ?$FlowFixMe,
2733
+ ?($FlowFixMe) => mixed,
2734
+ ) => $FlowFixMe = function (functionName, path, ...args) {
2735
+ if (!path || !path.length) {
2736
+ return;
2737
+ }
2738
+
2739
+ // First argument tells us which transformation will receive the call;
2740
+ // remaining arguments are used within that transformation to identify
2741
+ // a specific input.
2742
+ const innerPath = _.rest(path);
2743
+
2744
+ // Pass arguments down to appropriate 'transformation' component
2745
+ const transformationID = _.head(path);
2746
+ const caller = this._getTransformationForID(transformationID);
2747
+ return caller[functionName](innerPath, ...args);
2748
+ };
2749
+
2750
+ focus: () => boolean = () => {
2751
+ // Just focus the first showing input
2752
+ const inputs = this.getInputPaths();
2753
+ if (inputs.length > 0) {
2754
+ this.focusInputPath(inputs[0]);
2755
+ return true;
2756
+ }
2757
+ return false;
2758
+ };
2759
+
2760
+ focusInputPath: (Path) => boolean = (path) => {
2761
+ // Since the transformer exposes the input API, it needs to be robust
2762
+ // to empty paths. We don't expect this to happen, as entire-widget
2763
+ // focusing is typically done through the focus() method, which already
2764
+ // handles the empty path case properly, but it's better to be safe
2765
+ // here.
2766
+ if (path.length === 0) {
2767
+ return false;
2768
+ }
2769
+
2770
+ assert(path.length >= 2);
2771
+ return this._passToInner("focusInputPath", path);
2772
+ };
2773
+
2774
+ blurInputPath: (Path) => boolean = (path) => {
2775
+ // Since the transformer exposes the input API, it needs to be robust
2776
+ // to empty paths (which indicate a blurring of the entire widget,
2777
+ // e.g., when switching from interacting with the transformer to
2778
+ // interacting with some other widget).
2779
+ if (path.length === 0) {
2780
+ return false;
2781
+ }
2782
+
2783
+ assert(path.length >= 2);
2784
+ return this._passToInner("blurInputPath", path);
2785
+ };
2786
+
2787
+ setInputValue: (Path, $FlowFixMe, ($FlowFixMe) => mixed) => $FlowFixMe = (
2788
+ path,
2789
+ value,
2790
+ cb,
2791
+ ) => {
2792
+ assert(path.length >= 2);
2793
+ return this._passToInner("setInputValue", path, value, cb);
2794
+ };
2795
+
2796
+ getDOMNodeForPath: (Path) => $FlowFixMe = (path) => {
2797
+ assert(path.length >= 2);
2798
+ return this._passToInner("getDOMNodeForPath", path);
2799
+ };
2800
+
2801
+ getGrammarTypeForPath: (Path) => $FlowFixMe = (path) => {
2802
+ assert(path.length >= 2);
2803
+ return this._passToInner("getGrammarTypeForPath", path);
2804
+ };
2805
+
2806
+ static validate(guess: UserInput, rubric: Rubric): PerseusScore {
2807
+ // Check for any required transformations
2808
+ for (const type in Transformations) {
2809
+ if (rubric.tools[type].required) {
2810
+ const isUsed = _.any(
2811
+ _.map(guess.transformations, function (transform) {
2812
+ // Required transformations must appear in the
2813
+ // transformation list, and must not be no-ops
2814
+ return (
2815
+ transform.type === type &&
2816
+ !TransformOps.isEmpty(transform) &&
2817
+ !TransformOps.isNoOp(transform)
2818
+ );
2819
+ }),
2820
+ );
2821
+
2822
+ if (!isUsed) {
2823
+ return {
2824
+ type: "invalid",
2825
+ message: i18n._(
2826
+ "Your transformation must use a " + "%(type)s.",
2827
+ {
2828
+ type: Transformations[type].lowerNounName,
2829
+ },
2830
+ ),
2831
+ };
2832
+ }
2833
+ }
2834
+ }
2835
+
2836
+ // Compare shapes
2837
+ if (ShapeTypes.equal(guess.shape, rubric.correct.shape)) {
2838
+ return {
2839
+ type: "points",
2840
+ earned: 1,
2841
+ total: 1,
2842
+ message: null,
2843
+ };
2844
+ }
2845
+ if (
2846
+ !rubric.gradeEmpty &&
2847
+ deepEq(guess.shape.coords, rubric.starting.shape.coords)
2848
+ ) {
2849
+ return {
2850
+ type: "invalid",
2851
+ message: i18n._(
2852
+ "Use the interactive graph to define a " +
2853
+ "correct transformation.",
2854
+ ),
2855
+ };
2856
+ }
2857
+ return {
2858
+ type: "points",
2859
+ earned: 0,
2860
+ total: 1,
2861
+ message: null,
2862
+ };
2863
+ }
2864
+ }
2865
+
2866
+ export default ({
2867
+ name: "transformer",
2868
+ displayName: "Transformer",
2869
+ widget: Transformer,
2870
+ }: WidgetExports<typeof Transformer>);