diff --git a/frontend/src/__tests__/drop-slot.test.ts b/frontend/src/__tests__/drop-slot.test.ts new file mode 100644 index 00000000..fa784865 --- /dev/null +++ b/frontend/src/__tests__/drop-slot.test.ts @@ -0,0 +1,75 @@ +/** + * Where a new component lands. + * + * The complaint this fixes: "I add an element and sometimes I cannot even + * see where it went". Two causes — placement at fixed world coordinates + * (off-screen once the canvas is panned, fixed elsewhere by anchoring to the + * visible corner) and a cascade keyed on components.length, which kept + * marching down-right forever even after parts were moved away or deleted. + * + * These lock the rule that replaced it: first FREE slot from the corner. + */ +import { describe, it, expect } from 'vitest'; +import { pickDropSlot } from '../utils/dropSlot'; + +const ORIGIN = { x: 100, y: 50 }; +const STEP = 36; + +describe('pickDropSlot', () => { + it('drops at the corner when nothing is there', () => { + expect(pickDropSlot(ORIGIN, [], { step: STEP })).toEqual(ORIGIN); + }); + + it('steps down-right when the corner is taken', () => { + const slot = pickDropSlot(ORIGIN, [ORIGIN], { step: STEP }); + expect(slot).toEqual({ x: 100 + STEP, y: 50 + STEP }); + }); + + it('keeps cascading while each slot in turn is taken', () => { + const placed = [ORIGIN, { x: 100 + STEP, y: 50 + STEP }]; + expect(pickDropSlot(ORIGIN, placed, { step: STEP })).toEqual({ + x: 100 + 2 * STEP, + y: 50 + 2 * STEP, + }); + }); + + it('reclaims a slot whose component was moved away', () => { + // The whole point of not counting components: the user parked the last + // drop somewhere else, so the corner is free again and the next one + // belongs there — not three steps further down the diagonal. + const movedAside = [{ x: 900, y: 700 }]; + expect(pickDropSlot(ORIGIN, movedAside, { step: STEP })).toEqual(ORIGIN); + }); + + it('ignores components that are merely near, not on, the slot', () => { + const nearby = [{ x: ORIGIN.x + STEP * 0.9, y: ORIGIN.y + STEP * 0.9 }]; + expect(pickDropSlot(ORIGIN, nearby, { step: STEP })).toEqual(ORIGIN); + }); + + it('treats a slightly nudged component as still parked', () => { + // A few px of drag should not make the next drop land on top of it. + const nudged = [{ x: ORIGIN.x + 4, y: ORIGIN.y - 3 }]; + expect(pickDropSlot(ORIGIN, nudged, { step: STEP })).toEqual({ + x: ORIGIN.x + STEP, + y: ORIGIN.y + STEP, + }); + }); + + it('stops cascading instead of walking off the viewport', () => { + // Every slot occupied: it stacks at the last one rather than marching + // out of sight, which is the failure mode being fixed. + const full = Array.from({ length: 40 }, (_, i) => ({ + x: ORIGIN.x + i * STEP, + y: ORIGIN.y + i * STEP, + })); + const slot = pickDropSlot(ORIGIN, full, { step: STEP, maxSlots: 12 }); + expect(slot).toEqual({ x: ORIGIN.x + 12 * STEP, y: ORIGIN.y + 12 * STEP }); + }); + + it('scales with zoom, since the step arrives in world units', () => { + // The canvas passes 36 screen-px / zoom, so the gap looks the same + // whatever the zoom level. + const zoomedOut = pickDropSlot(ORIGIN, [ORIGIN], { step: 36 / 0.5 }); + expect(zoomedOut).toEqual({ x: 100 + 72, y: 50 + 72 }); + }); +}); diff --git a/frontend/src/components/DynamicComponent.tsx b/frontend/src/components/DynamicComponent.tsx index d2f3c1e4..6073b908 100644 --- a/frontend/src/components/DynamicComponent.tsx +++ b/frontend/src/components/DynamicComponent.tsx @@ -265,6 +265,8 @@ interface DynamicComponentProps { isSelected?: boolean; isHovered?: boolean; onMouseDown?: (e: React.MouseEvent) => void; + /** Right click: the canvas opens the properties + pins dialog here. */ + onContextMenu?: (e: React.MouseEvent) => void; onDoubleClick?: (e: React.MouseEvent) => void; onMouseEnter?: () => void; onMouseLeave?: () => void; @@ -280,6 +282,7 @@ export const DynamicComponent: React.FC = ({ isSelected = false, isHovered = false, onMouseDown, + onContextMenu, onDoubleClick, onMouseEnter, onMouseLeave, @@ -785,13 +788,18 @@ export const DynamicComponent: React.FC = ({ // while simulation is live. return (
= ({ transformOrigin: 'center center', }} onMouseDownCapture={handleMouseDown} + // Capture phase: interactive parts (pushbutton, switch, pot) stop + // propagation in their own handlers, which would otherwise swallow the + // right click before the canvas ever saw it. + onContextMenuCapture={onContextMenu} onTouchStartCapture={(e) => { // Mobile mirror of the ownsPointer guard: while the sim runs, a // finger on a declared touch screen is INPUT for the screen (its diff --git a/frontend/src/components/simulator/SimulatorCanvas.css b/frontend/src/components/simulator/SimulatorCanvas.css index 24d63131..796111c7 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.css +++ b/frontend/src/components/simulator/SimulatorCanvas.css @@ -537,3 +537,49 @@ outline-offset: 1px; border-radius: 4px; } + +/* Selected component: marching ants. + A newly added part lands in a corner of the viewport and the previous + flat dashed border was easy to miss on a busy canvas — a moving outline + is what the eye actually catches. Drawn on a pseudo-element just OUTSIDE + the body so it never covers the artwork, and pointer-events: none so it + never steals a click or a pin. + + Animating a dashed border is not possible (dash offset is not an + animatable property), hence the four gradient edges with a moving + background-position — the standard recipe. */ +.velxio-ants::after { + content: ''; + position: absolute; + inset: -3px; + border-radius: 5px; + pointer-events: none; + z-index: 6; + background-image: + linear-gradient(90deg, var(--ant-color) 50%, transparent 0), + linear-gradient(90deg, var(--ant-color) 50%, transparent 0), + linear-gradient(0deg, var(--ant-color) 50%, transparent 0), + linear-gradient(0deg, var(--ant-color) 50%, transparent 0); + background-repeat: repeat-x, repeat-x, repeat-y, repeat-y; + background-size: 10px 2px, 10px 2px, 2px 10px, 2px 10px; + background-position: 0 0, 0 100%, 0 0, 100% 0; + animation: velxio-ants 0.55s linear infinite; +} + +.velxio-ants { + --ant-color: #2f9bff; +} + +@keyframes velxio-ants { + to { + background-position: 10px 0, -10px 100%, 0 -10px, 100% 10px; + } +} + +/* Respect the OS setting: the outline still marks the selection, it just + stops moving. */ +@media (prefers-reduced-motion: reduce) { + .velxio-ants::after { + animation: none; + } +} diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index b50a4a8c..f0df89ca 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -35,6 +35,7 @@ import { SeatedPinMarkers } from './SeatedPinMarkers'; import { calculatePinPosition } from '../../utils/pinPositionCalculator'; import { isBoardComponent, boardPinToNumber } from '../../utils/boardPinMapping'; import { isBreadboard } from '../../utils/breadboardNets'; +import { pickDropSlot } from '../../utils/dropSlot'; import { autoWireColor, railWireColor, @@ -1433,19 +1434,21 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { ? toWorld(rect.left + screenMargin, rect.top + screenMargin) : { x: 100, y: 100 }; - // Tile additional drops so they don't stack exactly on top of each other, - // while still landing inside the viewport. - const tileStep = 40 / z; // 40 screen-px between successive drops - const cols = 4; - const idx = components.length; - const x = worldOrigin.x + (idx % cols) * tileStep; - const y = worldOrigin.y + Math.floor(idx / cols) * tileStep; + // Cascade down-right from that corner, taking the first FREE slot — see + // utils/dropSlot for why it is keyed on what is parked there rather than + // on how many components the project has. + const { x, y } = pickDropSlot(worldOrigin, components, { step: 36 / z }); const component = createComponentFromMetadata(metadata, x, y); trackAddComponent(metadata.id); // Recorded — user can Ctrl+Z to remove the just-added component. recordAddComponent(component as Parameters[0]); setShowComponentPicker(false); + // Select it: the marching ants are the answer to "where did it go?". + // Landing in the corner of the viewport is only half the fix — on a busy + // canvas a new part still disappears among the others unless something + // moves to mark it. + setSelectedComponentId(component.id); // Custom Chips need a compile step before they can do anything — open the // designer dialog immediately so the user lands in the editor. @@ -1817,14 +1820,16 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { return false; })() ) { - // handled — wire selected instead of opening the dialog + // handled — wire selected instead of selecting the component } else { - setPropertyDialogComponentId(draggedComponentId); - setPropertyDialogPosition({ - x: component.x * zoomRef.current + panRef.current.x, - y: component.y * zoomRef.current + panRef.current.y, - }); - setShowPropertyDialog(true); + // A plain left click SELECTS (marching ants) and nothing more. + // It used to open the property dialog, which meant you could + // not point at a part — every glance cost a popup you then had + // to dismiss, and after adding a component you could not tell + // which one was yours. Properties and pins now live on the + // right-click menu, where a destructive-ish, deliberate action + // belongs. (Touch keeps tap → dialog: there is no right button.) + setSelectedComponentId(draggedComponentId); } } } @@ -2381,6 +2386,17 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { onMouseDown={(e) => { handleComponentMouseDown(component.id, e); }} + onContextMenu={(e) => { + // Right click is where properties and pins live now. Selecting + // first means the ants mark what the dialog is about to edit. + e.preventDefault(); + e.stopPropagation(); + if (interactionRunning) return; + setSelectedComponentId(component.id); + setPropertyDialogComponentId(component.id); + setPropertyDialogPosition({ x: e.clientX, y: e.clientY }); + setShowPropertyDialog(true); + }} /> {/* Green dots on pins plugged into a breadboard — always visible so diff --git a/frontend/src/utils/dropSlot.ts b/frontend/src/utils/dropSlot.ts new file mode 100644 index 00000000..67c0dd57 --- /dev/null +++ b/frontend/src/utils/dropSlot.ts @@ -0,0 +1,55 @@ +/** + * Where a newly added component lands on the canvas. + * + * Two things went wrong before this existed. New parts were placed at fixed + * world coordinates, so once the canvas was panned they appeared outside the + * viewport — added, invisible, apparently broken. And the cascade that kept + * them from stacking was keyed on how many components the project had, so it + * marched on forever: move a part away or delete it and the next drop still + * landed further down-right, drifting away from where you were looking. + * + * The rule here is simpler and matches what people expect: start at the + * top-left of the VISIBLE canvas and take the first free slot, stepping + * down-right while something is still parked on one. Move the last part + * aside and the next drop reclaims its place. + */ + +export interface PlacedItem { + x: number; + y: number; +} + +export interface DropSlotOptions { + /** How far apart consecutive drops sit, in world units. */ + step: number; + /** Give up cascading after this many occupied slots and stack at the last + * one — past a dozen untouched drops the cascade would leave the viewport + * anyway, which is the very problem this solves. */ + maxSlots?: number; +} + +/** + * First free slot at or after `origin`, cascading down-right. + * + * "Free" means no item is parked within half a step of it, so a component + * the user nudged aside no longer counts as occupying its old slot. + */ +export function pickDropSlot( + origin: PlacedItem, + placed: readonly PlacedItem[], + { step, maxSlots = 12 }: DropSlotOptions, +): PlacedItem { + const tolerance = Math.abs(step) * 0.5; + const occupied = (px: number, py: number): boolean => + placed.some( + (item) => Math.abs(item.x - px) < tolerance && Math.abs(item.y - py) < tolerance, + ); + + let x = origin.x; + let y = origin.y; + for (let slot = 0; slot < maxSlots && occupied(x, y); slot++) { + x = origin.x + (slot + 1) * step; + y = origin.y + (slot + 1) * step; + } + return { x, y }; +}