자동 탭: 컷별 쌍둥이 카드 혼동 해소 + 컷 섹션 아코디언

같은 댓글이 컷마다 반복 렌더되는데 카드 DOM에 컷 번호가 없어 refreshSel()이
모든 복사본에 선택 링을 붙였다(1컷에서 고른 게 2컷에도 체크된 것처럼 보임).
쌍둥이를 눌러도 다른 컷으로 옮기려는 의도인데 무조건 해제됐다.

- cardEl에 data-ci를 남기고, refreshSel은 실제 배정된 컷의 복사본에만 링을 부여
- 다른 컷에 배정된 쌍둥이는 기존 .ccused 배지 구조를 재사용해 "컷 N에서 사용중"으로 표시
- 이미 선택된 쌍둥이를 다른 컷에서 누르면 해제 대신 그 컷으로 이동(sortSel로 순서 유지)
- 컷마다 추천+채우기 섹션을 아코디언으로 묶어 한 번에 한 컷만 펼침(카드 폭증 완화),
  헤더에 컷 채움 상태를 실시간 표시
- 빌드 캡처 직전 expandAllCuts()로 모든 컷 섹션을 강제로 펼침
  (검색 필터 때와 같은 함정 — 접힌 섹션의 카드는 domToBlob이 빈 이미지로 찍는다)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-08-04 18:02:55 +09:00
parent 0967e88e7b
commit d1ebfaff74
2 changed files with 103 additions and 11 deletions

View File

