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.
master
a2nr 2026-04-09 09:44:49 +07:00
parent 2a386f99b0
commit ea82602102
7 changed files with 313 additions and 26 deletions

View File

@ -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

View File

@ -32,7 +32,7 @@ function App() {
}, []);
return (
<Router>
<Router basename={import.meta.env.BASE_URL.replace(/\/+$/, '') || '/'}>
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/editor" element={<EditorPage />} />

View File

@ -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 (
<div style={{ height: '100%', width: '100%' }}>
<Editor
@ -27,6 +52,7 @@ export const CodeEditor = () => {
onChange={(value) => {
if (activeFileId) setFileContent(activeFileId, value || '');
}}
onMount={handleEditorMount}
options={{
minimap: { enabled: true },
fontSize,

View File

@ -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<BoardKind, string> = {
'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<string, unknown>)?.rotation || 0,
props: Object.fromEntries(
Object.entries(c.properties as Record<string, unknown>).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<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!importInputRef.current) return;
@ -524,6 +564,7 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi
{overflowOpen && (
<div className="tb-overflow-menu">
{!isEmbedMode && (
<button
className="tb-overflow-item"
onClick={() => { importInputRef.current?.click(); setOverflowOpen(false); }}
@ -535,6 +576,7 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi
</svg>
Import zip
</button>
)}
<button
className="tb-overflow-item"
onClick={() => { handleExport(); setOverflowOpen(false); }}
@ -546,6 +588,23 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi
</svg>
Export zip
</button>
{!isEmbedMode && (
<>
<div style={{ borderTop: '1px solid #444', margin: '4px 0' }} />
<button
className="tb-overflow-item"
onClick={() => { handleExportCircuitJSON(); setOverflowOpen(false); }}
>
📋 Export Circuit JSON
</button>
<button
className="tb-overflow-item"
onClick={() => { handleExportExpectedWiring(); setOverflowOpen(false); }}
>
📋 Export Expected Wiring
</button>
</>
)}
</div>
)}
</div>

View File

@ -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<HTMLDivElement>(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 (
<div className="app">
<AppHeader />
{!embedMode.enabled && <AppHeader />}
{/* ── Mobile tab bar (top, above panels) ── */}
{isMobile && (
{isMobile && !(embedMode.enabled && embedMode.hideEditor) && (
<nav className="mobile-tab-bar">
<button
className={`mobile-tab-btn${mobileView === 'code' ? ' mobile-tab-btn--active' : ''}`}
@ -266,8 +290,8 @@ export const EditorPage: React.FC = () => {
<div
className="editor-panel"
style={{
width: isMobile ? '100%' : `${editorWidthPct}%`,
display: isMobile && mobileView !== 'code' ? 'none' : 'flex',
width: (embedMode.enabled && embedMode.hideEditor) ? '0%' : isMobile ? '100%' : `${editorWidthPct}%`,
display: (embedMode.enabled && embedMode.hideEditor) ? 'none' : isMobile && mobileView !== 'code' ? 'none' : 'flex',
flexDirection: 'row',
}}
>
@ -341,8 +365,8 @@ export const EditorPage: React.FC = () => {
</div>
</div>
{/* Resize handle (desktop only) */}
{!isMobile && (
{/* Resize handle (desktop only, hidden in embed hideEditor mode) */}
{!isMobile && !(embedMode.enabled && embedMode.hideEditor) && (
<div className="resize-handle" onMouseDown={handleResizeMouseDown}>
<div className="resize-handle-grip" />
</div>
@ -352,7 +376,7 @@ export const EditorPage: React.FC = () => {
<div
className="simulator-panel"
style={{
width: isMobile ? '100%' : `${100 - editorWidthPct}%`,
width: (embedMode.enabled && embedMode.hideEditor) ? '100%' : isMobile ? '100%' : `${100 - editorWidthPct}%`,
display: isMobile && mobileView !== 'circuit' ? 'none' : 'flex',
flexDirection: 'column',
}}
@ -387,9 +411,9 @@ export const EditorPage: React.FC = () => {
</div>
</div>
{saveModalOpen && <SaveProjectModal onClose={() => setSaveModalOpen(false)} />}
{loginPromptOpen && <LoginPromptModal onClose={() => setLoginPromptOpen(false)} />}
{showStarBanner && <GitHubStarBanner onClose={handleDismissStarBanner} />}
{!embedMode.enabled && saveModalOpen && <SaveProjectModal onClose={() => setSaveModalOpen(false)} />}
{!embedMode.enabled && loginPromptOpen && <LoginPromptModal onClose={() => setLoginPromptOpen(false)} />}
{!embedMode.enabled && showStarBanner && <GitHubStarBanner onClose={handleDismissStarBanner} />}
</div>
);
};

View File

@ -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<typeof setInterval> | 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<string, unknown> = {}) {
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<string, unknown>;
}>;
wires?: Array<unknown>;
};
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;

View File

@ -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: {