Compare commits
2 Commits
52321dffe8
...
a11dd87d5a
| Author | SHA1 | Date |
|---|---|---|
|
|
a11dd87d5a | |
|
|
1973b0adbc |
|
|
@ -230,6 +230,7 @@ export const SimulatorCanvas = () => {
|
||||||
const currentTouchRef = useRef({ x: 0, y: 0 }); // latest screen position for timeout callback
|
const currentTouchRef = useRef({ x: 0, y: 0 }); // latest screen position for timeout callback
|
||||||
const [wireAiming, setWireAiming] = useState(false); // React state for rendering (crosshair visible)
|
const [wireAiming, setWireAiming] = useState(false); // React state for rendering (crosshair visible)
|
||||||
const [aimPosition, setAimPosition] = useState<{ x: number; y: number } | null>(null); // world coords of crosshair
|
const [aimPosition, setAimPosition] = useState<{ x: number; y: number } | null>(null); // world coords of crosshair
|
||||||
|
const [aimHoveredPinName, setAimHoveredPinName] = useState<string | null>(null); // Name of pin hovered by crosshair
|
||||||
|
|
||||||
// Convert viewport coords to world (canvas) coords
|
// Convert viewport coords to world (canvas) coords
|
||||||
const toWorld = useCallback((screenX: number, screenY: number) => {
|
const toWorld = useCallback((screenX: number, screenY: number) => {
|
||||||
|
|
@ -402,6 +403,9 @@ export const SimulatorCanvas = () => {
|
||||||
// Any phase that uses aiming: cancel previous timer first
|
// Any phase that uses aiming: cancel previous timer first
|
||||||
if (wireAimingTimerRef.current) { clearTimeout(wireAimingTimerRef.current); wireAimingTimerRef.current = null; }
|
if (wireAimingTimerRef.current) { clearTimeout(wireAimingTimerRef.current); wireAimingTimerRef.current = null; }
|
||||||
|
|
||||||
|
// Check if touch is on a segment handle - if so, skip aiming timer (handle drag takes priority)
|
||||||
|
const isOnSegmentHandle = target?.closest('[data-segment-handle]');
|
||||||
|
|
||||||
const aimPhase = wireAimingPhaseRef.current;
|
const aimPhase = wireAimingPhaseRef.current;
|
||||||
|
|
||||||
// Already in an aiming phase (aiming_start or aiming_end) → touch updates crosshair immediately
|
// Already in an aiming phase (aiming_start or aiming_end) → touch updates crosshair immediately
|
||||||
|
|
@ -415,16 +419,26 @@ export const SimulatorCanvas = () => {
|
||||||
|
|
||||||
// wire_started → long press to aim for endpoint, short drag = pan
|
// wire_started → long press to aim for endpoint, short drag = pan
|
||||||
if (aimPhase === 'wire_started') {
|
if (aimPhase === 'wire_started') {
|
||||||
wireAimingTimerRef.current = setTimeout(() => {
|
// Only start aiming timer if NOT on a segment handle
|
||||||
wireAimingPhaseRef.current = 'aiming_end';
|
if (!isOnSegmentHandle) {
|
||||||
setWireAiming(true);
|
wireAimingTimerRef.current = setTimeout(() => {
|
||||||
if (navigator.vibrate) navigator.vibrate(30);
|
// Double-check: only activate if segment drag hasn't started
|
||||||
const world = toWorld(currentTouchRef.current.x, currentTouchRef.current.y);
|
if (segmentDragRef.current) return;
|
||||||
const aimX = world.x;
|
wireAimingPhaseRef.current = 'aiming_end';
|
||||||
const aimY = world.y + AIMING_OFFSET_Y;
|
setWireAiming(true);
|
||||||
setAimPosition({ x: aimX, y: aimY });
|
if (navigator.vibrate) navigator.vibrate(30);
|
||||||
useSimulatorStore.getState().updateWireInProgress(aimX, aimY);
|
const world = toWorld(currentTouchRef.current.x, currentTouchRef.current.y);
|
||||||
}, AIMING_LONG_PRESS_MS);
|
const aimX = world.x;
|
||||||
|
const aimY = world.y + AIMING_OFFSET_Y;
|
||||||
|
setAimPosition({ x: aimX, y: aimY });
|
||||||
|
|
||||||
|
const snapDist = AIMING_PIN_SNAP_DIST / zoomRef.current;
|
||||||
|
const nearPin = findNearestPin(aimX, aimY, snapDist);
|
||||||
|
setAimHoveredPinName(nearPin ? nearPin.pinName : null);
|
||||||
|
|
||||||
|
useSimulatorStore.getState().updateWireInProgress(aimX, aimY);
|
||||||
|
}, AIMING_LONG_PRESS_MS);
|
||||||
|
}
|
||||||
|
|
||||||
isPanningRef.current = true;
|
isPanningRef.current = true;
|
||||||
panStartRef.current = {
|
panStartRef.current = {
|
||||||
|
|
@ -479,13 +493,21 @@ export const SimulatorCanvas = () => {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// ── 6. Empty canvas → start pan + long press starts aiming (idle phase only) ──
|
// ── 6. Empty canvas → start pan + long press starts aiming (idle phase only) ──
|
||||||
if (aimPhase === 'idle') {
|
if (aimPhase === 'idle' && !isOnSegmentHandle) {
|
||||||
wireAimingTimerRef.current = setTimeout(() => {
|
wireAimingTimerRef.current = setTimeout(() => {
|
||||||
|
// Double-check: only activate if segment drag hasn't started
|
||||||
|
if (segmentDragRef.current) return;
|
||||||
wireAimingPhaseRef.current = 'aiming_start';
|
wireAimingPhaseRef.current = 'aiming_start';
|
||||||
setWireAiming(true);
|
setWireAiming(true);
|
||||||
if (navigator.vibrate) navigator.vibrate(30);
|
if (navigator.vibrate) navigator.vibrate(30);
|
||||||
const world = toWorld(currentTouchRef.current.x, currentTouchRef.current.y);
|
const world = toWorld(currentTouchRef.current.x, currentTouchRef.current.y);
|
||||||
setAimPosition({ x: world.x, y: world.y + AIMING_OFFSET_Y });
|
const aimX = world.x;
|
||||||
|
const aimY = world.y + AIMING_OFFSET_Y;
|
||||||
|
setAimPosition({ x: aimX, y: aimY });
|
||||||
|
|
||||||
|
const snapDist = AIMING_PIN_SNAP_DIST / zoomRef.current;
|
||||||
|
const nearPin = findNearestPin(aimX, aimY, snapDist);
|
||||||
|
setAimHoveredPinName(nearPin ? nearPin.pinName : null);
|
||||||
}, AIMING_LONG_PRESS_MS);
|
}, AIMING_LONG_PRESS_MS);
|
||||||
}
|
}
|
||||||
isPanningRef.current = true;
|
isPanningRef.current = true;
|
||||||
|
|
@ -538,6 +560,11 @@ export const SimulatorCanvas = () => {
|
||||||
|
|
||||||
// ── Segment drag (wire editing) via touch ──
|
// ── Segment drag (wire editing) via touch ──
|
||||||
if (segmentDragRef.current) {
|
if (segmentDragRef.current) {
|
||||||
|
// Cancel any pending aiming timer - segment drag takes priority
|
||||||
|
if (wireAimingTimerRef.current) {
|
||||||
|
clearTimeout(wireAimingTimerRef.current);
|
||||||
|
wireAimingTimerRef.current = null;
|
||||||
|
}
|
||||||
const world = toWorld(touch.clientX, touch.clientY);
|
const world = toWorld(touch.clientX, touch.clientY);
|
||||||
const sd = segmentDragRef.current;
|
const sd = segmentDragRef.current;
|
||||||
sd.isDragging = true;
|
sd.isDragging = true;
|
||||||
|
|
@ -557,6 +584,11 @@ export const SimulatorCanvas = () => {
|
||||||
const aimX = world.x;
|
const aimX = world.x;
|
||||||
const aimY = world.y + AIMING_OFFSET_Y;
|
const aimY = world.y + AIMING_OFFSET_Y;
|
||||||
setAimPosition({ x: aimX, y: aimY });
|
setAimPosition({ x: aimX, y: aimY });
|
||||||
|
|
||||||
|
const snapDist = AIMING_PIN_SNAP_DIST / zoomRef.current;
|
||||||
|
const nearPin = findNearestPin(aimX, aimY, snapDist);
|
||||||
|
setAimHoveredPinName(nearPin ? nearPin.pinName : null);
|
||||||
|
|
||||||
if (aimPhase === 'aiming_end') {
|
if (aimPhase === 'aiming_end') {
|
||||||
useSimulatorStore.getState().updateWireInProgress(aimX, aimY);
|
useSimulatorStore.getState().updateWireInProgress(aimX, aimY);
|
||||||
}
|
}
|
||||||
|
|
@ -672,7 +704,7 @@ export const SimulatorCanvas = () => {
|
||||||
const snapDist = AIMING_PIN_SNAP_DIST / zoomRef.current;
|
const snapDist = AIMING_PIN_SNAP_DIST / zoomRef.current;
|
||||||
const nearPin = findNearestPin(aimX, aimY, snapDist);
|
const nearPin = findNearestPin(aimX, aimY, snapDist);
|
||||||
|
|
||||||
setWireAiming(false); setAimPosition(null);
|
setWireAiming(false); setAimPosition(null); setAimHoveredPinName(null);
|
||||||
if (nearPin) {
|
if (nearPin) {
|
||||||
// Start wire from this pin
|
// Start wire from this pin
|
||||||
startWireCreation(
|
startWireCreation(
|
||||||
|
|
@ -696,7 +728,7 @@ export const SimulatorCanvas = () => {
|
||||||
const snapDist = AIMING_PIN_SNAP_DIST / zoomRef.current;
|
const snapDist = AIMING_PIN_SNAP_DIST / zoomRef.current;
|
||||||
const nearPin = findNearestPin(aimX, aimY, snapDist);
|
const nearPin = findNearestPin(aimX, aimY, snapDist);
|
||||||
|
|
||||||
setWireAiming(false); setAimPosition(null);
|
setWireAiming(false); setAimPosition(null); setAimHoveredPinName(null);
|
||||||
if (nearPin) {
|
if (nearPin) {
|
||||||
// Finish wire at this pin
|
// Finish wire at this pin
|
||||||
finishWireCreation({ componentId: nearPin.componentId, pinName: nearPin.pinName, x: nearPin.x, y: nearPin.y });
|
finishWireCreation({ componentId: nearPin.componentId, pinName: nearPin.pinName, x: nearPin.x, y: nearPin.y });
|
||||||
|
|
@ -1708,6 +1740,27 @@ export const SimulatorCanvas = () => {
|
||||||
<circle cx={aimPosition.x} cy={aimPosition.y} r="2" fill="rgba(0, 200, 255, 0.9)" />
|
<circle cx={aimPosition.x} cy={aimPosition.y} r="2" fill="rgba(0, 200, 255, 0.9)" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Aim Hover Tooltip for Touch Devices */}
|
||||||
|
{wireAiming && aimPosition && aimHoveredPinName && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: aimPosition.x + 15,
|
||||||
|
top: aimPosition.y - 20,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
color: 'white',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 45,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
boxShadow: '0 2px 4px rgba(0,0,0,0.3)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)'
|
||||||
|
}}>
|
||||||
|
{aimHoveredPinName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Wire creation mode banner — visible on both desktop and mobile */}
|
{/* Wire creation mode banner — visible on both desktop and mobile */}
|
||||||
|
|
|
||||||
|
|
@ -55,19 +55,23 @@ export const WireLayer: React.FC<WireLayerProps> = ({
|
||||||
zIndex: 35,
|
zIndex: 35,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{wires.map((wire) => (
|
{wires.map((wire) => {
|
||||||
<WireRenderer
|
// Skip null/undefined wires (can happen during circuit loading)
|
||||||
key={wire.id}
|
if (!wire) return null;
|
||||||
wire={wire}
|
return (
|
||||||
isSelected={wire.id === selectedWireId}
|
<WireRenderer
|
||||||
isHovered={wire.id === hoveredWireId}
|
key={wire.id}
|
||||||
overridePath={
|
wire={wire}
|
||||||
segmentDragPreview?.wireId === wire.id
|
isSelected={wire.id === selectedWireId}
|
||||||
? segmentDragPreview.overridePath
|
isHovered={wire.id === hoveredWireId}
|
||||||
: undefined
|
overridePath={
|
||||||
}
|
segmentDragPreview?.wireId === wire.id
|
||||||
/>
|
? segmentDragPreview.overridePath
|
||||||
))}
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Segment handles for the selected wire — scaled inversely to zoom for constant screen size */}
|
{/* Segment handles for the selected wire — scaled inversely to zoom for constant screen size */}
|
||||||
{segmentHandles.map((handle) => {
|
{segmentHandles.map((handle) => {
|
||||||
|
|
@ -84,6 +88,7 @@ export const WireLayer: React.FC<WireLayerProps> = ({
|
||||||
stroke="#007acc"
|
stroke="#007acc"
|
||||||
strokeWidth={sw}
|
strokeWidth={sw}
|
||||||
style={{ pointerEvents: 'all', cursor: handle.axis === 'horizontal' ? 'ns-resize' : 'ew-resize', touchAction: 'none' }}
|
style={{ pointerEvents: 'all', cursor: handle.axis === 'horizontal' ? 'ns-resize' : 'ew-resize', touchAction: 'none' }}
|
||||||
|
data-segment-handle={handle.segIndex}
|
||||||
onMouseDown={(e) => onHandleMouseDown(e, handle.segIndex)}
|
onMouseDown={(e) => onHandleMouseDown(e, handle.segIndex)}
|
||||||
onTouchStart={(e) => onHandleTouchStart?.(e, handle.segIndex)}
|
onTouchStart={(e) => onHandleTouchStart?.(e, handle.segIndex)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ export const WireRenderer: React.FC<WireRendererProps> = ({
|
||||||
previewWaypoints,
|
previewWaypoints,
|
||||||
overridePath,
|
overridePath,
|
||||||
}) => {
|
}) => {
|
||||||
|
// Guard against null/undefined wire
|
||||||
|
if (!wire || !wire.start || !wire.end) return null;
|
||||||
|
|
||||||
const waypoints = previewWaypoints ?? wire.waypoints;
|
const waypoints = previewWaypoints ?? wire.waypoints;
|
||||||
const path = overridePath ?? generateOrthogonalPath(wire.start, waypoints, wire.end);
|
const path = overridePath ?? generateOrthogonalPath(wire.start, waypoints, wire.end);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,9 @@ class EmbedBridge {
|
||||||
|
|
||||||
// Set wires if provided
|
// Set wires if provided
|
||||||
if (data.wires && Array.isArray(data.wires)) {
|
if (data.wires && Array.isArray(data.wires)) {
|
||||||
store.setWires(data.wires as never[]);
|
// Filter out null/undefined wires to prevent runtime errors
|
||||||
|
const validWires = data.wires.filter((w) => w && w.start && w.end && w.id) as never[];
|
||||||
|
store.setWires(validWires);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue