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,
|
||||
formatTickLabel
|
||||
} from '$lib/document/timeline';
|
||||
import { createTimelineDrag } from '$lib/document/useTimelineDrag.svelte';
|
||||
import { getLocale } from '$lib/paraglide/runtime';
|
||||
import TimelineBars from '$lib/document/TimelineBars.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 !== '');
|
||||
|
||||
let dragStartIndex: number | null = $state(null);
|
||||
let dragEndIndex: number | null = $state(null);
|
||||
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() {
|
||||
onchange({ from: '', to: '' });
|
||||
@@ -103,10 +86,8 @@ function isSelected(label: string): boolean {
|
||||
return labelFrom >= from && labelFrom <= to;
|
||||
}
|
||||
|
||||
function isInDragPreview(index: number): boolean {
|
||||
if (!isDragging) return false;
|
||||
if (dragLowIndex === null || dragHighIndex === null) return false;
|
||||
return index >= dragLowIndex && index <= dragHighIndex;
|
||||
function isYearLabel(label: string): boolean {
|
||||
return label.length === 4;
|
||||
}
|
||||
|
||||
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)));
|
||||
}
|
||||
|
||||
function handleDocumentMove(e: PointerEvent) {
|
||||
const idx = indexFromClientX(e.clientX);
|
||||
if (idx !== null) dragEndIndex = idx;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
const drag = createTimelineDrag({
|
||||
indexFromClientX,
|
||||
labelAt: (i) => filled[i]?.month,
|
||||
isYearLabel,
|
||||
emit: emitSelection
|
||||
});
|
||||
|
||||
// Strip any in-flight document listeners if the component unmounts mid-drag
|
||||
// (route change, view toggle, breakpoint drop). Without this they survive on
|
||||
// document and keep writing to torn-down state cells.
|
||||
$effect(() => {
|
||||
return cleanupDragListeners;
|
||||
});
|
||||
$effect(() => drag.cleanup);
|
||||
|
||||
function finalizeDrag() {
|
||||
if (dragStartIndex === null || dragEndIndex === null) return;
|
||||
const start = dragStartIndex;
|
||||
const end = dragEndIndex;
|
||||
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);
|
||||
function isInDragPreview(index: number): boolean {
|
||||
if (!drag.isDragging) return false;
|
||||
if (drag.lowIndex === null || drag.highIndex === null) return false;
|
||||
return index >= drag.lowIndex && index <= drag.highIndex;
|
||||
}
|
||||
|
||||
const dragWindowLeftPct = $derived.by(() => {
|
||||
if (!isDragging || dragLowIndex === null || filled.length === 0) return 0;
|
||||
return (dragLowIndex / filled.length) * 100;
|
||||
if (!drag.isDragging || drag.lowIndex === null || filled.length === 0) return 0;
|
||||
return (drag.lowIndex / filled.length) * 100;
|
||||
});
|
||||
const dragWindowRightPct = $derived.by(() => {
|
||||
if (!isDragging || dragHighIndex === null || filled.length === 0) return 100;
|
||||
return ((filled.length - dragHighIndex - 1) / filled.length) * 100;
|
||||
if (!drag.isDragging || drag.highIndex === null || filled.length === 0) return 100;
|
||||
return ((filled.length - drag.highIndex - 1) / filled.length) * 100;
|
||||
});
|
||||
|
||||
// While dragging, expose the live preview range to assistive tech via a
|
||||
// polite live region. Empty text outside drag avoids announcing residual state.
|
||||
const dragLiveMessage = $derived.by(() => {
|
||||
if (!isDragging || dragLowIndex === null || dragHighIndex === null) return '';
|
||||
const fromLabel = filled[dragLowIndex]?.month;
|
||||
const toLabel = filled[dragHighIndex]?.month;
|
||||
if (!drag.isDragging || drag.lowIndex === null || drag.highIndex === null) return '';
|
||||
const fromLabel = filled[drag.lowIndex]?.month;
|
||||
const toLabel = filled[drag.highIndex]?.month;
|
||||
if (!fromLabel || !toLabel) return '';
|
||||
return m.timeline_dragging_aria_live({
|
||||
from: formatTickLabel(fromLabel, getLocale()),
|
||||
@@ -259,13 +177,13 @@ const dragLiveMessage = $derived.by(() => {
|
||||
barAreaHeight={BAR_AREA_HEIGHT}
|
||||
isSelected={isSelected}
|
||||
isInDragPreview={isInDragPreview}
|
||||
isDragging={isDragging}
|
||||
isDragging={drag.isDragging}
|
||||
dragWindowLeftPct={dragWindowLeftPct}
|
||||
dragWindowRightPct={dragWindowRightPct}
|
||||
bind:rowEl={rowEl}
|
||||
onbarpointerdown={handlePointerDown}
|
||||
onbarpointerenter={handlePointerEnter}
|
||||
onbarclick={handleClick}
|
||||
onbarpointerdown={drag.pointerDown}
|
||||
onbarpointerenter={drag.pointerEnter}
|
||||
onbarclick={drag.click}
|
||||
/>
|
||||
|
||||
<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