@khanacademy/perseus 0.0.1 → 0.2.1

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 (507) hide show
  1. package/.babelrc.js +1 -1
  2. package/CHANGELOG.md +29 -0
  3. package/dist/es/index.css +4361 -0
  4. package/dist/es/index.js +7 -1
  5. package/dist/es/index.js.map +1 -1
  6. package/dist/index.css +4361 -0
  7. package/dist/index.js +7 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/strings.js +148 -46
  10. package/package.json +50 -4
  11. package/src/README.md +35 -0
  12. package/src/__stories__/item-renderer.stories.jsx +27 -0
  13. package/src/__stories__/server-item-renderer.stories.jsx +27 -0
  14. package/src/__testdata__/item-renderer_testdata.js +161 -0
  15. package/src/__testdata__/renderer_testdata.js +123 -0
  16. package/src/__tests__/__snapshots__/item-renderer_test.jsx.snap +233 -0
  17. package/src/__tests__/__snapshots__/perseus-markdown_test.jsx.snap +516 -0
  18. package/src/__tests__/__snapshots__/pure-markdown_test.js.snap +113 -0
  19. package/src/__tests__/__snapshots__/renderer_test.jsx.snap +576 -0
  20. package/src/__tests__/__snapshots__/server-item-renderer_test.jsx.snap +306 -0
  21. package/src/__tests__/a11y_test.jsx +304 -0
  22. package/src/__tests__/item-renderer_test.jsx +892 -0
  23. package/src/__tests__/mock-asset-loading-widget.jsx +57 -0
  24. package/src/__tests__/mock-widget.jsx +35 -0
  25. package/src/__tests__/perseus-markdown_test.jsx +747 -0
  26. package/src/__tests__/pure-markdown_test.js +552 -0
  27. package/src/__tests__/renderability_test.jsx +481 -0
  28. package/src/__tests__/renderer-api_test.jsx +333 -0
  29. package/src/__tests__/renderer_test.jsx +1792 -0
  30. package/src/__tests__/server-item-renderer_test.jsx +516 -0
  31. package/src/__tests__/test-items/image-item.js +25 -0
  32. package/src/__tests__/test-items/input-number-1-item.js +27 -0
  33. package/src/__tests__/test-items/input-number-2-item.js +39 -0
  34. package/src/__tests__/test-items/table-item.js +30 -0
  35. package/src/__tests__/tex-wrangler_test.js +83 -0
  36. package/src/__tests__/util_test.js +41 -0
  37. package/src/a11y.js +46 -0
  38. package/src/all-widgets.js +9 -0
  39. package/src/article-renderer.jsx +215 -0
  40. package/src/asset-context.js +30 -0
  41. package/src/basic-widgets.js +13 -0
  42. package/src/components/__stories__/button-group.stories.jsx +61 -0
  43. package/src/components/__stories__/fixed-to-responsive.stories.jsx +132 -0
  44. package/src/components/__stories__/graph.stories.jsx +27 -0
  45. package/src/components/__stories__/graphie.stories.jsx +28 -0
  46. package/src/components/__stories__/hud.stories.jsx +36 -0
  47. package/src/components/__stories__/icon.stories.jsx +56 -0
  48. package/src/components/__stories__/image-loader.stories.jsx +60 -0
  49. package/src/components/__stories__/info-tip.stories.jsx +33 -0
  50. package/src/components/__stories__/inline-icon.stories.jsx +39 -0
  51. package/src/components/__stories__/input-with-examples.stories.jsx +51 -0
  52. package/src/components/__stories__/lint.stories.jsx +40 -0
  53. package/src/components/__stories__/math-input.stories.jsx +29 -0
  54. package/src/components/__stories__/math-output.stories.jsx +24 -0
  55. package/src/components/__stories__/multi-button-group.stories.jsx +69 -0
  56. package/src/components/__stories__/number-input.stories.jsx +42 -0
  57. package/src/components/__stories__/possible-answers.stories.jsx +22 -0
  58. package/src/components/__stories__/prop-check-box.stories.jsx +62 -0
  59. package/src/components/__stories__/range-input.stories.jsx +28 -0
  60. package/src/components/__stories__/simple-keypad-input.stories.jsx +28 -0
  61. package/src/components/__stories__/sortable.stories.jsx +73 -0
  62. package/src/components/__stories__/stub-tag-editor.stories.jsx +44 -0
  63. package/src/components/__stories__/svg-image.stories.jsx +60 -0
  64. package/src/components/__stories__/tex.stories.jsx +18 -0
  65. package/src/components/__stories__/text-buttons.stories.jsx +51 -0
  66. package/src/components/__stories__/text-input.stories.jsx +34 -0
  67. package/src/components/__stories__/text-list-editor.stories.jsx +33 -0
  68. package/src/components/__stories__/zoomable-tex.stories.jsx +39 -0
  69. package/src/components/__stories__/zoomable.stories.jsx +40 -0
  70. package/src/components/__testdata__/sorter_testdata.js +20 -0
  71. package/src/components/__tests__/__snapshots__/sortable_test.jsx.snap +79 -0
  72. package/src/components/__tests__/__snapshots__/sorter_test.jsx.snap +297 -0
  73. package/src/components/__tests__/number-input_test.jsx +112 -0
  74. package/src/components/__tests__/sortable_test.jsx +234 -0
  75. package/src/components/__tests__/sorter_test.jsx +111 -0
  76. package/src/components/__tests__/zoomable_test.jsx +388 -0
  77. package/src/components/button-group.jsx +131 -0
  78. package/src/components/constants.js +15 -0
  79. package/src/components/fixed-to-responsive.jsx +164 -0
  80. package/src/components/graph.jsx +415 -0
  81. package/src/components/graphie-classes.jsx +148 -0
  82. package/src/components/graphie-movables.jsx +342 -0
  83. package/src/components/graphie.jsx +353 -0
  84. package/src/components/highlighting/highlightable-content.jsx +245 -0
  85. package/src/components/highlighting/highlights.js +208 -0
  86. package/src/components/highlighting/ranges.js +299 -0
  87. package/src/components/highlighting/types.js +73 -0
  88. package/src/components/highlighting/ui/highlight-renderer.jsx +179 -0
  89. package/src/components/highlighting/ui/highlight-set-renderer.jsx +190 -0
  90. package/src/components/highlighting/ui/highlight-tooltip.jsx +153 -0
  91. package/src/components/highlighting/ui/highlighting-ui.jsx +163 -0
  92. package/src/components/highlighting/ui/selection-tracker.jsx +195 -0
  93. package/src/components/highlighting/ui/types.js +21 -0
  94. package/src/components/highlighting/ui/util.js +166 -0
  95. package/src/components/highlighting/word-indexer.jsx +155 -0
  96. package/src/components/hud.jsx +179 -0
  97. package/src/components/icon.jsx +149 -0
  98. package/src/components/image-loader.jsx +175 -0
  99. package/src/components/info-tip/info-tip.jsx +146 -0
  100. package/src/components/info-tip.jsx +38 -0
  101. package/src/components/inline-icon.jsx +81 -0
  102. package/src/components/input-with-examples.jsx +231 -0
  103. package/src/components/lint.jsx +454 -0
  104. package/src/components/math-input.jsx +282 -0
  105. package/src/components/math-output.jsx +128 -0
  106. package/src/components/multi-button-group.jsx +137 -0
  107. package/src/components/new-tooltip/README.md +110 -0
  108. package/src/components/new-tooltip/__stories__/flexible-position.stories.jsx +437 -0
  109. package/src/components/new-tooltip/__stories__/new-tooltip.stories.jsx +665 -0
  110. package/src/components/new-tooltip/__stories__/tooltip-arrow.stories.jsx +70 -0
  111. package/src/components/new-tooltip/__stories__/tooltip-bubble.stories.jsx +98 -0
  112. package/src/components/new-tooltip/__tests__/style-constants_test.js +8 -0
  113. package/src/components/new-tooltip/__tests__/util_test.js +47 -0
  114. package/src/components/new-tooltip/flexible-position.jsx +182 -0
  115. package/src/components/new-tooltip/new-tooltip.jsx +399 -0
  116. package/src/components/new-tooltip/style-constants.js +13 -0
  117. package/src/components/new-tooltip/tooltip-arrow.jsx +184 -0
  118. package/src/components/new-tooltip/tooltip-bubble.jsx +223 -0
  119. package/src/components/new-tooltip/tooltip-portal.jsx +101 -0
  120. package/src/components/new-tooltip/tooltip-positioner.jsx +529 -0
  121. package/src/components/new-tooltip/types.js +183 -0
  122. package/src/components/new-tooltip/util.js +27 -0
  123. package/src/components/number-input.jsx +253 -0
  124. package/src/components/possible-answers.jsx +36 -0
  125. package/src/components/prop-check-box.jsx +80 -0
  126. package/src/components/range-input.jsx +61 -0
  127. package/src/components/simple-keypad-input.jsx +91 -0
  128. package/src/components/sortable.jsx +873 -0
  129. package/src/components/stub-tag-editor.jsx +54 -0
  130. package/src/components/svg-image.jsx +762 -0
  131. package/src/components/tex-buttons.jsx +322 -0
  132. package/src/components/tex.jsx +70 -0
  133. package/src/components/text-input.jsx +82 -0
  134. package/src/components/text-list-editor.jsx +157 -0
  135. package/src/components/tooltip.jsx +395 -0
  136. package/src/components/visibility-observer/__stories__/visibility-observer.stories.jsx +140 -0
  137. package/src/components/visibility-observer/get-scroll-parent.js +45 -0
  138. package/src/components/visibility-observer/visibility-observer.js +109 -0
  139. package/src/components/visibility-observer/visibility-observer.jsx +123 -0
  140. package/src/components/zoomable-tex.jsx +70 -0
  141. package/src/components/zoomable.jsx +304 -0
  142. package/src/components.js +17 -0
  143. package/src/dependencies.js +22 -0
  144. package/src/error-boundary.jsx +48 -0
  145. package/src/extra-widgets.js +75 -0
  146. package/src/gorgon/README +40 -0
  147. package/src/gorgon/__tests__/matcher_test.js +497 -0
  148. package/src/gorgon/__tests__/rule_test.js +101 -0
  149. package/src/gorgon/__tests__/rules_test.js +487 -0
  150. package/src/gorgon/__tests__/selector-parser_test.js +52 -0
  151. package/src/gorgon/__tests__/tree-transformer_test.js +446 -0
  152. package/src/gorgon/gorgon.js +290 -0
  153. package/src/gorgon/proptypes.js +29 -0
  154. package/src/gorgon/rule.js +413 -0
  155. package/src/gorgon/rules/absolute-url.js +24 -0
  156. package/src/gorgon/rules/all-rules.js +72 -0
  157. package/src/gorgon/rules/blockquoted-math.js +10 -0
  158. package/src/gorgon/rules/blockquoted-widget.js +10 -0
  159. package/src/gorgon/rules/double-spacing-after-terminal.js +12 -0
  160. package/src/gorgon/rules/extra-content-spacing.js +12 -0
  161. package/src/gorgon/rules/heading-level-1.js +14 -0
  162. package/src/gorgon/rules/heading-level-skip.js +20 -0
  163. package/src/gorgon/rules/heading-sentence-case.js +11 -0
  164. package/src/gorgon/rules/heading-title-case.js +63 -0
  165. package/src/gorgon/rules/image-alt-text.js +21 -0
  166. package/src/gorgon/rules/image-in-table.js +10 -0
  167. package/src/gorgon/rules/image-spaces-around-urls.js +35 -0
  168. package/src/gorgon/rules/image-widget.js +50 -0
  169. package/src/gorgon/rules/link-click-here.js +11 -0
  170. package/src/gorgon/rules/lint-utils.js +48 -0
  171. package/src/gorgon/rules/long-paragraph.js +14 -0
  172. package/src/gorgon/rules/math-adjacent.js +10 -0
  173. package/src/gorgon/rules/math-align-extra-break.js +11 -0
  174. package/src/gorgon/rules/math-align-linebreaks.js +43 -0
  175. package/src/gorgon/rules/math-empty.js +10 -0
  176. package/src/gorgon/rules/math-font-size.js +12 -0
  177. package/src/gorgon/rules/math-frac.js +10 -0
  178. package/src/gorgon/rules/math-nested.js +11 -0
  179. package/src/gorgon/rules/math-starts-with-space.js +12 -0
  180. package/src/gorgon/rules/math-text-empty.js +10 -0
  181. package/src/gorgon/rules/math-without-dollars.js +14 -0
  182. package/src/gorgon/rules/nested-lists.js +11 -0
  183. package/src/gorgon/rules/profanity.js +10 -0
  184. package/src/gorgon/rules/table-missing-cells.js +20 -0
  185. package/src/gorgon/rules/unbalanced-code-delimiters.js +14 -0
  186. package/src/gorgon/rules/unescaped-dollar.js +10 -0
  187. package/src/gorgon/rules/widget-in-table.js +10 -0
  188. package/src/gorgon/selector.js +506 -0
  189. package/src/gorgon/tree-transformer.js +588 -0
  190. package/src/hint-renderer.jsx +159 -0
  191. package/src/hints-renderer.jsx +359 -0
  192. package/src/icon-paths.js +120 -0
  193. package/src/index.js +149 -2
  194. package/src/init.js +49 -0
  195. package/src/interaction-tracker.js +60 -0
  196. package/src/interactive2/__tests__/movable-mock.js +48 -0
  197. package/src/interactive2/__tests__/movable-point_test.js +146 -0
  198. package/src/interactive2/interactive-util.js +152 -0
  199. package/src/interactive2/movable-helper-methods.js +160 -0
  200. package/src/interactive2/movable-line-options.js +392 -0
  201. package/src/interactive2/movable-line.js +322 -0
  202. package/src/interactive2/movable-point-options.js +190 -0
  203. package/src/interactive2/movable-point.jsx +603 -0
  204. package/src/interactive2/movable-polygon-options.js +351 -0
  205. package/src/interactive2/movable-polygon.js +368 -0
  206. package/src/interactive2/movable.js +273 -0
  207. package/src/interactive2/objective_.js +79 -0
  208. package/src/interactive2/types.js +3 -0
  209. package/src/interactive2/wrapped-defaults.js +86 -0
  210. package/src/interactive2/wrapped-ellipse.js +87 -0
  211. package/src/interactive2/wrapped-line.js +109 -0
  212. package/src/interactive2/wrapped-path.js +47 -0
  213. package/src/interactive2.js +91 -0
  214. package/src/item-renderer.jsx +518 -0
  215. package/src/item-version.js +12 -0
  216. package/src/jipt-paragraphs.js +44 -0
  217. package/src/jquery.mobile.vmouse.js +545 -0
  218. package/src/keypad-context.js +33 -0
  219. package/src/loading-context.js +20 -0
  220. package/src/logging/log.js +107 -0
  221. package/src/logging/log_test.js +41 -0
  222. package/src/mixins/changeable.jsx +115 -0
  223. package/src/mixins/editor-jsonify.jsx +13 -0
  224. package/src/mixins/provide-keypad.jsx +129 -0
  225. package/src/mixins/widget-jsonify-deprecated.jsx +23 -0
  226. package/src/mixins/widget-prop-blacklist.js +36 -0
  227. package/src/multi-items/__stories__/multi-renderer.stories.js +88 -0
  228. package/src/multi-items/__testdata__/multi-renderer_testdata.js +174 -0
  229. package/src/multi-items/__tests__/__snapshots__/multi-renderer_test.js.snap +994 -0
  230. package/src/multi-items/__tests__/items_test.js +245 -0
  231. package/src/multi-items/__tests__/multi-renderer_test.js +903 -0
  232. package/src/multi-items/__tests__/prop-type-builders_test.js +134 -0
  233. package/src/multi-items/__tests__/shapes_test.js +44 -0
  234. package/src/multi-items/__tests__/trees_test.js +316 -0
  235. package/src/multi-items/item-types.js +55 -0
  236. package/src/multi-items/items.js +176 -0
  237. package/src/multi-items/multi-renderer.jsx +543 -0
  238. package/src/multi-items/prop-type-builders.js +105 -0
  239. package/src/multi-items/shape-types.js +47 -0
  240. package/src/multi-items/shapes.js +53 -0
  241. package/src/multi-items/tree-types.js +42 -0
  242. package/src/multi-items/trees.js +345 -0
  243. package/src/multi-items.js +76 -0
  244. package/src/not-gorgon.js +129 -0
  245. package/src/perseus-api.jsx +270 -0
  246. package/src/perseus-env.js +26 -0
  247. package/src/perseus-error.js +19 -0
  248. package/src/perseus-markdown.jsx +433 -0
  249. package/src/perseus-types.js +1597 -0
  250. package/src/pure-markdown.js +347 -0
  251. package/src/question-paragraph.jsx +30 -0
  252. package/src/renderability.jsx +108 -0
  253. package/src/renderer.jsx +1932 -0
  254. package/src/server-item-renderer.jsx +467 -0
  255. package/src/sigfigs.jsx +492 -0
  256. package/src/styles/articles.less +748 -0
  257. package/src/styles/constants.js +119 -0
  258. package/src/styles/global-constants.js +350 -0
  259. package/src/styles/global-styles.js +307 -0
  260. package/src/styles/khan-exercise.css +1304 -0
  261. package/src/styles/media-queries.js +48 -0
  262. package/src/styles/perseus-renderer.less +1007 -0
  263. package/src/styles/shared.js +132 -0
  264. package/src/styles/util.less +47 -0
  265. package/src/styles/variables.boostrap.less +301 -0
  266. package/src/styles/variables.less +68 -0
  267. package/src/styles/variables.shared.less +356 -0
  268. package/src/styles/widgets/categorizer.less +39 -0
  269. package/src/styles/widgets/definition.less +86 -0
  270. package/src/styles/widgets/dropdown.less +3 -0
  271. package/src/styles/widgets/expression.less +147 -0
  272. package/src/styles/widgets/graded-group.less +36 -0
  273. package/src/styles/widgets/grapher.less +11 -0
  274. package/src/styles/widgets/group.less +19 -0
  275. package/src/styles/widgets/image.less +29 -0
  276. package/src/styles/widgets/interactive-graph.less +61 -0
  277. package/src/styles/widgets/label-image.less +41 -0
  278. package/src/styles/widgets/matcher.less +8 -0
  279. package/src/styles/widgets/matrix.less +120 -0
  280. package/src/styles/widgets/measurer.less +20 -0
  281. package/src/styles/widgets/orderer.less +97 -0
  282. package/src/styles/widgets/passage.less +151 -0
  283. package/src/styles/widgets/plotter.less +33 -0
  284. package/src/styles/widgets/radio.less +52 -0
  285. package/src/styles/widgets/reaction-diagram.less +35 -0
  286. package/src/styles/widgets/sequence.less +5 -0
  287. package/src/styles/widgets/sortable.less +127 -0
  288. package/src/styles/widgets/table.less +56 -0
  289. package/src/styles/widgets/transformer.less +28 -0
  290. package/src/styles/widgets/unit.less +14 -0
  291. package/src/tex-wrangler.js +176 -0
  292. package/src/traversal.jsx +141 -0
  293. package/src/types.js +538 -0
  294. package/src/util/a11y.js +24 -0
  295. package/src/util/answer-types.js +862 -0
  296. package/src/util/answer-types_test.js +115 -0
  297. package/src/util/colors.js +94 -0
  298. package/src/util/fix-passage-refs.jsx +122 -0
  299. package/src/util/graph-utils.js +16 -0
  300. package/src/util/graphie.js +1586 -0
  301. package/src/util/interactive.js +4078 -0
  302. package/src/util/katex-preprocess.js +16 -0
  303. package/src/util/math.js +296 -0
  304. package/src/util/mobile-native-utils.js +14 -0
  305. package/src/util/mobile-native-utils_test.js +24 -0
  306. package/src/util/register-all-widgets-for-testing.js +12 -0
  307. package/src/util/scroll-utils.js +162 -0
  308. package/src/util/sizing-utils.js +46 -0
  309. package/src/util/tex.js +203 -0
  310. package/src/util/url-utils.js +36 -0
  311. package/src/util.js +990 -0
  312. package/src/version.js +11 -0
  313. package/src/widget-container.jsx +174 -0
  314. package/src/widgets/__stories__/categorizer.stories.jsx +15 -0
  315. package/src/widgets/__stories__/cs-program.stories.jsx +15 -0
  316. package/src/widgets/__stories__/definition.stories.jsx +38 -0
  317. package/src/widgets/__stories__/dropdown.stories.jsx +15 -0
  318. package/src/widgets/__stories__/explanation.stories.jsx +19 -0
  319. package/src/widgets/__stories__/expression.stories.jsx +26 -0
  320. package/src/widgets/__stories__/graded-group-set.stories.jsx +32 -0
  321. package/src/widgets/__stories__/graded-group.stories.jsx +32 -0
  322. package/src/widgets/__stories__/grapher.stories.jsx +47 -0
  323. package/src/widgets/__stories__/group.stories.jsx +15 -0
  324. package/src/widgets/__stories__/iframe.stories.jsx +15 -0
  325. package/src/widgets/__stories__/image.stories.jsx +62 -0
  326. package/src/widgets/__stories__/input-number.stories.jsx +90 -0
  327. package/src/widgets/__stories__/interaction.stories.jsx +15 -0
  328. package/src/widgets/__stories__/interactive-graph.stories.jsx +61 -0
  329. package/src/widgets/__stories__/matcher.stories.jsx +15 -0
  330. package/src/widgets/__stories__/matrix.stories.jsx +15 -0
  331. package/src/widgets/__stories__/number-line.stories.jsx +15 -0
  332. package/src/widgets/__stories__/numeric-input.stories.jsx +19 -0
  333. package/src/widgets/__stories__/orderer.stories.jsx +15 -0
  334. package/src/widgets/__stories__/passage-ref.stories.jsx +19 -0
  335. package/src/widgets/__stories__/passage.stories.jsx +19 -0
  336. package/src/widgets/__stories__/radio.stories.jsx +82 -0
  337. package/src/widgets/__stories__/sorter.stories.jsx +15 -0
  338. package/src/widgets/__stories__/transformer.stories.jsx +15 -0
  339. package/src/widgets/__stories__/unit.stories.jsx +45 -0
  340. package/src/widgets/__stories__/video-transcript-link.stories.jsx +24 -0
  341. package/src/widgets/__stories__/video.stories.jsx +15 -0
  342. package/src/widgets/__testdata__/categorizer_testdata.js +50 -0
  343. package/src/widgets/__testdata__/cs-program_testdata.js +28 -0
  344. package/src/widgets/__testdata__/dropdown_testdata.js +34 -0
  345. package/src/widgets/__testdata__/explanation_testdata.js +56 -0
  346. package/src/widgets/__testdata__/expression_testdata.js +95 -0
  347. package/src/widgets/__testdata__/graded-group-set_testdata.js +358 -0
  348. package/src/widgets/__testdata__/graded-group_testdata.js +79 -0
  349. package/src/widgets/__testdata__/grapher_testdata.js +376 -0
  350. package/src/widgets/__testdata__/group_testdata.js +156 -0
  351. package/src/widgets/__testdata__/iframe_testdata.js +36 -0
  352. package/src/widgets/__testdata__/image_testdata.js +38 -0
  353. package/src/widgets/__testdata__/input-number_testdata.js +73 -0
  354. package/src/widgets/__testdata__/interaction_testdata.js +264 -0
  355. package/src/widgets/__testdata__/interactive-graph_testdata.js +646 -0
  356. package/src/widgets/__testdata__/matcher_testdata.js +34 -0
  357. package/src/widgets/__testdata__/matrix_testdata.js +29 -0
  358. package/src/widgets/__testdata__/number-line_testdata.js +31 -0
  359. package/src/widgets/__testdata__/numeric-input_testdata.js +266 -0
  360. package/src/widgets/__testdata__/orderer_testdata.js +58 -0
  361. package/src/widgets/__testdata__/passage-ref_testdata.js +41 -0
  362. package/src/widgets/__testdata__/passage_testdata.js +71 -0
  363. package/src/widgets/__testdata__/radio_testdata.js +210 -0
  364. package/src/widgets/__testdata__/sorter_testdata.js +20 -0
  365. package/src/widgets/__testdata__/transformer_testdata.js +109 -0
  366. package/src/widgets/__testdata__/video_testdata.js +22 -0
  367. package/src/widgets/__tests__/__snapshots__/categorizer_test.jsx.snap +928 -0
  368. package/src/widgets/__tests__/__snapshots__/cs-program_test.jsx.snap +75 -0
  369. package/src/widgets/__tests__/__snapshots__/definition_test.js.snap +99 -0
  370. package/src/widgets/__tests__/__snapshots__/dropdown_test.jsx.snap +143 -0
  371. package/src/widgets/__tests__/__snapshots__/explanation_test.js.snap +473 -0
  372. package/src/widgets/__tests__/__snapshots__/expression_test.jsx.snap +38 -0
  373. package/src/widgets/__tests__/__snapshots__/graded-group-set-jipt_test.jsx.snap +754 -0
  374. package/src/widgets/__tests__/__snapshots__/graded-group-set_test.jsx.snap +310 -0
  375. package/src/widgets/__tests__/__snapshots__/graded-group_test.jsx.snap +863 -0
  376. package/src/widgets/__tests__/__snapshots__/grapher_test.jsx.snap +2880 -0
  377. package/src/widgets/__tests__/__snapshots__/group_test.jsx.snap +1154 -0
  378. package/src/widgets/__tests__/__snapshots__/iframe_test.jsx.snap +69 -0
  379. package/src/widgets/__tests__/__snapshots__/image_test.jsx.snap +388 -0
  380. package/src/widgets/__tests__/__snapshots__/input-number_test.jsx.snap +978 -0
  381. package/src/widgets/__tests__/__snapshots__/interaction_test.jsx.snap +311 -0
  382. package/src/widgets/__tests__/__snapshots__/interactive-graph_test.jsx.snap +2915 -0
  383. package/src/widgets/__tests__/__snapshots__/matcher_test.jsx.snap +1039 -0
  384. package/src/widgets/__tests__/__snapshots__/matrix_test.jsx.snap +450 -0
  385. package/src/widgets/__tests__/__snapshots__/number-line_test.jsx.snap +888 -0
  386. package/src/widgets/__tests__/__snapshots__/numeric-input_test.jsx.snap +930 -0
  387. package/src/widgets/__tests__/__snapshots__/orderer_test.jsx.snap +275 -0
  388. package/src/widgets/__tests__/__snapshots__/passage-ref_test.jsx.snap +446 -0
  389. package/src/widgets/__tests__/__snapshots__/passage_test.jsx.snap +1988 -0
  390. package/src/widgets/__tests__/__snapshots__/radio_test.jsx.snap +7107 -0
  391. package/src/widgets/__tests__/__snapshots__/video_test.jsx.snap +163 -0
  392. package/src/widgets/__tests__/categorizer_test.jsx +205 -0
  393. package/src/widgets/__tests__/cs-program_test.jsx +45 -0
  394. package/src/widgets/__tests__/definition_test.js +152 -0
  395. package/src/widgets/__tests__/dropdown_test.jsx +106 -0
  396. package/src/widgets/__tests__/explanation_test.js +199 -0
  397. package/src/widgets/__tests__/expression_test.jsx +439 -0
  398. package/src/widgets/__tests__/graded-group-set-jipt_test.jsx +33 -0
  399. package/src/widgets/__tests__/graded-group-set_test.jsx +270 -0
  400. package/src/widgets/__tests__/graded-group_test.jsx +233 -0
  401. package/src/widgets/__tests__/grapher.cypress.jsx +486 -0
  402. package/src/widgets/__tests__/grapher_test.jsx +35 -0
  403. package/src/widgets/__tests__/group_test.jsx +512 -0
  404. package/src/widgets/__tests__/iframe_test.jsx +47 -0
  405. package/src/widgets/__tests__/image_test.jsx +65 -0
  406. package/src/widgets/__tests__/input-number_test.jsx +360 -0
  407. package/src/widgets/__tests__/interaction_test.jsx +33 -0
  408. package/src/widgets/__tests__/interactive-graph.cypress.jsx +697 -0
  409. package/src/widgets/__tests__/interactive-graph_test.jsx +105 -0
  410. package/src/widgets/__tests__/matcher_test.jsx +157 -0
  411. package/src/widgets/__tests__/matrix_test.jsx +84 -0
  412. package/src/widgets/__tests__/number-line_test.jsx +81 -0
  413. package/src/widgets/__tests__/numeric-input_test.jsx +574 -0
  414. package/src/widgets/__tests__/orderer_test.jsx +75 -0
  415. package/src/widgets/__tests__/passage-ref_test.jsx +166 -0
  416. package/src/widgets/__tests__/passage_test.jsx +133 -0
  417. package/src/widgets/__tests__/radio/base-radio_test.jsx +82 -0
  418. package/src/widgets/__tests__/radio_test.jsx +651 -0
  419. package/src/widgets/__tests__/renderQuestion.jsx +66 -0
  420. package/src/widgets/__tests__/unit_test.jsx +144 -0
  421. package/src/widgets/__tests__/video_test.jsx +55 -0
  422. package/src/widgets/categorizer.jsx +338 -0
  423. package/src/widgets/cs-program.jsx +227 -0
  424. package/src/widgets/definition.jsx +141 -0
  425. package/src/widgets/dropdown.jsx +143 -0
  426. package/src/widgets/explanation.jsx +315 -0
  427. package/src/widgets/expression.jsx +652 -0
  428. package/src/widgets/graded-group-answer-bar.jsx +152 -0
  429. package/src/widgets/graded-group-set.jsx +285 -0
  430. package/src/widgets/graded-group.jsx +448 -0
  431. package/src/widgets/grapher/util.jsx +806 -0
  432. package/src/widgets/grapher.jsx +663 -0
  433. package/src/widgets/group.jsx +213 -0
  434. package/src/widgets/iframe.jsx +198 -0
  435. package/src/widgets/image.jsx +310 -0
  436. package/src/widgets/input-number.jsx +411 -0
  437. package/src/widgets/interaction/arrow-picker.jsx +39 -0
  438. package/src/widgets/interaction/color-picker.jsx +69 -0
  439. package/src/widgets/interaction/dash-picker.jsx +46 -0
  440. package/src/widgets/interaction/element-container.jsx +97 -0
  441. package/src/widgets/interaction.jsx +742 -0
  442. package/src/widgets/interactive-graph.jsx +2820 -0
  443. package/src/widgets/label-image/__stories__/answer-choices.stories.jsx +93 -0
  444. package/src/widgets/label-image/__stories__/marker.stories.jsx +172 -0
  445. package/src/widgets/label-image/answer-choices.jsx +311 -0
  446. package/src/widgets/label-image/marker.jsx +365 -0
  447. package/src/widgets/label-image/types.js +33 -0
  448. package/src/widgets/label-image.jsx +841 -0
  449. package/src/widgets/label-image_test.js +840 -0
  450. package/src/widgets/lights-puzzle.jsx +356 -0
  451. package/src/widgets/matcher.jsx +271 -0
  452. package/src/widgets/matrix.jsx +616 -0
  453. package/src/widgets/measurer.jsx +215 -0
  454. package/src/widgets/molecule/__tests__/layout_test.jsx +159 -0
  455. package/src/widgets/molecule/__tests__/parser_test.jsx +142 -0
  456. package/src/widgets/molecule/molecule-drawing.jsx +175 -0
  457. package/src/widgets/molecule/molecule-layout.jsx +367 -0
  458. package/src/widgets/molecule/smiles-parser.jsx +300 -0
  459. package/src/widgets/molecule.jsx +172 -0
  460. package/src/widgets/number-line.jsx +858 -0
  461. package/src/widgets/numeric-input.jsx +622 -0
  462. package/src/widgets/orderer.jsx +753 -0
  463. package/src/widgets/passage/__tests__/passage-markdown_test.jsx +382 -0
  464. package/src/widgets/passage/get-line-height-for-node.js +10 -0
  465. package/src/widgets/passage/passage-markdown.jsx +416 -0
  466. package/src/widgets/passage-ref-target.jsx +70 -0
  467. package/src/widgets/passage-ref.jsx +198 -0
  468. package/src/widgets/passage.jsx +601 -0
  469. package/src/widgets/plotter.jsx +1198 -0
  470. package/src/widgets/radio/__stories__/base-radio.stories.jsx +40 -0
  471. package/src/widgets/radio/__stories__/choice-icon.stories.jsx +157 -0
  472. package/src/widgets/radio/__stories__/choice-none-above.stories.jsx +26 -0
  473. package/src/widgets/radio/__stories__/cross-out-button.stories.jsx +64 -0
  474. package/src/widgets/radio/__stories__/cross-out-menu.stories.jsx +59 -0
  475. package/src/widgets/radio/__stories__/cross-out-tooltip.stories.jsx +59 -0
  476. package/src/widgets/radio/__stories__/focus-ring.stories.jsx +38 -0
  477. package/src/widgets/radio/__stories__/option-status.stories.jsx +118 -0
  478. package/src/widgets/radio/__stories__/toggleable-radio-button.stories.jsx +28 -0
  479. package/src/widgets/radio/base-radio.jsx +642 -0
  480. package/src/widgets/radio/choice-icon.jsx +468 -0
  481. package/src/widgets/radio/choice-none-above.jsx +54 -0
  482. package/src/widgets/radio/choice.jsx +692 -0
  483. package/src/widgets/radio/choice.jsx.fixture.js +64 -0
  484. package/src/widgets/radio/cross-out-button.jsx +136 -0
  485. package/src/widgets/radio/cross-out-menu-wrapper.jsx +492 -0
  486. package/src/widgets/radio/cross-out-tooltip.jsx +129 -0
  487. package/src/widgets/radio/focus-ring.jsx +51 -0
  488. package/src/widgets/radio/option-status.jsx +89 -0
  489. package/src/widgets/radio/toggleable-radio-button.jsx +133 -0
  490. package/src/widgets/radio/util.js +32 -0
  491. package/src/widgets/radio/util_test.js +19 -0
  492. package/src/widgets/radio/widget.jsx +470 -0
  493. package/src/widgets/radio.jsx +156 -0
  494. package/src/widgets/reaction-diagram.jsx +137 -0
  495. package/src/widgets/sequence.jsx +140 -0
  496. package/src/widgets/simulator.jsx +808 -0
  497. package/src/widgets/sorter.jsx +146 -0
  498. package/src/widgets/table.jsx +394 -0
  499. package/src/widgets/transformer.jsx +2876 -0
  500. package/src/widgets/unit.jsx +372 -0
  501. package/src/widgets/video-transcript-link.jsx +84 -0
  502. package/src/widgets/video-transcript-link_test.js +211 -0
  503. package/src/widgets/video.jsx +126 -0
  504. package/src/widgets.js +431 -0
  505. package/src/zoom.js +539 -0
  506. package/README.md +0 -3
  507. package/src/__tests__/index.test.js +0 -14
