diff --git a/public/listEditor.js b/public/listEditor.js
index 25868db..cd77010 100644
--- a/public/listEditor.js
+++ b/public/listEditor.js
@@ -49,6 +49,17 @@
return ({ '&':'&','<':'<','>':'>','"':'"',"'":''' })[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 @@
'' +
'' +
'' + fmtTime(entry.durationSec) + ''
- 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 =
- '' + (idx + 1) + '' +
- '
'
- attachRowEvents(card, 'image', idx)
+ '
' +
+ '
' + (idx + 1) + '' +
+ '
 + ')
' +
+ '
' +
+ '' +
+ '
' + (escapeHtml(cap.title) || '(제목 없음)') + '
' +
+ '
' + escapeHtml(cap.sub) + '
' +
+ '
'
+ 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"]')
diff --git a/public/styles.css b/public/styles.css
index 273dc8b..da9cfc4 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -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 {