diff --git a/frontend/src/lib/document/TimelineDensityFilter.svelte b/frontend/src/lib/document/TimelineDensityFilter.svelte index 9a4866b0..5d859ea0 100644 --- a/frontend/src/lib/document/TimelineDensityFilter.svelte +++ b/frontend/src/lib/document/TimelineDensityFilter.svelte @@ -46,15 +46,30 @@ 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); +// 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 barHeight(count: number): number { if (count === 0) return ZERO_COUNT_BAR_HEIGHT; return Math.max(ZERO_COUNT_BAR_HEIGHT, (count / maxCount) * BAR_AREA_HEIGHT); } -function selectBar(label: string) { - onchange({ from: selectionBoundaryFrom(label), to: selectionBoundaryTo(label) }); -} - function clearSelection() { onchange({ from: '', to: '' }); } @@ -64,6 +79,60 @@ function isSelected(label: string): boolean { const labelFrom = selectionBoundaryFrom(label); 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 emitSelection(startIndex: number, endIndex: number) { + const lo = Math.min(startIndex, endIndex); + const hi = Math.max(startIndex, endIndex); + const startLabel = filled[lo]?.month; + const endLabel = filled[hi]?.month; + if (!startLabel || !endLabel) return; + onchange({ + from: selectionBoundaryFrom(startLabel), + to: selectionBoundaryTo(endLabel) + }); +} + +function handlePointerDown(event: PointerEvent, index: number) { + if (event.button !== 0) return; + dragStartIndex = index; + dragEndIndex = index; + (event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId); +} + +function handlePointerEnter(index: number) { + if (!isDragging) return; + dragEndIndex = index; +} + +function handlePointerUp() { + if (dragStartIndex === null || dragEndIndex === null) return; + emitSelection(dragStartIndex, dragEndIndex); + dragStartIndex = null; + dragEndIndex = null; + suppressClick = true; + queueMicrotask(() => { + suppressClick = false; + }); +} + +function handlePointerCancel() { + dragStartIndex = null; + dragEndIndex = null; +} + +function handleClick(index: number) { + if (suppressClick) { + suppressClick = false; + return; + } + emitSelection(index, index); +} {#if density !== null} @@ -74,15 +143,20 @@ function isSelected(label: string): boolean { class="relative rounded-sm border border-line bg-surface px-3 pt-3 pb-2 shadow-sm" >
- {#each filled as bucket (bucket.month)} + {#each filled as bucket, i (bucket.month)}