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