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 { PinSelector } from './PinSelector';
|
||||
import { WireLayer } from './WireLayer';
|
||||
import type { SegmentHandle } from './WireLayer';
|
||||
import { BoardOnCanvas } from './BoardOnCanvas';
|
||||
import { BoardPickerModal } from './BoardPickerModal';
|
||||
import { PartSimulationRegistry } from '../../simulation/parts';
|
||||
|
|
@ -14,8 +15,11 @@ import { isBoardComponent, boardPinToNumber } from '../../utils/boardPinMapping'
|
|||
import { autoWireColor, WIRE_KEY_COLORS } from '../../utils/wireUtils';
|
||||
import {
|
||||
findWireNearPoint,
|
||||
findSegmentNearPoint,
|
||||
computeDragWaypoints,
|
||||
getRenderedPoints,
|
||||
getRenderedSegments,
|
||||
moveSegment,
|
||||
renderedToWaypoints,
|
||||
renderedPointsToPath,
|
||||
} from '../../utils/wireHitDetection';
|
||||
import type { ComponentMetadata } from '../../types/component-metadata';
|
||||
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)
|
||||
const [hoveredWireId, setHoveredWireId] = useState<string | null>(null);
|
||||
const [wireDragPreview, setWireDragPreview] = useState<{
|
||||
const [segmentDragPreview, setSegmentDragPreview] = useState<{
|
||||
wireId: string;
|
||||
waypoints: { x: number; y: number }[];
|
||||
overridePath: string;
|
||||
} | null>(null);
|
||||
const wireInteractionRef = useRef<{
|
||||
const segmentDragRef = useRef<{
|
||||
wireId: string;
|
||||
startWorld: { x: number; y: number };
|
||||
segment: import('../../utils/wireHitDetection').RenderedSegment | null;
|
||||
originalWaypoints: { x: number; y: number }[];
|
||||
segIndex: number;
|
||||
axis: 'horizontal' | 'vertical';
|
||||
renderedPts: { x: number; y: number }[];
|
||||
isDragging: boolean;
|
||||
} | 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);
|
||||
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)
|
||||
const touchDraggedComponentIdRef = useRef<string | null>(null);
|
||||
const touchDragOffsetRef = useRef({ x: 0, y: 0 });
|
||||
|
|
@ -616,27 +635,15 @@ export const SimulatorCanvas = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
// Handle wire segment dragging
|
||||
if (wireInteractionRef.current) {
|
||||
// Handle segment handle dragging
|
||||
if (segmentDragRef.current) {
|
||||
const world = toWorld(e.clientX, e.clientY);
|
||||
const wi = wireInteractionRef.current;
|
||||
|
||||
if (!wi.isDragging) {
|
||||
const moved = Math.hypot(world.x - wi.startWorld.x, world.y - wi.startWorld.y);
|
||||
if (moved > 4 / zoomRef.current) {
|
||||
wi.isDragging = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (wi.isDragging && wi.segment) {
|
||||
const newWaypoints = computeDragWaypoints(
|
||||
wi.originalWaypoints,
|
||||
wi.segment.storedPairIndex,
|
||||
world.x,
|
||||
world.y,
|
||||
);
|
||||
setWireDragPreview({ wireId: wi.wireId, waypoints: newWaypoints });
|
||||
}
|
||||
const sd = segmentDragRef.current;
|
||||
sd.isDragging = true;
|
||||
const newValue = sd.axis === 'horizontal' ? world.y : world.x;
|
||||
const newPts = moveSegment(sd.renderedPts, sd.segIndex, sd.axis, newValue);
|
||||
const overridePath = renderedPointsToPath(newPts);
|
||||
setSegmentDragPreview({ wireId: sd.wireId, overridePath });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -657,14 +664,19 @@ export const SimulatorCanvas = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
// Commit wire segment drag
|
||||
if (wireInteractionRef.current) {
|
||||
const wi = wireInteractionRef.current;
|
||||
if (wi.isDragging && wireDragPreview) {
|
||||
updateWire(wi.wireId, { waypoints: wireDragPreview.waypoints });
|
||||
// Commit segment handle drag
|
||||
if (segmentDragRef.current) {
|
||||
const sd = segmentDragRef.current;
|
||||
if (sd.isDragging) {
|
||||
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;
|
||||
setWireDragPreview(null);
|
||||
segmentDragRef.current = null;
|
||||
setSegmentDragPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
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) => {
|
||||
if (e.button === 1 || e.button === 2) {
|
||||
e.preventDefault();
|
||||
|
|
@ -699,25 +711,32 @@ export const SimulatorCanvas = () => {
|
|||
panX: panRef.current.x,
|
||||
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
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -1028,8 +1047,11 @@ export const SimulatorCanvas = () => {
|
|||
addWireWaypoint(world.x, world.y);
|
||||
return;
|
||||
}
|
||||
// If a wire drag just finished, don't also select
|
||||
if (wireInteractionRef.current?.isDragging) return;
|
||||
// If a segment handle drag just finished, don't also select
|
||||
if (segmentDragJustCommittedRef.current) {
|
||||
segmentDragJustCommittedRef.current = false;
|
||||
return;
|
||||
}
|
||||
// Wire selection via canvas-level hit detection
|
||||
const world = toWorld(e.clientX, e.clientY);
|
||||
const threshold = 8 / zoomRef.current;
|
||||
|
|
@ -1065,7 +1087,9 @@ export const SimulatorCanvas = () => {
|
|||
{/* Wire Layer - Renders below all components */}
|
||||
<WireLayer
|
||||
hoveredWireId={hoveredWireId}
|
||||
wireDragPreview={wireDragPreview}
|
||||
segmentDragPreview={segmentDragPreview}
|
||||
segmentHandles={segmentHandles}
|
||||
onHandleMouseDown={handleHandleMouseDown}
|
||||
/>
|
||||
|
||||
{/* All boards on canvas */}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,29 @@ import { useSimulatorStore } from '../../store/useSimulatorStore';
|
|||
import { WireRenderer } from './WireRenderer';
|
||||
import { WireInProgressRenderer } from './WireInProgressRenderer';
|
||||
|
||||
interface WireLayerProps {
|
||||
hoveredWireId: string | null;
|
||||
wireDragPreview: { wireId: string; waypoints: { x: number; y: number }[] } | null;
|
||||
export interface SegmentHandle {
|
||||
segIndex: number;
|
||||
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 wireInProgress = useSimulatorStore((s) => s.wireInProgress);
|
||||
const selectedWireId = useSimulatorStore((s) => s.selectedWireId);
|
||||
|
|
@ -24,7 +41,7 @@ export const WireLayer: React.FC<WireLayerProps> = ({ hoveredWireId, wireDragPre
|
|||
height: '100%',
|
||||
overflow: 'visible',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
zIndex: 20,
|
||||
}}
|
||||
>
|
||||
{wires.map((wire) => (
|
||||
|
|
@ -33,12 +50,29 @@ export const WireLayer: React.FC<WireLayerProps> = ({ hoveredWireId, wireDragPre
|
|||
wire={wire}
|
||||
isSelected={wire.id === selectedWireId}
|
||||
isHovered={wire.id === hoveredWireId}
|
||||
previewWaypoints={
|
||||
wireDragPreview?.wireId === wire.id ? wireDragPreview.waypoints : undefined
|
||||
overridePath={
|
||||
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 && (
|
||||
<WireInProgressRenderer wireInProgress={wireInProgress} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ interface WireRendererProps {
|
|||
isHovered: boolean;
|
||||
/** Temporary waypoints used during drag preview */
|
||||
previewWaypoints?: { x: number; y: number }[];
|
||||
/** Override the full SVG path string (used during segment drag preview) */
|
||||
overridePath?: string;
|
||||
}
|
||||
|
||||
export const WireRenderer: React.FC<WireRendererProps> = ({
|
||||
|
|
@ -20,9 +22,10 @@ export const WireRenderer: React.FC<WireRendererProps> = ({
|
|||
isSelected,
|
||||
isHovered,
|
||||
previewWaypoints,
|
||||
overridePath,
|
||||
}) => {
|
||||
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;
|
||||
|
||||
|
|
|
|||
|
|
@ -145,3 +145,88 @@ export function computeDragWaypoints(
|
|||
...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