From aebf7127af453afc255823653ea6ee9d15285006 Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Sun, 5 Apr 2026 13:30:26 +0300 Subject: [PATCH] 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 --- .../src/lib/components/file/FileCard.svelte | 104 ++++++++++++- .../src/lib/components/layout/Header.svelte | 34 ++++- .../lib/components/layout/SelectionBar.svelte | 138 ++++++++++++++++++ frontend/src/lib/stores/selection.ts | 65 +++++++++ frontend/src/routes/files/+page.svelte | 118 +++++++++++++-- 5 files changed, 443 insertions(+), 16 deletions(-) create mode 100644 frontend/src/lib/components/layout/SelectionBar.svelte create mode 100644 frontend/src/lib/stores/selection.ts diff --git a/frontend/src/lib/components/file/FileCard.svelte b/frontend/src/lib/components/file/FileCard.svelte index a11d6d9..504025d 100644 --- a/frontend/src/lib/components/file/FileCard.svelte +++ b/frontend/src/lib/components/file/FileCard.svelte @@ -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(null); let failed = $state(false); @@ -40,8 +55,51 @@ }; }); - function handleClick() { - onclick?.(file); + // --- Long press + drag detection --- + let pressTimer: ReturnType | 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); } @@ -49,17 +107,38 @@
{ cancelPress(); didLongPress = false; }} + onpointerleave={cancelPress} + oncontextmenu={(e) => e.preventDefault()} + onclick={onClick} title={file.original_name ?? undefined} > {#if imgSrc} - {file.original_name + {file.original_name {:else if failed}
{:else}
{/if}
+ {#if selected} + + {:else if selectionMode} + + {/if}
\ No newline at end of file diff --git a/frontend/src/lib/stores/selection.ts b/frontend/src/lib/stores/selection.ts new file mode 100644 index 0000000..88da804 --- /dev/null +++ b/frontend/src/lib/stores/selection.ts @@ -0,0 +1,65 @@ +import { writable, derived } from 'svelte/store'; + +interface SelectionState { + active: boolean; + ids: Set; +} + +function createSelectionStore() { + const { subscribe, update, set } = writable({ + active: false, + ids: new Set(), + }); + + return { + subscribe, + + enter() { + update((s) => ({ ...s, active: true })); + }, + + exit() { + set({ active: false, ids: new Set() }); + }, + + toggle(id: string) { + update((s) => { + const ids = new Set(s.ids); + if (ids.has(id)) { + ids.delete(id); + } else { + ids.add(id); + } + // Exit selection mode automatically when last item is deselected + const active = ids.size > 0; + return { active, ids }; + }); + }, + + select(id: string) { + update((s) => { + const ids = new Set(s.ids); + ids.add(id); + return { active: true, ids }; + }); + }, + + deselect(id: string) { + update((s) => { + const ids = new Set(s.ids); + ids.delete(id); + const active = ids.size > 0; + return { active, ids }; + }); + }, + + clear() { + set({ active: false, ids: new Set() }); + }, + }; +} + +export const selectionStore = createSelectionStore(); + +export const selectionCount = derived(selectionStore, ($s) => $s.ids.size); +export const selectionActive = derived(selectionStore, ($s) => $s.active); \ No newline at end of file diff --git a/frontend/src/routes/files/+page.svelte b/frontend/src/routes/files/+page.svelte index 4f7f6cc..29316fa 100644 --- a/frontend/src/routes/files/+page.svelte +++ b/frontend/src/routes/files/+page.svelte @@ -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(`/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(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('[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); + }; + }); @@ -115,8 +195,15 @@ {/if}
- {#each files as file (file.id)} - + {#each files as file, i (file.id)} + handleTap(file, i, e)} + onLongPress={(pt) => handleLongPress(file, i, pt)} + /> {/each}
@@ -128,6 +215,19 @@ +{#if $selectionActive} + {/* TODO */}} + onAddToPool={() => {/* TODO */}} + onDelete={() => { + if (confirm(`Delete ${$selectionStore.ids.size} file(s)?`)) { + // TODO: call delete API + selectionStore.exit(); + } + }} + /> +{/if} + \ No newline at end of file +