diff --git a/src/main/ipc/files.js b/src/main/ipc/files.js index a486a0c..72c1d46 100644 --- a/src/main/ipc/files.js +++ b/src/main/ipc/files.js @@ -36,6 +36,39 @@ function register() { } }); + ipcMain.handle('check-files-exist', (_event, folderPath, names) => { + return names.filter(name => { + try { fs.accessSync(path.join(folderPath, name)); return true; } + catch { return false; } + }); + }); + + ipcMain.handle('move-files', async (_event, { targetFolder, files }) => { + const errors = []; + const moved = []; + for (const file of files) { + const destName = file.newName || file.name; + const dest = path.join(targetFolder, destName); + try { + fs.renameSync(file.path, dest); + moved.push({ src: file.path, dest, name: destName }); + } catch (e) { + if (e.code === 'EXDEV') { + try { + fs.copyFileSync(file.path, dest); + fs.unlinkSync(file.path); + moved.push({ src: file.path, dest, name: destName }); + } catch (e2) { + errors.push(`${file.name}: ${e2.message}`); + } + } else { + errors.push(`${file.name}: ${e.message}`); + } + } + } + return { moved, errors }; + }); + ipcMain.handle('rename-files', async (_event, { folderPath, files, prefix, digits }) => { const errors = []; const pad = (n) => String(n + 1).padStart(digits, '0'); diff --git a/src/preload/index.js b/src/preload/index.js index ac29c70..61bfdde 100644 --- a/src/preload/index.js +++ b/src/preload/index.js @@ -11,4 +11,6 @@ contextBridge.exposeInMainWorld('api', { setLastFolder: (p) => ipcRenderer.invoke('set-last-folder', p), listDirectories: (p) => ipcRenderer.invoke('list-directories', p), revealFolder: (p) => ipcRenderer.invoke('reveal-folder', p), + checkFilesExist: (folder, names) => ipcRenderer.invoke('check-files-exist', folder, names), + moveFiles: (args) => ipcRenderer.invoke('move-files', args), }); diff --git a/src/renderer/handlers/drag-drop.js b/src/renderer/handlers/drag-drop.js index d1a7614..f465cbc 100644 --- a/src/renderer/handlers/drag-drop.js +++ b/src/renderer/handlers/drag-drop.js @@ -1,21 +1,30 @@ -let _grid, _gridContainer, _dragGhostEl; -let _state, _cb; +let _grid, _gridContainer, _dragGhostEl, _fileExplorerEl; +let _state, _cb, _onFolderDrop; let dragMode = null; let dragSrcIdx = null; +let _hoveredTreeRow = 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; +export function init({ grid, gridContainer, dragGhostEl, state, callbacks, fileExplorerEl, onFolderDrop }) { + _grid = grid; + _gridContainer = gridContainer; + _dragGhostEl = dragGhostEl; + _state = state; + _cb = callbacks; + _fileExplorerEl = fileExplorerEl; + _onFolderDrop = onFolderDrop; window.addEventListener('dragover', _onWindowDragOver); + + if (fileExplorerEl) { + fileExplorerEl.addEventListener('dragover', _onExplorerDragOver); + fileExplorerEl.addEventListener('dragleave', _onExplorerDragLeave); + fileExplorerEl.addEventListener('drop', _onExplorerDrop); + } } // 카드 생성 시 호출해 드래그 이벤트를 부착 @@ -32,7 +41,7 @@ export function setupCard(card) { function _onWindowDragOver(e) { if (!dragMode) return; - if (dragMode === 'multi') _moveGhost(e); + _moveGhost(e); _trackScroll(e.clientY); } @@ -97,6 +106,7 @@ function _onDrop(e) { function _onDragEnd() { _stopScroll(); _hideGhost(); + _clearTreeHover(); document.querySelectorAll('.card').forEach(c => c.classList.remove('dragging', 'multi-dragging', 'drag-over') ); @@ -104,6 +114,63 @@ function _onDragEnd() { dragSrcIdx = null; } +// ── 폴더 탐색기 드롭 핸들러 ───────────────────────── + +function _onExplorerDragOver(e) { + if (!dragMode) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + + const row = e.target.closest('.tree-row'); + if (row !== _hoveredTreeRow) { + _clearTreeHover(); + _hoveredTreeRow = row; + if (_hoveredTreeRow) _hoveredTreeRow.classList.add('drop-target'); + } + + if (dragMode === 'single' && row && _dragGhostEl.style.display === 'none') { + _showGhost(e, '📁 1개 폴더로 이동'); + } + if (dragMode === 'multi' && row) { + _dragGhostEl.textContent = `📁 ${_state.selectedIdxs.size}개 폴더로 이동`; + } +} + +function _onExplorerDragLeave(e) { + if (!_fileExplorerEl.contains(e.relatedTarget)) { + _clearTreeHover(); + if (dragMode === 'single') _hideGhost(); + if (dragMode === 'multi') _dragGhostEl.textContent = `📦 ${_state.selectedIdxs.size}개 이동 중`; + } +} + +function _onExplorerDrop(e) { + e.preventDefault(); + const row = e.target.closest('.tree-row'); + _clearTreeHover(); + + if (!row || !_onFolderDrop) return; + const folderNode = row.closest('.tree-node'); + if (!folderNode) return; + + const targetPath = folderNode.dataset.path; + const indices = _getDraggedFileIndices(); + _onFolderDrop(targetPath, indices); +} + +function _getDraggedFileIndices() { + if (dragMode === 'multi') return [..._state.selectedIdxs]; + if (dragMode === 'single' && dragSrcIdx !== null) return [dragSrcIdx]; + return []; +} + +function _clearTreeHover() { + if (_hoveredTreeRow) { + _hoveredTreeRow.classList.remove('drop-target'); + _hoveredTreeRow = null; + } +} + // ── 이동 로직 ───────────────────────────────────── function _moveSingle(srcIdx, targetIdx) { diff --git a/src/renderer/index.html b/src/renderer/index.html index b0237a0..4250979 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -75,6 +75,29 @@
+ + + + diff --git a/src/renderer/renderer.js b/src/renderer/renderer.js index cb3610a..762adb7 100644 --- a/src/renderer/renderer.js +++ b/src/renderer/renderer.js @@ -30,6 +30,13 @@ 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 moveModal = document.getElementById('move-modal'); +const movePrefixInput = document.getElementById('move-prefix'); +const moveDigitsInput = document.getElementById('move-digits'); +const moveCancelBtn = document.getElementById('move-cancel'); +const moveConfirmBtn = document.getElementById('move-confirm'); +const moveErrorModal = document.getElementById('move-error-modal'); +const moveErrorCloseBtn = document.getElementById('move-error-close'); // ── 핸들러 초기화 ───────────────────────────────── const callbacks = { @@ -39,12 +46,13 @@ const callbacks = { updateNewNameLabels, }; +const fileExplorerContainer = document.querySelector('.file-explorer'); + pinchZoom.init({ gridContainer, thumbSizeHint }); selection.init({ gridContainer, rubberBand, grid, state, callbacks }); -dragDrop.init({ grid, gridContainer, dragGhostEl, state, callbacks }); +dragDrop.init({ grid, gridContainer, dragGhostEl, state, callbacks, fileExplorerEl: fileExplorerContainer, onFolderDrop: handleFolderDrop }); hoverPreview.init(); -const fileExplorerContainer = document.querySelector('.file-explorer'); fileExplorer.init(fileExplorerContainer, loadFolder); window.api.onFullscreenChange((isFullscreen) => { @@ -280,6 +288,99 @@ async function doRename() { } } +// ── 폴더로 파일 이동 ───────────────────────────── + +let _pendingMoveTarget = null; +let _pendingMoveFiles = null; + +async function handleFolderDrop(targetPath, fileIndices) { + if (!fileIndices.length || !state.currentFolder) return; + if (targetPath === state.currentFolder) return; + + const sorted = [...fileIndices].sort((a, b) => a - b); + const selectedFiles = sorted.map(i => state.files[i]).filter(Boolean); + if (!selectedFiles.length) return; + + const names = selectedFiles.map(f => f.name); + const conflicts = await window.api.checkFilesExist(targetPath, names); + + if (!conflicts.length) { + await _doMoveFiles(targetPath, selectedFiles); + } else { + _pendingMoveTarget = targetPath; + _pendingMoveFiles = selectedFiles; + movePrefixInput.value = 'temp'; + moveModal.style.display = 'flex'; + } +} + +moveCancelBtn.addEventListener('click', () => { + moveModal.style.display = 'none'; + _pendingMoveTarget = null; + _pendingMoveFiles = null; +}); + +moveConfirmBtn.addEventListener('click', async () => { + const prefix = movePrefixInput.value.trim() || 'photo'; + const digits = Math.max(1, Math.min(6, parseInt(moveDigitsInput.value) || 4)); + + const newNames = _pendingMoveFiles.map((f, i) => { + const ext = f.name.split('.').pop().toLowerCase(); + return `${prefix}_${String(i + 1).padStart(digits, '0')}.${ext}`; + }); + + const conflicts = await window.api.checkFilesExist(_pendingMoveTarget, newNames); + if (conflicts.length) { + moveModal.style.display = 'none'; + moveErrorModal.style.display = 'flex'; + return; + } + + moveModal.style.display = 'none'; + const targetPath = _pendingMoveTarget; + const filesToMove = _pendingMoveFiles.map((f, i) => ({ ...f, newName: newNames[i] })); + _pendingMoveTarget = null; + _pendingMoveFiles = null; + await _doMoveFiles(targetPath, filesToMove); +}); + +moveErrorCloseBtn.addEventListener('click', () => { + moveErrorModal.style.display = 'none'; +}); + +async function _doMoveFiles(targetPath, files) { + showLoading('파일 이동 중...'); + try { + const result = await window.api.moveFiles({ targetFolder: targetPath, files }); + hideLoading(); + if (result.errors.length) { + alert('일부 오류:\n' + result.errors.join('\n')); + } else { + showToast(`✅ ${result.moved.length}개 파일 이동 완료!`); + setStatus(`${result.moved.length}개 파일 이동 완료`, true); + } + const newList = await window.api.listImages(state.currentFolder); + state.files.length = 0; + state.files.push(...newList); + state.selectedIdxs.clear(); + if (!newList.length) { + grid.innerHTML = ''; + grid.style.display = 'none'; + emptyState.style.display = ''; + fileCount.textContent = '0개 파일'; + updateSelCount(); + btnRename.disabled = true; + setStatus('이미지 파일 없음', false); + } else { + renderGrid(); + setStatus(`${state.files.length}개 파일 로드됨`, true); + } + } catch (e) { + hideLoading(); + alert('오류 발생: ' + e.message); + } +} + // ── UI 업데이트 ────────────────────────────────── function refreshCardSelection() { diff --git a/src/renderer/style.css b/src/renderer/style.css index 887a0d0..f31dba0 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -475,6 +475,52 @@ footer span { font-size: 11px; color: var(--muted); } margin-left: 15px; } +/* ── 폴더 드롭 타겟 하이라이트 ── */ +.tree-row.drop-target { + background: rgba(124,106,247,.35) !important; + color: #e0d5ff; + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +/* ── 이동 모달 ── */ +.modal-overlay { + position: fixed; inset: 0; + background: rgba(0,0,0,.65); backdrop-filter: blur(4px); + z-index: 500; + display: flex; align-items: center; justify-content: center; +} +.modal-box { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 14px; + padding: 28px 32px; + min-width: 380px; max-width: 480px; + display: flex; flex-direction: column; gap: 20px; + box-shadow: 0 24px 60px rgba(0,0,0,.6); +} +.modal-msg { + font-size: 14px; color: var(--text); line-height: 1.7; +} +.modal-fields { + display: flex; flex-direction: column; gap: 10px; +} +.modal-fields label { + display: flex; align-items: center; justify-content: space-between; gap: 10px; + font-size: 13px; color: var(--muted); +} +.modal-fields 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; width: 160px; +} +.modal-fields input[type="number"] { width: 70px; text-align: center; } +.modal-fields input:focus { border-color: var(--accent); } +.modal-actions { + display: flex; justify-content: flex-end; gap: 10px; +} + /* ── 토스트 ── */ #toast { position: fixed; bottom: 40px; left: 50%;