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
|
# Build frontend
|
||||||
# components-metadata.json is already committed; skip generate:metadata
|
# components-metadata.json is already committed; skip generate:metadata
|
||||||
# (it requires wokwi-elements/src which isn't needed at runtime)
|
# (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
|
WORKDIR /app
|
||||||
COPY frontend/ frontend/
|
COPY frontend/ frontend/
|
||||||
WORKDIR /app/frontend
|
WORKDIR /app/frontend
|
||||||
|
|
@ -130,7 +138,8 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||||
# Copy backend application code
|
# Copy backend application code
|
||||||
COPY backend/app/ ./app/
|
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 deploy/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
# Copy built frontend assets from builder stage
|
# Copy built frontend assets from builder stage
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ function App() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Router>
|
<Router basename={import.meta.env.BASE_URL.replace(/\/+$/, '') || '/'}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<LandingPage />} />
|
<Route path="/" element={<LandingPage />} />
|
||||||
<Route path="/editor" element={<EditorPage />} />
|
<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 { 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 {
|
function getLanguage(filename: string): string {
|
||||||
const ext = filename.split('.').pop()?.toLowerCase() ?? '';
|
const ext = filename.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
|
@ -15,6 +18,28 @@ export const CodeEditor = () => {
|
||||||
useEditorStore();
|
useEditorStore();
|
||||||
const activeFile = files.find((f) => f.id === activeFileId);
|
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 (
|
return (
|
||||||
<div style={{ height: '100%', width: '100%' }}>
|
<div style={{ height: '100%', width: '100%' }}>
|
||||||
<Editor
|
<Editor
|
||||||
|
|
@ -27,6 +52,7 @@ export const CodeEditor = () => {
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
if (activeFileId) setFileContent(activeFileId, value || '');
|
if (activeFileId) setFileContent(activeFileId, value || '');
|
||||||
}}
|
}}
|
||||||
|
onMount={handleEditorMount}
|
||||||
options={{
|
options={{
|
||||||
minimap: { enabled: true },
|
minimap: { enabled: true },
|
||||||
fontSize,
|
fontSize,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { parseCompileResult } from '../../utils/compilationLogger';
|
||||||
import type { CompilationLog } from '../../utils/compilationLogger';
|
import type { CompilationLog } from '../../utils/compilationLogger';
|
||||||
import { exportToWokwiZip, importFromWokwiZip } from '../../utils/wokwiZip';
|
import { exportToWokwiZip, importFromWokwiZip } from '../../utils/wokwiZip';
|
||||||
import { trackCompileCode, trackRunSimulation, trackStopSimulation, trackResetSimulation, trackOpenLibraryManager } from '../../utils/analytics';
|
import { trackCompileCode, trackRunSimulation, trackStopSimulation, trackResetSimulation, trackOpenLibraryManager } from '../../utils/analytics';
|
||||||
|
import { embedBridge } from '../../services/EmbedBridge';
|
||||||
import './EditorToolbar.css';
|
import './EditorToolbar.css';
|
||||||
|
|
||||||
interface EditorToolbarProps {
|
interface EditorToolbarProps {
|
||||||
|
|
@ -43,6 +44,8 @@ const BOARD_PILL_COLOR: Record<BoardKind, string> = {
|
||||||
'esp32-c3': '#a5d6a7',
|
'esp32-c3': '#a5d6a7',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isEmbedMode = new URLSearchParams(window.location.search).get('embed') === 'true';
|
||||||
|
|
||||||
export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compileLogs, setCompileLogs }: EditorToolbarProps) => {
|
export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compileLogs, setCompileLogs }: EditorToolbarProps) => {
|
||||||
const { files, codeChangedSinceLastCompile, markCompiled } = useEditorStore();
|
const { files, codeChangedSinceLastCompile, markCompiled } = useEditorStore();
|
||||||
const {
|
const {
|
||||||
|
|
@ -143,6 +146,7 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi
|
||||||
setMessage({ type: 'success', text: 'Compiled successfully' });
|
setMessage({ type: 'success', text: 'Compiled successfully' });
|
||||||
markCompiled();
|
markCompiled();
|
||||||
setMissingLibHint(false);
|
setMissingLibHint(false);
|
||||||
|
embedBridge.notifyCompileResult(true);
|
||||||
} else {
|
} else {
|
||||||
const errText = result.error || result.stderr || 'Compile failed';
|
const errText = result.error || result.stderr || 'Compile failed';
|
||||||
setMessage({ type: 'error', text: errText });
|
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 handleImportFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!importInputRef.current) return;
|
if (!importInputRef.current) return;
|
||||||
|
|
@ -524,6 +564,7 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi
|
||||||
|
|
||||||
{overflowOpen && (
|
{overflowOpen && (
|
||||||
<div className="tb-overflow-menu">
|
<div className="tb-overflow-menu">
|
||||||
|
{!isEmbedMode && (
|
||||||
<button
|
<button
|
||||||
className="tb-overflow-item"
|
className="tb-overflow-item"
|
||||||
onClick={() => { importInputRef.current?.click(); setOverflowOpen(false); }}
|
onClick={() => { importInputRef.current?.click(); setOverflowOpen(false); }}
|
||||||
|
|
@ -535,6 +576,7 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi
|
||||||
</svg>
|
</svg>
|
||||||
Import zip
|
Import zip
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="tb-overflow-item"
|
className="tb-overflow-item"
|
||||||
onClick={() => { handleExport(); setOverflowOpen(false); }}
|
onClick={() => { handleExport(); setOverflowOpen(false); }}
|
||||||
|
|
@ -546,6 +588,23 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi
|
||||||
</svg>
|
</svg>
|
||||||
Export zip
|
Export zip
|
||||||
</button>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { CodeEditor } from '../components/editor/CodeEditor';
|
||||||
import { EditorToolbar } from '../components/editor/EditorToolbar';
|
import { EditorToolbar } from '../components/editor/EditorToolbar';
|
||||||
import { FileTabs } from '../components/editor/FileTabs';
|
import { FileTabs } from '../components/editor/FileTabs';
|
||||||
import { FileExplorer } from '../components/editor/FileExplorer';
|
import { FileExplorer } from '../components/editor/FileExplorer';
|
||||||
|
import { embedBridge } from '../services/EmbedBridge';
|
||||||
|
|
||||||
// Lazy-load Pi workspace so xterm.js isn't in the main bundle
|
// Lazy-load Pi workspace so xterm.js isn't in the main bundle
|
||||||
const RaspberryPiWorkspace = lazy(() =>
|
const RaspberryPiWorkspace = lazy(() =>
|
||||||
|
|
@ -54,6 +55,29 @@ export const EditorPage: React.FC = () => {
|
||||||
url: 'https://velxio.dev/editor',
|
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 [editorWidthPct, setEditorWidthPct] = useState(45);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const resizingRef = useRef(false);
|
const resizingRef = useRef(false);
|
||||||
|
|
@ -112,8 +136,8 @@ export const EditorPage: React.FC = () => {
|
||||||
const [explorerOpen, setExplorerOpen] = useState(true);
|
const [explorerOpen, setExplorerOpen] = useState(true);
|
||||||
const [explorerWidth, setExplorerWidth] = useState(EXPLORER_DEFAULT);
|
const [explorerWidth, setExplorerWidth] = useState(EXPLORER_DEFAULT);
|
||||||
const [isMobile, setIsMobile] = useState(() => window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`).matches);
|
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
|
// Embed mode on mobile: show circuit first (student needs to wire); standalone: show code
|
||||||
const [mobileView, setMobileView] = useState<'code' | 'circuit'>('code');
|
const [mobileView, setMobileView] = useState<'code' | 'circuit'>(embedMode.enabled ? 'circuit' : 'code');
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
|
|
||||||
const handleSaveClick = useCallback(() => {
|
const handleSaveClick = useCallback(() => {
|
||||||
|
|
@ -231,10 +255,10 @@ export const EditorPage: React.FC = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<AppHeader />
|
{!embedMode.enabled && <AppHeader />}
|
||||||
|
|
||||||
{/* ── Mobile tab bar (top, above panels) ── */}
|
{/* ── Mobile tab bar (top, above panels) ── */}
|
||||||
{isMobile && (
|
{isMobile && !(embedMode.enabled && embedMode.hideEditor) && (
|
||||||
<nav className="mobile-tab-bar">
|
<nav className="mobile-tab-bar">
|
||||||
<button
|
<button
|
||||||
className={`mobile-tab-btn${mobileView === 'code' ? ' mobile-tab-btn--active' : ''}`}
|
className={`mobile-tab-btn${mobileView === 'code' ? ' mobile-tab-btn--active' : ''}`}
|
||||||
|
|
@ -266,8 +290,8 @@ export const EditorPage: React.FC = () => {
|
||||||
<div
|
<div
|
||||||
className="editor-panel"
|
className="editor-panel"
|
||||||
style={{
|
style={{
|
||||||
width: isMobile ? '100%' : `${editorWidthPct}%`,
|
width: (embedMode.enabled && embedMode.hideEditor) ? '0%' : isMobile ? '100%' : `${editorWidthPct}%`,
|
||||||
display: isMobile && mobileView !== 'code' ? 'none' : 'flex',
|
display: (embedMode.enabled && embedMode.hideEditor) ? 'none' : isMobile && mobileView !== 'code' ? 'none' : 'flex',
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -341,8 +365,8 @@ export const EditorPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Resize handle (desktop only) */}
|
{/* Resize handle (desktop only, hidden in embed hideEditor mode) */}
|
||||||
{!isMobile && (
|
{!isMobile && !(embedMode.enabled && embedMode.hideEditor) && (
|
||||||
<div className="resize-handle" onMouseDown={handleResizeMouseDown}>
|
<div className="resize-handle" onMouseDown={handleResizeMouseDown}>
|
||||||
<div className="resize-handle-grip" />
|
<div className="resize-handle-grip" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -352,7 +376,7 @@ export const EditorPage: React.FC = () => {
|
||||||
<div
|
<div
|
||||||
className="simulator-panel"
|
className="simulator-panel"
|
||||||
style={{
|
style={{
|
||||||
width: isMobile ? '100%' : `${100 - editorWidthPct}%`,
|
width: (embedMode.enabled && embedMode.hideEditor) ? '100%' : isMobile ? '100%' : `${100 - editorWidthPct}%`,
|
||||||
display: isMobile && mobileView !== 'circuit' ? 'none' : 'flex',
|
display: isMobile && mobileView !== 'circuit' ? 'none' : 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
}}
|
}}
|
||||||
|
|
@ -387,9 +411,9 @@ export const EditorPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{saveModalOpen && <SaveProjectModal onClose={() => setSaveModalOpen(false)} />}
|
{!embedMode.enabled && saveModalOpen && <SaveProjectModal onClose={() => setSaveModalOpen(false)} />}
|
||||||
{loginPromptOpen && <LoginPromptModal onClose={() => setLoginPromptOpen(false)} />}
|
{!embedMode.enabled && loginPromptOpen && <LoginPromptModal onClose={() => setLoginPromptOpen(false)} />}
|
||||||
{showStarBanner && <GitHubStarBanner onClose={handleDismissStarBanner} />}
|
{!embedMode.enabled && showStarBanner && <GitHubStarBanner onClose={handleDismissStarBanner} />}
|
||||||
</div>
|
</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/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
base: process.env.VITE_BASE_PATH || '/',
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue