Compare commits

...

13 Commits

20 changed files with 847 additions and 199 deletions
+5
View File
@@ -1,3 +1,8 @@
### VS Code
.vscode/*
!.vscode/tasks.json
!.vscode/extensions.json
### IntelliJ
.idea/
*.iml
+28
View File
@@ -0,0 +1,28 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "앱 실행",
"type": "shell",
"command": "npm start",
"group": {
"kind": "build",
"isDefault": true
},
"presentation": {
"reveal": "always",
"panel": "shared"
}
},
{
"label": "앱 실행 (디버그)",
"type": "shell",
"command": "npm run debug",
"group": "build",
"presentation": {
"reveal": "always",
"panel": "shared"
}
}
]
}
+23 -8
View File
@@ -35,7 +35,8 @@ src/
│ ├── file-utils.js # Pure helpers: listImages, readImageAsDataUrl, fileExists,
│ │ # moveFileWithCopyFallback (EXDEV-safe cross-device move)
│ ├── folder.js # select-folder, list-directories, get/set-last-folder,
│ │ # get/set-preview-on, get/set-sidebar-width, get/set-thumb-size,
│ │ # get/set-preview-on, get/set-sidebar-width,
│ │ # get/set-file-explorer-visible, get/set-thumb-size,
│ │ # get-home-dir, reveal-folder
│ └── files.js # list-images, read-image, rename-files, stage/restore-delete,
│ # check-files-exist, move-files
@@ -66,9 +67,9 @@ src/
│ └── ghost.js # Drag ghost UI helper
├── file-explorer.js # Sidebar directory tree (lazy-load, expand/collapse)
├── sidebar-resize.js # Pointer-drag resizing for the aside/main split; persisted 340500px width
├── selection.js # Rubber-band, Shift-click, Cmd+click, resetAnchor
├── selection.js # Rubber-band, Shift-click, Cmd+click, keyboard navigation, resetAnchor
├── pinch-zoom.js # Ctrl+scroll to resize thumbnails (CSS --thumb-size, 150400px)
└── image-preview.js # Hover preview overlay with circular loading indicator
└── image-preview.js # Hover preview overlay; immediate show/hide when enabled/disabled
```
### IPC surface
@@ -80,7 +81,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 (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, postfix})` | invoke | Two-phase rename: temp names (crypto.randomUUID) first, then final names using `prefix + padded sequence + postfix + ext` |
| `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) |
| `stage-delete-files(paths[])` | invoke | Moves files to userData deleted_staging for undoable deletion; handles cross-device (EXDEV) |
@@ -90,6 +91,7 @@ Defined in `src/main/ipc/`, bridged via `src/preload/index.js` as `window.api`:
| `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 |
| `get-sidebar-width` / `set-sidebar-width` | invoke | Persists sidebar width in userData config.json |
| `get-file-explorer-visible` / `set-file-explorer-visible` | invoke | Persists file explorer visibility in userData config.json; default is visible |
| `get-thumb-size` / `set-thumb-size` | invoke | Persists thumbnail/card size 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 |
@@ -122,8 +124,12 @@ Prefer narrow utility modules (`utils/file-names.js`, `utils/dom.js`) over broad
- `A` — 전체 선택
- `Cmd+Z` — Undo (되돌리기)
- `Cmd+Shift+Z` — Redo (다시하기)
- `P` — 미리보기 on/off 토글
- `Space` — 미리보기 on/off 토글
- `D` — 파일 탐색기 표시/숨김 토글
- `Backspace` / `Delete` — 선택 파일 삭제 팝업
- `ArrowLeft` / `ArrowRight` / `ArrowUp` / `ArrowDown` — 해당 방향의 카드를 키보드 타겟으로 이동하고 단독 선택
- `Home` / `End` — 첫 카드 / 마지막 카드를 키보드 타겟으로 이동하고 단독 선택 (`grid-container` 최상단 / 최하단으로 스크롤)
- `Shift+Arrow*` / `Shift+Home` / `Shift+End` — 기존 선택 목록을 유지한 채 Finder처럼 anchor부터 새 키보드 타겟까지 범위 추가 선택
Global shortcuts are registered in `keyboard.js`. They do not fire while any `.modal-overlay` is open.
@@ -148,9 +154,12 @@ Global shortcuts are registered in `keyboard.js`. They do not fire while any `.m
- Click — 단독 선택 (재클릭 시 해제)
- Shift+Click — 범위 선택
- Cmd+Click — 개별 토글
- Arrow/Home/End keyboard navigation — `keyboardTargetIdx`를 이동하고 해당 카드를 단독 선택; 위/아래 이동은 현재 그리드의 실제 첫 행 카드 수로 column count를 계산
- Shift+Arrow/Home/End — Shift 확장 시작 시점의 선택 스냅샷을 보존하고, `lastSelectedIdx` anchor부터 `keyboardTargetIdx`까지의 범위를 추가 선택
- Home/End keyboard navigation — 선택 타겟 이동 후 `grid-container`를 각각 최상단/최하단으로 스크롤
- Rubber-band drag — 영역 선택 (rAF throttle 적용)
- Shift+Rubber-band — 토글 선택 (드래그 시작 시점 스냅샷 기준으로 선택/해제 반전)
- `resetAnchor()` — 그리드 재렌더 시 Shift 기준점 초기화 (grid.js에서 호출)
- `resetAnchor()` — 그리드 재렌더 시 Shift 기준점과 키보드 타겟 상태 초기화 (grid.js에서 호출)
**Drag-and-drop** (`drag-drop.js`):
- 그리드 내 카드 순서 재배치 (single/multi)
@@ -164,6 +173,10 @@ Global shortcuts are registered in `keyboard.js`. They do not fire while any `.m
**Rename** (`rename.js`):
- 선택된 파일만 이름 변경 (선택 없으면 전체)
- 파일명 포맷은 `input-prefix` + 자리순번 + `input-postfix` + 확장자이다. 구분자 `_`는 자동 삽입하지 않고 입력값 자체에 포함한다.
- `input-prefix`의 HTML 기본값은 `photo_`, `input-postfix`의 HTML 기본값은 `_`이다. 접미사를 비우면 접두사와 자리순번만으로 저장한다.
- 폴더 로드 시 이미지 파일 유무와 관계없이 폴더명 뒤에 `_`를 붙여 `input-prefix`로 설정한다.
- 상단 파일명 preview, 카드별 새 이름 라벨, rename 확인 목록은 모두 `utils/file-names.js``getNameFormat()``buildSequentialName()`을 사용해 같은 규칙을 공유한다.
- 변경될 이름이 미선택 파일과 충돌하면 충돌 목록 모달 표시
- 완료 후 history 리셋 (rename은 파일 경로가 바뀌므로 undo 대상 아님)
@@ -181,7 +194,9 @@ Global shortcuts are registered in `keyboard.js`. They do not fire while any `.m
**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에 영속.
**Hover preview** (`image-preview.js`): 활성화 상태에서 카드 hover 시 대기 없이 전체 크기 팝업을 표시한다. 썸네일 캐시 우선 사용. 상태는 config.json에 영속. `Space`로 preview를 켤 때 커서가 카드 위에 있으면 즉시 팝업을 표시하고, 끌 때는 열린 팝업을 즉시 닫는다.
**Workspace / file explorer:** `index.html` uses semantic `section.app-workspace` above the `aside` and `main`. The top `workspace-nav` contains the file explorer visibility toggle, folder open/rename actions, preview/undo/redo controls, and sort group. The file explorer visibility toggle uses the fixed text `🗂️ (D)`, persists as `fileExplorerVisible`, and hides both `aside` and `#sidebar-resizer` via `.file-explorer-hidden`.
**Sidebar tree** (`file-explorer.js`): Lazy-load 자식 폴더. 접근 불가 폴더는 `null` 반환 → "접근 불가" 표시. 행 클릭 시 항상 펼침, 화살표 클릭 시 토글.
@@ -193,4 +208,4 @@ Global shortcuts are registered in `keyboard.js`. They do not fire while any `.m
**Build output:** `electron-builder` targets DMG for arm64 and x64.
**Config persistence:** `app.getPath('userData')/config.json``lastFolder`, `previewOn`, `sidebarWidth`, `thumbSize`, `windowState` 저장. `windowState` includes fullscreen status and normal window bounds (`x`, `y`, `width`, `height`) and is restored on startup if the saved bounds overlap an available display.
**Config persistence:** `app.getPath('userData')/config.json``lastFolder`, `previewOn`, `sidebarWidth`, `fileExplorerVisible`, `thumbSize`, `windowState` 저장. `previewOn` and `fileExplorerVisible` default to `true`. `windowState` includes fullscreen status and normal window bounds (`x`, `y`, `width`, `height`) and is restored on startup if the saved bounds overlap an available display.
+90
View File
@@ -0,0 +1,90 @@
# Photo Renamer
macOS용 Electron 데스크톱 앱입니다. 폴더 안의 이미지 파일을 그리드로 확인하면서 순서를 조정하고, 일괄 이름 변경, 폴더 이동, 삭제를 수행할 수 있습니다.
## 주요 기능
- 이미지 폴더 열기 및 사이드바 폴더 탐색
- 파일 탐색기 표시/숨김 토글 및 상태 저장
- 썸네일 그리드 표시와 지연 로딩
- 드래그 앤 드롭으로 카드 순서 변경
- 선택 파일 또는 전체 파일의 순차 이름 변경
- 폴더 선택 시 이미지 유무와 관계없이 폴더명을 파일명 prefix로 자동 적용
- 사이드바 폴더로 파일 이동, 파일명 충돌 시 새 이름으로 이동
- 삭제 파일 staging 및 Undo/Redo
- Hover 이미지 미리보기 토글
- Ctrl+스크롤 썸네일 크기 조절
- Finder 스타일 카드 선택
- Click, Shift+Click, Cmd+Click
- Rubber-band drag
- Arrow, Home, End 키보드 네비게이션
- Shift+Arrow/Home/End 범위 추가 선택
## 설치 및 실행
```bash
npm install
npm start
```
처음 설정부터 실행하려면:
```bash
./setup.sh
```
## 빌드
macOS 앱 번들만 빌드:
```bash
npm run build
```
DMG 패키지까지 생성:
```bash
npm run dist
```
빌드 결과는 `dist/` 아래에 생성됩니다. 로컬에 유효한 Apple Developer 코드서명 인증서가 없으면 electron-builder가 코드서명을 건너뛰며 경고를 출력할 수 있습니다.
## 키보드 단축키
| 키 | 동작 |
|---|---|
| `Cmd+O` | 폴더 열기 |
| `Cmd+S` | 이름 변경 실행 |
| `A` | 전체 선택 |
| `Cmd+Z` | Undo |
| `Cmd+Shift+Z` | Redo |
| `Space` | Hover 미리보기 on/off |
| `D` | 파일 탐색기 표시/숨김 |
| `Backspace` / `Delete` | 선택 파일 삭제 |
| `ArrowLeft` / `ArrowRight` / `ArrowUp` / `ArrowDown` | 해당 방향 카드로 이동 및 단독 선택 |
| `Home` / `End` | 첫 카드 / 마지막 카드로 이동 및 그리드 최상단 / 최하단 스크롤 |
| `Shift+Arrow*` / `Shift+Home` / `Shift+End` | 기존 선택을 유지하면서 anchor부터 타겟까지 범위 추가 선택 |
입력 모드와 관계없이 동작하도록 키보드 처리는 `e.code` 기준입니다. 모달이 열려 있을 때는 전역 단축키가 동작하지 않습니다.
## 프로젝트 구조
```text
assets/
├── icon.png
└── icon.icns
src/
├── main/ # Electron main process, filesystem IPC
├── preload/ # contextBridge API
└── renderer/ # UI, selection, grid, rename/move/delete flows
```
Electron 보안 설정은 `contextIsolation: true`, `nodeIntegration: false`를 사용합니다. 파일 시스템 접근은 `src/main/`에만 두고, renderer는 `window.api`를 통해 IPC만 호출합니다.
## 개발 메모
- 테스트와 린트 스크립트는 현재 구성되어 있지 않습니다.
- 설정값은 `app.getPath('userData')/config.json`에 저장됩니다. 최근 폴더, Hover 미리보기, 사이드바 폭, 파일 탐색기 표시 상태, 썸네일 크기, 창 위치/크기를 포함합니다.
- 삭제 Undo를 위해 삭제된 파일은 `userData/deleted_staging/` 아래에 임시 보관됩니다.
- 자세한 코드 작업 규칙과 아키텍처 메모는 `AGENTS.md`를 참고하세요.
+28 -2
View File
@@ -1,13 +1,14 @@
{
"name": "photo-renamer",
"version": "2.0.0",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "photo-renamer",
"version": "2.0.0",
"version": "1.0.0",
"devDependencies": {
"cross-env": "^10.1.0",
"electron": "^28.0.0",
"electron-builder": "^24.9.1"
}
@@ -303,6 +304,13 @@
"node": ">= 10.0.0"
}
},
"node_modules/@epic-web/invariant": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
"dev": true,
"license": "MIT"
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -1496,6 +1504,24 @@
"node": ">= 10"
}
},
"node_modules/cross-env": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@epic-web/invariant": "^1.0.0",
"cross-spawn": "^7.0.6"
},
"bin": {
"cross-env": "dist/bin/cross-env.js",
"cross-env-shell": "dist/bin/cross-env-shell.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+32 -6
View File
@@ -1,22 +1,47 @@
{
"name": "photo-renamer",
"version": "2.0.0",
"version": "1.0.0",
"description": "사진 파일 이름 정리 앱",
"main": "src/main/index.js",
"scripts": {
"start": "NODE_ENV=development electron .",
"debug": "NODE_ENV=development electron --inspect=9229 --remote-debugging-port=9222 . 2>&1 | tee debug.log",
"build": "NODE_ENV=production electron-builder --mac --dir",
"dist": "NODE_ENV=production electron-builder --mac"
"start": "cross-env NODE_ENV=development electron .",
"debug": "cross-env NODE_ENV=development electron --inspect=9229 --remote-debugging-port=9222 .",
"build": "cross-env NODE_ENV=production CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --win --dir",
"dist": "cross-env NODE_ENV=production CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --win",
"build:mac": "cross-env NODE_ENV=production electron-builder --mac --dir",
"dist:mac": "cross-env NODE_ENV=production electron-builder --mac"
},
"build": {
"appId": "com.photorenamer.app",
"productName": "Photo Renamer",
"win": {
"icon": "assets/icon.png",
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
}
]
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true
},
"mac": {
"category": "public.app-category.photography",
"icon": "assets/icon.icns",
"target": [
{ "target": "dmg", "arch": ["arm64", "x64"] }
{
"target": "dmg",
"arch": [
"arm64",
"x64"
]
}
]
},
"files": [
@@ -26,6 +51,7 @@
]
},
"devDependencies": {
"cross-env": "^10.1.0",
"electron": "^28.0.0",
"electron-builder": "^24.9.1"
}
+41 -31
View File
@@ -1,16 +1,16 @@
const { app, BrowserWindow, screen } = require('electron');
const path = require('path');
const { registerAll } = require('./ipc');
const { readConfig, updateConfig } = require('./config');
const logger = require('./logger');
const { app, BrowserWindow, screen, Menu } = require("electron");
const path = require("path");
const { registerAll } = require("./ipc");
const { readConfig, updateConfig } = require("./config");
const logger = require("./logger");
app.setName('Photo Renamer');
app.setName("Photo Renamer");
let mainWindow;
function createWindow() {
const windowState = getSavedWindowState();
logger.log('main', 'createWindow', windowState);
logger.log("main", "createWindow", windowState);
mainWindow = new BrowserWindow({
x: windowState.bounds?.x,
y: windowState.bounds?.y,
@@ -18,33 +18,36 @@ function createWindow() {
height: windowState.bounds?.height || 820,
minWidth: 900,
minHeight: 600,
titleBarStyle: 'hiddenInset',
backgroundColor: '#0f0f17',
icon: path.join(__dirname, '../../assets/icon.icns'),
titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "hidden",
backgroundColor: "#0f0f17",
icon: path.join(__dirname, "../../assets/icon.icns"),
webPreferences: {
preload: path.join(__dirname, '../preload/index.js'),
preload: path.join(__dirname, "../preload/index.js"),
contextIsolation: true,
nodeIntegration: false,
},
show: false,
});
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'));
mainWindow.loadFile(path.join(__dirname, "../renderer/index.html"));
mainWindow.once('ready-to-show', () => {
logger.log('main', 'ready-to-show');
mainWindow.once("ready-to-show", () => {
logger.log("main", "ready-to-show");
mainWindow.show();
if (windowState.isFullScreen) mainWindow.setFullScreen(true);
});
mainWindow.on('close', saveWindowState);
mainWindow.on("close", saveWindowState);
mainWindow.on('enter-full-screen', () => {
mainWindow.webContents.send('fullscreen-change', true);
mainWindow.on("enter-full-screen", () => {
mainWindow.webContents.send("fullscreen-change", true);
});
mainWindow.on('leave-full-screen', () => {
mainWindow.webContents.send('fullscreen-change', false);
mainWindow.on("leave-full-screen", () => {
mainWindow.webContents.send("fullscreen-change", false);
});
mainWindow.on("maximize", () => mainWindow.webContents.send("maximize-change", true));
mainWindow.on("unmaximize", () => mainWindow.webContents.send("maximize-change", false));
}
function getSavedWindowState() {
@@ -62,36 +65,43 @@ function isUsableBounds(bounds) {
const values = [bounds.x, bounds.y, bounds.width, bounds.height];
if (!values.every(Number.isFinite)) return false;
if (bounds.width < 900 || bounds.height < 600) return false;
return screen.getAllDisplays().some(display => rectanglesOverlap(bounds, display.workArea));
return screen
.getAllDisplays()
.some((display) => rectanglesOverlap(bounds, display.workArea));
}
function rectanglesOverlap(a, b) {
return a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y;
return (
a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y
);
}
function saveWindowState() {
if (!mainWindow) return;
const isFullScreen = mainWindow.isFullScreen();
const bounds = isFullScreen ? mainWindow.getNormalBounds() : mainWindow.getBounds();
updateConfig(cfg => {
const bounds = isFullScreen
? mainWindow.getNormalBounds()
: mainWindow.getBounds();
updateConfig((cfg) => {
cfg.windowState = { isFullScreen, bounds };
});
}
app.whenReady().then(() => {
logger.log('main', 'app ready');
logger.log("main", "app ready");
Menu.setApplicationMenu(null);
createWindow();
registerAll(mainWindow);
logger.log('main', 'ipc registered');
logger.log("main", "ipc registered");
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});
app.on('activate', () => {
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
+2 -2
View File
@@ -92,7 +92,7 @@ function register() {
return { restored, errors };
});
ipcMain.handle('rename-files', async (_event, { folderPath, files, prefix, digits }) => {
ipcMain.handle('rename-files', async (_event, { folderPath, files, prefix, digits, postfix = '' }) => {
const errors = [];
const pad = (n) => String(n + 1).padStart(digits, '0');
@@ -113,7 +113,7 @@ function register() {
// 2단계: 최종 이름으로 이동
const renamed = [];
for (const { tmp, ext, idx } of tmpMap) {
const newName = `${prefix}_${pad(idx)}${ext}`;
const newName = `${prefix}${pad(idx)}${postfix}${ext}`;
const dest = path.join(folderPath, newName);
try {
fs.renameSync(tmp, dest);
+56 -7
View File
@@ -6,6 +6,14 @@ const { readConfig, updateConfig } = require('../config');
const logger = require('../logger');
function register(mainWindow) {
ipcMain.handle('window-minimize', () => mainWindow.minimize());
ipcMain.handle('window-maximize', () => {
if (mainWindow.isMaximized()) mainWindow.unmaximize();
else mainWindow.maximize();
});
ipcMain.handle('window-close', () => mainWindow.close());
ipcMain.handle('window-is-maximized', () => mainWindow.isMaximized());
ipcMain.handle('select-folder', async () => {
logger.log('ipc:folder', 'select-folder requested');
const result = await dialog.showOpenDialog(mainWindow, {
@@ -22,6 +30,19 @@ function register(mainWindow) {
ipcMain.handle('get-home-dir', () => os.homedir());
ipcMain.handle('get-drives', () => {
if (process.platform !== 'win32') return [];
const drives = [];
for (let i = 67; i <= 90; i++) { // C-Z
const drive = `${String.fromCharCode(i)}:\\`;
try {
fs.accessSync(drive);
drives.push(drive);
} catch {}
}
return drives;
});
ipcMain.handle('get-last-folder', () => {
const cfg = readConfig();
logger.log('ipc:folder', 'get-last-folder config', { lastFolder: cfg.lastFolder || null });
@@ -46,15 +67,14 @@ function register(mainWindow) {
try {
const entries = fs.readdirSync(folderPath, { withFileTypes: true });
return entries
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
.filter(e => !e.name.startsWith('.') && isDirectoryEntry(folderPath, e))
.map(e => {
const fullPath = path.join(folderPath, e.name);
let hasChildren = false;
try {
hasChildren = fs.readdirSync(fullPath, { withFileTypes: true })
.some(c => c.isDirectory() && !c.name.startsWith('.'));
} catch {}
return { name: e.name, path: fullPath, hasChildren };
return {
name: e.name,
path: fullPath,
hasChildren: hasVisibleDirectoryChildren(fullPath),
};
})
.sort((a, b) => a.name.localeCompare(b.name));
} catch { return null; }
@@ -80,6 +100,15 @@ function register(mainWindow) {
updateConfig(cfg => { cfg.sidebarWidth = width; });
});
ipcMain.handle('get-file-explorer-visible', () => {
const cfg = readConfig();
return cfg.fileExplorerVisible !== false; // 기본값 true
});
ipcMain.handle('set-file-explorer-visible', (_e, visible) => {
updateConfig(cfg => { cfg.fileExplorerVisible = visible; });
});
ipcMain.handle('get-thumb-size', () => {
const cfg = readConfig();
return Number.isFinite(cfg.thumbSize) ? cfg.thumbSize : null;
@@ -90,4 +119,24 @@ function register(mainWindow) {
});
}
function isDirectoryEntry(parentPath, entry) {
if (entry.isDirectory()) return true;
if (!entry.isSymbolicLink()) return false;
try {
return fs.statSync(path.join(parentPath, entry.name)).isDirectory();
} catch {
return false;
}
}
function hasVisibleDirectoryChildren(folderPath) {
try {
return fs.readdirSync(folderPath, { withFileTypes: true })
.some(entry => !entry.name.startsWith('.') && isDirectoryEntry(folderPath, entry));
} catch {
return false;
}
}
module.exports = { register };
+13
View File
@@ -8,7 +8,17 @@ contextBridge.exposeInMainWorld('api', {
isProduction: env.isProduction,
logEnabled: env.logEnabled,
nodeEnv: env.nodeEnv,
platform: process.platform,
}),
windowMinimize: () => ipcRenderer.invoke('window-minimize'),
windowMaximize: () => ipcRenderer.invoke('window-maximize'),
windowClose: () => ipcRenderer.invoke('window-close'),
windowIsMaximized: () => ipcRenderer.invoke('window-is-maximized'),
onMaximizeChange: (() => {
let _cb = null;
ipcRenderer.on('maximize-change', (_e, v) => { if (_cb) _cb(v); });
return (callback) => { _cb = callback; };
})(),
selectFolder: () => ipcRenderer.invoke('select-folder'),
listImages: (folder) => ipcRenderer.invoke('list-images', folder),
readImage: (path) => ipcRenderer.invoke('read-image', path),
@@ -19,6 +29,7 @@ contextBridge.exposeInMainWorld('api', {
return (callback) => { _cb = callback; };
})(),
getHomeDir: () => ipcRenderer.invoke('get-home-dir'),
getDrives: () => ipcRenderer.invoke('get-drives'),
getLastFolder: () => ipcRenderer.invoke('get-last-folder'),
setLastFolder: (p) => ipcRenderer.invoke('set-last-folder', p),
listDirectories: (p) => ipcRenderer.invoke('list-directories', p),
@@ -27,6 +38,8 @@ contextBridge.exposeInMainWorld('api', {
setPreviewOn: (on) => ipcRenderer.invoke('set-preview-on', on),
getSidebarWidth: () => ipcRenderer.invoke('get-sidebar-width'),
setSidebarWidth: (width) => ipcRenderer.invoke('set-sidebar-width', width),
getFileExplorerVisible: () => ipcRenderer.invoke('get-file-explorer-visible'),
setFileExplorerVisible: (visible) => ipcRenderer.invoke('set-file-explorer-visible', visible),
getThumbSize: () => ipcRenderer.invoke('get-thumb-size'),
setThumbSize: (size) => ipcRenderer.invoke('set-thumb-size', size),
checkFilesExist: (folder, names) => ipcRenderer.invoke('check-files-exist', folder, names),
+10 -8
View File
@@ -14,6 +14,7 @@ export function createFolderController({
updatePreview,
}) {
let homeDir = null;
let _drives = [];
async function openFolder() {
log('folder', 'openFolder start', { homeDir });
@@ -28,11 +29,7 @@ export function createFolderController({
log('folder', 'openFolder selected', { folder });
if (!folder) return;
if (homeDir && folder.startsWith(homeDir)) {
await fileExplorer.loadTree(homeDir, folder);
} else {
await fileExplorer.loadTree(folder, folder);
}
await fileExplorer.loadTree(homeDir, folder, _drives);
await loadFolder(folder);
}
@@ -43,6 +40,7 @@ export function createFolderController({
dirPath.textContent = folder;
await window.api.setLastFolder(folder);
await fileExplorer.setSelectedPath(folder);
updateFolderPrefix(folder);
showLoading('이미지 목록 불러오는 중...');
let list = [];
@@ -56,6 +54,7 @@ export function createFolderController({
state.selectedIdxs.clear();
showEmptyGrid();
btnRename.disabled = true;
updatePreview();
setStatus('이미지 파일 없음', false);
log('folder', 'loadFolder empty', { folder });
return;
@@ -68,9 +67,6 @@ export function createFolderController({
updateSortButtons();
setStatus(`${state.files.length}개 파일 로드됨`, true);
const folderName = folder.replace(/\\/g, '/').split('/').pop();
if (folderName) inputPrefix.value = folderName;
renderGrid();
updatePreview();
btnRename.disabled = false;
@@ -104,10 +100,16 @@ export function createFolderController({
btnSortDate.classList.toggle('active', state.sortMode === 'date');
}
function updateFolderPrefix(folder) {
const folderName = folder.replace(/\\/g, '/').split('/').pop();
if (folderName) inputPrefix.value = `${folderName}_`;
}
return {
loadFolder,
openFolder,
setHomeDir: value => { homeDir = value; },
setDrives: value => { _drives = value; },
sortFiles,
updateSortButtons,
};
+104 -1
View File
@@ -7,12 +7,112 @@ import { buildSequentialName, getNameFormat } from './utils/file-names.js';
const grid = document.getElementById('grid');
const emptyState = document.getElementById('empty-state');
const gridContainer = document.getElementById('grid-container');
const gridScrollbar = document.getElementById('grid-scrollbar');
const gridScrollbarThumb = document.getElementById('grid-scrollbar-thumb');
const fileCount = document.getElementById('file-count');
const selCount = document.getElementById('sel-count');
const inputPrefix = document.getElementById('input-prefix');
const inputDigits = document.getElementById('input-digits');
const inputPostfix = document.getElementById('input-postfix');
let _cardObserver = null;
let _scrollStateRaf = null;
let _scrollbarDrag = null;
function updateGridScrollState() {
if (_scrollStateRaf) cancelAnimationFrame(_scrollStateRaf);
_scrollStateRaf = requestAnimationFrame(() => {
_scrollStateRaf = null;
const isScrollable = gridContainer.scrollHeight > gridContainer.clientHeight;
gridContainer.classList.toggle('is-scrollable', isScrollable);
updateGridScrollbarFrame();
updateGridScrollbar();
});
}
function updateGridScrollbarFrame() {
if (!gridScrollbar) return;
const rect = gridContainer.getBoundingClientRect();
const top = rect.top + 20;
const height = Math.max(0, rect.height - 40);
gridScrollbar.style.setProperty('--grid-scrollbar-top', `${top}px`);
gridScrollbar.style.setProperty('--grid-scrollbar-height', `${height}px`);
}
function updateGridScrollbar() {
if (!gridScrollbar || !gridScrollbarThumb) return;
const { clientHeight, scrollHeight, scrollTop } = gridContainer;
const maxScrollTop = scrollHeight - clientHeight;
if (maxScrollTop <= 0) {
gridScrollbarThumb.style.height = '';
gridScrollbarThumb.style.transform = '';
return;
}
const trackHeight = gridScrollbar.clientHeight;
const thumbHeight = Math.max(36, Math.round((clientHeight / scrollHeight) * trackHeight));
const maxThumbTop = trackHeight - thumbHeight;
const thumbTop = Math.round((scrollTop / maxScrollTop) * maxThumbTop);
gridScrollbarThumb.style.height = `${thumbHeight}px`;
gridScrollbarThumb.style.transform = `translateY(${thumbTop}px)`;
}
function scrollGridFromPointer(clientY) {
const trackRect = gridScrollbar.getBoundingClientRect();
const thumbHeight = gridScrollbarThumb.offsetHeight;
const maxThumbTop = gridScrollbar.clientHeight - thumbHeight;
const maxScrollTop = gridContainer.scrollHeight - gridContainer.clientHeight;
if (maxThumbTop <= 0 || maxScrollTop <= 0) return;
const targetThumbTop = Math.min(
maxThumbTop,
Math.max(0, clientY - trackRect.top - _scrollbarDrag.thumbOffset),
);
gridContainer.scrollTop = (targetThumbTop / maxThumbTop) * maxScrollTop;
}
const _gridResizeObserver = new ResizeObserver(updateGridScrollState);
_gridResizeObserver.observe(gridContainer);
_gridResizeObserver.observe(grid);
gridContainer.addEventListener('scroll', updateGridScrollbar);
window.addEventListener('resize', updateGridScrollState);
gridScrollbar.addEventListener('pointerdown', (e) => {
if (!gridContainer.classList.contains('is-scrollable')) return;
const thumbRect = gridScrollbarThumb.getBoundingClientRect();
const isThumbPointer = e.target === gridScrollbarThumb;
_scrollbarDrag = {
thumbOffset: isThumbPointer ? e.clientY - thumbRect.top : thumbRect.height / 2,
};
gridScrollbar.classList.add('dragging');
gridScrollbar.setPointerCapture(e.pointerId);
scrollGridFromPointer(e.clientY);
e.preventDefault();
});
gridScrollbar.addEventListener('pointermove', (e) => {
if (!_scrollbarDrag) return;
scrollGridFromPointer(e.clientY);
});
gridScrollbar.addEventListener('pointerup', (e) => {
_scrollbarDrag = null;
gridScrollbar.classList.remove('dragging');
gridScrollbar.releasePointerCapture(e.pointerId);
});
gridScrollbar.addEventListener('pointercancel', (e) => {
_scrollbarDrag = null;
gridScrollbar.classList.remove('dragging');
gridScrollbar.releasePointerCapture(e.pointerId);
});
export function renderGrid() {
if (_cardObserver) { _cardObserver.disconnect(); _cardObserver = null; }
@@ -43,6 +143,7 @@ export function renderGrid() {
refreshCardSelection();
updateNumberBadges();
updateNewNameLabels();
updateGridScrollState();
}
export function createCard(file, idx) {
@@ -99,6 +200,7 @@ export function createCard(file, idx) {
export function refreshCardSelection() {
Array.from(grid.children).forEach((card, i) => {
card.classList.toggle('selected', state.selectedIdxs.has(i));
card.classList.toggle('keyboard-target', selection.isKeyboardTarget(i));
});
}
@@ -111,7 +213,7 @@ export function updateNumberBadges() {
}
export function updateNewNameLabels() {
const format = getNameFormat(inputPrefix, inputDigits);
const format = getNameFormat(inputPrefix, inputDigits, 3, inputPostfix);
Array.from(grid.children).forEach((card, i) => {
const el = card.querySelector('.card-new-name');
const file = state.files[i];
@@ -137,4 +239,5 @@ export function showEmptyGrid() {
emptyState.style.display = '';
fileCount.textContent = '0개 파일';
updateSelCount();
updateGridScrollState();
}
+34 -13
View File
@@ -8,6 +8,7 @@ let _selectedPath = null;
const ICON_CLOSED = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 3.5C1 2.67 1.67 2 2.5 2H5.38l1.5 1.5H11.5C12.33 3.5 13 4.17 13 5v5.5C13 11.33 12.33 12 11.5 12h-9C1.67 12 1 11.33 1 10.5V3.5Z" fill="#7c6af7" fill-opacity=".75"/><path d="M1 5.5h12v5C13 11.33 12.33 12 11.5 12h-9C1.67 12 1 11.33 1 10.5V5.5Z" fill="#9180ff" fill-opacity=".9"/></svg>`;
const ICON_OPEN = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 3.5C1 2.67 1.67 2 2.5 2H5.38l1.5 1.5H11.5C12.33 3.5 13 4.17 13 5v5.5C13 11.33 12.33 12 11.5 12h-9C1.67 12 1 11.33 1 10.5V3.5Z" fill="#c9b8ff" fill-opacity=".7"/><path d="M1 5.5h12v5C13 11.33 12.33 12 11.5 12h-9C1.67 12 1 11.33 1 10.5V5.5Z" fill="#e0d5ff" fill-opacity=".85"/></svg>`;
const ICON_HOME = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7 1.5L1 6.5V12.5H5.5V9H8.5V12.5H13V6.5L7 1.5Z" fill="#6ac8f7" fill-opacity=".9"/></svg>`;
const ICON_DRIVE = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="3.5" width="12" height="7" rx="1.5" fill="#7c6af7" fill-opacity=".75"/><rect x="1" y="7" width="12" height="3.5" rx="1.5" fill="#9180ff" fill-opacity=".9"/><circle cx="10.5" cy="9" r="1" fill="white" fill-opacity=".7"/></svg>`;
export function init(container, onSelect) {
_onSelect = onSelect;
@@ -15,29 +16,47 @@ export function init(container, onSelect) {
container.classList.add('tree-root');
}
export async function loadTree(rootPath, selectedPath) {
export async function loadTree(rootPath, selectedPath, drives = []) {
_selectedPath = selectedPath;
const container = document.querySelector('.file-explorer');
if (!container) return;
container.innerHTML = '';
const rootNode = createNode({
name: rootPath.split('/').pop() || rootPath,
path: rootPath,
hasChildren: true,
isRoot: true,
});
container.appendChild(rootNode);
await expandNode(rootNode, selectedPath);
if (drives.length > 0) {
for (const drive of drives) {
const driveNode = createNode({
name: drive.replace(/\\$/, ''),
path: drive,
hasChildren: true,
isRoot: true,
isDrive: true,
});
container.appendChild(driveNode);
const selectedUpper = (selectedPath || '').toUpperCase();
if (selectedUpper.startsWith(drive.toUpperCase())) {
await expandNode(driveNode, selectedPath);
}
}
} else {
const rootNode = createNode({
name: rootPath.split(/[\\/]/).filter(Boolean).pop() || rootPath,
path: rootPath,
hasChildren: true,
isRoot: true,
});
container.appendChild(rootNode);
await expandNode(rootNode, selectedPath);
}
}
function createNode({ name, path: nodePath, hasChildren, isRoot = false }) {
function createNode({ name, path: nodePath, hasChildren, isRoot = false, isDrive = false }) {
const wrap = document.createElement('div');
wrap.className = 'tree-node';
wrap.dataset.path = nodePath;
wrap.dataset.expanded = 'false';
wrap.dataset.hasChildren = hasChildren ? 'true' : 'false';
wrap.dataset.isRoot = isRoot ? 'true' : 'false';
wrap.dataset.isDrive = isDrive ? 'true' : 'false';
const row = document.createElement('div');
row.className = 'tree-row';
@@ -50,7 +69,7 @@ function createNode({ name, path: nodePath, hasChildren, isRoot = false }) {
const icon = document.createElement('span');
icon.className = 'tree-icon';
icon.innerHTML = isRoot ? ICON_HOME : ICON_CLOSED;
icon.innerHTML = isDrive ? ICON_DRIVE : (isRoot ? ICON_HOME : ICON_CLOSED);
const label = document.createElement('span');
label.className = 'tree-label';
@@ -91,7 +110,9 @@ function createNode({ name, path: nodePath, hasChildren, isRoot = false }) {
}
function nodeIcon(nodeEl) {
return nodeEl.dataset.isRoot === 'true' ? ICON_HOME : ICON_OPEN;
if (nodeEl.dataset.isDrive === 'true') return ICON_DRIVE;
if (nodeEl.dataset.isRoot === 'true') return ICON_HOME;
return ICON_OPEN;
}
function selectNode(nodeEl, { notify = true } = {}) {
@@ -140,7 +161,7 @@ async function expandNode(nodeEl, autoExpandPath) {
const childNodes = childrenEl.querySelectorAll(':scope > .tree-node');
for (const child of childNodes) {
const cp = child.dataset.path;
if (autoExpandPath === cp || autoExpandPath.startsWith(cp + '/')) {
if (autoExpandPath === cp || autoExpandPath.startsWith(cp + '/') || autoExpandPath.startsWith(cp + '\\')) {
await expandNode(child, autoExpandPath);
break;
}
+111 -1
View File
@@ -1,4 +1,4 @@
let _rubberBand, _grid;
let _rubberBand, _grid, _gridContainer;
let _state, _cb;
let rbActive = false;
@@ -8,15 +8,24 @@ let _rafPending = false;
let _lastMoveEvent = null;
let lastSelectedIdx = null;
let keyboardTargetIdx = null;
let keyboardExtendBaseSelection = null;
let _rbStartSelection = new Set(); // 러버밴드 시작 시점의 선택 스냅샷
export function resetAnchor() {
lastSelectedIdx = null;
keyboardTargetIdx = null;
keyboardExtendBaseSelection = null;
}
export function isKeyboardTarget(idx) {
return keyboardTargetIdx === idx;
}
export function init({ gridContainer, rubberBand, grid, state, callbacks }) {
_rubberBand = rubberBand;
_grid = grid;
_gridContainer = gridContainer;
_state = state;
_cb = callbacks;
@@ -51,10 +60,39 @@ export function onCardClick(e) {
}
}
keyboardTargetIdx = idx;
keyboardExtendBaseSelection = null;
_cb.refreshCardSelection();
_cb.updateSelCount();
}
export function handleKeyboardNavigation(code, { extend = false } = {}) {
if (!_state?.files.length) return false;
const currentIdx = _getCurrentKeyboardIndex(code);
const nextIdx = _getNextKeyboardIndex(currentIdx, code);
if (nextIdx === null) return false;
if (extend) {
if (lastSelectedIdx === null) lastSelectedIdx = currentIdx ?? nextIdx;
if (!keyboardExtendBaseSelection) {
keyboardExtendBaseSelection = new Set(_state.selectedIdxs);
}
_selectKeyboardRange(lastSelectedIdx, nextIdx, keyboardExtendBaseSelection);
} else {
_state.selectedIdxs.clear();
_state.selectedIdxs.add(nextIdx);
lastSelectedIdx = nextIdx;
keyboardExtendBaseSelection = null;
}
keyboardTargetIdx = nextIdx;
_cb.refreshCardSelection();
_cb.updateSelCount();
_scrollKeyboardTargetIntoView(code, nextIdx);
return true;
}
function _toggle(idx) {
if (_state.selectedIdxs.has(idx)) {
_state.selectedIdxs.delete(idx);
@@ -63,6 +101,7 @@ function _toggle(idx) {
_state.selectedIdxs.add(idx);
lastSelectedIdx = idx;
}
keyboardExtendBaseSelection = null;
}
function _selectRange(idx) {
@@ -70,6 +109,7 @@ function _selectRange(idx) {
_state.selectedIdxs.clear();
_state.selectedIdxs.add(idx);
lastSelectedIdx = idx;
keyboardExtendBaseSelection = null;
return;
}
const start = Math.min(lastSelectedIdx, idx);
@@ -77,6 +117,74 @@ function _selectRange(idx) {
_state.selectedIdxs.clear();
for (let i = start; i <= end; i++) _state.selectedIdxs.add(i);
// anchor(lastSelectedIdx)는 Shift 클릭으로 변경하지 않음
keyboardExtendBaseSelection = null;
}
function _selectKeyboardRange(anchorIdx, targetIdx, baseSelection) {
const start = Math.min(anchorIdx, targetIdx);
const end = Math.max(anchorIdx, targetIdx);
_state.selectedIdxs.clear();
baseSelection.forEach(idx => {
if (_isValidIndex(idx)) _state.selectedIdxs.add(idx);
});
for (let i = start; i <= end; i++) _state.selectedIdxs.add(i);
}
function _getCurrentKeyboardIndex(code) {
if (_isValidIndex(keyboardTargetIdx)) return keyboardTargetIdx;
if (_state.selectedIdxs.size) return Math.min(..._state.selectedIdxs);
return null;
}
function _getNextKeyboardIndex(currentIdx, code) {
const lastIdx = _state.files.length - 1;
if (currentIdx === null) {
return (code === 'ArrowLeft' || code === 'ArrowUp' || code === 'End')
? lastIdx
: 0;
}
if (code === 'Home') return 0;
if (code === 'End') return lastIdx;
if (code === 'ArrowLeft') return Math.max(0, currentIdx - 1);
if (code === 'ArrowRight') return Math.min(lastIdx, currentIdx + 1);
const columnCount = _getColumnCount();
if (code === 'ArrowUp') return Math.max(0, currentIdx - columnCount);
if (code === 'ArrowDown') return Math.min(lastIdx, currentIdx + columnCount);
return null;
}
function _getColumnCount() {
const cards = Array.from(_grid.children).filter(card => card.classList.contains('card'));
if (!cards.length) return 1;
const firstTop = cards[0].getBoundingClientRect().top;
const firstRowCount = cards.findIndex(card =>
Math.abs(card.getBoundingClientRect().top - firstTop) > 2
);
return firstRowCount === -1 ? cards.length : Math.max(1, firstRowCount);
}
function _scrollCardIntoView(idx) {
const card = _grid.querySelector(`.card[data-idx="${idx}"]`);
card?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
function _scrollKeyboardTargetIntoView(code, idx) {
if (code === 'Home') {
_gridContainer.scrollTop = 0;
return;
}
if (code === 'End') {
_gridContainer.scrollTop = _gridContainer.scrollHeight - _gridContainer.clientHeight;
return;
}
_scrollCardIntoView(idx);
}
function _isValidIndex(idx) {
return Number.isInteger(idx) && idx >= 0 && idx < _state.files.length;
}
function _onStart(e) {
@@ -102,6 +210,8 @@ function _onStart(e) {
_state.selectedIdxs.clear();
_rbStartSelection.clear();
lastSelectedIdx = null;
keyboardTargetIdx = null;
keyboardExtendBaseSelection = null;
_cb.refreshCardSelection();
}
}
+63 -43
View File
@@ -10,58 +10,78 @@
<header>
<h1>Photo Renamer</h1>
<div class="window-controls" id="window-controls">
<button class="wc-btn" id="btn-wc-minimize" title="최소화">
<svg width="10" height="1" viewBox="0 0 10 1"><rect width="10" height="1" fill="currentColor"/></svg>
</button>
<button class="wc-btn" id="btn-wc-maximize" title="최대화">
<svg width="10" height="10" viewBox="0 0 10 10"><rect x="0.5" y="0.5" width="9" height="9" stroke="currentColor" fill="none"/></svg>
</button>
<button class="wc-btn wc-close" id="btn-wc-close" title="닫기">
<svg width="10" height="10" viewBox="0 0 10 10"><line x1="0" y1="0" x2="10" y2="10" stroke="currentColor" stroke-width="1.2"/><line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1.2"/></svg>
</button>
</div>
</header>
<div class="app-layout">
<aside>
<nav class="sidebar-nav">
<section class="app-workspace" aria-label="사진 이름 변경 작업 영역">
<nav class="workspace-nav" aria-label="작업 도구">
<div class="workspace-actions">
<button class="btn btn-accent active" id="btn-file-explorer-toggle" aria-pressed="true">🗂️ (D)</button>
<div class="separator"></div>
<button class="btn btn-warning" id="btn-open">폴더 열기 (⌘O)</button>
<button class="btn btn-success" id="btn-rename" disabled>이름 변경 실행 (⌘S)</button>
</nav>
<section class="file-explorer">
<!-- 추후 구현 -->
</section>
</aside>
<div id="sidebar-resizer" aria-hidden="true"></div>
<main>
<nav class="content-nav">
<div class="sort-group">
<button class="sort-btn" id="btn-sort-name">이름순</button>
<button class="sort-btn" id="btn-sort-date">날짜순</button>
</div>
<div class="separator"></div>
<button class="btn btn-info" id="btn-preview-toggle">미리보기</button>
</div>
<div class="workspace-controls">
<button class="btn btn-info" id="btn-preview-toggle">미리보기 (SPACE)</button>
<div class="separator"></div>
<button class="btn btn-ghost" id="btn-undo" disabled>↩ 되돌리기</button>
<button class="btn btn-ghost" id="btn-redo" disabled>↪ 다시하기</button>
</nav>
<form id="format-bar">
<label for="input-prefix">파일명 포맷</label>
<input type="text" id="input-prefix" value="photo" placeholder="접두사" />
<span class="format-sep">_</span>
<input type="number" id="input-digits" value="4" min="1" max="6" />
<span class="format-sep">자리 순번</span>
<span class="format-arrow"></span>
<span id="preview-name">photo_001.jpg</span>
<span id="sel-count"></span>
<span id="file-count">0개 파일</span>
</form>
<section id="grid-container">
<div id="empty-state">
<div class="icon">🗂️</div>
<p>폴더를 열어 이미지를 불러오세요<br>
<span class="empty-state-hint">JPG · PNG · GIF · BMP · TIFF · WEBP · HEIC</span></p>
<div class="separator"></div>
<div class="sort-group" aria-label="정렬">
<button class="sort-btn" id="btn-sort-name">이름순</button>
<button class="sort-btn" id="btn-sort-date">날짜순</button>
</div>
<div id="grid" style="display:none;"></div>
</section>
</main>
</div>
</nav>
</div>
<div class="app-layout">
<aside>
<section class="file-explorer" aria-label="폴더 탐색기">
<!-- 추후 구현 -->
</section>
</aside>
<div id="sidebar-resizer" aria-hidden="true"></div>
<main>
<form id="format-bar">
<label for="input-prefix">파일명 포맷</label>
<input type="text" id="input-prefix" value="photo_" placeholder="접두사" />
<input type="number" id="input-digits" value="4" min="1" max="6" />
<span class="format-sep">자리 순번</span>
<input type="text" id="input-postfix" value="_" maxlength="3" size="3" placeholder="접미사" />
<span class="format-arrow"></span>
<span id="preview-name">photo_0001_.jpg</span>
<span id="sel-count"></span>
<span id="file-count">0개 파일</span>
</form>
<section id="grid-container">
<div id="empty-state">
<div class="icon">🗂️</div>
<p>폴더를 열어 이미지를 불러오세요<br>
<span class="empty-state-hint">JPG · PNG · GIF · BMP · TIFF · WEBP · HEIC</span></p>
</div>
<div id="grid" style="display:none;"></div>
<div id="grid-scrollbar" aria-hidden="true">
<div id="grid-scrollbar-thumb"></div>
</div>
</section>
</main>
</div>
</section>
<footer>
<div class="status-dot" id="status-dot"></div>
+12
View File
@@ -1,9 +1,11 @@
import { log } from './logger.js';
import * as selection from './handlers/selection.js';
export function initKeyboardShortcuts({
btnOpen,
btnRename,
btnPreviewToggle,
btnFileExplorerToggle,
deleteSelected,
performUndo,
performRedo,
@@ -37,12 +39,22 @@ export function initKeyboardShortcuts({
} else if (e.code === 'Space') {
e.preventDefault();
btnPreviewToggle.click();
} else if (e.code === 'KeyD' && !e.metaKey && !e.ctrlKey && !e.altKey) {
e.preventDefault();
btnFileExplorerToggle.click();
} else if (e.code === 'Backspace' || e.code === 'Delete') {
deleteSelected();
} else if (isNavigationKey(e.code) && !e.metaKey && !e.ctrlKey && !e.altKey) {
e.preventDefault();
selection.handleKeyboardNavigation(e.code, { extend: e.shiftKey });
}
});
}
function isNavigationKey(code) {
return ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(code);
}
function isEditableTarget(target) {
return ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || target.isContentEditable;
}
+4 -2
View File
@@ -8,6 +8,7 @@ import { buildSequentialName, getNameFormat } from './utils/file-name
const inputPrefix = document.getElementById('input-prefix');
const inputDigits = document.getElementById('input-digits');
const inputPostfix = document.getElementById('input-postfix');
const renameConfirmModal = document.getElementById('rename-confirm-modal');
const renameConfirmSelectedTitle = renameConfirmModal.querySelector('.rename-title-selected');
const renameConfirmAllTitle = renameConfirmModal.querySelector('.rename-title-all');
@@ -30,8 +31,8 @@ renameConfirmModal.addEventListener('modal:closed', () => {
export async function doRename() {
if (!state.files.length || !state.currentFolder) return;
const { prefix, digits } = getNameFormat(inputPrefix, inputDigits);
const format = { prefix, digits };
const { prefix, digits, postfix } = getNameFormat(inputPrefix, inputDigits, 3, inputPostfix);
const format = { prefix, digits, postfix };
const selectedArr = [...state.selectedIdxs].sort((a, b) => a - b);
const filesToRename = selectedArr.length > 0
@@ -75,6 +76,7 @@ export async function doRename() {
files: filesToRename,
prefix,
digits,
postfix,
});
hideLoading();
if (result.errors.length) {
+49 -8
View File
@@ -7,7 +7,7 @@ import {
import { doRename } from './rename.js';
import { handleFolderDrop } from './move.js';
import { initModalControls } from './modal.js';
import { clampDigits, getExtension } from './utils/file-names.js';
import { buildSequentialName, getNameFormat } from './utils/file-names.js';
import { initKeyboardShortcuts } from './keyboard.js';
import { createFolderController } from './folder-controller.js';
import { createHistoryController } from './history-controller.js';
@@ -27,6 +27,7 @@ const btnSortDate = document.getElementById('btn-sort-date');
const btnRename = document.getElementById('btn-rename');
const inputPrefix = document.getElementById('input-prefix');
const inputDigits = document.getElementById('input-digits');
const inputPostfix = document.getElementById('input-postfix');
const previewName = document.getElementById('preview-name');
const dirPath = document.getElementById('dir-path');
const rubberBand = document.getElementById('rubber-band');
@@ -35,9 +36,11 @@ 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 btnFileExplorerToggle = document.getElementById('btn-file-explorer-toggle');
const btnUndo = document.getElementById('btn-undo');
const btnRedo = document.getElementById('btn-redo');
const fileExplorerContainer = document.querySelector('.file-explorer');
const appWorkspace = document.querySelector('.app-workspace');
const aside = document.querySelector('aside');
const sidebarResizer = document.getElementById('sidebar-resizer');
@@ -75,21 +78,47 @@ window.api.onFullscreenChange((isFullscreen) => {
document.body.classList.toggle('fullscreen', isFullscreen);
});
// ── 플랫폼 / 창 컨트롤 ──────────────────────────────
if (window.api.env.platform === 'win32') {
document.body.classList.add('platform-win32');
const ICON_MAXIMIZE = `<svg width="10" height="10" viewBox="0 0 10 10"><rect x="0.5" y="0.5" width="9" height="9" stroke="currentColor" fill="none"/></svg>`;
const ICON_RESTORE = `<svg width="10" height="10" viewBox="0 0 10 10"><rect x="2" y="0.5" width="7.5" height="7.5" stroke="currentColor" fill="none"/><polyline points="0.5,2 0.5,9.5 8,9.5" stroke="currentColor" fill="none"/></svg>`;
const btnMaximize = document.getElementById('btn-wc-maximize');
function updateMaximizeIcon(isMaximized) {
btnMaximize.innerHTML = isMaximized ? ICON_RESTORE : ICON_MAXIMIZE;
btnMaximize.title = isMaximized ? '이전 크기로' : '최대화';
}
window.api.windowIsMaximized().then(updateMaximizeIcon);
window.api.onMaximizeChange(updateMaximizeIcon);
document.getElementById('btn-wc-minimize').addEventListener('click', () => window.api.windowMinimize());
btnMaximize.addEventListener('click', () => window.api.windowMaximize());
document.getElementById('btn-wc-close').addEventListener('click', () => window.api.windowClose());
}
// ── 앱 시작 ───────────────────────────────────────
(async () => {
log('startup', 'init start');
const [lastFolder, homeDir, previewOn, sidebarWidth, thumbSize] = await Promise.all([
const [lastFolder, homeDir, previewOn, sidebarWidth, fileExplorerVisible, thumbSize, drives] = await Promise.all([
window.api.getLastFolder(),
window.api.getHomeDir(),
window.api.getPreviewOn(),
window.api.getSidebarWidth(),
window.api.getFileExplorerVisible(),
window.api.getThumbSize(),
window.api.getDrives(),
]);
log('startup', 'settings loaded', { lastFolder, homeDir, previewOn, sidebarWidth, thumbSize });
log('startup', 'settings loaded', { lastFolder, homeDir, previewOn, sidebarWidth, fileExplorerVisible, thumbSize, drives });
folderController.setHomeDir(homeDir);
folderController.setDrives(drives);
btnPreviewToggle.classList.toggle('active', previewOn);
hoverPreview.setEnabled(previewOn);
setFileExplorerVisible(fileExplorerVisible, { persist: false });
sidebarResize.init({
aside,
handle: sidebarResizer,
@@ -105,7 +134,7 @@ window.api.onFullscreenChange((isFullscreen) => {
const target = lastFolder || homeDir;
log('startup', 'initial folder target', { target, fromLastFolder: Boolean(lastFolder) });
await fileExplorer.loadTree(homeDir, target);
await fileExplorer.loadTree(homeDir, target, drives);
await folderController.loadFolder(target);
log('startup', 'init done');
})();
@@ -113,6 +142,7 @@ window.api.onFullscreenChange((isFullscreen) => {
// ── 버튼 / 키보드 이벤트 ─────────────────────────
inputPrefix.addEventListener('input', updatePreview);
inputDigits.addEventListener('input', updatePreview);
inputPostfix.addEventListener('input', updatePreview);
btnOpen.addEventListener('click', () => {
log('ui', 'open button clicked');
folderController.openFolder();
@@ -127,11 +157,16 @@ btnPreviewToggle.addEventListener('click', () => {
hoverPreview.setEnabled(on);
window.api.setPreviewOn(on);
});
btnFileExplorerToggle.addEventListener('click', () => {
const visible = appWorkspace.classList.contains('file-explorer-hidden');
setFileExplorerVisible(visible);
});
initKeyboardShortcuts({
btnOpen,
btnRename,
btnPreviewToggle,
btnFileExplorerToggle,
deleteSelected: deleteHandler.deleteSelected,
performUndo: historyController.performUndo,
performRedo: historyController.performRedo,
@@ -144,9 +179,15 @@ initKeyboardShortcuts({
// ── 포맷 미리보기 ─────────────────────────────────
function updatePreview() {
const prefix = inputPrefix.value.trim() || 'photo';
const digits = clampDigits(inputDigits.value);
const ext = state.files.length ? getExtension(state.files[0].name) : 'jpg';
previewName.textContent = `${prefix}_${'1'.padStart(digits, '0')}.${ext}`;
const format = getNameFormat(inputPrefix, inputDigits, 3, inputPostfix);
const fileName = state.files[0]?.name || 'preview.jpg';
previewName.textContent = buildSequentialName(fileName, 0, format);
updateNewNameLabels();
}
function setFileExplorerVisible(visible, { persist = true } = {}) {
appWorkspace.classList.toggle('file-explorer-hidden', !visible);
btnFileExplorerToggle.classList.toggle('active', visible);
btnFileExplorerToggle.setAttribute('aria-pressed', String(visible));
if (persist) window.api.setFileExplorerVisible(visible);
}
+137 -63
View File
@@ -24,6 +24,7 @@
--sel-bg: rgba(124,106,247,0.22);
--sel-border: #7c6af7;
--thumb-size: 250px;
--grid-scroll-track: #282740;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
@@ -66,6 +67,40 @@ header h1 {
font-size: 13px; font-weight: 600; color: var(--muted);
letter-spacing: .08em; text-transform: uppercase;
}
/* 커스텀 창 컨트롤 버튼 (Windows) */
.window-controls {
display: none;
position: absolute;
right: 0;
top: 0;
height: 44px;
-webkit-app-region: no-drag;
}
body.platform-win32 .window-controls {
display: flex;
}
.wc-btn {
width: 46px;
height: 44px;
border: none;
background: transparent;
color: var(--muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.1s, color 0.1s;
}
.wc-btn:hover {
background: rgba(255,255,255,0.1);
color: var(--fg);
}
.wc-close:hover {
background: #c42b1c;
color: #fff;
}
.dir-path {
font-family: 'JetBrains Mono', monospace;
font-size: 11px; color: var(--muted); opacity: .7;
@@ -73,11 +108,46 @@ header h1 {
margin-left: auto;
}
/* ── 작업 영역 ── */
.app-workspace {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.workspace-nav {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.workspace-actions,
.workspace-controls {
display: flex;
align-items: center;
gap: 8px;
}
.workspace-actions {
flex-shrink: 0;
}
.workspace-controls {
margin-left: auto;
}
.app-workspace.file-explorer-hidden aside,
.app-workspace.file-explorer-hidden #sidebar-resizer {
display: none;
}
/* ── 앱 레이아웃 ── */
.app-layout {
display: flex;
flex-direction: row;
flex: 1;
min-height: 0;
overflow: hidden;
}
@@ -120,18 +190,6 @@ body.resizing-sidebar {
cursor: col-resize;
user-select: none;
}
.sidebar-nav {
display: flex;
flex-direction: row;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.sidebar-nav .btn {
flex: 1;
justify-content: center;
}
.file-explorer {
flex: 1;
overflow: auto;
@@ -145,30 +203,23 @@ main {
overflow: hidden;
}
/* ── 콘텐츠 툴바 ── */
.content-nav {
display: flex; align-items: center; gap: 8px;
padding: 10px 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 14px; border-radius: 8px; border: none;
font-family: inherit; font-size: 13px; font-weight: 600;
cursor: pointer; transition: all .15s;
}
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { background: #9180ff; transform: translateY(-1px); }
.btn-ghost { background: transparent; color: var(--text); border: 1px solid var(--border); }
.btn-ghost:hover { background: var(--card); }
.btn-ghost:disabled { color: var(--muted); border-color: var(--border); opacity: .4; cursor: not-allowed; }
.btn-ghost.active { border-color: var(--accent); color: var(--accent); }
.btn-info { background: #0d2535; color: var(--info); border: 1px solid #1a4060; }
.btn-info:hover { background: #132e42; }
.btn-info:disabled { opacity: .4; cursor: not-allowed; }
.btn-info:not(.active) { opacity: .4; }
.btn-accent { background: rgba(124,106,247,.16); color: var(--accent); border: 1px solid rgba(124,106,247,.38); }
.btn-accent:hover { background: rgba(124,106,247,.24); }
.btn-accent:disabled { opacity: .4; cursor: not-allowed; }
.btn-accent:not(.active) { opacity: .4; }
.sort-group {
display: flex;
background: #0d2535;
@@ -216,6 +267,7 @@ main {
#format-bar input:focus { border-color: var(--accent); }
#input-prefix { width: 160px; }
#input-digits { width: 50px; text-align: center; }
#input-postfix { width: 54px; }
.format-sep { color: var(--muted); font-family: 'JetBrains Mono', monospace; font-size: 14px; }
.format-arrow { color: var(--muted); font-size: 12px; }
#preview-name {
@@ -233,18 +285,70 @@ main {
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.file-explorer::-webkit-scrollbar,
#grid-container::-webkit-scrollbar { width: 6px; }
.file-explorer::-webkit-scrollbar { width: 6px; }
#grid-container { scrollbar-width: none; }
#grid-container::-webkit-scrollbar { width: 0; height: 0; }
.file-explorer::-webkit-scrollbar-track,
#grid-container::-webkit-scrollbar-track { background: transparent; }
.file-explorer::-webkit-scrollbar-thumb,
#grid-container::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
.file-explorer::-webkit-scrollbar-track { background: transparent; }
.file-explorer::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
/* ── 그리드 컨테이너 ── */
#grid-container {
flex: 1; overflow-y: auto; padding: 20px;
flex: 1;
overflow-x: hidden;
overflow-y: hidden;
padding: 20px;
position: relative;
}
#grid-container.is-scrollable {
overflow-y: scroll;
}
#grid-scrollbar {
position: fixed;
top: var(--grid-scrollbar-top, 0);
right: 0;
height: var(--grid-scrollbar-height, 0);
z-index: 25;
display: none;
width: 12px;
border-radius: 999px;
background: transparent;
cursor: pointer;
}
#grid-container.is-scrollable #grid-scrollbar {
display: block;
}
#grid-scrollbar:hover,
#grid-scrollbar.dragging {
width: 12px;
}
#grid-scrollbar::before {
content: "";
position: absolute;
inset: 0 0 0 auto;
width: 6px;
border-radius: 999px;
background: var(--grid-scroll-track);
transition: width .16s ease;
}
#grid-scrollbar:hover::before,
#grid-scrollbar.dragging::before {
width: 12px;
}
#grid-scrollbar-thumb {
position: absolute;
top: 0;
right: 0;
width: 6px;
min-height: 36px;
border-radius: 999px;
background: var(--accent);
transition: width .16s ease, background .16s ease;
}
#grid-scrollbar:hover #grid-scrollbar-thumb,
#grid-scrollbar.dragging #grid-scrollbar-thumb {
width: 12px;
}
/* ── 그리드: 고정 열 폭 (--thumb-size) ── */
#grid {
@@ -284,6 +388,10 @@ main {
background: var(--sel-bg);
box-shadow: 0 0 0 1px var(--sel-border), 0 8px 24px rgba(124,106,247,.2);
}
.card.keyboard-target {
outline: 2px solid rgba(255, 255, 255, .72);
outline-offset: 2px;
}
.card.dragging,
.card.multi-dragging { opacity: .3; transform: scale(.96); cursor: grabbing; }
.card.drag-over { border-color: var(--accent) !important; background: var(--drag-over); }
@@ -442,40 +550,6 @@ footer span { font-size: 11px; color: var(--muted); }
@keyframes spin { to { transform: rotate(360deg); } }
#loading p { color: var(--text); font-size: 14px; }
/* ── 카드 호버 원형 로딩바 ── */
.card-hover-loader {
position: absolute;
top: calc(var(--thumb-size) / 2);
left: 50%;
transform: translate(-50%, -50%);
width: 52px;
height: 52px;
pointer-events: none;
z-index: 10;
overflow: visible;
}
.card-hover-loader .loader-bg {
fill: rgba(0, 0, 0, 0.55);
}
.card-hover-loader .loader-track {
fill: none;
stroke: rgba(255, 255, 255, 0.15);
stroke-width: 3;
}
.card-hover-loader .loader-progress {
fill: none;
stroke: var(--accent);
stroke-width: 3;
stroke-linecap: round;
transform-origin: 24px 24px;
transform: rotate(-90deg);
animation: hover-loader-fill 1s linear forwards;
}
@keyframes hover-loader-fill {
from { stroke-dashoffset: 125.66; }
to { stroke-dashoffset: 0; }
}
/* ── 호버 이미지 팝업 ── */
.hover-preview {
position: fixed;
+5 -4
View File
@@ -7,14 +7,15 @@ export function clampDigits(value, fallback = 3) {
return Math.max(1, Math.min(6, parseInt(value, 10) || fallback));
}
export function buildSequentialName(fileName, index, { prefix, digits }) {
export function buildSequentialName(fileName, index, { prefix, digits, postfix = '' }) {
const ext = getExtension(fileName);
return `${prefix}_${String(index + 1).padStart(digits, '0')}.${ext}`;
return `${prefix}${String(index + 1).padStart(digits, '0')}${postfix}.${ext}`;
}
export function getNameFormat(prefixInput, digitsInput, digitsFallback = 3) {
export function getNameFormat(prefixInput, digitsInput, digitsFallback = 3, postfixInput = null) {
return {
prefix: prefixInput.value.trim() || 'photo',
prefix: prefixInput.value.trim() || 'photo_',
digits: clampDigits(digitsInput.value, digitsFallback),
postfix: postfixInput?.value.trim() || '',
};
}