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:
@@ -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} />
|
||||||
|
|||||||
155
frontend/src/lib/document/useTimelineDrag.svelte.test.ts
Normal file
155
frontend/src/lib/document/useTimelineDrag.svelte.test.ts
Normal 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']));
|
||||||
|
});
|
||||||
|
});
|
||||||
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