diff --git a/CLAUDE.md b/CLAUDE.md index bf70cb5..b6f1226 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ There are no tests or linting configured. ## Architecture -This is a macOS Electron desktop app for batch-renaming photo files. It follows strict Electron security practices: `contextIsolation: true`, `nodeIntegration: false`. +This is a macOS Electron desktop app for batch-renaming and moving photo files. It follows strict Electron security practices: `contextIsolation: true`, `nodeIntegration: false`. **Process boundary:** All filesystem access lives exclusively in `src/main/` (main process). The renderer never touches `fs` directly. @@ -27,20 +27,27 @@ src/ │ ├── index.js # Main process entry point; creates BrowserWindow, calls registerAll() │ └── ipc/ │ ├── index.js # registerAll(mainWindow) — delegates to folder and files modules -│ ├── folder.js # select-folder, reveal-folder -│ └── files.js # list-images, read-image, rename-files +│ ├── folder.js # select-folder, list-directories, get/set-last-folder, +│ │ # get/set-preview-on, get-home-dir, reveal-folder +│ └── files.js # list-images, read-image, rename-files, +│ # check-files-exist, move-files ├── preload/ │ └── index.js # Bridges IPC to window.api via contextBridge (isolated context) └── renderer/ - ├── index.html # App shell (dark theme, CSS variables in :root) + ├── index.html # App shell; dark theme, CSS variables in :root, modal HTML ├── style.css # All CSS - ├── renderer.js # Main renderer logic; imports state and handlers ├── state.js # Shared mutable state: files[], currentFolder, selectedIdxs, sortMode + ├── renderer.js # Entry point: init, loadFolder, sortFiles, keyboard shortcuts, startup + ├── 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 └── handlers/ - ├── drag-drop.js # Card drag-and-drop reorder (single and multi-card) - ├── selection.js # Rubber-band selection, Shift-click, Cmd+click - ├── pinch-zoom.js # Trackpad pinch to resize thumbnails (CSS --thumb-size, 150–400px) - └── image-preview.js # Hover preview overlay + ├── drag-drop.js # Card reorder (single/multi) + drag-to-folder in sidebar + ├── file-explorer.js # Sidebar directory tree (lazy-load, expand/collapse) + ├── selection.js # Rubber-band, Shift-click, Cmd+click, resetAnchor + ├── pinch-zoom.js # Ctrl+scroll to resize thumbnails (CSS --thumb-size, 150–400px) + └── image-preview.js # Hover preview overlay with circular loading indicator ``` ### IPC surface @@ -50,9 +57,15 @@ Defined in `src/main/ipc/`, bridged via `src/preload/index.js` as `window.api`: | Channel | Direction | Description | |---|---|---| | `select-folder` | invoke | Opens native folder picker, returns path or null | -| `list-images` | invoke | Reads directory, returns `{name, path, mtime, size}[]` filtered to image extensions, sorted by filename | +| `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 | -| `rename-files({folderPath, files, prefix, digits})` | invoke | Two-phase rename: temp names first, then final names to avoid collisions | +| `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) | +| `list-directories(folderPath)` | invoke | Returns `{name, path, hasChildren}[]` subdirs, or `null` on access error | +| `get-home-dir` | invoke | Returns `os.homedir()` | +| `get-last-folder` / `set-last-folder` | invoke | Persists last opened folder in userData config.json | +| `get-preview-on` / `set-preview-on` | invoke | Persists hover preview toggle state in userData config.json | | `reveal-folder` | invoke | Opens folder in Finder via `shell.openPath` | | `fullscreen-change` | push (main→renderer) | Sent on `enter-full-screen` / `leave-full-screen` window events | @@ -60,14 +73,54 @@ Defined in `src/main/ipc/`, bridged via `src/preload/index.js` as `window.api`: Uses ES module imports (`type="module"`). No framework. +**Dependency direction** (no cycles): +``` +renderer.js → rename.js, move.js → grid.js → handlers/* + ↘ ↗ + ui.js +``` + **State** (`src/renderer/state.js`): -- `files[]` — ordered array of file objects; order determines rename sequence +- `files[]` — ordered array of `{name, path, mtime}`; order determines rename sequence - `selectedIdxs` — `Set` of selected card indices - `currentFolder` — active directory path - `sortMode` — `'name'` | `'date'` | `null` -**Thumbnail loading:** Uses `IntersectionObserver` with `rootMargin: '400px 0px'` — images load lazily as cards enter the viewport. DOM card elements are created immediately without `src`; the observer fills in `img.src` on entry. Drag-and-drop reorder remaps existing card elements via `card.dataset.filepath` to avoid re-fetching images. +**Keyboard shortcuts:** +- `O` — 폴더 열기 +- `S` — 이름 변경 실행 +- `A` — 전체 선택 -**Styling:** All CSS lives in `src/renderer/index.html`. Dark theme with CSS variables defined in `:root`. The macOS traffic-light buttons sit in `#titlebar` with `-webkit-app-region: drag`. +**Card selection** (`selection.js`): +- Click — 단독 선택 (재클릭 시 해제) +- Shift+Click — 범위 선택 +- Cmd+Click — 개별 토글 +- Rubber-band drag — 영역 선택 (rAF throttle 적용) +- `resetAnchor()` — 그리드 재렌더 시 Shift 기준점 초기화 (grid.js에서 호출) + +**Drag-and-drop** (`drag-drop.js`): +- 그리드 내: 카드 순서 재배치 (single/multi) +- 사이드바 폴더로: 선택된 파일들을 해당 폴더로 이동 + - 파일명 충돌 시 rename-and-move 모달 표시 + - 드래그 중 `.tree-row.drop-target` 클래스로 대상 폴더 하이라이트 + +**Rename** (`rename.js`): +- 선택된 파일만 이름 변경 (선택 없으면 전체) +- 변경될 이름이 미선택 파일과 충돌하면 충돌 목록 모달 표시 + +**Move** (`move.js`): +- 충돌 없으면 즉시 이동 +- 충돌 있으면 prefix + digits 입력 모달 → 새 이름으로 이동 +- 새 이름도 충돌이면 에러 모달 + +**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. + +**Hover preview** (`image-preview.js`): 1초 지연 후 전체 크기 팝업. 썸네일 캐시 우선 사용. 상태는 config.json에 영속. + +**Sidebar tree** (`file-explorer.js`): Lazy-load 자식 폴더. 접근 불가 폴더는 `null` 반환 → "접근 불가" 표시. 행 클릭 시 항상 펼침, 화살표 클릭 시 토글. + +**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`. + +**Config persistence:** `app.getPath('userData')/config.json`에 `lastFolder`, `previewOn` 저장. diff --git a/src/main/ipc/files.js b/src/main/ipc/files.js index 72c1d46..49eea60 100644 --- a/src/main/ipc/files.js +++ b/src/main/ipc/files.js @@ -1,6 +1,7 @@ const { ipcMain } = require('electron'); const path = require('path'); const fs = require('fs'); +const crypto = require('crypto'); const EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp', '.heic', '.heif']); @@ -12,7 +13,7 @@ function register() { .map(f => { const fullPath = path.join(folderPath, f); const stat = fs.statSync(fullPath); - return { name: f, path: fullPath, mtime: stat.mtimeMs, size: stat.size }; + return { name: f, path: fullPath, mtime: stat.mtimeMs }; }) .sort((a, b) => a.name.localeCompare(b.name, 'ko')); return files; @@ -78,7 +79,7 @@ function register() { for (let i = 0; i < files.length; i++) { const src = files[i].path; const ext = path.extname(files[i].name).toLowerCase(); - const tmp = path.join(folderPath, `__tmp_rename_${i}_${Date.now()}${ext}`); + const tmp = path.join(folderPath, `__tmp_${crypto.randomUUID()}${ext}`); try { fs.renameSync(src, tmp); tmpMap.push({ tmp, ext, idx: i }); diff --git a/src/main/ipc/folder.js b/src/main/ipc/folder.js index 6f36724..28478a0 100644 --- a/src/main/ipc/folder.js +++ b/src/main/ipc/folder.js @@ -53,7 +53,7 @@ function register(mainWindow) { return { name: e.name, path: fullPath, hasChildren }; }) .sort((a, b) => a.name.localeCompare(b.name)); - } catch { return []; } + } catch { return null; } }); ipcMain.handle('reveal-folder', (_e, folderPath) => shell.openPath(folderPath)); diff --git a/src/preload/index.js b/src/preload/index.js index a99b63f..ce723b6 100644 --- a/src/preload/index.js +++ b/src/preload/index.js @@ -5,7 +5,11 @@ contextBridge.exposeInMainWorld('api', { listImages: (folder) => ipcRenderer.invoke('list-images', folder), readImage: (path) => ipcRenderer.invoke('read-image', path), renameFiles: (args) => ipcRenderer.invoke('rename-files', args), - onFullscreenChange: (callback) => ipcRenderer.on('fullscreen-change', (_e, v) => callback(v)), + onFullscreenChange: (() => { + let _cb = null; + ipcRenderer.on('fullscreen-change', (_e, v) => { if (_cb) _cb(v); }); + return (callback) => { _cb = callback; }; + })(), getHomeDir: () => ipcRenderer.invoke('get-home-dir'), getLastFolder: () => ipcRenderer.invoke('get-last-folder'), setLastFolder: (p) => ipcRenderer.invoke('set-last-folder', p), diff --git a/src/renderer/grid.js b/src/renderer/grid.js index 347d942..c99cc4f 100644 --- a/src/renderer/grid.js +++ b/src/renderer/grid.js @@ -31,13 +31,14 @@ export function renderGrid() { const img = entry.target.querySelector('img.card-thumb'); if (!img || img.src) return; const dataUrl = await window.api.readImage(entry.target.dataset.filepath); - if (dataUrl) img.src = dataUrl; + if (dataUrl && entry.target.isConnected) img.src = dataUrl; }); }, { root: gridContainer, rootMargin: '400px 0px', threshold: 0 }); Array.from(grid.children).forEach(card => observer.observe(card)); _cardObserver = observer; + selection.resetAnchor(); refreshCardSelection(); updateNumberBadges(); updateNewNameLabels(); diff --git a/src/renderer/handlers/file-explorer.js b/src/renderer/handlers/file-explorer.js index c1c0a0f..0e8b6ef 100644 --- a/src/renderer/handlers/file-explorer.js +++ b/src/renderer/handlers/file-explorer.js @@ -125,7 +125,14 @@ async function expandNode(nodeEl, autoExpandPath) { if (!childrenEl.dataset.loaded) { childrenEl.dataset.loaded = 'true'; const dirs = await window.api.listDirectories(nodeEl.dataset.path); - dirs.forEach(dir => childrenEl.appendChild(createNode(dir))); + if (dirs === null) { + const err = document.createElement('div'); + err.className = 'tree-access-error'; + err.textContent = '접근 불가'; + childrenEl.appendChild(err); + } else { + dirs.forEach(dir => childrenEl.appendChild(createNode(dir))); + } } // Auto-expand toward the target path diff --git a/src/renderer/handlers/selection.js b/src/renderer/handlers/selection.js index 3e2f8a9..3bf03bb 100644 --- a/src/renderer/handlers/selection.js +++ b/src/renderer/handlers/selection.js @@ -4,9 +4,15 @@ let _state, _cb; let rbActive = false; let rbStartX = 0; let rbStartY = 0; +let _rafPending = false; +let _lastMoveEvent = null; let lastSelectedIdx = null; +export function resetAnchor() { + lastSelectedIdx = null; +} + export function init({ gridContainer, rubberBand, grid, state, callbacks }) { _rubberBand = rubberBand; _grid = grid; @@ -98,6 +104,17 @@ function _onStart(e) { function _onMove(e) { if (!rbActive) return; + _lastMoveEvent = e; + if (!_rafPending) { + _rafPending = true; + requestAnimationFrame(_processMove); + } +} + +function _processMove() { + _rafPending = false; + const e = _lastMoveEvent; + if (!rbActive || !e) return; const left = Math.min(rbStartX, e.clientX); const top = Math.min(rbStartY, e.clientY); @@ -110,11 +127,11 @@ function _onMove(e) { }); const sel = { left, top, right: left + width, bottom: top + height }; + const preserve = e.shiftKey || e.ctrlKey || e.metaKey; Array.from(_grid.children).forEach((card, i) => { const r = card.getBoundingClientRect(); const overlaps = !(r.right < sel.left || r.left > sel.right || r.bottom < sel.top || r.top > sel.bottom); - const preserve = e.shiftKey || e.ctrlKey || e.metaKey; if (overlaps) { _state.selectedIdxs.add(i); } else if (!preserve) { diff --git a/src/renderer/rename.js b/src/renderer/rename.js index 3d4099b..4980a93 100644 --- a/src/renderer/rename.js +++ b/src/renderer/rename.js @@ -35,7 +35,11 @@ export async function doRename() { }); const conflicting = newNames.filter(n => unselectedNames.has(n)); if (conflicting.length) { - renameConflictList.innerHTML = conflicting.map(n => `
  • ${n}
  • `).join(''); + renameConflictList.replaceChildren(...conflicting.map(n => { + const li = document.createElement('li'); + li.textContent = n; + return li; + })); renameConflictModal.style.display = 'flex'; return; } diff --git a/src/renderer/renderer.js b/src/renderer/renderer.js index d705c14..060ba41 100644 --- a/src/renderer/renderer.js +++ b/src/renderer/renderer.js @@ -12,6 +12,8 @@ import * as dragDrop from './handlers/drag-drop.js'; import * as hoverPreview from './handlers/image-preview.js'; import * as fileExplorer from './handlers/file-explorer.js'; +let _homeDir = null; + // ── DOM ────────────────────────────────────────────── const btnOpen = document.getElementById('btn-open'); const btnSortName = document.getElementById('btn-sort-name'); @@ -48,6 +50,7 @@ window.api.onFullscreenChange((isFullscreen) => { window.api.getHomeDir(), window.api.getPreviewOn(), ]); + _homeDir = homeDir; btnPreviewToggle.classList.toggle('active', previewOn); hoverPreview.setEnabled(previewOn); @@ -85,7 +88,7 @@ document.addEventListener('keydown', (e) => { async function openFolder() { const folder = await window.api.selectFolder(); if (!folder) return; - const homeDir = await window.api.getHomeDir(); + const homeDir = _homeDir; if (folder.startsWith(homeDir)) { await fileExplorer.loadTree(homeDir, folder); } else { diff --git a/src/renderer/style.css b/src/renderer/style.css index bbf316d..b620785 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -474,6 +474,10 @@ footer span { font-size: 11px; color: var(--muted); } border-left: 1px solid rgba(255,255,255,.05); margin-left: 15px; } +.tree-access-error { + font-size: 11px; color: var(--muted); opacity: .5; + padding: 2px 8px; font-style: italic; +} /* ── 폴더 드롭 타겟 하이라이트 ── */ .tree-row.drop-target {