/** * Editor Page — main editor + simulator with resizable panels */ import React, { useRef, useState, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { startSimulation } from '../simulation/spice/start'; import { useSEO } from '../utils/useSEO'; import { getLocaleFromPath, localizedPath } from '../i18n/path'; import { restoreStashedWorkspace } from '../utils/workspaceDraft'; import { CodeEditor } from '../components/editor/CodeEditor'; import { EditorToolbar } from '../components/editor/EditorToolbar'; import { FileExplorer } from '../components/editor/FileExplorer'; // Lazy-load Pi workspace so xterm.js isn't in the main bundle import { CompilationConsole } from '../components/editor/CompilationConsole'; import { SimulatorCanvas } from '../components/simulator/SimulatorCanvas'; import { SerialMonitor } from '../components/simulator/SerialMonitor'; import { Oscilloscope } from '../components/simulator/Oscilloscope'; import { AppHeader } from '../components/layout/AppHeader'; import { triggerSaveAction } from '../lib/proSaveAction'; import { GitHubStarBanner } from '../components/layout/GitHubStarBanner'; import { NewsAnnouncer } from '../components/layout/NewsAnnouncer'; import { useSimulatorStore } from '../store/useSimulatorStore'; import { useEditorStore } from '../store/useEditorStore'; import { useCompileLogsStore } from '../store/useCompileLogsStore'; import { useOscilloscopeStore } from '../store/useOscilloscopeStore'; import { useProjectStore } from '../store/useProjectStore'; import { NewProjectDialog, clearWorkspaceForStarter, } from '../components/editor/NewProjectDialog'; import { useAutoSaveProject } from '../hooks/useAutoSaveProject'; import { registerEditorCommand } from '../lib/editorCommands'; import { whenNewsClear } from '../lib/newsGate'; import { EditorMenuBar } from '../components/editor/EditorMenuBar'; import { embedBridge } from '../services/EmbedBridge'; import type { CompilationLog } from '../utils/compilationLogger'; import '../App.css'; const MOBILE_BREAKPOINT = 768; const BOTTOM_PANEL_MIN = 80; const BOTTOM_PANEL_MAX = 600; const BOTTOM_PANEL_DEFAULT = 200; const EXPLORER_MIN = 100; const EXPLORER_MAX = 500; // Narrow by default: the explorer rows are compact and both headers stack // their actions ABOVE their label instead of beside it (see // FileExplorer.css), so nothing has to share a line — 124px fits a board // name and typical file names, and every px saved here goes to the code // editor. const EXPLORER_DEFAULT = 124; // Once per full page load: the pristine-visit starter dialog must not pop // again when the user closes it and later navigates away from and back to // /editor within the same SPA session. let starterDialogShownThisLoad = false; const resizeHandleStyle: React.CSSProperties = { height: 5, flexShrink: 0, cursor: 'row-resize', background: '#2a2d2e', borderTop: '1px solid #3c3c3c', borderBottom: '1px solid #3c3c3c', }; export const EditorPage: React.FC = () => { const { t } = useTranslation(); useSEO({ title: 'Multi-Board Simulator Editor — Arduino, ESP32, RP2040, RISC-V | Velxio', description: 'Write, compile and simulate Arduino, ESP32, Raspberry Pi Pico, ESP32-C3, and Raspberry Pi 3 code in your browser. 19 boards, 5 CPU architectures, 48+ components. Free and open-source.', url: 'https://velxio.dev/editor', }); // Silent auto-save for the loaded project (only fires when authed AND // currentProject has a UUID — see useAutoSaveProject for the gating rules). const autoSave = useAutoSaveProject(); // Embed mode: detect ?embed=true and optional flags from URL const [embedMode] = useState(() => { const params = new URLSearchParams(window.location.search); return { enabled: params.get('embed') === 'true', hideEditor: params.get('hideEditor') === 'true', lockComponents: params.get('lockComponents') === 'true', desktopLayout: params.get('desktopLayout') === 'true', }; }); const setLockComponents = useSimulatorStore((s) => s.setLockComponents); useEffect(() => { if (embedMode.lockComponents && typeof setLockComponents === 'function') { setLockComponents(true); } }, [embedMode.lockComponents, setLockComponents]); useEffect(() => { if (!embedMode.enabled) return; const handler = (e: Event) => { const detail = (e as CustomEvent).detail || {}; if (detail.lockComponents !== undefined && typeof setLockComponents === 'function') { setLockComponents(detail.lockComponents); } }; window.addEventListener('velxio-embed-mode', handler); embedBridge.notifyReady(); return () => window.removeEventListener('velxio-embed-mode', handler); }, [embedMode.enabled, setLockComponents]); const [editorWidthPct, setEditorWidthPct] = useState(45); // Desktop-only 3-way layout switch (code-only / circuit-only / both). // Lets users hide a pane to give the right-docked chat more room. const viewMode = useEditorStore((s) => s.viewMode); const setViewMode = useEditorStore((s) => s.setViewMode); const explorerOpen = useEditorStore((s) => s.explorerOpen); const setExplorerOpen = useEditorStore((s) => s.setExplorerOpen); const toggleExplorer = useEditorStore((s) => s.toggleExplorer); const containerRef = useRef(null); const resizingRef = useRef(false); const serialMonitorOpen = useSimulatorStore((s) => s.serialMonitorOpen); const activeBoardId = useSimulatorStore((s) => s.activeBoardId); const oscilloscopeOpen = useOscilloscopeStore((s) => s.open); const [consoleOpen, setConsoleOpen] = useState(false); // compileLogs live in a Zustand store so the velxio-pro agent overlay // (mounted in a separate React tree via slotMounter) can subscribe and // build a "diagnose this failure" prompt without prop-drilling. const compileLogs = useCompileLogsStore((s) => s.logs); const setCompileLogs = useCompileLogsStore((s) => s.setLogs); const [bottomPanelHeight, setBottomPanelHeight] = useState(BOTTOM_PANEL_DEFAULT); const [showStarBanner, setShowStarBanner] = useState(false); const [starRound, setStarRound] = useState<1 | 2>(1); // ── Electrical simulation (one-time mount) ──────────────────────────────── // `startSimulation()` is the single entry point: it constructs the // CircuitSimulationService, mounts the ADC bridge, and subscribes // PinManager → service.handleMcuEdge. No more legacy paths — the // WASM ngspice (via NgSpiceWorkerAdapter) is the only solver. useEffect(() => { return startSimulation(); }, []); // Restore an in-progress workspace stashed before a login redirect, so a // user who was building something and signed in lands back on their circuit // (not the empty starter board). One-shot; see utils/workspaceDraft. useEffect(() => { restoreStashedWorkspace(); }, []); const [showNewProjectDialog, setShowNewProjectDialog] = useState(false); // Pristine-visit starter dialog: a bare /editor landing still holds the // hardcoded Uno + LED starter (see useSimulatorStore INITIAL_BOARD / // builtin components). Clear it and offer the template picker over an // EMPTY canvas instead of silently dropping the user into the Arduino // blink (cancelling the dialog leaves a blank workspace). Guarded so it // never fires over a loaded project/example URL, a restored login draft // (both leave the stores non-pristine), or twice per page load. Declared // AFTER the restoreStashedWorkspace effect — mount order is what makes // the pristine check see the restored draft. // // Sequenced BEHIND the news announcement: the canvas is cleared right // away, but the dialog itself waits until NewsAnnouncer reports the // announcement flow is done (nothing to show, or its modal was closed). // The 2.5s bound applies only while the news decision is pending, so a // dead feed can't hold the dialog hostage — see lib/newsGate.ts. useEffect(() => { if (embedMode.enabled) return; if (starterDialogShownThisLoad) return; const locale = getLocaleFromPath(window.location.pathname); // /editor is prerendered, so a fresh load arrives as /editor/ (nginx // adds the slash); client-side navigation lands on /editor. Both count. if (window.location.pathname.replace(/\/+$/, '') !== localizedPath('/editor', locale)) return; if (window.location.search) return; if (useProjectStore.getState().currentProject) return; const sim = useSimulatorStore.getState(); const pristine = sim.boards.length === 1 && sim.boards[0].id === 'arduino-uno' && sim.components.length === 2 && sim.components.every((c) => c.id === 'led_builtin' || c.id === 'r_builtin'); if (!pristine) return; starterDialogShownThisLoad = true; clearWorkspaceForStarter(); let cancelled = false; void whenNewsClear(2500).then(() => { if (!cancelled) setShowNewProjectDialog(true); }); return () => { cancelled = true; }; }, []); // ── GitHub star prompt (show twice at most: 2nd visit OR after 3 min) ────── // Three localStorage flags drive this: // velxio_star_prompted → dismissed the first ask // velxio_star_prompted_v2 → dismissed the follow-up ask (stop forever) // velxio_star_clicked → clicked through to the repo (stop forever) // Anyone who dismissed the first ask WITHOUT clicking through gets one // follow-up (round 2) with a stronger message; clicking the repo link at // any time opts them out permanently. useEffect(() => { const STAR_KEY = 'velxio_star_prompted'; const STAR_KEY_V2 = 'velxio_star_prompted_v2'; const STAR_CLICKED_KEY = 'velxio_star_clicked'; const VISITS_KEY = 'velxio_editor_visits'; const FIRST_VISIT_KEY = 'velxio_editor_first_visit'; const THREE_MIN = 3 * 60 * 1000; // Never bother people who already starred or already saw the follow-up. if (localStorage.getItem(STAR_CLICKED_KEY)) return; if (localStorage.getItem(STAR_KEY_V2)) return; // Round 2 = they dismissed the first ask (without clicking through). const round = localStorage.getItem(STAR_KEY) ? 2 : 1; setStarRound(round); // Increment visit counter const visits = parseInt(localStorage.getItem(VISITS_KEY) ?? '0', 10) + 1; localStorage.setItem(VISITS_KEY, String(visits)); // Record timestamp of first visit if (!localStorage.getItem(FIRST_VISIT_KEY)) { localStorage.setItem(FIRST_VISIT_KEY, String(Date.now())); } const firstVisit = parseInt(localStorage.getItem(FIRST_VISIT_KEY)!, 10); // Show immediately on second+ visit if (visits >= 2) { setShowStarBanner(true); return; } // Otherwise schedule after the 3-minute mark const elapsed = Date.now() - firstVisit; const delay = Math.max(0, THREE_MIN - elapsed); const timer = setTimeout(() => { if (!localStorage.getItem(STAR_CLICKED_KEY) && !localStorage.getItem(STAR_KEY_V2)) { setShowStarBanner(true); } }, delay); return () => clearTimeout(timer); }, []); const handleDismissStarBanner = () => { // First dismiss → mark round 1; second dismiss → mark round 2 (stop forever). if (localStorage.getItem('velxio_star_prompted')) { localStorage.setItem('velxio_star_prompted_v2', '1'); } else { localStorage.setItem('velxio_star_prompted', '1'); } setShowStarBanner(false); }; const handleStarClick = () => { // They went to the repo — opt them out of any further prompts. localStorage.setItem('velxio_star_clicked', '1'); setShowStarBanner(false); }; const [explorerWidth, setExplorerWidth] = useState(EXPLORER_DEFAULT); const [isMobile, setIsMobile] = useState( () => window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`).matches, ); // Slot element for SimulatorCanvas to portal its header into. When set, the // canvas board selector / Serial / Scope / zoom / Add buttons render here // instead of above the canvas — keeping the top bar a single full-width row // that doesn't reflow when the editor/canvas splitter is dragged. const [canvasHeaderSlot, setCanvasHeaderSlot] = useState(null); // Default to 'code' on mobile — show the editor so users can write/view code const [mobileView, setMobileView] = useState<'code' | 'circuit'>( embedMode.desktopLayout ? 'code' : embedMode.enabled ? 'circuit' : 'code' ); // Save is dispatched to the pro overlay, which inspects auth state and // shows the right modal (Save vs Login prompt). In OSS without the // overlay this is a no-op today and becomes the .vlx Export entry // point in Phase 4 of the OSS split. const handleSaveClick = useCallback(() => { triggerSaveAction(); }, []); // "New workspace" opens the starter-template dialog. Destruction moved // from here into the dialog's selection handler — Cancel/ESC now leaves // the current workspace untouched, so the old confirm dialog is gone. const handleNewClick = useCallback(() => { setShowNewProjectDialog(true); }, []); // Track mobile breakpoint useEffect(() => { const mq = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`); const update = (e: MediaQueryListEvent | MediaQueryList) => { const mobile = e.matches; setIsMobile(mobile); if (mobile) setExplorerOpen(false); }; update(mq); mq.addEventListener('change', update); return () => mq.removeEventListener('change', update); }, [setExplorerOpen]); // Ctrl+S shortcut useEffect(() => { const handler = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); handleSaveClick(); } }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [handleSaveClick]); // File-menu commands owned by this page. Registered here (not in the // menu) so the menu never duplicates the confirm-dialog / autosave logic. useEffect(() => { const offSave = registerEditorCommand('project.save', handleSaveClick); const offNew = registerEditorCommand('project.new', () => { void handleNewClick(); }); const offExplorer = registerEditorCommand('view.toggleExplorer', () => toggleExplorer()); return () => { offSave(); offNew(); offExplorer(); }; }, [handleSaveClick, handleNewClick, toggleExplorer]); // Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z — canvas undo/redo. Skipped when the // user is typing in any input/textarea/contenteditable so the Monaco // editor's per-file history (and the AI chat composer, etc.) keep // working untouched. useEffect(() => { const handler = (e: KeyboardEvent) => { const t = e.target as HTMLElement | null; if (t) { const tag = t.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) { return; } } if (!(e.ctrlKey || e.metaKey)) return; const k = e.key.toLowerCase(); const sim = useSimulatorStore.getState(); if (k === 'z' && !e.shiftKey) { e.preventDefault(); sim.undo(); } else if (k === 'y' || (k === 'z' && e.shiftKey)) { e.preventDefault(); sim.redo(); } }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, []); // Prevent body scroll on the editor page useEffect(() => { const html = document.documentElement; const body = document.body; html.style.overflow = 'hidden'; body.style.overflow = 'hidden'; window.scrollTo(0, 0); return () => { html.style.overflow = ''; body.style.overflow = ''; }; }, []); const handleResizeMouseDown = useCallback((e: React.MouseEvent) => { e.preventDefault(); resizingRef.current = true; const handleMouseMove = (ev: MouseEvent) => { if (!resizingRef.current || !containerRef.current) return; const el = containerRef.current; const rect = el.getBoundingClientRect(); // The panels' percentage widths resolve against the container's CONTENT // box, but getBoundingClientRect() returns the border box — and overlays // may reserve space by padding .app-container (e.g. a docked side panel). // Measuring against the padded box makes the handle jump away from the // cursor, so strip borders and padding first. const cs = getComputedStyle(el); const padLeft = parseFloat(cs.paddingLeft) || 0; const padRight = parseFloat(cs.paddingRight) || 0; const contentLeft = rect.left + el.clientLeft + padLeft; const contentWidth = el.clientWidth - padLeft - padRight; if (contentWidth <= 0) return; const pct = ((ev.clientX - contentLeft) / contentWidth) * 100; setEditorWidthPct(Math.max(20, Math.min(80, pct))); }; const handleMouseUp = () => { resizingRef.current = false; document.body.style.cursor = ''; document.body.style.userSelect = ''; document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); }, []); const handleBottomPanelResizeMouseDown = useCallback( (e: React.MouseEvent) => { e.preventDefault(); const startY = e.clientY; const startHeight = bottomPanelHeight; const onMove = (ev: MouseEvent) => { const delta = startY - ev.clientY; setBottomPanelHeight( Math.max(BOTTOM_PANEL_MIN, Math.min(BOTTOM_PANEL_MAX, startHeight + delta)), ); }; const onUp = () => { document.body.style.cursor = ''; document.body.style.userSelect = ''; document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.body.style.cursor = 'row-resize'; document.body.style.userSelect = 'none'; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }, [bottomPanelHeight], ); const handleExplorerResizeMouseDown = useCallback( (e: React.MouseEvent) => { e.preventDefault(); const startX = e.clientX; const startWidth = explorerWidth; const onMove = (ev: MouseEvent) => { const delta = ev.clientX - startX; setExplorerWidth(Math.max(EXPLORER_MIN, Math.min(EXPLORER_MAX, startWidth + delta))); }; const onUp = () => { document.body.style.cursor = ''; document.body.style.userSelect = ''; document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }, [explorerWidth], ); /* ── Unified toolbar (desktop) ── Editor controls + canvas controls in one strip; the canvas side is portaled into `canvasHeaderSlot` from SimulatorCanvas. Since the marketing nav left the editor header, the header's middle is empty space — so this strip now rides INSIDE the AppHeader row (via the editorToolbar prop) instead of being a second bar: one 44px row where there used to be 44+38. On narrow widths the strip wraps internally and the header grows; the docked AI chat is avoided by the same padding-right the strip always had. */ /* Account block. Fused as the explorer panel's footer so a long file tree scrolls ABOVE it instead of disappearing underneath a floating box; when the explorer is collapsed it falls back to the small fixed corner box (avatar only there — no room for a name). */ const accountBlock = !isMobile ? ( // Just the account button. Language lives in the menubar's Language // menu (for everyone) and inside the account menu (for signed-in // users) — the standalone globe crowded the footer for no gain.
) : undefined; const unifiedToolbar = !isMobile ? (
{/* View-mode toggle: explorer | Code / Both / Circuit — one segmented group so the four pane switches read as one control. Hidden on mobile — there's already a code/circuit toggle in the mobile bottom-nav. */}
{( [ { key: 'code', label: t('editor.shell.code'), path: 'M16 18l6-6-6-6M8 6l-6 6 6 6' }, { key: 'both', label: t('editor.shell.both'), path: 'M3 3h7v18H3zM14 3h7v18h-7z' }, { key: 'circuit', label: t('editor.shell.circuit'), path: 'M5 12h14M12 5v14' }, ] as const ).map((m) => ( ))}
) : undefined; return (
{!embedMode.enabled ? ( : undefined} editorToolbar={unifiedToolbar} /> ) : ( !isMobile && !embedMode.hideEditor && unifiedToolbar )} {/* ── Mobile tab bar (top, above panels) ── */} {isMobile && !(embedMode.enabled && embedMode.hideEditor) && ( )}
{/* ── Editor side ── */}
{/* File explorer sidebar + resize handle */} {explorerOpen && ( <>
{accountBlock && (
{accountBlock}
)}
{!isMobile && (
)} )} {!explorerOpen && accountBlock && (
{accountBlock}
)} {/* Editor main area */}
{/* Mobile-only: explorer toggle + editor toolbar inside the panel. On desktop these are hoisted into the unified top toolbar. */} {isMobile && (
)} {/* Editor area — Monaco for every board, QEMU-Linux included. Pi boards edit script.py here like any other board edits its sketch; their interactive terminal lives in the bottom serial panel (SerialMonitor renders an xterm for Pi kinds). The old RaspberryPiWorkspace (own file tree + own editor + upload button) confused users with three competing file surfaces. */}
{/* Console */} {consoleOpen && ( <>
setConsoleOpen(false)} logs={compileLogs} onClear={() => setCompileLogs([])} />
)}
{/* Resize handle (desktop only, and only when both panes are visible) */} {!isMobile && viewMode === 'both' && !(embedMode.enabled && embedMode.hideEditor) && (
)} {/* ── Simulator side ── */}
{serialMonitorOpen && ( <>
)} {oscilloscopeOpen && ( <>
)}
{!embedMode.enabled && showStarBanner && ( )} {!embedMode.enabled && } {!embedMode.enabled && ( setShowNewProjectDialog(false)} /> )} {/* Slot reserved for the private pro overlay (e.g. agent chat panel). Self-hosted builds without an overlay see nothing here. */}
); };