* design: add multi-select notelist wireframes Three frames: selected notes with bulk action bar, Shift+click range select, and empty selection / hover states. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add multi-select notes with bulk archive and trash actions Cmd/Ctrl+Click toggles individual note selection, Shift+Click selects a range, Escape clears. A bulk action bar appears at the bottom with Archive and Trash buttons. Includes useMultiSelect hook, BulkActionBar component, Rust batch_archive_notes/batch_trash_notes commands, and 9 new tests covering the multi-select behavior. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: trigger GitHub Actions run --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
68 lines
1.8 KiB
TypeScript
68 lines
1.8 KiB
TypeScript
import { useState, useCallback, useRef } from 'react'
|
|
import type { VaultEntry } from '../types'
|
|
|
|
export interface MultiSelectState {
|
|
selectedPaths: Set<string>
|
|
isMultiSelecting: boolean
|
|
toggle: (path: string) => void
|
|
selectRange: (toPath: string) => void
|
|
clear: () => void
|
|
selectAll: () => void
|
|
}
|
|
|
|
export function useMultiSelect(visibleEntries: VaultEntry[]): MultiSelectState {
|
|
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set())
|
|
const lastClickedRef = useRef<string | null>(null)
|
|
|
|
const toggle = useCallback((path: string) => {
|
|
setSelectedPaths((prev) => {
|
|
const next = new Set(prev)
|
|
if (next.has(path)) next.delete(path)
|
|
else next.add(path)
|
|
return next
|
|
})
|
|
lastClickedRef.current = path
|
|
}, [])
|
|
|
|
const selectRange = useCallback((toPath: string) => {
|
|
const fromPath = lastClickedRef.current
|
|
if (!fromPath) {
|
|
toggle(toPath)
|
|
return
|
|
}
|
|
const paths = visibleEntries.map((e) => e.path)
|
|
const fromIdx = paths.indexOf(fromPath)
|
|
const toIdx = paths.indexOf(toPath)
|
|
if (fromIdx === -1 || toIdx === -1) {
|
|
toggle(toPath)
|
|
return
|
|
}
|
|
const start = Math.min(fromIdx, toIdx)
|
|
const end = Math.max(fromIdx, toIdx)
|
|
setSelectedPaths((prev) => {
|
|
const next = new Set(prev)
|
|
for (let i = start; i <= end; i++) next.add(paths[i])
|
|
return next
|
|
})
|
|
lastClickedRef.current = toPath
|
|
}, [visibleEntries, toggle])
|
|
|
|
const clear = useCallback(() => {
|
|
setSelectedPaths(new Set())
|
|
lastClickedRef.current = null
|
|
}, [])
|
|
|
|
const selectAll = useCallback(() => {
|
|
setSelectedPaths(new Set(visibleEntries.map((e) => e.path)))
|
|
}, [visibleEntries])
|
|
|
|
return {
|
|
selectedPaths,
|
|
isMultiSelecting: selectedPaths.size > 0,
|
|
toggle,
|
|
selectRange,
|
|
clear,
|
|
selectAll,
|
|
}
|
|
}
|