Compare commits

..

10 Commits

14 changed files with 574 additions and 141 deletions
+23 -8
View File
@@ -35,7 +35,8 @@ src/
│ ├── file-utils.js # Pure helpers: listImages, readImageAsDataUrl, fileExists,
│ │ # moveFileWithCopyFallback (EXDEV-safe cross-device move)
│ ├── folder.js # select-folder, list-directories, get/set-last-folder,
│ │ # get/set-preview-on, get/set-sidebar-width, get/set-thumb-size,
│ │ # get/set-preview-on, get/set-sidebar-width,
│ │ # get/set-file-explorer-visible, get/set-thumb-size,
│ │ # get-home-dir, reveal-folder
│ └── files.js # list-images, read-image, rename-files, stage/restore-delete,
│ # check-files-exist, move-files
@@ -66,9 +67,9 @@ src/
│ └── ghost.js # Drag ghost UI helper
├── file-explorer.js # Sidebar directory tree (lazy-load, expand/collapse)
├── sidebar-resize.js # Pointer-drag resizing for the aside/main split; persisted 340500px width
├── selection.js # Rubber-band, Shift-click, Cmd+click, resetAnchor
├── selection.js # Rubber-band, Shift-click, Cmd+click, keyboard navigation, resetAnchor
├── pinch-zoom.js # Ctrl+scroll to resize thumbnails (CSS --thumb-size, 150400px)
└── image-preview.js # Hover preview overlay with circular loading indicator
└── image-preview.js # Hover preview overlay; immediate show/hide when enabled/disabled
```
### IPC surface
@@ -80,7 +81,7 @@ Defined in `src/main/ipc/`, bridged via `src/preload/index.js` as `window.api`:
| `select-folder` | invoke | Opens native folder picker, returns path or null |
| `list-images` | invoke | Reads directory, returns `{name, path, mtime}[]` filtered to image extensions, sorted by filename |
| `read-image` | invoke | Reads file, returns base64 data URL (supports jpg/png/gif/webp/tiff/bmp/heic) |
| `rename-files({folderPath, files, prefix, digits})` | invoke | Two-phase rename: temp names (crypto.randomUUID) first, then final names |
| `rename-files({folderPath, files, prefix, digits, postfix})` | invoke | Two-phase rename: temp names (crypto.randomUUID) first, then final names using `prefix + padded sequence + postfix + ext` |
| `check-files-exist(folderPath, names[])` | invoke | Returns subset of names that already exist in folderPath |
| `move-files({targetFolder, files[]})` | invoke | Moves files; each file may have optional `newName`; handles cross-device (EXDEV) |
| `stage-delete-files(paths[])` | invoke | Moves files to userData deleted_staging for undoable deletion; handles cross-device (EXDEV) |
@@ -90,6 +91,7 @@ Defined in `src/main/ipc/`, bridged via `src/preload/index.js` as `window.api`:
| `get-last-folder` / `set-last-folder` | invoke | Persists last opened folder in userData config.json |
| `get-preview-on` / `set-preview-on` | invoke | Persists hover preview toggle state in userData config.json |
| `get-sidebar-width` / `set-sidebar-width` | invoke | Persists sidebar width in userData config.json |
| `get-file-explorer-visible` / `set-file-explorer-visible` | invoke | Persists file explorer visibility in userData config.json; default is visible |
| `get-thumb-size` / `set-thumb-size` | invoke | Persists thumbnail/card size in userData config.json |
| `reveal-folder` | invoke | Opens folder in Finder via `shell.openPath` |
| `fullscreen-change` | push (main→renderer) | Sent on `enter-full-screen` / `leave-full-screen` window events |
@@ -122,8 +124,12 @@ Prefer narrow utility modules (`utils/file-names.js`, `utils/dom.js`) over broad
- `A` — 전체 선택
- `Cmd+Z` — Undo (되돌리기)
- `Cmd+Shift+Z` — Redo (다시하기)
- `P` — 미리보기 on/off 토글
- `Space` — 미리보기 on/off 토글
- `D` — 파일 탐색기 표시/숨김 토글
- `Backspace` / `Delete` — 선택 파일 삭제 팝업
- `ArrowLeft` / `ArrowRight` / `ArrowUp` / `ArrowDown` — 해당 방향의 카드를 키보드 타겟으로 이동하고 단독 선택
- `Home` / `End` — 첫 카드 / 마지막 카드를 키보드 타겟으로 이동하고 단독 선택 (`grid-container` 최상단 / 최하단으로 스크롤)
- `Shift+Arrow*` / `Shift+Home` / `Shift+End` — 기존 선택 목록을 유지한 채 Finder처럼 anchor부터 새 키보드 타겟까지 범위 추가 선택
Global shortcuts are registered in `keyboard.js`. They do not fire while any `.modal-overlay` is open.
@@ -148,9 +154,12 @@ Global shortcuts are registered in `keyboard.js`. They do not fire while any `.m
- Click — 단독 선택 (재클릭 시 해제)
- Shift+Click — 범위 선택
- Cmd+Click — 개별 토글
- Arrow/Home/End keyboard navigation — `keyboardTargetIdx`를 이동하고 해당 카드를 단독 선택; 위/아래 이동은 현재 그리드의 실제 첫 행 카드 수로 column count를 계산
- Shift+Arrow/Home/End — Shift 확장 시작 시점의 선택 스냅샷을 보존하고, `lastSelectedIdx` anchor부터 `keyboardTargetIdx`까지의 범위를 추가 선택
- Home/End keyboard navigation — 선택 타겟 이동 후 `grid-container`를 각각 최상단/최하단으로 스크롤
- Rubber-band drag — 영역 선택 (rAF throttle 적용)
- Shift+Rubber-band — 토글 선택 (드래그 시작 시점 스냅샷 기준으로 선택/해제 반전)
- `resetAnchor()` — 그리드 재렌더 시 Shift 기준점 초기화 (grid.js에서 호출)
- `resetAnchor()` — 그리드 재렌더 시 Shift 기준점과 키보드 타겟 상태 초기화 (grid.js에서 호출)
**Drag-and-drop** (`drag-drop.js`):
- 그리드 내 카드 순서 재배치 (single/multi)
@@ -164,6 +173,10 @@ Global shortcuts are registered in `keyboard.js`. They do not fire while any `.m
**Rename** (`rename.js`):
- 선택된 파일만 이름 변경 (선택 없으면 전체)
- 파일명 포맷은 `input-prefix` + 자리순번 + `input-postfix` + 확장자이다. 구분자 `_`는 자동 삽입하지 않고 입력값 자체에 포함한다.
- `input-prefix`의 HTML 기본값은 `photo_`, `input-postfix`의 HTML 기본값은 `_`이다. 접미사를 비우면 접두사와 자리순번만으로 저장한다.
- 폴더 로드 시 이미지 파일 유무와 관계없이 폴더명 뒤에 `_`를 붙여 `input-prefix`로 설정한다.
- 상단 파일명 preview, 카드별 새 이름 라벨, rename 확인 목록은 모두 `utils/file-names.js``getNameFormat()``buildSequentialName()`을 사용해 같은 규칙을 공유한다.
- 변경될 이름이 미선택 파일과 충돌하면 충돌 목록 모달 표시
- 완료 후 history 리셋 (rename은 파일 경로가 바뀌므로 undo 대상 아님)
@@ -181,7 +194,9 @@ Global shortcuts are registered in `keyboard.js`. They do not fire while any `.m
**Thumbnail loading:** `IntersectionObserver` with `rootMargin: '400px 0px'` — lazy load as cards enter viewport. `entry.target.isConnected` 체크로 폴더 전환 중 스탈 카드 방지. Drag reorder remaps card elements via `card.dataset.filepath` to avoid re-fetching.
**Hover preview** (`image-preview.js`): 1초 지연 후 전체 크기 팝업. 썸네일 캐시 우선 사용. 상태는 config.json에 영속.
**Hover preview** (`image-preview.js`): 활성화 상태에서 카드 hover 시 대기 없이 전체 크기 팝업을 표시한다. 썸네일 캐시 우선 사용. 상태는 config.json에 영속. `Space`로 preview를 켤 때 커서가 카드 위에 있으면 즉시 팝업을 표시하고, 끌 때는 열린 팝업을 즉시 닫는다.
**Workspace / file explorer:** `index.html` uses semantic `section.app-workspace` above the `aside` and `main`. The top `workspace-nav` contains the file explorer visibility toggle, folder open/rename actions, preview/undo/redo controls, and sort group. The file explorer visibility toggle uses the fixed text `🗂️ (D)`, persists as `fileExplorerVisible`, and hides both `aside` and `#sidebar-resizer` via `.file-explorer-hidden`.
**Sidebar tree** (`file-explorer.js`): Lazy-load 자식 폴더. 접근 불가 폴더는 `null` 반환 → "접근 불가" 표시. 행 클릭 시 항상 펼침, 화살표 클릭 시 토글.
@@ -193,4 +208,4 @@ Global shortcuts are registered in `keyboard.js`. They do not fire while any `.m
**Build output:** `electron-builder` targets DMG for arm64 and x64.
**Config persistence:** `app.getPath('userData')/config.json``lastFolder`, `previewOn`, `sidebarWidth`, `thumbSize`, `windowState` 저장. `windowState` includes fullscreen status and normal window bounds (`x`, `y`, `width`, `height`) and is restored on startup if the saved bounds overlap an available display.
**Config persistence:** `app.getPath('userData')/config.json``lastFolder`, `previewOn`, `sidebarWidth`, `fileExplorerVisible`, `thumbSize`, `windowState` 저장. `previewOn` and `fileExplorerVisible` default to `true`. `windowState` includes fullscreen status and normal window bounds (`x`, `y`, `width`, `height`) and is restored on startup if the saved bounds overlap an available display.
+90
View File
@@ -0,0 +1,90 @@
# Photo Renamer
macOS용 Electron 데스크톱 앱입니다. 폴더 안의 이미지 파일을 그리드로 확인하면서 순서를 조정하고, 일괄 이름 변경, 폴더 이동, 삭제를 수행할 수 있습니다.
## 주요 기능
- 이미지 폴더 열기 및 사이드바 폴더 탐색
- 파일 탐색기 표시/숨김 토글 및 상태 저장
- 썸네일 그리드 표시와 지연 로딩
- 드래그 앤 드롭으로 카드 순서 변경
- 선택 파일 또는 전체 파일의 순차 이름 변경
- 폴더 선택 시 이미지 유무와 관계없이 폴더명을 파일명 prefix로 자동 적용
- 사이드바 폴더로 파일 이동, 파일명 충돌 시 새 이름으로 이동
- 삭제 파일 staging 및 Undo/Redo
- Hover 이미지 미리보기 토글
- Ctrl+스크롤 썸네일 크기 조절
- Finder 스타일 카드 선택
- Click, Shift+Click, Cmd+Click
- Rubber-band drag
- Arrow, Home, End 키보드 네비게이션
- Shift+Arrow/Home/End 범위 추가 선택
## 설치 및 실행
```bash
npm install
npm start
```
처음 설정부터 실행하려면:
```bash
./setup.sh
```
## 빌드
macOS 앱 번들만 빌드:
```bash
npm run build
```
DMG 패키지까지 생성:
```bash
npm run dist
```
빌드 결과는 `dist/` 아래에 생성됩니다. 로컬에 유효한 Apple Developer 코드서명 인증서가 없으면 electron-builder가 코드서명을 건너뛰며 경고를 출력할 수 있습니다.
## 키보드 단축키
| 키 | 동작 |
|---|---|
| `Cmd+O` | 폴더 열기 |
| `Cmd+S` | 이름 변경 실행 |
| `A` | 전체 선택 |
| `Cmd+Z` | Undo |
| `Cmd+Shift+Z` | Redo |
| `Space` | Hover 미리보기 on/off |
| `D` | 파일 탐색기 표시/숨김 |
| `Backspace` / `Delete` | 선택 파일 삭제 |
| `ArrowLeft` / `ArrowRight` / `ArrowUp` / `ArrowDown` | 해당 방향 카드로 이동 및 단독 선택 |
| `Home` / `End` | 첫 카드 / 마지막 카드로 이동 및 그리드 최상단 / 최하단 스크롤 |
| `Shift+Arrow*` / `Shift+Home` / `Shift+End` | 기존 선택을 유지하면서 anchor부터 타겟까지 범위 추가 선택 |
입력 모드와 관계없이 동작하도록 키보드 처리는 `e.code` 기준입니다. 모달이 열려 있을 때는 전역 단축키가 동작하지 않습니다.
## 프로젝트 구조
```text
assets/
├── icon.png
└── icon.icns
src/
├── main/ # Electron main process, filesystem IPC
├── preload/ # contextBridge API
└── renderer/ # UI, selection, grid, rename/move/delete flows
```
Electron 보안 설정은 `contextIsolation: true`, `nodeIntegration: false`를 사용합니다. 파일 시스템 접근은 `src/main/`에만 두고, renderer는 `window.api`를 통해 IPC만 호출합니다.
## 개발 메모
- 테스트와 린트 스크립트는 현재 구성되어 있지 않습니다.
- 설정값은 `app.getPath('userData')/config.json`에 저장됩니다. 최근 폴더, Hover 미리보기, 사이드바 폭, 파일 탐색기 표시 상태, 썸네일 크기, 창 위치/크기를 포함합니다.
- 삭제 Undo를 위해 삭제된 파일은 `userData/deleted_staging/` 아래에 임시 보관됩니다.
- 자세한 코드 작업 규칙과 아키텍처 메모는 `AGENTS.md`를 참고하세요.
+2 -2
View File
@@ -92,7 +92,7 @@ function register() {
return { restored, errors };
});
ipcMain.handle('rename-files', async (_event, { folderPath, files, prefix, digits }) => {
ipcMain.handle('rename-files', async (_event, { folderPath, files, prefix, digits, postfix = '' }) => {
const errors = [];
const pad = (n) => String(n + 1).padStart(digits, '0');
@@ -113,7 +113,7 @@ function register() {
// 2단계: 최종 이름으로 이동
const renamed = [];
for (const { tmp, ext, idx } of tmpMap) {
const newName = `${prefix}_${pad(idx)}${ext}`;
const newName = `${prefix}${pad(idx)}${postfix}${ext}`;
const dest = path.join(folderPath, newName);
try {
fs.renameSync(tmp, dest);
+35 -7
View File
@@ -46,15 +46,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 +79,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 +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 };
+2
View File
@@ -27,6 +27,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),
+7 -3
View File
@@ -43,6 +43,7 @@ export function createFolderController({
dirPath.textContent = folder;
await window.api.setLastFolder(folder);
await fileExplorer.setSelectedPath(folder);
updateFolderPrefix(folder);
showLoading('이미지 목록 불러오는 중...');
let list = [];
@@ -56,6 +57,7 @@ export function createFolderController({
state.selectedIdxs.clear();
showEmptyGrid();
btnRename.disabled = true;
updatePreview();
setStatus('이미지 파일 없음', false);
log('folder', 'loadFolder empty', { folder });
return;
@@ -68,9 +70,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,6 +103,11 @@ 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,
+104 -1
View File
@@ -7,12 +7,112 @@ import { buildSequentialName, getNameFormat } from './utils/file-names.js';
const grid = document.getElementById('grid');
const emptyState = document.getElementById('empty-state');
const gridContainer = document.getElementById('grid-container');
const gridScrollbar = document.getElementById('grid-scrollbar');
const gridScrollbarThumb = document.getElementById('grid-scrollbar-thumb');
const fileCount = document.getElementById('file-count');
const selCount = document.getElementById('sel-count');
const inputPrefix = document.getElementById('input-prefix');
const inputDigits = document.getElementById('input-digits');
const inputPostfix = document.getElementById('input-postfix');
let _cardObserver = null;
let _scrollStateRaf = null;
let _scrollbarDrag = null;
function updateGridScrollState() {
if (_scrollStateRaf) cancelAnimationFrame(_scrollStateRaf);
_scrollStateRaf = requestAnimationFrame(() => {
_scrollStateRaf = null;
const isScrollable = gridContainer.scrollHeight > gridContainer.clientHeight;
gridContainer.classList.toggle('is-scrollable', isScrollable);
updateGridScrollbarFrame();
updateGridScrollbar();
});
}
function updateGridScrollbarFrame() {
if (!gridScrollbar) return;
const rect = gridContainer.getBoundingClientRect();
const top = rect.top + 20;
const height = Math.max(0, rect.height - 40);
gridScrollbar.style.setProperty('--grid-scrollbar-top', `${top}px`);
gridScrollbar.style.setProperty('--grid-scrollbar-height', `${height}px`);
}
function updateGridScrollbar() {
if (!gridScrollbar || !gridScrollbarThumb) return;
const { clientHeight, scrollHeight, scrollTop } = gridContainer;
const maxScrollTop = scrollHeight - clientHeight;
if (maxScrollTop <= 0) {
gridScrollbarThumb.style.height = '';
gridScrollbarThumb.style.transform = '';
return;
}
const trackHeight = gridScrollbar.clientHeight;
const thumbHeight = Math.max(36, Math.round((clientHeight / scrollHeight) * trackHeight));
const maxThumbTop = trackHeight - thumbHeight;
const thumbTop = Math.round((scrollTop / maxScrollTop) * maxThumbTop);
gridScrollbarThumb.style.height = `${thumbHeight}px`;
gridScrollbarThumb.style.transform = `translateY(${thumbTop}px)`;
}
function scrollGridFromPointer(clientY) {
const trackRect = gridScrollbar.getBoundingClientRect();
const thumbHeight = gridScrollbarThumb.offsetHeight;
const maxThumbTop = gridScrollbar.clientHeight - thumbHeight;
const maxScrollTop = gridContainer.scrollHeight - gridContainer.clientHeight;
if (maxThumbTop <= 0 || maxScrollTop <= 0) return;
const targetThumbTop = Math.min(
maxThumbTop,
Math.max(0, clientY - trackRect.top - _scrollbarDrag.thumbOffset),
);
gridContainer.scrollTop = (targetThumbTop / maxThumbTop) * maxScrollTop;
}
const _gridResizeObserver = new ResizeObserver(updateGridScrollState);
_gridResizeObserver.observe(gridContainer);
_gridResizeObserver.observe(grid);
gridContainer.addEventListener('scroll', updateGridScrollbar);
window.addEventListener('resize', updateGridScrollState);
gridScrollbar.addEventListener('pointerdown', (e) => {
if (!gridContainer.classList.contains('is-scrollable')) return;
const thumbRect = gridScrollbarThumb.getBoundingClientRect();
const isThumbPointer = e.target === gridScrollbarThumb;
_scrollbarDrag = {
thumbOffset: isThumbPointer ? e.clientY - thumbRect.top : thumbRect.height / 2,
};
gridScrollbar.classList.add('dragging');
gridScrollbar.setPointerCapture(e.pointerId);
scrollGridFromPointer(e.clientY);
e.preventDefault();
});
gridScrollbar.addEventListener('pointermove', (e) => {
if (!_scrollbarDrag) return;
scrollGridFromPointer(e.clientY);
});
gridScrollbar.addEventListener('pointerup', (e) => {
_scrollbarDrag = null;
gridScrollbar.classList.remove('dragging');
gridScrollbar.releasePointerCapture(e.pointerId);
});
gridScrollbar.addEventListener('pointercancel', (e) => {
_scrollbarDrag = null;
gridScrollbar.classList.remove('dragging');
gridScrollbar.releasePointerCapture(e.pointerId);
});
export function renderGrid() {
if (_cardObserver) { _cardObserver.disconnect(); _cardObserver = null; }
@@ -43,6 +143,7 @@ export function renderGrid() {
refreshCardSelection();
updateNumberBadges();
updateNewNameLabels();
updateGridScrollState();
}
export function createCard(file, idx) {
@@ -99,6 +200,7 @@ export function createCard(file, idx) {
export function refreshCardSelection() {
Array.from(grid.children).forEach((card, i) => {
card.classList.toggle('selected', state.selectedIdxs.has(i));
card.classList.toggle('keyboard-target', selection.isKeyboardTarget(i));
});
}
@@ -111,7 +213,7 @@ export function updateNumberBadges() {
}
export function updateNewNameLabels() {
const format = getNameFormat(inputPrefix, inputDigits);
const format = getNameFormat(inputPrefix, inputDigits, 3, inputPostfix);
Array.from(grid.children).forEach((card, i) => {
const el = card.querySelector('.card-new-name');
const file = state.files[i];
@@ -137,4 +239,5 @@ export function showEmptyGrid() {
emptyState.style.display = '';
fileCount.textContent = '0개 파일';
updateSelCount();
updateGridScrollState();
}
+111 -1
View File
@@ -1,4 +1,4 @@
let _rubberBand, _grid;
let _rubberBand, _grid, _gridContainer;
let _state, _cb;
let rbActive = false;
@@ -8,15 +8,24 @@ let _rafPending = false;
let _lastMoveEvent = null;
let lastSelectedIdx = null;
let keyboardTargetIdx = null;
let keyboardExtendBaseSelection = null;
let _rbStartSelection = new Set(); // 러버밴드 시작 시점의 선택 스냅샷
export function resetAnchor() {
lastSelectedIdx = null;
keyboardTargetIdx = null;
keyboardExtendBaseSelection = null;
}
export function isKeyboardTarget(idx) {
return keyboardTargetIdx === idx;
}
export function init({ gridContainer, rubberBand, grid, state, callbacks }) {
_rubberBand = rubberBand;
_grid = grid;
_gridContainer = gridContainer;
_state = state;
_cb = callbacks;
@@ -51,10 +60,39 @@ export function onCardClick(e) {
}
}
keyboardTargetIdx = idx;
keyboardExtendBaseSelection = null;
_cb.refreshCardSelection();
_cb.updateSelCount();
}
export function handleKeyboardNavigation(code, { extend = false } = {}) {
if (!_state?.files.length) return false;
const currentIdx = _getCurrentKeyboardIndex(code);
const nextIdx = _getNextKeyboardIndex(currentIdx, code);
if (nextIdx === null) return false;
if (extend) {
if (lastSelectedIdx === null) lastSelectedIdx = currentIdx ?? nextIdx;
if (!keyboardExtendBaseSelection) {
keyboardExtendBaseSelection = new Set(_state.selectedIdxs);
}
_selectKeyboardRange(lastSelectedIdx, nextIdx, keyboardExtendBaseSelection);
} else {
_state.selectedIdxs.clear();
_state.selectedIdxs.add(nextIdx);
lastSelectedIdx = nextIdx;
keyboardExtendBaseSelection = null;
}
keyboardTargetIdx = nextIdx;
_cb.refreshCardSelection();
_cb.updateSelCount();
_scrollKeyboardTargetIntoView(code, nextIdx);
return true;
}
function _toggle(idx) {
if (_state.selectedIdxs.has(idx)) {
_state.selectedIdxs.delete(idx);
@@ -63,6 +101,7 @@ function _toggle(idx) {
_state.selectedIdxs.add(idx);
lastSelectedIdx = idx;
}
keyboardExtendBaseSelection = null;
}
function _selectRange(idx) {
@@ -70,6 +109,7 @@ function _selectRange(idx) {
_state.selectedIdxs.clear();
_state.selectedIdxs.add(idx);
lastSelectedIdx = idx;
keyboardExtendBaseSelection = null;
return;
}
const start = Math.min(lastSelectedIdx, idx);
@@ -77,6 +117,74 @@ function _selectRange(idx) {
_state.selectedIdxs.clear();
for (let i = start; i <= end; i++) _state.selectedIdxs.add(i);
// anchor(lastSelectedIdx)는 Shift 클릭으로 변경하지 않음
keyboardExtendBaseSelection = null;
}
function _selectKeyboardRange(anchorIdx, targetIdx, baseSelection) {
const start = Math.min(anchorIdx, targetIdx);
const end = Math.max(anchorIdx, targetIdx);
_state.selectedIdxs.clear();
baseSelection.forEach(idx => {
if (_isValidIndex(idx)) _state.selectedIdxs.add(idx);
});
for (let i = start; i <= end; i++) _state.selectedIdxs.add(i);
}
function _getCurrentKeyboardIndex(code) {
if (_isValidIndex(keyboardTargetIdx)) return keyboardTargetIdx;
if (_state.selectedIdxs.size) return Math.min(..._state.selectedIdxs);
return null;
}
function _getNextKeyboardIndex(currentIdx, code) {
const lastIdx = _state.files.length - 1;
if (currentIdx === null) {
return (code === 'ArrowLeft' || code === 'ArrowUp' || code === 'End')
? lastIdx
: 0;
}
if (code === 'Home') return 0;
if (code === 'End') return lastIdx;
if (code === 'ArrowLeft') return Math.max(0, currentIdx - 1);
if (code === 'ArrowRight') return Math.min(lastIdx, currentIdx + 1);
const columnCount = _getColumnCount();
if (code === 'ArrowUp') return Math.max(0, currentIdx - columnCount);
if (code === 'ArrowDown') return Math.min(lastIdx, currentIdx + columnCount);
return null;
}
function _getColumnCount() {
const cards = Array.from(_grid.children).filter(card => card.classList.contains('card'));
if (!cards.length) return 1;
const firstTop = cards[0].getBoundingClientRect().top;
const firstRowCount = cards.findIndex(card =>
Math.abs(card.getBoundingClientRect().top - firstTop) > 2
);
return firstRowCount === -1 ? cards.length : Math.max(1, firstRowCount);
}
function _scrollCardIntoView(idx) {
const card = _grid.querySelector(`.card[data-idx="${idx}"]`);
card?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
function _scrollKeyboardTargetIntoView(code, idx) {
if (code === 'Home') {
_gridContainer.scrollTop = 0;
return;
}
if (code === 'End') {
_gridContainer.scrollTop = _gridContainer.scrollHeight - _gridContainer.clientHeight;
return;
}
_scrollCardIntoView(idx);
}
function _isValidIndex(idx) {
return Number.isInteger(idx) && idx >= 0 && idx < _state.files.length;
}
function _onStart(e) {
@@ -102,6 +210,8 @@ function _onStart(e) {
_state.selectedIdxs.clear();
_rbStartSelection.clear();
lastSelectedIdx = null;
keyboardTargetIdx = null;
keyboardExtendBaseSelection = null;
_cb.refreshCardSelection();
}
}
+30 -21
View File
@@ -12,14 +12,31 @@
<h1>Photo Renamer</h1>
</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>
</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>
<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>
</nav>
<section class="file-explorer">
<div class="app-layout">
<aside>
<section class="file-explorer" aria-label="폴더 탐색기">
<!-- 추후 구현 -->
</section>
</aside>
@@ -27,26 +44,14 @@
<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>
<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="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_001.jpg</span>
<span id="preview-name">photo_0001_.jpg</span>
<span id="sel-count"></span>
<span id="file-count">0개 파일</span>
</form>
@@ -58,10 +63,14 @@
<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>
</div>
</section>
<footer>
<div class="status-dot" id="status-dot"></div>
+12
View File
@@ -1,9 +1,11 @@
import { log } from './logger.js';
import * as selection from './handlers/selection.js';
export function initKeyboardShortcuts({
btnOpen,
btnRename,
btnPreviewToggle,
btnFileExplorerToggle,
deleteSelected,
performUndo,
performRedo,
@@ -37,12 +39,22 @@ export function initKeyboardShortcuts({
} else if (e.code === 'Space') {
e.preventDefault();
btnPreviewToggle.click();
} else if (e.code === 'KeyD' && !e.metaKey && !e.ctrlKey && !e.altKey) {
e.preventDefault();
btnFileExplorerToggle.click();
} else if (e.code === 'Backspace' || e.code === 'Delete') {
deleteSelected();
} else if (isNavigationKey(e.code) && !e.metaKey && !e.ctrlKey && !e.altKey) {
e.preventDefault();
selection.handleKeyboardNavigation(e.code, { extend: e.shiftKey });
}
});
}
function isNavigationKey(code) {
return ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(code);
}
function isEditableTarget(target) {
return ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || target.isContentEditable;
}
+4 -2
View File
@@ -8,6 +8,7 @@ import { buildSequentialName, getNameFormat } from './utils/file-name
const inputPrefix = document.getElementById('input-prefix');
const inputDigits = document.getElementById('input-digits');
const inputPostfix = document.getElementById('input-postfix');
const renameConfirmModal = document.getElementById('rename-confirm-modal');
const renameConfirmSelectedTitle = renameConfirmModal.querySelector('.rename-title-selected');
const renameConfirmAllTitle = renameConfirmModal.querySelector('.rename-title-all');
@@ -30,8 +31,8 @@ renameConfirmModal.addEventListener('modal:closed', () => {
export async function doRename() {
if (!state.files.length || !state.currentFolder) return;
const { prefix, digits } = getNameFormat(inputPrefix, inputDigits);
const format = { prefix, digits };
const { prefix, digits, postfix } = getNameFormat(inputPrefix, inputDigits, 3, inputPostfix);
const format = { prefix, digits, postfix };
const selectedArr = [...state.selectedIdxs].sort((a, b) => a - b);
const filesToRename = selectedArr.length > 0
@@ -75,6 +76,7 @@ export async function doRename() {
files: filesToRename,
prefix,
digits,
postfix,
});
hideLoading();
if (result.errors.length) {
+24 -7
View File
@@ -7,7 +7,7 @@ import {
import { doRename } from './rename.js';
import { handleFolderDrop } from './move.js';
import { initModalControls } from './modal.js';
import { clampDigits, getExtension } from './utils/file-names.js';
import { buildSequentialName, getNameFormat } from './utils/file-names.js';
import { initKeyboardShortcuts } from './keyboard.js';
import { createFolderController } from './folder-controller.js';
import { createHistoryController } from './history-controller.js';
@@ -27,6 +27,7 @@ const btnSortDate = document.getElementById('btn-sort-date');
const btnRename = document.getElementById('btn-rename');
const inputPrefix = document.getElementById('input-prefix');
const inputDigits = document.getElementById('input-digits');
const inputPostfix = document.getElementById('input-postfix');
const previewName = document.getElementById('preview-name');
const dirPath = document.getElementById('dir-path');
const rubberBand = document.getElementById('rubber-band');
@@ -35,9 +36,11 @@ const gridContainer = document.getElementById('grid-container');
const dragGhostEl = document.getElementById('drag-ghost');
const thumbSizeHint = document.getElementById('thumb-size-hint');
const btnPreviewToggle = document.getElementById('btn-preview-toggle');
const btnFileExplorerToggle = document.getElementById('btn-file-explorer-toggle');
const btnUndo = document.getElementById('btn-undo');
const btnRedo = document.getElementById('btn-redo');
const fileExplorerContainer = document.querySelector('.file-explorer');
const appWorkspace = document.querySelector('.app-workspace');
const aside = document.querySelector('aside');
const sidebarResizer = document.getElementById('sidebar-resizer');
@@ -78,18 +81,20 @@ window.api.onFullscreenChange((isFullscreen) => {
// ── 앱 시작 ───────────────────────────────────────
(async () => {
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.getHomeDir(),
window.api.getPreviewOn(),
window.api.getSidebarWidth(),
window.api.getFileExplorerVisible(),
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);
btnPreviewToggle.classList.toggle('active', previewOn);
hoverPreview.setEnabled(previewOn);
setFileExplorerVisible(fileExplorerVisible, { persist: false });
sidebarResize.init({
aside,
handle: sidebarResizer,
@@ -113,6 +118,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 +133,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 +155,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);
}
+103 -63
View File
@@ -24,6 +24,7 @@
--sel-bg: rgba(124,106,247,0.22);
--sel-border: #7c6af7;
--thumb-size: 250px;
--grid-scroll-track: #282740;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
@@ -73,11 +74,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 +156,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 +169,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 +233,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 +251,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 +354,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 +516,6 @@ footer span { font-size: 11px; color: var(--muted); }
@keyframes spin { to { transform: rotate(360deg); } }
#loading p { color: var(--text); font-size: 14px; }
/* ── 카드 호버 원형 로딩바 ── */
.card-hover-loader {
position: absolute;
top: calc(var(--thumb-size) / 2);
left: 50%;
transform: translate(-50%, -50%);
width: 52px;
height: 52px;
pointer-events: none;
z-index: 10;
overflow: visible;
}
.card-hover-loader .loader-bg {
fill: rgba(0, 0, 0, 0.55);
}
.card-hover-loader .loader-track {
fill: none;
stroke: rgba(255, 255, 255, 0.15);
stroke-width: 3;
}
.card-hover-loader .loader-progress {
fill: none;
stroke: var(--accent);
stroke-width: 3;
stroke-linecap: round;
transform-origin: 24px 24px;
transform: rotate(-90deg);
animation: hover-loader-fill 1s linear forwards;
}
@keyframes hover-loader-fill {
from { stroke-dashoffset: 125.66; }
to { stroke-dashoffset: 0; }
}
/* ── 호버 이미지 팝업 ── */
.hover-preview {
position: fixed;
+5 -4
View File
@@ -7,14 +7,15 @@ export function clampDigits(value, fallback = 3) {
return Math.max(1, Math.min(6, parseInt(value, 10) || fallback));
}
export function buildSequentialName(fileName, index, { prefix, digits }) {
export function buildSequentialName(fileName, index, { prefix, digits, postfix = '' }) {
const ext = getExtension(fileName);
return `${prefix}_${String(index + 1).padStart(digits, '0')}.${ext}`;
return `${prefix}${String(index + 1).padStart(digits, '0')}${postfix}.${ext}`;
}
export function getNameFormat(prefixInput, digitsInput, digitsFallback = 3) {
export function getNameFormat(prefixInput, digitsInput, digitsFallback = 3, postfixInput = null) {
return {
prefix: prefixInput.value.trim() || 'photo',
prefix: prefixInput.value.trim() || 'photo_',
digits: clampDigits(digitsInput.value, digitsFallback),
postfix: postfixInput?.value.trim() || '',
};
}