diff --git a/src/handlers/dragDrop.js b/src/handlers/dragDrop.js new file mode 100644 index 0000000..d1a7614 --- /dev/null +++ b/src/handlers/dragDrop.js @@ -0,0 +1,219 @@ +let _grid, _gridContainer, _dragGhostEl; +let _state, _cb; + +let dragMode = null; +let dragSrcIdx = null; + +let autoScrollRAF = null; +let autoScrollSpeed = 0; +let lastScrollTs = null; + +export function init({ grid, gridContainer, dragGhostEl, state, callbacks }) { + _grid = grid; + _gridContainer = gridContainer; + _dragGhostEl = dragGhostEl; + _state = state; + _cb = callbacks; + + window.addEventListener('dragover', _onWindowDragOver); +} + +// 카드 생성 시 호출해 드래그 이벤트를 부착 +export function setupCard(card) { + card.addEventListener('dragstart', _onDragStart); + card.addEventListener('dragover', _onDragOver); + card.addEventListener('dragenter', _onDragEnter); + card.addEventListener('dragleave', _onDragLeave); + card.addEventListener('drop', _onDrop); + card.addEventListener('dragend', _onDragEnd); +} + +// ── window-level dragover: 고스트 위치 + 자동 스크롤 ── + +function _onWindowDragOver(e) { + if (!dragMode) return; + if (dragMode === 'multi') _moveGhost(e); + _trackScroll(e.clientY); +} + +// ── 카드 드래그 핸들러 ───────────────────────────── + +function _onDragStart(e) { + const idx = parseInt(this.dataset.idx); + + if (_state.selectedIdxs.has(idx) && _state.selectedIdxs.size > 1) { + dragMode = 'multi'; + dragSrcIdx = idx; + _state.selectedIdxs.forEach(i => { + const c = _getCardEl(i); + if (c) c.classList.add('multi-dragging'); + }); + _showGhost(e, `📦 ${_state.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'); + _state.selectedIdxs.clear(); + _cb.refreshCardSelection(); + } + + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', String(idx)); +} + +function _onDragOver(e) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; +} + +function _onDragEnter(e) { + e.preventDefault(); + const idx = parseInt(this.dataset.idx); + const isSrc = dragMode === 'single' ? idx === dragSrcIdx : _state.selectedIdxs.has(idx); + if (!isSrc) this.classList.add('drag-over'); +} + +function _onDragLeave() { + this.classList.remove('drag-over'); +} + +function _onDrop(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 (_state.selectedIdxs.has(targetIdx)) return; + _moveMulti(targetIdx); + } +} + +function _onDragEnd() { + _stopScroll(); + _hideGhost(); + document.querySelectorAll('.card').forEach(c => + c.classList.remove('dragging', 'multi-dragging', 'drag-over') + ); + dragMode = null; + dragSrcIdx = null; +} + +// ── 이동 로직 ───────────────────────────────────── + +function _moveSingle(srcIdx, targetIdx) { + const [moved] = _state.files.splice(srcIdx, 1); + _state.files.splice(targetIdx, 0, moved); + + const cards = Array.from(_grid.children); + const src = cards[srcIdx]; + const target = cards[targetIdx]; + if (srcIdx < targetIdx) { + _grid.insertBefore(src, target.nextSibling); + } else { + _grid.insertBefore(src, target); + } + Array.from(_grid.children).forEach((c, i) => { c.dataset.idx = i; }); + + _cb.updateNumberBadges(); + _cb.updateNewNameLabels(); +} + +function _moveMulti(targetIdx) { + const sorted = [..._state.selectedIdxs].sort((a, b) => a - b); + const movedFiles = sorted.map(i => _state.files[i]); + + const removedBefore = sorted.filter(i => i < targetIdx).length; + const adjustedTarget = targetIdx - removedBefore; + + for (let i = sorted.length - 1; i >= 0; i--) { + _state.files.splice(sorted[i], 1); + } + _state.files.splice(adjustedTarget, 0, ...movedFiles); + + const newSel = new Set(); + for (let i = 0; i < movedFiles.length; i++) newSel.add(adjustedTarget + i); + _state.selectedIdxs = newSel; + + // DOM 재정렬 (이미지 재로딩 없이) + const byPath = new Map(); + Array.from(_grid.children).forEach(c => byPath.set(c.dataset.filepath, c)); + _grid.innerHTML = ''; + _state.files.forEach((f, idx) => { + const c = byPath.get(f.path); + if (c) { c.dataset.idx = idx; _grid.appendChild(c); } + }); + + _cb.updateNumberBadges(); + _cb.updateNewNameLabels(); + _cb.refreshCardSelection(); + _cb.updateSelCount(); +} + +// ── 자동 스크롤 ─────────────────────────────────── + +function _trackScroll(clientY) { + const vh = window.innerHeight; + const topLine = vh * 0.1; + const botLine = vh * 0.9; + + if (clientY < topLine) { + const ratio = clientY / topLine; + _startScroll(-vh * (1 - 0.8 * ratio)); + } else if (clientY > botLine) { + const ratio = (clientY - botLine) / (vh * 0.1); + _startScroll(vh * (0.2 + 0.8 * ratio)); + } else { + _stopScroll(); + } +} + +function _startScroll(speed) { + autoScrollSpeed = speed; + if (!autoScrollRAF) { + lastScrollTs = performance.now(); + autoScrollRAF = requestAnimationFrame(_tickScroll); + } +} + +function _stopScroll() { + autoScrollSpeed = 0; + if (autoScrollRAF) { + cancelAnimationFrame(autoScrollRAF); + autoScrollRAF = null; + } +} + +function _tickScroll(now) { + if (!dragMode) { _stopScroll(); return; } + const dt = (now - lastScrollTs) / 1000; + lastScrollTs = now; + _gridContainer.scrollTop += autoScrollSpeed * dt; + autoScrollRAF = requestAnimationFrame(_tickScroll); +} + +// ── 드래그 고스트 ───────────────────────────────── + +function _showGhost(e, text) { + _dragGhostEl.textContent = text; + _dragGhostEl.style.display = 'block'; + _moveGhost(e); +} + +function _moveGhost(e) { + _dragGhostEl.style.left = (e.clientX + 16) + 'px'; + _dragGhostEl.style.top = (e.clientY - 14) + 'px'; +} + +function _hideGhost() { + _dragGhostEl.style.display = 'none'; +} + +function _getCardEl(idx) { + return _grid.querySelector(`.card[data-idx="${idx}"]`); +} diff --git a/src/handlers/pinchZoom.js b/src/handlers/pinchZoom.js new file mode 100644 index 0000000..41e9d0f --- /dev/null +++ b/src/handlers/pinchZoom.js @@ -0,0 +1,29 @@ +const THUMB_MIN = 150; +const THUMB_MAX = 400; + +let _thumbSizeHint; +let thumbSize = 250; +let thumbHintTimer = null; + +export function init({ gridContainer, thumbSizeHint }) { + _thumbSizeHint = thumbSizeHint; + + gridContainer.addEventListener('wheel', (e) => { + if (!e.ctrlKey) return; + e.preventDefault(); + setThumbSize(thumbSize + (-e.deltaY * 0.8)); + }, { passive: false }); +} + +export function setThumbSize(size) { + thumbSize = Math.round(Math.max(THUMB_MIN, Math.min(THUMB_MAX, size))); + document.documentElement.style.setProperty('--thumb-size', thumbSize + 'px'); + _showHint(thumbSize); +} + +function _showHint(size) { + _thumbSizeHint.textContent = `${size}px`; + _thumbSizeHint.classList.add('show'); + clearTimeout(thumbHintTimer); + thumbHintTimer = setTimeout(() => _thumbSizeHint.classList.remove('show'), 1000); +} diff --git a/src/handlers/selection.js b/src/handlers/selection.js new file mode 100644 index 0000000..4e9996c --- /dev/null +++ b/src/handlers/selection.js @@ -0,0 +1,90 @@ +let _rubberBand, _grid; +let _state, _cb; + +let rbActive = false; +let rbStartX = 0; +let rbStartY = 0; + +export function init({ gridContainer, rubberBand, grid, state, callbacks }) { + _rubberBand = rubberBand; + _grid = grid; + _state = state; + _cb = callbacks; + + gridContainer.addEventListener('mousedown', _onStart); + window.addEventListener('mousemove', _onMove); + window.addEventListener('mouseup', _onEnd); +} + +// 카드에 부착할 cmd+클릭 핸들러 (this = card element) +export function onCardClick(e) { + if (!e.metaKey) return; + e.stopPropagation(); + const idx = parseInt(this.dataset.idx); + if (_state.selectedIdxs.has(idx)) { + _state.selectedIdxs.delete(idx); + } else { + _state.selectedIdxs.add(idx); + } + _cb.refreshCardSelection(); + _cb.updateSelCount(); +} + +function _onStart(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; + + Object.assign(_rubberBand.style, { + display: 'block', + left: rbStartX + 'px', + top: rbStartY + 'px', + width: '0px', + height: '0px', + }); + + if (!e.shiftKey) { + _state.selectedIdxs.clear(); + _cb.refreshCardSelection(); + } +} + +function _onMove(e) { + if (!rbActive) return; + + const left = Math.min(rbStartX, e.clientX); + const top = Math.min(rbStartY, e.clientY); + const width = Math.abs(e.clientX - rbStartX); + const height = Math.abs(e.clientY - rbStartY); + + Object.assign(_rubberBand.style, { + left: left + 'px', top: top + 'px', + width: width + 'px', height: height + 'px', + }); + + const sel = { left, top, right: left + width, bottom: top + height }; + Array.from(_grid.children).forEach((card, i) => { + const r = card.getBoundingClientRect(); + const overlaps = !(r.right < sel.left || r.left > sel.right || + r.bottom < sel.top || r.top > sel.bottom); + if (overlaps) { + _state.selectedIdxs.add(i); + } else if (!e.shiftKey) { + _state.selectedIdxs.delete(i); + } + }); + + _cb.refreshCardSelection(); + _cb.updateSelCount(); +} + +function _onEnd() { + if (!rbActive) return; + rbActive = false; + _rubberBand.style.display = 'none'; + _cb.updateSelCount(); +} diff --git a/src/index.html b/src/index.html index 6f5018b..c0420f4 100644 --- a/src/index.html +++ b/src/index.html @@ -60,6 +60,6 @@
- + diff --git a/src/renderer.js b/src/renderer.js index 8e670b7..faaae73 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -1,44 +1,7 @@ -// ═══════════════════════════════════════════════════════ -// 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; +import { state } from './state.js'; +import * as pinchZoom from './handlers/pinchZoom.js'; +import * as selection from './handlers/selection.js'; +import * as dragDrop from './handlers/dragDrop.js'; // ── DOM ────────────────────────────────────────────── const grid = document.getElementById('grid'); @@ -64,7 +27,19 @@ const gridContainer = document.getElementById('grid-container'); const dragGhostEl = document.getElementById('drag-ghost'); const thumbSizeHint = document.getElementById('thumb-size-hint'); -// ── 초기화 ─────────────────────────────────────────── +// ── 핸들러 초기화 ───────────────────────────────── +const callbacks = { + refreshCardSelection, + updateSelCount, + updateNumberBadges, + updateNewNameLabels, +}; + +pinchZoom.init({ gridContainer, thumbSizeHint }); +selection.init({ gridContainer, rubberBand, grid, state, callbacks }); +dragDrop.init({ grid, gridContainer, dragGhostEl, state, callbacks }); + +// ── 버튼 이벤트 ────────────────────────────────── inputPrefix.addEventListener('input', updatePreview); inputDigits.addEventListener('input', updatePreview); btnOpen.addEventListener('click', openFolder); @@ -72,47 +47,14 @@ btnSortName.addEventListener('click', () => sortFiles('name')); btnSortDate.addEventListener('click', () => sortFiles('date')); btnRename.addEventListener('click', doRename); btnReveal.addEventListener('click', () => { - if (currentFolder) window.api.revealFolder(currentFolder); + if (state.currentFolder) window.api.revealFolder(state.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; + state.currentFolder = folder; dirPath.textContent = folder; showLoading('이미지 목록 불러오는 중...'); const list = await window.api.listImages(folder); @@ -122,34 +64,34 @@ async function openFolder() { showToast('⚠️ 이미지 파일이 없습니다', '#f76a6a', '#0a0a0a'); return; } - files = list; - selectedIdxs.clear(); - setStatus(`${files.length}개 파일 로드됨`, true); + state.files.length = 0; + state.files.push(...list); + state.selectedIdxs.clear(); + setStatus(`${state.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}개 파일`; + fileCount.textContent = `${state.files.length}개 파일`; updateSelCount(); - showLoading(`썸네일 로딩 중... (0/${files.length})`); + showLoading(`썸네일 로딩 중... (0/${state.files.length})`); const BATCH = 10; - for (let i = 0; i < files.length; i += BATCH) { - const batch = files.slice(i, i + BATCH); + for (let i = 0; i < state.files.length; i += BATCH) { + const batch = state.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)); + grid.appendChild(createCard(state.files[i + bi], i + bi, dataUrl)); }); - loadingText.textContent = `썸네일 로딩 중... (${Math.min(i + BATCH, files.length)}/${files.length})`; + loadingText.textContent = `썸네일 로딩 중... (${Math.min(i + BATCH, state.files.length)}/${state.files.length})`; } hideLoading(); @@ -158,7 +100,7 @@ async function renderGrid() { updateNewNameLabels(); } -// ── 카드 생성 ──────────────────────────────────────── +// ── 카드 생성 ──────────────────────────────────── function createCard(file, idx, dataUrl) { const card = document.createElement('div'); card.className = 'card'; @@ -187,14 +129,12 @@ function createCard(file, idx, dataUrl) { thumb.textContent = '🖼️'; } - const body = document.createElement('div'); + const body = document.createElement('div'); body.className = 'card-body'; - - const nameEl = document.createElement('div'); + 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'; @@ -205,309 +145,78 @@ function createCard(file, idx, dataUrl) { 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); + dragDrop.setupCard(card); + card.addEventListener('click', selection.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(); +// ── 정렬 ───────────────────────────────────────── +async function sortFiles(mode) { + if (!state.files.length) return; + if (mode === 'name') state.files.sort((a, b) => a.name.localeCompare(b.name, 'ko')); + else state.files.sort((a, b) => a.mtime - b.mtime); + state.selectedIdxs.clear(); + await renderGrid(); + setStatus(`${mode === 'name' ? '이름순' : '날짜순'}으로 정렬됨`, true); } -// ═══════════════════════════════════════════════════════ -// 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 updatePreview() { + const prefix = inputPrefix.value.trim() || 'photo'; + const digits = Math.max(1, Math.min(6, parseInt(inputDigits.value) || 3)); + const ext = state.files.length ? state.files[0].name.split('.').pop().toLowerCase() : 'jpg'; + previewName.textContent = `${prefix}_${'1'.padStart(digits, '0')}.${ext}`; + updateNewNameLabels(); } -function onRubberBandMove(e) { - if (!rbActive) return; +// ── 이름 변경 ──────────────────────────────────── +async function doRename() { + if (!state.files.length || !state.currentFolder) return; + const prefix = inputPrefix.value.trim() || 'photo'; + const digits = Math.max(1, Math.min(6, parseInt(inputDigits.value) || 3)); - const curX = e.clientX; - const curY = e.clientY; + const examples = state.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 = state.files.length > 3 ? `\n... 외 ${state.files.length - 3}개` : ''; - const left = Math.min(rbStartX, curX); - const top = Math.min(rbStartY, curY); - const width = Math.abs(curX - rbStartX); - const height = Math.abs(curY - rbStartY); + if (!confirm(`📂 ${state.files.length}개 파일을 이름 변경합니다:\n\n${examples}${more}\n\n계속하시겠습니까?`)) return; - rubberBand.style.left = left + 'px'; - rubberBand.style.top = top + 'px'; - rubberBand.style.width = width + 'px'; - rubberBand.style.height = height + 'px'; + showLoading('파일 이름 변경 중...'); + setStatus('이름 변경 중...', false); - // 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'); + try { + const result = await window.api.renameFiles({ + folderPath: state.currentFolder, + files: state.files, + prefix, + digits, }); - - 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); + hideLoading(); + if (result.errors.length) { + alert('일부 오류:\n' + result.errors.join('\n')); + } else { + showToast(`✅ ${result.renamed.length}개 파일 이름 변경 완료!`); + setStatus(`${result.renamed.length}개 파일 이름 변경 완료`, true); } - }); -} - -// ═══════════════════════════════════════════════════════ -// 자동 스크롤 (드래그 중 화면 상/하단 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(); + const newList = await window.api.listImages(state.currentFolder); + state.files.length = 0; + state.files.push(...newList); + state.selectedIdxs.clear(); + await renderGrid(); + } catch (e) { + hideLoading(); + alert('오류 발생: ' + e.message); } } -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 업데이트 헬퍼 -// ═══════════════════════════════════════════════════════ +// ── UI 업데이트 ────────────────────────────────── function refreshCardSelection() { Array.from(grid.children).forEach((card, i) => { - card.classList.toggle('selected', selectedIdxs.has(i)); + card.classList.toggle('selected', state.selectedIdxs.has(i)); }); } @@ -523,17 +232,16 @@ 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 el = card.querySelector('.card-new-name'); + const file = state.files[i]; + if (!el || !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; + const n = state.selectedIdxs.size; if (n > 0) { selCount.textContent = `${n}개 선택됨`; selCount.classList.add('visible'); @@ -543,82 +251,8 @@ function updateSelCount() { } } -function getCardEl(idx) { - return grid.querySelector(`.card[data-idx="${idx}"]`); -} +// ── 공통 UI 헬퍼 ───────────────────────────────── -// ── 드래그 고스트 배지 ──────────────────────────────── -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'); diff --git a/src/state.js b/src/state.js new file mode 100644 index 0000000..f58c0b6 --- /dev/null +++ b/src/state.js @@ -0,0 +1,5 @@ +export const state = { + files: [], + currentFolder: null, + selectedIdxs: new Set(), +};