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

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

근본 원인: <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) => {
@@ -1,16 +1,9 @@
// Justified 레이아웃 계산 워커 — 그리디 행 채우기.
// 입력: 종횡비 배열 → 출력: 항목별 [x,y,w,h] + 행 오프셋 인덱스.
export interface LayoutRequest {
seq: number;
ratios: Float32Array;
containerWidth: number;
targetRowHeight: number;
gap: number;
}
// Justified 레이아웃 — 그리디 행 채우기. 메인 스레드 동기 계산.
// (프로덕션 Tauri 빌드에서 module worker가 첫 메시지 후 응답하지 않는 이슈를 피하기 위해
// 워커 대신 동기 계산을 쓴다. 재계산은 리사이즈/줌/데이터 변경 시에만 발생하며
// 5만 항목도 수십 ms 내에 끝나 스크롤 성능에 영향 없다.)
export interface LayoutResult {
seq: number;
/** n*4: [x, y, w, h] */
boxes: Float32Array;
/** 행별 상단 y (마지막 요소 = 전체 높이) */
@@ -20,14 +13,17 @@ export interface LayoutResult {
totalHeight: number;
}
self.onmessage = (e: MessageEvent<LayoutRequest>) => {
const { seq, ratios, containerWidth, targetRowHeight, gap } = e.data;
export function computeLayout(
ratios: Float32Array,
containerWidth: number,
targetRowHeight: number,
gap: number,
): LayoutResult {
const n = ratios.length;
const boxes = new Float32Array(n * 4);
const rowTopsArr: number[] = [];
const rowFirstArr: number[] = [];
// 극단적 종횡비는 레이아웃 파손 방지를 위해 클램프
const clamp = (r: number) => Math.min(4, Math.max(0.25, r || 1));
let y = 0;
@@ -42,7 +38,6 @@ self.onmessage = (e: MessageEvent<LayoutRequest>) => {
if (justify) {
h = (containerWidth - gaps) / ratioSum;
} else {
// 마지막 행: 목표 높이 유지하되 넘치면 축소
const natural = ratioSum * targetRowHeight + gaps;
if (natural > containerWidth) h = (containerWidth - gaps) / ratioSum;
}
@@ -74,16 +69,10 @@ self.onmessage = (e: MessageEvent<LayoutRequest>) => {
const totalHeight = y > 0 ? y - gap : 0;
rowTopsArr.push(totalHeight); // 마지막 = 전체 높이 (이진탐색 경계)
const result: LayoutResult = {
seq,
return {
boxes,
rowTops: new Float32Array(rowTopsArr),
rowFirst: new Uint32Array(rowFirstArr),
totalHeight,
};
(self as unknown as Worker).postMessage(result, [
result.boxes.buffer,
result.rowTops.buffer,
result.rowFirst.buffer,
]);
};
}