Files
photo-renamer/src/renderer/grid.js
T

244 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}