Compare commits

..

2 Commits

Author SHA1 Message Date
a2nr a11dd87d5a feat: add pin hover tooltip during wire aiming and implement null-safety checks for wire rendering 2026-04-10 13:59:44 +07:00
a2nr 1973b0adbc fix(simulator): prevent crosshair from activating during wire segment editing on mobile
- Add data-segment-handle attribute to segment handle circles in WireLayer
- Skip long-press aiming timer when touch targets segment handle
- Add safety checks to cancel pending timer when segment drag starts
- Ensure segment drag always takes priority over crosshair activation

This fixes the bug where accidentally holding still while dragging a wire
segment would activate the crosshair overlay and interrupt the editing workflow.
2026-04-09 11:04:05 +07:00
4 changed files with 91 additions and 28 deletions

View File

@ -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,7 +419,11 @@ 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') {
// Only start aiming timer if NOT on a segment handle
if (!isOnSegmentHandle) {
wireAimingTimerRef.current = setTimeout(() => { wireAimingTimerRef.current = setTimeout(() => {
// Double-check: only activate if segment drag hasn't started
if (segmentDragRef.current) return;
wireAimingPhaseRef.current = 'aiming_end'; wireAimingPhaseRef.current = 'aiming_end';
setWireAiming(true); setWireAiming(true);
if (navigator.vibrate) navigator.vibrate(30); if (navigator.vibrate) navigator.vibrate(30);
@ -423,8 +431,14 @@ 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);
useSimulatorStore.getState().updateWireInProgress(aimX, aimY); useSimulatorStore.getState().updateWireInProgress(aimX, aimY);
}, AIMING_LONG_PRESS_MS); }, 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 */}

View File

@ -55,7 +55,10 @@ export const WireLayer: React.FC<WireLayerProps> = ({
zIndex: 35, zIndex: 35,
}} }}
> >
{wires.map((wire) => ( {wires.map((wire) => {
// Skip null/undefined wires (can happen during circuit loading)
if (!wire) return null;
return (
<WireRenderer <WireRenderer
key={wire.id} key={wire.id}
wire={wire} wire={wire}
@ -67,7 +70,8 @@ export const WireLayer: React.FC<WireLayerProps> = ({
: undefined : 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)}
/> />

View File

@ -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);

View File

@ -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;
} }