Skip to content
Open
6 changes: 6 additions & 0 deletions .changeset/bright-cats-leap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@khanacademy/perseus-editor": major
---

Migrate Perseus to use the new preview system (`PreviewWithIframe` and
`usePreviewController`).
99 changes: 57 additions & 42 deletions packages/perseus-editor/src/article-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,18 @@ import arrowCircleUpIcon from "@phosphor-icons/core/bold/arrow-circle-up-bold.sv
import plusIcon from "@phosphor-icons/core/bold/plus-bold.svg";
import trashIcon from "@phosphor-icons/core/bold/trash-bold.svg";
import * as React from "react";
import _ from "underscore";

import DeviceFramer from "./components/device-framer";
import IssuesPanel from "./components/issues-panel";
import JsonEditor from "./components/json-editor";
import SectionControlButton from "./components/section-control-button";
import Editor from "./editor";
import IframeContentRenderer from "./iframe-content-renderer";
import {WARNINGS} from "./messages";
import PreviewWithIframe from "./preview-with-iframe";
import {detectTexErrors} from "./util/tex-error-detector";

import type {Issue} from "./components/issues-panel";
import type {PreviewWithIframeRef} from "./preview-with-iframe";
import type {
APIOptions,
ImageUploader,
Expand All @@ -51,6 +51,7 @@ type DefaultProps = {
i: number,
) => React.ReactElement<React.ComponentProps<"span">>;
};

type Props = DefaultProps & {
apiOptions?: APIOptions;
dependencies: PerseusDependenciesV2;
Expand All @@ -70,8 +71,7 @@ type State = {

export default class ArticleEditor extends React.Component<Props, State> {
static defaultProps: DefaultProps = {
// NOTE(Jeremy):
json: [{} as any],
json: [{content: "", widgets: {}, images: {}}],
mode: "edit",
screen: "desktop",
sectionImageUploadGenerator: () => <span />,
Expand All @@ -82,6 +82,9 @@ export default class ArticleEditor extends React.Component<Props, State> {
issues: [],
};

// Store refs for preview iframes (keyed by section index or "all")
private frameRefs: Record<string, PreviewWithIframeRef | null> = {};
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This moves us away from string refs (see _updatePreviewFrames in it original form)


componentDidMount() {
this._updateIssues();
this._updatePreviewFrames();
Expand Down Expand Up @@ -146,47 +149,54 @@ export default class ArticleEditor extends React.Component<Props, State> {

_updatePreviewFrames() {
if (this.props.mode === "preview") {
// eslint-disable-next-line react/no-string-refs
// @ts-expect-error - TS2339 - Property 'sendNewData' does not exist on type 'ReactInstance'.
this.refs["frame-all"].sendNewData({
type: "article-all",
data: this._sections().map((section, i) => {
return this._apiOptionsForSection(section, i);
}),
});
const frameAll = this.frameRefs["all"];
if (frameAll) {
frameAll.sendNewData({
type: "article-all",
data: this._sections().map((section, i) =>
this._previewDataForSection(section, i),
),
});
}
} else if (this.props.mode === "edit") {
this._sections().forEach((section, i) => {
// eslint-disable-next-line react/no-string-refs
// @ts-expect-error - TS2339 - Property 'sendNewData' does not exist on type 'ReactInstance'.
this.refs["frame-" + i].sendNewData({
type: "article",
data: this._apiOptionsForSection(section, i),
});
const frame = this.frameRefs[String(i)];
if (frame) {
frame.sendNewData({
type: "article",
data: this._previewDataForSection(section, i),
});
}
});
}
}

_apiOptionsForSection(section: PerseusRenderer, sectionIndex: number): any {
_previewDataForSection(section: PerseusRenderer, sectionIndex: number) {
// eslint-disable-next-line react/no-string-refs
const editor = this.refs[`editor${sectionIndex}`];

return {
apiOptions: {
...ApiOptions.defaults,
...this.props.apiOptions,

// Alignment options are always available in article
// editors
showAlignmentOptions: true,
isArticle: true,
},
apiOptions: this._apiOptionsForPreview(),
json: section,
linterContext: {
contentType: "article",
highlightLint: this.state.highlightLint,
stack: [],
},
// @ts-expect-error - TS2339 - Property 'getSaveWarnings' does not exist on type 'ReactInstance'.
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
legacyPerseusLint: editor ? editor.getSaveWarnings() : [],
legacyPerseusLint: editor?.getSaveWarnings() ?? [],
};
}

_apiOptionsForPreview(): APIOptions {
return {
...ApiOptions.defaults,
...this.props.apiOptions,

// Alignment options are always available in article
// editors
showAlignmentOptions: true,
isArticle: true,
};
}

Expand Down Expand Up @@ -361,11 +371,12 @@ export default class ArticleEditor extends React.Component<Props, State> {

return (
<DeviceFramer deviceType={this.props.screen} nochrome={nochrome}>
<IframeContentRenderer
ref={"frame-" + i}
<PreviewWithIframe
ref={(node) => {
this.frameRefs[String(i)] = node;
}}
key={this.props.screen}
datasetKey="mobile"
datasetValue={isMobile}
isMobile={isMobile}
seamless={nochrome}
url={this.props.previewURL}
/>
Expand Down Expand Up @@ -423,20 +434,24 @@ export default class ArticleEditor extends React.Component<Props, State> {
_handleAddSectionAfter(i: number) {
// We do a full serialization here because we
// might be copying widgets:
const sections = _.clone(this.serialize());
const clonedArticle = this.serialize();
// Articles are (annoyingly) either a single PerseusRenderer _or_ an
// array of them! Would be nice for the article to always be an array!
const sections =
clonedArticle instanceof Array ? clonedArticle : [clonedArticle];

// Here we do magic to allow you to copy-paste
// things from the previous section into the new
// section while preserving widgets.
// To enable this, we preserve the widgets
// object for the new section, but wipe out
// the content.
const newSection =
i >= 0
? {
widgets: sections[i].widgets,
}
: {};
// @ts-expect-error - TS2339 - Property 'splice' does not exist on type 'PerseusArticle'.
const newSection = {
content: "",
images: {},
widgets: i >= 0 ? sections[i].widgets : {},
};

sections.splice(i + 1, 0, newSection);
this.props.onChange({
json: sections,
Expand Down
2 changes: 1 addition & 1 deletion packages/perseus-editor/src/editor-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ class EditorPage extends React.Component<Props, State> {
// 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).
// in order to send data to the preview iframe (PreviewWithIframe).
// 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
Expand Down
16 changes: 8 additions & 8 deletions packages/perseus-editor/src/hint-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@
* Collection of classes for rendering the hint editor area,
* hint editor boxes, and hint previews
*/
import {components, iconTrash} from "@khanacademy/perseus";
import {ApiOptions, components, iconTrash} from "@khanacademy/perseus";
import * as React from "react";
import invariant from "tiny-invariant";
import _ from "underscore";

import DeviceFramer from "./components/device-framer";
import Editor from "./editor";
import IframeContentRenderer from "./iframe-content-renderer";
import PreviewWithIframe from "./preview-with-iframe";
import {
iconCircleArrowDown,
iconCircleArrowUp,
Expand Down Expand Up @@ -203,7 +203,7 @@ class CombinedHintEditor extends React.Component<CombinedHintEditorProps> {
};

editor = React.createRef<HintEditor>();
frame = React.createRef<IframeContentRenderer>();
frame = React.createRef<React.ElementRef<typeof PreviewWithIframe>>();

componentDidMount() {
this.updatePreview();
Expand All @@ -219,10 +219,11 @@ class CombinedHintEditor extends React.Component<CombinedHintEditorProps> {
data: {
hint: this.props.hint,
pos: this.props.pos,
apiOptions: this.props.apiOptions,
apiOptions: this.props.apiOptions || ApiOptions.defaults,
linterContext: {
contentType: "hint",
highlightLint: this.props.highlightLint,
highlightLint: this.props.highlightLint || false,
stack: [],
},
},
});
Expand Down Expand Up @@ -277,10 +278,9 @@ class CombinedHintEditor extends React.Component<CombinedHintEditorProps> {
deviceType={this.props.deviceType}
nochrome={true}
>
<IframeContentRenderer
<PreviewWithIframe
ref={this.frame}
datasetKey="mobile"
datasetValue={isMobile}
isMobile={isMobile}
seamless={true}
url={this.props.previewURL}
/>
Expand Down
16 changes: 9 additions & 7 deletions packages/perseus-editor/src/item-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@ import _ from "underscore";
import DeviceFramer from "./components/device-framer";
import IssuesPanel from "./components/issues-panel";
import Editor from "./editor";
import IframeContentRenderer from "./iframe-content-renderer";
import ItemExtrasEditor from "./item-extras-editor";
import {WARNINGS} from "./messages";
import PreviewWithIframe from "./preview-with-iframe";
import {runAxeCoreOnUpdate} from "./util/a11y-checker";
import {ItemEditorContext} from "./util/item-editor-context";
import {detectTexErrors} from "./util/tex-error-detector";

import type {Issue} from "./components/issues-panel";
import type {PreviewContent} from "./preview/message-types";
import type {
APIOptions,
ImageUploader,
Expand All @@ -29,6 +30,8 @@ import type {
PerseusItem,
} from "@khanacademy/perseus-core";

type QuestionPreviewData = Extract<PreviewContent, {type: "question"}>;

type Props = {
/** Additional templates that the host application would like to display
* within the Perseus Editor.
Expand Down Expand Up @@ -72,7 +75,7 @@ class ItemEditor extends React.Component<Props, State> {
static prevWidgets: PerseusWidgetsMap | undefined;
a11yCheckerTimeoutId: any;

frame = React.createRef<IframeContentRenderer>();
frame = React.createRef<React.ElementRef<typeof PreviewWithIframe>>();
questionEditor = React.createRef<Editor>();
itemExtrasEditor = React.createRef<ItemExtrasEditor>();

Expand Down Expand Up @@ -151,9 +154,9 @@ class ItemEditor extends React.Component<Props, State> {
this.props.onChange(_(props).extend(newProps));
};

triggerPreviewUpdate: (newData?: any) => void = (newData: any) => {
triggerPreviewUpdate(newData: QuestionPreviewData) {
this.frame.current?.sendNewData(newData);
};
}

// eslint-disable-next-line import/no-deprecated
handleEditorChange: ChangeHandler = (newProps) => {
Expand Down Expand Up @@ -249,11 +252,10 @@ class ItemEditor extends React.Component<Props, State> {
deviceType={this.props.deviceType}
nochrome={true}
>
<IframeContentRenderer
<PreviewWithIframe
ref={this.frame}
key={this.props.deviceType}
datasetKey="mobile"
datasetValue={isMobile}
isMobile={isMobile}
seamless={true}
url={this.props.previewURL}
/>
Expand Down
Loading
Loading