diff --git a/client/package.json b/client/package.json index 9c8f2ac86ad8..9b4021e45a05 100644 --- a/client/package.json +++ b/client/package.json @@ -54,7 +54,7 @@ "node" ], "moduleNameMapper": { - "^.+\\.module\\.(css|sass|scss)$": "identity-obj-proxy" + "\\.s?css(\\?css)?$": "/config/jest/cssTransform.js" }, "modulePaths": [], "resetMocks": true, @@ -78,8 +78,7 @@ "^(?!.*\\.(js|jsx|mjs|cjs|ts|tsx|css|json)$)": "/config/jest/fileTransform.js" }, "transformIgnorePatterns": [ - "/node_modules/(?!@mozilla/glean/.*)/", - "^.+\\.module\\.(css|sass|scss)$" + "/node_modules/(?!@mozilla/glean/.*)/" ], "watchPlugins": [ "jest-watch-typeahead/filename", diff --git a/client/src/document/index.scss b/client/src/document/index.scss index ceb5bd5e80c6..6881382c2853 100644 --- a/client/src/document/index.scss +++ b/client/src/document/index.scss @@ -574,6 +574,10 @@ math[display="block"] { margin-bottom: 2rem; position: relative; + [class*="interactive-example"] { + display: none; + } + .example-header { align-items: baseline; background-color: var(--background-secondary); @@ -695,8 +699,7 @@ math[display="block"] { margin: 0; text-transform: capitalize; - &:hover, - &:focus { + &:hover { opacity: 0.6; } @@ -716,8 +719,7 @@ math[display="block"] { padding: 1px; text-transform: capitalize; - &:hover, - &:focus { + &:hover { opacity: 0.6; } } diff --git a/client/src/document/index.tsx b/client/src/document/index.tsx index c2d85be76075..b517d7fdbbe6 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.global.scss"; import { DocumentSurvey } from "../ui/molecules/document-survey"; import { useIncrementFrequentlyViewed } from "../plus/collections/frequently-viewed"; import { useInteractiveExamplesActionHandler as useInteractiveExamplesTelemetry } from "../telemetry/interactive-examples"; @@ -61,6 +62,10 @@ export class HTTPError extends Error { } export function Document(props /* TODO: define a TS interface for this */) { + React.useEffect(() => { + import("../lit/interactive-example.js"); + }, []); + const gleanClick = useGleanClick(); const isServer = useIsServer(); 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.global.scss b/client/src/lit/interactive-example.global.scss new file mode 100644 index 000000000000..65774531a36c --- /dev/null +++ b/client/src/lit/interactive-example.global.scss @@ -0,0 +1,13 @@ +interactive-example { + display: block; + height: 513px; + margin: 1rem 0; + + &[height="shorter"] { + height: 433px; + } + + &[height="taller"] { + height: 725px; + } +} diff --git a/client/src/lit/interactive-example.js b/client/src/lit/interactive-example.js new file mode 100644 index 000000000000..52222836ab62 --- /dev/null +++ b/client/src/lit/interactive-example.js @@ -0,0 +1,104 @@ +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[class*=interactive-example]" + ); + 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._telemetryHandler = this._telemetryHandler.bind(this); + this.renderRoot.addEventListener("focus", this._telemetryHandler); + this.renderRoot.addEventListener("copy", this._telemetryHandler); + this.renderRoot.addEventListener("cut", this._telemetryHandler); + this.renderRoot.addEventListener("paste", this._telemetryHandler); + this.renderRoot.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.renderRoot.removeEventListener("focus", this._telemetryHandler); + this.renderRoot.removeEventListener("copy", this._telemetryHandler); + this.renderRoot.removeEventListener("cut", this._telemetryHandler); + this.renderRoot.removeEventListener("paste", this._telemetryHandler); + this.renderRoot.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..0e98a6fc8b64 --- /dev/null +++ b/client/src/lit/interactive-example.scss @@ -0,0 +1,72 @@ +@use "../ui/vars" as *; +@use "../ui/atoms/button/mixins" as button; + +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..ac702b54acf6 100644 --- a/client/src/lit/play/console.js +++ b/client/src/lit/play/console.js @@ -1,29 +1,122 @@ 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) { + if (args.length > 1 && typeof args[0] === "string") { + // https://developer.mozilla.org/en-US/docs/Web/API/console#using_string_substitutions + // TODO: add unit testing of this + args[0] = args[0].replace( + /%(?:\.([0-9]+))?(.)/g, + (match, formatArg, format) => { + switch (format) { + case "o": + case "O": + const O = args.splice(1, 1)[0]; + return formatOutput(O); + case "d": + case "i": + const i = args.splice(1, 1)[0]; + return Math.trunc(i).toFixed(0).padStart(formatArg, "0"); + case "s": + const s = args.splice(1, 1)[0]; + return s.toString(); + case "f": + const f = args.splice(1, 1)[0]; + return (typeof f === "number" ? f : parseFloat(f)).toFixed( + formatArg ?? 6 + ); + case "c": + // TODO: Not implemented yet, so just remove the argument + args.splice(1, 1); + return ""; + case "%": + return "%"; + default: + return match; + } + } + ); + } + this.#host._messages = [ + ...this.#host._messages, + (args.every((x) => typeof x === "string") + ? args + : args.map((x) => formatOutput(x)) + ).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} @@ -35,8 +128,7 @@ export class PlayConsole extends LitElement { } updated() { - const output = this.renderRoot.querySelector("ul"); - output?.scrollTo({ top: output.scrollHeight }); + this.scrollTo({ top: this.scrollHeight }); } } diff --git a/client/src/lit/play/console.scss b/client/src/lit/play/console.scss index 42f969410d11..eecc6cf33735 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); + font-size: 0.875rem; + 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 new file mode 100644 index 000000000000..c7192d76d17f --- /dev/null +++ b/client/src/lit/play/editor.js @@ -0,0 +1,197 @@ +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 { ThemeController } from "../theme-controller.js"; + +import styles from "./editor.scss?css" with { type: "css" }; + +/** @import { PropertyValues } from "lit" */ + +export class PlayEditor extends LitElement { + static properties = { + language: { type: String }, + value: { attribute: false }, + }; + + static styles = styles; + + /** @type {EditorView | undefined} */ + _editor; + + /** @type {number} */ + _updateTimer = -1; + + constructor() { + super(); + this.theme = new ThemeController(this); + this.language = ""; + this._value = ""; + } + + /** @param {string} value */ + set value(value) { + this._value = value; + if (this._editor) { + let state = EditorState.create({ + doc: value, + extensions: this._extensions(), + }); + this._editor.setState(state); + } + } + + get value() { + return this._editor ? this._editor.state.doc.toString() : this._value; + } + + _dispatchUpdate() { + this.dispatchEvent(new Event("update", { bubbles: true, composed: true })); + } + + _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.theme.value === "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._dispatchUpdate(); + }, 1000); + } + }), + ]; + } + + async format() { + const prettier = await import("prettier/standalone"); + const config = (() => { + switch (this.language) { + case "javascript": + return { + parser: "babel", + plugins: [ + import("prettier/plugins/babel"), + import("prettier/plugins/estree"), + ], + }; + case "html": + return { + parser: "html", + plugins: [ + import("prettier/plugins/html"), + import("prettier/plugins/postcss"), + import("prettier/plugins/babel"), + import("prettier/plugins/estree"), + ], + }; + case "css": + return { + parser: "css", + plugins: [import("prettier/plugins/postcss")], + }; + default: + return undefined; + } + })(); + if (config) { + const plugins = await Promise.all(config.plugins); + const unformatted = this.value; + const formatted = await prettier.format(unformatted, { + parser: config.parser, + plugins: /** @type {import("prettier").Plugin[]} */ (plugins), + }); + if (this.value === unformatted) { + if (unformatted !== formatted) { + this.value = formatted; + this._dispatchUpdate(); + } + } + } + } + + /** @param {PropertyValues} changedProperties */ + willUpdate(changedProperties) { + if ( + changedProperties.has("language") || + changedProperties.has("ThemeController.value") + ) { + this._editor?.dispatch({ + effects: StateEffect.reconfigure.of(this._extensions()), + }); + } + } + + render() { + return html`
    `; + } + + firstUpdated() { + let startState = EditorState.create({ + doc: this._value, + 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..99728dd3e6c6 --- /dev/null +++ b/client/src/lit/play/editor.scss @@ -0,0 +1,15 @@ +@use "../../ui/vars" as *; + +:host { + display: block; + font-size: 0.875rem; +} + +.editor { + height: 100%; + + .cm-editor { + height: 100%; + width: 100%; + } +} diff --git a/client/src/lit/play/runner.js b/client/src/lit/play/runner.js new file mode 100644 index 000000000000..bc617daa302e --- /dev/null +++ b/client/src/lit/play/runner.js @@ -0,0 +1,105 @@ +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 { VConsole } from "./types" */ +/** @import { EventName } from "@lit/react" */ + +export class PlayRunner extends LitElement { + static properties = { + code: { type: Object }, + srcPrefix: { type: String, attribute: "src-prefix" }, + }; + + static styles = styles; + + constructor() { + super(); + /** @type {Record | undefined} */ + this.code = undefined; + /** @type {string | undefined} */ + this.srcPrefix = undefined; + this._subdomain = crypto.randomUUID(); + } + + /** @param {MessageEvent} e */ + _onMessage({ data: { typ, prop, args } }) { + if (typ === "console") { + /** @type {VConsole} */ + const detail = { prop, args }; + this.dispatchEvent( + new CustomEvent("console", { bubbles: true, composed: true, detail }) + ); + } + } + + _updateSrc = new Task(this, { + args: () => /** @type {const} */ ([this.code, this.srcPrefix]), + task: async ([code, srcPrefix], { signal }) => { + let src = "about:blank"; + if (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( + 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); + url.pathname = `${srcPrefix || ""}/runner.html`; + src = url.href; + } + // update iframe src without adding to browser history + this.shadowRoot + ?.querySelector("iframe") + ?.contentWindow?.location.replace(src); + }, + }); + + 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..83815129ec73 --- /dev/null +++ b/client/src/lit/play/runner.scss @@ -0,0 +1,6 @@ +iframe { + background: #fff; + border: none; + height: 100%; + width: 100%; +} 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..5fa7c58b8a01 --- /dev/null +++ b/client/src/lit/theme-controller.js @@ -0,0 +1,51 @@ +/** + * @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()); + this._matchMedia = window.matchMedia("(prefers-color-scheme: dark)"); + } + + _updateTheme() { + /** @type {Theme[]} */ + const themes = ["os-default", "dark", "light"]; + const { classList } = document.documentElement; + let value = themes.find((x) => classList.contains(x)) || "os-default"; + if (value === "os-default") { + value = this._matchMedia.matches ? "dark" : "light"; + } + const oldValue = this.value; + this.value = value; + this.#host.requestUpdate("ThemeController.value", oldValue); + } + + hostConnected() { + this._observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + this._updateTheme = this._updateTheme.bind(this); + this._matchMedia.addEventListener("change", this._updateTheme); + this._updateTheme(); + } + + hostDisconnected() { + this._observer.disconnect(); + this._matchMedia.removeEventListener("change", this._updateTheme); + } +} 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 8789a49388b3..781090a8bfab 100644 --- a/client/src/playground/index.scss +++ b/client/src/playground/index.scss @@ -193,22 +193,13 @@ main.play { padding: var(--editor-header-padding); } - .editor { + 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; - - .cm-editor { - min-height: 100%; - width: 100%; - - @media (max-width: $screen-sm) { - font-size: 1rem; - } - } } } } @@ -217,6 +208,7 @@ main.play { align-items: center; display: flex; flex-direction: column; + overflow: auto; button.flag-example { align-self: flex-end; @@ -231,10 +223,34 @@ main.play { } } - iframe { + play-runner { + border: 1px solid var(--border-primary); 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 c023cccf3811..869faa86dce1 100644 --- a/client/src/playground/index.tsx +++ b/client/src/playground/index.tsx @@ -1,30 +1,18 @@ 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 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 { 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 { useGleanClick } from "../telemetry/glean-context"; import { PLAYGROUND } from "../telemetry/constants"; -import type { VConsole } from "../lit/play/types"; const HTML_DEFAULT = ""; const CSS_DEFAULT = ""; @@ -78,16 +66,12 @@ 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); - let [codeSrc, setCodeSrc] = useState(); - let [iframeSrc, setIframeSrc] = useState("about:blank"); - const [isEmpty, setIsEmpty] = useState(true); - const subdomain = useRef(crypto.randomUUID()); + const [isShareable, setIsShareable] = useState(true); + const [isClearable, setIsClearable] = useState(true); const [initialContent, setInitialContent] = useState( null ); - const [flipFlop, setFlipFlop] = useState(0); let { data: initialCode } = useSWRImmutable( !stateParam && !shared && gistId ? `/api/v1/play/${encodeURIComponent(gistId)}` @@ -116,38 +100,9 @@ export default function Playground() { undefined, } ); - const htmlRef = useRef(null); - const cssRef = useRef(null); - const jsRef = useRef(null); - const iframe = useRef(null); + const controller = 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(() => { if (initialCode) { store(SESSION_KEY, initialCode); @@ -158,46 +113,33 @@ export default function Playground() { }, [initialCode, setInitialContent]); const getEditorContent = useCallback(() => { - const code = { - html: htmlRef.current?.getContent() || HTML_DEFAULT, - css: cssRef.current?.getContent() || CSS_DEFAULT, - js: jsRef.current?.getContent() || 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]); - 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 setIsEmpty = useCallback((content: EditorContent) => { + const { html, css, js } = content; + setIsShareable(!html.trim() && !css.trim() && !js.trim()); + setIsClearable(!html && !css && !js); }, []); - const setEditorContent = ({ html, css, js, src }: EditorContent) => { - htmlRef.current?.setContent(html); - cssRef.current?.setContent(css); - jsRef.current?.setContent(js); - if (src) { - setCodeSrc(src); - } - setIsEmpty(!html && !css && !js); - }; + const setEditorContent = useCallback( + (content: EditorContent) => { + if (controller.current) { + controller.current.code = { ...content }; + if (content.src) { + controller.current.srcPrefix = content.src; + } + setIsEmpty(content); + store(SESSION_KEY, content); + } + }, + [setIsEmpty] + ); useEffect(() => { (async () => { @@ -206,7 +148,7 @@ export default function Playground() { setEditorContent(initialCode); if (!gistId) { // don't auto run shared code - updateWithCode(initialCode); + controller.current?.run(); } } else if (stateParam) { try { @@ -226,22 +168,19 @@ 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, setEditorContent]); const clear = async () => { setSearchParams([], { replace: true }); - setCodeSrc(undefined); setInitialContent(null); - setEditorContent({ html: HTML_DEFAULT, css: CSS_DEFAULT, js: JS_DEFAULT }); + setEditorContent({ + html: HTML_DEFAULT, + css: CSS_DEFAULT, + js: JS_DEFAULT, + src: undefined, + }); - updateWithEditorContent(); + run(); }; const reset = async () => { @@ -251,7 +190,7 @@ export default function Playground() { js: initialContent?.js || JS_DEFAULT, }); - updateWithEditorContent(); + run(); }; const clearConfirm = async () => { @@ -268,10 +207,7 @@ export default function Playground() { } }; - const updateWithEditorContent = () => { - const { html, css, js, src } = getEditorContent(); - setIsEmpty(!html && !css && !js); - + const run = () => { const loading = [ {}, { @@ -279,43 +215,18 @@ export default function Playground() { }, {}, ]; - const timing = { duration: 1000, iterations: 1, }; document.getElementById("run")?.firstElementChild?.animate(loading, timing); - updateWithCode({ html, css, js, src }); + controller.current?.run(); }; 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); - } catch (e) { - console.error(e); - } + await controller.current?.format(); }; + const share = useCallback(async () => { const { url, id } = await save(getEditorContent()); setSearchParams([["id", id]], { replace: true }); @@ -329,8 +240,14 @@ export default function Playground() { } }; + const onEditorUpdate = () => { + const code = getEditorContent(); + setIsEmpty(code); + store(SESSION_KEY, code); + }; + return ( - <> +
    {dialogState === DialogState.flag && } @@ -345,17 +262,13 @@ export default function Playground() { - )} - - + +
    + Console + +
    - +
    ); } 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(); 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..6ab0c93f8e2a 100644 --- a/libs/play/index.js +++ b/libs/play/index.js @@ -236,13 +236,47 @@ export function renderHtml(state = null) {