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]; // 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]; 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('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 */}
{Math.round(zoom * 100)}%
{componentMap && Object.keys(componentMap).length > 0 && ( click values to edit )}
{/* SVG Canvas */}
{/* Inline edit overlay */} {editState && ( )}
); }