@khanacademy/perseus-editor 0.0.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/.babelrc.js +1 -1
  2. package/CHANGELOG.md +26 -0
  3. package/dist/es/index.css +1020 -0
  4. package/dist/es/index.js +9 -1
  5. package/dist/es/index.js.map +1 -1
  6. package/dist/index.css +1020 -0
  7. package/dist/index.js +9 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/strings.js +6 -1
  10. package/package.json +16 -4
  11. package/src/README.md +7 -0
  12. package/src/__stories__/editor.stories.js +33 -0
  13. package/src/__stories__/section-control-button.stories.jsx +27 -0
  14. package/src/__testdata__/input-number_testdata.js +28 -0
  15. package/src/__tests__/editor_test.jsx +123 -0
  16. package/src/__tests__/i18n_test.jsx +585 -0
  17. package/src/__tests__/traversal_test.jsx +378 -0
  18. package/src/all-editors.js +85 -0
  19. package/src/all-editors_test.js +17 -0
  20. package/src/all-widgets.js +10 -0
  21. package/src/article-editor.jsx +437 -0
  22. package/src/components/__stories__/blur-input.stories.jsx +24 -0
  23. package/src/components/blur-input.jsx +62 -0
  24. package/src/components/checkbox.jsx +194 -0
  25. package/src/components/device-framer.jsx +91 -0
  26. package/src/components/drag-target.jsx +88 -0
  27. package/src/components/dropdown-option.jsx +318 -0
  28. package/src/components/form-wrapped-text-field.jsx +248 -0
  29. package/src/components/graph-settings.jsx +631 -0
  30. package/src/components/hover-behavior.jsx +178 -0
  31. package/src/components/json-editor.jsx +121 -0
  32. package/src/components/link.jsx +173 -0
  33. package/src/components/simple-button.jsx +86 -0
  34. package/src/components/sortable.jsx +186 -0
  35. package/src/components/util.js +39 -0
  36. package/src/components/viewport-resizer.jsx +73 -0
  37. package/src/diffs/__stories__/perseus-diff-wrapper.jsx +8 -0
  38. package/src/diffs/__stories__/structured-item-diff.stories.jsx +855 -0
  39. package/src/diffs/__stories__/tags-diff.stories.jsx +38 -0
  40. package/src/diffs/__stories__/text-diff.stories.jsx +37 -0
  41. package/src/diffs/__tests__/split-diff_test.js +34 -0
  42. package/src/diffs/__tests__/string-array-diff_test.js +74 -0
  43. package/src/diffs/__tests__/structured-item-diff_test.js +432 -0
  44. package/src/diffs/__tests__/widget-diff-performer_test.js +44 -0
  45. package/src/diffs/article-diff.jsx +70 -0
  46. package/src/diffs/item-diff.jsx +77 -0
  47. package/src/diffs/renderer-diff.jsx +143 -0
  48. package/src/diffs/split-diff.jsx +30 -0
  49. package/src/diffs/string-array-diff.jsx +75 -0
  50. package/src/diffs/structured-item-diff.jsx +269 -0
  51. package/src/diffs/tags-diff.jsx +50 -0
  52. package/src/diffs/text-diff.jsx +194 -0
  53. package/src/diffs/widget-diff-performer.jsx +74 -0
  54. package/src/diffs/widget-diff.jsx +291 -0
  55. package/src/editor-page.jsx +351 -0
  56. package/src/editor.jsx +1364 -0
  57. package/src/hint-editor.jsx +447 -0
  58. package/src/i18n.jsx +139 -0
  59. package/src/iframe-content-renderer.jsx +201 -0
  60. package/src/index.js +27 -8
  61. package/src/item-editor.jsx +159 -0
  62. package/src/item-extras-editor.jsx +150 -0
  63. package/src/katex-error-view.jsx +87 -0
  64. package/src/multirenderer-editor.jsx +1074 -0
  65. package/src/section-control-button.jsx +53 -0
  66. package/src/stateful-article-editor.jsx +176 -0
  67. package/src/stateful-editor-page.jsx +90 -0
  68. package/src/styles/perseus-editor.less +1339 -0
  69. package/src/testing-widgets.js +12 -0
  70. package/src/util/register-all-widgets-and-editors-for-testing.js +15 -0
  71. package/src/util/register-all-widgets-and-editors-for-testing_test.js +18 -0
  72. package/src/widgets/__stories__/label-image-editor.stories.jsx +97 -0
  73. package/src/widgets/categorizer-editor.jsx +103 -0
  74. package/src/widgets/cs-program-editor.jsx +258 -0
  75. package/src/widgets/definition-editor.jsx +75 -0
  76. package/src/widgets/dropdown-editor.jsx +201 -0
  77. package/src/widgets/example-graphie-widget-editor.jsx +70 -0
  78. package/src/widgets/example-graphie-widget.jsx +175 -0
  79. package/src/widgets/example-widget-editor.jsx +60 -0
  80. package/src/widgets/example-widget.jsx +153 -0
  81. package/src/widgets/explanation-editor.jsx +89 -0
  82. package/src/widgets/expression-editor.jsx +560 -0
  83. package/src/widgets/graded-group-editor.jsx +162 -0
  84. package/src/widgets/graded-group-set-editor.jsx +101 -0
  85. package/src/widgets/grapher-editor.jsx +154 -0
  86. package/src/widgets/group-editor.jsx +83 -0
  87. package/src/widgets/iframe-editor.jsx +200 -0
  88. package/src/widgets/image-editor.jsx +326 -0
  89. package/src/widgets/input-number-editor.jsx +269 -0
  90. package/src/widgets/interaction/constraint-editor.jsx +147 -0
  91. package/src/widgets/interaction-editor.jsx +1389 -0
  92. package/src/widgets/interactive-graph-editor.jsx +322 -0
  93. package/src/widgets/label-image/__stories__/answer-choices.stories.jsx +81 -0
  94. package/src/widgets/label-image/__stories__/behavior.stories.jsx +32 -0
  95. package/src/widgets/label-image/__stories__/marker.stories.jsx +76 -0
  96. package/src/widgets/label-image/__stories__/question-markers.stories.jsx +106 -0
  97. package/src/widgets/label-image/__stories__/select-image.stories.jsx +60 -0
  98. package/src/widgets/label-image/answer-choices.jsx +231 -0
  99. package/src/widgets/label-image/behavior.jsx +92 -0
  100. package/src/widgets/label-image/marker.jsx +292 -0
  101. package/src/widgets/label-image/question-markers.jsx +181 -0
  102. package/src/widgets/label-image/select-image.jsx +86 -0
  103. package/src/widgets/label-image-editor.jsx +257 -0
  104. package/src/widgets/lights-puzzle-editor.jsx +267 -0
  105. package/src/widgets/matcher-editor.jsx +147 -0
  106. package/src/widgets/matrix-editor.jsx +155 -0
  107. package/src/widgets/measurer-editor.jsx +245 -0
  108. package/src/widgets/molecule-editor.jsx +63 -0
  109. package/src/widgets/number-line-editor.jsx +498 -0
  110. package/src/widgets/numeric-input-editor.jsx +554 -0
  111. package/src/widgets/orderer-editor.jsx +166 -0
  112. package/src/widgets/passage-editor.jsx +115 -0
  113. package/src/widgets/passage-ref-editor.jsx +77 -0
  114. package/src/widgets/passage-ref-target-editor.jsx +47 -0
  115. package/src/widgets/plotter-editor.jsx +537 -0
  116. package/src/widgets/radio/editor.jsx +398 -0
  117. package/src/widgets/reaction-diagram-editor.jsx +127 -0
  118. package/src/widgets/sequence-editor.jsx +205 -0
  119. package/src/widgets/simple-markdown-tester-editor.jsx +84 -0
  120. package/src/widgets/simple-markdown-tester.jsx +91 -0
  121. package/src/widgets/simulator-editor.jsx +144 -0
  122. package/src/widgets/sorter-editor.jsx +100 -0
  123. package/src/widgets/table-editor.jsx +164 -0
  124. package/src/widgets/transformer-editor.jsx +1006 -0
  125. package/src/widgets/unit-editor.jsx +293 -0
  126. package/src/widgets/video-editor.jsx +74 -0
  127. package/src/__tests__/index.test.js +0 -15
