From 0f72f4a0f04c3634d340a055057ab7a73d13cf90 Mon Sep 17 00:00:00 2001 From: jisangs Date: Sun, 17 May 2026 22:18:01 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=AC=ED=8E=99=ED=86=A0=EB=A7=81=20-=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20(Codex)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/ipc/file-utils.js | 59 ++++++ src/main/ipc/files.js | 71 ++----- src/renderer/delete.js | 13 +- src/renderer/folder-controller.js | 91 +++++++++ src/renderer/grid.js | 7 +- src/renderer/handlers/drag-drop.js | 93 ++------- src/renderer/handlers/drag/auto-scroll.js | 51 +++++ src/renderer/handlers/drag/ghost.js | 26 +++ src/renderer/history-controller.js | 100 ++++++++++ src/renderer/keyboard.js | 43 +++++ src/renderer/move.js | 9 +- src/renderer/rename.js | 29 +-- src/renderer/renderer.js | 220 +++++----------------- src/renderer/style.css | 23 ++- src/renderer/utils/dom.js | 9 + src/renderer/utils/file-names.js | 20 ++ 16 files changed, 508 insertions(+), 356 deletions(-) create mode 100644 src/main/ipc/file-utils.js create mode 100644 src/renderer/folder-controller.js create mode 100644 src/renderer/handlers/drag/auto-scroll.js create mode 100644 src/renderer/handlers/drag/ghost.js create mode 100644 src/renderer/history-controller.js create mode 100644 src/renderer/keyboard.js create mode 100644 src/renderer/utils/dom.js create mode 100644 src/renderer/utils/file-names.js diff --git a/src/main/ipc/file-utils.js b/src/main/ipc/file-utils.js new file mode 100644 index 0000000..e8af8f5 --- /dev/null +++ b/src/main/ipc/file-utils.js @@ -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, +}; diff --git a/src/main/ipc/files.js b/src/main/ipc/files.js index d99f3ab..be41bfc 100644 --- a/src/main/ipc/files.js +++ b/src/main/ipc/files.js @@ -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,20 +37,10 @@ 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}`); - } + 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 }; @@ -161,4 +122,4 @@ function register() { }); } -module.exports = { register }; \ No newline at end of file +module.exports = { register }; diff --git a/src/renderer/delete.js b/src/renderer/delete.js index 314deb3..2726fa9 100644 --- a/src/renderer/delete.js +++ b/src/renderer/delete.js @@ -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); }); diff --git a/src/renderer/folder-controller.js b/src/renderer/folder-controller.js new file mode 100644 index 0000000..28283b7 --- /dev/null +++ b/src/renderer/folder-controller.js @@ -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, + }; +} diff --git a/src/renderer/grid.js b/src/renderer/grid.js index e7b84df..53508e0 100644 --- a/src/renderer/grid.js +++ b/src/renderer/grid.js @@ -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)}`; }); } diff --git a/src/renderer/handlers/drag-drop.js b/src/renderer/handlers/drag-drop.js index c71334e..3d6e831 100644 --- a/src/renderer/handlers/drag-drop.js +++ b/src/renderer/handlers/drag-drop.js @@ -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}"]`); } diff --git a/src/renderer/handlers/drag/auto-scroll.js b/src/renderer/handlers/drag/auto-scroll.js new file mode 100644 index 0000000..3e5c0c4 --- /dev/null +++ b/src/renderer/handlers/drag/auto-scroll.js @@ -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 }; +} diff --git a/src/renderer/handlers/drag/ghost.js b/src/renderer/handlers/drag/ghost.js new file mode 100644 index 0000000..26753be --- /dev/null +++ b/src/renderer/handlers/drag/ghost.js @@ -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 }; +} diff --git a/src/renderer/history-controller.js b/src/renderer/history-controller.js new file mode 100644 index 0000000..2f59b59 --- /dev/null +++ b/src/renderer/history-controller.js @@ -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); + }); +} diff --git a/src/renderer/keyboard.js b/src/renderer/keyboard.js new file mode 100644 index 0000000..10811f0 --- /dev/null +++ b/src/renderer/keyboard.js @@ -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'); +} diff --git a/src/renderer/move.js b/src/renderer/move.js index 78804ad..93bf201 100644 --- a/src/renderer/move.js +++ b/src/renderer/move.js @@ -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) { diff --git a/src/renderer/rename.js b/src/renderer/rename.js index 1f3344e..a559643 100644 --- a/src/renderer/rename.js +++ b/src/renderer/rename.js @@ -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); diff --git a/src/renderer/renderer.js b/src/renderer/renderer.js index 4796a3e..e62c0e9 100644 --- a/src/renderer/renderer.js +++ b/src/renderer/renderer.js @@ -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(); } diff --git a/src/renderer/style.css b/src/renderer/style.css index 1956377..684a288 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -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; } diff --git a/src/renderer/utils/dom.js b/src/renderer/utils/dom.js new file mode 100644 index 0000000..203a49d --- /dev/null +++ b/src/renderer/utils/dom.js @@ -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; +} diff --git a/src/renderer/utils/file-names.js b/src/renderer/utils/file-names.js new file mode 100644 index 0000000..4458a7a --- /dev/null +++ b/src/renderer/utils/file-names.js @@ -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), + }; +}