feat(stammbaum): touch/mouse/wheel pan & pinch zoom gestures (#692)

Add a panZoomGestures action: one-finger/left-button drag pans, two-finger
pinch and Ctrl+wheel zoom around the centroid, plain wheel pans. Pan is
edge-clamped via clampPan (no infinite scroll), a real drag suppresses the
trailing node click, and inertia decays after release unless prefers-reduced-
motion. Canvas container switches from native scroll to overflow-hidden.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-29 16:45:18 +02:00
parent da1984b916
commit c8931071ba
6 changed files with 335 additions and 1 deletions

View File

@@ -549,6 +549,35 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => {
expect(onPanZoom.mock.calls[0][0].y).toBeGreaterThan(0);
});
it('pans on a pointer drag and suppresses the trailing node click (US-PAN-001)', async () => {
const onPanZoom = vi.fn();
const onSelect = vi.fn();
render(StammbaumTree, {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
// Zoomed in so panning is permitted (clampPan allows movement at z>1).
panZoom: { x: 0, y: 0, z: 2 },
onPanZoom,
onSelect
});
const svg = document.querySelector('svg')! as SVGSVGElement;
const node = document.querySelector('g[role="button"]') as SVGGElement;
const opts = (x: number) => ({ pointerId: 1, clientX: x, clientY: 100, bubbles: true });
svg.dispatchEvent(new PointerEvent('pointerdown', opts(100)));
svg.dispatchEvent(new PointerEvent('pointermove', opts(160)));
svg.dispatchEvent(new PointerEvent('pointerup', opts(160)));
expect(onPanZoom).toHaveBeenCalled();
// Dragging right reveals content to the left → pan x decreases.
expect(onPanZoom.mock.calls.at(-1)![0].x).toBeLessThan(0);
// The synthetic click after a real drag must not select the node.
node.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(onSelect).not.toHaveBeenCalled();
});
it('does not call onSelect for other keys', async () => {
const onSelect = vi.fn();
render(StammbaumTree, {