미디어위키:AnecdoteViewer.js: 두 판 사이의 차이

편집 요약 없음
편집 요약 없음
161번째 줄: 161번째 줄:
         var mode = String(config && config.mode ? config.mode : '').toLowerCase();
         var mode = String(config && config.mode ? config.mode : '').toLowerCase();
         return mode === 'index' || mode === 'work' || mode === 'hub' || !(config && config.episode);
         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();
     }
     }


167번째 줄: 178번째 줄:
         document.body.classList.remove(BODY_INDEX_CLASS);
         document.body.classList.remove(BODY_INDEX_CLASS);
         document.body.classList.add(BODY_CLASS);
         document.body.classList.add(BODY_CLASS);
        if (isIndexMode(config)) document.body.classList.add(BODY_INDEX_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', isIndexMode(config) ? 'index' : '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 || {});
        }
     }
     }


200번째 줄: 218번째 줄:


     function renderSystem(root, config, type, title, text, code) {
     function renderSystem(root, config, type, title, text, code) {
         setReaderMode(config || {});
         setModeForConfig(config || {});
         root.innerHTML = '' +
         root.innerHTML = '' +
             '<section class="anecdote-viewer is-' + escapeAttr(type || 'error') + '">' +
             '<section class="anecdote-viewer is-' + escapeAttr(type || 'error') + '">' +
588번째 줄: 606번째 줄:


     function initIndexRoot(root, config) {
     function initIndexRoot(root, config) {
         setReaderMode(config);
         setWorkIndexMode(config);
         removeDeck();
         removeDeck();
         activeViewer = null;
         activeViewer = null;
1,061번째 줄: 1,079번째 줄:


         var config = readRootConfig(root);
         var config = readRootConfig(root);
         setReaderMode(config);
         if (isIndexMode(config)) {
        ensureDeck();
            setWorkIndexMode(config);
        } else {
            setReaderMode(config);
            ensureDeck();
        }


         if (!config.work) {
         if (!config.work) {

2026년 5월 30일 (토) 23:22 판

/* =========================================
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 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, '&amp;')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;')
            .replace(/'/g, '&#039;');
    }

    function escapeAttr(value) {
        return escapeHtml(value).replace(/`/g, '&#096;');
    }

    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 background = resolveAsset(indexData, indexData.background || indexData.heroBackground || '');
        var sectionHtml;

        sectionHtml = sections.map(function (section) {
            var items = Array.isArray(section.items) ? section.items : [];
            return '' +
                '<div class="anecdote-index-section">' +
                    '<div class="anecdote-index-section-ribbon"><span class="anecdote-index-ep-title">' + escapeHtml(section.title || '') + '</span></div>' +
                    items.map(function (item) {
                        var href = buildIndexItemHref(indexData, item);
                        var title = item.title || item.label || '';
                        var className = href ? 'anecdote-index-ep-title anecdote-index-link-effect' : 'anecdote-index-ep-title is-disabled';
                        return '' +
                            '<div class="anecdote-index-ep-header">' +
                                (href ? '<a href="' + escapeAttr(href) + '" class="' + className + '">' + escapeHtml(title) + '</a>' : '<span class="' + className + '">' + escapeHtml(title) + '</span>') +
                            '</div>' +
                            '<hr class="anecdote-index-long-divider">';
                    }).join('') +
                '</div>';
        }).join('');

        return '' +
            '<section class="anecdote-viewer anecdote-work-index is-ready" data-work="' + escapeAttr(config.work) + '">' +
                '<div class="anecdote-index-scroll" data-anecdote-index-scroll>' +
                    '<div class="anecdote-index-container">' +
                        '<div class="anecdote-index-cover">' +
                            (cover ? '<img src="' + escapeAttr(cover) + '" alt="표지 이미지">' : '<div class="anecdote-index-cover-placeholder">NO COVER</div>') +
                        '</div>' +
                        '<div class="anecdote-index-info">' +
                            '<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 class="anecdote-index-actions">' +
                                '<a href="' + escapeAttr(firstHref || '#') + '" class="anecdote-index-button ' + (firstHref ? '' : 'is-disabled') + '">첫 화 보기</a>' +
                                (briefingHref ? '<a href="' + escapeAttr(briefingHref) + '" class="anecdote-index-button">작전 브리핑</a>' : '<span class="anecdote-index-button is-disabled">작전 브리핑</span>') +
                            '</div>' +
                            '<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>' +
                    sectionHtml +
                '</div>' +
                '<div class="anecdote-index-tutorial-overlay" data-anecdote-tutorial style="display:none;">' +
                    '<div class="anecdote-index-tutorial-popup">' +
                        '<button type="button" class="anecdote-index-close-btn" data-anecdote-tutorial-close>×</button>' +
                        '<p><strong>튜토리얼</strong><br>회차 안에서는 키보드 <code>Space</code> 키로 진행됩니다.<br>화면 중앙의 지시를 따라 진행해 주세요.</p>' +
                        '<label><input type="checkbox" data-anecdote-tutorial-dont-show> 다시 보지 않기</label>' +
                    '</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);
        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);