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
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:
118
frontend/src/lib/document/useTimelineDrag.svelte.ts
Normal file
118
frontend/src/lib/document/useTimelineDrag.svelte.ts
Normal 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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user