feat: implement pan and zoom functionality in simulator canvas
parent
88c5f3b19f
commit
3ace72d0f8
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue