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:
parent
63ea1a4d6a
commit
aebf7127af
@ -3,12 +3,27 @@
|
|||||||
import { authStore } from '$lib/stores/auth';
|
import { authStore } from '$lib/stores/auth';
|
||||||
import type { File } from '$lib/api/types';
|
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 {
|
interface Props {
|
||||||
file: File;
|
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 imgSrc = $state<string | null>(null);
|
||||||
let failed = $state(false);
|
let failed = $state(false);
|
||||||
@ -40,8 +55,51 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleClick() {
|
// --- Long press + drag detection ---
|
||||||
onclick?.(file);
|
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>
|
</script>
|
||||||
|
|
||||||
@ -49,17 +107,38 @@
|
|||||||
<div
|
<div
|
||||||
class="card"
|
class="card"
|
||||||
class:loaded={!!imgSrc}
|
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}
|
title={file.original_name ?? undefined}
|
||||||
>
|
>
|
||||||
{#if imgSrc}
|
{#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}
|
{:else if failed}
|
||||||
<div class="placeholder failed" aria-label="Failed to load"></div>
|
<div class="placeholder failed" aria-label="Failed to load"></div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="placeholder loading" aria-label="Loading"></div>
|
<div class="placeholder loading" aria-label="Loading"></div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="overlay"></div>
|
<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>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@ -73,6 +152,8 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background-color: var(--color-bg-elevated);
|
background-color: var(--color-bg-elevated);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumb {
|
.thumb {
|
||||||
@ -114,6 +195,17 @@
|
|||||||
background-color: rgba(0, 0, 0, 0.3);
|
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 {
|
@keyframes shimmer {
|
||||||
0% { background-position: 200% 0; }
|
0% { background-position: 200% 0; }
|
||||||
100% { background-position: -200% 0; }
|
100% { background-position: -200% 0; }
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { FileSortField, SortOrder } from '$lib/stores/sorting';
|
import type { SortOrder } from '$lib/stores/sorting';
|
||||||
|
import { selectionStore, selectionActive } from '$lib/stores/selection';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sortOptions: { value: string; label: string }[];
|
sortOptions: { value: string; label: string }[];
|
||||||
@ -23,6 +24,14 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
|
<button
|
||||||
|
class="select-btn"
|
||||||
|
class:active={$selectionActive}
|
||||||
|
onclick={() => ($selectionActive ? selectionStore.exit() : selectionStore.enter())}
|
||||||
|
>
|
||||||
|
{$selectionActive ? 'Cancel' : 'Select'}
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<select
|
<select
|
||||||
class="sort-select"
|
class="sort-select"
|
||||||
@ -70,6 +79,29 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.select-btn {
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-btn:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-btn.active {
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 25%, var(--color-bg-elevated));
|
||||||
|
color: var(--color-accent);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
138
frontend/src/lib/components/layout/SelectionBar.svelte
Normal file
138
frontend/src/lib/components/layout/SelectionBar.svelte
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { selectionStore, selectionCount } from '$lib/stores/selection';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onEditTags: () => void;
|
||||||
|
onAddToPool: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onEditTags, onAddToPool, onDelete }: Props = $props();
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') selectionStore.exit();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
<div class="bar" role="toolbar" aria-label="Selection actions">
|
||||||
|
<div class="row">
|
||||||
|
<!-- Count / deselect all -->
|
||||||
|
<button class="count" onclick={() => selectionStore.exit()} title="Clear selection">
|
||||||
|
<span class="num">{$selectionCount}</span>
|
||||||
|
<span class="label">selected</span>
|
||||||
|
<svg class="close-icon" width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
|
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="spacer"></div>
|
||||||
|
|
||||||
|
<button class="action edit-tags" onclick={onEditTags}>Edit tags</button>
|
||||||
|
<button class="action add-pool" onclick={onAddToPool}>Add to pool</button>
|
||||||
|
<button class="action delete" onclick={onDelete}>Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bar {
|
||||||
|
position: fixed;
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
bottom: 65px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 0 12px rgba(0, 0, 0, 0.5);
|
||||||
|
padding: 12px 14px;
|
||||||
|
z-index: 100;
|
||||||
|
animation: slide-up 0.18s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-up {
|
||||||
|
from { transform: translateY(12px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-icon {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count:hover .close-icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-tags {
|
||||||
|
color: var(--color-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-tags:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-info) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-pool {
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-pool:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-warning) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-danger) 15%, transparent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
65
frontend/src/lib/stores/selection.ts
Normal file
65
frontend/src/lib/stores/selection.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { writable, derived } from 'svelte/store';
|
||||||
|
|
||||||
|
interface SelectionState {
|
||||||
|
active: boolean;
|
||||||
|
ids: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSelectionStore() {
|
||||||
|
const { subscribe, update, set } = writable<SelectionState>({
|
||||||
|
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);
|
||||||
@ -6,8 +6,10 @@
|
|||||||
import FileCard from '$lib/components/file/FileCard.svelte';
|
import FileCard from '$lib/components/file/FileCard.svelte';
|
||||||
import FilterBar from '$lib/components/file/FilterBar.svelte';
|
import FilterBar from '$lib/components/file/FilterBar.svelte';
|
||||||
import Header from '$lib/components/layout/Header.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 InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
|
||||||
import { fileSorting, type FileSortField } from '$lib/stores/sorting';
|
import { fileSorting, type FileSortField } from '$lib/stores/sorting';
|
||||||
|
import { selectionStore, selectionActive } from '$lib/stores/selection';
|
||||||
import { parseDslFilter } from '$lib/utils/dsl';
|
import { parseDslFilter } from '$lib/utils/dsl';
|
||||||
import type { File, FileCursorPage } from '$lib/api/types';
|
import type { File, FileCursorPage } from '$lib/api/types';
|
||||||
|
|
||||||
@ -27,14 +29,10 @@
|
|||||||
let error = $state('');
|
let error = $state('');
|
||||||
let filterOpen = $state(false);
|
let filterOpen = $state(false);
|
||||||
|
|
||||||
// Derive current filter from URL ?filter= param
|
|
||||||
let filterParam = $derived($page.url.searchParams.get('filter'));
|
let filterParam = $derived($page.url.searchParams.get('filter'));
|
||||||
let activeTokens = $derived(parseDslFilter(filterParam));
|
let activeTokens = $derived(parseDslFilter(filterParam));
|
||||||
|
|
||||||
// Track sort/order from store
|
|
||||||
let sortState = $derived($fileSorting);
|
let sortState = $derived($fileSorting);
|
||||||
|
|
||||||
// Reset + reload whenever sort or filter changes
|
|
||||||
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${filterParam ?? ''}`);
|
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${filterParam ?? ''}`);
|
||||||
let prevKey = $state('');
|
let prevKey = $state('');
|
||||||
|
|
||||||
@ -52,7 +50,6 @@
|
|||||||
if (loading || !hasMore) return;
|
if (loading || !hasMore) return;
|
||||||
loading = true;
|
loading = true;
|
||||||
error = '';
|
error = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
limit: String(LIMIT),
|
limit: String(LIMIT),
|
||||||
@ -61,7 +58,6 @@
|
|||||||
});
|
});
|
||||||
if (nextCursor) params.set('cursor', nextCursor);
|
if (nextCursor) params.set('cursor', nextCursor);
|
||||||
if (filterParam) params.set('filter', filterParam);
|
if (filterParam) params.set('filter', filterParam);
|
||||||
|
|
||||||
const res = await api.get<FileCursorPage>(`/files?${params}`);
|
const res = await api.get<FileCursorPage>(`/files?${params}`);
|
||||||
files = [...files, ...(res.items ?? [])];
|
files = [...files, ...(res.items ?? [])];
|
||||||
nextCursor = res.next_cursor ?? null;
|
nextCursor = res.next_cursor ?? null;
|
||||||
@ -84,6 +80,90 @@
|
|||||||
goto(url.toString(), { replaceState: true });
|
goto(url.toString(), { replaceState: true });
|
||||||
filterOpen = false;
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@ -115,8 +195,15 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{#each files as file (file.id)}
|
{#each files as file, i (file.id)}
|
||||||
<FileCard {file} />
|
<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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -128,6 +215,19 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</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>
|
<style>
|
||||||
.page {
|
.page {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@ -169,4 +269,4 @@
|
|||||||
padding: 60px 20px;
|
padding: 60px 20px;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user