리펙토링 - 전체 프로젝트 코드 (Codex)

This commit is contained in:
2026-05-17 22:18:01 +09:00
parent 5337ea221b
commit 0f72f4a0f0
16 changed files with 508 additions and 356 deletions
+59
View File
@@ -0,0 +1,59 @@
const fs = require('fs');
const path = require('path');
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp', '.heic', '.heif']);
function listImages(folderPath) {
return fs.readdirSync(folderPath)
.filter(fileName => IMAGE_EXTENSIONS.has(path.extname(fileName).toLowerCase()))
.map(fileName => {
const fullPath = path.join(folderPath, fileName);
const stat = fs.statSync(fullPath);
return { name: fileName, path: fullPath, mtime: stat.mtimeMs };
})
.sort((a, b) => a.name.localeCompare(b.name, 'ko'));
}
function readImageAsDataUrl(filePath) {
const data = fs.readFileSync(filePath);
const mime = getImageMime(path.extname(filePath));
return `data:image/${mime};base64,${data.toString('base64')}`;
}
function getImageMime(extName) {
const ext = extName.toLowerCase().replace('.', '');
if (ext === 'jpg' || ext === 'jpeg') return 'jpeg';
if (ext === 'png') return 'png';
if (ext === 'gif') return 'gif';
if (ext === 'webp') return 'webp';
if (ext === 'tiff' || ext === 'tif') return 'tiff';
if (ext === 'bmp') return 'bmp';
if (ext === 'heic' || ext === 'heif') return 'heic';
return 'jpeg';
}
function fileExists(filePath) {
try {
fs.accessSync(filePath);
return true;
} catch {
return false;
}
}
function moveFileWithCopyFallback(src, dest) {
try {
fs.renameSync(src, dest);
} catch (e) {
if (e.code !== 'EXDEV') throw e;
fs.copyFileSync(src, dest);
fs.unlinkSync(src);
}
}
module.exports = {
fileExists,
listImages,
moveFileWithCopyFallback,
readImageAsDataUrl,
};
+14 -53
View File
@@ -2,21 +2,17 @@ const { ipcMain, app } = require('electron');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp', '.heic', '.heif']);
const {
fileExists,
listImages,
moveFileWithCopyFallback,
readImageAsDataUrl,
} = require('./file-utils');
function register() {
ipcMain.handle('list-images', async (_event, folderPath) => {
try {
const files = fs.readdirSync(folderPath)
.filter(f => EXTS.has(path.extname(f).toLowerCase()))
.map(f => {
const fullPath = path.join(folderPath, f);
const stat = fs.statSync(fullPath);
return { name: f, path: fullPath, mtime: stat.mtimeMs };
})
.sort((a, b) => a.name.localeCompare(b.name, 'ko'));
return files;
return listImages(folderPath);
} catch (e) {
return [];
}
@@ -24,27 +20,14 @@ function register() {
ipcMain.handle('read-image', async (_event, filePath) => {
try {
const data = fs.readFileSync(filePath);
const ext = path.extname(filePath).toLowerCase().replace('.', '');
const mime = (ext === 'jpg' || ext === 'jpeg') ? 'jpeg'
: ext === 'png' ? 'png'
: ext === 'gif' ? 'gif'
: ext === 'webp' ? 'webp'
: (ext === 'tiff' || ext === 'tif') ? 'tiff'
: ext === 'bmp' ? 'bmp'
: (ext === 'heic' || ext === 'heif') ? 'heic'
: 'jpeg';
return `data:image/${mime};base64,${data.toString('base64')}`;
return readImageAsDataUrl(filePath);
} catch (e) {
return null;
}
});
ipcMain.handle('check-files-exist', (_event, folderPath, names) => {
return names.filter(name => {
try { fs.accessSync(path.join(folderPath, name)); return true; }
catch { return false; }
});
return names.filter(name => fileExists(path.join(folderPath, name)));
});
ipcMain.handle('move-files', async (_event, { targetFolder, files }) => {
@@ -54,22 +37,12 @@ function register() {
const destName = file.newName || file.name;
const dest = path.join(targetFolder, destName);
try {
fs.renameSync(file.path, dest);
moveFileWithCopyFallback(file.path, dest);
moved.push({ src: file.path, dest, name: destName });
} catch (e) {
if (e.code === 'EXDEV') {
try {
fs.copyFileSync(file.path, dest);
fs.unlinkSync(file.path);
moved.push({ src: file.path, dest, name: destName });
} catch (e2) {
errors.push(`${file.name}: ${e2.message}`);
}
} else {
errors.push(`${file.name}: ${e.message}`);
}
}
}
return { moved, errors };
});
@@ -84,16 +57,10 @@ function register() {
const name = path.basename(fp);
const dest = path.join(batchDir, name);
try {
fs.renameSync(fp, dest);
moveFileWithCopyFallback(fp, dest);
staged.push({ name, originalPath: fp, stagingPath: dest });
} catch (e) {
if (e.code === 'EXDEV') {
try {
fs.copyFileSync(fp, dest);
fs.unlinkSync(fp);
staged.push({ name, originalPath: fp, stagingPath: dest });
} catch (e2) { errors.push(`${name}: ${e2.message}`); }
} else { errors.push(`${name}: ${e.message}`); }
errors.push(`${name}: ${e.message}`);
}
}
if (!staged.length) try { fs.rmdirSync(batchDir); } catch {}
@@ -111,16 +78,10 @@ function register() {
}
try {
fs.mkdirSync(path.dirname(originalPath), { recursive: true });
fs.renameSync(stagingPath, originalPath);
moveFileWithCopyFallback(stagingPath, originalPath);
restored.push(originalPath);
} catch (e) {
if (e.code === 'EXDEV') {
try {
fs.copyFileSync(stagingPath, originalPath);
fs.unlinkSync(stagingPath);
restored.push(originalPath);
} catch (e2) { errors.push(`${name}: ${e2.message}`); }
} else { errors.push(`${name}: ${e.message}`); }
errors.push(`${name}: ${e.message}`);
}
}
return { restored, errors };
+3 -10
View File
@@ -3,6 +3,7 @@ import { setStatus, showToast } from './ui.js';
import { renderGrid, showEmptyGrid } from './grid.js';
import * as history from './history.js';
import { openModal, closeModal } from './modal.js';
import { renderTextList } from './utils/dom.js';
const deleteModal = document.getElementById('delete-modal');
const deleteFileList = document.getElementById('delete-file-list');
@@ -28,11 +29,7 @@ export function deleteSelected() {
const filesToDelete = [...state.selectedIdxs].sort((a, b) => a - b)
.map(i => state.files[i]).filter(Boolean);
if (!filesToDelete.length) return;
deleteFileList.replaceChildren(...filesToDelete.map(f => {
const li = document.createElement('li');
li.textContent = f.name;
return li;
}));
renderTextList(deleteFileList, filesToDelete.map(f => f.name));
_pendingFiles = filesToDelete;
openModal(deleteModal);
}
@@ -56,11 +53,7 @@ export function init(gridEl) {
if (!filesToDelete.length) return;
deleteFileList.replaceChildren(...filesToDelete.map(f => {
const li = document.createElement('li');
li.textContent = f.name;
return li;
}));
renderTextList(deleteFileList, filesToDelete.map(f => f.name));
_pendingFiles = filesToDelete;
openModal(deleteModal);
});
+91
View File
@@ -0,0 +1,91 @@
import { state } from './state.js';
import { showLoading, hideLoading, setStatus } from './ui.js';
import { renderGrid, showEmptyGrid } from './grid.js';
import * as history from './history.js';
import * as fileExplorer from './handlers/file-explorer.js';
export function createFolderController({
btnRename,
btnSortName,
btnSortDate,
dirPath,
inputPrefix,
updatePreview,
}) {
let homeDir = null;
async function openFolder() {
const folder = await window.api.selectFolder();
if (!folder) return;
if (folder.startsWith(homeDir)) {
await fileExplorer.loadTree(homeDir, folder);
} else {
await fileExplorer.loadTree(folder, folder);
}
await loadFolder(folder);
}
async function loadFolder(folder) {
if (!folder) return;
state.currentFolder = folder;
dirPath.textContent = folder;
await window.api.setLastFolder(folder);
await fileExplorer.setSelectedPath(folder);
showLoading('이미지 목록 불러오는 중...');
const list = await window.api.listImages(folder);
hideLoading();
if (!list.length) {
state.files.length = 0;
state.selectedIdxs.clear();
showEmptyGrid();
btnRename.disabled = true;
setStatus('이미지 파일 없음', false);
return;
}
state.files.length = 0;
state.files.push(...list);
state.selectedIdxs.clear();
state.sortMode = 'name';
updateSortButtons();
setStatus(`${state.files.length}개 파일 로드됨`, true);
const folderName = folder.replace(/\\/g, '/').split('/').pop();
if (folderName) inputPrefix.value = folderName;
renderGrid();
updatePreview();
btnRename.disabled = false;
history.reset();
history.pushReorder(state.files);
}
async function sortFiles(mode) {
if (!state.files.length) return;
if (mode === 'name') state.files.sort((a, b) => a.name.localeCompare(b.name, 'ko'));
else state.files.sort((a, b) => a.mtime - b.mtime);
state.sortMode = mode;
state.selectedIdxs.clear();
updateSortButtons();
await renderGrid();
setStatus(`${mode === 'name' ? '이름순' : '날짜순'}으로 정렬됨`, true);
history.pushReorder(state.files);
}
function updateSortButtons() {
btnSortName.classList.toggle('active', state.sortMode === 'name');
btnSortDate.classList.toggle('active', state.sortMode === 'date');
}
return {
loadFolder,
openFolder,
setHomeDir: value => { homeDir = value; },
sortFiles,
updateSortButtons,
};
}
+3 -4
View File
@@ -2,6 +2,7 @@ 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');
@@ -110,14 +111,12 @@ export function updateNumberBadges() {
}
export function updateNewNameLabels() {
const prefix = inputPrefix.value.trim() || 'photo';
const digits = Math.max(1, Math.min(6, parseInt(inputDigits.value) || 3));
const format = getNameFormat(inputPrefix, inputDigits);
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}`;
el.textContent = `${buildSequentialName(file.name, i, format)}`;
});
}
+18 -75
View File
@@ -1,25 +1,27 @@
let _grid, _gridContainer, _dragGhostEl, _fileExplorerEl;
import { createAutoScroller } from './drag/auto-scroll.js';
import { createDragGhost } from './drag/ghost.js';
let _grid, _gridContainer, _fileExplorerEl;
let _state, _cb, _onFolderDrop;
let _dropSeparator = null;
let _originPlaceholders = [];
let _dropTargetIdx = null;
let _autoScroller = null;
let _ghost = null;
let dragMode = null;
let dragSrcIdx = null;
let _hoveredTreeRow = null;
let autoScrollRAF = null;
let autoScrollSpeed = 0;
let lastScrollTs = null;
export function init({ grid, gridContainer, dragGhostEl, state, callbacks, fileExplorerEl, onFolderDrop }) {
_grid = grid;
_gridContainer = gridContainer;
_dragGhostEl = dragGhostEl;
_state = state;
_cb = callbacks;
_fileExplorerEl = fileExplorerEl;
_onFolderDrop = onFolderDrop;
_autoScroller = createAutoScroller(_gridContainer, () => Boolean(dragMode));
_ghost = createDragGhost(dragGhostEl);
_dropSeparator = document.createElement('div');
_dropSeparator.className = 'drop-separator';
@@ -49,8 +51,8 @@ export function setupCard(card) {
function _onWindowDragOver(e) {
if (!dragMode) return;
_moveGhost(e);
_trackScroll(e.clientY);
_ghost.move(e);
_autoScroller.track(e.clientY);
}
// ── 카드 드래그 핸들러 ─────────────────────────────
@@ -68,7 +70,7 @@ function _onDragStart(e) {
const img = new Image();
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
e.dataTransfer.setDragImage(img, 0, 0);
_showGhost(e, `📦 ${count}개 이동 중`);
_ghost.show(e, `📦 ${count}개 이동 중`);
// 브라우저가 드래그 이미지를 캡처한 뒤 카드 숨기고 원본 위치 표시
requestAnimationFrame(() => {
@@ -136,8 +138,8 @@ function _performDrop() {
}
function _onDragEnd() {
_stopScroll();
_hideGhost();
_autoScroller.stop();
_ghost.hide();
_hideDropSeparator();
_clearTreeHover();
_clearOriginPlaceholders();
@@ -163,19 +165,19 @@ function _onExplorerDragOver(e) {
if (_hoveredTreeRow) _hoveredTreeRow.classList.add('drop-target');
}
if (dragMode === 'single' && row && _dragGhostEl.style.display === 'none') {
_showGhost(e, '📁 1개 폴더로 이동');
if (dragMode === 'single' && row && _ghost.isHidden()) {
_ghost.show(e, '📁 1개 폴더로 이동');
}
if (dragMode === 'multi' && row) {
_dragGhostEl.textContent = `📁 ${_state.selectedIdxs.size}개 폴더로 이동`;
_ghost.setText(`📁 ${_state.selectedIdxs.size}개 폴더로 이동`);
}
}
function _onExplorerDragLeave(e) {
if (!_fileExplorerEl.contains(e.relatedTarget)) {
_clearTreeHover();
if (dragMode === 'single') _hideGhost();
if (dragMode === 'multi') _dragGhostEl.textContent = `📦 ${_state.selectedIdxs.size}개 이동 중`;
if (dragMode === 'single') _ghost.hide();
if (dragMode === 'multi') _ghost.setText(`📦 ${_state.selectedIdxs.size}개 이동 중`);
}
}
@@ -287,65 +289,6 @@ function _moveMulti(targetIdx) {
_cb.pushHistory?.(movedFiles.map(f => f.path));
}
// ── 자동 스크롤 ───────────────────────────────────
function _trackScroll(clientY) {
const vh = window.innerHeight;
const topLine = vh * 0.1;
const botLine = vh * 0.9;
if (clientY < topLine) {
const ratio = clientY / topLine;
_startScroll(-vh * (1 - 0.8 * ratio));
} else if (clientY > botLine) {
const ratio = (clientY - botLine) / (vh * 0.1);
_startScroll(vh * (0.2 + 0.8 * ratio));
} else {
_stopScroll();
}
}
function _startScroll(speed) {
autoScrollSpeed = speed;
if (!autoScrollRAF) {
lastScrollTs = performance.now();
autoScrollRAF = requestAnimationFrame(_tickScroll);
}
}
function _stopScroll() {
autoScrollSpeed = 0;
if (autoScrollRAF) {
cancelAnimationFrame(autoScrollRAF);
autoScrollRAF = null;
}
}
function _tickScroll(now) {
if (!dragMode) { _stopScroll(); return; }
const dt = (now - lastScrollTs) / 1000;
lastScrollTs = now;
_gridContainer.scrollTop += autoScrollSpeed * dt;
autoScrollRAF = requestAnimationFrame(_tickScroll);
}
// ── 드래그 고스트 ─────────────────────────────────
function _showGhost(e, text) {
_dragGhostEl.textContent = text;
_dragGhostEl.style.display = 'block';
_moveGhost(e);
}
function _moveGhost(e) {
_dragGhostEl.style.left = (e.clientX + 16) + 'px';
_dragGhostEl.style.top = (e.clientY - 14) + 'px';
}
function _hideGhost() {
_dragGhostEl.style.display = 'none';
}
function _getCardEl(idx) {
return _grid.querySelector(`.card[data-idx="${idx}"]`);
}
+51
View File
@@ -0,0 +1,51 @@
export function createAutoScroller(container, isActive) {
let frameId = null;
let speed = 0;
let lastTs = null;
function track(clientY) {
const vh = window.innerHeight;
const topLine = vh * 0.1;
const botLine = vh * 0.9;
if (clientY < topLine) {
const ratio = clientY / topLine;
start(-vh * (1 - 0.8 * ratio));
} else if (clientY > botLine) {
const ratio = (clientY - botLine) / (vh * 0.1);
start(vh * (0.2 + 0.8 * ratio));
} else {
stop();
}
}
function start(nextSpeed) {
speed = nextSpeed;
if (!frameId) {
lastTs = performance.now();
frameId = requestAnimationFrame(tick);
}
}
function stop() {
speed = 0;
if (frameId) {
cancelAnimationFrame(frameId);
frameId = null;
}
}
function tick(now) {
if (!isActive()) {
stop();
return;
}
const dt = (now - lastTs) / 1000;
lastTs = now;
container.scrollTop += speed * dt;
frameId = requestAnimationFrame(tick);
}
return { track, stop };
}
+26
View File
@@ -0,0 +1,26 @@
export function createDragGhost(el) {
function show(e, text) {
el.textContent = text;
el.style.display = 'block';
move(e);
}
function move(e) {
el.style.left = `${e.clientX + 16}px`;
el.style.top = `${e.clientY - 14}px`;
}
function hide() {
el.style.display = 'none';
}
function setText(text) {
el.textContent = text;
}
function isHidden() {
return el.style.display === 'none';
}
return { hide, isHidden, move, setText, show };
}
+100
View File
@@ -0,0 +1,100 @@
import { state } from './state.js';
import { setStatus, showToast } from './ui.js';
import { renderGrid, showEmptyGrid } from './grid.js';
import * as history from './history.js';
export function createHistoryController({
btnRename,
btnUndo,
btnRedo,
updateSortButtons,
}) {
let busy = false;
function updateButtons() {
btnUndo.disabled = !history.canUndo();
btnRedo.disabled = !history.canRedo();
}
async function performUndo() {
if (busy || !history.canUndo()) {
if (!history.canUndo()) showToast('더 이상 되돌릴 수 없습니다');
return;
}
busy = true;
try {
const { entry, targetFiles } = history.undo();
if (entry.type === 'delete') {
const { restored, errors } = await window.api.restoreStagedFiles(entry.deletedEntries);
if (errors.length) showToast('일부 파일 복원 실패: ' + errors[0]);
applySnapshot(targetFiles, new Set(restored));
if (restored.length) setStatus(`${restored.length}개 파일 복원됨`, true);
} else {
applySnapshot(targetFiles, entry.affectedPaths);
}
} finally {
busy = false;
}
}
async function performRedo() {
if (busy || !history.canRedo()) {
if (!history.canRedo()) showToast('마지막 작업 상태입니다');
return;
}
busy = true;
try {
const nextEntry = history.peekRedo();
if (nextEntry.type === 'delete') {
const pathsToRedo = nextEntry.deletedEntries.map(e => e.originalPath);
const { staged, errors } = await window.api.stageDeleteFiles(pathsToRedo);
if (errors.length) showToast('일부 파일 삭제 실패: ' + errors[0]);
updateDeleteStagingPaths(nextEntry, staged);
const { targetFiles } = history.redo();
applySnapshot(targetFiles, null);
if (staged.length) setStatus(`${staged.length}개 파일 삭제됨`, true);
} else {
const { entry, targetFiles } = history.redo();
applySnapshot(targetFiles, entry.affectedPaths);
}
} finally {
busy = false;
}
}
function applySnapshot(targetFiles, affectedPaths = null) {
state.files.length = 0;
state.files.push(...targetFiles);
state.selectedIdxs.clear();
if (affectedPaths && affectedPaths.size > 0) {
const pathToIdx = new Map(state.files.map((f, i) => [f.path, i]));
affectedPaths.forEach(p => {
const idx = pathToIdx.get(p);
if (idx !== undefined) state.selectedIdxs.add(idx);
});
}
state.sortMode = null;
updateSortButtons();
if (!state.files.length) {
showEmptyGrid();
btnRename.disabled = true;
} else {
renderGrid();
btnRename.disabled = false;
}
updateButtons();
}
return { performUndo, performRedo, updateButtons };
}
function updateDeleteStagingPaths(entry, staged) {
const stagedByPath = new Map(staged.map(s => [s.originalPath, s.stagingPath]));
entry.deletedEntries.forEach(e => {
if (stagedByPath.has(e.originalPath)) e.stagingPath = stagedByPath.get(e.originalPath);
});
}
+43
View File
@@ -0,0 +1,43 @@
export function initKeyboardShortcuts({
btnOpen,
btnRename,
btnPreviewToggle,
deleteSelected,
performUndo,
performRedo,
refreshSelection,
selectAll,
}) {
document.addEventListener('keydown', (e) => {
if (hasOpenModal()) return;
if (isEditableTarget(e.target)) return;
if (e.code === 'KeyO') {
btnOpen.click();
} else if (e.code === 'KeyS' && !btnRename.disabled) {
btnRename.click();
} else if (e.code === 'KeyA') {
selectAll();
refreshSelection();
} else if (e.metaKey && !e.shiftKey && e.code === 'KeyZ') {
e.preventDefault();
performUndo();
} else if (e.metaKey && e.shiftKey && e.code === 'KeyZ') {
e.preventDefault();
performRedo();
} else if (e.code === 'KeyP') {
btnPreviewToggle.click();
} else if (e.code === 'Backspace' || e.code === 'Delete') {
deleteSelected();
}
});
}
function isEditableTarget(target) {
return ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || target.isContentEditable;
}
function hasOpenModal() {
return [...document.querySelectorAll('.modal-overlay')]
.some(modal => modal.style.display === 'flex');
}
+3 -6
View File
@@ -3,6 +3,7 @@ import { showLoading, hideLoading, setStatus, showToast } from './ui.js';
import { renderGrid, updateSelCount, showEmptyGrid } from './grid.js';
import * as history from './history.js';
import { openModal, closeModal } from './modal.js';
import { buildSequentialName, getNameFormat } from './utils/file-names.js';
const btnRename = document.getElementById('btn-rename');
const moveModal = document.getElementById('move-modal');
@@ -22,15 +23,11 @@ moveModal.addEventListener('modal:closed', () => {
moveConfirmBtn.addEventListener('click', async () => {
if (!_pendingMoveTarget || !_pendingMoveFiles) return;
const prefix = movePrefixInput.value.trim() || 'photo';
const digits = Math.max(1, Math.min(6, parseInt(moveDigitsInput.value) || 4));
const format = getNameFormat(movePrefixInput, moveDigitsInput, 4);
const targetPath = _pendingMoveTarget;
const pendingFiles = _pendingMoveFiles;
const newNames = pendingFiles.map((f, i) => {
const ext = f.name.split('.').pop().toLowerCase();
return `${prefix}_${String(i + 1).padStart(digits, '0')}.${ext}`;
});
const newNames = pendingFiles.map((f, i) => buildSequentialName(f.name, i, format));
const conflicts = await window.api.checkFilesExist(targetPath, newNames);
if (conflicts.length) {
+9 -20
View File
@@ -3,6 +3,8 @@ import { showLoading, hideLoading, setStatus, showToast } from './ui.js';
import { renderGrid } from './grid.js';
import * as history from './history.js';
import { openModal, closeModal } from './modal.js';
import { renderTextList } from './utils/dom.js';
import { buildSequentialName, getNameFormat } from './utils/file-names.js';
const inputPrefix = document.getElementById('input-prefix');
const inputDigits = document.getElementById('input-digits');
@@ -28,8 +30,8 @@ renameConfirmModal.addEventListener('modal:closed', () => {
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 { prefix, digits } = getNameFormat(inputPrefix, inputDigits);
const format = { prefix, digits };
const selectedArr = [...state.selectedIdxs].sort((a, b) => a - b);
const filesToRename = selectedArr.length > 0
@@ -43,26 +45,17 @@ export async function doRename() {
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 newNames = filesToRename.map((f, i) => buildSequentialName(f.name, i, format));
const conflicting = newNames.filter(n => unselectedNames.has(n));
if (conflicting.length) {
renameConflictList.replaceChildren(...conflicting.map(n => {
const li = document.createElement('li');
li.textContent = n;
return li;
}));
renderTextList(renameConflictList, conflicting);
openModal(renameConflictModal);
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}`;
});
const examples = filesToRename.slice(0, 3)
.map((f, i) => `${f.name}${buildSequentialName(f.name, i, format)}`);
const more = filesToRename.length > 3 ? `... 외 ${filesToRename.length - 3}` : '';
const confirmed = await showRenameConfirm({
@@ -111,11 +104,7 @@ function showRenameConfirm({ count, examples, more, isPartial }) {
const items = [...examples];
if (more) items.push(more);
renameConfirmList.replaceChildren(...items.map(text => {
const li = document.createElement('li');
li.textContent = text;
return li;
}));
renderTextList(renameConfirmList, items);
openModal(renameConfirmModal);
+42 -178
View File
@@ -1,13 +1,16 @@
import { state } from './state.js';
import { showLoading, hideLoading, setStatus, showToast } from './ui.js';
import * as history from './history.js';
import {
renderGrid, updateSelCount, updateNumberBadges,
updateNewNameLabels, refreshCardSelection, showEmptyGrid,
updateSelCount, updateNumberBadges,
updateNewNameLabels, refreshCardSelection,
} from './grid.js';
import { doRename } from './rename.js';
import { handleFolderDrop } from './move.js';
import { initModalControls } from './modal.js';
import { clampDigits, getExtension } from './utils/file-names.js';
import { initKeyboardShortcuts } from './keyboard.js';
import { createFolderController } from './folder-controller.js';
import { createHistoryController } from './history-controller.js';
import * as deleteHandler from './delete.js';
import * as pinchZoom from './handlers/pinch-zoom.js';
import * as selection from './handlers/selection.js';
@@ -15,8 +18,6 @@ import * as dragDrop from './handlers/drag-drop.js';
import * as hoverPreview from './handlers/image-preview.js';
import * as fileExplorer from './handlers/file-explorer.js';
let _homeDir = null;
// ── DOM ──────────────────────────────────────────────
const btnOpen = document.getElementById('btn-open');
const btnSortName = document.getElementById('btn-sort-name');
@@ -36,8 +37,23 @@ const btnUndo = document.getElementById('btn-undo');
const btnRedo = document.getElementById('btn-redo');
const fileExplorerContainer = document.querySelector('.file-explorer');
const folderController = createFolderController({
btnRename,
btnSortName,
btnSortDate,
dirPath,
inputPrefix,
updatePreview,
});
const historyController = createHistoryController({
btnRename,
btnUndo,
btnRedo,
updateSortButtons: folderController.updateSortButtons,
});
// ── 핸들러 초기화 ─────────────────────────────────
history.setOnUpdate(updateHistoryButtons);
history.setOnUpdate(historyController.updateButtons);
initModalControls();
const callbacks = {
@@ -50,7 +66,7 @@ selection.init({ gridContainer, rubberBand, grid, state, callbacks });
dragDrop.init({ grid, gridContainer, dragGhostEl, state, callbacks, fileExplorerEl: fileExplorerContainer, onFolderDrop: handleFolderDrop });
hoverPreview.init();
deleteHandler.init(grid);
fileExplorer.init(fileExplorerContainer, loadFolder);
fileExplorer.init(fileExplorerContainer, folderController.loadFolder);
window.api.onFullscreenChange((isFullscreen) => {
document.body.classList.toggle('fullscreen', isFullscreen);
@@ -63,202 +79,50 @@ window.api.onFullscreenChange((isFullscreen) => {
window.api.getHomeDir(),
window.api.getPreviewOn(),
]);
_homeDir = homeDir;
folderController.setHomeDir(homeDir);
btnPreviewToggle.classList.toggle('active', previewOn);
hoverPreview.setEnabled(previewOn);
const target = lastFolder || homeDir;
await fileExplorer.loadTree(homeDir, target);
await loadFolder(target);
await folderController.loadFolder(target);
})();
// ── 버튼 / 키보드 이벤트 ─────────────────────────
inputPrefix.addEventListener('input', updatePreview);
inputDigits.addEventListener('input', updatePreview);
btnOpen.addEventListener('click', openFolder);
btnSortName.addEventListener('click', () => sortFiles('name'));
btnSortDate.addEventListener('click', () => sortFiles('date'));
btnOpen.addEventListener('click', folderController.openFolder);
btnSortName.addEventListener('click', () => folderController.sortFiles('name'));
btnSortDate.addEventListener('click', () => folderController.sortFiles('date'));
btnRename.addEventListener('click', doRename);
btnUndo.addEventListener('click', performUndo);
btnRedo.addEventListener('click', performRedo);
btnUndo.addEventListener('click', historyController.performUndo);
btnRedo.addEventListener('click', historyController.performRedo);
btnPreviewToggle.addEventListener('click', () => {
const on = btnPreviewToggle.classList.toggle('active');
hoverPreview.setEnabled(on);
window.api.setPreviewOn(on);
});
document.addEventListener('keydown', (e) => {
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));
initKeyboardShortcuts({
btnOpen,
btnRename,
btnPreviewToggle,
deleteSelected: deleteHandler.deleteSelected,
performUndo: historyController.performUndo,
performRedo: historyController.performRedo,
refreshSelection: () => {
refreshCardSelection();
updateSelCount();
}
if (e.metaKey && !e.shiftKey && e.code === 'KeyZ') { e.preventDefault(); performUndo(); }
if (e.metaKey && e.shiftKey && e.code === 'KeyZ') { e.preventDefault(); performRedo(); }
if (e.code === 'KeyP') btnPreviewToggle.click();
if (e.code === 'Backspace' || e.code === 'Delete') deleteHandler.deleteSelected();
},
selectAll: () => state.files.forEach((_, i) => state.selectedIdxs.add(i)),
});
// ── 폴더 열기 (다이얼로그) ───────────────────────
async function openFolder() {
const folder = await window.api.selectFolder();
if (!folder) return;
const homeDir = _homeDir;
if (folder.startsWith(homeDir)) {
await fileExplorer.loadTree(homeDir, folder);
} else {
await fileExplorer.loadTree(folder, folder);
}
await loadFolder(folder);
}
// ── 폴더 로드 (트리 클릭 또는 다이얼로그 공통) ──
async function loadFolder(folder) {
if (!folder) return;
state.currentFolder = folder;
dirPath.textContent = folder;
await window.api.setLastFolder(folder);
await fileExplorer.setSelectedPath(folder);
showLoading('이미지 목록 불러오는 중...');
const list = await window.api.listImages(folder);
hideLoading();
if (!list.length) {
state.files.length = 0;
state.selectedIdxs.clear();
showEmptyGrid();
btnRename.disabled = true;
setStatus('이미지 파일 없음', false);
return;
}
state.files.length = 0;
state.files.push(...list);
state.selectedIdxs.clear();
state.sortMode = 'name';
updateSortButtons();
setStatus(`${state.files.length}개 파일 로드됨`, true);
const folderName = folder.replace(/\\/g, '/').split('/').pop();
if (folderName) inputPrefix.value = folderName;
renderGrid();
updatePreview();
btnRename.disabled = false;
history.reset();
history.pushReorder(state.files);
}
// ── 정렬 ─────────────────────────────────────────
async function sortFiles(mode) {
if (!state.files.length) return;
if (mode === 'name') state.files.sort((a, b) => a.name.localeCompare(b.name, 'ko'));
else state.files.sort((a, b) => a.mtime - b.mtime);
state.sortMode = mode;
state.selectedIdxs.clear();
updateSortButtons();
await renderGrid();
setStatus(`${mode === 'name' ? '이름순' : '날짜순'}으로 정렬됨`, true);
history.pushReorder(state.files);
}
function updateSortButtons() {
btnSortName.classList.toggle('active', state.sortMode === 'name');
btnSortDate.classList.toggle('active', state.sortMode === 'date');
}
// ── Undo / Redo ───────────────────────────────────
let _historyBusy = false;
function updateHistoryButtons() {
btnUndo.disabled = !history.canUndo();
btnRedo.disabled = !history.canRedo();
}
async function performUndo() {
if (_historyBusy || !history.canUndo()) {
if (!history.canUndo()) showToast('더 이상 되돌릴 수 없습니다');
return;
}
_historyBusy = true;
try {
const { entry, targetFiles } = history.undo();
if (entry.type === 'delete') {
const { restored, errors } = await window.api.restoreStagedFiles(entry.deletedEntries);
if (errors.length) showToast('일부 파일 복원 실패: ' + errors[0]);
_applyHistorySnapshot(targetFiles, new Set(restored));
if (restored.length) setStatus(`${restored.length}개 파일 복원됨`, true);
} else {
_applyHistorySnapshot(targetFiles, entry.affectedPaths);
}
} finally {
_historyBusy = false;
}
}
async function performRedo() {
if (_historyBusy || !history.canRedo()) {
if (!history.canRedo()) showToast('마지막 작업 상태입니다');
return;
}
_historyBusy = true;
try {
const nextEntry = history.peekRedo();
if (nextEntry.type === 'delete') {
const pathsToRedo = nextEntry.deletedEntries.map(e => e.originalPath);
const { staged, errors } = await window.api.stageDeleteFiles(pathsToRedo);
if (errors.length) showToast('일부 파일 삭제 실패: ' + errors[0]);
const stagedByPath = new Map(staged.map(s => [s.originalPath, s.stagingPath]));
nextEntry.deletedEntries.forEach(e => {
if (stagedByPath.has(e.originalPath)) e.stagingPath = stagedByPath.get(e.originalPath);
});
const { targetFiles } = history.redo();
_applyHistorySnapshot(targetFiles, null);
if (staged.length) setStatus(`${staged.length}개 파일 삭제됨`, true);
} else {
const { entry, targetFiles } = history.redo();
_applyHistorySnapshot(targetFiles, entry.affectedPaths);
}
} finally {
_historyBusy = false;
}
}
function _applyHistorySnapshot(targetFiles, affectedPaths = null) {
state.files.length = 0;
state.files.push(...targetFiles);
state.selectedIdxs.clear();
if (affectedPaths && affectedPaths.size > 0) {
const pathToIdx = new Map(state.files.map((f, i) => [f.path, i]));
affectedPaths.forEach(p => {
const idx = pathToIdx.get(p);
if (idx !== undefined) state.selectedIdxs.add(idx);
});
}
state.sortMode = null;
updateSortButtons();
if (!state.files.length) {
showEmptyGrid();
btnRename.disabled = true;
} else {
renderGrid();
btnRename.disabled = false;
}
updateHistoryButtons();
}
// ── 포맷 미리보기 ─────────────────────────────────
function updatePreview() {
const prefix = inputPrefix.value.trim() || 'photo';
const digits = Math.max(1, Math.min(6, parseInt(inputDigits.value) || 3));
const ext = state.files.length ? state.files[0].name.split('.').pop().toLowerCase() : 'jpg';
const digits = clampDigits(inputDigits.value);
const ext = state.files.length ? getExtension(state.files[0].name) : 'jpg';
previewName.textContent = `${prefix}_${'1'.padStart(digits, '0')}.${ext}`;
updateNewNameLabels();
}
+15 -8
View File
@@ -11,6 +11,13 @@
--warning: #f7c26a;
--danger: #ff6b7a;
--info: #6ac8f7;
--success-bg: #1a4a2e;
--success-bg-hover: #204a34;
--success-border: #2a6a40;
--warning-bg: rgba(247,194,106,.12);
--warning-border: rgba(247,194,106,.35);
--danger-bg: rgba(255,107,122,.12);
--danger-border: rgba(255,107,122,.35);
--text: #e8e8f0;
--muted: #8f8fb8;
--drag-over: #3a2e70;
@@ -155,8 +162,8 @@ main {
}
.sort-btn:hover { background: #132e42; color: var(--info); }
.sort-btn.active { background: #1a3a4a; color: var(--info); }
.btn-success { background: #1a4a2e; color: var(--success); border: 1px solid #2a6a40; }
.btn-success:hover { background: #204a34; }
.btn-success { background: var(--success-bg); color: var(--success); border: 1px solid var(--success-border); }
.btn-success:hover { background: var(--success-bg-hover); }
.btn-success:disabled { opacity: .4; cursor: not-allowed; transform: none; }
.btn-warning { background: #3a2a10; color: var(--warning); border: 1px solid #5a4020; }
.btn-warning:hover { background: #4a3518; }
@@ -551,13 +558,13 @@ footer span { font-size: 11px; color: var(--muted); }
}
.modal-titlebar-warning {
color: var(--warning);
background: rgba(247,194,106,.12);
border-bottom-color: rgba(247,194,106,.35);
background: var(--warning-bg);
border-bottom-color: var(--warning-border);
}
.modal-titlebar-danger {
color: var(--danger);
background: rgba(255,107,122,.12);
border-bottom-color: rgba(255,107,122,.35);
background: var(--danger-bg);
border-bottom-color: var(--danger-border);
}
.modal-msg {
font-size: 14px; color: var(--text); line-height: 1.7;
@@ -567,8 +574,8 @@ footer span { font-size: 11px; color: var(--muted); }
font-weight: 700;
line-height: 1.5;
color: var(--warning);
background: rgba(247,194,106,.12);
border: 1px solid rgba(247,194,106,.35);
background: var(--warning-bg);
border: 1px solid var(--warning-border);
border-radius: 8px;
padding: 9px 12px;
}
+9
View File
@@ -0,0 +1,9 @@
export function renderTextList(listEl, items) {
listEl.replaceChildren(...items.map(createTextListItem));
}
function createTextListItem(text) {
const li = document.createElement('li');
li.textContent = text;
return li;
}
+20
View File
@@ -0,0 +1,20 @@
export function getExtension(fileName, fallback = 'jpg') {
const parts = fileName.split('.');
return parts.length > 1 ? parts.pop().toLowerCase() : fallback;
}
export function clampDigits(value, fallback = 3) {
return Math.max(1, Math.min(6, parseInt(value, 10) || fallback));
}
export function buildSequentialName(fileName, index, { prefix, digits }) {
const ext = getExtension(fileName);
return `${prefix}_${String(index + 1).padStart(digits, '0')}.${ext}`;
}
export function getNameFormat(prefixInput, digitsInput, digitsFallback = 3) {
return {
prefix: prefixInput.value.trim() || 'photo',
digits: clampDigits(digitsInput.value, digitsFallback),
};
}