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)}