편집 요약 없음 |
편집 요약 없음 |
||
| 205번째 줄: | 205번째 줄: | ||
function getEntryType(entry) { | function getEntryType(entry) { | ||
return String(entry && entry.type ? entry.type : 'narration').toLowerCase(); | 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 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) || ''; | |||
} | } | ||
| 218번째 줄: | 278번째 줄: | ||
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 entry.portrait || (character && character.portrait) || ''; | return resolveAsset(data, entry.portrait || (character && character.portrait) || ''); | ||
} | } | ||
| 225번째 줄: | 285번째 줄: | ||
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 entry.avatar || (character && character.avatar) || getEntryPortrait(data, entry) || ''; | return resolveAsset(data, entry.avatar || (character && character.avatar) || getEntryPortrait(data, entry) || ''); | ||
} | } | ||
| 242번째 줄: | 302번째 줄: | ||
function getEntryHtml(data, entry) { | function getEntryHtml(data, entry) { | ||
if (!entry) return ''; | if (!entry) return ''; | ||
var type = getEntryType(entry); | var type = getEntryType(entry); | ||
| 249번째 줄: | 308번째 줄: | ||
if (type === 'chat') { | if (type === 'chat') { | ||
var avatar = getEntryAvatar(data, entry); | var avatar = getEntryAvatar(data, entry); | ||
var side = getEntrySide(data, entry); | |||
var bubble = entry.html != null ? resolveHtmlAssets(data, String(entry.html)) : formatText(text); | |||
var alt = entry.altText || entry.translation || ''; | |||
return '' + | return '' + | ||
'<div class="anecdote-chat-card">' + | '<div class="anecdote-chat-card ' + (side ? 'is-' + escapeAttr(side) : '') + '">' + | ||
(avatar ? '<img class="anecdote-chat-avatar" src="' + escapeAttr(avatar) + '" alt="">' : '<div class="anecdote-chat-avatar" aria-hidden="true"></div>') + | (avatar ? '<img class="anecdote-chat-avatar" src="' + escapeAttr(avatar) + '" alt="">' : '<div class="anecdote-chat-avatar" aria-hidden="true"></div>') + | ||
'<div class="anecdote-chat-bubble">' + formatText( | '<div class="anecdote-chat-bubble">' + | ||
bubble + | |||
(alt ? '<div class="anecdote-chat-alt">' + formatText(alt) + '</div>' : '') + | |||
'</div>' + | |||
'</div>'; | '</div>'; | ||
} | } | ||
if (entry.html != null) return resolveHtmlAssets(data, String(entry.html)); | |||
return formatText(text); | return formatText(text); | ||
} | } | ||
function getBackground(data, entry) { | function getBackground(data, entry) { | ||
if (entry && entry.background) | var raw = ''; | ||
if (data && data.assets && data.assets.background) | if (entry && entry.background) raw = entry.background; | ||
if (data && data.background) | else if (data && data.assets && data.assets.background) raw = data.assets.background; | ||
return | else if (data && data.background) raw = data.background; | ||
return resolveAsset(data, raw); | |||
} | } | ||
| 467번째 줄: | 535번째 줄: | ||
if (entryCard) { | if (entryCard) { | ||
entryCard.className = 'anecdote-entry-card is-' + escapeAttr(type || 'narration'); | var extraClasses = []; | ||
if (entry && Array.isArray(entry.classes)) { | |||
extraClasses = entry.classes.map(function (item) { return 'has-' + String(item).replace(/[^A-Za-z0-9_-]/g, ''); }); | |||
} | |||
if (entry && entry.tone) extraClasses.push('tone-' + String(entry.tone).replace(/[^A-Za-z0-9_-]/g, '')); | |||
entryCard.className = 'anecdote-entry-card is-' + escapeAttr(type || 'narration') + (extraClasses.length ? ' ' + extraClasses.join(' ') : ''); | |||
} | } | ||
2026년 5월 30일 (토) 22:19 판
/* =========================================
COASTLINE: BLACK ICE - AnecdoteViewer
Anecdote namespace reader module
========================================= */
(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;
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 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 getEntryPortrait(data, entry) {
if (!entry) return '';
var id = entry.character || entry.speaker || entry.characterId || '';
var character = getCharacter(data, id);
return resolveAsset(data, entry.portrait || (character && character.portrait) || '');
}
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 || (character && character.avatar) || getEntryPortrait(data, entry) || '');
}
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 '<p>' + paragraph.replace(/\n/g, '<br>') + '</p>';
})
.join('');
}
function getEntryHtml(data, entry) {
if (!entry) return '';
var type = getEntryType(entry);
var text = entry.text != null ? entry.text : '';
if (type === 'chat') {
var avatar = getEntryAvatar(data, entry);
var side = getEntrySide(data, entry);
var bubble = entry.html != null ? resolveHtmlAssets(data, String(entry.html)) : formatText(text);
var alt = entry.altText || entry.translation || '';
return '' +
'<div class="anecdote-chat-card ' + (side ? 'is-' + escapeAttr(side) : '') + '">' +
(avatar ? '<img class="anecdote-chat-avatar" src="' + escapeAttr(avatar) + '" alt="">' : '<div class="anecdote-chat-avatar" aria-hidden="true"></div>') +
'<div class="anecdote-chat-bubble">' +
bubble +
(alt ? '<div class="anecdote-chat-alt">' + formatText(alt) + '</div>' : '') +
'</div>' +
'</div>';
}
if (entry.html != null) return resolveHtmlAssets(data, String(entry.html));
return formatText(text);
}
function getBackground(data, entry) {
var raw = '';
if (entry && entry.background) raw = entry.background;
else if (data && data.assets && data.assets.background) raw = data.assets.background;
else if (data && data.background) raw = data.background;
return resolveAsset(data, raw);
}
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 progressKey(config) {
return 'anecdote.progress.' + normalizeSegment(config.work) + '.' + normalizeSegment(config.episode) + '.' + normalizeSegment(config.lang);
}
function getSavedIndex(config, max) {
var raw = localStorage.getItem(progressKey(config));
var value = parseInt(raw || '0', 10);
if (!isFinite(value) || value < 0) return 0;
return Math.min(value, Math.max(0, max - 1));
}
function saveIndex(config, index) {
try {
localStorage.setItem(progressKey(config), String(Math.max(0, index || 0)));
} catch (err) {}
}
function buildReturnHref(config) {
if (config.returnHref) return config.returnHref;
if (config.returnTitle) return mw.util.getUrl(config.returnTitle);
return mw.util.getUrl('대문');
}
function makeShell(root, config, indexData, episodeData) {
var entries = getEntries(episodeData);
var workTitle = episodeData.workTitle || indexData.title || config.workLabel || config.work;
var episodeTitle = episodeData.title || config.episodeLabel || config.episode;
var total = entries.length;
root.innerHTML = '' +
'<section class="anecdote-viewer is-ready" data-work="' + escapeAttr(config.work) + '" data-episode="' + escapeAttr(config.episode) + '">' +
'<div class="anecdote-frame">' +
'<div class="anecdote-titlebar">' +
'<div class="anecdote-title-main">' +
'<span class="anecdote-title-label">ANECDOTE</span>' +
'<span class="anecdote-title-text">' + escapeHtml(workTitle) + ' / ' + escapeHtml(episodeTitle) + '</span>' +
'</div>' +
'<div class="anecdote-title-meta">' +
'<span class="anecdote-counter"><span data-anecdote-current>000</span>/<span data-anecdote-total>' + String(total).padStart(3, '0') + '</span></span>' +
'<span class="anecdote-progress-meter" aria-hidden="true"><span class="anecdote-progress-fill" data-anecdote-progress></span></span>' +
'</div>' +
'</div>' +
'<div class="anecdote-stage">' +
'<div class="anecdote-backdrop"><div class="anecdote-backdrop-image" data-anecdote-backdrop></div></div>' +
'<div class="anecdote-stage-grid">' +
'<div class="anecdote-portrait-bay" data-anecdote-portrait-bay><div class="anecdote-portrait-empty" aria-hidden="true">?</div></div>' +
'<div class="anecdote-display">' +
'<article class="anecdote-entry-card is-narration" data-anecdote-entry>' +
'<div class="anecdote-speaker-row" data-anecdote-speaker-row>' +
'<span class="anecdote-speaker-kicker" data-anecdote-speaker-kicker>TEXT</span>' +
'<span class="anecdote-speaker-name" data-anecdote-speaker></span>' +
'</div>' +
'<div class="anecdote-entry-body" data-anecdote-body></div>' +
'<div class="anecdote-entry-footer">' +
'<span class="anecdote-entry-hint">SPACE / CLICK TO ADVANCE</span>' +
'<span class="anecdote-entry-count" data-anecdote-entry-count></span>' +
'</div>' +
'</article>' +
'<div class="anecdote-inline-controls">' +
'<button type="button" class="anecdote-button" data-anecdote-action="prev">PREV</button>' +
'<button type="button" class="anecdote-button" data-anecdote-action="log">LOG</button>' +
'<button type="button" class="anecdote-button" data-anecdote-action="next">NEXT</button>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="anecdote-log-panel" data-anecdote-log-panel>' +
'<div class="anecdote-log-titlebar"><span>READING LOG</span><button type="button" class="anecdote-button" data-anecdote-action="log-close">CLOSE</button></div>' +
'<div class="anecdote-log-list" data-anecdote-log-list></div>' +
'</div>' +
'</div>' +
'</div>' +
'</section>';
return root.querySelector('.anecdote-viewer');
}
function updateDeck(viewer) {
var deck = document.getElementById(DECK_ID);
if (!deck) return;
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);
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" data-deck-action="log">LOG</button>' +
'<button type="button" class="anecdote-button" data-deck-action="auto">AUTO</button>' +
'<a class="anecdote-link-button ' + (nextHref ? '' : 'is-disabled') + '" href="' + escapeAttr(nextHref || '#') + '">NEXT EP</a>' +
'<button type="button" class="anecdote-button" data-deck-action="next">NEXT</button>' +
'</div>';
var logBtn = deck.querySelector('[data-deck-action="log"]');
var autoBtn = deck.querySelector('[data-deck-action="auto"]');
var nextBtn = deck.querySelector('[data-deck-action="next"]');
if (logBtn) logBtn.addEventListener('click', function () { viewer.toggleLog(); });
if (autoBtn) autoBtn.addEventListener('click', function () { viewer.toggleAuto(); });
if (nextBtn) nextBtn.addEventListener('click', function () { viewer.next(); });
}
function ensureDeck() {
var deck = document.getElementById(DECK_ID);
if (deck) return deck;
deck = document.createElement('div');
deck.id = DECK_ID;
document.body.appendChild(deck);
return deck;
}
function createViewerController(root, config, indexData, episodeData) {
var entries = getEntries(episodeData);
var viewerEl = makeShell(root, config, indexData, episodeData);
var state = {
root: root,
el: viewerEl,
config: config,
indexData: indexData || {},
episodeData: episodeData || {},
entries: entries,
index: getSavedIndex(config, entries.length),
autoTimer: null,
next: next,
previous: previous,
toggleLog: toggleLog,
toggleAuto: toggleAuto,
destroy: destroy,
render: render
};
function destroy() {
if (state.autoTimer) {
clearInterval(state.autoTimer);
state.autoTimer = null;
}
if (state.el) {
state.el.classList.remove('is-auto-playing');
state.el.classList.remove('is-log-open');
}
}
function render() {
var entry = state.entries[state.index] || null;
var type = getEntryType(entry);
var speaker = getEntrySpeaker(state.episodeData, entry);
var portrait = getEntryPortrait(state.episodeData, entry);
var background = getBackground(state.episodeData, entry);
var entryCard = viewerEl.querySelector('[data-anecdote-entry]');
var speakerRow = viewerEl.querySelector('[data-anecdote-speaker-row]');
var speakerKicker = viewerEl.querySelector('[data-anecdote-speaker-kicker]');
var speakerName = viewerEl.querySelector('[data-anecdote-speaker]');
var body = viewerEl.querySelector('[data-anecdote-body]');
var current = viewerEl.querySelector('[data-anecdote-current]');
var progress = viewerEl.querySelector('[data-anecdote-progress]');
var count = viewerEl.querySelector('[data-anecdote-entry-count]');
var backdrop = viewerEl.querySelector('[data-anecdote-backdrop]');
var portraitBay = viewerEl.querySelector('[data-anecdote-portrait-bay]');
var previousButton = viewerEl.querySelector('[data-anecdote-action="prev"]');
var nextButton = viewerEl.querySelector('[data-anecdote-action="next"]');
var logButton = viewerEl.querySelector('[data-anecdote-action="log"]');
var deckAuto = document.querySelector('#' + DECK_ID + ' [data-deck-action="auto"]');
if (entryCard) {
var extraClasses = [];
if (entry && Array.isArray(entry.classes)) {
extraClasses = entry.classes.map(function (item) { return 'has-' + String(item).replace(/[^A-Za-z0-9_-]/g, ''); });
}
if (entry && entry.tone) extraClasses.push('tone-' + String(entry.tone).replace(/[^A-Za-z0-9_-]/g, ''));
entryCard.className = 'anecdote-entry-card is-' + escapeAttr(type || 'narration') + (extraClasses.length ? ' ' + extraClasses.join(' ') : '');
}
if (speakerRow) {
speakerRow.style.display = (type === 'narration' && !speaker) ? 'none' : 'flex';
}
if (speakerKicker) {
speakerKicker.textContent = type === 'dialogue' ? 'SPEAKER' : type.toUpperCase();
}
if (speakerName) {
speakerName.textContent = speaker || (type === 'narration' ? '' : 'SYSTEM');
}
if (body) {
body.innerHTML = entry ? getEntryHtml(state.episodeData, entry) : '<p>표시할 엔트리가 없습니다.</p>';
}
if (current) current.textContent = String(Math.min(state.index + 1, state.entries.length)).padStart(3, '0');
if (progress) progress.style.width = state.entries.length ? (((state.index + 1) / state.entries.length) * 100).toFixed(3) + '%' : '0%';
if (count) count.textContent = state.entries.length ? (String(state.index + 1) + ' / ' + String(state.entries.length)) : '0 / 0';
if (backdrop) {
backdrop.style.backgroundImage = background ? 'url("' + String(background).replace(/"/g, '\\"') + '")' : 'none';
}
if (portraitBay) {
portraitBay.innerHTML = portrait ? '<img class="anecdote-portrait-image" src="' + escapeAttr(portrait) + '" alt="">' : '<div class="anecdote-portrait-empty" aria-hidden="true">?</div>';
}
if (previousButton) previousButton.disabled = state.index <= 0;
if (nextButton) nextButton.disabled = state.entries.length === 0 || state.index >= state.entries.length - 1;
if (logButton) logButton.classList.toggle('is-active', viewerEl.classList.contains('is-log-open'));
if (deckAuto) deckAuto.classList.toggle('is-active', !!state.autoTimer);
renderLog();
saveIndex(state.config, state.index);
}
function renderLog() {
var logList = viewerEl.querySelector('[data-anecdote-log-list]');
if (!logList) return;
var html = [];
var end = Math.min(state.index, state.entries.length - 1);
for (var i = 0; i <= end; i += 1) {
var entry = state.entries[i];
var speaker = getEntrySpeaker(state.episodeData, entry);
var type = getEntryType(entry);
var plain = entry && entry.text != null ? entry.text : '';
if (!plain && entry && entry.html != null) {
plain = String(entry.html).replace(/<[^>]*>/g, '');
}
html.push(
'<div class="anecdote-log-item">' +
'<span class="anecdote-log-speaker">' + escapeHtml(speaker || type.toUpperCase()) + '</span>' +
'<span>' + formatText(plain) + '</span>' +
'</div>'
);
}
logList.innerHTML = html.join('') || '<div class="anecdote-log-item"><span>기록이 없습니다.</span></div>';
logList.scrollTop = logList.scrollHeight;
}
function next() {
if (state.index < state.entries.length - 1) {
state.index += 1;
render();
} else {
stopAuto();
}
}
function previous() {
if (state.index > 0) {
state.index -= 1;
render();
}
}
function toggleLog() {
viewerEl.classList.toggle('is-log-open');
render();
}
function stopAuto() {
if (state.autoTimer) {
clearInterval(state.autoTimer);
state.autoTimer = null;
render();
}
}
function toggleAuto() {
if (state.autoTimer) {
stopAuto();
return;
}
state.autoTimer = setInterval(function () {
if (document.hidden) return;
next();
}, parseInt(episodeData.autoInterval || 2600, 10));
render();
}
viewerEl.addEventListener('click', function (event) {
var actionEl = event.target.closest('[data-anecdote-action]');
if (!actionEl) {
if (event.target.closest('.anecdote-entry-card')) next();
return;
}
var action = actionEl.getAttribute('data-anecdote-action');
if (action === 'next') next();
if (action === 'prev') previous();
if (action === 'log' || action === 'log-close') toggleLog();
});
render();
return state;
}
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.key === ' ' || event.key === 'ArrowRight') {
event.preventDefault();
activeViewer.next();
}
if (event.key === 'ArrowLeft') {
event.preventDefault();
activeViewer.previous();
}
if (event.key && event.key.toLowerCase() === 'l') {
activeViewer.toggleLog();
}
});
}
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);