@khanacademy/perseus-editor 0.0.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/.babelrc.js +1 -1
  2. package/CHANGELOG.md +26 -0
  3. package/dist/es/index.css +1020 -0
  4. package/dist/es/index.js +9 -1
  5. package/dist/es/index.js.map +1 -1
  6. package/dist/index.css +1020 -0
  7. package/dist/index.js +9 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/strings.js +6 -1
  10. package/package.json +16 -4
  11. package/src/README.md +7 -0
  12. package/src/__stories__/editor.stories.js +33 -0
  13. package/src/__stories__/section-control-button.stories.jsx +27 -0
  14. package/src/__testdata__/input-number_testdata.js +28 -0
  15. package/src/__tests__/editor_test.jsx +123 -0
  16. package/src/__tests__/i18n_test.jsx +585 -0
  17. package/src/__tests__/traversal_test.jsx +378 -0
  18. package/src/all-editors.js +85 -0
  19. package/src/all-editors_test.js +17 -0
  20. package/src/all-widgets.js +10 -0
  21. package/src/article-editor.jsx +437 -0
  22. package/src/components/__stories__/blur-input.stories.jsx +24 -0
  23. package/src/components/blur-input.jsx +62 -0
  24. package/src/components/checkbox.jsx +194 -0
  25. package/src/components/device-framer.jsx +91 -0
  26. package/src/components/drag-target.jsx +88 -0
  27. package/src/components/dropdown-option.jsx +318 -0
  28. package/src/components/form-wrapped-text-field.jsx +248 -0
  29. package/src/components/graph-settings.jsx +631 -0
  30. package/src/components/hover-behavior.jsx +178 -0
  31. package/src/components/json-editor.jsx +121 -0
  32. package/src/components/link.jsx +173 -0
  33. package/src/components/simple-button.jsx +86 -0
  34. package/src/components/sortable.jsx +186 -0
  35. package/src/components/util.js +39 -0
  36. package/src/components/viewport-resizer.jsx +73 -0
  37. package/src/diffs/__stories__/perseus-diff-wrapper.jsx +8 -0
  38. package/src/diffs/__stories__/structured-item-diff.stories.jsx +855 -0
  39. package/src/diffs/__stories__/tags-diff.stories.jsx +38 -0
  40. package/src/diffs/__stories__/text-diff.stories.jsx +37 -0
  41. package/src/diffs/__tests__/split-diff_test.js +34 -0
  42. package/src/diffs/__tests__/string-array-diff_test.js +74 -0
  43. package/src/diffs/__tests__/structured-item-diff_test.js +432 -0
  44. package/src/diffs/__tests__/widget-diff-performer_test.js +44 -0
  45. package/src/diffs/article-diff.jsx +70 -0
  46. package/src/diffs/item-diff.jsx +77 -0
  47. package/src/diffs/renderer-diff.jsx +143 -0
  48. package/src/diffs/split-diff.jsx +30 -0
  49. package/src/diffs/string-array-diff.jsx +75 -0
  50. package/src/diffs/structured-item-diff.jsx +269 -0
  51. package/src/diffs/tags-diff.jsx +50 -0
  52. package/src/diffs/text-diff.jsx +194 -0
  53. package/src/diffs/widget-diff-performer.jsx +74 -0
  54. package/src/diffs/widget-diff.jsx +291 -0
  55. package/src/editor-page.jsx +351 -0
  56. package/src/editor.jsx +1364 -0
  57. package/src/hint-editor.jsx +447 -0
  58. package/src/i18n.jsx +139 -0
  59. package/src/iframe-content-renderer.jsx +201 -0
  60. package/src/index.js +27 -8
  61. package/src/item-editor.jsx +159 -0
  62. package/src/item-extras-editor.jsx +150 -0
  63. package/src/katex-error-view.jsx +87 -0
  64. package/src/multirenderer-editor.jsx +1074 -0
  65. package/src/section-control-button.jsx +53 -0
  66. package/src/stateful-article-editor.jsx +176 -0
  67. package/src/stateful-editor-page.jsx +90 -0
  68. package/src/styles/perseus-editor.less +1339 -0
  69. package/src/testing-widgets.js +12 -0
  70. package/src/util/register-all-widgets-and-editors-for-testing.js +15 -0
  71. package/src/util/register-all-widgets-and-editors-for-testing_test.js +18 -0
  72. package/src/widgets/__stories__/label-image-editor.stories.jsx +97 -0
  73. package/src/widgets/categorizer-editor.jsx +103 -0
  74. package/src/widgets/cs-program-editor.jsx +258 -0
  75. package/src/widgets/definition-editor.jsx +75 -0
  76. package/src/widgets/dropdown-editor.jsx +201 -0
  77. package/src/widgets/example-graphie-widget-editor.jsx +70 -0
  78. package/src/widgets/example-graphie-widget.jsx +175 -0
  79. package/src/widgets/example-widget-editor.jsx +60 -0
  80. package/src/widgets/example-widget.jsx +153 -0
  81. package/src/widgets/explanation-editor.jsx +89 -0
  82. package/src/widgets/expression-editor.jsx +560 -0
  83. package/src/widgets/graded-group-editor.jsx +162 -0
  84. package/src/widgets/graded-group-set-editor.jsx +101 -0
  85. package/src/widgets/grapher-editor.jsx +154 -0
  86. package/src/widgets/group-editor.jsx +83 -0
  87. package/src/widgets/iframe-editor.jsx +200 -0
  88. package/src/widgets/image-editor.jsx +326 -0
  89. package/src/widgets/input-number-editor.jsx +269 -0
  90. package/src/widgets/interaction/constraint-editor.jsx +147 -0
  91. package/src/widgets/interaction-editor.jsx +1389 -0
  92. package/src/widgets/interactive-graph-editor.jsx +322 -0
  93. package/src/widgets/label-image/__stories__/answer-choices.stories.jsx +81 -0
  94. package/src/widgets/label-image/__stories__/behavior.stories.jsx +32 -0
  95. package/src/widgets/label-image/__stories__/marker.stories.jsx +76 -0
  96. package/src/widgets/label-image/__stories__/question-markers.stories.jsx +106 -0
  97. package/src/widgets/label-image/__stories__/select-image.stories.jsx +60 -0
  98. package/src/widgets/label-image/answer-choices.jsx +231 -0
  99. package/src/widgets/label-image/behavior.jsx +92 -0
  100. package/src/widgets/label-image/marker.jsx +292 -0
  101. package/src/widgets/label-image/question-markers.jsx +181 -0
  102. package/src/widgets/label-image/select-image.jsx +86 -0
  103. package/src/widgets/label-image-editor.jsx +257 -0
  104. package/src/widgets/lights-puzzle-editor.jsx +267 -0
  105. package/src/widgets/matcher-editor.jsx +147 -0
  106. package/src/widgets/matrix-editor.jsx +155 -0
  107. package/src/widgets/measurer-editor.jsx +245 -0
  108. package/src/widgets/molecule-editor.jsx +63 -0
  109. package/src/widgets/number-line-editor.jsx +498 -0
  110. package/src/widgets/numeric-input-editor.jsx +554 -0
  111. package/src/widgets/orderer-editor.jsx +166 -0
  112. package/src/widgets/passage-editor.jsx +115 -0
  113. package/src/widgets/passage-ref-editor.jsx +77 -0
  114. package/src/widgets/passage-ref-target-editor.jsx +47 -0
  115. package/src/widgets/plotter-editor.jsx +537 -0
  116. package/src/widgets/radio/editor.jsx +398 -0
  117. package/src/widgets/reaction-diagram-editor.jsx +127 -0
  118. package/src/widgets/sequence-editor.jsx +205 -0
  119. package/src/widgets/simple-markdown-tester-editor.jsx +84 -0
  120. package/src/widgets/simple-markdown-tester.jsx +91 -0
  121. package/src/widgets/simulator-editor.jsx +144 -0
  122. package/src/widgets/sorter-editor.jsx +100 -0
  123. package/src/widgets/table-editor.jsx +164 -0
  124. package/src/widgets/transformer-editor.jsx +1006 -0
  125. package/src/widgets/unit-editor.jsx +293 -0
  126. package/src/widgets/video-editor.jsx +74 -0
  127. package/src/__tests__/index.test.js +0 -15
