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:
parent
8c66b10448
commit
2faa581e0b
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
39
frontend/src/lib/useFullscreen.ts
Normal file
39
frontend/src/lib/useFullscreen.ts
Normal 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 };
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user