파일 삭제 기능 추가, undo / redo 기능에 적용
This commit is contained in:
@@ -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
@@ -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');
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
+63
-18
@@ -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();
|
||||
if (!state.files.length) {
|
||||
showEmptyGrid();
|
||||
btnRename.disabled = true;
|
||||
} else {
|
||||
renderGrid();
|
||||
btnRename.disabled = false;
|
||||
}
|
||||
updateHistoryButtons();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user