diff --git a/CLAUDE.md b/CLAUDE.md index b6f1226..5a25de4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,9 +22,13 @@ This is a macOS Electron desktop app for batch-renaming and moving photo files. ### File structure ``` +assets/ +├── icon.png # Source icon (2048×2048, square) +└── icon.icns # macOS app icon for electron-builder + src/ ├── main/ -│ ├── index.js # Main process entry point; creates BrowserWindow, calls registerAll() +│ ├── index.js # Main process entry point; app.setName(), BrowserWindow, registerAll() │ └── ipc/ │ ├── index.js # registerAll(mainWindow) — delegates to folder and files modules │ ├── folder.js # select-folder, list-directories, get/set-last-folder, @@ -37,11 +41,12 @@ src/ ├── index.html # App shell; dark theme, CSS variables in :root, modal HTML ├── style.css # All CSS ├── state.js # Shared mutable state: files[], currentFolder, selectedIdxs, sortMode - ├── renderer.js # Entry point: init, loadFolder, sortFiles, keyboard shortcuts, startup + ├── renderer.js # Entry point: init, loadFolder, sortFiles, undo/redo, keyboard shortcuts ├── ui.js # showLoading, hideLoading, setStatus, showToast ├── grid.js # renderGrid, createCard, card UI update functions, showEmptyGrid - ├── rename.js # doRename + rename-conflict modal - ├── move.js # handleFolderDrop + move modals + _doMoveFiles + ├── rename.js # doRename + rename-conflict modal; resets history after rename + ├── move.js # handleFolderDrop + move modals + _doMoveFiles; resets history after move + ├── history.js # Undo/redo stack (max 50 entries); tracks file order + affected paths └── handlers/ ├── drag-drop.js # Card reorder (single/multi) + drag-to-folder in sidebar ├── file-explorer.js # Sidebar directory tree (lazy-load, expand/collapse) @@ -58,7 +63,7 @@ Defined in `src/main/ipc/`, bridged via `src/preload/index.js` as `window.api`: |---|---|---| | `select-folder` | invoke | Opens native folder picker, returns path or null | | `list-images` | invoke | Reads directory, returns `{name, path, mtime}[]` filtered to image extensions, sorted by filename | -| `read-image` | invoke | Reads file, returns base64 data URL for display | +| `read-image` | invoke | Reads file, returns base64 data URL (supports jpg/png/gif/webp/tiff/bmp/heic) | | `rename-files({folderPath, files, prefix, digits})` | invoke | Two-phase rename: temp names (crypto.randomUUID) first, then final names | | `check-files-exist(folderPath, names[])` | invoke | Returns subset of names that already exist in folderPath | | `move-files({targetFolder, files[]})` | invoke | Moves files; each file may have optional `newName`; handles cross-device (EXDEV) | @@ -76,8 +81,8 @@ Uses ES module imports (`type="module"`). No framework. **Dependency direction** (no cycles): ``` renderer.js → rename.js, move.js → grid.js → handlers/* - ↘ ↗ - ui.js + ↘ ↘ ↗ + history.js ui.js ``` **State** (`src/renderer/state.js`): @@ -86,32 +91,47 @@ renderer.js → rename.js, move.js → grid.js → handlers/* - `currentFolder` — active directory path - `sortMode` — `'name'` | `'date'` | `null` -**Keyboard shortcuts:** +**Keyboard shortcuts** (모두 `e.code` 기준 — 한글 입력 모드에서도 동작): - `O` — 폴더 열기 - `S` — 이름 변경 실행 - `A` — 전체 선택 +- `U` — Undo (되돌리기) +- `R` — Redo (다시하기) + +**Undo / Redo** (`history.js`): +- 파일 순서 변경(드래그, 정렬)만 추적; 파일시스템 변경(rename, move)은 히스토리 리셋 +- 각 엔트리: `{ files: [...], affectedPaths: Set | null }` +- `affectedPaths`: undo/redo 실행 후 해당 파일들을 선택 상태로 복원하는 데 사용 +- `setOnUpdate(fn)`: push/reset/undo/redo 시 자동 호출되는 콜백 등록 (버튼 상태 갱신용) +- 최대 50개 엔트리 유지 **Card selection** (`selection.js`): - Click — 단독 선택 (재클릭 시 해제) - Shift+Click — 범위 선택 - Cmd+Click — 개별 토글 - Rubber-band drag — 영역 선택 (rAF throttle 적용) +- Shift+Rubber-band — 토글 선택 (드래그 시작 시점 스냅샷 기준으로 선택/해제 반전) - `resetAnchor()` — 그리드 재렌더 시 Shift 기준점 초기화 (grid.js에서 호출) **Drag-and-drop** (`drag-drop.js`): -- 그리드 내: 카드 순서 재배치 (single/multi) -- 사이드바 폴더로: 선택된 파일들을 해당 폴더로 이동 +- 그리드 내 카드 순서 재배치 (single/multi) + - 드래그 중인 카드는 `.drag-hidden`으로 숨김; 삽입 위치에 `.card-placeholder` 표시 + - 마지막 카드 이후 빈 영역에 드롭하면 맨 끝에 삽입 + - 드롭 완료 후 이동한 파일들이 선택 상태로 유지되고 history에 push +- 사이드바 폴더로 드래그: 선택된 파일들을 해당 폴더로 이동 - 파일명 충돌 시 rename-and-move 모달 표시 - 드래그 중 `.tree-row.drop-target` 클래스로 대상 폴더 하이라이트 **Rename** (`rename.js`): - 선택된 파일만 이름 변경 (선택 없으면 전체) - 변경될 이름이 미선택 파일과 충돌하면 충돌 목록 모달 표시 +- 완료 후 history 리셋 (rename은 파일 경로가 바뀌므로 undo 대상 아님) **Move** (`move.js`): - 충돌 없으면 즉시 이동 - 충돌 있으면 prefix + digits 입력 모달 → 새 이름으로 이동 - 새 이름도 충돌이면 에러 모달 +- 완료 후 history 리셋 **Thumbnail loading:** `IntersectionObserver` with `rootMargin: '400px 0px'` — lazy load as cards enter viewport. `entry.target.isConnected` 체크로 폴더 전환 중 스탈 카드 방지. Drag reorder remaps card elements via `card.dataset.filepath` to avoid re-fetching. @@ -121,6 +141,8 @@ renderer.js → rename.js, move.js → grid.js → handlers/* **Styling:** CSS는 `src/renderer/style.css`에 집중. Dark theme, CSS variables in `:root`. macOS traffic-light은 `header`의 `-webkit-app-region: drag`. -**Build output:** `electron-builder` targets DMG for arm64 and x64. The app icon must be at `assets/icon.icns`. +**App icon:** 소스는 `assets/icon.png` (2048×2048). `assets/icon.icns`는 10개 해상도(16px~1024px@2x)를 포함하며 electron-builder가 사용. `app.setName('Photo Renamer')`로 개발 모드 메뉴바 이름도 지정. + +**Build output:** `electron-builder` targets DMG for arm64 and x64. **Config persistence:** `app.getPath('userData')/config.json`에 `lastFolder`, `previewOn` 저장. diff --git a/src/main/ipc/files.js b/src/main/ipc/files.js index 49eea60..035d6e5 100644 --- a/src/main/ipc/files.js +++ b/src/main/ipc/files.js @@ -26,10 +26,13 @@ function register() { 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' + 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')}`; } catch (e) { diff --git a/src/renderer/history.js b/src/renderer/history.js index 6787f1f..136de88 100644 --- a/src/renderer/history.js +++ b/src/renderer/history.js @@ -1,12 +1,18 @@ const MAX = 50; -let _stack = []; // each entry: { files: [], affectedPaths: Set | null } -let _cursor = -1; +let _stack = []; +let _cursor = -1; +let _onUpdate = null; + +export function setOnUpdate(fn) { + _onUpdate = fn; +} 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; + _onUpdate?.(); } // Returns { files, affectedPaths } where affectedPaths are from the entry being undone @@ -14,6 +20,7 @@ export function undo() { if (!canUndo()) return null; const affectedPaths = _stack[_cursor].affectedPaths; --_cursor; + _onUpdate?.(); return { files: [..._stack[_cursor].files], affectedPaths }; } @@ -21,6 +28,7 @@ export function undo() { export function redo() { if (!canRedo()) return null; ++_cursor; + _onUpdate?.(); return { files: [..._stack[_cursor].files], affectedPaths: _stack[_cursor].affectedPaths }; } @@ -30,4 +38,5 @@ export function canRedo() { return _cursor < _stack.length - 1; } export function reset() { _stack = []; _cursor = -1; + _onUpdate?.(); } diff --git a/src/renderer/move.js b/src/renderer/move.js index bd114cb..97faf11 100644 --- a/src/renderer/move.js +++ b/src/renderer/move.js @@ -1,6 +1,7 @@ import { state } from './state.js'; import { showLoading, hideLoading, setStatus, showToast } from './ui.js'; import { renderGrid, updateSelCount, showEmptyGrid } from './grid.js'; +import * as history from './history.js'; const btnRename = document.getElementById('btn-rename'); const moveModal = document.getElementById('move-modal'); @@ -84,6 +85,8 @@ async function _doMoveFiles(targetPath, files) { state.files.length = 0; state.files.push(...newList); state.selectedIdxs.clear(); + history.reset(); + history.push(state.files); if (!newList.length) { showEmptyGrid(); btnRename.disabled = true; diff --git a/src/renderer/rename.js b/src/renderer/rename.js index 4980a93..829eb95 100644 --- a/src/renderer/rename.js +++ b/src/renderer/rename.js @@ -1,6 +1,7 @@ import { state } from './state.js'; import { showLoading, hideLoading, setStatus, showToast } from './ui.js'; import { renderGrid } from './grid.js'; +import * as history from './history.js'; const inputPrefix = document.getElementById('input-prefix'); const inputDigits = document.getElementById('input-digits'); @@ -75,6 +76,8 @@ export async function doRename() { state.files.push(...newList); state.selectedIdxs.clear(); renderGrid(); + history.reset(); + history.push(state.files); } catch (e) { hideLoading(); alert('오류 발생: ' + e.message); diff --git a/src/renderer/renderer.js b/src/renderer/renderer.js index 5e33e21..ad6711b 100644 --- a/src/renderer/renderer.js +++ b/src/renderer/renderer.js @@ -25,6 +25,7 @@ const inputDigits = document.getElementById('input-digits'); const previewName = document.getElementById('preview-name'); const dirPath = document.getElementById('dir-path'); const rubberBand = document.getElementById('rubber-band'); +const grid = document.getElementById('grid'); const gridContainer = document.getElementById('grid-container'); const dragGhostEl = document.getElementById('drag-ghost'); const thumbSizeHint = document.getElementById('thumb-size-hint'); @@ -34,9 +35,11 @@ const btnRedo = document.getElementById('btn-redo'); const fileExplorerContainer = document.querySelector('.file-explorer'); // ── 핸들러 초기화 ───────────────────────────────── +history.setOnUpdate(updateHistoryButtons); + const callbacks = { refreshCardSelection, updateSelCount, updateNumberBadges, updateNewNameLabels, - pushHistory: (affectedPaths) => { history.push(state.files, affectedPaths); updateHistoryButtons(); }, + pushHistory: (affectedPaths) => history.push(state.files, affectedPaths), }; pinchZoom.init({ gridContainer, thumbSizeHint }); @@ -143,7 +146,6 @@ async function loadFolder(folder) { btnRename.disabled = false; history.reset(); history.push(state.files); - updateHistoryButtons(); } // ── 정렬 ───────────────────────────────────────── @@ -157,7 +159,6 @@ async function sortFiles(mode) { await renderGrid(); setStatus(`${mode === 'name' ? '이름순' : '날짜순'}으로 정렬됨`, true); history.push(state.files); - updateHistoryButtons(); } function updateSortButtons() {