From ea826021029b020d8d1bd44a6c7ef2c389936853 Mon Sep 17 00:00:00 2001 From: a2nr Date: Thu, 9 Apr 2026 09:44:49 +0700 Subject: [PATCH] feat: add EmbedBridge PostMessage protocol for LMS iframe integration Implement bidirectional PostMessage bridge (EmbedBridge.ts) enabling Velxio to be embedded as iframe in Elemes LMS. Supports: load_code, load_circuit, get_source_code, get_serial_log, get_wires, set_embed_mode, stop, ping. EditorPage/Toolbar hide auth UI in embed mode. --- Dockerfile.standalone | 11 +- frontend/src/App.tsx | 2 +- frontend/src/components/editor/CodeEditor.tsx | 28 ++- .../src/components/editor/EditorToolbar.tsx | 81 +++++++-- frontend/src/pages/EditorPage.tsx | 48 +++-- frontend/src/services/EmbedBridge.ts | 168 ++++++++++++++++++ frontend/vite.config.ts | 1 + 7 files changed, 313 insertions(+), 26 deletions(-) create mode 100644 frontend/src/services/EmbedBridge.ts diff --git a/Dockerfile.standalone b/Dockerfile.standalone index b40262d..bff0802 100644 --- a/Dockerfile.standalone +++ b/Dockerfile.standalone @@ -82,6 +82,14 @@ RUN npm install && npm run build --if-present # Build frontend # components-metadata.json is already committed; skip generate:metadata # (it requires wokwi-elements/src which isn't needed at runtime) +# +# VITE_BASE_PATH: sub-path prefix for assets (default "/" for standalone) +# VITE_API_BASE: API endpoint prefix (default "/api" for standalone) +ARG VITE_BASE_PATH=/ +ARG VITE_API_BASE=/api +ENV VITE_BASE_PATH=${VITE_BASE_PATH} +ENV VITE_API_BASE=${VITE_API_BASE} + WORKDIR /app COPY frontend/ frontend/ WORKDIR /app/frontend @@ -130,7 +138,8 @@ RUN pip install --no-cache-dir -r requirements.txt # Copy backend application code COPY backend/app/ ./app/ -# Setup Nginx configuration +# Setup Nginx configuration — remove default site so our config is the only server +RUN rm -f /etc/nginx/sites-enabled/default COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf # Copy built frontend assets from builder stage diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 58bb138..594b54d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -32,7 +32,7 @@ function App() { }, []); return ( - + } /> } /> diff --git a/frontend/src/components/editor/CodeEditor.tsx b/frontend/src/components/editor/CodeEditor.tsx index a430522..1c73297 100644 --- a/frontend/src/components/editor/CodeEditor.tsx +++ b/frontend/src/components/editor/CodeEditor.tsx @@ -1,5 +1,8 @@ -import Editor from '@monaco-editor/react'; +import Editor, { type OnMount } from '@monaco-editor/react'; import { useEditorStore } from '../../store/useEditorStore'; +import { useCallback } from 'react'; + +const isEmbedMode = typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('embed') === 'true'; function getLanguage(filename: string): string { const ext = filename.split('.').pop()?.toLowerCase() ?? ''; @@ -15,6 +18,28 @@ export const CodeEditor = () => { useEditorStore(); const activeFile = files.find((f) => f.id === activeFileId); + // In embed mode (LMS exercises), disable copy-paste so students type code themselves + const handleEditorMount: OnMount = useCallback((editor, monacoInstance) => { + if (!isEmbedMode) return; + + // Disable paste action + editor.addCommand( + // eslint-disable-next-line no-bitwise + monacoInstance.KeyMod.CtrlCmd | monacoInstance.KeyCode.KeyV, + () => { /* noop — paste disabled */ }, + ); + + // Disable cut (prevents copy-via-cut workaround) + editor.addCommand( + // eslint-disable-next-line no-bitwise + monacoInstance.KeyMod.CtrlCmd | monacoInstance.KeyCode.KeyX, + () => { /* noop — cut disabled */ }, + ); + + // Disable context menu paste via DOM event + editor.getDomNode()?.addEventListener('paste', (e) => e.preventDefault()); + }, []); + return (
{ onChange={(value) => { if (activeFileId) setFileContent(activeFileId, value || ''); }} + onMount={handleEditorMount} options={{ minimap: { enabled: true }, fontSize, diff --git a/frontend/src/components/editor/EditorToolbar.tsx b/frontend/src/components/editor/EditorToolbar.tsx index 966e448..2d11949 100644 --- a/frontend/src/components/editor/EditorToolbar.tsx +++ b/frontend/src/components/editor/EditorToolbar.tsx @@ -12,6 +12,7 @@ import { parseCompileResult } from '../../utils/compilationLogger'; import type { CompilationLog } from '../../utils/compilationLogger'; import { exportToWokwiZip, importFromWokwiZip } from '../../utils/wokwiZip'; import { trackCompileCode, trackRunSimulation, trackStopSimulation, trackResetSimulation, trackOpenLibraryManager } from '../../utils/analytics'; +import { embedBridge } from '../../services/EmbedBridge'; import './EditorToolbar.css'; interface EditorToolbarProps { @@ -43,6 +44,8 @@ const BOARD_PILL_COLOR: Record = { 'esp32-c3': '#a5d6a7', }; +const isEmbedMode = new URLSearchParams(window.location.search).get('embed') === 'true'; + export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compileLogs, setCompileLogs }: EditorToolbarProps) => { const { files, codeChangedSinceLastCompile, markCompiled } = useEditorStore(); const { @@ -143,6 +146,7 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi setMessage({ type: 'success', text: 'Compiled successfully' }); markCompiled(); setMissingLibHint(false); + embedBridge.notifyCompileResult(true); } else { const errText = result.error || result.stderr || 'Compile failed'; setMessage({ type: 'error', text: errText }); @@ -324,6 +328,42 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi } }; + /** Export circuit as JSON for Elemes lesson markdown (VELXIO_CIRCUIT block). */ + const handleExportCircuitJSON = () => { + const { components, wires, boards: allBoards, activeBoardId: bid } = useSimulatorStore.getState(); + const board = allBoards.find((b) => b.id === bid); + const circuitData = { + board: board ? BOARD_KIND_FQBN[board.boardKind] || board.boardKind : 'arduino:avr:uno', + components: components.map((c) => ({ + type: c.metadataId, + id: c.id, + x: c.x, + y: c.y, + rotation: (c.properties as Record)?.rotation || 0, + props: Object.fromEntries( + Object.entries(c.properties as Record).filter(([k]) => k !== 'rotation') + ), + })), + wires: wires.map((w) => ({ + start: { componentId: w.start.componentId, pinName: w.start.pinName }, + end: { componentId: w.end.componentId, pinName: w.end.pinName }, + })), + }; + navigator.clipboard.writeText(JSON.stringify(circuitData, null, 2)); + setMessage({ type: 'success', text: 'Circuit JSON copied!' }); + }; + + /** Export expected wiring as JSON for Elemes lesson markdown (EXPECTED_WIRING block). */ + const handleExportExpectedWiring = () => { + const { wires } = useSimulatorStore.getState(); + const wiringArray = wires.map((w) => [ + `${w.start.componentId}:${w.start.pinName}`, + `${w.end.componentId}:${w.end.pinName}`, + ]); + navigator.clipboard.writeText(JSON.stringify(wiringArray, null, 2)); + setMessage({ type: 'success', text: 'Expected wiring JSON copied!' }); + }; + const handleImportFile = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!importInputRef.current) return; @@ -524,17 +564,19 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi {overflowOpen && (
- + {!isEmbedMode && ( + + )} + {!isEmbedMode && ( + <> +
+ + + + )}
)}
diff --git a/frontend/src/pages/EditorPage.tsx b/frontend/src/pages/EditorPage.tsx index 7543bc4..69b92c1 100644 --- a/frontend/src/pages/EditorPage.tsx +++ b/frontend/src/pages/EditorPage.tsx @@ -8,6 +8,7 @@ import { CodeEditor } from '../components/editor/CodeEditor'; import { EditorToolbar } from '../components/editor/EditorToolbar'; import { FileTabs } from '../components/editor/FileTabs'; import { FileExplorer } from '../components/editor/FileExplorer'; +import { embedBridge } from '../services/EmbedBridge'; // Lazy-load Pi workspace so xterm.js isn't in the main bundle const RaspberryPiWorkspace = lazy(() => @@ -54,6 +55,29 @@ export const EditorPage: React.FC = () => { url: 'https://velxio.dev/editor', }); + // Embed mode: detect ?embed=true and optional flags + const [embedMode] = useState(() => { + const params = new URLSearchParams(window.location.search); + return { + enabled: params.get('embed') === 'true', + hideEditor: params.get('hideEditor') === 'true', + }; + }); + const [embedHideComponentPicker, setEmbedHideComponentPicker] = useState(false); + + // Listen for embed mode commands from parent + useEffect(() => { + if (!embedMode.enabled) return; + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail || {}; + if (detail.hideComponentPicker) setEmbedHideComponentPicker(true); + }; + window.addEventListener('velxio-embed-mode', handler); + // Notify parent that Velxio is ready + embedBridge.notifyReady(); + return () => window.removeEventListener('velxio-embed-mode', handler); + }, [embedMode.enabled]); + const [editorWidthPct, setEditorWidthPct] = useState(45); const containerRef = useRef(null); const resizingRef = useRef(false); @@ -112,8 +136,8 @@ export const EditorPage: React.FC = () => { const [explorerOpen, setExplorerOpen] = useState(true); const [explorerWidth, setExplorerWidth] = useState(EXPLORER_DEFAULT); const [isMobile, setIsMobile] = useState(() => window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`).matches); - // Default to 'code' on mobile — show the editor so users can write/view code - const [mobileView, setMobileView] = useState<'code' | 'circuit'>('code'); + // Embed mode on mobile: show circuit first (student needs to wire); standalone: show code + const [mobileView, setMobileView] = useState<'code' | 'circuit'>(embedMode.enabled ? 'circuit' : 'code'); const user = useAuthStore((s) => s.user); const handleSaveClick = useCallback(() => { @@ -231,10 +255,10 @@ export const EditorPage: React.FC = () => { return (
- + {!embedMode.enabled && } {/* ── Mobile tab bar (top, above panels) ── */} - {isMobile && ( + {isMobile && !(embedMode.enabled && embedMode.hideEditor) && (
- {/* Resize handle (desktop only) */} - {!isMobile && ( + {/* Resize handle (desktop only, hidden in embed hideEditor mode) */} + {!isMobile && !(embedMode.enabled && embedMode.hideEditor) && (
@@ -352,7 +376,7 @@ export const EditorPage: React.FC = () => {
{
- {saveModalOpen && setSaveModalOpen(false)} />} - {loginPromptOpen && setLoginPromptOpen(false)} />} - {showStarBanner && } + {!embedMode.enabled && saveModalOpen && setSaveModalOpen(false)} />} + {!embedMode.enabled && loginPromptOpen && setLoginPromptOpen(false)} />} + {!embedMode.enabled && showStarBanner && }
); }; diff --git a/frontend/src/services/EmbedBridge.ts b/frontend/src/services/EmbedBridge.ts new file mode 100644 index 0000000..d6e62bf --- /dev/null +++ b/frontend/src/services/EmbedBridge.ts @@ -0,0 +1,168 @@ +/** + * EmbedBridge — PostMessage bridge for Elemes LMS integration. + * + * When Velxio is loaded in an iframe with ?embed=true, this bridge + * listens for commands from the parent (Elemes) and responds with + * simulator state (source code, serial log, wires). + */ + +import { useEditorStore } from '../store/useEditorStore'; +import { useSimulatorStore } from '../store/useSimulatorStore'; + +class EmbedBridge { + private isEmbedded: boolean; + + constructor() { + this.isEmbedded = window.parent !== window; + if (this.isEmbedded) { + window.addEventListener('message', this.onMessage.bind(this)); + } + } + + private _readyInterval: ReturnType | null = null; + + /** Call after Velxio is fully loaded. Repeats until parent acknowledges. */ + notifyReady() { + this.send('velxio:ready', { version: '1.0' }); + // Keep sending until parent acknowledges (handles race with listener setup) + this._readyInterval = setInterval(() => { + this.send('velxio:ready', { version: '1.0' }); + }, 300); + } + + /** Notify parent that compilation finished. */ + notifyCompileResult(success: boolean) { + this.send('velxio:compile_result', { success }); + } + + /** Parent received ready — stop broadcasting. */ + private stopReadyBroadcast() { + if (this._readyInterval) { + clearInterval(this._readyInterval); + this._readyInterval = null; + } + } + + private send(type: string, payload: Record = {}) { + if (!this.isEmbedded) return; + window.parent.postMessage({ type, ...payload }, '*'); + } + + private onMessage(event: MessageEvent) { + const { type } = event.data || {}; + if (!type?.startsWith('elemes:')) return; + + // Any message from parent means it's listening — stop ready broadcast + this.stopReadyBroadcast(); + + switch (type) { + case 'elemes:load_code': { + const files = (event.data.files as { name: string; content: string }[]) || []; + useEditorStore.getState().loadFiles(files); + break; + } + + case 'elemes:load_circuit': { + const data = event.data as { + board?: string; + components?: Array<{ + type: string; + id: string; + x: number; + y: number; + rotation?: number; + props?: Record; + }>; + wires?: Array; + }; + + const store = useSimulatorStore.getState(); + + // Set components if provided + if (data.components) { + const mapped = data.components.map((c) => ({ + id: c.id, + metadataId: c.type, + x: c.x, + y: c.y, + properties: { ...(c.props || {}), rotation: c.rotation || 0 }, + })); + store.setComponents(mapped); + } + + // Set wires if provided + if (data.wires && Array.isArray(data.wires)) { + store.setWires(data.wires as never[]); + } + break; + } + + case 'elemes:get_source_code': { + const files = useEditorStore.getState().files; + const payload = { + files: files.map((f) => ({ name: f.name, content: f.content })), + }; + console.log('[EmbedBridge] Responding to get_source_code:', payload.files.length, 'files'); + this.send('velxio:source_code', payload); + break; + } + + case 'elemes:get_serial_log': { + const state = useSimulatorStore.getState(); + // Read from active board's serialOutput + const activeBoard = state.boards.find((b) => b.id === state.activeBoardId); + const log = activeBoard?.serialOutput ?? state.serialOutput ?? ''; + console.log('[EmbedBridge] Responding to get_serial_log:', JSON.stringify(log).substring(0, 200)); + console.log('[EmbedBridge] activeBoardId:', state.activeBoardId, 'boards:', state.boards.length); + this.send('velxio:serial_log', { log }); + break; + } + + case 'elemes:get_wires': { + const wires = useSimulatorStore.getState().wires; + console.log('[EmbedBridge] Responding to get_wires:', wires.length, 'wires'); + wires.forEach((w, i) => { + console.log(`[EmbedBridge] wire[${i}]: ${w.start.componentId}:${w.start.pinName} → ${w.end.componentId}:${w.end.pinName}`); + }); + this.send('velxio:wires', { wires }); + break; + } + + case 'elemes:set_embed_mode': { + window.dispatchEvent( + new CustomEvent('velxio-embed-mode', { detail: event.data }) + ); + break; + } + + case 'elemes:ping': { + // Parent missed the initial ready signal — re-send it + this.notifyReady(); + break; + } + + case 'elemes:compile_and_run': { + // Could trigger compile+run programmatically — future enhancement + break; + } + + case 'elemes:stop': { + const store = useSimulatorStore.getState(); + if (store.activeBoardId) { + store.stopBoard(store.activeBoardId); + } else { + store.stopSimulation(); + } + break; + } + } + } +} + +/** Singleton — created once when module loads. */ +export const embedBridge = new EmbedBridge(); + +// Expose Zustand stores on window so parent iframe (same-origin) can access them directly. +// This is a fallback for when PostMessage bridge doesn't connect. +(window as any).__VELXIO_EDITOR_STORE__ = useEditorStore; +(window as any).__VELXIO_SIMULATOR_STORE__ = useSimulatorStore; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index fafeaad..65ec604 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -4,6 +4,7 @@ import path from 'path' // https://vite.dev/config/ export default defineConfig({ + base: process.env.VITE_BASE_PATH || '/', plugins: [react()], resolve: { alias: {