244 lines
8.1 KiB
JavaScript
244 lines
8.1 KiB
JavaScript
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();
|
||
}
|