편집 요약 없음 |
편집 요약 없음 |
||
| 1번째 줄: | 1번째 줄: | ||
/* ========================================= | /* ========================================= | ||
COASTLINE: BLACK ICE - AnecdoteViewer | COASTLINE: BLACK ICE - AnecdoteViewer | ||
Original Crimson-Leather flow / CLBI platform shell | |||
========================================= */ | ========================================= */ | ||
| 18번째 줄: | 18번째 줄: | ||
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) { | ||
| 160번째 줄: | 173번째 줄: | ||
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); | ||
} | } | ||
| 238번째 줄: | 249번째 줄: | ||
} | } | ||
if (assets.base) | if (assets.base) return String(assets.base) + fileName; | ||
return raw; | return raw; | ||
} | } | ||
| 258번째 줄: | 266번째 줄: | ||
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번째 줄: | 301번째 줄: | ||
} | } | ||
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(); | |||
} | } | ||
| 360번째 줄: | 397번째 줄: | ||
next: episodes[index + 1] || null | next: episodes[index + 1] || null | ||
}; | }; | ||
} | } | ||
| 385번째 줄: | 405번째 줄: | ||
} | } | ||
function | 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; | |||
return | |||
} | } | ||
function updateDeck(viewer) { | function updateDeck(viewer) { | ||
var deck = | var deck = ensureDeck(); | ||
if (!viewer) { | if (!viewer) { | ||
deck.innerHTML = ''; | deck.innerHTML = ''; | ||
| 451번째 줄: | 434번째 줄: | ||
var nextHref = buildEpisodeHref(nav.next); | var nextHref = buildEpisodeHref(nav.next); | ||
var returnHref = buildReturnHref(viewer.config); | var returnHref = buildReturnHref(viewer.config); | ||
var autoText = viewer.isAutoPlaying() ? 'AUTO ON' : 'AUTO'; | |||
deck.innerHTML = '' + | deck.innerHTML = '' + | ||
| 457번째 줄: | 441번째 줄: | ||
'<a class="anecdote-link-button" href="' + escapeAttr(returnHref) + '">WIKI</a>' + | '<a class="anecdote-link-button" href="' + escapeAttr(returnHref) + '">WIKI</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번째 줄: | 747번째 줄: | ||
clearInterval(state.autoTimer); | clearInterval(state.autoTimer); | ||
state.autoTimer = null; | state.autoTimer = null; | ||
} | } | ||
state.autoPlay = false; | |||
updateDeckForThis(); | |||
} | } | ||
| 639번째 줄: | 757번째 줄: | ||
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; | |||
} | } | ||
| 671번째 줄: | 822번째 줄: | ||
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(); | event.preventDefault(); | ||
activeViewer. | activeViewer.toggleAuto(); | ||
} | } else if (event.code === 'KeyL') { | ||
event.preventDefault(); | |||
activeViewer.revealAll(); | |||
activeViewer. | |||
} | } | ||
}); | }); | ||
2026년 5월 30일 (토) 22:54 판
/* =========================================
COASTLINE: BLACK ICE - AnecdoteViewer
Original Crimson-Leather flow / CLBI platform shell
========================================= */
(function (mw, $) {
'use strict';
if (window.AnecdoteViewerInitialized) return;
window.AnecdoteViewerInitialized = true;
var ROOT_SELECTOR = '.anecdote-viewer-root';
var BODY_CLASS = 'anecdote-reader-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,
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 setReaderMode(config) {
document.documentElement.classList.add(HTML_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-lang', config && config.lang ? config.lang : DEFAULT_LANG);
}
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.removeAttribute('data-anecdote-work');
document.body.removeAttribute('data-anecdote-episode');
document.body.removeAttribute('data-anecdote-lang');
removeDeck();
booted = false;
}
function renderSystem(root, config, type, title, text, code) {
setReaderMode(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 buildReturnHref(config) {
if (config.returnHref) return config.returnHref;
if (config.returnTitle) return mw.util.getUrl(config.returnTitle);
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);
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) + '">WIKI</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) || !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);
setReaderMode(config);
ensureDeck();
if (!config.work || !config.episode) {
renderSystem(
root,
config,
'config-error',
'ANECDOTE CONFIG REQUIRED',
'AnecdoteViewer 마운트에 work와 episode가 필요합니다.',
'{{AnecdoteViewer\n|work=CrimsonLeather\n|episode=prologue\n|lang=ko\n}}'
);
updateDeck(null);
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);