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 {