후속 개선: 원격 썸네일·자동재연결, RAW 썸네일, 마퀴 선택, 이동 강건성

P1 원격 소스 UX:
- remote_thumbs: VFS 다운로드로 원격 썸네일 생성(이미지 EXIF 내장 우선,
  영상은 미디어서버 bridge URL로 ffmpeg Range 추출)
- 앱 시작 시 키체인 자격증명으로 원격 소스 자동 재연결
- fileops: physical_move에 Windows 공유위반 재시도

P3 그리드/포맷:
- RAW(cr2/nef/arw/dng 등) 내장 JPEG 프리뷰 추출 썸네일(scan_largest_jpeg)
- 그리드 마퀴(드래그 사각형) 다중선택
- .gitattributes 줄바꿈 정규화

Rust 테스트 51개 통과.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
강 한
2026-07-16 22:10:53 +09:00
co-authored by Claude Opus 4.8
parent 394d1b9805
commit 5a7952bd2d
5 changed files with 189 additions and 1 deletions
+86 -1
View File
@@ -13,10 +13,12 @@ import type { LayoutResult } from "./layout.worker";
import LayoutWorker from "./layout.worker?worker";
import {
anchorIndex,
clearSelection,
info,
openLightbox,
selected,
selectSingle,
setSelectionFromIds,
toggleSelect,
selectRange,
snapshot,
@@ -53,6 +55,8 @@ export const JustifiedGrid: Component = () => {
const [scrollTop, setScrollTop] = createSignal(0);
const [viewportH, setViewportH] = createSignal(600);
const [containerW, setContainerW] = createSignal(0);
// 마퀴(드래그 사각형) 선택 — 콘텐츠 좌표계 [x1,y1,x2,y2]
const [marquee, setMarquee] = createSignal<[number, number, number, number] | null>(null);
const worker = new LayoutWorker();
let layoutSeq = 0;
@@ -244,11 +248,75 @@ export const JustifiedGrid: Component = () => {
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
};
// ── 마퀴(드래그 사각형) 선택 ──
// 콘텐츠 좌표 = clientX - 컨테이너rect - 패딩(8) (+ y는 scrollTop)
const toContent = (clientX: number, clientY: number): [number, number] => {
const rect = containerRef!.getBoundingClientRect();
return [
clientX - rect.left - 8,
clientY - rect.top - 8 + containerRef!.scrollTop,
];
};
const onBgMouseDown = (e: MouseEvent) => {
// 배경(빈 공간) 좌클릭에서만 시작 — 셀 클릭은 제외
if (e.button !== 0 || !(e.target as HTMLElement).dataset.gridbg) return;
const [sx, sy] = toContent(e.clientX, e.clientY);
setMarquee([sx, sy, sx, sy]);
if (!e.ctrlKey && !e.metaKey) clearSelection();
const onMove = (me: MouseEvent) => {
const [cx, cy] = toContent(me.clientX, me.clientY);
const m = marquee();
if (m) setMarquee([m[0], m[1], cx, cy]);
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
commitMarquee();
setMarquee(null);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
};
const commitMarquee = () => {
const m = marquee();
const l = layout();
const snap = snapshot();
if (!m || !l || !snap) return;
const [x1, y1, x2, y2] = [Math.min(m[0], m[2]), Math.min(m[1], m[3]), Math.max(m[0], m[2]), Math.max(m[1], m[3])];
// 너무 작은 드래그는 무시(클릭으로 간주)
if (x2 - x1 < 4 && y2 - y1 < 4) return;
const ids: number[] = [];
for (let i = 0; i < snap.count; i++) {
const bx = l.boxes[i * 4];
const by = l.boxes[i * 4 + 1];
const bw = l.boxes[i * 4 + 2];
const bh = l.boxes[i * 4 + 3];
// AABB 교차
if (bx < x2 && bx + bw > x1 && by < y2 && by + bh > y1) ids.push(snap.id(i));
}
setSelectionFromIds(ids);
};
const marqueeRect = createMemo(() => {
const m = marquee();
if (!m) return null;
return {
left: Math.min(m[0], m[2]),
top: Math.min(m[1], m[3]),
width: Math.abs(m[2] - m[0]),
height: Math.abs(m[3] - m[1]),
};
});
return (
<div
ref={containerRef}
onScroll={onScroll}
onKeyDown={onKeyDown}
onMouseDown={onBgMouseDown}
tabindex={0}
class="relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-2 py-2 outline-none"
>
@@ -260,7 +328,24 @@ export const JustifiedGrid: Component = () => {
</div>
}
>
<div class="relative" style={{ height: `${layout()?.totalHeight ?? 0}px` }}>
<div
class="relative"
data-gridbg="1"
style={{ height: `${layout()?.totalHeight ?? 0}px` }}
>
<Show when={marqueeRect()}>
{(r) => (
<div
class="pointer-events-none absolute z-10 border border-[var(--accent)] bg-[var(--accent-soft)]"
style={{
left: `${r().left}px`,
top: `${r().top}px`,
width: `${r().width}px`,
height: `${r().height}px`,
}}
/>
)}
</Show>
<For each={indices()}>
{(i) => {
const snap = snapshot()!;
+5
View File
@@ -109,6 +109,11 @@ export function clearSelection(): void {
setAnchorIndex(-1);
}
/** 마퀴(드래그 사각형) 선택 — id 집합으로 직접 설정 */
export function setSelectionFromIds(ids: number[]): void {
setSelected(new Set<number>(ids));
}
// ── 썸네일 갱신 버전 (thumbs-ready 이벤트로 증가) ────────
const [thumbVersions, setThumbVersions] = createStore<Record<number, number>>({});
export { thumbVersions };