(function () { window.__all = []; // 전체 수집 댓글(분석 기준) window.__cards = []; // 호환용 별칭 window.__videoId = null; // 타임라인 링크용 let mosaicOn = true; // 프로필·아이디 모자이크 기본 ON let roundedOn = true; // 모서리 둥글게 토글(기본: 둥근 모서리) let wordMaskOn = true; // 단어 가리기 기본 ON(단어 클릭=가림). 끄면 카드 클릭=복사. let textScale = 1; // 내용 글자 크기 배율(카드 크기는 유지, .cc-text만 확대/축소) let bgMode = 'black'; // 카드 배경 기본값: default|white|black (black이면 글자 흰색) let exportScale = 4; // PNG 저장 배율(카드 CSS 픽셀 × 배율). 영상에서 확대해도 안 깨지게 기본 4배. let dirHandle = null; // 저장 폴더(File System Access API). null이면 브라우저 다운로드 폴백. let dirOptOut = false; // 사용자가 폴더 지정을 직접 해제 → '전체 저장' 시 다시 묻지 않음 const savedIds = new Set(); // 이미 저장한 댓글 id(재렌더·필터 변경에도 표시 유지) const DISPLAY_CAP = 2000; // 카드 DOM 렌더 상한(브라우저 보호). 분석은 전체 기준. const $ = (id) => document.getElementById(id); function esc(s) { const d = document.createElement('div'); d.textContent = String(s == null ? '' : s); return d.innerHTML; } function timeAgo(iso) { if (!iso) return ''; const then = new Date(iso).getTime(); if (isNaN(then)) return ''; const sec = Math.floor((Date.now() - then) / 1000); const units = [['년', 31536000], ['개월', 2592000], ['일', 86400], ['시간', 3600], ['분', 60]]; for (const [label, s] of units) { const v = Math.floor(sec / s); if (v >= 1) return v + label + ' 전'; } return '방금 전'; } // YouTube textDisplay(HTML) → 안전한 평문 (태그 제거,
→줄바꿈) function toPlainText(html) { const tmp = document.createElement('div'); tmp.innerHTML = String(html).replace(//gi, '\n'); return tmp.textContent || ''; } // URL/ID → videoId (백엔드 YoutubeVideoIdParser 미러) function parseVideoId(s) { s = (s || '').trim(); if (/^[A-Za-z0-9_-]{11}$/.test(s)) return s; const pats = [ /[?&]v=([A-Za-z0-9_-]{11})/, /youtu\.be\/([A-Za-z0-9_-]{11})/, /\/shorts\/([A-Za-z0-9_-]{11})/, /\/embed\/([A-Za-z0-9_-]{11})/, ]; for (const p of pats) { const m = s.match(p); if (m) return m[1]; } return null; } // 제외 단어: 쉼표/공백으로 구분. 하나라도 포함된 댓글은 카드·분석 모두에서 제외. function getExcludeTerms() { const raw = ($('ccExclude') && $('ccExclude').value) || ''; return raw.split(/[,\s]+/).map(s => s.trim().toLowerCase()).filter(Boolean); } // 검색어: 쉼표로 구분하면 OR 검색(하나라도 포함되면 표시). 공백은 구분자가 아님 → 구절 검색 유지. function getSearchTerms() { const raw = ($('ccSearch') && $('ccSearch').value) || ''; return raw.split(',').map(s => s.trim().toLowerCase()).filter(Boolean); } function excludeComments(list, terms) { if (!terms || !terms.length) return list; return list.filter(c => { const t = toPlainText(c.text).toLowerCase(); return !terms.some(term => t.includes(term)); }); } function debounce(fn, ms) { let h = null; return function () { clearTimeout(h); h = setTimeout(fn, ms); }; } // 댓글 텍스트를 단어 span으로 분해(공백/줄바꿈 보존). 단어 가리기 ON일 때 클릭하면 해당 단어만 블러. function buildWords(el, plain) { el.textContent = ''; for (const p of plain.split(/(\s+)/)) { if (!p) continue; if (/^\s+$/.test(p)) { el.appendChild(document.createTextNode(p)); continue; } const s = document.createElement('span'); s.className = 'cc-word'; s.textContent = p; s.addEventListener('click', (e) => { if (!wordMaskOn) return; // 꺼짐 모드: 무시 → 이벤트 버블 → 카드 클릭=복사 e.stopPropagation(); s.classList.toggle('masked'); }); el.appendChild(s); } } function applyFilterSort(cards) { const sort = $('ccSort').value; const minLikes = parseInt($('ccMinLikes').value, 10) || 0; const repliesOnly = $('ccRepliesOnly').checked; const terms = getSearchTerms(); let list = cards.filter(c => { if ((c.likeCount || 0) < minLikes) return false; if (repliesOnly && (c.replyCount || 0) <= 0) return false; if (!terms.length) return true; const t = toPlainText(c.text).toLowerCase(); // 내용에만 매칭 return terms.some(term => t.includes(term)); // 쉼표 구분 = OR }); if (sort === 'likes') list.sort((a, b) => (b.likeCount || 0) - (a.likeCount || 0)); else if (sort === 'replies') list.sort((a, b) => (b.replyCount || 0) - (a.replyCount || 0)); else if (sort === 'latest') list.sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt)); return list; } window.renderCards = function () { const grid = $('cardGrid'); const base = excludeComments(window.__all, getExcludeTerms()); let list = applyFilterSort(base); const total = list.length; const capped = total > DISPLAY_CAP; if (capped) list = list.slice(0, DISPLAY_CAP); $('ccCount').textContent = total.toLocaleString() + '개' + (capped ? ' (상위 ' + DISPLAY_CAP.toLocaleString() + '개 표시)' : ''); grid.classList.toggle('wordmask', wordMaskOn); grid.innerHTML = ''; list.forEach((c, i) => { const card = document.createElement('div'); card.className = 'comment-card' + (mosaicOn ? ' mosaic' : '') + (roundedOn ? ' rounded' : '') + (bgMode === 'white' ? ' bg-white' : bgMode === 'black' ? ' bg-black' : '') + (savedIds.has(c.__id) ? ' saved' : ''); // 이미 저장한 카드는 회색 톤 유지 if (textScale !== 1) card.style.setProperty('--cc-text-scale', String(textScale)); card.dataset.index = i; card.dataset.cid = c.__id; const avatarSrc = c.profileImageUrl ? '/api/comment-cards/avatar?url=' + encodeURIComponent(c.profileImageUrl) : ''; const head = document.createElement('div'); head.className = 'cc-head'; head.innerHTML = '' + '
' + '
' + '
' + '
👍 ' + (c.likeCount || 0).toLocaleString() + '' + '💬 ' + (c.replyCount || 0).toLocaleString() + '
' + '
'; head.querySelector('.cc-author').textContent = c.authorName || ''; head.querySelector('.cc-time').textContent = timeAgo(c.publishedAt); buildWords(head.querySelector('.cc-text'), toPlainText(c.text)); // 단어 가리기 ON: 카드 클릭 복사 잠금(단어 클릭으로 가림). OFF: 카드 클릭=복사. card.title = '단어 클릭 = 가리기 · 토글을 끄면 카드 클릭 = 복사'; card.setAttribute('role', 'button'); card.setAttribute('tabindex', '0'); card.setAttribute('aria-label', (c.authorName || '댓글') + ' 카드 · 복사'); card.addEventListener('click', () => { if (wordMaskOn) return; window.copyCard(card); }); card.addEventListener('keydown', (e) => { // 키보드 접근: Enter/Space로 복사 if (wordMaskOn) return; if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); window.copyCard(card); } }); // 저장(다운로드) 버튼 — cc-noexport라 캡처 이미지엔 안 찍힘. 캡컷엔 이 파일을 불러오기. const actions = document.createElement('div'); actions.className = 'cc-actions cc-noexport'; const saveBtn = document.createElement('button'); saveBtn.type = 'button'; saveBtn.className = 'cc-iconbtn'; saveBtn.addEventListener('click', (e) => { e.stopPropagation(); window.downloadCard(card, c); }); setSaveBtn(saveBtn, savedIds.has(c.__id), false); actions.appendChild(saveBtn); card.appendChild(head); card.appendChild(actions); grid.appendChild(card); }); if (window.lucide) window.lucide.createIcons(); }; /* --------------------------------------------------------------------- * 분석 (전부 클라이언트 계산 — 추가 API 쿼터 0) * ------------------------------------------------------------------- */ const KO_STOP = new Set(('그리고 그런데 그래서 하지만 그러나 그냥 정말 진짜 너무 완전 이거 저거 그거 이건 저건 그건 근데 ' + '이렇게 저렇게 그렇게 하는 하고 해서 있는 있고 없는 같아요 같은 같다 되게 무슨 어떤 이런 저런 그런 여기 저기 거기 ' + '우리 저는 제가 요즘 지금 오늘 내가 니가 네가 저도 나도 이제 그리 그럼 아니 아마 많이 조금 그때 이때 대한 위해 ' + '부분 사람 생각 때문 정도 그게 이게 저게 그건데 뭔가 거의 계속 진짜로 그거는').split(/\s+/)); const EN_STOP = new Set(('the a an and or but if is are was were be been to of in on for it this that with you your my ' + 'me we he she they i so just not no yes at as by from about into out up down all can will do does did have has had ' + 'im its lol').split(/\s+/)); // 조사 꼬리 제거(휴리스틱) — 긴 것부터 const JOSA = ['으로써', '으로서', '에서는', '에게서', '이라는', '이라고', '으로', '로써', '로서', '에서', '에게', '한테', '까지', '부터', '조차', '마저', '밖에', '처럼', '만큼', '보다', '이나', '이랑', '라는', '라고', '다는', '에는', '에도', '든지', '이며', '이고', '은', '는', '이', '가', '을', '를', '에', '의', '도', '로', '과', '와', '만', '랑', '님', '들'] .sort((a, b) => b.length - a.length); function stripJosa(w) { for (const j of JOSA) { if (w.length > j.length + 1 && w.endsWith(j)) return w.slice(0, -j.length); } return w; } function tokenize(text) { const t = toPlainText(text).toLowerCase().replace(/https?:\/\/\S+/g, ' '); const raw = t.replace(/[^\p{L}\p{N}\sㄱ-ㅎㅏ-ㅣ]/gu, ' ').split(/\s+/); const out = []; for (let w of raw) { if (!w) continue; if (/^[0-9]+$/.test(w)) continue; // 순수 숫자 제외 if (/[가-힣]/.test(w)) w = stripJosa(w); // 한글 어절이면 조사 제거 if (w.length < 2) continue; if (KO_STOP.has(w) || EN_STOP.has(w)) continue; out.push(w); } return out; } function computeWords(all, weighted) { const map = new Map(); for (const c of all) { const wt = weighted ? (1 + (c.likeCount || 0)) : 1; for (const w of tokenize(c.text)) map.set(w, (map.get(w) || 0) + wt); } return [...map.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20); } // 댓글 속 타임스탬프(mm:ss / h:mm:ss) 추출 const TS_RE = /(? b[1] - a[1]).slice(0, 15) .map(([sec, cnt]) => ({ sec, cnt, label: fmtTime(sec) })); } function computeStats(all) { let likes = 0, max = null; const byAuthor = new Map(), byMonth = new Map(); for (const c of all) { likes += c.likeCount || 0; if (!max || (c.likeCount || 0) > (max.likeCount || 0)) max = c; byAuthor.set(c.authorName, (byAuthor.get(c.authorName) || 0) + 1); const mo = (c.publishedAt || '').slice(0, 7); if (mo) byMonth.set(mo, (byMonth.get(mo) || 0) + 1); } const topAuthor = [...byAuthor.entries()].sort((a, b) => b[1] - a[1])[0]; const months = [...byMonth.entries()].sort((a, b) => a[0].localeCompare(b[0])); return { count: all.length, likes, avg: all.length ? likes / all.length : 0, max, authors: byAuthor.size, topAuthor, months, }; } function tile(l, v) { return '
' + esc(v) + '
' + esc(l) + '
'; } function bar(label, val, max, suffix) { const pct = max ? Math.max(4, Math.round(val / max * 100)) : 0; return '
' + '' + esc(label) + '' + '' + '' + esc(val.toLocaleString()) + (suffix || '') + '
'; } function renderAnalytics(all) { const box = $('ccAnalytics'); const exTerms = getExcludeTerms(); all = excludeComments(all || [], exTerms); if (!all.length) { box.style.display = 'none'; box.innerHTML = ''; return; } const weighted = !!($('ccWeighted') && $('ccWeighted').checked); const tlSort = ($('ccTlSort') && $('ccTlSort').value) || 'count'; const stats = computeStats(all); let tl = computeTimeline(all); // 언급 많은 순 top-N 선별 if (tlSort === 'time') tl = tl.slice().sort((a, b) => a.sec - b.sec); // 선별본을 시간순 재정렬 const words = computeWords(all, weighted); const vid = window.__videoId; let html = '
분석 ' + '' + all.length.toLocaleString() + '개 댓글 기준' + (exTerms.length ? ' · 제외어 적용' : '') + '' + '
' + '
'; html += '
' + tile('총 댓글', stats.count.toLocaleString()) + tile('총 좋아요', stats.likes.toLocaleString()) + tile('평균 좋아요', stats.avg.toFixed(1)) + tile('참여자', stats.authors.toLocaleString() + '명') + '
'; // 타임라인 html += '

많이 언급된 타임라인

' + '
'; if (tl.length) { const mx = tl[0].cnt; html += '
' + tl.map(t => { const inner = bar(t.label, t.cnt, mx, '회'); return vid ? '' + inner + '' : inner; }).join('') + '
'; } else { html += '

댓글에 타임스탬프 언급이 없습니다.

'; } html += '
'; // 단어 html += '

자주 나온 단어

' + '
'; if (words.length) { const mx = words[0][1]; html += '
' + words.map(([w, n]) => bar(w, Math.round(n), mx)).join('') + '
'; } else { html += '

분석할 단어가 부족합니다.

'; } html += '
'; // 최다 좋아요 댓글 if (stats.max) { html += '

최다 좋아요 댓글

' + '
' + esc(stats.max.authorName) + ' · 👍 ' + (stats.max.likeCount || 0).toLocaleString() + '
' + esc(toPlainText(stats.max.text).slice(0, 200)) + '
'; } // 월별 댓글량 if (stats.months.length > 1) { const mx = Math.max(...stats.months.map(m => m[1])); html += '

월별 댓글량

' + stats.months.slice(-18).map(([mo, n]) => '
' + '' + n.toLocaleString() + '' // 값 상시 노출 + '' + '' + esc(mo.slice(2)) + '
').join('') + '
'; } html += '
'; // #ccAnBody box.innerHTML = html; box.style.display = 'block'; if (window.lucide) window.lucide.createIcons(); // 섹션 헤더 아이콘(이모지 대체) 렌더 const tgl = $('ccAnToggle'); if (tgl) tgl.addEventListener('click', () => { const b = $('ccAnBody'); const hidden = b.style.display === 'none'; b.style.display = hidden ? '' : 'none'; tgl.textContent = hidden ? '접기' : '펼치기'; tgl.setAttribute('aria-expanded', String(hidden)); }); const wt = $('ccWeighted'); if (wt) wt.addEventListener('change', () => renderAnalytics(window.__all)); const tls = $('ccTlSort'); if (tls) tls.addEventListener('change', () => renderAnalytics(window.__all)); } /* ------------------------- 캡처/복사/저장 ------------------------- */ // 카드 저장 버튼의 저장 전/후 모습. // renderCards 루프에서는 renderIcons=false — 아이콘은 렌더 끝에 한 번만 그린다(카드 수만큼 전체 스캔 방지). function setSaveBtn(btn, saved, renderIcons) { btn.classList.toggle('saved', saved); btn.innerHTML = saved ? ' 저장됨' : ' 저장'; btn.setAttribute('aria-label', saved ? '저장 완료 (다시 저장하려면 클릭)' : 'PNG 파일로 저장'); btn.title = saved ? '이미 저장한 카드 · 다시 저장하려면 클릭' : 'PNG 파일로 저장 (캡컷에서 불러오기)'; if (renderIcons && window.lucide) window.lucide.createIcons(); } // 저장 완료 표시: 카드 회색 톤(.saved) + 버튼 '저장됨'. 캡처 이미지엔 반영 안 됨(captureBlob에서 제외). function markSaved(cardEl) { const cid = cardEl.dataset.cid; if (cid !== undefined) savedIds.add(Number(cid)); cardEl.classList.add('saved'); const btn = cardEl.querySelector('.cc-actions .cc-iconbtn'); if (btn) setSaveBtn(btn, true, true); } async function captureBlob(cardEl) { const ms = window.modernScreenshot; if (!ms || !ms.domToBlob) throw new Error('캡처 라이브러리 로드 실패'); // 저장 표시(회색 톤)는 화면 전용 → 캡처 동안만 벗겨서 원본 그대로 찍는다 const wasSaved = cardEl.classList.contains('saved'); if (wasSaved) cardEl.classList.remove('saved'); try { // scale = 저장 배율. 카드 폭이 ~320px라 2배로는 영상에서 확대 시 글자가 뭉개짐 → 기본 4배. return await ms.domToBlob(cardEl, { backgroundColor: null, scale: exportScale, filter: (node) => !(node instanceof Element && node.classList.contains('cc-noexport')), }); } finally { if (wasSaved) cardEl.classList.add('saved'); } } function downloadBlob(blob) { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'comment-card.png'; a.click(); URL.revokeObjectURL(a.href); } let toastTimer = null; function showToast(msg) { let t = document.getElementById('ccToast'); if (!t) { t = document.createElement('div'); t.id = 'ccToast'; t.className = 'cc-toast'; document.body.appendChild(t); } t.textContent = msg; t.classList.add('show'); if (toastTimer) clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove('show'), 1500); } function buildFileName(c) { var who = (c && c.authorName) ? c.authorName.replace(/[^\w가-힣]+/g, '').slice(0, 20) : 'card'; return 'comment-' + (who || 'card') + '.png'; } // 선택한 폴더에 blob을 파일로 씀(File System Access API) async function writeToDir(blob, filename) { const fh = await dirHandle.getFileHandle(filename, { create: true }); const w = await fh.createWritable(); await w.write(blob); await w.close(); } // 폴더가 선택돼 있으면 그 폴더로, 아니면 브라우저 다운로드 폴더로 저장 async function saveBlob(blob, filename) { if (dirHandle) { await writeToDir(blob, filename); return true; } const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename; a.click(); URL.revokeObjectURL(a.href); return false; } // 폴더 지정 해제 → 이후 저장은 브라우저 기본 다운로드 폴더로 function clearDir() { dirHandle = null; dirOptOut = true; $('ccPickDirName').textContent = '저장 폴더'; setToggle($('ccPickDir'), false); showToast('폴더 지정 해제 → 다운로드 폴더로 저장'); } // '저장 폴더' 버튼 — 지정돼 있으면 해제(다운로드 폴더), 아니면 폴더 선택 // 선택한 폴더는 세션 동안 이 사이트에만 적용(크롬 전역 다운로드 설정과 무관) async function pickDir() { if (dirHandle) { clearDir(); return; } if (!window.showDirectoryPicker) { showToast('이 브라우저는 폴더 저장 미지원 (크롬/엣지 권장)'); return; } try { dirHandle = await window.showDirectoryPicker({ id: 'cc-save', mode: 'readwrite' }); dirOptOut = false; $('ccPickDirName').textContent = dirHandle.name; setToggle($('ccPickDir'), true); showToast('저장 폴더: ' + dirHandle.name); } catch (e) { /* 사용자가 취소 → 무시 */ } } // '전체 저장' — 현재 표시된 카드를 순서대로 저장(폴더 선택 시 그 폴더로 일괄) async function saveAllVisible() { const cards = Array.from(document.querySelectorAll('#cardGrid .comment-card')); if (!cards.length) { showToast('저장할 카드가 없어요'); return; } // 폴더 미선택 상태면 먼저 폴더를 고르게 한다. 단, 사용자가 직접 해제했으면 묻지 않고 다운로드 폴더로. if (!dirHandle && !dirOptOut && window.showDirectoryPicker) { await pickDir(); if (!dirHandle) return; // 취소 } if (!dirHandle && !confirm(cards.length + '개 파일이 각각 다운로드됩니다. 계속할까요?')) return; if (cards.length > 50 && !confirm(cards.length + '개를 저장할까요? 시간이 걸릴 수 있어요.')) return; const btn = $('ccSaveAll'); btn.disabled = true; const pad = String(cards.length).length; let ok = 0, fail = 0; for (let i = 0; i < cards.length; i++) { btn.textContent = '저장 중… ' + (i + 1) + '/' + cards.length; try { const blob = await captureBlob(cards[i]); await saveBlob(blob, 'comment-' + String(i + 1).padStart(pad, '0') + '.png'); markSaved(cards[i]); ok++; } catch (e) { fail++; } } btn.disabled = false; btn.textContent = '전체 저장'; showToast('완료: ' + ok + '개 저장' + (fail ? ', ' + fail + ' 실패' : '') + (dirHandle ? ' → ' + dirHandle.name : ' (다운로드 폴더)')); } window.downloadCard = async function (cardEl, c) { if (cardEl.dataset.busy === '1') return; cardEl.dataset.busy = '1'; try { const blob = await captureBlob(cardEl); const toDir = await saveBlob(blob, buildFileName(c)); markSaved(cardEl); showToast(toDir ? '저장됨 → ' + dirHandle.name : '저장됨 (다운로드 폴더)'); } catch (e) { showToast('저장 실패'); } finally { setTimeout(() => { delete cardEl.dataset.busy; }, 600); } }; window.copyCard = async function (cardEl) { if (cardEl.dataset.copying === '1') return; // 중복 클릭 방지 cardEl.dataset.copying = '1'; try { const blob = await captureBlob(cardEl); if (navigator.clipboard && window.ClipboardItem) { await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); showToast('복사됨 ✓'); } else { downloadBlob(blob); showToast('다운로드됨'); } } catch (e) { // 클립보드 권한 거부 등 → 다운로드 폴백 try { const blob = await captureBlob(cardEl); downloadBlob(blob); showToast('다운로드됨'); } catch (e2) { showToast('복사 실패'); } } finally { setTimeout(() => { delete cardEl.dataset.copying; }, 600); } }; async function fetchComments() { const url = $('ccUrl').value.trim(); if (!url) return; window.__videoId = parseVideoId(url); const btn = $('ccFetch'); btn.disabled = true; btn.textContent = '수집 중…'; $('ccEmpty').innerHTML = '' + '전체 댓글을 모으는 중입니다. 댓글이 많으면 시간이 걸릴 수 있어요…'; try { const res = await fetch('/api/comment-cards/fetch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }) }); const json = await res.json(); if (!json.success) throw new Error(json.message || '가져오기 실패'); window.__all = json.data || []; // 재렌더(필터·정렬)에도 유지되는 고유 id 부여 → 저장 표시가 카드에 붙어 다님 window.__all.forEach((c, i) => { c.__id = i; }); savedIds.clear(); // 새로 수집한 영상이므로 저장 표시 초기화 window.__cards = window.__all; // 딥링크: 공유·새로고침 시 결과 유지되도록 URL에 videoId 반영 if (window.__videoId) history.replaceState(null, '', '?v=' + encodeURIComponent(window.__videoId)); $('ccFilters').style.display = window.__all.length ? 'flex' : 'none'; $('ccEmpty').textContent = window.__all.length ? '' : '댓글이 없습니다.'; renderAnalytics(window.__all); window.renderCards(); } catch (e) { $('ccFilters').style.display = 'none'; $('ccAnalytics').style.display = 'none'; $('cardGrid').innerHTML = ''; $('ccEmpty').textContent = '⚠️ ' + e.message; } finally { btn.disabled = false; btn.textContent = '가져오기'; } } // 토글 버튼 공통: 상태 클래스(.active)·aria-pressed 갱신 function setToggle(btn, on) { btn.classList.toggle('active', on); btn.setAttribute('aria-pressed', String(on)); } document.addEventListener('DOMContentLoaded', () => { $('ccFetch').addEventListener('click', fetchComments); $('ccUrl').addEventListener('keydown', (e) => { if (e.key === 'Enter') fetchComments(); }); // 즉시 반영해도 가벼운 것(정렬·체크박스) ['ccSort', 'ccRepliesOnly'].forEach(id => $(id).addEventListener('input', () => window.renderCards())); // 대량 카드(최대 2,000장) 대비 검색·좋아요 필터는 디바운스 const debouncedRender = debounce(() => window.renderCards(), 200); ['ccSearch', 'ccMinLikes'].forEach(id => $(id).addEventListener('input', debouncedRender)); // 제외 단어는 카드+분석 둘 다 갱신. $('ccExclude').addEventListener('input', debounce(() => { window.renderCards(); renderAnalytics(window.__all); }, 250)); $('ccMosaic').addEventListener('click', () => { mosaicOn = !mosaicOn; $('ccMosaic').textContent = mosaicOn ? '모자이크 해제' : '전체 모자이크'; setToggle($('ccMosaic'), mosaicOn); document.querySelectorAll('.comment-card').forEach(el => el.classList.toggle('mosaic', mosaicOn)); }); $('ccRounded').addEventListener('click', () => { roundedOn = !roundedOn; $('ccRounded').textContent = roundedOn ? '모서리 각지게' : '모서리 둥글게'; setToggle($('ccRounded'), roundedOn); document.querySelectorAll('.comment-card').forEach(el => el.classList.toggle('rounded', roundedOn)); }); $('ccWordMask').addEventListener('click', () => { wordMaskOn = !wordMaskOn; $('ccWordMask').textContent = wordMaskOn ? '단어 가리기: 켜짐' : '단어 가리기: 꺼짐(복사)'; setToggle($('ccWordMask'), wordMaskOn); $('cardGrid').classList.toggle('wordmask', wordMaskOn); }); // 내용 글자 크기: 카드는 그대로, .cc-text만 배율 조정(재렌더·캡처에도 유지되도록 카드 인라인 변수) function applyTextScale() { $('ccFontLabel').textContent = Math.round(textScale * 100) + '%'; document.querySelectorAll('#cardGrid .comment-card').forEach(el => el.style.setProperty('--cc-text-scale', String(textScale))); } $('ccFontDown').addEventListener('click', () => { textScale = Math.max(0.6, +(textScale - 0.1).toFixed(2)); applyTextScale(); }); $('ccFontUp').addEventListener('click', () => { textScale = Math.min(2, +(textScale + 0.1).toFixed(2)); applyTextScale(); }); // 저장 화질(배율) — 캡처 시에만 쓰이므로 재렌더 불필요 $('ccScale').addEventListener('change', () => { exportScale = parseInt($('ccScale').value, 10) || 4; }); // 배경색: 기본 → 흰색 → 검정 순환. 검정이면 글자는 흰색(대비). const BG_MODES = ['default', 'white', 'black']; const BG_LABEL = { default: '배경: 기본', white: '배경: 흰색', black: '배경: 검정' }; $('ccBg').addEventListener('click', () => { bgMode = BG_MODES[(BG_MODES.indexOf(bgMode) + 1) % BG_MODES.length]; $('ccBg').textContent = BG_LABEL[bgMode]; setToggle($('ccBg'), bgMode !== 'default'); document.querySelectorAll('#cardGrid .comment-card').forEach(el => { el.classList.toggle('bg-white', bgMode === 'white'); el.classList.toggle('bg-black', bgMode === 'black'); }); }); $('ccPickDir').addEventListener('click', pickDir); $('ccSaveAll').addEventListener('click', saveAllVisible); // 폴더 저장 미지원 브라우저(파이어폭스/사파리)면 폴더 버튼 숨김 → 전체 저장은 다운로드 폴백 if (!window.showDirectoryPicker) $('ccPickDir').style.display = 'none'; // 딥링크: URL ?v=가 있으면 자동 조회(공유·새로고침 시 결과 유지) const params = new URLSearchParams(location.search); const v = params.get('v'); if (v) { $('ccUrl').value = v; fetchComments(); } }); })();