refactor(ui): add the rubberband to select multiple items
This commit is contained in:
@@ -592,6 +592,121 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
toggleSelectAll();
|
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<HTMLElement | null>(null);
|
||||||
|
let rubberband = $state<{
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
mode: 'replace' | 'add' | 'toggle';
|
||||||
|
baseline: Set<string>;
|
||||||
|
} | 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<string>() : 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<HTMLElement>('.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
|
// `selectedItems` and the reap-stale effect below stay on the RAW
|
||||||
// items — selection persists across a display-filter toggle, and
|
// items — selection persists across a display-filter toggle, and
|
||||||
// stale-selection cleanup only fires when items truly leave the
|
// stale-selection cleanup only fires when items truly leave the
|
||||||
@@ -769,6 +884,7 @@
|
|||||||
tabindex={onopen ? 0 : undefined}
|
tabindex={onopen ? 0 : undefined}
|
||||||
aria-label={onopen ? item.name : undefined}
|
aria-label={onopen ? item.name : undefined}
|
||||||
data-testid={item.name}
|
data-testid={item.name}
|
||||||
|
data-item-id={item.id}
|
||||||
title={showOwner ? ownerTitle(item) : undefined}
|
title={showOwner ? ownerTitle(item) : undefined}
|
||||||
{draggable}
|
{draggable}
|
||||||
ondragstart={draggable && onitemdragstart ? (e) => onitemdragstart(e, item) : undefined}
|
ondragstart={draggable && onitemdragstart ? (e) => onitemdragstart(e, item) : undefined}
|
||||||
@@ -948,6 +1064,9 @@
|
|||||||
<div
|
<div
|
||||||
class="rl-root"
|
class="rl-root"
|
||||||
class:rl-root--drop-over={systemDropOver && enableSystemDrop}
|
class:rl-root--drop-over={systemDropOver && enableSystemDrop}
|
||||||
|
class:rl-root--rubberbanding={rubberband !== null}
|
||||||
|
bind:this={rlRoot}
|
||||||
|
onpointerdown={onRootPointerDown}
|
||||||
ondragenter={onSystemDragEnter}
|
ondragenter={onSystemDragEnter}
|
||||||
ondragover={onSystemDragOver}
|
ondragover={onSystemDragOver}
|
||||||
ondragleave={onSystemDragLeave}
|
ondragleave={onSystemDragLeave}
|
||||||
@@ -1103,6 +1222,19 @@
|
|||||||
<div bind:this={sentinel} class="rl-sentinel" aria-hidden="true"></div>
|
<div bind:this={sentinel} class="rl-sentinel" aria-hidden="true"></div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if rubberband}
|
||||||
|
<!-- Marquee selection rectangle. Positioned relative to `.rl-root`
|
||||||
|
(which is `position: relative`); pointer-events off so the
|
||||||
|
live pointermove handler on window still sees the drag. -->
|
||||||
|
<div
|
||||||
|
class="rl-rubberband"
|
||||||
|
style:left="{rubberband.x}px"
|
||||||
|
style:top="{rubberband.y}px"
|
||||||
|
style:width="{rubberband.w}px"
|
||||||
|
style:height="{rubberband.h}px"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
</div><!-- /.rl-root -->
|
</div><!-- /.rl-root -->
|
||||||
|
|
||||||
{#snippet listHeader()}
|
{#snippet listHeader()}
|
||||||
@@ -1190,6 +1322,25 @@
|
|||||||
pointer-events: none;
|
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 ── */
|
/* ── Breadcrumb strip inside the sticky header ── */
|
||||||
.rl-breadcrumb {
|
.rl-breadcrumb {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user