From a1df4244854050a3ab4dc3714c0998c16660f493 Mon Sep 17 00:00:00 2001 From: jisangs Date: Fri, 15 May 2026 22:24:25 +0900 Subject: [PATCH] =?UTF-8?q?Undo,=20Redo=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/handlers/drag-drop.js | 2 + src/renderer/history.js | 33 ++++++++++++++++ src/renderer/index.html | 3 ++ src/renderer/renderer.js | 60 +++++++++++++++++++++++++++++- 4 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 src/renderer/history.js diff --git a/src/renderer/handlers/drag-drop.js b/src/renderer/handlers/drag-drop.js index bdcc36f..7e57ec9 100644 --- a/src/renderer/handlers/drag-drop.js +++ b/src/renderer/handlers/drag-drop.js @@ -266,6 +266,7 @@ function _moveSingle(srcIdx, targetIdx) { Array.from(_grid.children).forEach((c, i) => { c.dataset.idx = i; }); _cb.updateNumberBadges(); _cb.updateNewNameLabels(); + _cb.pushHistory?.([moved.path]); } function _moveMulti(targetIdx) { @@ -301,6 +302,7 @@ function _moveMulti(targetIdx) { _cb.updateNewNameLabels(); _cb.refreshCardSelection(); _cb.updateSelCount(); + _cb.pushHistory?.(movedFiles.map(f => f.path)); } // ── 자동 스크롤 ─────────────────────────────────── diff --git a/src/renderer/history.js b/src/renderer/history.js new file mode 100644 index 0000000..6787f1f --- /dev/null +++ b/src/renderer/history.js @@ -0,0 +1,33 @@ +const MAX = 50; +let _stack = []; // each entry: { files: [], affectedPaths: Set | null } +let _cursor = -1; + +export function push(files, affectedPaths = null) { + _stack = _stack.slice(0, _cursor + 1); + _stack.push({ files: [...files], affectedPaths: affectedPaths ? new Set(affectedPaths) : null }); + if (_stack.length > MAX) _stack.shift(); + _cursor = _stack.length - 1; +} + +// Returns { files, affectedPaths } where affectedPaths are from the entry being undone +export function undo() { + if (!canUndo()) return null; + const affectedPaths = _stack[_cursor].affectedPaths; + --_cursor; + return { files: [..._stack[_cursor].files], affectedPaths }; +} + +// Returns { files, affectedPaths } from the entry being redone +export function redo() { + if (!canRedo()) return null; + ++_cursor; + return { files: [..._stack[_cursor].files], affectedPaths: _stack[_cursor].affectedPaths }; +} + +export function canUndo() { return _cursor > 0; } +export function canRedo() { return _cursor < _stack.length - 1; } + +export function reset() { + _stack = []; + _cursor = -1; +} diff --git a/src/renderer/index.html b/src/renderer/index.html index 71aefaf..8dcdd94 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -32,6 +32,9 @@
+
+ +
diff --git a/src/renderer/renderer.js b/src/renderer/renderer.js index 060ba41..5e33e21 100644 --- a/src/renderer/renderer.js +++ b/src/renderer/renderer.js @@ -1,5 +1,6 @@ import { state } from './state.js'; -import { showLoading, hideLoading, setStatus } from './ui.js'; +import { showLoading, hideLoading, setStatus, showToast } from './ui.js'; +import * as history from './history.js'; import { renderGrid, updateSelCount, updateNumberBadges, updateNewNameLabels, refreshCardSelection, showEmptyGrid, @@ -28,10 +29,15 @@ const gridContainer = document.getElementById('grid-container'); const dragGhostEl = document.getElementById('drag-ghost'); const thumbSizeHint = document.getElementById('thumb-size-hint'); const btnPreviewToggle = document.getElementById('btn-preview-toggle'); +const btnUndo = document.getElementById('btn-undo'); +const btnRedo = document.getElementById('btn-redo'); const fileExplorerContainer = document.querySelector('.file-explorer'); // ── 핸들러 초기화 ───────────────────────────────── -const callbacks = { refreshCardSelection, updateSelCount, updateNumberBadges, updateNewNameLabels }; +const callbacks = { + refreshCardSelection, updateSelCount, updateNumberBadges, updateNewNameLabels, + pushHistory: (affectedPaths) => { history.push(state.files, affectedPaths); updateHistoryButtons(); }, +}; pinchZoom.init({ gridContainer, thumbSizeHint }); selection.init({ gridContainer, rubberBand, grid, state, callbacks }); @@ -67,6 +73,8 @@ btnOpen.addEventListener('click', openFolder); btnSortName.addEventListener('click', () => sortFiles('name')); btnSortDate.addEventListener('click', () => sortFiles('date')); btnRename.addEventListener('click', doRename); +btnUndo.addEventListener('click', performUndo); +btnRedo.addEventListener('click', performRedo); btnPreviewToggle.addEventListener('click', () => { const on = btnPreviewToggle.classList.toggle('active'); hoverPreview.setEnabled(on); @@ -82,6 +90,8 @@ document.addEventListener('keydown', (e) => { refreshCardSelection(); updateSelCount(); } + if (e.code === 'KeyU') performUndo(); + if (e.code === 'KeyR') performRedo(); }); // ── 폴더 열기 (다이얼로그) ─────────────────────── @@ -131,6 +141,9 @@ async function loadFolder(folder) { renderGrid(); updatePreview(); btnRename.disabled = false; + history.reset(); + history.push(state.files); + updateHistoryButtons(); } // ── 정렬 ───────────────────────────────────────── @@ -143,6 +156,8 @@ async function sortFiles(mode) { updateSortButtons(); await renderGrid(); setStatus(`${mode === 'name' ? '이름순' : '날짜순'}으로 정렬됨`, true); + history.push(state.files); + updateHistoryButtons(); } function updateSortButtons() { @@ -150,6 +165,47 @@ function updateSortButtons() { btnSortDate.classList.toggle('active', state.sortMode === 'date'); } +// ── Undo / Redo ─────────────────────────────────── +function updateHistoryButtons() { + btnUndo.disabled = !history.canUndo(); + btnRedo.disabled = !history.canRedo(); +} + +function performUndo() { + if (!history.canUndo()) { + showToast('더 이상 되돌릴 수 없습니다'); + return; + } + _applyHistorySnapshot(history.undo()); +} + +function performRedo() { + if (!history.canRedo()) { + showToast('마지막 작업 상태입니다'); + return; + } + _applyHistorySnapshot(history.redo()); +} + +function _applyHistorySnapshot({ files, affectedPaths }) { + state.files.length = 0; + state.files.push(...files); + state.selectedIdxs.clear(); + + if (affectedPaths) { + const pathToIdx = new Map(state.files.map((f, i) => [f.path, i])); + affectedPaths.forEach(path => { + const idx = pathToIdx.get(path); + if (idx !== undefined) state.selectedIdxs.add(idx); + }); + } + + state.sortMode = null; + updateSortButtons(); + renderGrid(); + updateHistoryButtons(); +} + // ── 포맷 미리보기 ───────────────────────────────── function updatePreview() { const prefix = inputPrefix.value.trim() || 'photo';