From 2faa581e0be952e5f8f5558e468b8d8b362c7e19 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Tue, 24 Feb 2026 14:15:57 -0700 Subject: [PATCH] Add fullscreen expansion to schematic and waveform viewers Repurpose the Maximize2 button in SchematicViewer as a fullscreen toggle (was reset-zoom; percentage label is now clickable for that). Add matching fullscreen toggle to WaveformViewer alongside the scope button. Both use a shared useFullscreen hook for Escape-to-exit and body scroll lock. In fullscreen mode, the SVG canvas and plot areas fill the viewport with proper dimension tracking via ResizeObserver. --- .../notebook/output/SchematicViewer.tsx | 32 +++-- .../notebook/output/WaveformViewer.tsx | 115 ++++++++++++------ frontend/src/lib/useFullscreen.ts | 39 ++++++ 3 files changed, 143 insertions(+), 43 deletions(-) create mode 100644 frontend/src/lib/useFullscreen.ts diff --git a/frontend/src/components/notebook/output/SchematicViewer.tsx b/frontend/src/components/notebook/output/SchematicViewer.tsx index a22f183..3b60ece 100644 --- a/frontend/src/components/notebook/output/SchematicViewer.tsx +++ b/frontend/src/components/notebook/output/SchematicViewer.tsx @@ -1,6 +1,8 @@ import { useState, useRef, useCallback, useEffect } from 'react'; -import { ZoomIn, ZoomOut, Maximize2, Download } from 'lucide-react'; +import { ZoomIn, ZoomOut, Maximize2, Minimize2, Download } from 'lucide-react'; import DOMPurify from 'dompurify'; +import { cn } from '../../../lib/cn'; +import { useFullscreen } from '../../../lib/useFullscreen'; interface EditState { component: string; @@ -29,6 +31,7 @@ function sanitizeSvg(raw: string): string { export function SchematicViewer({ svg, componentMap, onValueChange }: SchematicViewerProps) { const [zoomIndex, setZoomIndex] = useState(2); // Start at 100% const [editState, setEditState] = useState(null); + const { isFullscreen, toggleFullscreen } = useFullscreen(); const containerRef = useRef(null); const svgWrapperRef = useRef(null); const inputRef = useRef(null); @@ -189,7 +192,7 @@ export function SchematicViewer({ svg, componentMap, onValueChange }: SchematicV ); return ( -
+
{/* Toolbar */}
- +
{componentMap && Object.keys(componentMap).length > 0 && ( @@ -237,8 +248,11 @@ export function SchematicViewer({ svg, componentMap, onValueChange }: SchematicV {/* SVG Canvas */}
(null); const uplotRef = useRef(null); @@ -102,7 +107,7 @@ function TransientPlot({ // Determine y-axis type from variable types const yType = waveform.variables.length > 1 ? waveform.variables[1].type : 'voltage'; - const opts = buildOpts(waveform, width, 280, waveform.x_type, yType); + const opts = buildOpts(waveform, width, height ?? 280, waveform.x_type, yType); const u = new uPlot(opts, data, plotRef.current); uplotRef.current = u; @@ -111,7 +116,7 @@ function TransientPlot({ u.destroy(); uplotRef.current = null; }; - }, [waveform, width]); + }, [waveform, width, height]); return
; } @@ -119,9 +124,11 @@ function TransientPlot({ function ACPlot({ waveform, width, + height, }: { waveform: WaveformData; width: number; + height?: number; }) { const magRef = useRef(null); const phaseRef = useRef(null); @@ -147,7 +154,8 @@ function ACPlot({ ), ]; - const magOpts = buildOpts(waveform, width, 220, 'frequency', 'dB'); + const magH = height ? Math.round(height * 0.55) : 220; + const magOpts = buildOpts(waveform, width, magH, 'frequency', 'dB'); magOpts.series = [ { label: 'Frequency' }, ...magTraces.map((name, i) => ({ @@ -171,10 +179,11 @@ function ACPlot({ ), ]; + const phaseH = height ? height - Math.round(height * 0.55) : 180; const phaseOpts = buildOpts( waveform, width, - 180, + phaseH, 'frequency', 'degrees', ); @@ -198,7 +207,7 @@ function ACPlot({ magPlotRef.current = null; phasePlotRef.current = null; }; - }, [waveform, width]); + }, [waveform, width, height]); return (
@@ -236,25 +245,28 @@ function ScopeToggleIcon({ active }: { active: boolean }) { export function WaveformViewer({ waveform, className }: WaveformViewerProps) { const containerRef = useRef(null); const [width, setWidth] = useState(0); + const [height, setHeight] = useState(0); + const { isFullscreen, toggleFullscreen } = useFullscreen(); const [scopeMode, setScopeMode] = useState(() => { try { return localStorage.getItem('spicebook-scope-mode') === 'true'; } catch { return false; } }); - const updateWidth = useCallback(() => { + const updateDimensions = useCallback(() => { if (containerRef.current) { setWidth(containerRef.current.clientWidth); + setHeight(containerRef.current.clientHeight); } }, []); useEffect(() => { - updateWidth(); - const observer = new ResizeObserver(updateWidth); + updateDimensions(); + const observer = new ResizeObserver(updateDimensions); if (containerRef.current) { observer.observe(containerRef.current); } return () => observer.disconnect(); - }, [updateWidth]); + }, [updateDimensions]); const toggleScope = useCallback(() => { setScopeMode(prev => { @@ -268,36 +280,71 @@ export function WaveformViewer({ waveform, className }: WaveformViewerProps) { const isEmpty = traceCount === 0 && !waveform.y_magnitude_db && !waveform.y_phase_deg; + // In fullscreen, subtract toolbar + padding from available height + const plotWidth = isFullscreen ? Math.max(width - 32, 100) : width; + const plotHeight = isFullscreen ? Math.max(height - 80, 200) : undefined; + return ( -
- {/* Scope toggle button */} +
+ {/* Control buttons */} {!isEmpty && ( - + + +
)} - {isEmpty ? ( -
- No waveform data to display. -
- ) : scopeMode ? ( - - ) : waveform.is_complex ? ( - - ) : ( - - )} + {/* Plot area */} +
+ {isEmpty ? ( +
+ No waveform data to display. +
+ ) : scopeMode ? ( + + ) : waveform.is_complex ? ( + + ) : ( + + )} +
); } diff --git a/frontend/src/lib/useFullscreen.ts b/frontend/src/lib/useFullscreen.ts new file mode 100644 index 0000000..f3297da --- /dev/null +++ b/frontend/src/lib/useFullscreen.ts @@ -0,0 +1,39 @@ +import { useState, useCallback, useEffect } from 'react'; + +/** + * Manages fullscreen overlay state with escape-to-exit and body scroll lock. + */ +export function useFullscreen() { + const [isFullscreen, setIsFullscreen] = useState(false); + + const toggleFullscreen = useCallback(() => { + setIsFullscreen((prev) => !prev); + }, []); + + // Escape key exits fullscreen (capture phase so it fires before other handlers) + useEffect(() => { + if (!isFullscreen) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setIsFullscreen(false); + e.stopPropagation(); + } + }; + document.addEventListener('keydown', handler, true); + return () => document.removeEventListener('keydown', handler, true); + }, [isFullscreen]); + + // Prevent background scrolling + useEffect(() => { + if (isFullscreen) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + return () => { + document.body.style.overflow = ''; + }; + }, [isFullscreen]); + + return { isFullscreen, toggleFullscreen }; +}