feat: implement pan and zoom functionality in simulator canvas

pull/10/head
David Montero Crespo 2026-03-07 02:00:56 -03:00
parent 88c5f3b19f
commit 3ace72d0f8
2 changed files with 214 additions and 57 deletions

View File

@ -150,16 +150,27 @@
cursor: not-allowed; cursor: not-allowed;
} }
/* ── Canvas content ──────────────────────────────── */ /* ── Canvas content (viewport) ───────────────────── */
.canvas-content { .canvas-content {
flex: 1; flex: 1;
padding: 20px;
position: relative; position: relative;
overflow: auto; overflow: hidden;
background-color: #1a1a1a; background-color: #1a1a1a;
background-image: background-image:
repeating-linear-gradient(0deg, transparent, transparent 19px, rgba(255,255,255,0.025) 19px, rgba(255,255,255,0.025) 20px), repeating-linear-gradient(0deg, transparent, transparent 19px, rgba(255,255,255,0.025) 19px, rgba(255,255,255,0.025) 20px),
repeating-linear-gradient(90deg, transparent, transparent 19px, rgba(255,255,255,0.025) 19px, rgba(255,255,255,0.025) 20px); repeating-linear-gradient(90deg, transparent, transparent 19px, rgba(255,255,255,0.025) 19px, rgba(255,255,255,0.025) 20px);
user-select: none;
}
/* ── Infinite canvas world ───────────────────────── */
.canvas-world {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 0;
/* min size so content is reachable even when empty */
width: 4000px;
height: 3000px;
} }
/* ── Components area ─────────────────────────────── */ /* ── Components area ─────────────────────────────── */
@ -171,6 +182,55 @@
bottom: 0; bottom: 0;
} }
/* ── Zoom controls ───────────────────────────────── */
.zoom-controls {
display: flex;
align-items: center;
gap: 2px;
background: #1e1e1e;
border: 1px solid #3a3a3a;
border-radius: 5px;
padding: 0 2px;
height: 28px;
}
.zoom-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: transparent;
border: none;
border-radius: 3px;
color: #9d9d9d;
cursor: pointer;
flex-shrink: 0;
}
.zoom-btn:hover {
background: #3a3a3a;
color: #ccc;
}
.zoom-level {
min-width: 42px;
text-align: center;
background: transparent;
border: none;
color: #9d9d9d;
font-size: 11px;
font-family: monospace;
cursor: pointer;
padding: 0 2px;
border-radius: 3px;
}
.zoom-level:hover {
background: #3a3a3a;
color: #ccc;
}
.component-label { .component-label {
font-size: 11px; font-size: 11px;
background-color: #252526; background-color: #252526;

View File

@ -1,6 +1,6 @@
import { useSimulatorStore, ARDUINO_POSITION, BOARD_LABELS } from '../../store/useSimulatorStore'; import { useSimulatorStore, ARDUINO_POSITION, BOARD_LABELS } from '../../store/useSimulatorStore';
import type { BoardType } from '../../store/useSimulatorStore'; import type { BoardType } from '../../store/useSimulatorStore';
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState, useRef, useCallback } from 'react';
import { ArduinoUno } from '../components-wokwi/ArduinoUno'; import { ArduinoUno } from '../components-wokwi/ArduinoUno';
import { NanoRP2040 } from '../components-wokwi/NanoRP2040'; import { NanoRP2040 } from '../components-wokwi/NanoRP2040';
import { ComponentPickerModal } from '../ComponentPickerModal'; import { ComponentPickerModal } from '../ComponentPickerModal';
@ -72,6 +72,25 @@ export const SimulatorCanvas = () => {
// Canvas ref for coordinate calculations // Canvas ref for coordinate calculations
const canvasRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLDivElement>(null);
// Pan & zoom state
const [pan, setPan] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
// Use refs during active pan to avoid setState lag
const isPanningRef = useRef(false);
const panStartRef = useRef({ mouseX: 0, mouseY: 0, panX: 0, panY: 0 });
const panRef = useRef({ x: 0, y: 0 });
const zoomRef = useRef(1);
// Convert viewport coords to world (canvas) coords
const toWorld = useCallback((screenX: number, screenY: number) => {
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return { x: screenX, y: screenY };
return {
x: (screenX - rect.left - panRef.current.x) / zoomRef.current,
y: (screenY - rect.top - panRef.current.y) / zoomRef.current,
};
}, []);
// Initialize simulator on mount // Initialize simulator on mount
useEffect(() => { useEffect(() => {
initSimulator(); initSimulator();
@ -208,63 +227,73 @@ export const SimulatorCanvas = () => {
// Component dragging handlers // Component dragging handlers
const handleComponentMouseDown = (componentId: string, e: React.MouseEvent) => { const handleComponentMouseDown = (componentId: string, e: React.MouseEvent) => {
// Don't start dragging if we're clicking on the pin selector or property dialog
if (showPinSelector || showPropertyDialog) return; if (showPinSelector || showPropertyDialog) return;
e.stopPropagation(); e.stopPropagation();
const component = components.find((c) => c.id === componentId); const component = components.find((c) => c.id === componentId);
if (!component || !canvasRef.current) return; if (!component) return;
// Record click start for click vs drag detection
setClickStartTime(Date.now()); setClickStartTime(Date.now());
setClickStartPos({ x: e.clientX, y: e.clientY }); setClickStartPos({ x: e.clientX, y: e.clientY });
// Get canvas position to convert viewport coords to canvas coords const world = toWorld(e.clientX, e.clientY);
const canvasRect = canvasRef.current.getBoundingClientRect();
// Calculate offset in canvas coordinate system
setDraggedComponentId(componentId); setDraggedComponentId(componentId);
setDragOffset({ setDragOffset({
x: (e.clientX - canvasRect.left) - component.x, x: world.x - component.x,
y: (e.clientY - canvasRect.top) - component.y, y: world.y - component.y,
}); });
setSelectedComponentId(componentId); setSelectedComponentId(componentId);
}; };
const handleCanvasMouseMove = (e: React.MouseEvent) => { const handleCanvasMouseMove = (e: React.MouseEvent) => {
if (!canvasRef.current) return; // Handle active panning (ref-based, no setState lag)
if (isPanningRef.current) {
const dx = e.clientX - panStartRef.current.mouseX;
const dy = e.clientY - panStartRef.current.mouseY;
const newPan = {
x: panStartRef.current.panX + dx,
y: panStartRef.current.panY + dy,
};
panRef.current = newPan;
// Update the transform directly for zero-lag panning
const world = canvasRef.current?.querySelector('.canvas-world') as HTMLElement | null;
if (world) {
world.style.transform = `translate(${newPan.x}px, ${newPan.y}px) scale(${zoomRef.current})`;
}
return;
}
// Handle component dragging // Handle component dragging
if (draggedComponentId) { if (draggedComponentId) {
const canvasRect = canvasRef.current.getBoundingClientRect(); const world = toWorld(e.clientX, e.clientY);
const newX = e.clientX - canvasRect.left - dragOffset.x;
const newY = e.clientY - canvasRect.top - dragOffset.y;
updateComponent(draggedComponentId, { updateComponent(draggedComponentId, {
x: Math.max(0, newX), x: Math.max(0, world.x - dragOffset.x),
y: Math.max(0, newY), y: Math.max(0, world.y - dragOffset.y),
} as any); } as any);
} }
// Handle wire creation preview // Handle wire creation preview
if (wireInProgress && canvasRef.current) { if (wireInProgress) {
const canvasRect = canvasRef.current.getBoundingClientRect(); const world = toWorld(e.clientX, e.clientY);
const currentX = e.clientX - canvasRect.left; updateWireInProgress(world.x, world.y);
const currentY = e.clientY - canvasRect.top;
updateWireInProgress(currentX, currentY);
} }
}; };
const handleCanvasMouseUp = (e: React.MouseEvent) => { const handleCanvasMouseUp = (e: React.MouseEvent) => {
// Finish panning — commit ref value to state so React knows the final pan
if (isPanningRef.current) {
isPanningRef.current = false;
setPan({ ...panRef.current });
return;
}
if (draggedComponentId) { if (draggedComponentId) {
// Check if this was a click or a drag
const timeDiff = Date.now() - clickStartTime; const timeDiff = Date.now() - clickStartTime;
const posDiff = Math.sqrt( const posDiff = Math.sqrt(
Math.pow(e.clientX - clickStartPos.x, 2) + Math.pow(e.clientX - clickStartPos.x, 2) +
Math.pow(e.clientY - clickStartPos.y, 2) Math.pow(e.clientY - clickStartPos.y, 2)
); );
// If moved < 5px and time < 300ms, treat as click
if (posDiff < 5 && timeDiff < 300) { if (posDiff < 5 && timeDiff < 300) {
const component = components.find((c) => c.id === draggedComponentId); const component = components.find((c) => c.id === draggedComponentId);
if (component) { if (component) {
@ -274,12 +303,57 @@ export const SimulatorCanvas = () => {
} }
} }
// Recalculate wire positions after moving component
recalculateAllWirePositions(); recalculateAllWirePositions();
setDraggedComponentId(null); setDraggedComponentId(null);
} }
}; };
// Start panning on middle-click or right-click
const handleCanvasMouseDown = (e: React.MouseEvent) => {
if (e.button === 1 || e.button === 2) {
e.preventDefault();
isPanningRef.current = true;
panStartRef.current = {
mouseX: e.clientX,
mouseY: e.clientY,
panX: panRef.current.x,
panY: panRef.current.y,
};
}
};
// Zoom centered on cursor
const handleWheel = (e: React.WheelEvent) => {
e.preventDefault();
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const factor = e.deltaY < 0 ? 1.1 : 0.9;
const newZoom = Math.min(5, Math.max(0.1, zoomRef.current * factor));
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
// Keep the world point under the cursor fixed
const worldX = (mx - panRef.current.x) / zoomRef.current;
const worldY = (my - panRef.current.y) / zoomRef.current;
const newPan = {
x: mx - worldX * newZoom,
y: my - worldY * newZoom,
};
zoomRef.current = newZoom;
panRef.current = newPan;
setZoom(newZoom);
setPan(newPan);
};
const handleResetView = () => {
zoomRef.current = 1;
panRef.current = { x: 0, y: 0 };
setZoom(1);
setPan({ x: 0, y: 0 });
};
// Wire creation via pin clicks // Wire creation via pin clicks
const handlePinClick = (componentId: string, pinName: string, x: number, y: number) => { const handlePinClick = (componentId: string, pinName: string, x: number, y: number) => {
// Close property dialog when starting wire creation // Close property dialog when starting wire creation
@ -418,6 +492,19 @@ export const SimulatorCanvas = () => {
</div> </div>
<div className="canvas-header-right"> <div className="canvas-header-right">
{/* Zoom controls */}
<div className="zoom-controls">
<button className="zoom-btn" onClick={() => handleWheel({ deltaY: 100, clientX: 0, clientY: 0, preventDefault: () => {} } as any)} title="Zoom out">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><line x1="5" y1="12" x2="19" y2="12" /></svg>
</button>
<button className="zoom-level" onClick={handleResetView} title="Reset view (click to reset)">
{Math.round(zoom * 100)}%
</button>
<button className="zoom-btn" onClick={() => handleWheel({ deltaY: -100, clientX: 0, clientY: 0, preventDefault: () => {} } as any)} title="Zoom in">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg>
</button>
</div>
{/* Component count */} {/* Component count */}
<span className="component-count" title={`${components.length} component${components.length !== 1 ? 's' : ''}`}> <span className="component-count" title={`${components.length} component${components.length !== 1 ? 's' : ''}`}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@ -445,42 +532,52 @@ export const SimulatorCanvas = () => {
<div <div
ref={canvasRef} ref={canvasRef}
className="canvas-content" className="canvas-content"
onMouseDown={handleCanvasMouseDown}
onMouseMove={handleCanvasMouseMove} onMouseMove={handleCanvasMouseMove}
onMouseUp={handleCanvasMouseUp} onMouseUp={handleCanvasMouseUp}
onMouseLeave={() => { isPanningRef.current = false; setPan({ ...panRef.current }); setDraggedComponentId(null); }}
onWheel={handleWheel}
onContextMenu={(e) => e.preventDefault()}
onClick={() => setSelectedComponentId(null)} onClick={() => setSelectedComponentId(null)}
style={{ cursor: wireInProgress ? 'crosshair' : 'default' }} style={{ cursor: isPanningRef.current ? 'grabbing' : wireInProgress ? 'crosshair' : 'default' }}
> >
{/* Wire Layer - Renders below all components */} {/* Infinite world — pan+zoom applied here */}
<WireLayer /> <div
className="canvas-world"
style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})` }}
>
{/* Wire Layer - Renders below all components */}
<WireLayer />
{/* Board visual — switches based on selected board type */} {/* Board visual — switches based on selected board type */}
{boardType === 'arduino-uno' ? ( {boardType === 'arduino-uno' ? (
<ArduinoUno <ArduinoUno
x={ARDUINO_POSITION.x} x={ARDUINO_POSITION.x}
y={ARDUINO_POSITION.y} y={ARDUINO_POSITION.y}
led13={Boolean(components.find((c) => c.id === 'led-builtin')?.properties.state)} led13={Boolean(components.find((c) => c.id === 'led-builtin')?.properties.state)}
/>
) : (
<NanoRP2040
x={ARDUINO_POSITION.x}
y={ARDUINO_POSITION.y}
ledBuiltIn={Boolean(components.find((c) => c.id === 'led-builtin')?.properties.state)}
/>
)}
{/* Board pin overlay */}
<PinOverlay
componentId={boardType === 'arduino-uno' ? 'arduino-uno' : 'nano-rp2040'}
componentX={ARDUINO_POSITION.x}
componentY={ARDUINO_POSITION.y}
onPinClick={handlePinClick}
showPins={true}
wrapperOffsetX={0}
wrapperOffsetY={0}
/> />
) : (
<NanoRP2040
x={ARDUINO_POSITION.x}
y={ARDUINO_POSITION.y}
ledBuiltIn={Boolean(components.find((c) => c.id === 'led-builtin')?.properties.state)}
/>
)}
{/* Board pin overlay */} {/* Components using wokwi-elements */}
<PinOverlay <div className="components-area">{registryLoaded && components.map(renderComponent)}</div>
componentId={boardType === 'arduino-uno' ? 'arduino-uno' : 'nano-rp2040'} </div>
componentX={ARDUINO_POSITION.x}
componentY={ARDUINO_POSITION.y}
onPinClick={handlePinClick}
showPins={true}
wrapperOffsetX={0}
wrapperOffsetY={0}
/>
{/* Components using wokwi-elements */}
<div className="components-area">{registryLoaded && components.map(renderComponent)}</div>
</div> </div>
</div> </div>