From caf3d4fabc8f050b1bf16892e1c40ec5edca7c62 Mon Sep 17 00:00:00 2001 From: jisangs Date: Sun, 17 May 2026 18:54:42 +0900 Subject: [PATCH] =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EB=93=9C=EB=9E=98?= =?UTF-8?q?=EA=B7=B8=EC=8B=9C=20=EB=B3=B4=EC=97=AC=EC=A7=80=EB=8A=94=20?= =?UTF-8?q?=EC=84=B8=EB=A1=9C=20seperator=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/handlers/drag-drop.js | 237 ++++++++++++++++++++++++----- src/renderer/style.css | 26 ++-- 2 files changed, 214 insertions(+), 49 deletions(-) diff --git a/src/renderer/handlers/drag-drop.js b/src/renderer/handlers/drag-drop.js index 15e319e..c71334e 100644 --- a/src/renderer/handlers/drag-drop.js +++ b/src/renderer/handlers/drag-drop.js @@ -1,6 +1,6 @@ let _grid, _gridContainer, _dragGhostEl, _fileExplorerEl; let _state, _cb, _onFolderDrop; -let _placeholder = null; +let _dropSeparator = null; let _originPlaceholders = []; let _dropTargetIdx = null; @@ -21,14 +21,9 @@ export function init({ grid, gridContainer, dragGhostEl, state, callbacks, fileE _fileExplorerEl = fileExplorerEl; _onFolderDrop = onFolderDrop; - _placeholder = document.createElement('div'); - _placeholder.className = 'card card-placeholder'; - const phLabel = document.createElement('div'); - phLabel.className = 'card-placeholder-label'; - _placeholder.appendChild(phLabel); - _placeholder.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }); - _placeholder.addEventListener('dragenter', e => e.preventDefault()); - _placeholder.addEventListener('drop', e => { e.preventDefault(); _performDrop(); }); + _dropSeparator = document.createElement('div'); + _dropSeparator.className = 'drop-separator'; + _gridContainer.appendChild(_dropSeparator); window.addEventListener('dragover', _onWindowDragOver); grid.addEventListener('dragover', _onGridDragOver); @@ -69,7 +64,6 @@ function _onDragStart(e) { const count = _state.selectedIdxs.size; const toHide = [..._state.selectedIdxs].sort((a, b) => a - b); - _placeholder.querySelector('.card-placeholder-label').textContent = `${count}개`; const img = new Image(); img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; @@ -81,7 +75,7 @@ function _onDragStart(e) { toHide.forEach(i => { const c = _getCardEl(i); if (c) { - const originPh = _createOriginPlaceholder(); + const originPh = _createOriginPlaceholder(i); _grid.insertBefore(originPh, c); _originPlaceholders.push(originPh); c.classList.add('drag-hidden'); @@ -91,13 +85,12 @@ function _onDragStart(e) { } else { dragMode = 'single'; dragSrcIdx = idx; - _placeholder.querySelector('.card-placeholder-label').textContent = '1개'; _state.selectedIdxs.clear(); _cb.refreshCardSelection(); const srcCard = this; requestAnimationFrame(() => { - const originPh = _createOriginPlaceholder(); + const originPh = _createOriginPlaceholder(idx); _grid.insertBefore(originPh, srcCard); _originPlaceholders.push(originPh); srcCard.classList.add('drag-hidden'); @@ -111,25 +104,21 @@ function _onDragStart(e) { function _onDragOver(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; + _updateDropTarget(e); } function _onDragEnter(e) { e.preventDefault(); - const idx = parseInt(this.dataset.idx); - const isSrc = dragMode === 'single' ? idx === dragSrcIdx : _state.selectedIdxs.has(idx); - if (isSrc) return; - - _dropTargetIdx = idx; - _grid.insertBefore(_placeholder, this); + _updateDropTarget(e); } function _onDragLeave() { - // 플레이스홀더가 위치를 표시하므로 별도 처리 없음 + // separator 위치는 다음 dragover에서 갱신한다. } function _onDrop(e) { e.preventDefault(); - _dropTargetIdx = parseInt(this.dataset.idx); + _updateDropTarget(e); _performDrop(); } @@ -149,9 +138,9 @@ function _performDrop() { function _onDragEnd() { _stopScroll(); _hideGhost(); + _hideDropSeparator(); _clearTreeHover(); _clearOriginPlaceholders(); - if (_placeholder.parentNode) _placeholder.parentNode.removeChild(_placeholder); _dropTargetIdx = null; document.querySelectorAll('.card').forEach(c => c.classList.remove('drag-hidden', 'drag-over') @@ -224,18 +213,15 @@ function _onGridDragOver(e) { if (e.target.closest('.card')) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; - - _dropTargetIdx = _state.files.length; - if (_grid.lastElementChild !== _placeholder) { - _grid.appendChild(_placeholder); - } + _updateDropTarget(e); } function _onGridDrop(e) { if (!dragMode) return; if (e.target.closest('.card')) return; e.preventDefault(); - _dropTargetIdx = null; // 빈 영역 드롭 → 이동 없이 복원 + _updateDropTarget(e); + _performDrop(); } // ── 이동 로직 ───────────────────────────────────── @@ -245,7 +231,7 @@ function _moveSingle(srcIdx, targetIdx) { const insertAt = srcIdx < targetIdx ? targetIdx - 1 : targetIdx; _state.files.splice(insertAt, 0, moved); - if (_placeholder.parentNode) _placeholder.parentNode.removeChild(_placeholder); + _hideDropSeparator(); _clearOriginPlaceholders(); const srcCard = _getCardEl(srcIdx); const targetCard = _getCardEl(targetIdx); @@ -281,7 +267,7 @@ function _moveMulti(targetIdx) { for (let i = 0; i < movedFiles.length; i++) newSel.add(adjustedTarget + i); _state.selectedIdxs = newSel; - if (_placeholder.parentNode) _placeholder.parentNode.removeChild(_placeholder); + _hideDropSeparator(); _clearOriginPlaceholders(); const byPath = new Map(); Array.from(_grid.children).forEach(c => { @@ -364,14 +350,197 @@ function _getCardEl(idx) { return _grid.querySelector(`.card[data-idx="${idx}"]`); } -function _createOriginPlaceholder() { +// ── 드롭 위치 표시 ──────────────────────────────── + +function _updateDropTarget(e) { + const target = _getDropTargetFromPoint(e.clientX, e.clientY); + if (!target) { + _dropTargetIdx = null; + _hideDropSeparator(); + return; + } + + _dropTargetIdx = target.idx; + _showDropSeparator(target); +} + +function _getDropTargetFromPoint(clientX, clientY) { + const items = _getVisualGridItems(); + if (items.length === 0) return null; + const rows = _getGridRows(items); + + const hovered = items.find(item => + clientX >= item.rect.left && + clientX <= item.rect.right && + clientY >= item.rect.top && + clientY <= item.rect.bottom + ); + if (hovered) { + const hoveredRow = _getRowForItem(rows, hovered); + if (!hoveredRow) return null; + + if (clientX <= hovered.rect.left + hovered.rect.width / 2) { + return _makeBoundaryDropTarget(hoveredRow, hovered.idx); + } + return _makeBoundaryDropTarget(hoveredRow, hovered.idx + 1); + } + + const row = rows.find(r => clientY >= r.top && clientY <= r.bottom) || + _getNearestRow(rows, clientY); + if (!row) return null; + + if (clientX <= row.items[0].rect.left) { + return _makeBoundaryDropTarget(row, row.items[0].idx); + } + + const last = row.items[row.items.length - 1]; + if (clientX >= last.rect.right) { + return _makeBoundaryDropTarget(row, last.idx + 1); + } + + for (let i = 0; i < row.items.length - 1; i++) { + const left = row.items[i]; + const right = row.items[i + 1]; + if (clientX > left.rect.right && clientX < right.rect.left) { + return _makeBoundaryDropTarget(row, right.idx); + } + } + + return null; +} + +function _getVisualGridItems() { + return Array.from(_grid.children) + .filter(el => !_isHiddenDragCard(el)) + .map(el => { + const idx = el.classList.contains('card-origin-placeholder') + ? parseInt(el.dataset.originIdx) + : parseInt(el.dataset.idx); + if (!Number.isFinite(idx)) return null; + return { el, idx, rect: el.getBoundingClientRect() }; + }) + .filter(Boolean) + .sort((a, b) => (a.rect.top - b.rect.top) || (a.rect.left - b.rect.left)); +} + +function _isHiddenDragCard(el) { + return el.classList.contains('drag-hidden') || getComputedStyle(el).display === 'none'; +} + +function _getGridRows(items) { + const rows = []; + for (const item of items) { + const row = rows.find(r => Math.abs(r.top - item.rect.top) < 4); + if (row) { + row.items.push(item); + row.top = Math.min(row.top, item.rect.top); + row.bottom = Math.max(row.bottom, item.rect.bottom); + } else { + rows.push({ + top: item.rect.top, + bottom: item.rect.bottom, + items: [item] + }); + } + } + + return rows.map(row => { + row.items.sort((a, b) => a.rect.left - b.rect.left); + row.rect = { + top: row.top, + bottom: row.bottom, + height: row.bottom - row.top + }; + return row; + }); +} + +function _getNearestRow(rows, clientY) { + if (rows.length === 0) return null; + return rows.reduce((nearest, row) => { + const rowDistance = clientY < row.top ? row.top - clientY : clientY - row.bottom; + const nearestDistance = clientY < nearest.top ? nearest.top - clientY : clientY - nearest.bottom; + return rowDistance < nearestDistance ? row : nearest; + }); +} + +function _getRowForItem(rows, item) { + return rows.find(row => row.items.includes(item)); +} + +function _makeBoundaryDropTarget(row, idx) { + const clampedIdx = Math.max(0, Math.min(_state.files.length, idx)); + const rightIndex = row.items.findIndex(item => item.idx >= clampedIdx); + const gap = _getRowGap(row); + + let clientX; + if (rightIndex === -1) { + const last = row.items[row.items.length - 1]; + clientX = last.rect.right + gap / 2; + } else if (rightIndex <= 0) { + clientX = row.items[0].rect.left - gap / 2; + } else if (rightIndex >= row.items.length) { + const last = row.items[row.items.length - 1]; + clientX = last.rect.right + gap / 2; + } else { + const left = row.items[rightIndex - 1]; + const right = row.items[rightIndex]; + clientX = (left.rect.right + right.rect.left) / 2; + } + + return _makeDropTarget(clampedIdx, clientX, row.rect); +} + +function _getRowGap(row) { + const gaps = []; + for (let i = 0; i < row.items.length - 1; i++) { + const gap = row.items[i + 1].rect.left - row.items[i].rect.right; + if (gap > 0) gaps.push(gap); + } + if (gaps.length > 0) return Math.min(...gaps); + + const style = getComputedStyle(_grid); + return parseFloat(style.columnGap || style.gap) || 14; +} + +function _makeDropTarget(idx, clientX, rect) { + return { + idx: Math.max(0, Math.min(_state.files.length, idx)), + clientX, + clientY: rect.top, + height: rect.height || (rect.bottom - rect.top) + }; +} + +function _showDropSeparator(target) { + const containerRect = _gridContainer.getBoundingClientRect(); + _dropSeparator.style.display = 'block'; + _dropSeparator.style.left = `${target.clientX - containerRect.left - 1}px`; + _dropSeparator.style.top = `${target.clientY - containerRect.top + _gridContainer.scrollTop}px`; + _dropSeparator.style.height = `${target.height}px`; +} + +function _hideDropSeparator() { + if (_dropSeparator) _dropSeparator.style.display = 'none'; +} + +function _createOriginPlaceholder(idx) { const ph = document.createElement('div'); ph.className = 'card card-origin-placeholder'; - ph.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }); - ph.addEventListener('dragenter', e => e.preventDefault()); + ph.dataset.originIdx = idx; + ph.addEventListener('dragover', e => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + _updateDropTarget(e); + }); + ph.addEventListener('dragenter', e => { + e.preventDefault(); + _updateDropTarget(e); + }); ph.addEventListener('drop', e => { e.preventDefault(); - _dropTargetIdx = null; // 원본 위치에 드롭 → 이동 없이 복원 + _updateDropTarget(e); + _performDrop(); }); return ph; } diff --git a/src/renderer/style.css b/src/renderer/style.css index c43caac..47f4e74 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -251,20 +251,16 @@ main { border-radius: 8px; } -.card-placeholder { - border: 2px dashed var(--accent); - background: rgba(124, 106, 247, 0.08); - display: flex; - align-items: center; - justify-content: center; - border-radius: 8px; - pointer-events: auto; -} -.card-placeholder-label { - color: var(--accent); - font-size: 13px; - font-weight: 600; - opacity: 0.8; +.drop-separator { + position: absolute; + z-index: 20; + display: none; + width: 3px; + min-height: 48px; + border-radius: 999px; + background: var(--accent); + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.16), 0 0 16px rgba(124, 106, 247, 0.55); + pointer-events: none; } /* ── 썸네일: 정사각형 + object-fit: contain ── */ @@ -581,4 +577,4 @@ footer span { font-size: 11px; color: var(--muted); } 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); } \ No newline at end of file +#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }