diff --git a/demo/Joystick Example/README.md b/demo/Joystick Example/README.md index 23e51de..c225373 100644 --- a/demo/Joystick Example/README.md +++ b/demo/Joystick Example/README.md @@ -1,4 +1,4 @@ -# πŸ•ΉοΈ Qwiic Joystick Asteroids +# Qwiic Joystick Asteroids Example Stream the SparkFun Qwiic Joystick's X/Y position (and button) over serial, then **play a full game of Asteroids** with it in the **Visual** tab. @@ -15,7 +15,7 @@ Built with **tinyStudio** for the **tinyCore** ESP32-S3 board by MR.INDUSTRIES. - [x] `visual.js` parses that stream and lets you **fly a ship, rotate, thrust, and shoot rocks** ```mermaid -flowchart LR +flowchart TD JOY[Qwiic Joystick] -->|IΒ²C SDA/SCL| MCU[tinyCore] MCU -->|"Serial.println x,y,button"| MON[Serial Stream] MON --> VIS[visual.js Β· Asteroids] diff --git a/demo/Joystick Example/asteroids.js b/demo/Joystick Example/asteroids.js index 18a0ba2..e5d7702 100644 --- a/demo/Joystick Example/asteroids.js +++ b/demo/Joystick Example/asteroids.js @@ -17,7 +17,7 @@ const DEADZONE = 90; // ignore small wobble around center const START_LIVES = 3; // ---- palette (matches the visualizer) ---- -const C_BG = [10, 15, 45]; +const C_BG = [0, 0, 0]; const C_SHIP = [255, 255, 255]; const C_ROCK = [150, 162, 196]; const C_BULLET = [255, 215, 0]; // gold diff --git a/demo/Joystick Example/diagram.json b/demo/Joystick Example/diagram.json index 0d33777..c99d803 100644 --- a/demo/Joystick Example/diagram.json +++ b/demo/Joystick Example/diagram.json @@ -3,65 +3,82 @@ "editor": "tinystudio", "parts": [ { - "type": "tinycore", - "id": "tinycore_bms", - "left": 364.8, - "top": 86.39999999999999 + "type": "potentiometer-rotary-16mm-5", + "id": "potentiometer-rotary-16mm-5_g9d", + "left": 710.4, + "top": -124.8, + "attrs": { + "labelOffset": [ + 65, + -68 + ], + "label": "Potentiometer" + } }, { - "type": "my-part", - "id": "my-part_y9r", - "left": 660, - "top": 242 + "type": "battery-aa", + "id": "battery-aa_2dp", + "left": 329, + "top": -27 + }, + { + "type": "led-generic-5mm", + "id": "led-generic-5mm_fsn", + "left": 637, + "top": -57.599999999999994, + "attrs": { + "labelOffset": [ + -42, + -67 + ] + } + }, + { + "type": "resistor", + "id": "resistor_hwt", + "left": 643.1999999999999, + "top": 63 } ], "connections": [ [ - "tinycore_bms:8", - "my-part_y9r:4", - "#36c46b", + "battery-aa_2dp:+", + "led-generic-5mm_fsn:cathode", + "#e5544b", [ - "v129.8", - "h194", - "v-19.2", - "h-1.8", - "v-26" + "h98.21", + "v-41.56", + "h-0.16" ] ], [ - "tinycore_bms:9", - "my-part_y9r:3", - "#ff4d6d", + "led-generic-5mm_fsn:anode", + "potentiometer-rotary-16mm-5_g9d:leg1", + "#42a5f5", [ - "v139.4", - "h214.6", - "v-19.2", - "h-2.4", - "v-36.6" + "v6.07", + "h66.91" ] ], [ - "tinycore_bms:10", - "my-part_y9r:2", - "#00f0ff", + "potentiometer-rotary-16mm-5_g9d:wiper", + "resistor_hwt:Pin 1", + "#42a5f5", [ - "v149", - "h236.2", - "v-9.6", - "h-3", - "v-55.8" + "v84.36", + "h-28.32", + "v-0.27", + "h-26.96" ] ], [ - "tinycore_bms:11", - "my-part_y9r:1", - "#f7a400", + "resistor_hwt:Pin 0", + "battery-aa_2dp:-", + "#79818c", [ - "v157.6", - "h253.8", - "v-57.6", - "h-3.6", - "v-16.4" + "h-39.36", + "v-30.35", + "h-59.81" ] ] ] diff --git a/demo/Joystick Example/pong.js b/demo/Joystick Example/pong.js index e5c2922..a4d7ad2 100644 --- a/demo/Joystick Example/pong.js +++ b/demo/Joystick Example/pong.js @@ -15,7 +15,7 @@ const RANGE = 1023; // 10-bit ADC full scale const WIN_SCORE = 7; // ---- palette (matches the visualizer) ---- -const C_BG = [10, 15, 45]; +const C_BG = [0, 0, 0]; const C_NET = [26, 34, 70]; const C_PLAYER = [255, 255, 255]; // cyan const C_AI = [255, 255, 255]; // pink diff --git a/demo/Joystick Example/visual.js b/demo/Joystick Example/visual.js index 19e2216..a7b1b74 100644 --- a/demo/Joystick Example/visual.js +++ b/demo/Joystick Example/visual.js @@ -28,7 +28,7 @@ function sx(x) { return map(x, RANGE, 0, PAD, width - PAD); } function sy(y) { return map(y, 0, RANGE, height - PAD, PAD); } function draw() { - background(10, 15, 45); + background(0, 0, 0); let p = parseLine(serialRead()); if (p) { px = p.x; py = p.y; pressed = p.b === 1; } diff --git a/package-lock.json b/package-lock.json index 6ad8b98..9b4367f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,7 +51,7 @@ "@types/react": "^19.1.1", "@types/react-dom": "^19.1.2", "@vitejs/plugin-react": "^4.3.4", - "electron": "^42.4.1", + "electron": "^42.5.0", "electron-builder": "^26.5.0", "electron-vite": "^3.1.0", "eslint": "^9.24.0", @@ -3688,9 +3688,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3704,9 +3701,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3720,9 +3714,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3736,9 +3727,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3752,9 +3740,6 @@ "cpu": [ "loong64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3768,9 +3753,6 @@ "cpu": [ "loong64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3784,9 +3766,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3800,9 +3779,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3816,9 +3792,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3832,9 +3805,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3848,9 +3818,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3864,9 +3831,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3880,9 +3844,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7319,9 +7280,9 @@ } }, "node_modules/electron": { - "version": "42.4.1", - "resolved": "https://registry.npmjs.org/electron/-/electron-42.4.1.tgz", - "integrity": "sha512-8CYHJP5O4wFO+ycoJR98yy907MmPeo+vWXrzjxmGGgRNKqv8pOjjm+wphO0CCgQJnBU7+QUPSJS4QXhbKrO50w==", + "version": "42.5.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-42.5.0.tgz", + "integrity": "sha512-cYEKS9XFz+c9fAB4jI0x49yz1FFzB55r3q96wu9YkwwJMv7t9202IE/ltlgy6yitl/J4M7C8JQcmUqdzDvPl/w==", "license": "MIT", "dependencies": { "@electron-internal/extract-zip": "^1.0.1", diff --git a/package.json b/package.json index 8d2b037..094ad16 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tinystudio", - "version": "0.1.1", + "version": "0.2.0", "description": "An open-source desktop + web IDE for building, wiring, simulating, and flashing tiny hardware projects β€” code, circuit, and visual views in one place.", "main": "./out/main/index.js", "author": "MR.INDUSTRIES", @@ -75,7 +75,7 @@ "@types/react": "^19.1.1", "@types/react-dom": "^19.1.2", "@vitejs/plugin-react": "^4.3.4", - "electron": "^42.4.1", + "electron": "^42.5.0", "electron-builder": "^26.5.0", "electron-vite": "^3.1.0", "eslint": "^9.24.0", diff --git a/src/renderer/src/.fuse_hidden0000001200000001 b/src/renderer/src/.fuse_hidden0000001200000001 new file mode 100644 index 0000000..7ee192b --- /dev/null +++ b/src/renderer/src/.fuse_hidden0000001200000001 @@ -0,0 +1,156 @@ +import { useEffect, useRef, useState } from 'react' +import { notify as toast } from './lib/notify' +import { BackendPrompt } from './components/BackendPrompt' +import { WelcomeDialog } from './components/WelcomeDialog' +import { LoadGitHubProjectCommand, OpenWorkspaceCommand } from './commands/fileCommands' +import { parseProjectRoute } from './lib/projectRouting' +import { DocsPanel } from './components/DocsPanel' +import { EditorPanel } from './components/EditorPanel' +import { ErrorBoundary } from './components/ErrorBoundary' +import { FileExplorer } from './components/FileExplorer' +import { Header } from './components/Header' +import { SerialMonitor } from './components/SerialMonitor' +import { StatusBar } from './components/StatusBar' +import { Toolbar } from './components/Toolbar' +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from './components/ui/Resizable' +import { ArduinoProvider } from './contexts/ArduinoContext' +import { SerialProvider } from './contexts/SerialContext' +import { fileSystem } from './lib/fileSystem' +import { + selectEditorView, + selectPanelState, + setPanelOpen, + useAppDispatch, + useAppSelector +} from './redux' +import { ArduinoServiceFactory } from './services/arduino/ArduinoServiceFactory' + +export default function App(): React.JSX.Element { + const { isFileExplorerOpen, isSerialMonitorOpen, isDocsPanelOpen } = + useAppSelector(selectPanelState) + const editorView = useAppSelector(selectEditorView) + const dispatch = useAppDispatch() + const [editorSize, setEditorSize] = useState(50) + + // The serial monitor / output dock only makes sense while coding β€” close it + // when switching to the full-window Circuit or Visual views, reopen on Code. + useEffect(() => { + dispatch(setPanelOpen({ panel: 'monitor', isOpen: editorView === 'code' })) + }, [editorView, dispatch]) + + // Cleanup Arduino service on unmount + useEffect(() => { + return () => { + console.log('Cleaning up Arduino service on app unmount') + ArduinoServiceFactory.cleanup() + } + }, []) + + // The "backend not reachable" messaging now lives in (a + // persistent, actionable banner with the start command) rather than a transient + // toast, so compile/upload/serial don't appear to fail silently. + + // On launch, a `///` deep link opens that GitHub project and + // takes precedence over reopening the last local workspace. Otherwise, reopen + // the last workspace (if it still exists on disk). + // + // Guard against running twice β€” StrictMode double-invokes effects in dev, and a + // second open rebuilds the tree with new ids, which previously opened a + // duplicate tab for the auto-opened sketch. + const reopenedRef = useRef(false) + useEffect(() => { + if (reopenedRef.current) return + reopenedRef.current = true + + const route = parseProjectRoute() + if (route) { + new LoadGitHubProjectCommand(route.owner, route.repo, route.path).execute().catch((e) => { + console.error('Failed to load project from URL:', e) + toast.error('Could not open that project', { + description: e instanceof Error ? e.message : String(e) + }) + }) + return + } + + const last = localStorage.getItem('tinystudio.lastWorkspace') + if (!last) return + fileSystem + .pathExists(last) + .then((exists) => { + if (exists) { + void new OpenWorkspaceCommand(last).execute() + } else { + localStorage.removeItem('tinystudio.lastWorkspace') + } + }) + .catch((e) => console.error('Failed to reopen last workspace:', e)) + }, []) + + // Honor browser back/forward between projects. + useEffect(() => { + const onPop = (): void => { + const route = parseProjectRoute() + if (route) { + new LoadGitHubProjectCommand(route.owner, route.repo, route.path) + .execute() + .catch((e) => console.error('Failed to load project on navigation:', e)) + } + } + window.addEventListener('popstate', onPop) + return () => window.removeEventListener('popstate', onPop) + }, []) + + return ( + + +
+
+ + + {isFileExplorerOpen && ( + <> + + + + + + )} + + + setEditorSize(size)} + > + + + {isSerialMonitorOpen && ( + <> + + + + + + )} + + + {isDocsPanelOpen && ( + <> + + + + + + + + )} + + + + +
+
+
+ ) +} diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index b94e4c4..4f1c42c 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,7 +1,6 @@ import { useEffect, useRef, useState } from 'react' -import { toast } from 'sonner' +import { notify as toast } from './lib/notify' import { BackendPrompt } from './components/BackendPrompt' -import { WelcomeDialog } from './components/WelcomeDialog' import { LoadGitHubProjectCommand, OpenWorkspaceCommand } from './commands/fileCommands' import { parseProjectRoute } from './lib/projectRouting' import { DocsPanel } from './components/DocsPanel' @@ -148,7 +147,6 @@ export default function App(): React.JSX.Element { - diff --git a/src/renderer/src/assets/base.css b/src/renderer/src/assets/base.css index 6334833..d946b23 100644 --- a/src/renderer/src/assets/base.css +++ b/src/renderer/src/assets/base.css @@ -1,163 +1,361 @@ /* ============================================================================ tinyStudio / MR.INDUSTRIES β€” application theme ---------------------------------------------------------------------------- - tinyForge design system: deep-navy surfaces, neon-cyan primary action, - hot-pink reserved for Studio AI / accent moments. - Fonts: Plus Jakarta Sans (UI) + JetBrains Mono (code). + tinyStudio design system: a clean digital workbench. Cold neutral greys + (near-white in light, Discord-charcoal in dark) form a calm ground; four + soft PCB inks (red/green/blue/yellow) carry meaning. Tactile "pixel-art" + controls, a faint notebook dot-grid, sentence-case copy, monospace for + anything that represents the machine. + + Tokens are ported from the tinyStudio Design System. Components consume the + semantic aliases (--bg, --surface-card, --text-body, --brand, --status-*) + and the shadcn-style mapping below (--background, --card, --primary, …) so + light + dark both work for free. + + Theme: dark is the default the user sees (set in main.tsx); light is fully + available. .dark on switches to the charcoal theme. + Fonts: Plus Jakarta Sans (UI/display) + Fira Code (mono / "the machine"). ============================================================================ */ -@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400&family=JetBrains+Mono:ital,wght@0,400;0,500;0,700;1,400&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;0,800;1,500&family=Fira+Code:wght@400;500;600;700&display=swap'); @import 'tailwindcss'; @import 'tw-animate-css'; +@import './ds-components.css'; + +/* Drive Tailwind's `dark:` variant off the `.dark` class (set by ThemeProvider), + not the OS `prefers-color-scheme`. Without this, `dark:` utilities silently + key off the media query and don't track the app's theme toggle. */ +@custom-variant dark (&:where(.dark, .dark *)); + :root { - /* ------------------------------------------------------------- BRAND RAW */ - --brand-primary: #0a0f2d; - --brand-secondary: #1a1f4d; - --brand-cyan: #00f0ff; - --brand-pink: #ff3f8c; - --brand-amber: #f7a400; - - /* ------------------------------------------------------------ NAVY SCALE */ - --navy-1000: #05081a; /* editor gutter / deepest well */ - --navy-900: #070b22; /* code canvas background */ - --navy-800: #0a0f2d; /* app background (== brand-primary) */ - --navy-700: #11173a; /* sidebars, docked panels */ - --navy-600: #1a1f4d; /* cards, elevated panels (== secondary)*/ - --navy-500: #262c5e; /* hover fill, input background */ - --navy-400: #353c78; /* borders, dividers (strong) */ - --navy-300: #4a5296; /* disabled text, faint lines */ - - /* ----------------------------------------------------------- ACCENT CYAN */ - --cyan-bright: #5cf7ff; - --cyan: #00f0ff; - --cyan-deep: #00b8d4; - --cyan-soft: rgba(0, 240, 255, 0.14); - --cyan-line: rgba(0, 240, 255, 0.4); - - /* ----------------------------------------------------------- ACCENT PINK */ - --pink-bright: #ff6ea8; - --pink: #ff3f8c; - --pink-deep: #d81f6a; - --pink-soft: rgba(255, 63, 140, 0.14); - --pink-line: rgba(255, 63, 140, 0.4); - - /* ------------------------------------------------------ SIGNAL/SEMANTIC */ - --signal-success: #3ddc97; - --signal-warning: #f7a400; - --signal-error: #ff4d6d; - --signal-info: #00f0ff; - --signal-success-soft: rgba(61, 220, 151, 0.14); - --signal-warning-soft: rgba(247, 164, 0, 0.14); - --signal-error-soft: rgba(255, 77, 109, 0.14); - - /* --------------------------------------------------------- TEXT ON DARK */ - --fg-1: #ffffff; - --fg-2: rgba(235, 238, 255, 0.72); - --fg-3: rgba(214, 220, 255, 0.46); - --fg-4: rgba(200, 208, 255, 0.3); - --fg-on-cyan: #04122b; - --fg-on-pink: #2a0413; - - /* ------------------------------------------------------------ GRADIENTS */ - --grad-brand: linear-gradient(45deg, var(--cyan), var(--pink)); - --grad-brand-r: linear-gradient(45deg, var(--pink), var(--cyan)); - --grad-ai: linear-gradient(120deg, #00f0ff 0%, #9b6cff 55%, #ff3f8c 100%); - --grad-panel: linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0)); - - /* --------------------------------------------------------------- SYNTAX */ - --syn-bg: var(--navy-900); - --syn-text: #d7deff; - --syn-comment: #5f6aa0; - --syn-keyword: #ff3f8c; - --syn-type: #00f0ff; - --syn-func: #5cc8ff; - --syn-string: #3ddc97; - --syn-number: #f7a400; - --syn-const: #c792ea; - --syn-punc: #8b94c8; - - /* --------------------------------------------------------- RADII/SHADOW */ - --r-sm: 6px; - --r-md: 10px; - --r-lg: 14px; - --r-pill: 999px; - --shadow-panel: 0 8px 30px rgba(0, 0, 0, 0.35); - --shadow-pop: 0 12px 40px rgba(0, 0, 0, 0.5); - --glow-cyan: 0 0 0 1px var(--cyan-line), 0 0 18px rgba(0, 240, 255, 0.35); - --glow-pink: 0 0 0 1px var(--pink-line), 0 0 18px rgba(255, 63, 140, 0.35); - - /* ----------------------------------------------- SEMANTIC THEME MAPPING - tinyStudio is a dark-first design system: light and dark resolve to the - same navy ramp so every shadcn-style component lands on brand. */ - --radius: 0.625rem; - --background: var(--navy-800); - --background-grid: var(--navy-900); - --foreground: #eef1ff; - --foreground-grid: var(--navy-400); - --card: var(--navy-700); - --card-foreground: #eef1ff; - --popover: var(--navy-600); - --popover-foreground: #eef1ff; - --primary: var(--cyan); - --primary-foreground: var(--fg-on-cyan); - --secondary: var(--navy-600); - --secondary-foreground: #eef1ff; - --muted: var(--navy-700); - --muted-foreground: var(--fg-3); - --accent: var(--navy-500); - --accent-foreground: #ffffff; - --destructive: var(--signal-error); - --border: var(--navy-400); - --input: var(--navy-500); - --ring: var(--cyan); - --sidebar: var(--navy-700); - --sidebar-foreground: #eef1ff; - --sidebar-primary: var(--cyan); - --sidebar-primary-foreground: var(--fg-on-cyan); - --sidebar-accent: var(--navy-500); - --sidebar-accent-foreground: #ffffff; - --sidebar-border: var(--navy-400); - --sidebar-ring: var(--cyan); + /* ── RAW INK RAMPS β€” the four PCB solder-mask colors ─────────────────────── + *-soft = light tint surface Β· *-deep = press/border shade Β· + *-on = accessible text on the soft tint. (LIGHT values; dark brightens.) */ + + /* RED β€” error Β· disconnected Β· stop */ + --red-soft: #fbe4e1; + --red: #e5544b; + --red-deep: #b23a33; + --red-on: #8c2a25; + + /* GREEN β€” success Β· connected Β· run */ + --green-soft: #daf1e5; + --green: #2fa46a; + --green-deep: #1e7a4d; + --green-on: #155c3a; + + /* BLUE β€” primary action Β· info Β· links (the brand) */ + --blue-soft: #dceefd; + --blue: #42a5f5; + --blue-deep: #1e88e5; + --blue-on: #0d5b9a; + + /* YELLOW β€” warning Β· uploading Β· highlight (gold) */ + --yellow-soft: #fbf1c2; + --yellow: #f3cb00; + --yellow-deep: #c6a500; + --yellow-on: #6b5800; + + /* ── COLD NEUTRAL RAMP β€” white β†’ slate ───────────────────────────────────── */ + --warm-50: #fbfcfd; + --warm-100: #f5f7f9; + --warm-150: #edf0f3; + --warm-200: #e3e7ec; + --warm-300: #cfd5dc; + --warm-400: #a3abb5; + --warm-500: #79818c; + --warm-600: #565d66; + --warm-700: #3c4147; + --warm-800: #24272c; + --warm-900: #181a1e; + --warm-950: #101113; + + /* ── SEMANTIC ALIASES β€” LIGHT (default) ──────────────────────────────────── */ + + /* surfaces β€” near-white canvas, light-grey wells */ + --bg: #fafbfc; + --bg-sunken: #edf0f3; + --bg-raised: #ffffff; + --surface-card: #ffffff; + --surface-overlay: #ffffff; + + /* text */ + --text-strong: var(--warm-900); + --text-body: var(--warm-800); + --text-muted: var(--warm-500); + --text-faint: var(--warm-400); + --text-inverse: var(--warm-50); + + /* borders */ + --border-soft: var(--warm-200); + --border-default: #cfd5dc; + --border-strong: var(--warm-900); + --border-interactive: var(--warm-400); + + /* brand / primary action */ + --brand: var(--blue); + --brand-deep: var(--blue-deep); + --brand-soft: var(--blue-soft); + --brand-on: var(--blue-on); + --brand-contrast: #ffffff; + + /* status (board state, compile, etc.) */ + --status-ok: var(--green); + --status-error: var(--red); + --status-warn: var(--yellow); + --status-info: var(--blue); + + /* focus ring */ + --ring-ink: var(--blue); + + /* dot-matrix "notebook" background */ + --dot-color: rgba(24, 26, 30, 0.09); + + /* translucent scrim for blur/overlay layers */ + --scrim: rgba(16, 17, 19, 0.42); + + /* ── TYPOGRAPHY tokens ───────────────────────────────────────────────────── */ + --fw-regular: 400; + --fw-medium: 500; + --fw-semibold: 600; + --fw-bold: 700; + --fw-extra: 800; + + --text-2xs: 11px; + --text-xs: 12px; + --text-sm: 14px; + --text-md: 16px; + --text-lg: 19px; + --text-xl: 23px; + --text-2xl: 28px; + --text-3xl: 34px; + + --leading-tight: 1.05; + --leading-snug: 1.2; + --leading-normal: 1.5; + --leading-relaxed: 1.65; + + --tracking-tight: -0.02em; + --tracking-normal: 0; + --tracking-wide: 0.04em; + --tracking-mono-label: 0.06em; + + /* ── SPACING / control heights (4px grid) ────────────────────────────────── */ + --control-sm: 30px; + --control-md: 38px; + --control-lg: 46px; + + /* ── EFFECTS β€” radius, shadows, motion, dot-grid ─────────────────────────── */ + --radius-xs: 3px; + --radius-sm: 5px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + --radius-pill: 999px; + + /* HARD offset shadows (the pixel-art / workbench edge) */ + --shadow-hard-xs: 1px 1px 0 0 var(--border-strong); + --shadow-hard-sm: 2px 2px 0 0 var(--border-strong); + --shadow-hard: 3px 3px 0 0 var(--border-strong); + --shadow-hard-lg: 5px 5px 0 0 var(--border-strong); + + /* top inner highlight that makes fills look molded / tactile */ + --highlight-top: inset 0 1px 0 0 rgba(255, 255, 255, 0.45); + --highlight-top-strong: inset 0 1.5px 0 0 rgba(255, 255, 255, 0.6); + + /* SOFT elevation β€” floating UI only */ + --shadow-soft-sm: 0 2px 6px rgba(20, 19, 16, 0.1); + --shadow-soft: 0 8px 24px rgba(20, 19, 16, 0.14); + --shadow-soft-lg: 0 20px 48px rgba(20, 19, 16, 0.2); + + /* motion */ + --ease-snap: cubic-bezier(0.2, 0.8, 0.2, 1); + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); + --dur-fast: 110ms; + --dur-med: 180ms; + --dur-slow: 260ms; + --press: 2px; + + /* dot-grid */ + --dot-gap: 18px; + --dots: radial-gradient(var(--dot-color) 1.1px, transparent 1.1px); + + /* ── EDITOR SYNTAX β€” derived from the ink palette ────────────────────────── */ + --syn-bg: var(--bg-sunken); + --syn-text: var(--text-body); + --syn-comment: var(--text-faint); + --syn-keyword: var(--blue-deep); + --syn-type: var(--blue-on); + --syn-func: var(--blue); + --syn-string: var(--green-deep); + --syn-number: var(--yellow-deep); + --syn-const: var(--red-deep); + --syn-punc: var(--text-muted); + + /* ── SHADCN SEMANTIC MAPPING β€” points the component system at design tokens. + These reference the semantic aliases above, so they flip with the theme + automatically; no need to redefine them in the dark block. ──────────────── */ + --radius: var(--radius-md); + --background: var(--bg); + --background-grid: var(--bg-sunken); + --foreground: var(--text-body); + --foreground-grid: var(--border-soft); + --card: var(--surface-card); + --card-foreground: var(--text-body); + --popover: var(--surface-overlay); + --popover-foreground: var(--text-body); + --primary: var(--brand); + --primary-foreground: var(--brand-contrast); + --secondary: var(--bg-sunken); + --secondary-foreground: var(--text-body); + --muted: var(--bg-sunken); + --muted-foreground: var(--text-muted); + --accent: var(--bg-sunken); + --accent-foreground: var(--text-strong); + --destructive: var(--status-error); + --destructive-foreground: #ffffff; + --border: var(--border-default); + --input: var(--border-default); + --ring: var(--ring-ink); + --chart-1: var(--blue); + --chart-2: var(--green); + --chart-3: var(--yellow); + --chart-4: var(--red); + --chart-5: var(--warm-500); + --sidebar: var(--bg-raised); + --sidebar-foreground: var(--text-body); + --sidebar-primary: var(--brand); + --sidebar-primary-foreground: var(--brand-contrast); + --sidebar-accent: var(--bg-sunken); + --sidebar-accent-foreground: var(--text-strong); + --sidebar-border: var(--border-default); + --sidebar-ring: var(--ring-ink); + + /* ── LEGACY BACK-COMPAT β€” old tinyForge token names re-pointed at the new + palette so chrome that still references them stays on-brand and theme-aware + until each component is restyled. Remove once nothing references them. ──── */ + --navy-1000: var(--bg-sunken); + --navy-900: var(--bg-raised); + --navy-800: var(--bg); + --navy-700: var(--bg-raised); + --navy-600: var(--border-default); + --navy-500: var(--bg-sunken); + --navy-400: var(--border-default); + --navy-300: var(--border-soft); + + --cyan-bright: var(--brand); + --cyan: var(--brand); + --cyan-deep: var(--brand-deep); + --cyan-soft: var(--brand-soft); + --cyan-line: var(--border-interactive); + + --pink-bright: var(--brand); + --pink: var(--brand); + --pink-deep: var(--brand-deep); + --pink-soft: var(--brand-soft); + --pink-line: var(--border-interactive); + + --signal-success: var(--status-ok); + --signal-warning: var(--status-warn); + --signal-error: var(--status-error); + --signal-info: var(--status-info); + --signal-success-soft: var(--green-soft); + --signal-warning-soft: var(--yellow-soft); + --signal-error-soft: var(--red-soft); + + --fg-1: var(--text-strong); + --fg-2: var(--text-body); + --fg-3: var(--text-muted); + --fg-4: var(--text-faint); + --fg-on-cyan: var(--brand-contrast); + --fg-on-pink: var(--brand-contrast); + + --grad-brand: var(--brand); + --grad-brand-r: var(--brand); + --grad-ai: var(--brand); + --grad-panel: linear-gradient(180deg, rgba(0, 0, 0, 0.02), rgba(0, 0, 0, 0)); + + --r-sm: var(--radius-sm); + --r-md: var(--radius-md); + --r-lg: var(--radius-lg); + --r-pill: var(--radius-pill); + --shadow-panel: var(--shadow-soft); + --shadow-pop: var(--shadow-soft-lg); + --glow-cyan: 0 0 0 1.5px var(--brand-soft); + --glow-pink: 0 0 0 1.5px var(--brand-soft); } -.dark { - --background: var(--navy-800); - --foreground: #eef1ff; - --card: var(--navy-700); - --card-foreground: #eef1ff; - --popover: var(--navy-600); - --popover-foreground: #eef1ff; - --primary: var(--cyan); - --primary-foreground: var(--fg-on-cyan); - --secondary: var(--navy-600); - --secondary-foreground: #eef1ff; - --muted: var(--navy-700); - --muted-foreground: var(--fg-3); - --accent: var(--navy-500); - --accent-foreground: #ffffff; - --destructive: var(--signal-error); - --border: var(--navy-400); - --input: var(--navy-500); - --ring: var(--cyan); - --sidebar: var(--navy-700); - --sidebar-foreground: #eef1ff; - --sidebar-primary: var(--cyan); - --sidebar-primary-foreground: var(--fg-on-cyan); - --sidebar-accent: var(--navy-500); - --sidebar-accent-foreground: #ffffff; - --sidebar-border: var(--navy-400); - --sidebar-ring: var(--cyan); +/* ── DARK THEME β€” Discord-style cold charcoal ─────────────────────────────── + Inks brighten so they sit on dark grey without vibrating. Neutrals are cold + dark grey (never pure black). Everything referencing the semantic aliases + (shadcn vars, legacy aliases) flips automatically. ────────────────────────── */ +.dark, +:root[data-theme='dark'] { + /* brightened inks */ + --red-soft: #3a2421; + --red: #f26d63; + --red-deep: #c9483f; + --red-on: #f8c4bf; + + --green-soft: #16291f; + --green: #46c586; + --green-deep: #2c9e66; + --green-on: #b4e8cd; + + --blue-soft: #16314a; + --blue: #42a5f5; + --blue-deep: #2a8fdb; + --blue-on: #bcdffb; + + --yellow-soft: #322b0c; + --yellow: #f5d426; + --yellow-deep: #cdae1f; + --yellow-on: #f4e29a; + + /* surfaces β€” Discord-ish cold grey ladder */ + --bg: #2b2d31; + --bg-sunken: #1e1f22; + --bg-raised: #313338; + --surface-card: #383a40; + --surface-overlay: #3e4046; + + /* text β€” cool soft whites */ + --text-strong: #f2f3f5; + --text-body: #dbdee1; + --text-muted: #969ba3; + --text-faint: #6b7077; + --text-inverse: var(--warm-900); + + /* borders */ + --border-soft: #26282c; + --border-default: #26282b; + --border-strong: #141517; + --border-interactive: #4a4d54; + + --brand-contrast: #06243b; + + /* dark effect overrides */ + --shadow-soft-sm: 0 2px 6px rgba(0, 0, 0, 0.4); + --shadow-soft: 0 8px 24px rgba(0, 0, 0, 0.5); + --shadow-soft-lg: 0 20px 48px rgba(0, 0, 0, 0.6); + --highlight-top: inset 0 1px 0 0 rgba(255, 255, 255, 0.12); + --highlight-top-strong: inset 0 1.5px 0 0 rgba(255, 255, 255, 0.18); + + --dot-color: rgba(242, 243, 245, 0.07); + --scrim: rgba(0, 0, 0, 0.6); } @theme { - --font-sans: 'Plus Jakarta Sans', system-ui, -apple-system, 'Segoe UI', sans-serif; - --font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code', monospace; + --font-sans: 'Plus Jakarta Sans', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; + --font-mono: 'Fira Code', ui-monospace, 'SF Mono', 'JetBrains Mono', 'Menlo', monospace; } @theme inline { + /* shadcn semantic colors (resolve at runtime β†’ theme switch works) */ --color-background: var(--background); --color-background-grid: var(--background-grid); --color-foreground: var(--foreground); @@ -184,10 +382,10 @@ --color-chart-3: var(--chart-3); --color-chart-4: var(--chart-4); --color-chart-5: var(--chart-5); - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); + --radius-sm: var(--radius-sm); + --radius-md: var(--radius-md); + --radius-lg: var(--radius-lg); + --radius-xl: var(--radius-xl); --color-sidebar: var(--sidebar); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-primary: var(--sidebar-primary); @@ -196,7 +394,44 @@ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); - /* tinyForge ramp exposed as utilities (bg-navy-700, text-cyan, …) */ + + /* design-system surfaces / text exposed as utilities (bg-surface-card, …) */ + --color-bg: var(--bg); + --color-bg-sunken: var(--bg-sunken); + --color-bg-raised: var(--bg-raised); + --color-surface-card: var(--surface-card); + --color-surface-overlay: var(--surface-overlay); + --color-text-strong: var(--text-strong); + --color-text-body: var(--text-body); + --color-text-muted: var(--text-muted); + --color-text-faint: var(--text-faint); + + /* brand + status + four inks exposed as utilities */ + --color-brand: var(--brand); + --color-brand-deep: var(--brand-deep); + --color-brand-soft: var(--brand-soft); + --color-brand-on: var(--brand-on); + --color-brand-contrast: var(--brand-contrast); + --color-status-ok: var(--status-ok); + --color-status-error: var(--status-error); + --color-status-warn: var(--status-warn); + --color-status-info: var(--status-info); + --color-ink-red: var(--red); + --color-ink-red-soft: var(--red-soft); + --color-ink-green: var(--green); + --color-ink-green-soft: var(--green-soft); + --color-ink-blue: var(--blue); + --color-ink-blue-soft: var(--blue-soft); + --color-ink-yellow: var(--yellow); + --color-ink-yellow-soft: var(--yellow-soft); + + /* border tokens */ + --color-border-soft: var(--border-soft); + --color-border-default: var(--border-default); + --color-border-strong: var(--border-strong); + --color-border-interactive: var(--border-interactive); + + /* legacy ramps still referenced by un-migrated chrome */ --color-navy-1000: var(--navy-1000); --color-navy-900: var(--navy-900); --color-navy-800: var(--navy-800); @@ -226,6 +461,10 @@ body { @apply bg-background text-foreground font-sans antialiased; } + ::selection { + background: var(--yellow); + color: var(--warm-900); + } ::-webkit-scrollbar { width: 10px; height: 10px; @@ -234,23 +473,66 @@ background: transparent; } ::-webkit-scrollbar-thumb { - background: var(--navy-400); + background: var(--border-interactive); border-radius: 999px; border: 2px solid transparent; background-clip: content-box; } ::-webkit-scrollbar-thumb:hover { - background: var(--navy-300); + background: var(--text-faint); border: 2px solid transparent; background-clip: content-box; } } -/* Signature gradient wordmark/text treatment. */ +/* ── mono eyebrow / label β€” the maker-signature microtype ──────────────────── + e.g. SERIAL MONITOR, β–Έ HARDWARE */ +.ts-eyebrow { + font-family: var(--font-mono); + font-size: var(--text-xs); + font-weight: var(--fw-medium); + letter-spacing: var(--tracking-mono-label); + text-transform: uppercase; + color: var(--text-muted); +} + +/* ── notebook dot-grid background (the one decorative motif) ───────────────── */ +.dot-grid { + background-color: var(--bg); + background-image: var(--dots); + background-size: var(--dot-gap) var(--dot-gap); + background-position: -1px -1px; +} +.dot-grid-tight { + --dot-gap: 12px; +} +.dot-grid-loose { + --dot-gap: 26px; +} + +/* ── RISO registration offset β€” colorful decorative graphics ONLY ─────────── */ +.ts-riso { + position: relative; + display: inline-block; + color: var(--text-strong); + isolation: isolate; +} +.ts-riso::before { + content: attr(data-riso-glyph); + position: absolute; + inset: 0; + transform: translate(var(--riso-x, 2px), var(--riso-y, 2px)); + color: var(--riso-ink, var(--red)); + mix-blend-mode: multiply; + z-index: -1; + pointer-events: none; +} +.dark .ts-riso::before { + mix-blend-mode: screen; +} + +/* Legacy wordmark hook β€” now a flat brand treatment (no neon gradient). + The Header wordmark is restyled in the chrome phase. */ .tf-gradient-text { - background: var(--grad-brand); - -webkit-background-clip: text; - background-clip: text; - -webkit-text-fill-color: transparent; - color: transparent; + color: var(--brand); } diff --git a/src/renderer/src/assets/ds-components.css b/src/renderer/src/assets/ds-components.css new file mode 100644 index 0000000..a13e535 --- /dev/null +++ b/src/renderer/src/assets/ds-components.css @@ -0,0 +1,717 @@ +/* ============================================================================ + tinyStudio β€” component layer + ---------------------------------------------------------------------------- + Tactile "pixel-art" primitives + additive display tokens, ported from the + tinyStudio Design System. Everything here is token-driven (see base.css), so + light + dark resolve for free. Imported after Tailwind so these classes can + be composed with utilities on the app's Radix-based components. + ============================================================================ */ + +/* ── TACTILE depth helper β€” shared by Button (filled variants) ──────────────── + A filled control = inset top highlight + a solid colored bottom edge + a soft + ambient shadow. Lifts on hover, presses DOWN on click. Variants set --_edge. */ +.tactile { + /* --_edge is set per-variant (e.g. brand-deep under a blue button) so the + bottom edge is a darker shade of the button color, not grey. */ + --_amb: 0 4px 10px rgba(20, 19, 16, 0.16); + box-shadow: + var(--highlight-top), + 0 2px 0 0 var(--_edge, var(--border-default)), + var(--_amb); + transition: + transform var(--dur-fast) var(--ease-snap), + box-shadow var(--dur-fast) var(--ease-snap), + background-color var(--dur-fast) var(--ease-snap), + filter var(--dur-fast) var(--ease-snap); +} +.tactile:hover:not(:disabled) { + transform: translateY(-1px); + filter: brightness(1.04); + box-shadow: + var(--highlight-top), + 0 3px 0 0 var(--_edge, var(--border-default)), + 0 7px 16px rgba(20, 19, 16, 0.2); +} +.tactile:active:not(:disabled) { + transform: translateY(2px); + filter: brightness(0.99); + box-shadow: + var(--highlight-top), + 0 0 0 0 var(--_edge, var(--border-default)), + 0 1px 3px rgba(20, 19, 16, 0.18); +} +.tactile:disabled { + box-shadow: + var(--highlight-top), + 0 2px 0 0 var(--_edge, var(--border-default)); +} + +/* bottom bar reads in Plus Jakarta Sans (no mono down here) */ +.ts-statusbar, +.ts-statusbar .ts-status, +.ts-statusbar .ts-status__label { + font-family: var(--font-sans); +} + +/* Bordered tactile (secondary / outline / icon) β€” adds a 1.5px inset ring that + the press collapses along with the bottom edge. */ +.tactile-bordered { + --_edge: var(--border-default); + box-shadow: + var(--highlight-top), + 0 0 0 1.5px var(--border-default) inset, + 0 2px 0 0 var(--_edge), + 0 3px 6px rgba(20, 19, 16, 0.12); + transition: + transform var(--dur-fast) var(--ease-snap), + box-shadow var(--dur-fast) var(--ease-snap), + background-color var(--dur-fast) var(--ease-snap), + filter var(--dur-fast) var(--ease-snap); +} +.tactile-bordered:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: + var(--highlight-top), + 0 0 0 1.5px var(--border-interactive) inset, + 0 3px 0 0 var(--_edge), + 0 6px 12px rgba(20, 19, 16, 0.16); +} +.tactile-bordered:active:not(:disabled) { + transform: translateY(2px); + box-shadow: + var(--highlight-top), + 0 0 0 1.5px var(--border-default) inset, + 0 0 0 0 var(--_edge), + 0 1px 2px rgba(20, 19, 16, 0.14); +} +.tactile-bordered:disabled { + box-shadow: + var(--highlight-top), + 0 0 0 1.5px var(--border-default) inset, + 0 2px 0 0 var(--_edge); +} + +/* ── CHECKBOX β€” tactile, hard-edged ──────────────────────────────────────── */ +.ts-check { + display: inline-flex; + align-items: flex-start; + gap: var(--space-2); + font-family: var(--font-sans); + font-size: var(--text-sm); + color: var(--text-body); + cursor: pointer; + user-select: none; +} +.ts-check input { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} +.ts-check__box { + flex: none; + width: 18px; + height: 18px; + margin-top: 1px; + border: 1.5px solid var(--border-default); + border-radius: var(--radius-xs); + background: var(--surface-card); + box-shadow: + var(--highlight-top), + 0 1px 2px rgba(20, 19, 16, 0.1); + display: inline-flex; + align-items: center; + justify-content: center; + color: #fff; + transition: + background-color var(--dur-fast) var(--ease-snap), + border-color var(--dur-fast) var(--ease-snap), + transform var(--dur-fast) var(--ease-snap); +} +.ts-check__box svg { + width: 13px; + height: 13px; + opacity: 0; + transform: scale(0.5); + transition: all var(--dur-fast) var(--ease-snap); +} +.ts-check input:checked + .ts-check__box { + background: var(--brand); + border-color: var(--brand); +} +.ts-check input:checked + .ts-check__box svg { + opacity: 1; + transform: scale(1); +} +.ts-check input:focus-visible + .ts-check__box { + outline: 2px solid var(--ring); + outline-offset: 2px; +} +.ts-check:active .ts-check__box { + transform: translateY(1px); + box-shadow: var(--highlight-top); +} +.ts-check input:disabled ~ * { + opacity: 0.5; +} +.ts-check--disabled { + cursor: not-allowed; +} +.ts-check__label { + line-height: 1.4; +} + +/* ── RADIO ───────────────────────────────────────────────────────────────── */ +.ts-radio { + display: inline-flex; + align-items: flex-start; + gap: var(--space-2); + font-family: var(--font-sans); + font-size: var(--text-sm); + color: var(--text-body); + cursor: pointer; + user-select: none; +} +.ts-radio input { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} +.ts-radio__dot { + flex: none; + width: 18px; + height: 18px; + margin-top: 1px; + border-radius: 50%; + border: 1.5px solid var(--border-default); + background: var(--surface-card); + box-shadow: + var(--highlight-top), + 0 1px 2px rgba(20, 19, 16, 0.1); + display: inline-flex; + align-items: center; + justify-content: center; + transition: + transform var(--dur-fast) var(--ease-snap), + border-color var(--dur-fast) var(--ease-snap); +} +.ts-radio__dot::after { + content: ''; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--brand); + transform: scale(0); + transition: transform var(--dur-fast) var(--ease-snap); +} +.ts-radio input:checked + .ts-radio__dot { + border-color: var(--brand); +} +.ts-radio input:checked + .ts-radio__dot::after { + transform: scale(1); +} +.ts-radio input:focus-visible + .ts-radio__dot { + outline: 2px solid var(--ring); + outline-offset: 2px; +} +.ts-radio:active .ts-radio__dot { + transform: translateY(1px); +} +.ts-radio input:disabled ~ * { + opacity: 0.5; +} +.ts-radiogroup { + display: flex; + flex-direction: column; + gap: var(--space-2); +} +.ts-radiogroup--row { + flex-direction: row; + gap: var(--space-5); +} + +/* ── BADGE β€” compact status/label token (mono) ───────────────────────────── */ +.ts-badge { + display: inline-flex; + align-items: center; + gap: 5px; + font-family: var(--font-mono); + font-size: var(--text-2xs); + font-weight: var(--fw-medium); + letter-spacing: 0.03em; + line-height: 1; + padding: 3px 8px; + border-radius: var(--radius-pill); + white-space: nowrap; + border: 1px solid transparent; +} +.ts-badge__dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + flex: none; +} +.ts-badge--soft.ts-badge--neutral { + background: var(--bg-sunken); + color: var(--text-muted); + border-color: var(--border-soft); +} +.ts-badge--soft.ts-badge--red { + background: var(--red-soft); + color: var(--red-on); +} +.ts-badge--soft.ts-badge--green { + background: var(--green-soft); + color: var(--green-on); +} +.ts-badge--soft.ts-badge--blue { + background: var(--blue-soft); + color: var(--blue-on); +} +.ts-badge--soft.ts-badge--yellow { + background: var(--yellow-soft); + color: var(--yellow-on); +} +.ts-badge--solid { + color: #fff; +} +.ts-badge--solid.ts-badge--neutral { + background: var(--warm-700); +} +.ts-badge--solid.ts-badge--red { + background: var(--red); +} +.ts-badge--solid.ts-badge--green { + background: var(--green); +} +.ts-badge--solid.ts-badge--blue { + background: var(--blue); +} +.ts-badge--solid.ts-badge--yellow { + background: var(--yellow); + color: var(--warm-900); +} +.ts-badge--outline { + background: transparent; +} +.ts-badge--outline.ts-badge--neutral { + color: var(--text-muted); + border-color: var(--border-default); +} +.ts-badge--outline.ts-badge--red { + color: var(--red); + border-color: var(--red); +} +.ts-badge--outline.ts-badge--green { + color: var(--green); + border-color: var(--green); +} +.ts-badge--outline.ts-badge--blue { + color: var(--blue); + border-color: var(--blue); +} +.ts-badge--outline.ts-badge--yellow { + color: var(--yellow-on); + border-color: var(--yellow); +} + +/* ── TAG / CHIP β€” libraries, board capabilities, filters ─────────────────── */ +.ts-tag { + display: inline-flex; + align-items: center; + gap: 6px; + font-family: var(--font-mono); + font-size: var(--text-xs); + font-weight: var(--fw-medium); + color: var(--text-body); + background: var(--bg-sunken); + border: 1.5px solid var(--border-soft); + border-radius: var(--radius-sm); + padding: 4px 8px; + line-height: 1.2; + white-space: nowrap; +} +.ts-tag--clickable { + cursor: pointer; + transition: + background-color var(--dur-fast), + border-color var(--dur-fast); +} +.ts-tag--clickable:hover { + background: var(--surface-card); + border-color: var(--border-interactive); +} +.ts-tag--selected { + background: var(--brand-soft); + border-color: var(--brand); + color: var(--brand-on); +} +.ts-tag__ico { + display: inline-flex; + line-height: 0; + color: var(--text-muted); +} +.ts-tag--selected .ts-tag__ico { + color: var(--brand-on); +} +.ts-tag__x { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + margin-right: -2px; + border-radius: 3px; + cursor: pointer; + color: var(--text-muted); + line-height: 0; +} +.ts-tag__x:hover { + background: var(--border-default); + color: var(--text-strong); +} + +/* ── STATUS PILL β€” the IDE's board / connection indicator ────────────────── */ +.ts-status { + display: inline-flex; + align-items: center; + gap: 7px; + font-family: var(--font-mono); + font-size: var(--text-xs); + font-weight: var(--fw-medium); + color: var(--text-body); + background: var(--surface-card); + border: 1.5px solid var(--border-soft); + border-radius: var(--radius-pill); + padding: 4px 11px 4px 9px; + line-height: 1; + white-space: nowrap; +} +.ts-status--bare { + background: transparent; + border-color: transparent; + padding-left: 0; +} +.ts-status__dot { + position: relative; + width: 8px; + height: 8px; + border-radius: 50%; + flex: none; +} +.ts-status--ok .ts-status__dot { + background: var(--green); +} +.ts-status--error .ts-status__dot { + background: var(--red); +} +.ts-status--warn .ts-status__dot { + background: var(--yellow); +} +.ts-status--info .ts-status__dot { + background: var(--blue); +} +.ts-status--idle .ts-status__dot { + background: var(--warm-400); +} +.ts-status--pulse .ts-status__dot::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 50%; + background: inherit; + animation: ts-status-pulse 1.6s var(--ease-out) infinite; +} +@keyframes ts-status-pulse { + 0% { + transform: scale(1); + opacity: 0.6; + } + 70%, + 100% { + transform: scale(2.6); + opacity: 0; + } +} +.ts-status__label b { + font-weight: var(--fw-semibold); + color: var(--text-strong); +} + +/* ── STATUS BAR chrome β€” icon buttons, bell + history, panel menu ────────── + The status bar (like the top bar) is brand-blue in light, raised-grey in + dark; these classes flip via .ts-statusbar / .dark .ts-statusbar. */ + +/* StatusPill label sits on the colored bar */ +.ts-statusbar .ts-status__label, +.ts-statusbar .ts-status__label b { + color: #fff; +} +.dark .ts-statusbar .ts-status__label, +.dark .ts-statusbar .ts-status__label b { + color: var(--text-body); +} + +/* status-bar icon buttons (panel menu + bell triggers) */ +.st-iconbtn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 20px; + border: none; + background: transparent; + color: var(--text-muted); + cursor: pointer; + border-radius: var(--radius-xs); + padding: 0; +} +.st-iconbtn:hover { + background: var(--bg-sunken); + color: var(--text-body); +} +.st-iconbtn--on { + background: var(--brand-soft); + color: var(--brand); +} +.ts-statusbar .st-iconbtn { + color: #fff; +} +.ts-statusbar .st-iconbtn:hover { + background: rgba(255, 255, 255, 0.18); + color: #fff; +} +.ts-statusbar .st-iconbtn--on { + background: rgba(255, 255, 255, 0.24); + color: #fff; +} +.dark .ts-statusbar .st-iconbtn { + color: var(--text-muted); +} +.dark .ts-statusbar .st-iconbtn:hover { + background: var(--bg-sunken); + color: var(--text-body); +} +.dark .ts-statusbar .st-iconbtn--on { + background: var(--surface-card); + color: var(--text-strong); +} + +.st-bell-badge { + position: absolute; + top: -3px; + right: -3px; + min-width: 13px; + height: 13px; + padding: 0 3px; + border-radius: 999px; + background: var(--red); + color: #fff; + font-family: var(--font-sans); + font-size: 9px; + font-weight: 700; + line-height: 13px; + text-align: center; + box-shadow: 0 0 0 1.5px var(--bg-raised); +} +.ts-statusbar .st-bell-badge { + box-shadow: 0 0 0 1.5px var(--brand); +} +.dark .ts-statusbar .st-bell-badge { + box-shadow: 0 0 0 1.5px var(--bg-raised); +} + +/* ── notification bell + history popover (anchored bottom-right) ──────────── */ +.notif { + position: relative; + display: inline-flex; + margin-left: 4px; +} +.notif__pop { + position: absolute; + bottom: calc(100% + 8px); + right: -6px; + width: 320px; + max-height: 320px; + display: flex; + flex-direction: column; + background: var(--surface-overlay); + border: 1.5px solid var(--border-default); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-soft-lg); + overflow: hidden; + animation: notif-pop var(--dur-med) var(--ease-out); +} +@keyframes notif-pop { + from { + transform: translateY(6px) scale(0.98); + } + to { + transform: none; + } +} +.notif__head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 10px 10px 14px; + border-bottom: 1.5px solid var(--border-soft); +} +.notif__hbtns { + display: flex; + gap: 2px; +} +.notif__hbtn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + background: none; + color: var(--text-muted); + cursor: pointer; + border-radius: var(--radius-sm); +} +.notif__hbtn:hover { + background: var(--bg-sunken); + color: var(--text-strong); +} +.notif__body { + overflow: auto; + padding: 6px; + display: flex; + flex-direction: column; + gap: 2px; +} +.notif__empty { + padding: 22px 14px; + text-align: center; + font-family: var(--font-sans); + font-size: 13px; + color: var(--text-muted); +} +.notif__item { + display: flex; + gap: 9px; + padding: 9px 10px; + border-radius: var(--radius-md); +} +.notif__item:hover { + background: var(--bg-sunken); +} +.notif__dot { + flex: none; + width: 8px; + height: 8px; + margin-top: 5px; + border-radius: 50%; +} +.notif__dot--ok { + background: var(--green); +} +.notif__dot--error { + background: var(--red); +} +.notif__dot--warn { + background: var(--yellow); +} +.notif__dot--info { + background: var(--blue); +} +.notif__itembody { + min-width: 0; +} +.notif__title { + font-family: var(--font-sans); + font-size: 13px; + font-weight: 700; + color: var(--text-strong); +} +.notif__msg { + font-family: var(--font-sans); + font-size: 12.5px; + line-height: 1.45; + color: var(--text-muted); + margin-top: 2px; +} + +/* ── panels "thought-bubble" menu (left / serial / right) ────────────────── */ +.pmenu { + position: relative; + display: inline-flex; + margin-left: 2px; +} +.pmenu__pop { + position: absolute; + bottom: calc(100% + 9px); + right: -4px; + display: flex; + gap: 4px; + padding: 6px; + background: var(--surface-overlay); + border: 1.5px solid var(--border-default); + border-radius: var(--radius-md); + box-shadow: var(--shadow-soft-lg); + animation: notif-pop var(--dur-med) var(--ease-out); +} +.pmenu__pop::after { + content: ''; + position: absolute; + bottom: -6px; + right: 13px; + width: 9px; + height: 9px; + background: var(--surface-overlay); + border-right: 1.5px solid var(--border-default); + border-bottom: 1.5px solid var(--border-default); + transform: rotate(45deg); +} +.pmenu__btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 30px; + border: none; + background: transparent; + color: var(--text-muted); + cursor: pointer; + border-radius: var(--radius-sm); +} +.pmenu__btn:hover { + background: var(--bg-sunken); + color: var(--text-body); +} +.pmenu__btn.on { + background: var(--brand-soft); + color: var(--brand); +} +.dark .pmenu__btn.on { + background: var(--border-interactive); + color: var(--text-strong); +} +.pmenu__btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* ── notification code block (e.g. the "Start tinyService" command) ───────── + A darker, monospace, selectable block so a command/link is readable and you + can copy it straight out of the bell. */ +.notif__code { + display: block; + margin-top: 6px; + padding: 7px 9px; + border-radius: var(--radius-sm); + background: var(--bg-sunken); + border: 1px solid var(--border-default); + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-strong); + white-space: pre-wrap; + word-break: break-all; + user-select: text; +} diff --git a/src/renderer/src/components/.fuse_hidden0000001300000001 b/src/renderer/src/components/.fuse_hidden0000001300000001 new file mode 100644 index 0000000..16de6c3 --- /dev/null +++ b/src/renderer/src/components/.fuse_hidden0000001300000001 @@ -0,0 +1,1864 @@ +/** + * DiagramEditor β€” renders a project's `diagram.json` as a live, editable circuit. + * + * The wiring system is ported from the tinySchematic prototype (a Fritzing-style + * breadboard/schematic wiring engine): absolute bendpoints that hold their place + * when parts move, orthogonal auto-routing with clean pin entry, junctions + * (wire-to-wire taps), straight/diagonal mode (hold Shift), per-segment / bend / + * endpoint handle editing, double-click to add or remove a bend, and a live + * equipotential net model that glows everything sharing a node. Wires are drawn + * exactly like tinySchematic: rounded glossy tubes (dark outline β†’ color core β†’ + * animated selection dashes) with a yellow net glow. + * + * Around it: a Fritzing-style two-column components rail, a right-hand Inspector + * that edits the selected part's name / location / rotation / properties (e.g. a + * resistor's value) β€” or a selected wire's color β€” drag-to-place, draggable part + * labels, zoom/fit, and Wokwi `diagram.json` serialization. + * + * Styling follows the tinyStudio design system: cold neutral surfaces, the four + * PCB inks, a notebook dot-grid canvas, tactile controls, mono eyebrows, + * sentence-case copy. + */ + +import { + ChevronDown, + ChevronRight, + CircuitBoard, + CodeXml, + Grid3x3, + Maximize, + MousePointer2, + Pencil, + Plus, + RotateCw, + Trash2, + Eye, + Spline, + Zap, + ZoomIn, + ZoomOut +} from 'lucide-react' +import React from 'react' +import { + ensureParts, + getPart, + loadPart, + partsByFamily, + registerPart, + viewFor, + type PartDef, + type PartMeta, + type ViewKind +} from '../lib/partsLibrary' +import { + bendsFromInstructions, + buildWirePoints, + calculateOrthogonalPath, + clampOntoSegment, + dragSegment, + getWireAt, + getWirePoints, + instructionsFromPoints, + isStraightRoute, + simplifyWirePoints, + type Connection, + type PinRef, + type Pt +} from '../lib/wireRouting' +import { buildNets, meaningfulNetCount } from '../lib/circuitNets' +import { PartsEditor } from './PartsEditor' + +const CANVAS_W = 1100 +const CANVAS_H = 640 +const GRID = 9.6 // 100 mil (0.1in) at 96 DPI β€” the standard breadboard pitch + +// wire look (mirrors tinySchematic config.js) +const WIRE_W = 2.8 +const WIRE_OUTLINE_W = WIRE_W + 1.8 +const WIRE_GLOW_W = WIRE_W + 5 +const WIRE_CORNER = 4 +const NET_GLOW = 'rgba(243, 203, 0, 0.30)' // brand-yellow equipotential glow + +interface Part { + id: string + type: string + left: number + top: number + rotate?: number // 0 | 90 | 180 | 270 (Wokwi-compatible) + attrs?: Record +} +// Per-view overrides for the schematic layout. The top-level parts/connections +// stay the Wokwi breadboard layout; the schematic view keeps its own positions +// and wire routes here so the two diagrams can diverge. A part/wire with no +// schematic entry falls back to its breadboard placement (until it's moved). +interface SchematicLayout { + pos: Record // partId -> [left, top] + routes: Record // connKey -> h/v instructions +} +interface Diagram { + parts: Part[] + connections: Connection[] + author?: string + schematic: SchematicLayout +} + +// The four PCB inks carry meaning (green = signal/connected, red = power/stop, +// blue = primary, gold = warn) plus a neutral white and slate for contrast. We +// persist concrete hex (not CSS vars) so the colors survive export. +const WIRE_COLORS = ['#2fa46a', '#e5544b', '#42a5f5', '#f3cb00', '#ffffff', '#79818c'] + +const uid = (): string => Math.random().toString(36).slice(2, 8) +const snap = (v: number): number => Math.round(v / GRID) * GRID + +// read-compat: accept Wokwi left/top or the legacy x/y +function readPart(p: Record): Part { + return { + id: String(p.id), + type: String(p.type), + left: Number(p.left ?? p.x ?? 0), + top: Number(p.top ?? p.y ?? 0), + rotate: p.rotate ? Number(p.rotate) : undefined, + attrs: (p.attrs as Record) || undefined + } +} + +// rotate a local point (px,py) around a part's centre by `deg` (CSS-rotate space) +function rotatePoint(px: number, py: number, w: number, h: number, deg: number): Pt { + const rad = (deg * Math.PI) / 180 + const cos = Math.cos(rad) + const sin = Math.sin(rad) + const dx = px - w / 2 + const dy = py - h / 2 + return { x: w / 2 + dx * cos - dy * sin, y: h / 2 + dx * sin + dy * cos } +} + +const eqRef = (a: PinRef, b: PinRef): boolean => { + if (typeof a === 'string' && typeof b === 'string') return a === b + if (typeof a === 'object' && typeof b === 'object') + return Math.abs(a.x - b.x) < 0.5 && Math.abs(a.y - b.y) < 0.5 + return false +} + +// Drag a bendpoint while keeping its neighbouring segments orthogonal (ported +// from tinySchematic's vertex-drag logic). +function vertexDrag(orig: Pt[], index: number, x: number, y: number): Pt[] { + const points = orig.map((p) => ({ x: p.x, y: p.y })) + points[index].x = x + points[index].y = y + if (index > 0) { + if ( + Math.abs(points[index - 1].y - points[index].y) < + Math.abs(points[index - 1].x - points[index].x) + ) { + if (index - 1 > 0) points[index - 1].y = points[index].y + else points[index].y = points[index - 1].y + } else { + if (index - 1 > 0) points[index - 1].x = points[index].x + else points[index].x = points[index - 1].x + } + } + if (index < points.length - 1) { + if ( + Math.abs(points[index + 1].y - points[index].y) < + Math.abs(points[index + 1].x - points[index].x) + ) { + if (index + 1 < points.length - 1) points[index + 1].y = points[index].y + else points[index].y = points[index + 1].y + } else { + if (index + 1 < points.length - 1) points[index + 1].x = points[index].x + else points[index].x = points[index + 1].x + } + } + return points +} + +// Build an SVG path with rounded corners (a quadratic fillet at each bend), +// matching tinySchematic's arcTo wire look. +function roundedPath(points: Pt[], r = WIRE_CORNER): string { + if (points.length < 2) return '' + if (points.length === 2) return `M${points[0].x} ${points[0].y} L${points[1].x} ${points[1].y}` + let d = `M${points[0].x} ${points[0].y}` + for (let i = 1; i < points.length - 1; i++) { + const p = points[i - 1] + const c = points[i] + const n = points[i + 1] + const d1 = Math.hypot(c.x - p.x, c.y - p.y) || 1 + const d2 = Math.hypot(n.x - c.x, n.y - c.y) || 1 + const rr = Math.max(0, Math.min(r, d1 / 2, d2 / 2)) + const a = { x: c.x - ((c.x - p.x) / d1) * rr, y: c.y - ((c.y - p.y) / d1) * rr } + const b = { x: c.x + ((n.x - c.x) / d2) * rr, y: c.y + ((n.y - c.y) / d2) * rr } + d += ` L${a.x} ${a.y} Q${c.x} ${c.y} ${b.x} ${b.y}` + } + const last = points[points.length - 1] + d += ` L${last.x} ${last.y}` + return d +} + +// A darker shade of a wire's color for its "3D" outline (Fritzing tube look). +function darken(hex: string, f = 0.55): string { + const m = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim()) + if (!m) return 'rgba(0,0,0,0.45)' + const n = parseInt(m[1], 16) + const r = Math.round(((n >> 16) & 255) * f) + const g = Math.round(((n >> 8) & 255) * f) + const b = Math.round((n & 255) * f) + return `rgb(${r}, ${g}, ${b})` +} + +// A small SVG thumbnail for the palette / part tiles. Fritzing icon art is dark +// line-work meant for a light background, so we put it on a light chip. +function Thumb({ svg, size = 40 }: { svg?: string; size?: number }): React.JSX.Element { + if (!svg) + return ( +
+ +
+ ) + return ( +
+ ) +} + +type HandleKind = 'vertex' | 'edge' | 'endpoint' + +export function DiagramEditor({ + content, + onChange, + onOpenCode +}: { + content: string + onChange: (next: string) => void + // Switch to the Code view with diagram.json selected (the `` button). + onOpenCode?: () => void +}): React.JSX.Element { + const diagram: Diagram = React.useMemo(() => { + try { + const d = JSON.parse(content || '{}') + const sch = d.schematic || {} + return { + parts: (d.parts || []).map(readPart), + connections: (d.connections || []) as Connection[], + author: d.author, + schematic: { pos: sch.pos || {}, routes: sch.routes || {} } + } + } catch { + return { parts: [], connections: [], schematic: { pos: {}, routes: {} } } + } + }, [content]) + + const [view, setView] = React.useState('breadboard') + const [editable, setEditable] = React.useState(false) + const [grid, setGrid] = React.useState(true) + const [editorPart, setEditorPart] = React.useState(undefined) // undefined = closed + const [selPart, setSelPart] = React.useState(null) + const [selWire, setSelWire] = React.useState(null) + const [wireColor, setWireColor] = React.useState(WIRE_COLORS[0]) + const [armed, setArmed] = React.useState<{ + from: PinRef + points: Pt[] + straight: boolean + } | null>(null) + const [hoverPin, setHoverPin] = React.useState<{ id: string; pin: string } | null>(null) + const [hoverWire, setHoverWire] = React.useState(null) + const [mouse, setMouse] = React.useState({ x: 0, y: 0 }) + const [straight, setStraight] = React.useState(false) + const [collapsed, setCollapsed] = React.useState>(new Set()) + const [editPts, setEditPts] = React.useState(null) // selected-wire editing buffer + const [newAttr, setNewAttr] = React.useState('') + const [tick, force] = React.useReducer((n) => n + 1, 0) + + const viewRef = React.useRef(null) + const innerRef = React.useRef(null) + const drag = React.useRef<{ + id: string + offX: number + offY: number + affected: { i: number; bends: Pt[]; straight: boolean }[] + } | null>(null) + const labelDrag = React.useRef<{ id: string; ox: number; oy: number } | null>(null) + const pan = React.useRef<{ x: number; y: number } | null>(null) + const handleDrag = React.useRef<{ kind: HandleKind } | null>(null) + const [cam, setCam] = React.useState({ scale: 0.62, tx: 0, ty: 0 }) + const scale = cam.scale + const didInit = React.useRef(false) + + React.useEffect(() => { + const types = diagram.parts.map((p) => p.type).filter((t) => !getPart(t)) + if (types.length) ensureParts(types).then(force) + }, [diagram.parts]) + + const write = (d: Partial): void => { + const sch = d.schematic || diagram.schematic + const hasSch = Object.keys(sch.pos).length > 0 || Object.keys(sch.routes).length > 0 + const next = { + version: 1, + editor: 'tinystudio', + author: diagram.author, + parts: (d.parts || diagram.parts).map((p) => ({ + type: p.type, + id: p.id, + left: p.left, + top: p.top, + ...(p.rotate ? { rotate: p.rotate } : {}), + ...(p.attrs && Object.keys(p.attrs).length ? { attrs: p.attrs } : {}) + })), + connections: d.connections || diagram.connections, + ...(hasSch ? { schematic: sch } : {}) + } + onChange(JSON.stringify(next, null, 2)) + } + + // ── per-view layout helpers ───────────────────────────────────────────────── + + const refStr = (r: PinRef): string => (typeof r === 'string' ? r : `${r.x},${r.y}`) + const connKey = (c: Connection): string => `${refStr(c[0])}>${refStr(c[1])}` + // position of a part in the *current* view (schematic falls back to breadboard) + const partXY = (part: Part): [number, number] => + view === 'schematic' + ? diagram.schematic.pos[part.id] || [part.left, part.top] + : [part.left, part.top] + // wire route in the current view (schematic starts clean, then diverges) + const routeFor = (c: Connection): string[] => + view === 'schematic' ? (diagram.schematic.routes[connKey(c)] ?? []) : c[3] || [] + + // ── geometry ────────────────────────────────────────────────────────────── + + const pinAbsAt = (part: Part, pin: string, left: number, top: number): Pt | null => { + const def = getPart(part.type) + if (!def) return null + const v = viewFor(def, view) + const p = v?.pins[pin] + if (!p) return null + const rp = part.rotate ? rotatePoint(p[0], p[1], v!.w, v!.h, part.rotate) : { x: p[0], y: p[1] } + return { x: left + rp.x, y: top + rp.y } + } + const pinAbs = (part: Part, pin: string): Pt | null => { + const [left, top] = partXY(part) + return pinAbsAt(part, pin, left, top) + } + // Resolve refs, optionally overriding some parts' positions (used mid-drag so + // bends stay fixed in space while a part moves). + const resolveWith = + (posMap: Record) => + (ref: PinRef): Pt | null => { + if (typeof ref === 'object') return ref + const [id, pin] = ref.split(':') + const part = diagram.parts.find((p) => p.id === id) + if (!part) return null + const [left, top] = posMap[id] ?? partXY(part) + return pinAbsAt(part, pin, left, top) + } + const resolve = resolveWith({}) + const wirePts = (c: Connection): Pt[] => getWirePoints(c, resolve, routeFor(c)) + + const canvasPoint = (e: { clientX: number; clientY: number }): Pt => { + const r = innerRef.current!.getBoundingClientRect() + return { x: (e.clientX - r.left) / scale, y: (e.clientY - r.top) / scale } + } + const getPinAtWorld = (wx: number, wy: number): { id: string; pin: string; pos: Pt } | null => { + const tol = 8 / scale + for (const part of diagram.parts) { + const def = getPart(part.type) + const v = def && viewFor(def, view) + if (!v) continue + for (const pin of Object.keys(v.pins)) { + const pos = pinAbs(part, pin) + if (pos && Math.hypot(pos.x - wx, pos.y - wy) < tol) return { id: part.id, pin, pos } + } + } + return null + } + + // ── equipotential net model (drives net glow + the routed count) ──────────── + + const netModel = React.useMemo( + () => buildNets(diagram.parts, diagram.connections, (c) => wirePts(c)), + // eslint-disable-next-line react-hooks/exhaustive-deps + [diagram, view, tick] + ) + // Highlight the net under the hovered pin, hovered wire, or selected wire β€” so + // you can see (and effectively "select") a whole net. + const highlightNet = React.useMemo(() => { + if (hoverPin) { + const i = netModel.pinToNet.get(`${hoverPin.id}:${hoverPin.pin}`) + if (i != null) return i + } + if (hoverWire != null) { + const i = netModel.connToNet[hoverWire] + if (i >= 0) return i + } + if (selWire != null) { + const i = netModel.connToNet[selWire] + if (i >= 0) return i + } + return -1 + }, [hoverPin, hoverWire, selWire, netModel]) + + // ── camera ────────────────────────────────────────────────────────────────── + + const fitView = (): void => { + const el = viewRef.current + if (!el) return + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + for (const part of diagram.parts) { + const def = getPart(part.type) + const v = def && viewFor(def, view) + if (!v) continue + const [l, t] = partXY(part) + minX = Math.min(minX, l) + minY = Math.min(minY, t) + maxX = Math.max(maxX, l + v.w) + maxY = Math.max(maxY, t + v.h) + } + for (const c of diagram.connections) { + for (const p of wirePts(c)) { + minX = Math.min(minX, p.x) + minY = Math.min(minY, p.y) + maxX = Math.max(maxX, p.x) + maxY = Math.max(maxY, p.y) + } + } + if (!isFinite(minX)) { + const f = Math.min(el.clientWidth / CANVAS_W, el.clientHeight / CANVAS_H, 1) + setCam({ + scale: f, + tx: (el.clientWidth - CANVAS_W * f) / 2, + ty: (el.clientHeight - CANVAS_H * f) / 2 + }) + return + } + const pad = 40 + minX -= pad + minY -= pad + maxX += pad + maxY += pad + const bw = maxX - minX + const bh = maxY - minY + const f = Math.max(0.25, Math.min(3, Math.min(el.clientWidth / bw, el.clientHeight / bh))) + setCam({ + scale: f, + tx: (el.clientWidth - bw * f) / 2 - minX * f, + ty: (el.clientHeight - bh * f) / 2 - minY * f + }) + } + React.useEffect(() => { + if (didInit.current) return + if (diagram.parts.length === 0 || diagram.parts.every((p) => getPart(p.type))) { + fitView() + didInit.current = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [content, tick]) + + const zoomAt = (factor: number, sx: number, sy: number): void => { + setCam((c) => { + const ns = Math.max(0.25, Math.min(3, c.scale * factor)) + const k = ns / c.scale + return { scale: ns, tx: sx - (sx - c.tx) * k, ty: sy - (sy - c.ty) * k } + }) + } + const zoomCenter = (factor: number): void => { + const el = viewRef.current + if (el) zoomAt(factor, el.clientWidth / 2, el.clientHeight / 2) + } + const onWheel = (e: React.WheelEvent): void => { + const rect = viewRef.current!.getBoundingClientRect() + zoomAt(e.deltaY < 0 ? 1.12 : 1 / 1.12, e.clientX - rect.left, e.clientY - rect.top) + } + const onViewPointerDown = (e: React.PointerEvent): void => { + if (e.button !== 1 && !(e.button === 0 && e.altKey)) return + e.preventDefault() + pan.current = { x: e.clientX, y: e.clientY } + window.addEventListener('pointermove', onPanMove) + window.addEventListener('pointerup', onPanUp) + } + const onPanMove = (e: PointerEvent): void => { + const p = pan.current + if (!p) return + setCam((c) => ({ ...c, tx: c.tx + (e.clientX - p.x), ty: c.ty + (e.clientY - p.y) })) + pan.current = { x: e.clientX, y: e.clientY } + } + const onPanUp = (): void => { + pan.current = null + window.removeEventListener('pointermove', onPanMove) + window.removeEventListener('pointerup', onPanUp) + } + + // ── shared: re-sync a part's wires after a discrete move/rotate ────────────── + + const touches = (ref: PinRef, id: string): boolean => + typeof ref === 'string' && ref.split(':')[0] === id + + const collectFrozen = (id: string): { i: number; bends: Pt[]; straight: boolean }[] => { + const f: { i: number; bends: Pt[]; straight: boolean }[] = [] + diagram.connections.forEach((c, i) => { + if (!touches(c[0], id) && !touches(c[1], id)) return + const route = routeFor(c) + const s = resolve(c[0]) + const t = resolve(c[1]) + if (s && t) + f.push({ i, bends: bendsFromInstructions(route, s, t), straight: isStraightRoute(route) }) + }) + return f + } + // Apply a new parts array (and optional schematic-pos patch) for `id`, holding + // its wires' bends fixed in world space. + const commitParts = ( + parts: Part[], + id: string, + schPosPatch?: Record + ): void => { + const frozen = collectFrozen(id) + const schPos = schPosPatch + ? { ...diagram.schematic.pos, ...schPosPatch } + : diagram.schematic.pos + const resolveNew = (ref: PinRef): Pt | null => { + if (typeof ref === 'object') return ref + const [pid, pin] = ref.split(':') + const part = parts.find((p) => p.id === pid) + if (!part) return null + const [l, t] = + view === 'schematic' ? schPos[pid] || [part.left, part.top] : [part.left, part.top] + return pinAbsAt(part, pin, l, t) + } + if (view === 'schematic') { + const routes = { ...diagram.schematic.routes } + frozen.forEach((a) => { + const c = diagram.connections[a.i] + const s = resolveNew(c[0]) + const t = resolveNew(c[1]) + if (s && t) + routes[connKey(c)] = instructionsFromPoints( + buildWirePoints(s, a.bends, t, a.straight), + a.straight + ) + }) + write({ parts, schematic: { pos: schPos, routes } }) + } else { + const conns = diagram.connections.slice() + frozen.forEach((a) => { + const c = diagram.connections[a.i] + const s = resolveNew(c[0]) + const t = resolveNew(c[1]) + if (s && t) { + const nc = conns[a.i].slice() as Connection + nc[3] = instructionsFromPoints(buildWirePoints(s, a.bends, t, a.straight), a.straight) + conns[a.i] = nc + } + }) + write({ parts, connections: conns }) + } + } + + // ── part drag (absolute bendpoints: bends hold while the part moves) ───────── + + const onPartDown = (e: React.PointerEvent, part: Part): void => { + if (!editable || (e.target as HTMLElement).closest('.pin-hit')) return + e.stopPropagation() + setSelPart(part.id) + selectWire(null) + const r = innerRef.current!.getBoundingClientRect() + const [left, top] = partXY(part) + drag.current = { + id: part.id, + offX: (e.clientX - r.left) / scale - left, + offY: (e.clientY - r.top) / scale - top, + affected: collectFrozen(part.id) + } + window.addEventListener('pointermove', onPartMove) + window.addEventListener('pointerup', onPartUp) + } + const onPartMove = (e: PointerEvent): void => { + const d = drag.current + if (!d) return + const r = innerRef.current!.getBoundingClientRect() + const nx = snap((e.clientX - r.left) / scale - d.offX) + const ny = snap((e.clientY - r.top) / scale - d.offY) + const resolveNext = resolveWith({ [d.id]: [nx, ny] }) + const updates: { i: number; key: string; instr: string[] }[] = [] + for (const a of d.affected) { + const c = diagram.connections[a.i] + const src = resolveNext(c[0]) + const tgt = resolveNext(c[1]) + if (!src || !tgt) continue + const pts = buildWirePoints(src, a.bends, tgt, a.straight) + updates.push({ i: a.i, key: connKey(c), instr: instructionsFromPoints(pts, a.straight) }) + } + if (view === 'schematic') { + const routes = { ...diagram.schematic.routes } + updates.forEach((u) => (routes[u.key] = u.instr)) + write({ schematic: { pos: { ...diagram.schematic.pos, [d.id]: [nx, ny] }, routes } }) + } else { + const parts = diagram.parts.map((p) => (p.id === d.id ? { ...p, left: nx, top: ny } : p)) + const conns = diagram.connections.slice() + updates.forEach((u) => { + const nc = conns[u.i].slice() as Connection + nc[3] = u.instr + conns[u.i] = nc + }) + write({ parts, connections: conns }) + } + } + const onPartUp = (): void => { + drag.current = null + window.removeEventListener('pointermove', onPartMove) + window.removeEventListener('pointerup', onPartUp) + } + + // drag a part's title label to reposition it (offset stored on the part) + const onLabelDown = (e: React.PointerEvent, part: Part): void => { + if (!editable) return + e.stopPropagation() + setSelPart(part.id) + selectWire(null) + const start = canvasPoint(e) + const off = (part.attrs?.labelOffset as [number, number]) || [0, 0] + labelDrag.current = { id: part.id, ox: off[0] - start.x, oy: off[1] - start.y } + const move = (ev: PointerEvent): void => { + const ld = labelDrag.current + if (!ld) return + const cp = canvasPoint(ev) + const offset: [number, number] = [Math.round(ld.ox + cp.x), Math.round(ld.oy + cp.y)] + patchAttr(ld.id, 'labelOffset', offset) + } + const up = (): void => { + labelDrag.current = null + window.removeEventListener('pointermove', move) + window.removeEventListener('pointerup', up) + } + window.addEventListener('pointermove', move) + window.addEventListener('pointerup', up) + } + + // ── wiring ────────────────────────────────────────────────────────────────── + + const selectWire = (i: number | null): void => { + setSelWire(i) + setEditPts(i == null ? null : wirePts(diagram.connections[i])) + } + + const commitNewWire = (from: PinRef, to: PinRef, instr: string[]): void => { + const conn: Connection = + view === 'schematic' ? [from, to, wireColor] : [from, to, wireColor, instr] + if (view === 'schematic') { + write({ + connections: [...diagram.connections, conn], + schematic: { + ...diagram.schematic, + routes: { ...diagram.schematic.routes, [connKey(conn)]: instr } + } + }) + } else { + write({ connections: [...diagram.connections, conn] }) + } + } + + const finalizeSegment = (target: Pt, clean: boolean): { pts: Pt[]; str: boolean } => { + const pts = [...armed!.points] + const last = pts[pts.length - 1] + const str = armed!.straight || straight + if (str) pts.push(target) + else pts.push(...calculateOrthogonalPath(last.x, last.y, target.x, target.y, clean).slice(1)) + return { pts: simplifyWirePoints(pts), str } + } + + const onPinClick = (e: React.MouseEvent, part: Part, pin: string): void => { + if (!editable) return + e.stopPropagation() + const ref = `${part.id}:${pin}` + const pos = pinAbs(part, pin) + if (!pos) return + if (!armed) { + setArmed({ from: ref, points: [pos], straight: false }) + setSelPart(null) + selectWire(null) + return + } + if (eqRef(armed.from, ref)) { + setArmed(null) + return + } + const { pts, str } = finalizeSegment(pos, true) + const instr = instructionsFromPoints(pts, str) + const exists = diagram.connections.some( + (c) => (eqRef(c[0], armed.from) && c[1] === ref) || (c[0] === ref && eqRef(c[1], armed.from)) + ) + if (!exists) commitNewWire(armed.from, ref, instr) + setArmed(null) + } + + // tap a junction onto an existing wire `i` at the click point + const tapJunction = (e: React.MouseEvent, i: number): void => { + const cp = canvasPoint(e) + const pts = wirePts(diagram.connections[i]) + const hit = getWireAt(cp.x, cp.y, [diagram.connections[i]], () => pts, scale) + const seg = hit ? { p1: hit.p1, p2: hit.p2 } : { p1: pts[0], p2: pts[1] } + const jp = clampOntoSegment(seg.p1, seg.p2, snap(cp.x), snap(cp.y)) + const { pts: fp, str } = finalizeSegment(jp, false) + commitNewWire(armed!.from, { x: jp.x, y: jp.y }, instructionsFromPoints(fp, str)) + setArmed(null) + } + + const onCanvasClick = (e: React.MouseEvent): void => { + if (armed) { + const cp = canvasPoint(e) + const hit = getWireAt(cp.x, cp.y, diagram.connections, (c) => wirePts(c), scale) + if (hit) { + tapJunction(e, hit.index) + return + } + const target = { x: snap(cp.x), y: snap(cp.y) } + const last = armed.points[armed.points.length - 1] + const seg = straight + ? [last, target] + : calculateOrthogonalPath(last.x, last.y, target.x, target.y, false) + setArmed({ + ...armed, + points: [...armed.points, ...seg.slice(1)], + straight: armed.straight || straight + }) + return + } + setSelPart(null) + selectWire(null) + } + const onCanvasMove = (e: React.PointerEvent): void => { + if (armed) setMouse(canvasPoint(e)) + } + + // ── wire editing β€” endpoint / bend / segment handles ───────────────────────── + + const resolveEndpointTarget = (wx: number, wy: number, selfIdx: number): PinRef => { + const pin = getPinAtWorld(wx, wy) + if (pin) return `${pin.id}:${pin.pin}` + const hit = getWireAt(wx, wy, diagram.connections, (c) => wirePts(c), scale) + if (hit && hit.index !== selfIdx) return clampOntoSegment(hit.p1, hit.p2, snap(wx), snap(wy)) + return { x: snap(wx), y: snap(wy) } + } + + const writeWire = (i: number, opts: { instr: string[]; from?: PinRef; to?: PinRef }): void => { + const c = diagram.connections[i] + const oldKey = connKey(c) + const nc = c.slice() as Connection + if (opts.from !== undefined) nc[0] = opts.from + if (opts.to !== undefined) nc[1] = opts.to + const newKey = connKey(nc) + const conns = diagram.connections.slice() + if (view === 'schematic') { + const routes = { ...diagram.schematic.routes } + if (oldKey !== newKey) delete routes[oldKey] + routes[newKey] = opts.instr + conns[i] = nc + write({ connections: conns, schematic: { ...diagram.schematic, routes } }) + } else { + nc[3] = opts.instr + conns[i] = nc + if (oldKey !== newKey && diagram.schematic.routes[oldKey]) { + const routes = { ...diagram.schematic.routes } + routes[newKey] = routes[oldKey] + delete routes[oldKey] + write({ connections: conns, schematic: { ...diagram.schematic, routes } }) + } else { + write({ connections: conns }) + } + } + } + + // recolor an existing wire (conn[2] is the logical color, shared across views) + const recolorWire = (i: number, color: string): void => { + const conns = diagram.connections.slice() + const nc = conns[i].slice() as Connection + nc[2] = color + conns[i] = nc + write({ connections: conns }) + } + // colour swatch: recolour the selected wire if one is selected; else set the + // colour future wires are drawn in. + const pickColor = (color: string): void => { + setWireColor(color) + if (selWire != null) recolorWire(selWire, color) + } + + const startHandleDrag = ( + e: React.PointerEvent, + kind: HandleKind, + idx: number, + end?: 0 | 1 + ): void => { + if (!editable || selWire == null || !editPts) return + e.stopPropagation() + const wireIdx = selWire + const orig = editPts + const str = isStraightRoute(routeFor(diagram.connections[wireIdx])) + const axis: 'horizontal' | 'vertical' = + kind === 'edge' && Math.abs(orig[idx].y - orig[idx + 1].y) < 0.001 ? 'horizontal' : 'vertical' + const endIdx = end === 0 ? 0 : orig.length - 1 + let live = orig + let lastWorld = { x: 0, y: 0 } + handleDrag.current = { kind } + const move = (ev: PointerEvent): void => { + const cp = canvasPoint(ev) + if (kind === 'vertex') { + live = vertexDrag(orig, idx, snap(cp.x), snap(cp.y)) + } else if (kind === 'edge') { + live = dragSegment(orig, idx, axis, snap(cp.x), snap(cp.y)) + } else { + lastWorld = { x: cp.x, y: cp.y } + const p = { x: snap(cp.x), y: snap(cp.y) } + live = orig.map((pt, k) => (k === endIdx ? p : pt)) + } + setEditPts(live) + } + const up = (): void => { + window.removeEventListener('pointermove', move) + window.removeEventListener('pointerup', up) + handleDrag.current = null + let from: PinRef | undefined + let to: PinRef | undefined + if (kind === 'endpoint') { + const tgt = resolveEndpointTarget(lastWorld.x, lastWorld.y, wireIdx) + if (end === 0) from = tgt + else to = tgt + const rp = typeof tgt === 'object' ? tgt : resolve(tgt) + if (rp) live = live.map((pt, k) => (k === endIdx ? rp : pt)) + } + writeWire(wireIdx, { instr: instructionsFromPoints(simplifyWirePoints(live), str), from, to }) + } + window.addEventListener('pointermove', move) + window.addEventListener('pointerup', up) + } + + const onWireDoubleClick = (e: React.MouseEvent, i: number): void => { + if (!editable) return + e.stopPropagation() + const c = diagram.connections[i] + const str = isStraightRoute(routeFor(c)) + const cp = canvasPoint(e) + const pts = wirePts(c) + const handleR = 7 / scale + for (let k = 1; k < pts.length - 1; k++) { + if (Math.hypot(pts[k].x - cp.x, pts[k].y - cp.y) < handleR) { + const next = simplifyWirePoints(pts.filter((_, j) => j !== k)) + writeWire(i, { instr: instructionsFromPoints(next, str) }) + setEditPts(next) + return + } + } + const hit = getWireAt(cp.x, cp.y, [c], () => pts, scale) + if (hit) { + const next = pts.slice() + next.splice(hit.segmentIndex + 1, 0, { x: snap(cp.x), y: snap(cp.y) }) + writeWire(i, { instr: instructionsFromPoints(next, str) }) + selectWire(i) + setEditPts(next) + } + } + + // wire click: tap a junction while drawing, else select the wire + const onWireClick = (e: React.MouseEvent, i: number): void => { + e.stopPropagation() + if (armed) { + tapJunction(e, i) + return + } + if (!editable) { + selectWire(i) // view-mode: select to inspect / highlight its net + setSelPart(null) + return + } + selectWire(i) + setSelPart(null) + } + + React.useEffect(() => { + if (selWire != null && !handleDrag.current) { + const c = diagram.connections[selWire] + if (c) setEditPts(wirePts(c)) + else selectWire(null) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [content, view]) + + // ── palette / add / delete / properties ───────────────────────────────────── + + const addPart = async (type: string, at?: Pt): Promise => { + const def = getPart(type) || (await loadPart(type)) + if (!def) return + force() + const v = viewFor(def, view) + const w = v?.w || 80 + const h = v?.h || 40 + const left = at ? Math.round(at.x - w / 2) : Math.round(CANVAS_W / 2 - w / 2) + const top = at ? Math.round(at.y - h / 2) : Math.round(CANVAS_H / 2 - h / 2) + const pid = type + '_' + uid().slice(0, 3) + const parts = [...diagram.parts, { id: pid, type, left, top }] + write( + view === 'schematic' + ? { + parts, + schematic: { + ...diagram.schematic, + pos: { ...diagram.schematic.pos, [pid]: [left, top] } + } + } + : { parts } + ) + setSelPart(pid) + } + const onDrop = (e: React.DragEvent): void => { + e.preventDefault() + const type = e.dataTransfer.getData('text/tinystudio-part') + if (type) addPart(type, canvasPoint(e)) + } + + const editExisting = async (type: string): Promise => { + const def = getPart(type) || (await loadPart(type)) + if (def) setEditorPart(def) + } + + // patch a part's attrs (used by the Inspector + label drag) + const patchAttr = (id: string, key: string, value: unknown): void => { + write({ + parts: diagram.parts.map((p) => + p.id === id ? { ...p, attrs: { ...(p.attrs || {}), [key]: value } } : p + ) + }) + } + const removeAttr = (id: string, key: string): void => { + write({ + parts: diagram.parts.map((p) => { + if (p.id !== id) return p + const attrs = { ...(p.attrs || {}) } + delete attrs[key] + return { ...p, attrs } + }) + }) + } + // move a part to an absolute location in the current view (Inspector inputs) + const movePartTo = (id: string, left: number, top: number): void => { + if (view === 'schematic') { + commitParts(diagram.parts, id, { [id]: [left, top] }) + } else { + commitParts( + diagram.parts.map((p) => (p.id === id ? { ...p, left, top } : p)), + id + ) + } + } + const setRotation = (id: string, deg: number): void => { + commitParts( + diagram.parts.map((p) => (p.id === id ? { ...p, rotate: ((deg % 360) + 360) % 360 } : p)), + id + ) + } + const rotatePart = (id: string): void => { + const part = diagram.parts.find((p) => p.id === id) + if (part) setRotation(id, (part.rotate || 0) + 90) + } + + const deletePart = (id: string): void => { + const keep = (c: Connection): boolean => + (typeof c[0] !== 'string' || c[0].split(':')[0] !== id) && + (typeof c[1] !== 'string' || c[1].split(':')[0] !== id) + const routes = { ...diagram.schematic.routes } + const pos = { ...diagram.schematic.pos } + delete pos[id] + diagram.connections.forEach((c) => { + if (!keep(c)) delete routes[connKey(c)] + }) + write({ + parts: diagram.parts.filter((p) => p.id !== id), + connections: diagram.connections.filter(keep), + schematic: { pos, routes } + }) + setSelPart(null) + } + const deleteWire = (i: number): void => { + const gone = diagram.connections[i] + const routes = { ...diagram.schematic.routes } + if (gone) delete routes[connKey(gone)] + write({ + connections: diagram.connections.filter((_, j) => j !== i), + schematic: { ...diagram.schematic, routes } + }) + selectWire(null) + } + + React.useEffect(() => { + const down = (e: KeyboardEvent): void => { + if (document.activeElement && /INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName)) + return + if (e.key === 'Shift') setStraight(true) + if (editable && (e.key === ' ' || e.key === 'r' || e.key === 'R')) { + const id = drag.current?.id || selPart + if (id) { + e.preventDefault() + rotatePart(id) + return + } + } + if (e.key === 'Escape') { + setArmed(null) + setSelPart(null) + selectWire(null) + } + if (e.key === 'Delete' || e.key === 'Backspace') { + if (selWire != null) deleteWire(selWire) + else if (selPart) deletePart(selPart) + } + } + const up = (e: KeyboardEvent): void => { + if (e.key === 'Shift') setStraight(false) + } + window.addEventListener('keydown', down) + window.addEventListener('keyup', up) + return () => { + window.removeEventListener('keydown', down) + window.removeEventListener('keyup', up) + } + }, [selPart, selWire, diagram, editable, view, straight]) + + const previewPts = React.useMemo((): Pt[] | null => { + if (!armed) return null + const last = armed.points[armed.points.length - 1] + let target = { x: snap(mouse.x), y: snap(mouse.y) } + let clean = false + if (hoverPin) { + const part = diagram.parts.find((p) => p.id === hoverPin.id) + const pos = part && pinAbs(part, hoverPin.pin) + if (pos) { + target = pos + clean = true + } + } + if (straight) return [...armed.points, target] + return [ + ...armed.points, + ...calculateOrthogonalPath(last.x, last.y, target.x, target.y, clean).slice(1) + ] + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [armed, mouse, hoverPin, diagram.parts, view, straight]) + + const isAI = (diagram.author || '').includes('Studio AI') + // tactile pixel-art toolbar buttons (design-system .tactile-bordered) + const tool = + 'tactile-bordered h-8 px-2.5 flex items-center gap-1.5 rounded-md bg-surface-card text-text-muted text-xs hover:text-text-body active:translate-y-px' + const families = partsByFamily() + const isOpen = (fam: string): boolean => !collapsed.has(fam) + const toggleFam = (fam: string): void => + setCollapsed((s) => { + const n = new Set(s) + if (n.has(fam)) n.delete(fam) + else n.add(fam) + return n + }) + const netCount = meaningfulNetCount(netModel) + const junctions: Pt[] = [] + diagram.connections.forEach((c) => { + ;[c[0], c[1]].forEach((r) => { + if (typeof r === 'object') junctions.push(r) + }) + }) + + const labelOf = (part: Part): string => { + const def = getPart(part.type) + return (part.attrs?.label as string) || def?.label || part.type + } + const selectedPart = selPart ? diagram.parts.find((p) => p.id === selPart) : null + + return ( +
+ {/* components rail (edit mode only) */} + {editable && ( +
+
+ Components + +
+ {/* wire color β€” recolours the selected wire, else sets the next-wire colour */} +
+ + Wire + + {WIRE_COLORS.map((c) => ( +
+
+ {families.map((group) => ( +
+ + {isOpen(group.family) && ( +
+ {group.parts.map((c: PartMeta) => ( +
e.dataTransfer.setData('text/tinystudio-part', c.type)} + onDoubleClick={() => addPart(c.type)} + className="group relative flex flex-col items-center gap-1 p-2 rounded-lg border border-border-default bg-surface-card hover:bg-bg-sunken hover:-translate-y-px transition cursor-grab active:cursor-grabbing" + title={`${c.label} Β· ${c.pins} pins β€” drag onto the canvas (or double-click)`} + > + +
+ {c.label} +
+ +
+ ))} +
+ )} +
+ ))} +
+
+ )} + + {/* canvas + tools */} +
+
+ + +
+ +
+ + +
+ +
+ + + {Math.round(scale * 100)}% + + + +
+ +
{ + e.preventDefault() + e.dataTransfer.dropEffect = 'copy' + }} + onDrop={onDrop} + onClick={onCanvasClick} + > +
+ + {diagram.connections.map((c, i) => { + const pts = selWire === i && editPts ? editPts : wirePts(c) + if (pts.length < 2) return null + const sel = selWire === i + const onHotNet = highlightNet >= 0 && netModel.connToNet[i] === highlightNet + const core = + view === 'schematic' ? 'var(--text-strong)' : (c[2] as string) || '#2fa46a' + const outline = view === 'schematic' ? 'rgba(0,0,0,0.45)' : darken(core) + const d = roundedPath(pts) + return ( + setHoverWire(i)} + onPointerLeave={() => setHoverWire((h) => (h === i ? null : h))} + onClick={(e) => onWireClick(e, i)} + onDoubleClick={(e) => onWireDoubleClick(e, i)} + > + {/* equipotential net glow */} + {onHotNet && ( + + )} + {/* darker outline β†’ glossy tube */} + + + {sel && ( + + + + )} + + ) + })} + + {/* junction solder dots where a wire taps another wire */} + {junctions.map((j, k) => ( + + ))} + + {previewPts && ( + + )} + + {/* handles on the selected wire: bend (circle), segment (square), + endpoint (ring) β€” Fritzing-style */} + {editable && selWire != null && editPts && ( + + {editPts.slice(0, -1).map((p, i) => { + const q = editPts[i + 1] + const m = { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 } + const horizontal = Math.abs(p.y - q.y) < 0.001 + return ( + startHandleDrag(e, 'edge', i)} + /> + ) + })} + {editPts.slice(1, -1).map((p, i) => ( + startHandleDrag(e, 'vertex', i + 1)} + /> + ))} + {[0, 1].map((end) => { + const p = end === 0 ? editPts[0] : editPts[editPts.length - 1] + return ( + startHandleDrag(e, 'endpoint', 0, end as 0 | 1)} + /> + ) + })} + + )} + + + {diagram.parts.map((part) => { + const def = getPart(part.type) + const v = def && viewFor(def, view) + const [pLeft, pTop] = partXY(part) + if (!def || !v) { + return ( +
+ {part.type}… +
+ ) + } + const labelOff = (part.attrs?.labelOffset as [number, number]) || [0, 0] + return ( +
onPartDown(e, part)} + onClick={(e) => e.stopPropagation()} + onContextMenu={(e) => { + if (!editable) return + e.preventDefault() + rotatePart(part.id) + }} + > +
+ {editable && + Object.keys(v.pins).map((pin) => { + const [px, py] = v.pins[pin] + const armedHere = armed && eqRef(armed.from, `${part.id}:${pin}`) + const hovered = hoverPin?.id === part.id && hoverPin?.pin === pin + const onHotNet = + highlightNet >= 0 && + netModel.pinToNet.get(`${part.id}:${pin}`) === highlightNet + const on = armedHere || hovered + return ( +
e.stopPropagation()} + onPointerEnter={() => setHoverPin({ id: part.id, pin })} + onPointerLeave={() => + setHoverPin((h) => (h?.id === part.id && h?.pin === pin ? null : h)) + } + onClick={(e) => onPinClick(e, part, pin)} + > + + {onHotNet && !on && ( + + )} + + +
+ ) + })} + {/* draggable title label */} +
onLabelDown(e, part)} + > + {labelOf(part)} +
+
+ ) + })} + + {diagram.parts.length === 0 && ( +
+ +
+ Drag parts from the components rail β€” or double-click one to drop it here. +
+
+ )} +
+
+ + {/* footer */} +
+ + Parts: {diagram.parts.length} Β· Connections: {diagram.connections.length} Β· Nets:{' '} + {netCount} + + {isAI && ( + + Auto-wired by Studio AI + + )} + {!editable ? ( + + View-only Β· hover a wire to light its net Β· click edit to change + + ) : armed ? ( + + Click a pin or wire to connect Β· click for a bend Β· hold Shift for + straight Β· Esc to cancel + + ) : selWire != null ? ( + + Drag a handle to reshape Β· double-click to add/remove a bend Β· pick a color Β· Del to + remove + + ) : ( + + Scroll to zoom Β· middle/Alt-drag to pan Β· click a pin to + wire + + )} +
+
+ + {/* inspector (edit mode only) */} + {editable && ( +
+
+ Inspector +
+
+ {selectedPart ? ( + touches(c[0], selectedPart.id) || touches(c[1], selectedPart.id) + ).length + } + onLabel={(val) => patchAttr(selectedPart.id, 'label', val)} + onMove={(l, t) => movePartTo(selectedPart.id, l, t)} + onRotate={(deg) => setRotation(selectedPart.id, deg)} + onSetAttr={(k, val) => patchAttr(selectedPart.id, k, val)} + onRemoveAttr={(k) => removeAttr(selectedPart.id, k)} + onDelete={() => deletePart(selectedPart.id)} + newAttr={newAttr} + setNewAttr={setNewAttr} + /> + ) : selWire != null ? ( + = 0 + ? netModel.nets[netModel.connToNet[selWire]].length + : 0 + } + onColor={(c) => recolorWire(selWire, c)} + onDelete={() => deleteWire(selWire)} + /> + ) : ( +
+ Select a component to edit its name, location, rotation, and properties β€” or a wire + to recolor it. +
+ )} +
+
+ )} + + {editorPart !== undefined && ( + setEditorPart(undefined)} + onSave={(def: PartDef) => { + registerPart(def) + force() + setEditorPart(undefined) + }} + /> + )} +
+ ) +} + +// ── Inspector subcomponents ─────────────────────────────────────────────────── + +const field = + 'bg-bg-sunken border border-border-default rounded px-2 py-1 text-text-strong outline-none focus:border-brand w-full' +const rowLabel = 'text-[10px] text-text-faint uppercase tracking-wide font-mono' + +function Inspector({ + part, + def, + view, + xy, + connCount, + onLabel, + onMove, + onRotate, + onSetAttr, + onRemoveAttr, + onDelete, + newAttr, + setNewAttr +}: { + part: { id: string; type: string; rotate?: number; attrs?: Record } + def?: PartDef + view: ViewKind + xy: [number, number] + connCount: number + onLabel: (v: string) => void + onMove: (l: number, t: number) => void + onRotate: (deg: number) => void + onSetAttr: (k: string, v: string) => void + onRemoveAttr: (k: string) => void + onDelete: () => void + newAttr: string + setNewAttr: (v: string) => void +}): React.JSX.Element { + const label = (part.attrs?.label as string) ?? def?.label ?? part.type + // attrs to expose as editable properties (hide internal layout keys) + const props = Object.entries(part.attrs || {}).filter( + ([k]) => k !== 'label' && k !== 'labelOffset' + ) + return ( + <> +
+ + onLabel(e.target.value)} + /> +
+
+ type + {part.type} + family + {def?.family || 'β€”'} + wires + {connCount} +
+ +
+ Location ({view}) +
+ onMove(parseFloat(e.target.value) || 0, xy[1])} + /> + onMove(xy[0], parseFloat(e.target.value) || 0)} + /> +
+
+ +
+ Rotation +
+ + +
+
+ +
+ Properties + {props.length === 0 && ( +
+ No properties yet. Add one below β€” e.g. a resistor’s{' '} + value. +
+ )} + {props.map(([k, val]) => ( +
+ + {k} + + onSetAttr(k, e.target.value)} + /> + +
+ ))} +
+ setNewAttr(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && newAttr.trim()) { + onSetAttr(newAttr.trim(), '') + setNewAttr('') + } + }} + /> + +
+
+ + + + ) +} + +function WireInspector({ + color, + colors, + netPins, + onColor, + onDelete +}: { + color: string + colors: string[] + netPins: number + onColor: (c: string) => void + onDelete: () => void +}): React.JSX.Element { + return ( + <> +
+ + Wire +
+
+ Color +
+ {colors.map((c) => ( +
+
+
+ net pins + {netPins} +
+ + + ) +} diff --git a/src/renderer/src/components/.fuse_hidden0000001300000002 b/src/renderer/src/components/.fuse_hidden0000001300000002 new file mode 100644 index 0000000..d37f699 --- /dev/null +++ b/src/renderer/src/components/.fuse_hidden0000001300000002 @@ -0,0 +1,395 @@ +/** + * PartsEditor β€” a top-level modal for authoring a custom part. Upload an SVG (or + * start from a blank box), set its size, then click on the preview to drop pins + * and drag them into place. Pins are named and saved into the live parts library + * in the same schema as Fritzing-imported parts (pixel coords @ 96 DPI), so a + * hand-made part wires up exactly like an imported one. + * + * Styled with the tinyStudio design system: cold neutral surfaces, soft-elevation + * floating dialog, dot-grid preview, brand-blue accents, sentence-case copy. + */ + +import { Plus, Trash2, UploadCloud, X } from 'lucide-react' +import React from 'react' +import type { PartDef } from '../lib/partsLibrary' + +const PX_PER_MM = 96 / 25.4 + +const slug = (s: string): string => + s + .replace(/[^a-zA-Z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .toLowerCase() || 'custom-part' + +interface Pin { + name: string + x: number + y: number +} + +const blankSvg = (w: number, h: number): string => + `` + +export function PartsEditor({ + initial, + onClose, + onSave +}: { + initial?: PartDef | null + onClose: () => void + onSave: (def: PartDef) => void +}): React.JSX.Element { + // when editing an existing part, seed from its (breadboard) view + const seed = + initial && + (initial.views.breadboard || initial.views.schematic || Object.values(initial.views)[0]) + const [name, setName] = React.useState(initial?.label || 'My Part') + const [w, setW] = React.useState(seed?.w || 80) + const [h, setH] = React.useState(seed?.h || 40) + const [svg, setSvg] = React.useState(seed?.svg || blankSvg(80, 40)) + const [pins, setPins] = React.useState( + seed ? Object.entries(seed.pins).map(([n, [x, y]]) => ({ name: n, x, y })) : [] + ) + const [sel, setSel] = React.useState(-1) + const surfaceRef = React.useRef(null) + const dragRef = React.useRef(-1) + + // fit the part into the preview area (cap zoom so tiny parts stay visible) + const scale = Math.min(440 / w, 320 / h, 14) + + const uniqueName = (base: string, skip = -1): string => { + let n = base + let i = 1 + while (pins.some((p, idx) => idx !== skip && p.name === n)) n = `${base}.${i++}` + return n + } + + const surfacePoint = (e: { clientX: number; clientY: number }): { x: number; y: number } => { + const r = surfaceRef.current!.getBoundingClientRect() + return { + x: Math.round(Math.max(0, Math.min(w, (e.clientX - r.left) / scale))), + y: Math.round(Math.max(0, Math.min(h, (e.clientY - r.top) / scale))) + } + } + + const onSurfaceClick = (e: React.MouseEvent): void => { + if ((e.target as HTMLElement).closest('.editor-pin')) return + const p = surfacePoint(e) + setPins((ps) => [...ps, { name: uniqueName(String(ps.length + 1)), x: p.x, y: p.y }]) + setSel(pins.length) + } + + const onPinDown = (e: React.PointerEvent, i: number): void => { + e.stopPropagation() + setSel(i) + dragRef.current = i + const move = (ev: PointerEvent): void => { + const p = surfacePoint(ev) + setPins((ps) => + ps.map((pin, idx) => (idx === dragRef.current ? { ...pin, x: p.x, y: p.y } : pin)) + ) + } + const up = (): void => { + dragRef.current = -1 + window.removeEventListener('pointermove', move) + window.removeEventListener('pointerup', up) + } + window.addEventListener('pointermove', move) + window.addEventListener('pointerup', up) + } + + const onUpload = (e: React.ChangeEvent): void => { + const file = e.target.files?.[0] + if (!file) return + const reader = new FileReader() + reader.onload = (evt) => { + const doc = new DOMParser().parseFromString(String(evt.target?.result), 'image/svg+xml') + const el = doc.querySelector('svg') + if (!el) return + const parseLen = (v: string | null): number | null => { + if (!v) return null + const m = v.trim().match(/^(-?[\d.]+)\s*(px|mm|cm|in|pt)?$/) + if (!m) return null + const n = parseFloat(m[1]) + return m[2] === 'mm' + ? n * PX_PER_MM + : m[2] === 'cm' + ? n * PX_PER_MM * 10 + : m[2] === 'in' + ? n * 96 + : m[2] === 'pt' + ? n * (96 / 72) + : n + } + let nw = parseLen(el.getAttribute('width')) + let nh = parseLen(el.getAttribute('height')) + const vb = (el.getAttribute('viewBox') || '') + .trim() + .split(/[\s,]+/) + .map(parseFloat) + if ((nw == null || nh == null) && vb.length === 4) { + nw = nw ?? vb[2] + nh = nh ?? vb[3] + } + if (nw && nh) { + setW(Math.round(nw)) + setH(Math.round(nh)) + } + el.removeAttribute('width') + el.removeAttribute('height') + if (!el.getAttribute('xmlns')) el.setAttribute('xmlns', 'http://www.w3.org/2000/svg') + if (!el.getAttribute('viewBox') && nw && nh) el.setAttribute('viewBox', `0 0 ${nw} ${nh}`) + setSvg(el.outerHTML) + } + reader.readAsText(file) + } + + // arrow keys nudge the selected pin (Shift = Γ—10) + React.useEffect(() => { + const onKey = (e: KeyboardEvent): void => { + if (sel < 0) return + if (document.activeElement && /INPUT|TEXTAREA/.test(document.activeElement.tagName)) return + const step = e.shiftKey ? 10 : 1 + const d: Record = { + ArrowLeft: [-step, 0], + ArrowRight: [step, 0], + ArrowUp: [0, -step], + ArrowDown: [0, step] + } + if (!d[e.key]) return + e.preventDefault() + const [dx, dy] = d[e.key] + setPins((ps) => + ps.map((p, i) => + i === sel + ? { ...p, x: Math.max(0, Math.min(w, p.x + dx)), y: Math.max(0, Math.min(h, p.y + dy)) } + : p + ) + ) + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [sel, w, h]) + + const save = (): void => { + const pinMap: Record = {} + pins.forEach((p) => { + pinMap[p.name] = [p.x, p.y] + }) + onSave({ + // editing keeps the original identity so it updates in place (not a copy) + type: initial ? initial.type : slug(name), + label: name, + family: initial?.family || 'Custom', + builtin: initial?.builtin, + // regenerate the palette icon from the current art so the thumbnail updates + icon: svg, + views: { breadboard: { svg, w, h, pins: pinMap } } + }) + } + + const numField = + 'w-20 bg-bg-sunken border border-border-default rounded px-2 py-1 text-sm text-text-strong focus:border-brand outline-none' + + return ( +
+
e.stopPropagation()} + > + {/* header */} +
+ Parts editor + + {initial ? `editing β€œ${initial.label}”` : 'author a custom part'} + +
+ +
+ +
+ {/* preview surface */} +
+
+
+ {pins.map((pin, i) => ( +
onPinDown(e, i)} + onClick={(e) => e.stopPropagation()} + > +
+
+ {pin.name} +
+
+ ))} +
+
+ + {/* inspector */} +
+
+ +
+ + +
+ + + +
+ Pins ({pins.length}) + +
+ +
+ {pins.map((pin, i) => ( +
setSel(i)} + > + { + const v = e.target.value + setPins((ps) => ps.map((p, idx) => (idx === i ? { ...p, name: v } : p))) + }} + onBlur={(e) => { + const fixed = uniqueName(e.target.value || String(i + 1), i) + setPins((ps) => ps.map((p, idx) => (idx === i ? { ...p, name: fixed } : p))) + }} + /> + + {pin.x},{pin.y} + + +
+ ))} + {pins.length === 0 && ( +
+ Click the preview to drop pins. +
+ )} +
+
+ +
+ + +
+
+
+
+
+ ) +} diff --git a/src/renderer/src/components/.fuse_hidden0000001300000003 b/src/renderer/src/components/.fuse_hidden0000001300000003 new file mode 100644 index 0000000..edebeae --- /dev/null +++ b/src/renderer/src/components/.fuse_hidden0000001300000003 @@ -0,0 +1,678 @@ +import { + OpenFileCommand, + OpenWorkspaceCommand, + RefreshWorkspaceCommand +} from '@renderer/commands/fileCommands' +import { loader } from '@monaco-editor/react' +import tinyLogo from '@renderer/assets/tinyLogo.png' +import { fileSystem } from '@renderer/lib/fileSystem' +import { enablePages, loadAccount, loadLink, pushFile } from '@renderer/lib/github' +import { buildVisualExportHtml } from '@renderer/lib/visualExport' +import { selectOpenFiles, useAppDispatch, useAppSelector } from '@renderer/redux' +import { selectEditorView, setEditorView, setPanelOpen } from '@renderer/redux/editorSlice' +import { + BaseFileItem, + closeFile, + EditorFile, + revealFile, + saveFileWithContent, + selectViewingFileId, + setViewingFile, + updateFileContent, + updateReadmeContent +} from '@renderer/redux/fileSlice' +import { + CircuitBoard, + CodeXml, + ExternalLink, + FolderOpen, + Loader2, + Plus, + UploadCloud +} from 'lucide-react' +import * as monaco from 'monaco-editor' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { notify as toast } from '@renderer/lib/notify' +import { BlocklyEditor } from './BlocklyEditor' +import { DiagramEditor } from './DiagramEditor' +import { MonacoEditor, MonacoEditorRef } from './MonacoEditor' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from './ui/Dialog' +import { FileTabContent, FileTabs, FileTabsList, FileTabTrigger } from './ui/FileTab' +import { Button } from './ui/Button' +import { IconButton } from './ui/IconButton' +import { Input } from './ui/Input' +import { VisualPreview } from './VisualPreview' + +loader.config({ monaco }) + +const DEFAULT_DIAGRAM = JSON.stringify( + { version: 1, editor: 'tinystudio', parts: [], connections: [] }, + null, + 2 +) +const DEFAULT_VISUAL = `// visual.js β€” Serial Plotter +// Graphs the latest number printed over Serial (serialValue()) as a scrolling +// line, auto-scaling to the data. Try Serial.println(analogRead(A0)) on the +// board. Switch to Code to edit this sketch; Visual to run it. + +let data = []; +const MAX = 240; // points kept on screen + +function setup() { + createCanvas(480, 280); + textFont('monospace'); +} + +function draw() { + background(7, 11, 34); + + // pull the most recent serial value each frame + data.push(serialValue()); + if (data.length > MAX) data.shift(); + + // auto-scale to the data range (with a little headroom) + let lo = Math.min(...data, 0); + let hi = Math.max(...data, 1); + if (hi === lo) hi = lo + 1; + + // grid + stroke(26, 31, 77); + strokeWeight(1); + for (let i = 0; i <= 4; i++) { + let y = map(i, 0, 4, 20, height - 24); + line(40, y, width - 12, y); + } + + // plotted line + noFill(); + stroke(0, 240, 255); + strokeWeight(2); + beginShape(); + for (let i = 0; i < data.length; i++) { + let x = map(i, 0, MAX - 1, 40, width - 12); + let y = map(data[i], lo, hi, height - 24, 20); + vertex(x, y); + } + endShape(); + + // readouts + noStroke(); + fill(235, 238, 255); + textSize(12); + text('value: ' + serialValue().toFixed(2), 44, 16); + fill(120, 130, 170); + text(hi.toFixed(0), 8, 24); + text(lo.toFixed(0), 8, height - 24); +} +` + +function findInTree( + items: BaseFileItem[], + match: (i: BaseFileItem) => boolean +): BaseFileItem | null { + for (const item of items) { + if (item.type === 'file' && item.name && match(item)) return item + if (item.children) { + const found = findInTree(item.children, match) + if (found) return found + } + } + return null +} + +/** Collect every file in the tree matching `match`, skipping noise folders. */ +function collectFiles( + items: BaseFileItem[], + match: (i: BaseFileItem) => boolean, + out: BaseFileItem[] = [] +): BaseFileItem[] { + for (const item of items) { + if (item.type === 'file' && item.name && match(item)) out.push(item) + if (item.children && !['node_modules', '.git', 'dist'].includes(item.name ?? '')) { + collectFiles(item.children, match, out) + } + } + return out +} + +/** Starter p5.js sketch dropped into newly created .js files. */ +const sketchTemplate = (title: string): string => `// ${title} β€” p5.js sketch. +// Switch to Code to edit this sketch; Visual to run it. + +let x = 240; +let y = 140; +let dx = 3; +let dy = 2; + +function setup() { + createCanvas(480, 280); + noStroke(); +} + +function draw() { + background(7, 11, 34); + x += dx; + y += dy; + if (x < 20 || x > width - 20) dx = -dx; + if (y < 20 || y > height - 20) dy = -dy; + fill(0, 240, 255); + circle(x, y, 40); +} +` + +/** + * Ensure a project file (e.g. diagram.json / visual.js) is open as an editor + * buffer, creating it from a default if it doesn't exist yet. Returns the open + * file once ready. Used by the full-window Circuit/Visual views so their edits + * live in the same buffer model as code (saved on Ctrl+S / build). + */ +function useProjectFile(name: string, makeDefault?: () => string): EditorFile | undefined { + const workspace = useAppSelector((s) => s.file.workspace) + const openFiles = useAppSelector(selectOpenFiles) + const file = openFiles.find((f) => f.name === name) + const busy = useRef(false) + + useEffect(() => { + if (file || !workspace || busy.current) return + busy.current = true + ;(async () => { + try { + let item = findInTree(workspace.root, (i) => i.name === name) + if (!item && makeDefault) { + const path = `${workspace.path}/${name}` + await fileSystem.writeFile(path, makeDefault()) + await new RefreshWorkspaceCommand(workspace).execute() + item = { id: crypto.randomUUID(), parentId: 'root', name, path, type: 'file' } + } + // Load as a hidden background buffer: the full-window view edits/saves + // it, but it stays out of the Code tab bar until the user clicks the + // in-view code button (which reveals it). + if (item) await new OpenFileCommand(item, { hidden: true }).execute() + } finally { + busy.current = false + } + })() + }, [file, workspace, name, makeDefault]) + + return file +} + +export function EditorPanel({ size }: { size: number }): React.JSX.Element { + const editorView = useAppSelector(selectEditorView) + // Track viewport height so the panel re-measures on resize / fullscreen toggle + // (otherwise the height is computed once from a stale window.innerHeight and + // the layout β€” and its buttons β€” break after going fullscreen). + const [winHeight, setWinHeight] = useState(window.innerHeight) + useEffect(() => { + const onResize = (): void => setWinHeight(window.innerHeight) + window.addEventListener('resize', onResize) + return () => window.removeEventListener('resize', onResize) + }, []) + const pixelSize = Math.round((size / 100) * (winHeight - 92)) + + return ( +
+ {editorView === 'circuit' ? ( + + ) : editorView === 'visual' ? ( + + ) : ( + + )} +
+ ) +} + +// ── Code view: the normal tabbed IDE ──────────────────────────────────────── + +function CodeView(): React.JSX.Element { + const allOpenFiles = useAppSelector(selectOpenFiles) + // Background buffers (diagram.json / visual.js loaded for the Circuit/Visual + // views) are hidden from the tab bar until revealed via their code button. + const openFiles = allOpenFiles.filter((f) => !f.hidden) + const viewingFileId = useAppSelector(selectViewingFileId) + const editorMode = useAppSelector((state) => state.editor.editorMode) + const workspace = useAppSelector((s) => s.file.workspace) + const dispatch = useAppDispatch() + const monacoEditorRef = useRef(null) + + const handleFileClose = useCallback( + (fileId: string): void => { + dispatch(closeFile(fileId)) + }, + [dispatch] + ) + + const handleFileSelect = useCallback( + (fileId: string): void => { + dispatch(setViewingFile(fileId)) + setTimeout(() => { + if (monacoEditorRef.current && editorMode !== 'blocks') monacoEditorRef.current.focus() + }, 50) + }, + [dispatch, editorMode] + ) + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent): void => { + if ((event.metaKey || event.ctrlKey) && event.key === 'w') { + event.preventDefault() + if (viewingFileId) handleFileClose(viewingFileId) + } + if (event.ctrlKey && event.key === 'Tab') { + event.preventDefault() + if (openFiles.length > 1) { + const i = openFiles.findIndex((f) => f.id === viewingFileId) + const next = event.shiftKey + ? i <= 0 + ? openFiles.length - 1 + : i - 1 + : (i + 1) % openFiles.length + if (openFiles[next]) handleFileSelect(openFiles[next].id) + } + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [viewingFileId, handleFileClose, openFiles, handleFileSelect]) + + const handleContentChange = useCallback( + (content: string, fileId: string) => { + const file = openFiles.find((f) => f.id === fileId) + if (file) { + dispatch(updateFileContent({ id: file.id, content })) + if (file.name === 'README.md') dispatch(updateReadmeContent(content)) + } + }, + [openFiles, dispatch] + ) + + const handleSaveFile = async (content: string, fileId: string): Promise => { + const file = openFiles.find((f) => f.id === fileId) + if (file && file.path) { + try { + await fileSystem.writeFile(file.path, content) + dispatch(saveFileWithContent({ id: file.id, content })) + } catch (error) { + console.error('Failed to save file:', error) + } + } + } + + if (openFiles.length === 0) { + return ( +
+
+
+
No project open
+
Open a folder to start building.
+
+ +
+ ) + } + + return ( + + +
+ {openFiles.map((file) => ( + + ))} +
+
+ {openFiles.map((file) => ( + + {editorMode === 'blocks' ? ( + + ) : ( + handleContentChange(content, file.id)} + onSaveFile={(content) => handleSaveFile(content, file.id)} + /> + )} + + ))} +
+ ) +} + +// ── Circuit view: full-window interactive diagram.json ────────────────────── + +function CircuitView(): React.JSX.Element { + const workspace = useAppSelector((s) => s.file.workspace) + const dispatch = useAppDispatch() + const file = useProjectFile('diagram.json', () => DEFAULT_DIAGRAM) + + if (!workspace) return + if (!file) return + + return ( + dispatch(updateFileContent({ id: file.id, content }))} + onOpenCode={() => { + dispatch(revealFile(file.id)) + dispatch(setEditorView('code')) + }} + onEditChange={(editing) => { + // entering edit mode: free horizontal space by closing the docs panel + if (editing) dispatch(setPanelOpen({ panel: 'docs', isOpen: false })) + }} + /> + ) +} + +// ── Visual view: full-window p5 sketch with an Edit-code button ───────────── + +function VisualView(): React.JSX.Element { + const workspace = useAppSelector((s) => s.file.workspace) + const dispatch = useAppDispatch() + + // Which .js sketch is shown in the Visual view. Defaults to visual.js, the + // sketch auto-created for every project; other .js files are selectable too. + const [activeSketch, setActiveSketch] = useState('visual.js') + const [showNew, setShowNew] = useState(false) + // Serial is owned app-wide by SerialProvider and feeds the running sketch via + // the shared bus β€” the Visual view no longer opens the port itself, so + // switching to/from this view doesn't reset the board. + const [publishing, setPublishing] = useState(false) + + const jsFiles = useMemo( + () => (workspace ? collectFiles(workspace.root, (i) => /\.js$/i.test(i.name!)) : []), + [workspace] + ) + + // If the active sketch isn't in this project (after a project switch or a + // delete), fall back to visual.js or the first sketch available. + useEffect(() => { + if (jsFiles.length && !jsFiles.some((f) => f.name === activeSketch)) { + setActiveSketch(jsFiles.find((f) => f.name === 'visual.js')?.name ?? jsFiles[0].name!) + } + }, [jsFiles, activeSketch]) + + // Only visual.js gets seeded from a default; other sketches must already exist. + const file = useProjectFile( + activeSketch, + activeSketch === 'visual.js' ? () => DEFAULT_VISUAL : undefined + ) + + if (!workspace) return + + const ws = workspace + + const createSketch = async (fileName: string): Promise => { + const path = `${ws.path}/${fileName}` + try { + await fileSystem.writeFile(path, sketchTemplate(fileName.replace(/\.js$/i, ''))) + await new RefreshWorkspaceCommand(ws).execute() + setActiveSketch(fileName) + setShowNew(false) + } catch (e) { + toast.error('Could not create sketch', { + description: e instanceof Error ? e.message : 'Unknown error' + }) + } + } + + // Build the standalone page, titled after the .ino project (not the folder). + const buildHtml = (): string => { + const ino = findInTree(ws.root, (i) => /\.ino$/i.test(i.name!)) + const projectName = ino?.name?.replace(/\.ino$/i, '') || ws.name || 'tinyStudio sketch' + return buildVisualExportHtml(projectName, file?.content ?? '') + } + + // Preview: write index.html into the project root and open it in the browser. + const preview = async (): Promise => { + const path = `${ws.path}/index.html` + try { + await window.api.fs.writeFile(path, buildHtml()) + await new RefreshWorkspaceCommand(ws).execute() + const err = await window.api.fs.openPath(path) + if (err) toast.error('Could not open preview', { description: err }) + else toast.success('Preview opened in your browser', { description: path }) + } catch (e) { + toast.error('Preview failed', { + description: e instanceof Error ? e.message : 'Unknown error' + }) + } + } + + // Publish: push index.html to the linked repo and turn on GitHub Pages. + const publish = async (): Promise => { + const account = loadAccount() + if (!account) { + toast.info('Connect GitHub first', { + description: 'Open the GitHub tab in the sidebar to sign in.' + }) + return + } + const link = loadLink(ws.path) + if (!link) { + toast.info('Link this project to a repo first', { + description: 'Use the GitHub tab to link or publish a repository.' + }) + return + } + setPublishing(true) + try { + const html = buildHtml() + await window.api.fs.writeFile(`${ws.path}/index.html`, html) + await new RefreshWorkspaceCommand(ws).execute() + await pushFile( + link.remote, + link.branch, + 'index.html', + html, + account.token, + 'Publish web export via tinyStudio' + ) + const url = await enablePages(link.remote, link.branch, account.token) + toast.success('Published to GitHub Pages', { + description: `${url} β€” the first build can take a minute.` + }) + window.api.fs.openExternal(url) + } catch (e) { + toast.error('Publish failed', { + description: e instanceof Error ? e.message : 'Unknown error' + }) + } finally { + setPublishing(false) + } + } + + return ( +
+ {/* Sketch picker + view actions. Tabs let you keep several p5.js renders + (asteroids.js, pong.js, …) in one project and switch between them. */} +
+
+ {jsFiles.map((f) => ( + + ))} + +
+
+
+ +
+ {file ? ( + + { + dispatch(revealFile(file.id)) + dispatch(setEditorView('code')) + }} + > + + + + + + + {publishing ? ( + + ) : ( + + )} + + + } + /> + ) : ( + + )} +
+ + f.name!)} + onCreate={createSketch} + /> +
+ ) +} + +function NewSketchDialog({ + open, + onOpenChange, + existing, + onCreate +}: { + open: boolean + onOpenChange: (o: boolean) => void + existing: string[] + onCreate: (fileName: string) => void +}): React.JSX.Element { + const [name, setName] = useState('') + + // Reset the field each time the dialog opens. + useEffect(() => { + if (open) setName('') + }, [open]) + + const trimmed = name.trim() + const fileName = trimmed ? (/\.js$/i.test(trimmed) ? trimmed : `${trimmed}.js`) : '' + const duplicate = !!fileName && existing.includes(fileName) + const invalid = !trimmed || /[\\/:*?"<>|]/.test(trimmed) || duplicate + + const submit = (): void => { + if (!invalid) onCreate(fileName) + } + + return ( + + + + New p5.js sketch + + Creates a new .js sketch in your project. Switch + between sketches with the tabs in the Visual view. + + +
+ setName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && submit()} + /> + {duplicate && ( +

+ A file named {fileName} already exists. +

+ )} +
+ + + + +
+
+ ) +} + +function EmptyHint({ label }: { icon: string; label: string }): React.JSX.Element { + return ( +
+ +

{label}

+
+ ) +} + +function LoadingHint({ label }: { label: string }): React.JSX.Element { + return ( +
+ +

{label}

+
+ ) +} diff --git a/src/renderer/src/components/.fuse_hidden0000001300000004 b/src/renderer/src/components/.fuse_hidden0000001300000004 new file mode 100644 index 0000000..de8a27a --- /dev/null +++ b/src/renderer/src/components/.fuse_hidden0000001300000004 @@ -0,0 +1,660 @@ +import { + OpenFileCommand, + RefreshWorkspaceCommand +} from '@renderer/commands/fileCommands' +import { loader } from '@monaco-editor/react' +import { fileSystem } from '@renderer/lib/fileSystem' +import { enablePages, loadAccount, loadLink, pushFile } from '@renderer/lib/github' +import { buildVisualExportHtml } from '@renderer/lib/visualExport' +import { selectOpenFiles, useAppDispatch, useAppSelector } from '@renderer/redux' +import { selectEditorView, setEditorView, setPanelOpen } from '@renderer/redux/editorSlice' +import { + BaseFileItem, + closeFile, + EditorFile, + revealFile, + saveFileWithContent, + selectViewingFileId, + setViewingFile, + updateFileContent, + updateReadmeContent +} from '@renderer/redux/fileSlice' +import { + CircuitBoard, + CodeXml, + ExternalLink, + Loader2, + Plus, + UploadCloud +} from 'lucide-react' +import * as monaco from 'monaco-editor' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { notify as toast } from '@renderer/lib/notify' +import { BlocklyEditor } from './BlocklyEditor' +import { DiagramEditor } from './DiagramEditor' +import { ExamplesContent } from './ExamplesContent' +import { MonacoEditor, MonacoEditorRef } from './MonacoEditor' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from './ui/Dialog' +import { FileTabContent, FileTabs, FileTabsList, FileTabTrigger } from './ui/FileTab' +import { Button } from './ui/Button' +import { IconButton } from './ui/IconButton' +import { Input } from './ui/Input' +import { VisualPreview } from './VisualPreview' + +loader.config({ monaco }) + +const DEFAULT_DIAGRAM = JSON.stringify( + { version: 1, editor: 'tinystudio', parts: [], connections: [] }, + null, + 2 +) +const DEFAULT_VISUAL = `// visual.js β€” Serial Plotter +// Graphs the latest number printed over Serial (serialValue()) as a scrolling +// line, auto-scaling to the data. Try Serial.println(analogRead(A0)) on the +// board. Switch to Code to edit this sketch; Visual to run it. + +let data = []; +const MAX = 240; // points kept on screen + +function setup() { + createCanvas(480, 280); + textFont('monospace'); +} + +function draw() { + background(7, 11, 34); + + // pull the most recent serial value each frame + data.push(serialValue()); + if (data.length > MAX) data.shift(); + + // auto-scale to the data range (with a little headroom) + let lo = Math.min(...data, 0); + let hi = Math.max(...data, 1); + if (hi === lo) hi = lo + 1; + + // grid + stroke(26, 31, 77); + strokeWeight(1); + for (let i = 0; i <= 4; i++) { + let y = map(i, 0, 4, 20, height - 24); + line(40, y, width - 12, y); + } + + // plotted line + noFill(); + stroke(0, 240, 255); + strokeWeight(2); + beginShape(); + for (let i = 0; i < data.length; i++) { + let x = map(i, 0, MAX - 1, 40, width - 12); + let y = map(data[i], lo, hi, height - 24, 20); + vertex(x, y); + } + endShape(); + + // readouts + noStroke(); + fill(235, 238, 255); + textSize(12); + text('value: ' + serialValue().toFixed(2), 44, 16); + fill(120, 130, 170); + text(hi.toFixed(0), 8, 24); + text(lo.toFixed(0), 8, height - 24); +} +` + +function findInTree( + items: BaseFileItem[], + match: (i: BaseFileItem) => boolean +): BaseFileItem | null { + for (const item of items) { + if (item.type === 'file' && item.name && match(item)) return item + if (item.children) { + const found = findInTree(item.children, match) + if (found) return found + } + } + return null +} + +/** Collect every file in the tree matching `match`, skipping noise folders. */ +function collectFiles( + items: BaseFileItem[], + match: (i: BaseFileItem) => boolean, + out: BaseFileItem[] = [] +): BaseFileItem[] { + for (const item of items) { + if (item.type === 'file' && item.name && match(item)) out.push(item) + if (item.children && !['node_modules', '.git', 'dist'].includes(item.name ?? '')) { + collectFiles(item.children, match, out) + } + } + return out +} + +/** Starter p5.js sketch dropped into newly created .js files. */ +const sketchTemplate = (title: string): string => `// ${title} β€” p5.js sketch. +// Switch to Code to edit this sketch; Visual to run it. + +let x = 240; +let y = 140; +let dx = 3; +let dy = 2; + +function setup() { + createCanvas(480, 280); + noStroke(); +} + +function draw() { + background(7, 11, 34); + x += dx; + y += dy; + if (x < 20 || x > width - 20) dx = -dx; + if (y < 20 || y > height - 20) dy = -dy; + fill(0, 240, 255); + circle(x, y, 40); +} +` + +/** + * Ensure a project file (e.g. diagram.json / visual.js) is open as an editor + * buffer, creating it from a default if it doesn't exist yet. Returns the open + * file once ready. Used by the full-window Circuit/Visual views so their edits + * live in the same buffer model as code (saved on Ctrl+S / build). + */ +function useProjectFile(name: string, makeDefault?: () => string): EditorFile | undefined { + const workspace = useAppSelector((s) => s.file.workspace) + const openFiles = useAppSelector(selectOpenFiles) + const file = openFiles.find((f) => f.name === name) + const busy = useRef(false) + + useEffect(() => { + if (file || !workspace || busy.current) return + busy.current = true + ;(async () => { + try { + let item = findInTree(workspace.root, (i) => i.name === name) + if (!item && makeDefault) { + const path = `${workspace.path}/${name}` + await fileSystem.writeFile(path, makeDefault()) + await new RefreshWorkspaceCommand(workspace).execute() + item = { id: crypto.randomUUID(), parentId: 'root', name, path, type: 'file' } + } + // Load as a hidden background buffer: the full-window view edits/saves + // it, but it stays out of the Code tab bar until the user clicks the + // in-view code button (which reveals it). + if (item) await new OpenFileCommand(item, { hidden: true }).execute() + } finally { + busy.current = false + } + })() + }, [file, workspace, name, makeDefault]) + + return file +} + +export function EditorPanel({ size }: { size: number }): React.JSX.Element { + const editorView = useAppSelector(selectEditorView) + // Track viewport height so the panel re-measures on resize / fullscreen toggle + // (otherwise the height is computed once from a stale window.innerHeight and + // the layout β€” and its buttons β€” break after going fullscreen). + const [winHeight, setWinHeight] = useState(window.innerHeight) + useEffect(() => { + const onResize = (): void => setWinHeight(window.innerHeight) + window.addEventListener('resize', onResize) + return () => window.removeEventListener('resize', onResize) + }, []) + const pixelSize = Math.round((size / 100) * (winHeight - 92)) + + return ( +
+ {editorView === 'circuit' ? ( + + ) : editorView === 'visual' ? ( + + ) : ( + + )} +
+ ) +} + +// ── Code view: the normal tabbed IDE ──────────────────────────────────────── + +function CodeView(): React.JSX.Element { + const allOpenFiles = useAppSelector(selectOpenFiles) + // Background buffers (diagram.json / visual.js loaded for the Circuit/Visual + // views) are hidden from the tab bar until revealed via their code button. + const openFiles = allOpenFiles.filter((f) => !f.hidden) + const viewingFileId = useAppSelector(selectViewingFileId) + const editorMode = useAppSelector((state) => state.editor.editorMode) + const workspace = useAppSelector((s) => s.file.workspace) + const dispatch = useAppDispatch() + const monacoEditorRef = useRef(null) + + const handleFileClose = useCallback( + (fileId: string): void => { + dispatch(closeFile(fileId)) + }, + [dispatch] + ) + + const handleFileSelect = useCallback( + (fileId: string): void => { + dispatch(setViewingFile(fileId)) + setTimeout(() => { + if (monacoEditorRef.current && editorMode !== 'blocks') monacoEditorRef.current.focus() + }, 50) + }, + [dispatch, editorMode] + ) + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent): void => { + if ((event.metaKey || event.ctrlKey) && event.key === 'w') { + event.preventDefault() + if (viewingFileId) handleFileClose(viewingFileId) + } + if (event.ctrlKey && event.key === 'Tab') { + event.preventDefault() + if (openFiles.length > 1) { + const i = openFiles.findIndex((f) => f.id === viewingFileId) + const next = event.shiftKey + ? i <= 0 + ? openFiles.length - 1 + : i - 1 + : (i + 1) % openFiles.length + if (openFiles[next]) handleFileSelect(openFiles[next].id) + } + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [viewingFileId, handleFileClose, openFiles, handleFileSelect]) + + const handleContentChange = useCallback( + (content: string, fileId: string) => { + const file = openFiles.find((f) => f.id === fileId) + if (file) { + dispatch(updateFileContent({ id: file.id, content })) + if (file.name === 'README.md') dispatch(updateReadmeContent(content)) + } + }, + [openFiles, dispatch] + ) + + const handleSaveFile = async (content: string, fileId: string): Promise => { + const file = openFiles.find((f) => f.id === fileId) + if (file && file.path) { + try { + await fileSystem.writeFile(file.path, content) + dispatch(saveFileWithContent({ id: file.id, content })) + } catch (error) { + console.error('Failed to save file:', error) + } + } + } + + if (openFiles.length === 0) { + // No file open β†’ land on the Examples browser so there is something to do + // right away (replaces the old logo / first-run welcome dialog). + return ( +
+ +
+ ) + } + + return ( + + +
+ {openFiles.map((file) => ( + + ))} +
+
+ {openFiles.map((file) => ( + + {editorMode === 'blocks' ? ( + + ) : ( + handleContentChange(content, file.id)} + onSaveFile={(content) => handleSaveFile(content, file.id)} + /> + )} + + ))} +
+ ) +} + +// ── Circuit view: full-window interactive diagram.json ────────────────────── + +function CircuitView(): React.JSX.Element { + const workspace = useAppSelector((s) => s.file.workspace) + const dispatch = useAppDispatch() + const file = useProjectFile('diagram.json', () => DEFAULT_DIAGRAM) + + if (!workspace) return + if (!file) return + + return ( + dispatch(updateFileContent({ id: file.id, content }))} + onOpenCode={() => { + dispatch(revealFile(file.id)) + dispatch(setEditorView('code')) + }} + onEditChange={(editing) => { + // entering edit mode: free horizontal space by closing the docs panel + if (editing) dispatch(setPanelOpen({ panel: 'docs', isOpen: false })) + }} + /> + ) +} + +// ── Visual view: full-window p5 sketch with an Edit-code button ───────────── + +function VisualView(): React.JSX.Element { + const workspace = useAppSelector((s) => s.file.workspace) + const dispatch = useAppDispatch() + + // Which .js sketch is shown in the Visual view. Defaults to visual.js, the + // sketch auto-created for every project; other .js files are selectable too. + const [activeSketch, setActiveSketch] = useState('visual.js') + const [showNew, setShowNew] = useState(false) + // Serial is owned app-wide by SerialProvider and feeds the running sketch via + // the shared bus β€” the Visual view no longer opens the port itself, so + // switching to/from this view doesn't reset the board. + const [publishing, setPublishing] = useState(false) + + const jsFiles = useMemo( + () => (workspace ? collectFiles(workspace.root, (i) => /\.js$/i.test(i.name!)) : []), + [workspace] + ) + + // If the active sketch isn't in this project (after a project switch or a + // delete), fall back to visual.js or the first sketch available. + useEffect(() => { + if (jsFiles.length && !jsFiles.some((f) => f.name === activeSketch)) { + setActiveSketch(jsFiles.find((f) => f.name === 'visual.js')?.name ?? jsFiles[0].name!) + } + }, [jsFiles, activeSketch]) + + // Only visual.js gets seeded from a default; other sketches must already exist. + const file = useProjectFile( + activeSketch, + activeSketch === 'visual.js' ? () => DEFAULT_VISUAL : undefined + ) + + if (!workspace) return + + const ws = workspace + + const createSketch = async (fileName: string): Promise => { + const path = `${ws.path}/${fileName}` + try { + await fileSystem.writeFile(path, sketchTemplate(fileName.replace(/\.js$/i, ''))) + await new RefreshWorkspaceCommand(ws).execute() + setActiveSketch(fileName) + setShowNew(false) + } catch (e) { + toast.error('Could not create sketch', { + description: e instanceof Error ? e.message : 'Unknown error' + }) + } + } + + // Build the standalone page, titled after the .ino project (not the folder). + const buildHtml = (): string => { + const ino = findInTree(ws.root, (i) => /\.ino$/i.test(i.name!)) + const projectName = ino?.name?.replace(/\.ino$/i, '') || ws.name || 'tinyStudio sketch' + return buildVisualExportHtml(projectName, file?.content ?? '') + } + + // Preview: write index.html into the project root and open it in the browser. + const preview = async (): Promise => { + const path = `${ws.path}/index.html` + try { + await window.api.fs.writeFile(path, buildHtml()) + await new RefreshWorkspaceCommand(ws).execute() + const err = await window.api.fs.openPath(path) + if (err) toast.error('Could not open preview', { description: err }) + else toast.success('Preview opened in your browser', { description: path }) + } catch (e) { + toast.error('Preview failed', { + description: e instanceof Error ? e.message : 'Unknown error' + }) + } + } + + // Publish: push index.html to the linked repo and turn on GitHub Pages. + const publish = async (): Promise => { + const account = loadAccount() + if (!account) { + toast.info('Connect GitHub first', { + description: 'Open the GitHub tab in the sidebar to sign in.' + }) + return + } + const link = loadLink(ws.path) + if (!link) { + toast.info('Link this project to a repo first', { + description: 'Use the GitHub tab to link or publish a repository.' + }) + return + } + setPublishing(true) + try { + const html = buildHtml() + await window.api.fs.writeFile(`${ws.path}/index.html`, html) + await new RefreshWorkspaceCommand(ws).execute() + await pushFile( + link.remote, + link.branch, + 'index.html', + html, + account.token, + 'Publish web export via tinyStudio' + ) + const url = await enablePages(link.remote, link.branch, account.token) + toast.success('Published to GitHub Pages', { + description: `${url} β€” the first build can take a minute.` + }) + window.api.fs.openExternal(url) + } catch (e) { + toast.error('Publish failed', { + description: e instanceof Error ? e.message : 'Unknown error' + }) + } finally { + setPublishing(false) + } + } + + return ( +
+ {/* Sketch picker + view actions. Tabs let you keep several p5.js renders + (asteroids.js, pong.js, …) in one project and switch between them. */} +
+
+ {jsFiles.map((f) => ( + + ))} + +
+
+
+ +
+ {file ? ( + + { + dispatch(revealFile(file.id)) + dispatch(setEditorView('code')) + }} + > + + + + + + + {publishing ? ( + + ) : ( + + )} + + + } + /> + ) : ( + + )} +
+ + f.name!)} + onCreate={createSketch} + /> +
+ ) +} + +function NewSketchDialog({ + open, + onOpenChange, + existing, + onCreate +}: { + open: boolean + onOpenChange: (o: boolean) => void + existing: string[] + onCreate: (fileName: string) => void +}): React.JSX.Element { + const [name, setName] = useState('') + + // Reset the field each time the dialog opens. + useEffect(() => { + if (open) setName('') + }, [open]) + + const trimmed = name.trim() + const fileName = trimmed ? (/\.js$/i.test(trimmed) ? trimmed : `${trimmed}.js`) : '' + const duplicate = !!fileName && existing.includes(fileName) + const invalid = !trimmed || /[\\/:*?"<>|]/.test(trimmed) || duplicate + + const submit = (): void => { + if (!invalid) onCreate(fileName) + } + + return ( + + + + New p5.js sketch + + Creates a new .js sketch in your project. Switch + between sketches with the tabs in the Visual view. + + +
+ setName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && submit()} + /> + {duplicate && ( +

+ A file named {fileName} already exists. +

+ )} +
+ + + + +
+
+ ) +} + +function EmptyHint({ label }: { icon: string; label: string }): React.JSX.Element { + return ( +
+ +

{label}

+
+ ) +} + +function LoadingHint({ label }: { label: string }): React.JSX.Element { + return ( +
+ +

{label}

+
+ ) +} diff --git a/src/renderer/src/components/.fuse_hidden0000001400000002 b/src/renderer/src/components/.fuse_hidden0000001400000002 new file mode 100644 index 0000000..69d91c2 --- /dev/null +++ b/src/renderer/src/components/.fuse_hidden0000001400000002 @@ -0,0 +1,1864 @@ +/** + * DiagramEditor β€” renders a project's `diagram.json` as a live, editable circuit. + * + * The wiring system is ported from the tinySchematic prototype (a Fritzing-style + * breadboard/schematic wiring engine): absolute bendpoints that hold their place + * when parts move, orthogonal auto-routing with clean pin entry, junctions + * (wire-to-wire taps), straight/diagonal mode (hold Shift), per-segment / bend / + * endpoint handle editing, double-click to add or remove a bend, and a live + * equipotential net model that glows everything sharing a node. Wires are drawn + * exactly like tinySchematic: rounded glossy tubes (dark outline β†’ color core β†’ + * animated selection dashes) with a yellow net glow. + * + * Around it: a Fritzing-style two-column components rail, a right-hand Inspector + * that edits the selected part's name / location / rotation / properties (e.g. a + * resistor's value) β€” or a selected wire's color β€” drag-to-place, draggable part + * labels, zoom/fit, and Wokwi `diagram.json` serialization. + * + * Styling follows the tinyStudio design system: cold neutral surfaces, the four + * PCB inks, a notebook dot-grid canvas, tactile controls, mono eyebrows, + * sentence-case copy. + */ + +import { + ChevronDown, + ChevronRight, + CircuitBoard, + CodeXml, + Grid3x3, + Maximize, + MousePointer2, + Pencil, + Plus, + RotateCw, + Trash2, + Eye, + Spline, + Zap, + ZoomIn, + ZoomOut +} from 'lucide-react' +import React from 'react' +import { + ensureParts, + getPart, + loadPart, + partsByFamily, + registerPart, + viewFor, + type PartDef, + type PartMeta, + type ViewKind +} from '../lib/partsLibrary' +import { + bendsFromInstructions, + buildWirePoints, + calculateOrthogonalPath, + clampOntoSegment, + dragSegment, + getWireAt, + getWirePoints, + instructionsFromPoints, + isStraightRoute, + simplifyWirePoints, + type Connection, + type PinRef, + type Pt +} from '../lib/wireRouting' +import { buildNets, meaningfulNetCount } from '../lib/circuitNets' +import { PartsEditor } from './PartsEditor' + +const CANVAS_W = 1100 +const CANVAS_H = 640 +const GRID = 9.6 // 100 mil (0.1in) at 96 DPI β€” the standard breadboard pitch + +// wire look (mirrors tinySchematic config.js) +const WIRE_W = 2.8 +const WIRE_OUTLINE_W = WIRE_W + 1.8 +const WIRE_GLOW_W = WIRE_W + 5 +const WIRE_CORNER = 4 +const NET_GLOW = 'rgba(243, 203, 0, 0.30)' // brand-yellow equipotential glow + +interface Part { + id: string + type: string + left: number + top: number + rotate?: number // 0 | 90 | 180 | 270 (Wokwi-compatible) + attrs?: Record +} +// Per-view overrides for the schematic layout. The top-level parts/connections +// stay the Wokwi breadboard layout; the schematic view keeps its own positions +// and wire routes here so the two diagrams can diverge. A part/wire with no +// schematic entry falls back to its breadboard placement (until it's moved). +interface SchematicLayout { + pos: Record // partId -> [left, top] + routes: Record // connKey -> h/v instructions +} +interface Diagram { + parts: Part[] + connections: Connection[] + author?: string + schematic: SchematicLayout +} + +// The four PCB inks carry meaning (green = signal/connected, red = power/stop, +// blue = primary, gold = warn) plus a neutral white and slate for contrast. We +// persist concrete hex (not CSS vars) so the colors survive export. +const WIRE_COLORS = ['#2fa46a', '#e5544b', '#42a5f5', '#f3cb00', '#ffffff', '#79818c'] + +const uid = (): string => Math.random().toString(36).slice(2, 8) +const snap = (v: number): number => Math.round(v / GRID) * GRID + +// read-compat: accept Wokwi left/top or the legacy x/y +function readPart(p: Record): Part { + return { + id: String(p.id), + type: String(p.type), + left: Number(p.left ?? p.x ?? 0), + top: Number(p.top ?? p.y ?? 0), + rotate: p.rotate ? Number(p.rotate) : undefined, + attrs: (p.attrs as Record) || undefined + } +} + +// rotate a local point (px,py) around a part's centre by `deg` (CSS-rotate space) +function rotatePoint(px: number, py: number, w: number, h: number, deg: number): Pt { + const rad = (deg * Math.PI) / 180 + const cos = Math.cos(rad) + const sin = Math.sin(rad) + const dx = px - w / 2 + const dy = py - h / 2 + return { x: w / 2 + dx * cos - dy * sin, y: h / 2 + dx * sin + dy * cos } +} + +const eqRef = (a: PinRef, b: PinRef): boolean => { + if (typeof a === 'string' && typeof b === 'string') return a === b + if (typeof a === 'object' && typeof b === 'object') + return Math.abs(a.x - b.x) < 0.5 && Math.abs(a.y - b.y) < 0.5 + return false +} + +// Drag a bendpoint while keeping its neighbouring segments orthogonal (ported +// from tinySchematic's vertex-drag logic). +function vertexDrag(orig: Pt[], index: number, x: number, y: number): Pt[] { + const points = orig.map((p) => ({ x: p.x, y: p.y })) + points[index].x = x + points[index].y = y + if (index > 0) { + if ( + Math.abs(points[index - 1].y - points[index].y) < + Math.abs(points[index - 1].x - points[index].x) + ) { + if (index - 1 > 0) points[index - 1].y = points[index].y + else points[index].y = points[index - 1].y + } else { + if (index - 1 > 0) points[index - 1].x = points[index].x + else points[index].x = points[index - 1].x + } + } + if (index < points.length - 1) { + if ( + Math.abs(points[index + 1].y - points[index].y) < + Math.abs(points[index + 1].x - points[index].x) + ) { + if (index + 1 < points.length - 1) points[index + 1].y = points[index].y + else points[index].y = points[index + 1].y + } else { + if (index + 1 < points.length - 1) points[index + 1].x = points[index].x + else points[index].x = points[index + 1].x + } + } + return points +} + +// Build an SVG path with rounded corners (a quadratic fillet at each bend), +// matching tinySchematic's arcTo wire look. +function roundedPath(points: Pt[], r = WIRE_CORNER): string { + if (points.length < 2) return '' + if (points.length === 2) return `M${points[0].x} ${points[0].y} L${points[1].x} ${points[1].y}` + let d = `M${points[0].x} ${points[0].y}` + for (let i = 1; i < points.length - 1; i++) { + const p = points[i - 1] + const c = points[i] + const n = points[i + 1] + const d1 = Math.hypot(c.x - p.x, c.y - p.y) || 1 + const d2 = Math.hypot(n.x - c.x, n.y - c.y) || 1 + const rr = Math.max(0, Math.min(r, d1 / 2, d2 / 2)) + const a = { x: c.x - ((c.x - p.x) / d1) * rr, y: c.y - ((c.y - p.y) / d1) * rr } + const b = { x: c.x + ((n.x - c.x) / d2) * rr, y: c.y + ((n.y - c.y) / d2) * rr } + d += ` L${a.x} ${a.y} Q${c.x} ${c.y} ${b.x} ${b.y}` + } + const last = points[points.length - 1] + d += ` L${last.x} ${last.y}` + return d +} + +// A darker shade of a wire's color for its "3D" outline (Fritzing tube look). +function darken(hex: string, f = 0.55): string { + const m = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim()) + if (!m) return 'rgba(0,0,0,0.45)' + const n = parseInt(m[1], 16) + const r = Math.round(((n >> 16) & 255) * f) + const g = Math.round(((n >> 8) & 255) * f) + const b = Math.round((n & 255) * f) + return `rgb(${r}, ${g}, ${b})` +} + +// A small SVG thumbnail for the palette / part tiles. Fritzing icon art is dark +// line-work meant for a light background, so we put it on a light chip. +function Thumb({ svg, size = 40 }: { svg?: string; size?: number }): React.JSX.Element { + if (!svg) + return ( +
+ +
+ ) + return ( +
+ ) +} + +type HandleKind = 'vertex' | 'edge' | 'endpoint' + +export function DiagramEditor({ + content, + onChange, + onOpenCode +}: { + content: string + onChange: (next: string) => void + // Switch to the Code view with diagram.json selected (the `` button). + onOpenCode?: () => void +}): React.JSX.Element { + const diagram: Diagram = React.useMemo(() => { + try { + const d = JSON.parse(content || '{}') + const sch = d.schematic || {} + return { + parts: (d.parts || []).map(readPart), + connections: (d.connections || []) as Connection[], + author: d.author, + schematic: { pos: sch.pos || {}, routes: sch.routes || {} } + } + } catch { + return { parts: [], connections: [], schematic: { pos: {}, routes: {} } } + } + }, [content]) + + const [view, setView] = React.useState('breadboard') + const [editable, setEditable] = React.useState(false) + const [grid, setGrid] = React.useState(true) + const [editorPart, setEditorPart] = React.useState(undefined) // undefined = closed + const [selPart, setSelPart] = React.useState(null) + const [selWire, setSelWire] = React.useState(null) + const [wireColor, setWireColor] = React.useState(WIRE_COLORS[0]) + const [armed, setArmed] = React.useState<{ + from: PinRef + points: Pt[] + straight: boolean + } | null>(null) + const [hoverPin, setHoverPin] = React.useState<{ id: string; pin: string } | null>(null) + const [hoverWire, setHoverWire] = React.useState(null) + const [mouse, setMouse] = React.useState({ x: 0, y: 0 }) + const [straight, setStraight] = React.useState(false) + const [collapsed, setCollapsed] = React.useState>(new Set()) + const [editPts, setEditPts] = React.useState(null) // selected-wire editing buffer + const [newAttr, setNewAttr] = React.useState('') + const [tick, force] = React.useReducer((n) => n + 1, 0) + + const viewRef = React.useRef(null) + const innerRef = React.useRef(null) + const drag = React.useRef<{ + id: string + offX: number + offY: number + affected: { i: number; bends: Pt[]; straight: boolean }[] + } | null>(null) + const labelDrag = React.useRef<{ id: string; ox: number; oy: number } | null>(null) + const pan = React.useRef<{ x: number; y: number } | null>(null) + const handleDrag = React.useRef<{ kind: HandleKind } | null>(null) + const [cam, setCam] = React.useState({ scale: 0.62, tx: 0, ty: 0 }) + const scale = cam.scale + const didInit = React.useRef(false) + + React.useEffect(() => { + const types = diagram.parts.map((p) => p.type).filter((t) => !getPart(t)) + if (types.length) ensureParts(types).then(force) + }, [diagram.parts]) + + const write = (d: Partial): void => { + const sch = d.schematic || diagram.schematic + const hasSch = Object.keys(sch.pos).length > 0 || Object.keys(sch.routes).length > 0 + const next = { + version: 1, + editor: 'tinystudio', + author: diagram.author, + parts: (d.parts || diagram.parts).map((p) => ({ + type: p.type, + id: p.id, + left: p.left, + top: p.top, + ...(p.rotate ? { rotate: p.rotate } : {}), + ...(p.attrs && Object.keys(p.attrs).length ? { attrs: p.attrs } : {}) + })), + connections: d.connections || diagram.connections, + ...(hasSch ? { schematic: sch } : {}) + } + onChange(JSON.stringify(next, null, 2)) + } + + // ── per-view layout helpers ───────────────────────────────────────────────── + + const refStr = (r: PinRef): string => (typeof r === 'string' ? r : `${r.x},${r.y}`) + const connKey = (c: Connection): string => `${refStr(c[0])}>${refStr(c[1])}` + // position of a part in the *current* view (schematic falls back to breadboard) + const partXY = (part: Part): [number, number] => + view === 'schematic' + ? diagram.schematic.pos[part.id] || [part.left, part.top] + : [part.left, part.top] + // wire route in the current view (schematic starts clean, then diverges) + const routeFor = (c: Connection): string[] => + view === 'schematic' ? (diagram.schematic.routes[connKey(c)] ?? []) : c[3] || [] + + // ── geometry ────────────────────────────────────────────────────────────── + + const pinAbsAt = (part: Part, pin: string, left: number, top: number): Pt | null => { + const def = getPart(part.type) + if (!def) return null + const v = viewFor(def, view) + const p = v?.pins[pin] + if (!p) return null + const rp = part.rotate ? rotatePoint(p[0], p[1], v!.w, v!.h, part.rotate) : { x: p[0], y: p[1] } + return { x: left + rp.x, y: top + rp.y } + } + const pinAbs = (part: Part, pin: string): Pt | null => { + const [left, top] = partXY(part) + return pinAbsAt(part, pin, left, top) + } + // Resolve refs, optionally overriding some parts' positions (used mid-drag so + // bends stay fixed in space while a part moves). + const resolveWith = + (posMap: Record) => + (ref: PinRef): Pt | null => { + if (typeof ref === 'object') return ref + const [id, pin] = ref.split(':') + const part = diagram.parts.find((p) => p.id === id) + if (!part) return null + const [left, top] = posMap[id] ?? partXY(part) + return pinAbsAt(part, pin, left, top) + } + const resolve = resolveWith({}) + const wirePts = (c: Connection): Pt[] => getWirePoints(c, resolve, routeFor(c)) + + const canvasPoint = (e: { clientX: number; clientY: number }): Pt => { + const r = innerRef.current!.getBoundingClientRect() + return { x: (e.clientX - r.left) / scale, y: (e.clientY - r.top) / scale } + } + const getPinAtWorld = (wx: number, wy: number): { id: string; pin: string; pos: Pt } | null => { + const tol = 8 / scale + for (const part of diagram.parts) { + const def = getPart(part.type) + const v = def && viewFor(def, view) + if (!v) continue + for (const pin of Object.keys(v.pins)) { + const pos = pinAbs(part, pin) + if (pos && Math.hypot(pos.x - wx, pos.y - wy) < tol) return { id: part.id, pin, pos } + } + } + return null + } + + // ── equipotential net model (drives net glow + the routed count) ──────────── + + const netModel = React.useMemo( + () => buildNets(diagram.parts, diagram.connections, (c) => wirePts(c)), + // eslint-disable-next-line react-hooks/exhaustive-deps + [diagram, view, tick] + ) + // Highlight the net under the hovered pin, hovered wire, or selected wire β€” so + // you can see (and effectively "select") a whole net. + const highlightNet = React.useMemo(() => { + if (hoverPin) { + const i = netModel.pinToNet.get(`${hoverPin.id}:${hoverPin.pin}`) + if (i != null) return i + } + if (hoverWire != null) { + const i = netModel.connToNet[hoverWire] + if (i >= 0) return i + } + if (selWire != null) { + const i = netModel.connToNet[selWire] + if (i >= 0) return i + } + return -1 + }, [hoverPin, hoverWire, selWire, netModel]) + + // ── camera ────────────────────────────────────────────────────────────────── + + const fitView = (): void => { + const el = viewRef.current + if (!el) return + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + for (const part of diagram.parts) { + const def = getPart(part.type) + const v = def && viewFor(def, view) + if (!v) continue + const [l, t] = partXY(part) + minX = Math.min(minX, l) + minY = Math.min(minY, t) + maxX = Math.max(maxX, l + v.w) + maxY = Math.max(maxY, t + v.h) + } + for (const c of diagram.connections) { + for (const p of wirePts(c)) { + minX = Math.min(minX, p.x) + minY = Math.min(minY, p.y) + maxX = Math.max(maxX, p.x) + maxY = Math.max(maxY, p.y) + } + } + if (!isFinite(minX)) { + const f = Math.min(el.clientWidth / CANVAS_W, el.clientHeight / CANVAS_H, 1) + setCam({ + scale: f, + tx: (el.clientWidth - CANVAS_W * f) / 2, + ty: (el.clientHeight - CANVAS_H * f) / 2 + }) + return + } + const pad = 40 + minX -= pad + minY -= pad + maxX += pad + maxY += pad + const bw = maxX - minX + const bh = maxY - minY + const f = Math.max(0.25, Math.min(3, Math.min(el.clientWidth / bw, el.clientHeight / bh))) + setCam({ + scale: f, + tx: (el.clientWidth - bw * f) / 2 - minX * f, + ty: (el.clientHeight - bh * f) / 2 - minY * f + }) + } + React.useEffect(() => { + if (didInit.current) return + if (diagram.parts.length === 0 || diagram.parts.every((p) => getPart(p.type))) { + fitView() + didInit.current = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [content, tick]) + + const zoomAt = (factor: number, sx: number, sy: number): void => { + setCam((c) => { + const ns = Math.max(0.25, Math.min(3, c.scale * factor)) + const k = ns / c.scale + return { scale: ns, tx: sx - (sx - c.tx) * k, ty: sy - (sy - c.ty) * k } + }) + } + const zoomCenter = (factor: number): void => { + const el = viewRef.current + if (el) zoomAt(factor, el.clientWidth / 2, el.clientHeight / 2) + } + const onWheel = (e: React.WheelEvent): void => { + const rect = viewRef.current!.getBoundingClientRect() + zoomAt(e.deltaY < 0 ? 1.12 : 1 / 1.12, e.clientX - rect.left, e.clientY - rect.top) + } + const onViewPointerDown = (e: React.PointerEvent): void => { + if (e.button !== 1 && !(e.button === 0 && e.altKey)) return + e.preventDefault() + pan.current = { x: e.clientX, y: e.clientY } + window.addEventListener('pointermove', onPanMove) + window.addEventListener('pointerup', onPanUp) + } + const onPanMove = (e: PointerEvent): void => { + const p = pan.current + if (!p) return + setCam((c) => ({ ...c, tx: c.tx + (e.clientX - p.x), ty: c.ty + (e.clientY - p.y) })) + pan.current = { x: e.clientX, y: e.clientY } + } + const onPanUp = (): void => { + pan.current = null + window.removeEventListener('pointermove', onPanMove) + window.removeEventListener('pointerup', onPanUp) + } + + // ── shared: re-sync a part's wires after a discrete move/rotate ────────────── + + const touches = (ref: PinRef, id: string): boolean => + typeof ref === 'string' && ref.split(':')[0] === id + + const collectFrozen = (id: string): { i: number; bends: Pt[]; straight: boolean }[] => { + const f: { i: number; bends: Pt[]; straight: boolean }[] = [] + diagram.connections.forEach((c, i) => { + if (!touches(c[0], id) && !touches(c[1], id)) return + const route = routeFor(c) + const s = resolve(c[0]) + const t = resolve(c[1]) + if (s && t) + f.push({ i, bends: bendsFromInstructions(route, s, t), straight: isStraightRoute(route) }) + }) + return f + } + // Apply a new parts array (and optional schematic-pos patch) for `id`, holding + // its wires' bends fixed in world space. + const commitParts = ( + parts: Part[], + id: string, + schPosPatch?: Record + ): void => { + const frozen = collectFrozen(id) + const schPos = schPosPatch + ? { ...diagram.schematic.pos, ...schPosPatch } + : diagram.schematic.pos + const resolveNew = (ref: PinRef): Pt | null => { + if (typeof ref === 'object') return ref + const [pid, pin] = ref.split(':') + const part = parts.find((p) => p.id === pid) + if (!part) return null + const [l, t] = + view === 'schematic' ? schPos[pid] || [part.left, part.top] : [part.left, part.top] + return pinAbsAt(part, pin, l, t) + } + if (view === 'schematic') { + const routes = { ...diagram.schematic.routes } + frozen.forEach((a) => { + const c = diagram.connections[a.i] + const s = resolveNew(c[0]) + const t = resolveNew(c[1]) + if (s && t) + routes[connKey(c)] = instructionsFromPoints( + buildWirePoints(s, a.bends, t, a.straight), + a.straight + ) + }) + write({ parts, schematic: { pos: schPos, routes } }) + } else { + const conns = diagram.connections.slice() + frozen.forEach((a) => { + const c = diagram.connections[a.i] + const s = resolveNew(c[0]) + const t = resolveNew(c[1]) + if (s && t) { + const nc = conns[a.i].slice() as Connection + nc[3] = instructionsFromPoints(buildWirePoints(s, a.bends, t, a.straight), a.straight) + conns[a.i] = nc + } + }) + write({ parts, connections: conns }) + } + } + + // ── part drag (absolute bendpoints: bends hold while the part moves) ───────── + + const onPartDown = (e: React.PointerEvent, part: Part): void => { + if (!editable || (e.target as HTMLElement).closest('.pin-hit')) return + e.stopPropagation() + setSelPart(part.id) + selectWire(null) + const r = innerRef.current!.getBoundingClientRect() + const [left, top] = partXY(part) + drag.current = { + id: part.id, + offX: (e.clientX - r.left) / scale - left, + offY: (e.clientY - r.top) / scale - top, + affected: collectFrozen(part.id) + } + window.addEventListener('pointermove', onPartMove) + window.addEventListener('pointerup', onPartUp) + } + const onPartMove = (e: PointerEvent): void => { + const d = drag.current + if (!d) return + const r = innerRef.current!.getBoundingClientRect() + const nx = snap((e.clientX - r.left) / scale - d.offX) + const ny = snap((e.clientY - r.top) / scale - d.offY) + const resolveNext = resolveWith({ [d.id]: [nx, ny] }) + const updates: { i: number; key: string; instr: string[] }[] = [] + for (const a of d.affected) { + const c = diagram.connections[a.i] + const src = resolveNext(c[0]) + const tgt = resolveNext(c[1]) + if (!src || !tgt) continue + const pts = buildWirePoints(src, a.bends, tgt, a.straight) + updates.push({ i: a.i, key: connKey(c), instr: instructionsFromPoints(pts, a.straight) }) + } + if (view === 'schematic') { + const routes = { ...diagram.schematic.routes } + updates.forEach((u) => (routes[u.key] = u.instr)) + write({ schematic: { pos: { ...diagram.schematic.pos, [d.id]: [nx, ny] }, routes } }) + } else { + const parts = diagram.parts.map((p) => (p.id === d.id ? { ...p, left: nx, top: ny } : p)) + const conns = diagram.connections.slice() + updates.forEach((u) => { + const nc = conns[u.i].slice() as Connection + nc[3] = u.instr + conns[u.i] = nc + }) + write({ parts, connections: conns }) + } + } + const onPartUp = (): void => { + drag.current = null + window.removeEventListener('pointermove', onPartMove) + window.removeEventListener('pointerup', onPartUp) + } + + // drag a part's title label to reposition it (offset stored on the part) + const onLabelDown = (e: React.PointerEvent, part: Part): void => { + if (!editable) return + e.stopPropagation() + setSelPart(part.id) + selectWire(null) + const start = canvasPoint(e) + const off = (part.attrs?.labelOffset as [number, number]) || [0, 0] + labelDrag.current = { id: part.id, ox: off[0] - start.x, oy: off[1] - start.y } + const move = (ev: PointerEvent): void => { + const ld = labelDrag.current + if (!ld) return + const cp = canvasPoint(ev) + const offset: [number, number] = [Math.round(ld.ox + cp.x), Math.round(ld.oy + cp.y)] + patchAttr(ld.id, 'labelOffset', offset) + } + const up = (): void => { + labelDrag.current = null + window.removeEventListener('pointermove', move) + window.removeEventListener('pointerup', up) + } + window.addEventListener('pointermove', move) + window.addEventListener('pointerup', up) + } + + // ── wiring ────────────────────────────────────────────────────────────────── + + const selectWire = (i: number | null): void => { + setSelWire(i) + setEditPts(i == null ? null : wirePts(diagram.connections[i])) + } + + const commitNewWire = (from: PinRef, to: PinRef, instr: string[]): void => { + const conn: Connection = + view === 'schematic' ? [from, to, wireColor] : [from, to, wireColor, instr] + if (view === 'schematic') { + write({ + connections: [...diagram.connections, conn], + schematic: { + ...diagram.schematic, + routes: { ...diagram.schematic.routes, [connKey(conn)]: instr } + } + }) + } else { + write({ connections: [...diagram.connections, conn] }) + } + } + + const finalizeSegment = (target: Pt, clean: boolean): { pts: Pt[]; str: boolean } => { + const pts = [...armed!.points] + const last = pts[pts.length - 1] + const str = armed!.straight || straight + if (str) pts.push(target) + else pts.push(...calculateOrthogonalPath(last.x, last.y, target.x, target.y, clean).slice(1)) + return { pts: simplifyWirePoints(pts), str } + } + + const onPinClick = (e: React.MouseEvent, part: Part, pin: string): void => { + if (!editable) return + e.stopPropagation() + const ref = `${part.id}:${pin}` + const pos = pinAbs(part, pin) + if (!pos) return + if (!armed) { + setArmed({ from: ref, points: [pos], straight: false }) + setSelPart(null) + selectWire(null) + return + } + if (eqRef(armed.from, ref)) { + setArmed(null) + return + } + const { pts, str } = finalizeSegment(pos, true) + const instr = instructionsFromPoints(pts, str) + const exists = diagram.connections.some( + (c) => (eqRef(c[0], armed.from) && c[1] === ref) || (c[0] === ref && eqRef(c[1], armed.from)) + ) + if (!exists) commitNewWire(armed.from, ref, instr) + setArmed(null) + } + + // tap a junction onto an existing wire `i` at the click point + const tapJunction = (e: React.MouseEvent, i: number): void => { + const cp = canvasPoint(e) + const pts = wirePts(diagram.connections[i]) + const hit = getWireAt(cp.x, cp.y, [diagram.connections[i]], () => pts, scale) + const seg = hit ? { p1: hit.p1, p2: hit.p2 } : { p1: pts[0], p2: pts[1] } + const jp = clampOntoSegment(seg.p1, seg.p2, snap(cp.x), snap(cp.y)) + const { pts: fp, str } = finalizeSegment(jp, false) + commitNewWire(armed!.from, { x: jp.x, y: jp.y }, instructionsFromPoints(fp, str)) + setArmed(null) + } + + const onCanvasClick = (e: React.MouseEvent): void => { + if (armed) { + const cp = canvasPoint(e) + const hit = getWireAt(cp.x, cp.y, diagram.connections, (c) => wirePts(c), scale) + if (hit) { + tapJunction(e, hit.index) + return + } + const target = { x: snap(cp.x), y: snap(cp.y) } + const last = armed.points[armed.points.length - 1] + const seg = straight + ? [last, target] + : calculateOrthogonalPath(last.x, last.y, target.x, target.y, false) + setArmed({ + ...armed, + points: [...armed.points, ...seg.slice(1)], + straight: armed.straight || straight + }) + return + } + setSelPart(null) + selectWire(null) + } + const onCanvasMove = (e: React.PointerEvent): void => { + if (armed) setMouse(canvasPoint(e)) + } + + // ── wire editing β€” endpoint / bend / segment handles ───────────────────────── + + const resolveEndpointTarget = (wx: number, wy: number, selfIdx: number): PinRef => { + const pin = getPinAtWorld(wx, wy) + if (pin) return `${pin.id}:${pin.pin}` + const hit = getWireAt(wx, wy, diagram.connections, (c) => wirePts(c), scale) + if (hit && hit.index !== selfIdx) return clampOntoSegment(hit.p1, hit.p2, snap(wx), snap(wy)) + return { x: snap(wx), y: snap(wy) } + } + + const writeWire = (i: number, opts: { instr: string[]; from?: PinRef; to?: PinRef }): void => { + const c = diagram.connections[i] + const oldKey = connKey(c) + const nc = c.slice() as Connection + if (opts.from !== undefined) nc[0] = opts.from + if (opts.to !== undefined) nc[1] = opts.to + const newKey = connKey(nc) + const conns = diagram.connections.slice() + if (view === 'schematic') { + const routes = { ...diagram.schematic.routes } + if (oldKey !== newKey) delete routes[oldKey] + routes[newKey] = opts.instr + conns[i] = nc + write({ connections: conns, schematic: { ...diagram.schematic, routes } }) + } else { + nc[3] = opts.instr + conns[i] = nc + if (oldKey !== newKey && diagram.schematic.routes[oldKey]) { + const routes = { ...diagram.schematic.routes } + routes[newKey] = routes[oldKey] + delete routes[oldKey] + write({ connections: conns, schematic: { ...diagram.schematic, routes } }) + } else { + write({ connections: conns }) + } + } + } + + // recolor an existing wire (conn[2] is the logical color, shared across views) + const recolorWire = (i: number, color: string): void => { + const conns = diagram.connections.slice() + const nc = conns[i].slice() as Connection + nc[2] = color + conns[i] = nc + write({ connections: conns }) + } + // colour swatch: recolour the selected wire if one is selected; else set the + // colour future wires are drawn in. + const pickColor = (color: string): void => { + setWireColor(color) + if (selWire != null) recolorWire(selWire, color) + } + + const startHandleDrag = ( + e: React.PointerEvent, + kind: HandleKind, + idx: number, + end?: 0 | 1 + ): void => { + if (!editable || selWire == null || !editPts) return + e.stopPropagation() + const wireIdx = selWire + const orig = editPts + const str = isStraightRoute(routeFor(diagram.connections[wireIdx])) + const axis: 'horizontal' | 'vertical' = + kind === 'edge' && Math.abs(orig[idx].y - orig[idx + 1].y) < 0.001 ? 'horizontal' : 'vertical' + const endIdx = end === 0 ? 0 : orig.length - 1 + let live = orig + let lastWorld = { x: 0, y: 0 } + handleDrag.current = { kind } + const move = (ev: PointerEvent): void => { + const cp = canvasPoint(ev) + if (kind === 'vertex') { + live = vertexDrag(orig, idx, snap(cp.x), snap(cp.y)) + } else if (kind === 'edge') { + live = dragSegment(orig, idx, axis, snap(cp.x), snap(cp.y)) + } else { + lastWorld = { x: cp.x, y: cp.y } + const p = { x: snap(cp.x), y: snap(cp.y) } + live = orig.map((pt, k) => (k === endIdx ? p : pt)) + } + setEditPts(live) + } + const up = (): void => { + window.removeEventListener('pointermove', move) + window.removeEventListener('pointerup', up) + handleDrag.current = null + let from: PinRef | undefined + let to: PinRef | undefined + if (kind === 'endpoint') { + const tgt = resolveEndpointTarget(lastWorld.x, lastWorld.y, wireIdx) + if (end === 0) from = tgt + else to = tgt + const rp = typeof tgt === 'object' ? tgt : resolve(tgt) + if (rp) live = live.map((pt, k) => (k === endIdx ? rp : pt)) + } + writeWire(wireIdx, { instr: instructionsFromPoints(simplifyWirePoints(live), str), from, to }) + } + window.addEventListener('pointermove', move) + window.addEventListener('pointerup', up) + } + + const onWireDoubleClick = (e: React.MouseEvent, i: number): void => { + if (!editable) return + e.stopPropagation() + const c = diagram.connections[i] + const str = isStraightRoute(routeFor(c)) + const cp = canvasPoint(e) + const pts = wirePts(c) + const handleR = 7 / scale + for (let k = 1; k < pts.length - 1; k++) { + if (Math.hypot(pts[k].x - cp.x, pts[k].y - cp.y) < handleR) { + const next = simplifyWirePoints(pts.filter((_, j) => j !== k)) + writeWire(i, { instr: instructionsFromPoints(next, str) }) + setEditPts(next) + return + } + } + const hit = getWireAt(cp.x, cp.y, [c], () => pts, scale) + if (hit) { + const next = pts.slice() + next.splice(hit.segmentIndex + 1, 0, { x: snap(cp.x), y: snap(cp.y) }) + writeWire(i, { instr: instructionsFromPoints(next, str) }) + selectWire(i) + setEditPts(next) + } + } + + // wire click: tap a junction while drawing, else select the wire + const onWireClick = (e: React.MouseEvent, i: number): void => { + e.stopPropagation() + if (armed) { + tapJunction(e, i) + return + } + if (!editable) { + selectWire(i) // view-mode: select to inspect / highlight its net + setSelPart(null) + return + } + selectWire(i) + setSelPart(null) + } + + React.useEffect(() => { + if (selWire != null && !handleDrag.current) { + const c = diagram.connections[selWire] + if (c) setEditPts(wirePts(c)) + else selectWire(null) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [content, view]) + + // ── palette / add / delete / properties ───────────────────────────────────── + + const addPart = async (type: string, at?: Pt): Promise => { + const def = getPart(type) || (await loadPart(type)) + if (!def) return + force() + const v = viewFor(def, view) + const w = v?.w || 80 + const h = v?.h || 40 + const left = at ? Math.round(at.x - w / 2) : Math.round(CANVAS_W / 2 - w / 2) + const top = at ? Math.round(at.y - h / 2) : Math.round(CANVAS_H / 2 - h / 2) + const pid = type + '_' + uid().slice(0, 3) + const parts = [...diagram.parts, { id: pid, type, left, top }] + write( + view === 'schematic' + ? { + parts, + schematic: { + ...diagram.schematic, + pos: { ...diagram.schematic.pos, [pid]: [left, top] } + } + } + : { parts } + ) + setSelPart(pid) + } + const onDrop = (e: React.DragEvent): void => { + e.preventDefault() + const type = e.dataTransfer.getData('text/tinystudio-part') + if (type) addPart(type, canvasPoint(e)) + } + + const editExisting = async (type: string): Promise => { + const def = getPart(type) || (await loadPart(type)) + if (def) setEditorPart(def) + } + + // patch a part's attrs (used by the Inspector + label drag) + const patchAttr = (id: string, key: string, value: unknown): void => { + write({ + parts: diagram.parts.map((p) => + p.id === id ? { ...p, attrs: { ...(p.attrs || {}), [key]: value } } : p + ) + }) + } + const removeAttr = (id: string, key: string): void => { + write({ + parts: diagram.parts.map((p) => { + if (p.id !== id) return p + const attrs = { ...(p.attrs || {}) } + delete attrs[key] + return { ...p, attrs } + }) + }) + } + // move a part to an absolute location in the current view (Inspector inputs) + const movePartTo = (id: string, left: number, top: number): void => { + if (view === 'schematic') { + commitParts(diagram.parts, id, { [id]: [left, top] }) + } else { + commitParts( + diagram.parts.map((p) => (p.id === id ? { ...p, left, top } : p)), + id + ) + } + } + const setRotation = (id: string, deg: number): void => { + commitParts( + diagram.parts.map((p) => (p.id === id ? { ...p, rotate: ((deg % 360) + 360) % 360 } : p)), + id + ) + } + const rotatePart = (id: string): void => { + const part = diagram.parts.find((p) => p.id === id) + if (part) setRotation(id, (part.rotate || 0) + 90) + } + + const deletePart = (id: string): void => { + const keep = (c: Connection): boolean => + (typeof c[0] !== 'string' || c[0].split(':')[0] !== id) && + (typeof c[1] !== 'string' || c[1].split(':')[0] !== id) + const routes = { ...diagram.schematic.routes } + const pos = { ...diagram.schematic.pos } + delete pos[id] + diagram.connections.forEach((c) => { + if (!keep(c)) delete routes[connKey(c)] + }) + write({ + parts: diagram.parts.filter((p) => p.id !== id), + connections: diagram.connections.filter(keep), + schematic: { pos, routes } + }) + setSelPart(null) + } + const deleteWire = (i: number): void => { + const gone = diagram.connections[i] + const routes = { ...diagram.schematic.routes } + if (gone) delete routes[connKey(gone)] + write({ + connections: diagram.connections.filter((_, j) => j !== i), + schematic: { ...diagram.schematic, routes } + }) + selectWire(null) + } + + React.useEffect(() => { + const down = (e: KeyboardEvent): void => { + if (document.activeElement && /INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName)) + return + if (e.key === 'Shift') setStraight(true) + if (editable && (e.key === ' ' || e.key === 'r' || e.key === 'R')) { + const id = drag.current?.id || selPart + if (id) { + e.preventDefault() + rotatePart(id) + return + } + } + if (e.key === 'Escape') { + setArmed(null) + setSelPart(null) + selectWire(null) + } + if (e.key === 'Delete' || e.key === 'Backspace') { + if (selWire != null) deleteWire(selWire) + else if (selPart) deletePart(selPart) + } + } + const up = (e: KeyboardEvent): void => { + if (e.key === 'Shift') setStraight(false) + } + window.addEventListener('keydown', down) + window.addEventListener('keyup', up) + return () => { + window.removeEventListener('keydown', down) + window.removeEventListener('keyup', up) + } + }, [selPart, selWire, diagram, editable, view, straight]) + + const previewPts = React.useMemo((): Pt[] | null => { + if (!armed) return null + const last = armed.points[armed.points.length - 1] + let target = { x: snap(mouse.x), y: snap(mouse.y) } + let clean = false + if (hoverPin) { + const part = diagram.parts.find((p) => p.id === hoverPin.id) + const pos = part && pinAbs(part, hoverPin.pin) + if (pos) { + target = pos + clean = true + } + } + if (straight) return [...armed.points, target] + return [ + ...armed.points, + ...calculateOrthogonalPath(last.x, last.y, target.x, target.y, clean).slice(1) + ] + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [armed, mouse, hoverPin, diagram.parts, view, straight]) + + const isAI = (diagram.author || '').includes('Studio AI') + // tactile pixel-art toolbar buttons (design-system .tactile-bordered) + const tool = + 'tactile-bordered h-8 px-2.5 flex items-center gap-1.5 rounded-md bg-surface-card text-text-muted text-xs hover:text-text-body active:translate-y-px' + const families = partsByFamily() + const isOpen = (fam: string): boolean => !collapsed.has(fam) + const toggleFam = (fam: string): void => + setCollapsed((s) => { + const n = new Set(s) + if (n.has(fam)) n.delete(fam) + else n.add(fam) + return n + }) + const netCount = meaningfulNetCount(netModel) + const junctions: Pt[] = [] + diagram.connections.forEach((c) => { + ;[c[0], c[1]].forEach((r) => { + if (typeof r === 'object') junctions.push(r) + }) + }) + + const labelOf = (part: Part): string => { + const def = getPart(part.type) + return (part.attrs?.label as string) || def?.label || part.type + } + const selectedPart = selPart ? diagram.parts.find((p) => p.id === selPart) : null + + return ( +
+ {/* components rail (edit mode only) */} + {editable && ( +
+
+ Components + +
+ {/* wire color β€” recolours the selected wire, else sets the next-wire colour */} +
+ + Wire + + {WIRE_COLORS.map((c) => ( +
+
+ {families.map((group) => ( +
+ + {isOpen(group.family) && ( +
+ {group.parts.map((c: PartMeta) => ( +
e.dataTransfer.setData('text/tinystudio-part', c.type)} + onDoubleClick={() => addPart(c.type)} + className="group relative flex flex-col items-center gap-1 p-2 rounded-lg border border-border-default bg-surface-card hover:bg-bg-sunken hover:-translate-y-px transition cursor-grab active:cursor-grabbing" + title={`${c.label} Β· ${c.pins} pins β€” drag onto the canvas (or double-click)`} + > + +
+ {c.label} +
+ +
+ ))} +
+ )} +
+ ))} +
+
+ )} + + {/* canvas + tools */} +
+
+ + +
+ +
+ + +
+ +
+ + + {Math.round(scale * 100)}% + + + +
+ +
{ + e.preventDefault() + e.dataTransfer.dropEffect = 'copy' + }} + onDrop={onDrop} + onClick={onCanvasClick} + > +
+ + {diagram.connections.map((c, i) => { + const pts = selWire === i && editPts ? editPts : wirePts(c) + if (pts.length < 2) return null + const sel = selWire === i + const onHotNet = highlightNet >= 0 && netModel.connToNet[i] === highlightNet + const core = + view === 'schematic' ? 'var(--text-strong)' : (c[2] as string) || '#2fa46a' + const outline = view === 'schematic' ? 'rgba(0,0,0,0.45)' : darken(core) + const d = roundedPath(pts) + return ( + setHoverWire(i)} + onPointerLeave={() => setHoverWire((h) => (h === i ? null : h))} + onClick={(e) => onWireClick(e, i)} + onDoubleClick={(e) => onWireDoubleClick(e, i)} + > + {/* equipotential net glow */} + {onHotNet && ( + + )} + {/* darker outline β†’ glossy tube */} + + + {sel && ( + + + + )} + + ) + })} + + {/* junction solder dots where a wire taps another wire */} + {junctions.map((j, k) => ( + + ))} + + {previewPts && ( + + )} + + {/* handles on the selected wire: bend (circle), segment (square), + endpoint (ring) β€” Fritzing-style */} + {editable && selWire != null && editPts && ( + + {editPts.slice(0, -1).map((p, i) => { + const q = editPts[i + 1] + const m = { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 } + const horizontal = Math.abs(p.y - q.y) < 0.001 + return ( + startHandleDrag(e, 'edge', i)} + /> + ) + })} + {editPts.slice(1, -1).map((p, i) => ( + startHandleDrag(e, 'vertex', i + 1)} + /> + ))} + {[0, 1].map((end) => { + const p = end === 0 ? editPts[0] : editPts[editPts.length - 1] + return ( + startHandleDrag(e, 'endpoint', 0, end as 0 | 1)} + /> + ) + })} + + )} + + + {diagram.parts.map((part) => { + const def = getPart(part.type) + const v = def && viewFor(def, view) + const [pLeft, pTop] = partXY(part) + if (!def || !v) { + return ( +
+ {part.type}… +
+ ) + } + const labelOff = (part.attrs?.labelOffset as [number, number]) || [0, 0] + return ( +
onPartDown(e, part)} + onClick={(e) => e.stopPropagation()} + onContextMenu={(e) => { + if (!editable) return + e.preventDefault() + rotatePart(part.id) + }} + > +
+ {editable && + Object.keys(v.pins).map((pin) => { + const [px, py] = v.pins[pin] + const armedHere = armed && eqRef(armed.from, `${part.id}:${pin}`) + const hovered = hoverPin?.id === part.id && hoverPin?.pin === pin + const onHotNet = + highlightNet >= 0 && + netModel.pinToNet.get(`${part.id}:${pin}`) === highlightNet + const on = armedHere || hovered + return ( +
e.stopPropagation()} + onPointerEnter={() => setHoverPin({ id: part.id, pin })} + onPointerLeave={() => + setHoverPin((h) => (h?.id === part.id && h?.pin === pin ? null : h)) + } + onClick={(e) => onPinClick(e, part, pin)} + > + + {onHotNet && !on && ( + + )} + + +
+ ) + })} + {/* draggable title label */} +
onLabelDown(e, part)} + > + {labelOf(part)} +
+
+ ) + })} + + {diagram.parts.length === 0 && ( +
+ +
+ Drag parts from the components rail β€” or double-click one to drop it here. +
+
+ )} +
+
+ + {/* footer */} +
+ + Parts: {diagram.parts.length} Β· Connections: {diagram.connections.length} Β· Nets:{' '} + {netCount} + + {isAI && ( + + Auto-wired by Studio AI + + )} + {!editable ? ( + + View-only Β· hover a wire to light its net Β· click edit to change + + ) : armed ? ( + + Click a pin or wire to connect Β· click for a bend Β· hold Shift for + straight Β· Esc to cancel + + ) : selWire != null ? ( + + Drag a handle to reshape Β· double-click to add/remove a bend Β· pick a color Β· Del to + remove + + ) : ( + + Scroll to zoom Β· middle/Alt-drag to pan Β· click a pin to + wire + + )} +
+
+ + {/* inspector (edit mode only) */} + {editable && ( +
+
+ Inspector +
+
+ {selectedPart ? ( + touches(c[0], selectedPart.id) || touches(c[1], selectedPart.id) + ).length + } + onLabel={(val) => patchAttr(selectedPart.id, 'label', val)} + onMove={(l, t) => movePartTo(selectedPart.id, l, t)} + onRotate={(deg) => setRotation(selectedPart.id, deg)} + onSetAttr={(k, val) => patchAttr(selectedPart.id, k, val)} + onRemoveAttr={(k) => removeAttr(selectedPart.id, k)} + onDelete={() => deletePart(selectedPart.id)} + newAttr={newAttr} + setNewAttr={setNewAttr} + /> + ) : selWire != null ? ( + = 0 + ? netModel.nets[netModel.connToNet[selWire]].length + : 0 + } + onColor={(c) => recolorWire(selWire, c)} + onDelete={() => deleteWire(selWire)} + /> + ) : ( +
+ Select a component to edit its name, location, rotation, and properties β€” or a wire + to recolor it. +
+ )} +
+
+ )} + + {editorPart !== undefined && ( + setEditorPart(undefined)} + onSave={(def: PartDef) => { + registerPart(def) + force() + setEditorPart(undefined) + }} + /> + )} +
+ ) +} + +// ── Inspector subcomponents ─────────────────────────────────────────────────── + +const field = + 'bg-bg-sunken border border-border-default rounded px-2 py-1 text-text-strong outline-none focus:border-brand w-full' +const rowLabel = 'text-[10px] text-text-faint uppercase tracking-wide font-mono' + +function Inspector({ + part, + def, + view, + xy, + connCount, + onLabel, + onMove, + onRotate, + onSetAttr, + onRemoveAttr, + onDelete, + newAttr, + setNewAttr +}: { + part: { id: string; type: string; rotate?: number; attrs?: Record } + def?: PartDef + view: ViewKind + xy: [number, number] + connCount: number + onLabel: (v: string) => void + onMove: (l: number, t: number) => void + onRotate: (deg: number) => void + onSetAttr: (k: string, v: string) => void + onRemoveAttr: (k: string) => void + onDelete: () => void + newAttr: string + setNewAttr: (v: string) => void +}): React.JSX.Element { + const label = (part.attrs?.label as string) ?? def?.label ?? part.type + // attrs to expose as editable properties (hide internal layout keys) + const propRows = Object.entries(part.attrs || {}).filter( + ([k]) => k !== 'label' && k !== 'labelOffset' + ) + return ( + <> +
+ + onLabel(e.target.value)} + /> +
+
+ type + {part.type} + family + {def?.family || 'β€”'} + wires + {connCount} +
+ +
+ Location ({view}) +
+ onMove(parseFloat(e.target.value) || 0, xy[1])} + /> + onMove(xy[0], parseFloat(e.target.value) || 0)} + /> +
+
+ +
+ Rotation +
+ + +
+
+ +
+ Properties + {props.length === 0 && ( +
+ No properties yet. Add one below β€” e.g. a resistor’s{' '} + value. +
+ )} + {props.map(([k, val]) => ( +
+ + {k} + + onSetAttr(k, e.target.value)} + /> + +
+ ))} +
+ setNewAttr(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && newAttr.trim()) { + onSetAttr(newAttr.trim(), '') + setNewAttr('') + } + }} + /> + +
+
+ + + + ) +} + +function WireInspector({ + color, + colors, + netPins, + onColor, + onDelete +}: { + color: string + colors: string[] + netPins: number + onColor: (c: string) => void + onDelete: () => void +}): React.JSX.Element { + return ( + <> +
+ + Wire +
+
+ Color +
+ {colors.map((c) => ( +
+
+
+ net pins + {netPins} +
+ + + ) +} diff --git a/src/renderer/src/components/.fuse_hidden0000001400000004 b/src/renderer/src/components/.fuse_hidden0000001400000004 new file mode 100644 index 0000000..fa41250 --- /dev/null +++ b/src/renderer/src/components/.fuse_hidden0000001400000004 @@ -0,0 +1,675 @@ +import { + OpenFileCommand, + RefreshWorkspaceCommand +} from '@renderer/commands/fileCommands' +import { loader } from '@monaco-editor/react' +import { fileSystem } from '@renderer/lib/fileSystem' +import { enablePages, loadAccount, loadLink, pushFile } from '@renderer/lib/github' +import { buildVisualExportHtml } from '@renderer/lib/visualExport' +import { selectOpenFiles, useAppDispatch, useAppSelector } from '@renderer/redux' +import { selectEditorView, setEditorView, setPanelOpen } from '@renderer/redux/editorSlice' +import { + BaseFileItem, + closeFile, + EditorFile, + revealFile, + saveFileWithContent, + selectViewingFileId, + setViewingFile, + updateFileContent, + updateReadmeContent +} from '@renderer/redux/fileSlice' +import { + CircuitBoard, + CodeXml, + ExternalLink, + Loader2, + Plus, + UploadCloud +} from 'lucide-react' +import * as monaco from 'monaco-editor' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { notify as toast } from '@renderer/lib/notify' +import { BlocklyEditor } from './BlocklyEditor' +import { DiagramEditor } from './DiagramEditor' +import { MonacoEditor, MonacoEditorRef } from './MonacoEditor' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from './ui/Dialog' +import { FileTabContent, FileTabs, FileTabsList, FileTabTrigger } from './ui/FileTab' +import { Button } from './ui/Button' +import { IconButton } from './ui/IconButton' +import { Input } from './ui/Input' +import { VisualPreview } from './VisualPreview' + +loader.config({ monaco }) + +const DEFAULT_DIAGRAM = JSON.stringify( + { version: 1, editor: 'tinystudio', parts: [], connections: [] }, + null, + 2 +) +const DEFAULT_VISUAL = `// visual.js β€” Serial Plotter +// Graphs the latest number printed over Serial (serialValue()) as a scrolling +// line, auto-scaling to the data. Try Serial.println(analogRead(A0)) on the +// board. Switch to Code to edit this sketch; Visual to run it. + +let data = []; +const MAX = 240; // points kept on screen + +function setup() { + createCanvas(480, 280); + textFont('monospace'); +} + +function draw() { + background(7, 11, 34); + + // pull the most recent serial value each frame + data.push(serialValue()); + if (data.length > MAX) data.shift(); + + // auto-scale to the data range (with a little headroom) + let lo = Math.min(...data, 0); + let hi = Math.max(...data, 1); + if (hi === lo) hi = lo + 1; + + // grid + stroke(26, 31, 77); + strokeWeight(1); + for (let i = 0; i <= 4; i++) { + let y = map(i, 0, 4, 20, height - 24); + line(40, y, width - 12, y); + } + + // plotted line + noFill(); + stroke(0, 240, 255); + strokeWeight(2); + beginShape(); + for (let i = 0; i < data.length; i++) { + let x = map(i, 0, MAX - 1, 40, width - 12); + let y = map(data[i], lo, hi, height - 24, 20); + vertex(x, y); + } + endShape(); + + // readouts + noStroke(); + fill(235, 238, 255); + textSize(12); + text('value: ' + serialValue().toFixed(2), 44, 16); + fill(120, 130, 170); + text(hi.toFixed(0), 8, 24); + text(lo.toFixed(0), 8, height - 24); +} +` + +function findInTree( + items: BaseFileItem[], + match: (i: BaseFileItem) => boolean +): BaseFileItem | null { + for (const item of items) { + if (item.type === 'file' && item.name && match(item)) return item + if (item.children) { + const found = findInTree(item.children, match) + if (found) return found + } + } + return null +} + +/** Collect every file in the tree matching `match`, skipping noise folders. */ +function collectFiles( + items: BaseFileItem[], + match: (i: BaseFileItem) => boolean, + out: BaseFileItem[] = [] +): BaseFileItem[] { + for (const item of items) { + if (item.type === 'file' && item.name && match(item)) out.push(item) + if (item.children && !['node_modules', '.git', 'dist'].includes(item.name ?? '')) { + collectFiles(item.children, match, out) + } + } + return out +} + +/** Starter p5.js sketch dropped into newly created .js files. */ +const sketchTemplate = (title: string): string => `// ${title} β€” p5.js sketch. +// Switch to Code to edit this sketch; Visual to run it. + +let x = 240; +let y = 140; +let dx = 3; +let dy = 2; + +function setup() { + createCanvas(480, 280); + noStroke(); +} + +function draw() { + background(7, 11, 34); + x += dx; + y += dy; + if (x < 20 || x > width - 20) dx = -dx; + if (y < 20 || y > height - 20) dy = -dy; + fill(0, 240, 255); + circle(x, y, 40); +} +` + +/** + * Ensure a project file (e.g. diagram.json / visual.js) is open as an editor + * buffer, creating it from a default if it doesn't exist yet. Returns the open + * file once ready. Used by the full-window Circuit/Visual views so their edits + * live in the same buffer model as code (saved on Ctrl+S / build). + */ +function useProjectFile(name: string, makeDefault?: () => string): EditorFile | undefined { + const workspace = useAppSelector((s) => s.file.workspace) + const openFiles = useAppSelector(selectOpenFiles) + const file = openFiles.find((f) => f.name === name) + const busy = useRef(false) + + useEffect(() => { + if (file || !workspace || busy.current) return + busy.current = true + ;(async () => { + try { + let item = findInTree(workspace.root, (i) => i.name === name) + if (!item && makeDefault) { + const path = `${workspace.path}/${name}` + await fileSystem.writeFile(path, makeDefault()) + await new RefreshWorkspaceCommand(workspace).execute() + item = { id: crypto.randomUUID(), parentId: 'root', name, path, type: 'file' } + } + // Load as a hidden background buffer: the full-window view edits/saves + // it, but it stays out of the Code tab bar until the user clicks the + // in-view code button (which reveals it). + if (item) await new OpenFileCommand(item, { hidden: true }).execute() + } finally { + busy.current = false + } + })() + }, [file, workspace, name, makeDefault]) + + return file +} + +export function EditorPanel({ size }: { size: number }): React.JSX.Element { + const editorView = useAppSelector(selectEditorView) + // Track viewport height so the panel re-measures on resize / fullscreen toggle + // (otherwise the height is computed once from a stale window.innerHeight and + // the layout β€” and its buttons β€” break after going fullscreen). + const [winHeight, setWinHeight] = useState(window.innerHeight) + useEffect(() => { + const onResize = (): void => setWinHeight(window.innerHeight) + window.addEventListener('resize', onResize) + return () => window.removeEventListener('resize', onResize) + }, []) + const pixelSize = Math.round((size / 100) * (winHeight - 92)) + + return ( +
+ {editorView === 'circuit' ? ( + + ) : editorView === 'visual' ? ( + + ) : ( + + )} +
+ ) +} + +// ── Code view: the normal tabbed IDE ──────────────────────────────────────── + +function CodeView(): React.JSX.Element { + const allOpenFiles = useAppSelector(selectOpenFiles) + // Background buffers (diagram.json / visual.js loaded for the Circuit/Visual + // views) are hidden from the tab bar until revealed via their code button. + const openFiles = allOpenFiles.filter((f) => !f.hidden) + const viewingFileId = useAppSelector(selectViewingFileId) + const editorMode = useAppSelector((state) => state.editor.editorMode) + const workspace = useAppSelector((s) => s.file.workspace) + const dispatch = useAppDispatch() + const monacoEditorRef = useRef(null) + + const handleFileClose = useCallback( + (fileId: string): void => { + dispatch(closeFile(fileId)) + }, + [dispatch] + ) + + const handleFileSelect = useCallback( + (fileId: string): void => { + dispatch(setViewingFile(fileId)) + setTimeout(() => { + if (monacoEditorRef.current && editorMode !== 'blocks') monacoEditorRef.current.focus() + }, 50) + }, + [dispatch, editorMode] + ) + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent): void => { + if ((event.metaKey || event.ctrlKey) && event.key === 'w') { + event.preventDefault() + if (viewingFileId) handleFileClose(viewingFileId) + } + if (event.ctrlKey && event.key === 'Tab') { + event.preventDefault() + if (openFiles.length > 1) { + const i = openFiles.findIndex((f) => f.id === viewingFileId) + const next = event.shiftKey + ? i <= 0 + ? openFiles.length - 1 + : i - 1 + : (i + 1) % openFiles.length + if (openFiles[next]) handleFileSelect(openFiles[next].id) + } + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [viewingFileId, handleFileClose, openFiles, handleFileSelect]) + + const handleContentChange = useCallback( + (content: string, fileId: string) => { + const file = openFiles.find((f) => f.id === fileId) + if (file) { + dispatch(updateFileContent({ id: file.id, content })) + if (file.name === 'README.md') dispatch(updateReadmeContent(content)) + } + }, + [openFiles, dispatch] + ) + + const handleSaveFile = async (content: string, fileId: string): Promise => { + const file = openFiles.find((f) => f.id === fileId) + if (file && file.path) { + try { + await fileSystem.writeFile(file.path, content) + dispatch(saveFileWithContent({ id: file.id, content })) + } catch (error) { + console.error('Failed to save file:', error) + } + } + } + + if (openFiles.length === 0) { + return ( +
+
+
+
No project open
+
Open a folder to start building.
+
+ +
+ ) + } + + return ( + + +
+ {openFiles.map((file) => ( + + ))} +
+
+ {openFiles.map((file) => ( + + {editorMode === 'blocks' ? ( + + ) : ( + handleContentChange(content, file.id)} + onSaveFile={(content) => handleSaveFile(content, file.id)} + /> + )} + + ))} +
+ ) +} + +// ── Circuit view: full-window interactive diagram.json ────────────────────── + +function CircuitView(): React.JSX.Element { + const workspace = useAppSelector((s) => s.file.workspace) + const dispatch = useAppDispatch() + const file = useProjectFile('diagram.json', () => DEFAULT_DIAGRAM) + + if (!workspace) return + if (!file) return + + return ( + dispatch(updateFileContent({ id: file.id, content }))} + onOpenCode={() => { + dispatch(revealFile(file.id)) + dispatch(setEditorView('code')) + }} + onEditChange={(editing) => { + // entering edit mode: free horizontal space by closing the docs panel + if (editing) dispatch(setPanelOpen({ panel: 'docs', isOpen: false })) + }} + /> + ) +} + +// ── Visual view: full-window p5 sketch with an Edit-code button ───────────── + +function VisualView(): React.JSX.Element { + const workspace = useAppSelector((s) => s.file.workspace) + const dispatch = useAppDispatch() + + // Which .js sketch is shown in the Visual view. Defaults to visual.js, the + // sketch auto-created for every project; other .js files are selectable too. + const [activeSketch, setActiveSketch] = useState('visual.js') + const [showNew, setShowNew] = useState(false) + // Serial is owned app-wide by SerialProvider and feeds the running sketch via + // the shared bus β€” the Visual view no longer opens the port itself, so + // switching to/from this view doesn't reset the board. + const [publishing, setPublishing] = useState(false) + + const jsFiles = useMemo( + () => (workspace ? collectFiles(workspace.root, (i) => /\.js$/i.test(i.name!)) : []), + [workspace] + ) + + // If the active sketch isn't in this project (after a project switch or a + // delete), fall back to visual.js or the first sketch available. + useEffect(() => { + if (jsFiles.length && !jsFiles.some((f) => f.name === activeSketch)) { + setActiveSketch(jsFiles.find((f) => f.name === 'visual.js')?.name ?? jsFiles[0].name!) + } + }, [jsFiles, activeSketch]) + + // Only visual.js gets seeded from a default; other sketches must already exist. + const file = useProjectFile( + activeSketch, + activeSketch === 'visual.js' ? () => DEFAULT_VISUAL : undefined + ) + + if (!workspace) return + + const ws = workspace + + const createSketch = async (fileName: string): Promise => { + const path = `${ws.path}/${fileName}` + try { + await fileSystem.writeFile(path, sketchTemplate(fileName.replace(/\.js$/i, ''))) + await new RefreshWorkspaceCommand(ws).execute() + setActiveSketch(fileName) + setShowNew(false) + } catch (e) { + toast.error('Could not create sketch', { + description: e instanceof Error ? e.message : 'Unknown error' + }) + } + } + + // Build the standalone page, titled after the .ino project (not the folder). + const buildHtml = (): string => { + const ino = findInTree(ws.root, (i) => /\.ino$/i.test(i.name!)) + const projectName = ino?.name?.replace(/\.ino$/i, '') || ws.name || 'tinyStudio sketch' + return buildVisualExportHtml(projectName, file?.content ?? '') + } + + // Preview: write index.html into the project root and open it in the browser. + const preview = async (): Promise => { + const path = `${ws.path}/index.html` + try { + await window.api.fs.writeFile(path, buildHtml()) + await new RefreshWorkspaceCommand(ws).execute() + const err = await window.api.fs.openPath(path) + if (err) toast.error('Could not open preview', { description: err }) + else toast.success('Preview opened in your browser', { description: path }) + } catch (e) { + toast.error('Preview failed', { + description: e instanceof Error ? e.message : 'Unknown error' + }) + } + } + + // Publish: push index.html to the linked repo and turn on GitHub Pages. + const publish = async (): Promise => { + const account = loadAccount() + if (!account) { + toast.info('Connect GitHub first', { + description: 'Open the GitHub tab in the sidebar to sign in.' + }) + return + } + const link = loadLink(ws.path) + if (!link) { + toast.info('Link this project to a repo first', { + description: 'Use the GitHub tab to link or publish a repository.' + }) + return + } + setPublishing(true) + try { + const html = buildHtml() + await window.api.fs.writeFile(`${ws.path}/index.html`, html) + await new RefreshWorkspaceCommand(ws).execute() + await pushFile( + link.remote, + link.branch, + 'index.html', + html, + account.token, + 'Publish web export via tinyStudio' + ) + const url = await enablePages(link.remote, link.branch, account.token) + toast.success('Published to GitHub Pages', { + description: `${url} β€” the first build can take a minute.` + }) + window.api.fs.openExternal(url) + } catch (e) { + toast.error('Publish failed', { + description: e instanceof Error ? e.message : 'Unknown error' + }) + } finally { + setPublishing(false) + } + } + + return ( +
+ {/* Sketch picker + view actions. Tabs let you keep several p5.js renders + (asteroids.js, pong.js, …) in one project and switch between them. */} +
+
+ {jsFiles.map((f) => ( + + ))} + +
+
+
+ +
+ {file ? ( + + { + dispatch(revealFile(file.id)) + dispatch(setEditorView('code')) + }} + > + + + + + + + {publishing ? ( + + ) : ( + + )} + + + } + /> + ) : ( + + )} +
+ + f.name!)} + onCreate={createSketch} + /> +
+ ) +} + +function NewSketchDialog({ + open, + onOpenChange, + existing, + onCreate +}: { + open: boolean + onOpenChange: (o: boolean) => void + existing: string[] + onCreate: (fileName: string) => void +}): React.JSX.Element { + const [name, setName] = useState('') + + // Reset the field each time the dialog opens. + useEffect(() => { + if (open) setName('') + }, [open]) + + const trimmed = name.trim() + const fileName = trimmed ? (/\.js$/i.test(trimmed) ? trimmed : `${trimmed}.js`) : '' + const duplicate = !!fileName && existing.includes(fileName) + const invalid = !trimmed || /[\\/:*?"<>|]/.test(trimmed) || duplicate + + const submit = (): void => { + if (!invalid) onCreate(fileName) + } + + return ( + + + + New p5.js sketch + + Creates a new .js sketch in your project. Switch + between sketches with the tabs in the Visual view. + + +
+ setName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && submit()} + /> + {duplicate && ( +

+ A file named {fileName} already exists. +

+ )} +
+ + + + +
+
+ ) +} + +function EmptyHint({ label }: { icon: string; label: string }): React.JSX.Element { + return ( +
+ +

{label}

+
+ ) +} + +function LoadingHint({ label }: { label: string }): React.JSX.Element { + return ( +
+ +

{label}

+
+ ) +} diff --git a/src/renderer/src/components/.fuse_hidden0000001500000001 b/src/renderer/src/components/.fuse_hidden0000001500000001 new file mode 100644 index 0000000..a7fb91c --- /dev/null +++ b/src/renderer/src/components/.fuse_hidden0000001500000001 @@ -0,0 +1,232 @@ +import { + Bell, + ChevronDown, + Cpu, + LayoutDashboard, + ListX, + PanelBottom, + PanelLeft, + PanelRight, + Plug, + PlugZap +} from 'lucide-react' +import React from 'react' +import { useArduinoContext } from '../contexts/ArduinoContext' +import { useSerial } from '../contexts/SerialContext' +import { + clearNotifications, + selectNotifications, + selectPanelState, + setPanelOpen, + useAppDispatch, + useAppSelector +} from '../redux' +import { Badge } from './ui/Badge' +import { StatusPill } from './ui/StatusPill' + +/** Shared dismiss-on-outside-click backdrop for the status-bar popovers. */ +function Backdrop({ onClose }: { onClose: () => void }): React.JSX.Element { + return
+} + +function PanelMenu(): React.JSX.Element { + const dispatch = useAppDispatch() + const { isFileExplorerOpen, isSerialMonitorOpen, isDocsPanelOpen } = + useAppSelector(selectPanelState) + const [open, setOpen] = React.useState(false) + + const toggle = (panel: 'file' | 'monitor' | 'docs', isOpen: boolean): void => { + dispatch(setPanelOpen({ panel, isOpen: !isOpen })) + } + + return ( +
+ {open && ( + <> + setOpen(false)} /> +
+ + + +
+ + )} + +
+ ) +} + +function NotificationBell(): React.JSX.Element { + const dispatch = useAppDispatch() + const items = useAppSelector(selectNotifications) + const [open, setOpen] = React.useState(false) + + return ( +
+ {open && ( + <> + setOpen(false)} /> +
+
+ Notifications +
+ + +
+
+
+ {items.length === 0 ? ( +
No notifications yet.
+ ) : ( + items.map((n) => ( +
+
+ )) + )} +
+
+ + )} + +
+ ) +} + +export function StatusBar(): React.JSX.Element { + const { isAgentConnected, checkAgentStatus, selectedBoard, isCompiling, isUploading } = + useArduinoContext() + const { connected, disconnected, port, baud, reconnect } = useSerial() + + const handleRetry = async (): Promise => { + try { + await checkAgentStatus() + } catch (error) { + console.error('Failed to check Arduino service status:', error) + } + } + + const busy = isUploading || isCompiling + const busyLabel = isUploading ? 'Uploading…' : isCompiling ? 'Compiling…' : 'Ready' + + // Links must read on the blue bar (light) and the grey bar (dark). + const link = + 'underline underline-offset-2 text-white/90 hover:text-white dark:text-[var(--brand)] dark:no-underline dark:hover:underline' + + return ( +
+
+ + {busyLabel} + + {selectedBoard && ( + <> + Β· + + + {selectedBoard.config.name} + + + )} + Β· + + {isAgentConnected ? : } + {isAgentConnected ? 'Connected' : 'Disconnected'} + + {isAgentConnected && port && ( + <> + Β· + + {disconnected + ? 'Serial released' + : connected + ? `${port} @ ${baud}` + : `Connecting ${port}…`} + {disconnected && ( + + )} + + + )} + {!isAgentConnected && ( + + )} +
+
+ UTF-8 + Β· + Arduino (C++) + Β· + tinyStudio 0.1.1 + + + alpha + + + + + +
+
+ ) +} diff --git a/src/renderer/src/components/.fuse_hidden0000001500000003 b/src/renderer/src/components/.fuse_hidden0000001500000003 new file mode 100644 index 0000000..c1e839d --- /dev/null +++ b/src/renderer/src/components/.fuse_hidden0000001500000003 @@ -0,0 +1,1864 @@ +/** + * DiagramEditor β€” renders a project's `diagram.json` as a live, editable circuit. + * + * The wiring system is ported from the tinySchematic prototype (a Fritzing-style + * breadboard/schematic wiring engine): absolute bendpoints that hold their place + * when parts move, orthogonal auto-routing with clean pin entry, junctions + * (wire-to-wire taps), straight/diagonal mode (hold Shift), per-segment / bend / + * endpoint handle editing, double-click to add or remove a bend, and a live + * equipotential net model that glows everything sharing a node. Wires are drawn + * exactly like tinySchematic: rounded glossy tubes (dark outline β†’ color core β†’ + * animated selection dashes) with a yellow net glow. + * + * Around it: a Fritzing-style two-column components rail, a right-hand Inspector + * that edits the selected part's name / location / rotation / properties (e.g. a + * resistor's value) β€” or a selected wire's color β€” drag-to-place, draggable part + * labels, zoom/fit, and Wokwi `diagram.json` serialization. + * + * Styling follows the tinyStudio design system: cold neutral surfaces, the four + * PCB inks, a notebook dot-grid canvas, tactile controls, mono eyebrows, + * sentence-case copy. + */ + +import { + ChevronDown, + ChevronRight, + CircuitBoard, + CodeXml, + Grid3x3, + Maximize, + MousePointer2, + Pencil, + Plus, + RotateCw, + Trash2, + Eye, + Spline, + Zap, + ZoomIn, + ZoomOut +} from 'lucide-react' +import React from 'react' +import { + ensureParts, + getPart, + loadPart, + partsByFamily, + registerPart, + viewFor, + type PartDef, + type PartMeta, + type ViewKind +} from '../lib/partsLibrary' +import { + bendsFromInstructions, + buildWirePoints, + calculateOrthogonalPath, + clampOntoSegment, + dragSegment, + getWireAt, + getWirePoints, + instructionsFromPoints, + isStraightRoute, + simplifyWirePoints, + type Connection, + type PinRef, + type Pt +} from '../lib/wireRouting' +import { buildNets, meaningfulNetCount } from '../lib/circuitNets' +import { PartsEditor } from './PartsEditor' + +const CANVAS_W = 1100 +const CANVAS_H = 640 +const GRID = 9.6 // 100 mil (0.1in) at 96 DPI β€” the standard breadboard pitch + +// wire look (mirrors tinySchematic config.js) +const WIRE_W = 2.8 +const WIRE_OUTLINE_W = WIRE_W + 1.8 +const WIRE_GLOW_W = WIRE_W + 5 +const WIRE_CORNER = 4 +const NET_GLOW = 'rgba(243, 203, 0, 0.30)' // brand-yellow equipotential glow + +interface Part { + id: string + type: string + left: number + top: number + rotate?: number // 0 | 90 | 180 | 270 (Wokwi-compatible) + attrs?: Record +} +// Per-view overrides for the schematic layout. The top-level parts/connections +// stay the Wokwi breadboard layout; the schematic view keeps its own positions +// and wire routes here so the two diagrams can diverge. A part/wire with no +// schematic entry falls back to its breadboard placement (until it's moved). +interface SchematicLayout { + pos: Record // partId -> [left, top] + routes: Record // connKey -> h/v instructions +} +interface Diagram { + parts: Part[] + connections: Connection[] + author?: string + schematic: SchematicLayout +} + +// The four PCB inks carry meaning (green = signal/connected, red = power/stop, +// blue = primary, gold = warn) plus a neutral white and slate for contrast. We +// persist concrete hex (not CSS vars) so the colors survive export. +const WIRE_COLORS = ['#2fa46a', '#e5544b', '#42a5f5', '#f3cb00', '#ffffff', '#79818c'] + +const uid = (): string => Math.random().toString(36).slice(2, 8) +const snap = (v: number): number => Math.round(v / GRID) * GRID + +// read-compat: accept Wokwi left/top or the legacy x/y +function readPart(p: Record): Part { + return { + id: String(p.id), + type: String(p.type), + left: Number(p.left ?? p.x ?? 0), + top: Number(p.top ?? p.y ?? 0), + rotate: p.rotate ? Number(p.rotate) : undefined, + attrs: (p.attrs as Record) || undefined + } +} + +// rotate a local point (px,py) around a part's centre by `deg` (CSS-rotate space) +function rotatePoint(px: number, py: number, w: number, h: number, deg: number): Pt { + const rad = (deg * Math.PI) / 180 + const cos = Math.cos(rad) + const sin = Math.sin(rad) + const dx = px - w / 2 + const dy = py - h / 2 + return { x: w / 2 + dx * cos - dy * sin, y: h / 2 + dx * sin + dy * cos } +} + +const eqRef = (a: PinRef, b: PinRef): boolean => { + if (typeof a === 'string' && typeof b === 'string') return a === b + if (typeof a === 'object' && typeof b === 'object') + return Math.abs(a.x - b.x) < 0.5 && Math.abs(a.y - b.y) < 0.5 + return false +} + +// Drag a bendpoint while keeping its neighbouring segments orthogonal (ported +// from tinySchematic's vertex-drag logic). +function vertexDrag(orig: Pt[], index: number, x: number, y: number): Pt[] { + const points = orig.map((p) => ({ x: p.x, y: p.y })) + points[index].x = x + points[index].y = y + if (index > 0) { + if ( + Math.abs(points[index - 1].y - points[index].y) < + Math.abs(points[index - 1].x - points[index].x) + ) { + if (index - 1 > 0) points[index - 1].y = points[index].y + else points[index].y = points[index - 1].y + } else { + if (index - 1 > 0) points[index - 1].x = points[index].x + else points[index].x = points[index - 1].x + } + } + if (index < points.length - 1) { + if ( + Math.abs(points[index + 1].y - points[index].y) < + Math.abs(points[index + 1].x - points[index].x) + ) { + if (index + 1 < points.length - 1) points[index + 1].y = points[index].y + else points[index].y = points[index + 1].y + } else { + if (index + 1 < points.length - 1) points[index + 1].x = points[index].x + else points[index].x = points[index + 1].x + } + } + return points +} + +// Build an SVG path with rounded corners (a quadratic fillet at each bend), +// matching tinySchematic's arcTo wire look. +function roundedPath(points: Pt[], r = WIRE_CORNER): string { + if (points.length < 2) return '' + if (points.length === 2) return `M${points[0].x} ${points[0].y} L${points[1].x} ${points[1].y}` + let d = `M${points[0].x} ${points[0].y}` + for (let i = 1; i < points.length - 1; i++) { + const p = points[i - 1] + const c = points[i] + const n = points[i + 1] + const d1 = Math.hypot(c.x - p.x, c.y - p.y) || 1 + const d2 = Math.hypot(n.x - c.x, n.y - c.y) || 1 + const rr = Math.max(0, Math.min(r, d1 / 2, d2 / 2)) + const a = { x: c.x - ((c.x - p.x) / d1) * rr, y: c.y - ((c.y - p.y) / d1) * rr } + const b = { x: c.x + ((n.x - c.x) / d2) * rr, y: c.y + ((n.y - c.y) / d2) * rr } + d += ` L${a.x} ${a.y} Q${c.x} ${c.y} ${b.x} ${b.y}` + } + const last = points[points.length - 1] + d += ` L${last.x} ${last.y}` + return d +} + +// A darker shade of a wire's color for its "3D" outline (Fritzing tube look). +function darken(hex: string, f = 0.55): string { + const m = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim()) + if (!m) return 'rgba(0,0,0,0.45)' + const n = parseInt(m[1], 16) + const r = Math.round(((n >> 16) & 255) * f) + const g = Math.round(((n >> 8) & 255) * f) + const b = Math.round((n & 255) * f) + return `rgb(${r}, ${g}, ${b})` +} + +// A small SVG thumbnail for the palette / part tiles. Fritzing icon art is dark +// line-work meant for a light background, so we put it on a light chip. +function Thumb({ svg, size = 40 }: { svg?: string; size?: number }): React.JSX.Element { + if (!svg) + return ( +
+ +
+ ) + return ( +
+ ) +} + +type HandleKind = 'vertex' | 'edge' | 'endpoint' + +export function DiagramEditor({ + content, + onChange, + onOpenCode +}: { + content: string + onChange: (next: string) => void + // Switch to the Code view with diagram.json selected (the `` button). + onOpenCode?: () => void +}): React.JSX.Element { + const diagram: Diagram = React.useMemo(() => { + try { + const d = JSON.parse(content || '{}') + const sch = d.schematic || {} + return { + parts: (d.parts || []).map(readPart), + connections: (d.connections || []) as Connection[], + author: d.author, + schematic: { pos: sch.pos || {}, routes: sch.routes || {} } + } + } catch { + return { parts: [], connections: [], schematic: { pos: {}, routes: {} } } + } + }, [content]) + + const [view, setView] = React.useState('breadboard') + const [editable, setEditable] = React.useState(false) + const [grid, setGrid] = React.useState(true) + const [editorPart, setEditorPart] = React.useState(undefined) // undefined = closed + const [selPart, setSelPart] = React.useState(null) + const [selWire, setSelWire] = React.useState(null) + const [wireColor, setWireColor] = React.useState(WIRE_COLORS[0]) + const [armed, setArmed] = React.useState<{ + from: PinRef + points: Pt[] + straight: boolean + } | null>(null) + const [hoverPin, setHoverPin] = React.useState<{ id: string; pin: string } | null>(null) + const [hoverWire, setHoverWire] = React.useState(null) + const [mouse, setMouse] = React.useState({ x: 0, y: 0 }) + const [straight, setStraight] = React.useState(false) + const [collapsed, setCollapsed] = React.useState>(new Set()) + const [editPts, setEditPts] = React.useState(null) // selected-wire editing buffer + const [newAttr, setNewAttr] = React.useState('') + const [tick, force] = React.useReducer((n) => n + 1, 0) + + const viewRef = React.useRef(null) + const innerRef = React.useRef(null) + const drag = React.useRef<{ + id: string + offX: number + offY: number + affected: { i: number; bends: Pt[]; straight: boolean }[] + } | null>(null) + const labelDrag = React.useRef<{ id: string; ox: number; oy: number } | null>(null) + const pan = React.useRef<{ x: number; y: number } | null>(null) + const handleDrag = React.useRef<{ kind: HandleKind } | null>(null) + const [cam, setCam] = React.useState({ scale: 0.62, tx: 0, ty: 0 }) + const scale = cam.scale + const didInit = React.useRef(false) + + React.useEffect(() => { + const types = diagram.parts.map((p) => p.type).filter((t) => !getPart(t)) + if (types.length) ensureParts(types).then(force) + }, [diagram.parts]) + + const write = (d: Partial): void => { + const sch = d.schematic || diagram.schematic + const hasSch = Object.keys(sch.pos).length > 0 || Object.keys(sch.routes).length > 0 + const next = { + version: 1, + editor: 'tinystudio', + author: diagram.author, + parts: (d.parts || diagram.parts).map((p) => ({ + type: p.type, + id: p.id, + left: p.left, + top: p.top, + ...(p.rotate ? { rotate: p.rotate } : {}), + ...(p.attrs && Object.keys(p.attrs).length ? { attrs: p.attrs } : {}) + })), + connections: d.connections || diagram.connections, + ...(hasSch ? { schematic: sch } : {}) + } + onChange(JSON.stringify(next, null, 2)) + } + + // ── per-view layout helpers ───────────────────────────────────────────────── + + const refStr = (r: PinRef): string => (typeof r === 'string' ? r : `${r.x},${r.y}`) + const connKey = (c: Connection): string => `${refStr(c[0])}>${refStr(c[1])}` + // position of a part in the *current* view (schematic falls back to breadboard) + const partXY = (part: Part): [number, number] => + view === 'schematic' + ? diagram.schematic.pos[part.id] || [part.left, part.top] + : [part.left, part.top] + // wire route in the current view (schematic starts clean, then diverges) + const routeFor = (c: Connection): string[] => + view === 'schematic' ? (diagram.schematic.routes[connKey(c)] ?? []) : c[3] || [] + + // ── geometry ────────────────────────────────────────────────────────────── + + const pinAbsAt = (part: Part, pin: string, left: number, top: number): Pt | null => { + const def = getPart(part.type) + if (!def) return null + const v = viewFor(def, view) + const p = v?.pins[pin] + if (!p) return null + const rp = part.rotate ? rotatePoint(p[0], p[1], v!.w, v!.h, part.rotate) : { x: p[0], y: p[1] } + return { x: left + rp.x, y: top + rp.y } + } + const pinAbs = (part: Part, pin: string): Pt | null => { + const [left, top] = partXY(part) + return pinAbsAt(part, pin, left, top) + } + // Resolve refs, optionally overriding some parts' positions (used mid-drag so + // bends stay fixed in space while a part moves). + const resolveWith = + (posMap: Record) => + (ref: PinRef): Pt | null => { + if (typeof ref === 'object') return ref + const [id, pin] = ref.split(':') + const part = diagram.parts.find((p) => p.id === id) + if (!part) return null + const [left, top] = posMap[id] ?? partXY(part) + return pinAbsAt(part, pin, left, top) + } + const resolve = resolveWith({}) + const wirePts = (c: Connection): Pt[] => getWirePoints(c, resolve, routeFor(c)) + + const canvasPoint = (e: { clientX: number; clientY: number }): Pt => { + const r = innerRef.current!.getBoundingClientRect() + return { x: (e.clientX - r.left) / scale, y: (e.clientY - r.top) / scale } + } + const getPinAtWorld = (wx: number, wy: number): { id: string; pin: string; pos: Pt } | null => { + const tol = 8 / scale + for (const part of diagram.parts) { + const def = getPart(part.type) + const v = def && viewFor(def, view) + if (!v) continue + for (const pin of Object.keys(v.pins)) { + const pos = pinAbs(part, pin) + if (pos && Math.hypot(pos.x - wx, pos.y - wy) < tol) return { id: part.id, pin, pos } + } + } + return null + } + + // ── equipotential net model (drives net glow + the routed count) ──────────── + + const netModel = React.useMemo( + () => buildNets(diagram.parts, diagram.connections, (c) => wirePts(c)), + // eslint-disable-next-line react-hooks/exhaustive-deps + [diagram, view, tick] + ) + // Highlight the net under the hovered pin, hovered wire, or selected wire β€” so + // you can see (and effectively "select") a whole net. + const highlightNet = React.useMemo(() => { + if (hoverPin) { + const i = netModel.pinToNet.get(`${hoverPin.id}:${hoverPin.pin}`) + if (i != null) return i + } + if (hoverWire != null) { + const i = netModel.connToNet[hoverWire] + if (i >= 0) return i + } + if (selWire != null) { + const i = netModel.connToNet[selWire] + if (i >= 0) return i + } + return -1 + }, [hoverPin, hoverWire, selWire, netModel]) + + // ── camera ────────────────────────────────────────────────────────────────── + + const fitView = (): void => { + const el = viewRef.current + if (!el) return + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + for (const part of diagram.parts) { + const def = getPart(part.type) + const v = def && viewFor(def, view) + if (!v) continue + const [l, t] = partXY(part) + minX = Math.min(minX, l) + minY = Math.min(minY, t) + maxX = Math.max(maxX, l + v.w) + maxY = Math.max(maxY, t + v.h) + } + for (const c of diagram.connections) { + for (const p of wirePts(c)) { + minX = Math.min(minX, p.x) + minY = Math.min(minY, p.y) + maxX = Math.max(maxX, p.x) + maxY = Math.max(maxY, p.y) + } + } + if (!isFinite(minX)) { + const f = Math.min(el.clientWidth / CANVAS_W, el.clientHeight / CANVAS_H, 1) + setCam({ + scale: f, + tx: (el.clientWidth - CANVAS_W * f) / 2, + ty: (el.clientHeight - CANVAS_H * f) / 2 + }) + return + } + const pad = 40 + minX -= pad + minY -= pad + maxX += pad + maxY += pad + const bw = maxX - minX + const bh = maxY - minY + const f = Math.max(0.25, Math.min(3, Math.min(el.clientWidth / bw, el.clientHeight / bh))) + setCam({ + scale: f, + tx: (el.clientWidth - bw * f) / 2 - minX * f, + ty: (el.clientHeight - bh * f) / 2 - minY * f + }) + } + React.useEffect(() => { + if (didInit.current) return + if (diagram.parts.length === 0 || diagram.parts.every((p) => getPart(p.type))) { + fitView() + didInit.current = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [content, tick]) + + const zoomAt = (factor: number, sx: number, sy: number): void => { + setCam((c) => { + const ns = Math.max(0.25, Math.min(3, c.scale * factor)) + const k = ns / c.scale + return { scale: ns, tx: sx - (sx - c.tx) * k, ty: sy - (sy - c.ty) * k } + }) + } + const zoomCenter = (factor: number): void => { + const el = viewRef.current + if (el) zoomAt(factor, el.clientWidth / 2, el.clientHeight / 2) + } + const onWheel = (e: React.WheelEvent): void => { + const rect = viewRef.current!.getBoundingClientRect() + zoomAt(e.deltaY < 0 ? 1.12 : 1 / 1.12, e.clientX - rect.left, e.clientY - rect.top) + } + const onViewPointerDown = (e: React.PointerEvent): void => { + if (e.button !== 1 && !(e.button === 0 && e.altKey)) return + e.preventDefault() + pan.current = { x: e.clientX, y: e.clientY } + window.addEventListener('pointermove', onPanMove) + window.addEventListener('pointerup', onPanUp) + } + const onPanMove = (e: PointerEvent): void => { + const p = pan.current + if (!p) return + setCam((c) => ({ ...c, tx: c.tx + (e.clientX - p.x), ty: c.ty + (e.clientY - p.y) })) + pan.current = { x: e.clientX, y: e.clientY } + } + const onPanUp = (): void => { + pan.current = null + window.removeEventListener('pointermove', onPanMove) + window.removeEventListener('pointerup', onPanUp) + } + + // ── shared: re-sync a part's wires after a discrete move/rotate ────────────── + + const touches = (ref: PinRef, id: string): boolean => + typeof ref === 'string' && ref.split(':')[0] === id + + const collectFrozen = (id: string): { i: number; bends: Pt[]; straight: boolean }[] => { + const f: { i: number; bends: Pt[]; straight: boolean }[] = [] + diagram.connections.forEach((c, i) => { + if (!touches(c[0], id) && !touches(c[1], id)) return + const route = routeFor(c) + const s = resolve(c[0]) + const t = resolve(c[1]) + if (s && t) + f.push({ i, bends: bendsFromInstructions(route, s, t), straight: isStraightRoute(route) }) + }) + return f + } + // Apply a new parts array (and optional schematic-pos patch) for `id`, holding + // its wires' bends fixed in world space. + const commitParts = ( + parts: Part[], + id: string, + schPosPatch?: Record + ): void => { + const frozen = collectFrozen(id) + const schPos = schPosPatch + ? { ...diagram.schematic.pos, ...schPosPatch } + : diagram.schematic.pos + const resolveNew = (ref: PinRef): Pt | null => { + if (typeof ref === 'object') return ref + const [pid, pin] = ref.split(':') + const part = parts.find((p) => p.id === pid) + if (!part) return null + const [l, t] = + view === 'schematic' ? schPos[pid] || [part.left, part.top] : [part.left, part.top] + return pinAbsAt(part, pin, l, t) + } + if (view === 'schematic') { + const routes = { ...diagram.schematic.routes } + frozen.forEach((a) => { + const c = diagram.connections[a.i] + const s = resolveNew(c[0]) + const t = resolveNew(c[1]) + if (s && t) + routes[connKey(c)] = instructionsFromPoints( + buildWirePoints(s, a.bends, t, a.straight), + a.straight + ) + }) + write({ parts, schematic: { pos: schPos, routes } }) + } else { + const conns = diagram.connections.slice() + frozen.forEach((a) => { + const c = diagram.connections[a.i] + const s = resolveNew(c[0]) + const t = resolveNew(c[1]) + if (s && t) { + const nc = conns[a.i].slice() as Connection + nc[3] = instructionsFromPoints(buildWirePoints(s, a.bends, t, a.straight), a.straight) + conns[a.i] = nc + } + }) + write({ parts, connections: conns }) + } + } + + // ── part drag (absolute bendpoints: bends hold while the part moves) ───────── + + const onPartDown = (e: React.PointerEvent, part: Part): void => { + if (!editable || (e.target as HTMLElement).closest('.pin-hit')) return + e.stopPropagation() + setSelPart(part.id) + selectWire(null) + const r = innerRef.current!.getBoundingClientRect() + const [left, top] = partXY(part) + drag.current = { + id: part.id, + offX: (e.clientX - r.left) / scale - left, + offY: (e.clientY - r.top) / scale - top, + affected: collectFrozen(part.id) + } + window.addEventListener('pointermove', onPartMove) + window.addEventListener('pointerup', onPartUp) + } + const onPartMove = (e: PointerEvent): void => { + const d = drag.current + if (!d) return + const r = innerRef.current!.getBoundingClientRect() + const nx = snap((e.clientX - r.left) / scale - d.offX) + const ny = snap((e.clientY - r.top) / scale - d.offY) + const resolveNext = resolveWith({ [d.id]: [nx, ny] }) + const updates: { i: number; key: string; instr: string[] }[] = [] + for (const a of d.affected) { + const c = diagram.connections[a.i] + const src = resolveNext(c[0]) + const tgt = resolveNext(c[1]) + if (!src || !tgt) continue + const pts = buildWirePoints(src, a.bends, tgt, a.straight) + updates.push({ i: a.i, key: connKey(c), instr: instructionsFromPoints(pts, a.straight) }) + } + if (view === 'schematic') { + const routes = { ...diagram.schematic.routes } + updates.forEach((u) => (routes[u.key] = u.instr)) + write({ schematic: { pos: { ...diagram.schematic.pos, [d.id]: [nx, ny] }, routes } }) + } else { + const parts = diagram.parts.map((p) => (p.id === d.id ? { ...p, left: nx, top: ny } : p)) + const conns = diagram.connections.slice() + updates.forEach((u) => { + const nc = conns[u.i].slice() as Connection + nc[3] = u.instr + conns[u.i] = nc + }) + write({ parts, connections: conns }) + } + } + const onPartUp = (): void => { + drag.current = null + window.removeEventListener('pointermove', onPartMove) + window.removeEventListener('pointerup', onPartUp) + } + + // drag a part's title label to reposition it (offset stored on the part) + const onLabelDown = (e: React.PointerEvent, part: Part): void => { + if (!editable) return + e.stopPropagation() + setSelPart(part.id) + selectWire(null) + const start = canvasPoint(e) + const off = (part.attrs?.labelOffset as [number, number]) || [0, 0] + labelDrag.current = { id: part.id, ox: off[0] - start.x, oy: off[1] - start.y } + const move = (ev: PointerEvent): void => { + const ld = labelDrag.current + if (!ld) return + const cp = canvasPoint(ev) + const offset: [number, number] = [Math.round(ld.ox + cp.x), Math.round(ld.oy + cp.y)] + patchAttr(ld.id, 'labelOffset', offset) + } + const up = (): void => { + labelDrag.current = null + window.removeEventListener('pointermove', move) + window.removeEventListener('pointerup', up) + } + window.addEventListener('pointermove', move) + window.addEventListener('pointerup', up) + } + + // ── wiring ────────────────────────────────────────────────────────────────── + + const selectWire = (i: number | null): void => { + setSelWire(i) + setEditPts(i == null ? null : wirePts(diagram.connections[i])) + } + + const commitNewWire = (from: PinRef, to: PinRef, instr: string[]): void => { + const conn: Connection = + view === 'schematic' ? [from, to, wireColor] : [from, to, wireColor, instr] + if (view === 'schematic') { + write({ + connections: [...diagram.connections, conn], + schematic: { + ...diagram.schematic, + routes: { ...diagram.schematic.routes, [connKey(conn)]: instr } + } + }) + } else { + write({ connections: [...diagram.connections, conn] }) + } + } + + const finalizeSegment = (target: Pt, clean: boolean): { pts: Pt[]; str: boolean } => { + const pts = [...armed!.points] + const last = pts[pts.length - 1] + const str = armed!.straight || straight + if (str) pts.push(target) + else pts.push(...calculateOrthogonalPath(last.x, last.y, target.x, target.y, clean).slice(1)) + return { pts: simplifyWirePoints(pts), str } + } + + const onPinClick = (e: React.MouseEvent, part: Part, pin: string): void => { + if (!editable) return + e.stopPropagation() + const ref = `${part.id}:${pin}` + const pos = pinAbs(part, pin) + if (!pos) return + if (!armed) { + setArmed({ from: ref, points: [pos], straight: false }) + setSelPart(null) + selectWire(null) + return + } + if (eqRef(armed.from, ref)) { + setArmed(null) + return + } + const { pts, str } = finalizeSegment(pos, true) + const instr = instructionsFromPoints(pts, str) + const exists = diagram.connections.some( + (c) => (eqRef(c[0], armed.from) && c[1] === ref) || (c[0] === ref && eqRef(c[1], armed.from)) + ) + if (!exists) commitNewWire(armed.from, ref, instr) + setArmed(null) + } + + // tap a junction onto an existing wire `i` at the click point + const tapJunction = (e: React.MouseEvent, i: number): void => { + const cp = canvasPoint(e) + const pts = wirePts(diagram.connections[i]) + const hit = getWireAt(cp.x, cp.y, [diagram.connections[i]], () => pts, scale) + const seg = hit ? { p1: hit.p1, p2: hit.p2 } : { p1: pts[0], p2: pts[1] } + const jp = clampOntoSegment(seg.p1, seg.p2, snap(cp.x), snap(cp.y)) + const { pts: fp, str } = finalizeSegment(jp, false) + commitNewWire(armed!.from, { x: jp.x, y: jp.y }, instructionsFromPoints(fp, str)) + setArmed(null) + } + + const onCanvasClick = (e: React.MouseEvent): void => { + if (armed) { + const cp = canvasPoint(e) + const hit = getWireAt(cp.x, cp.y, diagram.connections, (c) => wirePts(c), scale) + if (hit) { + tapJunction(e, hit.index) + return + } + const target = { x: snap(cp.x), y: snap(cp.y) } + const last = armed.points[armed.points.length - 1] + const seg = straight + ? [last, target] + : calculateOrthogonalPath(last.x, last.y, target.x, target.y, false) + setArmed({ + ...armed, + points: [...armed.points, ...seg.slice(1)], + straight: armed.straight || straight + }) + return + } + setSelPart(null) + selectWire(null) + } + const onCanvasMove = (e: React.PointerEvent): void => { + if (armed) setMouse(canvasPoint(e)) + } + + // ── wire editing β€” endpoint / bend / segment handles ───────────────────────── + + const resolveEndpointTarget = (wx: number, wy: number, selfIdx: number): PinRef => { + const pin = getPinAtWorld(wx, wy) + if (pin) return `${pin.id}:${pin.pin}` + const hit = getWireAt(wx, wy, diagram.connections, (c) => wirePts(c), scale) + if (hit && hit.index !== selfIdx) return clampOntoSegment(hit.p1, hit.p2, snap(wx), snap(wy)) + return { x: snap(wx), y: snap(wy) } + } + + const writeWire = (i: number, opts: { instr: string[]; from?: PinRef; to?: PinRef }): void => { + const c = diagram.connections[i] + const oldKey = connKey(c) + const nc = c.slice() as Connection + if (opts.from !== undefined) nc[0] = opts.from + if (opts.to !== undefined) nc[1] = opts.to + const newKey = connKey(nc) + const conns = diagram.connections.slice() + if (view === 'schematic') { + const routes = { ...diagram.schematic.routes } + if (oldKey !== newKey) delete routes[oldKey] + routes[newKey] = opts.instr + conns[i] = nc + write({ connections: conns, schematic: { ...diagram.schematic, routes } }) + } else { + nc[3] = opts.instr + conns[i] = nc + if (oldKey !== newKey && diagram.schematic.routes[oldKey]) { + const routes = { ...diagram.schematic.routes } + routes[newKey] = routes[oldKey] + delete routes[oldKey] + write({ connections: conns, schematic: { ...diagram.schematic, routes } }) + } else { + write({ connections: conns }) + } + } + } + + // recolor an existing wire (conn[2] is the logical color, shared across views) + const recolorWire = (i: number, color: string): void => { + const conns = diagram.connections.slice() + const nc = conns[i].slice() as Connection + nc[2] = color + conns[i] = nc + write({ connections: conns }) + } + // colour swatch: recolour the selected wire if one is selected; else set the + // colour future wires are drawn in. + const pickColor = (color: string): void => { + setWireColor(color) + if (selWire != null) recolorWire(selWire, color) + } + + const startHandleDrag = ( + e: React.PointerEvent, + kind: HandleKind, + idx: number, + end?: 0 | 1 + ): void => { + if (!editable || selWire == null || !editPts) return + e.stopPropagation() + const wireIdx = selWire + const orig = editPts + const str = isStraightRoute(routeFor(diagram.connections[wireIdx])) + const axis: 'horizontal' | 'vertical' = + kind === 'edge' && Math.abs(orig[idx].y - orig[idx + 1].y) < 0.001 ? 'horizontal' : 'vertical' + const endIdx = end === 0 ? 0 : orig.length - 1 + let live = orig + let lastWorld = { x: 0, y: 0 } + handleDrag.current = { kind } + const move = (ev: PointerEvent): void => { + const cp = canvasPoint(ev) + if (kind === 'vertex') { + live = vertexDrag(orig, idx, snap(cp.x), snap(cp.y)) + } else if (kind === 'edge') { + live = dragSegment(orig, idx, axis, snap(cp.x), snap(cp.y)) + } else { + lastWorld = { x: cp.x, y: cp.y } + const p = { x: snap(cp.x), y: snap(cp.y) } + live = orig.map((pt, k) => (k === endIdx ? p : pt)) + } + setEditPts(live) + } + const up = (): void => { + window.removeEventListener('pointermove', move) + window.removeEventListener('pointerup', up) + handleDrag.current = null + let from: PinRef | undefined + let to: PinRef | undefined + if (kind === 'endpoint') { + const tgt = resolveEndpointTarget(lastWorld.x, lastWorld.y, wireIdx) + if (end === 0) from = tgt + else to = tgt + const rp = typeof tgt === 'object' ? tgt : resolve(tgt) + if (rp) live = live.map((pt, k) => (k === endIdx ? rp : pt)) + } + writeWire(wireIdx, { instr: instructionsFromPoints(simplifyWirePoints(live), str), from, to }) + } + window.addEventListener('pointermove', move) + window.addEventListener('pointerup', up) + } + + const onWireDoubleClick = (e: React.MouseEvent, i: number): void => { + if (!editable) return + e.stopPropagation() + const c = diagram.connections[i] + const str = isStraightRoute(routeFor(c)) + const cp = canvasPoint(e) + const pts = wirePts(c) + const handleR = 7 / scale + for (let k = 1; k < pts.length - 1; k++) { + if (Math.hypot(pts[k].x - cp.x, pts[k].y - cp.y) < handleR) { + const next = simplifyWirePoints(pts.filter((_, j) => j !== k)) + writeWire(i, { instr: instructionsFromPoints(next, str) }) + setEditPts(next) + return + } + } + const hit = getWireAt(cp.x, cp.y, [c], () => pts, scale) + if (hit) { + const next = pts.slice() + next.splice(hit.segmentIndex + 1, 0, { x: snap(cp.x), y: snap(cp.y) }) + writeWire(i, { instr: instructionsFromPoints(next, str) }) + selectWire(i) + setEditPts(next) + } + } + + // wire click: tap a junction while drawing, else select the wire + const onWireClick = (e: React.MouseEvent, i: number): void => { + e.stopPropagation() + if (armed) { + tapJunction(e, i) + return + } + if (!editable) { + selectWire(i) // view-mode: select to inspect / highlight its net + setSelPart(null) + return + } + selectWire(i) + setSelPart(null) + } + + React.useEffect(() => { + if (selWire != null && !handleDrag.current) { + const c = diagram.connections[selWire] + if (c) setEditPts(wirePts(c)) + else selectWire(null) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [content, view]) + + // ── palette / add / delete / properties ───────────────────────────────────── + + const addPart = async (type: string, at?: Pt): Promise => { + const def = getPart(type) || (await loadPart(type)) + if (!def) return + force() + const v = viewFor(def, view) + const w = v?.w || 80 + const h = v?.h || 40 + const left = at ? Math.round(at.x - w / 2) : Math.round(CANVAS_W / 2 - w / 2) + const top = at ? Math.round(at.y - h / 2) : Math.round(CANVAS_H / 2 - h / 2) + const pid = type + '_' + uid().slice(0, 3) + const parts = [...diagram.parts, { id: pid, type, left, top }] + write( + view === 'schematic' + ? { + parts, + schematic: { + ...diagram.schematic, + pos: { ...diagram.schematic.pos, [pid]: [left, top] } + } + } + : { parts } + ) + setSelPart(pid) + } + const onDrop = (e: React.DragEvent): void => { + e.preventDefault() + const type = e.dataTransfer.getData('text/tinystudio-part') + if (type) addPart(type, canvasPoint(e)) + } + + const editExisting = async (type: string): Promise => { + const def = getPart(type) || (await loadPart(type)) + if (def) setEditorPart(def) + } + + // patch a part's attrs (used by the Inspector + label drag) + const patchAttr = (id: string, key: string, value: unknown): void => { + write({ + parts: diagram.parts.map((p) => + p.id === id ? { ...p, attrs: { ...(p.attrs || {}), [key]: value } } : p + ) + }) + } + const removeAttr = (id: string, key: string): void => { + write({ + parts: diagram.parts.map((p) => { + if (p.id !== id) return p + const attrs = { ...(p.attrs || {}) } + delete attrs[key] + return { ...p, attrs } + }) + }) + } + // move a part to an absolute location in the current view (Inspector inputs) + const movePartTo = (id: string, left: number, top: number): void => { + if (view === 'schematic') { + commitParts(diagram.parts, id, { [id]: [left, top] }) + } else { + commitParts( + diagram.parts.map((p) => (p.id === id ? { ...p, left, top } : p)), + id + ) + } + } + const setRotation = (id: string, deg: number): void => { + commitParts( + diagram.parts.map((p) => (p.id === id ? { ...p, rotate: ((deg % 360) + 360) % 360 } : p)), + id + ) + } + const rotatePart = (id: string): void => { + const part = diagram.parts.find((p) => p.id === id) + if (part) setRotation(id, (part.rotate || 0) + 90) + } + + const deletePart = (id: string): void => { + const keep = (c: Connection): boolean => + (typeof c[0] !== 'string' || c[0].split(':')[0] !== id) && + (typeof c[1] !== 'string' || c[1].split(':')[0] !== id) + const routes = { ...diagram.schematic.routes } + const pos = { ...diagram.schematic.pos } + delete pos[id] + diagram.connections.forEach((c) => { + if (!keep(c)) delete routes[connKey(c)] + }) + write({ + parts: diagram.parts.filter((p) => p.id !== id), + connections: diagram.connections.filter(keep), + schematic: { pos, routes } + }) + setSelPart(null) + } + const deleteWire = (i: number): void => { + const gone = diagram.connections[i] + const routes = { ...diagram.schematic.routes } + if (gone) delete routes[connKey(gone)] + write({ + connections: diagram.connections.filter((_, j) => j !== i), + schematic: { ...diagram.schematic, routes } + }) + selectWire(null) + } + + React.useEffect(() => { + const down = (e: KeyboardEvent): void => { + if (document.activeElement && /INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName)) + return + if (e.key === 'Shift') setStraight(true) + if (editable && (e.key === ' ' || e.key === 'r' || e.key === 'R')) { + const id = drag.current?.id || selPart + if (id) { + e.preventDefault() + rotatePart(id) + return + } + } + if (e.key === 'Escape') { + setArmed(null) + setSelPart(null) + selectWire(null) + } + if (e.key === 'Delete' || e.key === 'Backspace') { + if (selWire != null) deleteWire(selWire) + else if (selPart) deletePart(selPart) + } + } + const up = (e: KeyboardEvent): void => { + if (e.key === 'Shift') setStraight(false) + } + window.addEventListener('keydown', down) + window.addEventListener('keyup', up) + return () => { + window.removeEventListener('keydown', down) + window.removeEventListener('keyup', up) + } + }, [selPart, selWire, diagram, editable, view, straight]) + + const previewPts = React.useMemo((): Pt[] | null => { + if (!armed) return null + const last = armed.points[armed.points.length - 1] + let target = { x: snap(mouse.x), y: snap(mouse.y) } + let clean = false + if (hoverPin) { + const part = diagram.parts.find((p) => p.id === hoverPin.id) + const pos = part && pinAbs(part, hoverPin.pin) + if (pos) { + target = pos + clean = true + } + } + if (straight) return [...armed.points, target] + return [ + ...armed.points, + ...calculateOrthogonalPath(last.x, last.y, target.x, target.y, clean).slice(1) + ] + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [armed, mouse, hoverPin, diagram.parts, view, straight]) + + const isAI = (diagram.author || '').includes('Studio AI') + // tactile pixel-art toolbar buttons (design-system .tactile-bordered) + const tool = + 'tactile-bordered h-8 px-2.5 flex items-center gap-1.5 rounded-md bg-surface-card text-text-muted text-xs hover:text-text-body active:translate-y-px' + const families = partsByFamily() + const isOpen = (fam: string): boolean => !collapsed.has(fam) + const toggleFam = (fam: string): void => + setCollapsed((s) => { + const n = new Set(s) + if (n.has(fam)) n.delete(fam) + else n.add(fam) + return n + }) + const netCount = meaningfulNetCount(netModel) + const junctions: Pt[] = [] + diagram.connections.forEach((c) => { + ;[c[0], c[1]].forEach((r) => { + if (typeof r === 'object') junctions.push(r) + }) + }) + + const labelOf = (part: Part): string => { + const def = getPart(part.type) + return (part.attrs?.label as string) || def?.label || part.type + } + const selectedPart = selPart ? diagram.parts.find((p) => p.id === selPart) : null + + return ( +
+ {/* components rail (edit mode only) */} + {editable && ( +
+
+ Components + +
+ {/* wire color β€” recolours the selected wire, else sets the next-wire colour */} +
+ + Wire + + {WIRE_COLORS.map((c) => ( +
+
+ {families.map((group) => ( +
+ + {isOpen(group.family) && ( +
+ {group.parts.map((c: PartMeta) => ( +
e.dataTransfer.setData('text/tinystudio-part', c.type)} + onDoubleClick={() => addPart(c.type)} + className="group relative flex flex-col items-center gap-1 p-2 rounded-lg border border-border-default bg-surface-card hover:bg-bg-sunken hover:-translate-y-px transition cursor-grab active:cursor-grabbing" + title={`${c.label} Β· ${c.pins} pins β€” drag onto the canvas (or double-click)`} + > + +
+ {c.label} +
+ +
+ ))} +
+ )} +
+ ))} +
+
+ )} + + {/* canvas + tools */} +
+
+ + +
+ +
+ + +
+ +
+ + + {Math.round(scale * 100)}% + + + +
+ +
{ + e.preventDefault() + e.dataTransfer.dropEffect = 'copy' + }} + onDrop={onDrop} + onClick={onCanvasClick} + > +
+ + {diagram.connections.map((c, i) => { + const pts = selWire === i && editPts ? editPts : wirePts(c) + if (pts.length < 2) return null + const sel = selWire === i + const onHotNet = highlightNet >= 0 && netModel.connToNet[i] === highlightNet + const core = + view === 'schematic' ? 'var(--text-strong)' : (c[2] as string) || '#2fa46a' + const outline = view === 'schematic' ? 'rgba(0,0,0,0.45)' : darken(core) + const d = roundedPath(pts) + return ( + setHoverWire(i)} + onPointerLeave={() => setHoverWire((h) => (h === i ? null : h))} + onClick={(e) => onWireClick(e, i)} + onDoubleClick={(e) => onWireDoubleClick(e, i)} + > + {/* equipotential net glow */} + {onHotNet && ( + + )} + {/* darker outline β†’ glossy tube */} + + + {sel && ( + + + + )} + + ) + })} + + {/* junction solder dots where a wire taps another wire */} + {junctions.map((j, k) => ( + + ))} + + {previewPts && ( + + )} + + {/* handles on the selected wire: bend (circle), segment (square), + endpoint (ring) β€” Fritzing-style */} + {editable && selWire != null && editPts && ( + + {editPts.slice(0, -1).map((p, i) => { + const q = editPts[i + 1] + const m = { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 } + const horizontal = Math.abs(p.y - q.y) < 0.001 + return ( + startHandleDrag(e, 'edge', i)} + /> + ) + })} + {editPts.slice(1, -1).map((p, i) => ( + startHandleDrag(e, 'vertex', i + 1)} + /> + ))} + {[0, 1].map((end) => { + const p = end === 0 ? editPts[0] : editPts[editPts.length - 1] + return ( + startHandleDrag(e, 'endpoint', 0, end as 0 | 1)} + /> + ) + })} + + )} + + + {diagram.parts.map((part) => { + const def = getPart(part.type) + const v = def && viewFor(def, view) + const [pLeft, pTop] = partXY(part) + if (!def || !v) { + return ( +
+ {part.type}… +
+ ) + } + const labelOff = (part.attrs?.labelOffset as [number, number]) || [0, 0] + return ( +
onPartDown(e, part)} + onClick={(e) => e.stopPropagation()} + onContextMenu={(e) => { + if (!editable) return + e.preventDefault() + rotatePart(part.id) + }} + > +
+ {editable && + Object.keys(v.pins).map((pin) => { + const [px, py] = v.pins[pin] + const armedHere = armed && eqRef(armed.from, `${part.id}:${pin}`) + const hovered = hoverPin?.id === part.id && hoverPin?.pin === pin + const onHotNet = + highlightNet >= 0 && + netModel.pinToNet.get(`${part.id}:${pin}`) === highlightNet + const on = armedHere || hovered + return ( +
e.stopPropagation()} + onPointerEnter={() => setHoverPin({ id: part.id, pin })} + onPointerLeave={() => + setHoverPin((h) => (h?.id === part.id && h?.pin === pin ? null : h)) + } + onClick={(e) => onPinClick(e, part, pin)} + > + + {onHotNet && !on && ( + + )} + + +
+ ) + })} + {/* draggable title label */} +
onLabelDown(e, part)} + > + {labelOf(part)} +
+
+ ) + })} + + {diagram.parts.length === 0 && ( +
+ +
+ Drag parts from the components rail β€” or double-click one to drop it here. +
+
+ )} +
+
+ + {/* footer */} +
+ + Parts: {diagram.parts.length} Β· Connections: {diagram.connections.length} Β· Nets:{' '} + {netCount} + + {isAI && ( + + Auto-wired by Studio AI + + )} + {!editable ? ( + + View-only Β· hover a wire to light its net Β· click edit to change + + ) : armed ? ( + + Click a pin or wire to connect Β· click for a bend Β· hold Shift for + straight Β· Esc to cancel + + ) : selWire != null ? ( + + Drag a handle to reshape Β· double-click to add/remove a bend Β· pick a color Β· Del to + remove + + ) : ( + + Scroll to zoom Β· middle/Alt-drag to pan Β· click a pin to + wire + + )} +
+
+ + {/* inspector (edit mode only) */} + {editable && ( +
+
+ Inspector +
+
+ {selectedPart ? ( + touches(c[0], selectedPart.id) || touches(c[1], selectedPart.id) + ).length + } + onLabel={(val) => patchAttr(selectedPart.id, 'label', val)} + onMove={(l, t) => movePartTo(selectedPart.id, l, t)} + onRotate={(deg) => setRotation(selectedPart.id, deg)} + onSetAttr={(k, val) => patchAttr(selectedPart.id, k, val)} + onRemoveAttr={(k) => removeAttr(selectedPart.id, k)} + onDelete={() => deletePart(selectedPart.id)} + newAttr={newAttr} + setNewAttr={setNewAttr} + /> + ) : selWire != null ? ( + = 0 + ? netModel.nets[netModel.connToNet[selWire]].length + : 0 + } + onColor={(c) => recolorWire(selWire, c)} + onDelete={() => deleteWire(selWire)} + /> + ) : ( +
+ Select a component to edit its name, location, rotation, and properties β€” or a wire + to recolor it. +
+ )} +
+
+ )} + + {editorPart !== undefined && ( + setEditorPart(undefined)} + onSave={(def: PartDef) => { + registerPart(def) + force() + setEditorPart(undefined) + }} + /> + )} +
+ ) +} + +// ── Inspector subcomponents ─────────────────────────────────────────────────── + +const field = + 'bg-bg-sunken border border-border-default rounded px-2 py-1 text-text-strong outline-none focus:border-brand w-full' +const rowLabel = 'text-[10px] text-text-faint uppercase tracking-wide font-mono' + +function Inspector({ + part, + def, + view, + xy, + connCount, + onLabel, + onMove, + onRotate, + onSetAttr, + onRemoveAttr, + onDelete, + newAttr, + setNewAttr +}: { + part: { id: string; type: string; rotate?: number; attrs?: Record } + def?: PartDef + view: ViewKind + xy: [number, number] + connCount: number + onLabel: (v: string) => void + onMove: (l: number, t: number) => void + onRotate: (deg: number) => void + onSetAttr: (k: string, v: string) => void + onRemoveAttr: (k: string) => void + onDelete: () => void + newAttr: string + setNewAttr: (v: string) => void +}): React.JSX.Element { + const label = (part.attrs?.label as string) ?? def?.label ?? part.type + // attrs to expose as editable properties (hide internal layout keys) + const propRows = Object.entries(part.attrs || {}).filter( + ([k]) => k !== 'label' && k !== 'labelOffset' + ) + return ( + <> +
+ + onLabel(e.target.value)} + /> +
+
+ type + {part.type} + family + {def?.family || 'β€”'} + wires + {connCount} +
+ +
+ Location ({view}) +
+ onMove(parseFloat(e.target.value) || 0, xy[1])} + /> + onMove(xy[0], parseFloat(e.target.value) || 0)} + /> +
+
+ +
+ Rotation +
+ + +
+
+ +
+ Properties + {propRows.length === 0 && ( +
+ No properties yet. Add one below β€” e.g. a resistor’s{' '} + value. +
+ )} + {props.map(([k, val]) => ( +
+ + {k} + + onSetAttr(k, e.target.value)} + /> + +
+ ))} +
+ setNewAttr(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && newAttr.trim()) { + onSetAttr(newAttr.trim(), '') + setNewAttr('') + } + }} + /> + +
+
+ + + + ) +} + +function WireInspector({ + color, + colors, + netPins, + onColor, + onDelete +}: { + color: string + colors: string[] + netPins: number + onColor: (c: string) => void + onDelete: () => void +}): React.JSX.Element { + return ( + <> +
+ + Wire +
+
+ Color +
+ {colors.map((c) => ( +
+
+
+ net pins + {netPins} +
+ + + ) +} diff --git a/src/renderer/src/components/.fuse_hidden0000001500000005 b/src/renderer/src/components/.fuse_hidden0000001500000005 new file mode 100644 index 0000000..fa41250 --- /dev/null +++ b/src/renderer/src/components/.fuse_hidden0000001500000005 @@ -0,0 +1,675 @@ +import { + OpenFileCommand, + RefreshWorkspaceCommand +} from '@renderer/commands/fileCommands' +import { loader } from '@monaco-editor/react' +import { fileSystem } from '@renderer/lib/fileSystem' +import { enablePages, loadAccount, loadLink, pushFile } from '@renderer/lib/github' +import { buildVisualExportHtml } from '@renderer/lib/visualExport' +import { selectOpenFiles, useAppDispatch, useAppSelector } from '@renderer/redux' +import { selectEditorView, setEditorView, setPanelOpen } from '@renderer/redux/editorSlice' +import { + BaseFileItem, + closeFile, + EditorFile, + revealFile, + saveFileWithContent, + selectViewingFileId, + setViewingFile, + updateFileContent, + updateReadmeContent +} from '@renderer/redux/fileSlice' +import { + CircuitBoard, + CodeXml, + ExternalLink, + Loader2, + Plus, + UploadCloud +} from 'lucide-react' +import * as monaco from 'monaco-editor' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { notify as toast } from '@renderer/lib/notify' +import { BlocklyEditor } from './BlocklyEditor' +import { DiagramEditor } from './DiagramEditor' +import { MonacoEditor, MonacoEditorRef } from './MonacoEditor' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from './ui/Dialog' +import { FileTabContent, FileTabs, FileTabsList, FileTabTrigger } from './ui/FileTab' +import { Button } from './ui/Button' +import { IconButton } from './ui/IconButton' +import { Input } from './ui/Input' +import { VisualPreview } from './VisualPreview' + +loader.config({ monaco }) + +const DEFAULT_DIAGRAM = JSON.stringify( + { version: 1, editor: 'tinystudio', parts: [], connections: [] }, + null, + 2 +) +const DEFAULT_VISUAL = `// visual.js β€” Serial Plotter +// Graphs the latest number printed over Serial (serialValue()) as a scrolling +// line, auto-scaling to the data. Try Serial.println(analogRead(A0)) on the +// board. Switch to Code to edit this sketch; Visual to run it. + +let data = []; +const MAX = 240; // points kept on screen + +function setup() { + createCanvas(480, 280); + textFont('monospace'); +} + +function draw() { + background(7, 11, 34); + + // pull the most recent serial value each frame + data.push(serialValue()); + if (data.length > MAX) data.shift(); + + // auto-scale to the data range (with a little headroom) + let lo = Math.min(...data, 0); + let hi = Math.max(...data, 1); + if (hi === lo) hi = lo + 1; + + // grid + stroke(26, 31, 77); + strokeWeight(1); + for (let i = 0; i <= 4; i++) { + let y = map(i, 0, 4, 20, height - 24); + line(40, y, width - 12, y); + } + + // plotted line + noFill(); + stroke(0, 240, 255); + strokeWeight(2); + beginShape(); + for (let i = 0; i < data.length; i++) { + let x = map(i, 0, MAX - 1, 40, width - 12); + let y = map(data[i], lo, hi, height - 24, 20); + vertex(x, y); + } + endShape(); + + // readouts + noStroke(); + fill(235, 238, 255); + textSize(12); + text('value: ' + serialValue().toFixed(2), 44, 16); + fill(120, 130, 170); + text(hi.toFixed(0), 8, 24); + text(lo.toFixed(0), 8, height - 24); +} +` + +function findInTree( + items: BaseFileItem[], + match: (i: BaseFileItem) => boolean +): BaseFileItem | null { + for (const item of items) { + if (item.type === 'file' && item.name && match(item)) return item + if (item.children) { + const found = findInTree(item.children, match) + if (found) return found + } + } + return null +} + +/** Collect every file in the tree matching `match`, skipping noise folders. */ +function collectFiles( + items: BaseFileItem[], + match: (i: BaseFileItem) => boolean, + out: BaseFileItem[] = [] +): BaseFileItem[] { + for (const item of items) { + if (item.type === 'file' && item.name && match(item)) out.push(item) + if (item.children && !['node_modules', '.git', 'dist'].includes(item.name ?? '')) { + collectFiles(item.children, match, out) + } + } + return out +} + +/** Starter p5.js sketch dropped into newly created .js files. */ +const sketchTemplate = (title: string): string => `// ${title} β€” p5.js sketch. +// Switch to Code to edit this sketch; Visual to run it. + +let x = 240; +let y = 140; +let dx = 3; +let dy = 2; + +function setup() { + createCanvas(480, 280); + noStroke(); +} + +function draw() { + background(7, 11, 34); + x += dx; + y += dy; + if (x < 20 || x > width - 20) dx = -dx; + if (y < 20 || y > height - 20) dy = -dy; + fill(0, 240, 255); + circle(x, y, 40); +} +` + +/** + * Ensure a project file (e.g. diagram.json / visual.js) is open as an editor + * buffer, creating it from a default if it doesn't exist yet. Returns the open + * file once ready. Used by the full-window Circuit/Visual views so their edits + * live in the same buffer model as code (saved on Ctrl+S / build). + */ +function useProjectFile(name: string, makeDefault?: () => string): EditorFile | undefined { + const workspace = useAppSelector((s) => s.file.workspace) + const openFiles = useAppSelector(selectOpenFiles) + const file = openFiles.find((f) => f.name === name) + const busy = useRef(false) + + useEffect(() => { + if (file || !workspace || busy.current) return + busy.current = true + ;(async () => { + try { + let item = findInTree(workspace.root, (i) => i.name === name) + if (!item && makeDefault) { + const path = `${workspace.path}/${name}` + await fileSystem.writeFile(path, makeDefault()) + await new RefreshWorkspaceCommand(workspace).execute() + item = { id: crypto.randomUUID(), parentId: 'root', name, path, type: 'file' } + } + // Load as a hidden background buffer: the full-window view edits/saves + // it, but it stays out of the Code tab bar until the user clicks the + // in-view code button (which reveals it). + if (item) await new OpenFileCommand(item, { hidden: true }).execute() + } finally { + busy.current = false + } + })() + }, [file, workspace, name, makeDefault]) + + return file +} + +export function EditorPanel({ size }: { size: number }): React.JSX.Element { + const editorView = useAppSelector(selectEditorView) + // Track viewport height so the panel re-measures on resize / fullscreen toggle + // (otherwise the height is computed once from a stale window.innerHeight and + // the layout β€” and its buttons β€” break after going fullscreen). + const [winHeight, setWinHeight] = useState(window.innerHeight) + useEffect(() => { + const onResize = (): void => setWinHeight(window.innerHeight) + window.addEventListener('resize', onResize) + return () => window.removeEventListener('resize', onResize) + }, []) + const pixelSize = Math.round((size / 100) * (winHeight - 92)) + + return ( +
+ {editorView === 'circuit' ? ( + + ) : editorView === 'visual' ? ( + + ) : ( + + )} +
+ ) +} + +// ── Code view: the normal tabbed IDE ──────────────────────────────────────── + +function CodeView(): React.JSX.Element { + const allOpenFiles = useAppSelector(selectOpenFiles) + // Background buffers (diagram.json / visual.js loaded for the Circuit/Visual + // views) are hidden from the tab bar until revealed via their code button. + const openFiles = allOpenFiles.filter((f) => !f.hidden) + const viewingFileId = useAppSelector(selectViewingFileId) + const editorMode = useAppSelector((state) => state.editor.editorMode) + const workspace = useAppSelector((s) => s.file.workspace) + const dispatch = useAppDispatch() + const monacoEditorRef = useRef(null) + + const handleFileClose = useCallback( + (fileId: string): void => { + dispatch(closeFile(fileId)) + }, + [dispatch] + ) + + const handleFileSelect = useCallback( + (fileId: string): void => { + dispatch(setViewingFile(fileId)) + setTimeout(() => { + if (monacoEditorRef.current && editorMode !== 'blocks') monacoEditorRef.current.focus() + }, 50) + }, + [dispatch, editorMode] + ) + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent): void => { + if ((event.metaKey || event.ctrlKey) && event.key === 'w') { + event.preventDefault() + if (viewingFileId) handleFileClose(viewingFileId) + } + if (event.ctrlKey && event.key === 'Tab') { + event.preventDefault() + if (openFiles.length > 1) { + const i = openFiles.findIndex((f) => f.id === viewingFileId) + const next = event.shiftKey + ? i <= 0 + ? openFiles.length - 1 + : i - 1 + : (i + 1) % openFiles.length + if (openFiles[next]) handleFileSelect(openFiles[next].id) + } + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [viewingFileId, handleFileClose, openFiles, handleFileSelect]) + + const handleContentChange = useCallback( + (content: string, fileId: string) => { + const file = openFiles.find((f) => f.id === fileId) + if (file) { + dispatch(updateFileContent({ id: file.id, content })) + if (file.name === 'README.md') dispatch(updateReadmeContent(content)) + } + }, + [openFiles, dispatch] + ) + + const handleSaveFile = async (content: string, fileId: string): Promise => { + const file = openFiles.find((f) => f.id === fileId) + if (file && file.path) { + try { + await fileSystem.writeFile(file.path, content) + dispatch(saveFileWithContent({ id: file.id, content })) + } catch (error) { + console.error('Failed to save file:', error) + } + } + } + + if (openFiles.length === 0) { + return ( +
+
+
+
No project open
+
Open a folder to start building.
+
+ +
+ ) + } + + return ( + + +
+ {openFiles.map((file) => ( + + ))} +
+
+ {openFiles.map((file) => ( + + {editorMode === 'blocks' ? ( + + ) : ( + handleContentChange(content, file.id)} + onSaveFile={(content) => handleSaveFile(content, file.id)} + /> + )} + + ))} +
+ ) +} + +// ── Circuit view: full-window interactive diagram.json ────────────────────── + +function CircuitView(): React.JSX.Element { + const workspace = useAppSelector((s) => s.file.workspace) + const dispatch = useAppDispatch() + const file = useProjectFile('diagram.json', () => DEFAULT_DIAGRAM) + + if (!workspace) return + if (!file) return + + return ( + dispatch(updateFileContent({ id: file.id, content }))} + onOpenCode={() => { + dispatch(revealFile(file.id)) + dispatch(setEditorView('code')) + }} + onEditChange={(editing) => { + // entering edit mode: free horizontal space by closing the docs panel + if (editing) dispatch(setPanelOpen({ panel: 'docs', isOpen: false })) + }} + /> + ) +} + +// ── Visual view: full-window p5 sketch with an Edit-code button ───────────── + +function VisualView(): React.JSX.Element { + const workspace = useAppSelector((s) => s.file.workspace) + const dispatch = useAppDispatch() + + // Which .js sketch is shown in the Visual view. Defaults to visual.js, the + // sketch auto-created for every project; other .js files are selectable too. + const [activeSketch, setActiveSketch] = useState('visual.js') + const [showNew, setShowNew] = useState(false) + // Serial is owned app-wide by SerialProvider and feeds the running sketch via + // the shared bus β€” the Visual view no longer opens the port itself, so + // switching to/from this view doesn't reset the board. + const [publishing, setPublishing] = useState(false) + + const jsFiles = useMemo( + () => (workspace ? collectFiles(workspace.root, (i) => /\.js$/i.test(i.name!)) : []), + [workspace] + ) + + // If the active sketch isn't in this project (after a project switch or a + // delete), fall back to visual.js or the first sketch available. + useEffect(() => { + if (jsFiles.length && !jsFiles.some((f) => f.name === activeSketch)) { + setActiveSketch(jsFiles.find((f) => f.name === 'visual.js')?.name ?? jsFiles[0].name!) + } + }, [jsFiles, activeSketch]) + + // Only visual.js gets seeded from a default; other sketches must already exist. + const file = useProjectFile( + activeSketch, + activeSketch === 'visual.js' ? () => DEFAULT_VISUAL : undefined + ) + + if (!workspace) return + + const ws = workspace + + const createSketch = async (fileName: string): Promise => { + const path = `${ws.path}/${fileName}` + try { + await fileSystem.writeFile(path, sketchTemplate(fileName.replace(/\.js$/i, ''))) + await new RefreshWorkspaceCommand(ws).execute() + setActiveSketch(fileName) + setShowNew(false) + } catch (e) { + toast.error('Could not create sketch', { + description: e instanceof Error ? e.message : 'Unknown error' + }) + } + } + + // Build the standalone page, titled after the .ino project (not the folder). + const buildHtml = (): string => { + const ino = findInTree(ws.root, (i) => /\.ino$/i.test(i.name!)) + const projectName = ino?.name?.replace(/\.ino$/i, '') || ws.name || 'tinyStudio sketch' + return buildVisualExportHtml(projectName, file?.content ?? '') + } + + // Preview: write index.html into the project root and open it in the browser. + const preview = async (): Promise => { + const path = `${ws.path}/index.html` + try { + await window.api.fs.writeFile(path, buildHtml()) + await new RefreshWorkspaceCommand(ws).execute() + const err = await window.api.fs.openPath(path) + if (err) toast.error('Could not open preview', { description: err }) + else toast.success('Preview opened in your browser', { description: path }) + } catch (e) { + toast.error('Preview failed', { + description: e instanceof Error ? e.message : 'Unknown error' + }) + } + } + + // Publish: push index.html to the linked repo and turn on GitHub Pages. + const publish = async (): Promise => { + const account = loadAccount() + if (!account) { + toast.info('Connect GitHub first', { + description: 'Open the GitHub tab in the sidebar to sign in.' + }) + return + } + const link = loadLink(ws.path) + if (!link) { + toast.info('Link this project to a repo first', { + description: 'Use the GitHub tab to link or publish a repository.' + }) + return + } + setPublishing(true) + try { + const html = buildHtml() + await window.api.fs.writeFile(`${ws.path}/index.html`, html) + await new RefreshWorkspaceCommand(ws).execute() + await pushFile( + link.remote, + link.branch, + 'index.html', + html, + account.token, + 'Publish web export via tinyStudio' + ) + const url = await enablePages(link.remote, link.branch, account.token) + toast.success('Published to GitHub Pages', { + description: `${url} β€” the first build can take a minute.` + }) + window.api.fs.openExternal(url) + } catch (e) { + toast.error('Publish failed', { + description: e instanceof Error ? e.message : 'Unknown error' + }) + } finally { + setPublishing(false) + } + } + + return ( +
+ {/* Sketch picker + view actions. Tabs let you keep several p5.js renders + (asteroids.js, pong.js, …) in one project and switch between them. */} +
+
+ {jsFiles.map((f) => ( + + ))} + +
+
+
+ +
+ {file ? ( + + { + dispatch(revealFile(file.id)) + dispatch(setEditorView('code')) + }} + > + + + + + + + {publishing ? ( + + ) : ( + + )} + + + } + /> + ) : ( + + )} +
+ + f.name!)} + onCreate={createSketch} + /> +
+ ) +} + +function NewSketchDialog({ + open, + onOpenChange, + existing, + onCreate +}: { + open: boolean + onOpenChange: (o: boolean) => void + existing: string[] + onCreate: (fileName: string) => void +}): React.JSX.Element { + const [name, setName] = useState('') + + // Reset the field each time the dialog opens. + useEffect(() => { + if (open) setName('') + }, [open]) + + const trimmed = name.trim() + const fileName = trimmed ? (/\.js$/i.test(trimmed) ? trimmed : `${trimmed}.js`) : '' + const duplicate = !!fileName && existing.includes(fileName) + const invalid = !trimmed || /[\\/:*?"<>|]/.test(trimmed) || duplicate + + const submit = (): void => { + if (!invalid) onCreate(fileName) + } + + return ( + + + + New p5.js sketch + + Creates a new .js sketch in your project. Switch + between sketches with the tabs in the Visual view. + + +
+ setName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && submit()} + /> + {duplicate && ( +

+ A file named {fileName} already exists. +

+ )} +
+ + + + +
+
+ ) +} + +function EmptyHint({ label }: { icon: string; label: string }): React.JSX.Element { + return ( +
+ +

{label}

+
+ ) +} + +function LoadingHint({ label }: { label: string }): React.JSX.Element { + return ( +
+ +

{label}

+
+ ) +} diff --git a/src/renderer/src/components/.fuse_hidden0000001700000002 b/src/renderer/src/components/.fuse_hidden0000001700000002 new file mode 100644 index 0000000..a9f8160 --- /dev/null +++ b/src/renderer/src/components/.fuse_hidden0000001700000002 @@ -0,0 +1,274 @@ +/** + * Markdown β€” shared GitHub-flavored markdown renderer. + * + * Used by both the Documentation tab and the Studio AI chat so they stylize + * markdown the same way. Fenced ```mermaid blocks are rendered as diagrams + * instead of code; everything else falls back to themed HTML elements. + */ + +import mermaid from 'mermaid' +import React from 'react' +import ReactMarkdown, { type Components } from 'react-markdown' +import remarkGfm from 'remark-gfm' + +// Initialize mermaid once for the whole renderer. `startOnLoad: false` because +// we drive rendering ourselves from . +let mermaidReady = false +function ensureMermaid(): void { + if (mermaidReady) return + mermaid.initialize({ + startOnLoad: false, + securityLevel: 'strict', + // Never let mermaid inject its "Syntax error" bomb graphic into the DOM β€” + // we render our own inline fallback instead. + suppressErrorRendering: true, + theme: 'dark', + themeVariables: { + fontFamily: 'inherit', + primaryColor: '#1e2a4a', + primaryTextColor: '#e6ecff', + primaryBorderColor: '#3a4a6a', + lineColor: '#5a6a8a', + secondaryColor: '#243156', + tertiaryColor: '#1a2540' + } + }) + mermaidReady = true +} + +/** Recursively flatten React markdown children into plain text. */ +function childrenToText(children: React.ReactNode): string { + if (children == null) return '' + if (typeof children === 'string' || typeof children === 'number') return String(children) + if (Array.isArray(children)) return children.map(childrenToText).join('') + if (React.isValidElement(children)) { + return childrenToText((children.props as { children?: React.ReactNode }).children) + } + return '' +} + +/** Inspect a
's child  element for its language and source text. */
+function getCodeInfo(children: React.ReactNode): { lang: string | null; text: string } | null {
+  const child = React.Children.toArray(children)[0]
+  if (!React.isValidElement(child)) return null
+  const props = child.props as { className?: string; children?: React.ReactNode }
+  const match = /language-(\w+)/.exec(props.className ?? '')
+  return { lang: match ? match[1] : null, text: childrenToText(props.children) }
+}
+
+function MermaidDiagram({ chart }: { chart: string }): React.JSX.Element {
+  const ref = React.useRef(null)
+  const [error, setError] = React.useState(null)
+
+  React.useEffect(() => {
+    ensureMermaid()
+    let cancelled = false
+    const id = `mermaid-${Math.random().toString(36).slice(2)}`
+    ;(async () => {
+      try {
+        // Validate first (suppressErrors β†’ returns false instead of throwing/
+        // injecting), so an invalid diagram never touches the DOM.
+        const ok = await mermaid.parse(chart, { suppressErrors: true })
+        if (!ok) {
+          if (!cancelled) setError('invalid diagram')
+          return
+        }
+        const { svg } = await mermaid.render(id, chart)
+        if (!cancelled && ref.current) ref.current.innerHTML = svg
+      } catch (e: unknown) {
+        if (!cancelled) setError(e instanceof Error ? e.message : String(e))
+      } finally {
+        // belt-and-suspenders: remove any stray node mermaid may have left
+        document.getElementById(id)?.remove()
+        document.getElementById('d' + id)?.remove()
+      }
+    })()
+    return () => {
+      cancelled = true
+    }
+  }, [chart])
+
+  if (error) {
+    return (
+      
+        {chart}
+      
+ ) + } + return
+} + +const components: Components = { + h1: ({ children, className, ...props }) => ( +

+ {children} +

+ ), + h2: ({ children, className, ...props }) => ( +

+ {children} +

+ ), + h3: ({ children, className, ...props }) => ( +

+ {children} +

+ ), + p: ({ children, className, ...props }) => ( +

+ {children} +

+ ), + ul: ({ children, className, ...props }) => ( +
    + {children} +
+ ), + ol: ({ children, className, ...props }) => ( +
    + {children} +
+ ), + li: ({ children, className, ...props }) => ( +
  • + {children} +
  • + ), + strong: ({ children, className, ...props }) => ( + + {children} + + ), + em: ({ children, className, ...props }) => ( + + {children} + + ), + code: ({ children, className, ...props }) => ( + + {children} + + ), + pre: ({ children, className, ...props }) => { + const info = getCodeInfo(children) + if (info?.lang === 'mermaid') return + return ( +
    code]:bg-transparent [&>code]:p-0 [&>code]:text-fg-2'
    +        ]
    +          .filter(Boolean)
    +          .join(' ')}
    +      >
    +        {children}
    +      
    + ) + }, + blockquote: ({ children, className, ...props }) => ( +
    + {children} +
    + ), + a: ({ children, className, ...props }) => ( + + {children} + + ), + hr: ({ className, ...props }) => ( +
    + ), + table: ({ children, className, ...props }) => ( +
    + + {children} +
    +
    + ), + th: ({ children, className, ...props }) => ( + + {children} + + ), + td: ({ children, className, ...props }) => ( + + {children} + + ) +} + +export function Markdown({ + children, + className +}: { + children: string + className?: string +}): React.JSX.Element { + return ( +
    *:last-child]:mb-0', className].filter(Boolean).join(' ')}> + + {children} + +
    + ) +} diff --git a/src/renderer/src/components/AIAssistant.tsx b/src/renderer/src/components/AIAssistant.tsx index 81f55e8..463f314 100644 --- a/src/renderer/src/components/AIAssistant.tsx +++ b/src/renderer/src/components/AIAssistant.tsx @@ -366,7 +366,7 @@ function PermissionDialog({ Studio AI wants to change a file in your workspace. -
    +        
               {request?.preview}
             
    @@ -422,8 +422,9 @@ function SettingsDialog({
    -