Use in-place source move for drag instead of placeholder + display:none

The clone-placeholder approach hid the source element with
display:none, which some browsers treat as drag cancellation. The drag
appeared to "not respond at all" to mouse press.

Switch to a simpler approach: keep the source element in the DOM and
move it directly during dragover. Apply a .dragGhost class after the
drag image is captured (via setTimeout 0) so the source becomes a
translucent dashed placeholder at the prospective drop position. drop
just compares the source's current DOM index to its original
data-index.

Also add cursor:grabbing on :active for visible press feedback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-12 14:39:46 +09:00
parent e617c71b0a
commit f27c3690e3
2 changed files with 31 additions and 41 deletions

View File

@@ -155,31 +155,28 @@
})
}
// ── 드래그 시스템: 컨테이너 단위로 한 곳에서 관리 ─────
// drag = { type, srcIdx, srcEl, placeholder } | null
var drag = null
// ── 드래그 시스템 ───────────────────────────────────
// 원본 요소 자체를 dragover 동안 이동시켜서 "착지 자리에 반투명 고스트" 효과를 만든다.
// placeholder/clone 방식은 source 를 display:none 으로 숨기는 순간 일부 브라우저에서
// 드래그가 즉시 취소되는 문제가 있어 채택하지 않는다.
var drag = null // { type, srcEl } | null
function attachDraggable(el, type, idx) {
el.addEventListener('dragstart', function (e) {
if (!el.draggable) { e.preventDefault(); return }
var ph = el.cloneNode(true)
ph.classList.add('dragPlaceholder')
ph.removeAttribute('draggable')
// 클론 안의 입력 가능한 요소를 비활성화 (포커스 가능성 차단)
ph.querySelectorAll('[contenteditable]').forEach(function (c) {
c.removeAttribute('contenteditable')
})
drag = { type: type, srcIdx: idx, srcEl: el, placeholder: ph }
// 편집 중인 칸을 잡고 드래그하려는 경우는 드래그를 막음.
var t = e.target
if (t && t.getAttribute && t.getAttribute('contenteditable') === 'true') {
e.preventDefault()
return
}
drag = { type: type, srcEl: el }
try {
e.dataTransfer.setData('text/plain', String(idx))
e.dataTransfer.effectAllowed = 'move'
} catch (_) {}
// dragstart 가 완료된 직후에 원본을 숨김 (드래그 이미지 캡처 후).
// placeholder 는 같은 자리에 삽입.
var parent = el.parentNode
parent.insertBefore(ph, el)
// 드래그 이미지 캡처 이후에 ghost 스타일을 적용 (이미지에 ghost 가 묻지 않도록).
setTimeout(function () {
if (drag && drag.srcEl) drag.srcEl.classList.add('hiddenWhileDragging')
if (drag && drag.srcEl) drag.srcEl.classList.add('dragGhost')
}, 0)
})
el.addEventListener('dragend', cleanupDrag)
@@ -189,48 +186,42 @@
})
}
// 컨테이너 위에서 placeholder 의 삽입 지점을 다시 계산.
function bindContainerDnd(containerId, type, orientation) {
var container = document.getElementById(containerId)
container.addEventListener('dragover', function (e) {
if (!drag || drag.type !== type) return
e.preventDefault()
try { e.dataTransfer.dropEffect = 'move' } catch (_) {}
var children = []
// 컨테이너 자식 중 source 를 제외한 나머지에서 삽입 지점을 찾는다.
var target = null
for (var i = 0; i < container.children.length; i++) {
var c = container.children[i]
if (c === drag.placeholder) continue
if (c === drag.srcEl) continue
children.push(c)
}
var target = null
for (var j = 0; j < children.length; j++) {
var rect = children[j].getBoundingClientRect()
var rect = c.getBoundingClientRect()
var mid = (orientation === 'horizontal')
? rect.left + rect.width / 2
: rect.top + rect.height / 2
var pos = (orientation === 'horizontal') ? e.clientX : e.clientY
if (pos < mid) { target = children[j]; break }
if (pos < mid) { target = c; break }
}
if (target) {
if (drag.placeholder.nextSibling !== target) container.insertBefore(drag.placeholder, target)
if (drag.srcEl.nextSibling !== target) container.insertBefore(drag.srcEl, target)
} else {
if (drag.placeholder !== container.lastChild) container.appendChild(drag.placeholder)
if (drag.srcEl !== container.lastChild) container.appendChild(drag.srcEl)
}
})
container.addEventListener('drop', function (e) {
if (!drag || drag.type !== type) return
e.preventDefault()
// 새 인덱스 = srcEl 을 제외한 children 리스트에서 placeholder 의 위치.
// 새 인덱스 = source 의 현재 컨테이너 내 위치.
var newIdx = 0
for (var i = 0; i < container.children.length; i++) {
var c = container.children[i]
if (c === drag.srcEl) continue
if (c === drag.placeholder) break
newIdx++
if (container.children[i] === drag.srcEl) { newIdx = i; break }
}
var arr = (type === 'music') ? state.music : state.images
var moved = arr.splice(drag.srcIdx, 1)[0]
// 원래 인덱스: state 에서 동일 url 을 찾는 대신 data-index 가 렌더 시점의 위치이므로 사용.
var srcIdx = Number(drag.srcEl.dataset.index)
var moved = arr.splice(srcIdx, 1)[0]
arr.splice(newIdx, 0, moved)
cleanupDrag()
if (type === 'music') renderMusic(); else renderImage()
@@ -241,8 +232,7 @@
function cleanupDrag() {
if (!drag) return
if (drag.placeholder && drag.placeholder.parentNode) drag.placeholder.remove()
if (drag.srcEl) drag.srcEl.classList.remove('hiddenWhileDragging')
if (drag.srcEl) drag.srcEl.classList.remove('dragGhost')
drag = null
}

View File

@@ -440,16 +440,16 @@ body.siteBody.centerLayout {
}
.rowDur { color: var(--text-muted); font-size: 13px; }
/* 드래그 시스템 공통: 원본은 잠시 숨기고, 같은 모양의 placeholder 가 들어갈 자리에서 반투명하게 보임 */
.hiddenWhileDragging { display: none !important; }
.dragPlaceholder {
/* 드래그 시스템: 원본 요소가 그대로 새 위치로 이동하면서 반투명 ghost 로 보임 */
.dragGhost {
opacity: 0.45;
pointer-events: none;
outline: 2px dashed var(--accent);
outline-offset: -2px;
background: rgba(47, 129, 247, 0.08);
}
.dragPlaceholder * { pointer-events: none !important; }
.dragGhost * { pointer-events: none !important; }
.trackRow:active { cursor: grabbing; }
.imageCard:active { cursor: grabbing; }
/* 사진 그리드 */
.imageGrid {