package/src/editor.jsx ADDED
@@ -0,0 +1,1364 @@
1
+ // @flow
2
+ import {
3
+ components,
4
+ icons,
5
+ preprocessTex,
6
+ Dependencies,
7
+ Errors,
8
+ Log,
9
+ PerseusError,
10
+ PerseusMarkdown,
11
+ Util,
12
+ Widgets,
13
+ WIDGET_PROP_BLACKLIST,
14
+ } from "@khanacademy/perseus";
15
+ import $ from "jquery";
16
+ // eslint-disable-next-line import/no-unresolved
17
+ import typeof katex from "katex";
18
+ import * as React from "react";
19
+ import * as ReactDOM from "react-dom";
20
+ import _ from "underscore";
21
+
22
+ import DragTarget from "./components/drag-target.jsx";
23
+ import KatexErrorView from "./katex-error-view.jsx";
24
+ import SectionControlButton from "./section-control-button.jsx";
25
+
26
+ import type {Alignment, ChangeHandler, WidgetInfo} from "@khanacademy/perseus";
27
+
28
+ const {InlineIcon, PropCheckBox} = components;
29
+ const {iconChevronDown, iconChevronRight, iconTrash} = icons;
30
+
31
+ // like [[snowman input-number 1]]
32
+ const widgetPlaceholder = "[[\u2603 {id}]]";
33
+ const widgetRegExp = "(\\[\\[\u2603 {id}\\]\\])";
34
+ const rWidgetSplit = new RegExp(
35
+ widgetRegExp.replace("{id}", "[a-z-]+ [0-9]+"),
36
+ "g",
37
+ );
38
+
39
+ const shortcutRegexp = /^\[\[([a-z-]+)$/; // like [[nu, [[int, etc
40
+
41
+ const ENDS_WITH_A_PARAGRAPH = /(?:\n{2,}|^\n*)$/;
42
+ const TRAILING_NEWLINES = /(\n*)$/;
43
+ const LEADING_NEWLINES = /^(\n*)/;
44
+
45
+ const commafyInteger = (n) => {
46
+ let str = n.toString();
47
+ if (str.length >= 5) {
48
+ str = str.replace(/(\d)(?=(\d{3})+$)/g, "$1{,}");
49
+ }
50
+ return str;
51
+ };
52
+ const makeEndWithAParagraphIfNecessary = (content) => {
53
+ if (!ENDS_WITH_A_PARAGRAPH.test(content)) {
54
+ const match = TRAILING_NEWLINES.exec(content);
55
+ if (match) {
56
+ const newlines = match[1];
57
+ return content + "\n\n".slice(0, 2 - newlines.length);
58
+ }
59
+ }
60
+ return content;
61
+ };
62
+ const makeStartWithAParagraphAlways = (content) => {
63
+ const match = LEADING_NEWLINES.exec(content);
64
+ if (!match) {
65
+ return content;
66
+ }
67
+ const newlines = match[1];
68
+ return "\n\n".slice(0, 2 - newlines.length) + content;
69
+ };
70
+
71
+ type WidgetSelectProps = {|
72
+ onChange?: (widgetType: string) => mixed,
73
+ |};
74
+
75
+ class WidgetSelect extends React.Component<WidgetSelectProps> {
76
+ shouldComponentUpdate() {
77
+ return false;
78
+ }
79
+
80
+ handleChange = (e: SyntheticEvent<HTMLSelectElement>) => {
81
+ const widgetType = e.currentTarget.value;
82
+ if (widgetType === "") {
83
+ // TODO(alpert): Not sure if change will trigger here
84
+ // but might as well be safe
85
+ return;
86
+ }
87
+ if (this.props.onChange) {
88
+ this.props.onChange(widgetType);
89
+ }
90
+ };
91
+
92
+ render(): React.Node {
93
+ const widgets = Widgets.getPublicWidgets();
94
+ const orderedWidgetNames = _.sortBy(_.keys(widgets), (name) => {
95
+ return widgets[name].displayName;
96
+ });
97
+ const addWidgetString = "Add a widget\u2026";
98
+ return (
99
+ <select value="" onChange={this.handleChange}>
100
+ <option value="">{addWidgetString}</option>
101
+ <option disabled>--</option>
102
+ {_.map(orderedWidgetNames, (name) => {
103
+ return (
104
+ <option key={name} value={name}>
105
+ {widgets[name].displayName}
106
+ </option>
107
+ );
108
+ })}
109
+ </select>
110
+ );
111
+ }
112
+ }
113
+
114
+ type WidgetEditorProps = {|
115
+ // Unserialized props
116
+ id: string,
117
+ onChange: (
118
+ widgetInfo: WidgetInfo,
119
+ cb?: () => mixed,
120
+ silent?: boolean,
121
+ ) => mixed,
122
+ onRemove: () => mixed,
123
+ apiOptions: any,
124
+
125
+ ...WidgetInfo,
126
+ |};
127
+
128
+ type WidgetEditorState = {|
129
+ showWidget: boolean,
130
+ widgetInfo: WidgetInfo,
131
+ |};
132
+
133
+ const _upgradeWidgetInfo = (props: WidgetEditorProps): WidgetInfo => {
134
+ // We can't call serialize here because this.refs.widget
135
+ // doesn't exist before this component is mounted.
136
+ const filteredProps = _.omit(props, WIDGET_PROP_BLACKLIST);
137
+ return Widgets.upgradeWidgetInfoToLatestVersion(filteredProps);
138
+ };
139
+
140
+ // This component handles upgading widget editor props via prop
141
+ // upgrade transforms. Widget editors will always be rendered
142
+ // with all available transforms applied, but the results of those
143
+ // transforms will not be propogated upwards until serialization.
144
+ // eslint-disable-next-line react/no-unsafe
145
+ class WidgetEditor extends React.Component<
146
+ WidgetEditorProps,
147
+ WidgetEditorState,
148
+ > {
149
+ constructor(props: WidgetEditorProps) {
150
+ super(props);
151
+ this.state = {
152
+ showWidget: false,
153
+ widgetInfo: _upgradeWidgetInfo(props),
154
+ };
155
+ }
156
+
157
+ UNSAFE_componentWillReceiveProps(nextProps: WidgetEditorProps) {
158
+ this.setState({widgetInfo: _upgradeWidgetInfo(nextProps)});
159
+ }
160
+
161
+ _toggleWidget = (e: SyntheticEvent<>) => {
162
+ e.preventDefault();
163
+ this.setState({showWidget: !this.state.showWidget});
164
+ };
165
+
166
+ _handleWidgetChange = (
167
+ newProps: WidgetEditorProps,
168
+ cb: () => mixed,
169
+ silent: boolean,
170
+ ) => {
171
+ const newWidgetInfo = ((Object.assign(
172
+ {},
173
+ this.state.widgetInfo,
174
+ ): any): WidgetInfo);
175
+ newWidgetInfo.options = Object.assign(
176
+ // eslint-disable-next-line react/no-string-refs
177
+ this.refs.widget.serialize(),
178
+ newProps,
179
+ );
180
+ this.props.onChange(newWidgetInfo, cb, silent);
181
+ };
182
+
183
+ _toggleStatic = (e: Event) => {
184
+ e.preventDefault();
185
+ const newWidgetInfo = ((Object.assign({}, this.state.widgetInfo, {
186
+ static: !this.state.widgetInfo.static,
187
+ }): any): WidgetInfo);
188
+ this.props.onChange(newWidgetInfo);
189
+ };
190
+
191
+ _handleAlignmentChange = (e: SyntheticEvent<HTMLSelectElement>) => {
192
+ const newAlignment = ((e.currentTarget.value: any): Alignment);
193
+ const newWidgetInfo = ((Object.assign(
194
+ {},
195
+ this.state.widgetInfo,
196
+ ): any): WidgetInfo);
197
+ newWidgetInfo.alignment = newAlignment;
198
+ this.props.onChange(newWidgetInfo);
199
+ };
200
+
201
+ getSaveWarnings = () => {
202
+ // eslint-disable-next-line react/no-string-refs
203
+ const issuesFunc = this.refs.widget.getSaveWarnings;
204
+ return issuesFunc ? issuesFunc() : [];
205
+ };
206
+
207
+ serialize = () => {
208
+ // TODO(alex): Make this properly handle the case where we load json
209
+ // with a more recent widget version than this instance of Perseus
210
+ // knows how to handle.
211
+ const widgetInfo = this.state.widgetInfo;
212
+ return {
213
+ type: widgetInfo.type,
214
+ alignment: widgetInfo.alignment,
215
+ static: widgetInfo.static,
216
+ graded: widgetInfo.graded,
217
+ // eslint-disable-next-line react/no-string-refs
218
+ options: this.refs.widget.serialize(),
219
+ version: widgetInfo.version,
220
+ };
221
+ };
222
+
223
+ render(): React.Node {
224
+ const widgetInfo = this.state.widgetInfo;
225
+
226
+ const Ed = Widgets.getEditor(widgetInfo.type);
227
+ let supportedAlignments: $ReadOnlyArray<Alignment>;
228
+ if (this.props.apiOptions.showAlignmentOptions) {
229
+ supportedAlignments = Widgets.getSupportedAlignments(
230
+ widgetInfo.type,
231
+ );
232
+ } else {
233
+ // NOTE(kevinb): "default" is not one in `validAlignments` in widgets.js.
234
+ supportedAlignments = ["default"];
235
+ }
236
+
237
+ const supportsStaticMode = Widgets.supportsStaticMode(widgetInfo.type);
238
+
239
+ const isUngradedEnabled = widgetInfo.type === "transformer";
240
+ const gradedPropBox = (
241
+ <PropCheckBox
242
+ label="Graded:"
243
+ graded={widgetInfo.graded}
244
+ onChange={this.props.onChange}
245
+ />
246
+ );
247
+
248
+ return (
249
+ <div className="perseus-widget-editor">
250
+ <div
251
+ className={
252
+ "perseus-widget-editor-title " +
253
+ (this.state.showWidget ? "open" : "closed")
254
+ }
255
+ >
256
+ {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
257
+ <a
258
+ className="perseus-widget-editor-title-id"
259
+ href="#"
260
+ onClick={this._toggleWidget}
261
+ >
262
+ {this.props.id}
263
+ {this.state.showWidget ? (
264
+ <InlineIcon {...iconChevronDown} />
265
+ ) : (
266
+ <InlineIcon {...iconChevronRight} />
267
+ )}
268
+ </a>
269
+
270
+ {supportsStaticMode && (
271
+ <input
272
+ type="button"
273
+ onClick={this._toggleStatic}
274
+ className="simple-button--small"
275
+ value={
276
+ widgetInfo.static
277
+ ? "Unset as static"
278
+ : "Set as static"
279
+ }
280
+ />
281
+ )}
282
+ {supportedAlignments.length > 1 && (
283
+ <select
284
+ className="alignment"
285
+ value={widgetInfo.alignment}
286
+ onChange={this._handleAlignmentChange}
287
+ >
288
+ {supportedAlignments.map((alignment) => (
289
+ <option key={alignment}>{alignment}</option>
290
+ ))}
291
+ </select>
292
+ )}
293
+ <SectionControlButton
294
+ icon={iconTrash}
295
+ onClick={() => {
296
+ this.props.onRemove();
297
+ }}
298
+ title="Remove image widget"
299
+ />
300
+ </div>
301
+ <div
302
+ className={
303
+ "perseus-widget-editor-content " +
304
+ (this.state.showWidget ? "enter" : "leave")
305
+ }
306
+ >
307
+ {isUngradedEnabled && gradedPropBox}
308
+ {Ed && (
309
+ <Ed
310
+ // eslint-disable-next-line react/no-string-refs
311
+ ref="widget"
312
+ onChange={this._handleWidgetChange}
313
+ static={widgetInfo.static}
314
+ apiOptions={this.props.apiOptions}
315
+ {...widgetInfo.options}
316
+ />
317
+ )}
318
+ </div>
319
+ </div>
320
+ );
321
+ }
322
+ }
323
+
324
+ // This is more general than the actual markdown image parsing regex,
325
+ // which is fine for correctness since it should only mean we could
326
+ // store extra image dimensions, unless the question is insanely
327
+ // formatted.
328
+ // A simplified regex here should hopefully be easier to keep in
329
+ // sync if the markdown parsing changes, though if it becomes
330
+ // easy to hook into the actual markdown regex without copy-pasting
331
+ // it, we should do that.
332
+ const IMAGE_REGEX = /!\[[^\]]*\]\(([^\s)]+)[^)]*\)/g;
333
+
334
+ /**
335
+ * Find all the matches to a /g regex.
336
+ *
337
+ * Returns an array of the regex matches. Infinite loops if `regex` does not
338
+ * have a /g modifier.
339
+ *
340
+ * Note: Returns an array of the capture objects, whereas String::match
341
+ * ignores captures. If you don't need captures, use String::match
342
+ */
343
+ const allMatches = function (regex, str) {
344
+ const result = [];
345
+ // eslint-disable-next-line no-constant-condition
346
+ while (true) {
347
+ const match = regex.exec(str);
348
+ if (!match) {
349
+ break;
350
+ }
351
+ result.push(match);
352
+ }
353
+ return result;
354
+ };
355
+
356
+ /**
357
+ * Return an array of URLs of all the images in the given renderer
358
+ * markdown.
359
+ */
360
+ const imageUrlsFromContent = function (content) {
361
+ return _.map(allMatches(IMAGE_REGEX, content), (capture) => capture[1]);
362
+ };
363
+
364
+ type EditorProps = $ReadOnly<{|
365
+ apiOptions: any,
366
+ className?: string,
367
+ content: string,
368
+ replace?: any,
369
+ placeholder: string,
370
+ widgets: {|[name: string]: WidgetInfo|},
371
+ images: any,
372
+ disabled: boolean,
373
+ widgetEnabled: boolean,
374
+ immutableWidgets: boolean,
375
+ showWordCount: boolean,
376
+ warnNoPrompt: boolean,
377
+ warnNoWidgets: boolean,
378
+ imageUploader?: (file: string, callback: (url: string) => mixed) => mixed,
379
+ onChange: ChangeHandler,
380
+ |}>;
381
+
382
+ type DeafultProps = {|
383
+ content: string,
384
+ disabled: boolean,
385
+ images: {...},
386
+ immutableWidgets: boolean,
387
+ placeholder: string,
388
+ showWordCount: boolean,
389
+ warnNoPrompt: boolean,
390
+ warnNoWidgets: boolean,
391
+ widgetEnabled: boolean,
392
+ widgets: {|[name: string]: WidgetInfo|},
393
+ |};
394
+
395
+ type EditorState = {|
396
+ showKatexErrors: boolean,
397
+ textAreaValue: string,
398
+ katex?: katex,
399
+ |};
400
+
401
+ // eslint-disable-next-line react/no-unsafe
402
+ class Editor extends React.Component<EditorProps, EditorState> {
403
+ lastUserValue: ?string;
404
+ deferredChange: ?any;
405
+ widgetIds: ?any;
406
+
407
+ static defaultProps: DeafultProps = {
408
+ content: "",
409
+ placeholder: "",
410
+ widgets: {},
411
+ images: {},
412
+ disabled: false,
413
+ widgetEnabled: true,
414
+ immutableWidgets: false,
415
+ showWordCount: false,
416
+ warnNoPrompt: false,
417
+ warnNoWidgets: false,
418
+ };
419
+
420
+ state: EditorState = {
421
+ katex: undefined,
422
+ showKatexErrors: false,
423
+ textAreaValue: this.props.content,
424
+ };
425
+
426
+ componentDidMount() {
427
+ // See componentDidUpdate() for how this flag is used
428
+ this.lastUserValue = null;
429
+
430
+ // This can't be in componentWillMount because that's happening during
431
+ // the middle of our parent's render, so we can't call
432
+ // this.props.onChange during that, since it calls our parent's
433
+ // setState
434
+ this._sizeImages(this.props);
435
+ // eslint-disable-next-line react/no-string-refs
436
+ $(ReactDOM.findDOMNode(this.refs.textarea))
437
+ .on("copy cut", this._maybeCopyWidgets)
438
+ .on("paste", this._maybePasteWidgets);
439
+
440
+ Dependencies.getDependencies()
441
+ .getKaTeX()
442
+ .then((katex) => {
443
+ this.setState({
444
+ katex,
445
+ });
446
+ });
447
+ }
448
+
449
+ // TODO(arun): This is a deprecated method, use the appropriate replacement
450
+ UNSAFE_componentWillReceiveProps(nextProps: EditorProps) {
451
+ if (this.props.content !== nextProps.content) {
452
+ this.setState({textAreaValue: nextProps.content});
453
+ }
454
+ }
455
+
456
+ componentDidUpdate(prevProps: EditorProps) {
457
+ // TODO(alpert): Maybe fix React so this isn't necessary
458
+ // eslint-disable-next-line react/no-string-refs
459
+ const textarea = ReactDOM.findDOMNode(this.refs.textarea);
460
+
461
+ // Slightly unorthodox method to ensure that programmatic text changes
462
+ // are in the browser's undo stack.
463
+ // See https://stackoverflow.com/questions/41273569/how-to-enable-ctrl-z-when-you-change-an-input-text-dynamically-with-react
464
+ // Note: On unsupported browsers (*cough* Firefox *cough*) `execCommand`
465
+ // will return false. However at least in Firefox setting `value` on a
466
+ // textbox clears the undo stack so we don't get unexpected undo
467
+ // behavior.
468
+ if (this.lastUserValue !== null && textarea) {
469
+ /**
470
+ * TODO(somewhatabstract, JIRA-XXXX):
471
+ * textarea should be refined with an instanceof check to
472
+ * HTMLTextAreaElement so that these props are available.
473
+ */
474
+ // $FlowFixMe[prop-missing]
475
+ textarea.focus();
476
+ // $FlowFixMe[prop-missing]
477
+ textarea.value = this.lastUserValue;
478
+ // $FlowFixMe[prop-missing]
479
+ textarea.selectionStart = 0;
480
+ // $FlowFixMe[prop-missing]
481
+ textarea.select(0, prevProps.content.length);
482
+ if (
483
+ document.execCommand(
484
+ "insertText",
485
+ false,
486
+ this.props.content,
487
+ ) === false
488
+ ) {
489
+ // This command is not implemented. Fall back to setting `value`
490
+ // directly.
491
+ // $FlowFixMe[prop-missing]
492
+ textarea.value = this.props.content;
493
+ }
494
+ this.lastUserValue = null;
495
+ }
496
+
497
+ // This can't be in componentWillReceiveProps because that's happening
498
+ // during the middle of our parent's render.
499
+ if (this.props.content !== prevProps.content) {
500
+ this._sizeImages(this.props);
501
+ }
502
+ }
503
+
504
+ componentWillUnmount() {
505
+ // TODO(jeff, CP-3128): Use Wonder Blocks Timing API.
506
+ // eslint-disable-next-line no-restricted-syntax
507
+ clearTimeout(this.deferredChange);
508
+ }
509
+
510
+ getWidgetEditor: (id: string, type: string) => void | React.Node = (
511
+ id: string,
512
+ type: string,
513
+ ) => {
514
+ if (!Widgets.getEditor(type)) {
515
+ return;
516
+ }
517
+ return (
518
+ <WidgetEditor
519
+ key={id}
520
+ ref={id}
521
+ id={id}
522
+ type={type}
523
+ // eslint-disable-next-line react/jsx-no-bind
524
+ onChange={this._handleWidgetEditorChange.bind(this, id)}
525
+ // eslint-disable-next-line react/jsx-no-bind
526
+ onRemove={this._handleWidgetEditorRemove.bind(this, id)}
527
+ apiOptions={this.props.apiOptions}
528
+ {...this.props.widgets[id]}
529
+ />
530
+ );
531
+ };
532
+
533
+ _handleWidgetEditorChange: (
534
+ id: string,
535
+ newWidgetInfo: WidgetInfo,
536
+ cb?: () => mixed,
537
+ silent?: boolean,
538
+ ) => void = (
539
+ id: string,
540
+ newWidgetInfo: WidgetInfo,
541
+ cb?: () => mixed,
542
+ silent?: boolean,
543
+ ) => {
544
+ const widgets = Object.assign({}, this.props.widgets);
545
+ widgets[id] = Object.assign({}, widgets[id], newWidgetInfo);
546
+ this.props.onChange({widgets}, cb, silent);
547
+ };
548
+
549
+ _handleWidgetEditorRemove: (id: string) => void = (id: string) => {
550
+ // eslint-disable-next-line react/no-string-refs
551
+ const textarea = this.refs.textarea;
552
+ const re = new RegExp(widgetRegExp.replace("{id}", id), "gm");
553
+ this.props.onChange({content: textarea.value.replace(re, "")});
554
+ };
555
+
556
+ /**
557
+ * Calculate the size of all the images in props.content, and send
558
+ * those sizes to this.props.images using props.onChange.
559
+ */
560
+ _sizeImages: (props: EditorProps) => void = (props: EditorProps) => {
561
+ const imageUrls = imageUrlsFromContent(props.content);
562
+
563
+ // Discard any images in our dimension table that no
564
+ // longer exist in content.
565
+ const images = _.pick(props.images, imageUrls);
566
+
567
+ // Only calculate sizes for images that were not present previously.
568
+ // Most content edits shouldn't have new images.
569
+ // This could get weird in the case of multiple images with the same
570
+ // URL, if you've changed the backing image size, but given graphie
571
+ // hashes it's probably an edge case.
572
+ const newImageUrls = _.filter(imageUrls, (url) => !images[url]);
573
+
574
+ // TODO(jack): Q promises would make this nicer and only
575
+ // fire once.
576
+ _.each(newImageUrls, (url) => {
577
+ Util.getImageSize(url, (width, height) => {
578
+ // We keep modifying the same image object rather than a new
579
+ // copy from this.props because all changes here are additive.
580
+ // Maintaining old changes isn't strictly necessary if
581
+ // props.onChange calls are not batched, but would be if they
582
+ // were, so this is nice from that anti-race-condition
583
+ // perspective as well.
584
+ images[url] = {
585
+ width: width,
586
+ height: height,
587
+ };
588
+ props.onChange(
589
+ {
590
+ images: _.clone(images),
591
+ },
592
+ null, // callback
593
+ true, // silent
594
+ );
595
+ });
596
+ });
597
+ };
598
+
599
+ handleDrop: (e: DragEvent) => void = (e: DragEvent) => {
600
+ const {imageUploader} = this.props;
601
+ let content = this.state.textAreaValue || "";
602
+ const dataTransfer = e.dataTransfer;
603
+
604
+ if (!dataTransfer || !imageUploader) {
605
+ return;
606
+ }
607
+
608
+ // files will hold something if the drag was from the desktop or a file
609
+ // located on the user's computer.
610
+ const files = dataTransfer.files;
611
+
612
+ // ... but we only get a url if the drag originated in another window
613
+ if (files.length === 0) {
614
+ const imageUrl = dataTransfer.getData("URL");
615
+
616
+ if (imageUrl) {
617
+ // TODO(joel) - relocate when the image upload dialog lands
618
+ const newContent = content + "\n\n![](" + imageUrl + ")";
619
+ // See componentDidUpdate() for how this flag is used
620
+ this.lastUserValue = this.props.content;
621
+ this.props.onChange({content: newContent});
622
+ }
623
+
624
+ return;
625
+ }
626
+
627
+ const origContent = this.state.textAreaValue;
628
+
629
+ /* For each file we make sure it's an image, then create a sentinel -
630
+ * snowman + identifier to insert into the current text. The sentinel
631
+ * only lives there temporarily until we get a response back from the
632
+ * server that the image is now hosted on AWS, at which time we replace
633
+ * the temporary sentinel with the permanent url for the image.
634
+ *
635
+ * There is an abuse of tap in the middle of the pipeline to make sure
636
+ * everything is sequenced in the correct order. We want to modify the
637
+ * content (given any number of images) at the same time, i.e. only
638
+ * once, so we do that step with the tap. After the content has been
639
+ * changed we send off the request for each image.
640
+ *
641
+ * Note that the snowman doesn't do anything special in this case -
642
+ * it's effectively just part of a broken link. Perseus could be
643
+ * extended to recognize this sentinel and highlight it like for
644
+ * widgets.
645
+ */
646
+ _(files)
647
+ .chain()
648
+ .map(function (file) {
649
+ if (!file.type.match("image.*")) {
650
+ return null;
651
+ }
652
+
653
+ const sentinel = "\u2603 " + _.uniqueId("image_");
654
+ // TODO(joel) - figure out how to temporarily include the image
655
+ // before the server returns.
656
+ content += "\n\n![](" + sentinel + ")";
657
+
658
+ return {file: file, sentinel: sentinel};
659
+ })
660
+ .reject(_.isNull)
661
+ .tap(() => {
662
+ // See componentDidUpdate() for how this flag is used
663
+ this.lastUserValue = origContent;
664
+ this.props.onChange({content: content});
665
+ })
666
+ .each((fileAndSentinel) => {
667
+ imageUploader(fileAndSentinel.file, (url) => {
668
+ // See componentDidUpdate() for how this flag is used
669
+ this.lastUserValue = origContent;
670
+ this.props.onChange({
671
+ content: this.state.textAreaValue.replace(
672
+ fileAndSentinel.sentinel,
673
+ url,
674
+ ),
675
+ });
676
+ });
677
+ });
678
+ };
679
+
680
+ handleChange: (e: SyntheticEvent<HTMLTextAreaElement>) => void = (
681
+ e: SyntheticEvent<HTMLTextAreaElement>,
682
+ ) => {
683
+ // TODO(jeff, CP-3128): Use Wonder Blocks Timing API.
684
+ // eslint-disable-next-line no-restricted-syntax
685
+ clearTimeout(this.deferredChange);
686
+ this.setState({textAreaValue: e.currentTarget.value});
687
+ // TODO(jeff, CP-3128): Use Wonder Blocks Timing API.
688
+ // eslint-disable-next-line no-restricted-syntax
689
+ this.deferredChange = setTimeout(() => {
690
+ if (this.state.textAreaValue !== this.props.content) {
691
+ this.props.onChange({content: this.state.textAreaValue});
692
+ }
693
+ }, this.props.apiOptions.editorChangeDelay);
694
+ };
695
+
696
+ _handleKeyDown: (e: SyntheticKeyboardEvent<HTMLTextAreaElement>) => void = (
697
+ e: SyntheticKeyboardEvent<HTMLTextAreaElement>,
698
+ ) => {
699
+ // Tab-completion of widgets. For example, to insert an image:
700
+ // type `[[im`, then tab.
701
+ if (e.key === "Tab") {
702
+ // eslint-disable-next-line react/no-string-refs
703
+ const textarea = ReactDOM.findDOMNode(this.refs.textarea);
704
+
705
+ // findDOMNode can also return Text, but we know it's an element.
706
+ // $FlowFixMe[incompatible-call]
707
+ const word = Util.textarea.getWordBeforeCursor(textarea);
708
+ const matches = word.string.toLowerCase().match(shortcutRegexp);
709
+
710
+ if (matches != null) {
711
+ const text = matches[1];
712
+ const widgets = Widgets.getAllWidgetTypes();
713
+ const matchingWidgets = _.filter(widgets, (name) => {
714
+ return name.substring(0, text.length) === text;
715
+ });
716
+
717
+ if (matchingWidgets.length === 1) {
718
+ const widgetType = matchingWidgets[0];
719
+
720
+ this._addWidgetToContent(
721
+ this.props.content,
722
+ [word.pos.start, word.pos.end + 1],
723
+ widgetType,
724
+ );
725
+ }
726
+
727
+ e.preventDefault();
728
+ }
729
+ }
730
+ };
731
+
732
+ _maybeCopyWidgets: (e: SyntheticEvent<HTMLTextAreaElement>) => void = (
733
+ e: SyntheticEvent<HTMLTextAreaElement>,
734
+ ) => {
735
+ // If there are widgets being cut/copied, put the widget JSON in
736
+ // localStorage.perseusLastCopiedWidgets to allow copy-pasting of
737
+ // widgets between Editors. Also store the text to be pasted in
738
+ // localStorage.perseusLastCopiedText since we want to know if the user
739
+ // is actually pasting something originally from Perseus later.
740
+ const textarea = e.currentTarget;
741
+ const selectedText = textarea.value.substring(
742
+ textarea.selectionStart,
743
+ textarea.selectionEnd,
744
+ );
745
+
746
+ const widgetNames = _.map(
747
+ selectedText.match(rWidgetSplit),
748
+ (syntax) => {
749
+ // $FlowFixMe[incompatible-use] exec could return null.
750
+ return Util.rWidgetParts.exec(syntax)[1];
751
+ },
752
+ );
753
+
754
+ const widgetData = _.pick(this.serialize().widgets, widgetNames);
755
+
756
+ // $FlowIgnore[prop-missing] localStorage can be used as an object
757
+ localStorage.perseusLastCopiedText = selectedText;
758
+ // $FlowIgnore[prop-missing] localStorage can be used as an object
759
+ localStorage.perseusLastCopiedWidgets = JSON.stringify(widgetData);
760
+
761
+ Log.log(`Widgets copied: ${localStorage.perseusLastCopiedWidgets}`);
762
+ };
763
+
764
+ _maybePasteWidgets: (e: SyntheticEvent<HTMLTextAreaElement>) => void = (
765
+ e: SyntheticEvent<HTMLTextAreaElement>,
766
+ ) => {
767
+ // Use the data from localStorage to paste any widgets we copied
768
+ // before. Avoid name conflicts by renumbering pasted widgets so that
769
+ // their numbers are always higher than the highest numbered widget of
770
+ // their type.
771
+ // TODO(sam): Fix widget numbering in the widget editor titles
772
+
773
+ // $FlowIgnore[prop-missing] localStorage can be used as an object
774
+ const widgetJSON = localStorage.perseusLastCopiedWidgets;
775
+ // $FlowIgnore[prop-missing] localStorage can be used as an object
776
+ const lastCopiedText = localStorage.perseusLastCopiedText;
777
+ // $FlowIgnore[prop-missing] Unknown originalEvent
778
+ const textToBePasted = e.originalEvent.clipboardData.getData("text");
779
+
780
+ // Only intercept if we have widget data to paste and the user is
781
+ // pasting something originally from Perseus.
782
+ // TODO(sam/aria/alex): Make it so that you can paste arbitrary text
783
+ // (e.g. from a text editor) instead of exactly what was copied, and
784
+ // let the widgetJSON match up with it. This would let you copy text
785
+ // into a buffer, perform complex operations on it, then paste it back.
786
+ if (widgetJSON && lastCopiedText === textToBePasted) {
787
+ e.preventDefault();
788
+
789
+ const widgetData = JSON.parse(widgetJSON);
790
+ const safeWidgetMapping = this._safeWidgetNameMapping(widgetData);
791
+
792
+ // Use safe widget name map to construct the new widget data
793
+ // TODO(aria/alex): Don't use `rWidgetSplit` or other piecemeal
794
+ // regexes directly; abstract this out so that we don't have to
795
+ // worry about potential edge cases.
796
+ const safeWidgetData = {};
797
+ for (const [key, data] of Object.entries(widgetData)) {
798
+ safeWidgetData[safeWidgetMapping[key]] = data;
799
+ }
800
+ const newWidgets = _.extend(safeWidgetData, this.props.widgets);
801
+
802
+ // Use safe widget name map to construct new text
803
+ const safeText = lastCopiedText.replace(rWidgetSplit, (syntax) => {
804
+ const match = Util.rWidgetParts.exec(syntax);
805
+ // $FlowFixMe[incompatible-use] exec could return null.
806
+ const completeWidget = match[0];
807
+ // $FlowFixMe[incompatible-use] exec could return null.
808
+ const widget = match[1];
809
+ return completeWidget.replace(
810
+ widget,
811
+ safeWidgetMapping[widget],
812
+ );
813
+ });
814
+
815
+ // Add pasted text to previous content, replacing selected text to
816
+ // replicate normal paste behavior.
817
+ const textarea = e.currentTarget;
818
+ const selectionStart = textarea.selectionStart;
819
+ const newContent =
820
+ this.state.textAreaValue.substr(0, selectionStart) +
821
+ safeText +
822
+ this.state.textAreaValue.substr(textarea.selectionEnd);
823
+
824
+ // See componentDidUpdate() for how this flag is used
825
+ this.lastUserValue = this.state.textAreaValue;
826
+ this.props.onChange(
827
+ {content: newContent, widgets: newWidgets},
828
+ () => {
829
+ const expectedCursorPosition =
830
+ selectionStart + safeText.length;
831
+ Util.textarea.moveCursor(textarea, expectedCursorPosition);
832
+ },
833
+ );
834
+ }
835
+ };
836
+
837
+ _safeWidgetNameMapping: (widgetData: {[name: string]: any, ...}) => {...} =
838
+ (widgetData: {[name: string]: any, ...}) => {
839
+ // Helper function for _maybePasteWidgets.
840
+ // For each widget about to be pasted, construct a mapping from
841
+ // old widget name to a new widget name that doesn't have conflicts
842
+ // with widgets already in the editor.
843
+ // eg. If there is an "image 2" already present in the editor and we're
844
+ // about to paste in two new images, return
845
+ // { "image 1": "image 3", "image 2": "image 4" }
846
+
847
+ // List of widgets about to be pasted as [[name, number], ...]
848
+ const widgets = _.keys(widgetData).map((name) => name.split(" "));
849
+ const widgetTypes = _.uniq(widgets.map((widget) => widget[0]));
850
+
851
+ // List of existing widgets as [[name, number], ...]
852
+ const existingWidgets = _.keys(this.props.widgets).map((name) =>
853
+ name.split(" "),
854
+ );
855
+
856
+ // Mapping of widget type to a safe (non-conflicting) number
857
+ // eg. { "image": 2, "dropdown": 1 }
858
+ const safeWidgetNums = {};
859
+ _.each(widgetTypes, (type) => {
860
+ safeWidgetNums[type] = _.chain(existingWidgets)
861
+ .filter((existingWidget) => existingWidget[0] === type)
862
+ .map((existingWidget) => +existingWidget[1] + 1)
863
+ .max()
864
+ .value();
865
+ // If there are no existing widgets _.max returns -Infinity
866
+ safeWidgetNums[type] = Math.max(safeWidgetNums[type], 1);
867
+ });
868
+
869
+ // Construct mapping, incrementing the vals in safeWidgetNums as we go
870
+ const safeWidgetMapping = {};
871
+ _.each(widgets, (widget) => {
872
+ const widgetName = widget.join(" ");
873
+ const widgetType = widget[0];
874
+
875
+ safeWidgetMapping[
876
+ widgetName
877
+ ] = `${widgetType} ${safeWidgetNums[widgetType]}`;
878
+ safeWidgetNums[widgetType]++;
879
+ });
880
+
881
+ return safeWidgetMapping;
882
+ };
883
+
884
+ _addWidgetToContent: (
885
+ oldContent: string,
886
+ cursorRange: $ReadOnlyArray<number>,
887
+ widgetType: string,
888
+ ) => void = (
889
+ oldContent: string,
890
+ cursorRange: $ReadOnlyArray<number>,
891
+ widgetType: string,
892
+ ) => {
893
+ // eslint-disable-next-line react/no-string-refs
894
+ const textarea = ReactDOM.findDOMNode(this.refs.textarea);
895
+
896
+ // Note: we have to use _.map here instead of Array::map
897
+ // because the results of a .match might be null if no
898
+ // widgets were found.
899
+ const allWidgetIds = _.map(oldContent.match(rWidgetSplit), (syntax) => {
900
+ const match = Util.rWidgetParts.exec(syntax);
901
+ // $FlowFixMe[incompatible-use] exec could return null.
902
+ const type = match[2];
903
+ // $FlowFixMe[incompatible-use] exec could return null.
904
+ const num = +match[3];
905
+ return [type, num];
906
+ });
907
+
908
+ const widgetNum = _.reduce(
909
+ allWidgetIds,
910
+ (currentNum, otherId) => {
911
+ const [otherType, otherNum] = otherId;
912
+ if (otherType === widgetType) {
913
+ return Math.max(otherNum + 1, currentNum);
914
+ }
915
+ return currentNum;
916
+ },
917
+ 1,
918
+ );
919
+
920
+ const id = widgetType + " " + widgetNum;
921
+ const widgetContent = widgetPlaceholder.replace("{id}", id);
922
+
923
+ // Add newlines before block-display widgets like graphs
924
+ const isBlock = Widgets.getDefaultAlignment(widgetType) === "block";
925
+
926
+ const prelude = oldContent.slice(0, cursorRange[0]);
927
+ const postlude = oldContent.slice(cursorRange[1]);
928
+
929
+ const newPrelude = isBlock
930
+ ? makeEndWithAParagraphIfNecessary(prelude)
931
+ : prelude;
932
+ const newPostlude = isBlock
933
+ ? makeStartWithAParagraphAlways(postlude)
934
+ : postlude;
935
+
936
+ const newContent = newPrelude + widgetContent + newPostlude;
937
+
938
+ const newWidgets = _.clone(this.props.widgets);
939
+ newWidgets[id] = {
940
+ options: Widgets.getEditor(widgetType)?.defaultProps,
941
+ type: widgetType,
942
+ // Track widget version on creation, so that a widget editor
943
+ // without a valid version prop can only possibly refer to a
944
+ // pre-versioning creation time.
945
+ version: Widgets.getVersion(widgetType),
946
+ };
947
+
948
+ // See componentDidUpdate() for how this flag is used
949
+ this.lastUserValue = this.props.content;
950
+ this.props.onChange(
951
+ {
952
+ content: newContent,
953
+ widgets: newWidgets,
954
+ },
955
+ function () {
956
+ Util.textarea.moveCursor(
957
+ // findDOMNode can return Text but we know this is Element
958
+ // $FlowFixMe[incompatible-call]
959
+ textarea,
960
+ // We want to put the cursor after the widget
961
+ // and after any added newlines
962
+ newContent.length - postlude.length,
963
+ );
964
+ },
965
+ );
966
+ };
967
+
968
+ _addWidget: (widgetType: string) => void = (widgetType: string) => {
969
+ // eslint-disable-next-line react/no-string-refs
970
+ const textarea = this.refs.textarea;
971
+
972
+ this._addWidgetToContent(
973
+ this.props.content,
974
+ [textarea.selectionStart, textarea.selectionEnd],
975
+ widgetType,
976
+ );
977
+ textarea.focus();
978
+ };
979
+
980
+ addTemplate: (e: SyntheticEvent<HTMLTextAreaElement>) => void = (
981
+ e: SyntheticEvent<HTMLTextAreaElement>,
982
+ ) => {
983
+ const templateType = e.currentTarget.value;
984
+ if (templateType === "") {
985
+ return;
986
+ }
987
+ e.currentTarget.value = "";
988
+
989
+ let oldContent = this.props.content;
990
+
991
+ // Force templates to have a blank line before them,
992
+ // as they are usually used as block elements
993
+ // (especially important for tables)
994
+ oldContent = oldContent.replace(/\n*$/, "\n\n");
995
+
996
+ let template;
997
+ if (templateType === "table") {
998
+ template =
999
+ "header 1 | header 2 | header 3\n" +
1000
+ "- | - | -\n" +
1001
+ "data 1 | data 2 | data 3\n" +
1002
+ "data 4 | data 5 | data 6\n" +
1003
+ "data 7 | data 8 | data 9";
1004
+ } else if (templateType === "titledTable") {
1005
+ template =
1006
+ "|| **Table title** ||\n" +
1007
+ "header 1 | header 2 | header 3\n" +
1008
+ "- | - | -\n" +
1009
+ "data 1 | data 2 | data 3\n" +
1010
+ "data 4 | data 5 | data 6\n" +
1011
+ "data 7 | data 8 | data 9";
1012
+ } else if (templateType === "alignment") {
1013
+ template =
1014
+ "$\\begin{align} x+5 &= 30 \\\\\n" +
1015
+ "x+5-5 &= 30-5 \\\\\n" +
1016
+ "x &= 25 \\end{align}$";
1017
+ } else if (templateType === "piecewise") {
1018
+ template =
1019
+ "$f(x) = \\begin{cases}\n" +
1020
+ "7 & \\text{if }x=1 \\\\\n" +
1021
+ "f(x-1)+5 & \\text{if }x > 1\n" +
1022
+ "\\end{cases}$";
1023
+ } else if (templateType === "allWidgets") {
1024
+ template = Widgets.getAllWidgetTypes()
1025
+ .map((type) => `[[${Util.snowman} ${type} 1]]`)
1026
+ .join("\n\n");
1027
+ } else {
1028
+ throw new PerseusError(
1029
+ "Invalid template type: " + templateType,
1030
+ Errors.InvalidInput,
1031
+ {
1032
+ metadata: {templateType},
1033
+ },
1034
+ );
1035
+ }
1036
+
1037
+ const newContent = oldContent + template;
1038
+
1039
+ // See componentDidUpdate() for how this flag is used
1040
+ this.lastUserValue = this.props.content;
1041
+ this.props.onChange({content: newContent}, this.focusAndMoveToEnd);
1042
+ };
1043
+
1044
+ getSaveWarnings: () => any = () => {
1045
+ // eslint-disable-next-line react/no-string-refs
1046
+ const widgetIds = _.intersection(this.widgetIds, _.keys(this.refs));
1047
+ const warnings = _(widgetIds)
1048
+ .chain()
1049
+ .map((id) => {
1050
+ // eslint-disable-next-line react/no-string-refs
1051
+ const issuesFunc = this.refs[id].getSaveWarnings;
1052
+ const issues = issuesFunc ? issuesFunc() : [];
1053
+ return _.map(issues, (issue) => id + ": " + issue);
1054
+ })
1055
+ .flatten(true)
1056
+ .value();
1057
+
1058
+ return warnings;
1059
+ };
1060
+
1061
+ focus: () => void = () => {
1062
+ // eslint-disable-next-line react/no-string-refs
1063
+ const textarea = ReactDOM.findDOMNode(this.refs.textarea);
1064
+ if (textarea) {
1065
+ /**
1066
+ * TODO(somewhatabstract, JIRA-XXXX):
1067
+ * textarea should be refined with an instanceof check to
1068
+ * HTMLTextAreaElement so that these props are available.
1069
+ */
1070
+ // $FlowFixMe[prop-missing]
1071
+ textarea.focus();
1072
+ }
1073
+ };
1074
+
1075
+ focusAndMoveToEnd: () => void = () => {
1076
+ this.focus();
1077
+ // eslint-disable-next-line react/no-string-refs
1078
+ const textarea = ReactDOM.findDOMNode(this.refs.textarea);
1079
+ if (textarea) {
1080
+ /**
1081
+ * TODO(somewhatabstract, JIRA-XXXX):
1082
+ * textarea should be refined with an instanceof check to
1083
+ * HTMLTextAreaElement so that these props are available.
1084
+ */
1085
+ // $FlowFixMe[prop-missing]
1086
+ textarea.selectionStart = textarea.value.length;
1087
+ // $FlowFixMe[prop-missing]
1088
+ textarea.selectionEnd = textarea.value.length;
1089
+ }
1090
+ };
1091
+
1092
+ serialize: (options: any) => {|
1093
+ content: string,
1094
+ images: any,
1095
+ replace: any | void,
1096
+ widgets: {...},
1097
+ |} = (options: any) => {
1098
+ // need to serialize the widgets since the state might not be
1099
+ // completely represented in props. ahem //transformer// (and
1100
+ // interactive-graph and plotter).
1101
+ const widgets = {};
1102
+ // eslint-disable-next-line react/no-string-refs
1103
+ const widgetIds = _.intersection(this.widgetIds, _.keys(this.refs));
1104
+ _.each(widgetIds, (id) => {
1105
+ // eslint-disable-next-line react/no-string-refs
1106
+ widgets[id] = this.refs[id].serialize();
1107
+ });
1108
+
1109
+ // Preserve the data associated with deleted widgets in their last
1110
+ // modified form. This is only intended to be useful in the context of
1111
+ // immediate cut and paste operations if Editor.serialize() is called
1112
+ // in between the two (which ideally should not be happening).
1113
+ // TODO(alex): Remove this once all widget.serialize() methods
1114
+ // have been fixed to only return props,
1115
+ // and the above no longer applies.
1116
+ if (options && options.keepDeletedWidgets) {
1117
+ _.chain(this.props.widgets)
1118
+ .keys()
1119
+ .reject((id) => _.contains(widgetIds, id))
1120
+ .each((id) => {
1121
+ widgets[id] = this.props.widgets[id];
1122
+ });
1123
+ }
1124
+
1125
+ return {
1126
+ replace: this.props.replace,
1127
+ content: this.props.content,
1128
+ images: this.props.images,
1129
+ widgets: widgets,
1130
+ };
1131
+ };
1132
+
1133
+ render(): React.Element<"div"> {
1134
+ let pieces;
1135
+ let widgets;
1136
+ let underlayPieces;
1137
+ let widgetsDropDown;
1138
+ let templatesDropDown;
1139
+ let widgetsAndTemplates;
1140
+ let wordCountDisplay;
1141
+ const katexErrorList = [];
1142
+
1143
+ if (this.props.showWordCount) {
1144
+ const numChars = PerseusMarkdown.characterCount(this.props.content);
1145
+ const numWords = Math.floor(numChars / 6);
1146
+ wordCountDisplay = (
1147
+ <span
1148
+ className="perseus-editor-word-count"
1149
+ title={
1150
+ "~" +
1151
+ commafyInteger(numWords) +
1152
+ " words (" +
1153
+ commafyInteger(numChars) +
1154
+ " characters)"
1155
+ }
1156
+ >
1157
+ {commafyInteger(numWords)}
1158
+ </span>
1159
+ );
1160
+ }
1161
+
1162
+ const {katex} = this.state;
1163
+
1164
+ if (this.props.widgetEnabled && katex) {
1165
+ pieces = Util.split(this.props.content, rWidgetSplit);
1166
+ widgets = {};
1167
+ underlayPieces = [];
1168
+
1169
+ for (let i = 0; i < pieces.length; i++) {
1170
+ if (i % 2 === 0) {
1171
+ // Normal text
1172
+ underlayPieces.push(pieces[i]);
1173
+
1174
+ const ast = PerseusMarkdown.parse(pieces[i]);
1175
+
1176
+ PerseusMarkdown.traverseContent(ast, (node) => {
1177
+ if (node.type === "math" || node.type === "blockMath") {
1178
+ const content = preprocessTex(node.content);
1179
+ try {
1180
+ katex.renderToString(content, {
1181
+ colorIsTextColor: true,
1182
+ });
1183
+ } catch (e) {
1184
+ katexErrorList.push({
1185
+ math: content,
1186
+ message: e.message,
1187
+ });
1188
+ }
1189
+ }
1190
+ });
1191
+ } else {
1192
+ // Widget reference
1193
+ const match = Util.rWidgetParts.exec(pieces[i]);
1194
+ if (match != null) {
1195
+ const id = match[1];
1196
+ const type = match[2];
1197
+
1198
+ const selected = false;
1199
+ // TODO(alpert):
1200
+ // var selected = focused && selStart === selEnd &&
1201
+ // offset <= selStart &&
1202
+ // selStart < offset + text.length;
1203
+ // if (selected) {
1204
+ // selectedWidget = id;
1205
+ // }
1206
+
1207
+ const duplicate = id in widgets;
1208
+
1209
+ widgets[id] = this.getWidgetEditor(id, type);
1210
+ const classes =
1211
+ (duplicate || !widgets[id] ? "error " : "") +
1212
+ (selected ? "selected " : "");
1213
+ const key = duplicate ? i : id;
1214
+ underlayPieces.push(
1215
+ <b className={classes} key={key}>
1216
+ {pieces[i]}
1217
+ </b>,
1218
+ );
1219
+ }
1220
+ }
1221
+ }
1222
+
1223
+ // TODO(alpert): Move this to the content-change event handler
1224
+ // _.each(_.keys(this.props.widgets), function(id) {
1225
+ // if (!(id in widgets)) {
1226
+ // // It's strange if these preloaded options stick around
1227
+ // // since it's inconsistent with how things work if you
1228
+ // // don't have the serialize/deserialize step in the
1229
+ // // middle
1230
+ // // TODO(alpert): Save options in a consistent manner so
1231
+ // // that you can undo the deletion of a widget
1232
+ // delete this.props.widgets[id];
1233
+ // }
1234
+ // }, this);
1235
+
1236
+ this.widgetIds = _.keys(widgets);
1237
+ widgetsDropDown = (
1238
+ // eslint-disable-next-line react/no-string-refs
1239
+ <WidgetSelect ref="widgetSelect" onChange={this._addWidget} />
1240
+ );
1241
+
1242
+ const insertTemplateString = "Insert template\u2026";
1243
+ templatesDropDown = (
1244
+ <select onChange={this.addTemplate}>
1245
+ <option value="">{insertTemplateString}</option>
1246
+ <option disabled>--</option>
1247
+ <option value="table">Table</option>
1248
+ <option value="titledTable">Titled table</option>
1249
+ <option value="alignment">Aligned equations</option>
1250
+ <option value="piecewise">Piecewise function</option>
1251
+ <option disabled>--</option>
1252
+ <option value="allWidgets">
1253
+ All widgets (for testing)
1254
+ </option>
1255
+ </select>
1256
+ );
1257
+
1258
+ if (!this.props.immutableWidgets) {
1259
+ const widgetNodes = ((Object.values(widgets): any): React.Node);
1260
+ widgetsAndTemplates = (
1261
+ <div className="perseus-editor-widgets">
1262
+ <div className="perseus-editor-widgets-selectors">
1263
+ {widgetsDropDown}
1264
+ {templatesDropDown}
1265
+ {wordCountDisplay}
1266
+ </div>
1267
+ {widgetNodes}
1268
+ </div>
1269
+ );
1270
+ // Prevent word count from being displayed elsewhere
1271
+ wordCountDisplay = null;
1272
+ }
1273
+ } else {
1274
+ underlayPieces = [this.props.content];
1275
+ }
1276
+
1277
+ // Without this, the underlay isn't the proper size when the text ends
1278
+ // with a newline.
1279
+ underlayPieces.push(<br key="end" />);
1280
+
1281
+ const completeTextarea = [
1282
+ <div
1283
+ className="perseus-textarea-underlay"
1284
+ // eslint-disable-next-line react/no-string-refs
1285
+ ref="underlay"
1286
+ key="underlay"
1287
+ >
1288
+ {underlayPieces}
1289
+ </div>,
1290
+ <textarea
1291
+ // eslint-disable-next-line react/no-string-refs
1292
+ ref="textarea"
1293
+ key="textarea"
1294
+ onChange={this.handleChange}
1295
+ onKeyDown={this._handleKeyDown}
1296
+ placeholder={this.props.placeholder}
1297
+ disabled={this.props.disabled}
1298
+ value={this.state.textAreaValue}
1299
+ />,
1300
+ ];
1301
+
1302
+ let textareaWrapper;
1303
+ if (this.props.imageUploader) {
1304
+ textareaWrapper = (
1305
+ <DragTarget
1306
+ onDrop={this.handleDrop}
1307
+ className="perseus-textarea-pair"
1308
+ >
1309
+ {completeTextarea}
1310
+ </DragTarget>
1311
+ );
1312
+ } else {
1313
+ textareaWrapper = (
1314
+ <div className="perseus-textarea-pair">{completeTextarea}</div>
1315
+ );
1316
+ }
1317
+
1318
+ // sync-start:widget-id-regex 1271666319 services/content-editing/perseus/translation/content_traversal.go
1319
+ // sync-start:widget-id-regex 447601219 services/static/javascript/perseus-all-package/util.js
1320
+ // sync-start:widget-id-regex 1400368396 content_internal/perseus_traversal.py
1321
+ const contentWithoutWidgets = this.props.content.replace(
1322
+ /\[\[\u2603 (([a-z-]+) ([0-9]+))\]\]/g,
1323
+ "",
1324
+ );
1325
+ const noPrompt = contentWithoutWidgets.trim().length === 0;
1326
+ const noWidgets = !/\[\[\u2603 (([a-z-]+) ([0-9]+))\]\]/g.test(
1327
+ this.props.content,
1328
+ );
1329
+ // sync-end:widget-id-regex
1330
+
1331
+ const warningStyle = {
1332
+ borderTop: "none",
1333
+ padding: 4,
1334
+ backgroundColor: "pink",
1335
+ };
1336
+
1337
+ return (
1338
+ <div
1339
+ className={
1340
+ "perseus-single-editor " + (this.props.className || "")
1341
+ }
1342
+ >
1343
+ {textareaWrapper}
1344
+ {katexErrorList.length > 0 && (
1345
+ <KatexErrorView errorList={katexErrorList} />
1346
+ )}
1347
+ {this.props.warnNoPrompt && noPrompt && (
1348
+ <div style={warningStyle}>
1349
+ Graded Groups should contain a prompt
1350
+ </div>
1351
+ )}
1352
+ {this.props.warnNoWidgets && noWidgets && (
1353
+ <div style={warningStyle}>
1354
+ Graded Groups should contain at least one widget
1355
+ </div>
1356
+ )}
1357
+ {wordCountDisplay}
1358
+ {widgetsAndTemplates}
1359
+ </div>
1360
+ );
1361
+ }
1362
+ }
1363
+
1364
+ export default Editor;