diff --git a/packages/perseus-editor/src/__stories__/editor-with-layout.stories.tsx b/packages/perseus-editor/src/__stories__/editor-with-layout.stories.tsx new file mode 100644 index 0000000000..cf678e2665 --- /dev/null +++ b/packages/perseus-editor/src/__stories__/editor-with-layout.stories.tsx @@ -0,0 +1,116 @@ +import {View} from "@khanacademy/wonder-blocks-core"; +import {color, spacing} from "@khanacademy/wonder-blocks-tokens"; +import {HeadingSmall} from "@khanacademy/wonder-blocks-typography"; +import {action} from "@storybook/addon-actions"; +import * as React from "react"; + +import {EditorWithLayout} from ".."; + +import type {StyleType} from "@khanacademy/wonder-blocks-core"; +import type {Meta, StoryObj} from "@storybook/react"; + +type Story = StoryObj; + +const meta: Meta = { + title: "PerseusEditor/EditorWithLayout", + component: EditorWithLayout, +}; + +function Section( + props: React.PropsWithChildren<{title: string; style?: StyleType}>, +) { + return ( + + + {props.title} + + {props.children} + + ); +} + +export const Default: Story = { + render(props, context) { + return ( + + {(items) => {items.jsonModeEditor}} + + ); + }, +}; + +export const Controlled: Story = { + args: { + onChange: action("onChange"), + }, + render(props, context) { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [state, setState] = React.useState(props); + + return ( + + setState({...state, ...updatedProps}) + } + > + {(items) => ( + + +
+ {items.viewportResizerElement} +
+ +
+ {items.hudElement} +
+ +
+ {items.jsonModeEditor} +
+ +
+ {items.questionExtras} +
+
+ +
+ {items.itemEditor} +
+ +
+ +
+ +
+ {items.hintsEditor} +
+
+ )} +
+ ); + }, +}; + +export default meta; diff --git a/packages/perseus-editor/src/editor-page.tsx b/packages/perseus-editor/src/editor-page.tsx index 59b140e9f5..e0ddaf054d 100644 --- a/packages/perseus-editor/src/editor-page.tsx +++ b/packages/perseus-editor/src/editor-page.tsx @@ -7,6 +7,7 @@ import ViewportResizer from "./components/viewport-resizer"; import CombinedHintsEditor from "./hint-editor"; import ItemEditor from "./item-editor"; +import type {SerializeOptions} from "./types"; import type { APIOptions, APIOptionsWithDefaults, @@ -193,7 +194,7 @@ class EditorPage extends React.Component { return issues1.concat(issues2); } - serialize(options?: {keepDeletedWidgets?: boolean}): any | PerseusItem { + serialize(options?: SerializeOptions): PerseusItem { if (this.props.jsonMode) { return this.state.json; } diff --git a/packages/perseus-editor/src/editor-with-layout.tsx b/packages/perseus-editor/src/editor-with-layout.tsx new file mode 100644 index 0000000000..65f248372f --- /dev/null +++ b/packages/perseus-editor/src/editor-with-layout.tsx @@ -0,0 +1,410 @@ +import {components, ApiOptions, ClassNames} from "@khanacademy/perseus"; +import {Checkbox} from "@khanacademy/wonder-blocks-form"; +import * as React from "react"; +import _ from "underscore"; + +import DeviceFramer from "./components/device-framer"; +import JsonEditor from "./components/json-editor"; +import ViewportResizer from "./components/viewport-resizer"; +import CombinedHintsEditor from "./hint-editor"; +import IframeContentRenderer from "./iframe-content-renderer"; +import ItemEditor from "./item-editor"; +import ItemExtrasEditor from "./item-extras-editor"; + +import type { + APIOptions, + APIOptionsWithDefaults, + ChangeHandler, + DeviceType, + Hint, + ImageUploader, + Version, + PerseusItem, +} from "@khanacademy/perseus"; +import type {KEScore} from "@khanacademy/perseus-core"; +import type {StyleType} from "@khanacademy/wonder-blocks-core"; + +const {HUD} = components; + +type Props = { + apiOptions?: APIOptions; + answerArea?: any; // related to the question, + // TODO(CP-4838): Should this be a required prop? + contentPaths?: ReadonlyArray; + // Source HTML for the iframe to render + frameSource: string; + hints?: ReadonlyArray; // related to the question, + // A function which takes a file object (guaranteed to be an image) and + // a callback, then calls the callback with the url where the image + // will be hosted. Image drag and drop is disabled when imageUploader + // is null. + imageUploader?: ImageUploader; + // Part of the question + itemDataVersion?: Version; + // The content ID of the AssessmentItem being edited. + itemId: string; + // Whether the question is displaying as JSON or if it is + // showing the editor itself with the rendering + // Only used in the perseus demos. Consider removing. + jsonMode: boolean; + // A function which is called with the new JSON blob of content + onChange: ChangeHandler; + onPreviewDeviceChange: (arg1: DeviceType) => unknown; + previewDevice: DeviceType; + // Initial value of the question being edited + question?: any; + // URL of the route to show on initial load of the preview frames. + previewURL: string; + + children: (components: { + /** + * A rendered component that allows users to flip between the + * standard content editors or the raw JSON view. This view should be + * gated behind a permission check and only "Developer" level folks + * should be able to access this mode. + */ + jsonModeEditor: React.ReactNode; + + /** + * A React Component that renders the JSON view of the current + * item being edited. Note that this is a "Power User" editor and can + * easily break an item if not used carefully. + */ + JsonEditor: React.ComponentType<{style: StyleType}>; + + /** + * A rendered component that flips the preview component + * between different viewport sizes (ie. phone, tablet, desktop). + */ + viewportResizerElement: React.ReactNode; + + /** + * A rendered component of a button to toggle lint + * warnings on and off in the other editors. + */ + hudElement: React.ReactNode; + + /** + * A rendered component that provides the item editing experience. + * TODO: Inline this component into EditorWithLayout for more layout + * control. + */ + itemEditor: React.ReactNode; + + /** + * A rendered component that previews the current item. It is updated + * any time the question, hints, or answerArea changes. It is also + * updaed when the `jsonModeEditor` is toggled on and the `JsonEditor` + * is changed. + */ + itemPreview: React.ReactNode; + + /** + * A rendered component that provides the hints editing experience. + * TODO: Inline this component into EditorWithLayout for more layout + * control. + */ + hintsEditor: React.ReactNode; + + /** + * A rendered component that provides an editor to toggle question + * extras for this item (such as calculator, periodic table, etc). + */ + questionExtras: React.ReactNode; + }) => React.ReactNode; +}; + +type State = { + json?: PerseusItem; + question?: any; + answerArea: any; + hints: any; + itemDataVersion?: Version; + + gradeMessage: string; + wasAnswered: boolean; + highlightLint: boolean; +}; + +class EditorWithLayout extends React.Component { + _isMounted: boolean; + renderer: any; + + itemEditor = React.createRef(); + hintsEditor = React.createRef(); + itemExtrasEditor = React.createRef(); + previewRenderer = React.createRef(); + + static defaultProps: { + developerMode: boolean; + jsonMode: boolean; + onChange: () => void; + } = { + developerMode: false, + jsonMode: false, + onChange: () => {}, + }; + + constructor(props: Props) { + super(props); + + this.state = { + question: this.props.question, + answerArea: this.props.answerArea, + hints: this.props.hints, + itemDataVersion: this.props.itemDataVersion, + + gradeMessage: "", + wasAnswered: false, + highlightLint: true, + }; + + this._isMounted = false; + } + + componentDidMount() { + // TODO(scottgrant): This is a hack to remove the deprecated call to + // this.isMounted() but is still considered an anti-pattern. + this._isMounted = true; + + this.updateRenderer(); + } + + componentDidUpdate() { + // NOTE: It is required to delay the preview update until after the + // current frame, to allow for ItemEditor to render its widgets. + // This then enables to serialize the widgets properties correctly, + // in order to send data to the preview iframe (IframeContentRenderer). + // Otherwise, widgets will render in an "empty" state in the preview. + // TODO(jeff, CP-3128): Use Wonder Blocks Timing API + // eslint-disable-next-line no-restricted-syntax + setTimeout(() => { + this.updateRenderer(); + }); + } + + componentWillUnmount() { + this._isMounted = false; + } + + toggleJsonMode: () => void = () => { + this.setState( + { + json: this.serialize({keepDeletedWidgets: true}), + }, + () => { + this.props.onChange({ + jsonMode: !this.props.jsonMode, + }); + }, + ); + }; + + updateRenderer() { + // Some widgets (namely the image widget) like to call onChange before + // anything has actually been mounted, which causes problems here. We + // just ensure don't update until we've mounted + const hasEditor = !this.props.jsonMode; + if (!this._isMounted || !hasEditor) { + return; + } + + const touch = + this.props.previewDevice === "phone" || + this.props.previewDevice === "tablet"; + const deviceBasedApiOptions: APIOptionsWithDefaults = { + ...this.getApiOptions(), + customKeypad: touch, + isMobile: touch, + }; + + this.itemEditor.current?.triggerPreviewUpdate({ + type: "question", + data: _({ + item: this.serialize(), + apiOptions: deviceBasedApiOptions, + initialHintsVisible: 0, + device: this.props.previewDevice, + linterContext: { + contentType: "exercise", + highlightLint: this.state.highlightLint, + // TODO(CP-4838): is it okay to use [] as a default? + paths: this.props.contentPaths || [], + }, + reviewMode: true, + legacyPerseusLint: this.itemEditor.current?.getSaveWarnings(), + }).extend( + _(this.props).pick( + "workAreaSelector", + "solutionAreaSelector", + "hintsAreaSelector", + "problemNum", + ), + ), + }); + } + + getApiOptions(): APIOptionsWithDefaults { + return { + ...ApiOptions.defaults, + ...this.props.apiOptions, + }; + } + + getSaveWarnings(): any { + const issues1 = this.itemEditor.current?.getSaveWarnings(); + const issues2 = this.hintsEditor.current?.getSaveWarnings(); + return issues1.concat(issues2); + } + + serialize(options?: { + keepDeletedWidgets?: boolean; + }): PerseusItem | undefined { + if (this.props.jsonMode) { + return this.state.json; + } + return _.extend(this.itemEditor.current?.serialize(options), { + hints: this.hintsEditor.current?.serialize(options), + }); + } + + handleChange: ChangeHandler = (toChange, cb, silent) => { + const newProps = _(this.props).pick("question", "hints", "answerArea"); + _(newProps).extend(toChange); + this.props.onChange(newProps, cb, silent); + }; + + changeJSON: (newJson: PerseusItem) => void = (newJson: PerseusItem) => { + this.setState({ + json: newJson, + }); + this.props.onChange(newJson); + }; + + scorePreview(): KEScore | null | undefined { + // Do we actually ever set this.renderer anywhere in the codebase? + if (this.renderer) { + return this.renderer.scoreInput(); + } + return null; + } + + render(): React.ReactNode { + let className = "framework-perseus"; + + const touch = + this.props.previewDevice === "phone" || + this.props.previewDevice === "tablet"; + const deviceBasedApiOptions: APIOptionsWithDefaults = { + ...this.getApiOptions(), + customKeypad: touch, + isMobile: touch, + }; + + if (deviceBasedApiOptions.isMobile) { + className += " " + ClassNames.MOBILE; + } + + const jsonModeEditor = ( + + ); + + return ( +
+ {this.props.children({ + jsonModeEditor: jsonModeEditor, + JsonEditor: ({style}: {style: StyleType}) => ( + + ), + viewportResizerElement: ( + + ), + hudElement: ( + { + this.setState({ + highlightLint: !this.state.highlightLint, + }); + }} + /> + ), + itemEditor: !this.props.jsonMode && ( + + ), + itemPreview: ( + + + + ), + hintsEditor: !this.props.jsonMode && ( + + ), + questionExtras: ( + + this.handleChange({ + answerArea: { + ...this.props.answerArea, + ...answerArea, + }, + }) + } + {...this.props.answerArea} + /> + ), + })} +
+ ); + } +} + +export default EditorWithLayout; diff --git a/packages/perseus-editor/src/editor.tsx b/packages/perseus-editor/src/editor.tsx index f2dba9d015..17e9ad5504 100644 --- a/packages/perseus-editor/src/editor.tsx +++ b/packages/perseus-editor/src/editor.tsx @@ -25,6 +25,7 @@ import WidgetEditor from "./components/widget-editor"; import WidgetSelect from "./components/widget-select"; import TexErrorView from "./tex-error-view"; +import type {SerializeOptions} from "./types"; import type {ChangeHandler, PerseusWidget} from "@khanacademy/perseus"; // like [[snowman input-number 1]] @@ -830,12 +831,12 @@ class Editor extends React.Component { } }; - serialize: (options?: any) => { + serialize: (options?: SerializeOptions) => { content: string; images: any; replace: any | undefined; widgets: Record; - } = (options: any) => { + } = (options) => { // need to serialize the widgets since the state might not be // completely represented in props. ahem //transformer// (and // interactive-graph and plotter). diff --git a/packages/perseus-editor/src/hint-editor.tsx b/packages/perseus-editor/src/hint-editor.tsx index 82d13f747c..f0003de462 100644 --- a/packages/perseus-editor/src/hint-editor.tsx +++ b/packages/perseus-editor/src/hint-editor.tsx @@ -12,6 +12,7 @@ import DeviceFramer from "./components/device-framer"; import Editor from "./editor"; import IframeContentRenderer from "./iframe-content-renderer"; +import type {SerializeOptions} from "./types"; import type { APIOptions, WidgetDict, @@ -90,7 +91,7 @@ export class HintEditor extends React.Component { return this.editor.current?.getSaveWarnings(); }; - serialize: (options?: any) => any = (options: any) => { + serialize: (options?: SerializeOptions) => any = (options) => { return this.editor.current?.serialize(options); }; @@ -222,7 +223,7 @@ class CombinedHintEditor extends React.Component { return this.editor.current?.getSaveWarnings(); }; - serialize = (options: any) => { + serialize = (options: SerializeOptions) => { return this.editor.current?.serialize(options); }; @@ -390,7 +391,9 @@ class CombinedHintsEditor extends React.Component { .value(); }; - serialize: (options?: any) => ReadonlyArray = (options: any) => { + serialize: (options?: SerializeOptions) => ReadonlyArray = ( + options, + ) => { return this.props.hints.map((hint, i) => { return this.serializeHint(i, options); }); @@ -407,44 +410,32 @@ class CombinedHintsEditor extends React.Component { render(): React.ReactNode { const {itemId, hints} = this.props; - const hintElems = _.map( - hints, - function (hint, i) { - return ( - - ); - }, - this, - ); + const hintElems = hints.map((hint, i) => { + return ( + + ); + }); return (
diff --git a/packages/perseus-editor/src/index.ts b/packages/perseus-editor/src/index.ts index 4ea32eb5b2..e2f6fb9be8 100644 --- a/packages/perseus-editor/src/index.ts +++ b/packages/perseus-editor/src/index.ts @@ -8,6 +8,7 @@ export {default as ItemDiff} from "./diffs/item-diff"; export {default as StructuredItemDiff} from "./diffs/structured-item-diff"; export {default as EditorPage} from "./editor-page"; export {default as Editor} from "./editor"; +export {default as EditorWithLayout} from "./editor-with-layout"; export {default as i18n} from "./i18n"; export {default as IframeContentRenderer} from "./iframe-content-renderer"; export {default as MultiRendererEditor} from "./multirenderer-editor"; diff --git a/packages/perseus-editor/src/item-editor.tsx b/packages/perseus-editor/src/item-editor.tsx index b73f7c130a..10a5a84428 100644 --- a/packages/perseus-editor/src/item-editor.tsx +++ b/packages/perseus-editor/src/item-editor.tsx @@ -7,6 +7,7 @@ import Editor from "./editor"; import IframeContentRenderer from "./iframe-content-renderer"; import ItemExtrasEditor from "./item-extras-editor"; +import type {SerializeOptions} from "./types"; import type { APIOptions, ImageUploader, @@ -72,7 +73,7 @@ class ItemEditor extends React.Component { return this.questionEditor.current?.getSaveWarnings(); }; - serialize: (options?: any) => { + serialize: (options?: SerializeOptions) => { answerArea: any; itemDataVersion: { major: number; diff --git a/packages/perseus-editor/src/types.ts b/packages/perseus-editor/src/types.ts new file mode 100644 index 0000000000..163071f9a6 --- /dev/null +++ b/packages/perseus-editor/src/types.ts @@ -0,0 +1 @@ +export type SerializeOptions = {keepDeletedWidgets?: boolean}; diff --git a/packages/perseus/src/components/hud.tsx b/packages/perseus/src/components/hud.tsx index 03cb916531..49e5e43772 100644 --- a/packages/perseus/src/components/hud.tsx +++ b/packages/perseus/src/components/hud.tsx @@ -1,82 +1,11 @@ +import {PhosphorIcon} from "@khanacademy/wonder-blocks-icon"; +import eyeIcon from "@phosphor-icons/core/bold/eye-bold.svg"; +import eyeSlashIcon from "@phosphor-icons/core/bold/eye-slash-bold.svg"; import {StyleSheet, css} from "aphrodite"; import * as React from "react"; import * as constants from "../styles/constants"; -// Displays a stylized open eye: lint warnings are visible -const VisibleIcon = () => ( - - - - - - - - - - - - - - - -); - -// Displays a stylized eye with a line through it: I don't want to see lint -const HiddenIcon = () => ( - - - - - - - - - - - - - - - -); - type Props = { message: string; enabled: boolean; @@ -85,15 +14,9 @@ type Props = { }; const HUD = ({message, enabled, onClick, fixedPosition = true}: Props) => { - let state; - let icon; - if (enabled) { - state = styles.enabled; - icon = ; - } else { - state = styles.disabled; - icon = ; - } + const [state, icon] = enabled + ? [styles.enabled, eyeIcon] + : [styles.disabled, eyeSlashIcon]; return ( );