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>
119 lines
3.9 KiB
TypeScript
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
|
|
};
|
|
}
|