@ -30,6 +30,9 @@ function hlById(id){
function cardEl(c,hlId,ci){ function cardEl(c,hlId,ci){
const wrap=document.createElement("div"); const wrap=document.createElement("div");
wrap.className="ccwrap"; wrap.dataset.cidx=c.idx; wrap.className="ccwrap"; wrap.dataset.cidx=c.idx;
// 컷 소속을 DOM에 남긴다 — 없으면(폴백/구간 탭) 어느 복사본이 어느 컷 것인지 구분 불가해
// refreshSel 이 모든 복사본에 선택 링을 붙이는 버그가 난다(컷마다 후보 전체가 반복 렌더되므로).
if(ci!==undefined) wrap.dataset.ci=ci;
const card=document.createElement("div"); const card=document.createElement("div");
card.className="comment-card mosaic"; // 검정배경은 CSS 기본값 card.className="comment-card mosaic"; // 검정배경은 CSS 기본값
const av=c.profileImageUrl?"/auto/avatar?url="+encodeURIComponent(c.profileImageUrl):""; const av=c.profileImageUrl?"/auto/avatar?url="+encodeURIComponent(c.profileImageUrl):"";
@ -137,6 +140,27 @@ function clearSearch(hlId){
const bar=box&&box.querySelector(".ccsearch"); const bar=box&&box.querySelector(".ccsearch");
if(bar&&bar._clear) bar._clear(); if(bar&&bar._clear) bar._clear();
} }
/* ' + ' .
번에 하나만 펼쳐 화면에 후보가 다닥다닥 쌓이는 막는다(카드는 수만큼 DOM에
복제되므로 전부 펼치면 매우 길어진다). 헤더 채움 상태는 refreshSel 에서 갱신한다. */
function cutGroup(ci,title,sec,quota,isOpen){
const grp=document.createElement("div");
grp.className="cutgrp"+(isOpen?" open":"");
grp.dataset.ci=ci; grp.dataset.sec=sec; grp.dataset.quota=quota;
const head=document.createElement("button");
head.type="button"; head.className="cutgrp-head";
head.innerHTML='<span class="chev" aria-hidden="true">▶</span>'+
'<span class="cgtitle"></span><span class="cgstat"></span>';
head.querySelector(".cgtitle").textContent=title;
head.addEventListener("click",()=>{
const opening=!grp.classList.contains("open");
grp.parentElement.querySelectorAll(":scope > .cutgrp").forEach(g=>g.classList.remove("open"));
grp.classList.toggle("open",opening);
});
const body=document.createElement("div"); body.className="cutgrp-body";
grp.appendChild(head); grp.appendChild(body);
return {grp,body};
}
/* // . /* // .
갈라 두면 곳만 고치는 실수가 난다(배지·검색·선택 상한이 전부 여기 모여 있다). */ 갈라 두면 곳만 고치는 실수가 난다(배지·검색·선택 상한이 전부 여기 모여 있다). */
function renderCutPanel(box,panelId,data,opts){ function renderCutPanel(box,panelId,data,opts){
@ -155,15 +179,17 @@ function renderCutPanel(box,panelId,data,opts){
const rest=(data.matched||[]).filter(i=> const rest=(data.matched||[]).filter(i=>
byIdx[i]!==undefined&&!rec.includes(i)&& byIdx[i]!==undefined&&!rec.includes(i)&&
(byIdx[i].times||[]).some(t=>t>=cs.start&&t<=cs.end)); (byIdx[i].times||[]).some(t=>t>=cs.start&&t<=cs.end));
box.appendChild(cardSection( const {grp,body}=cutGroup(cu.i,unit+" "+(cu.i+1),cu.sec,cu.quota,cu.i===0);
body.appendChild(cardSection(
unit+" "+(cu.i+1)+" · "+cu.sec+"초 · 카드 "+cu.quota+"장"+ unit+" "+(cu.i+1)+" · "+cu.sec+"초 · 카드 "+cu.quota+"장"+
(cu.bottom?" — "+cu.bottom:"")+(why?" "+why:""), (cu.bottom?" — "+cu.bottom:"")+(why?" "+why:""),
rec.concat(rest),panelId,Math.max(CARD_PAGE,rec.length),cu.i)); rec.concat(rest),panelId,Math.max(CARD_PAGE,rec.length),cu.i));
// 부족분 채우기 — 컷마다 반복되므로 처음엔 조금만 그리고 나머지는 더보기로 // 부족분 채우기 — 컷마다 반복되므로 처음엔 조금만 그리고 나머지는 더보기로
if(cand.length) if(cand.length)
box.appendChild(cardSection( body.appendChild(cardSection(
"↳ 좋아요 상위에서 채우기 "+cand.length+"장 (이 컷에 넣기)", "↳ 좋아요 상위에서 채우기 "+cand.length+"장 (이 컷에 넣기)",
cand,panelId,CUT_FILL_PAGE,cu.i)); cand,panelId,CUT_FILL_PAGE,cu.i));
box.appendChild(grp);
} }
}else{ }else{
// 폴백 — cuts 정보가 없으면(구간 탭·구정보 없음) 예전 화면 그대로: ⭐ + 독립 // 폴백 — cuts 정보가 없으면(구간 탭·구정보 없음) 예전 화면 그대로: ⭐ + 독립
@ -196,8 +222,18 @@ function toggle(hlId,idx,ci){
const hl=hlById(hlId), list=sel[hlId]; const hl=hlById(hlId), list=sel[hlId];
selCut[hlId]=selCut[hlId]||{}; selCut[hlId]=selCut[hlId]||{};
const at=list.indexOf(idx); const at=list.indexOf(idx);
if(at>=0){ list.splice(at,1); delete selCut[hlId][idx]; } if(at>=0){
else{ const cur=selCut[hlId][idx];
// 이미 다른 컷에 배정된 댓글의 쌍둥이 카드를 누르면 해제가 아니라 '그 컷으로 옮기기'다
// — 사용자는 이 댓글을 지우려는 게 아니라 지금 보고 있는 컷에 넣으려는 것.
if(hl.cuts&&ci!==undefined&&cur!==undefined&&cur!==ci){
if(cutFull(hl,ci)) return; // 옮길 컷이 이미 꽉 참 — 아무것도 안 함
selCut[hlId][idx]=ci;
sortSel(hlId); // 업로드 순서 = 컷 순서 유지
}else{
list.splice(at,1); delete selCut[hlId][idx];
}
}else{
if(hl.cuts&&ci!==undefined){ if(hl.cuts&&ci!==undefined){
if(cutFull(hl,ci)) return; // 그 컷 장수 초과 방지 if(cutFull(hl,ci)) return; // 그 컷 장수 초과 방지
selCut[hlId][idx]=ci; selCut[hlId][idx]=ci;
@ -213,8 +249,25 @@ function toggle(hlId,idx,ci){
function refreshSel(hlId){ function refreshSel(hlId){
const hl=hlById(hlId), list=sel[hlId]; const hl=hlById(hlId), list=sel[hlId];
const box=$("#hlbox-"+hlId); if(!box) return; const box=$("#hlbox-"+hlId); if(!box) return;
box.querySelectorAll(".ccwrap").forEach(w=> const m=selCut[hlId]||{};
w.classList.toggle("sel",list.includes(parseInt(w.dataset.cidx,10)))); // 컷별 채우기 섹션은 같은 댓글의 복사본을 컷마다 다시 그린다 — 링은 '배정된 컷'의
// 복사본에만 붙인다(배정이 없으면 폴백 패널이라 항상 붙인다). 그래야 1컷에서 고른 카드가
// 2컷 복사본에도 ✓로 보여 헷갈리는 일이 없다. 나머지 복사본은 applyUsedMarks 가 표시한다.
box.querySelectorAll(".ccwrap").forEach(w=>{
const idx=parseInt(w.dataset.cidx,10);
const assigned=m[idx];
const wCi=("ci" in w.dataset)?parseInt(w.dataset.ci,10):undefined;
const isThisCopy=(assigned===undefined)||(assigned===wCi);
w.classList.toggle("sel",list.includes(idx)&&isThisCopy);
});
// 컷 아코디언 헤더 채움 상태(선택 수/quota) — 선택이 바뀔 때마다 갱신
box.querySelectorAll(".cutgrp").forEach(g=>{
const ci=parseInt(g.dataset.ci,10);
const cnt=list.filter(i=>m[i]===ci).length;
const stat=g.querySelector(".cgstat");
if(stat) stat.textContent="· "+g.dataset.sec+"초 · "+cnt+"/"+g.dataset.quota+"장";
});
applyUsedMarks(hlId); // 크로스 ID 사용중 + 같은 ID 안 다른 컷 쌍둥이 표시(한 곳에서 관리)
// 탭 카운터 // 탭 카운터
const tab=$("#idtab-"+hlId); const tab=$("#idtab-"+hlId);
if(tab){ if(tab){
@ -276,15 +329,29 @@ function usedByOthers(hlId,idx){
} }
function applyUsedMarks(hlId){ function applyUsedMarks(hlId){
const box=$("#hlbox-"+hlId); if(!box) return; const box=$("#hlbox-"+hlId); if(!box) return;
const hl=hlById(hlId);
const m=(hl&&hl.cuts)?(selCut[hlId]||{}):null; // 컷 개념이 있는 패널만 쌍둥이 체크
box.querySelectorAll(".ccwrap").forEach(w=>{ box.querySelectorAll(".ccwrap").forEach(w=>{
const idx=parseInt(w.dataset.cidx,10); const idx=parseInt(w.dataset.cidx,10);
let text=null;
// ① 같은 ID 안에서, 이 댓글이 실제로 배정된 컷과 다른 컷의 복사본 — '이동' 대상임을 알려준다
if(m){
const assigned=m[idx];
const wCi=("ci" in w.dataset)?parseInt(w.dataset.ci,10):undefined;
if(assigned!==undefined&&wCi!==undefined&&assigned!==wCi)
text="컷 "+(assigned+1)+"에서 사용중";
}
// ② 다른 ID(탭)에서 이미 선택한 댓글 — 기존 표시(문구 구분 유지)
if(!text){
const ids=usedByOthers(hlId,idx); const ids=usedByOthers(hlId,idx);
w.classList.toggle("used",ids.length>0); if(ids.length) text="ID "+ids.join("·")+" 사용중";
}
w.classList.toggle("used",!!text);
let b=w.querySelector(".ccused"); let b=w.querySelector(".ccused");
if(ids.length){ if(text){
if(!b){b=document.createElement("div");b.className="ccused";w.appendChild(b);} if(!b){b=document.createElement("div");b.className="ccused";w.appendChild(b);}
b.textContent="ID "+ids.join("·")+" 사용중"; b.textContent=text;
w.title="ID "+ids.join(", ")+"에서 이미 선택한 댓글"; w.title=text;
}else{ }else{
if(b) b.remove(); if(b) b.remove();
w.removeAttribute("title"); w.removeAttribute("title");
@ -613,6 +680,15 @@ async function captureCard(wrap){
} }
const nextFrame=()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))); const nextFrame=()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)));
/* / ( ) display:none
레이아웃이 확정되지 않아 안의 카드를 domToBlob 으로 찍으면 이미지가 나온다.
캡처는 wrapOf() DOM에서 '첫 번째로 찾은' 복사본을 찍으므로, 복사본이 하필
접힌 섹션 안에 있으면 조용히 깨진다 다음 사람이 "펼칠 필요 없는데?" 하고
지우면 버그가 다시 살아난다. 지우지 . */
function expandAllCuts(hlId){
const box=$("#hlbox-"+hlId); if(!box) return;
box.querySelectorAll(".cutgrp").forEach(g=>g.classList.add("open"));
}
/* 빌드 진행판 — 버튼 아래 ID별 한 줄씩, 전체 상황이 한눈에 보임 */ /* 빌드 진행판 — 버튼 아래 ID별 한 줄씩, 전체 상황이 한눈에 보임 */
function boardInit(hls){ function boardInit(hls){
const bd=$("#buildBoard"); const bd=$("#buildBoard");
@ -642,6 +718,7 @@ async function buildAll(){
for(const hl of hls){ for(const hl of hls){
showId(hl.id); // 캡처는 보이는 상태에서 showId(hl.id); // 캡처는 보이는 상태에서
clearSearch(hl.id); // 검색 중이면 카드가 DOM에 없어 캡처가 누락됨 clearSearch(hl.id); // 검색 중이면 카드가 DOM에 없어 캡처가 누락됨
expandAllCuts(hl.id); // 접힌 컷 섹션의 카드는 빈 이미지로 캡처된다 — 전부 펼침
await nextFrame(); await nextFrame();
boardSet(hl.id,"🔄 진행","", "active"); boardSet(hl.id,"🔄 진행","", "active");
try{ try{

View File

@ -236,6 +236,21 @@
.cutraw pre{margin:0;padding:11px 13px;background:#080A0E;font-family:var(--mono);font-size:11px; .cutraw pre{margin:0;padding:11px 13px;background:#080A0E;font-family:var(--mono);font-size:11px;
line-height:1.55;color:var(--muted2);max-height:280px;overflow:auto;} line-height:1.55;color:var(--muted2);max-height:280px;overflow:auto;}
.cutcopy{margin:0 13px 11px;width:auto;min-height:34px;padding:6px 12px;} .cutcopy{margin:0 13px 11px;width:auto;min-height:34px;padding:6px 12px;}
/* ── 컷별 카드 아코디언 — 한 번에 한 컷만 펼쳐 후보 카드가 한꺼번에 쌓이지 않게 한다 ── */
.cutgrp{margin-top:10px;border:1px solid var(--border);border-radius:9px;
background:var(--surf2);overflow:hidden;}
.cutgrp-head{width:100%;box-sizing:border-box;display:flex;align-items:center;gap:8px;
text-align:left;cursor:pointer;background:transparent;border:0;color:var(--muted2);
padding:11px 13px;min-height:44px;font-size:12.5px;font-weight:600;transition:background .15s,color .15s;}
.cutgrp-head:hover{color:var(--text);background:var(--surf);}
.cutgrp.open>.cutgrp-head{color:var(--text);border-bottom:1px solid var(--border);}
.cutgrp-head .chev{flex:none;color:var(--muted);font-size:10px;transition:transform .18s ease;}
.cutgrp.open .chev{transform:rotate(90deg);}
.cutgrp-head .cgstat{margin-left:auto;flex:none;font-family:var(--mono);font-size:11.5px;
color:var(--muted);font-weight:400;}
.cutgrp.open .cgstat{color:var(--accent);}
.cutgrp-body{display:none;padding:0 13px 12px;}
.cutgrp.open .cutgrp-body{display:block;}
/* 선택한 댓글 요약 바 */ /* 선택한 댓글 요약 바 */
.selbar{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin-top:10px; .selbar{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin-top:10px;
padding:10px;background:#101010;border:1px solid var(--border);border-radius:8px;min-height:44px;} padding:10px;background:#101010;border:1px solid var(--border);border-radius:8px;min-height:44px;}