그리드 레이아웃 리사이즈/줌 미반영 버그 수정 + 스크롤 성능 개선
증상: 창을 최대화하거나 썸네일 줌을 바꿔도 그리드가 옛 폭에 갇혀 오른쪽이 비고 느리게 느껴짐. 근본 원인: <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,
|
createMemo,
|
||||||
createSignal,
|
createSignal,
|
||||||
For,
|
For,
|
||||||
|
on,
|
||||||
onCleanup,
|
onCleanup,
|
||||||
onMount,
|
onMount,
|
||||||
Show,
|
Show,
|
||||||
type Component,
|
type Component,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import type { LayoutResult } from "./layout.worker";
|
import { computeLayout, type LayoutResult } from "./layout";
|
||||||
import LayoutWorker from "./layout.worker?worker";
|
|
||||||
import {
|
import {
|
||||||
anchorIndex,
|
anchorIndex,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
@@ -55,49 +55,61 @@ export const JustifiedGrid: Component = () => {
|
|||||||
const [layout, setLayout] = createSignal<LayoutResult | undefined>();
|
const [layout, setLayout] = createSignal<LayoutResult | undefined>();
|
||||||
const [scrollTop, setScrollTop] = createSignal(0);
|
const [scrollTop, setScrollTop] = createSignal(0);
|
||||||
const [viewportH, setViewportH] = createSignal(600);
|
const [viewportH, setViewportH] = createSignal(600);
|
||||||
const [containerW, setContainerW] = createSignal(0);
|
|
||||||
// 마퀴(드래그 사각형) 선택 — 콘텐츠 좌표계 [x1,y1,x2,y2]
|
// 마퀴(드래그 사각형) 선택 — 콘텐츠 좌표계 [x1,y1,x2,y2]
|
||||||
const [marquee, setMarquee] = createSignal<[number, number, number, number] | null>(null);
|
const [marquee, setMarquee] = createSignal<[number, number, number, number] | null>(null);
|
||||||
|
|
||||||
const worker = new LayoutWorker();
|
// 레이아웃 재계산 — 컨테이너 폭을 DOM에서 직접 읽고 메인 스레드에서 동기 계산.
|
||||||
let layoutSeq = 0;
|
const recompute = () => {
|
||||||
worker.onmessage = (e: MessageEvent<LayoutResult>) => {
|
|
||||||
if (e.data.seq === layoutSeq) setLayout(e.data);
|
|
||||||
};
|
|
||||||
onCleanup(() => worker.terminate());
|
|
||||||
|
|
||||||
// 스냅샷/폭/줌 변경 → 레이아웃 재계산
|
|
||||||
createEffect(() => {
|
|
||||||
const snap = snapshot();
|
const snap = snapshot();
|
||||||
const w = containerW();
|
if (!snap || !containerRef) {
|
||||||
const rowH = gridRowHeight();
|
|
||||||
if (!snap || w <= 0) {
|
|
||||||
setLayout(undefined);
|
setLayout(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
layoutSeq++;
|
const w = containerRef.clientWidth - 16; // 패딩 보정
|
||||||
|
if (w <= 0) return;
|
||||||
const ratios = snap.aspectRatios();
|
const ratios = snap.aspectRatios();
|
||||||
worker.postMessage(
|
setLayout(computeLayout(ratios, w, gridRowHeight(), GAP));
|
||||||
{ seq: layoutSeq, ratios, containerWidth: w, targetRowHeight: rowH, gap: GAP },
|
};
|
||||||
[ratios.buffer],
|
|
||||||
);
|
// 데이터(스냅샷) 변경 시 즉시 재계산.
|
||||||
});
|
createEffect(on(snapshot, recompute));
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const el = containerRef!;
|
const el = containerRef!;
|
||||||
const ro = new ResizeObserver(() => {
|
let lastW = -1;
|
||||||
setContainerW(el.clientWidth - 16); // 패딩 보정
|
let lastRh = -1;
|
||||||
|
// 폭과 줌(gridRowHeight)을 함께 감시 — 어느 쪽이 바뀌든 재배치.
|
||||||
|
// (이벤트/이펙트 누락 환경에 견고하도록 폴링을 단일 진실원으로 사용)
|
||||||
|
const tick = () => {
|
||||||
|
const w = el.clientWidth;
|
||||||
|
const rh = gridRowHeight();
|
||||||
setViewportH(el.clientHeight);
|
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);
|
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) => {
|
const onWheel = (e: WheelEvent) => {
|
||||||
if (!e.ctrlKey && !e.metaKey) return;
|
if (!e.ctrlKey && !e.metaKey) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
zoomGrid(e.deltaY < 0 ? 1.12 : 1 / 1.12);
|
zoomGrid(e.deltaY < 0 ? 1.12 : 1 / 1.12);
|
||||||
|
recompute();
|
||||||
};
|
};
|
||||||
|
|
||||||
// rAF 스로틀 스크롤
|
// rAF 스로틀 스크롤
|
||||||
@@ -359,12 +371,15 @@ export const JustifiedGrid: Component = () => {
|
|||||||
<For each={indices()}>
|
<For each={indices()}>
|
||||||
{(i) => {
|
{(i) => {
|
||||||
const snap = snapshot()!;
|
const snap = snapshot()!;
|
||||||
const l = layout()!;
|
|
||||||
const id = snap.id(i);
|
const id = snap.id(i);
|
||||||
const x = l.boxes[i * 4];
|
// 위치/크기는 반응형으로 — layout() 변경(리사이즈/줌) 시 For가 DOM을
|
||||||
const y = l.boxes[i * 4 + 1];
|
// 재사용해도 style이 갱신되도록 한다.
|
||||||
const w = l.boxes[i * 4 + 2];
|
const box = () => {
|
||||||
const h = l.boxes[i * 4 + 3];
|
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 version = () => thumbVersions[id] ?? 0;
|
||||||
const showImg = () => snap.hasThumb(i) || version() > 0;
|
const showImg = () => snap.hasThumb(i) || version() > 0;
|
||||||
return (
|
return (
|
||||||
@@ -374,9 +389,9 @@ export const JustifiedGrid: Component = () => {
|
|||||||
"outline outline-2 outline-[var(--accent)]": selected().has(id),
|
"outline outline-2 outline-[var(--accent)]": selected().has(id),
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(${x}px, ${y}px)`,
|
transform: `translate(${box()[0]}px, ${box()[1]}px)`,
|
||||||
width: `${w}px`,
|
width: `${box()[2]}px`,
|
||||||
height: `${h}px`,
|
height: `${box()[3]}px`,
|
||||||
}}
|
}}
|
||||||
onClick={(e) => onCellClick(e, snap, i)}
|
onClick={(e) => onCellClick(e, snap, i)}
|
||||||
onDblClick={() => openLightbox(i)}
|
onDblClick={() => openLightbox(i)}
|
||||||
@@ -388,7 +403,6 @@ export const JustifiedGrid: Component = () => {
|
|||||||
<img
|
<img
|
||||||
src={`${thumbUrl(info()!, id)}&v=${version()}`}
|
src={`${thumbUrl(info()!, id)}&v=${version()}`}
|
||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
decoding="async"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
// Justified 레이아웃 계산 워커 — 그리디 행 채우기.
|
// Justified 레이아웃 — 그리디 행 채우기. 메인 스레드 동기 계산.
|
||||||
// 입력: 종횡비 배열 → 출력: 항목별 [x,y,w,h] + 행 오프셋 인덱스.
|
// (프로덕션 Tauri 빌드에서 module worker가 첫 메시지 후 응답하지 않는 이슈를 피하기 위해
|
||||||
|
// 워커 대신 동기 계산을 쓴다. 재계산은 리사이즈/줌/데이터 변경 시에만 발생하며
|
||||||
export interface LayoutRequest {
|
// 5만 항목도 수십 ms 내에 끝나 스크롤 성능에 영향 없다.)
|
||||||
seq: number;
|
|
||||||
ratios: Float32Array;
|
|
||||||
containerWidth: number;
|
|
||||||
targetRowHeight: number;
|
|
||||||
gap: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LayoutResult {
|
export interface LayoutResult {
|
||||||
seq: number;
|
|
||||||
/** n*4: [x, y, w, h] */
|
/** n*4: [x, y, w, h] */
|
||||||
boxes: Float32Array;
|
boxes: Float32Array;
|
||||||
/** 행별 상단 y (마지막 요소 = 전체 높이) */
|
/** 행별 상단 y (마지막 요소 = 전체 높이) */
|
||||||
@@ -20,14 +13,17 @@ export interface LayoutResult {
|
|||||||
totalHeight: number;
|
totalHeight: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.onmessage = (e: MessageEvent<LayoutRequest>) => {
|
export function computeLayout(
|
||||||
const { seq, ratios, containerWidth, targetRowHeight, gap } = e.data;
|
ratios: Float32Array,
|
||||||
|
containerWidth: number,
|
||||||
|
targetRowHeight: number,
|
||||||
|
gap: number,
|
||||||
|
): LayoutResult {
|
||||||
const n = ratios.length;
|
const n = ratios.length;
|
||||||
const boxes = new Float32Array(n * 4);
|
const boxes = new Float32Array(n * 4);
|
||||||
const rowTopsArr: number[] = [];
|
const rowTopsArr: number[] = [];
|
||||||
const rowFirstArr: number[] = [];
|
const rowFirstArr: number[] = [];
|
||||||
|
|
||||||
// 극단적 종횡비는 레이아웃 파손 방지를 위해 클램프
|
|
||||||
const clamp = (r: number) => Math.min(4, Math.max(0.25, r || 1));
|
const clamp = (r: number) => Math.min(4, Math.max(0.25, r || 1));
|
||||||
|
|
||||||
let y = 0;
|
let y = 0;
|
||||||
@@ -42,7 +38,6 @@ self.onmessage = (e: MessageEvent<LayoutRequest>) => {
|
|||||||
if (justify) {
|
if (justify) {
|
||||||
h = (containerWidth - gaps) / ratioSum;
|
h = (containerWidth - gaps) / ratioSum;
|
||||||
} else {
|
} else {
|
||||||
// 마지막 행: 목표 높이 유지하되 넘치면 축소
|
|
||||||
const natural = ratioSum * targetRowHeight + gaps;
|
const natural = ratioSum * targetRowHeight + gaps;
|
||||||
if (natural > containerWidth) h = (containerWidth - gaps) / ratioSum;
|
if (natural > containerWidth) h = (containerWidth - gaps) / ratioSum;
|
||||||
}
|
}
|
||||||
@@ -74,16 +69,10 @@ self.onmessage = (e: MessageEvent<LayoutRequest>) => {
|
|||||||
const totalHeight = y > 0 ? y - gap : 0;
|
const totalHeight = y > 0 ? y - gap : 0;
|
||||||
rowTopsArr.push(totalHeight); // 마지막 = 전체 높이 (이진탐색 경계)
|
rowTopsArr.push(totalHeight); // 마지막 = 전체 높이 (이진탐색 경계)
|
||||||
|
|
||||||
const result: LayoutResult = {
|
return {
|
||||||
seq,
|
|
||||||
boxes,
|
boxes,
|
||||||
rowTops: new Float32Array(rowTopsArr),
|
rowTops: new Float32Array(rowTopsArr),
|
||||||
rowFirst: new Uint32Array(rowFirstArr),
|
rowFirst: new Uint32Array(rowFirstArr),
|
||||||
totalHeight,
|
totalHeight,
|
||||||
};
|
};
|
||||||
(self as unknown as Worker).postMessage(result, [
|
}
|
||||||
result.boxes.buffer,
|
|
||||||
result.rowTops.buffer,
|
|
||||||
result.rowFirst.buffer,
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user