diff --git a/frontend/src/__tests__/drop-slot.test.ts b/frontend/src/__tests__/drop-slot.test.ts index fa784865..b605d9cd 100644 --- a/frontend/src/__tests__/drop-slot.test.ts +++ b/frontend/src/__tests__/drop-slot.test.ts @@ -66,6 +66,27 @@ describe('pickDropSlot', () => { expect(slot).toEqual({ x: ORIGIN.x + 12 * STEP, y: ORIGIN.y + 12 * STEP }); }); + it('treats boards and components as one set of parked items', () => { + // Boards used to be placed 420 world-px right of the active board plus a + // per-board offset — never in the visible corner, and marching further + // out with every board added. The canvas now feeds boards and components + // through the same rule, so a board parked in the corner pushes the next + // component to the next slot and a component pushes the next board. + const boardInCorner = [{ x: ORIGIN.x, y: ORIGIN.y, boardKind: 'arduino-uno' }]; + expect(pickDropSlot(ORIGIN, boardInCorner, { step: STEP })).toEqual({ + x: ORIGIN.x + STEP, + y: ORIGIN.y + STEP, + }); + const mixed = [ + { x: ORIGIN.x, y: ORIGIN.y, metadataId: 'led' }, + { x: ORIGIN.x + STEP, y: ORIGIN.y + STEP, boardKind: 'esp32' }, + ]; + expect(pickDropSlot(ORIGIN, mixed, { step: STEP })).toEqual({ + x: ORIGIN.x + 2 * STEP, + y: ORIGIN.y + 2 * 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. diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index c6d86c35..5e0c5c07 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -1503,22 +1503,30 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { return () => window.removeEventListener('keydown', handleKeyDown); }, [selectedComponentId, recordRemoveComponent]); - // Handle component selection from modal - const handleSelectComponent = (metadata: ComponentMetadata) => { - // Anchor new components to the visible top-left of the canvas, so they - // appear in the user's current viewport regardless of pan/zoom (instead - // of growing off-screen at fixed world coords like (400, 100 + row*250)). + // Where the next thing added from the picker lands — components AND boards + // share this, so both start at the same corner and cascade over the same + // occupied slots (a board dropped in the corner counts as parking there for + // the next part, and vice versa). + // + // Anchored to the visible top-left of the canvas, so it appears in the + // user's current viewport regardless of pan/zoom (instead of growing + // off-screen at fixed world coords like (400, 100 + row*250)). From that + // corner it cascades down-right, taking the first FREE slot — see + // utils/dropSlot for why it is keyed on what is parked there rather than on + // how many items the project has. + const nextDropSlot = (): { x: number; y: number } => { const rect = canvasRef.current?.getBoundingClientRect(); const z = zoomRef.current || 1; const screenMargin = 60; // px on screen — keeps the part off the toolbar/edge const worldOrigin = rect ? toWorld(rect.left + screenMargin, rect.top + screenMargin) : { x: 100, y: 100 }; + return pickDropSlot(worldOrigin, [...components, ...boards], { step: 36 / z }); + }; - // 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 }); + // Handle component selection from modal + const handleSelectComponent = (metadata: ComponentMetadata) => { + const { x, y } = nextDropSlot(); const component = createComponentFromMetadata(metadata, x, y); trackAddComponent(metadata.id); @@ -3581,13 +3589,15 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { return; } trackSelectBoard(kind); - const sameKind = boards.filter((b) => b.boardKind === kind); - const newBoardId = sameKind.length === 0 ? kind : `${kind}-${sameKind.length + 1}`; - const x = boardPosition.x + boards.length * 60 + 420; - const y = boardPosition.y + boards.length * 30; + // Same landing rule as components: the visible corner, first free + // slot. Boards used to be placed 420 world-px right of the ACTIVE + // board plus 60/30 px per board already on the canvas — a fixed + // world offset that ignored pan/zoom (so the new board could land + // off-screen) and that kept marching further out as boards were + // added, even after they had been moved away or removed. + const { x, y } = nextDropSlot(); addBoard(kind, x, y); // file group is created inside addBoard - void newBoardId; }} />