feat(canvas): ver donde cae lo que anades, y clicks con sentido

Tres cambios que van juntos porque atacan la misma queja: "anado un
elemento y a veces ni veo donde se anadio".

1. Donde cae. Ya se anclaba a la esquina visible, pero la cascada que evita
   que se apilen iba indexada por components.length: seguia avanzando
   aunque movieras o borraras piezas, asi que las caidas se alejaban cada
   vez mas de donde estabas mirando. Ahora toma el primer hueco LIBRE desde
   la esquina, bajando en diagonal; si apartas la ultima, la siguiente
   recupera su sitio. Extraido a utils/dropSlot con 8 tests, incluido el
   caso de "la apartaron" y el tope para no salirse de la vista.

2. Que se vea. El recien anadido queda seleccionado, y la seleccion pasa de
   un borde discontinuo quieto a un caminito de hormigas. El movimiento es
   lo que capta el ojo en un canvas lleno; un borde fijo se pierde. Va en
   un pseudo-elemento por fuera del cuerpo, sin robar clicks ni tapar el
   dibujo, y se queda quieto si el sistema pide menos animacion.

3. Clicks. El izquierdo SELECCIONA y ya esta; antes abria el panel de
   propiedades, o sea que no podias ni senalar una pieza sin comerte un
   popup que luego habia que cerrar. Propiedades y pines pasan al click
   derecho, que es donde va lo deliberado. En tactil se mantiene tocar ->
   panel, que ahi no hay boton derecho.
This commit is contained in:
David Montero Crespo 2026-07-30 15:40:52 +02:00
parent b567ba2faf
commit fdcf0d19d2
5 changed files with 220 additions and 16 deletions

View File

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

View File

@ -265,6 +265,8 @@ interface DynamicComponentProps {
isSelected?: boolean; isSelected?: boolean;
isHovered?: boolean; isHovered?: boolean;
onMouseDown?: (e: React.MouseEvent) => void; 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; onDoubleClick?: (e: React.MouseEvent) => void;
onMouseEnter?: () => void; onMouseEnter?: () => void;
onMouseLeave?: () => void; onMouseLeave?: () => void;
@ -280,6 +282,7 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
isSelected = false, isSelected = false,
isHovered = false, isHovered = false,
onMouseDown, onMouseDown,
onContextMenu,
onDoubleClick, onDoubleClick,
onMouseEnter, onMouseEnter,
onMouseLeave, onMouseLeave,
@ -785,13 +788,18 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
// while simulation is live. // while simulation is live.
return ( return (
<div <div
className={`dynamic-component-wrapper${isBurnt ? ' velxio-burnt' : ''}`} className={`dynamic-component-wrapper${isBurnt ? ' velxio-burnt' : ''}${
isSelected ? ' velxio-ants' : ''
}`}
style={{ style={{
position: 'absolute', position: 'absolute',
left: `${x}px`, left: `${x}px`,
top: `${y}px`, top: `${y}px`,
cursor: interactionRunning && isInteractive ? 'pointer' : 'move', cursor: interactionRunning && isInteractive ? 'pointer' : 'move',
border: isSelected ? '2px dashed #007acc' : '2px solid transparent', // The selection outline itself is the .velxio-ants pseudo-element
// (a static dashed border cannot be animated). The transparent
// border stays so selecting does not shift the body by 2px.
border: '2px solid transparent',
borderRadius: '4px', borderRadius: '4px',
padding: '4px', padding: '4px',
userSelect: 'none', userSelect: 'none',
@ -801,6 +809,10 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
transformOrigin: 'center center', transformOrigin: 'center center',
}} }}
onMouseDownCapture={handleMouseDown} 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) => { onTouchStartCapture={(e) => {
// Mobile mirror of the ownsPointer guard: while the sim runs, a // Mobile mirror of the ownsPointer guard: while the sim runs, a
// finger on a declared touch screen is INPUT for the screen (its // finger on a declared touch screen is INPUT for the screen (its

View File

@ -537,3 +537,49 @@
outline-offset: 1px; outline-offset: 1px;
border-radius: 4px; 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;
}
}

View File

@ -35,6 +35,7 @@ import { SeatedPinMarkers } from './SeatedPinMarkers';
import { calculatePinPosition } from '../../utils/pinPositionCalculator'; import { calculatePinPosition } from '../../utils/pinPositionCalculator';
import { isBoardComponent, boardPinToNumber } from '../../utils/boardPinMapping'; import { isBoardComponent, boardPinToNumber } from '../../utils/boardPinMapping';
import { isBreadboard } from '../../utils/breadboardNets'; import { isBreadboard } from '../../utils/breadboardNets';
import { pickDropSlot } from '../../utils/dropSlot';
import { import {
autoWireColor, autoWireColor,
railWireColor, railWireColor,
@ -1433,19 +1434,21 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
? toWorld(rect.left + screenMargin, rect.top + screenMargin) ? toWorld(rect.left + screenMargin, rect.top + screenMargin)
: { x: 100, y: 100 }; : { x: 100, y: 100 };
// Tile additional drops so they don't stack exactly on top of each other, // Cascade down-right from that corner, taking the first FREE slot — see
// while still landing inside the viewport. // utils/dropSlot for why it is keyed on what is parked there rather than
const tileStep = 40 / z; // 40 screen-px between successive drops // on how many components the project has.
const cols = 4; const { x, y } = pickDropSlot(worldOrigin, components, { step: 36 / z });
const idx = components.length;
const x = worldOrigin.x + (idx % cols) * tileStep;
const y = worldOrigin.y + Math.floor(idx / cols) * tileStep;
const component = createComponentFromMetadata(metadata, x, y); const component = createComponentFromMetadata(metadata, x, y);
trackAddComponent(metadata.id); trackAddComponent(metadata.id);
// Recorded — user can Ctrl+Z to remove the just-added component. // Recorded — user can Ctrl+Z to remove the just-added component.
recordAddComponent(component as Parameters<typeof recordAddComponent>[0]); recordAddComponent(component as Parameters<typeof recordAddComponent>[0]);
setShowComponentPicker(false); 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 // Custom Chips need a compile step before they can do anything — open the
// designer dialog immediately so the user lands in the editor. // designer dialog immediately so the user lands in the editor.
@ -1817,14 +1820,16 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
return false; return false;
})() })()
) { ) {
// handled — wire selected instead of opening the dialog // handled — wire selected instead of selecting the component
} else { } else {
setPropertyDialogComponentId(draggedComponentId); // A plain left click SELECTS (marching ants) and nothing more.
setPropertyDialogPosition({ // It used to open the property dialog, which meant you could
x: component.x * zoomRef.current + panRef.current.x, // not point at a part — every glance cost a popup you then had
y: component.y * zoomRef.current + panRef.current.y, // to dismiss, and after adding a component you could not tell
}); // which one was yours. Properties and pins now live on the
setShowPropertyDialog(true); // 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) => { onMouseDown={(e) => {
handleComponentMouseDown(component.id, 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 {/* Green dots on pins plugged into a breadboard always visible so

View File

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