@@ -0,0 +1,1074 @@
1
+ // @flow
2
+ /**
3
+ * Editor for a multi-item question.
4
+ *
5
+ * TODO(mdr): The UI for managing arrays isn't visually consistent with
6
+ * HintsEditor. Should we bring them in line with each other?
7
+ */
8
+ import {
9
+ ApiOptions,
10
+ buildEmptyItemTreeForShape,
11
+ components,
12
+ icons,
13
+ itemToTree,
14
+ MultiItems,
15
+ } from "@khanacademy/perseus";
16
+ import {StyleSheet, css} from "aphrodite";
17
+ // eslint-disable-next-line import/no-extraneous-dependencies
18
+ import lens from "hubble";
19
+ import * as React from "react";
20
+ import ReactDOM from "react-dom";
21
+
22
+ import JsonEditor from "./components/json-editor.jsx";
23
+ import SimpleButton from "./components/simple-button.jsx";
24
+ import Editor from "./editor.jsx";
25
+ import {HintEditor} from "./hint-editor.jsx";
26
+
27
+ import type {
28
+ APIOptions,
29
+ ChangeHandler,
30
+ EditorMode,
31
+
32
+ // Multi-item item types
33
+ Item,
34
+ // ItemTree is used below, the linter is confused.
35
+ ItemTree, // eslint-disable-line no-unused-vars
36
+ ItemObjectNode,
37
+ ItemArrayNode,
38
+ ContentNode,
39
+ HintNode,
40
+ TagsNode,
41
+
42
+ // Multi-item shape types
43
+ Shape,
44
+ ArrayShape,
45
+ ObjectShape,
46
+ ContentShape,
47
+ HintShape,
48
+ TagsShape,
49
+ } from "@khanacademy/perseus";
50
+
51
+ const {InlineIcon} = components;
52
+ const {iconChevronDown, iconTrash} = icons;
53
+ const {MultiRenderer} = MultiItems;
54
+
55
+ // TODO(CP-4849): figure out when $ReadOnlyArray<number> vs $ReadOnlyArray<string> should be used
56
+ type Path = $ReadOnlyArray<$FlowFixMe>;
57
+
58
+ type ModeDropdownProps = {|
59
+ currentMode: EditorMode,
60
+
61
+ // A function that takes in a string signifying the mode (ex: "edit")
62
+ onChange: (mode: EditorMode) => mixed,
63
+ |};
64
+
65
+ /**
66
+ * Component that displays the mode dropdown.
67
+ *
68
+ * The mode dropdown is the selector at the top of the editor that lets you
69
+ * switch between edit, preview, and dev-only JSON mode.
70
+ */
71
+ class ModeDropdown extends React.Component<ModeDropdownProps> {
72
+ _handleSelectMode = (event: SyntheticInputEvent<HTMLSelectElement>) => {
73
+ if (this.props.onChange) {
74
+ // event.target.value corresponds to the options' values below which
75
+ // are limited to EditorMode, but flow doesn't know that so we have
76
+ // to cast through any here.
77
+ const value = ((event.target.value: any): EditorMode);
78
+ this.props.onChange(value);
79
+ }
80
+ };
81
+
82
+ render(): React.Node {
83
+ return (
84
+ <label>
85
+ Mode:{" "}
86
+ <select
87
+ value={this.props.currentMode}
88
+ onChange={this._handleSelectMode}
89
+ >
90
+ <option value="edit">Edit</option>
91
+ <option value="preview">Preview</option>
92
+ <option value="json">Dev-only JSON</option>
93
+ </select>
94
+ </label>
95
+ );
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Convert a camel-cased string to a human-formatted string.
101
+ * "superCoolThings" -> "super cool things"
102
+ */
103
+ function camelCaseToHuman(str: string) {
104
+ // Decapitalize the capital letters, and add a space before each.
105
+ return str.replace(/[A-Z]/g, (s) => " " + s.toLowerCase());
106
+ }
107
+
108
+ /**
109
+ * Capitalize the first letter of the given string.
110
+ * "super cool things" -> "Super cool things"
111
+ */
112
+ function capitalize(str: string) {
113
+ return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
114
+ }
115
+
116
+ /**
117
+ * Convert the given pluralized word to a singularized word.
118
+ * "super cool things" -> "super cool thing"
119
+ */
120
+ function pluralToSingular(str: string) {
121
+ if (str.charAt(str.length - 1) === "s") {
122
+ // Incredibly weak implementation :P
123
+ return str.slice(0, -1);
124
+ }
125
+ // Uh oh, dunno how to singularize anything but the simplest case!
126
+ // Let's just return the plural form, and hope the user forgives the
127
+ // grammatical inconsistency.
128
+ return str;
129
+ }
130
+
131
+ /**
132
+ * When iterating through the editors, we don't keep track of the extra
133
+ * `_multi` part at the beginning. This is a helper function which takes a path
134
+ * and prepends that key.
135
+ */
136
+ function multiPath(path) {
137
+ return ["_multi", ...path];
138
+ }
139
+
140
+ // Return an h1 if depth=0, h2 if depth=1, etc.
141
+ // NOTE: This component accepts pass-through props.
142
+ type HeaderProps = {
143
+ depth: number,
144
+ ...
145
+ };
146
+
147
+ const Header = ({depth, ...props}: HeaderProps) => {
148
+ const headerLevel = Math.min(depth, 5) + 1;
149
+ const HeaderTag = `h${headerLevel}`;
150
+ return <HeaderTag {...props} />;
151
+ };
152
+
153
+ // Actions is inexact so that we can pass an instance MultiRendererEditor.
154
+ interface Actions {
155
+ addArrayElement: (path: Path, shape: Shape) => void;
156
+ mergeValueAtPath: (path: Path, newValue: any) => void;
157
+ setValueAtPath: (path: Path, newValue: any) => void;
158
+ moveArrayElementDown: (path: Path) => void;
159
+ moveArrayElementUp: (path: Path) => void;
160
+ removeArrayElement: (path: Path) => void;
161
+ }
162
+
163
+ // This type is used to define prop types for various nodes. The S and D type
164
+ // params should match when used, e.g. the prop types for `HintNodeContent` is
165
+ // `NodePropTypes<HintShape, HintNode>`.
166
+ type NodePropTypes<S = Shape, D = ItemTree> = {|
167
+ shape: S,
168
+ data: D,
169
+ path: Path,
170
+ actions: Actions,
171
+ apiOptions: APIOptions,
172
+ // For the left-hand column, we use edit mode and leave renderers empty.
173
+ // For the right-hand column, we use preview mode and provide renderers
174
+ // via a MultiRenderer.
175
+ // TODO(CP-4850): figure out how to type this, it appears to be a tree where the
176
+ // leaf nodes could be typed using RendererInterface.
177
+ renderers?: $FlowFixMe,
178
+ |};
179
+
180
+ /**
181
+ * Render a node in the editor tree, given the shape of the target
182
+ * node, the data stored in the target node, the path to the target
183
+ * node, and any UI controls that affect how this node relates to its
184
+ * parent (e.g. remove from parent array).
185
+ *
186
+ * This returns a container element with a pretty title and additional
187
+ * UI controls for this node. Its contents are produced by
188
+ * `NodeContent`. The two functions are mutually recursive.
189
+ *
190
+ * Leaf nodes, like items and hints, render an editor pod around their
191
+ * content. Container nodes, like arrays and objects, render a header above
192
+ * their content.
193
+ */
194
+ const NodeContainer = (props: {|
195
+ ...NodePropTypes<>,
196
+ controls?: $ReadOnlyArray<React.Node>,
197
+ name?: string,
198
+ |}) => {
199
+ const {
200
+ shape,
201
+ data,
202
+ path,
203
+ actions,
204
+ name: givenName,
205
+ controls,
206
+ ...otherProps
207
+ } = props;
208
+
209
+ const name = givenName || camelCaseToHuman(path[path.length - 1] || "");
210
+
211
+ const children = (
212
+ <NodeContent
213
+ {...otherProps}
214
+ shape={shape}
215
+ data={data}
216
+ path={path}
217
+ actions={actions}
218
+ />
219
+ );
220
+
221
+ const key = path.join(".");
222
+
223
+ if (shape.type === "array") {
224
+ return (
225
+ <ArrayContainer
226
+ key={key}
227
+ name={name}
228
+ controls={controls}
229
+ path={path}
230
+ shape={shape}
231
+ actions={actions}
232
+ >
233
+ {children}
234
+ </ArrayContainer>
235
+ );
236
+ }
237
+ if (shape.type === "object") {
238
+ return (
239
+ <ObjectContainer
240
+ key={key}
241
+ name={name}
242
+ controls={controls}
243
+ path={path}
244
+ >
245
+ {children}
246
+ </ObjectContainer>
247
+ );
248
+ }
249
+ return (
250
+ <LeafContainer
251
+ key={key}
252
+ name={name}
253
+ controls={controls}
254
+ path={path}
255
+ shape={shape}
256
+ >
257
+ {children}
258
+ </LeafContainer>
259
+ );
260
+ };
261
+
262
+ type LeafContainerProps = {|
263
+ name: string,
264
+ controls?: React.Node,
265
+ children?: React.Node,
266
+ path: Path,
267
+ shape: Shape,
268
+ |};
269
+ const LeafContainer = ({
270
+ name,
271
+ controls,
272
+ children,
273
+ path,
274
+ shape,
275
+ }: LeafContainerProps) => {
276
+ const hasPreviewHeading = shape.type === "content" || shape.type === "hint";
277
+ const previewHeading = hasPreviewHeading && (
278
+ <div className={css(styles.containerHeader)}>
279
+ <Header depth={path.length} className={css(styles.containerTitle)}>
280
+ {capitalize(name)}
281
+ </Header>
282
+ </div>
283
+ );
284
+ return (
285
+ <div className={css(styles.container)}>
286
+ <span className={css(styles.row, styles.rowHeading)}>
287
+ <div className={css(styles.columnLeft)}>
288
+ <div className={"pod-title " + css(styles.containerHeader)}>
289
+ <div className={css(styles.containerTitle)}>
290
+ {capitalize(name)}
291
+ </div>
292
+ {controls}
293
+ </div>
294
+ </div>
295
+ <div className={css(styles.columnRight)}>{previewHeading}</div>
296
+ </span>
297
+ {children}
298
+ </div>
299
+ );
300
+ };
301
+
302
+ interface ArrayContainerActions {
303
+ addArrayElement: (path: Path, shape: Shape) => void;
304
+ }
305
+
306
+ type ArrayContainerProps = {|
307
+ name: string,
308
+ controls?: React.Node,
309
+ children?: React.Node,
310
+ path: Path,
311
+ shape: ArrayShape,
312
+ actions: ArrayContainerActions,
313
+ |};
314
+ const ArrayContainer = (props: ArrayContainerProps) => {
315
+ const {name, controls, children, path, shape, actions} = props;
316
+ return (
317
+ <div className={css(styles.container)}>
318
+ {controls && (
319
+ <div className={css(styles.columnLeft, styles.containerHeader)}>
320
+ {controls}
321
+ </div>
322
+ )}
323
+ <div>{children}</div>
324
+ <div className={css(styles.columnLeft)}>
325
+ {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
326
+ <a
327
+ href="javascript:void 0"
328
+ onClick={() =>
329
+ actions.addArrayElement(path, shape.elementShape)
330
+ }
331
+ >
332
+ Add a {pluralToSingular(name)}
333
+ </a>
334
+ </div>
335
+ </div>
336
+ );
337
+ };
338
+
339
+ type ObjectContainerProps = {|
340
+ name: string,
341
+ controls?: React.Node,
342
+ children?: React.Node,
343
+ path: Path,
344
+ |};
345
+ const ObjectContainer = ({
346
+ name,
347
+ controls,
348
+ children,
349
+ path,
350
+ }: ObjectContainerProps) => {
351
+ const headingEditor = (
352
+ <div className={css(styles.containerHeader)}>
353
+ <Header depth={path.length} className={css(styles.containerTitle)}>
354
+ {capitalize(name)}
355
+ </Header>
356
+ {controls}
357
+ </div>
358
+ );
359
+ const headingPreview = (name || controls) && (
360
+ <div
361
+ className={css(
362
+ styles.containerHeader,
363
+ styles.previewCollectionHeader,
364
+ )}
365
+ >
366
+ <Header depth={path.length} className={css(styles.containerTitle)}>
367
+ {capitalize(name)}
368
+ </Header>
369
+ </div>
370
+ );
371
+ const hasBothHeadings = headingEditor && headingPreview;
372
+ return (
373
+ <div className={css(styles.container)}>
374
+ {hasBothHeadings && (
375
+ <span className={css(styles.row)}>
376
+ <div className={css(styles.columnLeft)}>
377
+ {headingEditor}
378
+ </div>
379
+ <div className={css(styles.columnRight)}>
380
+ {headingPreview}
381
+ </div>
382
+ </span>
383
+ )}
384
+ <div className={css(path.length > 0 && styles.contentIndent)}>
385
+ {children}
386
+ </div>
387
+ </div>
388
+ );
389
+ };
390
+
391
+ /**
392
+ * Render the content of node in the editor tree, given the shape of
393
+ * the target node, the data stored in the target node, and the path to
394
+ * the target node.
395
+ *
396
+ * If the target node is a leaf, this returns an editor. Otherwise, it
397
+ * iterates over the child nodes, and outputs `NodeContainer` for
398
+ * each of them. The two functions are mutually recursive.
399
+ */
400
+ const NodeContent = (props: NodePropTypes<>) => {
401
+ const {shape, data, ...restProps} = props;
402
+
403
+ // All uses of `data` have been cast through any which isn't safe. This was
404
+ // done to avoid introducing new logic which may have resulted in a change
405
+ // of behavior. Also, there doesn't appear to be a way to tell flow the
406
+ // difference between and `TagsNode` and `ItemArrayNode`, see
407
+ // perseus-all-package/multi-items/item-types.js.
408
+ if (shape.type === "content") {
409
+ return (
410
+ <ItemNodeContent
411
+ shape={shape}
412
+ data={((data: any): ContentNode)}
413
+ {...restProps}
414
+ />
415
+ );
416
+ }
417
+ if (shape.type === "hint") {
418
+ return (
419
+ <HintNodeContent
420
+ shape={shape}
421
+ data={((data: any): HintNode)}
422
+ {...restProps}
423
+ />
424
+ );
425
+ }
426
+ if (shape.type === "tags") {
427
+ return (
428
+ <TagsNodeContent
429
+ shape={shape}
430
+ data={((data: any): TagsNode)}
431
+ {...restProps}
432
+ />
433
+ );
434
+ }
435
+ if (shape.type === "array") {
436
+ return (
437
+ <ArrayNodeContent
438
+ shape={shape}
439
+ data={((data: any): ItemArrayNode)}
440
+ {...restProps}
441
+ />
442
+ );
443
+ }
444
+ if (shape.type === "object") {
445
+ return (
446
+ <ObjectNodeContent
447
+ shape={shape}
448
+ data={((data: any): ItemObjectNode)}
449
+ {...restProps}
450
+ />
451
+ );
452
+ }
453
+ return null;
454
+ };
455
+
456
+ type WithStickinessProps = {|
457
+ sticky: boolean,
458
+ |};
459
+
460
+ type WithStickiness<T> = {|
461
+ ...$Exact<T>,
462
+ ...WithStickinessProps,
463
+ |};
464
+
465
+ type WithoutStickiness<T> = $Exact<$Rest<T, WithStickinessProps>>;
466
+
467
+ /**
468
+ * HOC that adds a "sticky" prop to the wrapped component that is true
469
+ * when the rendered component is taller than the window. Since sticky content
470
+ * can be somewhat distracting, we'd like to avoid it when not useful. This
471
+ * HOC is useful for only making content sticky when useful.
472
+ *
473
+ * It does so by polling the height and comparing it to the window height.
474
+ */
475
+ const withStickiness = <
476
+ Config: {...},
477
+ Component: React.ComponentType<WithStickiness<Config>>,
478
+ >(
479
+ WrappedComponent: Component,
480
+ ): React.ComponentType<WithoutStickiness<React.ElementConfig<Component>>> => {
481
+ type State = {|sticky: boolean|};
482
+ return class StickyComponent extends React.Component<Config, State> {
483
+ stickynessTimer: IntervalID;
484
+
485
+ state = {
486
+ sticky: false,
487
+ };
488
+
489
+ componentDidMount() {
490
+ // TODO(jeff, CP-3128): Use Wonder Blocks Timing API.
491
+ // eslint-disable-next-line no-restricted-syntax
492
+ this.stickynessTimer = setInterval(this.updateStickiness, 1000);
493
+ this.updateStickiness();
494
+ }
495
+
496
+ componentWillUnmount() {
497
+ // TODO(jeff, CP-3128): Use Wonder Blocks Timing API.
498
+ // eslint-disable-next-line no-restricted-syntax
499
+ clearInterval(this.stickynessTimer);
500
+ }
501
+
502
+ updateStickiness = () => {
503
+ const domNode = ReactDOM.findDOMNode(this);
504
+ // $FlowFixMe[prop-missing]
505
+ // $FlowFixMe[incompatible-use]: findDOMNode returns null | Text | Element
506
+ const height = domNode.offsetHeight;
507
+ const windowHeight = window.innerHeight;
508
+ const sticky = height > windowHeight;
509
+ if (sticky !== this.state.sticky) {
510
+ this.setState({
511
+ sticky,
512
+ });
513
+ }
514
+ };
515
+
516
+ render(): React.Node {
517
+ return (
518
+ /**
519
+ * $FlowFixMe[incompatible-exact]
520
+ * TODO(somewhatabstract, JIRA-XXXX):
521
+ * Cannot create WrappedComponent element because inexact props
522
+ * [1] is incompatible with exact WithStickiness [2].
523
+ */
524
+ <WrappedComponent {...this.props} sticky={this.state.sticky} />
525
+ );
526
+ }
527
+ };
528
+ };
529
+
530
+ const ItemNodeContent = withStickiness(
531
+ (props: {|
532
+ ...NodePropTypes<ContentShape, ContentNode>,
533
+ ...WithStickinessProps,
534
+ |}) => {
535
+ const {data, path, actions, apiOptions, renderers, sticky} = props;
536
+
537
+ const preview = (
538
+ <div className="framework-perseus">{lens(renderers).get(path)}</div>
539
+ );
540
+
541
+ return (
542
+ <span>
543
+ <div className={css(styles.row)}>
544
+ <div className={css(styles.columnLeft)}>
545
+ <div className={css(sticky && styles.sticky)}>
546
+ {/* TODO(CP-4852): only pass the props to Editor that it uses. */}
547
+ {
548
+ // $FlowFixMe[incompatible-exact]: {...data} pass too many props
549
+ // $FlowFixMe[incompatible-type]
550
+ // $FlowFixMe[prop-missing]
551
+ <Editor
552
+ {...data}
553
+ onChange={(newVal) =>
554
+ actions.mergeValueAtPath(path, newVal)
555
+ }
556
+ apiOptions={apiOptions}
557
+ />
558
+ }
559
+ </div>
560
+ </div>
561
+ <div className={css(styles.columnRight)}>
562
+ <div className={css(sticky && styles.sticky)}>
563
+ {preview}
564
+ </div>
565
+ </div>
566
+ </div>
567
+ </span>
568
+ );
569
+ },
570
+ );
571
+
572
+ const HintNodeContent = withStickiness(
573
+ (props: {|
574
+ ...NodePropTypes<HintShape, HintNode>,
575
+ ...WithStickinessProps,
576
+ |}) => {
577
+ const {data, path, actions, apiOptions, renderers, sticky} = props;
578
+
579
+ const preview = (
580
+ <div className="framework-perseus">{lens(renderers).get(path)}</div>
581
+ );
582
+
583
+ return (
584
+ <div className={css(styles.row)}>
585
+ <div className={css(styles.columnLeft)}>
586
+ <div className={css(sticky && styles.sticky)}>
587
+ {/**
588
+ * $FlowFixMe[incompatible-exact]
589
+ * TODO(somewhatabstract, JIRA-XXXX):
590
+ * Cannot create WrappedComponent element because
591
+ * inexact props [1] is incompatible with exact
592
+ * WithStickiness [2].
593
+ */}
594
+ <HintEditor
595
+ {...data}
596
+ className={css(styles.hintEditor)}
597
+ onChange={(newVal) =>
598
+ actions.mergeValueAtPath(path, newVal)
599
+ }
600
+ apiOptions={apiOptions}
601
+ showTitle={false}
602
+ showRemoveButton={false}
603
+ showMoveButtons={false}
604
+ // no-op handler since showRemoveButton={false}
605
+ onRemove={() => {}}
606
+ // no-op handler since showMoveButtons={false}
607
+ onMove={(direction: number) => {}}
608
+ isFirst={true}
609
+ isLast={true}
610
+ />
611
+ </div>
612
+ </div>
613
+ <div className={css(styles.columnRight)}>
614
+ <div className={css(sticky && styles.sticky)}>
615
+ {preview}
616
+ </div>
617
+ </div>
618
+ </div>
619
+ );
620
+ },
621
+ );
622
+
623
+ const TagsNodeContent = (props: NodePropTypes<TagsShape, TagsNode>) => {
624
+ const {data, path, actions, apiOptions} = props;
625
+ const {GroupMetadataEditor} = apiOptions;
626
+
627
+ if (GroupMetadataEditor == null) {
628
+ return null;
629
+ }
630
+ return (
631
+ <div className={css(styles.columnLeft)}>
632
+ <div className={css(styles.tagsEditor)}>
633
+ <GroupMetadataEditor
634
+ value={data}
635
+ onChange={(newVal) => actions.setValueAtPath(path, newVal)}
636
+ showTitle={false}
637
+ />
638
+ </div>
639
+ </div>
640
+ );
641
+ };
642
+
643
+ const ArrayNodeContent = (props: NodePropTypes<ArrayShape, ItemArrayNode>) => {
644
+ const {shape, data, path, actions, ...otherProps} = props;
645
+
646
+ const collectionName = camelCaseToHuman(path[path.length - 1]);
647
+ const elementName = pluralToSingular(collectionName);
648
+
649
+ const elementType = shape.elementShape.type;
650
+ const elementIsLeaf = elementType === "content" || elementType === "hint";
651
+
652
+ /**
653
+ * TODO(somewhatabstract, JIRA-XXXX):
654
+ * The NodePropTypes generic and specifically the ItemArrayNode could
655
+ * contain a variety of types that are not arrays. Probably need to refine
656
+ * the type more before doing this work, or rework the `ArrayNodeContent`
657
+ * component to be less permissive about the props it accepts.
658
+ */
659
+ // $FlowFixMe[prop-missing]
660
+ // $FlowFixMe[not-a-function]
661
+ const children = data.map((subdata, i) => {
662
+ const subpath = path.concat(i);
663
+ const controls = [
664
+ i > 0 && (
665
+ <div key="moveArrayElementUp" className={css(styles.control)}>
666
+ <SimpleButton
667
+ color="orange"
668
+ title="Move up"
669
+ onClick={() => actions.moveArrayElementUp(subpath)}
670
+ >
671
+ <div className={css(styles.verticalFlip)}>
672
+ <InlineIcon {...iconChevronDown} />
673
+ </div>
674
+ </SimpleButton>
675
+ </div>
676
+ ),
677
+ // $FlowFixMe[unsafe-addition]
678
+ // $FlowFixMe[prop-missing]
679
+ i < data.length - 1 && (
680
+ <div key="moveArrayElementDown" className={css(styles.control)}>
681
+ <SimpleButton
682
+ color="orange"
683
+ title="Move down"
684
+ onClick={() => actions.moveArrayElementDown(subpath)}
685
+ >
686
+ <InlineIcon {...iconChevronDown} />
687
+ </SimpleButton>
688
+ </div>
689
+ ),
690
+ <div key="removeArrayElement" className={css(styles.control)}>
691
+ <SimpleButton
692
+ color="orange"
693
+ title="Delete"
694
+ onClick={() => actions.removeArrayElement(subpath)}
695
+ >
696
+ <InlineIcon {...iconTrash} />
697
+ </SimpleButton>
698
+ </div>,
699
+ ];
700
+
701
+ return (
702
+ <div
703
+ key={i}
704
+ className={css(
705
+ styles.arrayElement,
706
+ !elementIsLeaf && styles.arrayElementAndNotLeaf,
707
+ )}
708
+ >
709
+ <NodeContainer
710
+ {...otherProps}
711
+ key={i}
712
+ shape={shape.elementShape}
713
+ data={subdata}
714
+ path={subpath}
715
+ actions={actions}
716
+ name={`${elementName} ${i + 1}`}
717
+ controls={controls}
718
+ />
719
+ </div>
720
+ );
721
+ });
722
+
723
+ return <div>{children}</div>;
724
+ };
725
+
726
+ const ObjectNodeContent = (
727
+ props: NodePropTypes<ObjectShape, ItemObjectNode>,
728
+ ) => {
729
+ const {shape, data, path, ...otherProps} = props;
730
+
731
+ // Object iteration order should automatically match the order in which the
732
+ // keys were defined in the object literal. So, whatever order semantically
733
+ // made sense to the shape's author is the order in which we'll iterate :)
734
+ const children = Object.keys(shape.shape).map((subkey) => (
735
+ <div key={subkey} className={css(styles.objectElement)}>
736
+ <NodeContainer
737
+ {...otherProps}
738
+ shape={shape.shape[subkey]}
739
+ data={data[subkey]}
740
+ path={path.concat(subkey)}
741
+ />
742
+ </div>
743
+ ));
744
+
745
+ return <div>{children}</div>;
746
+ };
747
+
748
+ interface LayoutStatics {
749
+ shape: Shape;
750
+ }
751
+
752
+ type MultiRendererEditorProps = {|
753
+ // eslint-disable-next-line no-restricted-syntax
754
+ Layout: React.ComponentType<any> & LayoutStatics,
755
+ apiOptions: APIOptions,
756
+
757
+ item: Item,
758
+ editorMode: EditorMode,
759
+
760
+ onChange: ChangeHandler,
761
+ |};
762
+
763
+ class MultiRendererEditor extends React.Component<MultiRendererEditorProps> {
764
+ layout: ?React.ElementRef<$FlowFixMe>;
765
+
766
+ _renderLayout: () => React.Node = () => {
767
+ const {Layout, apiOptions, item} = this.props;
768
+
769
+ return (
770
+ <Layout
771
+ ref={(node) => (this.layout = node)}
772
+ item={item}
773
+ apiOptions={apiOptions}
774
+ />
775
+ );
776
+ };
777
+
778
+ _renderJson: () => React.Element<"div"> = () => {
779
+ return (
780
+ <div>
781
+ <ModeDropdown
782
+ currentMode={this.props.editorMode}
783
+ onChange={(editorMode) => this.props.onChange({editorMode})}
784
+ />
785
+ <JsonEditor
786
+ multiLine
787
+ value={this.props.item}
788
+ onChange={(item) => this.props.onChange({item})}
789
+ />
790
+ </div>
791
+ );
792
+ };
793
+
794
+ _renderPreview: () => React.Element<"div"> = () => {
795
+ return (
796
+ <div>
797
+ <ModeDropdown
798
+ currentMode={this.props.editorMode}
799
+ onChange={(editorMode) => this.props.onChange({editorMode})}
800
+ />
801
+ {this._renderLayout()}
802
+ </div>
803
+ );
804
+ };
805
+
806
+ mergeValueAtPath: (path: Path, newValue: any) => void = (
807
+ path: Path,
808
+ newValue: mixed,
809
+ ) => {
810
+ this.props.onChange({
811
+ item: lens(this.props.item)
812
+ .merge(multiPath(path), newValue)
813
+ .freeze(),
814
+ });
815
+ };
816
+
817
+ setValueAtPath: (path: Path, newValue: any) => void = (
818
+ path: Path,
819
+ newValue: mixed,
820
+ ) => {
821
+ this.props.onChange({
822
+ item: lens(this.props.item).set(multiPath(path), newValue).freeze(),
823
+ });
824
+ };
825
+
826
+ addArrayElement: (path: Path, shape: Shape) => void = (
827
+ path: Path,
828
+ shape: Shape,
829
+ ) => {
830
+ const currentLength = lens(this.props.item).get(multiPath(path)).length;
831
+ const newElementPath = path.concat(currentLength);
832
+ const newValue = buildEmptyItemTreeForShape(shape);
833
+ this.props.onChange({
834
+ item: lens(this.props.item)
835
+ .set(multiPath(newElementPath), newValue)
836
+ .freeze(),
837
+ });
838
+ };
839
+
840
+ removeArrayElement: (path: Path) => void = (path: Path) => {
841
+ this.props.onChange({
842
+ item: lens(this.props.item).del(multiPath(path)).freeze(),
843
+ });
844
+ };
845
+
846
+ moveArrayElementDown: (path: Path) => void = (path: Path) => {
847
+ // Moving an element down can also be expressed as swapping it with the
848
+ // following element.
849
+ const index = path[path.length - 1];
850
+ const nextElementIndex = index + 1;
851
+ const nextElementPath = path.slice(0, -1).concat(nextElementIndex);
852
+
853
+ const element = lens(this.props.item).get(multiPath(path));
854
+ const nextElement = lens(this.props.item).get(
855
+ multiPath(nextElementPath),
856
+ );
857
+
858
+ this.props.onChange({
859
+ item: lens(this.props.item)
860
+ .set(multiPath(path), nextElement)
861
+ .set(multiPath(nextElementPath), element)
862
+ .freeze(),
863
+ });
864
+ };
865
+
866
+ moveArrayElementUp: (path: Path) => void = (path: Path) => {
867
+ // Moving an element up can also be expressed as moving the previous
868
+ // element down.
869
+ const index = path[path.length - 1];
870
+ const previousElementPath = path.slice(0, -1).concat(index - 1);
871
+ this.moveArrayElementDown(previousElementPath);
872
+ };
873
+
874
+ _renderEdit: () => React.Element<"div"> = () => {
875
+ const apiOptions = {
876
+ ...ApiOptions.defaults,
877
+ ...this.props.apiOptions,
878
+ };
879
+
880
+ const {item} = this.props;
881
+ const itemShape: Shape = this.props.Layout.shape;
882
+
883
+ return (
884
+ <div className="perseus-multirenderer-editor">
885
+ <ModeDropdown
886
+ currentMode={this.props.editorMode}
887
+ onChange={(editorMode) => this.props.onChange({editorMode})}
888
+ />
889
+
890
+ <MultiRenderer
891
+ item={item}
892
+ shape={itemShape}
893
+ apiOptions={apiOptions}
894
+ >
895
+ {({renderers}) => (
896
+ <NodeContainer
897
+ shape={itemShape}
898
+ data={itemToTree(item)}
899
+ path={[]}
900
+ actions={this}
901
+ apiOptions={apiOptions}
902
+ renderers={renderers}
903
+ />
904
+ )}
905
+ </MultiRenderer>
906
+ </div>
907
+ );
908
+ };
909
+
910
+ score: () => any | void = () => {
911
+ if (this.layout) {
912
+ return this.layout.score();
913
+ }
914
+ };
915
+
916
+ getSerializedState: () => any | void = () => {
917
+ if (this.layout) {
918
+ return this.layout.getSerializedState();
919
+ }
920
+ };
921
+
922
+ restoreSerializedState: (state: any) => void = (state: $FlowFixMe) => {
923
+ if (this.layout) {
924
+ this.layout.restoreSerializedState(state);
925
+ }
926
+ };
927
+
928
+ _renderContent: () => React.Element<"div"> | React.Node = () => {
929
+ switch (this.props.editorMode) {
930
+ case "json":
931
+ return this._renderJson();
932
+ case "preview":
933
+ return this._renderPreview();
934
+ case "edit":
935
+ return this._renderEdit();
936
+ default:
937
+ return (
938
+ <ModeDropdown
939
+ currentMode={this.props.editorMode}
940
+ onChange={(editorMode) =>
941
+ this.props.onChange({
942
+ editorMode,
943
+ })
944
+ }
945
+ />
946
+ );
947
+ }
948
+ };
949
+
950
+ render(): React.Element<"div"> {
951
+ return <div id="perseus">{this._renderContent()}</div>;
952
+ }
953
+ }
954
+
955
+ const styles = StyleSheet.create({
956
+ // This is used in a number of places throughout this file.
957
+ container: {},
958
+
959
+ // eslint-disable-next-line react-native/no-unused-styles
960
+ editor: {
961
+ width: "100%",
962
+ },
963
+
964
+ // eslint-disable-next-line react-native/no-unused-styles
965
+ treePreview: {
966
+ position: "relative",
967
+ },
968
+
969
+ verticalFlip: {
970
+ transform: "scaleY(-1)",
971
+ },
972
+
973
+ control: {
974
+ marginLeft: 12,
975
+ },
976
+
977
+ containerHeader: {
978
+ alignItems: "flex-end",
979
+ display: "flex",
980
+ flexDirection: "row",
981
+ },
982
+
983
+ previewCollectionHeader: {
984
+ marginBottom: 16,
985
+ },
986
+
987
+ containerTitle: {
988
+ flexGrow: 1,
989
+ margin: 0,
990
+ },
991
+
992
+ contentIndent: {
993
+ marginLeft: 8,
994
+ },
995
+
996
+ hintEditor: {
997
+ paddingBottom: 0,
998
+ },
999
+
1000
+ arrayElement: {
1001
+ marginBottom: 16,
1002
+ },
1003
+
1004
+ // Leaf nodes are already wrapped in cute little pods, so they don't need
1005
+ // this extra border between array elements.
1006
+ arrayElementAndNotLeaf: {
1007
+ borderBottom: "1px solid #ccc",
1008
+ ":first-child": {
1009
+ borderTop: "1px solid #ccc",
1010
+ paddingTop: 16,
1011
+ },
1012
+ },
1013
+
1014
+ objectElement: {
1015
+ marginBottom: 16,
1016
+ },
1017
+
1018
+ tagsEditor: {
1019
+ border: "1px solid #ddd",
1020
+ padding: "5px 10px",
1021
+ },
1022
+
1023
+ /**
1024
+ * A row contains a fixed width editor and a preview that expands as
1025
+ * needed.
1026
+ */
1027
+ row: {
1028
+ display: "flex",
1029
+ position: "relative",
1030
+ },
1031
+
1032
+ /**
1033
+ * The editor.
1034
+ */
1035
+ columnLeft: {
1036
+ width: 360,
1037
+ marginRight: 30,
1038
+ // so that the `position: absolute` of line markers are positioned
1039
+ // relative to this.
1040
+ position: "relative",
1041
+ },
1042
+
1043
+ /**
1044
+ * The preview.
1045
+ */
1046
+ columnRight: {
1047
+ flex: 1,
1048
+ marginLeft: 30,
1049
+ position: "relative",
1050
+ },
1051
+
1052
+ /**
1053
+ * Sticks to just under the heading.
1054
+ */
1055
+ sticky: {
1056
+ position: "sticky",
1057
+ top: 33, // height of the cute pod for the editor
1058
+ },
1059
+
1060
+ /**
1061
+ * Used for sticky headings.
1062
+ */
1063
+ rowHeading: {
1064
+ position: "sticky",
1065
+ backgroundColor: "white",
1066
+ width: "100%",
1067
+ // TODO(joshuan): Make this less arbitrary. It should be higher than
1068
+ // perseus content.
1069
+ zIndex: 101,
1070
+ top: -1,
1071
+ },
1072
+ });
1073
+
1074
+ export default MultiRendererEditor;