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