feat(canvas): drag-to-front — whatever you dragged last paints on top

An unseated board dropped over a component was painted underneath it
(boards z 0, components z 1) and became impossible to grab back; the
earlier blanket z bump for boards broke the opposite case, hiding LEDs
and transistors behind every board. Neither static order can win both.

The rule is now the user's own gesture: the FIRST movement of a drag
raises that item (board or part, symmetrically) to the top of a
monotonic dragged-stack, so a board dropped over an LED sits visibly on
top — and dragging the LED afterwards wins the stack right back.
Click-select alone never raises: selection must not reshuffle a scene
you arranged. Untouched items keep the static layering (components
above unseated boards, seated boards above their socket), and the rank
map is ephemeral — never saved with the project.
This commit is contained in:
David Montero Crespo 2026-07-31 16:02:26 +02:00
parent 94bfe627d3
commit cb26e36b28
4 changed files with 66 additions and 7 deletions

View File

@ -295,6 +295,10 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
const handleComponentEvent = useSimulatorStore((s) => s.handleComponentEvent);
const running = useSimulatorStore((s) => s.running);
const simulator = useSimulatorStore((s) => s.simulator);
// Drag-to-front rank: 0 = never dragged, static layering applies. A part
// the user dragged paints above everything it overlaps — including a board
// that was raised earlier — so the stack always reads "last dragged wins".
const zRaise = useSimulatorStore((s) => s.zOrders[id] ?? 0);
// Board-less SPICE circuits (digital / analog gallery) have no MCU to
// run, so `running` is always false — but interactive parts like
// slide-switches and pushbuttons should still show a pointer cursor
@ -803,7 +807,10 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
borderRadius: '4px',
padding: '4px',
userSelect: 'none',
zIndex: isSelected ? 5 : 1,
// Drag-to-front (zRaise) beats the static layers: a part dragged onto
// a board — or a board dragged onto a part — the last one dragged
// paints on top. Untouched parts keep the classic selected/idle z.
zIndex: zRaise > 0 ? 10 + zRaise : isSelected ? 5 : 1,
pointerEvents: 'auto',
transform: properties.rotation ? `rotate(${properties.rotation}deg)` : undefined,
transformOrigin: 'center center',

View File

@ -127,6 +127,8 @@ export const BoardOnCanvas = ({
() => isBoardSeated(id, boardKind, x, y, components),
[id, boardKind, x, y, components, seatEpoch],
);
// Drag-to-front rank: 0 = never dragged (static layering applies).
const zRaise = useSimulatorStore((st) => st.zOrders[id] ?? 0);
// Status dot color: green=running, amber=compiled, gray=idle
const statusColor = board.running ? '#22c55e' : board.compiledProgram ? '#f59e0b' : '#6b7280';
@ -211,12 +213,20 @@ export const BoardOnCanvas = ({
// Stacking: boards normally sit BELOW components (z 0 vs their 1/2) —
// a resistor next to an Arduino must be visible on top, and a blanket
// z bump here once hid it behind the board in every ordinary example.
// The ONE exception is a board SEATED on a socket component (the Round
// Display back header): then the board is the thing you see, the way
// the physical XIAO stacks on the shield. The magnet snap places the
// board exactly on the seat, so the moment it clicks it pops on top,
// and dragging it off drops it back under.
style={{ position: 'absolute', left: 0, top: 0, zIndex: seated ? 3 : 0 }}
// Two exceptions:
// - a board SEATED on a socket component (the Round Display / reSpeaker
// back header): then the board is the thing you see, the way the
// physical XIAO stacks on the shield;
// - a board the user has DRAGGED (zRaise > 0): drag-to-front puts
// whatever you dragged last above everything it overlaps, so a board
// dropped over a part is never lost underneath it — and dragging the
// part afterwards wins the stack right back.
style={{
position: 'absolute',
left: 0,
top: 0,
zIndex: zRaise > 0 ? 10 + zRaise : seated ? 3 : 0,
}}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>

View File

@ -339,6 +339,9 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
// Component dragging state
const [draggedComponentId, setDraggedComponentId] = useState<string | null>(null);
// Which drag session already raised its item (drag-to-front fires once per
// drag, on the first mousemove).
const raisedThisDragRef = useRef<string | null>(null);
// Captures (x, y) of the dragged component at mousedown so a drag-end
// can record the diff as a single undoable Move. Boards are intentionally
// skipped — board moves don't go through component history.
@ -1529,6 +1532,17 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
// Handle component/board dragging
if (draggedComponentId) {
// Drag-to-front: the FIRST movement of a drag raises the item above
// everything else on the canvas (boards over parts and parts over
// boards, symmetrically — whatever you dragged last wins). Movement,
// not mousedown: a plain click-select must not reshuffle the stack.
if (raisedThisDragRef.current !== draggedComponentId) {
raisedThisDragRef.current = draggedComponentId;
const raiseId = draggedComponentId.startsWith('__board__:')
? draggedComponentId.slice('__board__:'.length)
: draggedComponentId;
if (raiseId !== '__board__') useSimulatorStore.getState().raiseItem(raiseId);
}
const world = toWorld(e.clientX, e.clientY);
if (draggedComponentId.startsWith('__board__:')) {
const boardId = draggedComponentId.slice('__board__:'.length);
@ -1855,6 +1869,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
recalculateAllWirePositions();
setDraggedComponentId(null);
raisedThisDragRef.current = null;
}
};
@ -2809,6 +2824,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
isPanningRef.current = false;
setPan({ ...panRef.current });
setDraggedComponentId(null);
raisedThisDragRef.current = null;
}}
onContextMenu={(e) => {
e.preventDefault();

View File

@ -1016,6 +1016,23 @@ interface SimulatorState {
updateWirePositions: (componentId: string) => void;
recalculateAllWirePositions: () => void;
// ── Drag-to-front stacking ───────────────────────────────────────────────
/**
* Items the user has DRAGGED, in drag order: id -> monotonic rank. The
* canvas raises an item on the first movement of its drag, so whatever you
* dragged last paints above everything it overlaps boards over parts,
* parts over boards, symmetrically. Click-select alone never raises
* (deliberate: selection should not reshuffle a scene you arranged), and
* untouched items keep the static layering (components above unseated
* boards, seated boards above their socket). Ephemeral: not saved with the
* project.
*/
zOrders: Record<string, number>;
/** Highest rank handed out so far. */
zTop: number;
/** Move an item (board or component id) to the top of the dragged stack. */
raiseItem: (id: string) => void;
// ── Undo/redo ────────────────────────────────────────────────────────────
/** Bounded ring buffer of canvas mutations (HISTORY_MAX = 50). */
history: CanvasCommand[];
@ -3054,6 +3071,15 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
});
},
zOrders: {},
zTop: 0,
raiseItem: (id: string) => {
const s = get();
// Already on top: re-raising would only churn renders.
if (s.zOrders[id] === s.zTop && s.zTop > 0) return;
set({ zTop: s.zTop + 1, zOrders: { ...s.zOrders, [id]: s.zTop + 1 } });
},
recalculateAllWirePositions: () => {
const state = get();
const updatedWires = state.wires.map((wire) => {