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 { 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<EditState | null>(null);
const { isFullscreen, toggleFullscreen } = useFullscreen();
const containerRef = useRef<HTMLDivElement>(null);
const svgWrapperRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
@ -189,7 +192,7 @@ export function SchematicViewer({ svg, componentMap, onValueChange }: SchematicV
);
return (
<div className="relative">
<div className={cn('relative', isFullscreen && 'fixed inset-0 z-50 bg-slate-950 flex flex-col')}>
{/* Toolbar */}
<div className="flex items-center gap-1 px-3 py-1.5 border-b border-slate-700/50 bg-slate-800/50">
<button
@ -200,9 +203,13 @@ export function SchematicViewer({ svg, componentMap, onValueChange }: SchematicV
>
<ZoomOut className="w-3.5 h-3.5" />
</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)}%
</span>
</button>
<button
onClick={zoomIn}
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" />
</button>
<button
onClick={fitToWidth}
onClick={toggleFullscreen}
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>
<div className="flex-1" />
{componentMap && Object.keys(componentMap).length > 0 && (
@ -237,8 +248,11 @@ export function SchematicViewer({ svg, componentMap, onValueChange }: SchematicV
{/* SVG Canvas */}
<div
ref={containerRef}
className="overflow-auto bg-white rounded-b-lg relative"
style={{ maxHeight: '500px' }}
className={cn(
'overflow-auto bg-white relative',
isFullscreen ? 'flex-1' : 'rounded-b-lg',
)}
style={isFullscreen ? undefined : { maxHeight: '500px' }}
>
<div
ref={svgWrapperRef}

View File

@ -1,6 +1,9 @@
import { useRef, useEffect, useState, useCallback } from 'react';
import { Maximize2, Minimize2 } from 'lucide-react';
import type { WaveformData } from '../../../lib/types';
import { formatAxisValue, TRACE_COLORS } from '../../../lib/waveform-utils';
import { cn } from '../../../lib/cn';
import { useFullscreen } from '../../../lib/useFullscreen';
import { ScopeWaveformViewer } from './ScopeWaveformViewer';
// uPlot is a vanilla JS lib; import it and its CSS
@ -79,9 +82,11 @@ function buildOpts(
function TransientPlot({
waveform,
width,
height,
}: {
waveform: WaveformData;
width: number;
height?: number;
}) {
const plotRef = useRef<HTMLDivElement>(null);
const uplotRef = useRef<uPlot | null>(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 <div ref={plotRef} />;
}
@ -119,9 +124,11 @@ function TransientPlot({
function ACPlot({
waveform,
width,
height,
}: {
waveform: WaveformData;
width: number;
height?: number;
}) {
const magRef = 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 = [
{ 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 (
<div className="space-y-2">
@ -236,25 +245,28 @@ function ScopeToggleIcon({ active }: { active: boolean }) {
export function WaveformViewer({ waveform, className }: WaveformViewerProps) {
const containerRef = useRef<HTMLDivElement>(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 (
<div ref={containerRef} className={`relative ${className || ''}`}>
{/* Scope toggle button */}
<div
ref={containerRef}
className={cn(
'relative',
className,
isFullscreen && 'fixed inset-0 z-50 bg-slate-900 flex flex-col',
)}
>
{/* Control buttons */}
{!isEmpty && (
<button
type="button"
onClick={toggleScope}
className="absolute top-1 right-1 z-10 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'}
<div
className={cn(
'flex items-center gap-1 z-10',
isFullscreen
? 'px-4 py-2 justify-end border-b border-slate-700/50'
: '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 ? (
<div className="text-slate-500 text-sm py-4 text-center">
No waveform data to display.
</div>
) : scopeMode ? (
<ScopeWaveformViewer
waveform={waveform}
width={width}
onExitScope={toggleScope}
/>
) : waveform.is_complex ? (
<ACPlot waveform={waveform} width={width} />
) : (
<TransientPlot waveform={waveform} width={width} />
)}
{/* Plot area */}
<div className={cn(isFullscreen && 'flex-1 min-h-0 px-4 pb-4')}>
{isEmpty ? (
<div className="text-slate-500 text-sm py-4 text-center">
No waveform data to display.
</div>
) : scopeMode ? (
<ScopeWaveformViewer
waveform={waveform}
width={plotWidth}
onExitScope={toggleScope}
/>
) : waveform.is_complex ? (
<ACPlot waveform={waveform} width={plotWidth} height={plotHeight} />
) : (
<TransientPlot waveform={waveform} width={plotWidth} height={plotHeight} />
)}
</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 };
}