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:
@@ -1,5 +1,6 @@
|
||||
<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 {
|
||||
sortOptions: { value: string; label: string }[];
|
||||
@@ -23,6 +24,14 @@
|
||||
</script>
|
||||
|
||||
<header>
|
||||
<button
|
||||
class="select-btn"
|
||||
class:active={$selectionActive}
|
||||
onclick={() => ($selectionActive ? selectionStore.exit() : selectionStore.enter())}
|
||||
>
|
||||
{$selectionActive ? 'Cancel' : 'Select'}
|
||||
</button>
|
||||
|
||||
<div class="controls">
|
||||
<select
|
||||
class="sort-select"
|
||||
@@ -70,6 +79,29 @@
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user