From d71a359a9ed561547280a249a23ed59283c5d88b Mon Sep 17 00:00:00 2001 From: Leo McArdle Date: Thu, 5 Dec 2024 13:07:46 +0000 Subject: [PATCH 1/4] chore(playground): migrate editor to web component https://mozilla-hub.atlassian.net/browse/MP-1740 --- client/src/playground/editor.js | 146 ++++++++++++++++++++++++++++ client/src/playground/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/playground/editor.js create mode 100644 client/src/playground/editor.scss delete mode 100644 client/src/playground/editor.tsx diff --git a/client/src/playground/editor.js b/client/src/playground/editor.js new file mode 100644 index 000000000000..df0cc4f2b4a4 --- /dev/null +++ b/client/src/playground/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/playground/editor.scss b/client/src/playground/editor.scss new file mode 100644 index 000000000000..9bfd312dba83 --- /dev/null +++ b/client/src/playground/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 d4a921affbeb..b2b4c15bc984 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 eb8e759b557d..8c045eb1e532 100644 --- a/client/src/playground/index.tsx +++ b/client/src/playground/index.tsx @@ -9,7 +9,7 @@ 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 { ReactPlayEditor, PlayEditor } from "./editor"; import { SidePlacement } from "../ui/organisms/placement"; import { compressAndBase64Encode, @@ -24,6 +24,7 @@ import { FlagForm, ShareForm } from "./forms"; import { Console, VConsole } from "./console"; import { useGleanClick } from "../telemetry/glean-context"; import { PLAYGROUND } from "../telemetry/constants"; +import { useUIStatus } from "../ui-context"; const HTML_DEFAULT = ""; const CSS_DEFAULT = ""; @@ -70,6 +71,7 @@ function load(session: string) { } export default function Playground() { + const { colorScheme } = useUIStatus(); const gleanClick = useGleanClick(); let [searchParams, setSearchParams] = useSearchParams(); const gistId = searchParams.get("id"); @@ -115,9 +117,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 iframe = useRef(null); const diaRef = useRef(null); @@ -156,9 +158,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); @@ -187,9 +189,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); } @@ -382,21 +390,24 @@ export default function Playground() { )} - - + - + + colorScheme={colorScheme} + onUpdate={updateWithEditorContent} + >
{gistId && ( From c94cafaf6498489277b6e765849f620448419e54 Mon Sep 17 00:00:00 2001 From: Leo McArdle Date: Wed, 11 Dec 2024 12:49:35 +0000 Subject: [PATCH 2/4] 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/playground/editor.js | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/client/src/playground/editor.js b/client/src/playground/editor.js index df0cc4f2b4a4..dc073b9d68d5 100644 --- a/client/src/playground/editor.js +++ b/client/src/playground/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 be7f2a2eba4b89f611618ee937a4054c745ee0fd Mon Sep 17 00:00:00 2001 From: Leo McArdle Date: Wed, 11 Dec 2024 13:16:37 +0000 Subject: [PATCH 3/4] chore(playground): migrate formatting to editor component --- client/src/playground/editor.js | 42 +++++++++++++++++++++++++++++++++ client/src/playground/index.tsx | 35 +++++---------------------- 2 files changed, 48 insertions(+), 29 deletions(-) diff --git a/client/src/playground/editor.js b/client/src/playground/editor.js index dc073b9d68d5..4047b6ef8e1c 100644 --- a/client/src/playground/editor.js +++ b/client/src/playground/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 b1e77612db77..65ea4160a6e2 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 { ReactPlayEditor, PlayEditor } from "./editor"; import { SidePlacement } from "../ui/organisms/placement"; @@ -296,33 +289,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 684c2d627624806025b22bb0e1e00797f32451a0 Mon Sep 17 00:00:00 2001 From: Leo McArdle Date: Thu, 12 Dec 2024 12:34:36 +0000 Subject: [PATCH 4/4] 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();