편의 기능(정렬·종류필터·전체선택/단축키·정보 오버레이) + 스냅샷 범위 안전화

Snapshot 접근자 범위 안전화(핵심 버그):
- id/width/height/kind/hasThumb/thumbFailed가 count 밖 i에서 DataView RangeError를
  던져 Solid 반응성 그래프 전체가 무너지던 문제 수정. 스냅샷이 줄어들 때(필터/검색/
  폴더 전환) For가 초과 셀을 제거하기 전에 셀 접근자가 옛 인덱스로 재실행되며 발생.
  범위 밖이면 0/false 반환. (증상: 필터/검색이 한 번은 되고 이후 먹통)

기능:
- 정렬: 촬영일/이름/크기/수정일 × 오름·내림 (툴바 드롭다운+방향 토글).
  backend snap_tail로 ORDER BY 구성, 4개 스냅샷 커맨드에 sort/kind 파라미터 추가.
- 종류 필터: 전체/사진/영상 툴바 토글 (backend kind 필터).
- 전체 선택(Ctrl+A)/선택 반전(Ctrl+Shift+A), ? 키 단축키 도움말 오버레이.
- 썸네일 정보 오버레이(ⓘ 토글): 가시 셀에 파일명·크기 지연 로드 표시.

검증(사진120+영상25): 종류필터 120/25/145/120 반복 정상, 정렬 순서 변경, Ctrl+A
145개 선택, ? 오버레이, ⓘ로 파일명 표시 확인.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
강 한
2026-07-20 00:34:52 +09:00
co-authored by Claude Opus 4.8
parent ac3b527e8e
commit 67d2ddc59e
8 changed files with 367 additions and 36 deletions
+66
View File
@@ -10,6 +10,7 @@ import {
Show,
type Component,
} from "solid-js";
import { createStore, produce } from "solid-js/store";
import { computeLayout, type LayoutResult } from "./layout";
import {
anchorIndex,
@@ -17,9 +18,11 @@ import {
gridRowHeight,
info,
openLightbox,
openShortcuts,
selected,
selectSingle,
setSelectionFromIds,
showInfo,
toggleSelect,
selectRange,
snapshot,
@@ -38,6 +41,12 @@ import {
const GAP = 6;
const OVERSCAN_PX = 800;
function fmtSize(bytes: number): string {
if (bytes >= 1 << 20) return `${(bytes / (1 << 20)).toFixed(1)}MB`;
if (bytes >= 1 << 10) return `${(bytes / (1 << 10)).toFixed(0)}KB`;
return `${bytes}B`;
}
/** rowTops에서 y 이상이 처음 나오는 행 인덱스 (이진탐색) */
function findRow(rowTops: Float32Array, y: number): number {
let lo = 0;
@@ -156,6 +165,35 @@ export const JustifiedGrid: Component = () => {
});
onCleanup(() => clearTimeout(prioTimer));
// 정보 오버레이용 파일명/크기 지연 로드 (showInfo 켜졌을 때 가시 범위만)
const [detailsCache, setDetailsCache] = createStore<Record<number, { name: string; size: number }>>({});
let detTimer: ReturnType<typeof setTimeout> | undefined;
createEffect(() => {
if (!showInfo()) return;
const [first, last] = visibleRange();
const snap = snapshot();
if (!snap || last < first) return;
clearTimeout(detTimer);
detTimer = setTimeout(() => {
const need: number[] = [];
for (let i = first; i <= last && need.length < 512; i++) {
const id = snap.id(i);
if (detailsCache[id] === undefined) need.push(id);
}
if (need.length === 0) return;
void fileDetails(need)
.then((ds) =>
setDetailsCache(
produce((s) => {
for (const d of ds) s[d.id] = { name: d.name, size: d.size };
}),
),
)
.catch(() => {});
}, 150);
});
onCleanup(() => clearTimeout(detTimer));
const indices = createMemo(() => {
const [first, last] = visibleRange();
const out: number[] = [];
@@ -206,6 +244,25 @@ export const JustifiedGrid: Component = () => {
void performUndo();
return;
}
// Ctrl+A 전체 선택 / Ctrl+Shift+A 선택 반전
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") {
e.preventDefault();
const all: number[] = [];
for (let i = 0; i < snap.count; i++) all.push(snap.id(i));
if (e.shiftKey) {
const cur = selected();
setSelectionFromIds(all.filter((id) => !cur.has(id)));
} else {
setSelectionFromIds(all);
}
return;
}
// ? 단축키 도움말
if (e.key === "?") {
e.preventDefault();
openShortcuts();
return;
}
switch (e.key) {
case "Enter":
@@ -456,6 +513,15 @@ export const JustifiedGrid: Component = () => {
</span>
</Show>
{/* 파일명·크기 오버레이 (툴바 ⓘ 토글) */}
<Show when={showInfo()}>
<div class="pointer-events-none absolute inset-x-0 bottom-0 bg-black/55 px-1 py-0.5 leading-tight">
<div class="truncate text-[10px] text-white/90">{detailsCache[id()]?.name ?? ""}</div>
<Show when={detailsCache[id()]}>
<div class="text-[9px] text-white/60">{fmtSize(detailsCache[id()]!.size)}</div>
</Show>
</div>
</Show>
</div>
);
}}