@khanacademy/perseus-editor 4.1.5 → 4.1.7

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 (175) hide show
  1. package/dist/es/index.js +1 -1
  2. package/dist/index.js +1 -1
  3. package/package.json +9 -6
  4. package/.babelrc.js +0 -8
  5. package/.eslintrc.js +0 -12
  6. package/CHANGELOG.md +0 -1155
  7. package/src/README.md +0 -7
  8. package/src/__stories__/editor-page.stories.tsx +0 -65
  9. package/src/__stories__/editor.stories.tsx +0 -134
  10. package/src/__stories__/item-editor.stories.tsx +0 -41
  11. package/src/__stories__/tex-error-view.stories.tsx +0 -26
  12. package/src/__testdata__/input-number.testdata.ts +0 -27
  13. package/src/__tests__/editor.test.tsx +0 -142
  14. package/src/__tests__/hint-editor.test.tsx +0 -83
  15. package/src/__tests__/i18n.test.ts +0 -501
  16. package/src/__tests__/integration.test.ts +0 -5
  17. package/src/__tests__/item-extras-editor.test.tsx +0 -85
  18. package/src/__tests__/traversal.test.ts +0 -377
  19. package/src/all-editors.test.ts +0 -16
  20. package/src/all-editors.ts +0 -83
  21. package/src/all-widgets.ts +0 -7
  22. package/src/article-editor.tsx +0 -438
  23. package/src/components/__stories__/blur-input.stories.tsx +0 -22
  24. package/src/components/__stories__/section-control-button.stories.tsx +0 -26
  25. package/src/components/__tests__/blur-input.test.tsx +0 -67
  26. package/src/components/blur-input.tsx +0 -69
  27. package/src/components/checkbox.tsx +0 -194
  28. package/src/components/device-framer.tsx +0 -90
  29. package/src/components/drag-target.tsx +0 -87
  30. package/src/components/dropdown-option.tsx +0 -311
  31. package/src/components/form-wrapped-text-field.tsx +0 -245
  32. package/src/components/graph-points-count-selector.tsx +0 -31
  33. package/src/components/graph-settings.tsx +0 -627
  34. package/src/components/graph-type-selector.tsx +0 -31
  35. package/src/components/hover-behavior.ts +0 -177
  36. package/src/components/json-editor.tsx +0 -121
  37. package/src/components/link.ts +0 -173
  38. package/src/components/section-control-button.tsx +0 -53
  39. package/src/components/segment-count-selector.tsx +0 -27
  40. package/src/components/simple-button.tsx +0 -85
  41. package/src/components/sortable.tsx +0 -187
  42. package/src/components/util.ts +0 -38
  43. package/src/components/viewport-resizer.tsx +0 -72
  44. package/src/components/widget-editor.tsx +0 -222
  45. package/src/components/widget-select.tsx +0 -48
  46. package/src/diffs/__stories__/perseus-diff-wrapper.tsx +0 -11
  47. package/src/diffs/__stories__/structured-item-diff.stories.tsx +0 -855
  48. package/src/diffs/__stories__/tags-diff.stories.tsx +0 -36
  49. package/src/diffs/__stories__/text-diff.stories.tsx +0 -35
  50. package/src/diffs/__tests__/split-diff.test.ts +0 -33
  51. package/src/diffs/__tests__/string-array-diff.test.ts +0 -73
  52. package/src/diffs/__tests__/structured-item-diff.test.ts +0 -433
  53. package/src/diffs/__tests__/widget-diff-performer.test.ts +0 -43
  54. package/src/diffs/article-diff.tsx +0 -69
  55. package/src/diffs/item-diff.tsx +0 -76
  56. package/src/diffs/renderer-diff.tsx +0 -137
  57. package/src/diffs/split-diff.ts +0 -31
  58. package/src/diffs/string-array-diff.ts +0 -74
  59. package/src/diffs/structured-item-diff.tsx +0 -275
  60. package/src/diffs/tags-diff.tsx +0 -49
  61. package/src/diffs/text-diff.tsx +0 -193
  62. package/src/diffs/widget-diff-performer.ts +0 -69
  63. package/src/diffs/widget-diff.tsx +0 -290
  64. package/src/editor-page.tsx +0 -325
  65. package/src/editor.tsx +0 -1099
  66. package/src/hint-editor.tsx +0 -469
  67. package/src/i18n.ts +0 -140
  68. package/src/iframe-content-renderer.tsx +0 -188
  69. package/src/index.ts +0 -30
  70. package/src/item-editor.tsx +0 -158
  71. package/src/item-extras-editor.tsx +0 -230
  72. package/src/katex-mhchem/index.js +0 -73
  73. package/src/katex-mhchem/parser.js +0 -2659
  74. package/src/multirenderer-editor.tsx +0 -1061
  75. package/src/stateful-article-editor.tsx +0 -173
  76. package/src/stateful-editor-page.tsx +0 -88
  77. package/src/styles/perseus-editor.less +0 -1321
  78. package/src/testing-widgets.ts +0 -11
  79. package/src/tex-error-view.tsx +0 -90
  80. package/src/util/points.test.ts +0 -24
  81. package/src/util/points.ts +0 -13
  82. package/src/util/register-all-widgets-and-editors-for-testing.test.ts +0 -17
  83. package/src/util/register-all-widgets-and-editors-for-testing.ts +0 -17
  84. package/src/version.ts +0 -10
  85. package/src/widgets/__stories__/categorizer-editor.stories.tsx +0 -24
  86. package/src/widgets/__stories__/definition-editor.stories.tsx +0 -18
  87. package/src/widgets/__stories__/dropdown-editor.stories.tsx +0 -18
  88. package/src/widgets/__stories__/explanation-editor.stories.tsx +0 -18
  89. package/src/widgets/__stories__/expression-editor.stories.tsx +0 -122
  90. package/src/widgets/__stories__/image-editor.stories.tsx +0 -72
  91. package/src/widgets/__stories__/input-number-editor.stories.tsx +0 -18
  92. package/src/widgets/__stories__/interaction-editor.stories.tsx +0 -37
  93. package/src/widgets/__stories__/label-image-editor.stories.tsx +0 -99
  94. package/src/widgets/__stories__/matcher-editor.stories.tsx +0 -18
  95. package/src/widgets/__stories__/number-line-editor.stories.tsx +0 -18
  96. package/src/widgets/__stories__/numeric-input-editor.stories.tsx +0 -18
  97. package/src/widgets/__stories__/radio-editor.stories.tsx +0 -116
  98. package/src/widgets/__stories__/sorter-editor.stories.tsx +0 -18
  99. package/src/widgets/__tests__/categorizer-editor.test.tsx +0 -51
  100. package/src/widgets/__tests__/definition-editor.test.tsx +0 -41
  101. package/src/widgets/__tests__/dropdown-editor.test.tsx +0 -57
  102. package/src/widgets/__tests__/explanation-editor.test.tsx +0 -59
  103. package/src/widgets/__tests__/expression-editor.test.tsx +0 -403
  104. package/src/widgets/__tests__/input-number-editor.test.tsx +0 -128
  105. package/src/widgets/__tests__/matcher-editor.test.tsx +0 -47
  106. package/src/widgets/__tests__/number-line-editor.test.tsx +0 -144
  107. package/src/widgets/__tests__/numeric-input-editor.test.tsx +0 -177
  108. package/src/widgets/__tests__/radio-editor.test.tsx +0 -118
  109. package/src/widgets/__tests__/sorter-editor.test.tsx +0 -55
  110. package/src/widgets/categorizer-editor.tsx +0 -104
  111. package/src/widgets/cs-program-editor.tsx +0 -256
  112. package/src/widgets/definition-editor.tsx +0 -77
  113. package/src/widgets/deprecated-standin-editor.tsx +0 -29
  114. package/src/widgets/dropdown-editor.tsx +0 -211
  115. package/src/widgets/example-graphie-widget-editor.tsx +0 -70
  116. package/src/widgets/example-graphie-widget.tsx +0 -172
  117. package/src/widgets/example-widget-editor.tsx +0 -62
  118. package/src/widgets/example-widget.tsx +0 -154
  119. package/src/widgets/explanation-editor.tsx +0 -92
  120. package/src/widgets/expression-editor.tsx +0 -557
  121. package/src/widgets/graded-group-editor.tsx +0 -161
  122. package/src/widgets/graded-group-set-editor.tsx +0 -104
  123. package/src/widgets/grapher-editor.tsx +0 -155
  124. package/src/widgets/group-editor.tsx +0 -83
  125. package/src/widgets/iframe-editor.tsx +0 -202
  126. package/src/widgets/image-editor.tsx +0 -367
  127. package/src/widgets/input-number-editor.tsx +0 -288
  128. package/src/widgets/interaction-editor/constraint-editor.tsx +0 -134
  129. package/src/widgets/interaction-editor/function-editor.tsx +0 -108
  130. package/src/widgets/interaction-editor/index.ts +0 -3
  131. package/src/widgets/interaction-editor/interaction-editor.tsx +0 -749
  132. package/src/widgets/interaction-editor/label-editor.tsx +0 -87
  133. package/src/widgets/interaction-editor/line-editor.tsx +0 -125
  134. package/src/widgets/interaction-editor/mathquill-input.tsx +0 -81
  135. package/src/widgets/interaction-editor/movable-line-editor.tsx +0 -121
  136. package/src/widgets/interaction-editor/movable-point-editor.tsx +0 -89
  137. package/src/widgets/interaction-editor/parametric-editor.tsx +0 -117
  138. package/src/widgets/interaction-editor/point-editor.tsx +0 -72
  139. package/src/widgets/interaction-editor/rectangle-editor.tsx +0 -96
  140. package/src/widgets/interactive-graph-editor.tsx +0 -571
  141. package/src/widgets/label-image/__stories__/answer-choices.stories.tsx +0 -82
  142. package/src/widgets/label-image/__stories__/behavior.stories.tsx +0 -31
  143. package/src/widgets/label-image/__stories__/marker.stories.tsx +0 -75
  144. package/src/widgets/label-image/__stories__/question-markers.stories.tsx +0 -107
  145. package/src/widgets/label-image/__stories__/select-image.stories.tsx +0 -59
  146. package/src/widgets/label-image/answer-choices.tsx +0 -231
  147. package/src/widgets/label-image/behavior.tsx +0 -122
  148. package/src/widgets/label-image/marker.tsx +0 -290
  149. package/src/widgets/label-image/question-markers.tsx +0 -176
  150. package/src/widgets/label-image/select-image.tsx +0 -81
  151. package/src/widgets/label-image-editor.tsx +0 -252
  152. package/src/widgets/lights-puzzle-editor.tsx +0 -278
  153. package/src/widgets/matcher-editor.tsx +0 -149
  154. package/src/widgets/matrix-editor.tsx +0 -156
  155. package/src/widgets/measurer-editor.tsx +0 -253
  156. package/src/widgets/molecule-editor.tsx +0 -64
  157. package/src/widgets/number-line-editor.tsx +0 -512
  158. package/src/widgets/numeric-input-editor.tsx +0 -536
  159. package/src/widgets/orderer-editor.tsx +0 -172
  160. package/src/widgets/passage-editor.tsx +0 -117
  161. package/src/widgets/passage-ref-editor.tsx +0 -80
  162. package/src/widgets/passage-ref-target-editor.tsx +0 -49
  163. package/src/widgets/plotter-editor.tsx +0 -542
  164. package/src/widgets/radio/editor.tsx +0 -398
  165. package/src/widgets/reaction-diagram-editor.tsx +0 -131
  166. package/src/widgets/sequence-editor.tsx +0 -205
  167. package/src/widgets/simple-markdown-tester-editor.tsx +0 -86
  168. package/src/widgets/simple-markdown-tester.tsx +0 -92
  169. package/src/widgets/simulator-editor.tsx +0 -146
  170. package/src/widgets/sorter-editor.tsx +0 -103
  171. package/src/widgets/table-editor.tsx +0 -164
  172. package/src/widgets/unit-editor.tsx +0 -299
  173. package/src/widgets/video-editor.tsx +0 -73
  174. package/tsconfig-build.json +0 -22
  175. package/tsconfig-build.tsbuildinfo +0 -1
