그리드 레이아웃 리사이즈/줌 미반영 버그 수정 + 스크롤 성능 개선

증상: 창을 최대화하거나 썸네일 줌을 바꿔도 그리드가 옛 폭에 갇혀
오른쪽이 비고 느리게 느껴짐.

근본 원인: <For>가 인덱스로 셀 DOM을 재사용하는데 셀의 x/y/w/h를
item 함수 본문에서 1회만 계산해, layout 변경(리사이즈/줌) 시 재사용된
셀 위치가 갱신되지 않았음(뷰 변경=인덱스 전면 교체 때만 반영됐음).

수정:
- 셀 위치/크기를 반응형(box())으로 읽어 layout 변경 시 재배치
- 레이아웃 계산을 Web Worker→메인 스레드 동기 계산으로 전환
  (프로덕션 Tauri 빌드에서 module worker가 첫 메시지 후 무응답)
- 리사이즈/줌을 폴링(clientWidth+gridRowHeight 직접 읽기)+Ctrl휠 직접
  recompute로 구동, 스냅샷 변경은 effect로. 이벤트 누락에 견고.
- 가상 그리드 썸네일 lazy→eager 로딩으로 스크롤 중 즉시 표시

결과: 최대화 시 폭 99% 채움, 줌 재배치 정상, 스크롤 ~34→~57fps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
강 한
2026-07-17 08:30:19 +09:00
co-authored by Claude Opus 4.8
parent dd9d6bfa0c
commit 6bff43c4e9
2 changed files with 61 additions and 58 deletions
+49 -35
View File
@@ -4,13 +4,13 @@ import {
createMemo,
createSignal,
For,
on,
onCleanup,
onMount,
Show,
type Component,
} from "solid-js";
import type { LayoutResult } from "./layout.worker";
import LayoutWorker from "./layout.worker?worker";
import { computeLayout, type LayoutResult } from "./layout";
import {
anchorIndex,
clearSelection,
@@ -55,49 +55,61 @@ export const JustifiedGrid: Component = () => {
const [layout, setLayout] = createSignal<LayoutResult | undefined>();
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;
worker.onmessage = (e: MessageEvent<LayoutResult>) => {
if (e.data.seq === layoutSeq) setLayout(e.data);
};
onCleanup(() => worker.terminate());
// 스냅샷/폭/줌 변경 → 레이아웃 재계산
createEffect(() => {
// 레이아웃 재계산 — 컨테이너 폭을 DOM에서 직접 읽고 메인 스레드에서 동기 계산.
const recompute = () => {
const snap = snapshot();
const w = containerW();
const rowH = gridRowHeight();
if (!snap || w <= 0) {
if (!snap || !containerRef) {
setLayout(undefined);
return;
}
layoutSeq++;
const w = containerRef.clientWidth - 16; // 패딩 보정
if (w <= 0) return;
const ratios = snap.aspectRatios();
worker.postMessage(
{ seq: layoutSeq, ratios, containerWidth: w, targetRowHeight: rowH, gap: GAP },
[ratios.buffer],
);
});
setLayout(computeLayout(ratios, w, gridRowHeight(), GAP));
};
// 데이터(스냅샷) 변경 시 즉시 재계산.
createEffect(on(snapshot, recompute));
onMount(() => {
const el = containerRef!;
const ro = new ResizeObserver(() => {
setContainerW(el.clientWidth - 16); // 패딩 보정
let lastW = -1;
let lastRh = -1;
// 폭과 줌(gridRowHeight)을 함께 감시 — 어느 쪽이 바뀌든 재배치.
// (이벤트/이펙트 누락 환경에 견고하도록 폴링을 단일 진실원으로 사용)
const tick = () => {
const w = el.clientWidth;
const rh = gridRowHeight();
setViewportH(el.clientHeight);
});
if (Math.abs(w - lastW) > 0.5 || rh !== lastRh) {
lastW = w;
lastRh = rh;
recompute();
}
};
tick();
[50, 200, 500].forEach((ms) => setTimeout(tick, ms));
const ro = new ResizeObserver(tick);
ro.observe(el);
onCleanup(() => ro.disconnect());
const onWinResize = () => requestAnimationFrame(tick);
window.addEventListener("resize", onWinResize);
const poll = setInterval(tick, 150);
onCleanup(() => {
ro.disconnect();
window.removeEventListener("resize", onWinResize);
clearInterval(poll);
});
});
// Ctrl+휠 → 그리드 줌 (썸네일 크기)
// Ctrl+휠 → 그리드 줌 (썸네일 크기). 즉시 재계산.
const onWheel = (e: WheelEvent) => {
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
zoomGrid(e.deltaY < 0 ? 1.12 : 1 / 1.12);
recompute();
};
// rAF 스로틀 스크롤
@@ -359,12 +371,15 @@ export const JustifiedGrid: Component = () => {
<For each={indices()}>
{(i) => {
const snap = snapshot()!;
const l = layout()!;
const id = snap.id(i);
const x = l.boxes[i * 4];
const y = l.boxes[i * 4 + 1];
const w = l.boxes[i * 4 + 2];
const h = l.boxes[i * 4 + 3];
// 위치/크기는 반응형으로 — layout() 변경(리사이즈/줌) 시 For가 DOM을
// 재사용해도 style이 갱신되도록 한다.
const box = () => {
const l = layout();
return l
? [l.boxes[i * 4], l.boxes[i * 4 + 1], l.boxes[i * 4 + 2], l.boxes[i * 4 + 3]]
: [0, 0, 0, 0];
};
const version = () => thumbVersions[id] ?? 0;
const showImg = () => snap.hasThumb(i) || version() > 0;
return (
@@ -374,9 +389,9 @@ export const JustifiedGrid: Component = () => {
"outline outline-2 outline-[var(--accent)]": selected().has(id),
}}
style={{
transform: `translate(${x}px, ${y}px)`,
width: `${w}px`,
height: `${h}px`,
transform: `translate(${box()[0]}px, ${box()[1]}px)`,
width: `${box()[2]}px`,
height: `${box()[3]}px`,
}}
onClick={(e) => onCellClick(e, snap, i)}
onDblClick={() => openLightbox(i)}
@@ -388,7 +403,6 @@ export const JustifiedGrid: Component = () => {
<img
src={`${thumbUrl(info()!, id)}&v=${version()}`}
class="h-full w-full object-cover"
loading="lazy"
decoding="async"
draggable={false}
onError={(e) => {