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.
274 lines
8.6 KiB
TypeScript
274 lines
8.6 KiB
TypeScript
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<string, string>;
|
|
onValueChange?: (componentName: string, newValue: string) => void;
|
|
}
|
|
|
|
const ZOOM_STEPS = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
|
|
|
|
// 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<EditState | null>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const svgWrapperRef = useRef<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const zoom = ZOOM_STEPS[zoomIndex];
|
|
|
|
const zoomIn = useCallback(() => {
|
|
setZoomIndex((i) => Math.min(i + 1, ZOOM_STEPS.length - 1));
|
|
}, []);
|
|
|
|
const zoomOut = useCallback(() => {
|
|
setZoomIndex((i) => Math.max(i - 1, 0));
|
|
}, []);
|
|
|
|
const fitToWidth = useCallback(() => {
|
|
setZoomIndex(2); // Reset to 100%
|
|
}, []);
|
|
|
|
const downloadSvg = useCallback(() => {
|
|
const blob = new Blob([svg], { type: 'image/svg+xml' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'schematic.svg';
|
|
a.click();
|
|
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<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 (
|
|
<div className="relative">
|
|
{/* Toolbar */}
|
|
<div className="flex items-center gap-1 px-3 py-1.5 border-b border-slate-700/50 bg-slate-800/50">
|
|
<button
|
|
onClick={zoomOut}
|
|
disabled={zoomIndex === 0}
|
|
className="p-1 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
|
title="Zoom out"
|
|
>
|
|
<ZoomOut className="w-3.5 h-3.5" />
|
|
</button>
|
|
<span className="text-xs text-slate-500 tabular-nums w-10 text-center">
|
|
{Math.round(zoom * 100)}%
|
|
</span>
|
|
<button
|
|
onClick={zoomIn}
|
|
disabled={zoomIndex === ZOOM_STEPS.length - 1}
|
|
className="p-1 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
|
title="Zoom in"
|
|
>
|
|
<ZoomIn className="w-3.5 h-3.5" />
|
|
</button>
|
|
<button
|
|
onClick={fitToWidth}
|
|
className="p-1 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 transition-colors"
|
|
title="Reset zoom"
|
|
>
|
|
<Maximize2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
<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
|
|
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"
|
|
title="Download SVG"
|
|
>
|
|
<Download className="w-3.5 h-3.5" />
|
|
<span>SVG</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* SVG Canvas */}
|
|
<div
|
|
ref={containerRef}
|
|
className="overflow-auto bg-white rounded-b-lg relative"
|
|
style={{ maxHeight: '500px' }}
|
|
>
|
|
<div
|
|
ref={svgWrapperRef}
|
|
className="p-4"
|
|
style={{
|
|
transform: `scale(${zoom})`,
|
|
transformOrigin: 'top left',
|
|
}}
|
|
/>
|
|
|
|
{/* 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>
|
|
);
|
|
}
|