package/src/editor.tsx DELETED
@@ -1,1099 +0,0 @@
1
- /* eslint-disable @khanacademy/ts-no-error-suppressions */
2
- import {
3
- preprocessTex,
4
- Errors,
5
- Log,
6
- PerseusError,
7
- PerseusMarkdown,
8
- Util,
9
- Widgets,
10
- } from "@khanacademy/perseus";
11
- import $ from "jquery";
12
- // eslint-disable-next-line import/no-unresolved, import/no-extraneous-dependencies
13
- import katex from "katex";
14
- // ./katex-mhchem is imported for side effects. It adds the mhchem extension
15
- // to KaTeX, which is needed to render chemistry expressions. This prevents
16
- // spurious KaTeX errors from displaying in the editor for every chemistry
17
- // expression.
18
- // eslint-disable-next-line import/no-unassigned-import
19
- import "./katex-mhchem";
20
- import * as React from "react";
21
- import _ from "underscore";
22
-
23
- import DragTarget from "./components/drag-target";
24
- import WidgetEditor from "./components/widget-editor";
25
- import WidgetSelect from "./components/widget-select";
26
- import TexErrorView from "./tex-error-view";
27
-
28
- import type {ChangeHandler, PerseusWidget} from "@khanacademy/perseus";
29
-
30
- // like [[snowman input-number 1]]
31
- const widgetPlaceholder = "[[\u2603 {id}]]";
32
- const widgetRegExp = "(\\[\\[\u2603 {id}\\]\\])";
33
- const rWidgetSplit = new RegExp(
34
- widgetRegExp.replace("{id}", "[a-z-]+ [0-9]+"),
35
- "g",
36
- );
37
-
38
- const shortcutRegexp = /^\[\[([a-z-]+)$/; // like [[nu, [[int, etc
39
-
40
- const ENDS_WITH_A_PARAGRAPH = /(?:\n{2,}|^\n*)$/;
41
- const TRAILING_NEWLINES = /(\n*)$/;
42
- const LEADING_NEWLINES = /^(\n*)/;
43
-
44
- const commafyInteger = (n: number) => {
45
- let str = n.toString();
46
- if (str.length >= 5) {
47
- str = str.replace(/(\d)(?=(\d{3})+$)/g, "$1{,}");
48
- }
49
- return str;
50
- };
51
- const makeEndWithAParagraphIfNecessary = (content: string) => {
52
- if (!ENDS_WITH_A_PARAGRAPH.test(content)) {
53
- const match = TRAILING_NEWLINES.exec(content);
54
- if (match) {
55
- const newlines = match[1];
56
- return content + "\n\n".slice(0, 2 - newlines.length);
57
- }
58
- }
59
- return content;
60
- };
61
- const makeStartWithAParagraphAlways = (content) => {
62
- const match = LEADING_NEWLINES.exec(content);
63
- if (!match) {
64
- return content;
65
- }
66
- const newlines = match[1];
67
- return "\n\n".slice(0, 2 - newlines.length) + content;
68
- };
69
-
70
- // This is more general than the actual markdown image parsing regex,
71
- // which is fine for correctness since it should only mean we could
72
- // store extra image dimensions, unless the question is insanely
73
- // formatted.
74
- // A simplified regex here should hopefully be easier to keep in
75
- // sync if the markdown parsing changes, though if it becomes
76
- // easy to hook into the actual markdown regex without copy-pasting
77
- // it, we should do that.
78
- const IMAGE_REGEX = /!\[[^\]]*\]\(([^\s)]+)[^)]*\)/g;
79
-
80
- /**
81
- * Find all the matches to a /g regex.
82
- *
83
- * Returns an array of the regex matches. Infinite loops if `regex` does not
84
- * have a /g modifier.
85
- *
86
- * Note: Returns an array of the capture objects, whereas String::match
87
- * ignores captures. If you don't need captures, use String::match
88
- */
89
- const allMatches = function (regex: RegExp, str: string) {
90
- const result: Array<RegExpExecArray> = [];
91
- // eslint-disable-next-line no-constant-condition
92
- while (true) {
93
- const match = regex.exec(str);
94
- if (!match) {
95
- break;
96
- }
97
- result.push(match);
98
- }
99
- return result;
100
- };
101
-
102
- /**
103
- * Return an array of URLs of all the images in the given renderer
104
- * markdown.
105
- */
106
- const imageUrlsFromContent = function (content: string) {
107
- return allMatches(IMAGE_REGEX, content).map((capture) => capture[1]);
108
- };
109
-
110
- type Props = Readonly<{
111
- apiOptions: any;
112
- className?: string;
113
- content: string;
114
- replace?: any;
115
- placeholder: string;
116
- widgets: {
117
- [name: string]: PerseusWidget;
118
- };
119
- images: any;
120
- disabled: boolean;
121
- widgetEnabled: boolean;
122
- immutableWidgets: boolean;
123
- showWordCount: boolean;
124
- warnNoPrompt: boolean;
125
- warnNoWidgets: boolean;
126
- imageUploader?: (
127
- file: string,
128
- callback: (url: string) => unknown,
129
- ) => unknown;
130
- onChange: ChangeHandler;
131
- }>;
132
-
133
- type DefaultProps = {
134
- content: string;
135
- disabled: boolean;
136
- images: Record<any, any>;
137
- immutableWidgets: boolean;
138
- placeholder: string;
139
- showWordCount: boolean;
140
- warnNoPrompt: boolean;
141
- warnNoWidgets: boolean;
142
- widgetEnabled: boolean;
143
- widgets: {
144
- [name: string]: PerseusWidget;
145
- };
146
- };
147
-
148
- type State = {
149
- textAreaValue: string;
150
- };
151
-
152
- // eslint-disable-next-line react/no-unsafe
153
- class Editor extends React.Component<Props, State> {
154
- lastUserValue: string | null | undefined;
155
- deferredChange: any | null | undefined;
156
- widgetIds: any | null | undefined;
157
-
158
- underlay = React.createRef<HTMLDivElement>();
159
- textarea = React.createRef<HTMLTextAreaElement>();
160
-
161
- static defaultProps: DefaultProps = {
162
- content: "",
163
- placeholder: "",
164
- widgets: {},
165
- images: {},
166
- disabled: false,
167
- widgetEnabled: true,
168
- immutableWidgets: false,
169
- showWordCount: false,
170
- warnNoPrompt: false,
171
- warnNoWidgets: false,
172
- };
173
-
174
- state: State = {
175
- textAreaValue: this.props.content,
176
- };
177
-
178
- componentDidMount() {
179
- // See componentDidUpdate() for how this flag is used
180
- this.lastUserValue = null;
181
-
182
- // This can't be in componentWillMount because that's happening during
183
- // the middle of our parent's render, so we can't call
184
- // this.props.onChange during that, since it calls our parent's
185
- // setState
186
- this._sizeImages(this.props);
187
- // NOTE(jeremy): We use the non-null assertion here (!) because refs
188
- // are guaranteed to be up-to-date before componentDidMount or
189
- // componentDidUpdate fires.
190
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
191
- $(this.textarea.current!)
192
- // @ts-expect-error - TS2339 - Property 'on' does not exist on type 'JQueryStatic'.
193
- .on("copy cut", this._maybeCopyWidgets)
194
- // @ts-expect-error - TS2769 - No overload matches this call.
195
- .on("paste", this._maybePasteWidgets);
196
- }
197
-
198
- // TODO(arun): This is a deprecated method, use the appropriate replacement
199
- // eslint-disable-next-line react/no-unsafe
200
- UNSAFE_componentWillReceiveProps(nextProps: Props) {
201
- if (this.props.content !== nextProps.content) {
202
- this.setState({textAreaValue: nextProps.content});
203
- }
204
- }
205
-
206
- componentDidUpdate(prevProps: Props) {
207
- const textarea = this.textarea.current;
208
-
209
- // Slightly unorthodox method to ensure that programmatic text changes
210
- // are in the browser's undo stack.
211
- // See https://stackoverflow.com/questions/41273569/how-to-enable-ctrl-z-when-you-change-an-input-text-dynamically-with-react
212
- // Note: On unsupported browsers (*cough* Firefox *cough*) `execCommand`
213
- // will return false. However at least in Firefox setting `value` on a
214
- // textbox clears the undo stack so we don't get unexpected undo
215
- // behavior.
216
- if (this.lastUserValue != null && textarea) {
217
- textarea.focus();
218
- textarea.value = this.lastUserValue;
219
- textarea.selectionStart = 0;
220
- textarea.setSelectionRange(0, prevProps.content.length);
221
- if (
222
- document.execCommand(
223
- "insertText",
224
- false,
225
- this.props.content,
226
- ) === false
227
- ) {
228
- // This command is not implemented. Fall back to setting `value`
229
- // directly.
230
- textarea.value = this.props.content;
231
- }
232
- this.lastUserValue = null;
233
- }
234
-
235
- // This can't be in componentWillReceiveProps because that's happening
236
- // during the middle of our parent's render.
237
- if (this.props.content !== prevProps.content) {
238
- this._sizeImages(this.props);
239
- }
240
- }
241
-
242
- componentWillUnmount() {
243
- // TODO(jeff, CP-3128): Use Wonder Blocks Timing API.
244
- // eslint-disable-next-line no-restricted-syntax
245
- clearTimeout(this.deferredChange);
246
- }
247
-
248
- getWidgetEditor(
249
- id: string,
250
- type: PerseusWidget["type"],
251
- ): undefined | React.ReactNode {
252
- if (!Widgets.getEditor(type)) {
253
- return;
254
- }
255
- return (
256
- <WidgetEditor
257
- ref={id}
258
- id={id}
259
- // eslint-disable-next-line react/jsx-no-bind
260
- onChange={this._handleWidgetEditorChange.bind(this, id)}
261
- // eslint-disable-next-line react/jsx-no-bind
262
- onRemove={this._handleWidgetEditorRemove.bind(this, id)}
263
- apiOptions={this.props.apiOptions}
264
- {...this.props.widgets[id]}
265
- />
266
- );
267
- }
268
-
269
- _handleWidgetEditorChange: (
270
- id: string,
271
- newWidgetInfo: PerseusWidget,
272
- cb?: () => unknown,
273
- silent?: boolean,
274
- ) => void = (
275
- id: string,
276
- newWidgetInfo: PerseusWidget,
277
- cb?: () => unknown,
278
- silent?: boolean,
279
- ) => {
280
- const widgets = Object.assign({}, this.props.widgets);
281
- widgets[id] = Object.assign({}, widgets[id], newWidgetInfo);
282
- this.props.onChange({widgets}, cb, silent);
283
- };
284
-
285
- _handleWidgetEditorRemove: (id: string) => void = (id: string) => {
286
- // eslint-disable-next-line no-alert
287
- if (!confirm("Are you sure you want to delete this item?")) {
288
- return;
289
- }
290
-
291
- const textarea = this.textarea.current;
292
- const re = new RegExp(widgetRegExp.replace("{id}", id), "gm");
293
- this.props.onChange({content: textarea?.value.replace(re, "")});
294
- };
295
-
296
- /**
297
- * Calculate the size of all the images in props.content, and send
298
- * those sizes to this.props.images using props.onChange.
299
- */
300
- _sizeImages: (props: Props) => void = (props: Props) => {
301
- const imageUrls = imageUrlsFromContent(props.content);
302
-
303
- // Discard any images in our dimension table that no
304
- // longer exist in content.
305
- const images = _.pick(props.images, imageUrls);
306
-
307
- // Only calculate sizes for images that were not present previously.
308
- // Most content edits shouldn't have new images.
309
- // This could get weird in the case of multiple images with the same
310
- // URL, if you've changed the backing image size, but given graphie
311
- // hashes it's probably an edge case.
312
- const newImageUrls = _.filter(imageUrls, (url) => !images[url]);
313
-
314
- // TODO(jack): Q promises would make this nicer and only
315
- // fire once.
316
- _.each(newImageUrls, (url) => {
317
- Util.getImageSize(url, (width, height) => {
318
- // We keep modifying the same image object rather than a new
319
- // copy from this.props because all changes here are additive.
320
- // Maintaining old changes isn't strictly necessary if
321
- // props.onChange calls are not batched, but would be if they
322
- // were, so this is nice from that anti-race-condition
323
- // perspective as well.
324
- images[url] = {
325
- width: width,
326
- height: height,
327
- };
328
- props.onChange(
329
- {
330
- images: _.clone(images),
331
- },
332
- // @ts-expect-error - TS2345 - Argument of type 'null' is not assignable to parameter of type '(() => unknown) | undefined'.
333
- null, // callback
334
- true, // silent
335
- );
336
- });
337
- });
338
- };
339
-
340
- handleDrop: (e: DragEvent) => void = (e: DragEvent) => {
341
- const {imageUploader} = this.props;
342
- let content = this.state.textAreaValue || "";
343
- const dataTransfer = e.dataTransfer;
344
-
345
- if (!dataTransfer || !imageUploader) {
346
- return;
347
- }
348
-
349
- // files will hold something if the drag was from the desktop or a file
350
- // located on the user's computer.
351
- const files = dataTransfer.files;
352
-
353
- // ... but we only get a url if the drag originated in another window
354
- if (files.length === 0) {
355
- const imageUrl = dataTransfer.getData("URL");
356
-
357
- if (imageUrl) {
358
- // TODO(joel) - relocate when the image upload dialog lands
359
- const newContent = content + "\n\n![](" + imageUrl + ")";
360
- // See componentDidUpdate() for how this flag is used
361
- this.lastUserValue = this.props.content;
362
- this.props.onChange({content: newContent});
363
- }
364
-
365
- return;
366
- }
367
-
368
- const origContent = this.state.textAreaValue;
369
-
370
- /* For each file we make sure it's an image, then create a sentinel -
371
- * snowman + identifier to insert into the current text. The sentinel
372
- * only lives there temporarily until we get a response back from the
373
- * server that the image is now hosted on AWS, at which time we replace
374
- * the temporary sentinel with the permanent url for the image.
375
- *
376
- * There is an abuse of tap in the middle of the pipeline to make sure
377
- * everything is sequenced in the correct order. We want to modify the
378
- * content (given any number of images) at the same time, i.e. only
379
- * once, so we do that step with the tap. After the content has been
380
- * changed we send off the request for each image.
381
- *
382
- * Note that the snowman doesn't do anything special in this case -
383
- * it's effectively just part of a broken link. Perseus could be
384
- * extended to recognize this sentinel and highlight it like for
385
- * widgets.
386
- */
387
- _(files)
388
- .chain()
389
- .map(function (file) {
390
- if (!file.type.match("image.*")) {
391
- return null;
392
- }
393
-
394
- const sentinel = "\u2603 " + _.uniqueId("image_");
395
- // TODO(joel) - figure out how to temporarily include the image
396
- // before the server returns.
397
- content += "\n\n![](" + sentinel + ")";
398
-
399
- return {file: file, sentinel: sentinel};
400
- })
401
- .reject(_.isNull)
402
- .tap(() => {
403
- // See componentDidUpdate() for how this flag is used
404
- this.lastUserValue = origContent;
405
- this.props.onChange({content: content});
406
- })
407
- .each((fileAndSentinel) => {
408
- // @ts-expect-error - TS2531 - Object is possibly 'null'. | TS2345 - Argument of type 'File' is not assignable to parameter of type 'string'.
409
- imageUploader(fileAndSentinel.file, (url) => {
410
- // See componentDidUpdate() for how this flag is used
411
- this.lastUserValue = origContent;
412
- this.props.onChange({
413
- content: this.state.textAreaValue.replace(
414
- // @ts-expect-error - TS2531 - Object is possibly 'null'.
415
- fileAndSentinel.sentinel,
416
- url,
417
- ),
418
- });
419
- });
420
- });
421
- };
422
-
423
- handleChange: (e: React.SyntheticEvent<HTMLTextAreaElement>) => void = (
424
- e: React.SyntheticEvent<HTMLTextAreaElement>,
425
- ) => {
426
- // TODO(jeff, CP-3128): Use Wonder Blocks Timing API.
427
- // eslint-disable-next-line no-restricted-syntax
428
- clearTimeout(this.deferredChange);
429
- this.setState({textAreaValue: e.currentTarget.value});
430
- // TODO(jeff, CP-3128): Use Wonder Blocks Timing API.
431
- // eslint-disable-next-line no-restricted-syntax
432
- this.deferredChange = setTimeout(() => {
433
- if (this.state.textAreaValue !== this.props.content) {
434
- this.props.onChange({content: this.state.textAreaValue});
435
- }
436
- }, this.props.apiOptions.editorChangeDelay);
437
- };
438
-
439
- _handleKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void = (
440
- e: React.KeyboardEvent<HTMLTextAreaElement>,
441
- ) => {
442
- e.stopPropagation();
443
- // Tab-completion of widgets. For example, to insert an image:
444
- // type `[[im`, then tab.
445
- if (e.key === "Tab") {
446
- // We're in an event handler attached to the textarea, so the ref
447
- // can't be empty/undefined! (which is why its safe to use the
448
- // non-null-assertion here. aka the `!` suffix)
449
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
450
- const textarea = this.textarea.current!;
451
-
452
- const word = Util.textarea.getWordBeforeCursor(textarea);
453
- const matches = word.string.toLowerCase().match(shortcutRegexp);
454
-
455
- if (matches != null) {
456
- const text = matches[1];
457
- const widgets = Widgets.getAllWidgetTypes();
458
- const matchingWidgets = _.filter(widgets, (name) => {
459
- return name.substring(0, text.length) === text;
460
- });
461
-
462
- if (matchingWidgets.length === 1) {
463
- const widgetType = matchingWidgets[0];
464
-
465
- this._addWidgetToContent(
466
- this.props.content,
467
- [word.pos.start, word.pos.end + 1],
468
- widgetType,
469
- );
470
- }
471
-
472
- e.preventDefault();
473
- }
474
- }
475
- };
476
-
477
- _maybeCopyWidgets: (e: React.SyntheticEvent<HTMLTextAreaElement>) => void =
478
- (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
479
- // If there are widgets being cut/copied, put the widget JSON in
480
- // localStorage.perseusLastCopiedWidgets to allow copy-pasting of
481
- // widgets between Editors. Also store the text to be pasted in
482
- // localStorage.perseusLastCopiedText since we want to know if the user
483
- // is actually pasting something originally from Perseus later.
484
- const textarea = e.currentTarget;
485
- const selectedText = textarea.value.substring(
486
- textarea.selectionStart,
487
- textarea.selectionEnd,
488
- );
489
-
490
- const widgetNames = _.map(
491
- // @ts-expect-error - TS2345 - Argument of type 'RegExpMatchArray | null' is not assignable to parameter of type 'Collection<any>'.
492
- selectedText.match(rWidgetSplit),
493
- (syntax) => {
494
- // @ts-expect-error - TS2531 - Object is possibly 'null'.
495
- return Util.rWidgetParts.exec(syntax)[1];
496
- },
497
- );
498
-
499
- const widgetData = _.pick(this.serialize().widgets, widgetNames);
500
-
501
- localStorage.perseusLastCopiedText = selectedText;
502
- localStorage.perseusLastCopiedWidgets = JSON.stringify(widgetData);
503
-
504
- Log.log(`Widgets copied: ${localStorage.perseusLastCopiedWidgets}`);
505
- };
506
-
507
- _maybePasteWidgets: (e: React.SyntheticEvent<HTMLTextAreaElement>) => void =
508
- (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
509
- // Use the data from localStorage to paste any widgets we copied
510
- // before. Avoid name conflicts by renumbering pasted widgets so that
511
- // their numbers are always higher than the highest numbered widget of
512
- // their type.
513
- // TODO(sam): Fix widget numbering in the widget editor titles
514
-
515
- const widgetJSON = localStorage.perseusLastCopiedWidgets;
516
- const lastCopiedText = localStorage.perseusLastCopiedText;
517
- const textToBePasted =
518
- // @ts-expect-error - TS2339 - Property 'originalEvent' does not exist on type 'SyntheticEvent<HTMLTextAreaElement, Event>'.
519
- e.originalEvent.clipboardData.getData("text");
520
-
521
- // Only intercept if we have widget data to paste and the user is
522
- // pasting something originally from Perseus.
523
- // TODO(sam/aria/alex): Make it so that you can paste arbitrary text
524
- // (e.g. from a text editor) instead of exactly what was copied, and
525
- // let the widgetJSON match up with it. This would let you copy text
526
- // into a buffer, perform complex operations on it, then paste it back.
527
- if (widgetJSON && lastCopiedText === textToBePasted) {
528
- e.preventDefault();
529
-
530
- const widgetData = JSON.parse(widgetJSON);
531
- const safeWidgetMapping =
532
- this._safeWidgetNameMapping(widgetData);
533
-
534
- // Use safe widget name map to construct the new widget data
535
- // TODO(aria/alex): Don't use `rWidgetSplit` or other piecemeal
536
- // regexes directly; abstract this out so that we don't have to
537
- // worry about potential edge cases.
538
- const safeWidgetData: Record<string, any> = {};
539
- for (const [key, data] of Object.entries(widgetData)) {
540
- safeWidgetData[safeWidgetMapping[key]] = data;
541
- }
542
- const newWidgets = _.extend(safeWidgetData, this.props.widgets);
543
-
544
- // Use safe widget name map to construct new text
545
- const safeText = lastCopiedText.replace(
546
- rWidgetSplit,
547
- (syntax) => {
548
- const match = Util.rWidgetParts.exec(syntax);
549
- // @ts-expect-error - TS2531 - Object is possibly 'null'.
550
- const completeWidget = match[0];
551
- // @ts-expect-error - TS2531 - Object is possibly 'null'.
552
- const widget = match[1];
553
- return completeWidget.replace(
554
- widget,
555
- safeWidgetMapping[widget],
556
- );
557
- },
558
- );
559
-
560
- // Add pasted text to previous content, replacing selected text to
561
- // replicate normal paste behavior.
562
- const textarea = e.currentTarget;
563
- const selectionStart = textarea.selectionStart;
564
- const newContent =
565
- this.state.textAreaValue.substr(0, selectionStart) +
566
- safeText +
567
- this.state.textAreaValue.substr(textarea.selectionEnd);
568
-
569
- // See componentDidUpdate() for how this flag is used
570
- this.lastUserValue = this.state.textAreaValue;
571
- this.props.onChange(
572
- {content: newContent, widgets: newWidgets},
573
- () => {
574
- const expectedCursorPosition =
575
- selectionStart + safeText.length;
576
- Util.textarea.moveCursor(
577
- textarea,
578
- expectedCursorPosition,
579
- );
580
- },
581
- );
582
- }
583
- };
584
-
585
- _safeWidgetNameMapping: (widgetData: {
586
- [name: string]: any;
587
- }) => Record<any, any> = (widgetData: {[name: string]: any}) => {
588
- // Helper function for _maybePasteWidgets.
589
- // For each widget about to be pasted, construct a mapping from
590
- // old widget name to a new widget name that doesn't have conflicts
591
- // with widgets already in the editor.
592
- // eg. If there is an "image 2" already present in the editor and we're
593
- // about to paste in two new images, return
594
- // { "image 1": "image 3", "image 2": "image 4" }
595
-
596
- // List of widgets about to be pasted as [[name, number], ...]
597
- const widgets = _.keys(widgetData).map((name) => name.split(" "));
598
- const widgetTypes = _.uniq(widgets.map((widget) => widget[0]));
599
-
600
- // List of existing widgets as [[name, number], ...]
601
- const existingWidgets = _.keys(this.props.widgets).map((name) =>
602
- name.split(" "),
603
- );
604
-
605
- // Mapping of widget type to a safe (non-conflicting) number
606
- // eg. { "image": 2, "dropdown": 1 }
607
- const safeWidgetNums: Record<string, any> = {};
608
- _.each(widgetTypes, (type) => {
609
- safeWidgetNums[type] = _.chain(existingWidgets)
610
- .filter((existingWidget) => existingWidget[0] === type)
611
- .map((existingWidget) => +existingWidget[1] + 1)
612
- .max()
613
- .value();
614
- // If there are no existing widgets _.max returns -Infinity
615
- safeWidgetNums[type] = Math.max(safeWidgetNums[type], 1);
616
- });
617
-
618
- // Construct mapping, incrementing the vals in safeWidgetNums as we go
619
- const safeWidgetMapping: Record<string, any> = {};
620
- _.each(widgets, (widget) => {
621
- const widgetName = widget.join(" ");
622
- const widgetType = widget[0];
623
-
624
- safeWidgetMapping[
625
- widgetName
626
- ] = `${widgetType} ${safeWidgetNums[widgetType]}`;
627
- safeWidgetNums[widgetType]++;
628
- });
629
-
630
- return safeWidgetMapping;
631
- };
632
-
633
- _addWidgetToContent: (
634
- oldContent: string,
635
- cursorRange: ReadonlyArray<number>,
636
- widgetType: string,
637
- ) => void = (
638
- oldContent: string,
639
- cursorRange: ReadonlyArray<number>,
640
- widgetType: string,
641
- ) => {
642
- // Note: we have to use _.map here instead of Array::map
643
- // because the results of a .match might be null if no
644
- // widgets were found.
645
- // @ts-expect-error - TS2345 - Argument of type 'RegExpMatchArray | null' is not assignable to parameter of type 'Collection<any>'.
646
- const allWidgetIds = _.map(oldContent.match(rWidgetSplit), (syntax) => {
647
- const match = Util.rWidgetParts.exec(syntax);
648
- // @ts-expect-error - TS2531 - Object is possibly 'null'.
649
- const type = match[2];
650
- // @ts-expect-error - TS2531 - Object is possibly 'null'.
651
- const num = +match[3];
652
- return [type, num];
653
- });
654
-
655
- const widgetNum = _.reduce(
656
- allWidgetIds,
657
- (currentNum, otherId) => {
658
- const [otherType, otherNum] = otherId;
659
- if (otherType === widgetType) {
660
- return Math.max(otherNum + 1, currentNum);
661
- }
662
- return currentNum;
663
- },
664
- 1,
665
- );
666
-
667
- const id = widgetType + " " + widgetNum;
668
- const widgetContent = widgetPlaceholder.replace("{id}", id);
669
-
670
- // Add newlines before block-display widgets like graphs
671
- const isBlock = Widgets.getDefaultAlignment(widgetType) === "block";
672
-
673
- const prelude = oldContent.slice(0, cursorRange[0]);
674
- const postlude = oldContent.slice(cursorRange[1]);
675
-
676
- const newPrelude = isBlock
677
- ? makeEndWithAParagraphIfNecessary(prelude)
678
- : prelude;
679
- const newPostlude = isBlock
680
- ? makeStartWithAParagraphAlways(postlude)
681
- : postlude;
682
-
683
- const newContent = newPrelude + widgetContent + newPostlude;
684
-
685
- const newWidgets = _.clone(this.props.widgets);
686
- // @ts-expect-error TS(2345) Type '"categorizer" | undefined' is not assignable to type '"deprecated-standin"'.
687
- newWidgets[id] = {
688
- options: Widgets.getEditor(widgetType)?.defaultProps,
689
- type: widgetType as PerseusWidget["type"],
690
- // Track widget version on creation, so that a widget editor
691
- // without a valid version prop can only possibly refer to a
692
- // pre-versioning creation time.
693
- version: Widgets.getVersion(widgetType),
694
- };
695
-
696
- // See componentDidUpdate() for how this flag is used
697
- this.lastUserValue = this.props.content;
698
- this.props.onChange(
699
- {
700
- content: newContent,
701
- widgets: newWidgets,
702
- },
703
- () => {
704
- if (!this.textarea.current) {
705
- return;
706
- }
707
-
708
- Util.textarea.moveCursor(
709
- this.textarea.current,
710
- // We want to put the cursor after the widget
711
- // and after any added newlines
712
- newContent.length - postlude.length,
713
- );
714
- },
715
- );
716
- };
717
-
718
- _addWidget: (widgetType: string) => void = (widgetType: string) => {
719
- const textarea = this.textarea.current;
720
- if (!textarea) {
721
- return;
722
- }
723
-
724
- this._addWidgetToContent(
725
- this.props.content,
726
- [textarea.selectionStart, textarea.selectionEnd],
727
- widgetType,
728
- );
729
- textarea.focus();
730
- };
731
-
732
- addTemplate: (e: React.SyntheticEvent<HTMLSelectElement>) => void = (
733
- e: React.SyntheticEvent<HTMLSelectElement>,
734
- ) => {
735
- const templateType = e.currentTarget.value;
736
- if (templateType === "") {
737
- return;
738
- }
739
- e.currentTarget.value = "";
740
-
741
- let oldContent = this.props.content;
742
-
743
- // Force templates to have a blank line before them,
744
- // as they are usually used as block elements
745
- // (especially important for tables)
746
- oldContent = oldContent.replace(/\n*$/, "\n\n");
747
-
748
- let template;
749
- if (templateType === "table") {
750
- template =
751
- "header 1 | header 2 | header 3\n" +
752
- "- | - | -\n" +
753
- "data 1 | data 2 | data 3\n" +
754
- "data 4 | data 5 | data 6\n" +
755
- "data 7 | data 8 | data 9";
756
- } else if (templateType === "titledTable") {
757
- template =
758
- "|| **Table title** ||\n" +
759
- "header 1 | header 2 | header 3\n" +
760
- "- | - | -\n" +
761
- "data 1 | data 2 | data 3\n" +
762
- "data 4 | data 5 | data 6\n" +
763
- "data 7 | data 8 | data 9";
764
- } else if (templateType === "alignment") {
765
- template =
766
- "$\\begin{align} x+5 &= 30 \\\\\n" +
767
- "x+5-5 &= 30-5 \\\\\n" +
768
- "x &= 25 \\end{align}$";
769
- } else if (templateType === "piecewise") {
770
- template =
771
- "$f(x) = \\begin{cases}\n" +
772
- "7 & \\text{if }x=1 \\\\\n" +
773
- "f(x-1)+5 & \\text{if }x > 1\n" +
774
- "\\end{cases}$";
775
- } else if (templateType === "allWidgets") {
776
- template = Widgets.getAllWidgetTypes()
777
- .map((type) => `[[${Util.snowman} ${type} 1]]`)
778
- .join("\n\n");
779
- } else {
780
- throw new PerseusError(
781
- "Invalid template type: " + templateType,
782
- Errors.InvalidInput,
783
- {
784
- metadata: {templateType},
785
- },
786
- );
787
- }
788
-
789
- const newContent = oldContent + template;
790
-
791
- // See componentDidUpdate() for how this flag is used
792
- this.lastUserValue = this.props.content;
793
- this.props.onChange({content: newContent}, this.focusAndMoveToEnd);
794
- };
795
-
796
- getSaveWarnings: () => any = () => {
797
- // eslint-disable-next-line react/no-string-refs
798
- const widgetIds = _.intersection(this.widgetIds, _.keys(this.refs));
799
- const warnings = _(widgetIds)
800
- .chain()
801
- .map((id) => {
802
- // eslint-disable-next-line react/no-string-refs
803
- // @ts-expect-error - TS2339 - Property 'getSaveWarnings' does not exist on type 'ReactInstance'.
804
- const issuesFunc = this.refs[id].getSaveWarnings;
805
- const issues = issuesFunc ? issuesFunc() : [];
806
- return _.map(issues, (issue) => id + ": " + issue);
807
- })
808
- .flatten(true)
809
- .value();
810
-
811
- return warnings;
812
- };
813
-
814
- focus: () => void = () => {
815
- const textarea = this.textarea.current;
816
- if (textarea) {
817
- textarea.focus();
818
- }
819
- };
820
-
821
- focusAndMoveToEnd: () => void = () => {
822
- this.focus();
823
- const textarea = this.textarea.current;
824
- if (textarea) {
825
- textarea.selectionStart = textarea.value.length;
826
- textarea.selectionEnd = textarea.value.length;
827
- }
828
- };
829
-
830
- serialize: (options?: any) => {
831
- content: string;
832
- images: any;
833
- replace: any | undefined;
834
- widgets: Record<any, any>;
835
- } = (options: any) => {
836
- // need to serialize the widgets since the state might not be
837
- // completely represented in props. ahem //transformer// (and
838
- // interactive-graph and plotter).
839
- const widgets: Record<string, any> = {};
840
- // eslint-disable-next-line react/no-string-refs
841
- const widgetIds = _.intersection(this.widgetIds, _.keys(this.refs));
842
- _.each(widgetIds, (id) => {
843
- // eslint-disable-next-line react/no-string-refs
844
- // @ts-expect-error - TS2339 - Property 'serialize' does not exist on type 'ReactInstance'.
845
- widgets[id] = this.refs[id].serialize();
846
- });
847
-
848
- // Preserve the data associated with deleted widgets in their last
849
- // modified form. This is only intended to be useful in the context of
850
- // immediate cut and paste operations if Editor.serialize() is called
851
- // in between the two (which ideally should not be happening).
852
- // TODO(alex): Remove this once all widget.serialize() methods
853
- // have been fixed to only return props,
854
- // and the above no longer applies.
855
- if (options && options.keepDeletedWidgets) {
856
- _.chain(this.props.widgets)
857
- .keys()
858
- .reject((id) => _.contains(widgetIds, id))
859
- .each((id) => {
860
- widgets[id] = this.props.widgets[id];
861
- });
862
- }
863
-
864
- return {
865
- replace: this.props.replace,
866
- content: this.props.content,
867
- images: this.props.images,
868
- widgets: widgets,
869
- };
870
- };
871
-
872
- render(): React.ReactNode {
873
- let pieces;
874
- let widgets;
875
- let underlayPieces;
876
- let widgetsDropDown;
877
- let templatesDropDown;
878
- let widgetsAndTemplates;
879
- let wordCountDisplay;
880
- const katexErrorList: Array<{
881
- math: string;
882
- message: never;
883
- }> = [];
884
-
885
- if (this.props.showWordCount) {
886
- const numChars = PerseusMarkdown.characterCount(this.props.content);
887
- const numWords = Math.floor(numChars / 6);
888
- wordCountDisplay = (
889
- <span
890
- className="perseus-editor-word-count"
891
- title={
892
- "~" +
893
- commafyInteger(numWords) +
894
- " words (" +
895
- commafyInteger(numChars) +
896
- " characters)"
897
- }
898
- >
899
- {commafyInteger(numWords)}
900
- </span>
901
- );
902
- }
903
-
904
- if (this.props.widgetEnabled) {
905
- pieces = Util.split(this.props.content, rWidgetSplit);
906
- widgets = {};
907
- underlayPieces = [];
908
-
909
- for (let i = 0; i < pieces.length; i++) {
910
- // We split on widgets so every even-numbered indexed piece is
911
- // text and odd-numbered indexes are the widget references.
912
- if (i % 2 === 0) {
913
- // Normal text
914
- underlayPieces.push(pieces[i]);
915
-
916
- // @ts-expect-error - TS2554 - Expected 2 arguments, but got 1.
917
- const ast = PerseusMarkdown.parse(pieces[i]);
918
-
919
- PerseusMarkdown.traverseContent(ast, (node) => {
920
- if (node.type === "math" || node.type === "blockMath") {
921
- const content = preprocessTex(node.content);
922
- try {
923
- katex.renderToString(content, {
924
- colorIsTextColor: true,
925
- });
926
- } catch (e: any) {
927
- katexErrorList.push({
928
- math: content,
929
- // @ts-expect-error - TS2322 - Type 'any' is not assignable to type 'never'.
930
- message: e.message,
931
- });
932
- }
933
- }
934
- });
935
- } else {
936
- // Widget reference
937
- const match = Util.rWidgetParts.exec(pieces[i]);
938
- if (match != null) {
939
- const id = match[1];
940
- const type = match[2] as PerseusWidget["type"];
941
-
942
- const selected = false;
943
- // TODO(alpert):
944
- // var selected = focused && selStart === selEnd &&
945
- // offset <= selStart &&
946
- // selStart < offset + text.length;
947
- // if (selected) {
948
- // selectedWidget = id;
949
- // }
950
-
951
- const duplicate = id in widgets;
952
-
953
- widgets[id] = this.getWidgetEditor(id, type);
954
- const classes =
955
- (duplicate || !widgets[id] ? "error " : "") +
956
- (selected ? "selected " : "");
957
- const key = duplicate ? i : id;
958
- underlayPieces.push(
959
- <b className={classes} key={key}>
960
- {pieces[i]}
961
- </b>,
962
- );
963
- }
964
- }
965
- }
966
-
967
- // TODO(alpert): Move this to the content-change event handler
968
- // _.each(_.keys(this.props.widgets), function(id) {
969
- // if (!(id in widgets)) {
970
- // // It's strange if these preloaded options stick around
971
- // // since it's inconsistent with how things work if you
972
- // // don't have the serialize/deserialize step in the
973
- // // middle
974
- // // TODO(alpert): Save options in a consistent manner so
975
- // // that you can undo the deletion of a widget
976
- // delete this.props.widgets[id];
977
- // }
978
- // }, this);
979
-
980
- this.widgetIds = _.keys(widgets);
981
- widgetsDropDown = <WidgetSelect onChange={this._addWidget} />;
982
-
983
- const insertTemplateString = "Insert template\u2026";
984
- templatesDropDown = (
985
- <select onChange={this.addTemplate}>
986
- <option value="">{insertTemplateString}</option>
987
- <option disabled>--</option>
988
- <option value="table">Table</option>
989
- <option value="titledTable">Titled table</option>
990
- <option value="alignment">Aligned equations</option>
991
- <option value="piecewise">Piecewise function</option>
992
- <option disabled>--</option>
993
- <option value="allWidgets">
994
- All widgets (for testing)
995
- </option>
996
- </select>
997
- );
998
-
999
- if (!this.props.immutableWidgets) {
1000
- const widgetNodes = Object.values(widgets) as React.ReactNode;
1001
- widgetsAndTemplates = (
1002
- <div className="perseus-editor-widgets">
1003
- <div className="perseus-editor-widgets-selectors">
1004
- {widgetsDropDown}
1005
- {templatesDropDown}
1006
- {wordCountDisplay}
1007
- </div>
1008
- {widgetNodes}
1009
- </div>
1010
- );
1011
- // Prevent word count from being displayed elsewhere
1012
- wordCountDisplay = null;
1013
- }
1014
- } else {
1015
- underlayPieces = [this.props.content];
1016
- }
1017
-
1018
- // Without this, the underlay isn't the proper size when the text ends
1019
- // with a newline.
1020
- underlayPieces.push(<br key="end" />);
1021
-
1022
- const completeTextarea = [
1023
- <div
1024
- className="perseus-textarea-underlay"
1025
- ref={this.underlay}
1026
- key="underlay"
1027
- >
1028
- {underlayPieces}
1029
- </div>,
1030
- <textarea
1031
- ref={this.textarea}
1032
- key="textarea"
1033
- onChange={this.handleChange}
1034
- onKeyDown={this._handleKeyDown}
1035
- placeholder={this.props.placeholder}
1036
- disabled={this.props.disabled}
1037
- value={this.state.textAreaValue}
1038
- />,
1039
- ];
1040
-
1041
- let textareaWrapper;
1042
- if (this.props.imageUploader) {
1043
- textareaWrapper = (
1044
- <DragTarget
1045
- onDrop={this.handleDrop}
1046
- className="perseus-textarea-pair"
1047
- >
1048
- {completeTextarea}
1049
- </DragTarget>
1050
- );
1051
- } else {
1052
- textareaWrapper = (
1053
- <div className="perseus-textarea-pair">{completeTextarea}</div>
1054
- );
1055
- }
1056
-
1057
- const contentWithoutWidgets = this.props.content.replace(
1058
- /\[\[\u2603 (([a-z-]+) ([0-9]+))\]\]/g,
1059
- "",
1060
- );
1061
- const noPrompt = contentWithoutWidgets.trim().length === 0;
1062
- const noWidgets = !/\[\[\u2603 (([a-z-]+) ([0-9]+))\]\]/g.test(
1063
- this.props.content,
1064
- );
1065
-
1066
- const warningStyle = {
1067
- borderTop: "none",
1068
- padding: 4,
1069
- backgroundColor: "pink",
1070
- } as const;
1071
-
1072
- return (
1073
- <div
1074
- className={
1075
- "perseus-single-editor " + (this.props.className || "")
1076
- }
1077
- >
1078
- {textareaWrapper}
1079
- {katexErrorList.length > 0 && (
1080
- <TexErrorView errorList={katexErrorList} />
1081
- )}
1082
- {this.props.warnNoPrompt && noPrompt && (
1083
- <div style={warningStyle}>
1084
- Graded Groups should contain a prompt
1085
- </div>
1086
- )}
1087
- {this.props.warnNoWidgets && noWidgets && (
1088
- <div style={warningStyle}>
1089
- Graded Groups should contain at least one widget
1090
- </div>
1091
- )}
1092
- {wordCountDisplay}
1093
- {widgetsAndTemplates}
1094
- </div>
1095
- );
1096
- }
1097
- }
1098
-
1099
- export default Editor;