// 리스트(표) 보기 — 고정 행 높이 가상 스크롤. 이름·크기·날짜를 나란히 비교하고 // 열 머리글로 바로 정렬한다. 선택·정렬·필터·파일 작업은 격자 보기와 완전히 공유한다. import { IconArrowDown, IconArrowUp, IconTrash } from "../../ui/icons"; import { createEffect, createMemo, createSignal, For, on, onCleanup, onMount, Show, type Component, } from "solid-js"; import { createStore, produce, reconcile } from "solid-js/store"; import { anchorIndex, info, markedIds, openLightbox, registerItemViewFocus, selected, selectRange, selectSingle, setSort, snapshot, sortDesc, sortKey, tagVersion, thumbVersions, toggleSelect, trashTag, } from "../../state/store"; import { fileDetails, fileTagsBulk, setThumbPriority, thumbUrl, type FileDetails, type Snapshot, type SortKey, } from "../../ipc/commands"; import { ko } from "../../i18n/ko"; import { openContextMenu } from "../fileops/ContextMenu"; import { handleItemKeys } from "./itemKeys"; const ROW_H = 38; /** 화면 위·아래로 더 그려 두는 행 수 (스크롤 시 빈칸 방지) */ const OVERSCAN = 10; /** 한 번에 메타/태그를 채워 넣는 최대 행 수 (백엔드 file_details 상한 500 이내) */ const BATCH_MAX = 400; /** * 머리글과 행이 같은 열 정의를 공유한다. * 부가 정보(태그·해상도·길이)는 minmax(0,…)로 두어 창이 좁아지면 그 열부터 줄어든다 — * 이름·크기·날짜를 지키면서 가로 스크롤 없이 한 줄에 들어가게 하기 위함이다. */ const COLS = "2.75rem minmax(5rem,1fr) minmax(0,11rem) 3rem 4.75rem minmax(0,5.5rem) minmax(0,3.75rem) 8.5rem"; function fmtSize(bytes: number): string { if (bytes >= 1 << 30) return `${(bytes / (1 << 30)).toFixed(2)} GB`; 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`; } function fmtDate(ms: number | null | undefined): string { if (!ms) return "—"; const d = new Date(ms); const p = (n: number) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; } function fmtDuration(ms: number | null | undefined): string { if (!ms) return ""; const s = Math.round(ms / 1000); const m = Math.floor(s / 60); const h = Math.floor(m / 60); return h > 0 ? `${h}:${String(m % 60).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}` : `${m}:${String(s % 60).padStart(2, "0")}`; } export const ListView: Component = () => { let containerRef: HTMLDivElement | undefined; const [scrollTop, setScrollTop] = createSignal(0); const [viewportH, setViewportH] = createSignal(600); const count = () => snapshot()?.count ?? 0; // ── 가상 스크롤 ── const visibleRange = createMemo<[number, number]>(() => { const n = count(); if (n === 0) return [0, -1]; const first = Math.max(0, Math.floor(scrollTop() / ROW_H) - OVERSCAN); const last = Math.min(n - 1, Math.ceil((scrollTop() + viewportH()) / ROW_H) + OVERSCAN); return [first, last]; }); const indices = createMemo(() => { const [first, last] = visibleRange(); const out: number[] = []; for (let i = first; i <= last; i++) out.push(i); return out; }); let rafPending = false; const onScroll = () => { if (rafPending) return; rafPending = true; requestAnimationFrame(() => { rafPending = false; if (containerRef) setScrollTop(containerRef.scrollTop); }); }; /** 행을 화면 안으로 (키보드 이동·보기 전환용) */ const revealRow = (index: number) => { const el = containerRef; if (!el) return; const top = index * ROW_H; if (top < el.scrollTop) el.scrollTop = top; else if (top + ROW_H > el.scrollTop + el.clientHeight) el.scrollTop = top + ROW_H - el.clientHeight; }; onMount(() => { const el = containerRef!; // 크롬(툴바·브레드크럼·폴더 칩·사이드바)이 클릭 뒤 방향키를 여기로 되돌린다 onCleanup(registerItemViewFocus(() => containerRef?.focus({ preventScroll: true }))); // 높이는 폴링을 단일 진실원으로 — 값이 같으면 시그널이 갱신되지 않으므로 비용은 없다 const tick = () => setViewportH(el.clientHeight); tick(); const ro = new ResizeObserver(tick); ro.observe(el); const poll = setInterval(tick, 250); // 격자에서 넘어왔을 때 보고 있던 항목을 그대로 보여준다 const anchor = anchorIndex(); if (anchor >= 0) requestAnimationFrame(() => revealRow(anchor)); onCleanup(() => { ro.disconnect(); clearInterval(poll); }); }); // ── 지연 로드: 파일 메타 (이름·크기·날짜·길이) ── // 이름/경로는 이름변경·이동으로 바뀌므로 스냅샷이 갈릴 때 캐시를 버린다. const [detailsCache, setDetailsCache] = createStore>({}); let detTimer: ReturnType | undefined; createEffect(on(snapshot, () => setDetailsCache(reconcile({})), { defer: true })); createEffect(() => { 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 < BATCH_MAX; 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] = d; }), ), ) .catch(() => {}); }, 120); }); onCleanup(() => clearTimeout(detTimer)); // ── 지연 로드: 태그 (격자 오버레이와 같은 배치 조회) ── const [tagCache, setTagCache] = createStore>({}); let tagTimer: ReturnType | undefined; createEffect(on(tagVersion, () => setTagCache(reconcile({})), { defer: true })); createEffect(() => { const [first, last] = visibleRange(); const snap = snapshot(); tagVersion(); // 태그가 바뀌면 다시 읽는다 if (!snap || last < first) return; clearTimeout(tagTimer); tagTimer = setTimeout(() => { const need: number[] = []; for (let i = first; i <= last && need.length < BATCH_MAX; i++) { const id = snap.id(i); if (tagCache[id] === undefined) need.push(id); } if (need.length === 0) return; void fileTagsBulk(need) .then((links) => setTagCache( produce((s) => { for (const id of need) s[id] = []; for (const l of links) { if (l.tagId === trashTag()) continue; // 삭제 예정은 🗑 배지가 담당 (s[l.fileId] ??= []).push(l.name); } }), ), ) .catch(() => {}); }, 150); }); onCleanup(() => clearTimeout(tagTimer)); // 화면에 보이는데 썸네일이 없는 항목 우선 생성 요청 let prioTimer: ReturnType | undefined; createEffect(() => { const [first, last] = visibleRange(); const snap = snapshot(); if (!snap || last < first) return; clearTimeout(prioTimer); prioTimer = setTimeout(() => { const ids: number[] = []; for (let i = first; i <= last && ids.length < BATCH_MAX; i++) { if (!snap.hasThumb(i)) ids.push(snap.id(i)); } if (ids.length > 0) void setThumbPriority(ids); }, 200); }); onCleanup(() => clearTimeout(prioTimer)); // ── 상호작용 ── const onRowClick = (e: MouseEvent, snap: Snapshot, index: number) => { const id = snap.id(index); if (e.shiftKey) selectRange(snap, index); else if (e.ctrlKey || e.metaKey) toggleSelect(id, index); else selectSingle(id, index); }; const onRowContextMenu = (e: MouseEvent, snap: Snapshot, index: number) => { e.preventDefault(); const id = snap.id(index); if (!selected().has(id)) selectSingle(id, index); void fileDetails([id]).then((d) => { openContextMenu({ x: e.clientX, y: e.clientY, fileId: id, fileName: d[0]?.name }); }); }; const onRowDragStart = (e: DragEvent, snap: Snapshot, index: number) => { const id = snap.id(index); if (!selected().has(id)) selectSingle(id, index); e.dataTransfer?.setData("application/x-archive-move", "1"); if (e.dataTransfer) e.dataTransfer.effectAllowed = "move"; }; const onKeyDown = (e: KeyboardEvent) => { const snap = snapshot(); if (!snap) return; handleItemKeys(e, snap, { neighbor: (from, key) => key === "ArrowUp" || key === "ArrowLeft" ? from > 0 ? from - 1 : undefined : from < snap.count - 1 ? from + 1 : undefined, onMove: revealRow, }); }; // ── 날짜 열: 정렬이 '수정일'이면 수정일을, 그 밖에는 촬영일을 보여준다 ── const dateKey = (): SortKey => (sortKey() === "mtime" ? "mtime" : "taken"); const dateLabel = () => (sortKey() === "mtime" ? ko.list.modified : ko.list.taken); const dateOf = (d: FileDetails | undefined) => d ? (sortKey() === "mtime" ? d.mtimeMs : (d.takenAt ?? d.mtimeMs)) : undefined; /** 열 머리글 — 누르면 그 열로 정렬, 다시 누르면 방향 반전 */ const SortHead: Component<{ label: string; col: SortKey; right?: boolean }> = (props) => ( ); return (
{/* 머리글 — 열 폭은 행과 같은 정의를 쓴다 */}
{ko.list.tags} {ko.list.kind} {ko.list.dims} {ko.list.duration}
0} fallback={
{ko.grid.empty}
} >
{(i) => { // 모든 파생값은 반응형으로 — For가 같은 인덱스의 DOM을 재사용해도 // 뷰 전환으로 스냅샷이 바뀌면 즉시 갱신되도록 한다. const id = () => snapshot()?.id(i) ?? -1; const d = () => detailsCache[id()]; const isSel = () => selected().has(id()); const marked = () => markedIds().has(id()); const isVideo = () => snapshot()?.kind(i) === 1; const w = () => snapshot()?.width(i) ?? 0; const h = () => snapshot()?.height(i) ?? 0; const version = () => thumbVersions[id()] ?? 0; const showImg = () => { const s = snapshot(); return !!s && (s.hasThumb(i) || version() > 0); }; const rowTags = () => tagCache[id()] ?? []; return (
{ const s = snapshot(); if (s) onRowClick(e, s, i); }} onDblClick={() => openLightbox(i)} onContextMenu={(e) => { const s = snapshot(); if (s) onRowContextMenu(e, s, i); }} draggable={true} onDragStart={(e) => { const s = snapshot(); if (s) onRowDragStart(e, s, i); }} > {/* 썸네일 — 미디어 면이 아니라 28×36 크롬 슬롯이다. --grid-cell-bg(고정 다크)를 쓰면 흰 카드 안에 검은 사각형이 박힌다. */}
{ (e.currentTarget as HTMLImageElement).style.display = "none"; }} />
{/* 이름 (+ 삭제 예정 배지) */}
{d()?.name ?? "…"}
{/* 태그 */}
{(name) => ( {name} )} 3}> +{rowTags().length - 3}
{isVideo() ? ko.filter.video : ko.filter.image} {d() ? fmtSize(d()!.size) : ""} {/* 좁아지면 0폭까지 줄어드는 열 — 넘친 글자가 옆 열을 침범하지 않게 자른다 */} {w() > 0 && h() > 0 ? `${w()}×${h()}` : "—"} {fmtDuration(d()?.durationMs)} {fmtDate(dateOf(d()))}
); }}
); };