선택한 이미지들을 드래그하고 폴더에 드롭하면 파일 이동 실행

This commit is contained in:
2026-05-15 16:31:58 +09:00
parent 595ceac26b
commit 604c1aa806
6 changed files with 283 additions and 11 deletions
+33
View File
@@ -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 }) => { ipcMain.handle('rename-files', async (_event, { folderPath, files, prefix, digits }) => {
const errors = []; const errors = [];
const pad = (n) => String(n + 1).padStart(digits, '0'); const pad = (n) => String(n + 1).padStart(digits, '0');
+2
View File
@@ -11,4 +11,6 @@ contextBridge.exposeInMainWorld('api', {
setLastFolder: (p) => ipcRenderer.invoke('set-last-folder', p), setLastFolder: (p) => ipcRenderer.invoke('set-last-folder', p),
listDirectories: (p) => ipcRenderer.invoke('list-directories', p), listDirectories: (p) => ipcRenderer.invoke('list-directories', p),
revealFolder: (p) => ipcRenderer.invoke('reveal-folder', 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),
}); });
+71 -4
View File
@@ -1,21 +1,30 @@
let _grid, _gridContainer, _dragGhostEl; let _grid, _gridContainer, _dragGhostEl, _fileExplorerEl;
let _state, _cb; let _state, _cb, _onFolderDrop;
let dragMode = null; let dragMode = null;
let dragSrcIdx = null; let dragSrcIdx = null;
let _hoveredTreeRow = null;
let autoScrollRAF = null; let autoScrollRAF = null;
let autoScrollSpeed = 0; let autoScrollSpeed = 0;
let lastScrollTs = null; let lastScrollTs = null;
export function init({ grid, gridContainer, dragGhostEl, state, callbacks }) { export function init({ grid, gridContainer, dragGhostEl, state, callbacks, fileExplorerEl, onFolderDrop }) {
_grid = grid; _grid = grid;
_gridContainer = gridContainer; _gridContainer = gridContainer;
_dragGhostEl = dragGhostEl; _dragGhostEl = dragGhostEl;
_state = state; _state = state;
_cb = callbacks; _cb = callbacks;
_fileExplorerEl = fileExplorerEl;
_onFolderDrop = onFolderDrop;
window.addEventListener('dragover', _onWindowDragOver); 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) { function _onWindowDragOver(e) {
if (!dragMode) return; if (!dragMode) return;
if (dragMode === 'multi') _moveGhost(e); _moveGhost(e);
_trackScroll(e.clientY); _trackScroll(e.clientY);
} }
@@ -97,6 +106,7 @@ function _onDrop(e) {
function _onDragEnd() { function _onDragEnd() {
_stopScroll(); _stopScroll();
_hideGhost(); _hideGhost();
_clearTreeHover();
document.querySelectorAll('.card').forEach(c => document.querySelectorAll('.card').forEach(c =>
c.classList.remove('dragging', 'multi-dragging', 'drag-over') c.classList.remove('dragging', 'multi-dragging', 'drag-over')
); );
@@ -104,6 +114,63 @@ function _onDragEnd() {
dragSrcIdx = null; 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) { function _moveSingle(srcIdx, targetIdx) {
+23
View File
@@ -75,6 +75,29 @@
<div id="toast"></div> <div id="toast"></div>
<div id="move-modal" class="modal-overlay" style="display:none;">
<div class="modal-box">
<p class="modal-msg">대상 폴더에 이미 같은 이름의 파일이 존재합니다.<br>새로운 이름으로 파일들을 이동하시겠습니까?</p>
<div class="modal-fields">
<label>파일명 포맷<input type="text" id="move-prefix" value="photo" placeholder="접두사" /></label>
<label>자리 순번 숫자<input type="number" id="move-digits" value="4" min="1" max="6" /></label>
</div>
<div class="modal-actions">
<button id="move-cancel" class="btn btn-ghost">취소</button>
<button id="move-confirm" class="btn btn-success">실행</button>
</div>
</div>
</div>
<div id="move-error-modal" class="modal-overlay" style="display:none;">
<div class="modal-box">
<p class="modal-msg">새로운 이름의 파일도 존재합니다.</p>
<div class="modal-actions">
<button id="move-error-close" class="btn btn-ghost">닫기</button>
</div>
</div>
</div>
<script type="module" src="renderer.js"></script> <script type="module" src="renderer.js"></script>
</body> </body>
</html> </html>
+103 -2
View File
@@ -30,6 +30,13 @@ const gridContainer = document.getElementById('grid-container');
const dragGhostEl = document.getElementById('drag-ghost'); const dragGhostEl = document.getElementById('drag-ghost');
const thumbSizeHint = document.getElementById('thumb-size-hint'); const thumbSizeHint = document.getElementById('thumb-size-hint');
const btnPreviewToggle = document.getElementById('btn-preview-toggle'); const btnPreviewToggle = document.getElementById('btn-preview-toggle');
const 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 = { const callbacks = {
@@ -39,12 +46,13 @@ const callbacks = {
updateNewNameLabels, updateNewNameLabels,
}; };
const fileExplorerContainer = document.querySelector('.file-explorer');
pinchZoom.init({ gridContainer, thumbSizeHint }); pinchZoom.init({ gridContainer, thumbSizeHint });
selection.init({ gridContainer, rubberBand, grid, state, callbacks }); 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(); hoverPreview.init();
const fileExplorerContainer = document.querySelector('.file-explorer');
fileExplorer.init(fileExplorerContainer, loadFolder); fileExplorer.init(fileExplorerContainer, loadFolder);
window.api.onFullscreenChange((isFullscreen) => { 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 업데이트 ────────────────────────────────── // ── UI 업데이트 ──────────────────────────────────
function refreshCardSelection() { function refreshCardSelection() {
+46
View File
@@ -475,6 +475,52 @@ footer span { font-size: 11px; color: var(--muted); }
margin-left: 15px; 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 { #toast {
position: fixed; bottom: 40px; left: 50%; position: fixed; bottom: 40px; left: 50%;