// 커스텀 justified 가상 그리드 — 레이아웃은 워커에서, 렌더는 뷰포트 ±1화면만. import { IconImage, 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 { computeLayout, type LayoutResult } from "./layout"; import { clearSelection, gridRowHeight, info, markedIds, openLightbox, registerItemViewFocus, selected, selectSingle, setSelectionFromIds, showInfo, toggleSelect, selectRange, snapshot, tagVersion, thumbVersions, trashTag, zoomGrid, } from "../../state/store"; import { fileDetails, fileTagsBulk, setThumbPriority, thumbUrl, type Snapshot, } from "../../ipc/commands"; import { ko } from "../../i18n/ko"; import { openContextMenu } from "../fileops/ContextMenu"; import { handleItemKeys } from "../browse/itemKeys"; const GAP = 8; /** 스크롤러 안쪽 여백. 레이아웃 폭·마퀴 좌표가 이 한 상수를 공유한다 — 흩어져 있으면 여백을 바꾼 순간 드래그 사각형이 커서에서 어긋난다. */ const PAD = 8; const OVERSCAN_PX = 800; /** 이 폭 미만 셀에서는 태그 라벨을 감춘다 (썸네일을 가림) */ const TAG_MIN_CELL_W = 120; 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; let hi = rowTops.length - 2; // 마지막 요소는 전체 높이 while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (rowTops[mid] <= y) lo = mid; else hi = mid - 1; } return lo; } export const JustifiedGrid: Component = () => { let containerRef: HTMLDivElement | undefined; const [layout, setLayout] = createSignal(); const [scrollTop, setScrollTop] = createSignal(0); const [viewportH, setViewportH] = createSignal(600); // 마퀴(드래그 사각형) 선택 — 콘텐츠 좌표계 [x1,y1,x2,y2] const [marquee, setMarquee] = createSignal<[number, number, number, number] | null>(null); // 레이아웃 재계산 — 컨테이너 폭을 DOM에서 직접 읽고 메인 스레드에서 동기 계산. const recompute = () => { const snap = snapshot(); if (!snap || !containerRef) { setLayout(undefined); return; } const w = containerRef.clientWidth - PAD * 2; // 패딩 보정 if (w <= 0) return; const ratios = snap.aspectRatios(); setLayout(computeLayout(ratios, w, gridRowHeight(), GAP)); }; // 데이터(스냅샷) 변경 시 즉시 재계산. createEffect(on(snapshot, recompute)); onMount(() => { const el = containerRef!; // 크롬(툴바·브레드크럼·폴더 칩·사이드바)이 클릭 뒤 방향키를 여기로 되돌린다 onCleanup(registerItemViewFocus(() => containerRef?.focus({ preventScroll: true }))); let lastW = -1; let lastRh = -1; // 폭과 줌(gridRowHeight)을 함께 감시 — 어느 쪽이 바뀌든 재배치. // (이벤트/이펙트 누락 환경에 견고하도록 폴링을 단일 진실원으로 사용) const tick = () => { const w = el.clientWidth; const rh = gridRowHeight(); setViewportH(el.clientHeight); if (Math.abs(w - lastW) > 0.5 || rh !== lastRh) { lastW = w; lastRh = rh; recompute(); } }; tick(); [50, 200, 500].forEach((ms) => setTimeout(tick, ms)); const ro = new ResizeObserver(tick); ro.observe(el); const onWinResize = () => requestAnimationFrame(tick); window.addEventListener("resize", onWinResize); const poll = setInterval(tick, 150); onCleanup(() => { ro.disconnect(); window.removeEventListener("resize", onWinResize); clearInterval(poll); }); }); // Ctrl+휠 → 그리드 줌 (썸네일 크기). 즉시 재계산. const onWheel = (e: WheelEvent) => { if (!e.ctrlKey && !e.metaKey) return; e.preventDefault(); zoomGrid(e.deltaY < 0 ? 1.12 : 1 / 1.12); recompute(); }; // rAF 스로틀 스크롤 let rafPending = false; const onScroll = () => { if (rafPending) return; rafPending = true; requestAnimationFrame(() => { rafPending = false; if (containerRef) setScrollTop(containerRef.scrollTop); }); }; /** 가시 항목 인덱스 범위 */ const visibleRange = createMemo<[number, number]>(() => { const l = layout(); const snap = snapshot(); if (!l || !snap || l.rowFirst.length === 0) return [0, -1]; const top = Math.max(0, scrollTop() - OVERSCAN_PX); const bottom = scrollTop() + viewportH() + OVERSCAN_PX; const firstRow = findRow(l.rowTops, top); let lastRow = firstRow; while (lastRow + 1 < l.rowFirst.length && l.rowTops[lastRow + 1] < bottom) lastRow++; const first = l.rowFirst[firstRow]; const last = lastRow + 1 < l.rowFirst.length ? l.rowFirst[lastRow + 1] - 1 : snap.count - 1; return [first, last]; }); // 뷰포트 항목 중 썸네일 없는 것 우선 생성 요청 (디바운스 200ms) 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 < 512; i++) { if (!snap.hasThumb(i)) ids.push(snap.id(i)); } if (ids.length > 0) void setThumbPriority(ids); }, 200); }); onCleanup(() => clearTimeout(prioTimer)); // 썸네일 위 태그 라벨 — 가시 범위만 배치 조회. 태그가 바뀌면(tagVersion) 캐시를 버린다. 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 < 512; 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) => { // 조회한 id는 태그가 없어도 빈 배열로 채워 재조회를 막는다 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)); // 정보 오버레이용 파일명/크기 지연 로드 (showInfo 켜졌을 때 가시 범위만) const [detailsCache, setDetailsCache] = createStore>({}); let detTimer: ReturnType | 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[] = []; for (let i = first; i <= last; i++) out.push(i); return out; }); const onCellClick = (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); }; /** 위/아래 화살표: 레이아웃 기하 기반 — 인접 행에서 x중심이 가장 가까운 셀 */ const verticalNeighbor = (from: number, dir: -1 | 1): number | undefined => { const l = layout(); const snap = snapshot(); if (!l || !snap) return undefined; const cx = l.boxes[from * 4] + l.boxes[from * 4 + 2] / 2; const cy = l.boxes[from * 4 + 1]; let best: number | undefined; let bestDist = Infinity; // 최대 3행 거리까지 탐색 (마지막 행 채움 편차 대응) const step = dir === 1 ? 1 : -1; for (let i = from + step; i >= 0 && i < snap.count; i += step) { const y = l.boxes[i * 4 + 1]; if (dir === 1 ? y <= cy : y >= cy) continue; // 같은 행 스킵 const nx = l.boxes[i * 4] + l.boxes[i * 4 + 2] / 2; const d = Math.abs(nx - cx); if (d < bestDist) { bestDist = d; best = i; } else if (best !== undefined && Math.abs(y - l.boxes[best * 4 + 1]) > 1) { break; // 다음 행으로 넘어가면 종료 } } return best; }; /** 선택된 셀을 화면 안으로 (키보드 이동용) */ const revealCell = (index: number) => { const l = layout(); const el = containerRef; if (!l || !el) return; const top = l.boxes[index * 4 + 1]; const h = l.boxes[index * 4 + 3]; if (top < el.scrollTop) el.scrollTop = top; else if (top + h > el.scrollTop + el.clientHeight) el.scrollTop = top + h - el.clientHeight; }; const onKeyDown = (e: KeyboardEvent) => { const snap = snapshot(); if (!snap) return; handleItemKeys(e, snap, { neighbor: (from, key) => { if (key === "ArrowLeft") return from > 0 ? from - 1 : undefined; if (key === "ArrowRight") return from < snap.count - 1 ? from + 1 : undefined; return verticalNeighbor(from, key === "ArrowDown" ? 1 : -1); }, onMove: revealCell, }); }; const onCellContextMenu = (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 onCellDragStart = (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"; }; // ── 마퀴(드래그 사각형) 선택 ── // 콘텐츠 좌표 = clientX - 컨테이너rect - 패딩(PAD) (+ y는 scrollTop) const toContent = (clientX: number, clientY: number): [number, number] => { const rect = containerRef!.getBoundingClientRect(); return [ clientX - rect.left - PAD, clientY - rect.top - PAD + containerRef!.scrollTop, ]; }; const onBgMouseDown = (e: MouseEvent) => { // 배경(빈 공간) 좌클릭에서만 시작 — 셀 클릭은 제외 if (e.button !== 0 || !(e.target as HTMLElement).dataset.gridbg) return; const [sx, sy] = toContent(e.clientX, e.clientY); setMarquee([sx, sy, sx, sy]); if (!e.ctrlKey && !e.metaKey) clearSelection(); const onMove = (me: MouseEvent) => { const [cx, cy] = toContent(me.clientX, me.clientY); const m = marquee(); if (m) setMarquee([m[0], m[1], cx, cy]); }; const onUp = () => { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); commitMarquee(); setMarquee(null); }; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); }; const commitMarquee = () => { const m = marquee(); const l = layout(); const snap = snapshot(); if (!m || !l || !snap) return; const [x1, y1, x2, y2] = [Math.min(m[0], m[2]), Math.min(m[1], m[3]), Math.max(m[0], m[2]), Math.max(m[1], m[3])]; // 너무 작은 드래그는 무시(클릭으로 간주) if (x2 - x1 < 4 && y2 - y1 < 4) return; const ids: number[] = []; for (let i = 0; i < snap.count; i++) { const bx = l.boxes[i * 4]; const by = l.boxes[i * 4 + 1]; const bw = l.boxes[i * 4 + 2]; const bh = l.boxes[i * 4 + 3]; // AABB 교차 if (bx < x2 && bx + bw > x1 && by < y2 && by + bh > y1) ids.push(snap.id(i)); } setSelectionFromIds(ids); }; const marqueeRect = createMemo(() => { const m = marquee(); if (!m) return null; return { left: Math.min(m[0], m[2]), top: Math.min(m[1], m[3]), width: Math.abs(m[2] - m[0]), height: Math.abs(m[3] - m[1]), }; }); return (
0} fallback={
{ko.grid.empty}
} >
{(r) => (
)} {(i) => { // 모든 파생값은 반응형으로 — For가 같은 인덱스 값에 대해 DOM을 재사용해도 // 뷰(폴더) 전환 시 스냅샷이 바뀌면 id/썸네일/선택상태가 즉시 갱신되도록 한다. const box = () => { const l = layout(); return l ? [l.boxes[i * 4], l.boxes[i * 4 + 1], l.boxes[i * 4 + 2], l.boxes[i * 4 + 3]] : [0, 0, 0, 0]; }; const id = () => { const s = snapshot(); return s ? s.id(i) : -1; }; const version = () => thumbVersions[id()] ?? 0; const showImg = () => { const s = snapshot(); return !!s && (s.hasThumb(i) || version() > 0); }; // 썸네일 미준비 상태: 실패(state 2)면 정적 표시, 그 외엔 '생성 중' 스켈레톤. const failed = () => { const s = snapshot(); return !!s && s.thumbFailed(i); }; const isVideo = () => { const s = snapshot(); return !!s && s.kind(i) === 1; }; const marked = () => markedIds().has(id()); // 별점은 스냅샷 flags 비트2~4에서 온다(추가 조회 없음) const rating = () => { const s = snapshot(); return s ? s.rating(i) : 0; }; // 좁은 셀일수록 적게 — 나머지는 +N으로 접는다 const cellTags = () => tagCache[id()] ?? []; const tagLimit = () => (box()[2] >= 260 ? 3 : box()[2] >= 180 ? 2 : 1); const visibleTags = () => cellTags().slice(0, tagLimit()); const hiddenTagCount = () => Math.max(0, cellTags().length - tagLimit()); return (
{ const s = snapshot(); if (s) onCellClick(e, s, i); }} onDblClick={() => openLightbox(i)} onContextMenu={(e) => { const s = snapshot(); if (s) onCellContextMenu(e, s, i); }} draggable={true} onDragStart={(e) => { const s = snapshot(); if (s) onCellDragStart(e, s, i); }} >
} >
} > { (e.currentTarget as HTMLImageElement).style.display = ""; }} onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }} /> {ko.filter.video} {/* 별점 — 매긴 것만 보여준다. 0점까지 빈 별로 그리면 모든 셀에 별 5개가 깔려서 사진을 가린다(별점은 소수의 항목에만 붙는 표시다). */} 0}> {"★".repeat(rating())} {/* 상단 라벨 줄 — 삭제 예정 배지 + 태그. 셀이 좁으면 태그가 썸네일을 다 덮으므로 일정 폭부터만 보여준다. */}
= TAG_MIN_CELL_W}> {(name) => ( {name} )} 0}> +{hiddenTagCount()}
{/* 파일명·크기 오버레이 (툴바 ⓘ 토글) */}
{detailsCache[id()]?.name ?? ""}
{fmtSize(detailsCache[id()]!.size)}
); }}
); };