feat(stammbaum): sync view to shareable ?cx&cy&z URL (#692)
A view-keyed effect mirrors pan/zoom into the URL via replaceState (URL read untracked to avoid a feedback loop). State survives panel open/close (US-PANEL-002 AC1) and a shared link reproduces the view (AC2). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import StammbaumTree from './StammbaumTree.svelte';
|
||||
import type { PanZoomState } from './panZoom';
|
||||
|
||||
const ID_A = '00000000-0000-0000-0000-000000000001';
|
||||
const ID_B = '00000000-0000-0000-0000-000000000002';
|
||||
@@ -512,7 +513,7 @@ describe('StammbaumTree node rendering branches', () => {
|
||||
});
|
||||
|
||||
describe('StammbaumTree keyboard pan/zoom (#692)', () => {
|
||||
const renderTree = (onPanZoom: ReturnType<typeof vi.fn>) =>
|
||||
const renderTree = (onPanZoom: (state: PanZoomState) => void) =>
|
||||
render(StammbaumTree, {
|
||||
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
|
||||
edges: [],
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { page } from '$app/state';
|
||||
import { replaceState } from '$app/navigation';
|
||||
import StammbaumTree from '$lib/person/genealogy/StammbaumTree.svelte';
|
||||
import StammbaumSidePanel from '$lib/person/genealogy/StammbaumSidePanel.svelte';
|
||||
import StammbaumControls from '$lib/person/genealogy/StammbaumControls.svelte';
|
||||
@@ -8,6 +10,7 @@ import {
|
||||
type PanZoomState,
|
||||
DEFAULT_VIEW,
|
||||
clampZoom,
|
||||
serializePanZoomParams,
|
||||
ZOOM_STEP_KB
|
||||
} from '$lib/person/genealogy/panZoom';
|
||||
import { animateView, prefersReducedMotion } from '$lib/person/genealogy/animateView';
|
||||
@@ -45,6 +48,21 @@ function fitToScreen() {
|
||||
reducedMotion: prefersReducedMotion()
|
||||
});
|
||||
}
|
||||
|
||||
// Mirror the view into shareable ?cx&cy&z params (OQ-003). Only `view` is a
|
||||
// tracked dependency; the current URL is read untracked so the replaceState
|
||||
// write does not retrigger the effect. The state thus survives panel open/close
|
||||
// (US-PANEL-002 AC1) and a shared link reproduces it (AC2).
|
||||
$effect(() => {
|
||||
const { cx, cy, z } = serializePanZoomParams(view);
|
||||
untrack(() => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('cx', cx);
|
||||
url.searchParams.set('cy', cy);
|
||||
url.searchParams.set('z', z);
|
||||
replaceState(url, page.state);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- 4.25rem = 4rem navbar (h-16) + 0.25rem accent strip (h-1).
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DEFAULT_VIEW } from '$lib/person/genealogy/panZoom';
|
||||
|
||||
const mockPage = {
|
||||
url: new URL('http://localhost/stammbaum'),
|
||||
state: {},
|
||||
data: { canWrite: false } as { canWrite: boolean }
|
||||
};
|
||||
|
||||
@@ -14,6 +15,11 @@ vi.mock('$app/state', () => ({
|
||||
}
|
||||
}));
|
||||
|
||||
const replaceState = vi.fn();
|
||||
vi.mock('$app/navigation', () => ({
|
||||
replaceState: (...args: unknown[]) => replaceState(...args)
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
async function loadComponent() {
|
||||
@@ -94,4 +100,20 @@ describe('stammbaum page', () => {
|
||||
// Just verify that repeated clicks don't throw — branch coverage
|
||||
await expect.element(zoomOut).toBeVisible();
|
||||
});
|
||||
|
||||
it('mirrors the view into ?cx&cy&z when zoomed (US-PANEL-002 AC2)', async () => {
|
||||
mockPage.url = new URL('http://localhost/stammbaum');
|
||||
replaceState.mockClear();
|
||||
const Stammbaum = await loadComponent();
|
||||
render(Stammbaum, {
|
||||
props: { data: { nodes: sampleNodes, edges: [], initialView: DEFAULT_VIEW } }
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: /vergrößern/i }).click();
|
||||
await vi.waitFor(() => expect(replaceState).toHaveBeenCalled());
|
||||
const url = replaceState.mock.calls.at(-1)![0] as URL;
|
||||
expect(url.searchParams.get('z')).toBeTruthy();
|
||||
expect(url.searchParams.has('cx')).toBe(true);
|
||||
expect(url.searchParams.has('cy')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user