그리드 줌 · 사진 줌/팬 · 영상 재생목록 추가

- 그리드 썸네일 줌: Ctrl+휠 + 툴바 슬라이더(110~420px), localStorage 유지
- 라이트박스 사진 줌/팬: 휠(커서 기준)·드래그·더블클릭·+/-/0 키,
  파일 이동 시 리셋, 확대율 배지
- 영상 재생목록: 종료 시 다음 항목 자동재생, 🔁 반복 토글

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
강 한
2026-07-17 05:46:48 +09:00
co-authored by Claude Opus 4.8
parent cb0207154d
commit dd9d6bfa0c
5 changed files with 188 additions and 10 deletions
+13 -3
View File
@@ -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 = () => {
<div
ref={containerRef}
onScroll={onScroll}
onWheel={onWheel}
onKeyDown={onKeyDown}
onMouseDown={onBgMouseDown}
tabindex={0}
+101 -4
View File
@@ -1,8 +1,9 @@
// 라이트박스 — 사진 원본/영상 재생 오버레이. Esc 닫기, ←/→ 이동.
// 라이트박스 — 사진(줌/팬) + 영상 재생 오버레이. Esc 닫기, ←/→ 이동, 휠/+/- 줌.
import {
createEffect,
createMemo,
createResource,
createSignal,
onCleanup,
onMount,
Show,
@@ -20,6 +21,8 @@ import { VideoPlayer } from "./VideoPlayer";
/** 웹뷰가 <img>로 직접 그릴 수 있는 확장자 (그 외는 썸네일 폴백 — 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 = () => {
<span class="text-[11px] text-[var(--text-muted)]">
{it().index + 1} / {snapshot()?.count.toLocaleString("ko-KR")}
</span>
<Show when={it().kind !== 1 && scale() !== 1}>
<span class="rounded bg-[var(--bg-panel-raised)] px-1.5 py-0.5 text-[10px] tabular-nums text-[var(--text-secondary)]">
{Math.round(scale() * 100)}%
</span>
</Show>
<button
class="ml-auto rounded px-2 py-0.5 text-sm text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
onClick={closeLightbox}
@@ -99,11 +184,23 @@ export const Lightbox: Component = () => {
<Show
when={it().kind === 1}
fallback={
<div class="grid h-full w-full place-items-center">
<div
ref={imgWrapRef}
class="grid h-full w-full place-items-center overflow-hidden"
classList={{ "cursor-grab": scale() > 1 }}
onWheel={onWheel}
onDblClick={() => (scale() > 1 ? resetZoom() : zoomCentered(2.5))}
>
<img
src={imgSrc()}
class="max-h-full max-w-full object-contain"
class="max-h-full max-w-full touch-none select-none object-contain"
style={{
transform: `translate(${panX()}px, ${panY()}px) scale(${scale()})`,
transition: scale() === 1 ? "transform 0.15s ease-out" : "none",
}}
draggable={false}
decoding="async"
onPointerDown={onImgPointerDown}
onError={(e) => {
// 원본 디코드 실패 → 그리드 썸네일 폴백
const app = info();
+24 -2
View File
@@ -19,7 +19,7 @@ import {
type PlaybackPlan,
type VideoMode,
} from "../../ipc/commands";
import { info as appInfoSig, navLightbox } from "../../state/store";
import { info as appInfoSig, navLightbox, repeatMode, toggleRepeat } from "../../state/store";
function fmtTime(sec: number): string {
const s = Math.max(0, Math.floor(sec));
@@ -130,6 +130,17 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
const skip = (delta: number) => seekTo(current() + delta);
// 재생목록: 종료 시 반복 또는 다음 항목 자동 재생
const onEnded = () => {
setControlsShown(true);
if (repeatMode()) {
seekTo(0);
queueMicrotask(() => videoRef?.play().catch(() => {}));
} else {
navLightbox(1);
}
};
const cycleRate = () => {
const idx = RATES.indexOf(rate());
setRate(RATES[(idx + 1) % RATES.length]);
@@ -297,7 +308,7 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
onWaiting={() => setWaiting(true)}
onPlaying={() => setWaiting(false)}
onCanPlay={() => setWaiting(false)}
onEnded={() => setControlsShown(true)}
onEnded={onEnded}
onClick={togglePlay}
onDblClick={toggleFullscreen}
/>
@@ -399,6 +410,17 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
</span>
<div class="ml-auto flex items-center gap-2">
<button
class="rounded px-1 text-sm leading-none"
classList={{
"text-[var(--accent)]": repeatMode(),
"text-white hover:text-[var(--accent)]": !repeatMode(),
}}
onClick={toggleRepeat}
title={repeatMode() ? "반복 켜짐" : "반복 꺼짐 (끝나면 다음 항목)"}
>
🔁
</button>
<button
class="rounded border border-white/30 px-1.5 py-0.5 text-[10px] leading-none hover:border-[var(--accent)] hover:text-[var(--accent)]"
onClick={cycleRate}
+24 -1
View File
@@ -2,7 +2,17 @@ import { createResource, createSignal, onCleanup, Show, type Component } from "s
import { open } from "@tauri-apps/plugin-dialog";
import { ko } from "../i18n/ko";
import { JustifiedGrid } from "../features/grid/JustifiedGrid";
import { refreshSidebar, setView, snapshot, updateScanProgress, view } from "../state/store";
import {
GRID_ROW_MAX,
GRID_ROW_MIN,
gridRowHeight,
refreshSidebar,
setGridRowHeight,
setView,
snapshot,
updateScanProgress,
view,
} from "../state/store";
import { addLocalSource, listSources, startScan } from "../ipc/commands";
import { openRemoteDialog } from "../features/sources/RemoteDialog";
@@ -46,6 +56,19 @@ export const ContentArea: Component = () => {
<span class="text-[11px] text-[var(--text-muted)]">
{snapshot() ? ko.grid.items(snapshot()!.count) : ""}
</span>
{/* 그리드 줌 슬라이더 */}
<div class="ml-auto flex items-center gap-1.5 text-[var(--text-muted)]" title="썸네일 크기 (Ctrl+휠)">
<span class="text-xs">🔍</span>
<input
type="range"
class="h-1 w-28 accent-[var(--accent)]"
min={GRID_ROW_MIN}
max={GRID_ROW_MAX}
step={5}
value={gridRowHeight()}
onInput={(e) => setGridRowHeight(Number(e.currentTarget.value))}
/>
</div>
</div>
<Show
when={isEmpty()}
+26
View File
@@ -139,6 +139,32 @@ const [sidebarVersion, setSidebarVersion] = createSignal(0);
export { sidebarVersion };
export const refreshSidebar = () => 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 };