playground: basic editor functionality ready

This commit is contained in:
Yeicor
2025-07-19 21:49:02 +02:00
parent 667a08d2c6
commit fc32393635
10 changed files with 1115 additions and 380 deletions

View File

@@ -0,0 +1,195 @@
<script setup lang="ts">
import {setupMonaco} from "./monaco.ts";
import {VueMonacoEditor} from '@guolao/vue-monaco-editor'
import {ref, shallowRef} from "vue";
import Loading from "../misc/Loading.vue";
import {asyncRun, pyodideWorker, resetState} from "./pyodide-worker-api.ts";
import {mdiCircleOpacity, mdiClose, mdiLockReset, mdiPlay, mdiReload, mdiRun} from "@mdi/js";
import {VBtn, VCard, VCardText, VSlider, VSpacer, VToolbar, VToolbarTitle} from "vuetify/components";
import SvgIcon from '@jamescoyle/vue-icon';
const props = defineProps<{ initialCode: string }>();
const emit = defineEmits<{ (e: 'close'): void }>()
// ============ LOAD MONACO EDITOR ============
setupMonaco() // Must be called before using the editor
const code = ref(props.initialCode); // TODO: Default code as input (and autorun!)
const outputText = ref(``);
function output(text: string) {
outputText.value += text; // Append to output
console.log(text); // Also log to console
setTimeout(() => { // Scroll to bottom? TODO: Test
const consoleElement = document.querySelector('.playground-console pre');
if (consoleElement) {
consoleElement.scrollTop = consoleElement.scrollHeight;
}
}, 0);
}
const MONACO_EDITOR_OPTIONS = {
automaticLayout: true,
formatOnType: true,
formatOnPaste: true,
}
const editorTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? `vs-dark` : `vs`
const editor = shallowRef()
const handleMount = (editorInstance: typeof VueMonacoEditor) => (editor.value = editorInstance)
const opacity = ref(0.9); // Opacity for the editor
// ============ LOAD PYODIDE (ASYNC) ============
const running = ref(true);
async function setupPyodide() {
if (opacity.value == 0.0) opacity.value = 0.9; // User doesn't know how to show code again, reset after reopening
let firstTime = pyodideWorker === null;
if (firstTime) {
resetState();
output("Loading packages...\n");
await asyncRun(`import micropip, asyncio
micropip.set_index_urls(["https://yeicor.github.io/OCP.wasm", "https://pypi.org/simple"])
await (micropip.install("lib3mf"))
micropip.add_mock_package("py-lib3mf", "2.4.1", modules={"py_lib3mf": '''from lib3mf import *'''})
await (micropip.install(["build123d"]))`, output);
output("Preimporting build123d...\n");
await asyncRun(`import build123d`);
if (props.initialCode != "") {
await runCode();
opacity.value = 0.0; // Hide editor after running initial code
} else {
output("Ready for custom code.\n");
}
} else {
output("Reusing existing Pyodide instance...\n");
}
running.value = false; // Indicate that Pyodide is ready
}
setupPyodide()
async function runCode() {
output("Running code...\n");
try {
running.value = true;
await asyncRun(code.value, output);
} catch (e) {
output(`Error running initial code: ${e}\n`);
} finally {
running.value = false; // Indicate that Pyodide is ready
}
}
</script>
<template>
<v-card class="popup-card"
:style="opacity == 0 ? `position: absolute; top: calc(-50vh + 24px); width: calc(100vw - 64px);` : ``">
<v-toolbar class="popup">
<v-toolbar-title>Playground</v-toolbar-title>
<v-spacer></v-spacer>
<svg-icon :path="mdiCircleOpacity" type="mdi"></svg-icon>
<v-slider v-model="opacity" :max="1" :min="0" :step="0.1"
style="max-width: 100px; height: 32px; margin-right: 16px;"></v-slider>
<!-- TODO: snapshots... -->
<v-btn icon @click="resetState()">
<svg-icon :path="mdiReload" type="mdi"/>
</v-btn>
<v-btn icon @click="runCode()">
<svg-icon :path="mdiPlay" type="mdi"/>
</v-btn>
<v-btn icon @click="emit('close')">
<svg-icon :path="mdiClose" type="mdi"/>
</v-btn>
</v-toolbar>
<v-card-text class="popup-card-text" :style="opacity == 0 ? `display: none` : ``">
<!-- Only show content if opacity is greater than 0 -->
<div class="playground-container">
<div class="playground-editor">
<VueMonacoEditor
v-model:value="code"
:theme="editorTheme"
:options="MONACO_EDITOR_OPTIONS"
language="python"
@mount="handleMount"
/>
</div>
<div class="playground-console">
<h3>Console Output</h3>
<pre>{{ outputText }}</pre> <!-- Placeholder for console output -->
<Loading v-if="running"/>
</div>
</div>
</v-card-text>
</v-card>
</template>
<style scoped>
.popup-card {
background-color: #00000000; /* Transparent background */
}
.popup-card-text {
background-color: #1e1e1e; /* Matches the Monaco editor background */
opacity: v-bind(opacity);
}
.playground-container {
display: flex;
flex-direction: row;
}
.playground-editor {
flex: 1;
height: calc(100vh - 150px);
}
.playground-console {
flex: 0.5;
padding: 10px;
overflow-y: auto;
min-width: 100px;
height: calc(100vh - 150px);
}
.playground-console pre {
margin: 0;
white-space: pre-wrap;
word-break: break-all;
}
@media (min-height: 100vw) {
/* Adjust layout for vertical space */
.playground-container {
flex-direction: column;
}
.playground-editor {
flex: 1;
min-height: 60vh;
}
.playground-editor > * {
min-height: 60vh;
}
.playground-console {
max-height: calc(40vh - 150px);
}
}
/* TODO: Adjust more colors on bright mode */
</style>
<style>
/* https://stackoverflow.com/questions/47017753/monaco-editor-dynamically-resizable/71876526#71876526 */
.monaco-editor {
position: absolute !important;
}
</style>

View File

@@ -13,13 +13,14 @@ import {
import OrientationGizmo from "./OrientationGizmo.vue";
import type {PerspectiveCamera} from "three/src/cameras/PerspectiveCamera.js";
import {OrthographicCamera} from "three/src/cameras/OrthographicCamera.js";
import {mdiClose, mdiCrosshairsGps, mdiDownload, mdiGithub, mdiLicense, mdiProjector} from '@mdi/js'
import {mdiClose, mdiCrosshairsGps, mdiDownload, mdiGithub, mdiLicense, mdiProjector, mdiScriptTextPlay} from '@mdi/js'
import SvgIcon from '@jamescoyle/vue-icon';
import type {ModelViewerElement} from '@google/model-viewer';
import Loading from "../misc/Loading.vue";
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
import {defineAsyncComponent, ref, type Ref} from "vue";
import type {SelectionInfo} from "./selection";
import {settings} from "../misc/settings.ts";
const SelectionComponent = defineAsyncComponent({
loader: () => import("./Selection.vue"),
@@ -34,10 +35,23 @@ const LicensesDialogContent = defineAsyncComponent({
delay: 0,
});
const PlaygroundDialogContent = defineAsyncComponent({
loader: () => import("./PlaygroundDialogContent.vue"),
loadingComponent: Loading,
delay: 0,
});
let props = defineProps<{ viewer: InstanceType<typeof ModelViewerWrapper> | null }>();
const emit = defineEmits<{ findModel: [string] }>()
const sett = ref(null);
const showPlaygroundDialog = ref(false);
(async () => {
sett.value = await settings();
return showPlaygroundDialog.value = sett.value.code != "";
})();
let selection: Ref<Array<SelectionInfo>> = ref([]);
let selectionFaceCount = () => selection.value.filter((s) => s.kind == 'face').length
let selectionEdgeCount = () => selection.value.filter((s) => s.kind == 'edge').length
@@ -146,11 +160,23 @@ window.addEventListener('keydown', (event) => {
<v-divider/>
<v-spacer></v-spacer>
<h5>Extras</h5>
<v-dialog v-model="showPlaygroundDialog" persistent :scrim="false" attach="body">
<template v-slot:activator="{ props }">
<v-btn icon v-bind="props" style="width: 100%">
<v-tooltip activator="parent">Open a python editor and build models directly in the browser!</v-tooltip>
<svg-icon :path="mdiScriptTextPlay" type="mdi"/>
&nbsp;Sandbox
</v-btn>
</template>
<template v-slot:default="{ isActive }">
<playground-dialog-content v-if="sett != null" :initial-code="sett.code" @close="isActive.value = false"/>
</template>
</v-dialog>
<v-btn icon @click="downloadSceneGlb">
<v-tooltip activator="parent">(D)ownload Scene</v-tooltip>
<svg-icon :path="mdiDownload" type="mdi"/>
</v-btn>
<v-dialog id="licenses-dialog" fullscreen>
<v-dialog>
<template v-slot:activator="{ props }">
<v-btn icon v-bind="props">
<v-tooltip activator="parent">Show Licenses</v-tooltip>
@@ -158,11 +184,10 @@ window.addEventListener('keydown', (event) => {
</v-btn>
</template>
<template v-slot:default="{ isActive }">
<v-card>
<v-toolbar>
<v-card style="height: 90vh">
<v-toolbar class="popup">
<v-toolbar-title>Licenses</v-toolbar-title>
<v-spacer>
</v-spacer>
<v-spacer></v-spacer>
<v-btn icon @click="isActive.value = false">
<svg-icon :path="mdiClose" type="mdi"/>
</v-btn>
@@ -199,4 +224,17 @@ window.addEventListener('keydown', (event) => {
h5 {
font-size: 14px;
}
.v-toolbar {
position: sticky !important;
top: 0;
}
.v-toolbar.popup {
height: 32px;
}
.v-toolbar.popup > div {
height: 32px !important;
}
</style>

35
frontend/tools/monaco.ts Normal file
View File

@@ -0,0 +1,35 @@
import {loader} from "@guolao/vue-monaco-editor"
import * as monaco from "monaco-editor"
//@ts-ignore
import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker"
//@ts-ignore
import jsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker"
//@ts-ignore
import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker"
//@ts-ignore
import htmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker"
//@ts-ignore
import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker"
self.MonacoEnvironment = {
getWorker(_, label) {
if (label === "json") {
return new jsonWorker()
}
if (label === "css" || label === "scss" || label === "less") {
return new cssWorker()
}
if (label === "html" || label === "handlebars" || label === "razor") {
return new htmlWorker()
}
if (label === "typescript" || label === "javascript") {
return new tsWorker()
}
return new editorWorker()
}
}
export function setupMonaco() {
loader.config({monaco})
}

View File

@@ -0,0 +1,44 @@
// Each message needs a unique id to identify the response. In a real example,
// we might use a real uuid package
let lastId = 1;
function getId() {
return lastId++;
}
// Add an id to msg, send it to worker, then wait for a response with the same id.
// When we get such a response, use it to resolve the promise.
function requestResponse(worker: Worker, code: String, output: (msg: string) => void) {
return new Promise((resolve) => {
const idWorker = getId();
worker.addEventListener("message", function listener(event) {
if (event.data?.stdout) {
output(event.data.stdout + "\n");
return;
}
if (event.data?.stderr) {
output(event.data.stderr + "\n");
return;
}
if (event.data?.id !== idWorker) return;
// This listener is done so remove it.
worker.removeEventListener("message", listener);
// Filter the id out of the result
const {id, ...rest} = event.data;
resolve(rest);
});
worker.postMessage({id: idWorker, code});
});
}
export function asyncRun(code: String, output: (msg: string) => void) {
return requestResponse(pyodideWorker as Worker, code, output);
}
export function resetState() {
// Reset the worker state by terminating it and creating a new one.
if (pyodideWorker) pyodideWorker.terminate();
pyodideWorker = new Worker(new URL('./pyodide-worker.ts', import.meta.url), {type: "module"});
}
export let pyodideWorker: Worker | null = null;

View File

@@ -0,0 +1,27 @@
import {loadPyodide, version} from "pyodide";
let pyodideReadyPromise = loadPyodide({
indexURL: `https://cdn.jsdelivr.net/pyodide/v${version}/full/`, // FIXME: Local deployment?
packages: ["micropip", "sqlite3"], // Preloaded faster here...
stdout: (msg) => self.postMessage({stdout: msg}),
stderr: (msg) => self.postMessage({stderr: msg}),
stdin: () => {
console.warn("Input requested by Python code, but stdin is not supported in this playground.");
return "";
},
});
self.onmessage = async (event) => {
// make sure loading is done
const pyodide = await pyodideReadyPromise;
const {id, code} = event.data;
// Now load any packages we need, run the code, and send the result back.
await pyodide.loadPackagesFromImports(code);
try {
// Execute the python code in this context
const result = await pyodide.runPythonAsync(code);
self.postMessage({result, id});
} catch (error: any) {
self.postMessage({error: error.message, id});
}
};