Ryan Malloy a8c53f34f4 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.
2026-02-13 15:46:14 -07:00

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>
);
}