그리드 줌 · 사진 줌/팬 · 영상 재생목록 추가
- 그리드 썸네일 줌: Ctrl+휠 + 툴바 슬라이더(110~420px), localStorage 유지 - 라이트박스 사진 줌/팬: 휠(커서 기준)·드래그·더블클릭·+/-/0 키, 파일 이동 시 리셋, 확대율 배지 - 영상 재생목록: 종료 시 다음 항목 자동재생, 🔁 반복 토글 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user