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.
This commit is contained in:
Ryan Malloy 2026-02-24 14:15:57 -07:00
parent 8c66b10448
commit 2faa581e0b
3 changed files with 143 additions and 43 deletions

View File

@ -1,6 +1,8 @@
import { useState, useRef, useCallback, useEffect } from 'react'; 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 DOMPurify from 'dompurify';
import { cn } from '../../../lib/cn';
import { useFullscreen } from '../../../lib/useFullscreen';
interface EditState { interface EditState {
component: string; component: string;
@ -29,6 +31,7 @@ function sanitizeSvg(raw: string): string {
export function SchematicViewer({ svg, componentMap, onValueChange }: SchematicViewerProps) { 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 [editState, setEditState] = useState<EditState | null>(null);
const { isFullscreen, toggleFullscreen } = useFullscreen();
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const svgWrapperRef = useRef<HTMLDivElement>(null); const svgWrapperRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@ -189,7 +192,7 @@ export function SchematicViewer({ svg, componentMap, onValueChange }: SchematicV
); );
return ( return (
<div className="relative"> <div className={cn('relative', isFullscreen && 'fixed inset-0 z-50 bg-slate-950 flex flex-col')}>
{/* Toolbar */} {/* Toolbar */}
<div className="flex items-center gap-1 px-3 py-1.5 border-b border-slate-700/50 bg-slate-800/50"> <div className="flex items-center gap-1 px-3 py-1.5 border-b border-slate-700/50 bg-slate-800/50">
<button <button
@ -200,9 +203,13 @@ export function SchematicViewer({ svg, componentMap, onValueChange }: SchematicV
> >
<ZoomOut className="w-3.5 h-3.5" /> <ZoomOut className="w-3.5 h-3.5" />
</button> </button>
<span className="text-xs text-slate-500 tabular-nums w-10 text-center"> <button
onClick={fitToWidth}
className="text-xs text-slate-500 hover:text-slate-300 tabular-nums w-10 text-center transition-colors"
title="Reset to 100%"
>
{Math.round(zoom * 100)}% {Math.round(zoom * 100)}%
</span> </button>
<button <button
onClick={zoomIn} onClick={zoomIn}
disabled={zoomIndex === ZOOM_STEPS.length - 1} disabled={zoomIndex === ZOOM_STEPS.length - 1}
@ -212,11 +219,15 @@ export function SchematicViewer({ svg, componentMap, onValueChange }: SchematicV
<ZoomIn className="w-3.5 h-3.5" /> <ZoomIn className="w-3.5 h-3.5" />
</button> </button>
<button <button
onClick={fitToWidth} onClick={toggleFullscreen}
className="p-1 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 transition-colors" className="p-1 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 transition-colors"
title="Reset zoom" title={isFullscreen ? 'Exit fullscreen (Esc)' : 'Fullscreen'}
> >
<Maximize2 className="w-3.5 h-3.5" /> {isFullscreen ? (
<Minimize2 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 && ( {componentMap && Object.keys(componentMap).length > 0 && (
@ -237,8 +248,11 @@ export function SchematicViewer({ svg, componentMap, onValueChange }: SchematicV
{/* SVG Canvas */} {/* SVG Canvas */}
<div <div
ref={containerRef} ref={containerRef}
className="overflow-auto bg-white rounded-b-lg relative" className={cn(
style={{ maxHeight: '500px' }} 'overflow-auto bg-white relative',
isFullscreen ? 'flex-1' : 'rounded-b-lg',
)}
style={isFullscreen ? undefined : { maxHeight: '500px' }}
> >
<div <div
ref={svgWrapperRef} ref={svgWrapperRef}

View File

@ -1,6 +1,9 @@
import { useRef, useEffect, useState, useCallback } from 'react'; import { useRef, useEffect, useState, useCallback } from 'react';
import { Maximize2, Minimize2 } from 'lucide-react';
import type { WaveformData } from '../../../lib/types'; import type { WaveformData } from '../../../lib/types';
import { formatAxisValue, TRACE_COLORS } from '../../../lib/waveform-utils'; import { formatAxisValue, TRACE_COLORS } from '../../../lib/waveform-utils';
import { cn } from '../../../lib/cn';
import { useFullscreen } from '../../../lib/useFullscreen';
import { ScopeWaveformViewer } from './ScopeWaveformViewer'; import { ScopeWaveformViewer } from './ScopeWaveformViewer';
// uPlot is a vanilla JS lib; import it and its CSS // uPlot is a vanilla JS lib; import it and its CSS
@ -79,9 +82,11 @@ function buildOpts(
function TransientPlot({ function TransientPlot({
waveform, waveform,
width, width,
height,
}: { }: {
waveform: WaveformData; waveform: WaveformData;
width: number; width: number;
height?: number;
}) { }) {
const plotRef = useRef<HTMLDivElement>(null); const plotRef = useRef<HTMLDivElement>(null);
const uplotRef = useRef<uPlot | null>(null); const uplotRef = useRef<uPlot | null>(null);
@ -102,7 +107,7 @@ function TransientPlot({
// Determine y-axis type from variable types // Determine y-axis type from variable types
const yType = const yType =
waveform.variables.length > 1 ? waveform.variables[1].type : 'voltage'; 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); const u = new uPlot(opts, data, plotRef.current);
uplotRef.current = u; uplotRef.current = u;
@ -111,7 +116,7 @@ function TransientPlot({
u.destroy(); u.destroy();
uplotRef.current = null; uplotRef.current = null;
}; };
}, [waveform, width]); }, [waveform, width, height]);
return <div ref={plotRef} />; return <div ref={plotRef} />;
} }
@ -119,9 +124,11 @@ function TransientPlot({
function ACPlot({ function ACPlot({
waveform, waveform,
width, width,
height,
}: { }: {
waveform: WaveformData; waveform: WaveformData;
width: number; width: number;
height?: number;
}) { }) {
const magRef = useRef<HTMLDivElement>(null); const magRef = useRef<HTMLDivElement>(null);
const phaseRef = useRef<HTMLDivElement>(null); const phaseRef = useRef<HTMLDivElement>(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 = [ magOpts.series = [
{ label: 'Frequency' }, { label: 'Frequency' },
...magTraces.map((name, i) => ({ ...magTraces.map((name, i) => ({
@ -171,10 +179,11 @@ function ACPlot({
), ),
]; ];
const phaseH = height ? height - Math.round(height * 0.55) : 180;
const phaseOpts = buildOpts( const phaseOpts = buildOpts(
waveform, waveform,
width, width,
180, phaseH,
'frequency', 'frequency',
'degrees', 'degrees',
); );
@ -198,7 +207,7 @@ function ACPlot({
magPlotRef.current = null; magPlotRef.current = null;
phasePlotRef.current = null; phasePlotRef.current = null;
}; };
}, [waveform, width]); }, [waveform, width, height]);
return ( return (
<div className="space-y-2"> <div className="space-y-2">
@ -236,25 +245,28 @@ function ScopeToggleIcon({ active }: { active: boolean }) {
export function WaveformViewer({ waveform, className }: WaveformViewerProps) { export function WaveformViewer({ waveform, className }: WaveformViewerProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0); const [width, setWidth] = useState(0);
const [height, setHeight] = useState(0);
const { isFullscreen, toggleFullscreen } = useFullscreen();
const [scopeMode, setScopeMode] = useState(() => { const [scopeMode, setScopeMode] = useState(() => {
try { return localStorage.getItem('spicebook-scope-mode') === 'true'; } try { return localStorage.getItem('spicebook-scope-mode') === 'true'; }
catch { return false; } catch { return false; }
}); });
const updateWidth = useCallback(() => { const updateDimensions = useCallback(() => {
if (containerRef.current) { if (containerRef.current) {
setWidth(containerRef.current.clientWidth); setWidth(containerRef.current.clientWidth);
setHeight(containerRef.current.clientHeight);
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
updateWidth(); updateDimensions();
const observer = new ResizeObserver(updateWidth); const observer = new ResizeObserver(updateDimensions);
if (containerRef.current) { if (containerRef.current) {
observer.observe(containerRef.current); observer.observe(containerRef.current);
} }
return () => observer.disconnect(); return () => observer.disconnect();
}, [updateWidth]); }, [updateDimensions]);
const toggleScope = useCallback(() => { const toggleScope = useCallback(() => {
setScopeMode(prev => { setScopeMode(prev => {
@ -268,36 +280,71 @@ export function WaveformViewer({ waveform, className }: WaveformViewerProps) {
const isEmpty = const isEmpty =
traceCount === 0 && !waveform.y_magnitude_db && !waveform.y_phase_deg; 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 ( return (
<div ref={containerRef} className={`relative ${className || ''}`}> <div
{/* Scope toggle button */} ref={containerRef}
className={cn(
'relative',
className,
isFullscreen && 'fixed inset-0 z-50 bg-slate-900 flex flex-col',
)}
>
{/* Control buttons */}
{!isEmpty && ( {!isEmpty && (
<button <div
type="button" className={cn(
onClick={toggleScope} 'flex items-center gap-1 z-10',
className="absolute top-1 right-1 z-10 p-1 rounded hover:bg-slate-700/50 transition-colors" isFullscreen
aria-label={scopeMode ? 'Exit oscilloscope view' : 'Switch to oscilloscope view'} ? 'px-4 py-2 justify-end border-b border-slate-700/50'
title={scopeMode ? 'Standard view' : 'Oscilloscope view'} : 'absolute top-1 right-1',
)}
> >
<ScopeToggleIcon active={scopeMode} /> <button
</button> type="button"
onClick={toggleFullscreen}
className="p-1 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 transition-colors"
title={isFullscreen ? 'Exit fullscreen (Esc)' : 'Fullscreen'}
>
{isFullscreen ? (
<Minimize2 className="w-[18px] h-[18px]" />
) : (
<Maximize2 className="w-[18px] h-[18px]" />
)}
</button>
<button
type="button"
onClick={toggleScope}
className="p-1 rounded hover:bg-slate-700/50 transition-colors"
aria-label={scopeMode ? 'Exit oscilloscope view' : 'Switch to oscilloscope view'}
title={scopeMode ? 'Standard view' : 'Oscilloscope view'}
>
<ScopeToggleIcon active={scopeMode} />
</button>
</div>
)} )}
{isEmpty ? ( {/* Plot area */}
<div className="text-slate-500 text-sm py-4 text-center"> <div className={cn(isFullscreen && 'flex-1 min-h-0 px-4 pb-4')}>
No waveform data to display. {isEmpty ? (
</div> <div className="text-slate-500 text-sm py-4 text-center">
) : scopeMode ? ( No waveform data to display.
<ScopeWaveformViewer </div>
waveform={waveform} ) : scopeMode ? (
width={width} <ScopeWaveformViewer
onExitScope={toggleScope} waveform={waveform}
/> width={plotWidth}
) : waveform.is_complex ? ( onExitScope={toggleScope}
<ACPlot waveform={waveform} width={width} /> />
) : ( ) : waveform.is_complex ? (
<TransientPlot waveform={waveform} width={width} /> <ACPlot waveform={waveform} width={plotWidth} height={plotHeight} />
)} ) : (
<TransientPlot waveform={waveform} width={plotWidth} height={plotHeight} />
)}
</div>
</div> </div>
); );
} }

View File

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