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