diff --git a/src/features/grid/JustifiedGrid.tsx b/src/features/grid/JustifiedGrid.tsx index bde9bff..83a421e 100644 --- a/src/features/grid/JustifiedGrid.tsx +++ b/src/features/grid/JustifiedGrid.tsx @@ -14,6 +14,7 @@ import LayoutWorker from "./layout.worker?worker"; import { anchorIndex, clearSelection, + gridRowHeight, info, openLightbox, selected, @@ -23,6 +24,7 @@ import { selectRange, snapshot, thumbVersions, + zoomGrid, } from "../../state/store"; import { fileDetails, setThumbPriority, thumbUrl, type Snapshot } from "../../ipc/commands"; import { ko } from "../../i18n/ko"; @@ -33,7 +35,6 @@ import { performUndo, } from "../fileops/ContextMenu"; -const TARGET_ROW_HEIGHT = 200; const GAP = 6; const OVERSCAN_PX = 800; @@ -65,10 +66,11 @@ export const JustifiedGrid: Component = () => { }; onCleanup(() => worker.terminate()); - // 스냅샷/폭 변경 → 레이아웃 재계산 + // 스냅샷/폭/줌 변경 → 레이아웃 재계산 createEffect(() => { const snap = snapshot(); const w = containerW(); + const rowH = gridRowHeight(); if (!snap || w <= 0) { setLayout(undefined); return; @@ -76,7 +78,7 @@ export const JustifiedGrid: Component = () => { layoutSeq++; const ratios = snap.aspectRatios(); worker.postMessage( - { seq: layoutSeq, ratios, containerWidth: w, targetRowHeight: TARGET_ROW_HEIGHT, gap: GAP }, + { seq: layoutSeq, ratios, containerWidth: w, targetRowHeight: rowH, gap: GAP }, [ratios.buffer], ); }); @@ -91,6 +93,13 @@ export const JustifiedGrid: Component = () => { onCleanup(() => ro.disconnect()); }); + // Ctrl+휠 → 그리드 줌 (썸네일 크기) + const onWheel = (e: WheelEvent) => { + if (!e.ctrlKey && !e.metaKey) return; + e.preventDefault(); + zoomGrid(e.deltaY < 0 ? 1.12 : 1 / 1.12); + }; + // rAF 스로틀 스크롤 let rafPending = false; const onScroll = () => { @@ -315,6 +324,7 @@ export const JustifiedGrid: Component = () => {
로 직접 그릴 수 있는 확장자 (그 외는 썸네일 폴백 — HEIC/RAW 등) */ const IMG_NATIVE = new Set(["jpg", "jpeg", "jfif", "png", "gif", "webp", "bmp", "avif"]); +const ZOOM_MIN = 1; +const ZOOM_MAX = 8; export const Lightbox: Component = () => { const item = createMemo(() => { @@ -34,9 +37,69 @@ export const Lightbox: Component = () => { async (id) => (await fileDetails([id]))[0], ); + // ── 사진 줌/팬 상태 ── + const [scale, setScale] = createSignal(1); + const [panX, setPanX] = createSignal(0); + const [panY, setPanY] = createSignal(0); + let imgWrapRef: HTMLDivElement | undefined; + + const resetZoom = () => { + setScale(1); + setPanX(0); + setPanY(0); + }; + + // 파일이 바뀌면 줌 리셋 + createEffect(() => { + item()?.id; + resetZoom(); + }); + + /** 커서 위치 기준 줌 (center 기준 상대좌표) */ + const zoomAt = (factor: number, cx: number, cy: number) => { + const old = scale(); + const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, old * factor)); + if (next === old) return; + if (next === 1) { + resetZoom(); + return; + } + const ratio = next / old; + setPanX(cx * (1 - ratio) + panX() * ratio); + setPanY(cy * (1 - ratio) + panY() * ratio); + setScale(next); + }; + + const zoomCentered = (factor: number) => zoomAt(factor, 0, 0); + + const onWheel = (e: WheelEvent) => { + if (item()?.kind === 1) return; // 영상은 플레이어가 처리 + e.preventDefault(); + const rect = imgWrapRef?.getBoundingClientRect(); + const cx = rect ? e.clientX - rect.left - rect.width / 2 : 0; + const cy = rect ? e.clientY - rect.top - rect.height / 2 : 0; + zoomAt(e.deltaY < 0 ? 1.15 : 1 / 1.15, cx, cy); + }; + + const onImgPointerDown = (e: PointerEvent) => { + if (scale() <= 1) return; + e.preventDefault(); + const startX = e.clientX - panX(); + const startY = e.clientY - panY(); + const move = (me: PointerEvent) => { + setPanX(me.clientX - startX); + setPanY(me.clientY - startY); + }; + const up = () => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + }; + const onKey = (e: KeyboardEvent) => { if (lightboxIndex() == null) return; - // 영상일 때 ←/→ 는 플레이어(시크)가 처리하므로 여기서 가로채지 않는다. const isVideo = item()?.kind === 1; switch (e.key) { case "Escape": @@ -53,6 +116,23 @@ export const Lightbox: Component = () => { e.preventDefault(); navLightbox(1); break; + case "+": + case "=": + if (isVideo) break; + e.preventDefault(); + zoomCentered(1.25); + break; + case "-": + case "_": + if (isVideo) break; + e.preventDefault(); + zoomCentered(1 / 1.25); + break; + case "0": + if (isVideo) break; + e.preventDefault(); + resetZoom(); + break; } }; @@ -87,6 +167,11 @@ export const Lightbox: Component = () => { {it().index + 1} / {snapshot()?.count.toLocaleString("ko-KR")} + + + {Math.round(scale() * 100)}% + +
setSidebarVersion((v) => v + 1); +// ── 그리드 썸네일 크기(줌) ─────────────────────────────── +export const GRID_ROW_MIN = 110; +export const GRID_ROW_MAX = 420; +const GRID_ROW_KEY = "archive.grid.rowHeight"; +const initialRow = Number(localStorage.getItem(GRID_ROW_KEY) ?? "200"); +const [gridRowHeight, setGridRowHeightRaw] = createSignal( + Number.isFinite(initialRow) ? Math.min(GRID_ROW_MAX, Math.max(GRID_ROW_MIN, initialRow)) : 200, +); +export { gridRowHeight }; + +export function setGridRowHeight(px: number): void { + const clamped = Math.min(GRID_ROW_MAX, Math.max(GRID_ROW_MIN, Math.round(px))); + setGridRowHeightRaw(clamped); + localStorage.setItem(GRID_ROW_KEY, String(clamped)); +} + +/** 상대 배율로 줌 (Ctrl+휠) */ +export function zoomGrid(factor: number): void { + setGridRowHeight(gridRowHeight() * factor); +} + +// ── 영상 재생목록 옵션 ─────────────────────────────────── +const [repeatMode, setRepeatMode] = createSignal(false); +export { repeatMode }; +export const toggleRepeat = () => setRepeatMode((r) => !r); + // ── 중복 검토 패널 ─────────────────────────────────────── const [dedupeOpen, setDedupeOpen] = createSignal(false); export { dedupeOpen };