Add click-to-edit schematic values and DOMPurify sanitization
SchematicViewer now supports inline editing of component values directly on the SVG. Clicking an editable value opens an overlay input that updates the SPICE netlist on commit, triggering an auto-redraw after 800ms debounce. Added DOMPurify for SVG sanitization, netlist-utils for safe value substitution in netlists, and wired schematic generation through the notebook store with generation counters to discard stale responses.
This commit is contained in:
parent
4f174e9d0f
commit
a8c53f34f4
@ -1,5 +1,6 @@
|
|||||||
"""Schematic generation endpoints."""
|
"""Schematic generation endpoints."""
|
||||||
|
|
||||||
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
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.models.notebook import CellOutput, CellType
|
||||||
from spicebook.storage.filesystem import load_notebook, save_notebook
|
from spicebook.storage.filesystem import load_notebook, save_notebook
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["schematics"])
|
router = APIRouter(prefix="/api", tags=["schematics"])
|
||||||
|
|
||||||
|
|
||||||
@ -17,6 +20,7 @@ class SchematicResponse(BaseModel):
|
|||||||
svg: str | None = None
|
svg: str | None = None
|
||||||
success: bool
|
success: bool
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
component_map: dict[str, str] | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@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")
|
raise HTTPException(status_code=400, detail="Cell source is empty")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
svg = netlist_to_svg(cell.source)
|
result = netlist_to_svg(cell.source)
|
||||||
except (ValueError, RuntimeError) as exc:
|
except (ValueError, RuntimeError) as exc:
|
||||||
return SchematicResponse(success=False, error=str(exc))
|
return SchematicResponse(success=False, error=str(exc))
|
||||||
except Exception as exc:
|
except Exception:
|
||||||
return SchematicResponse(success=False, error=f"Schematic generation failed: {exc}")
|
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)
|
# Save schematic output to cell (additive — preserve simulation outputs)
|
||||||
now_iso = datetime.now(timezone.utc).isoformat()
|
now_iso = datetime.now(timezone.utc).isoformat()
|
||||||
schematic_output = CellOutput(
|
schematic_output = CellOutput(
|
||||||
output_type="schematic",
|
output_type="schematic",
|
||||||
data={"svg": svg, "success": True},
|
data={
|
||||||
|
"svg": result.svg,
|
||||||
|
"component_map": result.component_map,
|
||||||
|
"success": True,
|
||||||
|
},
|
||||||
timestamp=now_iso,
|
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)
|
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,
|
||||||
|
)
|
||||||
|
|||||||
28
frontend/package-lock.json
generated
28
frontend/package-lock.json
generated
@ -22,6 +22,7 @@
|
|||||||
"@lezer/lr": "^1.4.0",
|
"@lezer/lr": "^1.4.0",
|
||||||
"astro": "^5.0.0",
|
"astro": "^5.0.0",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
|
"dompurify": "^3.3.1",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@ -31,6 +32,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
@ -3026,6 +3028,16 @@
|
|||||||
"@types/ms": "*"
|
"@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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@ -3083,6 +3095,13 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@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": {
|
"node_modules/@types/unist": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||||
@ -4661,6 +4680,15 @@
|
|||||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
"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": {
|
"node_modules/domutils": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||||
|
|||||||
@ -23,6 +23,7 @@
|
|||||||
"@lezer/lr": "^1.4.0",
|
"@lezer/lr": "^1.4.0",
|
||||||
"astro": "^5.0.0",
|
"astro": "^5.0.0",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
|
"dompurify": "^3.3.1",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@ -32,6 +33,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import type { Cell } from '../../../lib/types';
|
import type { Cell } from '../../../lib/types';
|
||||||
import type { WaveformData } from '../../../lib/types';
|
import type { WaveformData } from '../../../lib/types';
|
||||||
import { useNotebookStore } from '../../../lib/notebook-store';
|
import { useNotebookStore } from '../../../lib/notebook-store';
|
||||||
|
import { updateNetlistValue } from '../../../lib/netlist-utils';
|
||||||
import { CellToolbar } from '../toolbar/CellToolbar';
|
import { CellToolbar } from '../toolbar/CellToolbar';
|
||||||
import { SpiceEditor } from '../editor/SpiceEditor';
|
import { SpiceEditor } from '../editor/SpiceEditor';
|
||||||
import { WaveformViewer } from '../output/WaveformViewer';
|
import { WaveformViewer } from '../output/WaveformViewer';
|
||||||
@ -60,6 +61,31 @@ export function SpiceCell({ cell, isFirst, isLast }: SpiceCellProps) {
|
|||||||
// Extract schematic output
|
// Extract schematic output
|
||||||
const schematicOutput = cell.outputs.find((o) => o.output_type === 'schematic');
|
const schematicOutput = cell.outputs.find((o) => o.output_type === 'schematic');
|
||||||
const schematicSvg = schematicOutput?.data?.svg as string | null;
|
const schematicSvg = schematicOutput?.data?.svg as string | null;
|
||||||
|
const componentMap = schematicOutput?.data?.component_map as Record<string, string> | 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<string | null>(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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -87,7 +113,11 @@ export function SpiceCell({ cell, isFirst, isLast }: SpiceCellProps) {
|
|||||||
{/* Schematic — shown above the editor when generated */}
|
{/* Schematic — shown above the editor when generated */}
|
||||||
{schematicSvg && (
|
{schematicSvg && (
|
||||||
<div className="border-b border-slate-700/50">
|
<div className="border-b border-slate-700/50">
|
||||||
<SchematicViewer svg={schematicSvg} />
|
<SchematicViewer
|
||||||
|
svg={schematicSvg}
|
||||||
|
componentMap={componentMap}
|
||||||
|
onValueChange={handleSchematicValueChange}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -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 { 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 {
|
interface SchematicViewerProps {
|
||||||
svg: string;
|
svg: string;
|
||||||
|
componentMap?: Record<string, string>;
|
||||||
|
onValueChange?: (componentName: string, newValue: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ZOOM_STEPS = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
|
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 [zoomIndex, setZoomIndex] = useState(2); // Start at 100%
|
||||||
|
const [editState, setEditState] = useState<EditState | null>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const svgWrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const zoom = ZOOM_STEPS[zoomIndex];
|
const zoom = ZOOM_STEPS[zoomIndex];
|
||||||
|
|
||||||
@ -35,6 +57,137 @@ export function SchematicViewer({ svg }: SchematicViewerProps) {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}, [svg]);
|
}, [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<SVGTSpanElement>('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<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
commitEdit();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
cancelEdit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[commitEdit, cancelEdit],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
@ -66,6 +219,11 @@ export function SchematicViewer({ svg }: SchematicViewerProps) {
|
|||||||
<Maximize2 className="w-3.5 h-3.5" />
|
<Maximize2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
{componentMap && Object.keys(componentMap).length > 0 && (
|
||||||
|
<span className="text-[10px] text-slate-600 mr-2">
|
||||||
|
click values to edit
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={downloadSvg}
|
onClick={downloadSvg}
|
||||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 transition-colors"
|
className="flex items-center gap-1 px-2 py-1 rounded text-xs text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 transition-colors"
|
||||||
@ -79,17 +237,36 @@ export function SchematicViewer({ svg }: SchematicViewerProps) {
|
|||||||
{/* SVG Canvas */}
|
{/* SVG Canvas */}
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="overflow-auto bg-white rounded-b-lg"
|
className="overflow-auto bg-white rounded-b-lg relative"
|
||||||
style={{ maxHeight: '500px' }}
|
style={{ maxHeight: '500px' }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
ref={svgWrapperRef}
|
||||||
className="p-4"
|
className="p-4"
|
||||||
style={{
|
style={{
|
||||||
transform: `scale(${zoom})`,
|
transform: `scale(${zoom})`,
|
||||||
transformOrigin: 'top left',
|
transformOrigin: 'top left',
|
||||||
}}
|
}}
|
||||||
dangerouslySetInnerHTML={{ __html: svg }}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Inline edit overlay */}
|
||||||
|
{editState && (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
defaultValue={editState.currentValue}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBlur={commitEdit}
|
||||||
|
className="absolute z-10 px-1 py-0 text-sm font-mono bg-white border-2 border-blue-500 rounded shadow-lg outline-none text-slate-900"
|
||||||
|
style={{
|
||||||
|
top: editState.rect.top - 2,
|
||||||
|
left: editState.rect.left - 4,
|
||||||
|
minWidth: Math.max(editState.rect.width + 16, 72),
|
||||||
|
height: editState.rect.height + 6,
|
||||||
|
lineHeight: `${editState.rect.height + 2}px`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -177,6 +177,7 @@ export interface SchematicResponse {
|
|||||||
svg: string | null;
|
svg: string | null;
|
||||||
success: boolean;
|
success: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
component_map?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateSchematic(
|
export async function generateSchematic(
|
||||||
|
|||||||
126
frontend/src/lib/netlist-utils.ts
Normal file
126
frontend/src/lib/netlist-utils.ts
Normal file
@ -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- <everything else is the value spec>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -38,6 +38,7 @@ interface NotebookStore {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
|
schematicGeneration: Map<string, number>;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
loadNotebook: (id: string) => Promise<void>;
|
loadNotebook: (id: string) => Promise<void>;
|
||||||
@ -65,6 +66,7 @@ export const useNotebookStore = create<NotebookStore>((set, get) => ({
|
|||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
saving: false,
|
saving: false,
|
||||||
|
schematicGeneration: new Map(),
|
||||||
|
|
||||||
loadNotebook: async (id: string) => {
|
loadNotebook: async (id: string) => {
|
||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
@ -265,23 +267,35 @@ export const useNotebookStore = create<NotebookStore>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
generateSchematic: async (cellId: string) => {
|
generateSchematic: async (cellId: string) => {
|
||||||
const { notebook, notebookId, runningCells } = get();
|
const { notebook, notebookId, runningCells, schematicGeneration } = get();
|
||||||
if (!notebook || !notebookId) return;
|
if (!notebook || !notebookId) return;
|
||||||
|
|
||||||
const cell = notebook.cells.find((c) => c.id === cellId);
|
const cell = notebook.cells.find((c) => c.id === cellId);
|
||||||
if (!cell || cell.type !== 'spice') return;
|
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);
|
const newRunning = new Set(runningCells);
|
||||||
newRunning.add(cellId);
|
newRunning.add(cellId);
|
||||||
set({ runningCells: newRunning });
|
set({ runningCells: newRunning, schematicGeneration: newGenMap });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await api.generateSchematic(notebookId, cellId);
|
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) {
|
if (result.success && result.svg) {
|
||||||
const output: CellOutput = {
|
const output: CellOutput = {
|
||||||
output_type: 'schematic',
|
output_type: 'schematic',
|
||||||
data: { svg: result.svg, success: true },
|
data: {
|
||||||
|
svg: result.svg,
|
||||||
|
component_map: result.component_map || {},
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -305,9 +319,12 @@ export const useNotebookStore = create<NotebookStore>((set, get) => ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// Only show error if this is still the current generation
|
||||||
|
if (get().schematicGeneration.get(cellId) === gen) {
|
||||||
set({
|
set({
|
||||||
error: err instanceof Error ? err.message : 'Schematic generation failed',
|
error: err instanceof Error ? err.message : 'Schematic generation failed',
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
const currentRunning = new Set(get().runningCells);
|
const currentRunning = new Set(get().runningCells);
|
||||||
currentRunning.delete(cellId);
|
currentRunning.delete(cellId);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user