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
parent
2a386f99b0
commit
ea82602102
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue