편집 요약 없음 |
편집 요약 없음 |
||
| (같은 사용자의 중간 판 4개는 보이지 않습니다) | |||
| 1번째 줄: | 1번째 줄: | ||
/* ========================================= | /* ========================================= | ||
COASTLINE: BLACK ICE - AnecdoteViewer | COASTLINE: BLACK ICE - AnecdoteViewer | ||
Original Crimson-Leather flow / CLBI platform shell / hub redesign reviewed 002 | |||
========================================= */ | ========================================= */ | ||
| 12번째 줄: | 12번째 줄: | ||
var ROOT_SELECTOR = '.anecdote-viewer-root'; | var ROOT_SELECTOR = '.anecdote-viewer-root'; | ||
var BODY_CLASS = 'anecdote-reader-mode'; | var BODY_CLASS = 'anecdote-reader-mode'; | ||
var BODY_INDEX_CLASS = 'anecdote-index-mode'; | |||
var HTML_CLASS = 'anecdote-reader-mode-html'; | var HTML_CLASS = 'anecdote-reader-mode-html'; | ||
var DECK_ID = 'anecdote-control-deck'; | var DECK_ID = 'anecdote-control-deck'; | ||
| 18번째 줄: | 19번째 줄: | ||
var activeViewer = null; | var activeViewer = null; | ||
var booted = false; | var booted = false; | ||
var FALLBACK_TIMELINE = { | |||
CrimsonLeather: { | |||
prologue: [ | |||
{ time: '2050.06.02 04:30', location: '키프로스, 아크데니즈 기지', note: '도착 및 장비 분산 완료', targetId: 'narration-highlight1' }, | |||
{ time: '11:10 - 13:55', location: '하마(Hama), 시리아', note: '화물기 탑승, 무장 최소화', targetId: 'narration-highlight2' }, | |||
{ time: '16:20 -', location: '지상 이동 개시', note: '', targetId: 'narration-highlight3' }, | |||
{ time: '추가예정', location: '요르단 북부 국경·이라크 국경 지대', note: '지상 이동', targetId: 'narration-highlight4' }, | |||
{ time: '-', location: '-', note: '', hidden: true }, | |||
{ time: '2050.06.04 21:50 - 22:15', location: '제6 군단 작전 경계선 <br> 군단 기술지원 경유소', note: '' } | |||
] | |||
} | |||
}; | |||
function escapeHtml(value) { | function escapeHtml(value) { | ||
| 116번째 줄: | 130번째 줄: | ||
work: work, | work: work, | ||
episode: episode, | episode: episode, | ||
mode: root.getAttribute('data-mode') || root.getAttribute('data-view') || '', | |||
lang: lang || DEFAULT_LANG, | lang: lang || DEFAULT_LANG, | ||
workLabel: root.getAttribute('data-work-title') || derived.workLabel || work, | workLabel: root.getAttribute('data-work-title') || derived.workLabel || work, | ||
| 141번째 줄: | 156번째 줄: | ||
parser.insertBefore(root, parser.firstChild || null); | parser.insertBefore(root, parser.firstChild || null); | ||
return [root]; | return [root]; | ||
} | |||
function isIndexMode(config) { | |||
var mode = String(config && config.mode ? config.mode : '').toLowerCase(); | |||
return mode === 'index' || mode === 'work' || mode === 'hub' || !(config && config.episode); | |||
} | |||
function setWorkIndexMode(config) { | |||
document.documentElement.classList.remove(HTML_CLASS); | |||
document.body.classList.remove(BODY_CLASS); | |||
document.body.classList.add(BODY_INDEX_CLASS); | |||
document.body.setAttribute('data-anecdote-work', config && config.work ? config.work : ''); | |||
document.body.setAttribute('data-anecdote-episode', ''); | |||
document.body.setAttribute('data-anecdote-mode', 'index'); | |||
document.body.setAttribute('data-anecdote-lang', config && config.lang ? config.lang : DEFAULT_LANG); | |||
removeDeck(); | |||
} | } | ||
function setReaderMode(config) { | function setReaderMode(config) { | ||
document.documentElement.classList.add(HTML_CLASS); | document.documentElement.classList.add(HTML_CLASS); | ||
document.body.classList.remove(BODY_INDEX_CLASS); | |||
document.body.classList.add(BODY_CLASS); | document.body.classList.add(BODY_CLASS); | ||
document.body.setAttribute('data-anecdote-work', config && config.work ? config.work : ''); | document.body.setAttribute('data-anecdote-work', config && config.work ? config.work : ''); | ||
document.body.setAttribute('data-anecdote-episode', config && config.episode ? config.episode : ''); | document.body.setAttribute('data-anecdote-episode', config && config.episode ? config.episode : ''); | ||
document.body.setAttribute('data-anecdote-mode', 'episode'); | |||
document.body.setAttribute('data-anecdote-lang', config && config.lang ? config.lang : DEFAULT_LANG); | document.body.setAttribute('data-anecdote-lang', config && config.lang ? config.lang : DEFAULT_LANG); | ||
} | |||
function setModeForConfig(config) { | |||
if (isIndexMode(config)) { | |||
setWorkIndexMode(config || {}); | |||
} else { | |||
setReaderMode(config || {}); | |||
} | |||
} | } | ||
| 160번째 줄: | 201번째 줄: | ||
function removeDeck() { | function removeDeck() { | ||
var deck = document.getElementById(DECK_ID); | var deck = document.getElementById(DECK_ID); | ||
if (deck && deck.parentNode) | if (deck && deck.parentNode) deck.parentNode.removeChild(deck); | ||
} | } | ||
| 169번째 줄: | 208번째 줄: | ||
document.documentElement.classList.remove(HTML_CLASS); | document.documentElement.classList.remove(HTML_CLASS); | ||
document.body.classList.remove(BODY_CLASS); | document.body.classList.remove(BODY_CLASS); | ||
document.body.classList.remove(BODY_INDEX_CLASS); | |||
document.body.removeAttribute('data-anecdote-work'); | document.body.removeAttribute('data-anecdote-work'); | ||
document.body.removeAttribute('data-anecdote-episode'); | document.body.removeAttribute('data-anecdote-episode'); | ||
document.body.removeAttribute('data-anecdote-mode'); | |||
document.body.removeAttribute('data-anecdote-lang'); | document.body.removeAttribute('data-anecdote-lang'); | ||
removeDeck(); | removeDeck(); | ||
| 177번째 줄: | 218번째 줄: | ||
function renderSystem(root, config, type, title, text, code) { | function renderSystem(root, config, type, title, text, code) { | ||
setModeForConfig(config || {}); | |||
root.innerHTML = '' + | root.innerHTML = '' + | ||
'<section class="anecdote-viewer is-' + escapeAttr(type || 'error') + '">' + | '<section class="anecdote-viewer is-' + escapeAttr(type || 'error') + '">' + | ||
| 238번째 줄: | 279번째 줄: | ||
} | } | ||
if (assets.base) | if (assets.base) return String(assets.base) + fileName; | ||
return raw; | return raw; | ||
} | } | ||
| 258번째 줄: | 296번째 줄: | ||
return wrap.innerHTML; | return wrap.innerHTML; | ||
} | |||
function formatText(value) { | |||
if (value == null) return ''; | |||
return escapeHtml(value) | |||
.replace(/\r\n/g, '\n') | |||
.replace(/\r/g, '\n') | |||
.split(/\n{2,}/) | |||
.map(function (paragraph) { | |||
return paragraph.replace(/\n/g, '<br>'); | |||
}) | |||
.join('<br>'); | |||
} | |||
function formatSpeakerName(name) { | |||
var raw = String(name || '').trim(); | |||
var match = raw.match(/^(.*?)(\s+GRIMM\s+[0-9-]+)$/i); | |||
if (!match) return escapeHtml(raw); | |||
return escapeHtml(match[1].trim()) + ' <small>' + escapeHtml(match[2].trim()) + '</small>'; | |||
} | } | ||
| 274번째 줄: | 331번째 줄: | ||
} | } | ||
function | function getEntryAvatar(data, entry) { | ||
if (!entry) return ''; | if (!entry) return ''; | ||
var id = entry.character || entry.speaker || entry.characterId || ''; | var id = entry.character || entry.speaker || entry.characterId || ''; | ||
var character = getCharacter(data, id); | var character = getCharacter(data, id); | ||
return resolveAsset(data, entry.portrait || (character && character.portrait) || ''); | return resolveAsset(data, entry.avatar || entry.portrait || (character && (character.avatar || character.portrait)) || ''); | ||
} | } | ||
function | function getEntryHtml(data, entry) { | ||
if (!entry) return ''; | if (!entry) return ''; | ||
if (entry.html != null) return resolveHtmlAssets(data, String(entry.html)); | |||
if (entry.text != null) return formatText(entry.text); | |||
if (entry.plain != null) return formatText(entry.plain); | |||
return ''; | |||
} | } | ||
function | function entryClasses(entry) { | ||
var classes = []; | |||
var type = getEntryType(entry); | |||
classes.push('anecdote-narration'); | |||
classes.push('is-' + type.replace(/[^A-Za-z0-9_-]/g, '')); | |||
. | if (entry && Array.isArray(entry.classes)) { | ||
entry.classes.forEach(function (item) { | |||
}) | var value = String(item || '').replace(/[^A-Za-z0-9_-]/g, ''); | ||
if (value) classes.push(value); | |||
}); | |||
} | |||
if (entry && entry.tone) classes.push('tone-' + String(entry.tone).replace(/[^A-Za-z0-9_-]/g, '')); | |||
return classes.join(' '); | |||
} | } | ||
function | function buildEntryHtml(data, entry) { | ||
var type = getEntryType(entry); | var type = getEntryType(entry); | ||
var | var side; | ||
var tone; | |||
var avatar; | |||
var speaker; | |||
var bubble; | |||
if (type === 'chat') { | if (type === 'chat') { | ||
side = getEntrySide(data, entry) || 'right'; | |||
tone = String(entry.tone || 'friendly').replace(/[^A-Za-z0-9_-]/g, '') || 'friendly'; | |||
avatar = getEntryAvatar(data, entry); | |||
speaker = getEntrySpeaker(data, entry) || '???'; | |||
bubble = getEntryHtml(data, entry); | |||
return '' + | return '' + | ||
'< | '<span class="chat-wrapper ' + escapeAttr(side) + ' fade-in-up">' + | ||
(avatar ? '<img | (avatar ? '<img src="' + escapeAttr(avatar) + '" class="chat-avatar left" alt="">' : '<span class="chat-avatar left" aria-hidden="true"></span>') + | ||
'< | '<span class="chat-block ' + escapeAttr(side) + '">' + | ||
'<span class="chat-name ' + escapeAttr(side) + ' ' + escapeAttr(tone) + '">' + formatSpeakerName(speaker) + '</span>' + | |||
'<span class="chat-bubble ' + escapeAttr(side) + '">' + bubble + '</span>' + | |||
'</ | '</span>' + | ||
'</ | '</span>'; | ||
} | } | ||
return getEntryHtml(data, entry); | |||
return | } | ||
function getBriefing(entries) { | |||
if (!entries.length) return null; | |||
return getEntryType(entries[0]) === 'briefing' ? entries[0] : null; | |||
} | } | ||
function | function getPlayableEntries(entries) { | ||
var | var briefing = getBriefing(entries); | ||
return briefing ? entries.slice(1) : entries.slice(); | |||
} | } | ||
| 362번째 줄: | 429번째 줄: | ||
} | } | ||
function | function getEpisodeById(indexData, episodeId) { | ||
var episodes = indexData && Array.isArray(indexData.episodes) ? indexData.episodes : []; | |||
var id = String(episodeId || ''); | |||
for (var i = 0; i < episodes.length; i += 1) { | |||
if (String(episodes[i].id || '') === id) return episodes[i]; | |||
} | |||
return null; | |||
} | } | ||
function | function buildIndexItemHref(indexData, item) { | ||
var | var episode; | ||
if ( | if (!item || item.disabled || item.status === 'disabled') return ''; | ||
return | if (item.href) return item.href; | ||
if (item.pageTitle) return mw.util.getUrl(item.pageTitle); | |||
if (item.page) return mw.util.getUrl(item.page); | |||
if (item.episode) { | |||
episode = getEpisodeById(indexData, item.episode); | |||
return buildEpisodeHref(episode); | |||
} | |||
return ''; | |||
} | } | ||
function | function getIndexParagraphs(indexData) { | ||
if (indexData && Array.isArray(indexData.introParagraphs)) return indexData.introParagraphs.slice(); | |||
if (indexData && Array.isArray(indexData.summaryParagraphs)) return indexData.summaryParagraphs.slice(); | |||
if (indexData && indexData.summary) return [indexData.summary]; | |||
return []; | |||
} | } | ||
function | function getIndexSections(indexData) { | ||
if ( | var episodes; | ||
if (indexData && Array.isArray(indexData.sections)) return indexData.sections.slice(); | |||
episodes = indexData && Array.isArray(indexData.episodes) ? indexData.episodes : []; | |||
return [{ | |||
title: 'EPISODES', | |||
items: episodes.map(function (episode) { | |||
return { title: episode.title || episode.id, episode: episode.id }; | |||
}) | |||
}]; | |||
} | } | ||
function | function buildWorkIndexHtml(config, indexData) { | ||
var | var title = indexData.displayTitle || indexData.title || config.workLabel || config.work; | ||
var | var author = indexData.author || 'Provided by Team CLBI'; | ||
var | var tags = Array.isArray(indexData.tags) ? indexData.tags : []; | ||
var paragraphs = getIndexParagraphs(indexData); | |||
var sections = getIndexSections(indexData); | |||
var firstEpisode = indexData.firstEpisode || (indexData.episodes && indexData.episodes[0] && indexData.episodes[0].id) || ''; | |||
var firstHref = buildIndexItemHref(indexData, { episode: firstEpisode }); | |||
var briefingHref = indexData.briefingHref || ''; | |||
var cover = resolveAsset(indexData, indexData.cover || ''); | |||
var episodes = Array.isArray(indexData.episodes) ? indexData.episodes : []; | |||
var readyCount = episodes.filter(function (episode) { | |||
return !!buildEpisodeHref(episode) && episode.status !== 'disabled'; | |||
}).length; | |||
var dataCode = 'AnecdoteData.' + normalizeSegment(config.work) + '.index.json'; | |||
var sectionHtml; | |||
sectionHtml = sections.map(function (section, sectionIndex) { | |||
var items = Array.isArray(section.items) ? section.items : []; | |||
var rows = items.map(function (item, itemIndex) { | |||
var href = buildIndexItemHref(indexData, item); | |||
var rowTag = href ? 'a' : 'span'; | |||
var statusRaw = item.status || (href ? 'ready' : 'locked'); | |||
var status = String(statusRaw || '').toUpperCase(); | |||
var title = item.title || item.label || ''; | |||
var number = String(sectionIndex + 1) + '-' + String(itemIndex + 1).padStart(2, '0'); | |||
var rowClass = 'anecdote-index-entry-row ' + (href ? 'is-open' : 'is-locked'); | |||
var attr = href ? ' href="' + escapeAttr(href) + '"' : ''; | |||
if (!href && status === 'DISABLED') status = 'LOCKED'; | |||
return '' + | |||
'<' + rowTag + attr + ' class="' + rowClass + '">' + | |||
'<span class="anecdote-index-entry-no">' + escapeHtml(number) + '</span>' + | |||
'<span class="anecdote-index-entry-title">' + escapeHtml(title) + '</span>' + | |||
'<span class="anecdote-index-entry-status">' + escapeHtml(status) + '</span>' + | |||
'<span class="anecdote-index-entry-arrow">›</span>' + | |||
'</' + rowTag + '>'; | |||
}).join(''); | |||
return '' + | |||
'<section class="anecdote-index-module anecdote-index-ledger-group">' + | |||
'<div class="anecdote-index-module-titlebar">' + | |||
'<span>' + escapeHtml(section.title || 'ENTRY BLOCK') + '</span>' + | |||
'<span>' + escapeHtml(String(items.length)) + ' ITEMS</span>' + | |||
'</div>' + | |||
'<div class="anecdote-index-ledger-list">' + rows + '</div>' + | |||
'</section>'; | |||
}).join(''); | |||
return '' + | |||
'<section class="anecdote-viewer is-ready" data-work="' + escapeAttr(config.work | '<section class="anecdote-viewer anecdote-work-index is-ready" data-work="' + escapeAttr(config.work) + '">' + | ||
'<div class="anecdote- | '<div class="anecdote-index-record">' + | ||
'<div class="anecdote-titlebar">' + | '<div class="anecdote-index-record-titlebar">' + | ||
'<span>ANECDOTE ARCHIVE RECORD</span>' + | |||
'<span>' + escapeHtml(normalizeSegment(config.work) || 'UNKNOWN') + ' / ' + escapeHtml((config.lang || DEFAULT_LANG).toUpperCase()) + '</span>' + | |||
'</div>' + | '</div>' + | ||
'<div class="anecdote- | '<div class="anecdote-index-record-body">' + | ||
'<div class="anecdote- | '<aside class="anecdote-index-cover-bay">' + | ||
'<div class="anecdote- | '<div class="anecdote-index-cover-frame">' + | ||
'<div class="anecdote- | (cover ? '<img src="' + escapeAttr(cover) + '" alt="표지 이미지">' : '<div class="anecdote-index-cover-placeholder">NO COVER</div>') + | ||
'</div>' + | |||
'<div class="anecdote-index-cover-meta">' + | |||
'<div class="anecdote- | '<div><span>VISUAL</span><strong>COVER REFERENCE</strong></div>' + | ||
'<div><span>SOURCE</span><strong>' + escapeHtml(dataCode) + '</strong></div>' + | |||
'</div>' + | |||
'</aside>' + | |||
'<div class="anecdote-index-main">' + | |||
'<div class="anecdote-index-top-grid">' + | |||
'<div class="anecdote-index-id-panel">' + | |||
'<div class="anecdote-index-kicker">RECORD TITLE</div>' + | |||
'<h1 class="anecdote-index-title">' + escapeHtml(title) + '</h1>' + | |||
'<p class="anecdote-index-author">' + escapeHtml(author) + '</p>' + | |||
'<div class="anecdote-index-tags">' + tags.map(function (tag) { return '<span>#' + escapeHtml(String(tag).replace(/^#/, '')) + '</span>'; }).join('') + '</div>' + | |||
'</div>' + | |||
'<div class="anecdote-index-status-panel">' + | |||
'<div class="anecdote-index-status-row"><span>WORK ID</span><strong>' + escapeHtml(config.work || '-') + '</strong></div>' + | |||
'<div class="anecdote-index-status-row"><span>LANG</span><strong>' + escapeHtml((config.lang || DEFAULT_LANG).toUpperCase()) + '</strong></div>' + | |||
'<div class="anecdote-index-status-row"><span>ENTRIES</span><strong>' + escapeHtml(String(readyCount)) + ' / ' + escapeHtml(String(episodes.length)) + '</strong></div>' + | |||
'<div class="anecdote-index-status-row"><span>FORMAT</span><strong>AnecdoteIndex/v1</strong></div>' + | |||
'</div>' + | |||
'</div>' + | |||
'<div class="anecdote-index-action-deck">' + | |||
'<a href="' + escapeAttr(firstHref || '#') + '" class="anecdote-index-action-button is-primary ' + (firstHref ? '' : 'is-disabled') + '">첫 화 보기<span>›</span></a>' + | |||
'<button type="button" class="anecdote-index-action-button" data-anecdote-open-tutorial>작동 안내<span>›</span></button>' + | |||
(briefingHref ? '<a href="' + escapeAttr(briefingHref) + '" class="anecdote-index-action-button">작전 브리핑<span>›</span></a>' : '<span class="anecdote-index-action-button is-disabled">작전 브리핑<span>›</span></span>') + | |||
'</div>' + | |||
'<div class="anecdote-index-synopsis-panel">' + | |||
'<div class="anecdote-index-module-titlebar">' + | |||
'<span>SYNOPSIS BUFFER</span>' + | |||
'<span>OPEN TEXT</span>' + | |||
'</div>' + | |||
'<div class="anecdote-index-synopsis-body">' + | |||
'<div class="anecdote-index-fade-wrapper" data-anecdote-readmore-box>' + | |||
'<div class="anecdote-index-fade-text">' + paragraphs.map(function (paragraph) { return '<p>' + formatText(paragraph) + '</p>'; }).join('') + '</div>' + | |||
'</div>' + | '</div>' + | ||
'<button type="button" class="anecdote-index-read-more" data-anecdote-readmore>더보기 ›</button>' + | |||
'<button type="button" class="anecdote- | |||
'</div>' + | '</div>' + | ||
'</div>' + | '</div>' + | ||
'</div>' + | '</div>' + | ||
'<div class="anecdote- | '</div>' + | ||
'</div>' + | |||
'< | '<div class="anecdote-index-lower-grid">' + | ||
'<div class="anecdote-index-ledger-stack">' + sectionHtml + '</div>' + | |||
'<aside class="anecdote-index-module anecdote-index-notes">' + | |||
'<div class="anecdote-index-module-titlebar">' + | |||
'<span>CONTROL NOTES</span>' + | |||
'<span>VIEWER</span>' + | |||
'</div>' + | |||
'<div class="anecdote-index-note-list">' + | |||
'<div><span>SPACE</span><strong>회차 감상 중 다음 문단 진행</strong></div>' + | |||
'<div><span>T</span><strong>작동 안내 다시 열기</strong></div>' + | |||
'<div><span>INDEX</span><strong>감상 모드에서 작품 허브로 복귀</strong></div>' + | |||
'</div>' + | |||
'</aside>' + | |||
'</div>' + | |||
'<div class="anecdote-index-tutorial-overlay" data-anecdote-tutorial style="display:none;">' + | |||
'<div class="anecdote-index-tutorial-popup">' + | |||
'<div class="anecdote-index-tutorial-titlebar"><span>ANECDOTE VIEWER GUIDE</span><span>INPUT</span></div>' + | |||
'<button type="button" class="anecdote-index-close-btn" data-anecdote-tutorial-close>×</button>' + | |||
'<div class="anecdote-index-tutorial-body">' + | |||
'<p><strong>회차 화면에서는 키보드 <code>Space</code> 키로 다음 문단을 진행합니다.</strong></p>' + | |||
'<p>화면 하단의 ANECDOTE 컨트롤덱에서 INDEX, PREV EP, AUTO, ALL, NEXT EP 기능을 사용할 수 있습니다.</p>' + | |||
'<label><input type="checkbox" data-anecdote-tutorial-dont-show> 다시 보지 않기</label>' + | |||
'</div>' + | '</div>' + | ||
'</div>' + | '</div>' + | ||
'</div>' + | '</div>' + | ||
'</section>'; | '</section>'; | ||
} | |||
function createWorkIndexController(root, config, indexData) { | |||
var viewer; | |||
var readBox; | |||
var readMore; | |||
var overlay; | |||
var close; | |||
var checkbox; | |||
var keyHandler; | |||
var storageKey = 'hideAnecdoteTutorial'; | |||
root.innerHTML = buildWorkIndexHtml(config, indexData || {}); | |||
viewer = root.querySelector('.anecdote-work-index'); | |||
if (viewer) { | |||
var bg = resolveAsset(indexData || {}, (indexData && (indexData.background || indexData.heroBackground)) || ''); | |||
if (bg) viewer.style.setProperty('--anecdote-index-bg', 'url("' + bg.replace(/"/g, '\\"') + '")'); | |||
} | |||
readBox = root.querySelector('[data-anecdote-readmore-box]'); | |||
readMore = root.querySelector('[data-anecdote-readmore]'); | |||
if (readBox && readMore) { | |||
readMore.addEventListener('click', function () { | |||
readBox.classList.add('expanded'); | |||
readMore.classList.add('hidden'); | |||
}); | |||
} | |||
overlay = root.querySelector('[data-anecdote-tutorial]'); | |||
close = root.querySelector('[data-anecdote-tutorial-close]'); | |||
checkbox = root.querySelector('[data-anecdote-tutorial-dont-show]'); | |||
function showTutorial() { | |||
if (overlay) overlay.style.display = 'flex'; | |||
} | |||
function closeTutorial() { | |||
if (checkbox && checkbox.checked) localStorage.setItem(storageKey, 'true'); | |||
if (overlay) overlay.style.display = 'none'; | |||
} | |||
if (close) close.addEventListener('click', closeTutorial); | |||
root.querySelectorAll('[data-anecdote-open-tutorial]').forEach(function (button) { | |||
button.addEventListener('click', function () { | |||
localStorage.removeItem(storageKey); | |||
showTutorial(); | |||
}); | |||
}); | |||
if (overlay && localStorage.getItem(storageKey) !== 'true') showTutorial(); | |||
keyHandler = function (event) { | |||
if (!document.body.classList.contains(BODY_INDEX_CLASS)) return; | |||
if (event.key === 't' || event.key === 'T') { | |||
localStorage.removeItem(storageKey); | |||
showTutorial(); | |||
} | |||
}; | |||
document.addEventListener('keydown', keyHandler); | |||
return root. | return { | ||
root: root, | |||
config: config, | |||
indexData: indexData || {}, | |||
next: function () {}, | |||
revealAll: function () {}, | |||
toggleAuto: function () {}, | |||
stopAuto: function () {}, | |||
isAutoPlaying: function () { return false; }, | |||
isTyping: function () { return false; }, | |||
skip: function () {}, | |||
destroy: function () { | |||
document.removeEventListener('keydown', keyHandler); | |||
} | |||
}; | |||
} | } | ||
function | function initIndexRoot(root, config) { | ||
setWorkIndexMode(config); | |||
removeDeck(); | |||
activeViewer = null; | |||
renderSystem(root, config, 'loading', 'ANECDOTE INDEX', '작품 정보를 불러오는 중입니다.', buildIndexTitle(config)); | |||
fetchJson(buildIndexTitle(config)).then(function (indexData) { | |||
if (!indexData || !Array.isArray(indexData.episodes)) { | |||
renderSystem(root, config, 'error', 'ANECDOTE INDEX EMPTY', '작품 인덱스에 episodes 배열이 없습니다.', buildIndexTitle(config)); | |||
return; | |||
} | |||
activeViewer = createWorkIndexController(root, config, indexData); | |||
}).catch(function (err) { | |||
renderSystem(root, config, 'error', 'ANECDOTE INDEX LOAD FAILED', String(err && err.message ? err.message : err), buildIndexTitle(config)); | |||
}); | |||
} | |||
function buildReturnHref(config, indexData) { | |||
if (config.returnHref) return config.returnHref; | |||
if (config.returnTitle) return mw.util.getUrl(config.returnTitle); | |||
if (indexData && indexData.pageTitle) return mw.util.getUrl(indexData.pageTitle); | |||
if (config.workLabel) return mw.util.getUrl('Anecdote:' + config.workLabel); | |||
return mw.util.getUrl('대문'); | |||
} | |||
function getTimelineData(config, episodeData) { | |||
if (Array.isArray(episodeData.timeline)) return episodeData.timeline.slice(); | |||
if (FALLBACK_TIMELINE[config.work] && FALLBACK_TIMELINE[config.work][config.episode]) { | |||
return FALLBACK_TIMELINE[config.work][config.episode].slice(); | |||
} | |||
return []; | |||
} | |||
function ensureDeck() { | |||
var deck = document.getElementById(DECK_ID); | var deck = document.getElementById(DECK_ID); | ||
if (!deck) return; | if (!deck) { | ||
deck = document.createElement('div'); | |||
deck.id = DECK_ID; | |||
document.body.appendChild(deck); | |||
} | |||
return deck; | |||
} | |||
function updateDeck(viewer) { | |||
var deck = ensureDeck(); | |||
if (!viewer) { | if (!viewer) { | ||
deck.innerHTML = ''; | deck.innerHTML = ''; | ||
| 450번째 줄: | 731번째 줄: | ||
var previousHref = buildEpisodeHref(nav.previous); | var previousHref = buildEpisodeHref(nav.previous); | ||
var nextHref = buildEpisodeHref(nav.next); | var nextHref = buildEpisodeHref(nav.next); | ||
var returnHref = buildReturnHref(viewer.config); | var returnHref = buildReturnHref(viewer.config, viewer.indexData); | ||
var autoText = viewer.isAutoPlaying() ? 'AUTO ON' : 'AUTO'; | |||
deck.innerHTML = '' + | deck.innerHTML = '' + | ||
'<div class="anecdote-control-frame">' + | '<div class="anecdote-control-frame">' + | ||
'<div class="anecdote-control-brand">ANECDOTE</div>' + | '<div class="anecdote-control-brand">ANECDOTE</div>' + | ||
'<a class="anecdote-link-button" href="' + escapeAttr(returnHref) + '"> | '<a class="anecdote-link-button" href="' + escapeAttr(returnHref) + '">INDEX</a>' + | ||
'<a class="anecdote-link-button ' + (previousHref ? '' : 'is-disabled') + '" href="' + escapeAttr(previousHref || '#') + '">PREV EP</a>' + | '<a class="anecdote-link-button ' + (previousHref ? '' : 'is-disabled') + '" href="' + escapeAttr(previousHref || '#') + '">PREV EP</a>' + | ||
'<button type="button" class="anecdote-button" data-deck-action=" | '<button type="button" class="anecdote-button ' + (viewer.isAutoPlaying() ? 'is-active' : '') + '" data-deck-action="auto">' + autoText + '</button>' + | ||
'<button type="button" class="anecdote-button" data-deck-action=" | '<button type="button" class="anecdote-button" data-deck-action="all">ALL</button>' + | ||
'<a class="anecdote-link-button ' + (nextHref ? '' : 'is-disabled') + '" href="' + escapeAttr(nextHref || '#') + '">NEXT EP</a>' + | '<a class="anecdote-link-button ' + (nextHref ? '' : 'is-disabled') + '" href="' + escapeAttr(nextHref || '#') + '">NEXT EP</a>' + | ||
'</div>'; | '</div>'; | ||
deck.querySelectorAll('[data-deck-action]').forEach(function (button) { | |||
button.addEventListener('click', function () { | |||
var | var action = button.getAttribute('data-deck-action'); | ||
if (action === 'auto') viewer.toggleAuto(); | |||
if (action === 'all') viewer.revealAll(); | |||
}); | |||
}); | |||
} | |||
async function typeHtmlWithTags(el, html, speed, context) { | |||
var tmp = document.createElement('div'); | |||
tmp.innerHTML = html; | |||
el.innerHTML = ''; | |||
context.isTyping = true; | |||
context.skipTyping = false; | |||
async function processNode(node, parent) { | |||
var span; | |||
var i; | |||
var finalText; | |||
if (node.nodeType === Node.TEXT_NODE) { | |||
if (parent.classList && parent.classList.contains('quick-swap')) { | |||
span = document.createElement('span'); | |||
parent.appendChild(span); | |||
i = 0; | |||
finalText = parent.getAttribute('data-final') || node.textContent; | |||
await new Promise(function (resolve) { | |||
var iv = setInterval(function () { | |||
if (context.skipTyping) { | |||
span.textContent += node.textContent.slice(i); | |||
clearInterval(iv); | |||
parent.textContent = finalText; | |||
parent.classList.remove('fade-out'); | |||
resolve(); | |||
return; | |||
} | |||
if (i < node.textContent.length) { | |||
span.textContent += node.textContent[i++]; | |||
} else { | |||
clearInterval(iv); | |||
setTimeout(function () { | |||
parent.classList.add('fade-out'); | |||
setTimeout(function () { | |||
parent.textContent = finalText; | |||
parent.classList.remove('fade-out'); | |||
}, 50); | |||
}, 300); | |||
resolve(); | |||
} | |||
}, speed); | |||
}); | |||
return; | |||
} | |||
span = document.createElement('span'); | |||
parent.appendChild(span); | |||
i = 0; | |||
await new Promise(function (resolve) { | |||
var iv = setInterval(function () { | |||
if (context.skipTyping) { | |||
span.textContent += node.textContent.slice(i); | |||
clearInterval(iv); | |||
resolve(); | |||
return; | |||
} | |||
if (i < node.textContent.length) { | |||
span.textContent += node.textContent[i++]; | |||
} else { | |||
clearInterval(iv); | |||
resolve(); | |||
} | |||
}, speed); | |||
}); | |||
return; | |||
} | |||
if (node.nodeType === Node.ELEMENT_NODE) { | |||
var newEl = node.cloneNode(false); | |||
parent.appendChild(newEl); | |||
for (var child = node.firstChild; child; child = child.nextSibling) { | |||
await processNode(child, newEl); | |||
} | |||
} | |||
} | |||
for (var node = tmp.firstChild; node; node = node.nextSibling) { | |||
await processNode(node, el); | |||
} | |||
context.isTyping = false; | |||
} | } | ||
function | function makeShell(root, config, indexData, episodeData) { | ||
var | var entries = getEntries(episodeData); | ||
var briefing = getBriefing(entries); | |||
var workTitle = episodeData.workTitle || indexData.title || config.workLabel || config.work; | |||
var episodeTitle = episodeData.title || config.episodeLabel || config.episode; | |||
root.innerHTML = '' + | |||
'<section class="anecdote-viewer is-ready" data-work="' + escapeAttr(config.work) + '" data-episode="' + escapeAttr(config.episode) + '">' + | |||
'<div class="anecdote-original-frame">' + | |||
'<aside class="timeline-sidebar anecdote-timeline-sidebar" data-anecdote-timeline></aside>' + | |||
'<div class="anecdote-original-scroll" data-anecdote-scroll>' + | |||
'<button type="button" class="anecdote-back-button" data-anecdote-action="back">← 뒤로가기</button>' + | |||
'<div class="anecdote-floating-title" data-anecdote-title>' + escapeHtml(episodeTitle) + '</div>' + | |||
'<div class="anecdote-narration-box" data-anecdote-box>' + | |||
'<p class="anecdote-prologueinfo" data-anecdote-briefing></p>' + | |||
'<div class="anecdote-flow" data-anecdote-flow></div>' + | |||
'<div class="anecdote-narration-hint" data-anecdote-hint>' + | |||
'<span class="anecdote-key-box">Space</span> 다음으로' + | |||
'</div>' + | |||
'</div>' + | |||
'</div>' + | |||
'</div>' + | |||
'</section>'; | |||
if (!briefing) { | |||
var briefingEl = root.querySelector('[data-anecdote-briefing]'); | |||
if (briefingEl) briefingEl.style.display = 'none'; | |||
} | |||
return { | |||
viewerEl: root.querySelector('.anecdote-viewer'), | |||
scrollEl: root.querySelector('[data-anecdote-scroll]'), | |||
flowEl: root.querySelector('[data-anecdote-flow]'), | |||
hintEl: root.querySelector('[data-anecdote-hint]'), | |||
briefingEl: root.querySelector('[data-anecdote-briefing]'), | |||
titleEl: root.querySelector('[data-anecdote-title]'), | |||
timelineEl: root.querySelector('[data-anecdote-timeline]'), | |||
workTitle: workTitle, | |||
episodeTitle: episodeTitle | |||
}; | |||
} | } | ||
function createViewerController(root, config, indexData, episodeData) { | function createViewerController(root, config, indexData, episodeData) { | ||
var | var allEntries = getEntries(episodeData); | ||
var | var briefing = getBriefing(allEntries); | ||
var entries = getPlayableEntries(allEntries); | |||
var shell = makeShell(root, config, indexData, episodeData); | |||
var timelineData = getTimelineData(config, episodeData); | |||
var state = { | var state = { | ||
root: root, | root: root, | ||
config: config, | config: config, | ||
indexData: indexData || {}, | indexData: indexData || {}, | ||
episodeData: episodeData || {}, | episodeData: episodeData || {}, | ||
entries: entries, | entries: entries, | ||
currentIndex: 0, | |||
timelineIndex: 0, | |||
isTyping: false, | |||
skipTyping: false, | |||
typingKeyCount: 0, | |||
autoPlay: false, | |||
autoTimer: null, | autoTimer: null, | ||
destroyed: false | |||
}; | }; | ||
function | function scrollToBottom() { | ||
if ( | if (!shell.scrollEl) return; | ||
shell.scrollEl.scrollTo({ top: shell.scrollEl.scrollHeight, behavior: 'smooth' }); | |||
} | |||
function updateDeckForThis() { | |||
updateDeck(controller); | |||
} | } | ||
function | function hideIntroIfFirst() { | ||
if (state.currentIndex !== 0) return; | |||
if (shell.titleEl) { | |||
shell.titleEl.classList.add('fade-out'); | |||
setTimeout(function () { shell.titleEl.style.display = 'none'; }, 600); | |||
} | } | ||
if (shell.briefingEl) { | |||
if ( | shell.briefingEl.classList.add('fade-out'); | ||
setTimeout(function () { shell.briefingEl.style.display = 'none'; }, 600); | |||
} | } | ||
} | |||
function highlightNarrationById(className) { | |||
Array.prototype.forEach.call(root.querySelectorAll('.highlighted, .anecdote-highlighted'), function (el) { | |||
} | el.classList.remove('highlighted'); | ||
el.classList.remove('anecdote-highlighted'); | |||
}); | |||
if ( | if (className) { | ||
Array.prototype.forEach.call(root.querySelectorAll('.' + className), function (el) { | |||
el.classList.add('highlighted'); | |||
el.classList.add('anecdote-highlighted'); | |||
}); | |||
} | } | ||
} | |||
function addTimeline(entry, index) { | |||
if (!shell.timelineEl || !entry || entry.hidden) return; | |||
var item = document.createElement('div'); | |||
item.className = 'timeline-item timeline-' + index; | |||
var dot = document.createElement('div'); | |||
dot.className = 'timeline-dot'; | |||
var content = document.createElement('div'); | |||
content.className = 'timeline-content'; | |||
content.innerHTML = '<strong>' + escapeHtml(entry.time || '') + '</strong><br>' + | |||
String(entry.location || '') + '<br>' + String(entry.note || ''); | |||
item.addEventListener('mouseenter', function () { | |||
highlightNarrationById(entry.targetId); | |||
}); | |||
item.addEventListener('mouseleave', function () { | |||
highlightNarrationById(null); | |||
}); | |||
item.appendChild(dot); | |||
item.appendChild(content); | |||
shell.timelineEl.appendChild(item); | |||
} | } | ||
function | function addNextTimeline() { | ||
while (state.timelineIndex < timelineData.length) { | |||
var entry = timelineData[state.timelineIndex]; | |||
var index = state.timelineIndex; | |||
state.timelineIndex += 1; | |||
if (!entry.hidden) { | |||
addTimeline(entry, index); | |||
var entry = state. | break; | ||
var | |||
if (! | |||
} | } | ||
} | } | ||
} | } | ||
function | function createEntryElement(entry) { | ||
var p = document.createElement('p'); | |||
p.className = entryClasses(entry); | |||
p.setAttribute('data-anecdote-entry-id', entry && entry.id ? entry.id : ''); | |||
return p; | |||
} | |||
async function showNext() { | |||
if (state.destroyed) return; | |||
if (state.currentIndex >= state.entries.length) { | |||
stopAuto(); | stopAuto(); | ||
if (shell.hintEl) { | |||
shell.hintEl.classList.add('reappear'); | |||
shell.hintEl.innerHTML = '<span class="anecdote-key-box">End</span> 끝'; | |||
} | |||
return; | |||
} | } | ||
hideIntroIfFirst(); | |||
scrollToBottom(); | |||
var entry = state.entries[state.currentIndex]; | |||
var p = createEntryElement(entry); | |||
var html = buildEntryHtml(state.episodeData, entry); | |||
if (shell.flowEl) shell.flowEl.appendChild(p); | |||
if (shell.hintEl) shell.hintEl.classList.add('hidden'); | |||
await typeHtmlWithTags(p, html, parseInt(state.episodeData.typeSpeed || 30, 10), state); | |||
addNextTimeline(); | |||
if (shell.hintEl) shell.hintEl.classList.remove('hidden'); | |||
state.currentIndex += 1; | |||
scrollToBottom(); | |||
} | } | ||
function | function revealAll() { | ||
var entry; | |||
state. | var p; | ||
var html; | |||
stopAuto(); | |||
state.skipTyping = true; | |||
hideIntroIfFirst(); | |||
while (state.currentIndex < state.entries.length) { | |||
entry = state.entries[state.currentIndex]; | |||
p = createEntryElement(entry); | |||
html = buildEntryHtml(state.episodeData, entry); | |||
p.innerHTML = html; | |||
if (shell.flowEl) shell.flowEl.appendChild(p); | |||
addNextTimeline(); | |||
state.currentIndex += 1; | |||
} | } | ||
if (shell.hintEl) { | |||
shell.hintEl.classList.remove('hidden'); | |||
shell.hintEl.classList.add('reappear'); | |||
shell.hintEl.innerHTML = '<span class="anecdote-key-box">End</span> 끝'; | |||
} | |||
scrollToBottom(); | |||
} | } | ||
| 630번째 줄: | 1,045번째 줄: | ||
clearInterval(state.autoTimer); | clearInterval(state.autoTimer); | ||
state.autoTimer = null; | state.autoTimer = null; | ||
} | } | ||
state.autoPlay = false; | |||
updateDeckForThis(); | |||
} | } | ||
| 639번째 줄: | 1,055번째 줄: | ||
return; | return; | ||
} | } | ||
state.autoPlay = true; | |||
if (state.currentIndex === 0 && !state.isTyping) showNext(); | |||
state.autoTimer = setInterval(function () { | state.autoTimer = setInterval(function () { | ||
if (document.hidden) | if (!state.isTyping && !document.hidden) showNext(); | ||
}, parseInt(state.episodeData.autoInterval || 1000, 10)); | |||
}, parseInt(episodeData.autoInterval || | updateDeckForThis(); | ||
} | } | ||
function handleAdvanceFromHint() { | |||
if (state.isTyping) { | |||
state.skipTyping = true; | |||
} else { | |||
showNext(); | |||
} | } | ||
} | |||
if (shell.hintEl) { | |||
shell.hintEl.addEventListener('click', handleAdvanceFromHint); | |||
} | |||
root.addEventListener('click', function (event) { | |||
var actionEl = event.target.closest('[data-anecdote-action]'); | |||
if (!actionEl) return; | |||
var action = actionEl.getAttribute('data-anecdote-action'); | var action = actionEl.getAttribute('data-anecdote-action'); | ||
if (action === ' | if (action === 'back') window.history.back(); | ||
}); | }); | ||
if (briefing && shell.briefingEl) { | |||
return state; | typeHtmlWithTags(shell.briefingEl, getEntryHtml(episodeData, briefing), 15, state).then(function () { | ||
if (state.destroyed) return; | |||
if (shell.hintEl) shell.hintEl.classList.remove('hidden'); | |||
}); | |||
} | |||
var controller = { | |||
root: root, | |||
config: config, | |||
indexData: indexData || {}, | |||
episodeData: episodeData || {}, | |||
next: showNext, | |||
revealAll: revealAll, | |||
toggleAuto: toggleAuto, | |||
stopAuto: stopAuto, | |||
isAutoPlaying: function () { return !!state.autoTimer; }, | |||
isTyping: function () { return !!state.isTyping; }, | |||
skip: function () { state.skipTyping = true; }, | |||
destroy: function () { | |||
state.destroyed = true; | |||
stopAuto(); | |||
} | |||
}; | |||
updateDeck(controller); | |||
return controller; | |||
} | } | ||
| 668번째 줄: | 1,117번째 줄: | ||
document.addEventListener('keydown', function (event) { | document.addEventListener('keydown', function (event) { | ||
if (!document.body.classList.contains(BODY_CLASS) || !activeViewer) return; | if (!document.body.classList.contains(BODY_CLASS) || document.body.classList.contains(BODY_INDEX_CLASS) || !activeViewer) return; | ||
if (event.target && /input|textarea|select/i.test(event.target.tagName)) return; | if (event.target && /input|textarea|select/i.test(event.target.tagName)) return; | ||
if (event. | if (event.code === 'Space') { | ||
event.preventDefault(); | event.preventDefault(); | ||
var keyBoxes = document.querySelectorAll('.anecdote-key-box'); | |||
keyBoxes.forEach(function (box) { box.classList.add('pressed'); }); | |||
setTimeout(function () { keyBoxes.forEach(function (box) { box.classList.remove('pressed'); }); }, 90); | |||
if (event. | if (activeViewer.isTyping()) { | ||
activeViewer.skip(); | |||
} else { | |||
activeViewer.next(); | |||
} | |||
} else if (event.code === 'KeyP') { | |||
event.preventDefault(); | |||
activeViewer.toggleAuto(); | |||
} else if (event.code === 'KeyL') { | |||
event.preventDefault(); | event.preventDefault(); | ||
activeViewer. | activeViewer.revealAll(); | ||
} | } | ||
}); | }); | ||
| 697번째 줄: | 1,151번째 줄: | ||
var config = readRootConfig(root); | var config = readRootConfig(root); | ||
setReaderMode(config); | if (isIndexMode(config)) { | ||
setWorkIndexMode(config); | |||
} else { | |||
setReaderMode(config); | |||
ensureDeck(); | |||
} | |||
if (!config.work | if (!config.work) { | ||
renderSystem( | renderSystem( | ||
root, | root, | ||
| 706번째 줄: | 1,164번째 줄: | ||
'config-error', | 'config-error', | ||
'ANECDOTE CONFIG REQUIRED', | 'ANECDOTE CONFIG REQUIRED', | ||
'AnecdoteViewer 마운트에 | 'AnecdoteViewer 마운트에 work가 필요합니다.', | ||
'{{AnecdoteViewer\n|work=CrimsonLeather\n| | '{{AnecdoteViewer\n|work=CrimsonLeather\n|mode=index\n|lang=ko\n}}' | ||
); | ); | ||
updateDeck(null); | updateDeck(null); | ||
return; | |||
} | |||
if (isIndexMode(config)) { | |||
initIndexRoot(root, config); | |||
return; | return; | ||
} | } | ||
2026년 6월 1일 (월) 19:33 기준 최신판
/* =========================================
COASTLINE: BLACK ICE - AnecdoteViewer
Original Crimson-Leather flow / CLBI platform shell / hub redesign reviewed 002
========================================= */
(function (mw, $) {
'use strict';
if (window.AnecdoteViewerInitialized) return;
window.AnecdoteViewerInitialized = true;
var ROOT_SELECTOR = '.anecdote-viewer-root';
var BODY_CLASS = 'anecdote-reader-mode';
var BODY_INDEX_CLASS = 'anecdote-index-mode';
var HTML_CLASS = 'anecdote-reader-mode-html';
var DECK_ID = 'anecdote-control-deck';
var DEFAULT_LANG = 'ko';
var initializedRoots = new WeakSet();
var activeViewer = null;
var booted = false;
var FALLBACK_TIMELINE = {
CrimsonLeather: {
prologue: [
{ time: '2050.06.02 04:30', location: '키프로스, 아크데니즈 기지', note: '도착 및 장비 분산 완료', targetId: 'narration-highlight1' },
{ time: '11:10 - 13:55', location: '하마(Hama), 시리아', note: '화물기 탑승, 무장 최소화', targetId: 'narration-highlight2' },
{ time: '16:20 -', location: '지상 이동 개시', note: '', targetId: 'narration-highlight3' },
{ time: '추가예정', location: '요르단 북부 국경·이라크 국경 지대', note: '지상 이동', targetId: 'narration-highlight4' },
{ time: '-', location: '-', note: '', hidden: true },
{ time: '2050.06.04 21:50 - 22:15', location: '제6 군단 작전 경계선 <br> 군단 기술지원 경유소', note: '' }
]
}
};
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function escapeAttr(value) {
return escapeHtml(value).replace(/`/g, '`');
}
function normalizeSegment(value) {
return String(value || '')
.replace(/\s+/g, '')
.replace(/[^A-Za-z0-9_.-]/g, '');
}
function normalizePageName(value) {
return String(value || '')
.split('?')[0]
.replace(/^\/index\.php\//, '')
.replace(/_/g, ' ')
.trim();
}
function isAnecdoteNamespace() {
var pageName = String(mw.config.get('wgPageName') || '');
if (!pageName && window.location && window.location.pathname) {
try {
pageName = decodeURIComponent(window.location.pathname.replace(/^\/index\.php\//, ''));
} catch (err) {
pageName = window.location.pathname.replace(/^\/index\.php\//, '');
}
}
pageName = pageName.replace(/_/g, ' ');
return pageName.indexOf('Anecdote:') === 0 || pageName.indexOf('에넥도트:') === 0;
}
function rawUrl(title, ctype) {
return mw.util.getUrl(title, {
action: 'raw',
ctype: ctype || 'application/json'
});
}
function fetchJson(title) {
return fetch(rawUrl(title, 'application/json'), { credentials: 'same-origin' }).then(function (res) {
if (!res.ok) throw new Error(title + ' HTTP ' + res.status);
return res.text();
}).then(function (text) {
if (!text || !text.trim()) return {};
try {
return JSON.parse(text);
} catch (err) {
err.message = title + ' JSON parse failed: ' + err.message;
throw err;
}
});
}
function buildDataTitle(meta) {
if (meta.dataTitle) return meta.dataTitle;
return 'MediaWiki:AnecdoteData.' + normalizeSegment(meta.work) + '.' + normalizeSegment(meta.episode) + '.' + normalizeSegment(meta.lang) + '.json';
}
function buildIndexTitle(meta) {
if (meta.indexTitle) return meta.indexTitle;
return 'MediaWiki:AnecdoteData.' + normalizeSegment(meta.work) + '.index.json';
}
function deriveFromPage() {
var title = normalizePageName(mw.config.get('wgTitle') || '');
var parts = title.split('/');
var workLabel = parts[0] || '';
var episodeLabel = parts.slice(1).join('/') || '';
return {
work: normalizeSegment(workLabel),
episode: normalizeSegment(episodeLabel),
workLabel: workLabel,
episodeLabel: episodeLabel
};
}
function readRootConfig(root) {
var derived = deriveFromPage();
var work = root.getAttribute('data-work') || derived.work;
var episode = root.getAttribute('data-episode') || derived.episode;
var lang = root.getAttribute('data-lang') || (typeof window.getCurrentLang === 'function' ? window.getCurrentLang() : DEFAULT_LANG);
return {
work: work,
episode: episode,
mode: root.getAttribute('data-mode') || root.getAttribute('data-view') || '',
lang: lang || DEFAULT_LANG,
workLabel: root.getAttribute('data-work-title') || derived.workLabel || work,
episodeLabel: root.getAttribute('data-episode-title') || derived.episodeLabel || episode,
indexTitle: root.getAttribute('data-index-title') || root.getAttribute('data-index') || '',
dataTitle: root.getAttribute('data-data-title') || root.getAttribute('data-source-title') || root.getAttribute('data-source') || '',
returnTitle: root.getAttribute('data-return-title') || '',
returnHref: root.getAttribute('data-return') || '',
autoRoot: root.getAttribute('data-auto-root') === 'true'
};
}
function ensureNamespaceRootIfNeeded() {
if (!isAnecdoteNamespace()) return [];
var existing = Array.prototype.slice.call(document.querySelectorAll(ROOT_SELECTOR));
if (existing.length) return existing;
var parser = document.querySelector('.liberty-content-main .mw-parser-output') || document.querySelector('.mw-parser-output');
if (!parser) return [];
var root = document.createElement('div');
root.className = ROOT_SELECTOR.slice(1);
root.setAttribute('data-auto-root', 'true');
parser.insertBefore(root, parser.firstChild || null);
return [root];
}
function isIndexMode(config) {
var mode = String(config && config.mode ? config.mode : '').toLowerCase();
return mode === 'index' || mode === 'work' || mode === 'hub' || !(config && config.episode);
}
function setWorkIndexMode(config) {
document.documentElement.classList.remove(HTML_CLASS);
document.body.classList.remove(BODY_CLASS);
document.body.classList.add(BODY_INDEX_CLASS);
document.body.setAttribute('data-anecdote-work', config && config.work ? config.work : '');
document.body.setAttribute('data-anecdote-episode', '');
document.body.setAttribute('data-anecdote-mode', 'index');
document.body.setAttribute('data-anecdote-lang', config && config.lang ? config.lang : DEFAULT_LANG);
removeDeck();
}
function setReaderMode(config) {
document.documentElement.classList.add(HTML_CLASS);
document.body.classList.remove(BODY_INDEX_CLASS);
document.body.classList.add(BODY_CLASS);
document.body.setAttribute('data-anecdote-work', config && config.work ? config.work : '');
document.body.setAttribute('data-anecdote-episode', config && config.episode ? config.episode : '');
document.body.setAttribute('data-anecdote-mode', 'episode');
document.body.setAttribute('data-anecdote-lang', config && config.lang ? config.lang : DEFAULT_LANG);
}
function setModeForConfig(config) {
if (isIndexMode(config)) {
setWorkIndexMode(config || {});
} else {
setReaderMode(config || {});
}
}
function destroyActiveViewer() {
if (activeViewer && typeof activeViewer.destroy === 'function') {
activeViewer.destroy();
}
activeViewer = null;
}
function removeDeck() {
var deck = document.getElementById(DECK_ID);
if (deck && deck.parentNode) deck.parentNode.removeChild(deck);
}
function clearReaderMode() {
destroyActiveViewer();
document.documentElement.classList.remove(HTML_CLASS);
document.body.classList.remove(BODY_CLASS);
document.body.classList.remove(BODY_INDEX_CLASS);
document.body.removeAttribute('data-anecdote-work');
document.body.removeAttribute('data-anecdote-episode');
document.body.removeAttribute('data-anecdote-mode');
document.body.removeAttribute('data-anecdote-lang');
removeDeck();
booted = false;
}
function renderSystem(root, config, type, title, text, code) {
setModeForConfig(config || {});
root.innerHTML = '' +
'<section class="anecdote-viewer is-' + escapeAttr(type || 'error') + '">' +
'<div class="anecdote-system-panel">' +
'<div class="anecdote-system-title">' + escapeHtml(title || 'ANECDOTE VIEWER') + '</div>' +
'<div class="anecdote-system-text">' + escapeHtml(text || '') + '</div>' +
(code ? '<pre class="anecdote-system-code">' + escapeHtml(code) + '</pre>' : '') +
'</div>' +
'</section>';
}
function getEntries(data) {
if (!data || typeof data !== 'object') return [];
return data.entries || data.scenes || data.lines || [];
}
function getCharacters(data) {
return data && data.characters && typeof data.characters === 'object' ? data.characters : {};
}
function getCharacter(data, id) {
var characters = getCharacters(data);
var key = String(id || '');
return characters[key] || characters[key.toLowerCase()] || null;
}
function getEntryType(entry) {
return String(entry && entry.type ? entry.type : 'narration').toLowerCase();
}
function isAbsoluteAsset(value) {
var v = String(value || '').trim();
return /^(https?:)?\/\//i.test(v) || /^data:/i.test(v) || v.indexOf('/') === 0;
}
function normalizeAssetName(value) {
return String(value || '')
.replace(/^\.\//, '')
.replace(/^images\//, '')
.trim();
}
function resolveAsset(data, value) {
var raw = String(value || '').trim();
var assets = data && data.assets && typeof data.assets === 'object' ? data.assets : {};
var fileName;
if (!raw) return '';
if (isAbsoluteAsset(raw)) return raw;
fileName = normalizeAssetName(raw);
if (raw.indexOf('file:') === 0) {
fileName = normalizeAssetName(raw.slice(5));
return mw.util.getUrl('특수:Redirect/file/' + fileName);
}
if (assets.fileMode === 'mediawiki' || assets.mode === 'mediawiki') {
return mw.util.getUrl('특수:Redirect/file/' + fileName);
}
if (assets.base) return String(assets.base) + fileName;
return raw;
}
function resolveHtmlAssets(data, html) {
var wrap = document.createElement('div');
wrap.innerHTML = String(html == null ? '' : html);
Array.prototype.forEach.call(wrap.querySelectorAll('img[src]'), function (img) {
img.setAttribute('src', resolveAsset(data, img.getAttribute('src')));
});
Array.prototype.forEach.call(wrap.querySelectorAll('object[data]'), function (obj) {
obj.setAttribute('data', resolveAsset(data, obj.getAttribute('data')));
});
return wrap.innerHTML;
}
function formatText(value) {
if (value == null) return '';
return escapeHtml(value)
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.split(/\n{2,}/)
.map(function (paragraph) {
return paragraph.replace(/\n/g, '<br>');
})
.join('<br>');
}
function formatSpeakerName(name) {
var raw = String(name || '').trim();
var match = raw.match(/^(.*?)(\s+GRIMM\s+[0-9-]+)$/i);
if (!match) return escapeHtml(raw);
return escapeHtml(match[1].trim()) + ' <small>' + escapeHtml(match[2].trim()) + '</small>';
}
function getEntrySide(data, entry) {
if (!entry) return '';
var id = entry.character || entry.speaker || entry.characterId || '';
var character = getCharacter(data, id);
return entry.side || (character && character.side) || '';
}
function getEntrySpeaker(data, entry) {
if (!entry) return '';
var id = entry.character || entry.speaker || entry.characterId || '';
var character = getCharacter(data, id);
return entry.name || (character && character.name) || id || '';
}
function getEntryAvatar(data, entry) {
if (!entry) return '';
var id = entry.character || entry.speaker || entry.characterId || '';
var character = getCharacter(data, id);
return resolveAsset(data, entry.avatar || entry.portrait || (character && (character.avatar || character.portrait)) || '');
}
function getEntryHtml(data, entry) {
if (!entry) return '';
if (entry.html != null) return resolveHtmlAssets(data, String(entry.html));
if (entry.text != null) return formatText(entry.text);
if (entry.plain != null) return formatText(entry.plain);
return '';
}
function entryClasses(entry) {
var classes = [];
var type = getEntryType(entry);
classes.push('anecdote-narration');
classes.push('is-' + type.replace(/[^A-Za-z0-9_-]/g, ''));
if (entry && Array.isArray(entry.classes)) {
entry.classes.forEach(function (item) {
var value = String(item || '').replace(/[^A-Za-z0-9_-]/g, '');
if (value) classes.push(value);
});
}
if (entry && entry.tone) classes.push('tone-' + String(entry.tone).replace(/[^A-Za-z0-9_-]/g, ''));
return classes.join(' ');
}
function buildEntryHtml(data, entry) {
var type = getEntryType(entry);
var side;
var tone;
var avatar;
var speaker;
var bubble;
if (type === 'chat') {
side = getEntrySide(data, entry) || 'right';
tone = String(entry.tone || 'friendly').replace(/[^A-Za-z0-9_-]/g, '') || 'friendly';
avatar = getEntryAvatar(data, entry);
speaker = getEntrySpeaker(data, entry) || '???';
bubble = getEntryHtml(data, entry);
return '' +
'<span class="chat-wrapper ' + escapeAttr(side) + ' fade-in-up">' +
(avatar ? '<img src="' + escapeAttr(avatar) + '" class="chat-avatar left" alt="">' : '<span class="chat-avatar left" aria-hidden="true"></span>') +
'<span class="chat-block ' + escapeAttr(side) + '">' +
'<span class="chat-name ' + escapeAttr(side) + ' ' + escapeAttr(tone) + '">' + formatSpeakerName(speaker) + '</span>' +
'<span class="chat-bubble ' + escapeAttr(side) + '">' + bubble + '</span>' +
'</span>' +
'</span>';
}
return getEntryHtml(data, entry);
}
function getBriefing(entries) {
if (!entries.length) return null;
return getEntryType(entries[0]) === 'briefing' ? entries[0] : null;
}
function getPlayableEntries(entries) {
var briefing = getBriefing(entries);
return briefing ? entries.slice(1) : entries.slice();
}
function findEpisodeIndex(indexData, episodeId) {
var episodes = indexData && Array.isArray(indexData.episodes) ? indexData.episodes : [];
var id = String(episodeId || '');
for (var i = 0; i < episodes.length; i += 1) {
if (String(episodes[i].id || '') === id) return i;
}
return -1;
}
function buildEpisodeHref(episode) {
if (!episode) return '';
if (episode.href) return episode.href;
if (episode.pageTitle) return mw.util.getUrl(episode.pageTitle);
if (episode.page) return mw.util.getUrl(episode.page);
return '';
}
function getEpisodeNav(indexData, episodeId) {
var episodes = indexData && Array.isArray(indexData.episodes) ? indexData.episodes : [];
var index = findEpisodeIndex(indexData, episodeId);
if (index < 0) return { previous: null, current: null, next: null };
return {
previous: episodes[index - 1] || null,
current: episodes[index] || null,
next: episodes[index + 1] || null
};
}
function getEpisodeById(indexData, episodeId) {
var episodes = indexData && Array.isArray(indexData.episodes) ? indexData.episodes : [];
var id = String(episodeId || '');
for (var i = 0; i < episodes.length; i += 1) {
if (String(episodes[i].id || '') === id) return episodes[i];
}
return null;
}
function buildIndexItemHref(indexData, item) {
var episode;
if (!item || item.disabled || item.status === 'disabled') return '';
if (item.href) return item.href;
if (item.pageTitle) return mw.util.getUrl(item.pageTitle);
if (item.page) return mw.util.getUrl(item.page);
if (item.episode) {
episode = getEpisodeById(indexData, item.episode);
return buildEpisodeHref(episode);
}
return '';
}
function getIndexParagraphs(indexData) {
if (indexData && Array.isArray(indexData.introParagraphs)) return indexData.introParagraphs.slice();
if (indexData && Array.isArray(indexData.summaryParagraphs)) return indexData.summaryParagraphs.slice();
if (indexData && indexData.summary) return [indexData.summary];
return [];
}
function getIndexSections(indexData) {
var episodes;
if (indexData && Array.isArray(indexData.sections)) return indexData.sections.slice();
episodes = indexData && Array.isArray(indexData.episodes) ? indexData.episodes : [];
return [{
title: 'EPISODES',
items: episodes.map(function (episode) {
return { title: episode.title || episode.id, episode: episode.id };
})
}];
}
function buildWorkIndexHtml(config, indexData) {
var title = indexData.displayTitle || indexData.title || config.workLabel || config.work;
var author = indexData.author || 'Provided by Team CLBI';
var tags = Array.isArray(indexData.tags) ? indexData.tags : [];
var paragraphs = getIndexParagraphs(indexData);
var sections = getIndexSections(indexData);
var firstEpisode = indexData.firstEpisode || (indexData.episodes && indexData.episodes[0] && indexData.episodes[0].id) || '';
var firstHref = buildIndexItemHref(indexData, { episode: firstEpisode });
var briefingHref = indexData.briefingHref || '';
var cover = resolveAsset(indexData, indexData.cover || '');
var episodes = Array.isArray(indexData.episodes) ? indexData.episodes : [];
var readyCount = episodes.filter(function (episode) {
return !!buildEpisodeHref(episode) && episode.status !== 'disabled';
}).length;
var dataCode = 'AnecdoteData.' + normalizeSegment(config.work) + '.index.json';
var sectionHtml;
sectionHtml = sections.map(function (section, sectionIndex) {
var items = Array.isArray(section.items) ? section.items : [];
var rows = items.map(function (item, itemIndex) {
var href = buildIndexItemHref(indexData, item);
var rowTag = href ? 'a' : 'span';
var statusRaw = item.status || (href ? 'ready' : 'locked');
var status = String(statusRaw || '').toUpperCase();
var title = item.title || item.label || '';
var number = String(sectionIndex + 1) + '-' + String(itemIndex + 1).padStart(2, '0');
var rowClass = 'anecdote-index-entry-row ' + (href ? 'is-open' : 'is-locked');
var attr = href ? ' href="' + escapeAttr(href) + '"' : '';
if (!href && status === 'DISABLED') status = 'LOCKED';
return '' +
'<' + rowTag + attr + ' class="' + rowClass + '">' +
'<span class="anecdote-index-entry-no">' + escapeHtml(number) + '</span>' +
'<span class="anecdote-index-entry-title">' + escapeHtml(title) + '</span>' +
'<span class="anecdote-index-entry-status">' + escapeHtml(status) + '</span>' +
'<span class="anecdote-index-entry-arrow">›</span>' +
'</' + rowTag + '>';
}).join('');
return '' +
'<section class="anecdote-index-module anecdote-index-ledger-group">' +
'<div class="anecdote-index-module-titlebar">' +
'<span>' + escapeHtml(section.title || 'ENTRY BLOCK') + '</span>' +
'<span>' + escapeHtml(String(items.length)) + ' ITEMS</span>' +
'</div>' +
'<div class="anecdote-index-ledger-list">' + rows + '</div>' +
'</section>';
}).join('');
return '' +
'<section class="anecdote-viewer anecdote-work-index is-ready" data-work="' + escapeAttr(config.work) + '">' +
'<div class="anecdote-index-record">' +
'<div class="anecdote-index-record-titlebar">' +
'<span>ANECDOTE ARCHIVE RECORD</span>' +
'<span>' + escapeHtml(normalizeSegment(config.work) || 'UNKNOWN') + ' / ' + escapeHtml((config.lang || DEFAULT_LANG).toUpperCase()) + '</span>' +
'</div>' +
'<div class="anecdote-index-record-body">' +
'<aside class="anecdote-index-cover-bay">' +
'<div class="anecdote-index-cover-frame">' +
(cover ? '<img src="' + escapeAttr(cover) + '" alt="표지 이미지">' : '<div class="anecdote-index-cover-placeholder">NO COVER</div>') +
'</div>' +
'<div class="anecdote-index-cover-meta">' +
'<div><span>VISUAL</span><strong>COVER REFERENCE</strong></div>' +
'<div><span>SOURCE</span><strong>' + escapeHtml(dataCode) + '</strong></div>' +
'</div>' +
'</aside>' +
'<div class="anecdote-index-main">' +
'<div class="anecdote-index-top-grid">' +
'<div class="anecdote-index-id-panel">' +
'<div class="anecdote-index-kicker">RECORD TITLE</div>' +
'<h1 class="anecdote-index-title">' + escapeHtml(title) + '</h1>' +
'<p class="anecdote-index-author">' + escapeHtml(author) + '</p>' +
'<div class="anecdote-index-tags">' + tags.map(function (tag) { return '<span>#' + escapeHtml(String(tag).replace(/^#/, '')) + '</span>'; }).join('') + '</div>' +
'</div>' +
'<div class="anecdote-index-status-panel">' +
'<div class="anecdote-index-status-row"><span>WORK ID</span><strong>' + escapeHtml(config.work || '-') + '</strong></div>' +
'<div class="anecdote-index-status-row"><span>LANG</span><strong>' + escapeHtml((config.lang || DEFAULT_LANG).toUpperCase()) + '</strong></div>' +
'<div class="anecdote-index-status-row"><span>ENTRIES</span><strong>' + escapeHtml(String(readyCount)) + ' / ' + escapeHtml(String(episodes.length)) + '</strong></div>' +
'<div class="anecdote-index-status-row"><span>FORMAT</span><strong>AnecdoteIndex/v1</strong></div>' +
'</div>' +
'</div>' +
'<div class="anecdote-index-action-deck">' +
'<a href="' + escapeAttr(firstHref || '#') + '" class="anecdote-index-action-button is-primary ' + (firstHref ? '' : 'is-disabled') + '">첫 화 보기<span>›</span></a>' +
'<button type="button" class="anecdote-index-action-button" data-anecdote-open-tutorial>작동 안내<span>›</span></button>' +
(briefingHref ? '<a href="' + escapeAttr(briefingHref) + '" class="anecdote-index-action-button">작전 브리핑<span>›</span></a>' : '<span class="anecdote-index-action-button is-disabled">작전 브리핑<span>›</span></span>') +
'</div>' +
'<div class="anecdote-index-synopsis-panel">' +
'<div class="anecdote-index-module-titlebar">' +
'<span>SYNOPSIS BUFFER</span>' +
'<span>OPEN TEXT</span>' +
'</div>' +
'<div class="anecdote-index-synopsis-body">' +
'<div class="anecdote-index-fade-wrapper" data-anecdote-readmore-box>' +
'<div class="anecdote-index-fade-text">' + paragraphs.map(function (paragraph) { return '<p>' + formatText(paragraph) + '</p>'; }).join('') + '</div>' +
'</div>' +
'<button type="button" class="anecdote-index-read-more" data-anecdote-readmore>더보기 ›</button>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="anecdote-index-lower-grid">' +
'<div class="anecdote-index-ledger-stack">' + sectionHtml + '</div>' +
'<aside class="anecdote-index-module anecdote-index-notes">' +
'<div class="anecdote-index-module-titlebar">' +
'<span>CONTROL NOTES</span>' +
'<span>VIEWER</span>' +
'</div>' +
'<div class="anecdote-index-note-list">' +
'<div><span>SPACE</span><strong>회차 감상 중 다음 문단 진행</strong></div>' +
'<div><span>T</span><strong>작동 안내 다시 열기</strong></div>' +
'<div><span>INDEX</span><strong>감상 모드에서 작품 허브로 복귀</strong></div>' +
'</div>' +
'</aside>' +
'</div>' +
'<div class="anecdote-index-tutorial-overlay" data-anecdote-tutorial style="display:none;">' +
'<div class="anecdote-index-tutorial-popup">' +
'<div class="anecdote-index-tutorial-titlebar"><span>ANECDOTE VIEWER GUIDE</span><span>INPUT</span></div>' +
'<button type="button" class="anecdote-index-close-btn" data-anecdote-tutorial-close>×</button>' +
'<div class="anecdote-index-tutorial-body">' +
'<p><strong>회차 화면에서는 키보드 <code>Space</code> 키로 다음 문단을 진행합니다.</strong></p>' +
'<p>화면 하단의 ANECDOTE 컨트롤덱에서 INDEX, PREV EP, AUTO, ALL, NEXT EP 기능을 사용할 수 있습니다.</p>' +
'<label><input type="checkbox" data-anecdote-tutorial-dont-show> 다시 보지 않기</label>' +
'</div>' +
'</div>' +
'</div>' +
'</section>';
}
function createWorkIndexController(root, config, indexData) {
var viewer;
var readBox;
var readMore;
var overlay;
var close;
var checkbox;
var keyHandler;
var storageKey = 'hideAnecdoteTutorial';
root.innerHTML = buildWorkIndexHtml(config, indexData || {});
viewer = root.querySelector('.anecdote-work-index');
if (viewer) {
var bg = resolveAsset(indexData || {}, (indexData && (indexData.background || indexData.heroBackground)) || '');
if (bg) viewer.style.setProperty('--anecdote-index-bg', 'url("' + bg.replace(/"/g, '\\"') + '")');
}
readBox = root.querySelector('[data-anecdote-readmore-box]');
readMore = root.querySelector('[data-anecdote-readmore]');
if (readBox && readMore) {
readMore.addEventListener('click', function () {
readBox.classList.add('expanded');
readMore.classList.add('hidden');
});
}
overlay = root.querySelector('[data-anecdote-tutorial]');
close = root.querySelector('[data-anecdote-tutorial-close]');
checkbox = root.querySelector('[data-anecdote-tutorial-dont-show]');
function showTutorial() {
if (overlay) overlay.style.display = 'flex';
}
function closeTutorial() {
if (checkbox && checkbox.checked) localStorage.setItem(storageKey, 'true');
if (overlay) overlay.style.display = 'none';
}
if (close) close.addEventListener('click', closeTutorial);
root.querySelectorAll('[data-anecdote-open-tutorial]').forEach(function (button) {
button.addEventListener('click', function () {
localStorage.removeItem(storageKey);
showTutorial();
});
});
if (overlay && localStorage.getItem(storageKey) !== 'true') showTutorial();
keyHandler = function (event) {
if (!document.body.classList.contains(BODY_INDEX_CLASS)) return;
if (event.key === 't' || event.key === 'T') {
localStorage.removeItem(storageKey);
showTutorial();
}
};
document.addEventListener('keydown', keyHandler);
return {
root: root,
config: config,
indexData: indexData || {},
next: function () {},
revealAll: function () {},
toggleAuto: function () {},
stopAuto: function () {},
isAutoPlaying: function () { return false; },
isTyping: function () { return false; },
skip: function () {},
destroy: function () {
document.removeEventListener('keydown', keyHandler);
}
};
}
function initIndexRoot(root, config) {
setWorkIndexMode(config);
removeDeck();
activeViewer = null;
renderSystem(root, config, 'loading', 'ANECDOTE INDEX', '작품 정보를 불러오는 중입니다.', buildIndexTitle(config));
fetchJson(buildIndexTitle(config)).then(function (indexData) {
if (!indexData || !Array.isArray(indexData.episodes)) {
renderSystem(root, config, 'error', 'ANECDOTE INDEX EMPTY', '작품 인덱스에 episodes 배열이 없습니다.', buildIndexTitle(config));
return;
}
activeViewer = createWorkIndexController(root, config, indexData);
}).catch(function (err) {
renderSystem(root, config, 'error', 'ANECDOTE INDEX LOAD FAILED', String(err && err.message ? err.message : err), buildIndexTitle(config));
});
}
function buildReturnHref(config, indexData) {
if (config.returnHref) return config.returnHref;
if (config.returnTitle) return mw.util.getUrl(config.returnTitle);
if (indexData && indexData.pageTitle) return mw.util.getUrl(indexData.pageTitle);
if (config.workLabel) return mw.util.getUrl('Anecdote:' + config.workLabel);
return mw.util.getUrl('대문');
}
function getTimelineData(config, episodeData) {
if (Array.isArray(episodeData.timeline)) return episodeData.timeline.slice();
if (FALLBACK_TIMELINE[config.work] && FALLBACK_TIMELINE[config.work][config.episode]) {
return FALLBACK_TIMELINE[config.work][config.episode].slice();
}
return [];
}
function ensureDeck() {
var deck = document.getElementById(DECK_ID);
if (!deck) {
deck = document.createElement('div');
deck.id = DECK_ID;
document.body.appendChild(deck);
}
return deck;
}
function updateDeck(viewer) {
var deck = ensureDeck();
if (!viewer) {
deck.innerHTML = '';
return;
}
var nav = getEpisodeNav(viewer.indexData, viewer.config.episode);
var previousHref = buildEpisodeHref(nav.previous);
var nextHref = buildEpisodeHref(nav.next);
var returnHref = buildReturnHref(viewer.config, viewer.indexData);
var autoText = viewer.isAutoPlaying() ? 'AUTO ON' : 'AUTO';
deck.innerHTML = '' +
'<div class="anecdote-control-frame">' +
'<div class="anecdote-control-brand">ANECDOTE</div>' +
'<a class="anecdote-link-button" href="' + escapeAttr(returnHref) + '">INDEX</a>' +
'<a class="anecdote-link-button ' + (previousHref ? '' : 'is-disabled') + '" href="' + escapeAttr(previousHref || '#') + '">PREV EP</a>' +
'<button type="button" class="anecdote-button ' + (viewer.isAutoPlaying() ? 'is-active' : '') + '" data-deck-action="auto">' + autoText + '</button>' +
'<button type="button" class="anecdote-button" data-deck-action="all">ALL</button>' +
'<a class="anecdote-link-button ' + (nextHref ? '' : 'is-disabled') + '" href="' + escapeAttr(nextHref || '#') + '">NEXT EP</a>' +
'</div>';
deck.querySelectorAll('[data-deck-action]').forEach(function (button) {
button.addEventListener('click', function () {
var action = button.getAttribute('data-deck-action');
if (action === 'auto') viewer.toggleAuto();
if (action === 'all') viewer.revealAll();
});
});
}
async function typeHtmlWithTags(el, html, speed, context) {
var tmp = document.createElement('div');
tmp.innerHTML = html;
el.innerHTML = '';
context.isTyping = true;
context.skipTyping = false;
async function processNode(node, parent) {
var span;
var i;
var finalText;
if (node.nodeType === Node.TEXT_NODE) {
if (parent.classList && parent.classList.contains('quick-swap')) {
span = document.createElement('span');
parent.appendChild(span);
i = 0;
finalText = parent.getAttribute('data-final') || node.textContent;
await new Promise(function (resolve) {
var iv = setInterval(function () {
if (context.skipTyping) {
span.textContent += node.textContent.slice(i);
clearInterval(iv);
parent.textContent = finalText;
parent.classList.remove('fade-out');
resolve();
return;
}
if (i < node.textContent.length) {
span.textContent += node.textContent[i++];
} else {
clearInterval(iv);
setTimeout(function () {
parent.classList.add('fade-out');
setTimeout(function () {
parent.textContent = finalText;
parent.classList.remove('fade-out');
}, 50);
}, 300);
resolve();
}
}, speed);
});
return;
}
span = document.createElement('span');
parent.appendChild(span);
i = 0;
await new Promise(function (resolve) {
var iv = setInterval(function () {
if (context.skipTyping) {
span.textContent += node.textContent.slice(i);
clearInterval(iv);
resolve();
return;
}
if (i < node.textContent.length) {
span.textContent += node.textContent[i++];
} else {
clearInterval(iv);
resolve();
}
}, speed);
});
return;
}
if (node.nodeType === Node.ELEMENT_NODE) {
var newEl = node.cloneNode(false);
parent.appendChild(newEl);
for (var child = node.firstChild; child; child = child.nextSibling) {
await processNode(child, newEl);
}
}
}
for (var node = tmp.firstChild; node; node = node.nextSibling) {
await processNode(node, el);
}
context.isTyping = false;
}
function makeShell(root, config, indexData, episodeData) {
var entries = getEntries(episodeData);
var briefing = getBriefing(entries);
var workTitle = episodeData.workTitle || indexData.title || config.workLabel || config.work;
var episodeTitle = episodeData.title || config.episodeLabel || config.episode;
root.innerHTML = '' +
'<section class="anecdote-viewer is-ready" data-work="' + escapeAttr(config.work) + '" data-episode="' + escapeAttr(config.episode) + '">' +
'<div class="anecdote-original-frame">' +
'<aside class="timeline-sidebar anecdote-timeline-sidebar" data-anecdote-timeline></aside>' +
'<div class="anecdote-original-scroll" data-anecdote-scroll>' +
'<button type="button" class="anecdote-back-button" data-anecdote-action="back">← 뒤로가기</button>' +
'<div class="anecdote-floating-title" data-anecdote-title>' + escapeHtml(episodeTitle) + '</div>' +
'<div class="anecdote-narration-box" data-anecdote-box>' +
'<p class="anecdote-prologueinfo" data-anecdote-briefing></p>' +
'<div class="anecdote-flow" data-anecdote-flow></div>' +
'<div class="anecdote-narration-hint" data-anecdote-hint>' +
'<span class="anecdote-key-box">Space</span> 다음으로' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'</section>';
if (!briefing) {
var briefingEl = root.querySelector('[data-anecdote-briefing]');
if (briefingEl) briefingEl.style.display = 'none';
}
return {
viewerEl: root.querySelector('.anecdote-viewer'),
scrollEl: root.querySelector('[data-anecdote-scroll]'),
flowEl: root.querySelector('[data-anecdote-flow]'),
hintEl: root.querySelector('[data-anecdote-hint]'),
briefingEl: root.querySelector('[data-anecdote-briefing]'),
titleEl: root.querySelector('[data-anecdote-title]'),
timelineEl: root.querySelector('[data-anecdote-timeline]'),
workTitle: workTitle,
episodeTitle: episodeTitle
};
}
function createViewerController(root, config, indexData, episodeData) {
var allEntries = getEntries(episodeData);
var briefing = getBriefing(allEntries);
var entries = getPlayableEntries(allEntries);
var shell = makeShell(root, config, indexData, episodeData);
var timelineData = getTimelineData(config, episodeData);
var state = {
root: root,
config: config,
indexData: indexData || {},
episodeData: episodeData || {},
entries: entries,
currentIndex: 0,
timelineIndex: 0,
isTyping: false,
skipTyping: false,
typingKeyCount: 0,
autoPlay: false,
autoTimer: null,
destroyed: false
};
function scrollToBottom() {
if (!shell.scrollEl) return;
shell.scrollEl.scrollTo({ top: shell.scrollEl.scrollHeight, behavior: 'smooth' });
}
function updateDeckForThis() {
updateDeck(controller);
}
function hideIntroIfFirst() {
if (state.currentIndex !== 0) return;
if (shell.titleEl) {
shell.titleEl.classList.add('fade-out');
setTimeout(function () { shell.titleEl.style.display = 'none'; }, 600);
}
if (shell.briefingEl) {
shell.briefingEl.classList.add('fade-out');
setTimeout(function () { shell.briefingEl.style.display = 'none'; }, 600);
}
}
function highlightNarrationById(className) {
Array.prototype.forEach.call(root.querySelectorAll('.highlighted, .anecdote-highlighted'), function (el) {
el.classList.remove('highlighted');
el.classList.remove('anecdote-highlighted');
});
if (className) {
Array.prototype.forEach.call(root.querySelectorAll('.' + className), function (el) {
el.classList.add('highlighted');
el.classList.add('anecdote-highlighted');
});
}
}
function addTimeline(entry, index) {
if (!shell.timelineEl || !entry || entry.hidden) return;
var item = document.createElement('div');
item.className = 'timeline-item timeline-' + index;
var dot = document.createElement('div');
dot.className = 'timeline-dot';
var content = document.createElement('div');
content.className = 'timeline-content';
content.innerHTML = '<strong>' + escapeHtml(entry.time || '') + '</strong><br>' +
String(entry.location || '') + '<br>' + String(entry.note || '');
item.addEventListener('mouseenter', function () {
highlightNarrationById(entry.targetId);
});
item.addEventListener('mouseleave', function () {
highlightNarrationById(null);
});
item.appendChild(dot);
item.appendChild(content);
shell.timelineEl.appendChild(item);
}
function addNextTimeline() {
while (state.timelineIndex < timelineData.length) {
var entry = timelineData[state.timelineIndex];
var index = state.timelineIndex;
state.timelineIndex += 1;
if (!entry.hidden) {
addTimeline(entry, index);
break;
}
}
}
function createEntryElement(entry) {
var p = document.createElement('p');
p.className = entryClasses(entry);
p.setAttribute('data-anecdote-entry-id', entry && entry.id ? entry.id : '');
return p;
}
async function showNext() {
if (state.destroyed) return;
if (state.currentIndex >= state.entries.length) {
stopAuto();
if (shell.hintEl) {
shell.hintEl.classList.add('reappear');
shell.hintEl.innerHTML = '<span class="anecdote-key-box">End</span> 끝';
}
return;
}
hideIntroIfFirst();
scrollToBottom();
var entry = state.entries[state.currentIndex];
var p = createEntryElement(entry);
var html = buildEntryHtml(state.episodeData, entry);
if (shell.flowEl) shell.flowEl.appendChild(p);
if (shell.hintEl) shell.hintEl.classList.add('hidden');
await typeHtmlWithTags(p, html, parseInt(state.episodeData.typeSpeed || 30, 10), state);
addNextTimeline();
if (shell.hintEl) shell.hintEl.classList.remove('hidden');
state.currentIndex += 1;
scrollToBottom();
}
function revealAll() {
var entry;
var p;
var html;
stopAuto();
state.skipTyping = true;
hideIntroIfFirst();
while (state.currentIndex < state.entries.length) {
entry = state.entries[state.currentIndex];
p = createEntryElement(entry);
html = buildEntryHtml(state.episodeData, entry);
p.innerHTML = html;
if (shell.flowEl) shell.flowEl.appendChild(p);
addNextTimeline();
state.currentIndex += 1;
}
if (shell.hintEl) {
shell.hintEl.classList.remove('hidden');
shell.hintEl.classList.add('reappear');
shell.hintEl.innerHTML = '<span class="anecdote-key-box">End</span> 끝';
}
scrollToBottom();
}
function stopAuto() {
if (state.autoTimer) {
clearInterval(state.autoTimer);
state.autoTimer = null;
}
state.autoPlay = false;
updateDeckForThis();
}
function toggleAuto() {
if (state.autoTimer) {
stopAuto();
return;
}
state.autoPlay = true;
if (state.currentIndex === 0 && !state.isTyping) showNext();
state.autoTimer = setInterval(function () {
if (!state.isTyping && !document.hidden) showNext();
}, parseInt(state.episodeData.autoInterval || 1000, 10));
updateDeckForThis();
}
function handleAdvanceFromHint() {
if (state.isTyping) {
state.skipTyping = true;
} else {
showNext();
}
}
if (shell.hintEl) {
shell.hintEl.addEventListener('click', handleAdvanceFromHint);
}
root.addEventListener('click', function (event) {
var actionEl = event.target.closest('[data-anecdote-action]');
if (!actionEl) return;
var action = actionEl.getAttribute('data-anecdote-action');
if (action === 'back') window.history.back();
});
if (briefing && shell.briefingEl) {
typeHtmlWithTags(shell.briefingEl, getEntryHtml(episodeData, briefing), 15, state).then(function () {
if (state.destroyed) return;
if (shell.hintEl) shell.hintEl.classList.remove('hidden');
});
}
var controller = {
root: root,
config: config,
indexData: indexData || {},
episodeData: episodeData || {},
next: showNext,
revealAll: revealAll,
toggleAuto: toggleAuto,
stopAuto: stopAuto,
isAutoPlaying: function () { return !!state.autoTimer; },
isTyping: function () { return !!state.isTyping; },
skip: function () { state.skipTyping = true; },
destroy: function () {
state.destroyed = true;
stopAuto();
}
};
updateDeck(controller);
return controller;
}
function bindKeyboard() {
if (window.AnecdoteViewerKeyboardBound) return;
window.AnecdoteViewerKeyboardBound = true;
document.addEventListener('keydown', function (event) {
if (!document.body.classList.contains(BODY_CLASS) || document.body.classList.contains(BODY_INDEX_CLASS) || !activeViewer) return;
if (event.target && /input|textarea|select/i.test(event.target.tagName)) return;
if (event.code === 'Space') {
event.preventDefault();
var keyBoxes = document.querySelectorAll('.anecdote-key-box');
keyBoxes.forEach(function (box) { box.classList.add('pressed'); });
setTimeout(function () { keyBoxes.forEach(function (box) { box.classList.remove('pressed'); }); }, 90);
if (activeViewer.isTyping()) {
activeViewer.skip();
} else {
activeViewer.next();
}
} else if (event.code === 'KeyP') {
event.preventDefault();
activeViewer.toggleAuto();
} else if (event.code === 'KeyL') {
event.preventDefault();
activeViewer.revealAll();
}
});
}
function initRoot(root) {
if (!root || initializedRoots.has(root)) return;
if (activeViewer && activeViewer.root && activeViewer.root !== root) {
destroyActiveViewer();
}
initializedRoots.add(root);
var config = readRootConfig(root);
if (isIndexMode(config)) {
setWorkIndexMode(config);
} else {
setReaderMode(config);
ensureDeck();
}
if (!config.work) {
renderSystem(
root,
config,
'config-error',
'ANECDOTE CONFIG REQUIRED',
'AnecdoteViewer 마운트에 work가 필요합니다.',
'{{AnecdoteViewer\n|work=CrimsonLeather\n|mode=index\n|lang=ko\n}}'
);
updateDeck(null);
return;
}
if (isIndexMode(config)) {
initIndexRoot(root, config);
return;
}
activeViewer = null;
renderSystem(root, config, 'loading', 'ANECDOTE VIEWER', '데이터를 불러오는 중입니다.', buildDataTitle(config));
Promise.all([
fetchJson(buildIndexTitle(config)).catch(function (err) {
console.warn('Anecdote index load failed:', err);
return { title: config.workLabel || config.work, episodes: [] };
}),
fetchJson(buildDataTitle(config))
]).then(function (result) {
var indexData = result[0] || {};
var episodeData = result[1] || {};
var entries = getEntries(episodeData);
if (!entries.length) {
renderSystem(
root,
config,
'error',
'ANECDOTE DATA EMPTY',
'데이터 파일을 읽었지만 entries 배열이 비어 있습니다.',
buildDataTitle(config)
);
updateDeck(null);
return;
}
activeViewer = createViewerController(root, config, indexData, episodeData);
updateDeck(activeViewer);
bindKeyboard();
}).catch(function (err) {
renderSystem(
root,
config,
'error',
'ANECDOTE DATA LOAD FAILED',
String(err && err.message ? err.message : err),
buildDataTitle(config)
);
updateDeck(null);
});
}
function boot() {
var roots = ensureNamespaceRootIfNeeded();
var found = Array.prototype.slice.call(document.querySelectorAll(ROOT_SELECTOR));
roots.forEach(function (root) {
if (found.indexOf(root) === -1) found.push(root);
});
if (!found.length) {
clearReaderMode();
return;
}
if (activeViewer && activeViewer.root && found.indexOf(activeViewer.root) === -1) {
destroyActiveViewer();
removeDeck();
}
booted = true;
found.forEach(initRoot);
}
$(function () {
boot();
});
mw.hook('wikipage.content').add(function () {
boot();
});
window.AnecdoteViewer = {
boot: boot,
isAnecdoteNamespace: isAnecdoteNamespace,
getActive: function () { return activeViewer; },
isBooted: function () { return booted; }
};
})(mediaWiki, jQuery);