From f7d0b2028c607d25b23e04e1e80d5da5489831eb Mon Sep 17 00:00:00 2001 From: Leo McArdle Date: Tue, 10 Dec 2024 17:00:07 +0000 Subject: [PATCH 01/21] chore(playground): migrate runner iframe to web component https://mozilla-hub.atlassian.net/browse/MP-1806 --- client/src/lit/play/runner.js | 123 +++++++++++++++++++++++++++++++ client/src/lit/play/runner.scss | 5 ++ client/src/playground/index.scss | 3 +- client/src/playground/index.tsx | 91 +++++------------------ package.json | 1 + yarn.lock | 9 ++- 6 files changed, 157 insertions(+), 75 deletions(-) create mode 100644 client/src/lit/play/runner.js create mode 100644 client/src/lit/play/runner.scss diff --git a/client/src/lit/play/runner.js b/client/src/lit/play/runner.js new file mode 100644 index 000000000000..a4154e691917 --- /dev/null +++ b/client/src/lit/play/runner.js @@ -0,0 +1,123 @@ +import { html, LitElement } from "lit"; +import { compressAndBase64Encode } from "../../playground/utils.ts"; +import { PLAYGROUND_BASE_HOST } from "../../env.ts"; +import { createComponent } from "@lit/react"; +import { Task } from "@lit/task"; +import React from "react"; + +import styles from "./runner.scss?css" with { type: "css" }; + +/** + * @import { EditorContent } from "../../playground/utils" + * @import { VConsole } from "./types" + * @import { EventName } from "@lit/react" + * */ + +export class PlayRunner extends LitElement { + static properties = { + code: { type: Object }, + srcPrefix: { type: String, attribute: "src-prefix" }, + _src: { state: true }, + }; + + static styles = styles; + + constructor() { + super(); + /** @type {EditorContent | undefined} */ + this.code = undefined; + /** @type {string | undefined} */ + this.srcPrefix = undefined; + this._src = "about:blank"; + + this._subdomain = crypto.randomUUID(); + this._flipFlop = 0; + } + + /** @param {MessageEvent} e */ + _onMessage({ data: { typ, prop, message } }) { + if (typ === "console") { + if ( + (prop === "log" || prop === "error" || prop === "warn") && + typeof message === "string" + ) { + /** @type {VConsole} */ + const detail = { prop, message }; + this.dispatchEvent( + new CustomEvent("console", { bubbles: true, composed: true, detail }) + ); + } else { + const warning = "[Playground] Unsupported console message"; + /** @type {VConsole} */ + const detail = { + prop: "warn", + message: `${warning} (see browser console)`, + }; + this.dispatchEvent( + new CustomEvent("console", { bubbles: true, composed: true, detail }) + ); + console.warn(warning, { prop, message }); + } + } + } + + _updateSrc = new Task(this, { + args: () => /** @type {const} */ ([this.code, this.srcPrefix]), + task: async ([code, srcPrefix], { signal }) => { + if (code) { + const { state } = await compressAndBase64Encode(JSON.stringify(code)); + signal.throwIfAborted(); + // We're using a random subdomain for origin isolation. + const url = new URL( + window.location.hostname.endsWith("localhost") + ? window.location.origin + : `${window.location.protocol}//${ + PLAYGROUND_BASE_HOST.startsWith("localhost") + ? "" + : `${this._subdomain}.` + }${PLAYGROUND_BASE_HOST}` + ); + url.searchParams.set("state", state); + // ensure iframe reloads even if code doesn't change + url.searchParams.set("f", this._flipFlop.toString()); + url.pathname = `${srcPrefix || code.src || ""}/runner.html`; + this._src = url.href; + this._flipFlop = (this._flipFlop + 1) % 2; + } else { + this._src = "about:blank"; + } + }, + }); + + connectedCallback() { + super.connectedCallback(); + this._onMessage = this._onMessage.bind(this); + window.addEventListener("message", this._onMessage); + } + + render() { + return html` + + `; + } + + disconnectedCallback() { + super.disconnectedCallback(); + window.removeEventListener("message", this._onMessage); + } +} + +customElements.define("play-runner", PlayRunner); + +export const ReactPlayRunner = createComponent({ + tagName: "play-runner", + elementClass: PlayRunner, + react: React, + events: { + onConsole: /** @type {EventName>} */ ("console"), + }, +}); diff --git a/client/src/lit/play/runner.scss b/client/src/lit/play/runner.scss new file mode 100644 index 000000000000..31d32859dceb --- /dev/null +++ b/client/src/lit/play/runner.scss @@ -0,0 +1,5 @@ +iframe { + border: none; + height: 100%; + width: 100%; +} diff --git a/client/src/playground/index.scss b/client/src/playground/index.scss index 8789a49388b3..a90fe32a5381 100644 --- a/client/src/playground/index.scss +++ b/client/src/playground/index.scss @@ -231,7 +231,8 @@ main.play { } } - iframe { + play-runner { + border: 1px solid var(--border-primary); height: 100%; width: 100%; } diff --git a/client/src/playground/index.tsx b/client/src/playground/index.tsx index c023cccf3811..9498c5be4de3 100644 --- a/client/src/playground/index.tsx +++ b/client/src/playground/index.tsx @@ -11,20 +11,15 @@ import prettierPluginHTML from "prettier/plugins/html"; import { Button } from "../ui/atoms/button"; import Editor, { EditorHandle } from "./editor"; import { SidePlacement } from "../ui/organisms/placement"; -import { - compressAndBase64Encode, - decompressFromBase64, - EditorContent, - SESSION_KEY, -} from "./utils"; +import { decompressFromBase64, EditorContent, SESSION_KEY } from "./utils"; import "./index.scss"; -import { PLAYGROUND_BASE_HOST } from "../env"; import { FlagForm, ShareForm } from "./forms"; import { ReactPlayConsole } from "../lit/play/console"; +import { ReactPlayRunner } from "../lit/play/runner"; +import type { VConsole } from "../lit/play/types"; import { useGleanClick } from "../telemetry/glean-context"; import { PLAYGROUND } from "../telemetry/constants"; -import type { VConsole } from "../lit/play/types"; const HTML_DEFAULT = ""; const CSS_DEFAULT = ""; @@ -80,14 +75,12 @@ export default function Playground() { let [shareUrl, setShareUrl] = useState(null); let [vConsole, setVConsole] = useState([]); let [state, setState] = useState(State.initial); + const [code, setCode] = useState(); let [codeSrc, setCodeSrc] = useState(); - let [iframeSrc, setIframeSrc] = useState("about:blank"); const [isEmpty, setIsEmpty] = useState(true); - const subdomain = useRef(crypto.randomUUID()); const [initialContent, setInitialContent] = useState( null ); - const [flipFlop, setFlipFlop] = useState(0); let { data: initialCode } = useSWRImmutable( !stateParam && !shared && gistId ? `/api/v1/play/${encodeURIComponent(gistId)}` @@ -119,34 +112,11 @@ export default function Playground() { const htmlRef = useRef(null); const cssRef = useRef(null); const jsRef = useRef(null); - const iframe = useRef(null); const diaRef = useRef(null); - const updateWithCode = useCallback( - async (code: EditorContent) => { - const { state } = await compressAndBase64Encode(JSON.stringify(code)); - - // We're using a random subdomain for origin isolation. - const url = new URL( - window.location.hostname.endsWith("localhost") - ? window.location.origin - : `${window.location.protocol}//${ - PLAYGROUND_BASE_HOST.startsWith("localhost") - ? "" - : `${subdomain.current}.` - }${PLAYGROUND_BASE_HOST}` - ); - setVConsole([]); - url.searchParams.set("state", state); - // ensure iframe reloads even if code doesn't change - url.searchParams.set("f", flipFlop.toString()); - url.pathname = `${codeSrc || code.src || ""}/runner.html`; - setIframeSrc(url.href); - // using an updater function causes the second "run" to not reload properly: - setFlipFlop((flipFlop + 1) % 2); - }, - [codeSrc, setVConsole, setIframeSrc, flipFlop, setFlipFlop] - ); + useEffect(() => { + setVConsole([]); + }, [code, setVConsole]); useEffect(() => { if (initialCode) { @@ -168,26 +138,9 @@ export default function Playground() { return code; }, [initialContent?.src, initialCode?.src]); - let messageListener = useCallback(({ data: { typ, prop, message } }) => { - if (typ === "console") { - if ( - (prop === "log" || prop === "error" || prop === "warn") && - typeof message === "string" - ) { - setVConsole((vConsole) => [...vConsole, { prop, message }]); - } else { - const warning = "[Playground] Unsupported console message"; - setVConsole((vConsole) => [ - ...vConsole, - { - prop: "warn", - message: `${warning} (see browser console)`, - }, - ]); - console.warn(warning, { prop, message }); - } - } - }, []); + const onConsole = ({ detail }: CustomEvent) => { + setVConsole((vConsole) => [...vConsole, detail]); + }; const setEditorContent = ({ html, css, js, src }: EditorContent) => { htmlRef.current?.setContent(html); @@ -206,7 +159,7 @@ export default function Playground() { setEditorContent(initialCode); if (!gistId) { // don't auto run shared code - updateWithCode(initialCode); + setCode(initialCode); } } else if (stateParam) { try { @@ -226,14 +179,7 @@ export default function Playground() { setState(State.ready); } })(); - }, [initialCode, state, gistId, stateParam, updateWithCode]); - - useEffect(() => { - window.addEventListener("message", messageListener); - return () => { - window.removeEventListener("message", messageListener); - }; - }, [messageListener]); + }, [initialCode, state, gistId, stateParam, setCode]); const clear = async () => { setSearchParams([], { replace: true }); @@ -285,7 +231,7 @@ export default function Playground() { iterations: 1, }; document.getElementById("run")?.firstElementChild?.animate(loading, timing); - updateWithCode({ html, css, js, src }); + setCode({ html, css, js, src }); }; const format = async () => { @@ -415,12 +361,11 @@ export default function Playground() { Seeing something inappropriate? )} - + diff --git a/package.json b/package.json index b8bdd2a445b3..fdc929918a91 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "@fast-csv/parse": "^5.0.2", "@inquirer/prompts": "^7.2.1", "@lit/react": "^1.0.6", + "@lit/task": "^1.0.1", "@mdn/bcd-utils-api": "^0.0.7", "@mdn/browser-compat-data": "^5.6.28", "@mdn/rari": "^0.1.14", diff --git a/yarn.lock b/yarn.lock index dd0384f22a47..a33b5551b38c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2183,13 +2183,20 @@ resolved "https://registry.yarnpkg.com/@lit/react/-/react-1.0.6.tgz#9518ba471157becd1a3e6fb7ddc16bcef16be64e" integrity sha512-QIss8MPh6qUoFJmuaF4dSHts3qCsA36S3HcOLiNPShxhgYPr4XJRnCBKPipk85sR9xr6TQrOcDMfexwbNdJHYA== -"@lit/reactive-element@^2.0.4": +"@lit/reactive-element@^1.0.0 || ^2.0.0", "@lit/reactive-element@^2.0.4": version "2.0.4" resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-2.0.4.tgz#8f2ed950a848016383894a26180ff06c56ae001b" integrity sha512-GFn91inaUa2oHLak8awSIigYz0cU0Payr1rcFsrkf5OJ5eSPxElyZfKh0f2p9FsTiZWXQdWGJeXZICEfXXYSXQ== dependencies: "@lit-labs/ssr-dom-shim" "^1.2.0" +"@lit/task@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@lit/task/-/task-1.0.1.tgz#7462aeaa973766822567f5ca90fe157404e8eb81" + integrity sha512-fVLDtmwCau8NywnFIXaJxsCZjzaIxnVq+cFRKYC1Y4tA4/0rMTvF6DLZZ2JE51BwzOluaKtgJX8x1QDsQtAaIw== + dependencies: + "@lit/reactive-element" "^1.0.0 || ^2.0.0" + "@marijn/find-cluster-break@^1.0.0": version "1.0.2" resolved "https://registry.yarnpkg.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz#775374306116d51c0c500b8c4face0f9a04752d8" From 493be80042e194d84331b9c602dab1415bdd8c4e Mon Sep 17 00:00:00 2001 From: Leo McArdle Date: Thu, 5 Dec 2024 13:07:46 +0000 Subject: [PATCH 02/21] chore(playground): migrate editor to web component https://mozilla-hub.atlassian.net/browse/MP-1740 --- client/src/lit/play/editor.js | 146 +++++++++++++++++++++++++++++ client/src/lit/play/editor.scss | 53 +++++++++++ client/src/playground/editor.tsx | 156 ------------------------------- client/src/playground/index.scss | 48 ---------- client/src/playground/index.tsx | 49 ++++++---- 5 files changed, 229 insertions(+), 223 deletions(-) create mode 100644 client/src/lit/play/editor.js create mode 100644 client/src/lit/play/editor.scss delete mode 100644 client/src/playground/editor.tsx diff --git a/client/src/lit/play/editor.js b/client/src/lit/play/editor.js new file mode 100644 index 000000000000..df0cc4f2b4a4 --- /dev/null +++ b/client/src/lit/play/editor.js @@ -0,0 +1,146 @@ +import { keymap, highlightActiveLine, lineNumbers } from "@codemirror/view"; +import { EditorState, StateEffect } from "@codemirror/state"; +import { indentOnInput, bracketMatching } from "@codemirror/language"; +import { defaultKeymap, indentWithTab } from "@codemirror/commands"; +import { + autocompletion, + completionKeymap, + closeBrackets, + closeBracketsKeymap, +} from "@codemirror/autocomplete"; +import { lintKeymap } from "@codemirror/lint"; +import { EditorView, minimalSetup } from "codemirror"; +import { javascript as langJS } from "@codemirror/lang-javascript"; +import { css as langCSS } from "@codemirror/lang-css"; +import { html as langHTML } from "@codemirror/lang-html"; +import { oneDark } from "@codemirror/theme-one-dark"; + +import { createComponent } from "@lit/react"; +import { html, LitElement } from "lit"; +import React from "react"; + +import styles from "./editor.scss?css" with { type: "css" }; + +/** @import { PropertyValues } from "lit" */ + +export class PlayEditor extends LitElement { + static properties = { + language: { type: String }, + colorScheme: { attribute: false }, + value: { attribute: false }, + }; + + static styles = styles; + + /** @type {EditorView | undefined} */ + _editor; + + /** @type {number} */ + _updateTimer = -1; + + constructor() { + super(); + this.language = ""; + this.colorScheme = "os-default"; + } + + /** @param {string} value */ + set value(value) { + let state = EditorState.create({ + doc: value, + extensions: this._extensions(), + }); + this._editor?.setState(state); + } + + get value() { + return this._editor?.state.doc.toString() || ""; + } + + _extensions() { + const language = (() => { + switch (this.language) { + case "javascript": + return [langJS()]; + case "html": + return [langHTML()]; + case "css": + return [langCSS()]; + default: + return []; + } + })(); + return [ + minimalSetup, + lineNumbers(), + indentOnInput(), + bracketMatching(), + closeBrackets(), + autocompletion(), + highlightActiveLine(), + keymap.of([ + ...closeBracketsKeymap, + ...defaultKeymap, + ...completionKeymap, + ...lintKeymap, + indentWithTab, + ]), + EditorView.lineWrapping, + ...(this.colorScheme === "dark" ? [oneDark] : []), + ...language, + EditorView.updateListener.of((update) => { + if (update.docChanged) { + if (this._updateTimer !== -1) { + clearTimeout(this._updateTimer); + } + this._updateTimer = window?.setTimeout(() => { + this._updateTimer = -1; + this.dispatchEvent( + new Event("update", { bubbles: false, composed: true }) + ); + }, 1000); + } + }), + ]; + } + + /** @param {PropertyValues} changedProperties */ + willUpdate(changedProperties) { + if ( + changedProperties.has("colorScheme") || + changedProperties.has("language") + ) { + this._editor?.dispatch({ + effects: StateEffect.reconfigure.of(this._extensions()), + }); + } + } + + render() { + return html`
+ ${this.language.toUpperCase()} +
+
`; + } + + firstUpdated() { + let startState = EditorState.create({ + extensions: this._extensions(), + }); + this._editor = new EditorView({ + state: startState, + parent: this.renderRoot.querySelector("div") || undefined, + }); + } +} + +customElements.define("play-editor", PlayEditor); + +export const ReactPlayEditor = createComponent({ + tagName: "play-editor", + elementClass: PlayEditor, + react: React, + events: { + onUpdate: "update", + }, +}); diff --git a/client/src/lit/play/editor.scss b/client/src/lit/play/editor.scss new file mode 100644 index 000000000000..9bfd312dba83 --- /dev/null +++ b/client/src/lit/play/editor.scss @@ -0,0 +1,53 @@ +@use "../ui/vars" as *; + +:host { + display: contents; +} + +.container { + --editor-header-height: 2.25rem; + --editor-header-padding: 0.25rem; + --editor-header-border-width: 1px; + + background-color: var(--background-secondary); + border: var(--editor-header-border-width) solid var(--border-primary); + height: 0; + min-height: var(--editor-header-height); + width: 100%; + + /* stylelint-disable-next-line selector-pseudo-element-no-unknown */ + &::details-content { + display: contents; + } + + &[open] { + height: 100%; + } + + &:not(:focus-within) summary { + color: var(--text-inactive); + } + + summary { + cursor: pointer; + padding: var(--editor-header-padding); + } + + .editor { + height: calc( + 100% - var(--editor-header-height) - 2 * + var(--editor-header-padding) - var(--editor-header-border-width) + ); + margin: 0.5rem 0 0; + overflow-y: scroll; + + .cm-editor { + min-height: 100%; + width: 100%; + + @media (max-width: $screen-sm) { + font-size: 1rem; + } + } + } +} diff --git a/client/src/playground/editor.tsx b/client/src/playground/editor.tsx deleted file mode 100644 index 78c28a53ac31..000000000000 --- a/client/src/playground/editor.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { useRef, useEffect, forwardRef, useImperativeHandle } from "react"; -import { keymap, highlightActiveLine, lineNumbers } from "@codemirror/view"; -import { EditorState, StateEffect } from "@codemirror/state"; -import { indentOnInput, bracketMatching } from "@codemirror/language"; -import { defaultKeymap, indentWithTab } from "@codemirror/commands"; -import { - autocompletion, - completionKeymap, - closeBrackets, - closeBracketsKeymap, -} from "@codemirror/autocomplete"; -import { lintKeymap } from "@codemirror/lint"; -import { EditorView, minimalSetup } from "codemirror"; -import { javascript } from "@codemirror/lang-javascript"; -import { css } from "@codemirror/lang-css"; -import { html } from "@codemirror/lang-html"; -import { oneDark } from "@codemirror/theme-one-dark"; -import { useUIStatus } from "../ui-context"; - -// @ts-ignore -// eslint-disable-next-line no-restricted-globals -self.MonacoEnvironment = { - getWorkerUrl: function (_moduleId: any, label: string) { - if (label === "json") { - return "./json.worker.js"; - } - if (label === "css" || label === "scss" || label === "less") { - return "./css.worker.js"; - } - if (label === "html" || label === "handlebars" || label === "razor") { - return "./html.worker.js"; - } - if (label === "typescript" || label === "javascript") { - return "./ts.worker.js"; - } - return "./editor.worker.js"; - }, -}; - -function lang(language: string) { - switch (language) { - case "javascript": - return [javascript()]; - case "html": - return [html()]; - case "css": - return [css()]; - default: - return []; - } -} - -export interface EditorHandle { - getContent(): string | undefined; - setContent(content: string): void; -} - -function cmExtensions(colorScheme: string, language: string) { - return [ - minimalSetup, - lineNumbers(), - indentOnInput(), - bracketMatching(), - closeBrackets(), - autocompletion(), - highlightActiveLine(), - keymap.of([ - ...closeBracketsKeymap, - ...defaultKeymap, - ...completionKeymap, - ...lintKeymap, - indentWithTab, - ]), - EditorView.lineWrapping, - ...(colorScheme === "dark" ? [oneDark] : []), - ...lang(language), - ]; -} - -const Editor = forwardRef< - EditorHandle, - { language: string; callback: () => void } ->(function EditorInner( - { - language, - callback = () => {}, - }: { - language: string; - callback: () => void; - }, - ref -) { - const { colorScheme } = useUIStatus(); - const timer = useRef(null); - const divEl = useRef(null); - let editor = useRef(null); - const updateListenerExtension = useRef( - EditorView.updateListener.of((update) => { - if (update.docChanged) { - if (timer.current !== null && timer.current !== -1) { - clearTimeout(timer.current); - } - timer.current = window?.setTimeout(() => { - timer.current = -1; - callback(); - }, 1000); - } - }) - ); - useEffect(() => { - const extensions = [ - ...cmExtensions(colorScheme, language), - updateListenerExtension.current, - ]; - if (divEl.current && editor.current === null) { - let startState = EditorState.create({ - extensions, - }); - editor.current = new EditorView({ - state: startState, - parent: divEl.current, - }); - } else { - editor.current?.dispatch({ - effects: StateEffect.reconfigure.of(extensions), - }); - } - return () => {}; - }, [language, colorScheme]); - - useImperativeHandle(ref, () => { - return { - getContent() { - return editor.current?.state.doc.toString(); - }, - setContent(content: string) { - let state = EditorState.create({ - doc: content, - extensions: [ - ...cmExtensions(colorScheme, language), - updateListenerExtension.current, - ], - }); - editor.current?.setState(state); - }, - }; - }, [language, colorScheme]); - return ( -
- {language.toUpperCase()} -
-
- ); -}); - -export default Editor; diff --git a/client/src/playground/index.scss b/client/src/playground/index.scss index a90fe32a5381..74d6fa1a697d 100644 --- a/client/src/playground/index.scss +++ b/client/src/playground/index.scss @@ -163,54 +163,6 @@ main.play { } } } - - details.editor-container { - --editor-header-height: 2.25rem; - --editor-header-padding: 0.25rem; - --editor-header-border-width: 1px; - - background-color: var(--background-secondary); - border: var(--editor-header-border-width) solid var(--border-primary); - height: 0; - min-height: var(--editor-header-height); - width: 100%; - - /* stylelint-disable-next-line selector-pseudo-element-no-unknown */ - &::details-content { - display: contents; - } - - &[open] { - height: 100%; - } - - &:not(:focus-within) summary { - color: var(--text-inactive); - } - - summary { - cursor: pointer; - padding: var(--editor-header-padding); - } - - .editor { - height: calc( - 100% - var(--editor-header-height) - 2 * - var(--editor-header-padding) - var(--editor-header-border-width) - ); - margin: 0.5rem 0 0; - overflow-y: scroll; - - .cm-editor { - min-height: 100%; - width: 100%; - - @media (max-width: $screen-sm) { - font-size: 1rem; - } - } - } - } } &.preview { diff --git a/client/src/playground/index.tsx b/client/src/playground/index.tsx index 9498c5be4de3..a36b8d357af8 100644 --- a/client/src/playground/index.tsx +++ b/client/src/playground/index.tsx @@ -9,17 +9,18 @@ import prettierPluginESTree from "prettier/plugins/estree.mjs"; import prettierPluginHTML from "prettier/plugins/html"; import { Button } from "../ui/atoms/button"; -import Editor, { EditorHandle } from "./editor"; import { SidePlacement } from "../ui/organisms/placement"; import { decompressFromBase64, EditorContent, SESSION_KEY } from "./utils"; import "./index.scss"; import { FlagForm, ShareForm } from "./forms"; +import { ReactPlayEditor, PlayEditor } from "../lit/play/editor"; import { ReactPlayConsole } from "../lit/play/console"; import { ReactPlayRunner } from "../lit/play/runner"; import type { VConsole } from "../lit/play/types"; import { useGleanClick } from "../telemetry/glean-context"; import { PLAYGROUND } from "../telemetry/constants"; +import { useUIStatus } from "../ui-context"; const HTML_DEFAULT = ""; const CSS_DEFAULT = ""; @@ -66,6 +67,7 @@ function load(session: string) { } export default function Playground() { + const { colorScheme } = useUIStatus(); const gleanClick = useGleanClick(); let [searchParams, setSearchParams] = useSearchParams(); const gistId = searchParams.get("id"); @@ -109,9 +111,9 @@ export default function Playground() { undefined, } ); - const htmlRef = useRef(null); - const cssRef = useRef(null); - const jsRef = useRef(null); + const htmlRef = useRef(null); + const cssRef = useRef(null); + const jsRef = useRef(null); const diaRef = useRef(null); useEffect(() => { @@ -129,9 +131,9 @@ export default function Playground() { const getEditorContent = useCallback(() => { const code = { - html: htmlRef.current?.getContent() || HTML_DEFAULT, - css: cssRef.current?.getContent() || CSS_DEFAULT, - js: jsRef.current?.getContent() || JS_DEFAULT, + html: htmlRef.current?.value || HTML_DEFAULT, + css: cssRef.current?.value || CSS_DEFAULT, + js: jsRef.current?.value || JS_DEFAULT, src: initialCode?.src || initialContent?.src, }; store(SESSION_KEY, code); @@ -143,9 +145,15 @@ export default function Playground() { }; const setEditorContent = ({ html, css, js, src }: EditorContent) => { - htmlRef.current?.setContent(html); - cssRef.current?.setContent(css); - jsRef.current?.setContent(js); + if (htmlRef.current) { + htmlRef.current.value = html; + } + if (cssRef.current) { + cssRef.current.value = css; + } + if (jsRef.current) { + jsRef.current.value = js; + } if (src) { setCodeSrc(src); } @@ -331,21 +339,24 @@ export default function Playground() { )} - - + - + + colorScheme={colorScheme} + onUpdate={updateWithEditorContent} + >
{gistId && ( From 44166f0a448565073192553327b748b712c1b612 Mon Sep 17 00:00:00 2001 From: Leo McArdle Date: Wed, 11 Dec 2024 12:49:35 +0000 Subject: [PATCH 03/21] fix(playground): editor was losing state if value set before component was ready this was noticeable every so often when opening a live sample in the playground --- client/src/lit/play/editor.js | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/client/src/lit/play/editor.js b/client/src/lit/play/editor.js index df0cc4f2b4a4..dc073b9d68d5 100644 --- a/client/src/lit/play/editor.js +++ b/client/src/lit/play/editor.js @@ -42,19 +42,23 @@ export class PlayEditor extends LitElement { super(); this.language = ""; this.colorScheme = "os-default"; + this._value = ""; } /** @param {string} value */ set value(value) { - let state = EditorState.create({ - doc: value, - extensions: this._extensions(), - }); - this._editor?.setState(state); + this._value = value; + if (this._editor) { + let state = EditorState.create({ + doc: value, + extensions: this._extensions(), + }); + this._editor.setState(state); + } } get value() { - return this._editor?.state.doc.toString() || ""; + return this._editor ? this._editor.state.doc.toString() : this._value; } _extensions() { @@ -125,6 +129,7 @@ export class PlayEditor extends LitElement { firstUpdated() { let startState = EditorState.create({ + doc: this._value, extensions: this._extensions(), }); this._editor = new EditorView({ From 36ef93827a896fd8d895d840c2f72b4914ef7da5 Mon Sep 17 00:00:00 2001 From: Leo McArdle Date: Wed, 11 Dec 2024 13:16:37 +0000 Subject: [PATCH 04/21] chore(playground): migrate formatting to editor component --- client/src/lit/play/editor.js | 42 +++++++++++++++++++++++++++++++++ client/src/playground/index.tsx | 35 +++++---------------------- 2 files changed, 48 insertions(+), 29 deletions(-) diff --git a/client/src/lit/play/editor.js b/client/src/lit/play/editor.js index dc073b9d68d5..4047b6ef8e1c 100644 --- a/client/src/lit/play/editor.js +++ b/client/src/lit/play/editor.js @@ -108,6 +108,48 @@ export class PlayEditor extends LitElement { ]; } + async format() { + const prettier = await import("prettier/standalone"); + const config = (() => { + switch (this.language) { + case "javascript": + return { + parser: "babel", + plugins: [ + import("prettier/plugins/babel"), + // XXX Using .mjs until https://github.com/prettier/prettier/pull/15018 is deployed + import("prettier/plugins/estree.mjs"), + ], + }; + case "html": + return { + parser: "html", + plugins: [ + import("prettier/plugins/html"), + import("prettier/plugins/postcss"), + import("prettier/plugins/babel"), + // XXX Using .mjs until https://github.com/prettier/prettier/pull/15018 is deployed + import("prettier/plugins/estree.mjs"), + ], + }; + case "css": + return { + parser: "css", + plugins: [import("prettier/plugins/postcss")], + }; + default: + return undefined; + } + })(); + if (config) { + const plugins = await Promise.all(config.plugins); + this.value = await prettier.format(this.value, { + parser: config.parser, + plugins, + }); + } + } + /** @param {PropertyValues} changedProperties */ willUpdate(changedProperties) { if ( diff --git a/client/src/playground/index.tsx b/client/src/playground/index.tsx index a36b8d357af8..5f2cc322de28 100644 --- a/client/src/playground/index.tsx +++ b/client/src/playground/index.tsx @@ -1,13 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useSearchParams } from "react-router-dom"; import useSWRImmutable from "swr/immutable"; -import prettier from "prettier/standalone"; -import prettierPluginBabel from "prettier/plugins/babel"; -import prettierPluginCSS from "prettier/plugins/postcss"; -// XXX Using .mjs until https://github.com/prettier/prettier/pull/15018 is deployed -import prettierPluginESTree from "prettier/plugins/estree.mjs"; -import prettierPluginHTML from "prettier/plugins/html"; - import { Button } from "../ui/atoms/button"; import { SidePlacement } from "../ui/organisms/placement"; import { decompressFromBase64, EditorContent, SESSION_KEY } from "./utils"; @@ -243,33 +236,17 @@ export default function Playground() { }; const format = async () => { - const { html, css, js } = getEditorContent(); - try { - const formatted = { - html: await prettier.format(html, { - parser: "html", - plugins: [ - prettierPluginHTML, - prettierPluginCSS, - prettierPluginBabel, - prettierPluginESTree, - ], - }), - css: await prettier.format(css, { - parser: "css", - plugins: [prettierPluginCSS], - }), - js: await prettier.format(js, { - parser: "babel", - plugins: [prettierPluginBabel, prettierPluginESTree], - }), - }; - setEditorContent(formatted); + await Promise.all([ + htmlRef.current?.format(), + cssRef.current?.format(), + jsRef.current?.format(), + ]); } catch (e) { console.error(e); } }; + const share = useCallback(async () => { const { url, id } = await save(getEditorContent()); setSearchParams([["id", id]], { replace: true }); From ba2f73088130ad8a2f89db0e3cbc7177da3cbc1a Mon Sep 17 00:00:00 2001 From: Leo McArdle Date: Thu, 12 Dec 2024 12:34:36 +0000 Subject: [PATCH 05/21] fix(playground): slash key would open search box --- client/src/search-utils.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/client/src/search-utils.ts b/client/src/search-utils.ts index 441210e7467d..de4e19bb644a 100644 --- a/client/src/search-utils.ts +++ b/client/src/search-utils.ts @@ -14,15 +14,16 @@ export function useFocusViaKeyboard( useEffect(() => { function focusOnSearchMaybe(event: KeyboardEvent) { const input = inputRef.current; - const target = event.target as HTMLElement; + const target = event.composedPath()?.[0] || event.target; const keyPressed = event.key; const ctrlOrMetaPressed = event.ctrlKey || event.metaKey; const isSlash = keyPressed === "/" && !ctrlOrMetaPressed; const isCtrlK = keyPressed === "k" && ctrlOrMetaPressed && !event.shiftKey; const isTextField = - ["TEXTAREA", "INPUT"].includes(target.tagName) || - target.isContentEditable; + target instanceof HTMLElement && + (["TEXTAREA", "INPUT"].includes(target.tagName) || + target.isContentEditable); if ((isSlash || isCtrlK) && !isTextField) { if (input && document.activeElement !== input) { event.preventDefault(); From 10d99b6d18dc0d79bfe7bd027a05b1ce15bb300a Mon Sep 17 00:00:00 2001 From: Leo McArdle Date: Wed, 8 Jan 2025 17:59:18 +0000 Subject: [PATCH 06/21] feat: add custom element --- client/src/document/index.scss | 4 + client/src/document/index.tsx | 1 + client/src/lit/glean-mixin.js | 11 ++ client/src/lit/globals.d.ts | 12 ++ client/src/lit/interactive-example.js | 102 ++++++++++++++++ client/src/lit/interactive-example.scss | 85 +++++++++++++ client/src/lit/play/console-utils.js | 146 +++++++++++++++++++++++ client/src/lit/play/console.js | 67 ++++++++++- client/src/lit/play/console.scss | 19 +-- client/src/lit/play/controller.js | 134 +++++++++++++++++++++ client/src/lit/play/editor.js | 25 ++-- client/src/lit/play/editor.scss | 51 ++------ client/src/lit/play/runner.js | 47 +++----- client/src/lit/play/types.d.ts | 2 +- client/src/lit/theme-controller.js | 43 +++++++ client/src/playground/index.scss | 62 ++++++++++ client/src/playground/index.tsx | 152 ++++++++++-------------- client/src/setupProxy.js | 10 +- client/src/telemetry/glean-context.tsx | 3 + libs/play/index.js | 7 +- 20 files changed, 781 insertions(+), 202 deletions(-) create mode 100644 client/src/lit/glean-mixin.js create mode 100644 client/src/lit/interactive-example.js create mode 100644 client/src/lit/interactive-example.scss create mode 100644 client/src/lit/play/console-utils.js create mode 100644 client/src/lit/play/controller.js create mode 100644 client/src/lit/theme-controller.js diff --git a/client/src/document/index.scss b/client/src/document/index.scss index 59c8463036b5..7ddb8a7f3228 100644 --- a/client/src/document/index.scss +++ b/client/src/document/index.scss @@ -570,6 +570,10 @@ math[display="block"] { margin-bottom: 2rem; position: relative; + interactive-example ~ & { + display: none; + } + .example-header { align-items: baseline; background-color: var(--background-secondary); diff --git a/client/src/document/index.tsx b/client/src/document/index.tsx index c2d85be76075..bf7c71c50464 100644 --- a/client/src/document/index.tsx +++ b/client/src/document/index.tsx @@ -34,6 +34,7 @@ import "./index.scss"; // code could come with its own styling rather than it having to be part of the // main bundle all the time. import "./interactive-examples.scss"; +import "../lit/interactive-example.js"; import { DocumentSurvey } from "../ui/molecules/document-survey"; import { useIncrementFrequentlyViewed } from "../plus/collections/frequently-viewed"; import { useInteractiveExamplesActionHandler as useInteractiveExamplesTelemetry } from "../telemetry/interactive-examples"; diff --git a/client/src/lit/glean-mixin.js b/client/src/lit/glean-mixin.js new file mode 100644 index 000000000000..7b1dd78a176d --- /dev/null +++ b/client/src/lit/glean-mixin.js @@ -0,0 +1,11 @@ +/** + * @template {new (...args: any[]) => {}} TBase + * @param {TBase} Base + */ +export const GleanMixin = (Base) => + class extends Base { + /** @param {string} detail */ + _gleanClick(detail) { + window.dispatchEvent(new CustomEvent("glean-click", { detail })); + } + }; diff --git a/client/src/lit/globals.d.ts b/client/src/lit/globals.d.ts index 51d5a73c8baf..bd8d8892343c 100644 --- a/client/src/lit/globals.d.ts +++ b/client/src/lit/globals.d.ts @@ -1,14 +1,26 @@ import { MDNImageHistory, TeamMember } from "./about"; +import { InteractiveExample } from "./interactive-example"; import { ContributorList } from "./community/contributor-list"; import { ScrimInline } from "./curriculum/scrim-inline"; import { PlayConsole } from "./play/console"; +import { PlayController } from "./play/controller"; +import { PlayEditor } from "./play/editor"; +import { PlayRunner } from "./play/runner"; declare global { interface HTMLElementTagNameMap { "mdn-image-history": MDNImageHistory; "team-member": TeamMember; + "interactive-example": InteractiveExample; "contributor-list": ContributorList; "scrim-inline": ScrimInline; "play-console": PlayConsole; + "play-controller": PlayController; + "play-editor": PlayEditor; + "play-runner": PlayRunner; + } + + interface WindowEventMap { + "glean-click": CustomEvent; } } diff --git a/client/src/lit/interactive-example.js b/client/src/lit/interactive-example.js new file mode 100644 index 000000000000..ababc2137cb7 --- /dev/null +++ b/client/src/lit/interactive-example.js @@ -0,0 +1,102 @@ +import { html, LitElement } from "lit"; +import { ref, createRef } from "lit/directives/ref.js"; +import "./play/editor.js"; +import "./play/controller.js"; +import "./play/console.js"; +import "./play/runner.js"; +import { GleanMixin } from "./glean-mixin.js"; + +import styles from "./interactive-example.scss?css" with { type: "css" }; + +/** + * @import { Ref } from 'lit/directives/ref.js'; + * @import { PlayController } from "./play/controller.js"; + */ + +export class InteractiveExample extends GleanMixin(LitElement) { + static styles = styles; + + /** @type {Ref} */ + _controller = createRef(); + + _run() { + this._controller.value?.run(); + } + + _reset() { + this._controller.value?.reset(); + } + + _initialCode() { + const examples = + this.closest("section")?.querySelectorAll(".code-example pre"); + return Array.from(examples || []).reduce((acc, pre) => { + const language = pre.classList[1]; + return language && pre.textContent + ? { + ...acc, + [language]: acc[language] + ? `${acc[language]}\n${pre.textContent}` + : pre.textContent, + } + : acc; + }, /** @type {Object} */ ({})); + } + + /** @param {Event} ev */ + _telemetryHandler(ev) { + let action = ev.type; + if ( + ev.type === "click" && + ev.target instanceof HTMLElement && + ev.target.id + ) { + action = `click@${ev.target.id}`; + } + this._gleanClick(`interactive-examples-lit: ${action}`); + } + + connectedCallback() { + super.connectedCallback(); + this.addEventListener("focus", this._telemetryHandler); + this.addEventListener("copy", this._telemetryHandler); + this.addEventListener("cut", this._telemetryHandler); + this.addEventListener("paste", this._telemetryHandler); + this.addEventListener("click", this._telemetryHandler); + } + + render() { + return html` + +
+

