diff --git a/backend/src/spicebook/routers/schematics.py b/backend/src/spicebook/routers/schematics.py index 527756f..d0fd90c 100644 --- a/backend/src/spicebook/routers/schematics.py +++ b/backend/src/spicebook/routers/schematics.py @@ -1,5 +1,6 @@ """Schematic generation endpoints.""" +import logging from datetime import datetime, timezone from fastapi import APIRouter, HTTPException @@ -10,6 +11,8 @@ from spicebook.engine.schematic import netlist_to_svg from spicebook.models.notebook import CellOutput, CellType from spicebook.storage.filesystem import load_notebook, save_notebook +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/api", tags=["schematics"]) @@ -17,6 +20,7 @@ class SchematicResponse(BaseModel): svg: str | None = None success: bool error: str | None = None + component_map: dict[str, str] | None = None @router.post( @@ -43,17 +47,25 @@ async def generate_schematic(notebook_id: str, cell_id: str): raise HTTPException(status_code=400, detail="Cell source is empty") try: - svg = netlist_to_svg(cell.source) + result = netlist_to_svg(cell.source) except (ValueError, RuntimeError) as exc: return SchematicResponse(success=False, error=str(exc)) - except Exception as exc: - return SchematicResponse(success=False, error=f"Schematic generation failed: {exc}") + except Exception: + logger.exception("Unexpected error during schematic generation") + return SchematicResponse( + success=False, + error="Schematic generation failed due to an internal error", + ) # Save schematic output to cell (additive — preserve simulation outputs) now_iso = datetime.now(timezone.utc).isoformat() schematic_output = CellOutput( output_type="schematic", - data={"svg": svg, "success": True}, + data={ + "svg": result.svg, + "component_map": result.component_map, + "success": True, + }, timestamp=now_iso, ) @@ -63,4 +75,8 @@ async def generate_schematic(notebook_id: str, cell_id: str): save_notebook(settings.notebook_dir, notebook_id, nb) - return SchematicResponse(svg=svg, success=True) + return SchematicResponse( + svg=result.svg, + component_map=result.component_map, + success=True, + ) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 10afec6..24a6011 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -22,6 +22,7 @@ "@lezer/lr": "^1.4.0", "astro": "^5.0.0", "clsx": "^2.1.0", + "dompurify": "^3.3.1", "lucide-react": "^0.468.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -31,6 +32,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@types/dompurify": "^3.0.5", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "tailwindcss": "^4.0.0", @@ -3026,6 +3028,16 @@ "@types/ms": "*" } }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3083,6 +3095,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -4661,6 +4680,15 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 126ac0c..f49fdd9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,7 @@ "@lezer/lr": "^1.4.0", "astro": "^5.0.0", "clsx": "^2.1.0", + "dompurify": "^3.3.1", "lucide-react": "^0.468.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -32,6 +33,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@types/dompurify": "^3.0.5", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "tailwindcss": "^4.0.0", diff --git a/frontend/src/components/notebook/cells/SpiceCell.tsx b/frontend/src/components/notebook/cells/SpiceCell.tsx index 21d7d5f..c62ce1e 100644 --- a/frontend/src/components/notebook/cells/SpiceCell.tsx +++ b/frontend/src/components/notebook/cells/SpiceCell.tsx @@ -1,7 +1,8 @@ -import { useCallback } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import type { Cell } from '../../../lib/types'; import type { WaveformData } from '../../../lib/types'; import { useNotebookStore } from '../../../lib/notebook-store'; +import { updateNetlistValue } from '../../../lib/netlist-utils'; import { CellToolbar } from '../toolbar/CellToolbar'; import { SpiceEditor } from '../editor/SpiceEditor'; import { WaveformViewer } from '../output/WaveformViewer'; @@ -60,6 +61,31 @@ export function SpiceCell({ cell, isFirst, isLast }: SpiceCellProps) { // Extract schematic output const schematicOutput = cell.outputs.find((o) => o.output_type === 'schematic'); const schematicSvg = schematicOutput?.data?.svg as string | null; + const componentMap = schematicOutput?.data?.component_map as Record | undefined; + + // Click-to-edit: update netlist when a schematic value is changed + const handleSchematicValueChange = useCallback( + (componentName: string, newValue: string) => { + const updated = updateNetlistValue(cell.source, componentName, newValue); + updateCellSource(cell.id, updated); + }, + [cell.id, cell.source, updateCellSource], + ); + + // Debounced auto-redraw: regenerate schematic 800ms after source changes. + // Track the source at last generation to avoid retriggering from our own SVG updates. + const lastGeneratedSource = useRef(null); + + useEffect(() => { + if (!schematicSvg) return; // only auto-redraw when schematic is visible + if (cell.source === lastGeneratedSource.current) return; // skip if source unchanged since last gen + + const timer = setTimeout(() => { + lastGeneratedSource.current = cell.source; + generateSchematic(cell.id); + }, 800); + return () => clearTimeout(timer); + }, [cell.source, cell.id, generateSchematic, schematicSvg]); return (
- +
)} diff --git a/frontend/src/components/notebook/output/SchematicViewer.tsx b/frontend/src/components/notebook/output/SchematicViewer.tsx index 01621a8..a22f183 100644 --- a/frontend/src/components/notebook/output/SchematicViewer.tsx +++ b/frontend/src/components/notebook/output/SchematicViewer.tsx @@ -1,15 +1,37 @@ -import { useState, useRef, useCallback } from 'react'; +import { useState, useRef, useCallback, useEffect } from 'react'; import { ZoomIn, ZoomOut, Maximize2, Download } from 'lucide-react'; +import DOMPurify from 'dompurify'; + +interface EditState { + component: string; + rect: { top: number; left: number; width: number; height: number }; + currentValue: string; +} interface SchematicViewerProps { svg: string; + componentMap?: Record; + onValueChange?: (componentName: string, newValue: string) => void; } const ZOOM_STEPS = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]; -export function SchematicViewer({ svg }: SchematicViewerProps) { +// Allow SPICE values: digits, SI prefixes, decimal points, signs, parens, common SPICE chars +const SPICE_VALUE_RE = /^[0-9a-zA-Z._+\-*/(){}=, ]{1,200}$/; + +function sanitizeSvg(raw: string): string { + return DOMPurify.sanitize(raw, { + USE_PROFILES: { svg: true, svgFilters: true }, + ADD_ATTR: ['data-component', 'data-editable', 'data-raw-value'], + }); +} + +export function SchematicViewer({ svg, componentMap, onValueChange }: SchematicViewerProps) { const [zoomIndex, setZoomIndex] = useState(2); // Start at 100% + const [editState, setEditState] = useState(null); const containerRef = useRef(null); + const svgWrapperRef = useRef(null); + const inputRef = useRef(null); const zoom = ZOOM_STEPS[zoomIndex]; @@ -35,6 +57,137 @@ export function SchematicViewer({ svg }: SchematicViewerProps) { URL.revokeObjectURL(url); }, [svg]); + // Dismiss edit overlay when SVG changes (e.g., after auto-redraw) + useEffect(() => { + setEditState(null); + }, [svg]); + + // Inject sanitized SVG into the DOM and attach interactive handlers + useEffect(() => { + const wrapper = svgWrapperRef.current; + if (!wrapper) return; + + wrapper.innerHTML = sanitizeSvg(svg); + + // Find all editable tspan elements and enhance them + const editables = wrapper.querySelectorAll('tspan[data-editable="true"]'); + + // Track all handlers for proper cleanup + const allHandlers: Array<{ el: SVGTSpanElement; event: string; handler: EventListener }> = []; + + const addHandler = (el: SVGTSpanElement, event: string, handler: EventListener) => { + el.addEventListener(event, handler); + allHandlers.push({ el, event, handler }); + }; + + editables.forEach((tspan) => { + tspan.style.cursor = 'pointer'; + + addHandler(tspan, 'mouseenter', () => { + tspan.style.fill = '#60a5fa'; // blue-400 + }); + addHandler(tspan, 'mouseleave', () => { + tspan.style.fill = ''; + }); + + // Click handler for inline editing + const handleClick = (e: Event) => { + (e as MouseEvent).stopPropagation(); + + const parentText = tspan.closest('text'); + const componentName = parentText?.getAttribute('data-component'); + const rawValue = tspan.getAttribute('data-raw-value'); + + if (!componentName || !rawValue) return; + + const container = containerRef.current; + if (!container) return; + + // Get tspan position relative to the scroll container + const tspanRect = tspan.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + + setEditState({ + component: componentName, + rect: { + top: tspanRect.top - containerRect.top + container.scrollTop, + left: tspanRect.left - containerRect.left + container.scrollLeft, + width: Math.max(tspanRect.width, 60), + height: tspanRect.height, + }, + currentValue: rawValue, + }); + }; + + addHandler(tspan, 'click', handleClick); + }); + + // Cleanup all event listeners on unmount or SVG change + return () => { + allHandlers.forEach(({ el, event, handler }) => { + el.removeEventListener(event, handler); + }); + }; + }, [svg]); + + // Focus input when edit overlay appears + useEffect(() => { + if (editState && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [editState]); + + const commitEdit = useCallback(() => { + if (!editState) return; + const input = inputRef.current; + if (!input) return; + + const newValue = input.value.trim(); + if (!newValue || newValue === editState.currentValue) { + setEditState(null); + return; + } + + // Validate value against SPICE-safe pattern + if (!SPICE_VALUE_RE.test(newValue)) { + setEditState(null); + return; + } + + // Optimistically update the tspan text in the live DOM + const wrapper = svgWrapperRef.current; + if (wrapper) { + const escaped = CSS.escape(editState.component); + const parentText = wrapper.querySelector(`text[data-component="${escaped}"]`); + const valueTspan = parentText?.querySelector('tspan[data-editable="true"]'); + if (valueTspan) { + valueTspan.textContent = newValue; + valueTspan.setAttribute('data-raw-value', newValue); + } + } + + onValueChange?.(editState.component, newValue); + setEditState(null); + }, [editState, onValueChange]); + + const cancelEdit = useCallback(() => { + setEditState(null); + }, []); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + commitEdit(); + } else if (e.key === 'Escape') { + e.preventDefault(); + cancelEdit(); + } + }, + [commitEdit, cancelEdit], + ); + return (
{/* Toolbar */} @@ -66,6 +219,11 @@ export function SchematicViewer({ svg }: SchematicViewerProps) {
+ {componentMap && Object.keys(componentMap).length > 0 && ( + + click values to edit + + )}
); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ce2cc02..f80cc9a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -177,6 +177,7 @@ export interface SchematicResponse { svg: string | null; success: boolean; error?: string; + component_map?: Record; } export async function generateSchematic( diff --git a/frontend/src/lib/netlist-utils.ts b/frontend/src/lib/netlist-utils.ts new file mode 100644 index 0000000..4843045 --- /dev/null +++ b/frontend/src/lib/netlist-utils.ts @@ -0,0 +1,126 @@ +/** + * SPICE netlist manipulation utilities. + * + * Pure functions for updating component values in SPICE netlist text. + */ + +/** + * Replace a component's value in a SPICE netlist string. + * + * Handles continuation lines (+ prefix) and preserves inline comments. + * Returns the original netlist unchanged if the component is not found. + */ +export function updateNetlistValue( + netlist: string, + componentName: string, + newValue: string, +): string { + const lines = netlist.split('\n'); + const target = componentName.toUpperCase(); + + // First pass: find the line (and any continuation lines) for this component + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim(); + if (!trimmed || trimmed.startsWith('*') || trimmed.startsWith(';') || trimmed.startsWith('.')) { + continue; + } + + // Merge this line with any continuation lines that follow + let fullLine = trimmed; + let lastContinuation = i; + for (let j = i + 1; j < lines.length; j++) { + const next = lines[j].trim(); + if (next.startsWith('+')) { + fullLine += ' ' + next.slice(1).trim(); + lastContinuation = j; + } else { + break; + } + } + + // Strip inline comment before tokenizing to avoid comment text in tokens + let comment = ''; + let workLine = fullLine; + const commentIdx = fullLine.indexOf(';'); + if (commentIdx >= 0) { + comment = ' ' + fullLine.slice(commentIdx); + workLine = fullLine.slice(0, commentIdx).trimEnd(); + } + + const tokens = workLine.split(/\s+/); + if (tokens.length === 0) continue; + + if (tokens[0].toUpperCase() !== target) continue; + + // Found the component — determine which token(s) hold the value + const prefix = tokens[0][0].toUpperCase(); + const replaced = replaceValue(tokens, prefix, newValue, comment); + if (replaced === null) return netlist; + + // Replace the original line(s) with the updated single line + // Preserve leading whitespace from the original first line + const leadingWs = lines[i].match(/^(\s*)/)?.[1] ?? ''; + const newLines = [...lines]; + newLines.splice(i, lastContinuation - i + 1, leadingWs + replaced); + return newLines.join('\n'); + } + + // Component not found — return unchanged + return netlist; +} + +/** + * Replace the value portion of a component line based on its SPICE prefix. + * + * Token positions by prefix: + * R, C, L: NAME N+ N- VALUE → token[3] + * V, I: NAME N+ N- VALUE_SPEC... → tokens[3:] + * D: NAME N+ N- MODEL → not editable (model name) + * Q: NAME NC NB NE [NS] MODEL → not editable + * M: NAME ND NG NS [NB] MODEL → not editable + * E, G, S: NAME N+ N- NC+ NC- VALUE... → tokens[5:] + * F, H: NAME N+ N- VNAME GAIN → tokens[3:] (value includes vname) + * B: NAME N+ N- EXPR → tokens[3:] + */ +function replaceValue( + tokens: string[], + prefix: string, + newValue: string, + comment: string, +): string | null { + switch (prefix) { + case 'R': + case 'C': + case 'L': { + // NAME N+ N- VALUE + if (tokens.length < 4) return null; + return [tokens[0], tokens[1], tokens[2], newValue].join(' ') + comment; + } + + case 'V': + case 'I': + case 'B': { + // NAME N+ N- + if (tokens.length < 3) return null; + return [tokens[0], tokens[1], tokens[2], newValue].join(' ') + comment; + } + + case 'E': + case 'G': + case 'S': { + // NAME N+ N- NC+ NC- VALUE... + if (tokens.length < 6) return null; + return [tokens[0], tokens[1], tokens[2], tokens[3], tokens[4], newValue].join(' ') + comment; + } + + case 'F': + case 'H': { + // NAME N+ N- VNAME GAIN → replace from token 3 onward + if (tokens.length < 4) return null; + return [tokens[0], tokens[1], tokens[2], newValue].join(' ') + comment; + } + + default: + return null; + } +} diff --git a/frontend/src/lib/notebook-store.ts b/frontend/src/lib/notebook-store.ts index 77c14cb..0b946a8 100644 --- a/frontend/src/lib/notebook-store.ts +++ b/frontend/src/lib/notebook-store.ts @@ -38,6 +38,7 @@ interface NotebookStore { loading: boolean; error: string | null; saving: boolean; + schematicGeneration: Map; // Actions loadNotebook: (id: string) => Promise; @@ -65,6 +66,7 @@ export const useNotebookStore = create((set, get) => ({ loading: false, error: null, saving: false, + schematicGeneration: new Map(), loadNotebook: async (id: string) => { set({ loading: true, error: null }); @@ -265,23 +267,35 @@ export const useNotebookStore = create((set, get) => ({ }, generateSchematic: async (cellId: string) => { - const { notebook, notebookId, runningCells } = get(); + const { notebook, notebookId, runningCells, schematicGeneration } = get(); if (!notebook || !notebookId) return; const cell = notebook.cells.find((c) => c.id === cellId); if (!cell || cell.type !== 'spice') return; + // Increment generation counter to detect stale responses + const gen = (schematicGeneration.get(cellId) ?? 0) + 1; + const newGenMap = new Map(schematicGeneration); + newGenMap.set(cellId, gen); + const newRunning = new Set(runningCells); newRunning.add(cellId); - set({ runningCells: newRunning }); + set({ runningCells: newRunning, schematicGeneration: newGenMap }); try { const result = await api.generateSchematic(notebookId, cellId); + // Discard stale response if a newer generation was started + if (get().schematicGeneration.get(cellId) !== gen) return; + if (result.success && result.svg) { const output: CellOutput = { output_type: 'schematic', - data: { svg: result.svg, success: true }, + data: { + svg: result.svg, + component_map: result.component_map || {}, + success: true, + }, timestamp: new Date().toISOString(), }; @@ -305,9 +319,12 @@ export const useNotebookStore = create((set, get) => ({ }); } } catch (err) { - set({ - error: err instanceof Error ? err.message : 'Schematic generation failed', - }); + // Only show error if this is still the current generation + if (get().schematicGeneration.get(cellId) === gen) { + set({ + error: err instanceof Error ? err.message : 'Schematic generation failed', + }); + } } finally { const currentRunning = new Set(get().runningCells); currentRunning.delete(cellId);