Stable drop-preview drag + image captions

Drag-and-drop UX rewrite:
- The old "highlight target row + margin-grow animation" approach was
  driven by per-row dragenter/dragleave events. Those fire in a noisy
  enter/leave/enter cascade as the pointer crosses sub-elements and as
  the row itself grows under the pointer, which is why the gap was
  pulsing open/closed.
- New approach: a single container-level dragover handler. On dragstart
  the source row is briefly cloned into a translucent "placeholder"
  element (dashed outline, 45% opacity, pointer-events:none) inserted in
  the source's slot; the original is then hidden (display:none) right
  after dragstart so the browser can still capture it as the drag image.
  As the cursor moves over the container we compute which sibling's
  midpoint the pointer just crossed and insertBefore the placeholder
  accordingly. The list length stays constant the whole time, so there
  is no growing/shrinking gap to fight with — what the user sees is the
  dragged item itself shown semi-transparently at the exact drop slot.
  On drop, splice the array using the placeholder's index among the
  non-source children, then re-render.
- The bindContainerDnd helper handles both lists; image grid uses
  vertical Y math (same midpoint rule as the track list since cards
  flow row-by-row in the auto-fill grid). attachDraggable now only sets
  up dragstart/dragend/contextmenu per row; no more dragenter/dragleave.

Image grid:
- Image cards now have a caption below the thumbnail. When the same
  URL appears in the music list, the music entry's title/artist are
  borrowed via captionForImage(url); otherwise "(제목 없음)" muted text.
  Layout changed from a square aspect-ratio card to a flex column:
  .imgWrap holds the square thumbnail, .cardCaption sits underneath
  with single-line title + smaller muted artist line.

CSS cleanup:
- Drop the old .dropAbove margin-grow rules and .dragOver border rule
  on .trackRow/.imageCard. Replaced with .dragPlaceholder +
  .hiddenWhileDragging.
- .imageCard no longer uses aspect-ratio on itself; aspect lives on
  .imgWrap so caption can extend the card vertically.
This commit is contained in:
2026-05-12 13:51:33 +09:00
parent 635c22c7ad
commit 7ac07a58ef
2 changed files with 135 additions and 78 deletions

View File

