2026-03-04 08:25:52 +07:00
/ * *
2026-03-05 08:05:23 +07:00
* Editor Page — main editor + simulator with resizable panels
2026-03-04 08:25:52 +07:00
* /
2026-03-16 00:04:01 +07:00
import React , { useRef , useState , useCallback , useEffect , lazy , Suspense } from 'react' ;
2026-03-23 23:32:15 +07:00
import { useSEO } from '../utils/useSEO' ;
2026-03-04 08:25:52 +07:00
import { CodeEditor } from '../components/editor/CodeEditor' ;
import { EditorToolbar } from '../components/editor/EditorToolbar' ;
2026-03-06 20:14:50 +07:00
import { FileTabs } from '../components/editor/FileTabs' ;
import { FileExplorer } from '../components/editor/FileExplorer' ;
2026-03-16 00:04:01 +07:00
// Lazy-load Pi workspace so xterm.js isn't in the main bundle
const RaspberryPiWorkspace = lazy ( ( ) = >
import ( '../components/raspberry-pi/RaspberryPiWorkspace' ) . then ( ( m ) = > ( { default : m . RaspberryPiWorkspace } ) )
) ;
2026-03-06 07:07:10 +07:00
import { CompilationConsole } from '../components/editor/CompilationConsole' ;
2026-03-04 08:25:52 +07:00
import { SimulatorCanvas } from '../components/simulator/SimulatorCanvas' ;
2026-03-05 16:56:14 +07:00
import { SerialMonitor } from '../components/simulator/SerialMonitor' ;
2026-03-11 22:09:24 +07:00
import { Oscilloscope } from '../components/simulator/Oscilloscope' ;
2026-03-06 20:14:50 +07:00
import { AppHeader } from '../components/layout/AppHeader' ;
import { SaveProjectModal } from '../components/layout/SaveProjectModal' ;
import { LoginPromptModal } from '../components/layout/LoginPromptModal' ;
2026-03-24 00:02:35 +07:00
import { GitHubStarBanner } from '../components/layout/GitHubStarBanner' ;
2026-03-05 16:56:14 +07:00
import { useSimulatorStore } from '../store/useSimulatorStore' ;
2026-03-11 22:09:24 +07:00
import { useOscilloscopeStore } from '../store/useOscilloscopeStore' ;
2026-03-06 20:14:50 +07:00
import { useAuthStore } from '../store/useAuthStore' ;
2026-03-06 07:07:10 +07:00
import type { CompilationLog } from '../utils/compilationLogger' ;
2026-03-04 08:25:52 +07:00
import '../App.css' ;
2026-03-10 23:09:32 +07:00
const MOBILE_BREAKPOINT = 768 ;
2026-03-06 07:07:10 +07:00
const BOTTOM_PANEL_MIN = 80 ;
const BOTTOM_PANEL_MAX = 600 ;
const BOTTOM_PANEL_DEFAULT = 200 ;
2026-03-06 21:20:47 +07:00
const EXPLORER_MIN = 120 ;
const EXPLORER_MAX = 500 ;
const EXPLORER_DEFAULT = 210 ;
2026-03-06 07:07:10 +07:00
const resizeHandleStyle : React.CSSProperties = {
height : 5 ,
flexShrink : 0 ,
cursor : 'row-resize' ,
background : '#2a2d2e' ,
borderTop : '1px solid #3c3c3c' ,
borderBottom : '1px solid #3c3c3c' ,
} ;
2026-03-04 08:25:52 +07:00
export const EditorPage : React.FC = ( ) = > {
2026-03-23 23:32:15 +07:00
useSEO ( {
2026-03-23 23:38:56 +07:00
title : 'Multi-Board Simulator Editor — Arduino, ESP32, RP2040, RISC-V | Velxio' ,
2026-03-23 23:32:15 +07:00
description :
2026-03-23 23:38:56 +07:00
'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.' ,
2026-03-23 23:32:15 +07:00
url : 'https://velxio.dev/editor' ,
} ) ;
2026-03-05 08:05:23 +07:00
const [ editorWidthPct , setEditorWidthPct ] = useState ( 45 ) ;
const containerRef = useRef < HTMLDivElement > ( null ) ;
const resizingRef = useRef ( false ) ;
2026-03-05 16:56:14 +07:00
const serialMonitorOpen = useSimulatorStore ( ( s ) = > s . serialMonitorOpen ) ;
2026-03-16 00:04:01 +07:00
const activeBoardId = useSimulatorStore ( ( s ) = > s . activeBoardId ) ;
const activeBoardKind = useSimulatorStore ( ( s ) = >
s . boards . find ( ( b ) = > b . id === s . activeBoardId ) ? . boardKind
) ;
const isRaspberryPi3 = activeBoardKind === 'raspberry-pi-3' ;
2026-03-11 22:09:24 +07:00
const oscilloscopeOpen = useOscilloscopeStore ( ( s ) = > s . open ) ;
2026-03-06 07:07:10 +07:00
const [ consoleOpen , setConsoleOpen ] = useState ( false ) ;
const [ compileLogs , setCompileLogs ] = useState < CompilationLog [ ] > ( [ ] ) ;
const [ bottomPanelHeight , setBottomPanelHeight ] = useState ( BOTTOM_PANEL_DEFAULT ) ;
2026-03-06 20:14:50 +07:00
const [ saveModalOpen , setSaveModalOpen ] = useState ( false ) ;
const [ loginPromptOpen , setLoginPromptOpen ] = useState ( false ) ;
2026-03-24 00:02:35 +07:00
const [ showStarBanner , setShowStarBanner ] = useState ( false ) ;
// ── GitHub star prompt (show once: 2nd visit OR after 3 min) ──────────────
useEffect ( ( ) = > {
const STAR_KEY = 'velxio_star_prompted' ;
const VISITS_KEY = 'velxio_editor_visits' ;
const FIRST_VISIT_KEY = 'velxio_editor_first_visit' ;
const THREE_MIN = 3 * 60 * 1000 ;
if ( localStorage . getItem ( STAR_KEY ) ) return ;
// 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_KEY ) ) setShowStarBanner ( true ) ;
} , delay ) ;
return ( ) = > clearTimeout ( timer ) ;
} , [ ] ) ;
const handleDismissStarBanner = ( ) = > {
localStorage . setItem ( 'velxio_star_prompted' , '1' ) ;
setShowStarBanner ( false ) ;
} ;
2026-03-06 20:14:50 +07:00
const [ explorerOpen , setExplorerOpen ] = useState ( true ) ;
2026-03-06 21:20:47 +07:00
const [ explorerWidth , setExplorerWidth ] = useState ( EXPLORER_DEFAULT ) ;
2026-03-10 23:09:32 +07:00
const [ isMobile , setIsMobile ] = useState ( ( ) = > window . matchMedia ( ` (max-width: ${ MOBILE_BREAKPOINT } px) ` ) . matches ) ;
2026-03-24 03:15:29 +07:00
// Default to 'code' on mobile — show the editor so users can write/view code
const [ mobileView , setMobileView ] = useState < 'code' | 'circuit' > ( 'code' ) ;
2026-03-06 20:14:50 +07:00
const user = useAuthStore ( ( s ) = > s . user ) ;
const handleSaveClick = useCallback ( ( ) = > {
if ( ! user ) {
setLoginPromptOpen ( true ) ;
} else {
setSaveModalOpen ( true ) ;
}
} , [ user ] ) ;
2026-03-10 23:09:32 +07:00
// 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 ) ;
} , [ ] ) ;
2026-03-06 20:14:50 +07:00
// 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 ] ) ;
2026-03-05 08:05:23 +07:00
2026-03-08 03:54:51 +07:00
// 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 = '' ;
} ;
} , [ ] ) ;
2026-03-05 08:05:23 +07:00
const handleResizeMouseDown = useCallback ( ( e : React.MouseEvent ) = > {
e . preventDefault ( ) ;
resizingRef . current = true ;
const handleMouseMove = ( ev : MouseEvent ) = > {
if ( ! resizingRef . current || ! containerRef . current ) return ;
const rect = containerRef . current . getBoundingClientRect ( ) ;
const pct = ( ( ev . clientX - rect . left ) / rect . width ) * 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 ) ;
} , [ ] ) ;
2026-03-06 07:07:10 +07:00
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 ] ) ;
2026-03-06 21:20:47 +07:00
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 ] ) ;
2026-03-04 08:25:52 +07:00
return (
< div className = "app" >
2026-03-06 20:24:03 +07:00
< AppHeader / >
2026-03-05 08:05:23 +07:00
2026-03-24 03:15:29 +07:00
{ /* ── Mobile tab bar (top, above panels) ── */ }
{ isMobile && (
< nav className = "mobile-tab-bar" >
< button
className = { ` mobile-tab-btn ${ mobileView === 'code' ? ' mobile-tab-btn--active' : '' } ` }
onClick = { ( ) = > setMobileView ( 'code' ) }
>
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" strokeLinecap = "round" strokeLinejoin = "round" >
< polyline points = "16 18 22 12 16 6" / >
< polyline points = "8 6 2 12 8 18" / >
< / svg >
< span > & lt ; / & g t ; C o d e < / s p a n >
< / button >
< button
className = { ` mobile-tab-btn ${ mobileView === 'circuit' ? ' mobile-tab-btn--active' : '' } ` }
onClick = { ( ) = > setMobileView ( 'circuit' ) }
>
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" strokeLinecap = "round" strokeLinejoin = "round" >
< rect x = "2" y = "7" width = "20" height = "14" rx = "2" / >
< path d = "M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2" / >
< line x1 = "12" y1 = "12" x2 = "12" y2 = "16" / >
< line x1 = "10" y1 = "14" x2 = "14" y2 = "14" / >
< / svg >
< span > Circuit < / span >
< / button >
< / nav >
) }
2026-03-05 08:05:23 +07:00
< div className = "app-container" ref = { containerRef } >
2026-03-06 20:14:50 +07:00
{ /* ── Editor side ── */ }
< div
className = "editor-panel"
2026-03-10 23:09:32 +07:00
style = { {
width : isMobile ? '100%' : ` ${ editorWidthPct } % ` ,
display : isMobile && mobileView !== 'code' ? 'none' : 'flex' ,
flexDirection : 'row' ,
} }
2026-03-06 20:14:50 +07:00
>
2026-03-06 21:20:47 +07:00
{ /* File explorer sidebar + resize handle */ }
{ explorerOpen && (
< >
< div style = { { width : explorerWidth , flexShrink : 0 , display : 'flex' , overflow : 'hidden' } } >
< FileExplorer onSaveClick = { handleSaveClick } / >
< / div >
2026-03-10 23:09:32 +07:00
{ ! isMobile && (
< div className = "explorer-resize-handle" onMouseDown = { handleExplorerResizeMouseDown } / >
) }
2026-03-06 21:20:47 +07:00
< / >
) }
2026-03-06 20:14:50 +07:00
{ /* Editor main area */ }
< div style = { { flex : 1 , display : 'flex' , flexDirection : 'column' , overflow : 'hidden' , minWidth : 0 } } >
{ /* Explorer toggle + toolbar */ }
< div style = { { display : 'flex' , alignItems : 'stretch' , flexShrink : 0 } } >
< button
className = "explorer-toggle-btn"
onClick = { ( ) = > setExplorerOpen ( ( v ) = > ! v ) }
title = { explorerOpen ? 'Hide file explorer' : 'Show file explorer' }
>
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" strokeLinecap = "round" strokeLinejoin = "round" >
< path d = "M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" / >
< / svg >
< / button >
< div style = { { flex : 1 } } >
< EditorToolbar
consoleOpen = { consoleOpen }
setConsoleOpen = { setConsoleOpen }
compileLogs = { compileLogs }
setCompileLogs = { setCompileLogs }
2026-03-06 07:07:10 +07:00
/ >
< / div >
2026-03-06 20:14:50 +07:00
< / div >
2026-03-16 00:04:01 +07:00
{ /* File tabs — hidden when Pi workspace is active */ }
{ ! isRaspberryPi3 && < FileTabs / > }
2026-03-06 20:14:50 +07:00
2026-03-16 00:04:01 +07:00
{ /* Editor area: Pi workspace or Monaco editor */ }
2026-03-06 20:14:50 +07:00
< div className = "editor-wrapper" style = { { flex : 1 , overflow : 'hidden' , minHeight : 0 } } >
2026-03-16 00:04:01 +07:00
{ isRaspberryPi3 && activeBoardId ? (
< Suspense fallback = { < div style = { { color : '#666' , padding : 16 , fontSize : 12 } } > Loading Pi workspace … < / div > } >
< RaspberryPiWorkspace boardId = { activeBoardId } / >
< / Suspense >
) : (
< CodeEditor / >
) }
2026-03-06 20:14:50 +07:00
< / div >
{ /* Console */ }
{ consoleOpen && (
< >
< div
onMouseDown = { handleBottomPanelResizeMouseDown }
style = { resizeHandleStyle }
title = "Drag to resize"
/ >
< div style = { { height : bottomPanelHeight , flexShrink : 0 } } >
< CompilationConsole
isOpen = { consoleOpen }
onClose = { ( ) = > setConsoleOpen ( false ) }
logs = { compileLogs }
onClear = { ( ) = > setCompileLogs ( [ ] ) }
/ >
< / div >
< / >
) }
< / div >
2026-03-04 08:25:52 +07:00
< / div >
2026-03-05 08:05:23 +07:00
2026-03-10 23:09:32 +07:00
{ /* Resize handle (desktop only) */ }
{ ! isMobile && (
< div className = "resize-handle" onMouseDown = { handleResizeMouseDown } >
< div className = "resize-handle-grip" / >
< / div >
) }
2026-03-05 08:05:23 +07:00
2026-03-06 20:14:50 +07:00
{ /* ── Simulator side ── */ }
< div
className = "simulator-panel"
2026-03-10 23:09:32 +07:00
style = { {
width : isMobile ? '100%' : ` ${ 100 - editorWidthPct } % ` ,
display : isMobile && mobileView !== 'circuit' ? 'none' : 'flex' ,
flexDirection : 'column' ,
} }
2026-03-06 20:14:50 +07:00
>
2026-03-06 07:07:10 +07:00
< div style = { { flex : 1 , overflow : 'hidden' , position : 'relative' , minHeight : 0 } } >
2026-03-05 16:56:14 +07:00
< SimulatorCanvas / >
< / div >
{ serialMonitorOpen && (
2026-03-06 07:07:10 +07:00
< >
< div
onMouseDown = { handleBottomPanelResizeMouseDown }
style = { resizeHandleStyle }
title = "Drag to resize"
/ >
< div style = { { height : bottomPanelHeight , flexShrink : 0 } } >
< SerialMonitor / >
< / div >
< / >
2026-03-05 16:56:14 +07:00
) }
2026-03-11 22:09:24 +07:00
{ oscilloscopeOpen && (
< >
< div
onMouseDown = { handleBottomPanelResizeMouseDown }
style = { resizeHandleStyle }
title = "Drag to resize"
/ >
< div style = { { height : bottomPanelHeight , flexShrink : 0 } } >
< Oscilloscope / >
< / div >
< / >
) }
2026-03-04 08:25:52 +07:00
< / div >
< / div >
2026-03-06 20:14:50 +07:00
{ saveModalOpen && < SaveProjectModal onClose = { ( ) = > setSaveModalOpen ( false ) } / > }
{ loginPromptOpen && < LoginPromptModal onClose = { ( ) = > setLoginPromptOpen ( false ) } / > }
2026-03-24 00:02:35 +07:00
{ showStarBanner && < GitHubStarBanner onClose = { handleDismissStarBanner } / > }
2026-03-04 08:25:52 +07:00
< / div >
) ;
} ;