리팩토링: renderer.js 구성 요소들을 독립적인 파일로 나누기
This commit is contained in:
@@ -0,0 +1,133 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
const grid = document.getElementById('grid');
|
||||||
|
const emptyState = document.getElementById('empty-state');
|
||||||
|
const gridContainer = document.getElementById('grid-container');
|
||||||
|
const fileCount = document.getElementById('file-count');
|
||||||
|
const selCount = document.getElementById('sel-count');
|
||||||
|
const inputPrefix = document.getElementById('input-prefix');
|
||||||
|
const inputDigits = document.getElementById('input-digits');
|
||||||
|
|
||||||
|
let _cardObserver = null;
|
||||||
|
|
||||||
|
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) img.src = dataUrl;
|
||||||
|
});
|
||||||
|
}, { root: gridContainer, rootMargin: '400px 0px', threshold: 0 });
|
||||||
|
|
||||||
|
Array.from(grid.children).forEach(card => observer.observe(card));
|
||||||
|
_cardObserver = observer;
|
||||||
|
|
||||||
|
refreshCardSelection();
|
||||||
|
updateNumberBadges();
|
||||||
|
updateNewNameLabels();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = '✓';
|
||||||
|
|
||||||
|
// 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(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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 prefix = inputPrefix.value.trim() || 'photo';
|
||||||
|
const digits = Math.max(1, Math.min(6, parseInt(inputDigits.value) || 3));
|
||||||
|
Array.from(grid.children).forEach((card, i) => {
|
||||||
|
const el = card.querySelector('.card-new-name');
|
||||||
|
const file = state.files[i];
|
||||||
|
if (!el || !file) return;
|
||||||
|
const ext = file.name.split('.').pop().toLowerCase();
|
||||||
|
el.textContent = `→ ${prefix}_${String(i + 1).padStart(digits, '0')}.${ext}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { state } from './state.js';
|
||||||
|
import { showLoading, hideLoading, setStatus, showToast } from './ui.js';
|
||||||
|
import { renderGrid, updateSelCount, showEmptyGrid } from './grid.js';
|
||||||
|
|
||||||
|
const btnRename = document.getElementById('btn-rename');
|
||||||
|
const moveModal = document.getElementById('move-modal');
|
||||||
|
const movePrefixInput = document.getElementById('move-prefix');
|
||||||
|
const moveDigitsInput = document.getElementById('move-digits');
|
||||||
|
const moveCancelBtn = document.getElementById('move-cancel');
|
||||||
|
const moveConfirmBtn = document.getElementById('move-confirm');
|
||||||
|
const moveErrorModal = document.getElementById('move-error-modal');
|
||||||
|
const moveErrorCloseBtn = document.getElementById('move-error-close');
|
||||||
|
|
||||||
|
let _pendingMoveTarget = null;
|
||||||
|
let _pendingMoveFiles = null;
|
||||||
|
|
||||||
|
moveCancelBtn.addEventListener('click', () => {
|
||||||
|
moveModal.style.display = 'none';
|
||||||
|
_pendingMoveTarget = null;
|
||||||
|
_pendingMoveFiles = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
moveConfirmBtn.addEventListener('click', async () => {
|
||||||
|
const prefix = movePrefixInput.value.trim() || 'photo';
|
||||||
|
const digits = Math.max(1, Math.min(6, parseInt(moveDigitsInput.value) || 4));
|
||||||
|
|
||||||
|
const newNames = _pendingMoveFiles.map((f, i) => {
|
||||||
|
const ext = f.name.split('.').pop().toLowerCase();
|
||||||
|
return `${prefix}_${String(i + 1).padStart(digits, '0')}.${ext}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const conflicts = await window.api.checkFilesExist(_pendingMoveTarget, newNames);
|
||||||
|
if (conflicts.length) {
|
||||||
|
moveModal.style.display = 'none';
|
||||||
|
moveErrorModal.style.display = 'flex';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
moveModal.style.display = 'none';
|
||||||
|
const targetPath = _pendingMoveTarget;
|
||||||
|
const filesToMove = _pendingMoveFiles.map((f, i) => ({ ...f, newName: newNames[i] }));
|
||||||
|
_pendingMoveTarget = null;
|
||||||
|
_pendingMoveFiles = null;
|
||||||
|
await _doMoveFiles(targetPath, filesToMove);
|
||||||
|
});
|
||||||
|
|
||||||
|
moveErrorCloseBtn.addEventListener('click', () => {
|
||||||
|
moveErrorModal.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function handleFolderDrop(targetPath, fileIndices) {
|
||||||
|
if (!fileIndices.length || !state.currentFolder) return;
|
||||||
|
if (targetPath === state.currentFolder) return;
|
||||||
|
|
||||||
|
const sorted = [...fileIndices].sort((a, b) => a - b);
|
||||||
|
const selectedFiles = sorted.map(i => state.files[i]).filter(Boolean);
|
||||||
|
if (!selectedFiles.length) return;
|
||||||
|
|
||||||
|
const names = selectedFiles.map(f => f.name);
|
||||||
|
const conflicts = await window.api.checkFilesExist(targetPath, names);
|
||||||
|
|
||||||
|
if (!conflicts.length) {
|
||||||
|
await _doMoveFiles(targetPath, selectedFiles);
|
||||||
|
} else {
|
||||||
|
_pendingMoveTarget = targetPath;
|
||||||
|
_pendingMoveFiles = selectedFiles;
|
||||||
|
movePrefixInput.value = 'temp';
|
||||||
|
moveModal.style.display = 'flex';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _doMoveFiles(targetPath, files) {
|
||||||
|
showLoading('파일 이동 중...');
|
||||||
|
try {
|
||||||
|
const result = await window.api.moveFiles({ targetFolder: targetPath, files });
|
||||||
|
hideLoading();
|
||||||
|
if (result.errors.length) {
|
||||||
|
alert('일부 오류:\n' + result.errors.join('\n'));
|
||||||
|
} else {
|
||||||
|
showToast(`✅ ${result.moved.length}개 파일 이동 완료!`);
|
||||||
|
setStatus(`${result.moved.length}개 파일 이동 완료`, true);
|
||||||
|
}
|
||||||
|
const newList = await window.api.listImages(state.currentFolder);
|
||||||
|
state.files.length = 0;
|
||||||
|
state.files.push(...newList);
|
||||||
|
state.selectedIdxs.clear();
|
||||||
|
if (!newList.length) {
|
||||||
|
showEmptyGrid();
|
||||||
|
btnRename.disabled = true;
|
||||||
|
setStatus('이미지 파일 없음', false);
|
||||||
|
} else {
|
||||||
|
renderGrid();
|
||||||
|
setStatus(`${state.files.length}개 파일 로드됨`, true);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
hideLoading();
|
||||||
|
alert('오류 발생: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { state } from './state.js';
|
||||||
|
import { showLoading, hideLoading, setStatus, showToast } from './ui.js';
|
||||||
|
import { renderGrid } from './grid.js';
|
||||||
|
|
||||||
|
const inputPrefix = document.getElementById('input-prefix');
|
||||||
|
const inputDigits = document.getElementById('input-digits');
|
||||||
|
const renameConflictModal = document.getElementById('rename-conflict-modal');
|
||||||
|
const renameConflictCloseBtn = document.getElementById('rename-conflict-close');
|
||||||
|
const renameConflictList = document.getElementById('rename-conflict-list');
|
||||||
|
|
||||||
|
renameConflictCloseBtn.addEventListener('click', () => {
|
||||||
|
renameConflictModal.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function doRename() {
|
||||||
|
if (!state.files.length || !state.currentFolder) return;
|
||||||
|
const prefix = inputPrefix.value.trim() || 'photo';
|
||||||
|
const digits = Math.max(1, Math.min(6, parseInt(inputDigits.value) || 3));
|
||||||
|
|
||||||
|
const selectedArr = [...state.selectedIdxs].sort((a, b) => a - b);
|
||||||
|
const filesToRename = selectedArr.length > 0
|
||||||
|
? selectedArr.map(i => state.files[i]).filter(Boolean)
|
||||||
|
: state.files;
|
||||||
|
|
||||||
|
if (!filesToRename.length) return;
|
||||||
|
|
||||||
|
// 선택된 파일이 있으면 변경될 이름이 미선택 파일 이름과 충돌하는지 확인
|
||||||
|
if (selectedArr.length > 0) {
|
||||||
|
const unselectedNames = new Set(
|
||||||
|
state.files.filter((_, i) => !state.selectedIdxs.has(i)).map(f => f.name)
|
||||||
|
);
|
||||||
|
const newNames = filesToRename.map((f, i) => {
|
||||||
|
const ext = f.name.split('.').pop().toLowerCase();
|
||||||
|
return `${prefix}_${String(i + 1).padStart(digits, '0')}.${ext}`;
|
||||||
|
});
|
||||||
|
const conflicting = newNames.filter(n => unselectedNames.has(n));
|
||||||
|
if (conflicting.length) {
|
||||||
|
renameConflictList.innerHTML = conflicting.map(n => `<li>${n}</li>`).join('');
|
||||||
|
renameConflictModal.style.display = 'flex';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const examples = filesToRename.slice(0, 3).map((f, i) => {
|
||||||
|
const ext = f.name.split('.').pop().toLowerCase();
|
||||||
|
return `${f.name} → ${prefix}_${String(i + 1).padStart(digits, '0')}.${ext}`;
|
||||||
|
}).join('\n');
|
||||||
|
const more = filesToRename.length > 3 ? `\n... 외 ${filesToRename.length - 3}개` : '';
|
||||||
|
|
||||||
|
if (!confirm(`📂 ${filesToRename.length}개 파일을 이름 변경합니다:\n\n${examples}${more}\n\n계속하시겠습니까?`)) return;
|
||||||
|
|
||||||
|
showLoading('파일 이름 변경 중...');
|
||||||
|
setStatus('이름 변경 중...', false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.api.renameFiles({
|
||||||
|
folderPath: state.currentFolder,
|
||||||
|
files: filesToRename,
|
||||||
|
prefix,
|
||||||
|
digits,
|
||||||
|
});
|
||||||
|
hideLoading();
|
||||||
|
if (result.errors.length) {
|
||||||
|
alert('일부 오류:\n' + result.errors.join('\n'));
|
||||||
|
} else {
|
||||||
|
showToast(`✅ ${result.renamed.length}개 파일 이름 변경 완료!`);
|
||||||
|
setStatus(`${result.renamed.length}개 파일 이름 변경 완료`, true);
|
||||||
|
}
|
||||||
|
const newList = await window.api.listImages(state.currentFolder);
|
||||||
|
state.files.length = 0;
|
||||||
|
state.files.push(...newList);
|
||||||
|
state.selectedIdxs.clear();
|
||||||
|
renderGrid();
|
||||||
|
} catch (e) {
|
||||||
|
hideLoading();
|
||||||
|
alert('오류 발생: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
-369
@@ -1,68 +1,47 @@
|
|||||||
import { state } from './state.js';
|
import { state } from './state.js';
|
||||||
import * as pinchZoom from './handlers/pinch-zoom.js';
|
import { showLoading, hideLoading, setStatus } from './ui.js';
|
||||||
import * as selection from './handlers/selection.js';
|
import {
|
||||||
import * as dragDrop from './handlers/drag-drop.js';
|
renderGrid, updateSelCount, updateNumberBadges,
|
||||||
import * as hoverPreview from './handlers/image-preview.js';
|
updateNewNameLabels, refreshCardSelection, showEmptyGrid,
|
||||||
import * as fileExplorer from './handlers/file-explorer.js';
|
} from './grid.js';
|
||||||
|
import { doRename } from './rename.js';
|
||||||
let _cardObserver = null;
|
import { handleFolderDrop } from './move.js';
|
||||||
|
import * as pinchZoom from './handlers/pinch-zoom.js';
|
||||||
|
import * as selection from './handlers/selection.js';
|
||||||
|
import * as dragDrop from './handlers/drag-drop.js';
|
||||||
|
import * as hoverPreview from './handlers/image-preview.js';
|
||||||
|
import * as fileExplorer from './handlers/file-explorer.js';
|
||||||
|
|
||||||
// ── DOM ──────────────────────────────────────────────
|
// ── DOM ──────────────────────────────────────────────
|
||||||
const grid = document.getElementById('grid');
|
const btnOpen = document.getElementById('btn-open');
|
||||||
const emptyState = document.getElementById('empty-state');
|
const btnSortName = document.getElementById('btn-sort-name');
|
||||||
const btnOpen = document.getElementById('btn-open');
|
const btnSortDate = document.getElementById('btn-sort-date');
|
||||||
const btnSortName = document.getElementById('btn-sort-name');
|
const btnRename = document.getElementById('btn-rename');
|
||||||
const btnSortDate = document.getElementById('btn-sort-date');
|
const inputPrefix = document.getElementById('input-prefix');
|
||||||
const btnRename = document.getElementById('btn-rename');
|
const inputDigits = document.getElementById('input-digits');
|
||||||
const inputPrefix = document.getElementById('input-prefix');
|
const previewName = document.getElementById('preview-name');
|
||||||
const inputDigits = document.getElementById('input-digits');
|
const dirPath = document.getElementById('dir-path');
|
||||||
const previewName = document.getElementById('preview-name');
|
const rubberBand = document.getElementById('rubber-band');
|
||||||
const fileCount = document.getElementById('file-count');
|
const gridContainer = document.getElementById('grid-container');
|
||||||
const selCount = document.getElementById('sel-count');
|
const dragGhostEl = document.getElementById('drag-ghost');
|
||||||
const dirPath = document.getElementById('dir-path');
|
const thumbSizeHint = document.getElementById('thumb-size-hint');
|
||||||
const statusDot = document.getElementById('status-dot');
|
const btnPreviewToggle = document.getElementById('btn-preview-toggle');
|
||||||
const statusText = document.getElementById('status-text');
|
const fileExplorerContainer = document.querySelector('.file-explorer');
|
||||||
const loading = document.getElementById('loading');
|
|
||||||
const loadingText = document.getElementById('loading-text');
|
|
||||||
const toast = document.getElementById('toast');
|
|
||||||
const rubberBand = document.getElementById('rubber-band');
|
|
||||||
const gridContainer = document.getElementById('grid-container');
|
|
||||||
const dragGhostEl = document.getElementById('drag-ghost');
|
|
||||||
const thumbSizeHint = document.getElementById('thumb-size-hint');
|
|
||||||
const btnPreviewToggle = document.getElementById('btn-preview-toggle');
|
|
||||||
const moveModal = document.getElementById('move-modal');
|
|
||||||
const movePrefixInput = document.getElementById('move-prefix');
|
|
||||||
const moveDigitsInput = document.getElementById('move-digits');
|
|
||||||
const moveCancelBtn = document.getElementById('move-cancel');
|
|
||||||
const moveConfirmBtn = document.getElementById('move-confirm');
|
|
||||||
const moveErrorModal = document.getElementById('move-error-modal');
|
|
||||||
const moveErrorCloseBtn = document.getElementById('move-error-close');
|
|
||||||
const renameConflictModal = document.getElementById('rename-conflict-modal');
|
|
||||||
const renameConflictCloseBtn = document.getElementById('rename-conflict-close');
|
|
||||||
const renameConflictList = document.getElementById('rename-conflict-list');
|
|
||||||
|
|
||||||
// ── 핸들러 초기화 ─────────────────────────────────
|
// ── 핸들러 초기화 ─────────────────────────────────
|
||||||
const callbacks = {
|
const callbacks = { refreshCardSelection, updateSelCount, updateNumberBadges, updateNewNameLabels };
|
||||||
refreshCardSelection,
|
|
||||||
updateSelCount,
|
|
||||||
updateNumberBadges,
|
|
||||||
updateNewNameLabels,
|
|
||||||
};
|
|
||||||
|
|
||||||
const fileExplorerContainer = document.querySelector('.file-explorer');
|
|
||||||
|
|
||||||
pinchZoom.init({ gridContainer, thumbSizeHint });
|
pinchZoom.init({ gridContainer, thumbSizeHint });
|
||||||
selection.init({ gridContainer, rubberBand, grid, state, callbacks });
|
selection.init({ gridContainer, rubberBand, grid, state, callbacks });
|
||||||
dragDrop.init({ grid, gridContainer, dragGhostEl, state, callbacks, fileExplorerEl: fileExplorerContainer, onFolderDrop: handleFolderDrop });
|
dragDrop.init({ grid, gridContainer, dragGhostEl, state, callbacks, fileExplorerEl: fileExplorerContainer, onFolderDrop: handleFolderDrop });
|
||||||
hoverPreview.init();
|
hoverPreview.init();
|
||||||
|
|
||||||
fileExplorer.init(fileExplorerContainer, loadFolder);
|
fileExplorer.init(fileExplorerContainer, loadFolder);
|
||||||
|
|
||||||
window.api.onFullscreenChange((isFullscreen) => {
|
window.api.onFullscreenChange((isFullscreen) => {
|
||||||
document.body.classList.toggle('fullscreen', isFullscreen);
|
document.body.classList.toggle('fullscreen', isFullscreen);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 앱 시작 시 마지막 폴더 또는 홈 디렉토리 로드
|
// ── 앱 시작 ───────────────────────────────────────
|
||||||
(async () => {
|
(async () => {
|
||||||
const [lastFolder, homeDir, previewOn] = await Promise.all([
|
const [lastFolder, homeDir, previewOn] = await Promise.all([
|
||||||
window.api.getLastFolder(),
|
window.api.getLastFolder(),
|
||||||
@@ -78,32 +57,35 @@ window.api.onFullscreenChange((isFullscreen) => {
|
|||||||
await loadFolder(target);
|
await loadFolder(target);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// ── 버튼 이벤트 ──────────────────────────────────
|
// ── 버튼 / 키보드 이벤트 ─────────────────────────
|
||||||
inputPrefix.addEventListener('input', updatePreview);
|
inputPrefix.addEventListener('input', updatePreview);
|
||||||
inputDigits.addEventListener('input', updatePreview);
|
inputDigits.addEventListener('input', updatePreview);
|
||||||
btnOpen.addEventListener('click', openFolder);
|
btnOpen.addEventListener('click', openFolder);
|
||||||
|
btnSortName.addEventListener('click', () => sortFiles('name'));
|
||||||
document.addEventListener('keydown', (e) => {
|
btnSortDate.addEventListener('click', () => sortFiles('date'));
|
||||||
if (e.target.tagName === 'INPUT') return;
|
btnRename.addEventListener('click', doRename);
|
||||||
if (e.code === 'KeyO') btnOpen.click();
|
|
||||||
if (e.code === 'KeyS' && !btnRename.disabled) btnRename.click();
|
|
||||||
if (e.code === 'KeyA') { state.files.forEach((_, i) => state.selectedIdxs.add(i)); refreshCardSelection(); updateSelCount(); }
|
|
||||||
});
|
|
||||||
btnPreviewToggle.addEventListener('click', () => {
|
btnPreviewToggle.addEventListener('click', () => {
|
||||||
const on = btnPreviewToggle.classList.toggle('active');
|
const on = btnPreviewToggle.classList.toggle('active');
|
||||||
hoverPreview.setEnabled(on);
|
hoverPreview.setEnabled(on);
|
||||||
window.api.setPreviewOn(on);
|
window.api.setPreviewOn(on);
|
||||||
});
|
});
|
||||||
btnSortName.addEventListener('click', () => sortFiles('name'));
|
|
||||||
btnSortDate.addEventListener('click', () => sortFiles('date'));
|
document.addEventListener('keydown', (e) => {
|
||||||
btnRename.addEventListener('click', doRename);
|
if (e.target.tagName === 'INPUT') return;
|
||||||
|
if (e.code === 'KeyO') btnOpen.click();
|
||||||
|
if (e.code === 'KeyS' && !btnRename.disabled) btnRename.click();
|
||||||
|
if (e.code === 'KeyA') {
|
||||||
|
state.files.forEach((_, i) => state.selectedIdxs.add(i));
|
||||||
|
refreshCardSelection();
|
||||||
|
updateSelCount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── 폴더 열기 (다이얼로그) ───────────────────────
|
// ── 폴더 열기 (다이얼로그) ───────────────────────
|
||||||
async function openFolder() {
|
async function openFolder() {
|
||||||
const folder = await window.api.selectFolder();
|
const folder = await window.api.selectFolder();
|
||||||
if (!folder) return;
|
if (!folder) return;
|
||||||
const homeDir = await window.api.getHomeDir();
|
const homeDir = await window.api.getHomeDir();
|
||||||
// 선택한 폴더가 홈 디렉토리 하위인지 확인해서 트리 업데이트
|
|
||||||
if (folder.startsWith(homeDir)) {
|
if (folder.startsWith(homeDir)) {
|
||||||
await fileExplorer.loadTree(homeDir, folder);
|
await fileExplorer.loadTree(homeDir, folder);
|
||||||
} else {
|
} else {
|
||||||
@@ -127,11 +109,7 @@ async function loadFolder(folder) {
|
|||||||
if (!list.length) {
|
if (!list.length) {
|
||||||
state.files.length = 0;
|
state.files.length = 0;
|
||||||
state.selectedIdxs.clear();
|
state.selectedIdxs.clear();
|
||||||
grid.innerHTML = '';
|
showEmptyGrid();
|
||||||
grid.style.display = 'none';
|
|
||||||
emptyState.style.display = '';
|
|
||||||
fileCount.textContent = '0개 파일';
|
|
||||||
updateSelCount();
|
|
||||||
btnRename.disabled = true;
|
btnRename.disabled = true;
|
||||||
setStatus('이미지 파일 없음', false);
|
setStatus('이미지 파일 없음', false);
|
||||||
return;
|
return;
|
||||||
@@ -152,84 +130,6 @@ async function loadFolder(folder) {
|
|||||||
btnRename.disabled = false;
|
btnRename.disabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 그리드 렌더링 ────────────────────────────────
|
|
||||||
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)));
|
|
||||||
|
|
||||||
// 뷰포트 ±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;
|
|
||||||
});
|
|
||||||
}, { root: gridContainer, rootMargin: '400px 0px', threshold: 0 });
|
|
||||||
|
|
||||||
Array.from(grid.children).forEach(card => observer.observe(card));
|
|
||||||
_cardObserver = observer;
|
|
||||||
|
|
||||||
refreshCardSelection();
|
|
||||||
updateNumberBadges();
|
|
||||||
updateNewNameLabels();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 카드 생성 ────────────────────────────────────
|
|
||||||
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 = '✓';
|
|
||||||
|
|
||||||
// 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(thumb);
|
|
||||||
card.appendChild(body);
|
|
||||||
|
|
||||||
dragDrop.setupCard(card);
|
|
||||||
hoverPreview.setupCard(card);
|
|
||||||
card.addEventListener('click', selection.onCardClick);
|
|
||||||
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 정렬 ─────────────────────────────────────────
|
// ── 정렬 ─────────────────────────────────────────
|
||||||
async function sortFiles(mode) {
|
async function sortFiles(mode) {
|
||||||
if (!state.files.length) return;
|
if (!state.files.length) return;
|
||||||
@@ -247,7 +147,7 @@ function updateSortButtons() {
|
|||||||
btnSortDate.classList.toggle('active', state.sortMode === 'date');
|
btnSortDate.classList.toggle('active', state.sortMode === 'date');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 미리보기 ─────────────────────────────────────
|
// ── 포맷 미리보기 ─────────────────────────────────
|
||||||
function updatePreview() {
|
function updatePreview() {
|
||||||
const prefix = inputPrefix.value.trim() || 'photo';
|
const prefix = inputPrefix.value.trim() || 'photo';
|
||||||
const digits = Math.max(1, Math.min(6, parseInt(inputDigits.value) || 3));
|
const digits = Math.max(1, Math.min(6, parseInt(inputDigits.value) || 3));
|
||||||
@@ -255,226 +155,3 @@ function updatePreview() {
|
|||||||
previewName.textContent = `${prefix}_${'1'.padStart(digits, '0')}.${ext}`;
|
previewName.textContent = `${prefix}_${'1'.padStart(digits, '0')}.${ext}`;
|
||||||
updateNewNameLabels();
|
updateNewNameLabels();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 이름 변경 ────────────────────────────────────
|
|
||||||
renameConflictCloseBtn.addEventListener('click', () => {
|
|
||||||
renameConflictModal.style.display = 'none';
|
|
||||||
});
|
|
||||||
|
|
||||||
async function doRename() {
|
|
||||||
if (!state.files.length || !state.currentFolder) return;
|
|
||||||
const prefix = inputPrefix.value.trim() || 'photo';
|
|
||||||
const digits = Math.max(1, Math.min(6, parseInt(inputDigits.value) || 3));
|
|
||||||
|
|
||||||
const selectedArr = [...state.selectedIdxs].sort((a, b) => a - b);
|
|
||||||
const filesToRename = selectedArr.length > 0
|
|
||||||
? selectedArr.map(i => state.files[i]).filter(Boolean)
|
|
||||||
: state.files;
|
|
||||||
|
|
||||||
if (!filesToRename.length) return;
|
|
||||||
|
|
||||||
// 선택된 파일이 있으면 변경될 이름이 미선택 파일 이름과 충돌하는지 확인
|
|
||||||
if (selectedArr.length > 0) {
|
|
||||||
const unselectedNames = new Set(
|
|
||||||
state.files.filter((_, i) => !state.selectedIdxs.has(i)).map(f => f.name)
|
|
||||||
);
|
|
||||||
const newNames = filesToRename.map((f, i) => {
|
|
||||||
const ext = f.name.split('.').pop().toLowerCase();
|
|
||||||
return `${prefix}_${String(i + 1).padStart(digits, '0')}.${ext}`;
|
|
||||||
});
|
|
||||||
const conflicting = newNames.filter(n => unselectedNames.has(n));
|
|
||||||
if (conflicting.length) {
|
|
||||||
renameConflictList.innerHTML = conflicting.map(n => `<li>${n}</li>`).join('');
|
|
||||||
renameConflictModal.style.display = 'flex';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const examples = filesToRename.slice(0, 3).map((f, i) => {
|
|
||||||
const ext = f.name.split('.').pop().toLowerCase();
|
|
||||||
return `${f.name} → ${prefix}_${String(i + 1).padStart(digits, '0')}.${ext}`;
|
|
||||||
}).join('\n');
|
|
||||||
const more = filesToRename.length > 3 ? `\n... 외 ${filesToRename.length - 3}개` : '';
|
|
||||||
|
|
||||||
if (!confirm(`📂 ${filesToRename.length}개 파일을 이름 변경합니다:\n\n${examples}${more}\n\n계속하시겠습니까?`)) return;
|
|
||||||
|
|
||||||
showLoading('파일 이름 변경 중...');
|
|
||||||
setStatus('이름 변경 중...', false);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await window.api.renameFiles({
|
|
||||||
folderPath: state.currentFolder,
|
|
||||||
files: filesToRename,
|
|
||||||
prefix,
|
|
||||||
digits,
|
|
||||||
});
|
|
||||||
hideLoading();
|
|
||||||
if (result.errors.length) {
|
|
||||||
alert('일부 오류:\n' + result.errors.join('\n'));
|
|
||||||
} else {
|
|
||||||
showToast(`✅ ${result.renamed.length}개 파일 이름 변경 완료!`);
|
|
||||||
setStatus(`${result.renamed.length}개 파일 이름 변경 완료`, true);
|
|
||||||
}
|
|
||||||
const newList = await window.api.listImages(state.currentFolder);
|
|
||||||
state.files.length = 0;
|
|
||||||
state.files.push(...newList);
|
|
||||||
state.selectedIdxs.clear();
|
|
||||||
renderGrid();
|
|
||||||
} catch (e) {
|
|
||||||
hideLoading();
|
|
||||||
alert('오류 발생: ' + e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 폴더로 파일 이동 ─────────────────────────────
|
|
||||||
|
|
||||||
let _pendingMoveTarget = null;
|
|
||||||
let _pendingMoveFiles = null;
|
|
||||||
|
|
||||||
async function handleFolderDrop(targetPath, fileIndices) {
|
|
||||||
if (!fileIndices.length || !state.currentFolder) return;
|
|
||||||
if (targetPath === state.currentFolder) return;
|
|
||||||
|
|
||||||
const sorted = [...fileIndices].sort((a, b) => a - b);
|
|
||||||
const selectedFiles = sorted.map(i => state.files[i]).filter(Boolean);
|
|
||||||
if (!selectedFiles.length) return;
|
|
||||||
|
|
||||||
const names = selectedFiles.map(f => f.name);
|
|
||||||
const conflicts = await window.api.checkFilesExist(targetPath, names);
|
|
||||||
|
|
||||||
if (!conflicts.length) {
|
|
||||||
await _doMoveFiles(targetPath, selectedFiles);
|
|
||||||
} else {
|
|
||||||
_pendingMoveTarget = targetPath;
|
|
||||||
_pendingMoveFiles = selectedFiles;
|
|
||||||
movePrefixInput.value = 'temp';
|
|
||||||
moveModal.style.display = 'flex';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
moveCancelBtn.addEventListener('click', () => {
|
|
||||||
moveModal.style.display = 'none';
|
|
||||||
_pendingMoveTarget = null;
|
|
||||||
_pendingMoveFiles = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
moveConfirmBtn.addEventListener('click', async () => {
|
|
||||||
const prefix = movePrefixInput.value.trim() || 'photo';
|
|
||||||
const digits = Math.max(1, Math.min(6, parseInt(moveDigitsInput.value) || 4));
|
|
||||||
|
|
||||||
const newNames = _pendingMoveFiles.map((f, i) => {
|
|
||||||
const ext = f.name.split('.').pop().toLowerCase();
|
|
||||||
return `${prefix}_${String(i + 1).padStart(digits, '0')}.${ext}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const conflicts = await window.api.checkFilesExist(_pendingMoveTarget, newNames);
|
|
||||||
if (conflicts.length) {
|
|
||||||
moveModal.style.display = 'none';
|
|
||||||
moveErrorModal.style.display = 'flex';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
moveModal.style.display = 'none';
|
|
||||||
const targetPath = _pendingMoveTarget;
|
|
||||||
const filesToMove = _pendingMoveFiles.map((f, i) => ({ ...f, newName: newNames[i] }));
|
|
||||||
_pendingMoveTarget = null;
|
|
||||||
_pendingMoveFiles = null;
|
|
||||||
await _doMoveFiles(targetPath, filesToMove);
|
|
||||||
});
|
|
||||||
|
|
||||||
moveErrorCloseBtn.addEventListener('click', () => {
|
|
||||||
moveErrorModal.style.display = 'none';
|
|
||||||
});
|
|
||||||
|
|
||||||
async function _doMoveFiles(targetPath, files) {
|
|
||||||
showLoading('파일 이동 중...');
|
|
||||||
try {
|
|
||||||
const result = await window.api.moveFiles({ targetFolder: targetPath, files });
|
|
||||||
hideLoading();
|
|
||||||
if (result.errors.length) {
|
|
||||||
alert('일부 오류:\n' + result.errors.join('\n'));
|
|
||||||
} else {
|
|
||||||
showToast(`✅ ${result.moved.length}개 파일 이동 완료!`);
|
|
||||||
setStatus(`${result.moved.length}개 파일 이동 완료`, true);
|
|
||||||
}
|
|
||||||
const newList = await window.api.listImages(state.currentFolder);
|
|
||||||
state.files.length = 0;
|
|
||||||
state.files.push(...newList);
|
|
||||||
state.selectedIdxs.clear();
|
|
||||||
if (!newList.length) {
|
|
||||||
grid.innerHTML = '';
|
|
||||||
grid.style.display = 'none';
|
|
||||||
emptyState.style.display = '';
|
|
||||||
fileCount.textContent = '0개 파일';
|
|
||||||
updateSelCount();
|
|
||||||
btnRename.disabled = true;
|
|
||||||
setStatus('이미지 파일 없음', false);
|
|
||||||
} else {
|
|
||||||
renderGrid();
|
|
||||||
setStatus(`${state.files.length}개 파일 로드됨`, true);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
hideLoading();
|
|
||||||
alert('오류 발생: ' + e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── UI 업데이트 ──────────────────────────────────
|
|
||||||
|
|
||||||
function refreshCardSelection() {
|
|
||||||
Array.from(grid.children).forEach((card, i) => {
|
|
||||||
card.classList.toggle('selected', state.selectedIdxs.has(i));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateNewNameLabels() {
|
|
||||||
const prefix = inputPrefix.value.trim() || 'photo';
|
|
||||||
const digits = Math.max(1, Math.min(6, parseInt(inputDigits.value) || 3));
|
|
||||||
Array.from(grid.children).forEach((card, i) => {
|
|
||||||
const el = card.querySelector('.card-new-name');
|
|
||||||
const file = state.files[i];
|
|
||||||
if (!el || !file) return;
|
|
||||||
const ext = file.name.split('.').pop().toLowerCase();
|
|
||||||
el.textContent = `→ ${prefix}_${String(i + 1).padStart(digits, '0')}.${ext}`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSelCount() {
|
|
||||||
const n = state.selectedIdxs.size;
|
|
||||||
if (n > 0) {
|
|
||||||
selCount.textContent = `${n}개 선택됨`;
|
|
||||||
selCount.classList.add('visible');
|
|
||||||
} else {
|
|
||||||
selCount.textContent = '';
|
|
||||||
selCount.classList.remove('visible');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 공통 UI 헬퍼 ─────────────────────────────────
|
|
||||||
|
|
||||||
function showLoading(msg = '처리 중...') {
|
|
||||||
loadingText.textContent = msg;
|
|
||||||
loading.classList.add('show');
|
|
||||||
}
|
|
||||||
function hideLoading() {
|
|
||||||
loading.classList.remove('show');
|
|
||||||
}
|
|
||||||
function setStatus(msg, ready) {
|
|
||||||
statusText.textContent = msg;
|
|
||||||
statusDot.className = 'status-dot' + (ready ? ' ready' : '');
|
|
||||||
}
|
|
||||||
function showToast(msg, bg = '#6af7a0', color = '#0a2a1a') {
|
|
||||||
toast.textContent = msg;
|
|
||||||
toast.style.background = bg;
|
|
||||||
toast.style.color = color;
|
|
||||||
toast.classList.add('show');
|
|
||||||
setTimeout(() => toast.classList.remove('show'), 3000);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
const loading = document.getElementById('loading');
|
||||||
|
const loadingText = document.getElementById('loading-text');
|
||||||
|
const toast = document.getElementById('toast');
|
||||||
|
const statusDot = document.getElementById('status-dot');
|
||||||
|
const statusText = document.getElementById('status-text');
|
||||||
|
|
||||||
|
export function showLoading(msg = '처리 중...') {
|
||||||
|
loadingText.textContent = msg;
|
||||||
|
loading.classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hideLoading() {
|
||||||
|
loading.classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStatus(msg, ready) {
|
||||||
|
statusText.textContent = msg;
|
||||||
|
statusDot.className = 'status-dot' + (ready ? ' ready' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showToast(msg, bg = '#6af7a0', color = '#0a2a1a') {
|
||||||
|
toast.textContent = msg;
|
||||||
|
toast.style.background = bg;
|
||||||
|
toast.style.color = color;
|
||||||
|
toast.classList.add('show');
|
||||||
|
setTimeout(() => toast.classList.remove('show'), 3000);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user