Files
familienarchiv/frontend/src/lib/document/useTimelineDrag.svelte.ts
Marcel 9fd1f3cde2
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
refactor(documents): extract timeline drag state into rune class (#385)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:50:34 +02:00

119 lines
3.9 KiB
TypeScript

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
};
}