feat: implement segment dragging functionality in simulator canvas
parent
405ff1f017
commit
fe89d06787
|
|
@ -6,6 +6,7 @@ import { DynamicComponent, createComponentFromMetadata } from '../DynamicCompone
|
||||||
import { ComponentRegistry } from '../../services/ComponentRegistry';
|
import { ComponentRegistry } from '../../services/ComponentRegistry';
|
||||||
import { PinSelector } from './PinSelector';
|
import { PinSelector } from './PinSelector';
|
||||||
import { WireLayer } from './WireLayer';
|
import { WireLayer } from './WireLayer';
|
||||||
|
import type { SegmentHandle } from './WireLayer';
|
||||||
import { BoardOnCanvas } from './BoardOnCanvas';
|
import { BoardOnCanvas } from './BoardOnCanvas';
|
||||||
import { BoardPickerModal } from './BoardPickerModal';
|
import { BoardPickerModal } from './BoardPickerModal';
|
||||||
import { PartSimulationRegistry } from '../../simulation/parts';
|
import { PartSimulationRegistry } from '../../simulation/parts';
|
||||||
|
|
@ -14,8 +15,11 @@ import { isBoardComponent, boardPinToNumber } from '../../utils/boardPinMapping'
|
||||||
import { autoWireColor, WIRE_KEY_COLORS } from '../../utils/wireUtils';
|
import { autoWireColor, WIRE_KEY_COLORS } from '../../utils/wireUtils';
|
||||||
import {
|
import {
|
||||||
findWireNearPoint,
|
findWireNearPoint,
|
||||||
findSegmentNearPoint,
|
getRenderedPoints,
|
||||||
computeDragWaypoints,
|
getRenderedSegments,
|
||||||
|
moveSegment,
|
||||||
|
renderedToWaypoints,
|
||||||
|
renderedPointsToPath,
|
||||||
} from '../../utils/wireHitDetection';
|
} from '../../utils/wireHitDetection';
|
||||||
import type { ComponentMetadata } from '../../types/component-metadata';
|
import type { ComponentMetadata } from '../../types/component-metadata';
|
||||||
import type { BoardKind } from '../../types/board';
|
import type { BoardKind } from '../../types/board';
|
||||||
|
|
@ -125,20 +129,35 @@ export const SimulatorCanvas = () => {
|
||||||
|
|
||||||
// Wire interaction state (canvas-level hit detection — bypasses SVG pointer-events issues)
|
// Wire interaction state (canvas-level hit detection — bypasses SVG pointer-events issues)
|
||||||
const [hoveredWireId, setHoveredWireId] = useState<string | null>(null);
|
const [hoveredWireId, setHoveredWireId] = useState<string | null>(null);
|
||||||
const [wireDragPreview, setWireDragPreview] = useState<{
|
const [segmentDragPreview, setSegmentDragPreview] = useState<{
|
||||||
wireId: string;
|
wireId: string;
|
||||||
waypoints: { x: number; y: number }[];
|
overridePath: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const wireInteractionRef = useRef<{
|
const segmentDragRef = useRef<{
|
||||||
wireId: string;
|
wireId: string;
|
||||||
startWorld: { x: number; y: number };
|
segIndex: number;
|
||||||
segment: import('../../utils/wireHitDetection').RenderedSegment | null;
|
axis: 'horizontal' | 'vertical';
|
||||||
originalWaypoints: { x: number; y: number }[];
|
renderedPts: { x: number; y: number }[];
|
||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
/** Set to true during mouseup if a segment drag committed, so onClick can skip selection. */
|
||||||
|
const segmentDragJustCommittedRef = useRef(false);
|
||||||
const wiresRef = useRef(wires);
|
const wiresRef = useRef(wires);
|
||||||
wiresRef.current = wires;
|
wiresRef.current = wires;
|
||||||
|
|
||||||
|
// Compute midpoint handles for the selected wire's segments
|
||||||
|
const segmentHandles = React.useMemo<SegmentHandle[]>(() => {
|
||||||
|
if (!selectedWireId) return [];
|
||||||
|
const wire = wires.find((w) => w.id === selectedWireId);
|
||||||
|
if (!wire) return [];
|
||||||
|
return getRenderedSegments(wire).map((seg, i) => ({
|
||||||
|
segIndex: i,
|
||||||
|
axis: seg.axis,
|
||||||
|
mx: (seg.x1 + seg.x2) / 2,
|
||||||
|
my: (seg.y1 + seg.y2) / 2,
|
||||||
|
}));
|
||||||
|
}, [selectedWireId, wires]);
|
||||||
|
|
||||||
// Touch-specific state refs (for single-finger drag and pinch-to-zoom)
|
// Touch-specific state refs (for single-finger drag and pinch-to-zoom)
|
||||||
const touchDraggedComponentIdRef = useRef<string | null>(null);
|
const touchDraggedComponentIdRef = useRef<string | null>(null);
|
||||||
const touchDragOffsetRef = useRef({ x: 0, y: 0 });
|
const touchDragOffsetRef = useRef({ x: 0, y: 0 });
|
||||||
|
|
@ -616,27 +635,15 @@ export const SimulatorCanvas = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle wire segment dragging
|
// Handle segment handle dragging
|
||||||
if (wireInteractionRef.current) {
|
if (segmentDragRef.current) {
|
||||||
const world = toWorld(e.clientX, e.clientY);
|
const world = toWorld(e.clientX, e.clientY);
|
||||||
const wi = wireInteractionRef.current;
|
const sd = segmentDragRef.current;
|
||||||
|
sd.isDragging = true;
|
||||||
if (!wi.isDragging) {
|
const newValue = sd.axis === 'horizontal' ? world.y : world.x;
|
||||||
const moved = Math.hypot(world.x - wi.startWorld.x, world.y - wi.startWorld.y);
|
const newPts = moveSegment(sd.renderedPts, sd.segIndex, sd.axis, newValue);
|
||||||
if (moved > 4 / zoomRef.current) {
|
const overridePath = renderedPointsToPath(newPts);
|
||||||
wi.isDragging = true;
|
setSegmentDragPreview({ wireId: sd.wireId, overridePath });
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wi.isDragging && wi.segment) {
|
|
||||||
const newWaypoints = computeDragWaypoints(
|
|
||||||
wi.originalWaypoints,
|
|
||||||
wi.segment.storedPairIndex,
|
|
||||||
world.x,
|
|
||||||
world.y,
|
|
||||||
);
|
|
||||||
setWireDragPreview({ wireId: wi.wireId, waypoints: newWaypoints });
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -657,14 +664,19 @@ export const SimulatorCanvas = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Commit wire segment drag
|
// Commit segment handle drag
|
||||||
if (wireInteractionRef.current) {
|
if (segmentDragRef.current) {
|
||||||
const wi = wireInteractionRef.current;
|
const sd = segmentDragRef.current;
|
||||||
if (wi.isDragging && wireDragPreview) {
|
if (sd.isDragging) {
|
||||||
updateWire(wi.wireId, { waypoints: wireDragPreview.waypoints });
|
segmentDragJustCommittedRef.current = true;
|
||||||
|
const world = toWorld(e.clientX, e.clientY);
|
||||||
|
const newValue = sd.axis === 'horizontal' ? world.y : world.x;
|
||||||
|
const newPts = moveSegment(sd.renderedPts, sd.segIndex, sd.axis, newValue);
|
||||||
|
updateWire(sd.wireId, { waypoints: renderedToWaypoints(newPts) });
|
||||||
}
|
}
|
||||||
wireInteractionRef.current = null;
|
segmentDragRef.current = null;
|
||||||
setWireDragPreview(null);
|
setSegmentDragPreview(null);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (draggedComponentId) {
|
if (draggedComponentId) {
|
||||||
|
|
@ -688,7 +700,7 @@ export const SimulatorCanvas = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start panning on middle-click or right-click; wire drag on left-click near wire
|
// Start panning on middle-click or right-click
|
||||||
const handleCanvasMouseDown = (e: React.MouseEvent) => {
|
const handleCanvasMouseDown = (e: React.MouseEvent) => {
|
||||||
if (e.button === 1 || e.button === 2) {
|
if (e.button === 1 || e.button === 2) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -699,25 +711,32 @@ export const SimulatorCanvas = () => {
|
||||||
panX: panRef.current.x,
|
panX: panRef.current.x,
|
||||||
panY: panRef.current.y,
|
panY: panRef.current.y,
|
||||||
};
|
};
|
||||||
} else if (e.button === 0 && !wireInProgress) {
|
|
||||||
// Check if clicking near a wire to start a drag interaction
|
|
||||||
const world = toWorld(e.clientX, e.clientY);
|
|
||||||
const threshold = 8 / zoomRef.current;
|
|
||||||
const wire = findWireNearPoint(wiresRef.current, world.x, world.y, threshold);
|
|
||||||
if (wire) {
|
|
||||||
const segment = findSegmentNearPoint(wire, world.x, world.y, threshold);
|
|
||||||
wireInteractionRef.current = {
|
|
||||||
wireId: wire.id,
|
|
||||||
startWorld: world,
|
|
||||||
segment,
|
|
||||||
originalWaypoints: [...(wire.waypoints ?? [])],
|
|
||||||
isDragging: false,
|
|
||||||
};
|
|
||||||
// Don't stopPropagation here — allow component drag detection to also run
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle mousedown on a segment handle circle (called from WireLayer)
|
||||||
|
const handleHandleMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent, segIndex: number) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
if (!selectedWireId) return;
|
||||||
|
const wire = wiresRef.current.find((w) => w.id === selectedWireId);
|
||||||
|
if (!wire) return;
|
||||||
|
const segments = getRenderedSegments(wire);
|
||||||
|
const seg = segments[segIndex];
|
||||||
|
if (!seg) return;
|
||||||
|
const expandedPts = getRenderedPoints(wire);
|
||||||
|
segmentDragRef.current = {
|
||||||
|
wireId: wire.id,
|
||||||
|
segIndex,
|
||||||
|
axis: seg.axis,
|
||||||
|
renderedPts: expandedPts,
|
||||||
|
isDragging: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[selectedWireId],
|
||||||
|
);
|
||||||
|
|
||||||
// Zoom centered on cursor
|
// Zoom centered on cursor
|
||||||
const handleWheel = (e: React.WheelEvent) => {
|
const handleWheel = (e: React.WheelEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -1028,8 +1047,11 @@ export const SimulatorCanvas = () => {
|
||||||
addWireWaypoint(world.x, world.y);
|
addWireWaypoint(world.x, world.y);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// If a wire drag just finished, don't also select
|
// If a segment handle drag just finished, don't also select
|
||||||
if (wireInteractionRef.current?.isDragging) return;
|
if (segmentDragJustCommittedRef.current) {
|
||||||
|
segmentDragJustCommittedRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Wire selection via canvas-level hit detection
|
// Wire selection via canvas-level hit detection
|
||||||
const world = toWorld(e.clientX, e.clientY);
|
const world = toWorld(e.clientX, e.clientY);
|
||||||
const threshold = 8 / zoomRef.current;
|
const threshold = 8 / zoomRef.current;
|
||||||
|
|
@ -1065,7 +1087,9 @@ export const SimulatorCanvas = () => {
|
||||||
{/* Wire Layer - Renders below all components */}
|
{/* Wire Layer - Renders below all components */}
|
||||||
<WireLayer
|
<WireLayer
|
||||||
hoveredWireId={hoveredWireId}
|
hoveredWireId={hoveredWireId}
|
||||||
wireDragPreview={wireDragPreview}
|
segmentDragPreview={segmentDragPreview}
|
||||||
|
segmentHandles={segmentHandles}
|
||||||
|
onHandleMouseDown={handleHandleMouseDown}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* All boards on canvas */}
|
{/* All boards on canvas */}
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,29 @@ import { useSimulatorStore } from '../../store/useSimulatorStore';
|
||||||
import { WireRenderer } from './WireRenderer';
|
import { WireRenderer } from './WireRenderer';
|
||||||
import { WireInProgressRenderer } from './WireInProgressRenderer';
|
import { WireInProgressRenderer } from './WireInProgressRenderer';
|
||||||
|
|
||||||
interface WireLayerProps {
|
export interface SegmentHandle {
|
||||||
hoveredWireId: string | null;
|
segIndex: number;
|
||||||
wireDragPreview: { wireId: string; waypoints: { x: number; y: number }[] } | null;
|
axis: 'horizontal' | 'vertical';
|
||||||
|
mx: number; // midpoint X
|
||||||
|
my: number; // midpoint Y
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WireLayer: React.FC<WireLayerProps> = ({ hoveredWireId, wireDragPreview }) => {
|
interface WireLayerProps {
|
||||||
|
hoveredWireId: string | null;
|
||||||
|
/** Segment drag preview: overrides the path of a specific wire */
|
||||||
|
segmentDragPreview: { wireId: string; overridePath: string } | null;
|
||||||
|
/** Handles to render for the selected wire */
|
||||||
|
segmentHandles: SegmentHandle[];
|
||||||
|
/** Called when user starts dragging a handle (passes segIndex) */
|
||||||
|
onHandleMouseDown: (e: React.MouseEvent, segIndex: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WireLayer: React.FC<WireLayerProps> = ({
|
||||||
|
hoveredWireId,
|
||||||
|
segmentDragPreview,
|
||||||
|
segmentHandles,
|
||||||
|
onHandleMouseDown,
|
||||||
|
}) => {
|
||||||
const wires = useSimulatorStore((s) => s.wires);
|
const wires = useSimulatorStore((s) => s.wires);
|
||||||
const wireInProgress = useSimulatorStore((s) => s.wireInProgress);
|
const wireInProgress = useSimulatorStore((s) => s.wireInProgress);
|
||||||
const selectedWireId = useSimulatorStore((s) => s.selectedWireId);
|
const selectedWireId = useSimulatorStore((s) => s.selectedWireId);
|
||||||
|
|
@ -24,7 +41,7 @@ export const WireLayer: React.FC<WireLayerProps> = ({ hoveredWireId, wireDragPre
|
||||||
height: '100%',
|
height: '100%',
|
||||||
overflow: 'visible',
|
overflow: 'visible',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
zIndex: 1,
|
zIndex: 20,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{wires.map((wire) => (
|
{wires.map((wire) => (
|
||||||
|
|
@ -33,12 +50,29 @@ export const WireLayer: React.FC<WireLayerProps> = ({ hoveredWireId, wireDragPre
|
||||||
wire={wire}
|
wire={wire}
|
||||||
isSelected={wire.id === selectedWireId}
|
isSelected={wire.id === selectedWireId}
|
||||||
isHovered={wire.id === hoveredWireId}
|
isHovered={wire.id === hoveredWireId}
|
||||||
previewWaypoints={
|
overridePath={
|
||||||
wireDragPreview?.wireId === wire.id ? wireDragPreview.waypoints : undefined
|
segmentDragPreview?.wireId === wire.id
|
||||||
|
? segmentDragPreview.overridePath
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Segment handles for the selected wire */}
|
||||||
|
{segmentHandles.map((handle) => (
|
||||||
|
<circle
|
||||||
|
key={handle.segIndex}
|
||||||
|
cx={handle.mx}
|
||||||
|
cy={handle.my}
|
||||||
|
r={7}
|
||||||
|
fill="white"
|
||||||
|
stroke="#007acc"
|
||||||
|
strokeWidth={2}
|
||||||
|
style={{ pointerEvents: 'all', cursor: handle.axis === 'horizontal' ? 'ns-resize' : 'ew-resize' }}
|
||||||
|
onMouseDown={(e) => onHandleMouseDown(e, handle.segIndex)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
{wireInProgress && (
|
{wireInProgress && (
|
||||||
<WireInProgressRenderer wireInProgress={wireInProgress} />
|
<WireInProgressRenderer wireInProgress={wireInProgress} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ interface WireRendererProps {
|
||||||
isHovered: boolean;
|
isHovered: boolean;
|
||||||
/** Temporary waypoints used during drag preview */
|
/** Temporary waypoints used during drag preview */
|
||||||
previewWaypoints?: { x: number; y: number }[];
|
previewWaypoints?: { x: number; y: number }[];
|
||||||
|
/** Override the full SVG path string (used during segment drag preview) */
|
||||||
|
overridePath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WireRenderer: React.FC<WireRendererProps> = ({
|
export const WireRenderer: React.FC<WireRendererProps> = ({
|
||||||
|
|
@ -20,9 +22,10 @@ export const WireRenderer: React.FC<WireRendererProps> = ({
|
||||||
isSelected,
|
isSelected,
|
||||||
isHovered,
|
isHovered,
|
||||||
previewWaypoints,
|
previewWaypoints,
|
||||||
|
overridePath,
|
||||||
}) => {
|
}) => {
|
||||||
const waypoints = previewWaypoints ?? wire.waypoints;
|
const waypoints = previewWaypoints ?? wire.waypoints;
|
||||||
const path = generateOrthogonalPath(wire.start, waypoints, wire.end);
|
const path = overridePath ?? generateOrthogonalPath(wire.start, waypoints, wire.end);
|
||||||
|
|
||||||
if (!path) return null;
|
if (!path) return null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -145,3 +145,88 @@ export function computeDragWaypoints(
|
||||||
...originalWaypoints.slice(storedPairIndex),
|
...originalWaypoints.slice(storedPairIndex),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move an entire rendered segment perpendicularly.
|
||||||
|
* - horizontal segment → moves up/down (change Y of both endpoints)
|
||||||
|
* - vertical segment → moves left/right (change X of both endpoints)
|
||||||
|
* If the segment is the first or last, inserts connector points to keep
|
||||||
|
* the wire connected to its fixed start/end.
|
||||||
|
*/
|
||||||
|
export function moveSegment(
|
||||||
|
renderedPts: { x: number; y: number }[],
|
||||||
|
segIndex: number,
|
||||||
|
axis: 'horizontal' | 'vertical',
|
||||||
|
newValue: number,
|
||||||
|
): { x: number; y: number }[] {
|
||||||
|
const n = renderedPts.length;
|
||||||
|
const numSegs = n - 1;
|
||||||
|
const pts = renderedPts.map((p) => ({ ...p }));
|
||||||
|
|
||||||
|
if (axis === 'horizontal') {
|
||||||
|
if (segIndex === 0 && numSegs > 0) {
|
||||||
|
// First segment: keep start fixed, insert connector
|
||||||
|
pts.splice(1, 0, { x: pts[0].x, y: newValue }, { x: pts[1].x, y: newValue });
|
||||||
|
pts.splice(3, 1); // remove original pts[1] copy
|
||||||
|
} else if (segIndex === numSegs - 1 && numSegs > 0) {
|
||||||
|
// Last segment: keep end fixed, insert connector
|
||||||
|
const last = pts[n - 1];
|
||||||
|
pts.splice(n - 1, 0, { x: pts[n - 2].x, y: newValue }, { x: last.x, y: newValue });
|
||||||
|
} else {
|
||||||
|
pts[segIndex].y = newValue;
|
||||||
|
pts[segIndex + 1].y = newValue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// vertical
|
||||||
|
if (segIndex === 0 && numSegs > 0) {
|
||||||
|
pts.splice(1, 0, { x: newValue, y: pts[0].y }, { x: newValue, y: pts[1].y });
|
||||||
|
pts.splice(3, 1);
|
||||||
|
} else if (segIndex === numSegs - 1 && numSegs > 0) {
|
||||||
|
const last = pts[n - 1];
|
||||||
|
pts.splice(n - 1, 0, { x: newValue, y: pts[n - 2].y }, { x: newValue, y: last.y });
|
||||||
|
} else {
|
||||||
|
pts[segIndex].x = newValue;
|
||||||
|
pts[segIndex + 1].x = newValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a list of rendered (expanded) points back to wire waypoints.
|
||||||
|
* Waypoints are the interior corner/bend points (excludes start and end).
|
||||||
|
* Consecutive collinear points are collapsed so only actual corners remain.
|
||||||
|
*/
|
||||||
|
export function renderedToWaypoints(
|
||||||
|
renderedPts: { x: number; y: number }[],
|
||||||
|
): { x: number; y: number }[] {
|
||||||
|
if (renderedPts.length <= 2) return [];
|
||||||
|
|
||||||
|
const waypoints: { x: number; y: number }[] = [];
|
||||||
|
for (let i = 1; i < renderedPts.length - 1; i++) {
|
||||||
|
const prev = renderedPts[i - 1];
|
||||||
|
const curr = renderedPts[i];
|
||||||
|
const next = renderedPts[i + 1];
|
||||||
|
const d1x = Math.sign(curr.x - prev.x);
|
||||||
|
const d1y = Math.sign(curr.y - prev.y);
|
||||||
|
const d2x = Math.sign(next.x - curr.x);
|
||||||
|
const d2y = Math.sign(next.y - curr.y);
|
||||||
|
// Keep only direction-change points (actual corners)
|
||||||
|
if (d1x !== d2x || d1y !== d2y) {
|
||||||
|
waypoints.push({ x: curr.x, y: curr.y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return waypoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an SVG path string from an ordered list of rendered points (straight segments).
|
||||||
|
*/
|
||||||
|
export function renderedPointsToPath(pts: { x: number; y: number }[]): string {
|
||||||
|
if (pts.length < 2) return '';
|
||||||
|
return (
|
||||||
|
`M ${pts[0].x} ${pts[0].y}` +
|
||||||
|
pts.slice(1).map((p) => ` L ${p.x} ${p.y}`).join('')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue