리펙토링: 전체 구조 검토, 개선
This commit is contained in:
@@ -22,9 +22,13 @@ This is a macOS Electron desktop app for batch-renaming and moving photo files.
|
|||||||
### File structure
|
### File structure
|
||||||
|
|
||||||
```
|
```
|
||||||
|
assets/
|
||||||
|
├── icon.png # Source icon (2048×2048, square)
|
||||||
|
└── icon.icns # macOS app icon for electron-builder
|
||||||
|
|
||||||
src/
|
src/
|
||||||
├── main/
|
├── main/
|
||||||
│ ├── index.js # Main process entry point; creates BrowserWindow, calls registerAll()
|
│ ├── index.js # Main process entry point; app.setName(), BrowserWindow, registerAll()
|
||||||
│ └── ipc/
|
│ └── ipc/
|
||||||
│ ├── index.js # registerAll(mainWindow) — delegates to folder and files modules
|
│ ├── index.js # registerAll(mainWindow) — delegates to folder and files modules
|
||||||
│ ├── folder.js # select-folder, list-directories, get/set-last-folder,
|
│ ├── 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
|
├── index.html # App shell; dark theme, CSS variables in :root, modal HTML
|
||||||
├── style.css # All CSS
|
├── style.css # All CSS
|
||||||
├── state.js # Shared mutable state: files[], currentFolder, selectedIdxs, sortMode
|
├── 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
|
├── ui.js # showLoading, hideLoading, setStatus, showToast
|
||||||
├── grid.js # renderGrid, createCard, card UI update functions, showEmptyGrid
|
├── grid.js # renderGrid, createCard, card UI update functions, showEmptyGrid
|
||||||
├── rename.js # doRename + rename-conflict modal
|
├── rename.js # doRename + rename-conflict modal; resets history after rename
|
||||||
├── move.js # handleFolderDrop + move modals + _doMoveFiles
|
├── move.js # handleFolderDrop + move modals + _doMoveFiles; resets history after move
|
||||||
|
├── history.js # Undo/redo stack (max 50 entries); tracks file order + affected paths
|
||||||
└── handlers/
|
└── handlers/
|
||||||
├── drag-drop.js # Card reorder (single/multi) + drag-to-folder in sidebar
|
├── drag-drop.js # Card reorder (single/multi) + drag-to-folder in sidebar
|
||||||
├── file-explorer.js # Sidebar directory tree (lazy-load, expand/collapse)
|
├── 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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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) |
|
| `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):
|
**Dependency direction** (no cycles):
|
||||||
```
|
```
|
||||||
renderer.js → rename.js, move.js → grid.js → handlers/*
|
renderer.js → rename.js, move.js → grid.js → handlers/*
|
||||||
↘ ↗
|
↘ ↘ ↗
|
||||||
ui.js
|
history.js ui.js
|
||||||
```
|
```
|
||||||
|
|
||||||
**State** (`src/renderer/state.js`):
|
**State** (`src/renderer/state.js`):
|
||||||
@@ -86,32 +91,47 @@ renderer.js → rename.js, move.js → grid.js → handlers/*
|
|||||||
- `currentFolder` — active directory path
|
- `currentFolder` — active directory path
|
||||||
- `sortMode` — `'name'` | `'date'` | `null`
|
- `sortMode` — `'name'` | `'date'` | `null`
|
||||||
|
|
||||||
**Keyboard shortcuts:**
|
**Keyboard shortcuts** (모두 `e.code` 기준 — 한글 입력 모드에서도 동작):
|
||||||
- `O` — 폴더 열기
|
- `O` — 폴더 열기
|
||||||
- `S` — 이름 변경 실행
|
- `S` — 이름 변경 실행
|
||||||
- `A` — 전체 선택
|
- `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`):
|
**Card selection** (`selection.js`):
|
||||||
- Click — 단독 선택 (재클릭 시 해제)
|
- Click — 단독 선택 (재클릭 시 해제)
|
||||||
- Shift+Click — 범위 선택
|
- Shift+Click — 범위 선택
|
||||||
- Cmd+Click — 개별 토글
|
- Cmd+Click — 개별 토글
|
||||||
- Rubber-band drag — 영역 선택 (rAF throttle 적용)
|
- Rubber-band drag — 영역 선택 (rAF throttle 적용)
|
||||||
|
- Shift+Rubber-band — 토글 선택 (드래그 시작 시점 스냅샷 기준으로 선택/해제 반전)
|
||||||
- `resetAnchor()` — 그리드 재렌더 시 Shift 기준점 초기화 (grid.js에서 호출)
|
- `resetAnchor()` — 그리드 재렌더 시 Shift 기준점 초기화 (grid.js에서 호출)
|
||||||
|
|
||||||
**Drag-and-drop** (`drag-drop.js`):
|
**Drag-and-drop** (`drag-drop.js`):
|
||||||
- 그리드 내: 카드 순서 재배치 (single/multi)
|
- 그리드 내 카드 순서 재배치 (single/multi)
|
||||||
- 사이드바 폴더로: 선택된 파일들을 해당 폴더로 이동
|
- 드래그 중인 카드는 `.drag-hidden`으로 숨김; 삽입 위치에 `.card-placeholder` 표시
|
||||||
|
- 마지막 카드 이후 빈 영역에 드롭하면 맨 끝에 삽입
|
||||||
|
- 드롭 완료 후 이동한 파일들이 선택 상태로 유지되고 history에 push
|
||||||
|
- 사이드바 폴더로 드래그: 선택된 파일들을 해당 폴더로 이동
|
||||||
- 파일명 충돌 시 rename-and-move 모달 표시
|
- 파일명 충돌 시 rename-and-move 모달 표시
|
||||||
- 드래그 중 `.tree-row.drop-target` 클래스로 대상 폴더 하이라이트
|
- 드래그 중 `.tree-row.drop-target` 클래스로 대상 폴더 하이라이트
|
||||||
|
|
||||||
**Rename** (`rename.js`):
|
**Rename** (`rename.js`):
|
||||||
- 선택된 파일만 이름 변경 (선택 없으면 전체)
|
- 선택된 파일만 이름 변경 (선택 없으면 전체)
|
||||||
- 변경될 이름이 미선택 파일과 충돌하면 충돌 목록 모달 표시
|
- 변경될 이름이 미선택 파일과 충돌하면 충돌 목록 모달 표시
|
||||||
|
- 완료 후 history 리셋 (rename은 파일 경로가 바뀌므로 undo 대상 아님)
|
||||||
|
|
||||||
**Move** (`move.js`):
|
**Move** (`move.js`):
|
||||||
- 충돌 없으면 즉시 이동
|
- 충돌 없으면 즉시 이동
|
||||||
- 충돌 있으면 prefix + digits 입력 모달 → 새 이름으로 이동
|
- 충돌 있으면 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.
|
**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`.
|
**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` 저장.
|
**Config persistence:** `app.getPath('userData')/config.json`에 `lastFolder`, `previewOn` 저장.
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ function register() {
|
|||||||
: ext === 'png' ? 'png'
|
: ext === 'png' ? 'png'
|
||||||
: ext === 'gif' ? 'gif'
|
: ext === 'gif' ? 'gif'
|
||||||
: ext === 'webp' ? 'webp'
|
: ext === 'webp' ? 'webp'
|
||||||
|
: (ext === 'tiff' || ext === 'tif') ? 'tiff'
|
||||||
|
: ext === 'bmp' ? 'bmp'
|
||||||
|
: (ext === 'heic' || ext === 'heif') ? 'heic'
|
||||||
: 'jpeg';
|
: 'jpeg';
|
||||||
return `data:image/${mime};base64,${data.toString('base64')}`;
|
return `data:image/${mime};base64,${data.toString('base64')}`;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
+10
-1
@@ -1,12 +1,18 @@
|
|||||||
const MAX = 50;
|
const MAX = 50;
|
||||||
let _stack = []; // each entry: { files: [], affectedPaths: Set | null }
|
let _stack = [];
|
||||||
let _cursor = -1;
|
let _cursor = -1;
|
||||||
|
let _onUpdate = null;
|
||||||
|
|
||||||
|
export function setOnUpdate(fn) {
|
||||||
|
_onUpdate = fn;
|
||||||
|
}
|
||||||
|
|
||||||
export function push(files, affectedPaths = null) {
|
export function push(files, affectedPaths = null) {
|
||||||
_stack = _stack.slice(0, _cursor + 1);
|
_stack = _stack.slice(0, _cursor + 1);
|
||||||
_stack.push({ files: [...files], affectedPaths: affectedPaths ? new Set(affectedPaths) : null });
|
_stack.push({ files: [...files], affectedPaths: affectedPaths ? new Set(affectedPaths) : null });
|
||||||
if (_stack.length > MAX) _stack.shift();
|
if (_stack.length > MAX) _stack.shift();
|
||||||
_cursor = _stack.length - 1;
|
_cursor = _stack.length - 1;
|
||||||
|
_onUpdate?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns { files, affectedPaths } where affectedPaths are from the entry being undone
|
// Returns { files, affectedPaths } where affectedPaths are from the entry being undone
|
||||||
@@ -14,6 +20,7 @@ export function undo() {
|
|||||||
if (!canUndo()) return null;
|
if (!canUndo()) return null;
|
||||||
const affectedPaths = _stack[_cursor].affectedPaths;
|
const affectedPaths = _stack[_cursor].affectedPaths;
|
||||||
--_cursor;
|
--_cursor;
|
||||||
|
_onUpdate?.();
|
||||||
return { files: [..._stack[_cursor].files], affectedPaths };
|
return { files: [..._stack[_cursor].files], affectedPaths };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,6 +28,7 @@ export function undo() {
|
|||||||
export function redo() {
|
export function redo() {
|
||||||
if (!canRedo()) return null;
|
if (!canRedo()) return null;
|
||||||
++_cursor;
|
++_cursor;
|
||||||
|
_onUpdate?.();
|
||||||
return { files: [..._stack[_cursor].files], affectedPaths: _stack[_cursor].affectedPaths };
|
return { files: [..._stack[_cursor].files], affectedPaths: _stack[_cursor].affectedPaths };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,4 +38,5 @@ export function canRedo() { return _cursor < _stack.length - 1; }
|
|||||||
export function reset() {
|
export function reset() {
|
||||||
_stack = [];
|
_stack = [];
|
||||||
_cursor = -1;
|
_cursor = -1;
|
||||||
|
_onUpdate?.();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { state } from './state.js';
|
import { state } from './state.js';
|
||||||
import { showLoading, hideLoading, setStatus, showToast } from './ui.js';
|
import { showLoading, hideLoading, setStatus, showToast } from './ui.js';
|
||||||
import { renderGrid, updateSelCount, showEmptyGrid } from './grid.js';
|
import { renderGrid, updateSelCount, showEmptyGrid } from './grid.js';
|
||||||
|
import * as history from './history.js';
|
||||||
|
|
||||||
const btnRename = document.getElementById('btn-rename');
|
const btnRename = document.getElementById('btn-rename');
|
||||||
const moveModal = document.getElementById('move-modal');
|
const moveModal = document.getElementById('move-modal');
|
||||||
@@ -84,6 +85,8 @@ async function _doMoveFiles(targetPath, files) {
|
|||||||
state.files.length = 0;
|
state.files.length = 0;
|
||||||
state.files.push(...newList);
|
state.files.push(...newList);
|
||||||
state.selectedIdxs.clear();
|
state.selectedIdxs.clear();
|
||||||
|
history.reset();
|
||||||
|
history.push(state.files);
|
||||||
if (!newList.length) {
|
if (!newList.length) {
|
||||||
showEmptyGrid();
|
showEmptyGrid();
|
||||||
btnRename.disabled = true;
|
btnRename.disabled = true;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { state } from './state.js';
|
import { state } from './state.js';
|
||||||
import { showLoading, hideLoading, setStatus, showToast } from './ui.js';
|
import { showLoading, hideLoading, setStatus, showToast } from './ui.js';
|
||||||
import { renderGrid } from './grid.js';
|
import { renderGrid } from './grid.js';
|
||||||
|
import * as history from './history.js';
|
||||||
|
|
||||||
const inputPrefix = document.getElementById('input-prefix');
|
const inputPrefix = document.getElementById('input-prefix');
|
||||||
const inputDigits = document.getElementById('input-digits');
|
const inputDigits = document.getElementById('input-digits');
|
||||||
@@ -75,6 +76,8 @@ export async function doRename() {
|
|||||||
state.files.push(...newList);
|
state.files.push(...newList);
|
||||||
state.selectedIdxs.clear();
|
state.selectedIdxs.clear();
|
||||||
renderGrid();
|
renderGrid();
|
||||||
|
history.reset();
|
||||||
|
history.push(state.files);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
hideLoading();
|
hideLoading();
|
||||||
alert('오류 발생: ' + e.message);
|
alert('오류 발생: ' + e.message);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const inputDigits = document.getElementById('input-digits');
|
|||||||
const previewName = document.getElementById('preview-name');
|
const previewName = document.getElementById('preview-name');
|
||||||
const dirPath = document.getElementById('dir-path');
|
const dirPath = document.getElementById('dir-path');
|
||||||
const rubberBand = document.getElementById('rubber-band');
|
const rubberBand = document.getElementById('rubber-band');
|
||||||
|
const grid = document.getElementById('grid');
|
||||||
const gridContainer = document.getElementById('grid-container');
|
const gridContainer = document.getElementById('grid-container');
|
||||||
const dragGhostEl = document.getElementById('drag-ghost');
|
const dragGhostEl = document.getElementById('drag-ghost');
|
||||||
const thumbSizeHint = document.getElementById('thumb-size-hint');
|
const thumbSizeHint = document.getElementById('thumb-size-hint');
|
||||||
@@ -34,9 +35,11 @@ const btnRedo = document.getElementById('btn-redo');
|
|||||||
const fileExplorerContainer = document.querySelector('.file-explorer');
|
const fileExplorerContainer = document.querySelector('.file-explorer');
|
||||||
|
|
||||||
// ── 핸들러 초기화 ─────────────────────────────────
|
// ── 핸들러 초기화 ─────────────────────────────────
|
||||||
|
history.setOnUpdate(updateHistoryButtons);
|
||||||
|
|
||||||
const callbacks = {
|
const callbacks = {
|
||||||
refreshCardSelection, updateSelCount, updateNumberBadges, updateNewNameLabels,
|
refreshCardSelection, updateSelCount, updateNumberBadges, updateNewNameLabels,
|
||||||
pushHistory: (affectedPaths) => { history.push(state.files, affectedPaths); updateHistoryButtons(); },
|
pushHistory: (affectedPaths) => history.push(state.files, affectedPaths),
|
||||||
};
|
};
|
||||||
|
|
||||||
pinchZoom.init({ gridContainer, thumbSizeHint });
|
pinchZoom.init({ gridContainer, thumbSizeHint });
|
||||||
@@ -143,7 +146,6 @@ async function loadFolder(folder) {
|
|||||||
btnRename.disabled = false;
|
btnRename.disabled = false;
|
||||||
history.reset();
|
history.reset();
|
||||||
history.push(state.files);
|
history.push(state.files);
|
||||||
updateHistoryButtons();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 정렬 ─────────────────────────────────────────
|
// ── 정렬 ─────────────────────────────────────────
|
||||||
@@ -157,7 +159,6 @@ async function sortFiles(mode) {
|
|||||||
await renderGrid();
|
await renderGrid();
|
||||||
setStatus(`${mode === 'name' ? '이름순' : '날짜순'}으로 정렬됨`, true);
|
setStatus(`${mode === 'name' ? '이름순' : '날짜순'}으로 정렬됨`, true);
|
||||||
history.push(state.files);
|
history.push(state.files);
|
||||||
updateHistoryButtons();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSortButtons() {
|
function updateSortButtons() {
|
||||||
|
|||||||
Reference in New Issue
Block a user