programmierhub/public/js/ide.js
Stefan Franke 502131f07c VR ProgrammierHub — begehbarer 3D-Lernraum für hybride Programmier-Lehre
Erste lauffähige Fassung: 3D-Raum, Echtzeit-Multiplayer (WebSocket),
Python-IDE via Pyodide im Browser, eigener Arbeitsplatz mit Live-Code-Monitor.
Modularer, self-hostbarer Aufbau (Three.js + Node/Express + ws), ohne CDN-
Abhängigkeit zur Laufzeit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 13:35:11 +00:00

110 lines
4 KiB
JavaScript

// ide.js — 2D-IDE-Overlay über der 3D-Szene: Python-Editor + Ausführung via Pyodide.
//
// Bewusst als DOM-Overlay (nicht auf eine 3D-Textur gerendert): scharfer Text, freie Maus,
// echtes Copy/Paste/Scrollen. Pyodide (CPython als WASM) läuft komplett im Browser —
// null Server-Last, keine Sandbox nötig (siehe Spec Abschnitt 7, Variante A).
// Editor ist zunächst ein Textarea; Upgrade auf CodeMirror (lokal gehostet) später.
import { loadPyodide } from '/vendor/pyodide/pyodide.mjs';
export class IDE {
constructor() {
this.root = document.getElementById('ide');
this.editor = document.getElementById('ide-editor');
this.output = document.getElementById('ide-output');
this.runBtn = document.getElementById('ide-run');
this.statusEl = document.getElementById('ide-status');
this.isOpen = false;
this.pyodide = null;
this._loading = null; // Promise, solange Pyodide lädt
this._openCb = null;
this._closeCb = null;
this._changeCb = null;
this._changeTimer = null;
document.getElementById('ide-close').addEventListener('click', () => this.close());
this.runBtn.addEventListener('click', () => this.run());
this.editor.addEventListener('keydown', (e) => {
if (e.key === 'Tab') { e.preventDefault(); insertText(this.editor, ' '); }
else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); this.run(); }
else if (e.key === 'Escape') this.close();
});
// Live-Änderungen (entprellt) an den Monitor melden.
this.editor.addEventListener('input', () => {
clearTimeout(this._changeTimer);
this._changeTimer = setTimeout(() => { if (this._changeCb) this._changeCb(this.editor.value); }, 250);
});
}
onOpen(cb) { this._openCb = cb; }
onClose(cb) { this._closeCb = cb; }
onChange(cb) { this._changeCb = cb; }
getCode() { return this.editor.value; }
open() {
if (this.isOpen) return;
this.isOpen = true;
this.root.style.display = 'flex';
this.editor.focus();
if (this._openCb) this._openCb();
}
close() {
if (!this.isOpen) return;
this.isOpen = false;
this.root.style.display = 'none';
if (this._closeCb) this._closeCb();
}
async run() {
let py;
try {
py = await this._ensurePyodide();
} catch (e) {
this._status('Python konnte nicht geladen werden', true);
this._append('\n[Laden fehlgeschlagen] ' + (e && e.message || e), true);
return;
}
this._clear();
py.setStdout({ batched: (s) => this._append(s) });
py.setStderr({ batched: (s) => this._append(s) });
this._status('läuft …');
try {
await py.runPythonAsync(this.editor.value);
this._status('fertig ✓');
} catch (e) {
this._append('\n' + (e && e.message ? e.message : e), true);
this._status('Fehler', true);
}
}
_ensurePyodide() {
if (this.pyodide) return Promise.resolve(this.pyodide);
if (!this._loading) {
this.runBtn.disabled = true;
this._status('Python wird geladen … (einmalig, ~10 MB)');
this._loading = loadPyodide({ indexURL: '/vendor/pyodide/' })
.then((py) => { this.pyodide = py; this.runBtn.disabled = false; this._status('bereit'); return py; })
.catch((e) => { this.runBtn.disabled = false; this._loading = null; throw e; });
}
return this._loading;
}
_clear() { this.output.textContent = ''; }
_append(text, isErr) {
const span = document.createElement('span');
if (isErr) span.className = 'err';
span.textContent = text;
this.output.appendChild(span);
this.output.scrollTop = this.output.scrollHeight;
}
_status(text, isErr) { this.statusEl.textContent = text; this.statusEl.classList.toggle('err', !!isErr); }
}
/** Fügt Text an der Cursorposition eines Textarea ein (für Tab-Einrückung). */
function insertText(ta, text) {
const start = ta.selectionStart, end = ta.selectionEnd;
ta.value = ta.value.slice(0, start) + text + ta.value.slice(end);
ta.selectionStart = ta.selectionEnd = start + text.length;
}