Compare commits
13 Commits
f3193672c6
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c34fa580cd | |||
| fd600001a0 | |||
| d60832120e | |||
| aa55a42988 | |||
| 48d5cfb7e3 | |||
| 48f86566d0 | |||
| 77622f8c6c | |||
| 0b9b2dea8b | |||
| ba0fe2f79f | |||
| 4000339477 | |||
| cc88aecac2 | |||
| bb5fd0a0e9 | |||
| 77bc2815a3 |
@@ -1,3 +1,8 @@
|
||||
### VS Code
|
||||
.vscode/*
|
||||
!.vscode/tasks.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
### IntelliJ
|
||||
.idea/
|
||||
*.iml
|
||||
|
||||
Vendored
+28
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 340–500px 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, 150–400px)
|
||||
└── 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.
|
||||
|
||||
@@ -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`를 참고하세요.
|
||||
Generated
+28
-2
@@ -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
@@ -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
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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() || '',
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user