그리드 레이아웃 리사이즈/줌 미반영 버그 수정 + 스크롤 성능 개선
증상: 창을 최대화하거나 썸네일 줌을 바꿔도 그리드가 옛 폭에 갇혀 오른쪽이 비고 느리게 느껴짐. 근본 원인: <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:
@@ -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,
|
||||
]);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user