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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user