diff --git a/.changeset/nasty-cobras-try.md b/.changeset/nasty-cobras-try.md new file mode 100644 index 0000000000..622dd930d8 --- /dev/null +++ b/.changeset/nasty-cobras-try.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +Migrate Lint component to use WonderBlocks ToolTip diff --git a/.changeset/quiet-glasses-join.md b/.changeset/quiet-glasses-join.md new file mode 100644 index 0000000000..e7a6c4d810 --- /dev/null +++ b/.changeset/quiet-glasses-join.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus-editor": minor +--- + +Add ContentPreview component diff --git a/.changeset/wise-cougars-hunt.md b/.changeset/wise-cougars-hunt.md new file mode 100644 index 0000000000..d609006d7e --- /dev/null +++ b/.changeset/wise-cougars-hunt.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus-dev-ui": patch +--- + +Update vite config to alias `/strings` expots to correct strings.ts file per package diff --git a/dev/vite.config.ts b/dev/vite.config.ts index 2185e291a7..92e6da7bad 100644 --- a/dev/vite.config.ts +++ b/dev/vite.config.ts @@ -10,7 +10,41 @@ const packageAliases = {}; glob.sync(resolve(__dirname, "../packages/*/package.json")).forEach( (packageJsonPath) => { const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")); - packageAliases[pkg.name] = join(dirname(packageJsonPath), pkg.source); + + // "exports" is the more modern way to declare package exports. Some, + // but not all, Perseus packages declare "exports". + if ("exports" in pkg) { + // Not all packages export strings, but for those that do we need + // to set up an alias so Vite properly resolves them. + // Eg `import {strings, mockStrings} from "@khanacademy/perseus/strings";` + // And MOST IMPORTANTLY, this alias _must_ precede the main + // import, otherwise Vite will just use the main export and tack + // `/strings` onto the end, resulting in a path like this: + // `packages/perseus/src/index.ts/strings` + const stringsSource = pkg.exports["./strings"]?.source; + if (stringsSource != null) { + packageAliases[`${pkg.name}/strings`] = join( + dirname(packageJsonPath), + stringsSource, + ); + } + + const mainSource = pkg.exports["."]?.source; + if (mainSource == null) { + throw new Error( + `Package declares 'exports', but not provide a main export (exports["."])`, + ); + } + packageAliases[pkg.name] = join( + dirname(packageJsonPath), + mainSource, + ); + } else { + packageAliases[pkg.name] = join( + dirname(packageJsonPath), + pkg.source, + ); + } }, ); diff --git a/packages/perseus-editor/package.json b/packages/perseus-editor/package.json index 0e406ef494..ee6c866767 100644 --- a/packages/perseus-editor/package.json +++ b/packages/perseus-editor/package.json @@ -35,6 +35,7 @@ }, "dependencies": { "@khanacademy/kas": "^0.3.11", + "@khanacademy/keypad-context": "^1.0.0", "@khanacademy/kmath": "^0.1.13", "@khanacademy/math-input": "^21.0.0", "@khanacademy/perseus": "^28.2.0", diff --git a/packages/perseus-editor/src/__stories__/content-preview.stories.tsx b/packages/perseus-editor/src/__stories__/content-preview.stories.tsx new file mode 100644 index 0000000000..55980bedfe --- /dev/null +++ b/packages/perseus-editor/src/__stories__/content-preview.stories.tsx @@ -0,0 +1,101 @@ +import {View} from "@khanacademy/wonder-blocks-core"; +import {spacing} from "@khanacademy/wonder-blocks-tokens"; +import {useState} from "react"; + +import {articleWithImages} from "../../../perseus/src/__testdata__/article-renderer.testdata"; +import {mockStrings} from "../../../perseus/src/strings"; +import {question} from "../../../perseus/src/widgets/__testdata__/radio.testdata"; +import DeviceFramer from "../components/device-framer"; +import ViewportResizer from "../components/viewport-resizer"; +import ContentPreview from "../content-preview"; + +import type {DeviceType} from "@khanacademy/perseus"; +import type {Meta, StoryObj} from "@storybook/react"; + +import "../styles/perseus-editor.less"; + +const PreviewWrapper = (props) => { + const [previewDevice, setPreviewDevice] = useState("phone"); + + return ( + + + + + + + ); +}; + +const meta: Meta = { + title: "PerseusEditor/Content Preview", + component: ContentPreview, + args: { + strings: mockStrings, + }, + decorators: [ + (Story) => ( + + + + ), + ], + render: (props) => , +}; + +export default meta; +type Story = StoryObj; + +export const Exercise: Story = { + args: { + question, + }, +}; + +export const Article: Story = { + args: { + question: articleWithImages, + }, +}; + +export const WithLintErrors: Story = { + args: { + linterContext: { + contentType: "exercise", + highlightLint: true, + stack: [], + paths: [], + }, + question: { + content: `# H1s bad + +Here is some unclosed math: $1+1=3 + +We should use \`\\dfrac{}\` instead of \`\\frac{}\`: $\\frac{3}{5}$ + +What is the best color in the world? + +[[☃ radio 1]]`, + widgets: { + "radio 1": { + type: "radio", + options: { + choices: [ + {content: "Red"}, + {content: "# Green"}, + {content: "Blue", correct: true}, + { + content: "None of these!", + isNoneOfTheAbove: true, + }, + ], + }, + }, + }, + images: {}, + }, + }, +}; diff --git a/packages/perseus-editor/src/content-preview.tsx b/packages/perseus-editor/src/content-preview.tsx new file mode 100644 index 0000000000..7f017e734a --- /dev/null +++ b/packages/perseus-editor/src/content-preview.tsx @@ -0,0 +1,91 @@ +import { + KeypadContext, + StatefulKeypadContextProvider, +} from "@khanacademy/keypad-context"; +import {MobileKeypad} from "@khanacademy/math-input"; +import { + Renderer, + constants, + type APIOptions, + type DeviceType, + type PerseusRenderer, +} from "@khanacademy/perseus"; +import {View} from "@khanacademy/wonder-blocks-core"; +import {spacing} from "@khanacademy/wonder-blocks-tokens"; +import {StyleSheet} from "aphrodite"; + +import type {LinterContextProps} from "@khanacademy/perseus-linter"; +import type {PropsFor} from "@khanacademy/wonder-blocks-core"; + +/** + * The `ContentPreview` component provides a simple preview system for Perseus + * Content. Due to how Persus styles are built, the preview styling matches the + * current device based on the viewport width (using `@media` queries for + * `min-width` and `max-width`). + * + * The preview will render the mobile variant (styling and layout) when the + * `previewDevice` is phone or tablet. Note that the styling cannot be matched + * 100% due to the above `@media` query limitation. + */ +function ContentPreview({ + question, + apiOptions, + seamless, + linterContext, + legacyPerseusLint, + previewDevice, + strings, +}: { + question?: PerseusRenderer; + apiOptions?: APIOptions; + seamless?: boolean; + linterContext?: LinterContextProps; + legacyPerseusLint?: ReadonlyArray; + previewDevice: DeviceType; + strings: PropsFor["strings"]; +}) { + const isMobile = previewDevice !== "desktop"; + + const className = isMobile ? "perseus-mobile" : ""; + + return ( + + + + {({setKeypadActive, keypadElement, setKeypadElement}) => ( + <> + + + Promise.resolve()} + onDismiss={() => setKeypadActive(false)} + onElementMounted={setKeypadElement} + /> + + )} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + padding: spacing.xxxSmall_4, + containerType: "inline-size", + containerName: "perseus-root", + }, + gutter: {marginRight: constants.lintGutterWidth}, +}); + +export default ContentPreview; diff --git a/packages/perseus-editor/src/index.ts b/packages/perseus-editor/src/index.ts index 1f3114764c..cf11fc0c8b 100644 --- a/packages/perseus-editor/src/index.ts +++ b/packages/perseus-editor/src/index.ts @@ -9,6 +9,7 @@ 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 i18n} from "./i18n"; +export {default as ContentPreview} from "./content-preview"; export {default as IframeContentRenderer} from "./iframe-content-renderer"; export {default as MultiRendererEditor} from "./multirenderer-editor"; export {default as StatefulEditorPage} from "./stateful-editor-page"; diff --git a/packages/perseus/src/__testdata__/article-renderer.testdata.ts b/packages/perseus/src/__testdata__/article-renderer.testdata.ts index 57b446bd11..3783830d4b 100644 --- a/packages/perseus/src/__testdata__/article-renderer.testdata.ts +++ b/packages/perseus/src/__testdata__/article-renderer.testdata.ts @@ -33,6 +33,96 @@ export const singleSectionArticle: PerseusRenderer = { }, }; +export const articleWithImages: PerseusRenderer = { + content: + "The word \"radiation\" sometimes gets a bad rap. People often associate radiation with something dangerous or scary, without really knowing what it is. In reality, we're surrounded by radiation all the time. \n\n**Radiation** is energy that travels through space (not just \"outer space\"—any space). Radiation can also interact with matter. How radiation interacts with matter depends on the type of radiation and the type of matter.\n\nRadiation comes in many forms, one of which is *electromagnetic radiation*. Without electromagnetic radiation life on Earth would not be possible, nor would most modern technologies.\n\n[[☃ image 13]]\n\nLet's take a closer look at this important and fascinating type of radiation.\n\n##Electromagnetic radiation\n\nAs the name suggests, **electromagnetic (EM) radiation** is energy transferred by *electromagnetic fields* oscillating through space.\n\nEM radiation is strange—it has both wave and particle properties. Let's take a look at both.\n\n###Electromagnetic waves\n\nAn animated model of an EM wave is shown below.\n[[☃ image 1]]\nThe electric field $(\\vec{\\textbf{E}})$ is shown in $\\color{blue}\\textbf{blue}$, and the magnetic field $(\\vec{\\textbf{B}})$ is shown in $\\color{red}\\textbf{red}$. They're perpendicular to each other.\n\nA changing electric field creates a magnetic field, and a changing magnetic field creates an electric field. So, once the EM wave is generated it propagates itself through space!\n\nAs with any wave, EM waves have wavelength, frequency, and speed. The wave model of EM radiation works best on large scales. But what about the atomic scale?\n\n###Photons\n\nAt the quantum level, EM radiation exists as particles. A particle of EM radiation is called a **photon**.\n\nWe can think of photons as wave *packets*—tiny bundles of EM radiation containing specific amounts of energy. Photons are visually represented using the following symbol.\n\n[[☃ image 3]]\n\nAll EM radiation, whether modeled as waves or photons, travels at the **speed of light** $\\textbf{(c)}$ in a vacuum: \n\n$\\text{c}=3\\times10^8\\space\\pu{m/s}=300{,}000{,}000\\space\\pu{m/s}$\n\nBut, EM radiation travels at a slower speed in matter, such as water or glass.", + images: {}, + widgets: { + "image 13": { + type: "image", + alignment: "block", + static: false, + graded: true, + options: { + static: false, + title: "", + range: [ + [0, 10], + [0, 10], + ], + box: [600, 254], + backgroundImage: { + url: "https://cdn.kastatic.org/ka-content-images/358a87c20ab6ee70447f5fcb547010f69986828e.jpg", + width: 600, + height: 254, + }, + labels: [], + alt: "From space, the sun appears over Earth's horizon, illuminating the atmosphere as a blue layer above Earth. Above the atmosphere, space appears black.", + caption: + "*Sunrise photo from the International Space Station. Earth's atmosphere scatters electromagnetic radiation from the sun, producing a bright sky during the day.*", + }, + version: { + major: 0, + minor: 0, + }, + }, + "image 1": { + type: "image", + alignment: "block", + static: false, + graded: true, + options: { + static: false, + title: "", + range: [ + [0, 10], + [0, 10], + ], + box: [627, 522], + backgroundImage: { + url: "https://cdn.kastatic.org/ka-content-images/8100369eaf3b581d4e7bfc9f1062625309def486.gif", + width: 627, + height: 522, + }, + labels: [], + alt: "An animation shows a blue electric field arrow oscillating up and down. Connected to the base of the electric field arrow is a magnetic field arrow, which oscillates from side to side. The two fields oscillate in unison: when one extends the other extends too, creating a repeating wave pattern.", + caption: "", + }, + version: { + major: 0, + minor: 0, + }, + }, + "image 3": { + type: "image", + alignment: "block", + static: false, + graded: true, + options: { + static: false, + title: "", + range: [ + [0, 10], + [0, 10], + ], + box: [350, 130], + backgroundImage: { + url: "https://cdn.kastatic.org/ka-content-images/74edeeb6c6605a4e854e3a3e9db69c01dcf5508f.svg", + width: 350, + height: 130, + }, + labels: [], + alt: "A squiggly curve drawn from left to right. The right end of the curve has an arrow point. The curve begins with a small amount of wiggle on the left, which grows in amplitude in the middle and then decreases again on the right. The result is a small wave packet.", + caption: "", + }, + version: { + major: 0, + minor: 0, + }, + }, + }, +}; + export const passageArticle: PerseusRenderer = { content: "###Group/Pair Activity \n\nThis passage is adapted from Ed Yong, “Turtles Use the Earth’s Magnetic Field as Global GPS.” ©2011 by Kalmbach Publishing Co.\n\n[[☃ passage 1]]\n\n**Question 9**\n\nThe passage most strongly suggests that Adelita used which of the following to navigate her 9,000-mile journey?\n\nA) The current of the North Atlantic gyre\n\nB) Cues from electromagnetic coils designed by Putman and Lohmann\n\nC) The inclination and intensity of Earth’s magnetic field\n\nD) A simulated “magnetic signature” configured by Lohmann\n\n10) Which choice provides the best evidence for the answer to the previous question?\n\nA) Lines 1–2 (“In 1996...way”)\n\nB) Lines 20–21 (“Using...surface”)\n\nC) Lines 36–37 (“In the wild...stars”)\n\nD) Lines 43–45 (“Neither...it is”)\n\n**Question 12** \n\nBased on the passage, which choice best describes the relationship between Putman’s and Lohmann’s research?\n\nA) Putman’s research contradicts Lohmann’s.\n\nB) Putman’s research builds on Lohmann’s.\n\nC) Lohmann’s research confirms Putman’s.\n\nD) Lohmann’s research corrects Putman’s.", diff --git a/packages/perseus/src/__tests__/server-item-renderer.test.tsx b/packages/perseus/src/__tests__/server-item-renderer.test.tsx index 46ca6ef3aa..9870da73e1 100644 --- a/packages/perseus/src/__tests__/server-item-renderer.test.tsx +++ b/packages/perseus/src/__tests__/server-item-renderer.test.tsx @@ -588,7 +588,7 @@ describe("server item renderer", () => { ).not.toBeInTheDocument(); }); - it("should show linting errors when highlightLint is true", () => { + it("should show linting errors when highlightLint is true", async () => { // Arrange and Act renderQuestion(itemWithLintingError, undefined, { linterContext: { @@ -599,6 +599,12 @@ describe("server item renderer", () => { }, }); + // Linting errors are surfaced as a link with a warning or error + // icon inside them. We need to click on it to open the tooltip + // that contains the error message. + const lintIcon = screen.getByRole("link"); + await userEvent.click(lintIcon); + expect( screen.getByText("Don't use level-1 headings", {exact: false}), ).toBeInTheDocument(); diff --git a/packages/perseus/src/components/lint.tsx b/packages/perseus/src/components/lint.tsx index d53dadf9d9..f43fcee1df 100644 --- a/packages/perseus/src/components/lint.tsx +++ b/packages/perseus/src/components/lint.tsx @@ -1,6 +1,7 @@ +import {color, font} from "@khanacademy/wonder-blocks-tokens"; +import Tooltip from "@khanacademy/wonder-blocks-tooltip"; import {StyleSheet, css} from "aphrodite"; import * as React from "react"; -import ReactDOM from "react-dom"; import * as constants from "../styles/constants"; @@ -38,10 +39,6 @@ type Props = { severity?: Severity; }; -type State = { - tooltipAbove: boolean; -}; - /** * This component renders "lint" nodes in a markdown parse tree. Lint nodes * are inserted into the tree by the Perseus linter (see @@ -64,43 +61,12 @@ type State = { * that has a right margin (like anything blockquoted) the circle will appear * to the left of where it belongs. And if there is more **/ -class Lint extends React.Component { +class Lint extends React.Component { _positionTimeout: number | undefined; - state: State = { - tooltipAbove: true, - }; - - componentDidMount() { - // TODO(somewhatabstract): Use WB timing - // eslint-disable-next-line no-restricted-syntax - this._positionTimeout = window.setTimeout(this.getPosition); - } - - componentWillUnmount() { - // TODO(somewhatabstract): Use WB timing - // eslint-disable-next-line no-restricted-syntax - window.clearTimeout(this._positionTimeout); - } - - // We can't call setState in componentDidMount without risking a render - // thrash, and we can't call getBoundingClientRect in render, so we - // borrow a timeout approach from learnstorm-dashboard.jsx and set our - // state once the component has mounted and we can get what we need. - getPosition: () => void = () => { - // @ts-expect-error - TS2531 - Object is possibly 'null'. | TS2339 - Property 'getBoundingClientRect' does not exist on type 'Element | Text'. - const rect = ReactDOM.findDOMNode(this).getBoundingClientRect(); - // TODO(scottgrant): This is a magic number! We don't know the size - // of the tooltip at this point, so we're arbitrarily choosing a - // point at which to flip the tooltip's position. - this.setState({tooltipAbove: rect.top > 100}); - }; - // Render the element that holds the indicator icon and the tooltip // We pass different styles for the inline and block cases renderLink: (arg1: any) => React.ReactElement = (style) => { - const tooltipAbove = this.state.tooltipAbove; - let severityStyle; let warningText; let warningTextStyle; @@ -119,38 +85,33 @@ class Lint extends React.Component { } return ( - + {this.props.message.split("\n\n").map((m, i) => ( +

+ + {warningText}:{" "} + + {m} +

+ ))} + + } > - - {this.props.severity === 1 && ( - - )} - -
- {this.props.message.split("\n\n").map((m, i) => ( -

- - {warningText}:{" "} - - {m} -

- ))} -
+ {this.props.severity === 1 && ( + )} - /> -
-
+ + + ); }; @@ -386,60 +347,11 @@ const styles = StyleSheet.create({ backgroundColor: "#ffbe26", }, - // These are the styles for the tooltip - tooltip: { - // Absolute positioning relative to the lint indicator circle. - position: "absolute", - right: -12, - - // The tooltip is hidden by default; only displayed on hover - display: "none", - - // When it is displayed, it goes on top! - zIndex: 1000, - - // These styles control what the tooltip looks like - color: constants.white, - backgroundColor: constants.gray17, - opacity: 0.9, - fontFamily: constants.baseFontFamily, - fontSize: "12px", - lineHeight: "15px", - width: "320px", - borderRadius: "4px", - }, - // If we're going to render the tooltip above the warning circle, we use - // the previous rules in tooltip, but change the position slightly. - tooltipAbove: { - bottom: 32, - }, - - // We give the tooltip a little triangular "tail" that points down at - // the lint indicator circle. This is inside the tooltip and positioned - // relative to it. It also shares the opacity of the tooltip. We're using - // the standard CSS trick for drawing triangles with a thick border. - tail: { - position: "absolute", - top: -12, - right: 16, - width: 0, - height: 0, - - // This is the CSS triangle trick - borderLeft: "8px solid transparent", - borderRight: "8px solid transparent", - borderBottom: "12px solid " + constants.gray17, - }, - tailAbove: { - bottom: -12, - borderBottom: "none", - borderTop: "12px solid " + constants.gray17, - top: "auto", - }, - // Each warning in the tooltip is its own

. They are 12 pixels from // the edges of the tooltip and 12 pixels from each other. tooltipParagraph: { + fontFamily: font.family.sans, + color: color.white, margin: 12, },