@@ -0,0 +1,841 @@
1
+ // @flow
2
+ /**
3
+ * Direct image labeling widget.
4
+ *
5
+ * Label on image widget enables creating more natural, conceptual questions
6
+ * that involve the use of images, and enable learners to demonstrate their
7
+ * knowledge by directly interacting with the image.
8
+ */
9
+
10
+ import * as i18n from "@khanacademy/wonder-blocks-i18n";
11
+ import {Popover, PopoverContentCore} from "@khanacademy/wonder-blocks-popover";
12
+ import {StyleSheet, css} from "aphrodite";
13
+ import classNames from "classnames";
14
+ import * as React from "react";
15
+ import ReactDOM from "react-dom";
16
+
17
+ import AssetContext from "../asset-context.js";
18
+ import SvgImage from "../components/svg-image.jsx";
19
+ import Renderer from "../renderer.jsx";
20
+ import {typography} from "../styles/global-styles.js";
21
+ import mediaQueries from "../styles/media-queries.js";
22
+
23
+ import AnswerChoices from "./label-image/answer-choices.jsx";
24
+ import Marker from "./label-image/marker.jsx";
25
+
26
+ import type {ChangeableProps} from "../mixins/changeable.jsx";
27
+ import type {APIOptions, PerseusScore, WidgetExports} from "../types.js";
28
+ import type {
29
+ InteractiveMarkerType,
30
+ InteractiveMarkerScore,
31
+ } from "./label-image/types.js";
32
+
33
+ type MarkersState = {|markers: $ReadOnlyArray<InteractiveMarkerType>|};
34
+
35
+ /**
36
+ * Represents a direction vector.
37
+ * No diagonals allowed.
38
+ */
39
+ type Direction =
40
+ | {|
41
+ x: 0,
42
+ y: 1 | -1,
43
+ |}
44
+ | {|
45
+ x: 1 | -1,
46
+ y: 0,
47
+ |}
48
+ | {|
49
+ x: 0,
50
+ y: 0,
51
+ |};
52
+
53
+ type Point = {|
54
+ x: number,
55
+ y: number,
56
+ |};
57
+
58
+ type LabelImageProps = {|
59
+ ...ChangeableProps,
60
+ apiOptions: APIOptions,
61
+ // The list of possible answer choices.
62
+ choices: $ReadOnlyArray<string>,
63
+ // The question image properties.
64
+ imageAlt: string,
65
+ imageUrl: string,
66
+ imageWidth: number,
67
+ imageHeight: number,
68
+ // The list of label markers on the question image.
69
+ markers: $ReadOnlyArray<InteractiveMarkerType>,
70
+ // Whether multiple answer choices may be selected for markers.
71
+ multipleAnswers: boolean,
72
+ // Whether to hide answer choices from user instructions.
73
+ hideChoicesFromInstructions: boolean,
74
+ // Whether the question has been answered by the user.
75
+ questionCompleted: boolean,
76
+ |};
77
+
78
+ type LabelImageState = {|
79
+ // The user selected marker index, defaults to -1, no selection.
80
+ selectedMarkerIndex: number,
81
+
82
+ // Whether any of the markers were interacted with by the user.
83
+ markersInteracted: boolean,
84
+ |};
85
+
86
+ class LabelImage extends React.Component<LabelImageProps, LabelImageState> {
87
+ // The rendered markers on the question image for labeling.
88
+ // eslint-disable-next-line flowtype/no-mutable-array
89
+ _markers: Array<?Marker>;
90
+
91
+ // The popup component containing the answer choices.
92
+ _selectedMarkerPopup: ?Popover;
93
+
94
+ // The rendered list of answer choices for the currently selected marker.
95
+ _answerChoices: ?AnswerChoices;
96
+
97
+ static gradeMarker(marker: InteractiveMarkerType): InteractiveMarkerScore {
98
+ const score = {
99
+ hasAnswers: false,
100
+ isCorrect: false,
101
+ };
102
+
103
+ if (marker.selected && marker.selected.length > 0) {
104
+ score.hasAnswers = true;
105
+ }
106
+
107
+ if (marker.answers.length > 0) {
108
+ if (
109
+ marker.selected &&
110
+ marker.selected.length === marker.answers.length
111
+ ) {
112
+ // All correct answers are selected by the user.
113
+ score.isCorrect = marker.selected.every((choice) =>
114
+ marker.answers.includes(choice),
115
+ );
116
+ }
117
+ } else if (!marker.selected || marker.selected.length === 0) {
118
+ // Correct as no answers should be selected by the user.
119
+ score.isCorrect = true;
120
+ }
121
+
122
+ return score;
123
+ }
124
+
125
+ static validate(
126
+ state: MarkersState,
127
+ rubric?: LabelImageProps,
128
+ ): PerseusScore {
129
+ let numAnswered = 0;
130
+ let numCorrect = 0;
131
+
132
+ for (const marker of state.markers) {
133
+ const score = LabelImage.gradeMarker(marker);
134
+
135
+ if (score.hasAnswers) {
136
+ numAnswered++;
137
+ }
138
+
139
+ if (score.isCorrect) {
140
+ numCorrect++;
141
+ }
142
+ }
143
+
144
+ // We expect all question markers to be answered before grading.
145
+ if (numAnswered !== state.markers.length) {
146
+ return {
147
+ type: "invalid",
148
+ message: null,
149
+ };
150
+ }
151
+
152
+ return {
153
+ type: "points",
154
+ // Markers with no expected answers are graded as correct if user
155
+ // makes no answer selection.
156
+ earned: numCorrect === state.markers.length ? 1 : 0,
157
+ total: 1,
158
+ message: null,
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Test whether point is contained within triangle.
164
+ *
165
+ * Implementation taken from: https://stackoverflow.com/a/2049593
166
+ */
167
+ static pointInTriangle(p: Point, a: Point, b: Point, c: Point): boolean {
168
+ const sign = (p1, p2, p3) =>
169
+ (p1.x - p3.x) * (p2.y - p3.y) - (p2.x - p3.x) * (p1.y - p3.y);
170
+
171
+ const b1 = sign(p, a, b) < 0;
172
+ const b2 = sign(p, b, c) < 0;
173
+ const b3 = sign(p, c, a) < 0;
174
+
175
+ return b1 === b2 && b2 === b3;
176
+ }
177
+
178
+ /**
179
+ * Determine the image side given a marker position (as percent of size).
180
+ */
181
+ static imageSideForMarkerPosition(
182
+ x: number,
183
+ y: number,
184
+ ): "bottom" | "left" | "right" | "top" | "center" {
185
+ // Special handling for when marker is positioned near the horizontal
186
+ // edges of the image. We want to ensure the returned side would not
187
+ // result in a popup rendering that may overflow outside the page.
188
+ if (x < 20) {
189
+ return "left";
190
+ }
191
+ if (x > 80) {
192
+ return "right";
193
+ }
194
+
195
+ // Points to define corners (and center) of a rectangle representing
196
+ // the constrained area of the image for which we want to determine the
197
+ // image side.
198
+ const tl = {x: 20, y: 0};
199
+ const tr = {x: 80, y: 0};
200
+ const br = {x: 80, y: 100};
201
+ const bl = {x: 20, y: 100};
202
+ const cp = {x: 50, y: 50};
203
+
204
+ // The triangles representing the sides to test.
205
+ const triangles = {
206
+ top: [tl, tr, cp],
207
+ right: [cp, tr, br],
208
+ bottom: [bl, cp, br],
209
+ left: [tl, cp, bl],
210
+ };
211
+
212
+ const p = {x, y};
213
+
214
+ // Test whether marker is positioned within one of the triangles
215
+ // representing the sides.
216
+ for (const side of Object.keys(triangles)) {
217
+ const corners = triangles[side];
218
+
219
+ if (LabelImage.pointInTriangle(p, ...corners)) {
220
+ return side;
221
+ }
222
+ }
223
+
224
+ // Fallback, if side was not found for marker position.
225
+ return "center";
226
+ }
227
+
228
+ /**
229
+ * Calculate the next marker to navigate to, from the "this marker".
230
+ *
231
+ * Given a cardinal navigation direction (in the x or y axis), return the
232
+ * next marker index to visit.
233
+ */
234
+ static navigateToMarkerIndex(
235
+ navigateDirection: Direction,
236
+ markers: $ReadOnlyArray<InteractiveMarkerType>,
237
+ thisIndex: number,
238
+ ): number {
239
+ const thisMarker = markers[thisIndex];
240
+
241
+ // Sort markers along the direction of navigation from "this" marker.
242
+ const sortedMarkers = markers
243
+ .map((otherMarker, index) => {
244
+ // Calculate distance and (normalized) vector to the marker from
245
+ // "this" marker.
246
+ const x = otherMarker.x - thisMarker.x;
247
+ const y = otherMarker.y - thisMarker.y;
248
+
249
+ const dist = Math.sqrt(x ** 2 + y ** 2);
250
+
251
+ return {
252
+ index,
253
+ dist,
254
+ dir: {
255
+ x: dist !== 0 ? x / dist : 0,
256
+ y: dist !== 0 ? y / dist : 0,
257
+ },
258
+ };
259
+ })
260
+ .filter((marker) => {
261
+ if (marker.index === thisIndex) {
262
+ return false;
263
+ }
264
+
265
+ // Exclude any marker that has been answered correctly,
266
+ // as it should no longer be interacted with by the user.
267
+ return markers[marker.index].showCorrectness !== "correct";
268
+ })
269
+ .sort((a, b) => {
270
+ // Project distances to the two markers being sorted (from the
271
+ // "this" marker) along the direction of navigation. This will
272
+ // order by the "closest" marker in the direction of navigation,
273
+ // not the "absolute" closest marker to "this" marker.
274
+ // We round the calculated distances so that if markers are
275
+ // found to be positioned "close enough" to the "this" marker,
276
+ // they will be treated as coplanar with it.
277
+ const distA = Math.round(
278
+ a.dist *
279
+ (navigateDirection.x * a.dir.x +
280
+ navigateDirection.y * a.dir.y),
281
+ );
282
+ const distB = Math.round(
283
+ b.dist *
284
+ (navigateDirection.x * b.dir.x +
285
+ navigateDirection.y * b.dir.y),
286
+ );
287
+
288
+ let dirA;
289
+ let dirB;
290
+ // Determine whether the two markers lie ahead or behind the
291
+ // "this" marker. A marker that lies in the same plane as the
292
+ // "this" marker will be considered to be behind it.
293
+ // Only navigation in the "x" or "y" axis is supported, no
294
+ // diagonals.
295
+ if (navigateDirection.x > 0) {
296
+ dirA = a.dir.x > 0 && distA !== 0;
297
+ dirB = b.dir.x > 0 && distB !== 0;
298
+ } else if (navigateDirection.x < 0) {
299
+ dirA = a.dir.x < 0 && distA !== 0;
300
+ dirB = b.dir.x < 0 && distB !== 0;
301
+ } else if (navigateDirection.y > 0) {
302
+ dirA = a.dir.y > 0 && distA !== 0;
303
+ dirB = b.dir.y > 0 && distB !== 0;
304
+ } else if (navigateDirection.y < 0) {
305
+ dirA = a.dir.y < 0 && distA !== 0;
306
+ dirB = b.dir.y < 0 && distB !== 0;
307
+ }
308
+
309
+ // If one of the markers is positioned behind, sort the other
310
+ // marker ahead of it in the order.
311
+ if (dirA !== dirB) {
312
+ if (dirA) {
313
+ return -1;
314
+ }
315
+ return 1;
316
+ }
317
+
318
+ // Both markers are either positioned ahead or behind, sort the
319
+ // closest (or furthest if behind, to "wrap" navigation) marker
320
+ // ahead in the order.
321
+ return distA - distB;
322
+ });
323
+
324
+ // Return the index in the provided markers array of the closest marker
325
+ // found along the direction of navigation, or "this" index if no other
326
+ // marker was found. This may happen if the user has answered all but
327
+ // one ("this" marker) of the markers correctly in the question.
328
+ return sortedMarkers.length > 0 ? sortedMarkers[0].index : thisIndex;
329
+ }
330
+
331
+ constructor(props: LabelImageProps) {
332
+ super(props);
333
+
334
+ this._markers = [];
335
+
336
+ this.state = {
337
+ selectedMarkerIndex: -1,
338
+ markersInteracted: false,
339
+ };
340
+ }
341
+
342
+ componentDidMount() {
343
+ document.addEventListener("click", this.handleDocumentClick, true);
344
+ document.addEventListener("keydown", this.handleDocumentKeyDown, true);
345
+ }
346
+
347
+ componentWillUnmount() {
348
+ document.removeEventListener("click", this.handleDocumentClick, true);
349
+ document.removeEventListener(
350
+ "keydown",
351
+ this.handleDocumentKeyDown,
352
+ true,
353
+ );
354
+ }
355
+
356
+ simpleValidate(rubric: LabelImageProps): PerseusScore {
357
+ return LabelImage.validate(this.getUserInput(), rubric);
358
+ }
359
+
360
+ getUserInput(): MarkersState {
361
+ const {markers} = this.props;
362
+ return {markers};
363
+ }
364
+
365
+ showRationalesForCurrentlySelectedChoices(rubric: LabelImageProps) {
366
+ const {markers} = this.props;
367
+ const {onChange} = this.props;
368
+
369
+ const updatedMarkers = markers.map((marker) => {
370
+ const score = LabelImage.gradeMarker(marker);
371
+
372
+ return {
373
+ ...marker,
374
+ // Reveal correctness state for markers with answers.
375
+ showCorrectness: score.hasAnswers
376
+ ? score.isCorrect
377
+ ? "correct"
378
+ : "incorrect"
379
+ : undefined,
380
+ };
381
+ });
382
+
383
+ // Update Perseus widget state with user selected answers without
384
+ // triggering interaction events for listeners.
385
+ onChange({markers: updatedMarkers}, null, true);
386
+ }
387
+
388
+ dismissMarkerPopup() {
389
+ const {selectedMarkerIndex: index} = this.state;
390
+
391
+ // No popup should be open if there's no selected marker.
392
+ if (index === -1) {
393
+ return;
394
+ }
395
+
396
+ this.setState({selectedMarkerIndex: -1}, () => {
397
+ const marker = this._markers[index];
398
+ // Set focus on the just-deselected-marker, to enable to resume
399
+ // navigating between the markers using the keyboard.
400
+ if (marker) {
401
+ ((ReactDOM.findDOMNode(marker): any): HTMLElement).focus();
402
+ }
403
+ });
404
+ }
405
+
406
+ handleDocumentClick: (e: MouseEvent) => void = (e: MouseEvent) => {
407
+ // Dismiss open popup with answer choices if user clicks outside it.
408
+ if (this._selectedMarkerPopup && this._answerChoices) {
409
+ const answerChoices = ReactDOM.findDOMNode(this._answerChoices);
410
+
411
+ const targetElement = ((e.target: any): Node);
412
+ // HACK(michaelpolyak): We want to determine if the click target is
413
+ // contained within the popup layer. As there's no public interface
414
+ // to get this layer from the popup, we traverse several levels of
415
+ // answer choices parents to test whether the click target is
416
+ // contained within.
417
+ const containsEventTarget = (element, depth = 3) =>
418
+ element &&
419
+ (element.contains(targetElement) ||
420
+ (depth > 0 &&
421
+ containsEventTarget(element.parentElement, depth - 1)));
422
+
423
+ if (!containsEventTarget(answerChoices)) {
424
+ // Close popup and set focus back to the marker for which it was
425
+ // open.
426
+ this.dismissMarkerPopup();
427
+ }
428
+ }
429
+ };
430
+
431
+ handleDocumentKeyDown: (e: KeyboardEvent) => void = (e: KeyboardEvent) => {
432
+ // Dismiss open popup with answer choices if user presses Escape key.
433
+ if (this._selectedMarkerPopup && e.keyCode === 27) {
434
+ // Ensure other listeners are not triggered on key down event that
435
+ // closes the popup, as this will also dismiss the modal that the
436
+ // widget may be rendered within.
437
+ e.stopPropagation();
438
+
439
+ // Close popup and set focus back to the marker for which it was
440
+ // open.
441
+ this.dismissMarkerPopup();
442
+ }
443
+ };
444
+
445
+ handleMarkerChange(index: number, marker: InteractiveMarkerType) {
446
+ const {markers, onChange} = this.props;
447
+
448
+ // Replace marker with a changed version at the specified index.
449
+ const updatedMarkers = [
450
+ ...markers.slice(0, index),
451
+ {
452
+ ...marker,
453
+ // Do not show correctness state if user changes answer
454
+ // selection for marker.
455
+ showCorrectness: undefined,
456
+ },
457
+ ...markers.slice(index + 1),
458
+ ];
459
+
460
+ // Update Perseus widget state with user selected answers.
461
+ onChange({markers: updatedMarkers});
462
+ }
463
+
464
+ handleMarkerClick(index: number, e: MouseEvent) {
465
+ const {selectedMarkerIndex} = this.state;
466
+
467
+ e.preventDefault();
468
+
469
+ // Select the marker, revealing answer choices.
470
+ if (selectedMarkerIndex !== index) {
471
+ this.setState(
472
+ {
473
+ selectedMarkerIndex: index,
474
+ markersInteracted: true,
475
+ },
476
+ () => {
477
+ // A delay is required to allow answer choices to first render
478
+ // before trying to set focus on the first choice.
479
+ // TODO(jeff, CP-3128): Use Wonder Blocks Timing API
480
+ // eslint-disable-next-line no-restricted-syntax
481
+ setTimeout(() => {
482
+ if (this._answerChoices) {
483
+ this._answerChoices.focusAnswer();
484
+ }
485
+ }, 100);
486
+ },
487
+ );
488
+ } else {
489
+ // The answer choices are shown within a popup attached to the
490
+ // selected marker. Close the popup and set focus back to the
491
+ // marker.
492
+ this.dismissMarkerPopup();
493
+ }
494
+ }
495
+
496
+ handleMarkerKeyDown(index: number, e: KeyboardEvent) {
497
+ const {markers} = this.props;
498
+
499
+ // One is the loneliest number.
500
+ if (markers.length < 2) {
501
+ return;
502
+ }
503
+
504
+ // Only navigation in the "x" or "y" axis is supported, no diagonals.
505
+ const navigateDirection = {
506
+ ArrowUp: {x: 0, y: -1},
507
+ ArrowRight: {x: 1, y: 0},
508
+ ArrowDown: {x: 0, y: 1},
509
+ ArrowLeft: {x: -1, y: 0},
510
+ }[e.key];
511
+
512
+ if (!navigateDirection) {
513
+ return;
514
+ }
515
+
516
+ e.preventDefault();
517
+
518
+ // Focus on the closest marker along the direction of navigation.
519
+ const marker =
520
+ this._markers[
521
+ LabelImage.navigateToMarkerIndex(
522
+ navigateDirection,
523
+ markers,
524
+ index,
525
+ )
526
+ ];
527
+
528
+ if (marker) {
529
+ ((ReactDOM.findDOMNode(marker): any): HTMLElement).focus();
530
+ }
531
+ }
532
+
533
+ handleAnswerChoicesChangeForMarker(
534
+ index: number,
535
+ selection: $ReadOnlyArray<boolean>,
536
+ ) {
537
+ const {choices, markers, multipleAnswers} = this.props;
538
+
539
+ // Compile the user selected answer choices.
540
+ const selected = choices.filter((choice, index) => selection[index]);
541
+
542
+ this.handleMarkerChange(index, {
543
+ ...markers[index],
544
+ selected,
545
+ });
546
+
547
+ if (!multipleAnswers) {
548
+ this.dismissMarkerPopup();
549
+ }
550
+ }
551
+
552
+ renderAnswerChoicesForMarker(
553
+ index: number,
554
+ marker: InteractiveMarkerType,
555
+ ): React.Element<"div"> {
556
+ const {choices, multipleAnswers} = this.props;
557
+
558
+ // The user selected answer choices.
559
+ const selected = marker.selected;
560
+
561
+ return (
562
+ <div
563
+ className={classNames(
564
+ "perseus-label-image-widget-answer-choices",
565
+ css(styles.scrollableChoices),
566
+ )}
567
+ >
568
+ <AnswerChoices
569
+ choices={choices.map((choice) => ({
570
+ content: choice,
571
+ checked: selected ? selected.includes(choice) : false,
572
+ }))}
573
+ multipleSelect={multipleAnswers}
574
+ onChange={(selection) =>
575
+ this.handleAnswerChoicesChangeForMarker(
576
+ index,
577
+ selection,
578
+ )
579
+ }
580
+ ref={(node) => (this._answerChoices = node)}
581
+ />
582
+ </div>
583
+ );
584
+ }
585
+
586
+ renderMarkers(): $ReadOnlyArray<React.Node> {
587
+ const {markers, questionCompleted} = this.props;
588
+
589
+ const {selectedMarkerIndex, markersInteracted} = this.state;
590
+
591
+ // Render all markers for widget.
592
+ return markers.map((marker, index): React.Node => {
593
+ const score = LabelImage.gradeMarker(marker);
594
+
595
+ const element = (
596
+ <Marker
597
+ {...marker}
598
+ // Once the question is answered, show markers
599
+ // with correct answers, otherwise passthrough
600
+ // the correctness state.
601
+ showCorrectness={
602
+ questionCompleted && score.hasAnswers && score.isCorrect
603
+ ? "correct"
604
+ : marker.showCorrectness
605
+ }
606
+ showSelected={index === selectedMarkerIndex}
607
+ showPulsate={!markersInteracted}
608
+ key={`${marker.x}.${marker.y}`}
609
+ onClick={(e) => this.handleMarkerClick(index, e)}
610
+ onKeyDown={(e) => this.handleMarkerKeyDown(index, e)}
611
+ ref={(node) => (this._markers[index] = node)}
612
+ />
613
+ );
614
+
615
+ // The user selected marker is wrapped with a popup that shows its
616
+ // answer choices, otherwise it's returned as is.
617
+ if (index !== selectedMarkerIndex) {
618
+ return element;
619
+ }
620
+
621
+ // Determine whether page is rendered in a narrow browser window.
622
+ const isNarrowPage = window.matchMedia(
623
+ mediaQueries.xsOrSmaller.replace("@media ", ""),
624
+ ).matches;
625
+
626
+ let side;
627
+ // Position popup closest to the center, preferring it renders
628
+ // entirely within the image area.
629
+ if (isNarrowPage) {
630
+ side = marker.y > 50 ? "top" : "bottom";
631
+ } else {
632
+ // This mirrors the calculated side of where the marker is
633
+ // located within the image, so that popup appears closer to
634
+ // the image center.
635
+ side = {
636
+ left: "right",
637
+ top: "bottom",
638
+ right: "left",
639
+ bottom: "top",
640
+ center: "top",
641
+ }[LabelImage.imageSideForMarkerPosition(marker.x, marker.y)];
642
+ }
643
+
644
+ // TODO(michaelpolyak): Ideally we would always render markers
645
+ // wrapped in the popover. Setting `opened={false}` for those
646
+ // markers that are unselected (to hide their popup), this would
647
+ // keep the React tree more stable.
648
+ return (
649
+ <Popover
650
+ content={() => (
651
+ <PopoverContentCore style={styles.choicesPopover}>
652
+ {this.renderAnswerChoicesForMarker(index, marker)}
653
+ </PopoverContentCore>
654
+ )}
655
+ placement={side}
656
+ opened={true}
657
+ key={`${marker.x}.${marker.y}`}
658
+ ref={(node) => (this._selectedMarkerPopup = node)}
659
+ >
660
+ {element}
661
+ </Popover>
662
+ );
663
+ });
664
+ }
665
+
666
+ renderInstructions(): React.Node {
667
+ const {
668
+ apiOptions: {isMobile},
669
+ choices,
670
+ multipleAnswers,
671
+ hideChoicesFromInstructions: hideChoices,
672
+ } = this.props;
673
+
674
+ const promptString = isMobile
675
+ ? multipleAnswers
676
+ ? i18n._(
677
+ "Tap each dot on the image to select all answers that apply.",
678
+ )
679
+ : i18n._("Tap each dot on the image to select an answer.")
680
+ : multipleAnswers
681
+ ? i18n._(
682
+ "Click each dot on the image to select all answers that apply.",
683
+ )
684
+ : i18n._("Click each dot on the image to select an answer.");
685
+
686
+ const choicesString = i18n._("Choices:");
687
+
688
+ return (
689
+ <div
690
+ className={classNames(
691
+ "perseus-label-image-widget-instructions",
692
+ css(styles.instructions),
693
+ )}
694
+ >
695
+ <div className={css(styles.instructionsCaption)}>
696
+ {promptString} {!hideChoices && choicesString}
697
+ </div>
698
+
699
+ {!hideChoices && (
700
+ <div className={css(styles.instructionsChoices)}>
701
+ {choices.map((choice, index) => (
702
+ <div
703
+ className={css(styles.instructionsChoice)}
704
+ key={index}
705
+ >
706
+ <Renderer content={choice} />
707
+ </div>
708
+ ))}
709
+ </div>
710
+ )}
711
+ </div>
712
+ );
713
+ }
714
+
715
+ render(): React.Node {
716
+ const {imageAlt, imageUrl, imageWidth, imageHeight} = this.props;
717
+
718
+ const {selectedMarkerIndex} = this.state;
719
+
720
+ return (
721
+ <div>
722
+ {this.renderInstructions()}
723
+ <div
724
+ className={css(styles.markersCanvas)}
725
+ style={{
726
+ maxWidth: imageWidth,
727
+ maxHeight: imageHeight,
728
+ }}
729
+ >
730
+ <div
731
+ className={css(
732
+ styles.imageContainer,
733
+ // Ensure image interaction is disabled while answer
734
+ // choices popup is visible. This addresses specific
735
+ // case where user may click on question image to
736
+ // dismiss the popup. If the image is larger in size
737
+ // than its rendered in the widget, this would
738
+ // result in a zoom of the image.
739
+ selectedMarkerIndex !== -1 &&
740
+ styles.imageInteractionDisabled,
741
+ )}
742
+ >
743
+ <AssetContext.Consumer>
744
+ {({setAssetStatus}) => (
745
+ <SvgImage
746
+ alt={imageAlt}
747
+ src={imageUrl}
748
+ width={imageWidth}
749
+ height={imageHeight}
750
+ setAssetStatus={setAssetStatus}
751
+ />
752
+ )}
753
+ </AssetContext.Consumer>
754
+ </div>
755
+ {this.renderMarkers()}
756
+ </div>
757
+ </div>
758
+ );
759
+ }
760
+ }
761
+
762
+ const styles = StyleSheet.create({
763
+ instructions: {
764
+ paddingBottom: 16,
765
+ },
766
+
767
+ instructionsCaption: {
768
+ ...typography.bodyXsmallBold,
769
+
770
+ paddingBottom: 16,
771
+ },
772
+
773
+ instructionsChoices: {
774
+ display: "flex",
775
+ flexWrap: "wrap",
776
+
777
+ margin: "-8px 0",
778
+ },
779
+
780
+ instructionsChoice: {
781
+ display: "flex",
782
+ alignItems: "center",
783
+
784
+ margin: "8px 0",
785
+
786
+ ":not(:last-child)": {
787
+ "::after": {
788
+ content: "''",
789
+ display: "inline-block",
790
+ position: "relative",
791
+
792
+ width: 2,
793
+ height: 2,
794
+
795
+ marginLeft: 5,
796
+ marginRight: 5,
797
+
798
+ background: "rgba(33, 36, 44, 0.32)",
799
+
800
+ borderRadius: 2,
801
+ },
802
+ },
803
+ },
804
+
805
+ markersCanvas: {
806
+ position: "relative",
807
+ },
808
+
809
+ imageContainer: {
810
+ // Remove the additional height added due to white-spacing, this ensures
811
+ // markers canvas will have the same height as the question image.
812
+ display: "flex",
813
+ },
814
+
815
+ imageInteractionDisabled: {
816
+ pointerEvents: "none",
817
+ },
818
+
819
+ scrollableChoices: {
820
+ paddingTop: 8,
821
+ paddingBottom: 8,
822
+
823
+ // Enable scroll for answer choices within popup on mobile phones.
824
+ [mediaQueries.xsOrSmaller]: {
825
+ maxHeight: 250,
826
+ overflowY: "auto",
827
+ },
828
+ },
829
+
830
+ choicesPopover: {
831
+ padding: 0,
832
+ },
833
+ });
834
+
835
+ export default ({
836
+ name: "label-image",
837
+ displayName: "Label Image",
838
+ widget: LabelImage,
839
+ accessible: true,
840
+ isLintable: true,
841
+ }: WidgetExports<typeof LabelImage>);