JavaScript Demo:

+ +
+ + +
+ + +
+
+ `; + } + + firstUpdated() { + const code = this._initialCode(); + if (this._controller.value) { + this._controller.value.code = code; + } + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.removeEventListener("focus", this._telemetryHandler); + this.removeEventListener("copy", this._telemetryHandler); + this.removeEventListener("cut", this._telemetryHandler); + this.removeEventListener("paste", this._telemetryHandler); + this.removeEventListener("click", this._telemetryHandler); + } +} + +customElements.define("interactive-example", InteractiveExample); diff --git a/client/src/lit/interactive-example.scss b/client/src/lit/interactive-example.scss new file mode 100644 index 000000000000..787fb8e2e709 --- /dev/null +++ b/client/src/lit/interactive-example.scss @@ -0,0 +1,85 @@ +@use "../ui/vars" as *; +@use "../ui/atoms/button/mixins" as button; + +:host { + display: block; + margin: 1rem 0; +} + +:host([height="shorter"]) { + height: 433px; +} + +:host([height="taller"]) { + height: 725px; +} + +h4 { + border: 1px solid var(--border-secondary); + border-top-left-radius: var(--elem-radius); + border-top-right-radius: var(--elem-radius); + font-size: 1rem; + font-weight: normal; + grid-area: header; + line-height: 1.1876; + margin: 0; + padding: 0.5rem 1rem; +} + +play-editor { + border: 1px solid var(--border-secondary); + border-bottom-left-radius: var(--elem-radius); + border-bottom-right-radius: var(--elem-radius); + border-top: none; + grid-area: editor; + margin-top: -0.5rem; + overflow: auto; +} + +.buttons { + display: flex; + flex-direction: column; + gap: 0.5rem; + grid-area: buttons; + + button { + @include button.secondary; + } +} + +play-console { + border: 1px solid var(--border-secondary); + border-radius: var(--elem-radius); + grid-area: console; +} + +.template-javascript { + align-content: start; + display: grid; + gap: 0.5rem; + grid-template-areas: + "header header" + "editor editor" + "buttons console"; + grid-template-columns: max-content 1fr; + grid-template-rows: max-content 1fr; + height: 100%; + + play-runner { + display: none; + } + + @media (max-width: $screen-sm) { + grid-template-areas: + "header" + "editor" + "buttons" + "console"; + grid-template-columns: 1fr; + + .buttons { + flex-direction: row; + justify-content: space-between; + } + } +} diff --git a/client/src/lit/play/console-utils.js b/client/src/lit/play/console-utils.js new file mode 100644 index 000000000000..af96c9c88a28 --- /dev/null +++ b/client/src/lit/play/console-utils.js @@ -0,0 +1,146 @@ +// Copied from https://github.com/mdn/bob/blob/9da42cd641d7f2a9796bf3406e74cad411ce9438/editor/js/editor-libs/console-utils.ts +/** + * Formats arrays: + * - quotes around strings in arrays + * - square brackets around arrays + * - adds commas appropriately (with spacing) + * designed to be used recursively + * @param {any} input - The output to log. + * @returns Formatted output as a string. + */ +export function formatArray(input) { + let output = ""; + for (let i = 0, l = input.length; i < l; i++) { + if (typeof input[i] === "string") { + output += '"' + input[i] + '"'; + } else if (Array.isArray(input[i])) { + output += "Array ["; + output += formatArray(input[i]); + output += "]"; + } else { + output += formatOutput(input[i]); + } + + if (i < input.length - 1) { + output += ", "; + } + } + return output; +} + +/** + * Formats objects: + * ArrayBuffer, DataView, SharedArrayBuffer, + * Int8Array, Int16Array, Int32Array, + * Uint8Array, Uint16Array, Uint32Array, + * Uint8ClampedArray, Float32Array, Float64Array + * Symbol + * @param {any} input - The output to log. + * @returns Formatted output as a string. + */ +export function formatObject(input) { + const bufferDataViewRegExp = /^(ArrayBuffer|SharedArrayBuffer|DataView)$/; + const complexArrayRegExp = + /^(Int8Array|Int16Array|Int32Array|Uint8Array|Uint16Array|Uint32Array|Uint8ClampedArray|Float32Array|Float64Array|BigInt64Array|BigUint64Array)$/; + + const objectName = input.constructor ? input.constructor.name : input; + + if (objectName === "String") { + // String object + return `String { "${input.valueOf()}" }`; + } + + if (input === JSON) { + // console.log(JSON) is outputed as "JSON {}" in browser console + return `JSON {}`; + } + + if (objectName.match && objectName.match(bufferDataViewRegExp)) { + return objectName + " {}"; + } + + if (objectName.match && objectName.match(complexArrayRegExp)) { + const arrayLength = input.length; + + if (arrayLength > 0) { + return objectName + " [" + formatArray(input) + "]"; + } else { + return objectName + " []"; + } + } + + if (objectName === "Symbol" && input !== undefined) { + return input.toString(); + } + + if (objectName === "Object") { + let formattedChild = ""; + let start = true; + for (const key in input) { + if (Object.prototype.hasOwnProperty.call(input, key)) { + if (start) { + start = false; + } else { + formattedChild = formattedChild + ", "; + } + formattedChild = formattedChild + key + ": " + formatOutput(input[key]); + } + } + return objectName + " { " + formattedChild + " }"; + } + + // Special object created with `OrdinaryObjectCreate(null)` returned by, for + // example, named capture groups in https://mzl.la/2RERfQL + // @see https://github.com/mdn/bob/issues/574#issuecomment-858213621 + if (!input.constructor && !input.prototype) { + let formattedChild = ""; + let start = true; + for (const key in input) { + if (start) { + start = false; + } else { + formattedChild = formattedChild + ", "; + } + formattedChild = formattedChild + key + ": " + formatOutput(input[key]); + } + return "Object { " + formattedChild + " }"; + } + + return input; +} + +/** + * Formats output to indicate its type: + * - quotes around strings + * - single quotes around strings containing double quotes + * - square brackets around arrays + * (also copes with arrays of arrays) + * does NOT detect Int32Array etc + * @param {any} input - The output to log. + * @returns Formatted output as a string. + */ +export function formatOutput(input) { + if (input === undefined || input === null || typeof input === "boolean") { + return String(input); + } else if (typeof input === "number") { + // Negative zero + if (Object.is(input, -0)) { + return "-0"; + } + return String(input); + } else if (typeof input === "bigint") { + return String(input) + "n"; + } else if (typeof input === "string") { + // string literal + if (input.includes('"')) { + return "'" + input + "'"; + } else { + return '"' + input + '"'; + } + } else if (Array.isArray(input)) { + // check the contents of the array + return "Array [" + formatArray(input) + "]"; + } else { + return formatObject(input); + } +} diff --git a/client/src/lit/play/console.js b/client/src/lit/play/console.js index e087a7c6b7b3..fd5b1ef79957 100644 --- a/client/src/lit/play/console.js +++ b/client/src/lit/play/console.js @@ -1,29 +1,84 @@ import { createComponent } from "@lit/react"; import { html, LitElement } from "lit"; import React from "react"; +import { formatOutput } from "./console-utils.js"; import styles from "./console.scss?css" with { type: "css" }; -/** @import { VConsole } from "./types" */ +/** @import { VConsole } from "./types.d.ts" */ + +/** @implements {Partial} */ +class VirtualConsole { + #host; + + /** @param {PlayConsole} host */ + constructor(host) { + this.#host = host; + } + + clear() { + this.#host._messages = []; + } + + /** @param {...any} args */ + debug(...args) { + return this.log(...args); + } + + /** @param {...any} args */ + error(...args) { + return this.log(...args); + } + + /** @param {...any} args */ + info(...args) { + return this.log(...args); + } + + /** @param {...any} args */ + log(...args) { + // TODO: support string substitutions: + // https://developer.mozilla.org/en-US/docs/Web/API/console#using_string_substitutions + const formatted = args.map((x) => formatOutput(x)); + this.#host._messages = [...this.#host._messages, formatted.join(" ")]; + } + + /** @param {...any} args */ + warn(...args) { + return this.log(...args); + } +} export class PlayConsole extends LitElement { static properties = { - vConsole: { attribute: false }, + _messages: { state: true }, }; static styles = styles; constructor() { super(); - /** @type {VConsole[]} */ - this.vConsole = []; + this.vconsole = new VirtualConsole(this); + /** @type {string[]} */ + this._messages = []; + } + + /** @param {CustomEvent} e */ + onConsole({ detail }) { + if (detail.prop in this.vconsole) { + const prop = /** @type keyof typeof this.vconsole */ (detail.prop); + detail.args ? this.vconsole[prop](...detail.args) : this.vconsole[prop](); + } else { + this.vconsole.warn( + "[Playground] Unsupported console message (see browser console)" + ); + } } render() { return html` - Console
    - ${this.vConsole.map(({ message }) => { + ${this._messages.map((message) => { return html`
  • ${message} diff --git a/client/src/lit/play/console.scss b/client/src/lit/play/console.scss index 42f969410d11..19827089b0b8 100644 --- a/client/src/lit/play/console.scss +++ b/client/src/lit/play/console.scss @@ -1,31 +1,24 @@ :host { + background-color: var(--code-background-inline); + box-sizing: border-box; display: flex; flex-direction: column; font-size: var(--type-smaller-font-size); + height: 6rem; margin: 0; - width: 100%; -} - -.header { - background-color: var(--code-background-inline); - font-weight: 600; - text-align: center; + max-height: 6rem; + overflow: auto; width: 100%; } ul { - background-color: var(--code-background-inline); - height: 6rem; list-style: none; margin: 0; - max-height: 6rem; - overflow: auto; padding: 0; - width: 100%; } li { - padding: 0 1rem; + padding: 0 0.5em; &::before { content: ">"; diff --git a/client/src/lit/play/controller.js b/client/src/lit/play/controller.js new file mode 100644 index 000000000000..44413e7f9688 --- /dev/null +++ b/client/src/lit/play/controller.js @@ -0,0 +1,134 @@ +import { css, html, LitElement } from "lit"; +import { createComponent } from "@lit/react"; +import React from "react"; + +/** @import { VConsole } from "./types.d.ts" */ + +export class PlayController extends LitElement { + static properties = { + runOnStart: { type: Boolean, attribute: "run-on-start" }, + runOnChange: { type: Boolean, attribute: "run-on-change" }, + srcPrefix: { attribute: false }, + }; + + static styles = css` + :host { + display: contents; + } + `; + + constructor() { + super(); + this.runOnStart = false; + this.runOnChange = false; + this.srcPrefix = ""; + } + + /** @param {Record} code */ + set code(code) { + if (!this.initialCode) { + this.initialCode = code; + } + const editors = this.querySelectorAll("play-editor"); + editors.forEach((editor) => { + const language = this._langAlias(editor.language); + if (language) { + const value = code[language]; + if (value !== undefined) { + editor.value = value; + } + } + }); + if (this.runOnStart) { + this.run(); + } + } + + get code() { + /** @type {Record} */ + const code = { ...this.initialCode }; + const editors = this.querySelectorAll("play-editor"); + editors.forEach((editor) => { + const language = this._langAlias(editor.language); + if (language) { + code[language] = editor.value; + } + }); + return code; + } + + async format() { + try { + await Promise.all( + Array.from(this.querySelectorAll("play-editor")).map((e) => e.format()) + ); + } catch (e) { + console.error(e); + } + } + + run() { + this.querySelector("play-console")?.vconsole.clear(); + const runner = this.querySelector("play-runner"); + if (runner) { + runner.srcPrefix = this.srcPrefix; + runner.code = this.code; + } + } + + reset() { + if (this.initialCode) { + this.code = this.initialCode; + } + if (this.runOnStart) { + this.run(); + } else { + this.querySelector("play-console")?.vconsole.clear(); + const runner = this.querySelector("play-runner"); + if (runner) { + runner.code = undefined; + } + } + } + + /** + * @param {string} lang + */ + _langAlias(lang) { + switch (lang) { + case "javascript": + return "js"; + default: + return lang; + } + } + + _onEditorUpdate() { + if (this.runOnChange) { + this.run(); + } + } + + /** @param {CustomEvent} ev */ + _onConsole(ev) { + this.querySelector("play-console")?.onConsole(ev); + } + + connectedCallback() { + super.connectedCallback(); + } + + render() { + return html` + + `; + } +} + +customElements.define("play-controller", PlayController); + +export const ReactPlayController = createComponent({ + tagName: "play-controller", + elementClass: PlayController, + react: React, +}); diff --git a/client/src/lit/play/editor.js b/client/src/lit/play/editor.js index 4047b6ef8e1c..ffc9a2ea65dd 100644 --- a/client/src/lit/play/editor.js +++ b/client/src/lit/play/editor.js @@ -18,6 +18,7 @@ import { oneDark } from "@codemirror/theme-one-dark"; import { createComponent } from "@lit/react"; import { html, LitElement } from "lit"; import React from "react"; +import { ThemeController } from "../theme-controller.js"; import styles from "./editor.scss?css" with { type: "css" }; @@ -26,7 +27,6 @@ import styles from "./editor.scss?css" with { type: "css" }; export class PlayEditor extends LitElement { static properties = { language: { type: String }, - colorScheme: { attribute: false }, value: { attribute: false }, }; @@ -40,8 +40,8 @@ export class PlayEditor extends LitElement { constructor() { super(); + this.theme = new ThemeController(this); this.language = ""; - this.colorScheme = "os-default"; this._value = ""; } @@ -90,7 +90,7 @@ export class PlayEditor extends LitElement { indentWithTab, ]), EditorView.lineWrapping, - ...(this.colorScheme === "dark" ? [oneDark] : []), + ...(this.theme.value === "dark" ? [oneDark] : []), ...language, EditorView.updateListener.of((update) => { if (update.docChanged) { @@ -100,7 +100,7 @@ export class PlayEditor extends LitElement { this._updateTimer = window?.setTimeout(() => { this._updateTimer = -1; this.dispatchEvent( - new Event("update", { bubbles: false, composed: true }) + new Event("update", { bubbles: true, composed: true }) ); }, 1000); } @@ -117,8 +117,7 @@ export class PlayEditor extends LitElement { parser: "babel", plugins: [ import("prettier/plugins/babel"), - // XXX Using .mjs until https://github.com/prettier/prettier/pull/15018 is deployed - import("prettier/plugins/estree.mjs"), + import("prettier/plugins/estree"), ], }; case "html": @@ -128,8 +127,7 @@ export class PlayEditor extends LitElement { import("prettier/plugins/html"), import("prettier/plugins/postcss"), import("prettier/plugins/babel"), - // XXX Using .mjs until https://github.com/prettier/prettier/pull/15018 is deployed - import("prettier/plugins/estree.mjs"), + import("prettier/plugins/estree"), ], }; case "css": @@ -145,7 +143,7 @@ export class PlayEditor extends LitElement { const plugins = await Promise.all(config.plugins); this.value = await prettier.format(this.value, { parser: config.parser, - plugins, + plugins: /** @type {import("prettier").Plugin[]} */ (plugins), }); } } @@ -153,8 +151,8 @@ export class PlayEditor extends LitElement { /** @param {PropertyValues} changedProperties */ willUpdate(changedProperties) { if ( - changedProperties.has("colorScheme") || - changedProperties.has("language") + changedProperties.has("language") || + changedProperties.has("ThemeController.value") ) { this._editor?.dispatch({ effects: StateEffect.reconfigure.of(this._extensions()), @@ -163,10 +161,7 @@ export class PlayEditor extends LitElement { } render() { - return html`
    - ${this.language.toUpperCase()} -
    -
    `; + return html`
    `; } firstUpdated() { diff --git a/client/src/lit/play/editor.scss b/client/src/lit/play/editor.scss index 9bfd312dba83..60466cd2dd72 100644 --- a/client/src/lit/play/editor.scss +++ b/client/src/lit/play/editor.scss @@ -1,53 +1,18 @@ -@use "../ui/vars" as *; +@use "../../ui/vars" as *; :host { - display: contents; + display: block; } -.container { - --editor-header-height: 2.25rem; - --editor-header-padding: 0.25rem; - --editor-header-border-width: 1px; +.editor { + height: 100%; - background-color: var(--background-secondary); - border: var(--editor-header-border-width) solid var(--border-primary); - height: 0; - min-height: var(--editor-header-height); - width: 100%; - - /* stylelint-disable-next-line selector-pseudo-element-no-unknown */ - &::details-content { - display: contents; - } - - &[open] { + .cm-editor { height: 100%; - } - - &:not(:focus-within) summary { - color: var(--text-inactive); - } - - summary { - cursor: pointer; - padding: var(--editor-header-padding); - } - - .editor { - height: calc( - 100% - var(--editor-header-height) - 2 * - var(--editor-header-padding) - var(--editor-header-border-width) - ); - margin: 0.5rem 0 0; - overflow-y: scroll; - - .cm-editor { - min-height: 100%; - width: 100%; + width: 100%; - @media (max-width: $screen-sm) { - font-size: 1rem; - } + @media (max-width: $screen-sm) { + font-size: 1rem; } } } diff --git a/client/src/lit/play/runner.js b/client/src/lit/play/runner.js index a4154e691917..f38730c88032 100644 --- a/client/src/lit/play/runner.js +++ b/client/src/lit/play/runner.js @@ -7,11 +7,8 @@ import React from "react"; import styles from "./runner.scss?css" with { type: "css" }; -/** - * @import { EditorContent } from "../../playground/utils" - * @import { VConsole } from "./types" - * @import { EventName } from "@lit/react" - * */ +/** @import { VConsole } from "./types" */ +/** @import { EventName } from "@lit/react" */ export class PlayRunner extends LitElement { static properties = { @@ -24,7 +21,7 @@ export class PlayRunner extends LitElement { constructor() { super(); - /** @type {EditorContent | undefined} */ + /** @type {Record | undefined} */ this.code = undefined; /** @type {string | undefined} */ this.srcPrefix = undefined; @@ -35,29 +32,13 @@ export class PlayRunner extends LitElement { } /** @param {MessageEvent} e */ - _onMessage({ data: { typ, prop, message } }) { + _onMessage({ data: { typ, prop, args } }) { if (typ === "console") { - if ( - (prop === "log" || prop === "error" || prop === "warn") && - typeof message === "string" - ) { - /** @type {VConsole} */ - const detail = { prop, message }; - this.dispatchEvent( - new CustomEvent("console", { bubbles: true, composed: true, detail }) - ); - } else { - const warning = "[Playground] Unsupported console message"; - /** @type {VConsole} */ - const detail = { - prop: "warn", - message: `${warning} (see browser console)`, - }; - this.dispatchEvent( - new CustomEvent("console", { bubbles: true, composed: true, detail }) - ); - console.warn(warning, { prop, message }); - } + /** @type {VConsole} */ + const detail = { prop, args }; + this.dispatchEvent( + new CustomEvent("console", { bubbles: true, composed: true, detail }) + ); } } @@ -65,7 +46,13 @@ export class PlayRunner extends LitElement { args: () => /** @type {const} */ ([this.code, this.srcPrefix]), task: async ([code, srcPrefix], { signal }) => { if (code) { - const { state } = await compressAndBase64Encode(JSON.stringify(code)); + const { state } = await compressAndBase64Encode( + JSON.stringify({ + html: code.html || "", + css: code.css || "", + js: code.js || "", + }) + ); signal.throwIfAborted(); // We're using a random subdomain for origin isolation. const url = new URL( @@ -80,7 +67,7 @@ export class PlayRunner extends LitElement { url.searchParams.set("state", state); // ensure iframe reloads even if code doesn't change url.searchParams.set("f", this._flipFlop.toString()); - url.pathname = `${srcPrefix || code.src || ""}/runner.html`; + url.pathname = `${srcPrefix || ""}/runner.html`; this._src = url.href; this._flipFlop = (this._flipFlop + 1) % 2; } else { diff --git a/client/src/lit/play/types.d.ts b/client/src/lit/play/types.d.ts index 14cb634d04f2..96470cb58045 100644 --- a/client/src/lit/play/types.d.ts +++ b/client/src/lit/play/types.d.ts @@ -1,4 +1,4 @@ export interface VConsole { prop: string; - message: string; + args: any[]; } diff --git a/client/src/lit/theme-controller.js b/client/src/lit/theme-controller.js new file mode 100644 index 000000000000..15041c27be60 --- /dev/null +++ b/client/src/lit/theme-controller.js @@ -0,0 +1,43 @@ +/** + * @import { LitElement } from "lit"; + * @import { Theme } from "../types/theme"; + */ + +/** + * Requests a Lit update when the theme changes, + * with a "ThemeController.value" changed property in `willUpdate`. + * Current theme can be accessed through `.value`. + */ +export class ThemeController { + #host; + + /** @param {LitElement} host */ + constructor(host) { + this.#host = host; + this.#host.addController(this); + /** @type {Theme} */ + this.value = "os-default"; + this._observer = new MutationObserver(() => this.#updateTheme()); + } + + #updateTheme() { + /** @type {Theme[]} */ + const themes = ["os-default", "dark", "light"]; + const { classList } = document.documentElement; + const oldValue = this.value; + this.value = themes.find((x) => classList.contains(x)) || "os-default"; + this.#host.requestUpdate("ThemeController.value", oldValue); + } + + hostConnected() { + this._observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + this.#updateTheme(); + } + + hostDisconnected() { + this._observer.disconnect(); + } +} diff --git a/client/src/playground/index.scss b/client/src/playground/index.scss index 74d6fa1a697d..9e2d1d4dcd1b 100644 --- a/client/src/playground/index.scss +++ b/client/src/playground/index.scss @@ -163,6 +163,45 @@ main.play { } } } + + details.editor-container { + --editor-header-height: 2.25rem; + --editor-header-padding: 0.25rem; + --editor-header-border-width: 1px; + + background-color: var(--background-secondary); + border: var(--editor-header-border-width) solid var(--border-primary); + height: 0; + min-height: var(--editor-header-height); + width: 100%; + + /* stylelint-disable-next-line selector-pseudo-element-no-unknown */ + &::details-content { + display: contents; + } + + &[open] { + height: 100%; + } + + &:not(:focus-within) summary { + color: var(--text-inactive); + } + + summary { + cursor: pointer; + padding: var(--editor-header-padding); + } + + play-editor { + height: calc( + 100% - var(--editor-header-height) - 2 * + var(--editor-header-padding) - var(--editor-header-border-width) + ); + margin: 0.5rem 0 0; + overflow-y: scroll; + } + } } &.preview { @@ -188,6 +227,29 @@ main.play { height: 100%; width: 100%; } + + #play-console { + display: flex; + flex-direction: column; + font-size: smaller; + margin: 0; + width: 100%; + + > span { + background-color: var(--code-background-inline); + font-weight: 600; + text-align: center; + width: 100%; + } + + play-console { + background-color: var(--code-background-inline); + height: 6rem; + max-height: 6rem; + overflow: auto; + width: 100%; + } + } } } } diff --git a/client/src/playground/index.tsx b/client/src/playground/index.tsx index 5f2cc322de28..1244a910cae6 100644 --- a/client/src/playground/index.tsx +++ b/client/src/playground/index.tsx @@ -7,13 +7,12 @@ import { decompressFromBase64, EditorContent, SESSION_KEY } from "./utils"; import "./index.scss"; import { FlagForm, ShareForm } from "./forms"; -import { ReactPlayEditor, PlayEditor } from "../lit/play/editor"; +import { PlayController, ReactPlayController } from "../lit/play/controller"; +import { ReactPlayEditor } from "../lit/play/editor"; import { ReactPlayConsole } from "../lit/play/console"; import { ReactPlayRunner } from "../lit/play/runner"; -import type { VConsole } from "../lit/play/types"; import { useGleanClick } from "../telemetry/glean-context"; import { PLAYGROUND } from "../telemetry/constants"; -import { useUIStatus } from "../ui-context"; const HTML_DEFAULT = ""; const CSS_DEFAULT = ""; @@ -60,7 +59,6 @@ function load(session: string) { } export default function Playground() { - const { colorScheme } = useUIStatus(); const gleanClick = useGleanClick(); let [searchParams, setSearchParams] = useSearchParams(); const gistId = searchParams.get("id"); @@ -68,10 +66,7 @@ export default function Playground() { let [dialogState, setDialogState] = useState(DialogState.none); let [shared, setShared] = useState(false); let [shareUrl, setShareUrl] = useState(null); - let [vConsole, setVConsole] = useState([]); let [state, setState] = useState(State.initial); - const [code, setCode] = useState(); - let [codeSrc, setCodeSrc] = useState(); const [isEmpty, setIsEmpty] = useState(true); const [initialContent, setInitialContent] = useState( null @@ -104,15 +99,9 @@ export default function Playground() { undefined, } ); - const htmlRef = useRef(null); - const cssRef = useRef(null); - const jsRef = useRef(null); + const controller = useRef(null); const diaRef = useRef(null); - useEffect(() => { - setVConsole([]); - }, [code, setVConsole]); - useEffect(() => { if (initialCode) { store(SESSION_KEY, initialCode); @@ -123,34 +112,21 @@ export default function Playground() { }, [initialCode, setInitialContent]); const getEditorContent = useCallback(() => { - const code = { - html: htmlRef.current?.value || HTML_DEFAULT, - css: cssRef.current?.value || CSS_DEFAULT, - js: jsRef.current?.value || JS_DEFAULT, + return { + html: controller.current?.code.html || HTML_DEFAULT, + css: controller.current?.code.css || CSS_DEFAULT, + js: controller.current?.code.js || JS_DEFAULT, src: initialCode?.src || initialContent?.src, }; - store(SESSION_KEY, code); - return code; }, [initialContent?.src, initialCode?.src]); - const onConsole = ({ detail }: CustomEvent) => { - setVConsole((vConsole) => [...vConsole, detail]); - }; - - const setEditorContent = ({ html, css, js, src }: EditorContent) => { - if (htmlRef.current) { - htmlRef.current.value = html; - } - if (cssRef.current) { - cssRef.current.value = css; - } - if (jsRef.current) { - jsRef.current.value = js; - } - if (src) { - setCodeSrc(src); + const setEditorContent = (content: EditorContent) => { + if (controller.current) { + controller.current.code = { ...content }; + if (content.src) { + controller.current.srcPrefix = content.src; + } } - setIsEmpty(!html && !css && !js); }; useEffect(() => { @@ -160,7 +136,7 @@ export default function Playground() { setEditorContent(initialCode); if (!gistId) { // don't auto run shared code - setCode(initialCode); + controller.current?.run(); } } else if (stateParam) { try { @@ -180,15 +156,20 @@ export default function Playground() { setState(State.ready); } })(); - }, [initialCode, state, gistId, stateParam, setCode]); + }, [initialCode, state, gistId, stateParam]); const clear = async () => { setSearchParams([], { replace: true }); - setCodeSrc(undefined); setInitialContent(null); - setEditorContent({ html: HTML_DEFAULT, css: CSS_DEFAULT, js: JS_DEFAULT }); + setIsEmpty(true); + setEditorContent({ + html: HTML_DEFAULT, + css: CSS_DEFAULT, + js: JS_DEFAULT, + src: undefined, + }); - updateWithEditorContent(); + run(); }; const reset = async () => { @@ -198,7 +179,7 @@ export default function Playground() { js: initialContent?.js || JS_DEFAULT, }); - updateWithEditorContent(); + run(); }; const clearConfirm = async () => { @@ -215,10 +196,7 @@ export default function Playground() { } }; - const updateWithEditorContent = () => { - const { html, css, js, src } = getEditorContent(); - setIsEmpty(!html && !css && !js); - + const run = () => { const loading = [ {}, { @@ -226,25 +204,16 @@ export default function Playground() { }, {}, ]; - const timing = { duration: 1000, iterations: 1, }; document.getElementById("run")?.firstElementChild?.animate(loading, timing); - setCode({ html, css, js, src }); + controller.current?.run(); }; const format = async () => { - try { - await Promise.all([ - htmlRef.current?.format(), - cssRef.current?.format(), - jsRef.current?.format(), - ]); - } catch (e) { - console.error(e); - } + await controller.current?.format(); }; const share = useCallback(async () => { @@ -260,8 +229,15 @@ export default function Playground() { } }; + const onEditorUpdate = () => { + const code = getEditorContent(); + const { html, css, js } = code; + setIsEmpty(!html && !css && !js); + store(SESSION_KEY, code); + }; + return ( - <> +
    {dialogState === DialogState.flag && } @@ -276,11 +252,7 @@ export default function Playground() { -
{gistId && ( @@ -349,15 +324,14 @@ export default function Playground() { Seeing something inappropriate? )} - - + +
+ Console + +
- + ); } diff --git a/client/src/setupProxy.js b/client/src/setupProxy.js index 4edaa6930fb6..6320de21ac2b 100644 --- a/client/src/setupProxy.js +++ b/client/src/setupProxy.js @@ -20,8 +20,16 @@ function config(app) { app.use(`**/*.(gif|jpeg|jpg|mp3|mp4|ogg|png|svg|webm|webp|woff2)`, proxy); // All those root-level images like /favicon-48x48.png app.use("/*.(png|webp|gif|jpe?g|svg)", proxy); + + const runnerProxy = createProxyMiddleware(["!**/*.hot-update.json"], { + target: `http://localhost:${SERVER_PORT}`, + changeOrigin: true, + onProxyRes: (proxyRes) => { + delete proxyRes.headers["clear-site-data"]; + }, + }); // Proxy play runner - app.use("**/runner.html", proxy); + app.use("**/runner.html", runnerProxy); } export default config; diff --git a/client/src/telemetry/glean-context.tsx b/client/src/telemetry/glean-context.tsx index caa8acb3b30e..2b94f36bbe80 100644 --- a/client/src/telemetry/glean-context.tsx +++ b/client/src/telemetry/glean-context.tsx @@ -158,6 +158,9 @@ function glean(): GleanAnalytics { handleButtonClick(ev, gleanClick); handleSidebarClick(ev, gleanClick); }); + window?.addEventListener("glean-click", (ev: CustomEvent) => { + gleanClick(ev.detail); + }); return gleanContext; } diff --git a/libs/play/index.js b/libs/play/index.js index df11deeb46f8..8b6c2f419113 100644 --- a/libs/play/index.js +++ b/libs/play/index.js @@ -236,13 +236,12 @@ export function renderHtml(state = null) {