feat(frontend): implement file selection with long-press, shift+click, and touch drag

- selection.ts: store with select/deselect/toggle/enter/exit, derived count and active
- FileCard: long-press (400ms) enters selection mode, shows check overlay, blocks context menu
- Header: Select/Cancel button toggles selection mode
- SelectionBar: floating bar above navbar with count, Edit tags, Add to pool, Delete
- Shift+click range-selects between last and current index (desktop)
- Touch drag-to-select/deselect after long-press; non-passive touchmove blocks scroll only during drag

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-05 13:30:26 +03:00
parent 63ea1a4d6a
commit aebf7127af
5 changed files with 443 additions and 16 deletions
@@ -3,12 +3,27 @@
import { authStore } from '$lib/stores/auth';
import type { File } from '$lib/api/types';
const LONG_PRESS_MS = 400;
const DRAG_THRESHOLD = 8; // px — cancel long-press if pointer moves more than this
interface Props {
file: File;
onclick?: (file: File) => void;
index: number;
selected?: boolean;
selectionMode?: boolean;
onTap?: (e: MouseEvent) => void;
/** Called when long-press fires; receives the pointerType of the gesture. */
onLongPress?: (pointerType: string) => void;
}
let { file, onclick }: Props = $props();
let {
file,
index,
selected = false,
selectionMode = false,
onTap,
onLongPress,
}: Props = $props();
let imgSrc = $state<string | null>(null);
let failed = $state(false);
@@ -40,8 +55,51 @@
};
});
function handleClick() {
onclick?.(file);
// --- Long press + drag detection ---
let pressTimer: ReturnType<typeof setTimeout> | null = null;
let didLongPress = false;
let pressStartX = 0;
let pressStartY = 0;
let currentPointerType = '';
function onPointerDown(e: PointerEvent) {
if (e.button !== 0 && e.pointerType === 'mouse') return;
didLongPress = false;
pressStartX = e.clientX;
pressStartY = e.clientY;
currentPointerType = e.pointerType;
pressTimer = setTimeout(() => {
didLongPress = true;
onLongPress?.(currentPointerType);
}, LONG_PRESS_MS);
}
function onPointerMoveInternal(e: PointerEvent) {
// Cancel long-press if pointer has moved significantly (user is scrolling)
if (pressTimer !== null) {
const dx = e.clientX - pressStartX;
const dy = e.clientY - pressStartY;
if (Math.hypot(dx, dy) > DRAG_THRESHOLD) {
clearTimeout(pressTimer);
pressTimer = null;
}
}
}
function cancelPress() {
if (pressTimer !== null) {
clearTimeout(pressTimer);
pressTimer = null;
}
}
function onClick(e: MouseEvent) {
if (didLongPress) {
didLongPress = false;
return;
}
cancelPress();
onTap?.(e);
}
</script>
@@ -49,17 +107,38 @@
<div
class="card"
class:loaded={!!imgSrc}
onclick={handleClick}
class:selected
data-file-index={index}
onpointerdown={onPointerDown}
onpointermove={onPointerMoveInternal}
onpointerup={() => { cancelPress(); didLongPress = false; }}
onpointerleave={cancelPress}
oncontextmenu={(e) => e.preventDefault()}
onclick={onClick}
title={file.original_name ?? undefined}
>
{#if imgSrc}
<img src={imgSrc} alt={file.original_name ?? ''} class="thumb" />
<img src={imgSrc} alt={file.original_name ?? ''} class="thumb" draggable="false" />
{:else if failed}
<div class="placeholder failed" aria-label="Failed to load"></div>
{:else}
<div class="placeholder loading" aria-label="Loading"></div>
{/if}
<div class="overlay"></div>
{#if selected}
<div class="check" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<circle cx="9" cy="9" r="8.5" fill="rgba(0,0,0,0.55)" stroke="white" stroke-width="1"/>
<path d="M5 9l3 3 5-5" stroke="white" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
{:else if selectionMode}
<div class="check" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<circle cx="9" cy="9" r="8.5" fill="rgba(0,0,0,0.35)" stroke="rgba(255,255,255,0.5)" stroke-width="1"/>
</svg>
</div>
{/if}
</div>
<style>
@@ -73,6 +152,8 @@
cursor: pointer;
background-color: var(--color-bg-elevated);
flex-shrink: 0;
user-select: none;
-webkit-user-select: none;
}
.thumb {
@@ -114,6 +195,17 @@
background-color: rgba(0, 0, 0, 0.3);
}
.card.selected .overlay {
background-color: color-mix(in srgb, var(--color-accent) 35%, transparent);
}
.check {
position: absolute;
top: 6px;
right: 6px;
pointer-events: none;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }