diff --git a/package-lock.json b/package-lock.json
index e744e4d1900..61ed74b5555 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9317,6 +9317,12 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"dev": true
},
+ "node_modules/@types/uuid": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
+ "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
+ "dev": true
+ },
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -9541,6 +9547,15 @@
"url": "https://opencollective.com/eslint"
}
},
+ "node_modules/@typescript/ata": {
+ "version": "0.9.8",
+ "resolved": "https://registry.npmjs.org/@typescript/ata/-/ata-0.9.8.tgz",
+ "integrity": "sha512-+M815CeDRJS5H5ciWfhFCKp25nNfF+LFWawWAaBhNlquFb2wS5IIMDI+2bKWN3GuU6mpj+FzySsOD29M4nG8Xg==",
+ "dev": true,
+ "peerDependencies": {
+ "typescript": ">=4.4.4"
+ }
+ },
"node_modules/@ungap/structured-clone": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
@@ -20428,24 +20443,33 @@
}
},
"node_modules/monaco-editor": {
- "version": "0.27.0",
- "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.27.0.tgz",
- "integrity": "sha512-UhwP78Wb8w0ZSYoKXQNTV/0CHObp6NS3nCt51QfKE6sKyBo5PBsvuDOHoI2ooBakc6uIwByRLHVeT7+yXQe2fQ==",
- "dev": true
+ "version": "0.53.0",
+ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.53.0.tgz",
+ "integrity": "sha512-0WNThgC6CMWNXXBxTbaYYcunj08iB5rnx4/G56UOPeL9UVIUGGHA1GR0EWIh9Ebabj7NpCRawQ5b0hfN1jQmYQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/trusted-types": "^1.0.6"
+ }
},
"node_modules/monaco-editor-webpack-plugin": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/monaco-editor-webpack-plugin/-/monaco-editor-webpack-plugin-4.2.0.tgz",
- "integrity": "sha512-/P3sFiEgBl+Y50he4mbknMhbLJVop5gBUZiPS86SuHUDOOnQiQ5rL1jU5lwt1XKAwMEkhwZbUwqaHxTPkb1Utw==",
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/monaco-editor-webpack-plugin/-/monaco-editor-webpack-plugin-7.1.0.tgz",
+ "integrity": "sha512-ZjnGINHN963JQkFqjjcBtn1XBtUATDZBMgNQhDQwd78w2ukRhFXAPNgWuacaQiDZsUr4h1rWv5Mv6eriKuOSzA==",
"dev": true,
"dependencies": {
- "loader-utils": "^2.0.0"
+ "loader-utils": "^2.0.2"
},
"peerDependencies": {
- "monaco-editor": "0.25.x || 0.26.x || 0.27.x || 0.28.x",
+ "monaco-editor": ">= 0.31.0",
"webpack": "^4.5.0 || 5.x"
}
},
+ "node_modules/monaco-editor/node_modules/@types/trusted-types": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-1.0.6.tgz",
+ "integrity": "sha512-230RC8sFeHoT6sSUlRO6a8cAnclO06eeiq1QDfiv2FGCLWFvvERWgwIQD4FWqD9A69BN7Lzee4OXwoMVnnsWDw==",
+ "dev": true
+ },
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
@@ -29608,13 +29632,16 @@
"@svgr/webpack": "^7.0.0",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
+ "@types/uuid": "^10.0.0",
+ "@typescript/ata": "^0.9.8",
"copy-webpack-plugin": "^11.0.0",
"css-loader": "^7.1.0",
+ "es-module-lexer": "^1.7.0",
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.4.0",
"mini-css-extract-plugin": "^2.4.3",
- "monaco-editor": "0.27.0",
- "monaco-editor-webpack-plugin": "^4.2.0",
+ "monaco-editor": "^0.53.0",
+ "monaco-editor-webpack-plugin": "^7.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-refresh": "^0.17.0",
diff --git a/packages/tools/playground/package.json b/packages/tools/playground/package.json
index f97268bcfd3..e4d8fd134c0 100644
--- a/packages/tools/playground/package.json
+++ b/packages/tools/playground/package.json
@@ -19,13 +19,16 @@
"@svgr/webpack": "^7.0.0",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
+ "@types/uuid": "^10.0.0",
+ "@typescript/ata": "^0.9.8",
"copy-webpack-plugin": "^11.0.0",
"css-loader": "^7.1.0",
+ "es-module-lexer": "^1.7.0",
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.4.0",
"mini-css-extract-plugin": "^2.4.3",
- "monaco-editor": "0.27.0",
- "monaco-editor-webpack-plugin": "^4.2.0",
+ "monaco-editor": "^0.53.0",
+ "monaco-editor-webpack-plugin": "^7.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-refresh": "^0.17.0",
diff --git a/packages/tools/playground/public/zipContent/index.html b/packages/tools/playground/public/zipContent/index.html
deleted file mode 100644
index 43bf44c620f..00000000000
--- a/packages/tools/playground/public/zipContent/index.html
+++ /dev/null
@@ -1,71 +0,0 @@
-
-
-
-
-
- Babylon.js sample code
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/packages/tools/playground/src/components/editor/activityBarComponent.tsx b/packages/tools/playground/src/components/editor/activityBarComponent.tsx
new file mode 100644
index 00000000000..e912b62e24b
--- /dev/null
+++ b/packages/tools/playground/src/components/editor/activityBarComponent.tsx
@@ -0,0 +1,173 @@
+/* eslint-disable jsdoc/require-jsdoc */
+// activityBarComponent.tsx
+import * as React from "react";
+import type { GlobalState } from "../../globalState";
+import HistoryIcon from "./icons/history.svg";
+import FilesIcon from "./icons/files.svg";
+import SearchIcon from "./icons/search.svg";
+import { FileDialog } from "./fileDialog";
+import { LocalSessionDialog } from "./localSessionDialog";
+import { Utilities } from "../../tools/utilities";
+import { Icon } from "./iconComponent";
+
+type DialogKind = "create" | "rename" | "duplicate";
+type DialogState = {
+ open: boolean;
+ type: DialogKind | null;
+ title: string;
+ initialValue: string;
+ targetPath?: string;
+};
+
+export type ActivityBarHandle = {
+ openCreateDialog: (suggestedName?: string) => void;
+ openRenameDialog: (targetPath: string, initialDisplay?: string) => void;
+ openDuplicateDialog: (targetPath: string, suggestedName?: string) => void;
+ toggleSessionDialog: () => void;
+};
+
+interface IActivityBarProps {
+ globalState: GlobalState;
+
+ // Monaco owns mutations
+ onConfirmFileDialog: (type: DialogKind, filename: string, targetPath?: string) => void;
+
+ // Lifted layout state
+ explorerOpen: boolean;
+ onToggleExplorer: () => void;
+
+ sessionOpen: boolean;
+ onToggleSession: () => void;
+
+ searchOpen: boolean;
+ onToggleSearch: () => void;
+}
+
+export const ActivityBar = React.forwardRef(function activityBar(
+ { globalState, onConfirmFileDialog, explorerOpen, onToggleExplorer, sessionOpen, onToggleSession, searchOpen, onToggleSearch },
+ ref
+) {
+ const [theme, setTheme] = React.useState<"dark" | "light">(() => (Utilities.ReadStringFromStore("theme", "Light") === "Dark" ? "dark" : "light"));
+
+ const [fileDialog, setFileDialog] = React.useState({
+ open: false,
+ type: null,
+ title: "",
+ initialValue: "",
+ });
+
+ // helpers
+ const toDisplay = (path: string) => path.replace(/^\/?src\//, "");
+ const toInternal = (displayPath: string) => (displayPath?.startsWith("/") ? displayPath.slice(1) : displayPath || "");
+ const defaultNewFileName = (): string => {
+ const isTS = globalState.language !== "JS";
+ const base = isTS ? "new.ts" : "new.js";
+ let idx = 0;
+ let candidate = base;
+ const files = globalState.files || {};
+ while (Object.prototype.hasOwnProperty.call(files, toInternal(candidate))) {
+ idx++;
+ const parts = base.split(".");
+ const ext = parts.pop();
+ candidate = `${parts.join(".")}${idx}.${ext}`;
+ }
+ return candidate;
+ };
+ const defaultDuplicateName = (path: string): string => {
+ const baseFull = path.replace(/\.(\w+)$/, "");
+ const ext = (path.match(/\.(\w+)$/) || ["", ""])[1];
+ let i = 1;
+ let candidate = `${baseFull}.copy${i}${ext ? "." + ext : ""}`;
+ const files = globalState.files || {};
+ while (Object.prototype.hasOwnProperty.call(files, candidate)) {
+ i++;
+ candidate = `${baseFull}.copy${i}${ext ? "." + ext : ""}`;
+ }
+ return toDisplay(candidate);
+ };
+
+ // imperative API
+ React.useImperativeHandle(ref, () => ({
+ openCreateDialog: (suggestedName?: string) => {
+ setFileDialog({ open: true, type: "create", title: "New file", initialValue: suggestedName ?? defaultNewFileName() });
+ },
+ openRenameDialog: (targetPath: string, initialDisplay?: string) => {
+ setFileDialog({
+ open: true,
+ type: "rename",
+ title: "Rename file",
+ initialValue: initialDisplay ?? toDisplay(targetPath),
+ targetPath,
+ });
+ },
+ openDuplicateDialog: (targetPath: string, suggestedName?: string) => {
+ setFileDialog({
+ open: true,
+ type: "duplicate",
+ title: "Duplicate file",
+ initialValue: suggestedName ?? defaultDuplicateName(targetPath),
+ targetPath,
+ });
+ },
+ toggleSessionDialog: () => onToggleSession(),
+ }));
+
+ // theme sync
+ React.useEffect(() => {
+ const updateTheme = () => {
+ setTheme(Utilities.ReadStringFromStore("theme", "Light") === "Dark" ? "dark" : "light");
+ };
+ globalState.onThemeChangedObservable.add(updateTheme);
+ return () => {
+ globalState.onThemeChangedObservable.removeCallback(updateTheme);
+ };
+ }, [globalState]);
+
+ // dialog handlers
+ const closeFileDialog = () => setFileDialog({ open: false, type: null, title: "", initialValue: "" });
+ const confirmFileDialog = (filename: string) => {
+ if (!fileDialog.type) {
+ return;
+ }
+ onConfirmFileDialog(fileDialog.type, filename, fileDialog.targetPath);
+ closeFileDialog();
+ };
+
+ return (
+
+ {/* Vertical Activity Bar (far left) */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* File Dialog */}
+
+
+ {/* Local Session Dialog */}
+
+
+ );
+});
diff --git a/packages/tools/playground/src/components/editor/fileDialog.tsx b/packages/tools/playground/src/components/editor/fileDialog.tsx
new file mode 100644
index 00000000000..b9dcfe7dbf7
--- /dev/null
+++ b/packages/tools/playground/src/components/editor/fileDialog.tsx
@@ -0,0 +1,110 @@
+import * as React from "react";
+import type { GlobalState } from "../../globalState";
+import { Utilities } from "../../tools/utilities";
+import "../../scss/dialogs.scss";
+
+interface IFileDialogProps {
+ globalState: GlobalState;
+ isOpen: boolean;
+ title: string;
+ initialValue?: string;
+ placeholder?: string;
+ submitLabel?: string;
+ onConfirm: (filename: string) => void;
+ onCancel: () => void;
+}
+
+/**
+ * @param param0 Props for the file dialog
+ * @returns JSX.Element
+ */
+export const FileDialog: React.FC = ({
+ globalState,
+ isOpen,
+ title,
+ initialValue = "",
+ placeholder = "Enter filename...",
+ submitLabel = "Create",
+ onConfirm,
+ onCancel,
+}) => {
+ const [filename, setFilename] = React.useState(initialValue);
+ const [theme, setTheme] = React.useState<"dark" | "light">(() => {
+ return Utilities.ReadStringFromStore("theme", "Light") === "Dark" ? "dark" : "light";
+ });
+ const inputRef = React.useRef(null);
+
+ React.useEffect(() => {
+ if (isOpen) {
+ setFilename(initialValue);
+ setTimeout(() => {
+ inputRef.current?.focus();
+ inputRef.current?.select();
+ }, 0);
+ }
+ }, [isOpen, initialValue]);
+
+ React.useEffect(() => {
+ const updateTheme = () => {
+ const newTheme = Utilities.ReadStringFromStore("theme", "Light") === "Dark" ? "dark" : "light";
+ setTheme(newTheme);
+ };
+
+ globalState.onThemeChangedObservable.add(updateTheme);
+ return () => {
+ globalState.onThemeChangedObservable.removeCallback(updateTheme);
+ };
+ }, [globalState]);
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (filename.trim()) {
+ onConfirm(filename.trim());
+ }
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Escape") {
+ onCancel();
+ }
+ };
+
+ if (!isOpen) {
+ return null;
+ }
+
+ return (
+
+
e.stopPropagation()}>
+
+
{title}
+
+ ✕
+
+
+
+
+
+
+
+ Cancel
+
+
+ {submitLabel}
+
+
+
+
+ );
+};
diff --git a/packages/tools/playground/src/components/editor/fileExplorerComponent.tsx b/packages/tools/playground/src/components/editor/fileExplorerComponent.tsx
new file mode 100644
index 00000000000..4b547b0f646
--- /dev/null
+++ b/packages/tools/playground/src/components/editor/fileExplorerComponent.tsx
@@ -0,0 +1,121 @@
+/* eslint-disable jsdoc/require-jsdoc */
+import * as React from "react";
+import type { GlobalState } from "../../globalState";
+
+type Node = { name: string; path?: string; kind: "file" | "dir"; children?: Map };
+function BuildTree(paths: string[]): Node {
+ const root: Node = { name: "", kind: "dir", children: new Map() };
+ for (const full of paths) {
+ const parts = full.split("/");
+ let cur = root;
+ for (let i = 0; i < parts.length; i++) {
+ const part = parts[i];
+ const isLast = i === parts.length - 1;
+ const key = part.toLowerCase() + (isLast ? "|f" : "|d");
+ if (!cur.children!.has(key)) {
+ cur.children!.set(key, isLast ? { name: part, path: full, kind: "file" } : { name: part, kind: "dir", children: new Map() });
+ }
+ cur = cur.children!.get(key)!;
+ }
+ }
+ return root;
+}
+
+export const FileExplorer: React.FC<{
+ globalState: GlobalState;
+ files: Record;
+ openFiles: string[];
+ active: string | undefined;
+ onOpen: (path: string) => void;
+ onClose: (path: string) => void;
+ onCreate: (suggestedPath?: string) => void;
+ onRename: (oldPath: string, newPath: string) => void;
+ onDelete: (path: string) => void;
+}> = ({ globalState, files, openFiles, active, onOpen, onClose, onRename, onDelete }) => {
+ const [tree, setTree] = React.useState(BuildTree(Object.keys(files)));
+ React.useEffect(() => {
+ const update = () => {
+ setTree(BuildTree(Object.keys(globalState.files)));
+ };
+ globalState.onFilesChangedObservable.add(update);
+ globalState.onActiveFileChangedObservable.add(update);
+
+ return () => {
+ globalState.onFilesChangedObservable.removeCallback(update);
+ globalState.onActiveFileChangedObservable.removeCallback(update);
+ };
+ }, []);
+ const render = (node: Node, depth = 0) => {
+ if (node.kind === "file") {
+ const isActive = node.path === active;
+ const isOpen = openFiles.includes(node.path!);
+ const isEntry = node.path === globalState.entryFilePath;
+ return (
+ onOpen(node.path!)}
+ title={node.path}
+ >
+
+ {node.name}
+ {isEntry && }
+
+
+ {isOpen && (
+ {
+ e.stopPropagation();
+ onClose(node.path!);
+ }}
+ title="Close"
+ className="action-btn close-btn"
+ >
+ ×
+
+ )}
+ {
+ e.stopPropagation();
+ onRename(node.path!, node.path!);
+ }}
+ title="Rename"
+ className="action-btn rename-btn"
+ >
+ ✎
+
+ {
+ e.stopPropagation();
+ onDelete(node.path!);
+ }}
+ title="Delete"
+ className="action-btn delete-btn"
+ >
+ 🗑
+
+
+
+ );
+ }
+ // dir
+ const entries = Array.from(node.children!.values()).sort((a, b) => {
+ if (a.path === globalState.entryFilePath) {
+ return -1;
+ }
+ if (b.path === globalState.entryFilePath) {
+ return 1;
+ }
+
+ if (a.kind !== b.kind) {
+ return a.kind === "dir" ? -1 : 1;
+ }
+
+ return a.name.localeCompare(b.name);
+ });
+ return {entries.map((child) => render(child, depth + (node.name ? 1 : 0)))}
;
+ };
+
+ return {render(tree)}
;
+};
diff --git a/packages/tools/playground/src/components/editor/iconComponent.tsx b/packages/tools/playground/src/components/editor/iconComponent.tsx
new file mode 100644
index 00000000000..baa23a2e1b1
--- /dev/null
+++ b/packages/tools/playground/src/components/editor/iconComponent.tsx
@@ -0,0 +1,30 @@
+// Icon.tsx
+import * as React from "react";
+
+type IconProps = {
+ size?: number;
+ strokeWidth?: number;
+ children: React.ReactElement;
+};
+
+/**
+ *
+ * @param param0
+ * @returns
+ */
+export function Icon({ size = 20, strokeWidth, children }: IconProps) {
+ const vb = children.props.viewBox as unknown as string | undefined;
+ const native = vb ? Number(vb.split(" ")[2]) : 24;
+ const scale = size / native;
+
+ return React.cloneElement(children, {
+ style: {
+ transformBox: "fill-box",
+ transformOrigin: "50% 50%",
+ transform: `scale(${scale})`,
+ display: "block",
+ ...((children.props.style as any) || {}),
+ },
+ ...(strokeWidth ? { strokeWidth } : {}),
+ });
+}
diff --git a/packages/tools/playground/src/components/editor/icons/caseSensitive.svg b/packages/tools/playground/src/components/editor/icons/caseSensitive.svg
new file mode 100644
index 00000000000..9faa9b7c414
--- /dev/null
+++ b/packages/tools/playground/src/components/editor/icons/caseSensitive.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/tools/playground/src/components/editor/icons/files.svg b/packages/tools/playground/src/components/editor/icons/files.svg
new file mode 100644
index 00000000000..8671a7b93f7
--- /dev/null
+++ b/packages/tools/playground/src/components/editor/icons/files.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/tools/playground/src/components/editor/icons/history.svg b/packages/tools/playground/src/components/editor/icons/history.svg
new file mode 100644
index 00000000000..53d41f7c7bb
--- /dev/null
+++ b/packages/tools/playground/src/components/editor/icons/history.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/tools/playground/src/components/editor/icons/newFile.svg b/packages/tools/playground/src/components/editor/icons/newFile.svg
new file mode 100644
index 00000000000..99d8286cb0d
--- /dev/null
+++ b/packages/tools/playground/src/components/editor/icons/newFile.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/tools/playground/src/components/editor/icons/regex.svg b/packages/tools/playground/src/components/editor/icons/regex.svg
new file mode 100644
index 00000000000..1f45856932d
--- /dev/null
+++ b/packages/tools/playground/src/components/editor/icons/regex.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/tools/playground/src/components/editor/icons/replace.svg b/packages/tools/playground/src/components/editor/icons/replace.svg
new file mode 100644
index 00000000000..65bfaacf47e
--- /dev/null
+++ b/packages/tools/playground/src/components/editor/icons/replace.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/tools/playground/src/components/editor/icons/replaceAll.svg b/packages/tools/playground/src/components/editor/icons/replaceAll.svg
new file mode 100644
index 00000000000..f5a51538d14
--- /dev/null
+++ b/packages/tools/playground/src/components/editor/icons/replaceAll.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/tools/playground/src/components/editor/icons/search.svg b/packages/tools/playground/src/components/editor/icons/search.svg
new file mode 100644
index 00000000000..c6e88e26f9e
--- /dev/null
+++ b/packages/tools/playground/src/components/editor/icons/search.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/tools/playground/src/components/editor/icons/wholeWord.svg b/packages/tools/playground/src/components/editor/icons/wholeWord.svg
new file mode 100644
index 00000000000..d415932ad74
--- /dev/null
+++ b/packages/tools/playground/src/components/editor/icons/wholeWord.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/tools/playground/src/components/editor/localSessionDialog.tsx b/packages/tools/playground/src/components/editor/localSessionDialog.tsx
new file mode 100644
index 00000000000..8d4fe7e1b4d
--- /dev/null
+++ b/packages/tools/playground/src/components/editor/localSessionDialog.tsx
@@ -0,0 +1,226 @@
+import * as React from "react";
+import type { GlobalState } from "../../globalState";
+import type { FileChange, RevisionContext, SnippetRevision } from "../../tools/localSession";
+import { ListRevisionContexts, LoadFileRevisionsForToken, MaxRevisions, RemoveFileRevisionForToken } from "../../tools/localSession";
+import { Utilities } from "../../tools/utilities";
+import type { V2Manifest } from "../../tools/snippet";
+import "../../scss/dialogs.scss";
+
+interface ILocalSessionDialogProps {
+ globalState: GlobalState;
+ isOpen: boolean;
+ onCancel: () => void;
+}
+
+/**
+ *
+ * @param param0
+ * @returns
+ */
+export const LocalSessionDialog: React.FC = ({ globalState, isOpen, onCancel }) => {
+ const [fileRevisions, setFileRevisions] = React.useState([]);
+ const [contexts, setContexts] = React.useState([]);
+
+ const [selectedToken, setSelectedToken] = React.useState("");
+ const [theme, setTheme] = React.useState<"dark" | "light">(() => {
+ return Utilities.ReadStringFromStore("theme", "Light") === "Dark" ? "dark" : "light";
+ });
+
+ React.useEffect(() => {
+ if (!isOpen) {
+ return;
+ }
+
+ const ctxs = ListRevisionContexts(globalState);
+ setContexts(ctxs);
+ const defaultToken = globalState.currentSnippetToken || "local-session";
+ const effective = ctxs.find((c) => c.token === defaultToken)?.token ?? ctxs[0]?.token ?? defaultToken;
+ setSelectedToken(effective);
+
+ const revs = LoadFileRevisionsForToken(globalState, effective);
+ setFileRevisions(revs);
+ }, [isOpen, globalState]);
+
+ React.useEffect(() => {
+ const updateTheme = () => {
+ const newTheme = Utilities.ReadStringFromStore("theme", "Light") === "Dark" ? "dark" : "light";
+ setTheme(newTheme);
+ };
+ globalState.onThemeChangedObservable.add(updateTheme);
+ return () => {
+ globalState.onThemeChangedObservable.removeCallback(updateTheme);
+ };
+ }, [globalState]);
+
+ const onSelectContext = (token: string) => {
+ setSelectedToken(token);
+ const revs = LoadFileRevisionsForToken(globalState, token);
+ setFileRevisions(revs);
+ };
+
+ const fileChangeBadge = (c: FileChange) => {
+ const symbol = c.type === "added" ? "+" : c.type === "removed" ? "–" : "∼";
+ const sizeText = (() => {
+ const fmt = (n: number | null) => (n == null ? "—" : `${n} B`);
+ return `${fmt(c.beforeSize)} → ${fmt(c.afterSize)}`;
+ })();
+ const title = `${c.type.toUpperCase()} • ${c.file} • ${sizeText}`;
+ return (
+
+ {symbol} {c.file}
+
+ );
+ };
+
+ const formatDate = (timestamp: number) =>
+ new Date(timestamp).toLocaleString("en-US", {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ });
+
+ const restoreRevision = (manifest: V2Manifest) => {
+ globalState.onV2HydrateRequiredObservable.notifyObservers(manifest);
+ onCancel();
+ };
+
+ const removeRevision = (index: number) => {
+ RemoveFileRevisionForToken(globalState, selectedToken, index);
+ const revs = LoadFileRevisionsForToken(globalState, selectedToken);
+ setFileRevisions(revs);
+ setContexts(ListRevisionContexts(globalState));
+ };
+
+ if (!isOpen) {
+ return null;
+ }
+ const hasRevisions = fileRevisions.length > 0;
+
+ return (
+
+
e.stopPropagation()}>
+
+
+
Playground Session History
+
+ {/* Context select */}
+
+ onSelectContext(e.target.value)}
+ >
+ {contexts.map((c) => {
+ const isSnippetContext = c.token !== "local-session";
+ const displayText = isSnippetContext ? `${c.title} (${c.token})` : c.title;
+ const countText = c.count ? ` - ${c.count} revision${c.count === 1 ? "" : "s"}` : "";
+
+ return (
+
+ {displayText}
+ {countText}
+
+ );
+ })}
+
+ ▼
+
+
+
+
+ ✕
+
+
+
+
+ {hasRevisions ? (
+
+
+
+
+ Title
+ Files
+ Date
+ Action
+
+
+
+ {fileRevisions.map((revision, index) => {
+ const changes = revision.filesChanged ?? [];
+ const maxInline = 1;
+ const shown = changes.slice(0, maxInline);
+ const notShown = changes.slice(maxInline);
+ const hiddenCount = Math.max(0, changes.length - shown.length);
+
+ return (
+
+
+ {revision.title}
+ {revision.link && (
+
+
+ {revision.link}
+
+
+ )}
+
+
+
+ {shown.map(fileChangeBadge)}
+ {hiddenCount > 0 && (
+ `${f.type.toUpperCase()} • ${f.file}`).join("\n")}>
+ +{hiddenCount} more
+
+ )}
+ {changes.length === 0 && No file changes }
+
+
+
+ {formatDate(revision.date)}
+
+
+ restoreRevision(revision.manifest)}
+ aria-label={`Restore revision ${revision.title} from ${formatDate(revision.date)}`}
+ >
+ Restore
+
+ removeRevision(index)}
+ aria-label={`Remove revision ${revision.title} from ${formatDate(revision.date)}`}
+ >
+ Remove
+
+
+
+ );
+ })}
+
+
+
+ ) : (
+
+
📝
+
No revisions found
+
This session doesn't have any saved revisions yet.
+
+ )}
+
+
+
+
+ Up to {MaxRevisions} local sessions are retained per snippet context, or local context if unsaved, and are stored when the Playground is run with changes to
+ the code.
+
+
+
+
+ );
+};
diff --git a/packages/tools/playground/src/components/editor/monacoComponent.tsx b/packages/tools/playground/src/components/editor/monacoComponent.tsx
new file mode 100644
index 00000000000..de3ba8d247b
--- /dev/null
+++ b/packages/tools/playground/src/components/editor/monacoComponent.tsx
@@ -0,0 +1,773 @@
+// monacoComponent.tsx
+import * as React from "react";
+import { MonacoManager } from "../../tools/monaco/monacoManager";
+import { Utilities } from "../../tools/utilities";
+import type { GlobalState } from "../../globalState";
+import type { Observer } from "core/Misc";
+
+import { SplitContainer } from "shared-ui-components/split/splitContainer";
+import { Splitter } from "shared-ui-components/split/splitter";
+import { ControlledSize, SplitDirection } from "shared-ui-components/split/splitContext";
+
+import type { ActivityBarHandle } from "./activityBarComponent";
+import { ActivityBar } from "./activityBarComponent";
+import { FileExplorer } from "./fileExplorerComponent";
+import AddFileIcon from "./icons/newFile.svg";
+import { SearchPanel } from "./searchPanelComponent";
+
+import "../../scss/monaco.scss";
+import "../../scss/editor.scss";
+
+interface IMonacoComponentProps {
+ className?: string;
+ refObject: React.RefObject;
+ globalState: GlobalState;
+}
+
+type CtxMenuState = { open: boolean; x: number; y: number; path: string | null };
+type DialogKind = "create" | "rename" | "duplicate";
+
+interface IComponentState {
+ files: string[];
+ active: string;
+ order: string[];
+ /** Local (non-serialized) ordering of open editor tabs */
+ tabOrder: string[];
+ ctx: CtxMenuState;
+ theme: "dark" | "light";
+ dragOverIndex: number;
+ explorerOpen: boolean;
+ searchOpen: boolean;
+ sessionOpen: boolean;
+}
+
+/**
+ *
+ */
+export class MonacoComponent extends React.Component {
+ private readonly _mutationObserver: MutationObserver;
+ private _monacoManager: MonacoManager;
+ private _draggingPath: string | null = null;
+ private _menuRef: React.RefObject = React.createRef();
+ private _monacoRef: React.RefObject = React.createRef();
+ private _tabsHostRef = React.createRef();
+ private _tabsContentRef = React.createRef();
+ private _disposableObservers: Observer[] = [];
+ private _activityBarRef = React.createRef();
+
+ public constructor(props: IMonacoComponentProps) {
+ super(props);
+ const gs = props.globalState;
+ const files = Object.keys(gs.files || {});
+ const order = gs.filesOrder?.length ? gs.filesOrder.slice() : files.slice();
+
+ if (!gs.openEditors || gs.openEditors.length === 0) {
+ const defaultFile = gs.activeFilePath || gs.entryFilePath || files[0];
+ if (defaultFile) {
+ gs.openEditors = [defaultFile];
+ gs.activeEditorPath = defaultFile;
+ }
+ }
+
+ this.state = {
+ files,
+ active: gs.activeFilePath,
+ order,
+ tabOrder: (gs.openEditors || []).slice(),
+ ctx: { open: false, x: 0, y: 0, path: null },
+ theme: this._getCurrentTheme(),
+ dragOverIndex: -1,
+ explorerOpen: false,
+ sessionOpen: false,
+ searchOpen: false,
+ };
+
+ this._monacoManager = new MonacoManager(gs);
+ this._mutationObserver = new MutationObserver((mutations) => {
+ for (const mutation of mutations) {
+ for (const node of mutation.addedNodes) {
+ if (node.nodeType === Node.ELEMENT_NODE) {
+ if ((node as HTMLElement).tagName === "TEXTAREA") {
+ (node as HTMLTextAreaElement).contentEditable = "true";
+ }
+ (node as HTMLElement).querySelectorAll?.("textarea").forEach((textArea) => ((textArea as HTMLTextAreaElement).contentEditable = "true"));
+ }
+ }
+ }
+ });
+ }
+
+ override componentDidMount() {
+ const gs = this.props.globalState;
+
+ this._disposableObservers.push(
+ gs.onEditorFullcreenRequiredObservable.add(async () => {
+ const editorDiv = this.props.refObject.current! as HTMLElement & {
+ webkitRequestFullscreen?: (opts?: FullscreenOptions) => Promise | void;
+ msRequestFullscreen?: () => Promise | void;
+ mozRequestFullScreen?: () => Promise | void;
+ };
+
+ if (editorDiv.requestFullscreen) {
+ await editorDiv.requestFullscreen();
+ } else if (editorDiv.webkitRequestFullscreen) {
+ await editorDiv.webkitRequestFullscreen();
+ }
+ })
+ );
+
+ this._disposableObservers.push(gs.onManifestChangedObservable.add(() => this.setState((s) => ({ ...s }))));
+
+ this._disposableObservers.push(
+ gs.onFilesChangedObservable.add(() => {
+ const existing = new Set(Object.keys(gs.files));
+ const filtered = gs.openEditors.filter((p) => existing.has(p));
+ if (filtered.length !== gs.openEditors.length) {
+ gs.openEditors = filtered;
+ gs.onOpenEditorsChangedObservable?.notifyObservers();
+ }
+
+ if (gs.activeEditorPath && !existing.has(gs.activeEditorPath)) {
+ gs.activeEditorPath = filtered[0] ?? gs.entryFilePath ?? Object.keys(gs.files)[0];
+ gs.onActiveEditorChangedObservable?.notifyObservers();
+ if (gs.activeEditorPath) {
+ this._monacoManager.switchActiveFile(gs.activeEditorPath);
+ }
+ }
+
+ const f = Object.keys(gs.files || {});
+ const nextOrder = this._mergeOrder(this.state.order, f);
+ this.setState({ files: f, order: nextOrder }, this._scrollActiveIntoView);
+ })
+ );
+
+ this._disposableObservers.push(
+ gs.onActiveFileChangedObservable.add(() => {
+ const p = this.props.globalState.activeFilePath;
+ if (!p) {
+ return;
+ }
+ if (!gs.openEditors.includes(p)) {
+ gs.openEditors = [...gs.openEditors, p];
+ gs.onOpenEditorsChangedObservable?.notifyObservers();
+ }
+ if (gs.activeEditorPath !== p) {
+ gs.activeEditorPath = p;
+ gs.onActiveEditorChangedObservable?.notifyObservers();
+ }
+ this.setState((s) => ({ ...s }));
+ })
+ );
+
+ this._disposableObservers.push(
+ gs.onOpenEditorsChangedObservable.add(() => {
+ // Reconcile local tab order with new open editors set
+ const open = gs.openEditors || [];
+ this.setState((s) => ({
+ ...s,
+ tabOrder: this._mergeTabOrder(s.tabOrder, open),
+ }));
+ })
+ );
+ this._disposableObservers.push(
+ gs.onActiveEditorChangedObservable.add(() => {
+ const open = gs.openEditors || [];
+ this.setState((s) => ({
+ ...s,
+ tabOrder: this._mergeTabOrder(s.tabOrder, open),
+ }));
+ })
+ );
+
+ this._disposableObservers.push(
+ gs.onFilesOrderChangedObservable?.add(() => {
+ const ord = gs.filesOrder?.slice() || [];
+ this.setState({ order: this._mergeOrder(ord, Object.keys(gs.files || {})) }, this._scrollActiveIntoView);
+ })
+ );
+
+ this._disposableObservers.push(gs.onThemeChangedObservable.add(() => this.setState({ theme: this._getCurrentTheme() })));
+
+ window.addEventListener("click", this._closeCtxMenu, { capture: true });
+ window.addEventListener("keydown", this._handleKeyDown, { capture: true });
+
+ const hostElement = this._monacoRef.current!;
+ this._mutationObserver.observe(hostElement, { childList: true, subtree: true });
+ void this._monacoManager.setupMonacoAsync(hostElement);
+ requestAnimationFrame(this._scrollActiveIntoView);
+ }
+
+ override componentWillUnmount(): void {
+ this._mutationObserver.disconnect();
+ window.removeEventListener("click", this._closeCtxMenu, { capture: true });
+ window.removeEventListener("keydown", this._handleKeyDown, { capture: true });
+ for (const d of this._disposableObservers) {
+ d.remove();
+ }
+ this._disposableObservers = [];
+ this._monacoManager?.dispose();
+ }
+
+ override componentDidUpdate(_prevProps: any, prevState: IComponentState): void {
+ if (this.state.ctx.open && this._menuRef.current) {
+ const { x, y } = this.state.ctx;
+ const el = this._menuRef.current;
+ el.style.left = x + "px";
+ el.style.top = y + "px";
+ el.style.position = "fixed";
+ }
+ const currentTheme = this._getCurrentTheme();
+ if (this.state.theme !== currentTheme) {
+ this.setState({ theme: currentTheme });
+ }
+ if (prevState.active !== this.state.active || prevState.files.length !== this.state.files.length || prevState.order.join("|") !== this.state.order.join("|")) {
+ this._scrollActiveIntoView();
+ }
+ }
+
+ private _getCurrentTheme(): "dark" | "light" {
+ return Utilities.ReadStringFromStore("theme", "Light") === "Dark" ? "dark" : "light";
+ }
+
+ private _mergeOrder(order: string[], files: string[]) {
+ const set = new Set(files);
+ const kept: string[] = [];
+ const seen = new Set();
+ for (const p of order) {
+ if (set.has(p) && !seen.has(p)) {
+ kept.push(p);
+ seen.add(p);
+ }
+ }
+ const extras = files.filter((p) => !seen.has(p));
+ return kept.concat(extras);
+ }
+
+ private _toDisplay(path: string): string {
+ return path.replace(/^\/?src\//, "");
+ }
+ private _toInternal(displayPath: string): string {
+ if (!displayPath) {
+ return displayPath;
+ }
+ return displayPath.startsWith("/") ? displayPath.slice(1) : displayPath;
+ }
+
+ private _scrollActiveIntoView = () => {
+ const content = this._tabsContentRef.current;
+ if (!content) {
+ return;
+ }
+ const el = content.querySelector(`.pg-tab[data-path="${CSS.escape(this.props.globalState.activeEditorPath || "")}"]`);
+ el?.scrollIntoView({ inline: "nearest", block: "nearest", behavior: "smooth" });
+ };
+
+ // ---------- Monaco-owned file ops ----------
+ private _commitOrder(next: string[]) {
+ const deduped = this._dedupeOrder(next);
+ this.setState({ order: deduped });
+ this.props.globalState.filesOrder = deduped.slice();
+ this.props.globalState.onFilesOrderChangedObservable?.notifyObservers();
+ }
+ private _dedupeOrder(arr: string[]): string[] {
+ const seen = new Set();
+ const out: string[] = [];
+ for (const p of arr) {
+ if (!seen.has(p)) {
+ seen.add(p);
+ out.push(p);
+ }
+ }
+ return out;
+ }
+
+ /**
+ * Merge existing local tab order with the current set of open editors.
+ * - Preserves relative order of already tracked tabs.
+ * - Appends any new open editors at the end.
+ * - Removes tabs that were closed.
+ * @param current Current local tab order array
+ * @param openEditors Current open editors from global state
+ * @returns New merged tab order array
+ */
+ private _mergeTabOrder(current: string[], openEditors: string[]): string[] {
+ const openSet = new Set(openEditors);
+ const kept = current.filter((p) => openSet.has(p));
+ for (const p of openEditors) {
+ if (!kept.includes(p)) {
+ kept.push(p);
+ }
+ }
+ return kept;
+ }
+
+ private _openEditor = (path: string) => {
+ const gs = this.props.globalState;
+ if (!gs.openEditors.includes(path)) {
+ gs.openEditors = [...gs.openEditors, path];
+ gs.onOpenEditorsChangedObservable?.notifyObservers();
+ }
+ gs.activeEditorPath = path;
+ gs.onActiveEditorChangedObservable?.notifyObservers();
+ this._monacoManager.switchActiveFile(path);
+ this.setState((s) => ({ tabOrder: this._mergeTabOrder(s.tabOrder, gs.openEditors) }));
+ };
+
+ private _closeEditor = (path: string) => {
+ const gs = this.props.globalState;
+ const idx = gs.openEditors.indexOf(path);
+ if (idx === -1) {
+ return;
+ }
+
+ const wasActive = gs.activeEditorPath === path;
+ const next = gs.openEditors.filter((p) => p !== path);
+ gs.openEditors = next;
+ gs.onOpenEditorsChangedObservable?.notifyObservers();
+ this.setState((s) => ({ tabOrder: s.tabOrder.filter((p) => p !== path) }));
+
+ if (wasActive) {
+ const fallback = next[idx - 1] ?? next[idx] ?? Object.keys(gs.files)[0] ?? undefined;
+ gs.activeEditorPath = fallback;
+ gs.onActiveEditorChangedObservable?.notifyObservers();
+ if (fallback) {
+ this._monacoManager.switchActiveFile(fallback);
+ }
+ }
+ };
+
+ private _closeOthers = (path: string) => {
+ const gs = this.props.globalState;
+ gs.openEditors = [path];
+ gs.onOpenEditorsChangedObservable?.notifyObservers();
+ if (gs.activeEditorPath !== path) {
+ gs.activeEditorPath = path;
+ gs.onActiveEditorChangedObservable?.notifyObservers();
+ this._monacoManager.switchActiveFile(path);
+ }
+ this.setState({ tabOrder: [path] });
+ };
+
+ private _closeAll = () => {
+ const gs = this.props.globalState;
+ gs.openEditors = [];
+ gs.activeEditorPath = undefined;
+ gs.onOpenEditorsChangedObservable?.notifyObservers();
+ gs.onActiveEditorChangedObservable?.notifyObservers();
+ const entry = gs.entryFilePath;
+ if (entry) {
+ this._openEditor(entry);
+ }
+ this.setState({ tabOrder: entry ? [entry] : [] });
+ };
+
+ private _removeFile = (path: string) => {
+ if (this.props.globalState.entryFilePath === path) {
+ alert("You can’t delete the entry file.");
+ return;
+ }
+ const disp = this._toDisplay(path);
+ if (!confirm(`Delete ${disp}?`)) {
+ return;
+ }
+ this._monacoManager.removeFile(path);
+ const next = this.state.order.filter((p: string) => p !== path);
+ this._commitOrder(next);
+ };
+
+ private _setEntry = (path: string) => {
+ this.props.globalState.entryFilePath = path;
+ this.props.globalState.onManifestChangedObservable.notifyObservers();
+ };
+
+ // Dialog confirm path (from ActivityBar)
+ private _confirmFileDialog = (type: DialogKind, filename: string, targetPath?: string) => {
+ const internal = this._toInternal(filename.trim());
+ const filesMap = this.props.globalState.files || {};
+ const exists = Object.prototype.hasOwnProperty.call(filesMap, internal);
+
+ if (type === "create") {
+ if (!internal) {
+ return alert("Please enter a file name.");
+ }
+ if (exists) {
+ return alert("A file with that name already exists.");
+ }
+ this._monacoManager.addFile(internal, `// ${filename}`);
+
+ if (Object.prototype.hasOwnProperty.call(this.props.globalState.files || {}, internal)) {
+ const next = this._dedupeOrder(this.state.order.concat(internal));
+ this._commitOrder(next);
+ this._monacoManager.switchActiveFile(internal);
+ }
+ return;
+ }
+
+ if (type === "rename" && targetPath) {
+ if (internal === targetPath || this._toDisplay(targetPath) === filename.trim()) {
+ return;
+ }
+ if (exists) {
+ return alert("A file with that name already exists.");
+ }
+
+ this._monacoManager.renameFile(targetPath, internal);
+ const order = this.state.order.map((p: string) => (p === targetPath ? internal : p));
+ this._commitOrder(order);
+ if (this.props.globalState.entryFilePath === targetPath) {
+ this.props.globalState.entryFilePath = internal;
+ this.props.globalState.onManifestChangedObservable.notifyObservers();
+ }
+ return;
+ }
+
+ if (type === "duplicate" && targetPath) {
+ if (exists) {
+ return alert("A file with that name already exists.");
+ }
+ const content = this.props.globalState.files[targetPath] ?? "";
+ this._monacoManager.addFile(internal, content);
+
+ if (Object.prototype.hasOwnProperty.call(this.props.globalState.files || {}, internal)) {
+ const next = this.state.order.slice();
+ const idxInOrder = next.indexOf(targetPath);
+ const insertAt = idxInOrder === -1 ? next.length : idxInOrder + 1;
+ next.splice(insertAt, 0, internal);
+ this._commitOrder(this._dedupeOrder(next));
+ this._monacoManager.switchActiveFile(internal);
+ }
+ return;
+ }
+ };
+
+ // ---------- Keyboard ----------
+ private _handleKeyDown = (e: KeyboardEvent) => {
+ // New file
+ if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "g") {
+ e.preventDefault();
+ e.stopPropagation();
+ this._activityBarRef.current?.openCreateDialog();
+ return;
+ }
+ // Open search
+ if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "f") {
+ e.preventDefault();
+ e.stopPropagation();
+ this.setState({
+ searchOpen: true,
+ explorerOpen: false,
+ sessionOpen: false,
+ });
+ return;
+ }
+ // Open File Explorer
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "e") {
+ e.preventDefault();
+ e.stopPropagation();
+ this.setState({
+ searchOpen: false,
+ explorerOpen: true,
+ sessionOpen: false,
+ });
+ return;
+ }
+ // Toggle Activity Bar
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "b") {
+ e.preventDefault();
+ e.stopPropagation();
+ this.setState((s) => ({
+ searchOpen: false,
+ explorerOpen: !s.explorerOpen,
+ sessionOpen: false,
+ }));
+ return;
+ }
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
+ const handleSecondKey = (e2: KeyboardEvent) => {
+ if (e2.key.toLowerCase() === "w") {
+ e2.preventDefault();
+ const activeTab = this.props.globalState.activeEditorPath;
+ if (activeTab) {
+ this._closeOthers(activeTab);
+ }
+ }
+ if ((e2.metaKey || e2.ctrlKey) && e2.key.toLowerCase() === "w") {
+ e2.preventDefault();
+ this._closeAll();
+ }
+ window.removeEventListener("keydown", handleSecondKey);
+ };
+ window.addEventListener("keydown", handleSecondKey);
+ }
+ };
+
+ // ---------- Context menu ----------
+ private _openCtxMenu = (e: React.MouseEvent, path: string) => {
+ e.preventDefault();
+ this.setState({ ctx: { open: true, x: e.clientX, y: e.clientY, path } });
+ };
+ private _closeCtxMenu = (e?: MouseEvent) => {
+ if (!this.state.ctx.open) {
+ return;
+ }
+ if (e && (e.target as HTMLElement).closest(".pg-tab-menu")) {
+ return;
+ }
+ this.setState({ ctx: { open: false, x: 0, y: 0, path: null } });
+ };
+
+ // ---------- Drag & drop (tabs) ----------
+ private _onDragStart = (e: React.DragEvent, path: string) => {
+ this._draggingPath = path;
+ e.dataTransfer.setData("text/plain", path);
+ e.dataTransfer.effectAllowed = "move";
+ const target = e.target as HTMLElement;
+ const rect = target.getBoundingClientRect();
+ e.dataTransfer.setDragImage(target, rect.width / 2, rect.height / 2);
+ };
+ private _onDragOver = (e: React.DragEvent, targetPath: string) => {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = "move";
+ if (this._draggingPath && this._draggingPath !== targetPath) {
+ const targetIndex = this.state.tabOrder.indexOf(targetPath);
+ if (targetIndex !== -1) {
+ this.setState({ dragOverIndex: targetIndex });
+ }
+ }
+ };
+ private _onDrop = (e: React.DragEvent, targetPath: string) => {
+ e.preventDefault();
+ const src = this._draggingPath || e.dataTransfer.getData("text/plain");
+ this._draggingPath = null;
+ this.setState({ dragOverIndex: -1 });
+ if (!src || src === targetPath) {
+ return;
+ }
+ // Reorder open editor tabs (visual tab order) instead of full file order.
+ const order = [...this.state.tabOrder];
+ const from = order.indexOf(src);
+ const to = order.indexOf(targetPath);
+ if (from === -1 || to === -1 || from === to) {
+ return;
+ }
+ const [moved] = order.splice(from, 1);
+ order.splice(to, 0, moved);
+ this.setState({ tabOrder: order, dragOverIndex: -1 });
+ };
+ private _onDragEnd = () => {
+ this._draggingPath = null;
+ this.setState({ dragOverIndex: -1 });
+ };
+ private _onMouseDownTab = (e: React.MouseEvent, path: string) => {
+ if (e.button === 1) {
+ e.preventDefault();
+ if (this.props.globalState.entryFilePath === path) {
+ return;
+ }
+ this._removeFile(path);
+ }
+ };
+
+ public override render() {
+ const { theme, explorerOpen, searchOpen, sessionOpen } = this.state;
+ const entry = this.props.globalState.entryFilePath;
+
+ return (
+
+
+ {/* Far-left Activity Bar */}
+
this.setState((s) => ({ searchOpen: !s.searchOpen, explorerOpen: false, sessionOpen: false }))}
+ explorerOpen={explorerOpen}
+ onToggleExplorer={() => this.setState((s) => ({ explorerOpen: !s.explorerOpen, sessionOpen: false, searchOpen: false }))}
+ sessionOpen={sessionOpen}
+ onToggleSession={() => this.setState((s) => ({ sessionOpen: !s.sessionOpen, explorerOpen: false, searchOpen: false }))}
+ />
+
+ {/* Split between Explorer (left) and Main (right) */}
+
+ {/* Left: Explorer panel (can be hidden) */}
+ {explorerOpen ? (
+
+
+
EXPLORER
+
+
this._activityBarRef.current?.openCreateDialog()} title="New File">
+
+
+
+
+
this._activityBarRef.current?.openCreateDialog()}
+ onRename={(oldPath) => this._activityBarRef.current?.openRenameDialog(oldPath, this._toDisplay(oldPath))}
+ onDelete={(p) => this._removeFile(p)}
+ />
+
+ ) : searchOpen ? (
+
+
+
{
+ const p = path;
+ this._openEditor(p);
+ const ed = this._monacoManager.editorHost.editor;
+ if (ed) {
+ ed.revealRangeInCenter(range, 0 /** ScrollType::Smooth */);
+ ed.setSelection(range);
+ ed.focus();
+ }
+ }}
+ />
+
+ ) : (
+
+ )}
+ {explorerOpen || searchOpen ? :
}
+
+ {/* Right: Tabs (top) + Monaco (below) */}
+
+ {/* Tabs Bar */}
+
+
{
+ this._activityBarRef.current?.openCreateDialog();
+ e.stopPropagation();
+ }}
+ ref={this._tabsHostRef}
+ >
+
+ {this.state.tabOrder
+ .filter((p) => (this.props.globalState.openEditors || []).includes(p))
+ .map((p) => {
+ const isActive = p === this.props.globalState.activeEditorPath;
+ const display = this._toDisplay(p);
+ const isEntry = p === entry;
+ return (
+
this._openEditor(p)}
+ onContextMenu={(e) => this._openCtxMenu(e, p)}
+ onMouseDown={(e) => this._onMouseDownTab(e, p)}
+ onDragStart={(e) => this._onDragStart(e, p)}
+ onDragOver={(e) => this._onDragOver(e, p)}
+ onDrop={(e) => this._onDrop(e, p)}
+ onDragEnd={this._onDragEnd}
+ title={`${display}${isEntry ? " (entry)" : ""}${isActive ? " (active)" : ""}`}
+ >
+ {isEntry && (
+
+ {"★"}
+
+ )}
+ {display}
+ {
+ e.stopPropagation();
+ this._closeEditor(p);
+ }}
+ title="Close tab"
+ >
+ ×
+
+
+ );
+ })}
+
+
+
+
+ {/* Editor host */}
+
+
+
+
+
+ {/* Tab context menu */}
+ {this.state.ctx.open &&
+ this.state.ctx.path &&
+ (() => {
+ const isEntryTarget = this.state.ctx.path === entry;
+ return (
+
e.stopPropagation()}>
+ {
+ this._openEditor(this.state.ctx.path!);
+ this._closeCtxMenu();
+ }}
+ >
+ Open
+
+ this._activityBarRef.current?.openRenameDialog(this.state.ctx.path!, this._toDisplay(this.state.ctx.path!))}>Rename…
+ this._activityBarRef.current?.openDuplicateDialog(this.state.ctx.path!)}>Duplicate…
+
+ {
+ this._closeOthers(this.state.ctx.path!);
+ this._closeCtxMenu();
+ }}
+ >
+ Close Others
+
+ {
+ this._closeAll();
+ this._closeCtxMenu();
+ }}
+ >
+ Close All
+
+
+ {
+ if (!isEntryTarget) {
+ this._setEntry(this.state.ctx.path!);
+ this._closeCtxMenu();
+ }
+ }}
+ title={isEntryTarget ? "Already the entry file" : "Set as entry"}
+ >
+ Set as entry
+
+
+ {
+ if (!isEntryTarget) {
+ this._removeFile(this.state.ctx.path!);
+ this._closeCtxMenu();
+ }
+ }}
+ title={isEntryTarget ? "Entry file cannot be deleted" : "Delete"}
+ >
+ Delete
+
+
+ );
+ })()}
+
+ );
+ }
+}
diff --git a/packages/tools/playground/src/components/editor/searchPanelComponent.tsx b/packages/tools/playground/src/components/editor/searchPanelComponent.tsx
new file mode 100644
index 00000000000..7e0f374a7cd
--- /dev/null
+++ b/packages/tools/playground/src/components/editor/searchPanelComponent.tsx
@@ -0,0 +1,351 @@
+// components/searchPanel.tsx
+/* eslint-disable jsdoc/require-jsdoc */
+import * as React from "react";
+import * as monaco from "monaco-editor/esm/vs/editor/editor.api";
+import { debounce } from "ts-debounce";
+import RegexIcon from "./icons/regex.svg";
+import WholeWordIcon from "./icons/wholeWord.svg";
+import CaseSensitiveIcon from "./icons/caseSensitive.svg";
+import ReplaceIcon from "./icons/replace.svg";
+import ReplaceAllIcon from "./icons/replaceAll.svg";
+import SearchIcon from "./icons/search.svg";
+import { Icon } from "./iconComponent";
+
+import "../../scss/search.scss";
+
+type Match = {
+ filePath: string;
+ range: monaco.IRange;
+ lineText: string;
+ previewStartCol: number;
+ previewEndCol: number;
+};
+
+type ResultsByFile = Record;
+
+function EscapeRegExp(s: string) {
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+export const SearchPanel: React.FC<{
+ onOpenAt: (path: string, range: monaco.IRange) => void;
+}> = ({ onOpenAt }) => {
+ const [query, setQuery] = React.useState("");
+ const [replacement, setReplacement] = React.useState("");
+ const [useRegex, setUseRegex] = React.useState(false);
+ const [matchCase, setMatchCase] = React.useState(false);
+ const [wholeWord, setWholeWord] = React.useState(false);
+
+ const [results, setResults] = React.useState({});
+ const [isSearching, setIsSearching] = React.useState(false);
+ const [expanded, setExpanded] = React.useState>({});
+
+ const filesCount = Object.keys(results).length;
+ const matchesCount = React.useMemo(() => Object.values(results).reduce((s, arr) => s + arr.length, 0), [results]);
+
+ // Build a preview window around the match for nice context
+ const buildMatchPreview = React.useCallback((model: monaco.editor.ITextModel, rng: monaco.IRange) => {
+ const line = model.getLineContent(rng.startLineNumber);
+ // Show ~40 chars around the match
+ const context = 40;
+ const start = Math.max(1, rng.startColumn - context);
+ const end = Math.min(model.getLineMaxColumn(rng.startLineNumber), rng.endColumn + context);
+
+ return {
+ lineText: line.slice(start - 1, end - 1),
+ previewStartCol: rng.startColumn - start + 1,
+ previewEndCol: rng.endColumn - start + 1,
+ };
+ }, []);
+
+ const compilePattern = React.useCallback(() => {
+ if (!query) {
+ return null;
+ }
+
+ if (useRegex || wholeWord) {
+ const source = useRegex ? query : `\\b${EscapeRegExp(query)}\\b`;
+ const flags = matchCase ? "g" : "gi";
+ try {
+ return new RegExp(source, flags);
+ } catch {
+ return null;
+ }
+ }
+
+ return null;
+ }, [query, useRegex, matchCase, wholeWord]);
+
+ const doSearch = React.useCallback(() => {
+ const q = query.trim();
+ if (!q) {
+ setResults({});
+ return;
+ }
+ setIsSearching(true);
+
+ const models = monaco.editor.getModels();
+
+ const byFile: ResultsByFile = {};
+ const rx = compilePattern();
+
+ for (const model of models) {
+ const uriStr = model.uri.toString();
+ if (!uriStr.startsWith("file:///pg/")) {
+ continue;
+ }
+ let found: monaco.editor.FindMatch[] = [];
+
+ if (rx) {
+ // Regex path
+ found = model.findMatches(rx.source, false, true, matchCase, null, true, 10000);
+ } else {
+ // Plain substring path
+ found = model.findMatches(q, false, false, matchCase, null, true, 10000);
+ }
+
+ if (!found.length) {
+ continue;
+ }
+
+ const filePath = model.uri.path || uriStr;
+ const matches: Match[] = found.map((mm) => {
+ const rng = mm.range;
+ const { lineText, previewStartCol, previewEndCol } = buildMatchPreview(model, rng);
+ return { filePath, range: rng, lineText, previewStartCol, previewEndCol };
+ });
+
+ byFile[filePath] = matches;
+ }
+
+ // Expand sections that have matches (default: expanded)
+ const nextExpanded: Record = {};
+ for (const f of Object.keys(byFile)) {
+ nextExpanded[f] = true;
+ }
+
+ setExpanded(nextExpanded);
+ setResults(byFile);
+ setIsSearching(false);
+ }, [query, matchCase, buildMatchPreview, compilePattern]);
+
+ const debouncedSearch = React.useMemo(() => debounce(doSearch, 250, { maxWait: 600 }), [doSearch]);
+
+ React.useEffect(() => {
+ // eslint-disable-next-line
+ debouncedSearch();
+ return () => {
+ try {
+ debouncedSearch.cancel();
+ } catch {}
+ };
+ }, [debouncedSearch, query, matchCase, useRegex, wholeWord]);
+
+ const runSearchNow = React.useCallback(() => {
+ try {
+ debouncedSearch.cancel();
+ } catch {}
+ doSearch();
+ }, [debouncedSearch, doSearch]);
+
+ // Replace helpers
+ const replaceInFile = React.useCallback(
+ (filePath: string) => {
+ const model = monaco.editor.getModels().find((m) => (m.uri.path || m.uri.toString()) === filePath);
+ if (!model || !results[filePath]?.length) {
+ return;
+ }
+
+ const rx = compilePattern();
+ const edits: monaco.editor.IIdentifiedSingleEditOperation[] = [];
+
+ // Apply bottom-up so earlier edits don't disturb later ranges
+ const matches = [...results[filePath]].sort((a, b) =>
+ a.range.startLineNumber === b.range.startLineNumber ? b.range.startColumn - a.range.startColumn : b.range.startLineNumber - a.range.startLineNumber
+ );
+
+ for (const m of matches) {
+ const text = model.getValueInRange(m.range);
+ let newText = replacement;
+ if (rx) {
+ try {
+ // allow $1 backrefs when regex toggle is on or whole-word (regex) is used
+ const realRx = new RegExp(rx.source, matchCase ? "g" : "gi");
+ newText = text.replace(realRx, replacement);
+ } catch {
+ // fallback: literal replacement
+ newText = replacement;
+ }
+ }
+ edits.push({ range: m.range, text: newText, forceMoveMarkers: true });
+ }
+
+ if (edits.length) {
+ model.pushEditOperations([], edits, () => null);
+ }
+
+ // Trigger a refresh
+ runSearchNow();
+ },
+ [results, replacement, compilePattern, matchCase, runSearchNow]
+ );
+
+ const replaceAll = React.useCallback(() => {
+ // Confirm with window.alert
+ if (!window.confirm(`Are you sure you want to replace all occurrences over ${Object.keys(results).length} files?`)) {
+ return;
+ }
+ for (const file of Object.keys(results)) {
+ replaceInFile(file);
+ }
+ }, [results, replaceInFile]);
+
+ return (
+
+
+
+
+
+
+
+
+ {isSearching ? "Searching" : `${matchesCount} results in ${filesCount} file${filesCount === 1 ? "" : "s"}`}
+
+ {
+ const all = Object.fromEntries(Object.keys(results).map((k) => [k, true]));
+ setExpanded(all);
+ }}
+ >
+ Expand all
+
+ {
+ const none = Object.fromEntries(Object.keys(results).map((k) => [k, false]));
+ setExpanded(none);
+ }}
+ >
+ Collapse all
+
+
+
+
+
+
+ {filesCount === 0 ? (
+
No results
+ ) : (
+ Object.keys(results).map((file) => {
+ const items = results[file];
+ const isOpen = expanded[file];
+ return (
+
+
setExpanded((s) => ({ ...s, [file]: !s[file] }))}>
+ {isOpen ? "▾" : "▸"}
+ {file.replace("/pg/", "")}
+ {items.length}
+ {
+ e.stopPropagation();
+ replaceInFile(file);
+ }}
+ title="Replace all in this file"
+ >
+
+
+
+
+
+
+ {isOpen && (
+
+ {items.map((m, idx) => {
+ const before = m.lineText.slice(0, Math.max(0, m.previewStartCol - 1));
+ const hit = m.lineText.slice(Math.max(0, m.previewStartCol - 1), Math.max(0, m.previewEndCol - 1));
+ const after = m.lineText.slice(Math.max(0, m.previewEndCol - 1));
+ return (
+ onOpenAt(m.filePath.replace("/pg/", ""), m.range)}
+ title={`${m.range.startLineNumber}:${m.range.startColumn}`}
+ >
+
+ {m.range.startLineNumber}:{m.range.startColumn}
+
+
+ {before}
+ {hit}
+ {after}
+
+
+ );
+ })}
+
+ )}
+
+ );
+ })
+ )}
+
+
+ );
+};
diff --git a/packages/tools/playground/src/components/monacoComponent.tsx b/packages/tools/playground/src/components/monacoComponent.tsx
deleted file mode 100644
index f59b4f6b147..00000000000
--- a/packages/tools/playground/src/components/monacoComponent.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-import * as React from "react";
-import { MonacoManager } from "../tools/monacoManager";
-import type { GlobalState } from "../globalState";
-
-import "../scss/monaco.scss";
-
-interface IMonacoComponentProps {
- className?: string;
- refObject: React.RefObject;
- globalState: GlobalState;
-}
-export class MonacoComponent extends React.Component {
- private readonly _mutationObserver: MutationObserver;
- private _monacoManager: MonacoManager;
-
- public constructor(props: IMonacoComponentProps) {
- super(props);
-
- this._monacoManager = new MonacoManager(this.props.globalState);
-
- this.props.globalState.onEditorFullcreenRequiredObservable.add(() => {
- const editorDiv = this.props.refObject.current! as any;
- if (editorDiv.requestFullscreen) {
- editorDiv.requestFullscreen();
- // iOS 12 introduced the ewbkit prefixed version of the Fullscreen API. Not needed since 16.4
- } else if (editorDiv.webkitRequestFullscreen) {
- editorDiv.webkitRequestFullscreen();
- }
- });
-
- // NOTE: This is a workaround currently needed when using Fluent. Specifically, Fluent currently manages focus (handling tab key events),
- // and only excludes elements with `contentEditable` set to `"true"`. Monaco does not set this attribute on textareas by default. Probably
- // Fluent should be checking `isContentEditable` instead as `contentEditable` can be set to `"inherit"`, in which case `isContentEditable`
- // is inherited from the parent element. If it worked this way, then we could simply set `contentEditable` to `"true"` on the monacoHost
- // div in this file.
- this._mutationObserver = new MutationObserver((mutations) => {
- for (const mutation of mutations) {
- for (const node of mutation.addedNodes) {
- if (node.nodeType === Node.ELEMENT_NODE) {
- // If the added node is a textarea
- if ((node as HTMLElement).tagName === "TEXTAREA") {
- (node as HTMLTextAreaElement).contentEditable = "true";
- }
- // If the added node contains textareas as descendants
- (node as HTMLElement).querySelectorAll?.("textarea").forEach((textArea) => {
- textArea.contentEditable = "true";
- });
- }
- }
- }
- });
- }
-
- override componentDidMount() {
- const hostElement = this.props.refObject.current!;
- this._mutationObserver.observe(hostElement, { childList: true, subtree: true });
- // eslint-disable-next-line @typescript-eslint/no-floating-promises
- this._monacoManager.setupMonacoAsync(hostElement, true);
- }
-
- override componentWillUnmount(): void {
- this._mutationObserver.disconnect();
- }
-
- public override render() {
- return
;
- }
-}
diff --git a/packages/tools/playground/src/components/rendererComponent.tsx b/packages/tools/playground/src/components/rendererComponent.tsx
index 1c60c3b4554..5df53ce3eac 100644
--- a/packages/tools/playground/src/components/rendererComponent.tsx
+++ b/packages/tools/playground/src/components/rendererComponent.tsx
@@ -6,9 +6,10 @@ import type { GlobalState } from "../globalState";
import { RuntimeMode } from "../globalState";
import { Utilities } from "../tools/utilities";
import { DownloadManager } from "../tools/downloadManager";
-import { Engine, EngineStore, WebGPUEngine, LastCreatedAudioEngine } from "@dev/core";
+import { AddFileRevision } from "../tools/localSession";
-import type { Nullable, Scene } from "@dev/core";
+import { Engine, EngineStore, WebGPUEngine, LastCreatedAudioEngine, Logger } from "@dev/core";
+import type { Nullable, Scene, ThinEngine } from "@dev/core";
import "../scss/rendering.scss";
@@ -19,23 +20,24 @@ interface IRenderingComponentProps {
globalState: GlobalState;
}
-declare const Ammo: any;
-declare const Recast: any;
-declare const HavokPhysics: any;
-declare const HK: any;
-
+/**
+ *
+ */
export class RenderingComponent extends React.Component {
- private _engine: Nullable;
- private _scene: Nullable;
+ /** Engine instance for current run */
+ private _engine!: Nullable;
+ /** Active scene */
+ private _scene!: Nullable;
private _canvasRef: React.RefObject;
private _downloadManager: DownloadManager;
- private _babylonToolkitWasLoaded = false;
- private _tmpErrorEvent?: ErrorEvent;
private _inspectorFallback: boolean = false;
+ /**
+ * Create the rendering component.
+ * @param props Props
+ */
public constructor(props: IRenderingComponentProps) {
super(props);
-
this._canvasRef = React.createRef();
// Create the global handleException
@@ -55,7 +57,7 @@ export class RenderingComponent extends React.Component {
@@ -110,22 +112,10 @@ export class RenderingComponent extends React.Component {
- this._tmpErrorEvent = err;
+ private _saveError = (_err: ErrorEvent) => {
+ // no-op placeholder retained for backward compatibility
};
- private async _loadScriptAsync(url: string): Promise {
- return await new Promise((resolve) => {
- const script = document.createElement("script");
- script.setAttribute("type", "text/javascript");
- script.setAttribute("src", url);
- script.onload = () => {
- resolve();
- };
- document.head.appendChild(script);
- });
- }
-
private _notifyError(message: string) {
this.props.globalState.onErrorObservable.notifyObservers({
message: message,
@@ -135,40 +125,6 @@ export class RenderingComponent extends React.Component = {
- "\u200B": "⟦ZWSP⟧",
- "\u200C": "⟦ZWNJ⟧",
- "\u200D": "⟦ZWJ⟧",
- "\u200E": "⟦LRM⟧",
- "\u200F": "⟦RLM⟧",
- "\u202A": "⟦LRE⟧",
- "\u202B": "⟦RLE⟧",
- "\u202C": "⟦PDF⟧",
- "\u202D": "⟦LRO⟧",
- "\u202E": "⟦RLO⟧",
- "\u2060": "⟦WJ⟧",
- "\u2066": "⟦LRI⟧",
- "\u2067": "⟦RLI⟧",
- "\u2068": "⟦FSI⟧",
- "\u2069": "⟦PDI⟧",
- "\uFEFF": "⟦BOM⟧",
- };
-
- result = result.replace(/[\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/g, (ch) => markers[ch] || `⟦U+${ch.charCodeAt(0).toString(16).toUpperCase()}⟧`);
-
- result = result.replace(hiddenCharsRegex, "").replace(controlCharsRegex, "");
-
- return result;
- }
-
private async _compileAndRunAsync() {
if (this._preventReentrancy) {
return;
@@ -177,8 +133,6 @@ export class RenderingComponent extends React.Component {
+ engine.runRenderLoop(() => {
+ if (!this._scene || !this._engine) {
+ return;
+ }
+
+ if (this.props.globalState.runtimeMode === RuntimeMode.Editor && window.innerWidth > this.props.globalState.MobileSizeTrigger) {
+ if (canvasEl.width !== canvasEl.clientWidth || canvasEl.height !== canvasEl.clientHeight) {
+ this._engine.resize();
+ }
+ }
+
+ if (this._scene.activeCamera || this._scene.frameGraph || (this._scene.activeCameras && this._scene.activeCameras.length > 0)) {
+ this._scene.render();
+ }
+
+ // Update FPS if camera is not a webxr camera
+ if (!(this._scene.activeCamera && this._scene.activeCamera.getClassName && this._scene.activeCamera.getClassName() === "WebXRCamera")) {
+ if (this.props.globalState.runtimeMode !== RuntimeMode.Full) {
+ this.props.globalState.fpsElement.innerHTML = this._engine.getFps().toFixed() + " fps";
+ }
+ }
+ });
+ };
+
if (useWebGPU) {
globalObject.createDefaultEngine = async function () {
try {
@@ -247,252 +227,84 @@ export class RenderingComponent extends React.Component -1 && typeof Ammo === "function") {
- ammoInit = "await Ammo();";
- }
-
- // Check for Recast.js
- let recastInit = "";
- if (code.indexOf("RecastJSPlugin") > -1 && typeof Recast === "function") {
- recastInit = "await Recast();";
- }
-
- let havokInit = "";
- if (code.includes("HavokPlugin") && typeof HavokPhysics === "function" && typeof HK === "undefined") {
- havokInit = "globalThis.HK = await HavokPhysics();";
- }
-
- let audioInit = "";
- if (code.includes("BABYLON.Sound")) {
- audioInit =
- "BABYLON.AbstractEngine.audioEngine = BABYLON.AbstractEngine.AudioEngineFactory(window.engine.getRenderingCanvas(), window.engine.getAudioContext(), window.engine.getAudioDestination());";
- }
-
- const babylonToolkit =
- !this._babylonToolkitWasLoaded &&
- (code.includes("BABYLON.Toolkit.SceneManager.InitializePlayground") ||
- code.includes("SM.InitializePlayground") ||
- location.href.indexOf("BabylonToolkit") !== -1 ||
- Utilities.ReadBoolFromStore("babylon-toolkit", false));
- // Check for Babylon Toolkit
- if (babylonToolkit) {
- await this._loadScriptAsync("https://cdn.jsdelivr.net/gh/BabylonJS/BabylonToolkit@master/Runtime/babylon.toolkit.js");
- this._babylonToolkitWasLoaded = true;
- }
- Utilities.StoreBoolToStore("babylon-toolkit-used", babylonToolkit);
-
- let createEngineFunction = "createDefaultEngine";
- let createSceneFunction = "";
- let checkCamera = true;
- let checkSceneCount = true;
-
- if (code.indexOf("createEngine") !== -1) {
- createEngineFunction = "createEngine";
- }
-
- // Check for different typos
- if (code.indexOf("delayCreateScene") !== -1) {
- // delayCreateScene
- createSceneFunction = "delayCreateScene";
- checkCamera = false;
- } else if (code.indexOf("createScene") !== -1) {
- // createScene
- createSceneFunction = "createScene";
- } else if (code.indexOf("CreateScene") !== -1) {
- // CreateScene
- createSceneFunction = "CreateScene";
- } else if (code.indexOf("createscene") !== -1) {
- // createscene
- createSceneFunction = "createscene";
- }
+ (window as any).engine = this._engine;
- if (!createSceneFunction) {
- this._preventReentrancy = false;
- return this._notifyError("You must provide a function named createScene.");
- } else {
- // Write an "initFunction" that creates engine and scene
- // using the appropriate default or user-provided functions.
- // (Use "window.x = foo" to allow later deletion, see above.)
- code += `
- window.initFunction = async function() {
- ${ammoInit}
- ${havokInit}
- ${recastInit}
- var asyncEngineCreation = async function() {
- try {
- return ${createEngineFunction}();
- } catch(e) {
- console.log("the available createEngine function failed. Creating the default engine instead");
- return createDefaultEngine();
- }
+ const createEngineAsync = async () => {
+ let engine: Engine | null = null;
+ if (useWebGPU) {
+ try {
+ const wgpu = new WebGPUEngine(this._canvasRef.current!, { enableAllFeatures: true, setMaximumLimits: true, enableGPUDebugMarkers: true });
+ await wgpu.initAsync();
+ engine = wgpu as any;
+ } catch {
+ Logger.Warn("WebGPU not supported. Falling back to WebGL.");
}
-
- window.engine = await asyncEngineCreation();
-
- const engineOptions = window.engine.getCreationOptions?.();
- if (!engineOptions || engineOptions.audioEngine !== false) {
- ${audioInit}
- }`;
- code += "\r\nif (!engine) throw 'engine should not be null.';";
-
- globalObject.startRenderLoop = (engine: Engine, canvas: HTMLCanvasElement) => {
- engine.runRenderLoop(() => {
- if (!this._scene || !this._engine) {
- return;
- }
-
- if (this.props.globalState.runtimeMode === RuntimeMode.Editor && window.innerWidth > this.props.globalState.MobileSizeTrigger) {
- if (canvas.width !== canvas.clientWidth || canvas.height !== canvas.clientHeight) {
- this._engine.resize();
- }
- }
-
- if (this._scene.activeCamera || this._scene.frameGraph || (this._scene.activeCameras && this._scene.activeCameras.length > 0)) {
- this._scene.render();
- }
-
- // Update FPS if camera is not a webxr camera
- if (!(this._scene.activeCamera && this._scene.activeCamera.getClassName && this._scene.activeCamera.getClassName() === "WebXRCamera")) {
- if (this.props.globalState.runtimeMode !== RuntimeMode.Full) {
- this.props.globalState.fpsElement.innerHTML = this._engine.getFps().toFixed() + " fps";
- }
- }
- });
- };
- code += "\r\nstartRenderLoop(engine, canvas);";
-
- if (this.props.globalState.language === "JS") {
- code += "\r\n" + "window.scene = " + createSceneFunction + "();";
- } else {
- const startCar = code.search("var " + createSceneFunction);
- code = code.substring(0, startCar) + code.substr(startCar + 4);
- code += "\n" + "window.scene = " + createSceneFunction + "();";
- }
-
- code += `}`; // Finish "initFunction" definition.
-
- this._tmpErrorEvent = undefined;
-
- try {
- // Execute the code
- Utilities.FastEval(code);
- } catch (e) {
- (window as any).handleException(e);
}
-
- // Return early if there is a parameter to prevent auto-running
- if (this.props.globalState.doNotRun) {
- this.props.globalState.doNotRun = false;
- this._preventReentrancy = false;
- this.props.globalState.onDisplayWaitRingObservable.notifyObservers(false);
- return;
- }
-
- await globalObject.initFunction();
-
- this._engine = globalObject.engine;
-
- if (!this._engine) {
- this._preventReentrancy = false;
- return this._notifyError("createEngine function must return an engine.");
- }
-
- if (!globalObject.scene) {
- this._preventReentrancy = false;
- return this._notifyError("createScene function must return a scene.");
- }
-
- let sceneToRenderCode = "sceneToRender = scene";
-
- // if scene returns a promise avoid checks
- if (globalObject.scene.then) {
- checkCamera = false;
- checkSceneCount = false;
- sceneToRenderCode = "scene.then(returnedScene => { sceneToRender = returnedScene; });\r\n";
+ if (!engine) {
+ engine = new Engine(canvas, true, {
+ disableWebGL2Support: forceWebGL1,
+ preserveDrawingBuffer: true,
+ stencil: true,
+ });
}
-
- const createEngineZip = createEngineFunction === "createEngine" ? zipVariables : zipVariables + defaultEngineZip;
-
- this.props.globalState.zipCode = createEngineZip + ";\r\n" + code + ";\r\ninitFunction().then(() => {" + sceneToRenderCode;
- }
-
- if (globalObject.scene.then) {
- globalObject.scene.then((s: Scene) => {
- this._scene = s;
- globalObject.scene = this._scene;
- });
- } else {
- this._scene = globalObject.scene as Scene;
+ return engine;
+ };
+ (window as any).canvas = canvas;
+
+ let sceneResult: Scene | null = null;
+ let createdEngine: ThinEngine | null = null;
+ try {
+ [sceneResult, createdEngine] = await runner.run(createEngineAsync, canvas);
+ this._engine = createdEngine as Engine;
+ } catch (err) {
+ (window as any).handleException(err as Error);
+ this._preventReentrancy = false;
+ this.props.globalState.onDisplayWaitRingObservable.notifyObservers(false);
+ return;
}
-
- if (checkSceneCount && this._engine.scenes.length === 0) {
+ if (!sceneResult) {
this._preventReentrancy = false;
- return this._notifyError("You must at least create a scene.");
+ return this._notifyError("createScene export not found or returned null.");
}
- if (this._engine.scenes[0] && displayInspector && !globalObject.scene.then) {
- this.props.globalState.onInspectorRequiredObservable.notifyObservers();
- }
+ this._scene = sceneResult as Scene;
+ (window as any).scene = this._scene;
+ (window as any).startRenderLoop(this._engine, canvas);
+
+ this._engine!.scenes[0]?.executeWhenReady(() => {
+ this.props.globalState.onRunExecutedObservable.notifyObservers();
+ });
- if (checkCamera && this._engine.scenes[0].activeCamera == null) {
- this._preventReentrancy = false;
- return this._notifyError("You must at least create a camera.");
- } else if (globalObject.scene.then) {
- globalObject.scene.then(
- () => {
- if (this._engine!.scenes[0] && displayInspector) {
- this.props.globalState.onInspectorRequiredObservable.notifyObservers();
- }
- this._engine!.scenes[0].executeWhenReady(() => {
- this.props.globalState.onRunExecutedObservable.notifyObservers();
- });
- this._preventReentrancy = false;
- },
- (err: any) => {
- // eslint-disable-next-line no-console
- console.error(err);
- this._preventReentrancy = false;
- }
- );
- } else {
- this._engine.scenes[0].executeWhenReady(() => {
- this.props.globalState.onRunExecutedObservable.notifyObservers();
- });
- this._preventReentrancy = false;
- }
- } catch (err) {
- // eslint-disable-next-line no-console
- console.error(err, "Retrying if possible. If this error persists please notify the team.");
- this.props.globalState.onErrorObservable.notifyObservers(this._tmpErrorEvent || err);
+ this._preventReentrancy = false;
+ this.props.globalState.onDisplayWaitRingObservable.notifyObservers(false);
+ return;
+ } catch (e) {
+ (window as any).handleException(e as Error);
this._preventReentrancy = false;
}
- this.props.globalState.onDisplayWaitRingObservable.notifyObservers(false);
}
+ /**
+ * Render canvas element
+ * @returns Canvas element
+ */
public override render() {
- return ;
+ return ;
}
}
diff --git a/packages/tools/playground/src/custom.d.ts b/packages/tools/playground/src/custom.d.ts
index 47da7f206b9..7c95290a5ea 100644
--- a/packages/tools/playground/src/custom.d.ts
+++ b/packages/tools/playground/src/custom.d.ts
@@ -1,4 +1,5 @@
/* eslint-disable @typescript-eslint/naming-convention */
+/* eslint-disable jsdoc/require-jsdoc */
declare module "*.svg" {
const content: string;
export default content;
@@ -9,7 +10,13 @@ declare module "*.module.scss" {
export = content;
}
-declare module "monaco-editor/esm/vs/language/typescript/languageFeatures" {
+declare module "monaco-editor/esm/vs/base/common/lifecycle" {
+ export interface IDisposable {
+ dispose(): void;
+ }
+}
+
+declare module "monaco-editor/esm/vs/editor/common/services/languageFeatures" {
const SuggestAdapter: any;
export type SuggestAdapter = any;
}
diff --git a/packages/tools/playground/src/globalState.ts b/packages/tools/playground/src/globalState.ts
index be7be588547..b4b11293b4a 100644
--- a/packages/tools/playground/src/globalState.ts
+++ b/packages/tools/playground/src/globalState.ts
@@ -1,8 +1,11 @@
+/* eslint-disable jsdoc/require-jsdoc */
+
import { Utilities } from "./tools/utilities";
import type { CompilationError } from "./components/errorDisplayComponent";
import { Observable } from "@dev/core";
-
import type { Nullable } from "@dev/core";
+import type { V2Runner } from "./tools/monaco/run/runner";
+import type { V2Manifest } from "./tools/snippet";
export enum EditionMode {
Desktop,
@@ -15,30 +18,43 @@ export enum RuntimeMode {
Full = 1,
Frame = 2,
}
-
export class GlobalState {
// eslint-disable-next-line @typescript-eslint/naming-convention
public readonly MobileSizeTrigger = 1024;
// eslint-disable-next-line @typescript-eslint/naming-convention
public SnippetServerUrl = "https://snippet.babylonjs.com";
+ public currentCode!: string;
- public currentCode: string;
// eslint-disable-next-line @typescript-eslint/naming-convention
- public getCompiledCode: () => Promise = async () => {
- return await Promise.resolve(this.currentCode);
+ public getRunnable: () => Promise = async () => {
+ throw new Error("Must be set in runtime");
};
+ currentRunner?: V2Runner | null;
+
public language = Utilities.ReadStringFromStore("language", "JS");
- public fpsElement: HTMLDivElement;
+ public fpsElement!: HTMLDivElement;
public mobileDefaultMode = EditionMode.RenderingOnly;
-
public runtimeMode = RuntimeMode.Editor;
-
public currentSnippetTitle = "";
public currentSnippetDescription = "";
public currentSnippetTags = "";
public currentSnippetToken = "";
+ public currentSnippetRevision = "";
+ public files: Record = {};
- public zipCode = "";
+ /** Active file path (internal) */
+ public activeFilePath: string = Utilities.ReadStringFromStore("language", "JS") === "JS" ? "index.js" : "index.ts";
+ /** Import map for V2 multi-file */
+ public importsMap: Record = {};
+ /** Entry file for execution */
+ public entryFilePath: string = Utilities.ReadStringFromStore("language", "JS") === "JS" ? "index.js" : "index.ts";
+ /** Manual tab order */
+ public filesOrder: string[] = [];
+
+ public openEditors: string[] = []; // paths that are open in tabs
+ public activeEditorPath: string | undefined; // current active tab
+ public onOpenEditorsChangedObservable: Observable = new Observable();
+ public onActiveEditorChangedObservable: Observable = new Observable();
public onRunRequiredObservable = new Observable();
public onRunExecutedObservable = new Observable();
@@ -66,13 +82,20 @@ export class GlobalState {
public onThemeChangedObservable = new Observable();
public onFontSizeChangedObservable = new Observable();
public onLanguageChangedObservable = new Observable();
- public onNavigateRequiredObservable = new Observable<{ lineNumber: number; column: number }>();
+ public onNavigateRequiredObservable = new Observable<{
+ lineNumber: number;
+ column: number;
+ }>();
public onExamplesDisplayChangedObservable = new Observable();
public onQRCodeRequiredObservable = new Observable();
public onNewDropdownButtonClicked = new Observable();
+ public onFilesChangedObservable = new Observable();
+ public onActiveFileChangedObservable = new Observable();
+ public onManifestChangedObservable = new Observable();
+ public onFilesOrderChangedObservable = new Observable();
+ public onV2HydrateRequiredObservable = new Observable();
public loadingCodeInProgress = false;
public onCodeLoaded = new Observable();
-
public doNotRun = false;
}
diff --git a/packages/tools/playground/src/playground.tsx b/packages/tools/playground/src/playground.tsx
index aa01ba4c8ff..eb1b5087354 100644
--- a/packages/tools/playground/src/playground.tsx
+++ b/packages/tools/playground/src/playground.tsx
@@ -1,6 +1,6 @@
import * as React from "react";
import { createRoot } from "react-dom/client";
-import { MonacoComponent } from "./components/monacoComponent";
+import { MonacoComponent } from "./components/editor/monacoComponent";
import { RenderingComponent } from "./components/rendererComponent";
import { GlobalState, EditionMode, RuntimeMode } from "./globalState";
import { FooterComponent } from "./components/footerComponent";
@@ -25,7 +25,22 @@ interface IPlaygroundProps {
runtimeMode: RuntimeMode;
}
-export class Playground extends React.Component {
+/**
+ *
+ */
+export class Playground extends React.Component<
+ IPlaygroundProps,
+ {
+ /**
+ *
+ */
+ errorMessage: string;
+ /**
+ *
+ */
+ mode: EditionMode;
+ }
+> {
private _monacoRef: React.RefObject;
private _renderingRef: React.RefObject;
private _splitterRef: React.RefObject;
@@ -33,8 +48,17 @@ export class Playground extends React.Component div {
+ height: 100%;
+ }
+}
+
+.pg-main-fullwidth {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ position: relative;
+}
+
+.pg-sidebar {
+ border-right: 1px solid var(--pg-border);
+ background: var(--pg-bg);
+ overflow: auto;
+ display: flex;
+ flex-direction: column;
+ min-width: 200px;
+
+ &.collapsed {
+ width: 0 !important;
+ overflow: hidden;
+ border-right: none;
+ }
+}
+
+.pg-sidebar-header {
+ padding: 8px 12px;
+ font-size: 11px;
+ letter-spacing: 0.08em;
+ color: var(--pg-muted);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ border-bottom: 1px solid var(--pg-border);
+ flex-shrink: 0;
+}
+
+.pg-sidebar-actions {
+ display: flex;
+ gap: 4px;
+ align-items: center;
+}
+
+.pg-sidebar-add {
+ background: none;
+ border: none;
+ color: var(--pg-muted);
+ cursor: pointer;
+ padding: 2px 6px;
+ font-size: 14px;
+ font-weight: bold;
+ transition: color 0.1s ease;
+ border-radius: var(--pg-radius);
+
+ &:hover {
+ color: var(--pg-text);
+ background: var(--pg-elev-1);
+ }
+}
+
+.pg-sidebar-toggle {
+ background: none;
+ border: none;
+ color: var(--pg-muted);
+ cursor: pointer;
+ padding: 2px 4px;
+ font-size: 12px;
+ transition: color 0.1s ease;
+
+ &:hover {
+ color: var(--pg-text);
+ }
+}
+
+.pg-sidebar-toggle-collapsed {
+ position: absolute;
+ top: 8px;
+ left: 8px;
+ background: var(--pg-bg);
+ border: 1px solid var(--pg-border);
+ color: var(--pg-muted);
+ cursor: pointer;
+ padding: 4px 6px;
+ font-size: 12px;
+ border-radius: var(--pg-radius);
+ z-index: 10;
+ transition: all 0.1s ease;
+
+ &:hover {
+ color: var(--pg-text);
+ background: var(--pg-tab-hover);
+ }
+}
+
+.pg-explorer {
+ font-size: 13px;
+ flex: 1;
+ overflow: auto;
+ color: var(--pg-text);
+}
+
+.pg-splitter {
+ height: 2px !important;
+ background-color: var(--pg-tab-active) !important;
+}
+
+/* VS Code-like layout styles */
+.pg-vscode-layout {
+ display: flex;
+ height: 100%;
+ background: var(--pg-bg);
+}
+
+.pg-vscode-layout .split-container,
+.pg-vscode-layout .pg-split-main {
+ flex: 1;
+ height: 100%;
+}
+
+// Splitter bar
+.pg-split-main > div:nth-child(2) {
+ background: var(--pg-border);
+ width: 3px !important;
+ &:hover {
+ background: rgba(0, 0, 0, 0.2);
+ }
+}
+.pg-activity-bar-col {
+ width: 40px;
+}
+
+.pg-activity-bar {
+ width: 40px;
+ background: rgb(32, 25, 54);
+ border-right: 1px inset rgba(0, 0, 0, 0.2);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 0px 0;
+ flex-shrink: 0;
+ height: 100%;
+}
+
+.pg-activity-item {
+ width: 40px;
+ height: 40px;
+ background: none;
+ border: none;
+ cursor: pointer;
+ border-radius: var(--pg-radius);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ transition: all 0.1s ease;
+ overflow: visible;
+
+ &.active {
+ background: #3f3461;
+ }
+ &:hover {
+ background: lighten(#3f3461, 10%);
+ }
+
+ // svg {
+ // width: 20px;
+ // height: 20px;
+ // display: block;
+ // stroke-width: 0.5;
+ // fill: white;
+ // vector-effect: non-scaling-stroke;
+ // }
+ // svg [fill]:not([fill="none"]) {
+ // fill: white !important;
+ // }
+ // svg [stroke]:not([stroke="none"]) {
+ // stroke: white !important;
+ // }
+ // svg path,
+ // svg rect,
+ // svg circle,
+ // svg polygon,
+ // svg line,
+ // svg polyline {
+ // fill: white;
+ // stroke: white;
+ // }
+}
+
+.pg-explorer-panel {
+ width: 280px;
+ background: var(--pg-bg);
+ display: flex;
+ flex-direction: column;
+ border-right: 1px solid var(--pg-border);
+ height: 100%;
+ min-width: 0; /* Allow collapsing to 0 */
+}
+
+.pg-explorer-panel-collapsed {
+ width: 0 !important;
+ min-width: 0;
+ background: var(--pg-bg);
+ height: 100%;
+}
+
+.pg-panel-header {
+ padding: 8px 5px 8px 12px;
+ font-size: 11px;
+ letter-spacing: 0.08em;
+ color: var(--pg-muted);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ border-bottom: 1px solid var(--pg-border);
+ flex-shrink: 0;
+ background: var(--pg-bg);
+}
+
+.pg-panel-actions {
+ display: flex;
+ gap: 2px;
+ align-items: center;
+ height: 24px;
+ width: 24px;
+}
+
+.pg-panel-action {
+ width: 24px;
+ height: 24px;
+ margin: 0;
+ padding: 0;
+ background: none;
+ border: none;
+ color: var(--pg-muted);
+ cursor: pointer;
+ transition: color 0.1s ease;
+ border-radius: var(--pg-radius);
+
+ &:hover {
+ color: var(--pg-text);
+ background: var(--pg-elev-1);
+ }
+ color: currentColor;
+ svg {
+ stroke-width: 0.5;
+ fill: currentColor;
+ }
+ svg [fill]:not([fill="none"]) {
+ fill: currentColor !important;
+ }
+ svg [stroke]:not([stroke="none"]) {
+ stroke: currentColor !important;
+ }
+ svg path,
+ svg rect,
+ svg circle,
+ svg polygon,
+ svg line,
+ svg polyline {
+ fill: currentColor;
+ stroke: currentColor;
+ }
+}
+
+.pg-main-content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ width: 100%;
+ background: var(--pg-bg);
+}
+
+.pg-expl-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 2px 8px;
+ cursor: default;
+ -webkit-user-select: none;
+ user-select: none;
+ color: var(--pg-text);
+
+ // Dynamic padding based on depth
+ &[data-depth="0"] {
+ padding-left: 10px;
+ }
+ &[data-depth="1"] {
+ padding-left: 20px;
+ }
+ &[data-depth="2"] {
+ padding-left: 32px;
+ }
+ &[data-depth="3"] {
+ padding-left: 44px;
+ }
+ &[data-depth="4"] {
+ padding-left: 56px;
+ }
+ &[data-depth="5"] {
+ padding-left: 68px;
+ }
+
+ &.file {
+ cursor: pointer;
+ min-height: 22px;
+
+ &:hover {
+ background: var(--pg-tab-hover);
+ }
+
+ &.active {
+ background: var(--pg-file-active) !important;
+ font-weight: 500;
+ color: var(--pg-text);
+ font-style: italic;
+ }
+
+ &.entry {
+ .name::after {
+ left: 5px;
+ top: -1px;
+ font-size: 9px;
+ content: "★";
+ color: var(--pg-accent);
+ position: relative;
+ }
+ }
+ }
+
+ &.dir {
+ font-weight: 500;
+ color: var(--pg-text);
+ min-height: 22px;
+
+ .name {
+ flex: 1;
+ }
+
+ .new {
+ opacity: 0;
+ }
+
+ &:hover .new {
+ opacity: 1;
+ }
+ }
+
+ .name {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+}
+
+.pg-expl-item .actions {
+ display: flex;
+ gap: 2px;
+
+ .action-btn {
+ opacity: 0;
+ background: none;
+ border: none;
+ color: var(--pg-muted);
+ cursor: pointer;
+ padding: 2px 4px;
+ font-size: 12px;
+ transition: all 0.1s ease;
+ border-radius: var(--pg-radius);
+ min-width: 16px;
+ height: 16px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ &:hover {
+ color: var(--pg-text);
+ background: var(--pg-elev-1);
+ }
+
+ &.close-btn {
+ font-size: 14px;
+ font-weight: bold;
+ }
+
+ &.delete-btn:hover {
+ color: var(--pg-danger);
+ background: rgba(215, 58, 73, 0.1);
+ }
+ }
+}
+
+.pg-expl-item:hover .actions .action-btn {
+ opacity: 1;
+}
+
+.pg-expl-item.dir .action-btn.new {
+ background: none;
+ border: none;
+ color: var(--pg-muted);
+ cursor: pointer;
+ padding: 2px 4px;
+ font-size: 12px;
+ transition: all 0.1s ease;
+ border-radius: var(--pg-radius);
+
+ &:hover {
+ color: var(--pg-text);
+ background: var(--pg-elev-1);
+ }
+}
+.pg-main {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ height: 100%;
+ flex: 1;
+}
+.pg-tabs-bar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ border-bottom: 1px solid var(--pg-border);
+}
+.pg-tabs-host {
+ overflow: auto;
+ flex: 1;
+}
+.pg-tabs {
+ display: flex;
+ gap: 2px;
+}
+.pg-tab {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 8px;
+ border: 1px solid transparent;
+ border-bottom: none;
+}
+.pg-tab.active {
+ background: var(--pg-tab-active);
+ border-color: var(--pg-border);
+ border-bottom: 1px solid var(--pg-tab-active);
+}
+
+.pg-tab__entry {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 9px;
+ line-height: 1;
+ z-index: 2;
+ color: var(--pg-accent);
+ pointer-events: none;
+}
+
+.pg-editor-host {
+ flex: 1;
+ min-height: 0;
+}
+
+.pg-tabs-bar {
+ display: flex;
+ align-items: stretch;
+ height: var(--pg-bar-height);
+ min-height: var(--pg-bar-height);
+ flex: 0 0 auto;
+ background: var(--pg-bg);
+ border-bottom: 1px solid var(--pg-border);
+ padding: 0;
+ margin: 0;
+ position: relative;
+ &.entry > .name {
+ position: relative;
+ padding-right: 12px;
+ }
+ &.entry > .name .pg-expl-entry {
+ position: absolute;
+ top: 50%;
+ right: 0;
+ transform: translateY(-50%);
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--pg-accent);
+ }
+ z-index: 1;
+}
+
+.pg-tabs {
+ display: inline-flex;
+ flex: none;
+ white-space: nowrap;
+ max-width: 100%;
+
+ overflow-x: auto;
+ overflow-y: hidden;
+ -webkit-overflow-scrolling: touch;
+ scrollbar-gutter: stable;
+
+ padding-bottom: 6px;
+ margin-bottom: -6px;
+
+ scrollbar-width: thin;
+ scrollbar-color: var(--vscode-scrollbarSlider-background) transparent;
+
+ &:hover {
+ scrollbar-color: var(--vscode-scrollbarSlider-hoverBackground) transparent;
+ }
+
+ &::-webkit-scrollbar {
+ height: 6px;
+ background: transparent;
+ }
+ &::-webkit-scrollbar-track {
+ background: transparent;
+ margin-inline: 2px;
+ }
+ &::-webkit-scrollbar-thumb {
+ background-color: var(--vscode-scrollbarSlider-background);
+ border-radius: 6px;
+ }
+ &:hover::-webkit-scrollbar-thumb {
+ background-color: var(--vscode-scrollbarSlider-hoverBackground);
+ }
+ &:active::-webkit-scrollbar-thumb {
+ background-color: var(--vscode-scrollbarSlider-activeBackground);
+ }
+ &::-webkit-scrollbar-corner {
+ background: transparent;
+ }
+}
+
+.pg-tabs-bar {
+ display: flex;
+ align-items: stretch;
+ height: var(--pg-bar-height);
+ flex: 0 0 auto;
+ background: var(--pg-bg);
+ border-bottom: 1px solid var(--pg-border);
+ position: relative;
+ z-index: 1;
+}
+
+.pg-tabs-host {
+ flex: 1 1 auto;
+ min-width: 0;
+ height: var(--pg-bar-height);
+ position: relative;
+ overflow-y: hidden;
+ overflow-x: auto;
+}
+
+.pg-tab {
+ position: relative;
+ display: flex;
+ align-items: center;
+ min-width: 120px;
+ max-width: 240px;
+ height: var(--pg-bar-height);
+ padding: 0 8px 0 16px;
+ font-size: 13px;
+ background: var(--pg-tab-bg);
+ color: var(--pg-text);
+ border: none;
+ border-right: 1px solid var(--pg-elev-1);
+ cursor: pointer;
+ -webkit-user-select: none;
+ user-select: none;
+ white-space: nowrap;
+ transition: all var(--pg-speed) ease;
+
+ &::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: inherit;
+ border-top-left-radius: var(--pg-radius-lg);
+ border-top-right-radius: var(--pg-radius-lg);
+ }
+
+ &:first-child::before {
+ border-top-left-radius: 0;
+ }
+
+ &:hover {
+ background: var(--pg-tab-hover);
+ &::before {
+ background: var(--pg-tab-hover);
+ }
+ }
+
+ &.active {
+ background: var(--pg-tab-active);
+ color: var(--pg-text);
+ border: 1px solid var(--pg-elev-2);
+ border-bottom: none;
+ margin-bottom: -1px;
+ z-index: 2;
+
+ &::before {
+ background: var(--pg-tab-active);
+ border-radius: var(--pg-radius);
+ box-shadow:
+ inset 0 1px 0 var(--pg-sunken-top),
+ inset 0 -1px 0 var(--pg-sunken-side-sh),
+ inset 1px 0 0 var(--pg-sunken-side-sh),
+ inset -1px 0 0 var(--pg-sunken-side-hl);
+ }
+
+ &::after {
+ content: "";
+ position: absolute;
+ bottom: -1px;
+ left: 0;
+ right: 0;
+ height: 1px;
+ background: var(--pg-tab-active);
+ z-index: 3;
+ }
+ }
+
+ &.dragging {
+ opacity: 0.5;
+ transform: rotate(2deg);
+ z-index: 1000;
+ }
+
+ &.drag-over {
+ position: relative;
+ &::before {
+ border: 2px solid #007acc;
+ border-bottom: none;
+ }
+ }
+
+ .pg-tab__name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ flex: 1 1 auto;
+ padding-right: 6px;
+ position: relative;
+ z-index: 2;
+ }
+
+ .pg-tab__close {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ width: 20px;
+ height: 20px;
+ margin-left: auto;
+ font-size: 16px;
+ line-height: 1;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ opacity: 0;
+ border-radius: var(--pg-radius);
+ cursor: pointer;
+ transition: all var(--pg-speed) ease;
+ position: relative;
+ z-index: 2;
+
+ &:hover {
+ opacity: 1;
+ background: rgba(0, 0, 0, 0.1);
+ }
+ &:active {
+ background: rgba(0, 0, 0, 0.2);
+ }
+ }
+
+ &:hover .pg-tab__close {
+ opacity: 0.7;
+ }
+ &.active .pg-tab__close {
+ opacity: 0.8;
+ }
+}
+
+.pg-monaco-wrapper.pg-theme-dark {
+ .pg-tab {
+ border-right-color: var(--pg-elev-1);
+ border: 1px solid var(--pg-elev-1);
+ border-bottom: none;
+ margin-bottom: -1px;
+
+ &.active {
+ border-right-color: var(--pg-tab-bg);
+ }
+
+ .pg-tab__close {
+ &:hover {
+ background: rgba(255, 255, 255, 0.1);
+ }
+ &:active {
+ background: rgba(255, 255, 255, 0.2);
+ }
+ }
+
+ &.drag-over::before {
+ border-color: #0e639c;
+ }
+ }
+}
+
+.pg-tab__add,
+.pg-tab__code {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: var(--pg-bar-height);
+ height: var(--pg-bar-height);
+ padding: 0;
+ margin: 0;
+ border: none;
+ background: transparent;
+ color: var(--pg-muted);
+ font-size: 16px;
+ line-height: 1;
+ cursor: pointer;
+ transition: all var(--pg-speed) ease;
+
+ &:hover {
+ background: rgba(0, 0, 0, 0.05);
+ }
+ &:active {
+ background: rgba(0, 0, 0, 0.1);
+ }
+}
+
+.pg-tab-menu {
+ position: fixed;
+ z-index: 1000;
+ min-width: 180px;
+ background: var(--pg-menu-bg);
+ color: var(--pg-menu-text);
+ border: 1px solid var(--pg-menu-border);
+ border-radius: 0;
+ box-shadow: var(--pg-menu-shadow);
+ padding: 4px 0;
+ font-size: 13px;
+ font-family: var(--pg-font);
+ height: initial !important;
+ button {
+ display: block;
+ width: 100%;
+ text-align: left;
+ border: 0;
+ background: transparent;
+ padding: 6px 12px;
+ cursor: pointer;
+ color: inherit;
+ font-size: inherit;
+ font-family: inherit;
+
+ &:hover {
+ background: var(--pg-menu-hover);
+ }
+ &.danger {
+ color: var(--pg-danger);
+ }
+ }
+
+ hr {
+ border: 0;
+ border-top: 1px solid var(--pg-border);
+ margin: 4px 0;
+ }
+}
+
+.pg-monaco-wrapper.pg-theme-dark .pg-tab__add {
+ color: var(--pg-text);
+ &:hover {
+ background: rgba(255, 255, 255, 0.05);
+ }
+ &:active {
+ background: rgba(255, 255, 255, 0.1);
+ }
+}
+
+.pg-monaco-wrapper.pg-theme-dark .pg-tab__code {
+ svg {
+ &:hover {
+ background: rgba(255, 255, 255, 0.05);
+ }
+ &:active {
+ background: rgba(255, 255, 255, 0.1);
+ }
+ }
+}
+
+.pg-tab__code {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: var(--pg-bar-height);
+ height: var(--pg-bar-height);
+ padding: 0;
+ margin: 0;
+ border: none;
+ background: transparent;
+ color: var(--pg-muted);
+ line-height: 1;
+ cursor: pointer;
+ transition: all var(--pg-speed) ease;
+
+ svg {
+ transform: scale(0.9);
+
+ path,
+ rect,
+ circle,
+ polygon,
+ line,
+ polyline {
+ fill: currentColor;
+ }
+
+ *[stroke] {
+ stroke: currentColor;
+ }
+ }
+
+ &:hover {
+ background: rgba(0, 0, 0, 0.05);
+ }
+ &:active {
+ background: rgba(0, 0, 0, 0.1);
+ }
+}
+
+.pg-monaco-wrapper.pg-theme-dark .pg-tab__code {
+ color: var(--pg-text); // This will now inherit to the SVG fills
+
+ &:hover {
+ background: rgba(255, 255, 255, 0.05);
+ }
+ &:active {
+ background: rgba(255, 255, 255, 0.1);
+ }
+}
+
+// (Duplicate earlier refined) keep dark theme inheritance working
+
+.pg-tab .pg-tab__close[disabled] {
+ opacity: 0.35;
+ cursor: not-allowed;
+}
+
+.pg-tab-menu button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ pointer-events: none;
+}
+
+.pg-tabs-host {
+ flex: 1 1 auto;
+ min-width: 0;
+ height: var(--pg-bar-height);
+ position: relative;
+ overflow: hidden;
+}
+
+@media screen and (max-width: 1140px) {
+ .pg-tabs-bar {
+ height: 32px;
+ }
+
+ .pg-tab {
+ height: 32px;
+ min-width: 100px;
+ max-width: 180px;
+ padding: 0 6px 0 12px;
+ font-size: 12px;
+ }
+
+ .pg-tab__add {
+ width: 32px;
+ height: 32px;
+ font-size: 14px;
+ }
+}
+
+@media screen and (max-width: 768px) {
+ .pg-monaco-wrapper {
+ --pg-bar-height: 25px;
+ }
+ .pg-tabs-bar {
+ height: 25px;
+ }
+
+ .pg-tab {
+ height: 25px;
+ min-width: 80px;
+ max-width: 140px;
+ padding: 0 4px 0 8px;
+ font-size: 11px;
+ }
+
+ .pg-tab__name {
+ max-width: 80px;
+ }
+
+ .pg-tab__close {
+ width: 16px;
+ height: 16px;
+
+ svg {
+ width: 10px;
+ height: 10px;
+ }
+ }
+
+ .pg-tab__add {
+ width: 25px;
+ height: 25px;
+ font-size: 12px;
+ }
+
+ .pg-tab__entry {
+ font-size: 10px;
+ }
+}
+
+@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
+ .pg-tab {
+ border-width: 0.5px;
+ }
+
+ .pg-tabs-bar {
+ border-bottom-width: 0.5px;
+ }
+}
+
+.monaco-editor-container {
+ .pg-monaco-wrapper {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ }
+}
+
+#pg-split .pg-monaco-wrapper {
+ height: 100%;
+ width: 100%;
+}
+
+.monaco-container .pg-monaco-wrapper,
+.code-panel .pg-monaco-wrapper,
+[data-panel="code"] .pg-monaco-wrapper {
+ height: 100%;
+ width: 100%;
+}
+
+.pg-monaco-wrapper .monaco-editor {
+ height: 100%;
+ width: 100%;
+}
+
+.pg-monaco-wrapper,
+.pg-monaco-wrapper .monaco-editor .monaco-scrollable-element,
+.pg-monaco-wrapper .monaco-editor .overflow-guard {
+ height: 100% !important;
+}
+
+.pg-monaco-wrapper {
+ grid-row: 1 / -1;
+ grid-column: 1 / -1;
+}
+
+.pg-tabs-host > .monaco-scrollable-element {
+ touch-action: none;
+ overscroll-behavior-x: contain;
+}
+.pg-tabs-host {
+ touch-action: pan-x;
+ overscroll-behavior-x: contain;
+}
+
+// monaco overrides
+
+.minimap,
+.sticky-widget,
+.editor-widget {
+ z-index: 1 !important;
+}
diff --git a/packages/tools/playground/src/scss/monaco.scss b/packages/tools/playground/src/scss/monaco.scss
index a1a1958c85d..80d6cb5acbb 100644
--- a/packages/tools/playground/src/scss/monaco.scss
+++ b/packages/tools/playground/src/scss/monaco.scss
@@ -4,4 +4,8 @@
padding: 0;
margin: 0;
overflow: unset;
+ .monaco-editor {
+ width: 100% !important;
+ height: 100%;
+ }
}
diff --git a/packages/tools/playground/src/scss/search.scss b/packages/tools/playground/src/scss/search.scss
new file mode 100644
index 00000000000..4da589c5ddb
--- /dev/null
+++ b/packages/tools/playground/src/scss/search.scss
@@ -0,0 +1,305 @@
+/* scss/search.scss */
+
+.pg-search-panel {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ background: var(--pg-tab-bg, #ececec);
+ color: var(--pg-text, #333);
+ border-right: 1px solid var(--pg-border, #e5e5e5);
+ font-family: var(--pg-font, system-ui, -apple-system, "Segoe UI", sans-serif);
+}
+.pg-search-option {
+ cursor: pointer;
+ margin: 0px;
+ padding: 0px;
+ border-radius: 2px;
+ width: 18px;
+ height: 18px;
+ background: transparent;
+ border: none;
+ color: gray;
+ &:hover {
+ background-color: var(--pg-tab-hover);
+ }
+}
+.pg-search-option-enabled {
+ color: white;
+ border: 1px solid;
+ border-color: #007acc;
+ background-color: rgba(0, 127, 212, 0.4);
+}
+
+.pg-search-bar {
+ padding: 10px;
+ border-bottom: 1px solid var(--pg-border, #e5e5e5);
+ background: var(--pg-bg, #f3f3f3);
+ display: grid;
+ gap: 8px;
+}
+
+.pg-search-row {
+ display: flex;
+ gap: 2px;
+ align-items: center;
+ position: relative;
+ width: 100%;
+
+ &--options {
+ flex-wrap: wrap;
+ gap: 1px;
+ width: unset;
+ height: 18px;
+ position: absolute;
+ right: 10px;
+ top: 2px;
+ }
+}
+
+textarea.pg-search-input {
+ flex: 1 1 auto;
+ height: 16px;
+ padding: 4px 4px;
+ resize: vertical;
+ background: var(--pg-elev-2, #fffffe) !important;
+ color: inherit;
+ outline: none;
+ width: 33%;
+ min-width: 20px !important;
+ font-size: 11px;
+ &::placeholder {
+ font-size: 11px;
+ color: inherit;
+ opacity: 0.7;
+ }
+ &:focus {
+ border-color: var(--pg-accent, #0675fc);
+ box-shadow: 0 0 0 2px color-mix(in oklab, var(--pg-accent, #0675fc) 30%, transparent);
+ }
+}
+
+.pg-search-btn {
+ min-width: 22px;
+ height: 22px;
+ padding: 0px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid var(--pg-border, #e5e5e5);
+ border-radius: 4px;
+ color: inherit;
+ cursor: pointer;
+
+ &:disabled {
+ opacity: 0.2;
+ cursor: default;
+ }
+
+ &--secondary {
+ background: var(--pg-elev-1, #eee);
+ &:hover:enabled {
+ background: var(--pg-elev-2, #ddd);
+ }
+ }
+}
+
+.pg-search-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ color: var(--pg-muted, #424242);
+
+ textarea {
+ accent-color: var(--pg-accent, #0675fc);
+ }
+
+ span:first-of-type {
+ display: inline-block;
+ min-width: 16px;
+ text-align: center;
+ font-weight: 600;
+ opacity: 0.85;
+ }
+
+ .pg-search-toggle-label {
+ opacity: 0.9;
+ }
+}
+
+.pg-search-meta {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 12px;
+ color: var(--pg-muted, #424242);
+}
+
+.pg-search-meta__actions {
+ display: flex;
+ gap: 10px;
+}
+
+.pg-link-btn {
+ background: transparent;
+ border: none;
+ color: var(--pg-accent, #0675fc);
+ cursor: pointer;
+ padding: 0;
+}
+
+.pg-search-results {
+ flex: 1 1 auto;
+ overflow: auto;
+ padding: 8px 0;
+ background: var(--pg-bg);
+
+ scrollbar-width: thin;
+ scrollbar-color: var(--vscode-scrollbarSlider-background) transparent;
+
+ &:hover {
+ scrollbar-color: var(--vscode-scrollbarSlider-hoverBackground) transparent;
+ }
+
+ &::-webkit-scrollbar {
+ height: 6px;
+ background: transparent;
+ }
+ &::-webkit-scrollbar-track {
+ background: transparent;
+ margin-inline: 2px;
+ }
+ &::-webkit-scrollbar-thumb {
+ background-color: var(--vscode-scrollbarSlider-background);
+ border-radius: 6px;
+ }
+ &:hover::-webkit-scrollbar-thumb {
+ background-color: var(--vscode-scrollbarSlider-hoverBackground);
+ }
+ &:active::-webkit-scrollbar-thumb {
+ background-color: var(--vscode-scrollbarSlider-activeBackground);
+ }
+ &::-webkit-scrollbar-corner {
+ background: transparent;
+ }
+}
+
+.pg-search-empty {
+ color: var(--pg-muted, #424242);
+ font-size: 13px;
+ padding: 12px 14px;
+}
+
+.pg-search-file {
+ border-top: 1px solid var(--pg-border, #e5e5e5);
+
+ &__title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 4px 8px;
+ cursor: pointer;
+ -webkit-user-select: none;
+ user-select: none;
+ background: var(--pg-bg);
+ &:hover {
+ background: var(--pg-tab-hover, #f8f8f8);
+ }
+ }
+
+ &__list {
+ list-style: none;
+ margin: 0px 0px 0px 10px;
+ padding: 6px 4px 0px 0px;
+ border-left: 2px solid var(--pg-border, #e5e5e5);
+ }
+}
+
+.pg-caret {
+ width: 12px;
+ color: var(--pg-muted, #424242);
+}
+
+.pg-filename {
+ flex: 1;
+ font-weight: 500;
+ font-size: 12px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.pg-count {
+ font-size: 10px;
+ background: var(--pg-elev-1, #eee);
+ text-align: center;
+ vertical-align: middle;
+ width: 17px;
+ line-height: 17px;
+ height: 17px;
+ padding: 1px;
+ border-radius: 20px;
+ color: var(--pg-muted, #424242);
+}
+
+.pg-file-replace {
+ margin-left: 8px;
+}
+
+.pg-search-hit {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 20px;
+ padding: 3px 20px 3px 12px;
+ width: calc(100% - 32px);
+ cursor: pointer;
+ border-radius: 4px;
+ font-size: 12px;
+ &:hover {
+ background: color-mix(in oklab, var(--pg-accent, #0675fc) 10%, transparent);
+ }
+}
+
+.pg-search-hit__loc {
+ min-width: 26px;
+ font-variant-numeric: tabular-nums;
+ color: var(--pg-muted, #424242);
+}
+
+.pg-search-hit__preview {
+ width: calc(100% - 26px);
+ text-align: left;
+ font-family: Menlo, Consolas, "Liberation Mono";
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.pg-hit {
+ background: color-mix(in oklab, var(--pg-accent, #0675fc) 30%, transparent);
+ color: inherit;
+ padding: 0 1px;
+ border-radius: 2px;
+}
+
+/* Dark theme tweaks */
+.pg-theme-dark .pg-search-panel {
+ background: var(--pg-tab-bg, #1f1f1f);
+ border-right-color: var(--pg-border, #2a2a2a);
+ color: var(--pg-text, #ddd);
+}
+.pg-theme-dark .pg-search-bar {
+ background: var(--pg-bg, #222);
+ border-bottom-color: var(--pg-border, #2a2a2a);
+}
+.pg-theme-dark .pg-search-input {
+ border-color: var(--pg-border, #2a2a2a);
+ background: var(--pg-tab-active, #1a1a1a);
+}
+.pg-theme-dark .pg-search-file__title:hover {
+ background: var(--pg-tab-hover, #2a2a2a);
+}
+.pg-theme-dark .pg-search-hit:hover {
+ background: color-mix(in oklab, var(--pg-accent, #3b82f6) 18%, transparent);
+}
diff --git a/packages/tools/playground/src/tools/downloadManager.ts b/packages/tools/playground/src/tools/downloadManager.ts
index ad5179a70ee..a678ab86500 100644
--- a/packages/tools/playground/src/tools/downloadManager.ts
+++ b/packages/tools/playground/src/tools/downloadManager.ts
@@ -1,162 +1,280 @@
-/* eslint-disable github/no-then */
-import { DynamicTexture, RawTexture } from "@dev/core";
+/* eslint-disable jsdoc/require-jsdoc */
+/* eslint-disable no-await-in-loop */
+
import type { GlobalState } from "../globalState";
-import type { Nullable } from "@dev/core";
-import type { Engine } from "@dev/core";
+import { Logger } from "@dev/core";
+import type { V2Manifest } from "./snippet";
// eslint-disable-next-line @typescript-eslint/naming-convention
declare let JSZip: any;
// eslint-disable-next-line @typescript-eslint/naming-convention
-declare let saveAs: any;
+declare let saveAs: (blob: Blob, name: string) => void;
+
+type V2PackSnapshot = {
+ manifest: V2Manifest;
+ cdnBase: string;
+ entryPathJs: string; // normalized to .js by the runner
+ rewritten: Record; // code as-run (imports currently __pg__/... blobs)
+ importMap: Record; // bare imports -> CDN URLs
+ usedBareImports: readonly string[];
+};
export class DownloadManager {
public constructor(public globalState: GlobalState) {}
- private async _addContentToZipAsync(zip: typeof JSZip, name: string, url: string, replace: Nullable, buffer = false): Promise {
- return await new Promise((resolve) => {
- if (url.substring(0, 5) == "data:" || url.substring(0, 5) == "http:" || url.substring(0, 5) == "blob:" || url.substring(0, 6) == "https:") {
- resolve();
- return;
+ private async _loadEsbuildAsync(): Promise {
+ let esbuild: any;
+ try {
+ // Use importShim to load the ESM module from URL
+ const module = await (window as any).importShim("https://unpkg.com/esbuild-wasm@0.21.5/esm/browser.js");
+ esbuild = module.default || module;
+
+ if (!esbuild) {
+ throw new Error("esbuild not found in imported module");
}
+ } catch (error) {
+ throw new Error(`Could not load esbuild: ${error}`);
+ }
+ if (!(esbuild as any).__pgInit) {
+ await esbuild.initialize({
+ wasmURL: "https://unpkg.com/esbuild-wasm@0.21.5/esbuild.wasm",
+ worker: true,
+ });
+ (esbuild as any).__pgInit = true;
+ }
+ return esbuild;
+ }
+ private async _bundleWithEsbuildAsync(snap: V2PackSnapshot): Promise {
+ const esbuild = await this._loadEsbuildAsync();
+ if (!esbuild) {
+ throw new Error("Esbuild failed to load");
+ }
- const xhr = new XMLHttpRequest();
+ const pathMapping: Record = {};
- xhr.open("GET", url, true);
+ for (const [originalPath] of Object.entries(snap.manifest.files)) {
+ const normalizedPath = originalPath.replace(/[.]tsx?$/i, ".js");
- if (buffer) {
- xhr.responseType = "arraybuffer";
- }
+ pathMapping[`__pg__/${normalizedPath}`] = originalPath;
+ pathMapping[normalizedPath] = originalPath;
+ }
- xhr.onreadystatechange = function () {
- if (xhr.readyState === 4) {
- if (xhr.status === 200) {
- let text;
- if (!buffer) {
- if (replace) {
- const splits = replace.split("\r\n");
- for (let index = 0; index < splits.length; index++) {
- splits[index] = " " + splits[index];
- }
- replace = splits.join("\r\n");
-
- text = xhr.responseText.replace("####INJECT####", replace);
- } else {
- text = xhr.responseText;
- }
- }
+ const entry = snap.entryPathJs;
+ const entrySpec = `__pg__/${entry}`;
- zip.file(name, buffer ? xhr.response : text);
+ const snapshotPlugin = {
+ name: "snapshot-loader",
+ setup(build: any) {
+ build.onResolve({ filter: /^__pg__\// }, (args: any) => {
+ const normalizedPath = args.path.slice(7); // remove __pg__/ prefix
+ const actualPath = pathMapping[args.path] || pathMapping[normalizedPath];
- resolve();
+ if (!actualPath) {
+ return { path: normalizedPath, namespace: "snapshot" };
}
- }
- };
- xhr.send(null);
- });
- }
+ return { path: actualPath, namespace: "snapshot" };
+ });
- private async _addTexturesToZipAsync(zip: typeof JSZip, index: number, textures: any[], folder: Nullable): Promise {
- if (index === textures.length || !textures[index].name) {
- return await Promise.resolve();
- }
+ build.onLoad({ filter: /.*/, namespace: "snapshot" }, (args: any) => {
+ args.path = args.path.split("?")[0];
+ const code = snap.rewritten[args.path];
+ if (!code) {
+ return { errors: [{ text: `Missing rewritten module: ${args.path}` }] };
+ }
- if (textures[index].isRenderTarget || textures[index] instanceof RawTexture || textures[index] instanceof DynamicTexture || textures[index].name.indexOf("data:") !== -1) {
- return await this._addTexturesToZipAsync(zip, index + 1, textures, folder);
- }
+ const ext = (args.path.match(/[.][^.]+$/)?.[0] || "").toLowerCase();
+ const loader = ext === ".ts" ? "js" : "js";
+
+ return {
+ contents: code,
+ loader,
+ resolveDir: "/",
+ };
+ });
+ },
+ };
+
+ const cdnPlugin = {
+ name: "cdn-loader",
+ setup(build: any) {
+ const cache = new Map();
+ const importMap = snap.importMap || {};
- if (textures[index].isCube) {
- if (textures[index].name.indexOf("dds") === -1 && textures[index].name.indexOf(".env") === -1) {
- if (textures[index]._extensions) {
- for (let i = 0; i < 6; i++) {
- textures.push({ name: textures[index].name + textures[index]._extensions[i] });
+ // Resolve bare imports to CDN URLs from importMap
+ build.onResolve({ filter: /^[^./]/ }, (args: any) => {
+ // Skip if it's already a URL
+ if (/^https?:\/\//i.test(args.path)) {
+ return null;
}
- } else if (textures[index]._files) {
- for (let i = 0; i < 6; i++) {
- textures.push({ name: textures[index]._files[i] });
+
+ // Use the importMap to resolve bare imports
+ const resolvedUrl = importMap[args.path];
+ if (resolvedUrl) {
+ return { path: resolvedUrl, namespace: "cdn" };
}
- }
- } else {
- textures.push({ name: textures[index].name });
- }
- return await this._addTexturesToZipAsync(zip, index + 1, textures, folder);
- }
- if (folder == null) {
- folder = zip.folder("textures");
- }
- let url;
+ // Fallback to esm.sh if not in importMap
+ return { path: `https://esm.sh/${args.path}`, namespace: "cdn" };
+ });
- if (textures[index].video) {
- url = textures[index].video.currentSrc;
- } else {
- url = textures[index].url;
- }
+ // Handle relative URLs within CDN packages
+ build.onResolve({ filter: /.*/, namespace: "cdn" }, (args: any) => {
+ if (/^https?:\/\//i.test(args.path)) {
+ return { path: args.path, namespace: "cdn" };
+ }
+ // Rebase relative imports within CDN packages
+ const url = new URL(args.path, args.importer);
+ return { path: url.href, namespace: "cdn" };
+ });
- const name = textures[index].name.replace("textures/", "");
+ // Load CDN modules
+ build.onLoad({ filter: /.*/, namespace: "cdn" }, async (args: any) => {
+ if (cache.has(args.path)) {
+ return { contents: cache.get(args.path)!, loader: "js" };
+ }
- if (url != null) {
- return await this._addContentToZipAsync(folder, name, url, null, true).then(async () => {
- return await this._addTexturesToZipAsync(zip, index + 1, textures, folder);
- });
- } else {
- return await this._addTexturesToZipAsync(zip, index + 1, textures, folder);
- }
- }
+ try {
+ const res = await fetch(args.path);
+ if (!res.ok) {
+ return { errors: [{ text: `HTTP ${res.status} ${args.path}` }] };
+ }
+ const text = await res.text();
+ cache.set(args.path, text);
+ return { contents: text, loader: "js" };
+ } catch (error) {
+ return { errors: [{ text: `Failed to fetch ${args.path}: ${error}` }] };
+ }
+ });
+ },
+ };
- private async _addImportedFilesToZipAsync(zip: typeof JSZip, index: number, importedFiles: string[], folder: Nullable): Promise {
- if (index === importedFiles.length) {
- return await Promise.resolve();
- }
+ const result = await esbuild.build({
+ entryPoints: [entrySpec],
+ bundle: true,
+ format: "esm",
+ platform: "browser",
+ target: ["es2020"],
+ plugins: [snapshotPlugin, cdnPlugin],
+ write: false,
+ sourcemap: "inline",
+ logLevel: "info",
+ });
- if (!folder) {
- folder = zip.folder("scenes");
+ if (result.errors && result.errors.length > 0) {
+ throw new Error(`Build failed: ${result.errors.map((e: any) => e.text).join(", ")}`);
}
- const url = importedFiles[index];
- const name = url.substring(url.lastIndexOf("/") + 1);
+ const output = result.outputFiles?.[0]?.text;
+ if (!output) {
+ throw new Error("No output generated by esbuild");
+ }
- return await this._addContentToZipAsync(folder, name, url, null, true).then(async () => {
- return await this._addImportedFilesToZipAsync(zip, index + 1, importedFiles, folder);
- });
+ return output;
}
- public download(engine: Engine) {
- const zip = new JSZip();
+ private _buildSingleFileHtmlFromBundle(bundleCode: string, title = "Babylon.js Playground") {
+ const codeLiteral = JSON.stringify(bundleCode); // safely embed
+ return `
+
+
+
+ ${title}
+
- const scene = engine.scenes[0];
- const textures = scene.textures?.slice(0) as any;
- const importedFiles = scene.importedMeshesFiles?.slice(0);
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
- const zipCode = this.globalState.zipCode;
+
+
+
+
+
+
+`;
+ }
+
+ /**
+ * Produces a zip with a single self-contained index.html.
+ * - All local modules are compiled with esbuild with source maps inlined.
+ * - Bare deps use CDN from the runner snapshot (no asset inlining/patching).
+ */
+ public async downloadAsync() {
+ this.globalState.onDisplayWaitRingObservable.notifyObservers(true);
+ try {
+ const runner = await this.globalState.getRunnable();
+ const snap = runner.getPackSnapshot?.() as V2PackSnapshot | null;
+ if (!snap) {
+ throw new Error("No pack snapshot available. Please run the scene once before downloading.");
}
- textures.push({ name: match[1] });
- // eslint-disable-next-line no-constant-condition
- } while (true);
-
- // eslint-disable-next-line @typescript-eslint/no-floating-promises
- this._addContentToZipAsync(zip, "index.html", "./zipContent/index.html", zipCode)
- .then(async () => {
- return await this._addTexturesToZipAsync(zip, 0, textures, null);
- })
- .then(async () => {
- return await this._addImportedFilesToZipAsync(zip, 0, importedFiles, null);
- })
- .then(() => {
- const blob = zip.generate({ type: "blob" });
- saveAs(blob, "sample.zip");
- this.globalState.onDisplayWaitRingObservable.notifyObservers(false);
- });
+ const bundleCode = await this._bundleWithEsbuildAsync(snap);
+ if (!bundleCode) {
+ throw new Error("Bundling produced no output.");
+ }
+
+ const html = this._buildSingleFileHtmlFromBundle(bundleCode, "Babylon.js Playground");
+
+ const zip = new JSZip();
+ zip.file("index.html", html);
+ const blob: Blob = await zip.generate({ type: "blob" });
+ saveAs(blob, "playground-bundled.zip");
+ } catch (e) {
+ Logger.Warn(`Download failed: ${(e as Error)?.message || e}`);
+ this.globalState.onErrorObservable.notifyObservers({
+ message: String((e as Error)?.message || e),
+ lineNumber: 0,
+ columnNumber: 0,
+ } as any);
+ } finally {
+ this.globalState.onDisplayWaitRingObservable.notifyObservers(false);
+ }
}
}
diff --git a/packages/tools/playground/src/tools/loadManager.ts b/packages/tools/playground/src/tools/loadManager.ts
index 53423bd81c6..e46aeddb780 100644
--- a/packages/tools/playground/src/tools/loadManager.ts
+++ b/packages/tools/playground/src/tools/loadManager.ts
@@ -1,6 +1,10 @@
+/* eslint-disable jsdoc/require-jsdoc */
import { DecodeBase64ToBinary, Logger } from "@dev/core";
import type { GlobalState } from "../globalState";
import { Utilities } from "./utilities";
+import { ReadLastLocal } from "./localSession";
+import type { SnippetData, SnippetPayload } from "./snippet";
+import { ManifestVersion, type V2Manifest } from "./snippet";
const DecodeBase64ToString = (base64Data: string): string => {
return atob(base64Data);
@@ -19,7 +23,6 @@ const DecodeBase64ToBinaryReproduced = (base64Data: string): ArrayBuffer => {
return bufferView.buffer;
};
-
export class LoadManager {
private _previousHash = "";
@@ -44,7 +47,8 @@ export class LoadManager {
const json = await this._pickJsonFileAsync();
if (json) {
location.hash = "";
- this._processJsonPayload(json);
+ // eslint-disable-next-line
+ this._processJsonPayloadAsync(json);
} else {
globalState.onDisplayWaitRingObservable.notifyObservers(false);
}
@@ -72,7 +76,7 @@ export class LoadManager {
const text = await file.text();
return text; // This is the raw JSON string
- } catch (err) {
+ } catch (err: any) {
if (err.name === "AbortError") {
Logger.Warn("User canceled file selection");
} else {
@@ -134,20 +138,19 @@ export class LoadManager {
}
}
- private _processJsonPayload(data: string) {
- if (data.indexOf("class Playground") !== -1) {
- if (this.globalState.language === "JS") {
- Utilities.SwitchLanguage("TS", this.globalState, true);
- }
- } else {
- // If we're loading JS content and it's TS page
- if (this.globalState.language === "TS") {
- Utilities.SwitchLanguage("JS", this.globalState, true);
- }
- }
-
- const snippet = JSON.parse(data);
-
+ // These are potential variables defined by existing Playgrounds
+ // That need to be exported in order to work in V2 format
+ private readonly _jsFunctions = [
+ "delayCreateScene",
+ "createScene",
+ "CreateScene",
+ "createscene",
+
+ // Engine
+ "createEngine",
+ ];
+ private async _processJsonPayloadAsync(data: string) {
+ const snippet = JSON.parse(data) as SnippetData;
// Check if title / descr / tags are already set
if (snippet.name != null && snippet.name != "") {
this.globalState.currentSnippetTitle = snippet.name;
@@ -168,7 +171,7 @@ export class LoadManager {
}
// Extract code
- const payload = JSON.parse(snippet.jsonPayload || snippet.payload);
+ const payload = JSON.parse(snippet.jsonPayload ?? snippet.payload ?? "") as SnippetPayload;
let code: string = payload.code.toString();
if (payload.unicode) {
@@ -200,40 +203,139 @@ Confirm to switch to ${payload.engine}, cancel to keep ${currentEngine}`
}
}
- this.globalState.onCodeLoaded.notifyObservers(code);
+ try {
+ const manifestPayload = JSON.parse(code);
+ if (manifestPayload && manifestPayload.files && typeof manifestPayload.files === "object") {
+ const v2 = manifestPayload as V2Manifest;
+
+ if (v2.language !== this.globalState.language) {
+ Utilities.SwitchLanguage(v2.language, this.globalState, true);
+ }
+
+ // In the case we're loading from a #local revision id,
+ // The execution flow reaches this block before MonacoManager has been instantiated
+ // And the observable attached. Instead of refactoring the instantiation flow
+ // We can handle this one-off case here
+ while (!this.globalState.onV2HydrateRequiredObservable.hasObservers()) {
+ // eslint-disable-next-line
+ await new Promise((res) => setTimeout(res, 10));
+ }
+ this.globalState.onV2HydrateRequiredObservable.notifyObservers({
+ v: ManifestVersion,
+ files: v2.files,
+ entry: v2.entry || (v2.language === "JS" ? "index.js" : "index.ts"),
+ imports: v2.imports || {},
+ language: v2.language,
+ });
+
+ this.globalState.loadingCodeInProgress = false;
+ this.globalState.onMetadataUpdatedObservable.notifyObservers();
+
+ return;
+ }
+ } catch (e: any) {
+ Logger.Warn("Loading legacy snippet");
+ }
+
+ const guessed = this._guessLanguageFromCode(code); // "TS" | "JS"
+ if (guessed !== this.globalState.language) {
+ Utilities.SwitchLanguage(guessed, this.globalState, true);
+ }
+ // In this case we are loading a v1 playground snippet
+ // And in all likelihood it didn't include export statements
+ // Since that would not have run in the old playground
+ // So we append to the end of the file to satisfy our module-based runner
+ const fileName = guessed === "TS" ? "index.ts" : "index.js";
+ code += `\nexport default ${guessed === "TS" ? "Playground" : (this._jsFunctions.find((fn) => code.includes(fn)) ?? "createScene")}\n`;
+ if (guessed === "JS" && code.includes("createEngine")) {
+ code += `\nexport { createEngine }\n`;
+ }
+ queueMicrotask(() => {
+ this.globalState.onV2HydrateRequiredObservable.notifyObservers({
+ v: ManifestVersion,
+ files: { [fileName]: code },
+ entry: fileName,
+ imports: {},
+ language: guessed,
+ });
+ });
+ this.globalState.loadingCodeInProgress = false;
this.globalState.onMetadataUpdatedObservable.notifyObservers();
}
private _loadPlayground(id: string) {
this.globalState.loadingCodeInProgress = true;
try {
- const xmlHttp = new XMLHttpRequest();
- xmlHttp.onreadystatechange = () => {
- if (xmlHttp.readyState === 4) {
- if (xmlHttp.status === 200) {
- this._processJsonPayload(xmlHttp.responseText);
- }
- }
- };
-
if (id[0] === "#") {
id = id.substring(1);
}
this.globalState.currentSnippetToken = id.split("#")[0];
+ this.globalState.currentSnippetRevision = id.split("#")[1] ?? "0";
if (!id.split("#")[1]) {
id += "#0";
}
+ if (this.globalState.currentSnippetRevision === "local") {
+ const localRevision = ReadLastLocal(this.globalState);
+ if (localRevision) {
+ // eslint-disable-next-line
+ this._processJsonPayloadAsync(localRevision);
+ return;
+ }
+ }
+
+ const xmlHttp = new XMLHttpRequest();
+ xmlHttp.onreadystatechange = () => {
+ if (xmlHttp.readyState === 4) {
+ if (xmlHttp.status === 200) {
+ // eslint-disable-next-line
+ this._processJsonPayloadAsync(xmlHttp.responseText);
+ }
+ }
+ };
// defensive-handling a safari issue
id.replace(/%23/g, "#");
xmlHttp.open("GET", this.globalState.SnippetServerUrl + "/" + id.replace(/#/g, "/"));
xmlHttp.send();
- } catch (e) {
+ } catch {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
this.globalState.loadingCodeInProgress = false;
this.globalState.onCodeLoaded.notifyObservers("");
}
}
+ private _guessLanguageFromCode(code: string): "TS" | "JS" {
+ if (code.includes("class Playground")) {
+ return "TS";
+ }
+ if (this._jsFunctions.some((fn) => code.includes(fn))) {
+ return "JS";
+ }
+ if (!code) {
+ return this.globalState.language as "TS" | "JS";
+ }
+
+ // Strong TS signals
+ const tsSignals = [
+ /\binterface\s+[A-Za-z_]\w*/m, // interface Foo
+ /\benum\s+[A-Za-z_]\w*/m, // enum X
+ /\btype\s+[A-Za-z_]\w*\s*=/m, // type T = ...
+ /\bimplements\s+[A-Za-z_]/m, // class C implements X
+ /\breadonly\b/m, // readonly
+ /\bpublic\b|\bprivate\b|\bprotected\b/m, // visibility modifiers
+ /\babstract\s+class\b/m, // abstract class
+ /\bas\s+const\b/m, // as const
+ /\bimport\s+type\s+/m, // import type { X }
+ ];
+
+ const hasTypeAnn = /[:]\s*[A-Za-z_$][\w$.<>,\s?\\[\]|&]*\b(?![:=])/m.test(code);
+
+ if (tsSignals.some((r) => r.test(code)) || hasTypeAnn) {
+ return "TS";
+ }
+
+ return "JS";
+ }
}
diff --git a/packages/tools/playground/src/tools/localSession.ts b/packages/tools/playground/src/tools/localSession.ts
new file mode 100644
index 00000000000..d08945e8f1f
--- /dev/null
+++ b/packages/tools/playground/src/tools/localSession.ts
@@ -0,0 +1,353 @@
+/* eslint-disable jsdoc/require-jsdoc */
+import { Logger } from "@dev/core";
+import type { GlobalState } from "../globalState";
+import { PackSnippetData } from "./snippet";
+import type { V2Manifest } from "./snippet";
+import { Utilities } from "./utilities";
+
+declare let JSZip: any;
+
+const Compress = JSZip.compressions.DEFLATE.compress as (data: Uint8Array) => Uint8Array;
+const Decompress = JSZip.compressions.DEFLATE.uncompress as (data: Uint8Array) => Uint8Array;
+
+const Decoder = new TextDecoder();
+const Encoder = new TextEncoder();
+
+export const MaxRevisions = 5;
+
+export type FileChange = {
+ file: string;
+ type: "added" | "removed" | "modified";
+ beforeSize: number | null;
+ afterSize: number | null;
+};
+
+export type SnippetRevision = {
+ date: number;
+ manifest: V2Manifest;
+ title: string;
+ link?: string;
+ filesChanged: FileChange[];
+};
+
+export type SnippetRevisionsBundle = {
+ lastLocal?: string; // Snippet data as JSON string
+ revisions: SnippetRevision[];
+};
+
+export type SnippetFileRevisions = {
+ [snippetId: string]: string;
+};
+export type RevisionContext = {
+ token: string;
+ title: string;
+ count: number;
+ latestDate?: number;
+};
+
+// Storage key in localStorage
+const LocalRevisionKey = "snippetRevisions";
+
+// For unsaved Playground sessions use a default token
+const GetDefaultToken = (globalState: GlobalState) => {
+ return globalState.currentSnippetToken || "local-session";
+};
+
+export const CompressJson = (jsonData: string): string => {
+ const data = Encoder.encode(jsonData);
+ const compressed = Compress(data);
+ return Uint8ToBase64(compressed);
+};
+
+export const DecompressJson = (base64Data: string): string => {
+ const bytes = Base64ToUint8(base64Data);
+ const decompressed = Decompress(bytes);
+ return Decoder.decode(decompressed);
+};
+
+function ReadAll(): SnippetFileRevisions {
+ const raw = Utilities.ReadStringFromStore(LocalRevisionKey, "{}");
+ try {
+ return JSON.parse(raw) as SnippetFileRevisions;
+ } catch {
+ return {};
+ }
+}
+
+function WriteAll(all: SnippetFileRevisions) {
+ Utilities.StoreStringToStore(LocalRevisionKey, JSON.stringify(all));
+}
+
+function ParseBundleFromCompressed(compressed: string): SnippetRevisionsBundle | null {
+ if (!compressed) {
+ return null;
+ }
+ try {
+ const decompressed = DecompressJson(compressed);
+ const parsed = JSON.parse(decompressed);
+ const bundle = parsed as SnippetRevisionsBundle;
+ bundle.revisions ||= [];
+ return bundle;
+ } catch (e) {
+ Logger.Warn("Failed to parse bundle: " + (e as any)?.message);
+ return null;
+ }
+}
+
+function SerializeBundleToCompressed(bundle: SnippetRevisionsBundle): string {
+ return CompressJson(JSON.stringify(bundle));
+}
+
+function LoadBundleForToken(token: string): SnippetRevisionsBundle {
+ const all = ReadAll();
+ const compressed = all[token];
+ const bundle = compressed ? ParseBundleFromCompressed(compressed) : null;
+ return bundle ?? { revisions: [] };
+}
+
+function StoreBundleForToken(token: string, bundle: SnippetRevisionsBundle) {
+ const all = ReadAll();
+ all[token] = SerializeBundleToCompressed(bundle);
+
+ try {
+ WriteAll(all);
+ } catch (e) {
+ // This is a potential rare case we want to handle with localStorage quota
+ // Which varies from browser to browser - no silent failures or undefined behavior
+ // But make this actionable
+ const code = (e as any)?.code;
+ const name = (e as any)?.name;
+ if (code === 22 || name === "QuotaExceededError") {
+ if (window.confirm("Local storage quota exceeded for saved revisions. Clear all saved revisions?")) {
+ WriteAll({});
+ WriteAll(all);
+ }
+ } else {
+ throw e;
+ }
+ }
+}
+
+export function ListRevisionContexts(globalState: GlobalState): Array {
+ const all = ReadAll();
+ const entries: Array = [];
+
+ for (const token of Object.keys(all)) {
+ try {
+ const bundle = ParseBundleFromCompressed(all[token]);
+ if (!bundle) {
+ continue;
+ }
+
+ const revs = bundle.revisions ?? [];
+ const latest = revs[0];
+ const title = (latest?.title?.trim()?.length ? latest.title : token === "local-session" ? "Local Session" : "Snippet") + "";
+
+ entries.push({
+ token,
+ title,
+ count: revs.length,
+ latestDate: latest?.date,
+ });
+ } catch {
+ // skip malformed
+ }
+ }
+
+ const current = GetDefaultToken(globalState);
+ if (!entries.some((e) => e.token === current)) {
+ entries.push({
+ token: current,
+ title: current === "local-session" ? "Local Session" : "Snippet",
+ count: 0,
+ });
+ }
+
+ entries.sort((a, b) => {
+ const dateA = a.latestDate ?? 0;
+ const dateB = b.latestDate ?? 0;
+ if (dateA !== dateB) {
+ return dateB - dateA;
+ }
+ if (a.count !== b.count) {
+ return b.count - a.count;
+ }
+ return a.title.localeCompare(b.title);
+ });
+
+ return entries;
+}
+
+export function LoadFileRevisionsForToken(globalState: GlobalState, token: string): SnippetRevision[] {
+ try {
+ const bundle = LoadBundleForToken(token);
+ return bundle.revisions ?? [];
+ } catch (e) {
+ Logger.Warn("Failed to load local revisions for token: " + token + " - " + (e as any)?.message);
+ return [];
+ }
+}
+
+export function LoadFileRevisions(globalState: GlobalState): SnippetRevision[] {
+ const token = GetDefaultToken(globalState);
+ return LoadFileRevisionsForToken(globalState, token);
+}
+
+export function ReadLastLocal(globalState: GlobalState): string | undefined {
+ const token = GetDefaultToken(globalState);
+ const bundle = LoadBundleForToken(token);
+ return bundle.lastLocal;
+}
+export function WriteLastLocal(globalState: GlobalState) {
+ const token = GetDefaultToken(globalState);
+ const bundle = LoadBundleForToken(token);
+ bundle.lastLocal = PackSnippetData(globalState);
+ StoreBundleForToken(token, bundle);
+}
+
+export function RemoveFileRevisionForToken(globalState: GlobalState, token: string, index: number) {
+ const bundle = LoadBundleForToken(token);
+ const revs = bundle.revisions ?? [];
+ if (index < 0 || index >= revs.length) {
+ return;
+ }
+
+ revs.splice(index, 1);
+ bundle.revisions = revs;
+ StoreBundleForToken(token, bundle);
+}
+
+export function AddFileRevision(globalState: GlobalState, manifest: V2Manifest) {
+ const token = GetDefaultToken(globalState);
+ const bundle = LoadBundleForToken(token);
+ const revisions = bundle.revisions ?? [];
+
+ const previousManifest = revisions.length > 0 ? revisions[0].manifest : undefined;
+ const filesChanged = DiffFiles(previousManifest ?? null, manifest);
+
+ // Skip if an identical manifest already exists in history
+ for (const revision of revisions) {
+ if (JSON.stringify(revision.manifest) === JSON.stringify(manifest)) {
+ return;
+ }
+ }
+ // Only push diffs so we don't dupe the stack
+ if (!filesChanged.length) {
+ return;
+ }
+
+ const title = globalState.currentSnippetTitle ? `${globalState.currentSnippetTitle}` : "Local Session";
+ let link: string | undefined;
+ if (globalState.currentSnippetToken) {
+ link = `#${globalState.currentSnippetToken}#${globalState.currentSnippetRevision ?? ""}`;
+ }
+
+ revisions.push({
+ date: Date.now(),
+ title,
+ link,
+ manifest,
+ filesChanged,
+ });
+
+ revisions.sort((a, b) => b.date - a.date);
+ while (revisions.length > MaxRevisions) {
+ revisions.pop();
+ }
+
+ bundle.revisions = revisions;
+ StoreBundleForToken(token, bundle);
+}
+
+export function RemoveFileRevision(globalState: GlobalState, index: number) {
+ const token = GetDefaultToken(globalState);
+ RemoveFileRevisionForToken(globalState, token, index);
+}
+
+export function ClearSnippetFileRevisions(globalState: GlobalState) {
+ const token = GetDefaultToken(globalState);
+ if (token === "local-session") {
+ WriteAll({});
+ return;
+ }
+
+ const all = ReadAll();
+ if (all[token]) {
+ delete all[token];
+ WriteAll(all);
+ }
+}
+
+const DiffFiles = (prev: V2Manifest | null, next: V2Manifest): FileChange[] => {
+ const changes: FileChange[] = [];
+ const prevFiles = prev?.files ?? {};
+ const nextFiles = next.files ?? {};
+
+ const prevKeys = new Set(Object.keys(prevFiles));
+ const nextKeys = new Set(Object.keys(nextFiles));
+
+ for (const file of nextKeys) {
+ const prevContent = prevFiles[file];
+ const nextContent = nextFiles[file];
+
+ if (!prevKeys.has(file)) {
+ changes.push({
+ file,
+ type: "added",
+ beforeSize: null,
+ afterSize: nextContent ? Encoder.encode(nextContent).length : 0,
+ });
+ } else if (prevContent !== nextContent) {
+ changes.push({
+ file,
+ type: "modified",
+ beforeSize: prevContent ? Encoder.encode(prevContent).length : 0,
+ afterSize: nextContent ? Encoder.encode(nextContent).length : 0,
+ });
+ }
+ prevKeys.delete(file);
+ }
+
+ for (const removed of prevKeys) {
+ const prevContent = prevFiles[removed];
+ changes.push({
+ file: removed,
+ type: "removed",
+ beforeSize: prevContent ? Encoder.encode(prevContent).length : 0,
+ afterSize: null,
+ });
+ }
+
+ const rank = { modified: 0, added: 1, removed: 2 } as const;
+ changes.sort((a, b) => {
+ const r = rank[a.type] - rank[b.type];
+ return r !== 0 ? r : a.file.localeCompare(b.file);
+ });
+
+ return changes;
+};
+
+// Manual b64 helpers to avoid exhausting call stack
+
+function Uint8ToBase64(bytes: Uint8Array): string {
+ const chunk = 0x8000;
+ let binary = "";
+ for (let i = 0; i < bytes.length; i += chunk) {
+ const slice = bytes.subarray(i, i + chunk);
+ let chunkStr = "";
+ for (let j = 0; j < slice.length; j++) {
+ chunkStr += String.fromCharCode(slice[j]);
+ }
+ binary += chunkStr;
+ }
+ return btoa(binary);
+}
+
+function Base64ToUint8(b64: string): Uint8Array {
+ const binary = atob(b64);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return bytes;
+}
diff --git a/packages/tools/playground/src/tools/monaco/analysis/codeAnalysisService.ts b/packages/tools/playground/src/tools/monaco/analysis/codeAnalysisService.ts
new file mode 100644
index 00000000000..1bd39fbcc3d
--- /dev/null
+++ b/packages/tools/playground/src/tools/monaco/analysis/codeAnalysisService.ts
@@ -0,0 +1,115 @@
+/* eslint-disable jsdoc/require-jsdoc */
+import * as monaco from "monaco-editor/esm/vs/editor/editor.api";
+import type { GlobalState } from "../../../globalState";
+
+export type TagCandidate = {
+ name: string;
+ tagName: string;
+};
+
+/**
+ * Handles code analysis for Monaco editor, including tag candidate detection and diagnostics
+ */
+export class CodeAnalysisService {
+ private _tagCandidates: TagCandidate[] | undefined;
+
+ setTagCandidates(candidates: TagCandidate[] | undefined) {
+ this._tagCandidates = candidates;
+ }
+
+ async analyzeCodeAsync(model: monaco.editor.ITextModel, globalState: GlobalState) {
+ if (!this._tagCandidates || !model || model.isDisposed()) {
+ return;
+ }
+
+ const uri = model.uri;
+ const worker = globalState.language === "JS" ? await monaco.languages.typescript.getJavaScriptWorker() : await monaco.languages.typescript.getTypeScriptWorker();
+
+ const languageService = await worker(uri);
+ const source = "[preview]";
+ monaco.editor.setModelMarkers(model, source, []);
+
+ const markers: monaco.editor.IMarkerData[] = [];
+
+ for (const candidate of this._tagCandidates) {
+ if (model.isDisposed()) {
+ continue;
+ }
+ const matches = model.findMatches(candidate.name, false, false, true, null, false);
+ if (!matches) {
+ continue;
+ }
+
+ for (const match of matches) {
+ if (model.isDisposed()) {
+ continue;
+ }
+ const position = { lineNumber: match.range.startLineNumber, column: match.range.startColumn };
+ const wordInfo = model.getWordAtPosition(position);
+ const offset = model.getOffsetAt(position);
+ if (!wordInfo) {
+ continue;
+ }
+
+ if (markers.find((m) => m.startLineNumber === position.lineNumber && m.startColumn === position.column)) {
+ continue;
+ }
+
+ try {
+ // eslint-disable-next-line no-await-in-loop
+ const details = await languageService.getCompletionEntryDetails(uri.toString(), offset, wordInfo.word);
+ if (!details || !details.tags) {
+ continue;
+ }
+
+ const tag = details.tags.find((t: any) => t.name === candidate.tagName);
+ if (tag) {
+ markers.push({
+ startLineNumber: match.range.startLineNumber,
+ endLineNumber: match.range.endLineNumber,
+ startColumn: wordInfo.startColumn,
+ endColumn: wordInfo.endColumn,
+ message: this._getTagMessage(tag),
+ severity: this._getCandidateMarkerSeverity(candidate),
+ source,
+ });
+ }
+ } catch {
+ // Ignore analysis errors
+ }
+ }
+ }
+
+ monaco.editor.setModelMarkers(model, source, markers);
+ }
+
+ private _getCandidateMarkerSeverity(candidate: TagCandidate) {
+ switch (candidate.tagName) {
+ case "deprecated":
+ return monaco.MarkerSeverity.Warning;
+ default:
+ return monaco.MarkerSeverity.Info;
+ }
+ }
+
+ private _getCandidateCompletionSuffix(candidate: TagCandidate) {
+ switch (candidate.tagName) {
+ case "deprecated":
+ return " ⚠️";
+ default:
+ return "";
+ }
+ }
+
+ private _getTagMessage(tag: any) {
+ let text = tag.text || "";
+ if (text.length > 80) {
+ text = text.substr(0, 80) + "...";
+ }
+ return text;
+ }
+
+ getCandidateCompletionSuffix(candidate: TagCandidate) {
+ return this._getCandidateCompletionSuffix(candidate);
+ }
+}
diff --git a/packages/tools/playground/src/tools/monaco/codeLens/codeLensProvider.ts b/packages/tools/playground/src/tools/monaco/codeLens/codeLensProvider.ts
new file mode 100644
index 00000000000..983067d17a9
--- /dev/null
+++ b/packages/tools/playground/src/tools/monaco/codeLens/codeLensProvider.ts
@@ -0,0 +1,49 @@
+import * as monaco from "monaco-editor/esm/vs/editor/editor.api";
+
+const ImportLocalRe = /\bfrom\s+['"]([^'"]+@local(?:\/[^'"]*)?)['"]|^\s*import\s+['"]([^'"]+@local(?:\/[^'"]*)?)['"]/gm;
+
+/**
+ *
+ */
+export class CodeLensService {
+ constructor(private _resolveOneLocalAsync: (fullSpec: string) => Promise) {}
+ private _disposables: monaco.IDisposable[] = [];
+ private _langId(lang: "JS" | "TS"): "javascript" | "typescript" {
+ return lang === "JS" ? "javascript" : "typescript";
+ }
+ register(lang: "JS" | "TS") {
+ this._disposables.forEach((d) => d.dispose());
+ const language = this._langId(lang);
+
+ this._disposables.push(
+ monaco.languages.registerCodeLensProvider(language, {
+ provideCodeLenses: (model) => {
+ const code = model.getValue();
+ const lenses: monaco.languages.CodeLens[] = [];
+ let m: RegExpExecArray | null;
+ ImportLocalRe.lastIndex = 0;
+ while ((m = ImportLocalRe.exec(code))) {
+ const fullSpec = (m[1] || m[2])!;
+ const start = model.getPositionAt(m.index);
+ lenses.push({
+ range: new monaco.Range(start.lineNumber, 1, start.lineNumber, 1),
+ command: {
+ id: "pg.resolveLocalTypings.one",
+ title: `Resolve and start FS watch for local package for ${fullSpec}`,
+ tooltip: "Links a built local npm package to the Playground. Select the containing build/dist folder.",
+ arguments: [fullSpec],
+ },
+ });
+ }
+ return { lenses, dispose: () => {} };
+ },
+ })
+ );
+
+ this._disposables.push(
+ monaco.editor.registerCommand("pg.resolveLocalTypings.one", async (_acc, fullSpec: string) => {
+ await this._resolveOneLocalAsync(fullSpec);
+ })
+ );
+ }
+}
diff --git a/packages/tools/playground/src/tools/monaco/completion/completionService.ts b/packages/tools/playground/src/tools/monaco/completion/completionService.ts
new file mode 100644
index 00000000000..de8acd3f664
--- /dev/null
+++ b/packages/tools/playground/src/tools/monaco/completion/completionService.ts
@@ -0,0 +1,133 @@
+import * as monaco from "monaco-editor/esm/vs/editor/editor.api";
+import type { TemplateItem } from "./templatesService";
+import type { TagCandidate } from "../analysis/codeAnalysisService";
+
+/**
+ *
+ */
+export class CompletionService {
+ private _disposable: monaco.IDisposable | null = null;
+ private _tagCandidates: TagCandidate[] | undefined;
+
+ setTagCandidates(v: TagCandidate[] | undefined) {
+ this._tagCandidates = v;
+ }
+
+ private _langId(lang: "JS" | "TS"): "javascript" | "typescript" {
+ return lang === "JS" ? "javascript" : "typescript";
+ }
+
+ private _tsKindToMonaco(kind: string): monaco.languages.CompletionItemKind {
+ switch (kind) {
+ case "method":
+ return monaco.languages.CompletionItemKind.Method;
+ case "function":
+ return monaco.languages.CompletionItemKind.Function;
+ case "constructor":
+ return monaco.languages.CompletionItemKind.Constructor;
+ case "field":
+ return monaco.languages.CompletionItemKind.Field;
+ case "variable":
+ return monaco.languages.CompletionItemKind.Variable;
+ case "class":
+ return monaco.languages.CompletionItemKind.Class;
+ case "interface":
+ return monaco.languages.CompletionItemKind.Interface;
+ case "module":
+ return monaco.languages.CompletionItemKind.Module;
+ case "property":
+ return monaco.languages.CompletionItemKind.Property;
+ case "enum":
+ return monaco.languages.CompletionItemKind.Enum;
+ case "keyword":
+ return monaco.languages.CompletionItemKind.Keyword;
+ case "snippet":
+ return monaco.languages.CompletionItemKind.Snippet;
+ default:
+ return monaco.languages.CompletionItemKind.Text;
+ }
+ }
+
+ private _shouldDecorateLabel = (label: string) => {
+ return this._tagCandidates?.some((t) => t.name === label);
+ };
+
+ register(lang: "JS" | "TS", templates: TemplateItem[]) {
+ this._disposable?.dispose();
+ const language = this._langId(lang);
+
+ this._disposable = monaco.languages.registerCompletionItemProvider(language, {
+ triggerCharacters: [".", '"', "'", "/", "@"],
+ // eslint-disable-next-line
+ provideCompletionItems: async (model, position, context) => {
+ const getSvc = lang === "JS" ? await monaco.languages.typescript.getJavaScriptWorker() : await monaco.languages.typescript.getTypeScriptWorker();
+ const svc = await getSvc(model.uri);
+ const offset = model.getOffsetAt(position);
+ const info = await svc.getCompletionsAtPosition(model.uri.toString(), offset);
+
+ const word = model.getWordUntilPosition(position);
+ const replaceRange = new monaco.Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn);
+
+ const suggestions: monaco.languages.CompletionItem[] = [];
+ for (const e of info?.entries ?? []) {
+ if (e.name?.startsWith("_")) {
+ continue;
+ }
+ suggestions.push({
+ label: e.name,
+ kind: this._tsKindToMonaco(e.kind),
+ sortText: e.sortText ?? e.name,
+ filterText: e.insertText ?? e.name,
+ insertText: e.insertText ?? e.name,
+ range: replaceRange,
+ // eslint-disable-next-line
+ ...({ __uri: model.uri.toString(), __offset: offset, __source: e.source } as any),
+ });
+ }
+
+ if (context.triggerKind === monaco.languages.CompletionTriggerKind.Invoke) {
+ const tmpl = templates.filter((t) => !t.language || t.language === language).map((t) => ({ ...t, range: replaceRange }));
+ suggestions.push(...(tmpl as any));
+ }
+ const incomplete = !!info?.isIncomplete || (this._tagCandidates?.length ?? 0) === 0;
+ return { suggestions, incomplete };
+ },
+ // eslint-disable-next-line
+ resolveCompletionItem: async (item) => {
+ try {
+ const uriStr = (item as any).__uri ?? monaco.editor.getModels()[0]?.uri.toString();
+ if (!uriStr) {
+ return item;
+ }
+ const getSvc = lang === "JS" ? await monaco.languages.typescript.getJavaScriptWorker() : await monaco.languages.typescript.getTypeScriptWorker();
+ const svc = await getSvc(monaco.Uri.parse(uriStr));
+ let offset: number | undefined = (item as any).__offset;
+ if (offset == null && item.range) {
+ const m = monaco.editor.getModel(monaco.Uri.parse(uriStr));
+ if (m && (item as any).range?.startLineNumber) {
+ const r = item.range as monaco.IRange;
+ offset = m.getOffsetAt(new monaco.Position(r.startLineNumber, r.startColumn));
+ }
+ }
+ if (offset == null) {
+ return item;
+ }
+
+ const labelStr = typeof item.label === "string" ? item.label : item.label.label;
+ const shouldDecorate = this._shouldDecorateLabel(labelStr);
+ if (!shouldDecorate) {
+ return item;
+ }
+
+ const details = await svc.getCompletionEntryDetails(uriStr, offset, labelStr);
+ const candidate = this._tagCandidates?.find((t) => t.name === labelStr);
+ const hit = (details as any)?.tags?.find((t: any) => t.name === candidate?.tagName);
+ if (hit) {
+ (item as any).label = labelStr + " ⚠️";
+ }
+ } catch {}
+ return item;
+ },
+ });
+ }
+}
diff --git a/packages/tools/playground/src/tools/monaco/completion/templatesService.ts b/packages/tools/playground/src/tools/monaco/completion/templatesService.ts
new file mode 100644
index 00000000000..0a6997cc6e1
--- /dev/null
+++ b/packages/tools/playground/src/tools/monaco/completion/templatesService.ts
@@ -0,0 +1,32 @@
+/* eslint-disable jsdoc/require-jsdoc */
+export type TemplateItem = {
+ label: string;
+ key: string;
+ documentation: string;
+ insertText: string;
+ language: string;
+ kind: number;
+ sortText: string;
+ insertTextRules: number;
+};
+
+export class TemplatesService {
+ private _templates: TemplateItem[] = [];
+ get templates() {
+ return this._templates;
+ }
+
+ async loadAsync() {
+ try {
+ const templatesCodeUrl = "templates.json?uncacher=" + Date.now();
+ this._templates = await (await fetch(templatesCodeUrl)).json();
+ for (const t of this._templates) {
+ t.kind = 27 as any; // Snippet
+ t.sortText = "!" + t.label;
+ t.insertTextRules = 4 as any; // InsertAsSnippet
+ }
+ } catch {
+ // ignore
+ }
+ }
+}
diff --git a/packages/tools/playground/src/tools/monaco/editor/editorHost.ts b/packages/tools/playground/src/tools/monaco/editor/editorHost.ts
new file mode 100644
index 00000000000..9ac97274edf
--- /dev/null
+++ b/packages/tools/playground/src/tools/monaco/editor/editorHost.ts
@@ -0,0 +1,79 @@
+import * as monaco from "monaco-editor/esm/vs/editor/editor.api";
+import type { editor } from "monaco-editor/esm/vs/editor/editor.api";
+import { Utilities } from "../../../tools/utilities";
+
+/**
+ *
+ */
+export class EditorHost {
+ private _editor!: editor.IStandaloneCodeEditor;
+ private _host!: HTMLDivElement;
+ private _onScroll?: (vs: editor.ICodeEditorViewState) => void;
+
+ get editor() {
+ return this._editor;
+ }
+ get host() {
+ return this._host;
+ }
+
+ create(host: HTMLDivElement, lang: "javascript" | "typescript") {
+ this._host = host;
+ if (this._editor) {
+ this._editor.dispose();
+ }
+
+ const editorOptions: editor.IStandaloneEditorConstructionOptions = {
+ value: "",
+ language: lang,
+ lineNumbers: "on",
+ roundedSelection: true,
+ automaticLayout: true,
+ scrollBeyondLastLine: false,
+ occurrencesHighlight: "off",
+ selectionHighlight: false,
+ readOnly: false,
+ theme: Utilities.ReadStringFromStore("theme", "Light") === "Dark" ? "vs-dark" : "vs-light",
+ contextmenu: false,
+ folding: true,
+ showFoldingControls: "always",
+ fontSize: parseInt(Utilities.ReadStringFromStore("font-size", "14")),
+ minimap: { enabled: Utilities.ReadBoolFromStore("minimap", true) },
+ definitionLinkOpensInPeek: true,
+ multiCursorModifier: "alt",
+ gotoLocation: {
+ multiple: "peek",
+ multipleDefinitions: "peek",
+ multipleDeclarations: "peek",
+ multipleImplementations: "peek",
+ multipleTypeDefinitions: "peek",
+ multipleReferences: "peek",
+ },
+ };
+ this._editor = monaco.editor.create(host, editorOptions as any);
+
+ this._editor.onDidScrollChange(() => {
+ if (!this._onScroll) {
+ return;
+ }
+ const vs = this._editor.saveViewState();
+ if (vs) {
+ this._onScroll(vs);
+ }
+ });
+ }
+
+ onScroll(cb: (vs: editor.ICodeEditorViewState) => void) {
+ this._onScroll = cb;
+ }
+
+ updateOptions(opts: editor.IStandaloneEditorConstructionOptions) {
+ this._editor?.updateOptions(opts);
+ }
+
+ dispose() {
+ this.editor?.dispose();
+ this._editor?.dispose();
+ this._onScroll = undefined;
+ }
+}
diff --git a/packages/tools/playground/src/tools/monaco/files/filesManager.ts b/packages/tools/playground/src/tools/monaco/files/filesManager.ts
new file mode 100644
index 00000000000..9dbdfd8c773
--- /dev/null
+++ b/packages/tools/playground/src/tools/monaco/files/filesManager.ts
@@ -0,0 +1,143 @@
+import * as monaco from "monaco-editor/esm/vs/editor/editor.api";
+import type { editor } from "monaco-editor/esm/vs/editor/editor.api";
+import { MonacoLanguageFor } from "../utils/path";
+
+/**
+ *
+ */
+export class FilesManager {
+ private _models = new Map();
+ private _viewStates = new Map();
+ private _isDirty = false;
+
+ constructor(private _getLangFallback: () => "javascript" | "typescript") {}
+
+ get isDirty() {
+ return this._isDirty;
+ }
+ setDirty(v: boolean) {
+ this._isDirty = v;
+ }
+
+ has(path: string) {
+ return this._models.has(path);
+ }
+ getModel(path: string) {
+ return this._models.get(path);
+ }
+ paths() {
+ return [...this._models.keys()];
+ }
+
+ getFiles(): Record {
+ const out: Record = {};
+ for (const [p, m] of this._models) {
+ out[p] = m.getValue();
+ }
+ return out;
+ }
+
+ setFiles(files: Record, onContentChange: (path: string, code: string) => void) {
+ // dispose removed
+ for (const [p, m] of this._models) {
+ if (!files[p]) {
+ m.dispose();
+ this._models.delete(p);
+ }
+ }
+
+ const fallback = this._getLangFallback();
+ for (const [path, code] of Object.entries(files)) {
+ const existing = this._models.get(path);
+ if (existing) {
+ if (existing.getValue() !== code) {
+ existing.setValue(code);
+ }
+ } else {
+ const uri = monaco.Uri.parse(`file:///pg/${path.replace(/^\//, "")}`);
+ const model = monaco.editor.createModel(code, MonacoLanguageFor(path, fallback), uri);
+ model.onDidChangeContent(() => {
+ onContentChange(path, model.getValue());
+ this._isDirty = true;
+ });
+ this._models.set(path, model);
+ }
+ }
+ }
+
+ addFile(path: string, initial: string, onContentChange: (path: string, code: string) => void) {
+ if (this._models.has(path)) {
+ return;
+ }
+ const fallback = this._getLangFallback();
+ const uri = monaco.Uri.parse(`file:///pg/${path.replace(/^\//, "")}`);
+ const model = monaco.editor.createModel(initial, MonacoLanguageFor(path, fallback), uri);
+ model.onDidChangeContent(() => {
+ onContentChange(path, model.getValue());
+ this._isDirty = true;
+ });
+ this._models.set(path, model);
+ }
+
+ removeAllFiles() {
+ for (const m of this._models.values()) {
+ m.dispose();
+ }
+ this._models.clear();
+ this._viewStates.clear();
+ this._isDirty = false;
+ }
+
+ removeFile(path: string) {
+ const m = this._models.get(path);
+ if (m) {
+ m.dispose();
+ this._models.delete(path);
+ }
+ this._viewStates.delete(path);
+ }
+
+ renameFile(oldPath: string, newPath: string, onContentChange: (path: string, code: string) => void) {
+ const model = this._models.get(oldPath);
+ if (!model) {
+ return false;
+ }
+ if (this._models.has(newPath)) {
+ throw new Error("Target file already exists");
+ }
+
+ if (this._viewStates.has(oldPath)) {
+ this._viewStates.set(newPath, this._viewStates.get(oldPath)!);
+ this._viewStates.delete(oldPath);
+ }
+
+ const lang = model.getLanguageId();
+ const value = model.getValue();
+ const uri = monaco.Uri.parse(`file:///pg/${newPath.replace(/^\//, "")}`);
+ const newModel = monaco.editor.createModel(value, lang, uri);
+ newModel.onDidChangeContent(() => onContentChange(newPath, newModel.getValue()));
+ this._models.set(newPath, newModel);
+ this._models.delete(oldPath);
+ model.dispose();
+ return true;
+ }
+
+ saveViewState(path: string, vs: editor.ICodeEditorViewState | null) {
+ this._viewStates.set(path, vs);
+ }
+
+ restoreViewState(path: string, editor: editor.IStandaloneCodeEditor) {
+ const vs = this._viewStates.get(path);
+ if (vs) {
+ editor.restoreViewState(vs);
+ editor.focus();
+ } else {
+ editor.setScrollTop(0);
+ }
+ }
+ dispose() {
+ this.removeAllFiles();
+ this._models.clear();
+ this._viewStates.clear();
+ }
+}
diff --git a/packages/tools/playground/src/tools/monaco/language/colorProvider.ts b/packages/tools/playground/src/tools/monaco/language/colorProvider.ts
new file mode 100644
index 00000000000..80db3ac37e3
--- /dev/null
+++ b/packages/tools/playground/src/tools/monaco/language/colorProvider.ts
@@ -0,0 +1,33 @@
+import * as monaco from "monaco-editor/esm/vs/editor/editor.api";
+
+/**
+ *
+ * @param lang The language to register the color provider for.
+ */
+export function RegisterColorProvider(lang: "javascript" | "typescript") {
+ monaco.languages.registerColorProvider(lang, {
+ provideColorPresentations: (_model: any, colorInfo: any) => {
+ const c = colorInfo.color;
+ const p = 100.0;
+ const cvt = (n: number) => Math.round(n * p) / p;
+ const label =
+ c.alpha === undefined || c.alpha === 1.0 ? `(${cvt(c.red)}, ${cvt(c.green)}, ${cvt(c.blue)})` : `(${cvt(c.red)}, ${cvt(c.green)}, ${cvt(c.blue)}, ${cvt(c.alpha)})`;
+ return [{ label }];
+ },
+ provideDocumentColors: (model: any) => {
+ const digitGroup = "\\s*(\\d*(?:\\.\\d+)?)\\s*";
+ const regex = `BABYLON\\.Color(?:3|4)\\s*\\(${digitGroup},${digitGroup},${digitGroup}(?:,${digitGroup})?\\)\\n{0}`;
+ const matches = model.findMatches(regex, false, true, true, null, true);
+ const num = (g: string) => (g === undefined ? undefined : Number(g));
+ return matches.map((m: any) => ({
+ color: { red: num(m.matches![1])!, green: num(m.matches![2])!, blue: num(m.matches![3])!, alpha: num(m.matches![4])! },
+ range: {
+ startLineNumber: m.range.startLineNumber,
+ startColumn: m.range.startColumn + m.matches![0].indexOf("("),
+ endLineNumber: m.range.startLineNumber,
+ endColumn: m.range.endColumn,
+ },
+ }));
+ },
+ });
+}
diff --git a/packages/tools/playground/src/tools/monaco/language/shaderLanguages.ts b/packages/tools/playground/src/tools/monaco/language/shaderLanguages.ts
new file mode 100644
index 00000000000..06e275fd63f
--- /dev/null
+++ b/packages/tools/playground/src/tools/monaco/language/shaderLanguages.ts
@@ -0,0 +1,87 @@
+import * as monaco from "monaco-editor/esm/vs/editor/editor.api";
+
+/**
+ *
+ */
+export function RegisterShaderLanguages() {
+ const wgslId = "wgsl";
+ const glslId = "glsl";
+ const ensure = (id: string) => {
+ try {
+ monaco.languages.getLanguages().find((l) => l.id === id) || monaco.languages.register({ id });
+ } catch {
+ monaco.languages.register({ id });
+ }
+ };
+ ensure(wgslId);
+ ensure(glslId);
+
+ const slashComments: any[] = [
+ [/(\/\/.*$)/, "comment"],
+ [/\/\*/, { token: "comment", next: "@comment" }],
+ ];
+ const numberRule: any[] = [/(\d+(\.\d+)?([eE][+-]?\d+)?[fF]?)/, "number"];
+ const ident = /[A-Za-z_]\w*/;
+
+ monaco.languages.setMonarchTokensProvider(wgslId, {
+ defaultToken: "source",
+ tokenizer: {
+ root: [
+ ...slashComments,
+ numberRule,
+ [/(struct|var|let|const|override|fn|return|if|else|switch|case|default|break|continue|loop|for|while|discard|enable|requires|type|alias)\b/, "keyword"],
+ [/(true|false)/, "constant"],
+ [/(i32|u32|f32|f16|vec[234](?:i|u|f)?|mat[234]x[234]|ptr|array|texture\w*|sampler|bool)/, "type"],
+ [/@(binding|group|builtin|location|stage|vertex|fragment|compute|workgroup_size)/, "annotation"],
+ [ident, "identifier"],
+ [/\"([^\"\\]|\\.)*\"?/, "string"],
+ ],
+ comment: [
+ [/[^/*]+/, "comment"],
+ [/\*\//, "comment", "@pop"],
+ [/./, "comment"],
+ ],
+ },
+ } as any);
+
+ monaco.languages.setMonarchTokensProvider(glslId, {
+ defaultToken: "source",
+ tokenizer: {
+ root: [
+ [/#\s*(version|define|undef|if|ifdef|ifndef|else|elif|endif|extension|pragma|line).*/, "meta"],
+ ...slashComments,
+ numberRule,
+ [
+ /(attribute|varying|uniform|buffer|layout|in|out|inout|const|struct|return|if|else|switch|case|default|break|continue|discard|while|for|do|precision|highp|mediump|lowp)\b/,
+ "keyword",
+ ],
+ [/(void|bool|int|uint|float|double|mat[234](?:x[234])?|vec[234]|ivec[234]|u?sampler\w*|image\w*)/, "type"],
+ [/(true|false)/, "constant"],
+ [ident, "identifier"],
+ [/"([^"\\]|\\.)*"?/, "string"],
+ ],
+ comment: [
+ [/[^/*]+/, "comment"],
+ [/\*\//, "comment", "@pop"],
+ [/./, "comment"],
+ ],
+ },
+ } as any);
+
+ const cfg: monaco.languages.LanguageConfiguration = {
+ comments: { lineComment: "//", blockComment: ["/*", "*/"] },
+ brackets: [
+ ["{", "}"],
+ ["[", "]"],
+ ["(", ")"],
+ ],
+ autoClosingPairs: [
+ { open: "{", close: "}" },
+ { open: "[", close: "]" },
+ { open: "(", close: ")" },
+ { open: '"', close: '"' },
+ ],
+ };
+ monaco.languages.setLanguageConfiguration(wgslId, cfg);
+ monaco.languages.setLanguageConfiguration(glslId, cfg);
+}
diff --git a/packages/tools/playground/src/tools/monaco/monacoManager.ts b/packages/tools/playground/src/tools/monaco/monacoManager.ts
new file mode 100644
index 00000000000..b201ef2ea44
--- /dev/null
+++ b/packages/tools/playground/src/tools/monaco/monacoManager.ts
@@ -0,0 +1,1007 @@
+/* eslint-disable no-await-in-loop */
+/* eslint-disable @typescript-eslint/no-floating-promises */
+import * as monaco from "monaco-editor/esm/vs/editor/editor.api";
+import type { GlobalState } from "../../globalState";
+import { Utilities } from "../utilities";
+import { Logger, Observable } from "@dev/core";
+import { debounce } from "ts-debounce";
+import { v5 as uuidv5 } from "uuid";
+
+import { EditorHost } from "./editor/editorHost";
+import { FilesManager } from "./files/filesManager";
+import { TsPipeline } from "./ts/tsPipeline";
+import { TypingsService } from "./typings/typingsService";
+import { RegisterShaderLanguages } from "./language/shaderLanguages";
+import { RegisterColorProvider } from "./language/colorProvider";
+import { TemplatesService } from "./completion/templatesService";
+import { CompletionService } from "./completion/completionService";
+import { CodeAnalysisService } from "./analysis/codeAnalysisService";
+import { DefinitionService } from "./navigation/definitionService";
+import type { V2RunnerOptions } from "./run/runner";
+import { ManifestVersion, type V2Manifest } from "../snippet";
+import { CreateV2Runner } from "./run/runner";
+import { CompilationError } from "../../components/errorDisplayComponent";
+import { ParseSpec } from "./typings/utils";
+import { CodeLensService } from "./codeLens/codeLensProvider";
+import type { RequestLocalResolve } from "./typings/types";
+import { WriteLastLocal } from "../localSession";
+
+interface IRunConfig {
+ manifest: V2Manifest;
+ options: V2RunnerOptions;
+}
+
+const NamespaceUUID = "6ba7b810-9dad-11d1-80b4-00c04fd430c8";
+/**
+ *
+ */
+export class MonacoManager {
+ private _editorHost = new EditorHost();
+ private _files = new FilesManager(() => (this.globalState.language === "JS" ? "javascript" : "typescript"));
+ private _tsPipeline = new TsPipeline();
+ private _typings = new TypingsService(
+ (spec, target) => this._tsPipeline.addPathsFor(spec, target),
+ (resolveInfo) => this._onRequestLocalResolve(resolveInfo)
+ );
+ private _templates = new TemplatesService();
+ private _completions = new CompletionService();
+ private _codeAnalysis = new CodeAnalysisService();
+ private _definitions = new DefinitionService(this._files, (path) => this.switchActiveFile(path));
+ private _codeLens = new CodeLensService(async (fullSpec) => await this._resolveOneLocalAsync(fullSpec));
+
+ private _hostElement!: HTMLDivElement;
+ private _lastRunConfig: IRunConfig | null = null;
+ private _lastRunConfigHash: string | null = null;
+
+ private _hydrating = false;
+ private _initialized = false;
+ private _skipOnceLocal = false;
+
+ private _localPkgHandles = new Map();
+ private _localPkgIntervals = new Map();
+
+ public constructor(public globalState: GlobalState) {
+ window.addEventListener("beforeunload", (evt) => {
+ if (this._files.isDirty && Utilities.ReadBoolFromStore("safe-mode", false)) {
+ const message = "Are you sure you want to leave. You have unsaved work.";
+ evt.preventDefault();
+ (evt as any).returnValue = message;
+ }
+ });
+
+ globalState.onNewRequiredObservable.add(() => {
+ if (Utilities.CheckSafeMode("Are you sure you want to create a new playground?")) {
+ this._setNewContent();
+ this._resetEditor(true);
+ }
+ });
+
+ globalState.onClearRequiredObservable.add(() => {
+ if (Utilities.CheckSafeMode("Are you sure you want to remove all your code?")) {
+ this._editorHost.editor?.setValue("");
+ this._resetEditor();
+ }
+ });
+
+ globalState.onInsertSnippetRequiredObservable.add((snippetKey) => {
+ this.insertSnippet(snippetKey);
+ });
+
+ globalState.onNavigateRequiredObservable.add((position) => {
+ this._editorHost.editor?.revealPositionInCenter(position, monaco.editor.ScrollType.Smooth);
+ this._editorHost.editor?.setPosition(position);
+ });
+
+ globalState.onRunExecutedObservable.add(() => {
+ // ATA should complete before run, not after - this call is redundant
+ // this._syncBareImportStubsAsync();
+ });
+
+ globalState.onSavedObservable.add(() => {
+ this._files.setDirty(false);
+ });
+
+ globalState.onCodeLoaded.add((code) => {
+ if (!code) {
+ this._setDefaultContent();
+ this._syncBareImportStubsAsync();
+ return;
+ }
+ if (this._editorHost.editor) {
+ this._editorHost.editor.setValue(code);
+ this._files.setDirty(false);
+ this.globalState.onRunRequiredObservable.notifyObservers();
+ this._syncBareImportStubsAsync();
+ } else {
+ this.globalState.currentCode = code;
+ this._syncBareImportStubsAsync();
+ }
+ });
+
+ globalState.onFormatCodeRequiredObservable.add(() => {
+ this._editorHost.editor?.getAction("editor.action.formatDocument")?.run();
+ });
+
+ globalState.onMinimapChangedObservable.add((value) => {
+ this._editorHost.editor?.updateOptions({ minimap: { enabled: value } as any });
+ });
+
+ globalState.onFontSizeChangedObservable.add(() => {
+ this._editorHost.editor?.updateOptions({ fontSize: parseInt(Utilities.ReadStringFromStore("font-size", "14")) } as any);
+ });
+
+ globalState.onLanguageChangedObservable.add(async () => {
+ this._setNewContent();
+ this._syncBareImportStubsAsync();
+ this.invalidateRunnerCache();
+ globalState.onFilesChangedObservable.notifyObservers();
+ });
+
+ globalState.onThemeChangedObservable.add(() => {
+ const theme = Utilities.ReadStringFromStore("theme", "Light") === "Dark" ? "vs-dark" : "vs-light";
+ (this.editorHost.editor as any)._themeService.setTheme(theme);
+ });
+
+ // V2 hydrate
+ this.globalState.onV2HydrateRequiredObservable.add(async ({ files, entry, imports, language }) => {
+ this._hydrating = true;
+ while (!this._initialized) {
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ }
+ if (language !== this.globalState.language) {
+ Utilities.SwitchLanguage(language, this.globalState, true);
+ }
+ const first = entry && files[entry] ? entry : Object.keys(files)[0];
+ this.setFiles(files, first, entry, imports);
+
+ this._initializeFileState(first);
+
+ // Force sync models after all files are loaded to avoid race conditions
+ this._tsPipeline.forceSyncModels();
+
+ // Sync ATA after hydration to ensure types are loaded
+ await this._syncBareImportStubsAsync();
+
+ globalState.onRunRequiredObservable.notifyObservers();
+ this._hydrating = false;
+ });
+
+ this.globalState.onFilesChangedObservable.add(() => {
+ // Prevent worker restart during hydration to avoid race conditions
+ if (!this._hydrating) {
+ this._tsPipeline.forceSyncModels();
+ }
+ this._tsPipeline.addWorkspaceFileDeclarations(this.globalState.files || {});
+ this.invalidateRunnerCache();
+ });
+
+ const pgConnect = { onRequestCodeChangeObservable: new Observable() };
+ pgConnect.onRequestCodeChangeObservable.add((options: any) => {
+ let code = this._editorHost.editor?.getValue() || "";
+ code = code.replace(options.regex, options.replace);
+ this._editorHost.editor?.setValue(code);
+ });
+ (window as any).Playground = pgConnect;
+
+ // Initialize getRunnable as a bound method
+ this.globalState.getRunnable = this.getRunnableAsync.bind(this);
+ }
+
+ private _initializeFileState(entry: string) {
+ this.globalState.openEditors = [entry];
+ this.globalState.activeEditorPath = entry;
+ this.globalState.onOpenEditorsChangedObservable?.notifyObservers();
+ this.globalState.onActiveEditorChangedObservable?.notifyObservers();
+ }
+ public setFiles(files: Record, activePath: string, entryPath?: string, imports?: Record) {
+ const defaultEntry = this.globalState.language === "JS" ? "index.js" : "index.ts";
+ const entry = entryPath || defaultEntry;
+ if (!files[entry]) {
+ files[entry] = this.globalState.language === "JS" ? "// Entry file\n" : "// Entry file\n";
+ }
+ if (!activePath) {
+ activePath = entry;
+ }
+
+ this._files.setFiles(files, (p, code) => {
+ this.globalState.files[p] = code;
+ this._files.setDirty(true);
+ });
+ this.globalState.files = { ...files };
+ if (imports) {
+ this.globalState.importsMap = { ...imports };
+ }
+ this.globalState.entryFilePath = entry;
+ this.globalState.activeFilePath = activePath;
+
+ this.globalState.onFilesChangedObservable.notifyObservers();
+ this.globalState.onManifestChangedObservable.notifyObservers();
+
+ if (this._editorHost.editor) {
+ const model = this._files.getModel(activePath) || (monaco.editor.getModels()[0] ?? null);
+ if (model) {
+ this._editorHost.editor.setModel(model);
+ this._files.restoreViewState(activePath, this._editorHost.editor);
+ }
+ }
+ this._syncBareImportStubsAsync();
+ }
+
+ public getFiles() {
+ return this._files.getFiles();
+ }
+
+ public switchActiveFile(path: string) {
+ const editor = this._editorHost.editor as monaco.editor.IStandaloneCodeEditor;
+ if (!editor) {
+ return;
+ }
+ const prev = this.globalState.activeFilePath;
+ if (prev) {
+ this._files.saveViewState(prev, editor.saveViewState());
+ }
+ const model = this._files.getModel(path);
+ if (!model) {
+ return;
+ }
+ editor.setModel(model);
+ this.globalState.activeFilePath = path;
+ this._files.restoreViewState(path, editor);
+ this.globalState.onActiveFileChangedObservable.notifyObservers();
+ }
+
+ public addFile(path: string, initial = "") {
+ if (this._files.has(path)) {
+ return;
+ }
+
+ this._files.addFile(path, initial, (p, code) => {
+ this.globalState.files[p] = code;
+ this._files.setDirty(true);
+ });
+ this.globalState.files[path] = initial;
+ this.switchActiveFile(path);
+ this.globalState.onFilesChangedObservable.notifyObservers();
+ this.globalState.onManifestChangedObservable.notifyObservers();
+ }
+
+ public removeFile(path: string) {
+ this._files.removeFile(path);
+ delete this.globalState.files[path];
+
+ const fallback = this.globalState.language === "JS" ? "index.js" : "index.ts";
+ if (this.globalState.entryFilePath === path) {
+ if (!this.globalState.files[fallback]) {
+ this.addFile(fallback, "// Entry file\n");
+ }
+ this.globalState.entryFilePath = fallback;
+ }
+ if (!this.globalState.files[fallback]) {
+ this.addFile(fallback, "// Entry file\n");
+ }
+ const next = Object.keys(this.globalState.files)[0] || fallback;
+ this.switchActiveFile(next);
+ this.globalState.onFilesChangedObservable.notifyObservers();
+ this.globalState.onManifestChangedObservable.notifyObservers();
+ }
+
+ public renameFile(oldPath: string, newPath: string) {
+ const success = this._files.renameFile(oldPath, newPath, (p, code) => {
+ this.globalState.files[p] = code;
+ this._files.setDirty(true);
+ });
+
+ if (success) {
+ const content = this.globalState.files[oldPath];
+ delete this.globalState.files[oldPath];
+ this.globalState.files[newPath] = content;
+
+ if (this.globalState.entryFilePath === oldPath) {
+ this.globalState.entryFilePath = newPath;
+ }
+
+ if (this.globalState.activeFilePath === oldPath) {
+ this.globalState.activeFilePath = newPath;
+ }
+
+ this.globalState.onFilesChangedObservable.notifyObservers();
+ this.globalState.onManifestChangedObservable.notifyObservers();
+ this.globalState.onActiveFileChangedObservable.notifyObservers();
+
+ this._syncBareImportStubsAsync();
+ }
+
+ return success;
+ }
+
+ /**
+ * Create a configuration hash for caching runners
+ * @param config The run configuration to hash
+ * @returns A hash string representing the configuration
+ */
+ private _createConfigHash(config: IRunConfig): string {
+ const manifestStr = JSON.stringify(config.manifest);
+ const optionsStr = JSON.stringify({
+ monaco: !!config.options.monaco,
+ createModelsIfMissing: config.options.createModelsIfMissing,
+ importMapId: config.options.importMapId,
+ runtime: config.options.runtime,
+ });
+ return uuidv5(manifestStr + optionsStr, NamespaceUUID);
+ }
+
+ public get manifest(): V2Manifest {
+ const entry = this.globalState.entryFilePath || (this.globalState.language === "JS" ? "index.js" : "index.ts");
+ const files = this._files.getFiles();
+ const imports = this.globalState.importsMap || {};
+ return {
+ v: ManifestVersion,
+ language: this.globalState.language as "JS" | "TS",
+ entry,
+ imports,
+ files,
+ };
+ }
+
+ /**
+ * Get or create a V2 runner with caching based on configuration
+ * @returns Promise that resolves to a V2Runner instance
+ */
+ public async getRunnableAsync() {
+ const manifest = this.manifest;
+
+ const options: V2RunnerOptions = {
+ monaco,
+ createModelsIfMissing: true,
+ importMapId: "pg-v2-import-map",
+ skipDiagnostics: this._hydrating, // Skip diagnostics during hydration to avoid timeouts
+ onDiagnosticError: ({ path, message, line, column }) => {
+ const err = new CompilationError();
+ err.message = `${path}:${line}:${column} ${message}`;
+ err.lineNumber = line;
+ err.columnNumber = column;
+ this.globalState.onErrorObservable.notifyObservers(err);
+ },
+ };
+
+ const config: IRunConfig = { manifest, options };
+ const configHash = this._createConfigHash(config);
+
+ // Check if we can reuse the existing runner
+ if (this._lastRunConfig && this._lastRunConfigHash === configHash && this.globalState.currentRunner && this._isConfigurationEquivalent(this._lastRunConfig, config)) {
+ return this.globalState.currentRunner;
+ }
+
+ // Wait for any ongoing ATA operations before creating runner
+ if (this._typings.isAtaInFlight) {
+ Logger.Log("ATA is in flight, waiting for completion before creating runner...");
+ const ataCompleted = await this._typings.waitForAtaCompletionAsync(1500);
+ if (!ataCompleted) {
+ Logger.Warn("ATA did not complete within timeout, proceeding with runner creation anyway");
+ } else {
+ Logger.Log("ATA completed, proceeding with runner creation");
+ }
+ }
+
+ // Dispose the previous runner if it exists
+ try {
+ this.globalState.currentRunner?.dispose?.();
+ } catch {}
+
+ // Create new runner and cache the configuration
+ this.globalState.currentRunner = await CreateV2Runner(manifest, options, this._tsPipeline);
+ this._lastRunConfig = config;
+ this._lastRunConfigHash = configHash;
+
+ return this.globalState.currentRunner;
+ }
+
+ /**
+ * Check if two configurations are equivalent for caching purposes
+ * @param config1 First configuration to compare
+ * @param config2 Second configuration to compare
+ * @returns true if configurations are equivalent
+ */
+ private _isConfigurationEquivalent(config1: IRunConfig, config2: IRunConfig): boolean {
+ // Compare manifests deeply
+ if (config1.manifest.language !== config2.manifest.language || config1.manifest.entry !== config2.manifest.entry) {
+ return false;
+ }
+
+ // Compare file contents
+ const files1Keys = Object.keys(config1.manifest.files).sort();
+ const files2Keys = Object.keys(config2.manifest.files).sort();
+
+ if (files1Keys.length !== files2Keys.length) {
+ return false;
+ }
+
+ for (let i = 0; i < files1Keys.length; i++) {
+ const key = files1Keys[i];
+ if (key !== files2Keys[i] || config1.manifest.files[key] !== config2.manifest.files[key]) {
+ return false;
+ }
+ }
+
+ // Compare imports
+ const imports1 = JSON.stringify(config1.manifest.imports || {});
+ const imports2 = JSON.stringify(config2.manifest.imports || {});
+
+ return imports1 === imports2;
+ }
+
+ /**
+ * Invalidate the runner cache (call when files or configuration changes)
+ */
+ public invalidateRunnerCache(): void {
+ this._lastRunConfig = null;
+ this._lastRunConfigHash = null;
+ }
+
+ private _createEditor() {
+ const lang = this.globalState.language === "JS" ? "javascript" : "typescript";
+ if (!this._hostElement) {
+ return;
+ }
+ this._editorHost.create(this._hostElement, lang);
+
+ // Key binding to run the PG code - ctrl/cmd + enter
+ this._editorHost.editor.addAction({
+ id: "pg.run",
+ label: "Run",
+ keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter],
+ // eslint-disable-next-line
+ run: () => {
+ this.globalState.onRunRequiredObservable.notifyObservers();
+ },
+ });
+
+ const analyzeCodeDebounced = debounce(async () => {
+ const model = this._editorHost.editor.getModel();
+ if (model) {
+ await this._codeAnalysis.analyzeCodeAsync(model, this.globalState);
+ }
+ }, 500);
+ const refreshStubsDebounced = debounce(async () => await this._syncBareImportStubsAsync(), 300);
+ const serializeSessionDebounced = debounce(() => {
+ WriteLastLocal(this.globalState);
+ }, 500);
+
+ this._editorHost.editor.onDidChangeModelContent(() => {
+ const newCode = this._editorHost.editor.getValue();
+ if (this.globalState.currentCode !== newCode) {
+ this.globalState.currentCode = newCode;
+ this._files.setDirty(true);
+ analyzeCodeDebounced();
+ refreshStubsDebounced();
+
+ // After any user input we can serialize the snippet's session state
+ // Important this only is triggered when these are real user inputs and not from PG load itself
+ // But if we just hydrated from a local session, don't immediately overwrite it
+ if (this.globalState.currentSnippetRevision === "local" && !this._skipOnceLocal) {
+ this._skipOnceLocal = true;
+ return;
+ }
+ serializeSessionDebounced();
+ }
+ });
+ if (this.globalState.currentCode) {
+ this._editorHost.editor.setValue(this.globalState.currentCode);
+ }
+
+ if (this.globalState.currentCode) {
+ this.globalState.onRunRequiredObservable.notifyObservers();
+ }
+
+ this._editorHost.editor.onDidChangeModel(() => {
+ const m = this._editorHost.editor.getModel();
+ if (!m) {
+ return;
+ }
+ let path: string | undefined;
+ for (const p of this._files.paths()) {
+ const model = this._files.getModel(p)!;
+ if (model.uri.toString() === m.uri.toString()) {
+ path = p;
+ break;
+ }
+ }
+ if (!path) {
+ return;
+ }
+ if (this.globalState.activeFilePath !== path) {
+ this.globalState.activeFilePath = path;
+ this.globalState.onActiveFileChangedObservable.notifyObservers();
+ }
+ });
+
+ this._syncBareImportStubsAsync();
+ }
+
+ public async setupMonacoAsync(hostElement: HTMLDivElement) {
+ this._hostElement = hostElement;
+
+ // Register shader languages
+ RegisterShaderLanguages();
+
+ // Register color providers
+ RegisterColorProvider("javascript");
+ RegisterColorProvider("typescript");
+
+ // Load templates
+ await this._templates.loadAsync();
+
+ const declarations = [
+ "https://preview.babylonjs.com/babylon.d.ts",
+ "https://preview.babylonjs.com/gui/babylon.gui.d.ts",
+ "https://preview.babylonjs.com/loaders/babylonjs.loaders.d.ts",
+ "https://preview.babylonjs.com/materialsLibrary/babylonjs.materials.d.ts",
+ "https://preview.babylonjs.com/nodeEditor/babylon.nodeEditor.d.ts",
+ "https://preview.babylonjs.com/postProcessesLibrary/babylonjs.postProcess.d.ts",
+ "https://preview.babylonjs.com/proceduralTexturesLibrary/babylonjs.proceduralTextures.d.ts",
+ "https://preview.babylonjs.com/serializers/babylonjs.serializers.d.ts",
+ "https://preview.babylonjs.com/inspector/babylon.inspector.d.ts",
+ "https://preview.babylonjs.com/accessibility/babylon.accessibility.d.ts",
+ "https://preview.babylonjs.com/addons/babylonjs.addons.d.ts",
+ "https://preview.babylonjs.com/glTF2Interface/babylon.glTF2Interface.d.ts",
+ "https://assets.babylonjs.com/generated/Assets.d.ts",
+ ];
+
+ // snapshot/version/local overrides
+ let snapshot = "";
+ if (window.location.search.indexOf("snapshot=") !== -1) {
+ snapshot = window.location.search.split("snapshot=")[1].split("&")[0];
+ for (let i = 0; i < declarations.length; i++) {
+ declarations[i] = declarations[i].replace("https://preview.babylonjs.com", "https://snapshots-cvgtc2eugrd3cgfd.z01.azurefd.net/" + snapshot);
+ }
+ }
+
+ let version = "";
+ if (window.location.search.indexOf("version=") !== -1) {
+ version = window.location.search.split("version=")[1].split("&")[0];
+ for (let i = 0; i < declarations.length; i++) {
+ declarations[i] = declarations[i].replace("https://preview.babylonjs.com", "https://cdn.babylonjs.com/v" + version);
+ }
+ }
+
+ if (location.hostname === "localhost" && location.search.indexOf("dist") === -1) {
+ for (let i = 0; i < declarations.length; i++) {
+ declarations[i] = declarations[i].replace("https://preview.babylonjs.com/", "//localhost:1337/");
+ }
+ }
+
+ if (location.href.indexOf("BabylonToolkit") !== -1 || Utilities.ReadBoolFromStore("babylon-toolkit", false) || Utilities.ReadBoolFromStore("babylon-toolkit-used", false)) {
+ declarations.push("https://cdn.jsdelivr.net/gh/BabylonJS/BabylonToolkit@master/Runtime/babylon.toolkit.d.ts");
+ declarations.push("https://cdn.jsdelivr.net/gh/BabylonJS/BabylonToolkit@master/Runtime/default.playground.d.ts");
+ }
+
+ const timestamp = (typeof globalThis !== "undefined" && (globalThis as any).__babylonSnapshotTimestamp__) || 0;
+ if (timestamp) {
+ for (let i = 0; i < declarations.length; i++) {
+ if (declarations[i].indexOf("preview.babylonjs.com") !== -1) {
+ declarations[i] = declarations[i] + "?t=" + timestamp;
+ }
+ }
+ }
+
+ let libContent = "";
+ const responses = await Promise.all(declarations.map(async (d) => await fetch(d)));
+ const fallbackUrl = "https://snapshots-cvgtc2eugrd3cgfd.z01.azurefd.net/refs/heads/master";
+ for (const response of responses) {
+ if (!response.ok) {
+ const fallbackResponse = await fetch(response.url.replace("https://preview.babylonjs.com", fallbackUrl));
+ if (fallbackResponse.ok) {
+ libContent += await fallbackResponse.text();
+ } else {
+ Logger.Log(`missing declaration: ${response.url}`);
+ }
+ } else {
+ libContent += await response.text();
+ }
+ }
+ libContent += `
+interface Window { engine: BABYLON.Engine; canvas: HTMLCanvasElement; }
+declare var engine: BABYLON.Engine;
+declare var canvas: HTMLCanvasElement;
+ `;
+
+ this._tsPipeline.setup(libContent);
+
+ // Register completion provider
+ this._completions.register(this.globalState.language as "JS" | "TS", this._templates.templates);
+
+ // Register code lens
+ this._codeLens.register(this.globalState.language as "JS" | "TS");
+
+ // Install definition provider
+ this._definitions.installProvider();
+
+ // Force sync models for better import recognition
+ this._tsPipeline.forceSyncModels();
+
+ this._createEditor();
+ await this._syncBareImportStubsAsync();
+ await this.typingsService.waitForAtaCompletionAsync();
+
+ if (!this.globalState.loadingCodeInProgress && !this._hydrating) {
+ setTimeout(() => this._setDefaultContent(), 100);
+ }
+ this._initialized = true;
+ }
+
+ // ---------------- Defaults ----------------
+ private _setDefaultContent() {
+ const entry = this.globalState.language === "JS" ? "index.js" : "index.ts";
+ const defaultJs = `export const createScene = function () {
+ // This creates a basic Babylon Scene object (non-mesh)
+ var scene = new BABYLON.Scene(engine);
+
+ // This creates and positions a free camera (non-mesh)
+ var camera = new BABYLON.FreeCamera("camera1", new BABYLON.Vector3(0, 5, -10), scene);
+
+ // This targets the camera to scene origin
+ camera.setTarget(BABYLON.Vector3.Zero());
+
+ // This attaches the camera to the canvas
+ camera.attachControl(canvas, true);
+
+ // This creates a light, aiming 0,1,0 - to the sky (non-mesh)
+ var light = new BABYLON.HemisphericLight("light", new BABYLON.Vector3(0, 1, 0), scene);
+
+ // Default intensity is 1. Let's dim the light a small amount
+ light.intensity = 0.7;
+
+ // Our built-in 'sphere' shape.
+ var sphere = BABYLON.MeshBuilder.CreateSphere("sphere", {diameter: 2, segments: 32}, scene);
+
+ // Move the sphere upward 1/2 its height
+ sphere.position.y = 1;
+
+ // Our built-in 'ground' shape.
+ var ground = BABYLON.MeshBuilder.CreateGround("ground", {width: 6, height: 6}, scene);
+
+ return scene;
+};`;
+ const defaultTs = `class Playground {
+ public static CreateScene(engine: BABYLON.Engine, canvas: HTMLCanvasElement): BABYLON.Scene {
+ // This creates a basic Babylon Scene object (non-mesh)
+ var scene = new BABYLON.Scene(engine);
+
+ // This creates and positions a free camera (non-mesh)
+ var camera = new BABYLON.FreeCamera("camera1", new BABYLON.Vector3(0, 5, -10), scene);
+
+ // This targets the camera to scene origin
+ camera.setTarget(BABYLON.Vector3.Zero());
+
+ // This attaches the camera to the canvas
+ camera.attachControl(canvas, true);
+
+ // This creates a light, aiming 0,1,0 - to the sky (non-mesh)
+ var light = new BABYLON.HemisphericLight("light", new BABYLON.Vector3(0, 1, 0), scene);
+ // Default intensity is 1. Let's dim the light a small amount
+ light.intensity = 0.7;
+
+ // Our built-in 'sphere' shape.
+ var sphere = BABYLON.MeshBuilder.CreateSphere("sphere", {diameter: 2, segments: 32}, scene);
+
+ // Move the sphere upward 1/2 its height
+ sphere.position.y = 1;
+
+ // Our built-in 'ground' shape.
+ var ground = BABYLON.MeshBuilder.CreateGround("ground", {width: 6, height: 6}, scene);
+
+ return scene;
+ }
+}
+export { Playground };`;
+ const defaultCode = this.globalState.language === "JS" ? defaultJs : defaultTs;
+
+ this.globalState.entryFilePath = entry;
+ this.globalState.activeFilePath = entry;
+ this.globalState.openEditors = [entry];
+
+ if (!this._files.has(entry)) {
+ this._files.addFile(entry, defaultCode, (p, code) => {
+ this.globalState.files[p] = code;
+ this._files.setDirty(true);
+ });
+ this.globalState.files[entry] = defaultCode;
+ const model = this._files.getModel(entry)!;
+ if (this._editorHost.editor) {
+ this._editorHost.editor.setModel(model);
+ }
+ } else {
+ const model = this._files.getModel(entry)!;
+ if (!model.getValue()) {
+ model.setValue(defaultCode);
+ }
+ this.switchActiveFile(entry);
+ }
+
+ if (!this.globalState.filesOrder || !this.globalState.filesOrder.length) {
+ this.globalState.filesOrder = [entry];
+ this.globalState.onFilesOrderChangedObservable?.notifyObservers();
+ }
+ this._files.setDirty(false);
+ this._initializeFileState(entry);
+ this.globalState.onFilesChangedObservable.notifyObservers();
+ this.globalState.onManifestChangedObservable.notifyObservers();
+ this.globalState.onRunRequiredObservable.notifyObservers();
+ }
+
+ private _setNewContent() {
+ const editor = this._editorHost.editor;
+ if (editor) {
+ editor.setValue("");
+ }
+
+ this._files.setFiles({}, (p, code) => {
+ this.globalState.files[p] = code;
+ this._files.setDirty(true);
+ });
+
+ this.globalState.files = {};
+ this.globalState.filesOrder = [];
+ this.globalState.importsMap = {};
+ this.globalState.entryFilePath = undefined as any;
+ this.globalState.activeFilePath = undefined as any;
+ this.globalState.currentSnippetToken = "";
+
+ this.globalState.onFilesChangedObservable.notifyObservers();
+ this.globalState.onManifestChangedObservable.notifyObservers();
+
+ this._setDefaultContent();
+
+ this.globalState.onRunRequiredObservable.notifyObservers();
+
+ if (location.pathname.indexOf("pg/") !== -1) {
+ (window as any).location.pathname = "";
+ }
+
+ this._syncBareImportStubsAsync();
+ }
+
+ private _resetEditor(resetMetadata?: boolean) {
+ (window as any).location.hash = "";
+ if (resetMetadata) {
+ this.globalState.currentSnippetTitle = "";
+ this.globalState.currentSnippetDescription = "";
+ this.globalState.currentSnippetTags = "";
+ }
+ this._files.setDirty(true);
+ }
+
+ // ---------------- Typings / bare import stubs ----------------
+ private _collectAllSourceTexts(): string[] {
+ return Object.values(this.globalState.files || {});
+ }
+
+ private async _syncBareImportStubsAsync() {
+ const specs = this._typings.discoverBareImports(this._collectAllSourceTexts());
+ this._typings.installBareImportStubs(specs);
+ await this._typings.acquireForAsync(specs);
+ }
+
+ public setTagCandidates(candidates: { name: string; tagName: string }[] | undefined) {
+ this._codeAnalysis.setTagCandidates(candidates);
+ this._completions.setTagCandidates(candidates);
+ }
+
+ /**
+ * Get the current editor host instance
+ */
+ public get editorHost() {
+ return this._editorHost;
+ }
+
+ /**
+ * Get the current files manager instance
+ */
+ public get filesManager() {
+ return this._files;
+ }
+
+ /**
+ * Get the current typings service instance
+ */
+ public get typingsService() {
+ return this._typings;
+ }
+
+ public insertSnippet(snippetKey: string) {
+ const editor = this._editorHost.editor;
+ if (!editor) {
+ return;
+ }
+
+ const template = this._templates.templates.find((t) => t.key === snippetKey);
+ if (!template) {
+ return;
+ }
+
+ const selection = editor.getSelection();
+ if (selection) {
+ editor.executeEdits("snippet", [
+ {
+ range: selection,
+ text: template.insertText,
+ forceMoveMarkers: true,
+ },
+ ]);
+ }
+ }
+
+ public insertCodeAtCursor(code: string, indentation = 0) {
+ const editor = this._editorHost.editor;
+ if (!editor) {
+ return;
+ }
+
+ const indentedCode = this._indentCode(code, indentation);
+ const position = editor.getPosition();
+ if (position) {
+ editor.executeEdits("insert", [
+ {
+ range: new monaco.Range(position.lineNumber, position.column, position.lineNumber, position.column),
+ text: indentedCode,
+ forceMoveMarkers: true,
+ },
+ ]);
+ }
+ }
+
+ private _indentCode(code: string, indentation: number): string {
+ if (indentation <= 0) {
+ return code;
+ }
+
+ const indent = " ".repeat(indentation);
+ return code
+ .split("\n")
+ .map((line) => (line.length > 0 ? indent + line : line))
+ .join("\n");
+ }
+
+ private async _resolveOneLocalAsync(fullSpec: string) {
+ const picked = await this._pickDirectoryAsync();
+ if (!picked) {
+ return;
+ }
+ if (this._localPkgHandles.has(fullSpec)) {
+ const existingInterval = this._localPkgIntervals.get(fullSpec);
+ if (existingInterval) {
+ clearInterval(existingInterval);
+ this._localPkgIntervals.delete(fullSpec);
+ }
+ }
+
+ let initial = await this._enumerateDirectoryAsync(picked.handle, true);
+ // Monitor for changes every 3s
+ const interval = setInterval(async () => {
+ const next = await this._enumerateDirectoryAsync(picked.handle, true);
+ let changed = false;
+ if (next.files.length !== initial.files.length) {
+ changed = true;
+ } else {
+ for (let i = 0; i < next.files.length; i++) {
+ if (next.files[i].path !== initial.files[i].path || next.files[i].lastModified !== initial.files[i].lastModified) {
+ changed = true;
+ break;
+ }
+ }
+ }
+ if (!changed) {
+ return;
+ }
+ Logger.Log(`Detected change in local package: ${fullSpec}, updating...`);
+ const nextContent = await this._enumerateDirectoryAsync(picked.handle, false);
+ if (nextContent.files.length === 0) {
+ Logger.Warn(`No relevant files found in local package: ${fullSpec}, skipping update`);
+ return;
+ }
+ await this._typings.mapLocalTypingsAsync(fullSpec, `${ParseSpec(fullSpec).name}@local`, nextContent.files);
+ this._tsPipeline.forceSyncModels();
+ this._syncBareImportStubsAsync();
+ initial = next;
+ this._localPkgHandles.set(fullSpec, nextContent.handle);
+ this._publishLocalHandlesToWindow();
+ }, 3000);
+
+ this._localPkgIntervals.set(fullSpec, interval);
+ this._localPkgHandles.set(fullSpec, picked.handle);
+ this._publishLocalHandlesToWindow();
+ await this._typings.mapLocalTypingsAsync(fullSpec, `${ParseSpec(fullSpec).name}@local`, picked.files);
+ this._tsPipeline.forceSyncModels();
+ this._syncBareImportStubsAsync();
+ }
+
+ private _hasFsAccessApi(): boolean {
+ // @ts-expect-error: FS Access API
+ return !!window.showDirectoryPicker;
+ }
+
+ private async _enumerateDirectoryAsync(
+ handle: FileSystemDirectoryHandle,
+ skipContent: boolean = false
+ ): Promise<{
+ dirName: string;
+ handle: FileSystemDirectoryHandle;
+ files: Array<{ path: string; content: string; lastModified: number }>;
+ }> {
+ const files: Array<{ path: string; content: string; lastModified: number }> = [];
+ const skipDir = /^(node_modules|\.git|\.hg|\.svn|\.idea|\.vscode)$/i;
+ const walkAsync = async (dir: FileSystemDirectoryHandle, prefix = "") => {
+ // @ts-expect-error: .values() is not in TS lib yet
+ for await (const entry of dir.values()) {
+ if (entry.kind === "directory") {
+ if (skipDir.test(entry.name)) {
+ continue;
+ }
+ await walkAsync(entry as FileSystemDirectoryHandle, `${prefix}${entry.name}/`);
+ } else {
+ const lower = entry.name.toLowerCase();
+ if (!(lower.endsWith(".d.ts") || entry.name === "package.json")) {
+ continue;
+ }
+ const file = await (entry as FileSystemFileHandle).getFile();
+ files.push({
+ path: `${prefix}${entry.name}`,
+ content: skipContent ? "" : await file.text(),
+ lastModified: file.lastModified,
+ });
+ }
+ }
+ };
+
+ await walkAsync(handle, "");
+ return { dirName: handle.name, handle, files };
+ }
+
+ private async _pickDirectoryAsync(): Promise<{
+ dirName: string;
+ handle: FileSystemDirectoryHandle;
+ files: Array<{ path: string; content: string; lastModified: number }>;
+ } | null> {
+ if (!this._hasFsAccessApi()) {
+ alert("Your browser does not support the File System Access API. Please use a compatible browser like Chrome or Edge.");
+ return null;
+ }
+
+ // @ts-expect-error: FS Access API
+ const handle: FileSystemDirectoryHandle = await window.showDirectoryPicker();
+
+ // Ask for (and cache) read permission so we can re-read at runtime
+ // @ts-expect-error request perms
+ const perm = await handle.requestPermission?.({ mode: "read" });
+ if (perm === "denied") {
+ return null;
+ }
+
+ return await this._enumerateDirectoryAsync(handle);
+ }
+
+ private _publishLocalHandlesToWindow() {
+ (window as any).__PG_LOCAL_PKG_HANDLES__ = Object.fromEntries(this._localPkgHandles);
+ }
+
+ private _onRequestLocalResolve = (fullSpec: RequestLocalResolve) => {
+ Logger.Log("Requesting local package for: " + fullSpec.fullSpec);
+ };
+
+ public dispose() {
+ Logger.Log("Disposing monaco manager");
+ this._typings?.dispose();
+ this._files?.dispose();
+ this._tsPipeline?.dispose();
+ this._editorHost?.dispose();
+
+ // Clear any cached runners
+ this.globalState.currentRunner?.dispose?.();
+ this.globalState.currentRunner = undefined;
+
+ // Clear caches
+ this._lastRunConfig = null;
+ this._lastRunConfigHash = null;
+ this._localPkgHandles.clear();
+ }
+}
diff --git a/packages/tools/playground/src/tools/monaco/navigation/definitionService.ts b/packages/tools/playground/src/tools/monaco/navigation/definitionService.ts
new file mode 100644
index 00000000000..e20d0736815
--- /dev/null
+++ b/packages/tools/playground/src/tools/monaco/navigation/definitionService.ts
@@ -0,0 +1,427 @@
+import * as monaco from "monaco-editor/esm/vs/editor/editor.api";
+import type { FilesManager } from "../files/filesManager";
+import { ImportIndexService } from "./importIndexService";
+
+/**
+ * Provides cross-module definition and reference support for Monaco editor
+ */
+export class DefinitionService {
+ private _disposables: monaco.IDisposable[] = [];
+ private _definitionWorker?: Worker;
+ private _importIndex = new ImportIndexService();
+
+ constructor(
+ private _files: FilesManager,
+ private _switchFile?: (path: string) => void
+ ) {}
+
+ dispose() {
+ this._disposables.forEach((d) => d.dispose());
+ this._disposables = [];
+ this._definitionWorker?.terminate();
+ }
+
+ /**
+ * Install the cross-module definition provider and cmd+click override
+ */
+ installProvider() {
+ this._disposables.forEach((d) => d.dispose());
+ this._disposables = [];
+
+ // Wire cmd+click override for better navigation
+ this._wireCmdClickOverride();
+
+ // Definition provider for cross-file navigation
+ const defProvider = monaco.languages.registerDefinitionProvider(["typescript", "javascript"], {
+ // eslint-disable-next-line
+ provideDefinition: async (model, position) => {
+ const word = model.getWordAtPosition(position);
+ if (!word) {
+ return [];
+ }
+
+ const imports = this._findImportsInModel(model);
+ for (const imp of imports) {
+ if (this._isPositionInRange(position, imp.range)) {
+ const targetPath = this._resolveImportPath(model.uri.path, imp.spec);
+ const targetModel = this._files.getModel(targetPath);
+ if (targetModel) {
+ return [
+ {
+ uri: targetModel.uri,
+ range: new monaco.Range(1, 1, 1, 1),
+ },
+ ];
+ }
+ }
+
+ // Check if position is on an imported symbol
+ for (const entry of imp.entries) {
+ if (this._isPositionInRange(position, entry.range)) {
+ const targetPath = this._resolveImportPath(model.uri.path, imp.spec);
+ const targetModel = this._files.getModel(targetPath);
+ if (targetModel) {
+ const exportRange = this._findExportRangeInTarget(targetModel, targetPath, entry.imported, entry.isDefault);
+ if (exportRange) {
+ return [
+ {
+ uri: targetModel.uri,
+ range: exportRange,
+ },
+ ];
+ }
+ }
+ }
+ }
+ }
+
+ return [];
+ },
+ });
+
+ this._disposables.push(defProvider);
+ }
+
+ /**
+ * Wire cmd+click override for enhanced navigation
+ */
+ private _wireCmdClickOverride() {
+ let pendingNav: { targetPath: string; destRange: monaco.Range } | null = null;
+
+ const deferNav = (fn: () => void) => setTimeout(() => requestAnimationFrame(() => setTimeout(fn, 0)), 0);
+
+ // Override cmd+click for imports navigation
+ this._disposables.push(
+ monaco.editor.onDidCreateEditor((editor) => {
+ const mouseDown = editor.onMouseDown((e) => {
+ if (!e.event.leftButton) {
+ return;
+ }
+ const isCmd = e.event.metaKey || e.event.ctrlKey;
+ if (!isCmd) {
+ return;
+ }
+
+ const model = editor.getModel();
+ const pos = e.target.position;
+ if (!model || !pos) {
+ return;
+ }
+
+ e.event.preventDefault?.();
+ e.event.stopPropagation?.();
+
+ void (async () => {
+ try {
+ const hit = await this._importAtPositionAsync(model, pos);
+ if (!hit) {
+ pendingNav = null;
+ return;
+ }
+
+ const { spec, entries, isOnSpec, clickedBinding } = hit;
+ const isRelative = spec.startsWith("./") || spec.startsWith("../") || spec.startsWith("/");
+
+ // Only handle relative imports for now
+ if (!isRelative) {
+ pendingNav = null;
+ return;
+ }
+
+ const fromPath = model.uri.path.replace(/^[\\/]*pg[\\/]*/, "").replace(/\\/g, "/");
+ const targetPath = this._resolveRelativePath(fromPath, spec);
+
+ if (!targetPath || !this._files.has(targetPath)) {
+ pendingNav = null;
+ return;
+ }
+
+ const targetModel = this._files.getModel(targetPath);
+ if (!targetModel) {
+ pendingNav = null;
+ return;
+ }
+
+ let destRange: monaco.Range;
+ if (isOnSpec || entries.length === 0) {
+ destRange = new monaco.Range(1, 1, 1, 1);
+ } else {
+ const b = clickedBinding ?? entries[0];
+ destRange = this._findExportRangeInTarget(targetModel, targetPath, b.imported, b.isDefault) || new monaco.Range(1, 1, 1, 1);
+ }
+
+ pendingNav = { targetPath, destRange };
+ } catch {
+ pendingNav = null;
+ }
+ })();
+ });
+
+ const mouseUp = editor.onMouseUp((_e) => {
+ if (!pendingNav) {
+ return;
+ }
+
+ const { targetPath, destRange } = pendingNav;
+ pendingNav = null;
+
+ deferNav(() => {
+ if (!this._files.has(targetPath)) {
+ return;
+ }
+ // Use file switching callback if available, otherwise set model directly
+ if (this._switchFile) {
+ this._switchFile(targetPath);
+ } else {
+ const targetModel = this._files.getModel(targetPath);
+ if (targetModel && editor.getModel() !== targetModel) {
+ editor.setModel(targetModel);
+ }
+ }
+ editor.revealRangeInCenter(destRange, monaco.editor.ScrollType.Smooth);
+ editor.setPosition({ lineNumber: destRange.startLineNumber, column: destRange.startColumn });
+ editor.focus();
+ });
+ });
+
+ this._disposables.push(mouseDown, mouseUp);
+ })
+ );
+ }
+
+ private _findImportsInModel(model: monaco.editor.ITextModel) {
+ const code = model.getValue();
+ const imports: Array<{
+ spec: string;
+ range: monaco.Range;
+ entries: Array<{
+ imported: string;
+ local: string;
+ isDefault: boolean;
+ range: monaco.Range;
+ }>;
+ }> = [];
+
+ // Simple regex-based import parsing (could be enhanced with proper AST parsing)
+ const importRegex = /import\s+([^'"]*?)\s+from\s+['"]([^'"]+)['"];?/g;
+ let match;
+
+ while ((match = importRegex.exec(code)) !== null) {
+ const fullMatch = match[0];
+ const importClause = match[1];
+ const spec = match[2];
+
+ const startPos = model.getPositionAt(match.index);
+ const endPos = model.getPositionAt(match.index + fullMatch.length);
+ const range = new monaco.Range(startPos.lineNumber, startPos.column, endPos.lineNumber, endPos.column);
+
+ const entries = this._parseImportClause(model, importClause, match.index);
+
+ imports.push({
+ spec,
+ range,
+ entries,
+ });
+ }
+
+ return imports;
+ }
+
+ private _parseImportClause(
+ model: monaco.editor.ITextModel,
+ clause: string,
+ baseOffset: number
+ ): Array<{
+ imported: string;
+ local: string;
+ isDefault: boolean;
+ range: monaco.Range;
+ }> {
+ const entries: Array<{
+ imported: string;
+ local: string;
+ isDefault: boolean;
+ range: monaco.Range;
+ }> = [];
+
+ // Default import: import Foo from ...
+ const defaultMatch = clause.match(/^\s*(\w+)/);
+ if (defaultMatch) {
+ const name = defaultMatch[1];
+ const startPos = model.getPositionAt(baseOffset + clause.indexOf(name));
+ const endPos = model.getPositionAt(baseOffset + clause.indexOf(name) + name.length);
+ entries.push({
+ imported: "default",
+ local: name,
+ isDefault: true,
+ range: new monaco.Range(startPos.lineNumber, startPos.column, endPos.lineNumber, endPos.column),
+ });
+ }
+
+ // Named imports: import { a, b as c } from ...
+ const namedMatch = clause.match(/\{([^}]+)\}/);
+ if (namedMatch) {
+ const namedImports = namedMatch[1];
+ const imports = namedImports.split(",").map((s) => s.trim());
+
+ for (const imp of imports) {
+ const asMatch = imp.match(/(\w+)\s+as\s+(\w+)/);
+ if (asMatch) {
+ const imported = asMatch[1];
+ const local = asMatch[2];
+ const startPos = model.getPositionAt(baseOffset + clause.indexOf(imported));
+ const endPos = model.getPositionAt(baseOffset + clause.indexOf(imported) + imported.length);
+ entries.push({
+ imported,
+ local,
+ isDefault: false,
+ range: new monaco.Range(startPos.lineNumber, startPos.column, endPos.lineNumber, endPos.column),
+ });
+ } else if (imp.match(/^\w+$/)) {
+ const name = imp;
+ const startPos = model.getPositionAt(baseOffset + clause.indexOf(name));
+ const endPos = model.getPositionAt(baseOffset + clause.indexOf(name) + name.length);
+ entries.push({
+ imported: name,
+ local: name,
+ isDefault: false,
+ range: new monaco.Range(startPos.lineNumber, startPos.column, endPos.lineNumber, endPos.column),
+ });
+ }
+ }
+ }
+
+ return entries;
+ }
+
+ private _resolveImportPath(fromPath: string, spec: string): string {
+ if (spec.startsWith("./") || spec.startsWith("../")) {
+ // Strip any leading pg/ or similar prefixes
+ const cleanFromPath = fromPath.replace(/^[\\/]*pg[\\/]*/, "");
+ const base = cleanFromPath.split("/").slice(0, -1);
+ const parts = spec.split("/");
+
+ for (const part of parts) {
+ if (part === "..") {
+ base.pop();
+ } else if (part !== ".") {
+ base.push(part);
+ }
+ }
+
+ const resolved = base.join("/");
+
+ // Use _pickActual to handle extension resolution
+ return this._pickActual(resolved) || resolved;
+ }
+
+ return spec;
+ }
+
+ /**
+ * Find import at specific position in model
+ * @param model The Monaco text model
+ * @param position The cursor position
+ * @returns Import information if found
+ */
+ private async _importAtPositionAsync(model: monaco.editor.ITextModel, position: monaco.Position) {
+ const imports = this._importIndex.indexImports(model);
+ const pos = model.getOffsetAt(position);
+
+ const hit = imports.find((imp) => {
+ return pos >= imp.s && pos <= imp.e;
+ });
+
+ if (!hit) {
+ return null;
+ }
+
+ const clickedBinding =
+ hit.entries.find((e: any) => {
+ const startPos = model.getOffsetAt(new monaco.Position(e.range.startLineNumber, e.range.startColumn));
+ const endPos = model.getOffsetAt(new monaco.Position(e.range.endLineNumber, e.range.endColumn));
+ return pos >= startPos && pos <= endPos;
+ }) || null;
+
+ // Check if click is on the import spec itself
+ const specStart = model.getOffsetAt(new monaco.Position(hit.originSelectionRange.startLineNumber, hit.originSelectionRange.startColumn));
+ const specEnd = model.getOffsetAt(new monaco.Position(hit.originSelectionRange.endLineNumber, hit.originSelectionRange.endColumn));
+ const isOnSpec = pos >= specStart && pos <= specEnd;
+
+ return { ...hit, clickedBinding, isOnSpec };
+ }
+
+ /**
+ * Resolve relative import path
+ * @param fromPath The source file path
+ * @param relativePath The relative import path
+ * @returns The resolved absolute path or null if not found
+ */
+ private _resolveRelativePath(fromPath: string, relativePath: string): string | null {
+ // Strip any leading pg/ or similar prefixes
+ const cleanFromPath = fromPath.replace(/^[\\/]*pg[\\/]*/, "");
+ const fromParts = cleanFromPath.split("/");
+ fromParts.pop(); // Remove filename
+
+ const relParts = relativePath.split("/");
+ const resolved = [...fromParts];
+
+ for (const part of relParts) {
+ if (part === "..") {
+ resolved.pop();
+ } else if (part !== "." && part !== "") {
+ resolved.push(part);
+ }
+ }
+
+ let targetPath = resolved.join("/");
+
+ // If we resolved to an empty path, it means we're at the root
+ if (!targetPath) {
+ targetPath = relativePath.replace(/^\.\//, "");
+ }
+
+ // Use _pickActual to handle extension resolution
+ return this._pickActual(targetPath);
+ }
+
+ private _isPositionInRange(position: monaco.Position, range: monaco.Range): boolean {
+ return range.containsPosition(position);
+ }
+
+ private _findExportRangeInTarget(targetModel: monaco.editor.ITextModel, targetPath: string, name: string, wantDefault: boolean): monaco.Range | null {
+ const code = targetModel.getValue();
+
+ if (wantDefault) {
+ // Look for export default
+ const defaultExportRegex = /export\s+default\s+(?:function\s+)?(\w+)/;
+ const match = defaultExportRegex.exec(code);
+ if (match) {
+ const pos = targetModel.getPositionAt(match.index + match[0].indexOf(match[1]));
+ return new monaco.Range(pos.lineNumber, pos.column, pos.lineNumber, pos.column + match[1].length);
+ }
+ } else {
+ // Look for named export
+ const namedExportRegex = new RegExp(`export\\s+(?:function\\s+|class\\s+|const\\s+|let\\s+|var\\s+)?${name}\\b`);
+ const match = namedExportRegex.exec(code);
+ if (match) {
+ const pos = targetModel.getPositionAt(match.index + match[0].indexOf(name));
+ return new monaco.Range(pos.lineNumber, pos.column, pos.lineNumber, pos.column + name.length);
+ }
+ }
+
+ return null;
+ }
+
+ private _pickActual(p: string): string | null {
+ if (this._files.has(p)) {
+ return p;
+ }
+ for (const ext of [".ts", ".tsx", ".js", ".jsx"]) {
+ if (this._files.has(p + ext)) {
+ return p + ext;
+ }
+ }
+ return null;
+ }
+}
diff --git a/packages/tools/playground/src/tools/monaco/navigation/importIndexService.ts b/packages/tools/playground/src/tools/monaco/navigation/importIndexService.ts
new file mode 100644
index 00000000000..819b2af5c1a
--- /dev/null
+++ b/packages/tools/playground/src/tools/monaco/navigation/importIndexService.ts
@@ -0,0 +1,166 @@
+import * as monaco from "monaco-editor/esm/vs/editor/editor.api";
+import * as lexer from "es-module-lexer";
+
+lexer.initSync();
+/**
+ * Service for indexing and analyzing imports in Monaco models using es-module-lexer
+ */
+export class ImportIndexService {
+ private _importIndexCache = new WeakMap();
+
+ indexImports(model: monaco.editor.ITextModel) {
+ const version = model.getVersionId();
+ const cached = this._importIndexCache.get(model);
+ if (cached && cached.version === version) {
+ return cached.items;
+ }
+
+ const { parse } = lexer;
+ const code = model.getValue();
+ const [imports] = parse(code);
+
+ // Normalize to objects with statement + spec + binding ranges
+ const items = imports.map((im: any) => {
+ const spec = im.n; // import specifier
+ const clauseStart = im.s;
+ const clauseEnd = im.e;
+
+ // Parse clause to extract individual bindings
+ const clause = code.slice(clauseStart, clauseEnd);
+ const entries: Array<{
+ imported: string;
+ local: string;
+ isDefault: boolean;
+ range: monaco.Range;
+ }> = [];
+
+ // Get positions for the import statement
+ const ss = model.getPositionAt(im.ss);
+ const se = model.getPositionAt(im.se);
+ const originSelectionRange = new monaco.Range(ss.lineNumber, ss.column, se.lineNumber, se.column);
+
+ // Parse import bindings more accurately
+ this._parseImportBindings(model, clause, clauseStart, entries);
+
+ return {
+ ss,
+ se,
+ s: im.s,
+ e: im.e,
+ spec,
+ clauseStart,
+ clauseEnd,
+ entries,
+ originSelectionRange,
+ };
+ });
+
+ this._importIndexCache.set(model, { version, items });
+ return items;
+ }
+
+ private _parseImportBindings(
+ model: monaco.editor.ITextModel,
+ clause: string,
+ clauseStart: number,
+ entries: Array<{
+ imported: string;
+ local: string;
+ isDefault: boolean;
+ range: monaco.Range;
+ }>
+ ) {
+ // Default import
+ const defaultMatch = /^\s*import\s+([A-Za-z_$][\w$]*)\s*(?:,|from\b|$)/.exec(clause);
+ if (defaultMatch) {
+ const local = defaultMatch[1];
+ const startOffset = clauseStart + defaultMatch.index! + defaultMatch[0].indexOf(local);
+ const endOffset = startOffset + local.length;
+ const startPos = model.getPositionAt(startOffset);
+ const endPos = model.getPositionAt(endOffset);
+
+ entries.push({
+ imported: "default",
+ local,
+ isDefault: true,
+ range: new monaco.Range(startPos.lineNumber, startPos.column, endPos.lineNumber, endPos.column),
+ });
+ }
+
+ // Namespace import
+ const namespaceMatch = /import\s+\*\s+as\s+([A-Za-z_$][\w$]*)/.exec(clause);
+ if (namespaceMatch) {
+ const local = namespaceMatch[1];
+ const startOffset = clauseStart + namespaceMatch.index! + namespaceMatch[0].indexOf(local);
+ const endOffset = startOffset + local.length;
+ const startPos = model.getPositionAt(startOffset);
+ const endPos = model.getPositionAt(endOffset);
+
+ entries.push({
+ imported: "*",
+ local,
+ isDefault: false,
+ range: new monaco.Range(startPos.lineNumber, startPos.column, endPos.lineNumber, endPos.column),
+ });
+ }
+
+ // Named imports
+ const namedMatch = /\{([\s\S]*?)\}/.exec(clause);
+ if (namedMatch) {
+ const inner = namedMatch[1];
+ const innerStart = clauseStart + namedMatch.index! + 1;
+
+ // Split by comma and parse each binding
+ const bindings = inner
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean);
+ let currentOffset = innerStart;
+
+ for (const binding of bindings) {
+ const asMatch = binding.match(/([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)/);
+ if (asMatch) {
+ const imported = asMatch[1];
+ const local = asMatch[2];
+ const bindingOffset = inner.indexOf(binding, currentOffset - innerStart);
+ const importedOffset = innerStart + bindingOffset + binding.indexOf(imported);
+ const importedEnd = importedOffset + imported.length;
+ const startPos = model.getPositionAt(importedOffset);
+ const endPos = model.getPositionAt(importedEnd);
+
+ entries.push({
+ imported,
+ local,
+ isDefault: false,
+ range: new monaco.Range(startPos.lineNumber, startPos.column, endPos.lineNumber, endPos.column),
+ });
+ } else {
+ const name = binding.trim();
+ if (name && /^[A-Za-z_$][\w$]*$/.test(name)) {
+ const bindingOffset = inner.indexOf(binding, currentOffset - innerStart);
+ const nameOffset = innerStart + bindingOffset + binding.indexOf(name);
+ const nameEnd = nameOffset + name.length;
+ const startPos = model.getPositionAt(nameOffset);
+ const endPos = model.getPositionAt(nameEnd);
+
+ entries.push({
+ imported: name,
+ local: name,
+ isDefault: false,
+ range: new monaco.Range(startPos.lineNumber, startPos.column, endPos.lineNumber, endPos.column),
+ });
+ }
+ }
+
+ currentOffset = innerStart + inner.indexOf(binding) + binding.length;
+ }
+ }
+ }
+
+ /**
+ * Clear the import index cache
+ */
+ clearCache() {
+ this._importIndexCache = new WeakMap();
+ }
+}
diff --git a/packages/tools/playground/src/tools/monaco/run/localPackage.ts b/packages/tools/playground/src/tools/monaco/run/localPackage.ts
new file mode 100644
index 00000000000..177595ea05e
--- /dev/null
+++ b/packages/tools/playground/src/tools/monaco/run/localPackage.ts
@@ -0,0 +1,168 @@
+/* eslint-disable no-await-in-loop */
+/* eslint-disable github/no-then */
+/* eslint-disable jsdoc/require-jsdoc */
+
+import * as lexer from "es-module-lexer";
+
+lexer.initSync();
+
+export type DirHandle = FileSystemDirectoryHandle;
+
+async function FileFromHandle(root: DirHandle, path: string): Promise {
+ const parts = path.split("/").filter(Boolean);
+ let cur: any = root;
+ for (let i = 0; i < parts.length - 1; i++) {
+ cur = await cur.getDirectoryHandle(parts[i], { create: false }).catch(() => null);
+ if (!cur) {
+ return null;
+ }
+ }
+ const fh = await cur.getFileHandle(parts[parts.length - 1], { create: false }).catch(() => null);
+ if (!fh) {
+ return null;
+ }
+ return await fh.getFile();
+}
+
+async function ReadTextIfExists(root: DirHandle, path: string) {
+ const f = await FileFromHandle(root, path);
+ return f ? await f.text() : null;
+}
+
+async function WalkLocalJs(root: DirHandle) {
+ const out: Array<{ path: string; code: string; isJson: boolean }> = [];
+ const skipDir = /^(node_modules|\.git|\.hg|\.svn|\.idea|\.vscode)$/i;
+
+ const walkAsync = async (dir: DirHandle, prefix = "") => {
+ // @ts-expect-error dir handle
+ for await (const entry of dir.values()) {
+ if (entry.kind === "directory") {
+ if (skipDir.test(entry.name)) {
+ continue;
+ }
+ await walkAsync(entry as DirHandle, `${prefix}${entry.name}/`);
+ } else {
+ const name = entry.name.toLowerCase();
+ const isJs = /\.(mjs|js|cjs)$/i.test(name);
+ const isJson = /\.json$/i.test(name);
+ if (!isJs && !isJson) {
+ continue;
+ }
+ const file = await (entry as any).getFile();
+ out.push({ path: `${prefix}${entry.name}`, code: await file.text(), isJson });
+ }
+ }
+ };
+ await walkAsync(root, "");
+ return out;
+}
+
+function PickEntryFromPkgJson(pkg: any): string | null {
+ const expDot = pkg?.exports && typeof pkg.exports === "object" && typeof pkg.exports["."] === "string" ? pkg.exports["."] : null;
+ return (
+ expDot ??
+ (typeof pkg?.module === "string" ? pkg.module : null) ??
+ (typeof pkg?.browser === "string" ? pkg.browser : null) ??
+ (typeof pkg?.main === "string" ? pkg.main : null)
+ );
+}
+
+function NormalizeEntryGuess(files: string[]): string | null {
+ const candidates = ["index.mjs", "index.js", "index.cjs", "dist/index.mjs", "dist/index.js", "dist/index.cjs"];
+ for (const c of candidates) {
+ if (files.includes(c)) {
+ return c;
+ }
+ }
+ return null;
+}
+
+function RewriteLocalRelativeImports(pkgName: string, relPath: string, code: string) {
+ const { parse } = lexer;
+ const [imports] = parse(code);
+ if (!imports.length) {
+ return code;
+ }
+
+ // resolve "relPath" parent folder for "./" and "../"
+ const resolveRel = (from: string, spec: string) => {
+ const base = from.split("/");
+ base.pop();
+ const segs = spec.split("/");
+ for (const s of segs) {
+ if (!s || s === ".") {
+ continue;
+ }
+ if (s === "..") {
+ base.pop();
+ } else {
+ base.push(s);
+ }
+ }
+ return base.join("/");
+ };
+
+ let out = "";
+ let last = 0;
+ for (const im of imports) {
+ const spec = im.n as string | undefined;
+ if (!spec) {
+ continue;
+ }
+ const isRel = spec.startsWith("./") || spec.startsWith("../");
+ let replacement = spec;
+ if (isRel) {
+ const target = resolveRel(relPath, spec);
+ replacement = `${pkgName}@local/${target}`;
+ }
+ out += code.slice(last, im.s) + replacement;
+ last = im.e;
+ }
+ out += code.slice(last);
+ return out;
+}
+
+export async function BuildLocalPackageImportMap(
+ pkgSpec: string, // e.g. "shader-object@local" or "@scope/pkg@local"
+ handle: DirHandle
+): Promise> {
+ const pkgName = pkgSpec.replace(/@local$/, "");
+ const pkgJsonText = await ReadTextIfExists(handle, "package.json");
+ const pkgJson = pkgJsonText ? JSON.parse(pkgJsonText) : {};
+ const files = await WalkLocalJs(handle);
+ const allPaths = files.map((f) => f.path);
+
+ let entry = PickEntryFromPkgJson(pkgJson);
+ if (entry && entry.startsWith("./")) {
+ entry = entry.slice(2);
+ }
+ if (!entry) {
+ entry = NormalizeEntryGuess(allPaths) || null;
+ }
+ if (!entry) {
+ entry = allPaths.find((p) => /\.(mjs|js|cjs)$/i.test(p)) || null;
+ }
+ if (!entry) {
+ return {};
+ }
+
+ const urls: Record = {};
+ for (const f of files) {
+ let code = f.code;
+ if (f.isJson) {
+ code = `export default ${code.trim()};`;
+ } else if (/\.cjs$/i.test(f.path)) {
+ code = `const module = { exports: {} }, exports = module.exports;\n${code}\nexport default module.exports;`;
+ }
+ code = RewriteLocalRelativeImports(pkgName, f.path, code);
+
+ const blob = new Blob([code], { type: "text/javascript" });
+ const url = URL.createObjectURL(blob);
+ urls[`${pkgName}@local/${f.path}`] = url;
+ }
+
+ if (entry) {
+ urls[`${pkgName}@local`] = urls[`${pkgName}@local/${entry}`];
+ }
+ return urls;
+}
diff --git a/packages/tools/playground/src/tools/monaco/run/runner.ts b/packages/tools/playground/src/tools/monaco/run/runner.ts
new file mode 100644
index 00000000000..08e673e284e
--- /dev/null
+++ b/packages/tools/playground/src/tools/monaco/run/runner.ts
@@ -0,0 +1,579 @@
+/* eslint-disable no-await-in-loop */
+/* eslint-disable jsdoc/require-jsdoc */
+
+import { Logger } from "@dev/core";
+import type { ThinEngine, Scene } from "@dev/core";
+import type * as monacoNs from "monaco-editor/esm/vs/editor/editor.api";
+import * as lexer from "es-module-lexer";
+import type { TsPipeline } from "../ts/tsPipeline";
+import type { DirHandle } from "./localPackage";
+import { BuildLocalPackageImportMap } from "./localPackage";
+import type { V2Manifest } from "../../snippet";
+
+lexer.initSync();
+
+export type V2PackSnapshot = {
+ manifest: V2Manifest;
+ cdnBase: string;
+ entryPathJs: string;
+ rewritten: Record;
+ importMap: Record;
+ usedBareImports: readonly string[];
+};
+
+export type RuntimeDeps = {
+ autoProbe?: boolean;
+ enable?: Partial>;
+ urls?: Partial<{
+ ammo: string; // e.g. "https://cdn.babylonjs.com/ammo/ammo.js"
+ recast: string; // e.g. "https://cdn.babylonjs.com/recast.js"
+ havok: string; // e.g. "https://cdn.babylonjs.com/havok/HavokPhysics.js"
+ toolkit: string; // default BabylonToolkit URL
+ }>;
+ allowScriptInjection?: boolean;
+};
+
+export type V2RunnerOptions = {
+ monaco: typeof import("monaco-editor/esm/vs/editor/editor.api");
+ createModelsIfMissing?: boolean;
+ onDiagnosticError?: (err: { path: string; message: string; line: number; column: number }) => void;
+ importMapId?: string; // default: "pg-v2-import-map"
+ runtime?: RuntimeDeps;
+ skipDiagnostics?: boolean; // Skip TypeScript diagnostics to avoid timeout issues
+};
+
+export type V2Runner = {
+ run(createEngine: () => Promise, canvas: HTMLCanvasElement): Promise<[Scene, ThinEngine]>;
+ dispose(): void;
+ getPackSnapshot(): V2PackSnapshot;
+};
+
+/**
+ * Sanitize and normalize code for processing
+ * @param code
+ * @returns
+ */
+function SanitizeCode(code: string): string {
+ let result = code.normalize("NFKC");
+
+ const hiddenCharsRegex = /[\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/g;
+ // eslint-disable-next-line no-control-regex
+ const controlCharsRegex = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g;
+
+ // Visualizer markers for hidden characters
+ /* eslint-disable @typescript-eslint/naming-convention */
+ const markers: Record = {
+ "\u200B": "⟦ZWSP⟧",
+ "\u200C": "⟦ZWNJ⟧",
+ "\u200D": "⟦ZWJ⟧",
+ "\u200E": "⟦LRM⟧",
+ "\u200F": "⟦RLM⟧",
+ "\u202A": "⟦LRE⟧",
+ "\u202B": "⟦RLE⟧",
+ "\u202C": "⟦PDF⟧",
+ "\u202D": "⟦LRO⟧",
+ "\u202E": "⟦RLO⟧",
+ "\u2060": "⟦WJ⟧",
+ "\u2066": "⟦LRI⟧",
+ "\u2067": "⟦RLI⟧",
+ "\u2068": "⟦FSI⟧",
+ "\u2069": "⟦PDI⟧",
+ "\uFEFF": "⟦BOM⟧",
+ };
+ /* eslint-enable @typescript-eslint/naming-convention */
+ result = result.replace(/[\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/g, (ch) => markers[ch] || `⟦U+${ch.charCodeAt(0).toString(16).toUpperCase()}⟧`);
+
+ result = result.replace(hiddenCharsRegex, "").replace(controlCharsRegex, "");
+
+ return result;
+}
+
+/**
+ * @param manifest
+ * @param opts
+ * @param pipeline
+ * @returns
+ */
+export async function CreateV2Runner(manifest: V2Manifest, opts: V2RunnerOptions, pipeline: TsPipeline): Promise {
+ const ts = {};
+ const monaco = opts.monaco as typeof monacoNs;
+ const importMapId = opts.importMapId || "pg-v2-import-map";
+ const runNonce = Date.now().toString(36) + Math.random().toString(36).slice(2);
+ const specKey = (p: string) => `__pg__/${p}?v=${runNonce}`;
+
+ // ---------- 0) tiny utils ----------
+ const loadScriptOnce = (() => {
+ const seen = new Set();
+ return async (url: string) =>
+ await new Promise((res, rej) => {
+ if (seen.has(url)) {
+ return res();
+ }
+ const s = document.createElement("script");
+ s.src = url;
+ s.async = true;
+ s.onload = () => {
+ seen.add(url);
+ res();
+ };
+ s.onerror = () => rej(new Error(`Failed to load ${url}`));
+ document.head.appendChild(s);
+ });
+ })();
+
+ async function ensureImportShim() {
+ if ((window as any).importShim) {
+ return;
+ }
+ await loadScriptOnce("https://cdn.jsdelivr.net/npm/es-module-shims@2.6.2/dist/es-module-shims.js");
+ }
+
+ // ---------- 1) DIAGNOSTICS (Monaco TS worker) ----------
+ // Ensure Monaco models exist for every TS/TSX file so diagnostics work.
+ const tsPaths = Object.keys(manifest.files).filter((p) => /[.]tsx?$/i.test(p));
+ if (tsPaths.length && !opts.skipDiagnostics) {
+ const ensureModel = (path: string, code: string) => {
+ const existing = monaco.editor.getModels().find((m) => m.uri.path.endsWith("/" + path));
+ if (existing) {
+ if (existing.getValue() !== code) {
+ existing.setValue(code);
+ }
+ return existing;
+ }
+ if (opts.createModelsIfMissing !== false) {
+ const lang = path.endsWith(".tsx") ? "typescript" : "typescript";
+ const uri = monaco.Uri.parse(`file:///pg/${path.replace(/^\//, "")}`);
+ return monaco.editor.createModel(code, lang, uri);
+ }
+ return null;
+ };
+
+ for (const p of tsPaths) {
+ ensureModel(p, manifest.files[p]);
+ }
+
+ // Use the worker manager for coordinated access
+ const modelsToCheck = monaco.editor.getModels().filter((m) => /[.]tsx?$/.test(m.uri.path));
+
+ // Wait for ATA completion before running diagnostics to avoid race conditions
+ const worker = await monaco.languages.typescript.getTypeScriptWorker();
+
+ // Process models sequentially to avoid worker contention
+ for (let i = 0; i < modelsToCheck.length; i++) {
+ const model = modelsToCheck[i];
+
+ // Check if model still exists and is valid before processing
+ if (model.isDisposed()) {
+ Logger.Warn(`Skipping disposed model: ${model.uri.path}`);
+ continue;
+ }
+ for (const model of monaco.editor.getModels().filter((m) => /[.]tsx?$/.test(m.uri.path))) {
+ const svc = await worker(model.uri);
+ const uriStr = model.uri.toString();
+ const [syn, sem] = await Promise.all([svc.getSyntacticDiagnostics(uriStr), svc.getSemanticDiagnostics(uriStr)]);
+ const first = [...syn, ...sem][0];
+ if (first) {
+ const pos = model.getPositionAt(first.start ?? 0);
+ const errObj = {
+ path: model.uri.path.split("/pg/")[1] || model.uri.path,
+ message: String(first.messageText),
+ line: pos.lineNumber,
+ column: pos.column,
+ };
+ if (opts.onDiagnosticError) {
+ opts.onDiagnosticError(errObj);
+ // throw new Error("Aborted run due to diagnostics.");
+ } else {
+ const e = new Error(`${errObj.path}:${errObj.line}:${errObj.column} ${errObj.message}`);
+ (e as any).__pgDiag = errObj;
+ throw e;
+ }
+ }
+ }
+ }
+ } else if (opts.skipDiagnostics && tsPaths.length) {
+ Logger.Log(`Skipping TypeScript diagnostics for ${tsPaths.length} files (skipDiagnostics=true)`);
+ }
+
+ const cdnBase = String(manifest.cdnBase || "https://esm.sh/");
+ const cdnUrl = (spec: string) => {
+ if (cdnBase.includes("esm.sh")) {
+ return cdnBase.replace(/\/$/, "") + "/" + spec;
+ }
+ if (cdnBase.includes("cdn.jsdelivr.net")) {
+ return cdnBase.replace(/\/$/, "") + "/" + spec + "/+esm";
+ }
+ return cdnBase.replace(/\/$/, "") + "/" + spec;
+ };
+
+ const resolveRelative = (fromPath: string, rel: string) => {
+ const base = fromPath.split("/");
+ base.pop();
+ const segs = rel.split("/");
+ for (const s of segs) {
+ if (!s || s === ".") {
+ continue;
+ }
+ if (s === "..") {
+ base.pop();
+ } else {
+ base.push(s);
+ }
+ }
+ return base.join("/");
+ };
+
+ const known = new Set(Object.keys(manifest.files));
+ const pickActual = (p: string) =>
+ known.has(p) ? p : known.has(p + ".ts") ? p + ".ts" : known.has(p + ".tsx") ? p + ".tsx" : known.has(p + ".js") ? p + ".js" : known.has(p + ".mjs") ? p + ".mjs" : null;
+ const normalizeEntry = (p: string) => {
+ const clean = p.replace(/^\//, "");
+ // pickActual prefers .ts/.tsx/.js/.mjs in that order
+ return pickActual(clean) ?? clean;
+ };
+ const defaultEntry = manifest.language === "TS" ? "index.ts" : "index.js";
+ const entryPath = normalizeEntry(manifest.entry || defaultEntry);
+
+ const compiled: Record = {};
+
+ // Phase 1: per-file transpile (TS->JS) and shader wrapping
+ for (const [path, rawSrc] of Object.entries(manifest.files)) {
+ const src = SanitizeCode(rawSrc);
+ // Shader wrap as raw string
+ if (/[.](wgsl|glsl|fx)$/i.test(path)) {
+ compiled[path] = `export default ${JSON.stringify(src)};`;
+ continue;
+ }
+ let code = src;
+ if (/[.]tsx?$/i.test(path) && ts) {
+ code = (await pipeline.emitOneAsync(path)).js;
+ } else {
+ code += `\n//# sourceURL=file:///pg/${path}`;
+ }
+ compiled[path] = code;
+ }
+
+ // ------ Phase 1.5: map local packages (hot-read from disk) ------
+ const localHandles: Record = (window as any).__PG_LOCAL_PKG_HANDLES__ || {};
+ const localImports: Record = {};
+ for (const fullSpec of Object.keys(localHandles)) {
+ // safety: only accept “…@local”
+ if (!/@local$/.test(fullSpec)) {
+ continue;
+ }
+ try {
+ Object.assign(localImports, await BuildLocalPackageImportMap(fullSpec, localHandles[fullSpec]));
+ } catch {
+ Logger.Warn("Failed to build local package import map for " + fullSpec);
+ }
+ }
+
+ // Phase 2: rewrite imports to local spec keys or CDN
+ const { parse } = lexer;
+ const rewritten: Record = {};
+
+ function normalizeForCdn(spec: string) {
+ if (spec.startsWith("npm:") || spec.startsWith("pkg:")) {
+ return spec.replace(/^npm:|^pkg:/, "");
+ }
+ return spec;
+ }
+
+ function resolveLocal(spec: string): string | null {
+ if (localImports[spec]) {
+ return localImports[spec];
+ }
+ let bestKey: string | null = null;
+ for (const k of Object.keys(localImports)) {
+ if (spec === k) {
+ return localImports[k];
+ }
+ if (spec.startsWith(k) && spec[k.length] === "/") {
+ if (!bestKey || k.length > bestKey.length) {
+ bestKey = k;
+ }
+ }
+ }
+ if (bestKey) {
+ const baseUrl = localImports[bestKey].replace(/\/*$/, "");
+ const rest = spec.slice(bestKey.length);
+ return `${baseUrl}${rest}`;
+ }
+ return null;
+ }
+ for (const [path, code] of Object.entries(compiled)) {
+ const [imports] = parse(code);
+ if (!imports.length) {
+ rewritten[path] = code;
+ continue;
+ }
+
+ let out = "";
+ let last = 0;
+
+ for (const im of imports) {
+ const spec = (im as any).n as string | undefined;
+ if (!spec) {
+ continue;
+ } // skip non-static or unsupported cases
+
+ const isRel = spec.startsWith("./") || spec.startsWith("../") || spec.startsWith("/");
+ let replacement = spec;
+ const normalized = normalizeForCdn(spec);
+
+ const localHit = resolveLocal(spec) ?? resolveLocal(normalized);
+ if (localHit) {
+ replacement = localHit;
+ } else if (/@local(?:\/|$)/.test(spec)) {
+ throw new Error(`Unresolved local import: ${spec}. Link the package and export types/entry in the editor.`);
+ } else if (isRel) {
+ const target = pickActual(resolveRelative(path, spec).replace(/\\/g, "/"));
+ if (target) {
+ replacement = specKey(target);
+ }
+ } else {
+ replacement = manifest.imports?.[spec] ?? manifest.imports?.[normalized] ?? cdnUrl(normalized);
+ }
+
+ out += code.slice(last, (im as any).s) + replacement;
+ last = (im as any).e;
+ }
+
+ out += code.slice(last);
+ rewritten[path] = out;
+ }
+
+ // Phase 3: Build import map and blob URLs
+ const blobUrls: string[] = [];
+ const imports: Record = { ...(manifest.imports || {}) };
+ // seed local blobs first
+ for (const [k, v] of Object.entries(localImports)) {
+ imports[k] = v;
+ }
+ for (const [path, code] of Object.entries(rewritten)) {
+ const spec = specKey(path);
+ const blob = new Blob([code], { type: "text/javascript" });
+ const url = URL.createObjectURL(blob);
+ blobUrls.push(url);
+ imports[spec] = url;
+ }
+ // Phase 4: Install/replace import map; store blobs in data attribute for safe cleanup later
+ const prev = document.getElementById(importMapId) as HTMLScriptElement | null;
+ if (prev?.dataset.urls) {
+ try {
+ for (const u of JSON.parse(prev.dataset.urls)) {
+ URL.revokeObjectURL(u);
+ }
+ } catch {}
+ }
+ prev?.remove();
+
+ const importMapEl = document.createElement("script");
+ importMapEl.type = "importmap-shim";
+ importMapEl.id = importMapId;
+ importMapEl.textContent = JSON.stringify({ imports });
+ importMapEl.dataset.urls = JSON.stringify(blobUrls);
+ document.head.appendChild(importMapEl);
+
+ // ---------- 4) Runtime feature probing + init ----------
+ const rt = opts.runtime || {};
+ const autoProbe = rt.autoProbe !== false;
+ const enable = rt.enable || {};
+ const urls = rt.urls || {};
+ const allowInject = !!rt.allowScriptInjection;
+
+ const allSource = Object.values(manifest.files).join("\n");
+ const want = {
+ ammo: enable.ammo ?? ((autoProbe && /\bAmmoJSPlugin\b/.test(allSource)) || false),
+ recast: enable.recast ?? ((autoProbe && /\bRecastJSPlugin\b/.test(allSource)) || false),
+ havok: enable.havok ?? ((autoProbe && /\bHavokPlugin\b/.test(allSource)) || false),
+ sound: enable.sound ?? ((autoProbe && /\bBABYLON\.Sound\b/.test(allSource)) || false),
+ toolkit:
+ enable.toolkit ??
+ ((autoProbe &&
+ (/\bBABYLON\.Toolkit\.SceneManager\.InitializePlayground\b/.test(allSource) ||
+ /\bSM\.InitializePlayground\b/.test(allSource) ||
+ location.href.includes("BabylonToolkit") ||
+ ((): boolean => {
+ try {
+ return localStorage.getItem("babylon-toolkit") === "true" || localStorage.getItem("babylon-toolkit-used") === "true";
+ } catch {
+ return false;
+ }
+ })())) ||
+ false),
+ };
+
+ const defaults = {
+ ammo: "https://cdn.babylonjs.com/ammo/ammo.js",
+ recast: "https://cdn.babylonjs.com/recast.js",
+ havok: "https://cdn.babylonjs.com/havok/HavokPhysics.js",
+ toolkit: "https://cdn.jsdelivr.net/gh/BabylonJS/BabylonToolkit@master/Runtime/babylon.toolkit.js",
+ };
+
+ async function initRuntime(engine: ThinEngine) {
+ // AMMO
+ if (want.ammo) {
+ const hasFactory = typeof (window as any).Ammo === "function";
+ if (!hasFactory && allowInject) {
+ await loadScriptOnce(urls.ammo || defaults.ammo);
+ }
+ if (typeof (window as any).Ammo === "function") {
+ try {
+ await (window as any).Ammo();
+ } catch {}
+ }
+ }
+
+ // RECAST
+ if (want.recast) {
+ const hasFactory = typeof (window as any).Recast === "function";
+ if (!hasFactory && allowInject) {
+ await loadScriptOnce(urls.recast || defaults.recast);
+ }
+ if (typeof (window as any).Recast === "function") {
+ try {
+ await (window as any).Recast();
+ } catch {}
+ }
+ }
+
+ // HAVOK
+ if (want.havok) {
+ const hasFactory = typeof (window as any).HavokPhysics === "function";
+ if (!hasFactory && allowInject) {
+ await loadScriptOnce(urls.havok || defaults.havok);
+ }
+ if (typeof (window as any).HavokPhysics === "function" && typeof (window as any).HK === "undefined") {
+ try {
+ (window as any).HK = await (window as any).HavokPhysics();
+ } catch {}
+ }
+ }
+
+ // SOUND
+ if (want.sound) {
+ try {
+ const anyB = (window as any).BABYLON as any;
+ const opts = (engine as any).getCreationOptions?.();
+ if (!opts || opts.audioEngine !== false) {
+ anyB.AbstractEngine.audioEngine = anyB.AbstractEngine.AudioEngineFactory(
+ engine.getRenderingCanvas(),
+ engine.getAudioContext?.(),
+ engine.getAudioDestination?.()
+ );
+ }
+ } catch {
+ /* ignore */
+ }
+ }
+
+ // TOOLKIT
+ if (want.toolkit) {
+ await loadScriptOnce(urls.toolkit || defaults.toolkit);
+ try {
+ localStorage.setItem("babylon-toolkit-used", "true");
+ } catch {}
+ }
+ }
+
+ // ---------- 5) Runner ----------
+ async function run(createEngine: () => Promise, canvas: HTMLCanvasElement): Promise<[Scene, ThinEngine]> {
+ await ensureImportShim();
+ const importFn: (s: string) => Promise =
+ (window as any).importShim ??
+ (async (s: string) => {
+ return await import(/* webpackIgnore: true */ s);
+ });
+ const entrySpec = specKey(entryPath);
+ const mod = await importFn(entrySpec);
+ let engine: ThinEngine | null = null;
+ if (typeof mod.createEngine === "function") {
+ try {
+ engine = await mod.createEngine();
+ if (!engine) {
+ throw new Error("createEngine() returned null.");
+ }
+ } catch (e) {
+ Logger.Warn("Failed to call user createEngine(): " + (e as Error).message);
+ Logger.Warn("Falling back to default engine creation.");
+ }
+ }
+ if (!engine) {
+ engine = await createEngine();
+ }
+ if (!engine) {
+ throw new Error("Failed to create engine.");
+ }
+ (window as any).engine = engine;
+ await initRuntime(engine);
+
+ let createScene: any = null;
+ if (mod.default?.CreateScene) {
+ createScene = mod.default.CreateScene;
+ } else if (mod.Playground?.CreateScene) {
+ createScene = mod.Playground.CreateScene;
+ } else if (typeof mod.createScene === "function") {
+ createScene = mod.createScene;
+ } else if (typeof mod.default === "function") {
+ createScene = mod.default;
+ }
+ if (!createScene) {
+ throw new Error("No createScene export (createScene / default / Playground.CreateScene) found in entry module.");
+ }
+ return [await (createScene(engine, canvas) ?? createScene()), engine];
+ }
+
+ function dispose() {
+ const el = document.getElementById(importMapId) as HTMLScriptElement | null;
+ if (el?.dataset.urls) {
+ try {
+ for (const u of JSON.parse(el.dataset.urls)) {
+ URL.revokeObjectURL(u);
+ }
+ } catch {}
+ }
+ el?.remove();
+ }
+
+ // -------- 6) Pack snapshot for download --------
+ const bareImportsUsed = new Set();
+ for (const code of Object.values(rewritten)) {
+ const [imps] = lexer.parse(code);
+ for (const im of imps) {
+ const spec = (im as any).n as string | undefined;
+ if (!spec) {
+ continue;
+ }
+ if (!(spec.startsWith("./") || spec.startsWith("../") || spec.startsWith("/"))) {
+ bareImportsUsed.add(spec.replace(/^npm:|^pkg:/, ""));
+ }
+ }
+ }
+ const bareImportMap: Record = {};
+ for (const spec of bareImportsUsed) {
+ const pinned = manifest.imports?.[spec];
+ bareImportMap[spec] = pinned || cdnUrl(spec);
+ }
+
+ // normalize entry to .js (disk-friendly)
+ const normalizedEntryJs = (entryPath || "").replace(/[.]tsx?$/i, ".js") || (manifest.language === "TS" ? "index.js" : "index.js");
+
+ // keep a frozen copy for download later
+ const packSnapshot: V2PackSnapshot = {
+ manifest: JSON.parse(JSON.stringify(manifest)),
+ cdnBase,
+ entryPathJs: normalizedEntryJs,
+ rewritten: Object.freeze({ ...rewritten }),
+ importMap: Object.freeze({ ...bareImportMap }),
+ usedBareImports: Object.freeze(Array.from(bareImportsUsed)),
+ };
+
+ return {
+ run,
+ dispose,
+ getPackSnapshot() {
+ return packSnapshot;
+ },
+ };
+}
diff --git a/packages/tools/playground/src/tools/monaco/ts/tsPipeline.ts b/packages/tools/playground/src/tools/monaco/ts/tsPipeline.ts
new file mode 100644
index 00000000000..47b9fae5f81
--- /dev/null
+++ b/packages/tools/playground/src/tools/monaco/ts/tsPipeline.ts
@@ -0,0 +1,230 @@
+// ts/tsPipeline.ts
+import * as monaco from "monaco-editor/esm/vs/editor/editor.api";
+
+/**
+ *
+ */
+
+const TsOptions: monaco.languages.typescript.CompilerOptions = {
+ allowJs: true,
+ allowSyntheticDefaultImports: true,
+ esModuleInterop: true,
+ module: monaco.languages.typescript.ModuleKind.ESNext,
+ moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs,
+ resolvePackageJsonExports: true,
+ resolvePackageJsonImports: true,
+ target: monaco.languages.typescript.ScriptTarget.ESNext,
+ noEmit: false,
+ allowNonTsExtensions: true,
+ skipLibCheck: true,
+ strict: false,
+ baseUrl: "file:///pg/",
+ typeRoots: [],
+ isolatedModules: true,
+ experimentalDecorators: true,
+ emitDecoratorMetadata: false,
+ allowUmdGlobalAccess: true,
+ inlineSourceMap: true,
+ inlineSources: true,
+ sourceRoot: "file:///pg/",
+ jsx: monaco.languages.typescript.JsxEmit.ReactJSX,
+ jsxFactory: "JSXAlone.createElement",
+ lib: ["es2020", "dom", "dom.iterable"],
+};
+
+const JsOptions: monaco.languages.typescript.CompilerOptions = {
+ ...TsOptions,
+ checkJs: false,
+ noImplicitAny: false,
+ allowJs: true,
+ jsxFactory: "JSXAlone.createElement",
+ jsx: monaco.languages.typescript.JsxEmit.ReactJSX,
+};
+/**
+ *
+ */
+export class TsPipeline {
+ private _paths: Record = {};
+ private _extraLibUris = new Set();
+ private _extraLibDisposables: monaco.IDisposable[] = [];
+ private _setupDone = false;
+
+ setup(libContent: string) {
+ if (!this._setupDone) {
+ const options = { ...TsOptions, paths: this._paths };
+ const jsOptions = { ...JsOptions, paths: this._paths };
+
+ monaco.languages.typescript.typescriptDefaults.setCompilerOptions(options);
+ monaco.languages.typescript.javascriptDefaults.setCompilerOptions(jsOptions);
+
+ monaco.languages.typescript.typescriptDefaults.setEagerModelSync(true);
+ monaco.languages.typescript.javascriptDefaults.setEagerModelSync(true);
+
+ this._setupDone = true;
+ }
+
+ if (libContent) {
+ const tsDisposable = monaco.languages.typescript.typescriptDefaults.addExtraLib(libContent, "file:///external/babylon.globals.d.ts");
+ const jsDisposable = monaco.languages.typescript.javascriptDefaults.addExtraLib(libContent, "file:///external/babylon.globals.d.ts");
+ this._extraLibDisposables.push(tsDisposable, jsDisposable);
+ }
+
+ const shaderDts = `
+declare module "*.wgsl" { const content: string; export default content; }
+declare module "*.glsl" { const content: string; export default content; }
+declare module "*.fx" { const content: string; export default content; }`;
+ const shaderTsDisposable = monaco.languages.typescript.typescriptDefaults.addExtraLib(shaderDts, "file:///external/shaders.d.ts");
+ const shaderJsDisposable = monaco.languages.typescript.javascriptDefaults.addExtraLib(shaderDts, "file:///external/shaders.d.ts");
+ this._extraLibDisposables.push(shaderTsDisposable, shaderJsDisposable);
+ }
+ addPathsFor(raw: string, canonical: string) {
+ if (!raw || raw === canonical) {
+ return;
+ }
+ this._paths[raw] = [canonical];
+
+ monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
+ ...TsOptions,
+ paths: { ...this._paths },
+ });
+ monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
+ ...JsOptions,
+ paths: { ...this._paths },
+ });
+ monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
+ noSemanticValidation: false,
+ noSyntaxValidation: false,
+ noSuggestionDiagnostics: false,
+ });
+ }
+
+ addForwarder(raw: string, canonical: string) {
+ if (!raw || raw === canonical) {
+ return;
+ }
+ const uri = `file:///__pg__/forwarders/${encodeURIComponent(raw)}.d.ts`;
+ if (this._extraLibUris.has(uri)) {
+ return;
+ }
+
+ const dts = `declare module "${raw}" {` + ` export * from "${canonical}";` + ` export { default } from "${canonical}";` + `}\n`;
+
+ monaco.languages.typescript.typescriptDefaults.addExtraLib(dts, uri);
+ this._extraLibUris.add(uri);
+ }
+
+ ensureTsModel(path: string, code: string) {
+ const clean = path.replace(/^\//, "");
+ const uri = monaco.Uri.parse(`file:///pg/${clean}`);
+ const existing = monaco.editor.getModel(uri);
+ if (existing) {
+ if (existing.getValue() !== code) {
+ existing.setValue(code);
+ }
+ return existing;
+ }
+ return monaco.editor.createModel(code, "typescript", uri);
+ }
+
+ async emitOneAsync(path: string): Promise<{
+ js: string;
+ map?: string;
+ }> {
+ const clean = path.replace(/^\//, "");
+ const uri = monaco.Uri.parse(`file:///pg/${clean}`);
+ const wf = await monaco.languages.typescript.getTypeScriptWorker();
+ const svc = await wf(uri);
+ const out = await svc.getEmitOutput(uri.toString());
+ if (out.emitSkipped) {
+ throw new Error(`Emit skipped for ${clean}`);
+ }
+
+ const jsFile = out.outputFiles.find((f) => f.name.endsWith(".js"));
+ if (!jsFile) {
+ throw new Error(`No JS output for ${clean}`);
+ }
+
+ const mapFile = out.outputFiles.find((f) => f.name.endsWith(".js.map"));
+ return { js: jsFile.text, map: mapFile?.text };
+ }
+
+ /**
+ * Force sync models with TypeScript service for better import recognition
+ */
+ forceSyncModels() {
+ // Ensure TypeScript service is aware of all models
+ const ts = monaco.languages.typescript;
+
+ // Force worker restart to pick up all models
+ ts.typescriptDefaults.setDiagnosticsOptions({
+ noSemanticValidation: false,
+ noSyntaxValidation: false,
+ noSuggestionDiagnostics: false,
+ });
+
+ ts.javascriptDefaults.setDiagnosticsOptions({
+ noSemanticValidation: false,
+ noSyntaxValidation: false,
+ noSuggestionDiagnostics: false,
+ });
+ }
+ private _workspaceDecls?: { ts: monaco.IDisposable; js: monaco.IDisposable };
+
+ addWorkspaceFileDeclarations(files: Record) {
+ if (this._workspaceDecls) {
+ try {
+ this._workspaceDecls.ts.dispose();
+ } catch {}
+ try {
+ this._workspaceDecls.js.dispose();
+ } catch {}
+ this._workspaceDecls = undefined;
+ }
+
+ let declarations = "";
+
+ for (const [path, content] of Object.entries(files)) {
+ const isJS = /\.jsx?$/.test(path);
+ if (!isJS) {
+ continue;
+ }
+
+ const moduleName = path.replace(/\.(ts|tsx|js|jsx)$/, "");
+
+ declarations += `declare module "./${moduleName}" {\n`;
+ const exportMatches = content.match(/export\s+(?:default\s+)?(?:class|function|const|let|var)\s+(\w+)/g) || [];
+ for (const m of exportMatches) {
+ const name = m.match(/(\w+)$/)?.[1];
+ if (name) {
+ declarations += ` export const ${name}: any;\n`;
+ }
+ }
+ declarations += ` const _default: any;\n export default _default;\n`;
+ declarations += `}\n`;
+ }
+
+ if (declarations) {
+ const uri = "file:///external/workspace-declarations.d.ts";
+ const tsDisp = monaco.languages.typescript.typescriptDefaults.addExtraLib(declarations, uri);
+ const jsDisp = monaco.languages.typescript.javascriptDefaults.addExtraLib(declarations, uri);
+ this._workspaceDecls = { ts: tsDisp, js: jsDisp };
+ }
+
+ this.forceSyncModels();
+ }
+
+ dispose() {
+ // Dispose all extra lib disposables
+ for (const disposable of this._extraLibDisposables) {
+ try {
+ disposable.dispose();
+ } catch {
+ // Ignore errors during cleanup
+ }
+ }
+ this._extraLibDisposables = [];
+ this._extraLibUris.clear();
+ this._paths = {};
+ this._setupDone = false;
+ }
+}
diff --git a/packages/tools/playground/src/tools/monaco/typings/constants.ts b/packages/tools/playground/src/tools/monaco/typings/constants.ts
new file mode 100644
index 00000000000..dc5db8b5e9d
--- /dev/null
+++ b/packages/tools/playground/src/tools/monaco/typings/constants.ts
@@ -0,0 +1,79 @@
+export const NodeBuiltins = new Set([
+ "assert",
+ "buffer",
+ "console",
+ "constants",
+ "crypto",
+ "dns",
+ "domain",
+ "events",
+ "fs",
+ "http",
+ "http2",
+ "https",
+ "inspector",
+ "module",
+ "net",
+ "os",
+ "path",
+ "perf_hooks",
+ "process",
+ "punycode",
+ "querystring",
+ "readline",
+ "repl",
+ "stream",
+ "string_decoder",
+ "timers",
+ "tls",
+ "tty",
+ "url",
+ "util",
+ "v8",
+ "vm",
+ "worker_threads",
+ "zlib",
+]);
+
+// Packages that drag a Node-only tree or are pointless to ATA in-browser
+// Or pollute the namespace with non-modules
+export const BlocklistBase = new Set([
+ // We pull these in already to the global namespace for typing
+ "@babylonjs/core",
+ "babylonjs",
+
+ "node",
+ "nodetypes",
+ "npm",
+ "npx",
+ "ts-node",
+ "typescript",
+ "ws",
+ "yargs",
+ "yargs-parser",
+ "@types/node",
+ "@types/node-globals",
+ "@types/node-fetch",
+ "@types/webpack-env",
+ "envify",
+ "source-map-support",
+ "source-map",
+ "bufferutil",
+ "utf-8-validate",
+ "fsevents",
+ "original-fs",
+ "graceful-fs",
+ "mock-fs",
+ "enhanced-resolve",
+ "memory-fs",
+ "pnpapi",
+ "jest-haste-map",
+ "metro-resolver",
+ "metro-source-map",
+ "react-native/Libraries/Core/InitializeCore",
+ "aws-sdk",
+ "firebase-admin",
+ "firebase-functions",
+ "assemblyscript",
+ "assemblyscript/asc",
+]);
diff --git a/packages/tools/playground/src/tools/monaco/typings/tsService.ts b/packages/tools/playground/src/tools/monaco/typings/tsService.ts
new file mode 100644
index 00000000000..68e1757aba6
--- /dev/null
+++ b/packages/tools/playground/src/tools/monaco/typings/tsService.ts
@@ -0,0 +1,100 @@
+// super-minimal TS shim for @typescript/ata, with lib refs + positions
+import { initSync, parse } from "es-module-lexer";
+initSync();
+
+// ///
+const TypesRefRe = /\/\/\/\s* /g;
+// ///
+const LibRefRe = /\/\/\/\s* /g;
+// ///
+const PathRefRe = /\/\/\/\s* /g;
+
+function CapPos(text: string, m: RegExpMatchArray, group = 1) {
+ const start = (m.index ?? 0) + m[0].indexOf(m[group]);
+ return { pos: start, end: start + m[group].length };
+}
+
+function CollectRefs(text: string, re: RegExp) {
+ const out: { fileName: string; pos: number; end: number }[] = [];
+ for (const m of text.matchAll(re)) {
+ const { pos, end } = CapPos(text, m, 1);
+ out.push({ fileName: m[1], pos, end });
+ }
+ return out;
+}
+const DefaultLibNames = [
+ "dom",
+ "dom.iterable",
+ "webworker",
+ "scripthost",
+ "es5",
+ "es2015",
+ "es2015.promise",
+ "es2016",
+ "es2017",
+ "es2018",
+ "es2019",
+ "es2020",
+ "es2021",
+ "es2022",
+ "es2023",
+ "es2024",
+ "esnext",
+];
+
+/**
+ * Create a TypeScript shim for the specified options.
+ * @param opts Options for the TypeScript shim.
+ * @returns The created TypeScript shim.
+ */
+export function CreateTsShim(opts?: { libNames?: string[] }) {
+ const libNames = opts?.libNames ?? DefaultLibNames;
+ const libMap = new Map(libNames.map((n) => [n, true] as const));
+
+ const tsShim = {
+ version: "5.x-shim",
+ libMap, // used by ATA to ignore lib refs
+ preProcessFile(text: string) {
+ let imports: { s: number; e: number }[] = [];
+ try {
+ imports = parse(text)[0] as any as { s: number; e: number }[];
+ } catch {
+ return {
+ referencedFiles: [],
+ importedFiles: [],
+ libraryReferencedFiles: [],
+ typeReferenceDirectives: [],
+ libReferenceDirectives: [],
+ amdDependencies: [],
+ hasNoDefaultLib: false,
+ isLibFile: false,
+ };
+ }
+
+ // import specifiers (static + dynamic) with real positions
+ const importedFiles = imports.map((i) => ({
+ fileName: text.slice(i.s, i.e),
+ pos: i.s,
+ end: i.e,
+ }));
+
+ // triple-slash refs
+ const typeReferenceDirectives = CollectRefs(text, TypesRefRe);
+ const libReferenceDirectives = CollectRefs(text, LibRefRe);
+ const referencedFiles = CollectRefs(text, PathRefRe);
+
+ return {
+ referencedFiles,
+ importedFiles,
+ libraryReferencedFiles: [],
+ typeReferenceDirectives,
+ libReferenceDirectives,
+ amdDependencies: [],
+ hasNoDefaultLib: false,
+ isLibFile: false,
+ };
+ },
+ } as const;
+
+ return tsShim;
+}
diff --git a/packages/tools/playground/src/tools/monaco/typings/types.ts b/packages/tools/playground/src/tools/monaco/typings/types.ts
new file mode 100644
index 00000000000..e3e2a8c51a1
--- /dev/null
+++ b/packages/tools/playground/src/tools/monaco/typings/types.ts
@@ -0,0 +1,16 @@
+/* eslint-disable jsdoc/require-jsdoc */
+
+export type AddPathsFn = (spec: string, target: string) => void;
+
+export type ParsedSpec = {
+ raw: string; // as written by the user
+ name: string; // @scope/pkg or pkg (no version, no subpath)
+ version?: string; // "4.17.21" | "local" | "latest"
+ subpath?: string; // "get" in "lodash@4.17.21/get"
+ scoped: boolean;
+};
+
+export type RequestLocalResolve = {
+ base: string; // e.g. "shader-object"
+ fullSpec: string; // e.g. "shader-object@local"
+};
diff --git a/packages/tools/playground/src/tools/monaco/typings/typingsService.ts b/packages/tools/playground/src/tools/monaco/typings/typingsService.ts
new file mode 100644
index 00000000000..40e1197a9d7
--- /dev/null
+++ b/packages/tools/playground/src/tools/monaco/typings/typingsService.ts
@@ -0,0 +1,614 @@
+/* eslint-disable no-await-in-loop */
+/* eslint-disable jsdoc/require-jsdoc */
+import * as monaco from "monaco-editor/esm/vs/editor/editor.api";
+
+import { setupTypeAcquisition } from "@typescript/ata";
+import { CreateTsShim } from "./tsService";
+import { BlocklistBase } from "./constants";
+import type { AddPathsFn, RequestLocalResolve } from "./types";
+import { BasePackage, BuildSyntheticAtaEntry, CanonicalSpec, IsBare, IsNodeish, NormalizeVirtualPath, ParseSpec, SanitizeSpecifier } from "./utils";
+
+export class TypingsService {
+ private _acquired = new Set();
+ private _failed = new Set();
+
+ private _typeLibDisposables: monaco.IDisposable[] = [];
+ private _bareStubBySpec = new Map();
+ private _currentBare = new Set();
+ private _entryMapped = new Set();
+ private _versionsByBase = new Map>();
+ private _pinnedByBase = new Map(); // base -> version
+ private _requestedNamesBySpec = new Map>();
+
+ // Local resolution support
+ private _pendingLocal = new Set(); // fullSpec waiting to be mapped
+ private _localLibsBySpec = new Map();
+ private _localDirBySpec = new Map();
+
+ // ATA runner
+ private _ata: ReturnType;
+ private _ataInFlight = false;
+ private _ataSafetyTimer: number | undefined;
+ private _ata404s = 0;
+
+ constructor(
+ private _addPaths: AddPathsFn,
+ private _onRequestLocalResolve?: (req: RequestLocalResolve) => void
+ ) {
+ function toVfsUriFromAtaPath(path: string) {
+ // put everything under file:///...
+ const clean = NormalizeVirtualPath(path);
+ return `file:///${clean}`;
+ }
+ function isEntryDts(path: string) {
+ const p = NormalizeVirtualPath(path);
+ if (/\/@types\/node\//i.test(p)) {
+ return false;
+ }
+ return /\/index\.d\.ts$/i.test(p);
+ }
+
+ const baseFromUrl = (u: string): string | null => {
+ try {
+ const m = u.match(/\/npm\/((?:@[^/]+\/)?[^@/]+)(?:@[^/]+)?\//i);
+ return m ? decodeURIComponent(m[1]) : null;
+ } catch {
+ return null;
+ }
+ };
+
+ // helper: true if this URL targets a blocked package
+ const isBlockedPkgUrl = (u: string): boolean => {
+ const base = baseFromUrl(u);
+ return !!(base && BlocklistBase.has(base));
+ };
+
+ const ts = CreateTsShim({
+ libNames: monaco.languages.typescript.typescriptDefaults.getCompilerOptions().lib ?? undefined,
+ });
+
+ const fetcherAsync: (input: RequestInfo, init?: RequestInit) => Promise = async (input, init) => {
+ let url = typeof input === "string" ? input : input.url;
+
+ if (url.includes("/npm/")) {
+ // scoped or unscoped, any version token
+ url = url.replace(/\/npm\/((?:@[^/]+\/)?[^@/]+)@([^/]+)@latest(?=\/|$)/, "/npm/$1@$2");
+ }
+
+ if (isBlockedPkgUrl(url)) {
+ this._ata404s++;
+ return new Response("blocked-by-policy", { status: 404 });
+ }
+
+ const pinVersionInUrl = (u: string) => {
+ // jsDelivr metadata endpoint
+ u = u.replace(/(\/v1\/package\/npm\/)((?:@[^/]+\/)?[^@/]+)@latest\b/gi, (m, pre, base) => {
+ if (base.startsWith("@types/")) {
+ return m;
+ }
+ const v = this._pinnedByBase.get(base);
+ return v ? `${pre}${base}@${v}` : m;
+ });
+ // generic /npm/[@ver]/...
+ u = u.replace(/(\/npm\/)((?:@[^/]+\/)?[^@/]+)(?:@([^/]+))?(?=\/|$)/gi, (m, pre, base, ver) => {
+ if (base.startsWith("@types/")) {
+ return m;
+ }
+ const v = this._pinnedByBase.get(base);
+ if (!v) {
+ return m;
+ }
+ // If no version or "latest", insert the pinned version
+ if (!ver || ver.toLowerCase() === "latest") {
+ return `${pre}${base}@${v}`;
+ }
+ return m;
+ });
+ return u;
+ };
+
+ url = pinVersionInUrl(url);
+
+ // jsDelivr resolve endpoint often sees quoted specs from upstream — strip them:
+ url = url.replace(/%22/g, ""); // remove encoded quotes
+ url = url.replace(/"([^"]+)"/g, "$1"); // belt-and-suspenders for unencoded quotes
+
+ // quick denylist: if it’s trying to resolve a node builtin, short-circuit
+ const m = url.match(/\/npm\/([^@/]+)@/);
+ if (m && IsNodeish(decodeURIComponent(m[1]))) {
+ this._ata404s++;
+ return new Response("builtin-ignored", { status: 404 });
+ }
+
+ // stop-the-bleed if we’ve seen too many 404s this run
+ if (this._ata404s >= 20) {
+ return new Response("too-many-404s", { status: 429 });
+ }
+
+ // timeout wrapper
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), 8000);
+ try {
+ if (/\/npm\/.+\/package\.json(?:$|\?)/i.test(url)) {
+ const res2 = await fetch(url, { ...(init ?? {}), signal: controller.signal });
+ if (!res2.ok) {
+ return res2;
+ }
+ // eslint-disable-next-line
+ const pkg = await res2.json().catch(() => null);
+ if (pkg && typeof pkg === "object") {
+ const stripped = {
+ ...pkg,
+ dependencies: {},
+ devDependencies: {},
+ optionalDependencies: {},
+ peerDependencies: {},
+ // Also neuter "exports" that sometimes point to deep trees, but keep "types".
+ // eslint-disable-next-line
+ exports: pkg.types ? { ".": { types: pkg.types } } : undefined,
+ };
+ return new Response(JSON.stringify(stripped), {
+ status: 200,
+ // eslint-disable-next-line
+ headers: { "content-type": "application/json" },
+ });
+ }
+ return res2;
+ } else {
+ const res = await fetch(url, { ...(init ?? {}), signal: controller.signal });
+ if (res.status === 404) {
+ this._ata404s++;
+ }
+ return res;
+ }
+ } finally {
+ clearTimeout(timer);
+ }
+ };
+ this._ata = setupTypeAcquisition({
+ projectName: "pg",
+ typescript: ts as any,
+ logger: console,
+ fetcher: fetcherAsync as any,
+ delegate: {
+ started: () => {
+ this._ataInFlight = true;
+ this._ata404s = 0;
+ clearTimeout(this._ataSafetyTimer);
+ this._ataSafetyTimer = window.setTimeout(() => {
+ this._ataInFlight = false;
+ }, 10_000);
+ },
+ finished: () => {
+ this._ataInFlight = false;
+ clearTimeout(this._ataSafetyTimer);
+ },
+
+ receivedFile: (code: string, path: string) => {
+ // Ignore JSON and Node types
+ if (!path || /\.json$/i.test(path) || path.includes("@types/node/")) {
+ return;
+ }
+
+ const vuri = toVfsUriFromAtaPath(path);
+ const d1 = monaco.languages.typescript.typescriptDefaults.addExtraLib(code, vuri);
+ const d2 = monaco.languages.typescript.javascriptDefaults.addExtraLib(code, vuri);
+ this._typeLibDisposables.push(d1, d2);
+
+ if (!isEntryDts(path)) {
+ return;
+ }
+
+ // Figure out which base package this file corresponds to:
+ // - If it's an @types package, map '@types/' -> '' base.
+ // - Else if path looks like '@/index.d.ts', use that base.
+ const clean = NormalizeVirtualPath(path);
+ let base: string | null = null;
+
+ // @types/foo[/...]/index.d.ts
+ const typesMatch = clean.match(/(?:^|\/)@types\/([^/]+)\/index\.d\.ts$/i);
+ if (typesMatch) {
+ const name = typesMatch[1];
+ // scoped dts publish as @types/__
+ if (name.includes("__")) {
+ const [scope, pkg] = name.split("__");
+ base = `@${scope}/${pkg}`;
+ } else {
+ base = name;
+ }
+ } else {
+ // foo@1.2.3/index.d.ts or @scope/foo@1.2.3/index.d.ts or foo/index.d.ts
+ const m = clean.match(/(?:^|\/)((?:@[^/]+\/)?[^/@]+)(?:@[^/]+)?\/index\.d\.ts$/i);
+ if (m) {
+ base = m[1];
+ }
+ }
+ if (!base) {
+ return;
+ }
+
+ const vers = this._versionsByBase.get(base);
+ const vdir = vuri.replace(/\/index\.d\.ts$/i, "");
+
+ if (vers && vers.size) {
+ for (const fullSpec of vers) {
+ this._removeBareStub(fullSpec);
+ this._removeBareStub(fullSpec + "/*");
+
+ this._addPaths(fullSpec, `${vdir}/index.d.ts`);
+ this._addPaths(fullSpec + "/*", `${vdir}/*`);
+
+ this._entryMapped.add(fullSpec);
+ this._acquired.add(fullSpec);
+ }
+ } else {
+ if (!this._entryMapped.has(base)) {
+ this._addPaths(base, `${vdir}/index.d.ts`);
+ this._addPaths(base + "/*", `${vdir}/*`);
+ this._entryMapped.add(base);
+ this._acquired.add(base);
+ }
+ }
+ },
+ },
+ });
+ }
+
+ public get bareImports(): Set {
+ return this._currentBare;
+ }
+
+ dispose() {
+ this._typeLibDisposables.forEach((d) => d.dispose());
+ this._typeLibDisposables = [];
+ for (const [, d] of this._bareStubBySpec) {
+ d.ts.dispose();
+ d.js.dispose();
+ }
+ this._bareStubBySpec.clear();
+ this._acquired.clear();
+ this._failed.clear();
+ this._currentBare.clear();
+ }
+
+ private _rememberVersionedSpec(raw: string) {
+ const p = ParseSpec(raw);
+ const base = BasePackage(p);
+ if (!this._versionsByBase.has(base)) {
+ this._versionsByBase.set(base, new Set());
+ }
+ this._versionsByBase.get(base)!.add(CanonicalSpec(p)); // store full (maybe versioned) spec
+ }
+
+ discoverBareImports(sourceTexts: string[]): Set {
+ this._requestedNamesBySpec.clear();
+
+ const specs = new Set();
+
+ // import { A, B as C } from 'x'
+ const namedRe = /import\s*\{\s*([^}]+)\s*\}\s*from\s*['"]([^'"]+)['"]/g;
+ // import Default, { A } from 'x'
+ const mixedRe = /import\s+([A-Za-z_$][\w$]*)\s*,\s*\{\s*([^}]+)\s*\}\s*from\s*['"]([^'"]+)['"]/g;
+ // import Default from 'x'
+ const defRe = /import\s+([A-Za-z_$][\w$]*)\s*from\s*['"]([^'"]+)['"]/g;
+
+ // import ... from 'x' | export ... from 'x'
+ const fromRe = /\bfrom\s+['"]([^'"]+)['"]/g;
+ // import('x')
+ const dynRe = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
+ // import 'x'
+ const bareRe = /^\s*import\s+['"]([^'"]+)['"]/gm;
+
+ const remember = (specRaw: string, names: string[] = []) => {
+ const spec = specRaw.split(/[?#]/)[0].trim();
+ if (!IsBare(spec)) {
+ return;
+ }
+ specs.add(spec);
+
+ const key = CanonicalSpec(ParseSpec(spec)); // keep version if present
+ if (!names.length) {
+ return;
+ }
+ const set = this._requestedNamesBySpec.get(key) ?? new Set();
+ for (const n of names) {
+ set.add(n);
+ }
+ this._requestedNamesBySpec.set(key, set);
+ };
+
+ const parseNamedList = (s: string) =>
+ s
+ .split(",")
+ .map((x) => x.trim())
+ .flatMap((x) => {
+ // handle "X as Y" — add both X and Y (safe)
+ const m = x.match(/^([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/i);
+ return m ? (m[2] ? [m[1], m[2]] : [m[1]]) : [];
+ });
+
+ for (const code of sourceTexts) {
+ if (!code) {
+ continue;
+ }
+
+ // collect specifiers first (for names)
+ for (const m of code.matchAll(mixedRe)) {
+ const def = m[1],
+ list = m[2],
+ spec = m[3];
+ remember.call(this, spec, [def, ...parseNamedList(list)]);
+ }
+ for (const m of code.matchAll(namedRe)) {
+ remember.call(this, m[2], parseNamedList(m[1]));
+ }
+ for (const m of code.matchAll(defRe)) {
+ remember.call(this, m[2], [m[1]]);
+ }
+
+ // also keep plain/bare specs
+ for (const re of [fromRe, dynRe, bareRe]) {
+ re.lastIndex = 0;
+ for (const m of code.matchAll(re)) {
+ remember.call(this, m[1]);
+ }
+ }
+ }
+
+ return specs;
+ }
+
+ installBareImportStubs(bareSpecs: Set) {
+ this._currentBare.clear();
+
+ // Rebuild version map for the current source set
+ this._versionsByBase.clear();
+
+ // Clear existing stubs first
+ for (const [, d] of this._bareStubBySpec) {
+ d.ts.dispose();
+ d.js.dispose();
+ }
+ this._bareStubBySpec.clear();
+
+ for (const raw of bareSpecs) {
+ if (!IsBare(raw)) {
+ continue;
+ }
+
+ const p = ParseSpec(raw);
+ const base = BasePackage(p);
+ const full = CanonicalSpec(p); // keeps version
+
+ if (p.version === "local") {
+ // Emit stubs so code keeps compiling while we wait.
+ if (!this._acquired.has(full)) {
+ this._addBareStub(full);
+ }
+ // mark as pending + notify UI once
+ if (!this._entryMapped.has(full) && !this._pendingLocal.has(full)) {
+ this._pendingLocal.add(full);
+ this._onRequestLocalResolve?.({ base, fullSpec: full });
+ }
+ // Don’t add a fake path here; we’ll map after user picks a folder.
+ continue;
+ }
+
+ this._currentBare.add(base);
+ if (p.version) {
+ this._pinnedByBase.set(base, p.version);
+ }
+
+ this._rememberVersionedSpec(raw);
+
+ // If real types already arrived for this *exact* spec, skip stub
+ if (this._acquired.has(full)) {
+ continue;
+ }
+
+ // Add stub for the exact spec + wildcard
+ this._addBareStub(full);
+
+ // Map the exact spec to a versioned "virtual" entry (so TS can resolve imports immediately)
+ const vdir = `file:///${p.name}${p.version ? `@${p.version}` : ""}`;
+ this._addPaths(full, `${vdir}/index.d.ts`);
+ this._addPaths(full + "/*", `${vdir}/*`);
+ }
+ }
+ private _firstFetch = true;
+ async acquireForAsync(sourceTexts: Set) {
+ if (this._ataInFlight) {
+ return;
+ }
+
+ const candidates = new Set(
+ Array.from(sourceTexts)
+ .map(SanitizeSpecifier)
+ .filter((s) => IsBare(s))
+ .filter((s) => !/@local$/.test(s))
+ .filter((s) => !IsNodeish(s))
+ .filter((s) => !this._acquired.has(CanonicalSpec(ParseSpec(s))))
+ .filter((s) => !BlocklistBase.has(BasePackage(ParseSpec(s))))
+ );
+ if (candidates.size === 0) {
+ return;
+ }
+
+ const ataCandidates = new Set();
+ for (const s of candidates) {
+ ataCandidates.add(BasePackage(ParseSpec(s))); // drop @version + subpath
+ }
+ if (this._firstFetch) {
+ ataCandidates.add("react");
+ ataCandidates.add("react-dom");
+ ataCandidates.add("@types/react");
+ ataCandidates.add("@types/react-dom");
+ ataCandidates.add("react/jsx-runtime");
+ this._firstFetch = false;
+ }
+ this._ataInFlight = true;
+ await this._ata(BuildSyntheticAtaEntry(ataCandidates));
+ }
+
+ public collectBareFromSources(sourceTexts: string[]): Set {
+ return this.discoverBareImports(sourceTexts);
+ }
+
+ public getBareImportsSnapshot(): Set {
+ return new Set(this._currentBare);
+ }
+
+ public getPinnedVersion(base: string): string | undefined {
+ return this._pinnedByBase.get(base);
+ }
+
+ /**
+ * Wait for any in-flight ATA requests to complete
+ * @param timeoutMs Maximum time to wait in milliseconds
+ * @returns Promise that resolves when ATA is complete or timeout is reached
+ */
+ async waitForAtaCompletionAsync(timeoutMs = 5000): Promise {
+ if (!this._ataInFlight) {
+ return true;
+ }
+
+ const startTime = Date.now();
+ while (this._ataInFlight && Date.now() - startTime < timeoutMs) {
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ }
+
+ return !this._ataInFlight;
+ }
+
+ /**
+ * Check if ATA is currently in flight
+ */
+ get isAtaInFlight(): boolean {
+ return this._ataInFlight;
+ }
+
+ private _disposeLocalFor(fullSpec: string) {
+ const arr = this._localLibsBySpec.get(fullSpec);
+ if (arr && arr.length) {
+ for (const d of arr) {
+ try {
+ d.dispose();
+ } catch {}
+ }
+ }
+ this._localLibsBySpec.delete(fullSpec);
+ this._localDirBySpec.delete(fullSpec);
+
+ this._removeBareStub(fullSpec);
+ this._removeBareStub(fullSpec + "/*");
+ }
+
+ public async mapLocalTypingsAsync(fullSpec: string, dirName: string, files: Array<{ path: string; content: string; lastModified: number }>) {
+ this._disposeLocalFor(fullSpec);
+
+ const perSpecDisposables: monaco.IDisposable[] = [];
+ for (const f of files) {
+ const normPath = f.path.replace(/\\/g, "/");
+ const vuri = `file:///local/${dirName}/${normPath}`;
+ perSpecDisposables.push(
+ monaco.languages.typescript.typescriptDefaults.addExtraLib(f.content, vuri),
+ monaco.languages.typescript.javascriptDefaults.addExtraLib(f.content, vuri)
+ );
+ }
+ this._localLibsBySpec.set(fullSpec, perSpecDisposables);
+ this._localDirBySpec.set(fullSpec, dirName);
+
+ this._typeLibDisposables.push(...perSpecDisposables);
+
+ const pickEntry = () => {
+ const has = (p: string) => files.some((f) => f.path.replace(/\\/g, "/") === p);
+ if (has("index.d.ts")) {
+ return `file:///local/${dirName}/index.d.ts`;
+ }
+
+ const pkgJson = files.find((f) => f.path.replace(/\\/g, "/") === "package.json");
+ if (pkgJson) {
+ try {
+ const pkg = JSON.parse(pkgJson.content);
+ const fromPkg = typeof pkg.types === "string" ? pkg.types : typeof pkg.typings === "string" ? pkg.typings : null;
+ if (fromPkg) {
+ return `file:///local/${dirName}/${fromPkg.replace(/^\.\//, "")}`.replace(/\\/g, "/");
+ }
+ } catch {}
+ }
+
+ const idx = files.find((f) => /(?:^|\/)index\.d\.ts$/i.test(f.path.replace(/\\/g, "/")));
+ if (idx) {
+ return `file:///local/${dirName}/${idx.path.replace(/\\/g, "/")}`;
+ }
+
+ const any = files.find((f) => f.path.toLowerCase().endsWith(".d.ts"));
+ if (any) {
+ return `file:///local/${dirName}/${any.path.replace(/\\/g, "/")}`;
+ }
+
+ return null;
+ };
+
+ const entry = pickEntry();
+ if (!entry) {
+ this._disposeLocalFor(fullSpec);
+ return;
+ }
+
+ const p = ParseSpec(fullSpec);
+ const base = BasePackage(p);
+ const vdir = entry.replace(/\/index\.d\.ts$/i, "").replace(/\.d\.ts$/i, "");
+
+ this._addPaths(base, entry);
+ this._addPaths(base + "/*", vdir + "/*");
+ this._addPaths(fullSpec, entry);
+ this._addPaths(fullSpec + "/*", vdir + "/*");
+
+ this._entryMapped.add(base);
+ this._entryMapped.add(fullSpec);
+ this._acquired.add(base);
+ this._acquired.add(fullSpec);
+
+ this._pendingLocal.delete(fullSpec);
+ }
+
+ private _addBareStub(spec: string) {
+ // Guard local - this should always be renewed explicitly
+ if (/@local$/.test(spec)) {
+ return;
+ }
+ const names = this._requestedNamesBySpec.get(spec) ?? this._requestedNamesBySpec.get(BasePackage(ParseSpec(spec))) ?? new Set();
+ let text = `declare module "${spec}" {\n const __def: any;\n export default __def;\n`;
+ for (const n of names) {
+ if (!n || !/^[A-Za-z_$][\w$]*$/.test(n)) {
+ continue;
+ }
+ text += ` export const ${n}: any;\n`;
+ }
+ text += `}\n`;
+
+ const fname = `pg-bare/${spec.replace(/[^\w@/.-]/g, "_")}.d.ts`;
+ const tsDisp = monaco.languages.typescript.typescriptDefaults.addExtraLib(text, fname);
+ const jsDisp = monaco.languages.typescript.javascriptDefaults.addExtraLib(text, fname);
+ this._bareStubBySpec.set(spec, { ts: tsDisp, js: jsDisp });
+
+ if (!spec.endsWith("/*")) {
+ const w = `${spec}/*`;
+ const wText = `declare module "${w}" { const __any: any; export default __any; }\n`;
+ const wFname = `pg-bare/${w.replace(/[^\w@/.-]/g, "_")}.d.ts`;
+ const wTs = monaco.languages.typescript.typescriptDefaults.addExtraLib(wText, wFname);
+ const wJs = monaco.languages.typescript.javascriptDefaults.addExtraLib(wText, wFname);
+ this._bareStubBySpec.set(w, { ts: wTs, js: wJs });
+ }
+ }
+
+ private _removeBareStub(spec: string) {
+ const d = this._bareStubBySpec.get(spec);
+ if (d) {
+ d.ts.dispose();
+ d.js.dispose();
+ this._bareStubBySpec.delete(spec);
+ }
+ }
+}
diff --git a/packages/tools/playground/src/tools/monaco/typings/utils.ts b/packages/tools/playground/src/tools/monaco/typings/utils.ts
new file mode 100644
index 00000000000..95736ddd9fb
--- /dev/null
+++ b/packages/tools/playground/src/tools/monaco/typings/utils.ts
@@ -0,0 +1,84 @@
+/* eslint-disable jsdoc/require-jsdoc */
+
+import type { ParsedSpec } from "./types";
+import { NodeBuiltins } from "./constants";
+
+export function IsBare(spec: string) {
+ if (!spec) {
+ return false;
+ }
+ if (spec.startsWith("./") || spec.startsWith("../") || spec.startsWith("/") || spec.startsWith("__pg__/") || spec.startsWith("data:")) {
+ return false;
+ }
+ return true;
+}
+
+export function NormalizeVirtualPath(path: string) {
+ return path
+ .replace(/^\/node_modules\//, "")
+ .replace(/^\/+/, "")
+ .replace("/dist/", "/")
+ .replace(/\?[^#]*$/, "")
+ .replace(/#[\s\S]*$/, "");
+}
+export function BuildSyntheticAtaEntry(bare: Set) {
+ const bases = new Set();
+ for (const s of bare) {
+ const p = ParseSpec(s);
+ bases.add(BasePackage(p));
+ }
+ return Array.from(bases)
+ .map((b) => `import "${b}";`)
+ .join("\n");
+}
+
+export function ParseSpec(rawIn: string): ParsedSpec {
+ let raw = rawIn.replace(/^npm:|^pkg:/, "");
+ if (/^https?:\/\//i.test(raw)) {
+ try {
+ const u = new URL(raw);
+ raw = u.pathname.replace(/^\/+/, "");
+ } catch {}
+ }
+
+ const scoped = raw.startsWith("@");
+ const parts = raw.split("/");
+ const pkgOrScoped = scoped ? parts.slice(0, 2).join("/") : parts[0];
+ const rest = scoped ? parts.slice(2) : parts.slice(1);
+
+ let name = pkgOrScoped;
+ let version: string | undefined;
+
+ const m = pkgOrScoped.match(scoped ? /^(@[^/]+\/[^@/]+)(?:@([^/]+))?$/ : /^([^@/]+)(?:@([^/]+))?$/);
+ if (m) {
+ name = m[1];
+ version = m[2];
+ }
+
+ const subpath = rest.length ? rest.join("/") : undefined;
+ return { raw: rawIn, name, version, subpath, scoped };
+}
+
+// Canonical spec string preserving version + subpath
+export function CanonicalSpec(p: ParsedSpec): string {
+ const head = p.version ? `${p.name}@${p.version}` : p.name;
+ return p.subpath ? `${head}/${p.subpath}` : head;
+}
+
+// Base package (no version, no subpath)
+export function BasePackage(p: ParsedSpec): string {
+ return p.name;
+}
+
+export function IsNodeish(spec: string) {
+ return spec.startsWith("node:") || NodeBuiltins.has(spec);
+}
+export function SanitizeSpecifier(s: string) {
+ s = s.replace(/^['"]|['"]$/g, "");
+ try {
+ s = decodeURIComponent(s);
+ } catch {
+ /* noop */
+ }
+ return s;
+}
diff --git a/packages/tools/playground/src/tools/monaco/utils/path.ts b/packages/tools/playground/src/tools/monaco/utils/path.ts
new file mode 100644
index 00000000000..48007ce14d1
--- /dev/null
+++ b/packages/tools/playground/src/tools/monaco/utils/path.ts
@@ -0,0 +1,120 @@
+export type LangId = "javascript" | "typescript" | "wgsl" | "glsl" | "fx" | "txt";
+
+/**
+ * Gets the file extension from a file path.
+ * @param p The file path to check.
+ * @returns The file extension.
+ */
+export function ExtFromPath(p: string): LangId {
+ const low = p.toLowerCase();
+ if (low.endsWith(".ts") || low.endsWith(".tsx")) {
+ return "typescript";
+ }
+ if (low.endsWith(".js") || low.endsWith(".jsx")) {
+ return "javascript";
+ }
+ if (low.endsWith(".wgsl")) {
+ return "wgsl";
+ }
+ if (low.endsWith(".glsl")) {
+ return "glsl";
+ }
+ if (low.endsWith(".fx")) {
+ return "fx";
+ }
+ return "txt";
+}
+
+/**
+ *
+ * @param text The content of the shader file.
+ * @returns The detected language or the fallback.
+ */
+export function DetectFxLangFromContent(text: string): "wgsl" | "glsl" {
+ const t = text || "";
+ const isWGSL = /@group\(|@binding\(|@location\(|\bfn\b|\blet\b|\bvar\s*<|\btexture\w*\b|\bsampler\b/.test(t);
+ const isGLSL = /#\s*version\b|\b(attribute|varying|precision)\b|\bvoid\s+main\s*\(/.test(t);
+ if (isWGSL && !isGLSL) {
+ return "wgsl";
+ }
+ if (isGLSL && !isWGSL) {
+ return "glsl";
+ }
+ return "wgsl";
+}
+
+/**
+ * Determines the language of a file based on its path and content.
+ * @param path The file path to check.
+ * @param fallback The fallback language if the path cannot be determined.
+ * @param content The content of the file, used for more accurate language detection.
+ * @returns The detected language or the fallback.
+ */
+export function MonacoLanguageFor(path: string, fallback: "javascript" | "typescript", content?: string) {
+ const ext = ExtFromPath(path);
+ if (ext === "typescript") {
+ return "typescript";
+ }
+ if (ext === "javascript") {
+ return "javascript";
+ }
+ if (ext === "wgsl") {
+ return "wgsl";
+ }
+ if (ext === "glsl") {
+ return "glsl";
+ }
+ if (ext === "fx") {
+ return DetectFxLangFromContent(content || "");
+ }
+ return fallback;
+}
+
+/**
+ *
+ * @param fromPath
+ * @param rel
+ * @returns
+ */
+export function ResolveRelative(fromPath: string, rel: string) {
+ const base = fromPath.split("/");
+ base.pop();
+ for (const part of rel.split("/")) {
+ if (!part || part === ".") {
+ continue;
+ }
+ if (part === "..") {
+ base.pop();
+ } else {
+ base.push(part);
+ }
+ }
+ return base.join("/");
+}
+
+/**
+ * Picks the actual file path from a list of possible paths.
+ * @param path The base path to check.
+ * @param has A function that checks if a path exists.
+ * @returns The actual file path if found, or null.
+ */
+export function PickActual(path: string, has: (p: string) => boolean): string | null {
+ if (has(path)) {
+ return path;
+ }
+ for (const ext of [".ts", ".tsx", ".js", ".mjs"]) {
+ if (has(path + ext)) {
+ return path + ext;
+ }
+ }
+ return null;
+}
+
+/**
+ * Strips the query string from a URL.
+ * @param s string
+ * @returns string
+ */
+export function StripQuery(s: string) {
+ return s.replace(/\?.*$/, "");
+}
diff --git a/packages/tools/playground/src/tools/monacoManager.ts b/packages/tools/playground/src/tools/monacoManager.ts
deleted file mode 100644
index 72047bf21e6..00000000000
--- a/packages/tools/playground/src/tools/monacoManager.ts
+++ /dev/null
@@ -1,805 +0,0 @@
-/* eslint-disable no-await-in-loop */
-/* eslint-disable @typescript-eslint/no-floating-promises */
-import * as monaco from "monaco-editor/esm/vs/editor/editor.api";
-
-// import 'monaco-editor/esm/vs/basic-languages/typescript/typescript.contribution';
-// import 'monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution';
-
-import * as languageFeatures from "monaco-editor/esm/vs/language/typescript/languageFeatures";
-
-import type { GlobalState } from "../globalState";
-import { Utilities } from "./utilities";
-import { CompilationError } from "../components/errorDisplayComponent";
-import { Observable, Logger } from "@dev/core";
-import { debounce } from "ts-debounce";
-
-import type { editor } from "monaco-editor/esm/vs/editor/editor.api";
-
-//declare var monaco: any;
-
-export class MonacoManager {
- private _editor: editor.IStandaloneCodeEditor;
- private _definitionWorker: Worker;
- private _tagCandidates: { name: string; tagName: string }[];
- private _hostElement: HTMLDivElement;
- private _templates: {
- label: string;
- key: string;
- documentation: string;
- insertText: string;
- language: string;
- kind: number;
- sortText: string;
- insertTextRules: number;
- }[];
-
- private _isDirty = false;
-
- public constructor(public globalState: GlobalState) {
- this._templates = [];
- this._load(globalState);
- }
-
- private _load(globalState: GlobalState) {
- window.addEventListener("beforeunload", (evt) => {
- if (this._isDirty && Utilities.ReadBoolFromStore("safe-mode", false)) {
- const message = "Are you sure you want to leave. You have unsaved work.";
- evt.preventDefault();
- evt.returnValue = message;
- }
- });
-
- globalState.onNewRequiredObservable.add(() => {
- if (Utilities.CheckSafeMode("Are you sure you want to create a new playground?")) {
- this._setNewContent();
- this._resetEditor(true);
- }
- });
-
- globalState.onInsertSnippetRequiredObservable.add((snippetKey: string) => {
- this._insertSnippet(snippetKey);
- });
-
- globalState.onClearRequiredObservable.add(() => {
- if (Utilities.CheckSafeMode("Are you sure you want to remove all your code?")) {
- this._editor?.setValue("");
- this._resetEditor();
- }
- });
-
- globalState.onNavigateRequiredObservable.add((position) => {
- this._editor?.revealPositionInCenter(position, monaco.editor.ScrollType.Smooth);
- this._editor?.setPosition(position);
- });
-
- globalState.onSavedObservable.add(() => {
- this._isDirty = false;
- });
-
- globalState.onCodeLoaded.add((code) => {
- if (!code) {
- this._setDefaultContent();
- return;
- }
-
- if (this._editor) {
- this._editor?.setValue(code);
- this._isDirty = false;
- this.globalState.onRunRequiredObservable.notifyObservers();
- } else {
- this.globalState.currentCode = code;
- }
- });
-
- globalState.onFormatCodeRequiredObservable.add(() => {
- this._editor?.getAction("editor.action.formatDocument").run();
- });
-
- globalState.onMinimapChangedObservable.add((value) => {
- this._editor?.updateOptions({
- minimap: {
- enabled: value,
- },
- });
- });
-
- globalState.onFontSizeChangedObservable.add(() => {
- this._editor?.updateOptions({
- fontSize: parseInt(Utilities.ReadStringFromStore("font-size", "14")),
- });
- });
-
- globalState.onLanguageChangedObservable.add(async () => {
- await this.setupMonacoAsync(this._hostElement);
- });
-
- globalState.onThemeChangedObservable.add(() => {
- this._createEditor();
- });
-
- // Register a global observable for inspector to request code changes
- const pgConnect = {
- onRequestCodeChangeObservable: new Observable(),
- };
-
- pgConnect.onRequestCodeChangeObservable.add((options: any) => {
- let code = this._editor?.getValue() || "";
- code = code.replace(options.regex, options.replace);
-
- this._editor?.setValue(code);
- });
-
- (window as any).Playground = pgConnect;
- }
-
- private _setNewContent() {
- this._createEditor();
-
- this.globalState.currentSnippetToken = "";
-
- if (this.globalState.language === "JS") {
- this._editor?.setValue(`// You have to create a function called createScene. This function must return a BABYLON.Scene object
-// You can reference the following variables: engine, canvas
-// You must at least define a camera
-
-var createScene = function() {
- var scene = new BABYLON.Scene(engine);
-
- //var camera = new BABYLON.ArcRotateCamera("Camera", -Math.PI / 2, Math.PI / 2, 12, BABYLON.Vector3.Zero(), scene);
- var camera = new BABYLON.FreeCamera("camera1", new BABYLON.Vector3(0, 5, -10), scene);
-
- // This targets the camera to scene origin
- camera.setTarget(BABYLON.Vector3.Zero());
-
- // This attaches the camera to the canvas
- camera.attachControl(canvas, true);
-
- return scene;
-};`);
- } else {
- this._editor
- ?.setValue(`// You have to create a class called Playground. This class must provide a static function named CreateScene(engine, canvas) which must return a Scene object
-// You must at least define a camera inside the CreateScene function
-
-class Playground {
- public static CreateScene(engine: BABYLON.Engine, canvas: HTMLCanvasElement): BABYLON.Scene {
- var scene = new BABYLON.Scene(engine);
-
- //var camera = new BABYLON.ArcRotateCamera("Camera", -Math.PI / 2, Math.PI / 2, 12, BABYLON.Vector3.Zero(), scene);
- var camera = new BABYLON.FreeCamera("camera1", new BABYLON.Vector3(0, 5, -10), scene);
-
- // This targets the camera to scene origin
- camera.setTarget(BABYLON.Vector3.Zero());
-
- // This attaches the camera to the canvas
- camera.attachControl(canvas, true);
-
- return scene;
- }
-}`);
- }
-
- this.globalState.onRunRequiredObservable.notifyObservers();
-
- if (location.pathname.indexOf("pg/") !== -1) {
- // reload to create a new pg if in full-path playground mode.
- window.location.pathname = "";
- }
- }
-
- private _indentCode(code: string, indentation: number): string {
- const indent = " ".repeat(indentation);
- const lines = code.split("\n");
- const indentedCode = lines.map((line) => indent + line).join("\n");
- return indentedCode;
- }
-
- private _getCode(key: string): string {
- let code = "";
- this._templates.forEach(function (item) {
- if (item.key === key) {
- // Remove monaco placeholders
- const regex = /\$\{[0-9]+:([^}]+)\}|\$\{[0-9]+\}/g;
- code = item.insertText.replace(regex, (match, p1) => p1 || "");
- }
- });
- return code + "\n";
- }
-
- private _insertCodeAtCursor(code: string) {
- if (this._editor) {
- // Get the current position of the cursor
- const position = this._editor.getPosition();
- if (position) {
- // Fix indent regarding current position
- if (position.column && position.column > 1) {
- code = this._indentCode(code, position.column - 1).slice(position.column - 1);
- }
- // Insert code
- this._editor.executeEdits("", [
- {
- range: new monaco.Range(position.lineNumber, position.column, position.lineNumber, position.column),
- text: code,
- forceMoveMarkers: true,
- },
- ]);
- }
- }
- }
-
- private _insertSnippet(snippetKey: string) {
- const snippet = this._getCode(snippetKey);
- this._insertCodeAtCursor(snippet);
- }
-
- private _resetEditor(resetMetadata?: boolean) {
- location.hash = "";
- if (resetMetadata) {
- this.globalState.currentSnippetTitle = "";
- this.globalState.currentSnippetDescription = "";
- this.globalState.currentSnippetTags = "";
- }
- this._isDirty = true;
- }
-
- private _createEditor() {
- if (this._editor) {
- this._editor.dispose();
- }
-
- const editorOptions: editor.IStandaloneEditorConstructionOptions = {
- value: "",
- language: this.globalState.language === "JS" ? "javascript" : "typescript",
- lineNumbers: "on",
- roundedSelection: true,
- automaticLayout: true,
- scrollBeyondLastLine: false,
- readOnly: false,
- theme: Utilities.ReadStringFromStore("theme", "Light") === "Dark" ? "vs-dark" : "vs-light",
- contextmenu: false,
- folding: true,
- showFoldingControls: "always",
- fontSize: parseInt(Utilities.ReadStringFromStore("font-size", "14")),
- renderIndentGuides: true,
- minimap: {
- enabled: Utilities.ReadBoolFromStore("minimap", true),
- },
- };
-
- this._editor = monaco.editor.create(this._hostElement, editorOptions as any);
-
- const analyzeCodeDebounced = debounce(async () => await this._analyzeCodeAsync(), 500);
- this._editor.onDidChangeModelContent(() => {
- const newCode = this._editor.getValue();
- if (this.globalState.currentCode !== newCode) {
- this.globalState.currentCode = newCode;
- this._isDirty = true;
- analyzeCodeDebounced();
- }
- });
-
- if (this.globalState.currentCode) {
- this._editor.setValue(this.globalState.currentCode);
- }
-
- this.globalState.getCompiledCode = async () => await this._getRunCodeAsync();
-
- if (this.globalState.currentCode) {
- this.globalState.onRunRequiredObservable.notifyObservers();
- }
- }
-
- public async setupMonacoAsync(hostElement: HTMLDivElement, initialCall = false) {
- this._hostElement = hostElement;
-
- const declarations = [
- "https://preview.babylonjs.com/babylon.d.ts",
- "https://preview.babylonjs.com/gui/babylon.gui.d.ts",
- "https://preview.babylonjs.com/loaders/babylonjs.loaders.d.ts",
- "https://preview.babylonjs.com/materialsLibrary/babylonjs.materials.d.ts",
- "https://preview.babylonjs.com/nodeEditor/babylon.nodeEditor.d.ts",
- "https://preview.babylonjs.com/postProcessesLibrary/babylonjs.postProcess.d.ts",
- "https://preview.babylonjs.com/proceduralTexturesLibrary/babylonjs.proceduralTextures.d.ts",
- "https://preview.babylonjs.com/serializers/babylonjs.serializers.d.ts",
- "https://preview.babylonjs.com/inspector/babylon.inspector.d.ts",
- "https://preview.babylonjs.com/accessibility/babylon.accessibility.d.ts",
- "https://preview.babylonjs.com/addons/babylonjs.addons.d.ts",
- ];
-
- let snapshot = "";
- // see if a snapshot should be used
- if (window.location.search.indexOf("snapshot=") !== -1) {
- snapshot = window.location.search.split("snapshot=")[1];
- // cleanup, just in case
- snapshot = snapshot.split("&")[0];
- for (let index = 0; index < declarations.length; index++) {
- declarations[index] = declarations[index].replace("https://preview.babylonjs.com", "https://snapshots-cvgtc2eugrd3cgfd.z01.azurefd.net/" + snapshot);
- }
- }
-
- let version = "";
- if (window.location.search.indexOf("version=") !== -1) {
- version = window.location.search.split("version=")[1];
- // cleanup, just in case
- version = version.split("&")[0];
- for (let index = 0; index < declarations.length; index++) {
- declarations[index] = declarations[index].replace("https://preview.babylonjs.com", "https://cdn.babylonjs.com/v" + version);
- }
- }
-
- // Local mode
- if (location.hostname === "localhost" && location.search.indexOf("dist") === -1) {
- for (let index = 0; index < declarations.length; index++) {
- declarations[index] = declarations[index].replace("https://preview.babylonjs.com/", "//localhost:1337/");
- }
- }
-
- declarations.push("https://preview.babylonjs.com/glTF2Interface/babylon.glTF2Interface.d.ts");
- declarations.push("https://assets.babylonjs.com/generated/Assets.d.ts");
-
- // Check for Babylon Toolkit
- if (location.href.indexOf("BabylonToolkit") !== -1 || Utilities.ReadBoolFromStore("babylon-toolkit", false) || Utilities.ReadBoolFromStore("babylon-toolkit-used", false)) {
- declarations.push("https://cdn.jsdelivr.net/gh/BabylonJS/BabylonToolkit@master/Runtime/babylon.toolkit.d.ts");
- declarations.push("https://cdn.jsdelivr.net/gh/BabylonJS/BabylonToolkit@master/Runtime/default.playground.d.ts");
- }
-
- const timestamp = typeof globalThis !== "undefined" && (globalThis as any).__babylonSnapshotTimestamp__ ? (globalThis as any).__babylonSnapshotTimestamp__ : 0;
- if (timestamp) {
- for (let index = 0; index < declarations.length; index++) {
- if (declarations[index].indexOf("preview.babylonjs.com") !== -1) {
- declarations[index] = declarations[index] + "?t=" + timestamp;
- }
- }
- }
-
- let libContent = "";
- const responses = await Promise.all(declarations.map(async (declaration) => await fetch(declaration)));
- const fallbackUrl = "https://snapshots-cvgtc2eugrd3cgfd.z01.azurefd.net/refs/heads/master";
- for (const response of responses) {
- if (!response.ok) {
- // attempt a fallback
- const fallbackResponse = await fetch(response.url.replace("https://preview.babylonjs.com", fallbackUrl));
- if (fallbackResponse.ok) {
- libContent += await fallbackResponse.text();
- } else {
- // eslint-disable-next-line no-console
- console.log("missing declaration", response.url);
- }
- } else {
- libContent += await response.text();
- }
- }
- libContent += `
-interface Window {
- engine: BABYLON.Engine;
- canvas: HTMLCanvasElement;
-};
-
-declare var engine: BABYLON.Engine;
-declare var canvas: HTMLCanvasElement;
- `;
-
- this._createEditor();
-
- // Definition worker
- this._setupDefinitionWorker(libContent);
-
- // Setup the Monaco compilation pipeline, so we can reuse it directly for our scripting needs
- this._setupMonacoCompilationPipeline(libContent);
-
- // This is used for a vscode-like color preview for ColorX types
- this._setupMonacoColorProvider();
-
- if (initialCall) {
- try {
- // Fetch JSON data for templates code
- const templatesCodeUrl = "templates.json?uncacher=" + Date.now();
- this._templates = await (await fetch(templatesCodeUrl)).json();
-
- // enhance templates with extra properties
- for (const template of this._templates) {
- template.kind = monaco.languages.CompletionItemKind.Snippet;
- template.sortText = "!" + template.label; // make sure templates are on top of the completion window
- template.insertTextRules = monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet;
- }
- } catch {
- Logger.Log("Error loading templates code");
- }
-
- this._hookMonacoCompletionProviderAsync();
- }
-
- if (!this.globalState.loadingCodeInProgress) {
- this._setDefaultContent();
- }
- }
-
- private _setDefaultContent() {
- if (this.globalState.language === "JS") {
- this._editor.setValue(`var createScene = function () {
- // This creates a basic Babylon Scene object (non-mesh)
- var scene = new BABYLON.Scene(engine);
-
- // This creates and positions a free camera (non-mesh)
- var camera = new BABYLON.FreeCamera("camera1", new BABYLON.Vector3(0, 5, -10), scene);
-
- // This targets the camera to scene origin
- camera.setTarget(BABYLON.Vector3.Zero());
-
- // This attaches the camera to the canvas
- camera.attachControl(canvas, true);
-
- // This creates a light, aiming 0,1,0 - to the sky (non-mesh)
- var light = new BABYLON.HemisphericLight("light", new BABYLON.Vector3(0, 1, 0), scene);
-
- // Default intensity is 1. Let's dim the light a small amount
- light.intensity = 0.7;
-
- // Our built-in 'sphere' shape.
- var sphere = BABYLON.MeshBuilder.CreateSphere("sphere", {diameter: 2, segments: 32}, scene);
-
- // Move the sphere upward 1/2 its height
- sphere.position.y = 1;
-
- // Our built-in 'ground' shape.
- var ground = BABYLON.MeshBuilder.CreateGround("ground", {width: 6, height: 6}, scene);
-
- return scene;
-};`);
- } else {
- this._editor.setValue(`class Playground {
- public static CreateScene(engine: BABYLON.Engine, canvas: HTMLCanvasElement): BABYLON.Scene {
- // This creates a basic Babylon Scene object (non-mesh)
- var scene = new BABYLON.Scene(engine);
-
- // This creates and positions a free camera (non-mesh)
- var camera = new BABYLON.FreeCamera("camera1", new BABYLON.Vector3(0, 5, -10), scene);
-
- // This targets the camera to scene origin
- camera.setTarget(BABYLON.Vector3.Zero());
-
- // This attaches the camera to the canvas
- camera.attachControl(canvas, true);
-
- // This creates a light, aiming 0,1,0 - to the sky (non-mesh)
- var light = new BABYLON.HemisphericLight("light1", new BABYLON.Vector3(0, 1, 0), scene);
-
- // Default intensity is 1. Let's dim the light a small amount
- light.intensity = 0.7;
-
- // Our built-in 'sphere' shape. Params: name, options, scene
- var sphere = BABYLON.MeshBuilder.CreateSphere("sphere", {diameter: 2, segments: 32}, scene);
-
- // Move the sphere upward 1/2 its height
- sphere.position.y = 1;
-
- // Our built-in 'ground' shape. Params: name, options, scene
- var ground = BABYLON.MeshBuilder.CreateGround("ground", {width: 6, height: 6}, scene);
-
- return scene;
- }
-}`);
- }
-
- this._isDirty = false;
-
- this.globalState.onRunRequiredObservable.notifyObservers();
- }
-
- // Provide an adornment for BABYLON.ColorX types: color preview
- protected _setupMonacoColorProvider() {
- monaco.languages.registerColorProvider(this.globalState.language == "JS" ? "javascript" : "typescript", {
- provideColorPresentations: (model: any, colorInfo: any) => {
- const color = colorInfo.color;
-
- const precision = 100.0;
- const converter = (n: number) => Math.round(n * precision) / precision;
-
- let label;
- if (color.alpha === undefined || color.alpha === 1.0) {
- label = `(${converter(color.red)}, ${converter(color.green)}, ${converter(color.blue)})`;
- } else {
- label = `(${converter(color.red)}, ${converter(color.green)}, ${converter(color.blue)}, ${converter(color.alpha)})`;
- }
-
- return [
- {
- label: label,
- },
- ];
- },
-
- provideDocumentColors: (model: any) => {
- const digitGroup = "\\s*(\\d*(?:\\.\\d+)?)\\s*";
- // we add \n{0} to workaround a Monaco bug, when setting regex options on their side
- const regex = `BABYLON\\.Color(?:3|4)\\s*\\(${digitGroup},${digitGroup},${digitGroup}(?:,${digitGroup})?\\)\\n{0}`;
- const matches = model.findMatches(regex, false, true, true, null, true);
-
- const converter = (g: string) => (g === undefined ? undefined : Number(g));
-
- return matches.map((match: any) => ({
- color: {
- red: converter(match.matches![1])!,
- green: converter(match.matches![2])!,
- blue: converter(match.matches![3])!,
- alpha: converter(match.matches![4])!,
- },
- range: {
- startLineNumber: match.range.startLineNumber,
- startColumn: match.range.startColumn + match.matches![0].indexOf("("),
- endLineNumber: match.range.startLineNumber,
- endColumn: match.range.endColumn,
- },
- }));
- },
- });
- }
-
- // Setup both JS and TS compilation pipelines to work with our scripts.
- protected _setupMonacoCompilationPipeline(libContent: string) {
- const typescript = monaco.languages.typescript;
-
- if (this.globalState.language === "JS") {
- typescript.javascriptDefaults.setCompilerOptions({
- noLib: false,
- allowNonTsExtensions: true, // required to prevent Uncaught Error: Could not find file: 'inmemory://model/1'.
- allowJs: true,
- });
-
- typescript.javascriptDefaults.addExtraLib(libContent, "babylon.d.ts");
- } else {
- typescript.typescriptDefaults.setCompilerOptions({
- module: typescript.ModuleKind.AMD,
- target: typescript.ScriptTarget.ESNext,
- noLib: false,
- strict: false,
- alwaysStrict: false,
- strictFunctionTypes: false,
- suppressExcessPropertyErrors: false,
- suppressImplicitAnyIndexErrors: true,
- noResolve: true,
- suppressOutputPathCheck: true,
-
- allowNonTsExtensions: true, // required to prevent Uncaught Error: Could not find file: 'inmemory://model/1'.
- });
- typescript.typescriptDefaults.addExtraLib(libContent, "babylon.d.ts");
- }
- }
-
- protected _setupDefinitionWorker(libContent: string) {
- this._definitionWorker = new Worker("workers/definitionWorker.js");
- this._definitionWorker.addEventListener("message", ({ data }) => {
- this._tagCandidates = data.result;
- this._analyzeCodeAsync();
- });
- this._definitionWorker.postMessage({
- code: libContent,
- });
- }
-
- // This will make sure that all members marked with an interesting jsdoc attribute will be marked as such in Monaco UI
- // We use a prefiltered list of tag candidates, because the effective call to Monaco API can be slow.
- // @see setupDefinitionWorker
- private async _analyzeCodeAsync() {
- // if the definition worker is very fast, this can be called out of context. @see setupDefinitionWorker
- if (!this._editor) {
- return;
- }
-
- if (!this._tagCandidates) {
- return;
- }
-
- const model = this._editor.getModel();
- if (!model || model.isDisposed()) {
- return;
- }
-
- const uri = model.uri;
-
- let worker = null;
- if (this.globalState.language === "JS") {
- worker = await monaco.languages.typescript.getJavaScriptWorker();
- } else {
- worker = await monaco.languages.typescript.getTypeScriptWorker();
- }
-
- const languageService = await worker(uri);
- const source = "[preview]";
-
- monaco.editor.setModelMarkers(model, source, []);
- const markers: {
- startLineNumber: number;
- endLineNumber: number;
- startColumn: number;
- endColumn: number;
- message: string;
- severity: number;
- source: string;
- }[] = [];
-
- for (const candidate of this._tagCandidates) {
- if (model.isDisposed()) {
- continue;
- }
- const matches = model.findMatches(candidate.name, false, false, true, null, false);
- if (!matches) {
- continue;
- }
-
- for (const match of matches) {
- if (model.isDisposed()) {
- continue;
- }
- const position = {
- lineNumber: match.range.startLineNumber,
- column: match.range.startColumn,
- };
- const wordInfo = model.getWordAtPosition(position);
- const offset = model.getOffsetAt(position);
-
- if (!wordInfo) {
- continue;
- }
-
- // continue if we already found an issue here
- if (markers.find((m) => m.startLineNumber == position.lineNumber && m.startColumn == position.column)) {
- continue;
- }
-
- // the following is time consuming on all suggestions, that's why we precompute tag candidate names in the definition worker to filter calls
- // @see setupDefinitionWorker
- const details = await languageService.getCompletionEntryDetails(uri.toString(), offset, wordInfo.word);
- if (!details || !details.tags) {
- continue;
- }
-
- const tag = details.tags.find((t: { name: string }) => t.name === candidate.tagName);
- if (tag) {
- markers.push({
- startLineNumber: match.range.startLineNumber,
- endLineNumber: match.range.endLineNumber,
- startColumn: wordInfo.startColumn,
- endColumn: wordInfo.endColumn,
- message: this._getTagMessage(tag),
- severity: this._getCandidateMarkerSeverity(candidate),
- source: source,
- });
- }
- }
- }
-
- monaco.editor.setModelMarkers(model, source, markers);
- }
-
- private _getCandidateMarkerSeverity(candidate: { tagName: string }) {
- switch (candidate.tagName) {
- case "deprecated":
- return monaco.MarkerSeverity.Warning;
- default:
- return monaco.MarkerSeverity.Info;
- }
- }
-
- private _getCandidateCompletionSuffix(candidate: { tagName: string }) {
- switch (candidate.tagName) {
- case "deprecated":
- return "⚠️";
- default:
- return "🧪";
- }
- }
-
- private _getTagMessage(tag: any) {
- if (tag?.text instanceof String) {
- if (tag.text.indexOf("data:") === 0) {
- return ` `;
- }
- return tag.text;
- }
-
- if (tag?.text instanceof Array) {
- return tag.text
- .filter((i: { kind: string }) => i.kind === "text")
- .map((i: { text: any }) => (i.text.indexOf("data:") === 0 ? ` ` : i.text))
- .join(", ");
- }
-
- return "";
- }
-
- // This is our hook in the Monaco suggest adapter, we are called everytime a completion UI is displayed
- // So we need to be super fast.
- private async _hookMonacoCompletionProviderAsync() {
- const oldProvideCompletionItems = languageFeatures.SuggestAdapter.prototype.provideCompletionItems;
- // eslint-disable-next-line @typescript-eslint/no-this-alias
- const owner = this;
-
- languageFeatures.SuggestAdapter.prototype.provideCompletionItems = async function (model: any, position: any, context: any, token: any) {
- // reuse 'this' to preserve context through call (using apply)
- const result = await oldProvideCompletionItems.apply(this, [model, position, context, token]);
-
- if (!result || !result.suggestions) {
- return result;
- }
-
- // filter non public members
- const suggestions = result.suggestions.filter((item: any) => !item.label.startsWith("_"));
-
- for (const suggestion of suggestions) {
- const candidate = owner._tagCandidates.find((t) => t.name === suggestion.label);
- if (candidate) {
- // the following is time consuming on all suggestions, that's why we precompute deprecated candidate names in the definition worker to filter calls
- // @see setupDefinitionWorker
- const uri = suggestion.uri;
- const worker = await this._worker(uri);
- const model = monaco.editor.getModel(uri);
- const details = await worker.getCompletionEntryDetails(uri.toString(), model!.getOffsetAt(position), suggestion.label);
-
- if (!details || !details.tags) {
- continue;
- }
-
- const tag = details.tags.find((t: { name: string }) => t.name === candidate.tagName);
- if (tag) {
- const suffix = owner._getCandidateCompletionSuffix(candidate);
- suggestion.label = suggestion.label + suffix;
- }
- }
- }
-
- // add our own templates when invoked without context
- if (context.triggerKind == monaco.languages.CompletionTriggerKind.Invoke) {
- const language = owner.globalState.language === "JS" ? "javascript" : "typescript";
- for (const template of owner._templates) {
- if (template.language && language !== template.language) {
- continue;
- }
-
- suggestions.push(template);
- }
- }
-
- // preserve incomplete flag or force it when the definition is not yet analyzed
- const incomplete = (result.incomplete && result.incomplete == true) || owner._tagCandidates.length == 0;
- return {
- suggestions: JSON.parse(JSON.stringify(suggestions)),
- incomplete: incomplete,
- };
- };
- }
-
- private async _getRunCodeAsync() {
- if (this.globalState.language == "JS") {
- return this._editor.getValue();
- } else {
- const model = this._editor.getModel()!;
- const uri = model.uri;
-
- const worker = await monaco.languages.typescript.getTypeScriptWorker();
- const languageService = await worker(uri);
-
- const uriStr = uri.toString();
- const result = await languageService.getEmitOutput(uriStr);
- const diagnostics = await Promise.all([languageService.getSyntacticDiagnostics(uriStr), languageService.getSemanticDiagnostics(uriStr)]);
-
- diagnostics.forEach(function (diagset) {
- if (diagset.length) {
- const diagnostic = diagset[0];
- const position = model.getPositionAt(diagnostic.start!);
-
- const err = new CompilationError();
- err.message = diagnostic.messageText as string;
- err.lineNumber = position.lineNumber;
- err.columnNumber = position.column;
- throw err;
- }
- });
-
- const output = result.outputFiles[0].text;
- const stub = "var createScene = function() { return Playground.CreateScene(engine, engine.getRenderingCanvas()); }";
-
- return output + stub;
- }
- }
-}
diff --git a/packages/tools/playground/src/tools/saveManager.ts b/packages/tools/playground/src/tools/saveManager.ts
index f375ce2aca7..ff71f4a218e 100644
--- a/packages/tools/playground/src/tools/saveManager.ts
+++ b/packages/tools/playground/src/tools/saveManager.ts
@@ -1,8 +1,16 @@
-import { EncodeArrayBufferToBase64, Logger } from "@dev/core";
+import { Logger } from "@dev/core";
import type { GlobalState } from "../globalState";
import { Utilities } from "./utilities";
+import { PackSnippetData } from "./snippet";
+/**
+ * Handles saving playground code and multi-file manifests.
+ */
export class SaveManager {
+ /**
+ * Creates a new SaveManager.
+ * @param globalState Shared global state instance.
+ */
public constructor(public globalState: GlobalState) {
globalState.onSaveRequiredObservable.add(() => {
if (!this.globalState.currentSnippetTitle || !this.globalState.currentSnippetDescription || !this.globalState.currentSnippetTags) {
@@ -31,34 +39,6 @@ export class SaveManager {
});
}
- private _getSnippetData() {
- const encoder = new TextEncoder();
- const buffer = encoder.encode(this.globalState.currentCode);
-
- // Check if we need to encode it to store the unicode characters
- let testData = "";
-
- for (let i = 0; i < buffer.length; i++) {
- testData += String.fromCharCode(buffer[i]);
- }
- const activeEngineVersion = Utilities.ReadStringFromStore("engineVersion", "WebGL2", true);
-
- const payLoad = JSON.stringify({
- code: this.globalState.currentCode,
- unicode: testData !== this.globalState.currentCode ? EncodeArrayBufferToBase64(buffer) : undefined,
- engine: activeEngineVersion,
- });
-
- const dataToSend = {
- payload: payLoad,
- name: this.globalState.currentSnippetTitle,
- description: this.globalState.currentSnippetDescription,
- tags: this.globalState.currentSnippetTags,
- };
-
- return JSON.stringify(dataToSend);
- }
-
private async _saveJsonFileAsync(snippetData: string) {
try {
// Open "Save As" dialog
@@ -81,7 +61,7 @@ export class SaveManager {
// Close the file
await writable.close();
- } catch (err) {
+ } catch (err: any) {
if (err.name === "AbortError") {
Logger.Warn("User canceled save dialog");
} else {
@@ -91,7 +71,7 @@ export class SaveManager {
}
private _localSaveSnippet() {
- void this._saveJsonFileAsync(this._getSnippetData());
+ void this._saveJsonFileAsync(PackSnippetData(this.globalState));
}
private _saveSnippet() {
@@ -101,14 +81,12 @@ export class SaveManager {
if (xmlHttp.status === 200) {
const snippet = JSON.parse(xmlHttp.responseText);
if (location.pathname && location.pathname.indexOf("pg/") !== -1) {
- // full path with /pg/??????
if (location.pathname.indexOf("revision") !== -1) {
location.href = location.href.replace(/revision\/(\d+)/, "revision/" + snippet.version);
} else {
location.href = location.href + "/revision/" + snippet.version;
}
} else if (location.search && location.search.indexOf("pg=") !== -1) {
- // query string with ?pg=??????
const currentQuery = Utilities.ParseQuery();
if (currentQuery.revision) {
location.href = location.href.replace(/revision=(\d+)/, "revision=" + snippet.version);
@@ -116,7 +94,6 @@ export class SaveManager {
location.href = location.href + "&revision=" + snippet.version;
}
} else {
- // default behavior!
const baseUrl = location.href.replace(location.hash, "");
let toolkit = "";
@@ -130,6 +107,7 @@ export class SaveManager {
if (snippet.version && snippet.version !== "0") {
newUrl += "#" + snippet.version;
}
+ this.globalState.currentSnippetRevision = `#${snippet.version}`;
location.href = newUrl;
}
@@ -141,8 +119,9 @@ export class SaveManager {
};
xmlHttp.open("POST", this.globalState.SnippetServerUrl + (this.globalState.currentSnippetToken ? "/" + this.globalState.currentSnippetToken : ""), true);
+ xmlHttp.withCredentials = false;
xmlHttp.setRequestHeader("Content-Type", "application/json");
- xmlHttp.send(this._getSnippetData());
+ xmlHttp.send(PackSnippetData(this.globalState));
}
}
diff --git a/packages/tools/playground/src/tools/snippet.ts b/packages/tools/playground/src/tools/snippet.ts
new file mode 100644
index 00000000000..faeeb2a578c
--- /dev/null
+++ b/packages/tools/playground/src/tools/snippet.ts
@@ -0,0 +1,68 @@
+/* eslint-disable jsdoc/require-jsdoc */
+import { EncodeArrayBufferToBase64 } from "@dev/core";
+import { Utilities } from "./utilities";
+import type { GlobalState } from "../globalState";
+
+export const ManifestVersion = 2;
+
+export type V2Manifest = {
+ v: number;
+ language: "JS" | "TS";
+ entry: string;
+ imports: Record;
+ files: Record;
+ cdnBase?: string;
+};
+
+export type SnippetData = {
+ payload?: string;
+ jsonPayload?: string;
+ name: string;
+ description: string;
+ tags: string;
+};
+
+export type SnippetPayload = {
+ code: string;
+ unicode?: string;
+ engine: string;
+ version?: number;
+};
+
+export function GenerateV2Manifest(globalState: GlobalState): V2Manifest {
+ const entry = globalState.entryFilePath || (globalState.language === "JS" ? "index.js" : "index.ts");
+ const files = Object.keys(globalState.files || {}).length ? globalState.files : { [entry]: globalState.currentCode || "" };
+ return {
+ v: ManifestVersion,
+ language: (globalState.language === "JS" ? "JS" : "TS") as "JS" | "TS",
+ entry,
+ imports: globalState.importsMap || {},
+ files,
+ };
+}
+
+export function PackSnippetData(globalState: GlobalState): string {
+ const activeEngineVersion = Utilities.ReadStringFromStore("engineVersion", "WebGL2", true);
+ const v2 = GenerateV2Manifest(globalState);
+ const codeToSave = JSON.stringify(v2);
+ const encoder = new TextEncoder();
+ const buffer = encoder.encode(codeToSave);
+ let testData = "";
+ for (let i = 0; i < buffer.length; i++) {
+ testData += String.fromCharCode(buffer[i]);
+ }
+ const payload = JSON.stringify({
+ code: codeToSave,
+ unicode: testData !== codeToSave ? EncodeArrayBufferToBase64(buffer) : undefined,
+ engine: activeEngineVersion,
+ version: ManifestVersion,
+ } as SnippetPayload);
+ const snippetData: SnippetData = {
+ payload,
+ name: globalState.currentSnippetTitle,
+ description: globalState.currentSnippetDescription,
+ tags: globalState.currentSnippetTags,
+ };
+
+ return JSON.stringify(snippetData);
+}
diff --git a/packages/tools/playground/test/interactions.playground.test.ts b/packages/tools/playground/test/interactions.playground.test.ts
index 86c5a909bcf..8395a8eff8e 100644
--- a/packages/tools/playground/test/interactions.playground.test.ts
+++ b/packages/tools/playground/test/interactions.playground.test.ts
@@ -66,11 +66,17 @@ test("User can interact with the playground", async ({ page }) => {
height: 1080,
});
- await page.locator(".view-lines > div:nth-child(16)").click();
+ // There is a real condition that can be waiting with an evaluated promise in the browser
+ // via the Playground window global... This is a timing hack but the small amount of tests here
+ // should make it ok for now.
+
+ await page.waitForTimeout(1500);
+ await page.locator(".view-line:nth-of-type(16)").click();
+ await page.waitForTimeout(1500);
await page.keyboard.type("camera", { delay: 50 });
await expect(page.locator(".editor-widget")).toBeVisible();
await page.waitForTimeout(100);
- await page.getByLabel("camera", { exact: true }).locator("span").filter({ hasText: "camera" }).first().click();
+ await page.keyboard.press("Escape");
// change light's intensity to 0.2
await page.getByText("0.7").click();
diff --git a/packages/tools/playground/tsconfig.json b/packages/tools/playground/tsconfig.json
index 84a2133a685..bfabd61a683 100644
--- a/packages/tools/playground/tsconfig.json
+++ b/packages/tools/playground/tsconfig.json
@@ -2,6 +2,8 @@
"extends": "../../../tsconfig.json",
"compilerOptions": {
+ "strict": true,
"jsx": "react-jsx",
+ "allowSyntheticDefaultImports": true
},
}
diff --git a/packages/tools/playground/webpack.config.js b/packages/tools/playground/webpack.config.js
index adf6eb75cbc..ad19d022ab2 100644
--- a/packages/tools/playground/webpack.config.js
+++ b/packages/tools/playground/webpack.config.js
@@ -1,4 +1,5 @@
const MonacoWebpackPlugin = require("monaco-editor-webpack-plugin");
+const ReactRefreshWebpackPlugin = require("@pmmmwh/react-refresh-webpack-plugin");
const webpackTools = require("@dev/build-tools").webpackTools;
const path = require("path");
@@ -80,9 +81,19 @@ module.exports = (env) => {
rootDir: "../../",
},
},
- enableFastRefresh: !production,
}),
},
};
- return commonConfig;
+ const plugins = (commonConfig.plugins || []).filter((p) => !(p && p.constructor && p.constructor.name === "ReactRefreshWebpackPlugin"));
+ return {
+ ...commonConfig,
+ devServer: {
+ ...(commonConfig.devServer || {}),
+ client: {
+ ...(commonConfig.devServer?.client || {}),
+ overlay: false,
+ },
+ },
+ plugins: [...plugins, !production && new ReactRefreshWebpackPlugin({ overlay: false })].filter(Boolean),
+ };
};
diff --git a/packages/tools/testTools/src/visualizationUtils.ts b/packages/tools/testTools/src/visualizationUtils.ts
index b11221d05f6..77b5e999f63 100644
--- a/packages/tools/testTools/src/visualizationUtils.ts
+++ b/packages/tools/testTools/src/visualizationUtils.ts
@@ -96,7 +96,19 @@ export const evaluatePrepareScene = async (
const runSnippet = async function () {
const data = await fetch(globalConfig.snippetUrl + sceneMetadata.playgroundId!.replace(/#/g, "/"));
const snippet = await data.json();
- let code = JSON.parse(snippet.jsonPayload).code.toString();
+
+ const payload = JSON.parse(snippet.jsonPayload);
+ let code = "";
+ // Definitely v2 manifest
+ if (Object.prototype.hasOwnProperty.call(payload, "version")) {
+ const v2Manifest = JSON.parse(payload.code);
+ code = v2Manifest.files[v2Manifest.entry];
+ // Sanitize two common export types for existing and migrated PGs and newly-created PGs.
+ code = code.replace(/export default \w+/g, "").replace("export const ", "const ");
+ } else {
+ code = payload.code.toString();
+ }
+
code = code
.replace(/"\/textures\//g, '"' + globalConfig.pgRoot + "/textures/")
.replace(/"textures\//g, '"' + globalConfig.pgRoot + "/textures/")
diff --git a/packages/tools/tests/test/playwright/visualizationPlaywright.utils.ts b/packages/tools/tests/test/playwright/visualizationPlaywright.utils.ts
index 24969b47742..9d77f1d7b2f 100644
--- a/packages/tools/tests/test/playwright/visualizationPlaywright.utils.ts
+++ b/packages/tools/tests/test/playwright/visualizationPlaywright.utils.ts
@@ -296,7 +296,23 @@ export const evaluatePrepareScene = async ({
const runSnippet = async function () {
const data = await fetch(globalConfig.snippetUrl + sceneMetadata.playgroundId!.replace(/#/g, "/"));
const snippet = await data.json();
- let code = JSON.parse(snippet.jsonPayload).code.toString();
+ const payload = JSON.parse(snippet.jsonPayload);
+ let code = "";
+ // If payload.version, definitely v2 manifest
+ // This is intentionally constrained to the existing happy path of running vis tests
+ // Rules for V2 manifests assuming:
+ // - Single entry point
+ // - No relative/npm imports
+ // - JS only
+ if (Object.prototype.hasOwnProperty.call(payload, "version")) {
+ const v2Manifest = JSON.parse(payload.code);
+ code = v2Manifest.files[v2Manifest.entry];
+ // Sanitize two common export types for existing and migrated PGs and newly-created PGs.
+ code = code.replace(/export default \w+/g, "").replace("export const ", "const ");
+ } else {
+ code = payload.code.toString();
+ }
+
code = code
.replace(/("|')\/textures\//g, "$1" + globalConfig.pgRoot + "/textures/")
.replace(/("|')textures\//g, "$1" + globalConfig.pgRoot + "/textures/")
diff --git a/packages/tools/tests/test/visualization/ReferenceImages/Specular-Reflectance-with-IOR-V2-Manifest.png b/packages/tools/tests/test/visualization/ReferenceImages/Specular-Reflectance-with-IOR-V2-Manifest.png
new file mode 100644
index 00000000000..aa2c76b7d79
Binary files /dev/null and b/packages/tools/tests/test/visualization/ReferenceImages/Specular-Reflectance-with-IOR-V2-Manifest.png differ
diff --git a/packages/tools/tests/test/visualization/config.json b/packages/tools/tests/test/visualization/config.json
index b75aa7c3df8..21e44168572 100644
--- a/packages/tools/tests/test/visualization/config.json
+++ b/packages/tools/tests/test/visualization/config.json
@@ -2980,6 +2980,10 @@
"playgroundId": "#GRQHVV#78",
"excludedEngines": ["webgl1"]
},
+ {
+ "title": "Specular Reflectance with IOR V2 Manifest",
+ "playgroundId": "#KQYNYS#49"
+ },
{
"title": "OpenPBR Coat Roughness vs Coat Ior - Analytic Lights",
"playgroundId": "#GRQHVV#80",