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:
parent
94bfe627d3
commit
cb26e36b28
|
|
@ -295,6 +295,10 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
|
||||||
const handleComponentEvent = useSimulatorStore((s) => s.handleComponentEvent);
|
const handleComponentEvent = useSimulatorStore((s) => s.handleComponentEvent);
|
||||||
const running = useSimulatorStore((s) => s.running);
|
const running = useSimulatorStore((s) => s.running);
|
||||||
const simulator = useSimulatorStore((s) => s.simulator);
|
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
|
// Board-less SPICE circuits (digital / analog gallery) have no MCU to
|
||||||
// run, so `running` is always false — but interactive parts like
|
// run, so `running` is always false — but interactive parts like
|
||||||
// slide-switches and pushbuttons should still show a pointer cursor
|
// slide-switches and pushbuttons should still show a pointer cursor
|
||||||
|
|
@ -803,7 +807,10 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
padding: '4px',
|
padding: '4px',
|
||||||
userSelect: 'none',
|
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',
|
pointerEvents: 'auto',
|
||||||
transform: properties.rotation ? `rotate(${properties.rotation}deg)` : undefined,
|
transform: properties.rotation ? `rotate(${properties.rotation}deg)` : undefined,
|
||||||
transformOrigin: 'center center',
|
transformOrigin: 'center center',
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,8 @@ export const BoardOnCanvas = ({
|
||||||
() => isBoardSeated(id, boardKind, x, y, components),
|
() => isBoardSeated(id, boardKind, x, y, components),
|
||||||
[id, boardKind, x, y, components, seatEpoch],
|
[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
|
// Status dot color: green=running, amber=compiled, gray=idle
|
||||||
const statusColor = board.running ? '#22c55e' : board.compiledProgram ? '#f59e0b' : '#6b7280';
|
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) —
|
// 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
|
// 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.
|
// 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
|
// Two exceptions:
|
||||||
// Display back header): then the board is the thing you see, the way
|
// - a board SEATED on a socket component (the Round Display / reSpeaker
|
||||||
// the physical XIAO stacks on the shield. The magnet snap places the
|
// back header): then the board is the thing you see, the way the
|
||||||
// board exactly on the seat, so the moment it clicks it pops on top,
|
// physical XIAO stacks on the shield;
|
||||||
// and dragging it off drops it back under.
|
// - a board the user has DRAGGED (zRaise > 0): drag-to-front puts
|
||||||
style={{ position: 'absolute', left: 0, top: 0, zIndex: seated ? 3 : 0 }}
|
// 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}
|
onMouseEnter={onMouseEnter}
|
||||||
onMouseLeave={onMouseLeave}
|
onMouseLeave={onMouseLeave}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -339,6 +339,9 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
|
||||||
|
|
||||||
// Component dragging state
|
// Component dragging state
|
||||||
const [draggedComponentId, setDraggedComponentId] = useState<string | null>(null);
|
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
|
// 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
|
// can record the diff as a single undoable Move. Boards are intentionally
|
||||||
// skipped — board moves don't go through component history.
|
// skipped — board moves don't go through component history.
|
||||||
|
|
@ -1529,6 +1532,17 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
|
||||||
|
|
||||||
// Handle component/board dragging
|
// Handle component/board dragging
|
||||||
if (draggedComponentId) {
|
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);
|
const world = toWorld(e.clientX, e.clientY);
|
||||||
if (draggedComponentId.startsWith('__board__:')) {
|
if (draggedComponentId.startsWith('__board__:')) {
|
||||||
const boardId = draggedComponentId.slice('__board__:'.length);
|
const boardId = draggedComponentId.slice('__board__:'.length);
|
||||||
|
|
@ -1855,6 +1869,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
|
||||||
|
|
||||||
recalculateAllWirePositions();
|
recalculateAllWirePositions();
|
||||||
setDraggedComponentId(null);
|
setDraggedComponentId(null);
|
||||||
|
raisedThisDragRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -2809,6 +2824,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
|
||||||
isPanningRef.current = false;
|
isPanningRef.current = false;
|
||||||
setPan({ ...panRef.current });
|
setPan({ ...panRef.current });
|
||||||
setDraggedComponentId(null);
|
setDraggedComponentId(null);
|
||||||
|
raisedThisDragRef.current = null;
|
||||||
}}
|
}}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
|
||||||
|
|
@ -1016,6 +1016,23 @@ interface SimulatorState {
|
||||||
updateWirePositions: (componentId: string) => void;
|
updateWirePositions: (componentId: string) => void;
|
||||||
recalculateAllWirePositions: () => 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 ────────────────────────────────────────────────────────────
|
// ── Undo/redo ────────────────────────────────────────────────────────────
|
||||||
/** Bounded ring buffer of canvas mutations (HISTORY_MAX = 50). */
|
/** Bounded ring buffer of canvas mutations (HISTORY_MAX = 50). */
|
||||||
history: CanvasCommand[];
|
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: () => {
|
recalculateAllWirePositions: () => {
|
||||||
const state = get();
|
const state = get();
|
||||||
const updatedWires = state.wires.map((wire) => {
|
const updatedWires = state.wires.map((wire) => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue