fix: Beute-Ziehen bricht bei Pinch ab, Rähmchen-Druck zieht keine Zarge

- zweiter Finger während Druck oder Ziehen bricht ab (kein Ablegen)
- Druck auf ein Rähmchen im Editor erreicht die Zarge nicht mehr
- Canvas-Rechteck bei Scroll neu gemessen, Motion-Ziele nur bei
  Abstand oder bewegtem Teil neu berechnet, toter Re-Export entfernt

Refs #21

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 10:06:46 +02:00
1 parent 45274f4301
commit 19550d1394
6 files changed
+66 -12

No files matched your search

@@ -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" });
@@ -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);
@@ -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;
@@ -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<DOMRect | null>(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]);
@@ -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<typeof import("./use-hive-motion")>();
return {
...actual,
hiveMotionTargets: (...args: Parameters<typeof actual.hiveMotionTargets>) => {
motionCalls.count += 1;
return actual.hiveMotionTargets(...args);
},
};
});
vi.mock("./drop-anchors", async (importOriginal) => {
const actual = await importOriginal<typeof import("./drop-anchors")>();
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", () => {
</HiveViewProvider>
);
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();
});
});
@@ -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);
if (editor && parsed?.kind === "part") {
// Only the nearest hit takes the press (inserts and frames inside a box, thin parts on top).
event.stopPropagation();
// 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") {
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);