From 146a1a32bd8f3bfea6dd7fc543482e3874e7435a Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 20 Jul 2026 00:47:41 +0200 Subject: [PATCH] refactor(ui): add the rubberband to select multiple items --- .../src/lib/components/ResourceList.svelte | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/frontend/src/lib/components/ResourceList.svelte b/frontend/src/lib/components/ResourceList.svelte index 24d133e7..cea00a9a 100644 --- a/frontend/src/lib/components/ResourceList.svelte +++ b/frontend/src/lib/components/ResourceList.svelte @@ -592,6 +592,121 @@ e.preventDefault(); toggleSelectAll(); } + + // ── Rubberband (marquee) selection ──────────────────────────────────────── + // + // Click-and-drag on empty space draws a translucent rectangle; every row + // whose bounding box intersects the rectangle joins the selection. Behavior: + // * Plain drag → replace the current selection with what the box covers. + // * Shift+drag → add to the current selection (union). + // * ⌘/Ctrl+drag → toggle: rows inside the box flip their state relative + // to the pre-drag baseline. + // + // The gesture only starts when the mousedown lands on truly empty space — + // mousedowns on `.file-item`, links, buttons, or the checkbox pass through + // to their own handlers. This keeps row-drag (files browser) uncontested. + // + // Intersections are computed via `getBoundingClientRect()` on every mouse + // move, so this only sees VISIBLE rows — which is what a user in a + // virtualized list expects anyway ("I can't rubberband something I can't + // see"). No auto-scroll during drag today; the user can release, scroll, + // then start another gesture with Shift held. + let rlRoot = $state(null); + let rubberband = $state<{ + startX: number; + startY: number; + x: number; + y: number; + w: number; + h: number; + mode: 'replace' | 'add' | 'toggle'; + baseline: Set; + } | null>(null); + + function onRootPointerDown(e: PointerEvent) { + if (!selectable) return; + if (e.button !== 0) return; // Left button only + const target = e.target as HTMLElement | null; + if (!target || !rlRoot) return; + // Ignore mousedowns on interactive descendants or on a row. + if ( + target.closest('.file-item') || + target.closest('a, button, input, select, textarea, [role="menuitem"]') + ) { + return; + } + // Ignore when the click landed on the sticky header (bar + breadcrumb). + if (target.closest('.page-sticky-header')) return; + + const rect = rlRoot.getBoundingClientRect(); + const startX = e.clientX - rect.left; + const startY = e.clientY - rect.top; + const mode: 'replace' | 'add' | 'toggle' = e.shiftKey + ? 'add' + : isToggleModifier(e) + ? 'toggle' + : 'replace'; + const baseline = mode === 'replace' ? new Set() : new Set(selected); + + rubberband = { startX, startY, x: startX, y: startY, w: 0, h: 0, mode, baseline }; + + if (mode === 'replace') selected.clear(); + + // preventDefault so text under the drag doesn't get selected as we drag. + e.preventDefault(); + window.addEventListener('pointermove', onRubberbandMove); + window.addEventListener('pointerup', onRubberbandUp, { once: true }); + } + + function onRubberbandMove(e: PointerEvent) { + if (!rubberband || !rlRoot) return; + const rect = rlRoot.getBoundingClientRect(); + const curX = Math.max(0, Math.min(rect.width, e.clientX - rect.left)); + const curY = Math.max(0, Math.min(rect.height, e.clientY - rect.top)); + rubberband.x = Math.min(rubberband.startX, curX); + rubberband.y = Math.min(rubberband.startY, curY); + rubberband.w = Math.abs(curX - rubberband.startX); + rubberband.h = Math.abs(curY - rubberband.startY); + applyRubberbandSelection(); + } + + function onRubberbandUp() { + window.removeEventListener('pointermove', onRubberbandMove); + rubberband = null; + } + + function applyRubberbandSelection() { + if (!rubberband || !rlRoot) return; + const rootRect = rlRoot.getBoundingClientRect(); + // Absolute viewport rect of the current band. + const bandLeft = rootRect.left + rubberband.x; + const bandTop = rootRect.top + rubberband.y; + const bandRight = bandLeft + rubberband.w; + const bandBottom = bandTop + rubberband.h; + + const rows = rlRoot.querySelectorAll('.file-item[data-item-id]'); + const nextSelection = new Set(rubberband.baseline); + for (const row of rows) { + const id = row.dataset.itemId; + if (!id) continue; + const b = row.getBoundingClientRect(); + const overlaps = + b.left < bandRight && b.right > bandLeft && b.top < bandBottom && b.bottom > bandTop; + if (overlaps) { + if (rubberband.mode === 'toggle') { + if (rubberband.baseline.has(id)) nextSelection.delete(id); + else nextSelection.add(id); + } else { + nextSelection.add(id); + } + } + } + // Rewrite the `selected` set in place — SvelteSet is reactive on + // per-key operations, so we only mutate the diff. + for (const id of selected) if (!nextSelection.has(id)) selected.delete(id); + for (const id of nextSelection) if (!selected.has(id)) selected.add(id); + onselectionchange?.(selected); + } // `selectedItems` and the reap-stale effect below stay on the RAW // items — selection persists across a display-filter toggle, and // stale-selection cleanup only fires when items truly leave the @@ -769,6 +884,7 @@ tabindex={onopen ? 0 : undefined} aria-label={onopen ? item.name : undefined} data-testid={item.name} + data-item-id={item.id} title={showOwner ? ownerTitle(item) : undefined} {draggable} ondragstart={draggable && onitemdragstart ? (e) => onitemdragstart(e, item) : undefined} @@ -948,6 +1064,9 @@ {/if} +{#if rubberband} + + +{/if} {#snippet listHeader()} @@ -1190,6 +1322,25 @@ pointer-events: none; } + /* ── Rubberband (marquee) selection ──────────────────────────── + Absolute overlay drawn while the user drags. Positioned inside + `.rl-root`; the pointer events go to the window listener, so + the rectangle itself is inert. `.rl-root--rubberbanding` + suppresses text selection under the cursor so dragging over row + text doesn't leave a highlighted mess behind. */ + .rl-root--rubberbanding { + user-select: none; + } + + .rl-rubberband { + position: absolute; + z-index: 5; + background: color-mix(in srgb, var(--color-accent) 12%, transparent); + border: 1px solid var(--color-accent); + border-radius: var(--radius-sm); + pointer-events: none; + } + /* ── Breadcrumb strip inside the sticky header ── */ .rl-breadcrumb { display: flex;