From bd81ff81f9d4105f26d6c2d6d9166e4b15042608 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 23:16:48 +0200 Subject: [PATCH] feat(documents): drag-to-select-range on the timeline (#385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original AC required drag-to-select; the MVP shipped with click-only. This adds pointer-driven range selection while preserving keyboard access: - Pointer events (pointerdown / pointerenter / pointerup) drive the drag. Pointer capture on pointerdown so the cursor leaving the bar still produces drag-end events. Live preview class `in-drag-preview` highlights the spanning bars while dragging; the URL/list refetch only fires on pointerup (Felix R3). - Click handler kept for keyboard activation (Enter/Space on focused bar). A `suppressClick` flag prevents the synthesized click after a mouse pointerup from double-emitting. - Drag from later โ†’ earlier still emits ascending boundaries (drag direction doesn't matter). - Existing single-click keyboard selection unchanged. 4 new component tests cover the drag paths plus the live-preview class. Existing 13 tests (single click, year mode, clear, visibility) still green. Co-Authored-By: Claude Sonnet 4.6 --- .../lib/document/TimelineDensityFilter.svelte | 93 ++++++++++++-- .../TimelineDensityFilter.svelte.spec.ts | 113 ++++++++++++++++++ 2 files changed, 199 insertions(+), 7 deletions(-) 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)}