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