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 { describe, it, expect, vi } from 'vitest';
|
||||||
import { render } from 'vitest-browser-svelte';
|
import { render } from 'vitest-browser-svelte';
|
||||||
import StammbaumTree from './StammbaumTree.svelte';
|
import StammbaumTree from './StammbaumTree.svelte';
|
||||||
|
import type { PanZoomState } from './panZoom';
|
||||||
|
|
||||||
const ID_A = '00000000-0000-0000-0000-000000000001';
|
const ID_A = '00000000-0000-0000-0000-000000000001';
|
||||||
const ID_B = '00000000-0000-0000-0000-000000000002';
|
const ID_B = '00000000-0000-0000-0000-000000000002';
|
||||||
@@ -512,7 +513,7 @@ describe('StammbaumTree node rendering branches', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('StammbaumTree keyboard pan/zoom (#692)', () => {
|
describe('StammbaumTree keyboard pan/zoom (#692)', () => {
|
||||||
const renderTree = (onPanZoom: ReturnType<typeof vi.fn>) =>
|
const renderTree = (onPanZoom: (state: PanZoomState) => void) =>
|
||||||
render(StammbaumTree, {
|
render(StammbaumTree, {
|
||||||
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
|
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
|
||||||
edges: [],
|
edges: [],
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
|
import { replaceState } from '$app/navigation';
|
||||||
import StammbaumTree from '$lib/person/genealogy/StammbaumTree.svelte';
|
import StammbaumTree from '$lib/person/genealogy/StammbaumTree.svelte';
|
||||||
import StammbaumSidePanel from '$lib/person/genealogy/StammbaumSidePanel.svelte';
|
import StammbaumSidePanel from '$lib/person/genealogy/StammbaumSidePanel.svelte';
|
||||||
import StammbaumControls from '$lib/person/genealogy/StammbaumControls.svelte';
|
import StammbaumControls from '$lib/person/genealogy/StammbaumControls.svelte';
|
||||||
@@ -8,6 +10,7 @@ import {
|
|||||||
type PanZoomState,
|
type PanZoomState,
|
||||||
DEFAULT_VIEW,
|
DEFAULT_VIEW,
|
||||||
clampZoom,
|
clampZoom,
|
||||||
|
serializePanZoomParams,
|
||||||
ZOOM_STEP_KB
|
ZOOM_STEP_KB
|
||||||
} from '$lib/person/genealogy/panZoom';
|
} from '$lib/person/genealogy/panZoom';
|
||||||
import { animateView, prefersReducedMotion } from '$lib/person/genealogy/animateView';
|
import { animateView, prefersReducedMotion } from '$lib/person/genealogy/animateView';
|
||||||
@@ -45,6 +48,21 @@ function fitToScreen() {
|
|||||||
reducedMotion: prefersReducedMotion()
|
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>
|
</script>
|
||||||
|
|
||||||
<!-- 4.25rem = 4rem navbar (h-16) + 0.25rem accent strip (h-1).
|
<!-- 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 = {
|
const mockPage = {
|
||||||
url: new URL('http://localhost/stammbaum'),
|
url: new URL('http://localhost/stammbaum'),
|
||||||
|
state: {},
|
||||||
data: { canWrite: false } as { canWrite: boolean }
|
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);
|
afterEach(cleanup);
|
||||||
|
|
||||||
async function loadComponent() {
|
async function loadComponent() {
|
||||||
@@ -94,4 +100,20 @@ describe('stammbaum page', () => {
|
|||||||
// Just verify that repeated clicks don't throw — branch coverage
|
// Just verify that repeated clicks don't throw — branch coverage
|
||||||
await expect.element(zoomOut).toBeVisible();
|
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