선택한 이미지들을 드래그하고 폴더에 드롭하면 파일 이동 실행
This commit is contained in:
@@ -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');
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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() {
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
|||||||
Reference in New Issue
Block a user