@khanacademy/perseus-editor 0.0.1 → 0.1.0

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