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)}%
+
+