Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa55a42988 | |||
| 48d5cfb7e3 | |||
| 48f86566d0 | |||
| 77622f8c6c | |||
| 0b9b2dea8b | |||
| ba0fe2f79f | |||
| 4000339477 | |||
| cc88aecac2 | |||
| bb5fd0a0e9 | |||
| 77bc2815a3 |
@@ -35,7 +35,8 @@ src/
|
|||||||
│ ├── file-utils.js # Pure helpers: listImages, readImageAsDataUrl, fileExists,
|
│ ├── file-utils.js # Pure helpers: listImages, readImageAsDataUrl, fileExists,
|
||||||
│ │ # moveFileWithCopyFallback (EXDEV-safe cross-device move)
|
│ │ # moveFileWithCopyFallback (EXDEV-safe cross-device move)
|
||||||
│ ├── folder.js # select-folder, list-directories, get/set-last-folder,
|
│ ├── 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
|
│ │ # get-home-dir, reveal-folder
|
||||||
│ └── files.js # list-images, read-image, rename-files, stage/restore-delete,
|
│ └── files.js # list-images, read-image, rename-files, stage/restore-delete,
|
||||||
│ # check-files-exist, move-files
|
│ # check-files-exist, move-files
|
||||||
@@ -66,9 +67,9 @@ src/
|
|||||||
│ └── ghost.js # Drag ghost UI helper
|
│ └── ghost.js # Drag ghost UI helper
|
||||||
├── file-explorer.js # Sidebar directory tree (lazy-load, expand/collapse)
|
├── 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
|
├── 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)
|
├── 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
|
### 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 |
|
| `select-folder` | invoke | Opens native folder picker, returns path or null |
|
||||||
| `list-images` | invoke | Reads directory, returns `{name, path, mtime}[]` filtered to image extensions, sorted by filename |
|
| `list-images` | invoke | Reads directory, returns `{name, path, mtime}[]` filtered to image extensions, sorted by filename |
|
||||||
| `read-image` | invoke | Reads file, returns base64 data URL (supports jpg/png/gif/webp/tiff/bmp/heic) |
|
| `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 |
|
| `check-files-exist(folderPath, names[])` | invoke | Returns subset of names that already exist in folderPath |
|
||||||
| `move-files({targetFolder, files[]})` | invoke | Moves files; each file may have optional `newName`; handles cross-device (EXDEV) |
|
| `move-files({targetFolder, files[]})` | invoke | Moves files; each file may have optional `newName`; handles cross-device (EXDEV) |
|
||||||
| `stage-delete-files(paths[])` | invoke | Moves files to userData deleted_staging for undoable deletion; 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-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-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-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 |
|
| `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` |
|
| `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 |
|
| `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` — 전체 선택
|
- `A` — 전체 선택
|
||||||
- `Cmd+Z` — Undo (되돌리기)
|
- `Cmd+Z` — Undo (되돌리기)
|
||||||
- `Cmd+Shift+Z` — Redo (다시하기)
|
- `Cmd+Shift+Z` — Redo (다시하기)
|
||||||
- `P` — 미리보기 on/off 토글
|
- `Space` — 미리보기 on/off 토글
|
||||||
|
- `D` — 파일 탐색기 표시/숨김 토글
|
||||||
- `Backspace` / `Delete` — 선택 파일 삭제 팝업
|
- `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.
|
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 — 단독 선택 (재클릭 시 해제)
|
- Click — 단독 선택 (재클릭 시 해제)
|
||||||
- Shift+Click — 범위 선택
|
- Shift+Click — 범위 선택
|
||||||
- Cmd+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 적용)
|
- Rubber-band drag — 영역 선택 (rAF throttle 적용)
|
||||||
- Shift+Rubber-band — 토글 선택 (드래그 시작 시점 스냅샷 기준으로 선택/해제 반전)
|
- Shift+Rubber-band — 토글 선택 (드래그 시작 시점 스냅샷 기준으로 선택/해제 반전)
|
||||||
- `resetAnchor()` — 그리드 재렌더 시 Shift 기준점 초기화 (grid.js에서 호출)
|
- `resetAnchor()` — 그리드 재렌더 시 Shift 기준점과 키보드 타겟 상태 초기화 (grid.js에서 호출)
|
||||||
|
|
||||||
**Drag-and-drop** (`drag-drop.js`):
|
**Drag-and-drop** (`drag-drop.js`):
|
||||||
- 그리드 내 카드 순서 재배치 (single/multi)
|
- 그리드 내 카드 순서 재배치 (single/multi)
|
||||||
@@ -164,6 +173,10 @@ Global shortcuts are registered in `keyboard.js`. They do not fire while any `.m
|
|||||||
|
|
||||||
**Rename** (`rename.js`):
|
**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 대상 아님)
|
- 완료 후 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.
|
**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` 반환 → "접근 불가" 표시. 행 클릭 시 항상 펼침, 화살표 클릭 시 토글.
|
**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.
|
**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`를 참고하세요.
|
||||||
@@ -92,7 +92,7 @@ function register() {
|
|||||||
return { restored, errors };
|
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 errors = [];
|
||||||
const pad = (n) => String(n + 1).padStart(digits, '0');
|
const pad = (n) => String(n + 1).padStart(digits, '0');
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ function register() {
|
|||||||
// 2단계: 최종 이름으로 이동
|
// 2단계: 최종 이름으로 이동
|
||||||
const renamed = [];
|
const renamed = [];
|
||||||
for (const { tmp, ext, idx } of tmpMap) {
|
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);
|
const dest = path.join(folderPath, newName);
|
||||||
try {
|
try {
|
||||||
fs.renameSync(tmp, dest);
|
fs.renameSync(tmp, dest);
|
||||||
|
|||||||
+35
-7
@@ -46,15 +46,14 @@ function register(mainWindow) {
|
|||||||
try {
|
try {
|
||||||
const entries = fs.readdirSync(folderPath, { withFileTypes: true });
|
const entries = fs.readdirSync(folderPath, { withFileTypes: true });
|
||||||
return entries
|
return entries
|
||||||
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
|
.filter(e => !e.name.startsWith('.') && isDirectoryEntry(folderPath, e))
|
||||||
.map(e => {
|
.map(e => {
|
||||||
const fullPath = path.join(folderPath, e.name);
|
const fullPath = path.join(folderPath, e.name);
|
||||||
let hasChildren = false;
|
return {
|
||||||
try {
|
name: e.name,
|
||||||
hasChildren = fs.readdirSync(fullPath, { withFileTypes: true })
|
path: fullPath,
|
||||||
.some(c => c.isDirectory() && !c.name.startsWith('.'));
|
hasChildren: hasVisibleDirectoryChildren(fullPath),
|
||||||
} catch {}
|
};
|
||||||
return { name: e.name, path: fullPath, hasChildren };
|
|
||||||
})
|
})
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
} catch { return null; }
|
} catch { return null; }
|
||||||
@@ -80,6 +79,15 @@ function register(mainWindow) {
|
|||||||
updateConfig(cfg => { cfg.sidebarWidth = width; });
|
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', () => {
|
ipcMain.handle('get-thumb-size', () => {
|
||||||
const cfg = readConfig();
|
const cfg = readConfig();
|
||||||
return Number.isFinite(cfg.thumbSize) ? cfg.thumbSize : null;
|
return Number.isFinite(cfg.thumbSize) ? cfg.thumbSize : null;
|
||||||
@@ -90,4 +98,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 };
|
module.exports = { register };
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
setPreviewOn: (on) => ipcRenderer.invoke('set-preview-on', on),
|
setPreviewOn: (on) => ipcRenderer.invoke('set-preview-on', on),
|
||||||
getSidebarWidth: () => ipcRenderer.invoke('get-sidebar-width'),
|
getSidebarWidth: () => ipcRenderer.invoke('get-sidebar-width'),
|
||||||
setSidebarWidth: (width) => ipcRenderer.invoke('set-sidebar-width', 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'),
|
getThumbSize: () => ipcRenderer.invoke('get-thumb-size'),
|
||||||
setThumbSize: (size) => ipcRenderer.invoke('set-thumb-size', size),
|
setThumbSize: (size) => ipcRenderer.invoke('set-thumb-size', size),
|
||||||
checkFilesExist: (folder, names) => ipcRenderer.invoke('check-files-exist', folder, names),
|
checkFilesExist: (folder, names) => ipcRenderer.invoke('check-files-exist', folder, names),
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export function createFolderController({
|
|||||||
dirPath.textContent = folder;
|
dirPath.textContent = folder;
|
||||||
await window.api.setLastFolder(folder);
|
await window.api.setLastFolder(folder);
|
||||||
await fileExplorer.setSelectedPath(folder);
|
await fileExplorer.setSelectedPath(folder);
|
||||||
|
updateFolderPrefix(folder);
|
||||||
|
|
||||||
showLoading('이미지 목록 불러오는 중...');
|
showLoading('이미지 목록 불러오는 중...');
|
||||||
let list = [];
|
let list = [];
|
||||||
@@ -56,6 +57,7 @@ export function createFolderController({
|
|||||||
state.selectedIdxs.clear();
|
state.selectedIdxs.clear();
|
||||||
showEmptyGrid();
|
showEmptyGrid();
|
||||||
btnRename.disabled = true;
|
btnRename.disabled = true;
|
||||||
|
updatePreview();
|
||||||
setStatus('이미지 파일 없음', false);
|
setStatus('이미지 파일 없음', false);
|
||||||
log('folder', 'loadFolder empty', { folder });
|
log('folder', 'loadFolder empty', { folder });
|
||||||
return;
|
return;
|
||||||
@@ -68,9 +70,6 @@ export function createFolderController({
|
|||||||
updateSortButtons();
|
updateSortButtons();
|
||||||
setStatus(`${state.files.length}개 파일 로드됨`, true);
|
setStatus(`${state.files.length}개 파일 로드됨`, true);
|
||||||
|
|
||||||
const folderName = folder.replace(/\\/g, '/').split('/').pop();
|
|
||||||
if (folderName) inputPrefix.value = folderName;
|
|
||||||
|
|
||||||
renderGrid();
|
renderGrid();
|
||||||
updatePreview();
|
updatePreview();
|
||||||
btnRename.disabled = false;
|
btnRename.disabled = false;
|
||||||
@@ -104,6 +103,11 @@ export function createFolderController({
|
|||||||
btnSortDate.classList.toggle('active', state.sortMode === 'date');
|
btnSortDate.classList.toggle('active', state.sortMode === 'date');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateFolderPrefix(folder) {
|
||||||
|
const folderName = folder.replace(/\\/g, '/').split('/').pop();
|
||||||
|
if (folderName) inputPrefix.value = `${folderName}_`;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loadFolder,
|
loadFolder,
|
||||||
openFolder,
|
openFolder,
|
||||||
|
|||||||
+104
-1
@@ -7,12 +7,112 @@ import { buildSequentialName, getNameFormat } from './utils/file-names.js';
|
|||||||
const grid = document.getElementById('grid');
|
const grid = document.getElementById('grid');
|
||||||
const emptyState = document.getElementById('empty-state');
|
const emptyState = document.getElementById('empty-state');
|
||||||
const gridContainer = document.getElementById('grid-container');
|
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 fileCount = document.getElementById('file-count');
|
||||||
const selCount = document.getElementById('sel-count');
|
const selCount = document.getElementById('sel-count');
|
||||||
const inputPrefix = document.getElementById('input-prefix');
|
const inputPrefix = document.getElementById('input-prefix');
|
||||||
const inputDigits = document.getElementById('input-digits');
|
const inputDigits = document.getElementById('input-digits');
|
||||||
|
const inputPostfix = document.getElementById('input-postfix');
|
||||||
|
|
||||||
let _cardObserver = null;
|
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() {
|
export function renderGrid() {
|
||||||
if (_cardObserver) { _cardObserver.disconnect(); _cardObserver = null; }
|
if (_cardObserver) { _cardObserver.disconnect(); _cardObserver = null; }
|
||||||
@@ -43,6 +143,7 @@ export function renderGrid() {
|
|||||||
refreshCardSelection();
|
refreshCardSelection();
|
||||||
updateNumberBadges();
|
updateNumberBadges();
|
||||||
updateNewNameLabels();
|
updateNewNameLabels();
|
||||||
|
updateGridScrollState();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCard(file, idx) {
|
export function createCard(file, idx) {
|
||||||
@@ -99,6 +200,7 @@ export function createCard(file, idx) {
|
|||||||
export function refreshCardSelection() {
|
export function refreshCardSelection() {
|
||||||
Array.from(grid.children).forEach((card, i) => {
|
Array.from(grid.children).forEach((card, i) => {
|
||||||
card.classList.toggle('selected', state.selectedIdxs.has(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() {
|
export function updateNewNameLabels() {
|
||||||
const format = getNameFormat(inputPrefix, inputDigits);
|
const format = getNameFormat(inputPrefix, inputDigits, 3, inputPostfix);
|
||||||
Array.from(grid.children).forEach((card, i) => {
|
Array.from(grid.children).forEach((card, i) => {
|
||||||
const el = card.querySelector('.card-new-name');
|
const el = card.querySelector('.card-new-name');
|
||||||
const file = state.files[i];
|
const file = state.files[i];
|
||||||
@@ -137,4 +239,5 @@ export function showEmptyGrid() {
|
|||||||
emptyState.style.display = '';
|
emptyState.style.display = '';
|
||||||
fileCount.textContent = '0개 파일';
|
fileCount.textContent = '0개 파일';
|
||||||
updateSelCount();
|
updateSelCount();
|
||||||
|
updateGridScrollState();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
let _rubberBand, _grid;
|
let _rubberBand, _grid, _gridContainer;
|
||||||
let _state, _cb;
|
let _state, _cb;
|
||||||
|
|
||||||
let rbActive = false;
|
let rbActive = false;
|
||||||
@@ -8,15 +8,24 @@ let _rafPending = false;
|
|||||||
let _lastMoveEvent = null;
|
let _lastMoveEvent = null;
|
||||||
|
|
||||||
let lastSelectedIdx = null;
|
let lastSelectedIdx = null;
|
||||||
|
let keyboardTargetIdx = null;
|
||||||
|
let keyboardExtendBaseSelection = null;
|
||||||
let _rbStartSelection = new Set(); // 러버밴드 시작 시점의 선택 스냅샷
|
let _rbStartSelection = new Set(); // 러버밴드 시작 시점의 선택 스냅샷
|
||||||
|
|
||||||
export function resetAnchor() {
|
export function resetAnchor() {
|
||||||
lastSelectedIdx = null;
|
lastSelectedIdx = null;
|
||||||
|
keyboardTargetIdx = null;
|
||||||
|
keyboardExtendBaseSelection = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isKeyboardTarget(idx) {
|
||||||
|
return keyboardTargetIdx === idx;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function init({ gridContainer, rubberBand, grid, state, callbacks }) {
|
export function init({ gridContainer, rubberBand, grid, state, callbacks }) {
|
||||||
_rubberBand = rubberBand;
|
_rubberBand = rubberBand;
|
||||||
_grid = grid;
|
_grid = grid;
|
||||||
|
_gridContainer = gridContainer;
|
||||||
_state = state;
|
_state = state;
|
||||||
_cb = callbacks;
|
_cb = callbacks;
|
||||||
|
|
||||||
@@ -51,10 +60,39 @@ export function onCardClick(e) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
keyboardTargetIdx = idx;
|
||||||
|
keyboardExtendBaseSelection = null;
|
||||||
_cb.refreshCardSelection();
|
_cb.refreshCardSelection();
|
||||||
_cb.updateSelCount();
|
_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) {
|
function _toggle(idx) {
|
||||||
if (_state.selectedIdxs.has(idx)) {
|
if (_state.selectedIdxs.has(idx)) {
|
||||||
_state.selectedIdxs.delete(idx);
|
_state.selectedIdxs.delete(idx);
|
||||||
@@ -63,6 +101,7 @@ function _toggle(idx) {
|
|||||||
_state.selectedIdxs.add(idx);
|
_state.selectedIdxs.add(idx);
|
||||||
lastSelectedIdx = idx;
|
lastSelectedIdx = idx;
|
||||||
}
|
}
|
||||||
|
keyboardExtendBaseSelection = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _selectRange(idx) {
|
function _selectRange(idx) {
|
||||||
@@ -70,6 +109,7 @@ function _selectRange(idx) {
|
|||||||
_state.selectedIdxs.clear();
|
_state.selectedIdxs.clear();
|
||||||
_state.selectedIdxs.add(idx);
|
_state.selectedIdxs.add(idx);
|
||||||
lastSelectedIdx = idx;
|
lastSelectedIdx = idx;
|
||||||
|
keyboardExtendBaseSelection = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const start = Math.min(lastSelectedIdx, idx);
|
const start = Math.min(lastSelectedIdx, idx);
|
||||||
@@ -77,6 +117,74 @@ function _selectRange(idx) {
|
|||||||
_state.selectedIdxs.clear();
|
_state.selectedIdxs.clear();
|
||||||
for (let i = start; i <= end; i++) _state.selectedIdxs.add(i);
|
for (let i = start; i <= end; i++) _state.selectedIdxs.add(i);
|
||||||
// anchor(lastSelectedIdx)는 Shift 클릭으로 변경하지 않음
|
// 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) {
|
function _onStart(e) {
|
||||||
@@ -102,6 +210,8 @@ function _onStart(e) {
|
|||||||
_state.selectedIdxs.clear();
|
_state.selectedIdxs.clear();
|
||||||
_rbStartSelection.clear();
|
_rbStartSelection.clear();
|
||||||
lastSelectedIdx = null;
|
lastSelectedIdx = null;
|
||||||
|
keyboardTargetIdx = null;
|
||||||
|
keyboardExtendBaseSelection = null;
|
||||||
_cb.refreshCardSelection();
|
_cb.refreshCardSelection();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-43
@@ -12,56 +12,65 @@
|
|||||||
<h1>Photo Renamer</h1>
|
<h1>Photo Renamer</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="app-layout">
|
<section class="app-workspace" aria-label="사진 이름 변경 작업 영역">
|
||||||
|
<nav class="workspace-nav" aria-label="작업 도구">
|
||||||
<aside>
|
<div class="workspace-actions">
|
||||||
<nav class="sidebar-nav">
|
<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-warning" id="btn-open">폴더 열기 (⌘O)</button>
|
||||||
<button class="btn btn-success" id="btn-rename" disabled>이름 변경 실행 (⌘S)</button>
|
<button class="btn btn-success" id="btn-rename" disabled>이름 변경 실행 (⌘S)</button>
|
||||||
</nav>
|
</div>
|
||||||
<section class="file-explorer">
|
<div class="workspace-controls">
|
||||||
<!-- 추후 구현 -->
|
<button class="btn btn-info" id="btn-preview-toggle">미리보기 (SPACE)</button>
|
||||||
</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 class="separator"></div>
|
<div class="separator"></div>
|
||||||
<button class="btn btn-ghost" id="btn-undo" disabled>↩ 되돌리기</button>
|
<button class="btn btn-ghost" id="btn-undo" disabled>↩ 되돌리기</button>
|
||||||
<button class="btn btn-ghost" id="btn-redo" disabled>↪ 다시하기</button>
|
<button class="btn btn-ghost" id="btn-redo" disabled>↪ 다시하기</button>
|
||||||
</nav>
|
<div class="separator"></div>
|
||||||
|
<div class="sort-group" aria-label="정렬">
|
||||||
<form id="format-bar">
|
<button class="sort-btn" id="btn-sort-name">이름순</button>
|
||||||
<label for="input-prefix">파일명 포맷</label>
|
<button class="sort-btn" id="btn-sort-date">날짜순</button>
|
||||||
<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>
|
</div>
|
||||||
<div id="grid" style="display:none;"></div>
|
</div>
|
||||||
</section>
|
</nav>
|
||||||
</main>
|
|
||||||
|
|
||||||
</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>
|
<footer>
|
||||||
<div class="status-dot" id="status-dot"></div>
|
<div class="status-dot" id="status-dot"></div>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { log } from './logger.js';
|
import { log } from './logger.js';
|
||||||
|
import * as selection from './handlers/selection.js';
|
||||||
|
|
||||||
export function initKeyboardShortcuts({
|
export function initKeyboardShortcuts({
|
||||||
btnOpen,
|
btnOpen,
|
||||||
btnRename,
|
btnRename,
|
||||||
btnPreviewToggle,
|
btnPreviewToggle,
|
||||||
|
btnFileExplorerToggle,
|
||||||
deleteSelected,
|
deleteSelected,
|
||||||
performUndo,
|
performUndo,
|
||||||
performRedo,
|
performRedo,
|
||||||
@@ -37,12 +39,22 @@ export function initKeyboardShortcuts({
|
|||||||
} else if (e.code === 'Space') {
|
} else if (e.code === 'Space') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
btnPreviewToggle.click();
|
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') {
|
} else if (e.code === 'Backspace' || e.code === 'Delete') {
|
||||||
deleteSelected();
|
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) {
|
function isEditableTarget(target) {
|
||||||
return ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || target.isContentEditable;
|
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 inputPrefix = document.getElementById('input-prefix');
|
||||||
const inputDigits = document.getElementById('input-digits');
|
const inputDigits = document.getElementById('input-digits');
|
||||||
|
const inputPostfix = document.getElementById('input-postfix');
|
||||||
const renameConfirmModal = document.getElementById('rename-confirm-modal');
|
const renameConfirmModal = document.getElementById('rename-confirm-modal');
|
||||||
const renameConfirmSelectedTitle = renameConfirmModal.querySelector('.rename-title-selected');
|
const renameConfirmSelectedTitle = renameConfirmModal.querySelector('.rename-title-selected');
|
||||||
const renameConfirmAllTitle = renameConfirmModal.querySelector('.rename-title-all');
|
const renameConfirmAllTitle = renameConfirmModal.querySelector('.rename-title-all');
|
||||||
@@ -30,8 +31,8 @@ renameConfirmModal.addEventListener('modal:closed', () => {
|
|||||||
|
|
||||||
export async function doRename() {
|
export async function doRename() {
|
||||||
if (!state.files.length || !state.currentFolder) return;
|
if (!state.files.length || !state.currentFolder) return;
|
||||||
const { prefix, digits } = getNameFormat(inputPrefix, inputDigits);
|
const { prefix, digits, postfix } = getNameFormat(inputPrefix, inputDigits, 3, inputPostfix);
|
||||||
const format = { prefix, digits };
|
const format = { prefix, digits, postfix };
|
||||||
|
|
||||||
const selectedArr = [...state.selectedIdxs].sort((a, b) => a - b);
|
const selectedArr = [...state.selectedIdxs].sort((a, b) => a - b);
|
||||||
const filesToRename = selectedArr.length > 0
|
const filesToRename = selectedArr.length > 0
|
||||||
@@ -75,6 +76,7 @@ export async function doRename() {
|
|||||||
files: filesToRename,
|
files: filesToRename,
|
||||||
prefix,
|
prefix,
|
||||||
digits,
|
digits,
|
||||||
|
postfix,
|
||||||
});
|
});
|
||||||
hideLoading();
|
hideLoading();
|
||||||
if (result.errors.length) {
|
if (result.errors.length) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
import { doRename } from './rename.js';
|
import { doRename } from './rename.js';
|
||||||
import { handleFolderDrop } from './move.js';
|
import { handleFolderDrop } from './move.js';
|
||||||
import { initModalControls } from './modal.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 { initKeyboardShortcuts } from './keyboard.js';
|
||||||
import { createFolderController } from './folder-controller.js';
|
import { createFolderController } from './folder-controller.js';
|
||||||
import { createHistoryController } from './history-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 btnRename = document.getElementById('btn-rename');
|
||||||
const inputPrefix = document.getElementById('input-prefix');
|
const inputPrefix = document.getElementById('input-prefix');
|
||||||
const inputDigits = document.getElementById('input-digits');
|
const inputDigits = document.getElementById('input-digits');
|
||||||
|
const inputPostfix = document.getElementById('input-postfix');
|
||||||
const previewName = document.getElementById('preview-name');
|
const previewName = document.getElementById('preview-name');
|
||||||
const dirPath = document.getElementById('dir-path');
|
const dirPath = document.getElementById('dir-path');
|
||||||
const rubberBand = document.getElementById('rubber-band');
|
const rubberBand = document.getElementById('rubber-band');
|
||||||
@@ -35,9 +36,11 @@ const gridContainer = document.getElementById('grid-container');
|
|||||||
const dragGhostEl = document.getElementById('drag-ghost');
|
const dragGhostEl = document.getElementById('drag-ghost');
|
||||||
const thumbSizeHint = document.getElementById('thumb-size-hint');
|
const thumbSizeHint = document.getElementById('thumb-size-hint');
|
||||||
const btnPreviewToggle = document.getElementById('btn-preview-toggle');
|
const btnPreviewToggle = document.getElementById('btn-preview-toggle');
|
||||||
|
const btnFileExplorerToggle = document.getElementById('btn-file-explorer-toggle');
|
||||||
const btnUndo = document.getElementById('btn-undo');
|
const btnUndo = document.getElementById('btn-undo');
|
||||||
const btnRedo = document.getElementById('btn-redo');
|
const btnRedo = document.getElementById('btn-redo');
|
||||||
const fileExplorerContainer = document.querySelector('.file-explorer');
|
const fileExplorerContainer = document.querySelector('.file-explorer');
|
||||||
|
const appWorkspace = document.querySelector('.app-workspace');
|
||||||
const aside = document.querySelector('aside');
|
const aside = document.querySelector('aside');
|
||||||
const sidebarResizer = document.getElementById('sidebar-resizer');
|
const sidebarResizer = document.getElementById('sidebar-resizer');
|
||||||
|
|
||||||
@@ -78,18 +81,20 @@ window.api.onFullscreenChange((isFullscreen) => {
|
|||||||
// ── 앱 시작 ───────────────────────────────────────
|
// ── 앱 시작 ───────────────────────────────────────
|
||||||
(async () => {
|
(async () => {
|
||||||
log('startup', 'init start');
|
log('startup', 'init start');
|
||||||
const [lastFolder, homeDir, previewOn, sidebarWidth, thumbSize] = await Promise.all([
|
const [lastFolder, homeDir, previewOn, sidebarWidth, fileExplorerVisible, thumbSize] = await Promise.all([
|
||||||
window.api.getLastFolder(),
|
window.api.getLastFolder(),
|
||||||
window.api.getHomeDir(),
|
window.api.getHomeDir(),
|
||||||
window.api.getPreviewOn(),
|
window.api.getPreviewOn(),
|
||||||
window.api.getSidebarWidth(),
|
window.api.getSidebarWidth(),
|
||||||
|
window.api.getFileExplorerVisible(),
|
||||||
window.api.getThumbSize(),
|
window.api.getThumbSize(),
|
||||||
]);
|
]);
|
||||||
log('startup', 'settings loaded', { lastFolder, homeDir, previewOn, sidebarWidth, thumbSize });
|
log('startup', 'settings loaded', { lastFolder, homeDir, previewOn, sidebarWidth, fileExplorerVisible, thumbSize });
|
||||||
folderController.setHomeDir(homeDir);
|
folderController.setHomeDir(homeDir);
|
||||||
|
|
||||||
btnPreviewToggle.classList.toggle('active', previewOn);
|
btnPreviewToggle.classList.toggle('active', previewOn);
|
||||||
hoverPreview.setEnabled(previewOn);
|
hoverPreview.setEnabled(previewOn);
|
||||||
|
setFileExplorerVisible(fileExplorerVisible, { persist: false });
|
||||||
sidebarResize.init({
|
sidebarResize.init({
|
||||||
aside,
|
aside,
|
||||||
handle: sidebarResizer,
|
handle: sidebarResizer,
|
||||||
@@ -113,6 +118,7 @@ window.api.onFullscreenChange((isFullscreen) => {
|
|||||||
// ── 버튼 / 키보드 이벤트 ─────────────────────────
|
// ── 버튼 / 키보드 이벤트 ─────────────────────────
|
||||||
inputPrefix.addEventListener('input', updatePreview);
|
inputPrefix.addEventListener('input', updatePreview);
|
||||||
inputDigits.addEventListener('input', updatePreview);
|
inputDigits.addEventListener('input', updatePreview);
|
||||||
|
inputPostfix.addEventListener('input', updatePreview);
|
||||||
btnOpen.addEventListener('click', () => {
|
btnOpen.addEventListener('click', () => {
|
||||||
log('ui', 'open button clicked');
|
log('ui', 'open button clicked');
|
||||||
folderController.openFolder();
|
folderController.openFolder();
|
||||||
@@ -127,11 +133,16 @@ btnPreviewToggle.addEventListener('click', () => {
|
|||||||
hoverPreview.setEnabled(on);
|
hoverPreview.setEnabled(on);
|
||||||
window.api.setPreviewOn(on);
|
window.api.setPreviewOn(on);
|
||||||
});
|
});
|
||||||
|
btnFileExplorerToggle.addEventListener('click', () => {
|
||||||
|
const visible = appWorkspace.classList.contains('file-explorer-hidden');
|
||||||
|
setFileExplorerVisible(visible);
|
||||||
|
});
|
||||||
|
|
||||||
initKeyboardShortcuts({
|
initKeyboardShortcuts({
|
||||||
btnOpen,
|
btnOpen,
|
||||||
btnRename,
|
btnRename,
|
||||||
btnPreviewToggle,
|
btnPreviewToggle,
|
||||||
|
btnFileExplorerToggle,
|
||||||
deleteSelected: deleteHandler.deleteSelected,
|
deleteSelected: deleteHandler.deleteSelected,
|
||||||
performUndo: historyController.performUndo,
|
performUndo: historyController.performUndo,
|
||||||
performRedo: historyController.performRedo,
|
performRedo: historyController.performRedo,
|
||||||
@@ -144,9 +155,15 @@ initKeyboardShortcuts({
|
|||||||
|
|
||||||
// ── 포맷 미리보기 ─────────────────────────────────
|
// ── 포맷 미리보기 ─────────────────────────────────
|
||||||
function updatePreview() {
|
function updatePreview() {
|
||||||
const prefix = inputPrefix.value.trim() || 'photo';
|
const format = getNameFormat(inputPrefix, inputDigits, 3, inputPostfix);
|
||||||
const digits = clampDigits(inputDigits.value);
|
const fileName = state.files[0]?.name || 'preview.jpg';
|
||||||
const ext = state.files.length ? getExtension(state.files[0].name) : 'jpg';
|
previewName.textContent = buildSequentialName(fileName, 0, format);
|
||||||
previewName.textContent = `${prefix}_${'1'.padStart(digits, '0')}.${ext}`;
|
|
||||||
updateNewNameLabels();
|
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);
|
||||||
|
}
|
||||||
|
|||||||
+103
-63
@@ -24,6 +24,7 @@
|
|||||||
--sel-bg: rgba(124,106,247,0.22);
|
--sel-bg: rgba(124,106,247,0.22);
|
||||||
--sel-border: #7c6af7;
|
--sel-border: #7c6af7;
|
||||||
--thumb-size: 250px;
|
--thumb-size: 250px;
|
||||||
|
--grid-scroll-track: #282740;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
@@ -73,11 +74,46 @@ header h1 {
|
|||||||
margin-left: auto;
|
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 {
|
.app-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,18 +156,6 @@ body.resizing-sidebar {
|
|||||||
cursor: col-resize;
|
cursor: col-resize;
|
||||||
user-select: none;
|
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 {
|
.file-explorer {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@@ -145,30 +169,23 @@ main {
|
|||||||
overflow: hidden;
|
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 {
|
.btn {
|
||||||
display: inline-flex; align-items: center; gap: 6px;
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
padding: 7px 14px; border-radius: 8px; border: none;
|
padding: 7px 14px; border-radius: 8px; border: none;
|
||||||
font-family: inherit; font-size: 13px; font-weight: 600;
|
font-family: inherit; font-size: 13px; font-weight: 600;
|
||||||
cursor: pointer; transition: all .15s;
|
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 { background: transparent; color: var(--text); border: 1px solid var(--border); }
|
||||||
.btn-ghost:hover { background: var(--card); }
|
.btn-ghost:hover { background: var(--card); }
|
||||||
.btn-ghost:disabled { color: var(--muted); border-color: var(--border); opacity: .4; cursor: not-allowed; }
|
.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 { background: #0d2535; color: var(--info); border: 1px solid #1a4060; }
|
||||||
.btn-info:hover { background: #132e42; }
|
.btn-info:hover { background: #132e42; }
|
||||||
.btn-info:disabled { opacity: .4; cursor: not-allowed; }
|
.btn-info:disabled { opacity: .4; cursor: not-allowed; }
|
||||||
.btn-info:not(.active) { opacity: .4; }
|
.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 {
|
.sort-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
background: #0d2535;
|
background: #0d2535;
|
||||||
@@ -216,6 +233,7 @@ main {
|
|||||||
#format-bar input:focus { border-color: var(--accent); }
|
#format-bar input:focus { border-color: var(--accent); }
|
||||||
#input-prefix { width: 160px; }
|
#input-prefix { width: 160px; }
|
||||||
#input-digits { width: 50px; text-align: center; }
|
#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-sep { color: var(--muted); font-family: 'JetBrains Mono', monospace; font-size: 14px; }
|
||||||
.format-arrow { color: var(--muted); font-size: 12px; }
|
.format-arrow { color: var(--muted); font-size: 12px; }
|
||||||
#preview-name {
|
#preview-name {
|
||||||
@@ -233,18 +251,70 @@ main {
|
|||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: var(--border) transparent;
|
scrollbar-color: var(--border) transparent;
|
||||||
}
|
}
|
||||||
.file-explorer::-webkit-scrollbar,
|
.file-explorer::-webkit-scrollbar { width: 6px; }
|
||||||
#grid-container::-webkit-scrollbar { width: 6px; }
|
#grid-container { scrollbar-width: none; }
|
||||||
|
#grid-container::-webkit-scrollbar { width: 0; height: 0; }
|
||||||
.file-explorer::-webkit-scrollbar-track,
|
.file-explorer::-webkit-scrollbar-track,
|
||||||
#grid-container::-webkit-scrollbar-track { background: transparent; }
|
.file-explorer::-webkit-scrollbar-track { background: transparent; }
|
||||||
.file-explorer::-webkit-scrollbar-thumb,
|
.file-explorer::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||||
#grid-container::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
|
||||||
|
|
||||||
/* ── 그리드 컨테이너 ── */
|
/* ── 그리드 컨테이너 ── */
|
||||||
#grid-container {
|
#grid-container {
|
||||||
flex: 1; overflow-y: auto; padding: 20px;
|
flex: 1;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: hidden;
|
||||||
|
padding: 20px;
|
||||||
position: relative;
|
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) ── */
|
/* ── 그리드: 고정 열 폭 (--thumb-size) ── */
|
||||||
#grid {
|
#grid {
|
||||||
@@ -284,6 +354,10 @@ main {
|
|||||||
background: var(--sel-bg);
|
background: var(--sel-bg);
|
||||||
box-shadow: 0 0 0 1px var(--sel-border), 0 8px 24px rgba(124,106,247,.2);
|
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.dragging,
|
||||||
.card.multi-dragging { opacity: .3; transform: scale(.96); cursor: grabbing; }
|
.card.multi-dragging { opacity: .3; transform: scale(.96); cursor: grabbing; }
|
||||||
.card.drag-over { border-color: var(--accent) !important; background: var(--drag-over); }
|
.card.drag-over { border-color: var(--accent) !important; background: var(--drag-over); }
|
||||||
@@ -442,40 +516,6 @@ footer span { font-size: 11px; color: var(--muted); }
|
|||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
#loading p { color: var(--text); font-size: 14px; }
|
#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 {
|
.hover-preview {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -7,14 +7,15 @@ export function clampDigits(value, fallback = 3) {
|
|||||||
return Math.max(1, Math.min(6, parseInt(value, 10) || fallback));
|
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);
|
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 {
|
return {
|
||||||
prefix: prefixInput.value.trim() || 'photo',
|
prefix: prefixInput.value.trim() || 'photo_',
|
||||||
digits: clampDigits(digitsInput.value, digitsFallback),
|
digits: clampDigits(digitsInput.value, digitsFallback),
|
||||||
|
postfix: postfixInput?.value.trim() || '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user