1st commit

This commit is contained in:
2026-05-14 00:06:35 +09:00
commit 7e3b7619cd
11 changed files with 5334 additions and 0 deletions
+346
View File
@@ -0,0 +1,346 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Photo Renamer</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;600&display=swap');
:root {
--bg: #0f0f17;
--surface: #17172a;
--card: #1e1e34;
--border: #2e2e50;
--accent: #7c6af7;
--accent2: #e96af7;
--success: #6af7a0;
--text: #e8e8f0;
--muted: #6b6b8a;
--drag-over: #3a2e70;
--sel-bg: rgba(124,106,247,0.22);
--sel-border: #7c6af7;
--thumb-size: 250px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Instrument Sans', -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
user-select: none;
-webkit-app-region: no-drag;
}
/* ── 타이틀바 ── */
#titlebar {
height: 44px;
background: var(--bg);
-webkit-app-region: drag;
display: flex;
align-items: center;
padding: 0 80px 0 20px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
#titlebar h1 {
font-size: 13px; font-weight: 600; color: var(--muted);
letter-spacing: .08em; text-transform: uppercase;
}
#titlebar .dir-path {
margin-left: 16px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px; color: var(--muted); opacity: .7;
max-width: 600px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* ── 툴바 ── */
#toolbar {
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(--muted); border: 1px solid var(--border); }
.btn-ghost:hover { background: var(--card); color: var(--text); }
.btn-success { background: #1a4a2e; color: var(--success); border: 1px solid #2a6a40; }
.btn-success:hover { background: #204a34; }
.btn-success:disabled { opacity: .4; cursor: not-allowed; transform: none; }
.separator { width: 1px; height: 24px; background: var(--border); margin: 0 4px; }
/* ── 포맷 바 ── */
#format-bar {
display: flex; align-items: center; gap: 10px;
padding: 10px 16px;
background: var(--bg);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
#format-bar label {
font-size: 12px; color: var(--muted); font-weight: 600;
text-transform: uppercase; letter-spacing: .06em;
}
#format-bar input {
background: var(--card); border: 1px solid var(--border);
color: var(--text); font-family: 'JetBrains Mono', monospace;
font-size: 13px; padding: 5px 10px; border-radius: 6px; outline: none;
transition: border-color .15s;
}
#format-bar input:focus { border-color: var(--accent); }
#input-prefix { width: 160px; }
#input-digits { width: 50px; text-align: center; }
.format-sep { color: var(--muted); font-family: 'JetBrains Mono', monospace; font-size: 14px; }
#preview-name {
font-family: 'JetBrains Mono', monospace; font-size: 13px;
color: var(--accent2); background: rgba(124,106,247,.1);
padding: 4px 12px; border-radius: 6px; border: 1px solid rgba(124,106,247,.2);
}
#file-count { margin-left: auto; font-size: 12px; color: var(--muted); }
#sel-count { font-size: 12px; color: var(--accent); font-weight: 700; display: none; }
#sel-count.visible { display: inline; }
/* ── 그리드 컨테이너 ── */
#grid-container {
flex: 1; overflow-y: auto; padding: 20px;
position: relative;
scrollbar-width: thin; scrollbar-color: var(--border) transparent;
}
#grid-container::-webkit-scrollbar { width: 6px; }
#grid-container::-webkit-scrollbar-track { background: transparent; }
#grid-container::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
/* ── 그리드: 고정 열 폭 (--thumb-size) ── */
#grid {
display: grid;
grid-template-columns: repeat(auto-fill, var(--thumb-size));
gap: 14px;
justify-content: start;
}
/* ── 빈 상태 ── */
#empty-state {
display: flex; flex-direction: column; align-items: center;
justify-content: center; height: 100%; gap: 16px; color: var(--muted);
}
#empty-state .icon { font-size: 64px; opacity: .3; }
#empty-state p { font-size: 15px; text-align: center; line-height: 1.6; }
/* ── 카드 ── */
.card {
background: var(--card);
border: 2px solid transparent;
border-radius: 12px;
overflow: hidden;
cursor: grab;
transition: border-color .15s, transform .15s, box-shadow .15s, background .15s;
position: relative;
width: var(--thumb-size);
}
.card:hover {
border-color: var(--border);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0,0,0,.4);
}
.card.selected {
border-color: var(--sel-border) !important;
background: var(--sel-bg);
box-shadow: 0 0 0 1px var(--sel-border), 0 8px 24px rgba(124,106,247,.2);
}
.card.dragging,
.card.multi-dragging { opacity: .3; transform: scale(.96); cursor: grabbing; }
.card.drag-over { border-color: var(--accent) !important; background: var(--drag-over); }
/* ── 썸네일: 정사각형 + object-fit: cover ── */
.card-thumb {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
display: block;
}
.card-thumb-placeholder {
width: 100%;
aspect-ratio: 1 / 1;
background: #08080f;
display: flex; align-items: center; justify-content: center;
font-size: 52px; opacity: .25;
}
.card-body { padding: 8px 10px; }
.card-num {
position: absolute; top: 8px; left: 8px;
background: var(--accent); color: #fff;
font-size: 11px; font-weight: 700;
font-family: 'JetBrains Mono', monospace;
padding: 2px 7px; border-radius: 99px; line-height: 1.6;
pointer-events: none; z-index: 2;
}
.card-check {
position: absolute; top: 8px; right: 8px;
width: 22px; height: 22px; border-radius: 50%;
background: var(--accent); color: #fff;
font-size: 13px; display: flex; align-items: center; justify-content: center;
opacity: 0; transition: opacity .15s;
pointer-events: none; z-index: 2;
}
.card.selected .card-check { opacity: 1; }
.card-name {
font-size: 11px; color: var(--muted);
font-family: 'JetBrains Mono', monospace;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.card-new-name {
font-size: 11px; color: var(--accent2);
font-family: 'JetBrains Mono', monospace;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
margin-top: 2px;
}
/* ── Rubber-band 선택 사각형 ── */
#rubber-band {
position: fixed;
border: 1.5px solid var(--accent);
background: rgba(124,106,247,.1);
border-radius: 3px;
pointer-events: none;
display: none;
z-index: 40;
}
/* ── 다중 드래그 고스트 배지 ── */
#drag-ghost {
position: fixed;
pointer-events: none;
z-index: 9999;
display: none;
background: var(--accent);
color: #fff;
font-size: 13px; font-weight: 700;
font-family: 'Instrument Sans', sans-serif;
padding: 6px 16px;
border-radius: 99px;
box-shadow: 0 4px 20px rgba(0,0,0,.5);
}
/* ── 상태바 ── */
#statusbar {
height: 28px; background: var(--bg);
border-top: 1px solid var(--border);
display: flex; align-items: center; padding: 0 16px; gap: 12px; flex-shrink: 0;
}
#statusbar span { font-size: 11px; color: var(--muted); }
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--muted); }
.status-dot.ready { background: var(--success); }
/* ── 핀치 크기 힌트 ── */
#thumb-size-hint {
position: fixed; bottom: 40px; right: 24px;
background: rgba(124,106,247,.9);
color: #fff; font-size: 12px; font-weight: 700;
font-family: 'JetBrains Mono', monospace;
padding: 5px 12px; border-radius: 8px;
opacity: 0; transition: opacity .2s;
pointer-events: none; z-index: 500;
}
#thumb-size-hint.show { opacity: 1; }
/* ── 로딩 ── */
#loading {
display: none; position: fixed; inset: 0;
background: rgba(15,15,23,.85); backdrop-filter: blur(4px);
z-index: 100; align-items: center; justify-content: center;
flex-direction: column; gap: 16px;
}
#loading.show { display: flex; }
.spinner {
width: 40px; height: 40px;
border: 3px solid var(--border); border-top-color: var(--accent);
border-radius: 50%; animation: spin .8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
#loading p { color: var(--text); font-size: 14px; }
/* ── 토스트 ── */
#toast {
position: fixed; bottom: 40px; left: 50%;
transform: translateX(-50%) translateY(20px);
background: var(--success); color: #0a2a1a;
font-weight: 700; font-size: 14px;
padding: 12px 24px; border-radius: 12px;
opacity: 0; transition: all .3s; pointer-events: none; z-index: 200;
}
#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
</style>
</head>
<body>
<div id="titlebar">
<h1>📷 Photo Renamer</h1>
<span class="dir-path" id="dir-path">폴더를 선택하세요</span>
</div>
<div id="toolbar">
<button class="btn btn-primary" id="btn-open">📁 폴더 열기</button>
<div class="separator"></div>
<button class="btn btn-ghost" id="btn-sort-name">이름순 정렬</button>
<button class="btn btn-ghost" id="btn-sort-date">날짜순 정렬</button>
<div class="separator"></div>
<button class="btn btn-success" id="btn-rename" disabled>✅ 이름 변경 실행</button>
<button class="btn btn-ghost" id="btn-reveal" style="margin-left:auto;" disabled>Finder에서 열기</button>
</div>
<div id="format-bar">
<label>파일명 포맷</label>
<input type="text" id="input-prefix" value="photo" placeholder="접두사" />
<span class="format-sep">_</span>
<input type="number" id="input-digits" value="3" min="1" max="6" />
<span class="format-sep">자리 순번</span>
<span style="color:var(--muted);font-size:12px;"></span>
<span id="preview-name">photo_001.jpg</span>
<span id="sel-count"></span>
<span id="file-count">0개 파일</span>
</div>
<div id="grid-container">
<div id="empty-state">
<div class="icon">🗂️</div>
<p>폴더를 열어 이미지를 불러오세요<br>
<span style="font-size:12px;opacity:.6;">JPG · PNG · GIF · BMP · TIFF · WEBP · HEIC</span></p>
</div>
<div id="grid" style="display:none;"></div>
</div>
<div id="statusbar">
<div class="status-dot" id="status-dot"></div>
<span id="status-text">준비</span>
</div>
<div id="rubber-band"></div>
<div id="drag-ghost"></div>
<div id="thumb-size-hint"></div>
<div id="loading">
<div class="spinner"></div>
<p id="loading-text">이미지 로딩 중...</p>
</div>
<div id="toast"></div>
<script src="renderer.js"></script>
</body>
</html>
+119
View File
@@ -0,0 +1,119 @@
const { app, BrowserWindow, ipcMain, dialog, shell } = require('electron');
const path = require('path');
const fs = require('fs');
let mainWindow;
function createWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 820,
minWidth: 900,
minHeight: 600,
titleBarStyle: 'hiddenInset',
backgroundColor: '#0f0f17',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
show: false,
});
mainWindow.loadFile(path.join(__dirname, 'index.html'));
mainWindow.once('ready-to-show', () => {
mainWindow.show();
});
}
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
// ── IPC 핸들러 ──────────────────────────────────────
ipcMain.handle('select-folder', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openDirectory'],
title: '이미지 폴더 선택',
});
if (result.canceled) return null;
return result.filePaths[0];
});
ipcMain.handle('list-images', async (_event, folderPath) => {
const EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp', '.heic', '.heif']);
try {
const files = fs.readdirSync(folderPath)
.filter(f => EXTS.has(path.extname(f).toLowerCase()))
.map(f => {
const fullPath = path.join(folderPath, f);
const stat = fs.statSync(fullPath);
return { name: f, path: fullPath, mtime: stat.mtimeMs, size: stat.size };
})
.sort((a, b) => a.name.localeCompare(b.name, 'ko'));
return files;
} catch (e) {
return [];
}
});
ipcMain.handle('read-image', async (_event, filePath) => {
try {
const data = fs.readFileSync(filePath);
const ext = path.extname(filePath).toLowerCase().replace('.', '');
const mime = (ext === 'jpg' || ext === 'jpeg') ? 'jpeg'
: ext === 'png' ? 'png'
: ext === 'gif' ? 'gif'
: ext === 'webp' ? 'webp'
: 'jpeg';
return `data:image/${mime};base64,${data.toString('base64')}`;
} catch (e) {
return null;
}
});
ipcMain.handle('rename-files', async (_event, { folderPath, files, prefix, digits }) => {
const errors = [];
const pad = (n) => String(n + 1).padStart(digits, '0');
// 1단계: 임시 이름으로 이동 (충돌 방지)
const tmpMap = [];
for (let i = 0; i < files.length; i++) {
const src = files[i].path;
const ext = path.extname(files[i].name).toLowerCase();
const tmp = path.join(folderPath, `__tmp_rename_${i}_${Date.now()}${ext}`);
try {
fs.renameSync(src, tmp);
tmpMap.push({ tmp, ext, idx: i });
} catch (e) {
errors.push(`${files[i].name}: ${e.message}`);
}
}
// 2단계: 최종 이름으로 이동
const renamed = [];
for (const { tmp, ext, idx } of tmpMap) {
const newName = `${prefix}_${pad(idx)}${ext}`;
const dest = path.join(folderPath, newName);
try {
fs.renameSync(tmp, dest);
renamed.push({ oldPath: tmp, newPath: dest, newName });
} catch (e) {
errors.push(`${newName}: ${e.message}`);
}
}
return { renamed, errors };
});
ipcMain.handle('reveal-folder', async (_event, folderPath) => {
shell.openPath(folderPath);
});
+9
View File
@@ -0,0 +1,9 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('api', {
selectFolder: () => ipcRenderer.invoke('select-folder'),
listImages: (folder) => ipcRenderer.invoke('list-images', folder),
readImage: (path) => ipcRenderer.invoke('read-image', path),
renameFiles: (args) => ipcRenderer.invoke('rename-files', args),
revealFolder: (folder) => ipcRenderer.invoke('reveal-folder', folder),
});
+639
View File
@@ -0,0 +1,639 @@
// ═══════════════════════════════════════════════════════
// Photo Renamer v2 — renderer.js
//
// 기능:
// • 썸네일 그리드 (정사각형, object-fit:cover, 기본 250px)
// • 트랙패드 핀치로 썸네일 크기 조절 (150~400px)
// • Rubber-band(클릭 드래그) 다중 선택
// • Shift + 클릭 드래그 → 기존 선택에 추가
// • Command + 클릭 → 개별 토글 선택
// • 선택된 카드들 한꺼번에 드래그 → 드롭 이동
// • 단일 카드 드래그 → 드롭 이동
// • 드래그 중 화면 상/하단 10% 진입 시 자동 스크롤
// ═══════════════════════════════════════════════════════
// ── 썸네일 크기 상수 ─────────────────────────────────
const THUMB_MIN = 150;
const THUMB_MAX = 400;
const THUMB_DEFAULT = 250;
let thumbSize = THUMB_DEFAULT;
// ── 전역 상태 ────────────────────────────────────────
let files = [];
let currentFolder = null;
let selectedIdxs = new Set();
// 드래그 상태
let dragMode = null; // 'single' | 'multi'
let dragSrcIdx = null;
// Rubber-band 상태
let rbActive = false;
let rbStartX = 0;
let rbStartY = 0; // viewport 기준
// 자동 스크롤 상태
let autoScrollRAF = null;
let autoScrollSpeed = 0;
let lastScrollTs = null;
// 핀치 힌트 타이머
let thumbHintTimer = null;
// ── DOM ──────────────────────────────────────────────
const grid = document.getElementById('grid');
const emptyState = document.getElementById('empty-state');
const btnOpen = document.getElementById('btn-open');
const btnSortName = document.getElementById('btn-sort-name');
const btnSortDate = document.getElementById('btn-sort-date');
const btnRename = document.getElementById('btn-rename');
const btnReveal = document.getElementById('btn-reveal');
const inputPrefix = document.getElementById('input-prefix');
const inputDigits = document.getElementById('input-digits');
const previewName = document.getElementById('preview-name');
const fileCount = document.getElementById('file-count');
const selCount = document.getElementById('sel-count');
const dirPath = document.getElementById('dir-path');
const statusDot = document.getElementById('status-dot');
const statusText = document.getElementById('status-text');
const loading = document.getElementById('loading');
const loadingText = document.getElementById('loading-text');
const toast = document.getElementById('toast');
const rubberBand = document.getElementById('rubber-band');
const gridContainer = document.getElementById('grid-container');
const dragGhostEl = document.getElementById('drag-ghost');
const thumbSizeHint = document.getElementById('thumb-size-hint');
// ── 초기화 ───────────────────────────────────────────
inputPrefix.addEventListener('input', updatePreview);
inputDigits.addEventListener('input', updatePreview);
btnOpen.addEventListener('click', openFolder);
btnSortName.addEventListener('click', () => sortFiles('name'));
btnSortDate.addEventListener('click', () => sortFiles('date'));
btnRename.addEventListener('click', doRename);
btnReveal.addEventListener('click', () => {
if (currentFolder) window.api.revealFolder(currentFolder);
});
// Rubber-band: grid-container 위에서 시작
gridContainer.addEventListener('mousedown', onRubberBandStart);
window.addEventListener('mousemove', onRubberBandMove);
window.addEventListener('mouseup', onRubberBandEnd);
// 드래그 중 window-level dragover → 고스트 이동 + 자동 스크롤
window.addEventListener('dragover', onWindowDragOver);
// ── 핀치 줌 (트랙패드 두 손가락) ─────────────────────
// macOS 트랙패드 핀치 = ctrlKey + wheel 이벤트
gridContainer.addEventListener('wheel', (e) => {
if (!e.ctrlKey) return;
e.preventDefault();
// deltaY: 음수 = 벌리기(크게), 양수 = 오므리기(작게)
const delta = -e.deltaY * 0.8;
setThumbSize(thumbSize + delta);
}, { passive: false });
// ── 썸네일 크기 변경 ─────────────────────────────────
function setThumbSize(size) {
thumbSize = Math.round(Math.max(THUMB_MIN, Math.min(THUMB_MAX, size)));
document.documentElement.style.setProperty('--thumb-size', thumbSize + 'px');
showThumbSizeHint(thumbSize);
}
function showThumbSizeHint(size) {
thumbSizeHint.textContent = `${size}px`;
thumbSizeHint.classList.add('show');
clearTimeout(thumbHintTimer);
thumbHintTimer = setTimeout(() => thumbSizeHint.classList.remove('show'), 1000);
}
// ── 폴더 열기 ────────────────────────────────────────
async function openFolder() {
const folder = await window.api.selectFolder();
if (!folder) return;
currentFolder = folder;
dirPath.textContent = folder;
showLoading('이미지 목록 불러오는 중...');
const list = await window.api.listImages(folder);
hideLoading();
if (!list.length) {
setStatus('이미지 파일 없음', false);
showToast('⚠️ 이미지 파일이 없습니다', '#f76a6a', '#0a0a0a');
return;
}
files = list;
selectedIdxs.clear();
setStatus(`${files.length}개 파일 로드됨`, true);
await renderGrid();
updatePreview();
btnRename.disabled = false;
btnReveal.disabled = false;
}
// ── 그리드 렌더링 ────────────────────────────────────
async function renderGrid() {
grid.innerHTML = '';
emptyState.style.display = 'none';
grid.style.display = 'grid';
fileCount.textContent = `${files.length}개 파일`;
updateSelCount();
showLoading(`썸네일 로딩 중... (0/${files.length})`);
const BATCH = 10;
for (let i = 0; i < files.length; i += BATCH) {
const batch = files.slice(i, i + BATCH);
const results = await Promise.all(batch.map(f => window.api.readImage(f.path)));
results.forEach((dataUrl, bi) => {
const idx = i + bi;
grid.appendChild(createCard(files[idx], idx, dataUrl));
});
loadingText.textContent = `썸네일 로딩 중... (${Math.min(i + BATCH, files.length)}/${files.length})`;
}
hideLoading();
refreshCardSelection();
updateNumberBadges();
updateNewNameLabels();
}
// ── 카드 생성 ────────────────────────────────────────
function createCard(file, idx, dataUrl) {
const card = document.createElement('div');
card.className = 'card';
card.dataset.idx = idx;
card.dataset.filepath = file.path;
card.draggable = true;
const numBadge = document.createElement('div');
numBadge.className = 'card-num';
numBadge.textContent = `#${idx + 1}`;
const checkBadge = document.createElement('div');
checkBadge.className = 'card-check';
checkBadge.textContent = '✓';
let thumb;
if (dataUrl) {
thumb = document.createElement('img');
thumb.className = 'card-thumb';
thumb.src = dataUrl;
thumb.alt = file.name;
thumb.draggable = false;
} else {
thumb = document.createElement('div');
thumb.className = 'card-thumb-placeholder';
thumb.textContent = '🖼️';
}
const body = document.createElement('div');
body.className = 'card-body';
const nameEl = document.createElement('div');
nameEl.className = 'card-name';
nameEl.title = file.name;
nameEl.textContent = file.name;
const newNameEl = document.createElement('div');
newNameEl.className = 'card-new-name';
body.appendChild(nameEl);
body.appendChild(newNameEl);
card.appendChild(numBadge);
card.appendChild(checkBadge);
card.appendChild(thumb);
card.appendChild(body);
card.addEventListener('dragstart', onCardDragStart);
card.addEventListener('dragover', onCardDragOver);
card.addEventListener('dragenter', onCardDragEnter);
card.addEventListener('dragleave', onCardDragLeave);
card.addEventListener('drop', onCardDrop);
card.addEventListener('dragend', onCardDragEnd);
card.addEventListener('click', onCardClick);
return card;
}
// ═══════════════════════════════════════════════════════
// Command+클릭 → 개별 토글 선택
// ═══════════════════════════════════════════════════════
function onCardClick(e) {
if (!e.metaKey) return;
e.stopPropagation();
const idx = parseInt(this.dataset.idx);
if (selectedIdxs.has(idx)) {
selectedIdxs.delete(idx);
} else {
selectedIdxs.add(idx);
}
refreshCardSelection();
updateSelCount();
}
// ═══════════════════════════════════════════════════════
// RUBBER-BAND 다중 선택
// ═══════════════════════════════════════════════════════
function onRubberBandStart(e) {
if (e.target.closest('.card')) return;
if (e.target.closest('button, input, label')) return;
if (e.button !== 0) return;
rbActive = true;
rbStartX = e.clientX;
rbStartY = e.clientY;
rubberBand.style.display = 'block';
rubberBand.style.left = rbStartX + 'px';
rubberBand.style.top = rbStartY + 'px';
rubberBand.style.width = '0px';
rubberBand.style.height = '0px';
if (!e.shiftKey) {
selectedIdxs.clear();
refreshCardSelection();
}
}
function onRubberBandMove(e) {
if (!rbActive) return;
const curX = e.clientX;
const curY = e.clientY;
const left = Math.min(rbStartX, curX);
const top = Math.min(rbStartY, curY);
const width = Math.abs(curX - rbStartX);
const height = Math.abs(curY - rbStartY);
rubberBand.style.left = left + 'px';
rubberBand.style.top = top + 'px';
rubberBand.style.width = width + 'px';
rubberBand.style.height = height + 'px';
// rubber-band 사각형 (viewport 기준)
const selRect = { left, top, right: left + width, bottom: top + height };
Array.from(grid.children).forEach((card, i) => {
const cr = card.getBoundingClientRect();
const overlaps = !(cr.right < selRect.left ||
cr.left > selRect.right ||
cr.bottom < selRect.top ||
cr.top > selRect.bottom);
if (overlaps) {
selectedIdxs.add(i);
} else if (!e.shiftKey) {
selectedIdxs.delete(i);
}
});
refreshCardSelection();
updateSelCount();
}
function onRubberBandEnd() {
if (!rbActive) return;
rbActive = false;
rubberBand.style.display = 'none';
updateSelCount();
}
// ═══════════════════════════════════════════════════════
// 드래그 앤 드롭
// ═══════════════════════════════════════════════════════
function onCardDragStart(e) {
const idx = parseInt(this.dataset.idx);
if (selectedIdxs.has(idx) && selectedIdxs.size > 1) {
// 다중 드래그
dragMode = 'multi';
dragSrcIdx = idx;
selectedIdxs.forEach(i => {
const card = getCardEl(i);
if (card) card.classList.add('multi-dragging');
});
showDragGhost(e, `📦 ${selectedIdxs.size}개 이동 중`);
const img = new Image();
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
e.dataTransfer.setDragImage(img, 0, 0);
} else {
// 단일 드래그
dragMode = 'single';
dragSrcIdx = idx;
this.classList.add('dragging');
selectedIdxs.clear();
refreshCardSelection();
}
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(idx));
}
function onCardDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}
function onCardDragEnter(e) {
e.preventDefault();
const idx = parseInt(this.dataset.idx);
const isSource = dragMode === 'single' ? idx === dragSrcIdx : selectedIdxs.has(idx);
if (!isSource) this.classList.add('drag-over');
}
function onCardDragLeave() {
this.classList.remove('drag-over');
}
function onCardDrop(e) {
e.preventDefault();
this.classList.remove('drag-over');
const targetIdx = parseInt(this.dataset.idx);
if (dragMode === 'single') {
if (dragSrcIdx === null || dragSrcIdx === targetIdx) return;
moveSingle(dragSrcIdx, targetIdx);
} else if (dragMode === 'multi') {
if (selectedIdxs.has(targetIdx)) return;
moveMulti(targetIdx);
}
}
function onCardDragEnd() {
stopAutoScroll();
hideDragGhost();
document.querySelectorAll('.card').forEach(c => {
c.classList.remove('dragging', 'multi-dragging', 'drag-over');
});
dragMode = null;
dragSrcIdx = null;
}
// ── window-level dragover: 고스트 위치 + 자동 스크롤 ──
function onWindowDragOver(e) {
if (!dragMode) return;
if (dragMode === 'multi') moveDragGhost(e);
trackAutoScroll(e.clientY);
}
// ── 단일 이동 ─────────────────────────────────────────
function moveSingle(srcIdx, targetIdx) {
const [moved] = files.splice(srcIdx, 1);
files.splice(targetIdx, 0, moved);
reorderGridSingle(srcIdx, targetIdx);
updateNumberBadges();
updateNewNameLabels();
}
function reorderGridSingle(srcIdx, targetIdx) {
const cards = Array.from(grid.children);
const srcCard = cards[srcIdx];
const targetCard = cards[targetIdx];
if (srcIdx < targetIdx) {
grid.insertBefore(srcCard, targetCard.nextSibling);
} else {
grid.insertBefore(srcCard, targetCard);
}
Array.from(grid.children).forEach((c, i) => { c.dataset.idx = i; });
}
// ── 다중 이동 ─────────────────────────────────────────
function moveMulti(targetIdx) {
const sortedSel = [...selectedIdxs].sort((a, b) => a - b);
const movedFiles = sortedSel.map(i => files[i]);
const removedBefore = sortedSel.filter(i => i < targetIdx).length;
const adjustedTarget = targetIdx - removedBefore;
for (let i = sortedSel.length - 1; i >= 0; i--) {
files.splice(sortedSel[i], 1);
}
files.splice(adjustedTarget, 0, ...movedFiles);
const newSelected = new Set();
for (let i = 0; i < movedFiles.length; i++) {
newSelected.add(adjustedTarget + i);
}
selectedIdxs = newSelected;
rerenderCardsInPlace();
updateNumberBadges();
updateNewNameLabels();
refreshCardSelection();
updateSelCount();
}
// ── cards를 files[] 순서에 맞게 DOM 재정렬 (이미지 재로딩 없이) ──
function rerenderCardsInPlace() {
const cardsByPath = new Map();
Array.from(grid.children).forEach(card => {
cardsByPath.set(card.dataset.filepath, card);
});
grid.innerHTML = '';
files.forEach((f, idx) => {
const card = cardsByPath.get(f.path);
if (card) {
card.dataset.idx = idx;
grid.appendChild(card);
}
});
}
// ═══════════════════════════════════════════════════════
// 자동 스크롤 (드래그 중 화면 상/하단 10% 진입 시)
// ═══════════════════════════════════════════════════════
function trackAutoScroll(clientY) {
const vh = window.innerHeight;
const topLine = vh * 0.1;
const botLine = vh * 0.9;
if (clientY < topLine) {
// 화면 상단 10% — 위로 스크롤
// ratio: 0 = 화면 최상단(빠름), 1 = 10% 선(느림)
const ratio = clientY / topLine;
// 속도: vh px/s (최상단) → vh/5 px/s (10% 선)
const speed = vh * (1 - 0.8 * ratio);
startAutoScroll(-speed);
} else if (clientY > botLine) {
// 화면 하단 10% — 아래로 스크롤
// ratio: 0 = 90% 선(느림), 1 = 화면 최하단(빠름)
const ratio = (clientY - botLine) / (vh * 0.1);
const speed = vh * (0.2 + 0.8 * ratio);
startAutoScroll(speed);
} else {
stopAutoScroll();
}
}
function startAutoScroll(speed) {
autoScrollSpeed = speed;
if (!autoScrollRAF) {
lastScrollTs = performance.now();
autoScrollRAF = requestAnimationFrame(tickAutoScroll);
}
}
function stopAutoScroll() {
autoScrollSpeed = 0;
if (autoScrollRAF) {
cancelAnimationFrame(autoScrollRAF);
autoScrollRAF = null;
}
}
function tickAutoScroll(now) {
if (!dragMode) { stopAutoScroll(); return; }
const dt = (now - lastScrollTs) / 1000;
lastScrollTs = now;
gridContainer.scrollTop += autoScrollSpeed * dt;
autoScrollRAF = requestAnimationFrame(tickAutoScroll);
}
// ═══════════════════════════════════════════════════════
// UI 업데이트 헬퍼
// ═══════════════════════════════════════════════════════
function refreshCardSelection() {
Array.from(grid.children).forEach((card, i) => {
card.classList.toggle('selected', selectedIdxs.has(i));
});
}
function updateNumberBadges() {
Array.from(grid.children).forEach((card, i) => {
const badge = card.querySelector('.card-num');
if (badge) badge.textContent = `#${i + 1}`;
card.dataset.idx = i;
});
}
function updateNewNameLabels() {
const prefix = inputPrefix.value.trim() || 'photo';
const digits = Math.max(1, Math.min(6, parseInt(inputDigits.value) || 3));
Array.from(grid.children).forEach((card, i) => {
const el = card.querySelector('.card-new-name');
if (!el) return;
const file = files[i];
if (!file) return;
const ext = file.name.split('.').pop().toLowerCase();
el.textContent = `${prefix}_${String(i + 1).padStart(digits, '0')}.${ext}`;
});
}
function updateSelCount() {
const n = selectedIdxs.size;
if (n > 0) {
selCount.textContent = `${n}개 선택됨`;
selCount.classList.add('visible');
} else {
selCount.textContent = '';
selCount.classList.remove('visible');
}
}
function getCardEl(idx) {
return grid.querySelector(`.card[data-idx="${idx}"]`);
}
// ── 드래그 고스트 배지 ────────────────────────────────
function showDragGhost(e, text) {
dragGhostEl.textContent = text;
dragGhostEl.style.display = 'block';
moveDragGhost(e);
}
function moveDragGhost(e) {
dragGhostEl.style.left = (e.clientX + 16) + 'px';
dragGhostEl.style.top = (e.clientY - 14) + 'px';
}
function hideDragGhost() {
dragGhostEl.style.display = 'none';
}
// ── 정렬 ─────────────────────────────────────────────
async function sortFiles(mode) {
if (!files.length) return;
if (mode === 'name') files.sort((a, b) => a.name.localeCompare(b.name, 'ko'));
else files.sort((a, b) => a.mtime - b.mtime);
selectedIdxs.clear();
await renderGrid();
setStatus(`${mode === 'name' ? '이름순' : '날짜순'}으로 정렬됨`, true);
}
// ── 미리보기 ─────────────────────────────────────────
function updatePreview() {
const prefix = inputPrefix.value.trim() || 'photo';
const digits = Math.max(1, Math.min(6, parseInt(inputDigits.value) || 3));
const ext = files.length ? files[0].name.split('.').pop().toLowerCase() : 'jpg';
previewName.textContent = `${prefix}_${'1'.padStart(digits, '0')}.${ext}`;
updateNewNameLabels();
}
// ── 이름 변경 ────────────────────────────────────────
async function doRename() {
if (!files.length || !currentFolder) return;
const prefix = inputPrefix.value.trim() || 'photo';
const digits = Math.max(1, Math.min(6, parseInt(inputDigits.value) || 3));
const examples = files.slice(0, 3).map((f, i) => {
const ext = f.name.split('.').pop().toLowerCase();
return `${f.name}${prefix}_${String(i + 1).padStart(digits, '0')}.${ext}`;
}).join('\n');
const more = files.length > 3 ? `\n... 외 ${files.length - 3}` : '';
if (!confirm(`📂 ${files.length}개 파일을 이름 변경합니다:\n\n${examples}${more}\n\n계속하시겠습니까?`)) return;
showLoading('파일 이름 변경 중...');
setStatus('이름 변경 중...', false);
try {
const result = await window.api.renameFiles({ folderPath: currentFolder, files, prefix, digits });
hideLoading();
if (result.errors.length) {
alert('일부 오류:\n' + result.errors.join('\n'));
} else {
showToast(`${result.renamed.length}개 파일 이름 변경 완료!`);
setStatus(`${result.renamed.length}개 파일 이름 변경 완료`, true);
}
const newList = await window.api.listImages(currentFolder);
files = newList;
selectedIdxs.clear();
await renderGrid();
} catch (e) {
hideLoading();
alert('오류 발생: ' + e.message);
}
}
// ── 유틸 ─────────────────────────────────────────────
function showLoading(msg = '처리 중...') {
loadingText.textContent = msg;
loading.classList.add('show');
}
function hideLoading() {
loading.classList.remove('show');
}
function setStatus(msg, ready) {
statusText.textContent = msg;
statusDot.className = 'status-dot' + (ready ? ' ready' : '');
}
function showToast(msg, bg = '#6af7a0', color = '#0a2a1a') {
toast.textContent = msg;
toast.style.background = bg;
toast.style.color = color;
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 3000);
}