그리드 줌 · 사진 줌/팬 · 영상 재생목록 추가
- 그리드 썸네일 줌: 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 {
|
import {
|
||||||
anchorIndex,
|
anchorIndex,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
|
gridRowHeight,
|
||||||
info,
|
info,
|
||||||
openLightbox,
|
openLightbox,
|
||||||
selected,
|
selected,
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
selectRange,
|
selectRange,
|
||||||
snapshot,
|
snapshot,
|
||||||
thumbVersions,
|
thumbVersions,
|
||||||
|
zoomGrid,
|
||||||
} from "../../state/store";
|
} from "../../state/store";
|
||||||
import { fileDetails, setThumbPriority, thumbUrl, type Snapshot } from "../../ipc/commands";
|
import { fileDetails, setThumbPriority, thumbUrl, type Snapshot } from "../../ipc/commands";
|
||||||
import { ko } from "../../i18n/ko";
|
import { ko } from "../../i18n/ko";
|
||||||
@@ -33,7 +35,6 @@ import {
|
|||||||
performUndo,
|
performUndo,
|
||||||
} from "../fileops/ContextMenu";
|
} from "../fileops/ContextMenu";
|
||||||
|
|
||||||
const TARGET_ROW_HEIGHT = 200;
|
|
||||||
const GAP = 6;
|
const GAP = 6;
|
||||||
const OVERSCAN_PX = 800;
|
const OVERSCAN_PX = 800;
|
||||||
|
|
||||||
@@ -65,10 +66,11 @@ export const JustifiedGrid: Component = () => {
|
|||||||
};
|
};
|
||||||
onCleanup(() => worker.terminate());
|
onCleanup(() => worker.terminate());
|
||||||
|
|
||||||
// 스냅샷/폭 변경 → 레이아웃 재계산
|
// 스냅샷/폭/줌 변경 → 레이아웃 재계산
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const snap = snapshot();
|
const snap = snapshot();
|
||||||
const w = containerW();
|
const w = containerW();
|
||||||
|
const rowH = gridRowHeight();
|
||||||
if (!snap || w <= 0) {
|
if (!snap || w <= 0) {
|
||||||
setLayout(undefined);
|
setLayout(undefined);
|
||||||
return;
|
return;
|
||||||
@@ -76,7 +78,7 @@ export const JustifiedGrid: Component = () => {
|
|||||||
layoutSeq++;
|
layoutSeq++;
|
||||||
const ratios = snap.aspectRatios();
|
const ratios = snap.aspectRatios();
|
||||||
worker.postMessage(
|
worker.postMessage(
|
||||||
{ seq: layoutSeq, ratios, containerWidth: w, targetRowHeight: TARGET_ROW_HEIGHT, gap: GAP },
|
{ seq: layoutSeq, ratios, containerWidth: w, targetRowHeight: rowH, gap: GAP },
|
||||||
[ratios.buffer],
|
[ratios.buffer],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -91,6 +93,13 @@ export const JustifiedGrid: Component = () => {
|
|||||||
onCleanup(() => ro.disconnect());
|
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 스로틀 스크롤
|
// rAF 스로틀 스크롤
|
||||||
let rafPending = false;
|
let rafPending = false;
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
@@ -315,6 +324,7 @@ export const JustifiedGrid: Component = () => {
|
|||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
onScroll={onScroll}
|
onScroll={onScroll}
|
||||||
|
onWheel={onWheel}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
onMouseDown={onBgMouseDown}
|
onMouseDown={onBgMouseDown}
|
||||||
tabindex={0}
|
tabindex={0}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// 라이트박스 — 사진 원본/영상 재생 오버레이. Esc 닫기, ←/→ 이동.
|
// 라이트박스 — 사진(줌/팬) + 영상 재생 오버레이. Esc 닫기, ←/→ 이동, 휠/+/- 줌.
|
||||||
import {
|
import {
|
||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
createResource,
|
createResource,
|
||||||
|
createSignal,
|
||||||
onCleanup,
|
onCleanup,
|
||||||
onMount,
|
onMount,
|
||||||
Show,
|
Show,
|
||||||
@@ -20,6 +21,8 @@ import { VideoPlayer } from "./VideoPlayer";
|
|||||||
|
|
||||||
/** 웹뷰가 <img>로 직접 그릴 수 있는 확장자 (그 외는 썸네일 폴백 — HEIC/RAW 등) */
|
/** 웹뷰가 <img>로 직접 그릴 수 있는 확장자 (그 외는 썸네일 폴백 — HEIC/RAW 등) */
|
||||||
const IMG_NATIVE = new Set(["jpg", "jpeg", "jfif", "png", "gif", "webp", "bmp", "avif"]);
|
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 = () => {
|
export const Lightbox: Component = () => {
|
||||||
const item = createMemo(() => {
|
const item = createMemo(() => {
|
||||||
@@ -34,9 +37,69 @@ export const Lightbox: Component = () => {
|
|||||||
async (id) => (await fileDetails([id]))[0],
|
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) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (lightboxIndex() == null) return;
|
if (lightboxIndex() == null) return;
|
||||||
// 영상일 때 ←/→ 는 플레이어(시크)가 처리하므로 여기서 가로채지 않는다.
|
|
||||||
const isVideo = item()?.kind === 1;
|
const isVideo = item()?.kind === 1;
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case "Escape":
|
case "Escape":
|
||||||
@@ -53,6 +116,23 @@ export const Lightbox: Component = () => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
navLightbox(1);
|
navLightbox(1);
|
||||||
break;
|
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)]">
|
<span class="text-[11px] text-[var(--text-muted)]">
|
||||||
{it().index + 1} / {snapshot()?.count.toLocaleString("ko-KR")}
|
{it().index + 1} / {snapshot()?.count.toLocaleString("ko-KR")}
|
||||||
</span>
|
</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
|
<button
|
||||||
class="ml-auto rounded px-2 py-0.5 text-sm text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
|
class="ml-auto rounded px-2 py-0.5 text-sm text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
|
||||||
onClick={closeLightbox}
|
onClick={closeLightbox}
|
||||||
@@ -99,11 +184,23 @@ export const Lightbox: Component = () => {
|
|||||||
<Show
|
<Show
|
||||||
when={it().kind === 1}
|
when={it().kind === 1}
|
||||||
fallback={
|
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
|
<img
|
||||||
src={imgSrc()}
|
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"
|
decoding="async"
|
||||||
|
onPointerDown={onImgPointerDown}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
// 원본 디코드 실패 → 그리드 썸네일 폴백
|
// 원본 디코드 실패 → 그리드 썸네일 폴백
|
||||||
const app = info();
|
const app = info();
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
type PlaybackPlan,
|
type PlaybackPlan,
|
||||||
type VideoMode,
|
type VideoMode,
|
||||||
} from "../../ipc/commands";
|
} 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 {
|
function fmtTime(sec: number): string {
|
||||||
const s = Math.max(0, Math.floor(sec));
|
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 skip = (delta: number) => seekTo(current() + delta);
|
||||||
|
|
||||||
|
// 재생목록: 종료 시 반복 또는 다음 항목 자동 재생
|
||||||
|
const onEnded = () => {
|
||||||
|
setControlsShown(true);
|
||||||
|
if (repeatMode()) {
|
||||||
|
seekTo(0);
|
||||||
|
queueMicrotask(() => videoRef?.play().catch(() => {}));
|
||||||
|
} else {
|
||||||
|
navLightbox(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const cycleRate = () => {
|
const cycleRate = () => {
|
||||||
const idx = RATES.indexOf(rate());
|
const idx = RATES.indexOf(rate());
|
||||||
setRate(RATES[(idx + 1) % RATES.length]);
|
setRate(RATES[(idx + 1) % RATES.length]);
|
||||||
@@ -297,7 +308,7 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
|
|||||||
onWaiting={() => setWaiting(true)}
|
onWaiting={() => setWaiting(true)}
|
||||||
onPlaying={() => setWaiting(false)}
|
onPlaying={() => setWaiting(false)}
|
||||||
onCanPlay={() => setWaiting(false)}
|
onCanPlay={() => setWaiting(false)}
|
||||||
onEnded={() => setControlsShown(true)}
|
onEnded={onEnded}
|
||||||
onClick={togglePlay}
|
onClick={togglePlay}
|
||||||
onDblClick={toggleFullscreen}
|
onDblClick={toggleFullscreen}
|
||||||
/>
|
/>
|
||||||
@@ -399,6 +410,17 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="ml-auto flex items-center gap-2">
|
<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
|
<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)]"
|
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}
|
onClick={cycleRate}
|
||||||
|
|||||||
@@ -2,7 +2,17 @@ import { createResource, createSignal, onCleanup, Show, type Component } from "s
|
|||||||
import { open } from "@tauri-apps/plugin-dialog";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import { ko } from "../i18n/ko";
|
import { ko } from "../i18n/ko";
|
||||||
import { JustifiedGrid } from "../features/grid/JustifiedGrid";
|
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 { addLocalSource, listSources, startScan } from "../ipc/commands";
|
||||||
import { openRemoteDialog } from "../features/sources/RemoteDialog";
|
import { openRemoteDialog } from "../features/sources/RemoteDialog";
|
||||||
|
|
||||||
@@ -46,6 +56,19 @@ export const ContentArea: Component = () => {
|
|||||||
<span class="text-[11px] text-[var(--text-muted)]">
|
<span class="text-[11px] text-[var(--text-muted)]">
|
||||||
{snapshot() ? ko.grid.items(snapshot()!.count) : ""}
|
{snapshot() ? ko.grid.items(snapshot()!.count) : ""}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
<Show
|
<Show
|
||||||
when={isEmpty()}
|
when={isEmpty()}
|
||||||
|
|||||||
@@ -139,6 +139,32 @@ const [sidebarVersion, setSidebarVersion] = createSignal(0);
|
|||||||
export { sidebarVersion };
|
export { sidebarVersion };
|
||||||
export const refreshSidebar = () => setSidebarVersion((v) => v + 1);
|
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);
|
const [dedupeOpen, setDedupeOpen] = createSignal(false);
|
||||||
export { dedupeOpen };
|
export { dedupeOpen };
|
||||||
|
|||||||
Reference in New Issue
Block a user