diff --git a/CLAUDE.md b/CLAUDE.md index 5a25de4..d256c64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,8 +95,9 @@ renderer.js → rename.js, move.js → grid.js → handlers/* - `O` — 폴더 열기 - `S` — 이름 변경 실행 - `A` — 전체 선택 -- `U` — Undo (되돌리기) -- `R` — Redo (다시하기) +- `Cmd+Z` — Undo (되돌리기) +- `Cmd+Shift+Z` — Redo (다시하기) +- `P` — 미리보기 on/off 토글 **Undo / Redo** (`history.js`): - 파일 순서 변경(드래그, 정렬)만 추적; 파일시스템 변경(rename, move)은 히스토리 리셋 diff --git a/src/main/ipc/files.js b/src/main/ipc/files.js index 035d6e5..d99f3ab 100644 --- a/src/main/ipc/files.js +++ b/src/main/ipc/files.js @@ -1,4 +1,4 @@ -const { ipcMain } = require('electron'); +const { ipcMain, app } = require('electron'); const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); @@ -73,6 +73,59 @@ function register() { return { moved, errors }; }); + // 삭제 → 스테이징 폴더로 이동 (undo 가능) + ipcMain.handle('stage-delete-files', async (_event, filePaths) => { + const batchDir = path.join(app.getPath('userData'), 'deleted_staging', Date.now().toString()); + fs.mkdirSync(batchDir, { recursive: true }); + + const staged = []; + const errors = []; + for (const fp of filePaths) { + const name = path.basename(fp); + const dest = path.join(batchDir, name); + try { + fs.renameSync(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}`); } + } + } + if (!staged.length) try { fs.rmdirSync(batchDir); } catch {} + return { staged, errors }; + }); + + // 스테이징 파일 원래 위치로 복원 (undo delete) + ipcMain.handle('restore-staged-files', async (_event, deletedEntries) => { + const restored = []; + const errors = []; + for (const { name, originalPath, stagingPath } of deletedEntries) { + if (fs.existsSync(originalPath)) { + errors.push(`${name}: 같은 이름의 파일이 이미 존재합니다`); + continue; + } + try { + fs.mkdirSync(path.dirname(originalPath), { recursive: true }); + fs.renameSync(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}`); } + } + } + return { restored, errors }; + }); + ipcMain.handle('rename-files', async (_event, { folderPath, files, prefix, digits }) => { const errors = []; const pad = (n) => String(n + 1).padStart(digits, '0'); diff --git a/src/preload/index.js b/src/preload/index.js index ce723b6..bc47d24 100644 --- a/src/preload/index.js +++ b/src/preload/index.js @@ -19,4 +19,6 @@ contextBridge.exposeInMainWorld('api', { setPreviewOn: (on) => ipcRenderer.invoke('set-preview-on', on), checkFilesExist: (folder, names) => ipcRenderer.invoke('check-files-exist', folder, names), moveFiles: (args) => ipcRenderer.invoke('move-files', args), + stageDeleteFiles: (paths) => ipcRenderer.invoke('stage-delete-files', paths), + restoreStagedFiles: (entries) => ipcRenderer.invoke('restore-staged-files', entries), }); diff --git a/src/renderer/delete.js b/src/renderer/delete.js new file mode 100644 index 0000000..6a0ad99 --- /dev/null +++ b/src/renderer/delete.js @@ -0,0 +1,95 @@ +import { state } from './state.js'; +import { setStatus, showToast } from './ui.js'; +import { renderGrid, showEmptyGrid } from './grid.js'; +import * as history from './history.js'; + +const deleteModal = document.getElementById('delete-modal'); +const deleteFileList = document.getElementById('delete-file-list'); +const deleteCancelBtn = document.getElementById('delete-cancel'); +const deleteConfirmBtn = document.getElementById('delete-confirm'); +const btnRename = document.getElementById('btn-rename'); + +let _pendingFiles = null; + +deleteCancelBtn.addEventListener('click', () => { + deleteModal.style.display = 'none'; + _pendingFiles = null; +}); + +deleteConfirmBtn.addEventListener('click', async () => { + if (!_pendingFiles) return; + const files = _pendingFiles; + _pendingFiles = null; + deleteModal.style.display = 'none'; + await _doDelete(files); +}); + +export function deleteSelected() { + if (!state.selectedIdxs.size) return; + 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; + })); + _pendingFiles = filesToDelete; + deleteModal.style.display = 'flex'; +} + +export function init(gridEl) { + gridEl.addEventListener('click', (e) => { + const btn = e.target.closest('.card-delete'); + if (!btn) return; + + const card = btn.closest('.card'); + if (!card) return; + const idx = parseInt(card.dataset.idx); + + let filesToDelete; + if (state.selectedIdxs.size > 0 && state.selectedIdxs.has(idx)) { + filesToDelete = [...state.selectedIdxs].sort((a, b) => a - b) + .map(i => state.files[i]).filter(Boolean); + } else { + filesToDelete = [state.files[idx]].filter(Boolean); + } + + if (!filesToDelete.length) return; + + deleteFileList.replaceChildren(...filesToDelete.map(f => { + const li = document.createElement('li'); + li.textContent = f.name; + return li; + })); + _pendingFiles = filesToDelete; + deleteModal.style.display = 'flex'; + }); +} + +async function _doDelete(files) { + const result = await window.api.stageDeleteFiles(files.map(f => f.path)); + + if (result.errors.length) { + alert('일부 오류:\n' + result.errors.join('\n')); + } + + const stagedSet = new Set(result.staged.map(s => s.originalPath)); + for (let i = state.files.length - 1; i >= 0; i--) { + if (stagedSet.has(state.files[i].path)) state.files.splice(i, 1); + } + state.selectedIdxs.clear(); + + history.pushDelete([...state.files], result.staged); + + const n = result.staged.length; + if (!state.files.length) { + showEmptyGrid(); + btnRename.disabled = true; + setStatus('이미지 파일 없음', false); + } else { + renderGrid(); + setStatus(`${n}개 파일 삭제됨`, true); + } + if (n > 0) showToast(`${n}개 파일 삭제됨`, '#8b1a1a', '#ffd0d0'); +} diff --git a/src/renderer/grid.js b/src/renderer/grid.js index c99cc4f..e7b84df 100644 --- a/src/renderer/grid.js +++ b/src/renderer/grid.js @@ -59,6 +59,12 @@ export function createCard(file, idx) { checkBadge.className = 'card-check'; checkBadge.textContent = '✓'; + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'card-delete'; + deleteBtn.title = '삭제'; + deleteBtn.textContent = '×'; + deleteBtn.draggable = false; + // src 없이 생성 — IntersectionObserver가 뷰포트 진입 시 로딩 const thumb = document.createElement('img'); thumb.className = 'card-thumb'; @@ -78,6 +84,7 @@ export function createCard(file, idx) { body.appendChild(newNameEl); card.appendChild(numBadge); card.appendChild(checkBadge); + card.appendChild(deleteBtn); card.appendChild(thumb); card.appendChild(body); diff --git a/src/renderer/handlers/selection.js b/src/renderer/handlers/selection.js index d1cafb2..a6d85ab 100644 --- a/src/renderer/handlers/selection.js +++ b/src/renderer/handlers/selection.js @@ -27,6 +27,7 @@ export function init({ gridContainer, rubberBand, grid, state, callbacks }) { // 카드에 부착할 클릭 핸들러 (this = card element) export function onCardClick(e) { + if (e.target.closest('.card-delete')) return; e.stopPropagation(); const idx = parseInt(this.dataset.idx); const isCtrl = e.ctrlKey || e.metaKey; diff --git a/src/renderer/history.js b/src/renderer/history.js index 136de88..a0b3e57 100644 --- a/src/renderer/history.js +++ b/src/renderer/history.js @@ -1,35 +1,62 @@ +// History entries +// reorder: { type:'reorder', files:[...], affectedPaths: Set|null } +// delete: { type:'delete', files:[...] /* filesAfter */, +// deletedEntries:[{name, originalPath, stagingPath}] /* mutable */ } + const MAX = 50; let _stack = []; let _cursor = -1; let _onUpdate = null; -export function setOnUpdate(fn) { - _onUpdate = fn; +export function setOnUpdate(fn) { _onUpdate = fn; } + +// ── Push ───────────────────────────────────────── +export function pushReorder(files, affectedPaths = null) { + _truncate(); + _stack.push({ + type: 'reorder', + files: [...files], + affectedPaths: affectedPaths ? new Set(affectedPaths) : null, + }); + _finalize(); } -export function push(files, affectedPaths = null) { - _stack = _stack.slice(0, _cursor + 1); - _stack.push({ files: [...files], affectedPaths: affectedPaths ? new Set(affectedPaths) : null }); +export function pushDelete(filesAfter, deletedEntries) { + _truncate(); + _stack.push({ type: 'delete', files: [...filesAfter], deletedEntries }); + _finalize(); +} + +function _truncate() { _stack = _stack.slice(0, _cursor + 1); } +function _finalize() { if (_stack.length > MAX) _stack.shift(); _cursor = _stack.length - 1; _onUpdate?.(); } -// Returns { files, affectedPaths } where affectedPaths are from the entry being undone +// ── Undo / Redo ─────────────────────────────────── +// Returns { entry, targetFiles } +// entry — the entry being undone/redone (for type-specific side-effects) +// targetFiles — the files[] to restore in state export function undo() { if (!canUndo()) return null; - const affectedPaths = _stack[_cursor].affectedPaths; + const entry = _stack[_cursor]; // operation being undone --_cursor; _onUpdate?.(); - return { files: [..._stack[_cursor].files], affectedPaths }; + return { entry, targetFiles: [..._stack[_cursor].files] }; } -// Returns { files, affectedPaths } from the entry being redone export function redo() { if (!canRedo()) return null; ++_cursor; _onUpdate?.(); - return { files: [..._stack[_cursor].files], affectedPaths: _stack[_cursor].affectedPaths }; + const entry = _stack[_cursor]; + return { entry, targetFiles: [...entry.files] }; +} + +// Peek at the next redo entry without moving cursor (for pre-processing) +export function peekRedo() { + return canRedo() ? _stack[_cursor + 1] : null; } export function canUndo() { return _cursor > 0; } diff --git a/src/renderer/index.html b/src/renderer/index.html index 8dcdd94..96ed881 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -102,6 +102,17 @@ +
+