import { state } from './state.js'; import * as dragDrop from './handlers/drag-drop.js'; import * as hoverPreview from './handlers/image-preview.js'; import * as selection from './handlers/selection.js'; import { buildSequentialName, getNameFormat } from './utils/file-names.js'; const grid = document.getElementById('grid'); const emptyState = document.getElementById('empty-state'); const gridContainer = document.getElementById('grid-container'); const gridScrollbar = document.getElementById('grid-scrollbar'); const gridScrollbarThumb = document.getElementById('grid-scrollbar-thumb'); const fileCount = document.getElementById('file-count'); const selCount = document.getElementById('sel-count'); const inputPrefix = document.getElementById('input-prefix'); const inputDigits = document.getElementById('input-digits'); const inputPostfix = document.getElementById('input-postfix'); let _cardObserver = null; let _scrollStateRaf = null; let _scrollbarDrag = null; function updateGridScrollState() { if (_scrollStateRaf) cancelAnimationFrame(_scrollStateRaf); _scrollStateRaf = requestAnimationFrame(() => { _scrollStateRaf = null; const isScrollable = gridContainer.scrollHeight > gridContainer.clientHeight; gridContainer.classList.toggle('is-scrollable', isScrollable); updateGridScrollbarFrame(); updateGridScrollbar(); }); } function updateGridScrollbarFrame() { if (!gridScrollbar) return; const rect = gridContainer.getBoundingClientRect(); const top = rect.top + 20; const height = Math.max(0, rect.height - 40); gridScrollbar.style.setProperty('--grid-scrollbar-top', `${top}px`); gridScrollbar.style.setProperty('--grid-scrollbar-height', `${height}px`); } function updateGridScrollbar() { if (!gridScrollbar || !gridScrollbarThumb) return; const { clientHeight, scrollHeight, scrollTop } = gridContainer; const maxScrollTop = scrollHeight - clientHeight; if (maxScrollTop <= 0) { gridScrollbarThumb.style.height = ''; gridScrollbarThumb.style.transform = ''; return; } const trackHeight = gridScrollbar.clientHeight; const thumbHeight = Math.max(36, Math.round((clientHeight / scrollHeight) * trackHeight)); const maxThumbTop = trackHeight - thumbHeight; const thumbTop = Math.round((scrollTop / maxScrollTop) * maxThumbTop); gridScrollbarThumb.style.height = `${thumbHeight}px`; gridScrollbarThumb.style.transform = `translateY(${thumbTop}px)`; } function scrollGridFromPointer(clientY) { const trackRect = gridScrollbar.getBoundingClientRect(); const thumbHeight = gridScrollbarThumb.offsetHeight; const maxThumbTop = gridScrollbar.clientHeight - thumbHeight; const maxScrollTop = gridContainer.scrollHeight - gridContainer.clientHeight; if (maxThumbTop <= 0 || maxScrollTop <= 0) return; const targetThumbTop = Math.min( maxThumbTop, Math.max(0, clientY - trackRect.top - _scrollbarDrag.thumbOffset), ); gridContainer.scrollTop = (targetThumbTop / maxThumbTop) * maxScrollTop; } const _gridResizeObserver = new ResizeObserver(updateGridScrollState); _gridResizeObserver.observe(gridContainer); _gridResizeObserver.observe(grid); gridContainer.addEventListener('scroll', updateGridScrollbar); window.addEventListener('resize', updateGridScrollState); gridScrollbar.addEventListener('pointerdown', (e) => { if (!gridContainer.classList.contains('is-scrollable')) return; const thumbRect = gridScrollbarThumb.getBoundingClientRect(); const isThumbPointer = e.target === gridScrollbarThumb; _scrollbarDrag = { thumbOffset: isThumbPointer ? e.clientY - thumbRect.top : thumbRect.height / 2, }; gridScrollbar.classList.add('dragging'); gridScrollbar.setPointerCapture(e.pointerId); scrollGridFromPointer(e.clientY); e.preventDefault(); }); gridScrollbar.addEventListener('pointermove', (e) => { if (!_scrollbarDrag) return; scrollGridFromPointer(e.clientY); }); gridScrollbar.addEventListener('pointerup', (e) => { _scrollbarDrag = null; gridScrollbar.classList.remove('dragging'); gridScrollbar.releasePointerCapture(e.pointerId); }); gridScrollbar.addEventListener('pointercancel', (e) => { _scrollbarDrag = null; gridScrollbar.classList.remove('dragging'); gridScrollbar.releasePointerCapture(e.pointerId); }); export function renderGrid() { if (_cardObserver) { _cardObserver.disconnect(); _cardObserver = null; } grid.innerHTML = ''; emptyState.style.display = 'none'; grid.style.display = 'grid'; fileCount.textContent = `${state.files.length}개 파일`; updateSelCount(); state.files.forEach((file, idx) => grid.appendChild(createCard(file, idx))); 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 && entry.target.isConnected) img.src = dataUrl; }); }, { root: gridContainer, rootMargin: '400px 0px', threshold: 0 }); Array.from(grid.children).forEach(card => observer.observe(card)); _cardObserver = observer; selection.resetAnchor(); refreshCardSelection(); updateNumberBadges(); updateNewNameLabels(); updateGridScrollState(); } export function createCard(file, idx) { const card = document.createElement('div'); card.className = 'card'; card.dataset.idx = idx; card.dataset.filepath = file.path; card.draggable = true; const numBadge = document.createElement('div'); numBadge.className = 'card-num'; numBadge.textContent = `#${idx + 1}`; const checkBadge = document.createElement('div'); checkBadge.className = 'card-check'; checkBadge.textContent = '✓'; const deleteBtn = document.createElement('button'); deleteBtn.className = 'card-delete'; deleteBtn.title = '삭제'; deleteBtn.textContent = '×'; deleteBtn.draggable = false; // 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'; const nameEl = document.createElement('div'); nameEl.className = 'card-name'; nameEl.title = file.name; nameEl.textContent = file.name; const newNameEl = document.createElement('div'); newNameEl.className = 'card-new-name'; body.appendChild(nameEl); body.appendChild(newNameEl); card.appendChild(numBadge); card.appendChild(checkBadge); card.appendChild(deleteBtn); card.appendChild(thumb); card.appendChild(body); dragDrop.setupCard(card); hoverPreview.setupCard(card); card.addEventListener('click', selection.onCardClick); return card; } export function refreshCardSelection() { Array.from(grid.children).forEach((card, i) => { card.classList.toggle('selected', state.selectedIdxs.has(i)); card.classList.toggle('keyboard-target', selection.isKeyboardTarget(i)); }); } export function updateNumberBadges() { Array.from(grid.children).forEach((card, i) => { const badge = card.querySelector('.card-num'); if (badge) badge.textContent = `#${i + 1}`; card.dataset.idx = i; }); } export function updateNewNameLabels() { const format = getNameFormat(inputPrefix, inputDigits, 3, inputPostfix); Array.from(grid.children).forEach((card, i) => { const el = card.querySelector('.card-new-name'); const file = state.files[i]; if (!el || !file) return; el.textContent = `→ ${buildSequentialName(file.name, i, format)}`; }); } export function updateSelCount() { const n = state.selectedIdxs.size; if (n > 0) { selCount.textContent = `${n}개 선택됨`; selCount.classList.add('visible'); } else { selCount.textContent = ''; selCount.classList.remove('visible'); } } export function showEmptyGrid() { grid.innerHTML = ''; grid.style.display = 'none'; emptyState.style.display = ''; fileCount.textContent = '0개 파일'; updateSelCount(); updateGridScrollState(); }