@khanacademy/perseus-editor 4.1.4 → 4.1.6
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.
- package/dist/es/index.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +9 -6
- package/.babelrc.js +0 -8
- package/.eslintrc.js +0 -12
- package/CHANGELOG.md +0 -1146
- package/src/README.md +0 -7
- package/src/__stories__/editor-page.stories.tsx +0 -65
- package/src/__stories__/editor.stories.tsx +0 -134
- package/src/__stories__/item-editor.stories.tsx +0 -41
- package/src/__stories__/tex-error-view.stories.tsx +0 -26
- package/src/__testdata__/input-number.testdata.ts +0 -27
- package/src/__tests__/editor.test.tsx +0 -142
- package/src/__tests__/hint-editor.test.tsx +0 -83
- package/src/__tests__/i18n.test.ts +0 -501
- package/src/__tests__/integration.test.ts +0 -5
- package/src/__tests__/item-extras-editor.test.tsx +0 -85
- package/src/__tests__/traversal.test.ts +0 -377
- package/src/all-editors.test.ts +0 -16
- package/src/all-editors.ts +0 -83
- package/src/all-widgets.ts +0 -7
- package/src/article-editor.tsx +0 -438
- package/src/components/__stories__/blur-input.stories.tsx +0 -22
- package/src/components/__stories__/section-control-button.stories.tsx +0 -26
- package/src/components/__tests__/blur-input.test.tsx +0 -67
- package/src/components/blur-input.tsx +0 -69
- package/src/components/checkbox.tsx +0 -194
- package/src/components/device-framer.tsx +0 -90
- package/src/components/drag-target.tsx +0 -87
- package/src/components/dropdown-option.tsx +0 -311
- package/src/components/form-wrapped-text-field.tsx +0 -245
- package/src/components/graph-points-count-selector.tsx +0 -31
- package/src/components/graph-settings.tsx +0 -627
- package/src/components/graph-type-selector.tsx +0 -31
- package/src/components/hover-behavior.ts +0 -177
- package/src/components/json-editor.tsx +0 -121
- package/src/components/link.ts +0 -173
- package/src/components/section-control-button.tsx +0 -53
- package/src/components/segment-count-selector.tsx +0 -27
- package/src/components/simple-button.tsx +0 -85
- package/src/components/sortable.tsx +0 -187
- package/src/components/util.ts +0 -38
- package/src/components/viewport-resizer.tsx +0 -72
- package/src/components/widget-editor.tsx +0 -222
- package/src/components/widget-select.tsx +0 -48
- package/src/diffs/__stories__/perseus-diff-wrapper.tsx +0 -11
- package/src/diffs/__stories__/structured-item-diff.stories.tsx +0 -855
- package/src/diffs/__stories__/tags-diff.stories.tsx +0 -36
- package/src/diffs/__stories__/text-diff.stories.tsx +0 -35
- package/src/diffs/__tests__/split-diff.test.ts +0 -33
- package/src/diffs/__tests__/string-array-diff.test.ts +0 -73
- package/src/diffs/__tests__/structured-item-diff.test.ts +0 -433
- package/src/diffs/__tests__/widget-diff-performer.test.ts +0 -43
- package/src/diffs/article-diff.tsx +0 -69
- package/src/diffs/item-diff.tsx +0 -76
- package/src/diffs/renderer-diff.tsx +0 -137
- package/src/diffs/split-diff.ts +0 -31
- package/src/diffs/string-array-diff.ts +0 -74
- package/src/diffs/structured-item-diff.tsx +0 -275
- package/src/diffs/tags-diff.tsx +0 -49
- package/src/diffs/text-diff.tsx +0 -193
- package/src/diffs/widget-diff-performer.ts +0 -69
- package/src/diffs/widget-diff.tsx +0 -290
- package/src/editor-page.tsx +0 -325
- package/src/editor.tsx +0 -1099
- package/src/hint-editor.tsx +0 -469
- package/src/i18n.ts +0 -140
- package/src/iframe-content-renderer.tsx +0 -188
- package/src/index.ts +0 -30
- package/src/item-editor.tsx +0 -158
- package/src/item-extras-editor.tsx +0 -230
- package/src/katex-mhchem/index.js +0 -73
- package/src/katex-mhchem/parser.js +0 -2659
- package/src/multirenderer-editor.tsx +0 -1061
- package/src/stateful-article-editor.tsx +0 -173
- package/src/stateful-editor-page.tsx +0 -88
- package/src/styles/perseus-editor.less +0 -1321
- package/src/testing-widgets.ts +0 -11
- package/src/tex-error-view.tsx +0 -90
- package/src/util/points.test.ts +0 -24
- package/src/util/points.ts +0 -13
- package/src/util/register-all-widgets-and-editors-for-testing.test.ts +0 -17
- package/src/util/register-all-widgets-and-editors-for-testing.ts +0 -17
- package/src/version.ts +0 -10
- package/src/widgets/__stories__/categorizer-editor.stories.tsx +0 -24
- package/src/widgets/__stories__/definition-editor.stories.tsx +0 -18
- package/src/widgets/__stories__/dropdown-editor.stories.tsx +0 -18
- package/src/widgets/__stories__/explanation-editor.stories.tsx +0 -18
- package/src/widgets/__stories__/expression-editor.stories.tsx +0 -122
- package/src/widgets/__stories__/image-editor.stories.tsx +0 -72
- package/src/widgets/__stories__/input-number-editor.stories.tsx +0 -18
- package/src/widgets/__stories__/interaction-editor.stories.tsx +0 -37
- package/src/widgets/__stories__/label-image-editor.stories.tsx +0 -99
- package/src/widgets/__stories__/matcher-editor.stories.tsx +0 -18
- package/src/widgets/__stories__/number-line-editor.stories.tsx +0 -18
- package/src/widgets/__stories__/numeric-input-editor.stories.tsx +0 -18
- package/src/widgets/__stories__/radio-editor.stories.tsx +0 -116
- package/src/widgets/__stories__/sorter-editor.stories.tsx +0 -18
- package/src/widgets/__tests__/categorizer-editor.test.tsx +0 -51
- package/src/widgets/__tests__/definition-editor.test.tsx +0 -41
- package/src/widgets/__tests__/dropdown-editor.test.tsx +0 -57
- package/src/widgets/__tests__/explanation-editor.test.tsx +0 -59
- package/src/widgets/__tests__/expression-editor.test.tsx +0 -403
- package/src/widgets/__tests__/input-number-editor.test.tsx +0 -128
- package/src/widgets/__tests__/matcher-editor.test.tsx +0 -47
- package/src/widgets/__tests__/number-line-editor.test.tsx +0 -144
- package/src/widgets/__tests__/numeric-input-editor.test.tsx +0 -177
- package/src/widgets/__tests__/radio-editor.test.tsx +0 -118
- package/src/widgets/__tests__/sorter-editor.test.tsx +0 -55
- package/src/widgets/categorizer-editor.tsx +0 -104
- package/src/widgets/cs-program-editor.tsx +0 -256
- package/src/widgets/definition-editor.tsx +0 -77
- package/src/widgets/deprecated-standin-editor.tsx +0 -29
- package/src/widgets/dropdown-editor.tsx +0 -211
- package/src/widgets/example-graphie-widget-editor.tsx +0 -70
- package/src/widgets/example-graphie-widget.tsx +0 -172
- package/src/widgets/example-widget-editor.tsx +0 -62
- package/src/widgets/example-widget.tsx +0 -154
- package/src/widgets/explanation-editor.tsx +0 -92
- package/src/widgets/expression-editor.tsx +0 -557
- package/src/widgets/graded-group-editor.tsx +0 -161
- package/src/widgets/graded-group-set-editor.tsx +0 -104
- package/src/widgets/grapher-editor.tsx +0 -155
- package/src/widgets/group-editor.tsx +0 -83
- package/src/widgets/iframe-editor.tsx +0 -202
- package/src/widgets/image-editor.tsx +0 -367
- package/src/widgets/input-number-editor.tsx +0 -288
- package/src/widgets/interaction-editor/constraint-editor.tsx +0 -134
- package/src/widgets/interaction-editor/function-editor.tsx +0 -108
- package/src/widgets/interaction-editor/index.ts +0 -3
- package/src/widgets/interaction-editor/interaction-editor.tsx +0 -749
- package/src/widgets/interaction-editor/label-editor.tsx +0 -87
- package/src/widgets/interaction-editor/line-editor.tsx +0 -125
- package/src/widgets/interaction-editor/mathquill-input.tsx +0 -81
- package/src/widgets/interaction-editor/movable-line-editor.tsx +0 -121
- package/src/widgets/interaction-editor/movable-point-editor.tsx +0 -89
- package/src/widgets/interaction-editor/parametric-editor.tsx +0 -117
- package/src/widgets/interaction-editor/point-editor.tsx +0 -72
- package/src/widgets/interaction-editor/rectangle-editor.tsx +0 -96
- package/src/widgets/interactive-graph-editor.tsx +0 -571
- package/src/widgets/label-image/__stories__/answer-choices.stories.tsx +0 -82
- package/src/widgets/label-image/__stories__/behavior.stories.tsx +0 -31
- package/src/widgets/label-image/__stories__/marker.stories.tsx +0 -75
- package/src/widgets/label-image/__stories__/question-markers.stories.tsx +0 -107
- package/src/widgets/label-image/__stories__/select-image.stories.tsx +0 -59
- package/src/widgets/label-image/answer-choices.tsx +0 -231
- package/src/widgets/label-image/behavior.tsx +0 -122
- package/src/widgets/label-image/marker.tsx +0 -290
- package/src/widgets/label-image/question-markers.tsx +0 -176
- package/src/widgets/label-image/select-image.tsx +0 -81
- package/src/widgets/label-image-editor.tsx +0 -252
- package/src/widgets/lights-puzzle-editor.tsx +0 -278
- package/src/widgets/matcher-editor.tsx +0 -149
- package/src/widgets/matrix-editor.tsx +0 -156
- package/src/widgets/measurer-editor.tsx +0 -253
- package/src/widgets/molecule-editor.tsx +0 -64
- package/src/widgets/number-line-editor.tsx +0 -512
- package/src/widgets/numeric-input-editor.tsx +0 -536
- package/src/widgets/orderer-editor.tsx +0 -172
- package/src/widgets/passage-editor.tsx +0 -117
- package/src/widgets/passage-ref-editor.tsx +0 -80
- package/src/widgets/passage-ref-target-editor.tsx +0 -49
- package/src/widgets/plotter-editor.tsx +0 -542
- package/src/widgets/radio/editor.tsx +0 -398
- package/src/widgets/reaction-diagram-editor.tsx +0 -131
- package/src/widgets/sequence-editor.tsx +0 -205
- package/src/widgets/simple-markdown-tester-editor.tsx +0 -86
- package/src/widgets/simple-markdown-tester.tsx +0 -92
- package/src/widgets/simulator-editor.tsx +0 -146
- package/src/widgets/sorter-editor.tsx +0 -103
- package/src/widgets/table-editor.tsx +0 -164
- package/src/widgets/unit-editor.tsx +0 -299
- package/src/widgets/video-editor.tsx +0 -73
- package/tsconfig-build.json +0 -22
- 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";
|
|
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";
|
|
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;
|