@@ -49,6 +49,17 @@
return ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' })[c]
})
}
// 사진 항목에 어울리는 캡션. 동일한 URL 의 음악 항목이 있으면 그 제목/가수 를 빌려옴.
function captionForImage(url) {
var match = null
for (var i = 0; i < state.music.length; i++) {
if (state.music[i].url === url) { match = state.music[i]; break }
}
if (match && (match.title || match.artist)) {
return { title: match.title || '', sub: match.artist || '' }
}
return { title: '', sub: '' }
}
// ── 렌더 ──────────────────────────────────────────
function renderMusic() {
@@ -71,7 +82,7 @@
'</div>' +
'</div>' +
'<span class="rowDur">' + fmtTime(entry.durationSec) + '</span>'
attachRowEvents(li, 'music', idx)
attachDraggable(li, 'music', idx)
attachInlineEdit(li, idx)
ol.appendChild(li)
})
@@ -81,14 +92,21 @@
var grid = document.getElementById('image-list')
grid.innerHTML = ''
state.images.forEach(function (entry, idx) {
var cap = captionForImage(entry.url)
var card = document.createElement('div')
card.className = 'imageCard'
card.draggable = true
card.dataset.index = String(idx)
card.innerHTML =
'<span class="cardNum">' + (idx + 1) + '</span>' +
'<img src="' + thumbUrl(entry.url) + '" alt="" loading="lazy"/>'
attachRowEvents(card, 'image', idx)
'<div class="imgWrap">' +
'<span class="cardNum">' + (idx + 1) + '</span>' +
'<img src="' + thumbUrl(entry.url) + '" alt="" loading="lazy"/>' +
'</div>' +
'<div class="cardCaption">' +
'<div class="cardTitle" title="' + escapeHtml(cap.title) + '">' + (escapeHtml(cap.title) || '<span class="muted">(제목 없음)</span>') + '</div>' +
'<div class="cardSub">' + escapeHtml(cap.sub) + '</div>' +
'</div>'
attachDraggable(card, 'image', idx)
grid.appendChild(card)
})
}
@@ -96,8 +114,6 @@
// ── 인라인 편집 (제목/가수) ─────────────────────────
function attachInlineEdit(li, idx) {
li.querySelectorAll('[contenteditable="true"]').forEach(function (el) {
// contenteditable 요소 위에서 드래그가 시작되면 텍스트 선택이 의도였을 가능성이 크다.
// 편집 중에는 부모 li 가 드래그되지 않도록 한다.
el.addEventListener('focus', function () { li.draggable = false })
el.addEventListener('blur', function () { li.draggable = true })
el.addEventListener('input', function () {
@@ -112,74 +128,97 @@
})
}
// ── 행/카드 이벤트 (드래그·우클릭) ─────────────────
var dragSrc = null
var lastDropTarget = null
// ── 드래그 시스템: 컨테이너 단위로 한 곳에서 관리 ─────
// drag = { type, srcIdx, srcEl, placeholder } | null
var drag = null
function clearDropTargets(container) {
container.querySelectorAll('.dropAbove').forEach(function (el) {
el.classList.remove('dropAbove')
})
lastDropTarget = null
}
function attachRowEvents(el, type, idx) {
function attachDraggable(el, type, idx) {
el.addEventListener('dragstart', function (e) {
// contenteditable 안에서 시작된 드래그(텍스트 선택)는 무시.
if (!el.draggable) { e.preventDefault(); return }
dragSrc = { type: type, index: idx, el: el }
el.classList.add('dragging')
try { e.dataTransfer.setData('text/plain', String(idx)) } catch (_) {}
e.dataTransfer.effectAllowed = 'move'
})
el.addEventListener('dragend', function () {
el.classList.remove('dragging')
var parent = (type === 'music')
? document.getElementById('music-list')
: document.getElementById('image-list')
clearDropTargets(parent)
dragSrc = null
})
el.addEventListener('dragover', function (e) {
if (!dragSrc || dragSrc.type !== type) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
if (lastDropTarget !== el) {
if (lastDropTarget) lastDropTarget.classList.remove('dropAbove')
// 자기 자신 위에서는 표식 없음(이동 효과 없음).
if (el !== dragSrc.el) el.classList.add('dropAbove')
lastDropTarget = el
}
})
el.addEventListener('dragleave', function (e) {
// 자식으로 이동한 경우는 leave 가 아님 — pointer 가 진짜로 벗어났을 때만 해제.
if (e.relatedTarget && el.contains(e.relatedTarget)) return
el.classList.remove('dropAbove')
if (lastDropTarget === el) lastDropTarget = null
})
el.addEventListener('drop', function (e) {
e.preventDefault()
var parent = (type === 'music')
? document.getElementById('music-list')
: document.getElementById('image-list')
clearDropTargets(parent)
if (!dragSrc || dragSrc.type !== type) return
var srcIdx = dragSrc.index
var dstIdx = idx
if (srcIdx === dstIdx) return
var arr = (type === 'music') ? state.music : state.images
var moved = arr.splice(srcIdx, 1)[0]
// "dropAbove" 의미를 그대로 → 항상 target 행의 자리에 끼워 넣는다.
var insertAt = (srcIdx < dstIdx) ? dstIdx - 1 : dstIdx
arr.splice(insertAt, 0, moved)
if (type === 'music') renderMusic(); else renderImage()
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 }
try {
e.dataTransfer.setData('text/plain', String(idx))
e.dataTransfer.effectAllowed = 'move'
} catch (_) {}
// dragstart 가 완료된 직후에 원본을 숨김 (드래그 이미지 캡처 후).
// placeholder 는 같은 자리에 삽입.
var parent = el.parentNode
parent.insertBefore(ph, el)
setTimeout(function () {
if (drag && drag.srcEl) drag.srcEl.classList.add('hiddenWhileDragging')
}, 0)
})
el.addEventListener('dragend', cleanupDrag)
el.addEventListener('contextmenu', function (e) {
e.preventDefault()
openCtxMenu(e.pageX, e.pageY, type, idx)
})
}
// 컨테이너 위에서 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 = []
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 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 (target) {
if (drag.placeholder.nextSibling !== target) container.insertBefore(drag.placeholder, target)
} else {
if (drag.placeholder !== container.lastChild) container.appendChild(drag.placeholder)
}
})
container.addEventListener('drop', function (e) {
if (!drag || drag.type !== type) return
e.preventDefault()
// 새 인덱스 = srcEl 을 제외한 children 리스트에서 placeholder 의 위치.
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++
}
var arr = (type === 'music') ? state.music : state.images
var moved = arr.splice(drag.srcIdx, 1)[0]
arr.splice(newIdx, 0, moved)
cleanupDrag()
if (type === 'music') renderMusic(); else renderImage()
})
}
bindContainerDnd('music-list', 'music', 'vertical')
bindContainerDnd('image-list', 'image', 'vertical')
function cleanupDrag() {
if (!drag) return
if (drag.placeholder && drag.placeholder.parentNode) drag.placeholder.remove()
if (drag.srcEl) drag.srcEl.classList.remove('hiddenWhileDragging')
drag = null
}
// ── 컨텍스트 메뉴 ─────────────────────────────────
var ctxMenu = document.getElementById('ctxMenu')
var ctxTarget = null
@@ -245,7 +284,6 @@
})
})
// 음악 수정 저장: URL 변경 시 yt-dlp 로 제목/가수/시간 자동 갱신.
document.getElementById('edit-music-save').addEventListener('click', function () {
var url = document.getElementById('edit-music-url').value.trim()
if (!url) return
@@ -260,7 +298,6 @@
return r.json().then(function (body) { return { ok: r.ok, status: r.status, body: body } })
}).then(function (result) {
if (!result.ok || !result.body || !result.body.ok) {
// yt-dlp 가 없거나 메타 실패면, 사용자가 명시적으로 적용할지 결정.
var msg = (result.body && result.body.message) ? result.body.message : '메타 조회 실패'
ask('메타데이터 조회 실패', msg + '\n주소만 변경하고 제목/가수/시간은 그대로 둘까요?', function () {
state.music[editingIdx].url = url
@@ -303,7 +340,7 @@
renderImage()
})
// ── 사진목록: 음악목록 그대로 복사 (각 곡의 유튜브 썸네일이 자동으로 사용됨) ──
// ── 사진목록: 음악목록 그대로 복사 ─────────────────
document.getElementById('image-from-music').addEventListener('click', function () {
if (state.music.length === 0) {
setStatus('status-image', '음악목록이 비어 있어 가져올 수 없습니다.', true)
@@ -354,7 +391,6 @@
function doSave(target) {
state.musicPlaylistUrl = document.getElementById('music-playlist-url').value.trim()
state.imagePlaylistUrl = document.getElementById('image-playlist-url').value.trim()
// 인라인 contenteditable 의 최신 값을 한 번 더 동기화 (focus 가 input 이벤트 없이 끝나는 케이스 보호).
document.querySelectorAll('#music-list .trackRow').forEach(function (li) {
var idx = Number(li.dataset.index)
var t = li.querySelector('[data-field="title"]')

View File

@@ -412,11 +412,7 @@ body.siteBody.centerLayout {
padding: 8px 12px; background: var(--bg-card);
border: 1px solid var(--border); border-radius: 8px;
cursor: grab; user-select: none;
transition: margin-top 0.18s ease, border-color 0.12s ease;
}
.trackRow.dragging { opacity: 0.5; }
/* 드래그 중 hover 표시: 위쪽으로 64px 공간이 벌어지며 "여기 끼울 수 있다" 를 표현 */
.trackRow.dropAbove { margin-top: 64px; border-color: var(--accent); }
.rowNum { color: var(--text-muted); font-size: 14px; text-align: center; }
.rowThumb { width: 80px; height: 45px; object-fit: cover; border-radius: 4px; background: #000; }
.rowMeta { min-width: 0; }
@@ -436,7 +432,6 @@ body.siteBody.centerLayout {
box-shadow: 0 0 0 1px var(--accent);
white-space: normal; cursor: text;
}
/* placeholder 효과: 비어 있을 때 회색 안내 텍스트 */
.rowTitle[contenteditable="true"]:empty::before,
.rowSub[contenteditable="true"]:empty::before {
content: attr(data-placeholder);
@@ -445,6 +440,17 @@ body.siteBody.centerLayout {
}
.rowDur { color: var(--text-muted); font-size: 13px; }
/* 드래그 시스템 공통: 원본은 잠시 숨기고, 같은 모양의 placeholder 가 들어갈 자리에서 반투명하게 보임 */
.hiddenWhileDragging { display: none !important; }
.dragPlaceholder {
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; }
/* 사진 그리드 */
.imageGrid {
display: grid;
@@ -452,14 +458,14 @@ body.siteBody.centerLayout {
gap: 12px;
}
.imageCard {
position: relative; aspect-ratio: 1 / 1; background: var(--bg-card);
background: var(--bg-card);
border: 1px solid var(--border); border-radius: 10px;
overflow: hidden; cursor: grab; user-select: none;
transition: margin-left 0.18s ease, border-color 0.12s ease;
display: flex; flex-direction: column;
}
.imageCard .imgWrap {
position: relative; aspect-ratio: 1 / 1; overflow: hidden;
}
.imageCard.dragging { opacity: 0.5; }
/* 사진 그리드도 동일하게 "옆이 벌어진다" 표현 */
.imageCard.dropAbove { margin-left: 80px; border-color: var(--accent); }
.imageCard img { width: 100%; height: 100%; object-fit: cover; display: block; }
.cardNum {
position: absolute; top: 6px; left: 6px;
@@ -467,6 +473,21 @@ body.siteBody.centerLayout {
padding: 2px 8px; border-radius: 999px;
font-size: 12px; font-weight: 600;
}
.cardCaption {
padding: 8px 10px;
border-top: 1px solid var(--border);
background: var(--bg-card);
}
.cardTitle {
font-size: 13px; color: var(--text);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.cardSub {
font-size: 11px; color: var(--text-muted);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
margin-top: 2px;
}
.cardTitle .muted { color: var(--text-muted); }
/* 컨텍스트 메뉴 */
.ctxMenu {