From cb26e36b280ec761fa4a2f69cadb66adea59b577 Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Fri, 31 Jul 2026 16:02:26 +0200 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20drag-to-front=20=E2=80=94=20wha?= =?UTF-8?q?tever=20you=20dragged=20last=20paints=20on=20top?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/components/DynamicComponent.tsx | 9 ++++++- .../components/simulator/BoardOnCanvas.tsx | 22 +++++++++++----- .../components/simulator/SimulatorCanvas.tsx | 16 ++++++++++++ frontend/src/store/useSimulatorStore.ts | 26 +++++++++++++++++++ 4 files changed, 66 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/DynamicComponent.tsx b/frontend/src/components/DynamicComponent.tsx index 6073b908..377dab23 100644 --- a/frontend/src/components/DynamicComponent.tsx +++ b/frontend/src/components/DynamicComponent.tsx @@ -295,6 +295,10 @@ export const DynamicComponent: React.FC = ({ 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 = ({ 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', diff --git a/frontend/src/components/simulator/BoardOnCanvas.tsx b/frontend/src/components/simulator/BoardOnCanvas.tsx index aa6eb4da..3eeb120a 100644 --- a/frontend/src/components/simulator/BoardOnCanvas.tsx +++ b/frontend/src/components/simulator/BoardOnCanvas.tsx @@ -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} > diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index 8fdeac45..9cc45fcf 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -339,6 +339,9 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { // Component dragging state const [draggedComponentId, setDraggedComponentId] = useState(null); + // Which drag session already raised its item (drag-to-front fires once per + // drag, on the first mousemove). + const raisedThisDragRef = useRef(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(); diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index 28cf5f04..4240d58d 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -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; + /** 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((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) => {