파일 삭제 기능 추가, undo / redo 기능에 적용

This commit is contained in:
2026-05-15 23:32:29 +09:00
parent 1e88859484
commit 999cc9f3fb
12 changed files with 289 additions and 34 deletions
+3 -2
View File
@@ -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)은 히스토리 리셋
+54 -1
View File
@@ -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');
+2
View File
@@ -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),
});
+95
View File
@@ -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');
}
+7
View File
@@ -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);
+1
View File
@@ -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;
+37 -10
View File
@@ -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; }
+11
View File
@@ -102,6 +102,17 @@
</div>
</div>
<div id="delete-modal" class="modal-overlay" style="display:none;">
<div class="modal-box">
<p class="modal-msg">아래 파일을 삭제합니다.</p>
<ul id="delete-file-list" class="modal-conflict-list"></ul>
<div class="modal-actions">
<button id="delete-cancel" class="btn btn-ghost">취소</button>
<button id="delete-confirm" class="btn btn-warning">확인</button>
</div>
</div>
</div>
<div id="move-error-modal" class="modal-overlay" style="display:none;">
<div class="modal-box">
<p class="modal-msg">새로운 이름의 파일도 존재합니다.</p>
+1 -1
View File
@@ -86,7 +86,7 @@ async function _doMoveFiles(targetPath, files) {
state.files.push(...newList);
state.selectedIdxs.clear();
history.reset();
history.push(state.files);
history.pushReorder(state.files);
if (!newList.length) {
showEmptyGrid();
btnRename.disabled = true;
+1 -1
View File
@@ -77,7 +77,7 @@ export async function doRename() {
state.selectedIdxs.clear();
renderGrid();
history.reset();
history.push(state.files);
history.pushReorder(state.files);
} catch (e) {
hideLoading();
alert('오류 발생: ' + e.message);
+64 -19
View File
@@ -7,6 +7,7 @@ import {
} from './grid.js';
import { doRename } from './rename.js';
import { handleFolderDrop } from './move.js';
import * as deleteHandler from './delete.js';
import * as pinchZoom from './handlers/pinch-zoom.js';
import * as selection from './handlers/selection.js';
import * as dragDrop from './handlers/drag-drop.js';
@@ -39,13 +40,14 @@ history.setOnUpdate(updateHistoryButtons);
const callbacks = {
refreshCardSelection, updateSelCount, updateNumberBadges, updateNewNameLabels,
pushHistory: (affectedPaths) => history.push(state.files, affectedPaths),
pushHistory: (affectedPaths) => history.pushReorder(state.files, affectedPaths),
};
pinchZoom.init({ gridContainer, thumbSizeHint });
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);
window.api.onFullscreenChange((isFullscreen) => {
@@ -93,8 +95,10 @@ document.addEventListener('keydown', (e) => {
refreshCardSelection();
updateSelCount();
}
if (e.code === 'KeyU') performUndo();
if (e.code === 'KeyR') performRedo();
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();
});
// ── 폴더 열기 (다이얼로그) ───────────────────────
@@ -145,7 +149,7 @@ async function loadFolder(folder) {
updatePreview();
btnRename.disabled = false;
history.reset();
history.push(state.files);
history.pushReorder(state.files);
}
// ── 정렬 ─────────────────────────────────────────
@@ -158,7 +162,7 @@ async function sortFiles(mode) {
updateSortButtons();
await renderGrid();
setStatus(`${mode === 'name' ? '이름순' : '날짜순'}으로 정렬됨`, true);
history.push(state.files);
history.pushReorder(state.files);
}
function updateSortButtons() {
@@ -167,43 +171,84 @@ function updateSortButtons() {
}
// ── Undo / Redo ───────────────────────────────────
let _historyBusy = false;
function updateHistoryButtons() {
btnUndo.disabled = !history.canUndo();
btnRedo.disabled = !history.canRedo();
}
function performUndo() {
if (!history.canUndo()) {
showToast('더 이상 되돌릴 수 없습니다');
async function performUndo() {
if (_historyBusy || !history.canUndo()) {
if (!history.canUndo()) showToast('더 이상 되돌릴 수 없습니다');
return;
}
_applyHistorySnapshot(history.undo());
_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;
}
}
function performRedo() {
if (!history.canRedo()) {
showToast('마지막 작업 상태입니다');
async function performRedo() {
if (_historyBusy || !history.canRedo()) {
if (!history.canRedo()) showToast('마지막 작업 상태입니다');
return;
}
_applyHistorySnapshot(history.redo());
_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({ files, affectedPaths }) {
function _applyHistorySnapshot(targetFiles, affectedPaths = null) {
state.files.length = 0;
state.files.push(...files);
state.files.push(...targetFiles);
state.selectedIdxs.clear();
if (affectedPaths) {
if (affectedPaths && affectedPaths.size > 0) {
const pathToIdx = new Map(state.files.map((f, i) => [f.path, i]));
affectedPaths.forEach(path => {
const idx = pathToIdx.get(path);
affectedPaths.forEach(p => {
const idx = pathToIdx.get(p);
if (idx !== undefined) state.selectedIdxs.add(idx);
});
}
state.sortMode = null;
updateSortButtons();
renderGrid();
if (!state.files.length) {
showEmptyGrid();
btnRename.disabled = true;
} else {
renderGrid();
btnRename.disabled = false;
}
updateHistoryButtons();
}
+13
View File
@@ -307,6 +307,19 @@ main {
}
.card.selected .card-check { opacity: 1; }
.card-delete {
position: absolute; top: 8px; right: 8px;
width: 22px; height: 22px; border-radius: 50%; border: none;
background: rgba(200, 40, 40, 0.88); color: #fff;
font-size: 16px; line-height: 1;
display: flex; align-items: center; justify-content: center;
cursor: pointer; padding: 0;
opacity: 0; transition: opacity .15s, transform .1s;
z-index: 3;
}
.card:hover .card-delete { opacity: 1; }
.card-delete:hover { background: rgb(210, 30, 30); transform: scale(1.12); }
.card-name {
font-size: 11px; color: var(--muted);
font-family: 'JetBrains Mono', monospace;