diff --git a/client/src/playground/editor.js b/client/src/playground/editor.js
new file mode 100644
index 000000000000..4047b6ef8e1c
--- /dev/null
+++ b/client/src/playground/editor.js
@@ -0,0 +1,193 @@
+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";
+ 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;
+ }
+
+ _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);
+ }
+ }),
+ ];
+ }
+
+ 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 (
+ 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({
+ 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/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 fd494c5e26af..65ea4160a6e2 100644
--- a/client/src/playground/index.tsx
+++ b/client/src/playground/index.tsx
@@ -1,15 +1,8 @@
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 { ReactPlayEditor, PlayEditor } from "./editor";
import { SidePlacement } from "../ui/organisms/placement";
import {
compressAndBase64Encode,
@@ -24,6 +17,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 +64,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 +110,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);
@@ -158,9 +153,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);
@@ -189,9 +184,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);
}
@@ -288,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 });
@@ -384,21 +369,24 @@ export default function Playground() {
)}
-
-
+
-
+
+ colorScheme={colorScheme}
+ onUpdate={updateWithEditorContent}
+ >
{gistId && (
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();