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:
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 }]);
|
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", () => {
|
it("cancels when the window loses focus (Alt+Tab) or the tab is hidden", () => {
|
||||||
const first = setup();
|
const first = setup();
|
||||||
first.begin({ partTypeId: "box" });
|
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
|
// 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
|
// few pixels (a tap stays a tap); the scene reports the slot under the
|
||||||
// pointer; release drops there, on „Entfernen“ removes; Esc, leaving 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
|
// Window listeners, no pointer capture: a finger is captured to the element it
|
||||||
// started on anyway, so the scene reads the position from `pointer`.
|
// 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();
|
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. */
|
/** Ends without dropping; says so when a drag was running. */
|
||||||
function abort() {
|
function abort() {
|
||||||
const active = dragRef.current !== null;
|
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("pointermove", onMove);
|
||||||
window.addEventListener("pointerup", onUp);
|
window.addEventListener("pointerup", onUp);
|
||||||
window.addEventListener("pointercancel", onCancel);
|
window.addEventListener("pointercancel", onCancel);
|
||||||
@@ -210,6 +217,7 @@ export function useHiveDrag(options: UseHiveDragOptions): HiveDrag {
|
|||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("blur", onBlur);
|
window.removeEventListener("blur", onBlur);
|
||||||
document.removeEventListener("visibilitychange", onVisibility);
|
document.removeEventListener("visibilitychange", onVisibility);
|
||||||
|
window.removeEventListener("pointerdown", onDown);
|
||||||
window.removeEventListener("pointermove", onMove);
|
window.removeEventListener("pointermove", onMove);
|
||||||
window.removeEventListener("pointerup", onUp);
|
window.removeEventListener("pointerup", onUp);
|
||||||
window.removeEventListener("pointercancel", onCancel);
|
window.removeEventListener("pointercancel", onCancel);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChoiceGroup } from "@/components/form/choice-group";
|
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 type { CatalogPartType, HiveFrame } from "@/lib/hive/types";
|
||||||
import { StateFieldControl } from "./state-field-control";
|
import { StateFieldControl } from "./state-field-control";
|
||||||
import type { FieldDraft } 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);
|
return feederRange({ id: "", partTypeId: type.id, state: {}, slot: start }, type);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Kept for existing imports; lives in lib/hive/feeder-slots. */
|
|
||||||
export { frameFeederOps };
|
|
||||||
|
|
||||||
interface FrameFeederPickerProps {
|
interface FrameFeederPickerProps {
|
||||||
boxId: string;
|
boxId: string;
|
||||||
type: CatalogPartType;
|
type: CatalogPartType;
|
||||||
|
|||||||
@@ -126,12 +126,12 @@ export function DropTargets({ anchors, hover, pointer, subscribePointer, onHover
|
|||||||
};
|
};
|
||||||
}, [materials]);
|
}, [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);
|
const rect = useRef<DOMRect | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const element = gl.domElement as HTMLElement | undefined;
|
const element = gl.domElement as HTMLElement | undefined;
|
||||||
rect.current = element?.getBoundingClientRect ? element.getBoundingClientRect() : null;
|
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.
|
// Render only when the pointer moved; flights and the opening stack request their own frames.
|
||||||
useEffect(() => subscribePointer(() => invalidate()), [subscribePointer, invalidate]);
|
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
|
// 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.
|
// portals (drei Html), so they are replaced by a marker here.
|
||||||
const anchorCalls = vi.hoisted(() => ({ count: 0 }));
|
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) => {
|
vi.mock("./drop-anchors", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("./drop-anchors")>();
|
const actual = await importOriginal<typeof import("./drop-anchors")>();
|
||||||
return {
|
return {
|
||||||
@@ -291,6 +302,22 @@ describe("HiveSceneContent in the editor", () => {
|
|||||||
expect(stopPropagation).toHaveBeenCalled();
|
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 () => {
|
it("shows one glowing marker per legal slot while dragging and opens the stack", async () => {
|
||||||
const rest = await renderScene("off");
|
const rest = await renderScene("off");
|
||||||
await rest.renderer.advanceFrames(1, 1 / 60);
|
await rest.renderer.advanceFrames(1, 1 / 60);
|
||||||
@@ -309,7 +336,7 @@ describe("HiveSceneContent in the editor", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("HiveSceneContent drop anchors", () => {
|
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 state = hiveState();
|
||||||
const layout = hiveLayout(state, catalog);
|
const layout = hiveLayout(state, catalog);
|
||||||
const store = createHiveViewStore();
|
const store = createHiveViewStore();
|
||||||
@@ -341,11 +368,15 @@ describe("HiveSceneContent drop anchors", () => {
|
|||||||
</HiveViewProvider>
|
</HiveViewProvider>
|
||||||
);
|
);
|
||||||
anchorCalls.count = 0;
|
anchorCalls.count = 0;
|
||||||
|
motionCalls.count = 0;
|
||||||
const renderer = await ReactThreeTestRenderer.create(element(editor(null)));
|
const renderer = await ReactThreeTestRenderer.create(element(editor(null)));
|
||||||
const afterMount = anchorCalls.count;
|
const afterMount = anchorCalls.count;
|
||||||
|
const motionAfterMount = motionCalls.count;
|
||||||
await renderer.update(element(editor(slots[0])));
|
await renderer.update(element(editor(slots[0])));
|
||||||
await renderer.update(element(editor(slots[1])));
|
await renderer.update(element(editor(slots[1])));
|
||||||
expect(anchorCalls.count).toBe(afterMount);
|
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();
|
await renderer.unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -110,15 +110,15 @@ export function HiveSceneContent({
|
|||||||
const motionView = useMemo(() => ({ ...view, selectedFrame }), [view, selectedFrame]);
|
const motionView = useMemo(() => ({ ...view, selectedFrame }), [view, selectedFrame]);
|
||||||
const drag = editor?.drag ?? null;
|
const drag = editor?.drag ?? null;
|
||||||
const gap = drag ? DRAG_GAP : REST_GAP;
|
const gap = drag ? DRAG_GAP : REST_GAP;
|
||||||
|
const movedId = drag && "partId" in drag.source ? drag.source.partId : null;
|
||||||
const targets: HiveMotionTargets = useMemo(() => {
|
const targets: HiveMotionTargets = useMemo(() => {
|
||||||
const computed = hiveMotionTargets(layout, motionView, gap);
|
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;
|
const moved = movedId ? computed.nodes.get(movedId) : undefined;
|
||||||
if (!movedId || !moved) return computed;
|
if (!movedId || !moved) return computed;
|
||||||
const nodes = new Map(computed.nodes);
|
const nodes = new Map(computed.nodes);
|
||||||
nodes.set(movedId, { ...moved, opacity: Math.min(moved.opacity, DRAGGED_OPACITY), interactive: false });
|
nodes.set(movedId, { ...moved, opacity: Math.min(moved.opacity, DRAGGED_OPACITY), interactive: false });
|
||||||
return { ...computed, nodes };
|
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.
|
// Once per drag: the hover changes the drag object, not its source or its slots.
|
||||||
const dragSource = drag?.source ?? null;
|
const dragSource = drag?.source ?? null;
|
||||||
const dragSlots = drag?.slots ?? null;
|
const dragSlots = drag?.slots ?? null;
|
||||||
@@ -199,9 +199,10 @@ export function HiveSceneContent({
|
|||||||
onPointerDown(event) {
|
onPointerDown(event) {
|
||||||
pointerType.current = event.pointerType ?? "mouse";
|
pointerType.current = event.pointerType ?? "mouse";
|
||||||
const parsed = parseKey(key);
|
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).
|
// 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;
|
if (signals.pinching) return;
|
||||||
// A press on a part may become a part drag: the rig must not turn the model for it.
|
// A press on a part may become a part drag: the rig must not turn the model for it.
|
||||||
markPartPress(signals, true);
|
markPartPress(signals, true);
|
||||||
|
|||||||
Reference in new issue
Block a user