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:
parent
b567ba2faf
commit
fdcf0d19d2
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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<DynamicComponentProps> = ({
|
|||
isSelected = false,
|
||||
isHovered = false,
|
||||
onMouseDown,
|
||||
onContextMenu,
|
||||
onDoubleClick,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
|
|
@ -785,13 +788,18 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
|
|||
// while simulation is live.
|
||||
return (
|
||||
<div
|
||||
className={`dynamic-component-wrapper${isBurnt ? ' velxio-burnt' : ''}`}
|
||||
className={`dynamic-component-wrapper${isBurnt ? ' velxio-burnt' : ''}${
|
||||
isSelected ? ' velxio-ants' : ''
|
||||
}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
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',
|
||||
padding: '4px',
|
||||
userSelect: 'none',
|
||||
|
|
@ -801,6 +809,10 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof recordAddComponent>[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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
Loading…
Reference in New Issue