From 6bff43c4e9ad63b035f23c1b2ed6edf83607dd78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=20=ED=95=9C?= Date: Fri, 17 Jul 2026 08:30:19 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EB=93=9C=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=95=84=EC=9B=83=20=EB=A6=AC=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EC=A6=88/=EC=A4=8C=20=EB=AF=B8=EB=B0=98=EC=98=81=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=EC=88=98=EC=A0=95=20+=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4=20=EC=84=B1=EB=8A=A5=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 증상: 창을 최대화하거나 썸네일 줌을 바꿔도 그리드가 옛 폭에 갇혀 오른쪽이 비고 느리게 느껴짐. 근본 원인: 가 인덱스로 셀 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 --- src/features/grid/JustifiedGrid.tsx | 84 +++++++++++-------- .../grid/{layout.worker.ts => layout.ts} | 35 +++----- 2 files changed, 61 insertions(+), 58 deletions(-) rename src/features/grid/{layout.worker.ts => layout.ts} (69%) diff --git a/src/features/grid/JustifiedGrid.tsx b/src/features/grid/JustifiedGrid.tsx index 83a421e..c58e566 100644 --- a/src/features/grid/JustifiedGrid.tsx +++ b/src/features/grid/JustifiedGrid.tsx @@ -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(); 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) => { - 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 = () => { {(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 = () => { { diff --git a/src/features/grid/layout.worker.ts b/src/features/grid/layout.ts similarity index 69% rename from src/features/grid/layout.worker.ts rename to src/features/grid/layout.ts index db37f69..fbe42c8 100644 --- a/src/features/grid/layout.worker.ts +++ b/src/features/grid/layout.ts @@ -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) => { - 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) => { 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) => { 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, - ]); -}; +}