diff --git a/ApiCore-Frontend/src/components/hive/editor/use-hive-drag.spec.ts b/ApiCore-Frontend/src/components/hive/editor/use-hive-drag.spec.ts index 41c299e..01de84a 100644 --- a/ApiCore-Frontend/src/components/hive/editor/use-hive-drag.spec.ts +++ b/ApiCore-Frontend/src/components/hive/editor/use-hive-drag.spec.ts @@ -122,6 +122,23 @@ describe("useHiveDrag", () => { expect(apply).toHaveBeenCalledWith([{ op: "movePart", id: "c", to: 4 }]); }); + it("cancels a press or drag when a second finger comes down (a pinch starts, no part drag)", () => { + const { result, apply, announce, begin, move, up } = setup(); + begin({ partId: "c" }); + act(() => void window.dispatchEvent(pointer("pointerdown", 300, 100, 2))); + move(160); + expect(result.current.drag).toBeNull(); + + begin({ partId: "c" }); + move(160); + expect(result.current.drag).not.toBeNull(); + act(() => void window.dispatchEvent(pointer("pointerdown", 300, 100, 2))); + expect(result.current.drag).toBeNull(); + expect(announce).toHaveBeenLastCalledWith("Ziehen abgebrochen."); + up(160); + expect(apply).not.toHaveBeenCalled(); + }); + it("cancels when the window loses focus (Alt+Tab) or the tab is hidden", () => { const first = setup(); first.begin({ partTypeId: "box" }); diff --git a/ApiCore-Frontend/src/components/hive/editor/use-hive-drag.ts b/ApiCore-Frontend/src/components/hive/editor/use-hive-drag.ts index 65191dc..8d0750f 100644 --- a/ApiCore-Frontend/src/components/hive/editor/use-hive-drag.ts +++ b/ApiCore-Frontend/src/components/hive/editor/use-hive-drag.ts @@ -11,7 +11,8 @@ import type { DraftApplyResult } from "./use-hive-draft"; // so mouse, pen and finger behave the same. A press becomes a drag after a // few pixels (a tap stays a tap); the scene reports the slot under the // pointer; release drops there, on „Entfernen“ removes; Esc, leaving the -// window (Alt+Tab, hidden tab) or a mouse released outside cancel. +// window (Alt+Tab, hidden tab), a mouse released outside or a second finger +// (pinch) cancel. // Window listeners, no pointer capture: a finger is captured to the element it // started on anyway, so the scene reads the position from `pointer`. @@ -180,6 +181,11 @@ export function useHiveDrag(options: UseHiveDragOptions): HiveDrag { if (samePointer(event)) end(); }; + // A second finger means a pinch zoom: the part under the first finger is not dragged. + const onDown = (event: Event) => { + if (!samePointer(event)) abort(); + }; + /** Ends without dropping; says so when a drag was running. */ function abort() { const active = dragRef.current !== null; @@ -201,6 +207,7 @@ export function useHiveDrag(options: UseHiveDragOptions): HiveDrag { } }; + window.addEventListener("pointerdown", onDown); window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onUp); window.addEventListener("pointercancel", onCancel); @@ -210,6 +217,7 @@ export function useHiveDrag(options: UseHiveDragOptions): HiveDrag { return () => { window.removeEventListener("blur", onBlur); document.removeEventListener("visibilitychange", onVisibility); + window.removeEventListener("pointerdown", onDown); window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onUp); window.removeEventListener("pointercancel", onCancel); diff --git a/ApiCore-Frontend/src/components/hive/frame-feeder-picker.tsx b/ApiCore-Frontend/src/components/hive/frame-feeder-picker.tsx index e232a5d..76bf268 100644 --- a/ApiCore-Frontend/src/components/hive/frame-feeder-picker.tsx +++ b/ApiCore-Frontend/src/components/hive/frame-feeder-picker.tsx @@ -1,7 +1,7 @@ "use client"; import { ChoiceGroup } from "@/components/form/choice-group"; -import { coveredSlots, feederRange, frameFeederOps, rangeText } from "@/lib/hive/feeder-slots"; +import { coveredSlots, feederRange, rangeText } from "@/lib/hive/feeder-slots"; import type { CatalogPartType, HiveFrame } from "@/lib/hive/types"; import { StateFieldControl } from "./state-field-control"; import type { FieldDraft } from "./state-field-control"; @@ -25,9 +25,6 @@ function rangeAt(start: number, type: CatalogPartType) { return feederRange({ id: "", partTypeId: type.id, state: {}, slot: start }, type); } -/** Kept for existing imports; lives in lib/hive/feeder-slots. */ -export { frameFeederOps }; - interface FrameFeederPickerProps { boxId: string; type: CatalogPartType; diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/drop-targets.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/drop-targets.tsx index a18527b..5eb605b 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/drop-targets.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-scene/drop-targets.tsx @@ -126,12 +126,12 @@ export function DropTargets({ anchors, hover, pointer, subscribePointer, onHover }; }, [materials]); - // The canvas rect only changes with the canvas size (the editor does not scroll). + // The canvas rect changes with the canvas size and position (r3f measures scroll and resize). const rect = useRef(null); useEffect(() => { const element = gl.domElement as HTMLElement | undefined; rect.current = element?.getBoundingClientRect ? element.getBoundingClientRect() : null; - }, [gl, size.width, size.height]); + }, [gl, size.width, size.height, size.top, size.left]); // Render only when the pointer moved; flights and the opening stack request their own frames. useEffect(() => subscribePointer(() => invalidate()), [subscribePointer, invalidate]); diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/hive-scene-content.spec.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/hive-scene-content.spec.tsx index bd06ea9..6278042 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/hive-scene-content.spec.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-scene/hive-scene-content.spec.tsx @@ -20,6 +20,17 @@ import { SceneKitProvider } from "./scene-kit"; // The scene content runs on the r3f test renderer (no WebGL). Labels are DOM // portals (drei Html), so they are replaced by a marker here. const anchorCalls = vi.hoisted(() => ({ count: 0 })); +const motionCalls = vi.hoisted(() => ({ count: 0 })); +vi.mock("./use-hive-motion", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + hiveMotionTargets: (...args: Parameters) => { + motionCalls.count += 1; + return actual.hiveMotionTargets(...args); + }, + }; +}); vi.mock("./drop-anchors", async (importOriginal) => { const actual = await importOriginal(); return { @@ -291,6 +302,22 @@ describe("HiveSceneContent in the editor", () => { expect(stopPropagation).toHaveBeenCalled(); }); + it("takes a press on a frame of the open box there: the host box does not start a drag", async () => { + const editor = editorOf(); + const { renderer, store } = await renderScene("full", editor); + await act(async () => store.openBox("b1")); + const stopPropagation = vi.fn(); + await renderer.fireEvent(hit(renderer, "b1#4"), "onPointerDown", { + pointerType: "mouse", + pointerId: 3, + clientX: 10, + clientY: 20, + stopPropagation, + }); + expect(stopPropagation).toHaveBeenCalled(); + expect(editor.onPartPress).not.toHaveBeenCalled(); + }); + it("shows one glowing marker per legal slot while dragging and opens the stack", async () => { const rest = await renderScene("off"); await rest.renderer.advanceFrames(1, 1 / 60); @@ -309,7 +336,7 @@ describe("HiveSceneContent in the editor", () => { }); describe("HiveSceneContent drop anchors", () => { - it("computes the slot anchors once per drag, not again on every hover change", async () => { + it("computes the slot anchors and motion targets once per drag, not again on every hover change", async () => { const state = hiveState(); const layout = hiveLayout(state, catalog); const store = createHiveViewStore(); @@ -341,11 +368,15 @@ describe("HiveSceneContent drop anchors", () => { ); anchorCalls.count = 0; + motionCalls.count = 0; const renderer = await ReactThreeTestRenderer.create(element(editor(null))); const afterMount = anchorCalls.count; + const motionAfterMount = motionCalls.count; await renderer.update(element(editor(slots[0]))); await renderer.update(element(editor(slots[1]))); expect(anchorCalls.count).toBe(afterMount); + // The motion targets depend on the gap and the moved part, not on the hover. + expect(motionCalls.count).toBe(motionAfterMount); await renderer.unmount(); }); }); diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/hive-scene-content.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/hive-scene-content.tsx index 0e6ff1b..b691a47 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/hive-scene-content.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-scene/hive-scene-content.tsx @@ -110,15 +110,15 @@ export function HiveSceneContent({ const motionView = useMemo(() => ({ ...view, selectedFrame }), [view, selectedFrame]); const drag = editor?.drag ?? null; const gap = drag ? DRAG_GAP : REST_GAP; + const movedId = drag && "partId" in drag.source ? drag.source.partId : null; const targets: HiveMotionTargets = useMemo(() => { const computed = hiveMotionTargets(layout, motionView, gap); - const movedId = drag && "partId" in drag.source ? drag.source.partId : null; const moved = movedId ? computed.nodes.get(movedId) : undefined; if (!movedId || !moved) return computed; const nodes = new Map(computed.nodes); nodes.set(movedId, { ...moved, opacity: Math.min(moved.opacity, DRAGGED_OPACITY), interactive: false }); return { ...computed, nodes }; - }, [layout, motionView, gap, drag]); + }, [layout, motionView, gap, movedId]); // Once per drag: the hover changes the drag object, not its source or its slots. const dragSource = drag?.source ?? null; const dragSlots = drag?.slots ?? null; @@ -199,9 +199,10 @@ export function HiveSceneContent({ onPointerDown(event) { pointerType.current = event.pointerType ?? "mouse"; const parsed = parseKey(key); + // Only the nearest hit takes the press (inserts and frames inside a box, thin parts on top). + // Frames are not dragged: a press on one must not reach the box around it. + if (editor && parsed) event.stopPropagation(); if (editor && parsed?.kind === "part") { - // Only the nearest hit takes the press (inserts and frames inside a box, thin parts on top). - event.stopPropagation(); if (signals.pinching) return; // A press on a part may become a part drag: the rig must not turn the model for it. markPartPress(signals, true);