From 2879e1ee0a325a12822e2c36abc014f0b56b0adb Mon Sep 17 00:00:00 2001 From: jisangs Date: Thu, 14 May 2026 11:56:31 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20lazy=20loading=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/handlers/hover-preview.js | 13 ++++++- src/renderer/renderer.js | 54 +++++++++++++------------- src/renderer/style.css | 10 +++++ 3 files changed, 49 insertions(+), 28 deletions(-) diff --git a/src/renderer/handlers/hover-preview.js b/src/renderer/handlers/hover-preview.js index 17d130d..5030af2 100644 --- a/src/renderer/handlers/hover-preview.js +++ b/src/renderer/handlers/hover-preview.js @@ -29,11 +29,20 @@ function _onEnter() { clearTimeout(_timer); _showLoader(card); - _timer = setTimeout(() => { + _timer = setTimeout(async () => { _removeLoader(); const thumb = card.querySelector('img.card-thumb'); if (!thumb) return; - _imgEl.src = thumb.src; + + // 아직 lazy-load가 안 된 경우 직접 로딩 + let src = thumb.src; + if (!src) { + src = await window.api.readImage(card.dataset.filepath) || ''; + if (src) thumb.src = src; // 카드에도 캐싱 + } + if (!src) return; + + _imgEl.src = src; _visible = true; _show(card); }, DELAY); diff --git a/src/renderer/renderer.js b/src/renderer/renderer.js index 0b9e0cc..c7e31be 100644 --- a/src/renderer/renderer.js +++ b/src/renderer/renderer.js @@ -4,6 +4,8 @@ import * as selection from './handlers/selection.js'; import * as dragDrop from './handlers/drag-drop.js'; import * as hoverPreview from './handlers/hover-preview.js'; +let _cardObserver = null; + // ── DOM ────────────────────────────────────────────── const grid = document.getElementById('grid'); const emptyState = document.getElementById('empty-state'); @@ -70,40 +72,47 @@ async function openFolder() { state.files.push(...list); state.selectedIdxs.clear(); setStatus(`${state.files.length}개 파일 로드됨`, true); - await renderGrid(); + renderGrid(); updatePreview(); btnRename.disabled = false; btnReveal.disabled = false; } // ── 그리드 렌더링 ──────────────────────────────── -async function renderGrid() { +function renderGrid() { + if (_cardObserver) { _cardObserver.disconnect(); _cardObserver = null; } + grid.innerHTML = ''; emptyState.style.display = 'none'; grid.style.display = 'grid'; fileCount.textContent = `${state.files.length}개 파일`; updateSelCount(); - showLoading(`썸네일 로딩 중... (0/${state.files.length})`); + // 카드 껍데기만 즉시 생성 (이미지 없음) + state.files.forEach((file, idx) => grid.appendChild(createCard(file, idx))); - const BATCH = 10; - for (let i = 0; i < state.files.length; i += BATCH) { - const batch = state.files.slice(i, i + BATCH); - const results = await Promise.all(batch.map(f => window.api.readImage(f.path))); - results.forEach((dataUrl, bi) => { - grid.appendChild(createCard(state.files[i + bi], i + bi, dataUrl)); + // 뷰포트 ±400px 범위에 들어온 카드만 이미지 로딩 + const observer = new IntersectionObserver((entries) => { + entries.forEach(async (entry) => { + if (!entry.isIntersecting) return; + observer.unobserve(entry.target); + const img = entry.target.querySelector('img.card-thumb'); + if (!img || img.src) return; + const dataUrl = await window.api.readImage(entry.target.dataset.filepath); + if (dataUrl) img.src = dataUrl; }); - loadingText.textContent = `썸네일 로딩 중... (${Math.min(i + BATCH, state.files.length)}/${state.files.length})`; - } + }, { root: gridContainer, rootMargin: '400px 0px', threshold: 0 }); + + Array.from(grid.children).forEach(card => observer.observe(card)); + _cardObserver = observer; - hideLoading(); refreshCardSelection(); updateNumberBadges(); updateNewNameLabels(); } // ── 카드 생성 ──────────────────────────────────── -function createCard(file, idx, dataUrl) { +function createCard(file, idx) { const card = document.createElement('div'); card.className = 'card'; card.dataset.idx = idx; @@ -118,18 +127,11 @@ function createCard(file, idx, dataUrl) { checkBadge.className = 'card-check'; checkBadge.textContent = '✓'; - let thumb; - if (dataUrl) { - thumb = document.createElement('img'); - thumb.className = 'card-thumb'; - thumb.src = dataUrl; - thumb.alt = file.name; - thumb.draggable = false; - } else { - thumb = document.createElement('div'); - thumb.className = 'card-thumb-placeholder'; - thumb.textContent = '🖼️'; - } + // src 없이 생성 — IntersectionObserver가 뷰포트 진입 시 로딩 + const thumb = document.createElement('img'); + thumb.className = 'card-thumb'; + thumb.alt = file.name; + thumb.draggable = false; const body = document.createElement('div'); body.className = 'card-body'; @@ -208,7 +210,7 @@ async function doRename() { state.files.length = 0; state.files.push(...newList); state.selectedIdxs.clear(); - await renderGrid(); + renderGrid(); } catch (e) { hideLoading(); alert('오류 발생: ' + e.message); diff --git a/src/renderer/style.css b/src/renderer/style.css index 4c595e0..7efbe32 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -165,6 +165,16 @@ body { background: #08080f; display: block; } +/* 이미지 로딩 전 shimmer */ +.card-thumb:not([src]) { + background: linear-gradient(90deg, #08080f 25%, #14141f 50%, #08080f 75%); + background-size: 200% 100%; + animation: thumb-shimmer 1.4s ease-in-out infinite; +} +@keyframes thumb-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} .card-thumb-placeholder { width: 100%; aspect-ratio: 1 / 1;