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
+109 -9
View File
@@ -6,8 +6,10 @@
import FileCard from '$lib/components/file/FileCard.svelte';
import FilterBar from '$lib/components/file/FilterBar.svelte';
import Header from '$lib/components/layout/Header.svelte';
import SelectionBar from '$lib/components/layout/SelectionBar.svelte';
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
import { fileSorting, type FileSortField } from '$lib/stores/sorting';
import { selectionStore, selectionActive } from '$lib/stores/selection';
import { parseDslFilter } from '$lib/utils/dsl';
import type { File, FileCursorPage } from '$lib/api/types';
@@ -27,14 +29,10 @@
let error = $state('');
let filterOpen = $state(false);
// Derive current filter from URL ?filter= param
let filterParam = $derived($page.url.searchParams.get('filter'));
let activeTokens = $derived(parseDslFilter(filterParam));
// Track sort/order from store
let sortState = $derived($fileSorting);
// Reset + reload whenever sort or filter changes
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${filterParam ?? ''}`);
let prevKey = $state('');
@@ -52,7 +50,6 @@
if (loading || !hasMore) return;
loading = true;
error = '';
try {
const params = new URLSearchParams({
limit: String(LIMIT),
@@ -61,7 +58,6 @@
});
if (nextCursor) params.set('cursor', nextCursor);
if (filterParam) params.set('filter', filterParam);
const res = await api.get<FileCursorPage>(`/files?${params}`);
files = [...files, ...(res.items ?? [])];
nextCursor = res.next_cursor ?? null;
@@ -84,6 +80,90 @@
goto(url.toString(), { replaceState: true });
filterOpen = false;
}
function openFile(file: File) {
if (file.id) goto(`/files/${file.id}`);
}
// ---- Selection logic ----
let lastSelectedIdx = $state<number | null>(null);
function handleTap(file: File, idx: number, e: MouseEvent) {
if (!$selectionActive) {
openFile(file);
return;
}
if (e.shiftKey && lastSelectedIdx !== null) {
// Range-select between lastSelectedIdx and idx (desktop)
const from = Math.min(lastSelectedIdx, idx);
const to = Math.max(lastSelectedIdx, idx);
for (let i = from; i <= to; i++) {
if (files[i]?.id) selectionStore.select(files[i].id!);
}
lastSelectedIdx = idx;
} else {
if (file.id) selectionStore.toggle(file.id);
lastSelectedIdx = idx;
}
}
function handleLongPress(file: File, idx: number, pointerType: string) {
// Determine drag mode from whether this card is already selected
const alreadySelected = $selectionStore.ids.has(file.id!);
if (alreadySelected) {
selectionStore.deselect(file.id!);
dragMode = 'deselect';
} else {
selectionStore.select(file.id!);
dragMode = 'select';
}
lastSelectedIdx = idx;
// Only enter drag-select for touch — shift+click covers desktop range selection
if (pointerType === 'touch') dragSelecting = true;
}
// ---- Drag-to-select / deselect (touch only) ----
// Entered only after a long-press (400ms stillness), so by the time we
// add the touchmove listener the scroll gesture hasn't started yet.
// A non-passive touchmove listener lets us call preventDefault() to block
// scroll while the user slides their finger across cards.
let dragSelecting = $state(false);
let dragMode = $state<'select' | 'deselect'>('select');
$effect(() => {
if (!dragSelecting) return;
function onTouchMove(e: TouchEvent) {
e.preventDefault(); // block scroll while drag-selecting
const touch = e.touches[0];
const el = document.elementFromPoint(touch.clientX, touch.clientY);
const card = el?.closest<HTMLElement>('[data-file-index]');
if (!card) return;
const idx = parseInt(card.dataset.fileIndex ?? '');
if (isNaN(idx) || !files[idx]?.id) return;
if (dragMode === 'select') {
selectionStore.select(files[idx].id!);
} else {
selectionStore.deselect(files[idx].id!);
}
lastSelectedIdx = idx;
}
function onTouchEnd() {
dragSelecting = false;
}
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd);
document.addEventListener('touchcancel', onTouchEnd);
return () => {
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
document.removeEventListener('touchcancel', onTouchEnd);
};
});
</script>
<svelte:head>
@@ -115,8 +195,15 @@
{/if}
<div class="grid">
{#each files as file (file.id)}
<FileCard {file} />
{#each files as file, i (file.id)}
<FileCard
{file}
index={i}
selected={$selectionStore.ids.has(file.id ?? '')}
selectionMode={$selectionActive}
onTap={(e) => handleTap(file, i, e)}
onLongPress={(pt) => handleLongPress(file, i, pt)}
/>
{/each}
</div>
@@ -128,6 +215,19 @@
</main>
</div>
{#if $selectionActive}
<SelectionBar
onEditTags={() => {/* TODO */}}
onAddToPool={() => {/* TODO */}}
onDelete={() => {
if (confirm(`Delete ${$selectionStore.ids.size} file(s)?`)) {
// TODO: call delete API
selectionStore.exit();
}
}}
/>
{/if}
<style>
.page {
flex: 1;
@@ -169,4 +269,4 @@
padding: 60px 20px;
font-size: 0.95rem;
}
</style>
</style>