refactor(documents): extract timeline drag state into rune class (#385)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 4m5s
CI / OCR Service Tests (pull_request) Successful in 38s
CI / Backend Unit Tests (pull_request) Failing after 3m22s
CI / Unit & Component Tests (push) Failing after 3m57s
CI / OCR Service Tests (push) Successful in 37s
CI / Backend Unit Tests (push) Failing after 3m22s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit was merged in pull request #478.
This commit is contained in:
Marcel
2026-05-08 11:50:34 +02:00
parent 5cd6ecc624
commit 9fd1f3cde2
3 changed files with 298 additions and 107 deletions

View File

@@ -8,6 +8,7 @@ import {
selectionBoundaryTo, selectionBoundaryTo,
formatTickLabel formatTickLabel
} from '$lib/document/timeline'; } from '$lib/document/timeline';
import { createTimelineDrag } from '$lib/document/useTimelineDrag.svelte';
import { getLocale } from '$lib/paraglide/runtime'; import { getLocale } from '$lib/paraglide/runtime';
import TimelineBars from '$lib/document/TimelineBars.svelte'; import TimelineBars from '$lib/document/TimelineBars.svelte';
import TimelineYAxis from '$lib/document/TimelineYAxis.svelte'; import TimelineYAxis from '$lib/document/TimelineYAxis.svelte';
@@ -73,25 +74,7 @@ const maxCount = $derived(Math.max(...filled.map((b) => b.count), 1));
const hasSelection = $derived(from !== '' || to !== ''); const hasSelection = $derived(from !== '' || to !== '');
let dragStartIndex: number | null = $state(null);
let dragEndIndex: number | null = $state(null);
let rowEl: HTMLDivElement | undefined = $state(); let rowEl: HTMLDivElement | undefined = $state();
// Set when a pointerup just emitted a selection so the synthesized click that
// follows mouse interaction is suppressed (we'd otherwise emit twice). Keyboard
// Enter/Space on a focused button still fires `click` without preceding
// pointerdown, so the click path remains the keyboard accessibility surface.
let suppressClick = $state(false);
const isDragging = $derived(dragStartIndex !== null);
const dragLowIndex = $derived(
dragStartIndex !== null && dragEndIndex !== null
? Math.min(dragStartIndex, dragEndIndex)
: dragStartIndex
);
const dragHighIndex = $derived(
dragStartIndex !== null && dragEndIndex !== null
? Math.max(dragStartIndex, dragEndIndex)
: dragStartIndex
);
function clearSelection() { function clearSelection() {
onchange({ from: '', to: '' }); onchange({ from: '', to: '' });
@@ -103,10 +86,8 @@ function isSelected(label: string): boolean {
return labelFrom >= from && labelFrom <= to; return labelFrom >= from && labelFrom <= to;
} }
function isInDragPreview(index: number): boolean { function isYearLabel(label: string): boolean {
if (!isDragging) return false; return label.length === 4;
if (dragLowIndex === null || dragHighIndex === null) return false;
return index >= dragLowIndex && index <= dragHighIndex;
} }
function emitSelection(startIndex: number, endIndex: number, includeZoom: boolean) { function emitSelection(startIndex: number, endIndex: number, includeZoom: boolean) {
@@ -138,102 +119,39 @@ function indexFromClientX(clientX: number): number | null {
return Math.min(filled.length - 1, Math.max(0, Math.floor(x / barWidth))); return Math.min(filled.length - 1, Math.max(0, Math.floor(x / barWidth)));
} }
function handleDocumentMove(e: PointerEvent) { const drag = createTimelineDrag({
const idx = indexFromClientX(e.clientX); indexFromClientX,
if (idx !== null) dragEndIndex = idx; labelAt: (i) => filled[i]?.month,
} isYearLabel,
emit: emitSelection
function handleDocumentUp() { });
cleanupDragListeners();
finalizeDrag();
}
function handleDocumentCancel() {
cleanupDragListeners();
dragStartIndex = null;
dragEndIndex = null;
}
function cleanupDragListeners() {
document.removeEventListener('pointermove', handleDocumentMove);
document.removeEventListener('pointerup', handleDocumentUp);
document.removeEventListener('pointercancel', handleDocumentCancel);
}
// Strip any in-flight document listeners if the component unmounts mid-drag // Strip any in-flight document listeners if the component unmounts mid-drag
// (route change, view toggle, breakpoint drop). Without this they survive on // (route change, view toggle, breakpoint drop). Without this they survive on
// document and keep writing to torn-down state cells. // document and keep writing to torn-down state cells.
$effect(() => { $effect(() => drag.cleanup);
return cleanupDragListeners;
});
function finalizeDrag() { function isInDragPreview(index: number): boolean {
if (dragStartIndex === null || dragEndIndex === null) return; if (!drag.isDragging) return false;
const start = dragStartIndex; if (drag.lowIndex === null || drag.highIndex === null) return false;
const end = dragEndIndex; return index >= drag.lowIndex && index <= drag.highIndex;
dragStartIndex = null;
dragEndIndex = null;
const isRangeDrag = start !== end;
const startLabel = filled[start]?.month;
// Range drag → atomic zoom + filter.
// Same-bar release on a year bar → zoom into that year's months.
// Same-bar release on a month bar → filter only.
const includeZoom = isRangeDrag || (!!startLabel && isYearLabel(startLabel));
emitSelection(start, end, includeZoom);
// Suppress the synthesized click that follows pointerup so we don't
// double-emit. Keyboard Enter/Space fires click without a preceding
// pointerup and stays the keyboard accessibility surface.
suppressClick = true;
queueMicrotask(() => {
suppressClick = false;
});
}
function handlePointerDown(event: PointerEvent, index: number) {
if (event.button !== 0) return;
dragStartIndex = index;
dragEndIndex = index;
document.addEventListener('pointermove', handleDocumentMove);
document.addEventListener('pointerup', handleDocumentUp);
document.addEventListener('pointercancel', handleDocumentCancel);
}
function handlePointerEnter(index: number) {
if (!isDragging) return;
dragEndIndex = index;
}
function isYearLabel(label: string): boolean {
return label.length === 4;
}
function handleClick(index: number) {
if (suppressClick) {
suppressClick = false;
return;
}
const label = filled[index]?.month;
// Click on a year bar zooms into that year so the user can see month-level
// density. Click on a month bar just filters.
const includeZoom = !!label && isYearLabel(label);
emitSelection(index, index, includeZoom);
} }
const dragWindowLeftPct = $derived.by(() => { const dragWindowLeftPct = $derived.by(() => {
if (!isDragging || dragLowIndex === null || filled.length === 0) return 0; if (!drag.isDragging || drag.lowIndex === null || filled.length === 0) return 0;
return (dragLowIndex / filled.length) * 100; return (drag.lowIndex / filled.length) * 100;
}); });
const dragWindowRightPct = $derived.by(() => { const dragWindowRightPct = $derived.by(() => {
if (!isDragging || dragHighIndex === null || filled.length === 0) return 100; if (!drag.isDragging || drag.highIndex === null || filled.length === 0) return 100;
return ((filled.length - dragHighIndex - 1) / filled.length) * 100; return ((filled.length - drag.highIndex - 1) / filled.length) * 100;
}); });
// While dragging, expose the live preview range to assistive tech via a // While dragging, expose the live preview range to assistive tech via a
// polite live region. Empty text outside drag avoids announcing residual state. // polite live region. Empty text outside drag avoids announcing residual state.
const dragLiveMessage = $derived.by(() => { const dragLiveMessage = $derived.by(() => {
if (!isDragging || dragLowIndex === null || dragHighIndex === null) return ''; if (!drag.isDragging || drag.lowIndex === null || drag.highIndex === null) return '';
const fromLabel = filled[dragLowIndex]?.month; const fromLabel = filled[drag.lowIndex]?.month;
const toLabel = filled[dragHighIndex]?.month; const toLabel = filled[drag.highIndex]?.month;
if (!fromLabel || !toLabel) return ''; if (!fromLabel || !toLabel) return '';
return m.timeline_dragging_aria_live({ return m.timeline_dragging_aria_live({
from: formatTickLabel(fromLabel, getLocale()), from: formatTickLabel(fromLabel, getLocale()),
@@ -259,13 +177,13 @@ const dragLiveMessage = $derived.by(() => {
barAreaHeight={BAR_AREA_HEIGHT} barAreaHeight={BAR_AREA_HEIGHT}
isSelected={isSelected} isSelected={isSelected}
isInDragPreview={isInDragPreview} isInDragPreview={isInDragPreview}
isDragging={isDragging} isDragging={drag.isDragging}
dragWindowLeftPct={dragWindowLeftPct} dragWindowLeftPct={dragWindowLeftPct}
dragWindowRightPct={dragWindowRightPct} dragWindowRightPct={dragWindowRightPct}
bind:rowEl={rowEl} bind:rowEl={rowEl}
onbarpointerdown={handlePointerDown} onbarpointerdown={drag.pointerDown}
onbarpointerenter={handlePointerEnter} onbarpointerenter={drag.pointerEnter}
onbarclick={handleClick} onbarclick={drag.click}
/> />
<TimelineXAxis filled={filled} /> <TimelineXAxis filled={filled} />

View File

@@ -0,0 +1,155 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { flushSync } from 'svelte';
const { createTimelineDrag } = await import('./useTimelineDrag.svelte');
type EmitCall = { start: number; end: number; includeZoom: boolean };
function makeOpts(overrides: Partial<Parameters<typeof createTimelineDrag>[0]> = {}) {
const labels = ['1915-08', '1915-09', '1915-10', '1915']; // last entry is a year label
const calls: EmitCall[] = [];
const opts = {
indexFromClientX: vi.fn(() => 0),
labelAt: (i: number) => labels[i],
isYearLabel: (l: string) => l.length === 4,
emit: (start: number, end: number, includeZoom: boolean) => {
calls.push({ start, end, includeZoom });
},
...overrides
};
return { opts, calls };
}
function pointerDownEvent(button = 0): PointerEvent {
return { button } as unknown as PointerEvent;
}
describe('createTimelineDrag', () => {
beforeEach(() => {
// Reset listeners attached to the JSDOM document between tests
document.removeEventListener('pointermove', () => undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('starts with no drag in progress and null low/high indices', () => {
const { opts } = makeOpts();
const drag = createTimelineDrag(opts);
expect(drag.isDragging).toBe(false);
expect(drag.lowIndex).toBeNull();
expect(drag.highIndex).toBeNull();
});
it('pointerDown on a primary button enters drag state at that index', () => {
const { opts } = makeOpts();
const drag = createTimelineDrag(opts);
drag.pointerDown(pointerDownEvent(0), 1);
flushSync();
expect(drag.isDragging).toBe(true);
expect(drag.lowIndex).toBe(1);
expect(drag.highIndex).toBe(1);
});
it('pointerDown on a non-primary button is ignored', () => {
const { opts } = makeOpts();
const drag = createTimelineDrag(opts);
drag.pointerDown(pointerDownEvent(2), 1);
flushSync();
expect(drag.isDragging).toBe(false);
});
it('pointerEnter during drag widens the range high boundary', () => {
const { opts } = makeOpts();
const drag = createTimelineDrag(opts);
drag.pointerDown(pointerDownEvent(0), 0);
drag.pointerEnter(2);
flushSync();
expect(drag.lowIndex).toBe(0);
expect(drag.highIndex).toBe(2);
});
it('pointerEnter outside drag is a no-op', () => {
const { opts } = makeOpts();
const drag = createTimelineDrag(opts);
drag.pointerEnter(2);
flushSync();
expect(drag.isDragging).toBe(false);
expect(drag.lowIndex).toBeNull();
});
it('click on a month bar emits filter only (no zoom)', () => {
const { opts, calls } = makeOpts();
const drag = createTimelineDrag(opts);
drag.click(0);
expect(calls).toEqual([{ start: 0, end: 0, includeZoom: false }]);
});
it('click on a year bar emits filter + zoom atomically', () => {
const { opts, calls } = makeOpts();
const drag = createTimelineDrag(opts);
drag.click(3); // labels[3] = '1915' (year label)
expect(calls).toEqual([{ start: 3, end: 3, includeZoom: true }]);
});
it('range drag commits emit with zoom in ascending order', async () => {
const { opts, calls } = makeOpts({ indexFromClientX: vi.fn(() => 2) });
const drag = createTimelineDrag(opts);
drag.pointerDown(pointerDownEvent(0), 0);
document.dispatchEvent(new PointerEvent('pointermove', { clientX: 999 }));
document.dispatchEvent(new PointerEvent('pointerup'));
flushSync();
expect(calls).toEqual([{ start: 0, end: 2, includeZoom: true }]);
expect(drag.isDragging).toBe(false);
});
it('reverse-direction drag still emits ascending boundaries via emit ordering', async () => {
const { opts, calls } = makeOpts({ indexFromClientX: vi.fn(() => 0) });
const drag = createTimelineDrag(opts);
drag.pointerDown(pointerDownEvent(0), 2);
document.dispatchEvent(new PointerEvent('pointermove', { clientX: 0 }));
document.dispatchEvent(new PointerEvent('pointerup'));
flushSync();
// emit receives raw start/end — orchestrator's emit() handles ordering
expect(calls).toEqual([{ start: 2, end: 0, includeZoom: true }]);
});
it('pointerup on the same bar (no-range) on a month label emits filter without zoom', () => {
const { opts, calls } = makeOpts({ indexFromClientX: vi.fn(() => 0) });
const drag = createTimelineDrag(opts);
drag.pointerDown(pointerDownEvent(0), 0);
document.dispatchEvent(new PointerEvent('pointerup'));
flushSync();
expect(calls).toEqual([{ start: 0, end: 0, includeZoom: false }]);
});
it('pointercancel resets state without emitting', () => {
const { opts, calls } = makeOpts();
const drag = createTimelineDrag(opts);
drag.pointerDown(pointerDownEvent(0), 1);
document.dispatchEvent(new PointerEvent('pointercancel'));
flushSync();
expect(calls).toEqual([]);
expect(drag.isDragging).toBe(false);
});
it('click after pointerup is suppressed (no double emit)', async () => {
const { opts, calls } = makeOpts({ indexFromClientX: vi.fn(() => 0) });
const drag = createTimelineDrag(opts);
drag.pointerDown(pointerDownEvent(0), 0);
document.dispatchEvent(new PointerEvent('pointerup'));
flushSync();
drag.click(0); // synthesized click after pointerup
expect(calls.length).toBe(1); // only the pointerup-driven emit, not the click
});
it('cleanup removes document pointer listeners', () => {
const removeSpy = vi.spyOn(document, 'removeEventListener');
const { opts } = makeOpts();
const drag = createTimelineDrag(opts);
drag.pointerDown(pointerDownEvent(0), 0);
drag.cleanup();
const removed = removeSpy.mock.calls.map((c) => c[0]);
expect(removed).toEqual(expect.arrayContaining(['pointermove', 'pointerup', 'pointercancel']));
});
});

View File

@@ -0,0 +1,118 @@
type DragOptions = {
/**
* Maps a viewport X coordinate to a bar index, or null if outside the row.
* The orchestrator owns the row element bound from `TimelineBars`.
*/
indexFromClientX(clientX: number): number | null;
/** Returns the bucket label at index, or undefined if out of range. */
labelAt(index: number): string | undefined;
/**
* True when a label represents an aggregated year bar — controls click-to-zoom
* semantics (clicking a year zooms into its 12 months; clicking a month doesn't).
*/
isYearLabel(label: string): boolean;
/**
* Emits a selection. `includeZoom` is true for a range drag or a click on a
* year bar; false for a click on a month bar.
*/
emit(startIndex: number, endIndex: number, includeZoom: boolean): void;
};
/**
* Drag state machine for the timeline density widget. Exposes:
* - `isDragging`, `lowIndex`, `highIndex` reactive read-onlies for the UI
* - `pointerDown` / `pointerEnter` / `click` handlers for bar buttons
* - `cleanup()` to drop document-level listeners on component unmount
*
* Document-level listeners (pointermove, pointerup, pointercancel) keep drag
* tracking alive while the cursor leaves the original bar or the timeline row.
* `cleanup()` must be called from a Svelte `$effect` teardown so a route change
* mid-drag does not leak listeners.
*/
export function createTimelineDrag(opts: DragOptions) {
let startIndex = $state<number | null>(null);
let endIndex = $state<number | null>(null);
// Set after a pointerup-driven emit so the synthesized click that follows is
// suppressed (we'd otherwise emit twice). Keyboard Enter/Space fires click
// without preceding pointerdown, so click stays the keyboard surface.
let suppressClick = $state(false);
function handleDocumentMove(e: PointerEvent) {
const idx = opts.indexFromClientX(e.clientX);
if (idx !== null) endIndex = idx;
}
function handleDocumentUp() {
cleanup();
finalizeDrag();
}
function handleDocumentCancel() {
cleanup();
startIndex = null;
endIndex = null;
}
function cleanup() {
document.removeEventListener('pointermove', handleDocumentMove);
document.removeEventListener('pointerup', handleDocumentUp);
document.removeEventListener('pointercancel', handleDocumentCancel);
}
function finalizeDrag() {
if (startIndex === null || endIndex === null) return;
const start = startIndex;
const end = endIndex;
startIndex = null;
endIndex = null;
const isRangeDrag = start !== end;
const startLabel = opts.labelAt(start);
// Range drag → atomic zoom + filter. Same-bar release on a year bar →
// zoom into that year's months. Same-bar release on a month bar →
// filter only.
const includeZoom = isRangeDrag || (!!startLabel && opts.isYearLabel(startLabel));
opts.emit(start, end, includeZoom);
suppressClick = true;
queueMicrotask(() => {
suppressClick = false;
});
}
return {
get isDragging() {
return startIndex !== null;
},
get lowIndex() {
if (startIndex === null) return null;
if (endIndex === null) return startIndex;
return Math.min(startIndex, endIndex);
},
get highIndex() {
if (startIndex === null) return null;
if (endIndex === null) return startIndex;
return Math.max(startIndex, endIndex);
},
pointerDown(event: PointerEvent, index: number) {
if (event.button !== 0) return;
startIndex = index;
endIndex = index;
document.addEventListener('pointermove', handleDocumentMove);
document.addEventListener('pointerup', handleDocumentUp);
document.addEventListener('pointercancel', handleDocumentCancel);
},
pointerEnter(index: number) {
if (startIndex === null) return;
endIndex = index;
},
click(index: number) {
if (suppressClick) {
suppressClick = false;
return;
}
const label = opts.labelAt(index);
const includeZoom = !!label && opts.isYearLabel(label);
opts.emit(index, index, includeZoom);
},
cleanup
};
}