Jump to content
메뉴 여닫기
환경 설정 메뉴 여닫기
개인 메뉴 여닫기
로그인하지 않음
편집을 하면 임시 계정이 생성되어 부여됩니다.

참고: 설정을 저장한 후에 바뀐 점을 확인하기 위해서는 브라우저의 캐시를 새로 고쳐야 합니다.

  • 파이어폭스 / 사파리: Shift 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5 또는 Ctrl-R을 입력 (Mac에서는 ⌘-R)
  • 구글 크롬: Ctrl-Shift-R키를 입력 (Mac에서는 ⌘-Shift-R)
  • 엣지: Ctrl 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5를 입력.
/* eslint-disable mediawiki/class-doc */
/**
 * MediaWiki 가사 Viewer — MediaWiki: 네임스페이스 JS
 * UseResource 확장으로 직접 로드되는 클라이언트 스크립트입니다.
 * 리소스 모듈 등록 없이 MediaWiki: 네임스페이스의 JS 페이지로 동작합니다.
 * 의존성: MediaWiki 1.45+ ResourceLoader가 제공하는 vue, @wikimedia/codex (또는 codex)
 */
/* global mw, Vue, require */
( function () {
	'use strict';

	// 디버그 플래그 저장 키
	var DEBUG_STORAGE_KEY = 'mw-lyrics-viewer-debug';

	// 디버그 로그 버퍼 저장 키 — 새로고침으로 콘솔이 초기화되어도 이전 세션 로그를 확인할 수 있게 버퍼링합니다.
	var DEBUG_LOG_STORAGE_KEY = 'mw-lyrics-viewer-debuglog';
	var DEBUG_LOG_MAX_ENTRIES = 50;

	// 디버그 활성화 여부 확인
	function isDebugEnabled() {
		try {
			// 디버그 플래그가 저장되어 있으면 활성화로 반환합니다.
			if ( window.localStorage.getItem( DEBUG_STORAGE_KEY ) === '1' ) { return true; }
		} catch ( error ) {}
		try {
			return /(?:\?|&)lyricsViewerDebug=1(?:&|$)/.test( window.location.search );
		} catch ( error ) {
			return false;
		}
	}

	// 디버그 로그 출력
	function debugLog() {
		// 디버그가 비활성화되어 있다면 중단합니다.
		if ( !isDebugEnabled() ) { return; }

		var args = Array.prototype.slice.call( arguments );
		args.unshift( '[LyricsViewer:debug]' );
		try { window.console.log.apply( window.console, args ); } catch ( error ) {}
		bufferDebugLog( 'log', args );
	}

	// 디버그 경고 출력
	function debugWarn() {
		var args = Array.prototype.slice.call( arguments );
		args.unshift( '[LyricsViewer]' );
		try { window.console.warn.apply( window.console, args ); } catch ( error ) {}
		bufferDebugLog( 'warn', args );
	}

	// 디버그 로그를 저장소 버퍼에 기록
	function bufferDebugLog( level, logArguments ) {
		try {
			var entries = JSON.parse( window.sessionStorage.getItem( DEBUG_LOG_STORAGE_KEY ) || '[]' );
			entries.push( {
				level: level,
				time: new Date().toISOString(),
				text: Array.prototype.map.call( logArguments, function ( item ) {
					// 객체는 사람이 읽을 수 있는 형태로 직렬화합니다.
					if ( typeof item === 'object' ) {
						try { return JSON.stringify( item ); } catch ( innerError ) { return String( item ); }
					}
					return String( item );
				} ).join( ' ' )
			} );

			// 최대 개수를 넘으면 오래된 로그부터 버립니다.
			if ( entries.length > DEBUG_LOG_MAX_ENTRIES ) { entries = entries.slice( -DEBUG_LOG_MAX_ENTRIES ); }
			window.sessionStorage.setItem( DEBUG_LOG_STORAGE_KEY, JSON.stringify( entries ) );
		} catch ( error ) {}
	}

	// 새로고침 전 세션에 기록된 디버그 로그를 콘솔로 다시 출력
	function dumpBufferedDebugLogs() {
		try {
			var rawEntries = window.sessionStorage.getItem( DEBUG_LOG_STORAGE_KEY );
			window.sessionStorage.removeItem( DEBUG_LOG_STORAGE_KEY );
			var entries = rawEntries ? JSON.parse( rawEntries ) : [];

			// 이전 세션에 기록된 로그가 있다면 출력합니다.
			if ( entries.length ) {
				entries.forEach( function ( entry ) {
					var consoleMethod = entry.level === 'warn' ? window.console.warn : window.console.log;
					consoleMethod.call( window.console, '[LyricsViewer:debug@' + entry.time + ']', entry.text );
				} );
			}
		} catch ( error ) {}
	}

	// 전역 디버그 핸들 노출
	try {
		window.__lyricsViewerDebug = window.__lyricsViewerDebug || {};
		window.__lyricsViewerDebug.enable = function () {
			try { window.localStorage.setItem( DEBUG_STORAGE_KEY, '1' ); } catch ( error ) {}
			try { window.console.info( '[LyricsViewer] 디버그 모드 활성화 — 새로고침 후 상세 로그가 출력되며, 새로고침 전 세션의 로그도 함께 출력됩니다.' ); } catch ( error2 ) {}
		};
		window.__lyricsViewerDebug.disable = function () {
			try { window.localStorage.removeItem( DEBUG_STORAGE_KEY ); } catch ( error ) {}
			try { window.console.info( '[LyricsViewer] 디버그 모드 비활성화' ); } catch ( error2 ) {}
		};
		window.__lyricsViewerDebug.isEnabled = isDebugEnabled;
	} catch ( error ) {}

	// ※ Codex 아이콘은 ResourceLoader에 모듈이 존재하지 않으므로 MediaWiki 1.44+의
	//   list=codexicons API를 통해 필요한 아이콘만 URL 기반으로 동적 로드합니다.
	//   외부 CDN보다 Same-Origin이라 더 안전합니다.
	//   https://www.mediawiki.org/wiki/API:CodexIcons
	//   "For performance reasons, there is no ResourceLoader module named codex-icons containing all the icons"
	var API_ICON_NAMES = [ 'cdxIconAdd', 'cdxIconEdit', 'cdxIconSettings' ];

	// 아이콘 세션 캐시 키 — 대상 아이콘 목록이나 응답 형식이 바뀌면 버전을 올립니다.
	var API_ICON_STORAGE_KEY = 'mw-lyrics-viewer-codex-icons-v1';

	var apiIconCache = null;
	var apiIconFetchPromise = null;

	// 라디오 그룹 고유 번호 카운터
	var radioGroupCounter = 0;

	// 캐시된 아이콘을 콜백으로 전달
	function deliverCachedIcons( callback ) {
		try { callback( apiIconCache ); } catch ( error ) {}
	}

	// 진행 중인 요청에 콜백 연결
	function attachToPendingFetch( callback ) {
		apiIconFetchPromise.then( function ( cachedIcons ) {
			try { callback( cachedIcons ); } catch ( error ) {}
		} ).catch( function () {} );
	}

	// API 응답에서 필요한 아이콘만 추출
	function pickNeededIcons( iconData ) {
		var pickedIcons = {};
		API_ICON_NAMES.forEach( function ( iconName ) {
			pickedIcons[ iconName ] = iconData[ iconName ] || null;
		} );
		return pickedIcons;
	}

	// API URL 생성
	function buildCodexIconsApiUrl() {
		var namesParam = encodeURIComponent( API_ICON_NAMES.join( '|' ) );
		var queryString = 'action=query&list=codexicons&names=' + namesParam + '&format=json&formatversion=2';
		try {
			return mw.util.wikiScript( 'api' ) + '?' + queryString;
		} catch ( error ) {
			return '/w/api.php?' + queryString;
		}
	}

	// 세션 저장소에서 아이콘 캐시를 읽습니다. 형식이 맞지 않으면 캐시 없음으로 취급합니다.
	function readIconsFromSessionStorage() {
		try {
			var storedIcons = JSON.parse( window.sessionStorage.getItem( API_ICON_STORAGE_KEY ) || 'null' );

			// 필요한 아이콘 이름이 모두 갖춰진 객체인지 검사합니다.
			if ( !storedIcons || typeof storedIcons !== 'object' ) { return null; }
			for ( var index = 0; index < API_ICON_NAMES.length; index++ ) {
				if ( !( API_ICON_NAMES[ index ] in storedIcons ) ) { return null; }
			}
			return storedIcons;
		} catch ( error ) {
			return null;
		}
	}

	// 아이콘 캐시를 세션 저장소에 기록합니다. 실제 아이콘 데이터가 하나도 없다면 기록하지 않습니다.
	function saveIconsToSessionStorage( icons ) {
		try {
			var hasIcon = false;
			API_ICON_NAMES.forEach( function ( iconName ) {
				if ( icons[ iconName ] ) { hasIcon = true; }
			} );
			if ( !hasIcon ) { return; }
			window.sessionStorage.setItem( API_ICON_STORAGE_KEY, JSON.stringify( icons ) );
		} catch ( error ) {}
	}

	// list=codexicons API에서 아이콘 JSON 가져오기
	function fetchIconsFromApi( callback ) {
		// 캐시된 아이콘이 있다면 즉시 전달합니다.
		if ( apiIconCache ) { deliverCachedIcons( callback ); return; }

		// 진행 중인 요청이 있다면 콜백만 연결합니다.
		if ( apiIconFetchPromise ) { attachToPendingFetch( callback ); return; }

		// 세션 저장소에 캐시가 남아 있다면 요청 없이 사용합니다.
		var storedIcons = readIconsFromSessionStorage();
		if ( storedIcons ) {
			apiIconCache = storedIcons;
			deliverCachedIcons( callback );
			return;
		}

		// fetch API를 사용할 수 없다면 경고를 남기고 중단합니다.
		if ( typeof window.fetch !== 'function' ) { debugWarn( 'fetch API를 사용할 수 없어 list=codexicons에서 아이콘을 가져올 수 없습니다.' ); return; }

		var apiUrl = buildCodexIconsApiUrl();

		// API 요청 시작
		apiIconFetchPromise = window.fetch( apiUrl ).then( function ( response ) {
			// 응답이 정상이 아니라면 에러를 던집니다.
			if ( !response.ok ) { throw new Error( 'HTTP ' + response.status ); }
			return response.json();
		} ).then( function ( responseData ) {
			var iconData = responseData && responseData.query && responseData.query.codexicons ? responseData.query.codexicons : responseData;
			var loadedIcons = pickNeededIcons( iconData );
			apiIconCache = loadedIcons;
			saveIconsToSessionStorage( loadedIcons );
			debugLog( 'list=codexicons API에서 아이콘 로드 성공:', apiUrl, 'iconAdd:', !!loadedIcons.cdxIconAdd, 'iconEdit:', !!loadedIcons.cdxIconEdit, 'iconSettings:', !!loadedIcons.cdxIconSettings );
			return loadedIcons;
		} ).catch( function ( fetchError ) {
			debugWarn( 'list=codexicons API에서 아이콘을 가져오지 못했습니다: ' + apiUrl + ' — ' + ( fetchError && fetchError.message ) );
			apiIconFetchPromise = null;
			throw fetchError;
		} );

		attachToPendingFetch( callback );
	}

	// 브라우저 저장소 키
	var STORAGE_PREFIX = 'mw-lyrics-viewer-';
	var STORAGE_KEYS = {
		multimode: STORAGE_PREFIX + 'multimode',
		fontSize: STORAGE_PREFIX + 'fontSize',
		fontFamily: STORAGE_PREFIX + 'fontFamily',
		align: STORAGE_PREFIX + 'align',
		lineHeight: STORAGE_PREFIX + 'lineHeight',
		letterSpacing: STORAGE_PREFIX + 'letterSpacing'
	};

	// UI 문자열
	var MESSAGES = {
		defaultVariationLabel: '가사',
		documentNamespacePrefix: '가사:',
		addButtonLabel: '다른 가사 추가',
		addDialogTitle: '다른 가사 추가',
		targetDocumentLabel: '대상 문서:',
		customVariationLabel: '그 외',
		customVariationPlaceholder: '가사 변형 이름 입력',
		documentNameLabel: '문서 이름:',
		createLabel: '생성',
		editLabel: '편집',
		editButtonAriaLabel: '편집',
		editButtonTooltip: '편집',
		addVariationAriaLabel: '추가',
		addVariationTooltip: '추가',
		closeLabel: '닫기',
		editModalTitle: '가사 편집',
		saveLabel: '저장',
		editSummaryPlaceholder: '편집 요약',
		unsavedChangesMessage: '저장하지 않은 변경 사항이 있습니다. 정말로 닫으시겠습니까?',
		saveFailedMessage: '저장에 실패했습니다. 잠시 후 다시 시도해 주세요.',
		diffLabel: '차이 보기',
		noDiffLabel: '차이 없음',
		displaySettingsTitle: '가사 표시 설정',
		displaySettingsAriaLabel: '가사 표시 설정',
		multiSelectLabel: '다중 선택',
		fontSizeLabel: '글자 크기',
		fontSizeSmall: '작게',
		fontSizeMedium: '보통',
		fontSizeLarge: '크게',
		fontFamilyLabel: '글꼴',
		fontFamilyDefault: '기본',
		fontFamilySerif: '세리프',
		fontFamilySans: '산세리프',
		fontFamilyMono: '고정폭',
		alignmentLabel: '정렬',
		alignLeft: '왼쪽',
		alignCenter: '가운데',
		alignRight: '오른쪽',
		lineHeightLabel: '줄 간격',
		lineHeightNarrow: '좁게',
		lineHeightNormal: '보통',
		lineHeightWide: '넓게',
		letterSpacingLabel: '글자 간격',
		letterSpacingNarrow: '좁게',
		letterSpacingNormal: '보통',
		letterSpacingWide: '넓게',
		variationTypeTranslation: { value: '번역', label: '번역', description: '외국어 가사를 한국어로 번역한 가사' },
		variationTypePronunciation: { value: '발음', label: '발음', description: '가사를 발음에 맞게 표기한 가사' },
		variationTypePrompt: { value: '프롬프트', label: '프롬프트', description: 'AI 음악 생성 도구 등에 사용할 수 있도록 구조나 지시문이 포함된 가사' }
	};

	// 표준 가사 문서 유형
	var STANDARD_LYRICS_TYPES = [
		MESSAGES.variationTypeTranslation,
		MESSAGES.variationTypePronunciation,
		MESSAGES.variationTypePrompt
	];

	// 스타일 매핑 테이블
	var FONT_SIZE_MAP = { small: '0.875em', medium: '', large: '1.2em' };
	var FONT_FAMILY_MAP = {
		default: '',
		serif: 'serif',
		'sans-serif': 'sans-serif',
		monospace: 'monospace'
	};
	var LINE_HEIGHT_MAP = { narrow: '1.4', normal: '', wide: '2.0' };
	var LETTER_SPACING_MAP = { narrow: '-0.02em', normal: '', wide: '0.06em' };

	// Viewer 템플릿
	var VIEWER_TEMPLATE = `<div v-if="shouldShowVariationToggle" class="mw--lyrics-viewer-variation"><cdx-toggle-button-group v-model="toggleValue" :buttons="variationToggleButtons"></cdx-toggle-button-group></div>
<div class="mw--lyrics-viewer-add"><cdx-button @click="dialogOpen = true" :aria-label="messages.addButtonLabel" v-tooltip="tooltipDirectiveAvailable ? messages.addButtonLabel : null"><cdx-icon v-if="iconAdd" :icon="iconAdd"></cdx-icon> <span class="mw--lyrics-viewer-add__label">{{ messages.addButtonLabel }}</span></cdx-button>
  <cdx-dialog v-model:open="dialogOpen" :title="messages.addDialogTitle" :use-close-button="true" :primary-action="primaryAction" :default-action="defaultAction" @primary="handlePrimaryAction" @default="dialogOpen = false">
    <div class="mw--lyrics-viewer-dialog-body">
      <p class="mw--lyrics-viewer-dialog-target">{{ messages.targetDocumentLabel }} <strong>{{ songPageTitle }}</strong></p>
      <div v-for="lyricType in standardTypes" :key="lyricType.value" class="mw--lyrics-viewer-type-card" :style="{ background: selectedType===lyricType.value ? 'var(--background-color-progressive-subtle)' : '' }" @click="selectedType=lyricType.value">
        <label class="mw--lyrics-viewer-type-card__header"><input type="radio" :name="lyricTypeRadioGroupName" :value="lyricType.value" v-model="selectedType" /> <strong>{{ lyricType.label }}</strong></label>
        <div class="mw--lyrics-viewer-type-card__description">{{ lyricType.description }}</div>
      </div>
      <div class="mw--lyrics-viewer-type-card" :style="{ background: selectedType==='__custom' ? 'var(--background-color-progressive-subtle)' : '' }">
        <label class="mw--lyrics-viewer-type-card__header" @click="selectedType='__custom'"><input type="radio" :name="lyricTypeRadioGroupName" value="__custom" v-model="selectedType" /> <strong>{{ messages.customVariationLabel }}</strong></label>
        <div class="mw--lyrics-viewer-type-card__input-wrap"><cdx-text-input v-model="customVariationName" :placeholder="messages.customVariationPlaceholder" @focus="selectedType='__custom'"></cdx-text-input></div>
      </div>
      <div v-if="previewDocumentTitle" class="mw--lyrics-viewer-dialog-preview">{{ messages.documentNameLabel }} <code>{{ previewDocumentTitle }}</code></div>
    </div>
  </cdx-dialog>
</div>
<div class="mw--lyrics-viewer-bar__spacer" aria-hidden="true"></div>
<div class="mw--lyrics-viewer-settings"><cdx-button ref="settingsButtonAnchorRef" :aria-label="messages.displaySettingsAriaLabel" v-tooltip="tooltipDirectiveAvailable ? messages.displaySettingsAriaLabel : null" @click="popoverOpen = !popoverOpen"><cdx-icon v-if="iconSettings" :icon="iconSettings"></cdx-icon></cdx-button>
  <cdx-popover v-model:open="popoverOpen" :anchor="settingsButtonAnchorEl" :title="messages.displaySettingsTitle" :use-close-button="true">
    <div class="mw--lyrics-viewer-popover-body">
      <div class="mw--lyrics-viewer-popover-row">
        <span class="mw--lyrics-viewer-popover-row__label">{{ messages.multiSelectLabel }}</span>
        <cdx-toggle-switch v-if="isToggleSwitchAvailable" :model-value="displayState.isMultimodeEnabled" @update:model-value="handleMultimodeChange"></cdx-toggle-switch>
        <input v-else type="checkbox" :checked="displayState.isMultimodeEnabled" @change="handleMultimodeChange($event.target.checked)" />
      </div>
      <div><div class="mw--lyrics-viewer-popover-section-title">{{ messages.fontSizeLabel }}</div><cdx-toggle-button-group :model-value="displayState.fontSize" :buttons="fontSizeButtons" @update:model-value="handleFontSizeChange"></cdx-toggle-button-group></div>
      <div><div class="mw--lyrics-viewer-popover-section-title">{{ messages.fontFamilyLabel }}</div><cdx-toggle-button-group :model-value="displayState.fontFamily" :buttons="fontFamilyButtons" @update:model-value="handleFontFamilyChange"></cdx-toggle-button-group></div>
      <div><div class="mw--lyrics-viewer-popover-section-title">{{ messages.alignmentLabel }}</div><cdx-toggle-button-group :model-value="displayState.textAlignment" :buttons="alignmentButtons" @update:model-value="handleAlignmentChange"></cdx-toggle-button-group></div>
      <div><div class="mw--lyrics-viewer-popover-section-title">{{ messages.lineHeightLabel }}</div><cdx-toggle-button-group :model-value="displayState.lineHeight" :buttons="lineHeightButtons" @update:model-value="handleLineHeightChange"></cdx-toggle-button-group></div>
      <div><div class="mw--lyrics-viewer-popover-section-title">{{ messages.letterSpacingLabel }}</div><cdx-toggle-button-group :model-value="displayState.letterSpacing" :buttons="letterSpacingButtons" @update:model-value="handleLetterSpacingChange"></cdx-toggle-button-group></div>
    </div>
  </cdx-popover>
</div>
<cdx-dialog v-model:open="editModalOpen" :title="messages.editModalTitle" :use-close-button="true" :primary-action="saveAction" :default-action="cancelAction" @primary="handleEditSave" @default="editModalOpen = false">
  <div class="mw--lyrics-viewer-edit-dialog-body">
    <p class="mw--lyrics-viewer-dialog-target">{{ messages.targetDocumentLabel }} <strong v-if="editTargetUrl"><a :href="editTargetUrl">{{ editTargetTitle }}</a></strong><strong v-else>{{ editTargetTitle }}</strong></p>
    <cdx-text-area v-model="wikitext" class="mw--lyrics-viewer-edit-textarea" :disabled="isFetchingWikitext"></cdx-text-area>
    <cdx-text-input v-model="editSummary" :placeholder="messages.editSummaryPlaceholder" :disabled="isSavingWikitext"></cdx-text-input>
    <div><cdx-button size="small" :disabled="isDiffLoading" @click="handleDiffRefresh">{{ messages.diffLabel }}</cdx-button></div>
    <div v-if="isDiffShown" class="mw--lyrics-viewer-edit-diff">
      <table v-if="diffHtml" class="diff"><colgroup><col class="diff-marker"><col class="diff-content"><col class="diff-marker"><col class="diff-content"></colgroup><tbody v-html="diffHtml"></tbody></table>
      <p v-else-if="diffErrorMessage" class="mw--lyrics-viewer-edit-error">{{ diffErrorMessage }}</p>
      <p v-else class="mw--lyrics-viewer-edit-diff-empty">{{ messages.noDiffLabel }}</p>
    </div>
    <cdx-message v-if="isMessageAvailable && editErrorMessage" type="error">{{ editErrorMessage }}</cdx-message>
    <p v-else-if="editErrorMessage" class="mw--lyrics-viewer-edit-error">{{ editErrorMessage }}</p>
  </div>
</cdx-dialog>`;

	// localStorage 읽기
	function readFromStorage( key ) {
		try { return window.localStorage.getItem( key ); } catch ( error ) { return null; }
	}

	// localStorage 쓰기
	function writeToStorage( key, value ) {
		try { window.localStorage.setItem( key, value ); } catch ( error ) {}
	}

	// API 에러를 사용자에게 표시할 메시지로 변환
	function formatApiError( apiError ) {
		var errorData = apiError && apiError.error;
		// 표준 API 에러라면 코드와 설명을 함께 표시합니다.
		if ( errorData && errorData.info ) { return ( errorData.code ? errorData.code + ': ' : '' ) + errorData.info; }
		return String( ( apiError && ( apiError.text || apiError.exception ) ) || apiError );
	}

	// mw.Api 실패는 (코드, 결과) 인자로 거부되지만 .then 체인에서는 코드 문자열만 전달되므로,
	// 모든 인자를 받는 .fail에서 결과 객체를 묶어 다시 거부합니다.
	function normalizeApiPromise( apiPromise ) {
		var wrapped = window.jQuery.Deferred();
		apiPromise.done( function ( responseData ) {
			wrapped.resolve( responseData );
		} );
		apiPromise.fail( function ( apiErrorCode, apiResult ) {
			var errorData = apiResult && ( apiResult.error || ( apiResult.errors && apiResult.errors[ 0 ] ) ) || null;
			wrapped.reject( {
				code: apiErrorCode,
				error: errorData,
				text: apiResult && apiResult.text,
				exception: apiResult && apiResult.exception
			} );
		} );
		return wrapped.promise();
	}

	// MediaWiki API가 받아들이는 ISO 8601 타임스탬프 생성
	function toApiTimestamp( date ) {
		return date.toISOString().replace( /\.\d+Z$/, 'Z' );
	}

	// 가사 문서 위키텍스트 조회/저장 헬퍼 생성
	function createLyricsRepository( mwApi ) {
		return {
			fetchWikitext: function ( documentTitle ) {
				// 편집 시작 시각 — 저장 시 starttimestamp로 전달해 그 사이 문서 이동/삭제를 감지합니다.
				var fetchStartTimestamp = toApiTimestamp( new Date() );
				return normalizeApiPromise( mwApi.get( {
					action: 'query',
					prop: 'revisions',
					titles: documentTitle,
					rvslots: 'main',
					rvprop: 'content|timestamp',
					formatversion: 2
				} ) ).then( function ( responseData ) {
					var page = responseData && responseData.query && responseData.query.pages ? responseData.query.pages[ 0 ] : null;

					// 문서가 존재하지 않으면 빈 내용의 새 문서로 취급합니다.
					if ( !page || page.missing ) { return { text: '', baseTimestamp: '', startTimestamp: fetchStartTimestamp }; }
					var revision = page.revisions && page.revisions[ 0 ];
					return {
						text: revision && revision.slots && revision.slots.main ? revision.slots.main.content : '',
						baseTimestamp: revision ? revision.timestamp : '',
						startTimestamp: fetchStartTimestamp
					};
				} );
			},
			saveWikitext: function ( documentTitle, text, summary, baseTimestamp, startTimestamp ) {
				var editParams = {
					action: 'edit',
					title: documentTitle,
					text: text,
					summary: summary
				};
				// 불러온 판의 시각과 편집 시작 시각을 넘겨 편집 충돌을 감지합니다.
				if ( baseTimestamp ) { editParams.basetimestamp = baseTimestamp; }
				if ( startTimestamp ) { editParams.starttimestamp = startTimestamp; }
				return normalizeApiPromise( mwApi.postWithEditToken( editParams ) ).then( function ( responseData ) {
					// 무변경(null edit) 편집도 성공으로 처리합니다.
					return !!( responseData && responseData.edit && responseData.edit.result === 'Success' );
				} );
			},
			purgePage: function ( documentTitle ) {
				return normalizeApiPromise( mwApi.post( {
					action: 'purge',
					titles: documentTitle,
					forcelinkupdate: 1
				} ) );
			},
			fetchWikitextDiff: function ( fromText, toText ) {
				return normalizeApiPromise( mwApi.get( {
					action: 'compare',
					fromslots: 'main',
					'fromtext-main': fromText,
					toslots: 'main',
					'totext-main': toText,
					difftype: 'table',
					formatversion: 2
				} ) ).then( function ( responseData ) {
					return responseData && responseData.compare && responseData.compare.body ? responseData.compare.body : '';
				} );
			}
		};
	}

	// 현재 문서 제목 반환 — wgPageName은 URL 형식이므로 밑줄을 공백으로 되돌립니다.
	function getCurrentPageTitle() {
		try { return String( mw.config.get( 'wgPageName' ) || '' ).replace( /_/g, ' ' ); } catch ( error ) { return ''; }
	}

	// 가사 문서 제목 생성 — mw.Title로 정규화된 문서 제목을 만들어 반환합니다.
	// 곡 문서 제목이나 변형 이름에 제목으로 쓸 수 없는 문자가 포함되었다면 null을 반환합니다.
	function buildLyricsDocumentTitle( songPageTitle, variationName ) {
		var rawTitle = MESSAGES.documentNamespacePrefix + songPageTitle + ( variationName ? '/' + variationName : '' );
		try {
			var title = mw.Title.newFromText( rawTitle );
			return title ? title.getPrefixedText() : null;
		} catch ( error ) {
			return null;
		}
	}

	// 전역 표시 상태 생성
	function createGlobalDisplayState( Vue ) {
		return Vue.reactive( {
			isMultimodeEnabled: readFromStorage( STORAGE_KEYS.multimode ) === 'true',
			fontSize: readFromStorage( STORAGE_KEYS.fontSize ) || 'medium',
			fontFamily: readFromStorage( STORAGE_KEYS.fontFamily ) || 'default',
			textAlignment: readFromStorage( STORAGE_KEYS.align ) || 'left',
			lineHeight: readFromStorage( STORAGE_KEYS.lineHeight ) || 'normal',
			letterSpacing: readFromStorage( STORAGE_KEYS.letterSpacing ) || 'normal'
		} );
	}

	// 다른 탭의 저장소 변경을 전역 상태에 동기화
	function syncDisplayStateWithStorage( globalDisplayState ) {
		try {
			window.addEventListener( 'storage', function ( storageEvent ) {
				// 변경된 저장소 키에 따라 상태를 갱신합니다.
				if ( storageEvent.key === STORAGE_KEYS.multimode ) {
					globalDisplayState.isMultimodeEnabled = storageEvent.newValue === 'true';
				} else if ( storageEvent.key === STORAGE_KEYS.fontSize ) {
					globalDisplayState.fontSize = storageEvent.newValue || 'medium';
				} else if ( storageEvent.key === STORAGE_KEYS.fontFamily ) {
					globalDisplayState.fontFamily = storageEvent.newValue || 'default';
				} else if ( storageEvent.key === STORAGE_KEYS.align ) {
					globalDisplayState.textAlignment = storageEvent.newValue || 'left';
				} else if ( storageEvent.key === STORAGE_KEYS.lineHeight ) {
					globalDisplayState.lineHeight = storageEvent.newValue || 'normal';
				} else if ( storageEvent.key === STORAGE_KEYS.letterSpacing ) {
					globalDisplayState.letterSpacing = storageEvent.newValue || 'normal';
				}
			} );
		} catch ( error ) {}
	}

	// 가사 변형 데이터 수집
	function collectVariationData( viewerContainer ) {
		var variationElements = Array.from(
			viewerContainer.querySelectorAll( '.mw--lyrics-variation[data-lyric-variation]' )
		);
		var variationValues = variationElements.map( function ( element ) {
			return element.getAttribute( 'data-lyric-variation' ) || '';
		} );
		var hasNonDefaultVariation = variationValues.some( function ( value ) { return value !== ''; } );
		var existingVariationSet = new Set( variationValues );
		var variationToggleButtons = variationValues.map( function ( value ) {
			return { value: value, label: value === '' ? MESSAGES.defaultVariationLabel : value };
		} );
		var shouldShowVariationToggle = hasNonDefaultVariation && variationValues.length > 1;

		return {
			variationElements: variationElements,
			variationValues: variationValues,
			existingVariationSet: existingVariationSet,
			variationToggleButtons: variationToggleButtons,
			shouldShowVariationToggle: shouldShowVariationToggle
		};
	}

	// 서버 렌더링된 뷰어 바 참조
	function findViewerBar( viewerContainer ) {
		var viewerBar = viewerContainer.querySelector( ':scope > .mw--lyrics-viewer-bar' );

		// 뷰어 바가 없다면 경고를 남기고 null을 반환합니다.
		if ( !viewerBar ) {
			debugWarn( '컨테이너 안에서 .mw--lyrics-viewer-bar를 찾을 수 없어 Viewer를 초기화하지 않습니다. 서버 마크업에 뷰어 바가 렌더링되어 있는지 확인하세요.' );
			return null;
		}

		return viewerBar;
	}

	// 가로 넘침이 실제로 생겼는지 확인해 컨테이너 속성을 갱신
	function updateOverflowState( viewerContainer ) {
		// 변형 컨테이너가 없다면 경고를 남기고 판단하지 않습니다.
		var variationContainer = viewerContainer.querySelector( '.mw--lyrics-variation-container' );
		if ( !variationContainer ) {
			debugWarn( '컨테이너 안에서 .mw--lyrics-variation-container를 찾을 수 없어 가로 넘침을 판정하지 않습니다.' );
			return;
		}

		// float 해제(clear)가 적용된 상태에서는 폭이 넓어져 판정이 뒤집히고, 해제→재적용이 무한 반복될 수 있습니다.
		// 속성을 잠시 지운 '해제 상태'에서 측정해 판정한 뒤 필요하면 다시 적용합니다 — 동기 실행이라 중간 상태는 화면에 그려지지 않습니다.
		if ( viewerContainer.getAttribute( 'data-lyrics-viewer-overflow' ) === 'true' ) {
			viewerContainer.removeAttribute( 'data-lyrics-viewer-overflow' );
		}

		// 변형 컨테이너 자신의 넘침은 overflow-X 값과 무관하게 검사합니다 —
		// Citizen 스킨처럼 컨테이너가 직접 스크롤하지 않고 넘친 내용을 상위 상자가 스크롤/클립하는 환경이 있기 때문입니다.
		var hasOverflow = variationContainer.scrollWidth > variationContainer.clientWidth;

		if ( !hasOverflow ) {
			// 컨테이너에서 넘치지 않았다면 넘친 내용을 잘라내는 상위 상자가 있는지 확인합니다.
			var current = variationContainer.parentElement;
			while ( current && current !== document.documentElement ) {
				if ( current.scrollWidth > current.clientWidth ) {
					var overflowX = window.getComputedStyle( current ).overflowX;
					if ( overflowX !== 'visible' ) { hasOverflow = true; break; }
				}
				current = current.parentElement;
			}
		}

		debugLog( '가로 넘침 판정:', variationContainer, '컨테이너:', variationContainer.scrollWidth + '/' + variationContainer.clientWidth, '→ 넘침:', hasOverflow );

		if ( hasOverflow ) { viewerContainer.setAttribute( 'data-lyrics-viewer-overflow', 'true' ); }
	}

	// 가사 표시/숨김 적용 함수 생성
	function createVariationApplier( viewerContainer, variationElements ) {
		// 창 크기 변경 등으로 컨테이너 폭이 변하면 스크롤 여부를 다시 판단합니다.
		try {
			var variationContainer = viewerContainer.querySelector( '.mw--lyrics-variation-container' );
			if ( variationContainer && typeof window.ResizeObserver === 'function' ) {
				new window.ResizeObserver( function () { updateOverflowState( viewerContainer ); } ).observe( variationContainer );
			}
		} catch ( error ) {}

		return function ( singleSelectedValue, multiSelectedValues, isMultimodeActive ) {
			// 다중 선택 여부에 따라 표시할 집합을 결정합니다.
			var visibleSet;
			if ( isMultimodeActive ) {
				visibleSet = new Set( multiSelectedValues.length ? multiSelectedValues : [ singleSelectedValue ] );
			} else {
				visibleSet = new Set( [ singleSelectedValue ] );
			}

			// 각 변형 요소의 표시 여부 갱신
			variationElements.forEach( function ( element ) {
				var variationValue = element.getAttribute( 'data-lyric-variation' ) || '';
				element.hidden = !visibleSet.has( variationValue );
			} );

			// 라벨 표시를 다중 선택 모드에 맞춰 갱신합니다.
			viewerContainer.querySelectorAll( '.ts-lyric-label' ).forEach( function ( lyricLabel ) { lyricLabel.hidden = !isMultimodeActive; } );

			// 다중 선택 모드에 따라 컨테이너 속성을 갱신합니다.
			if ( isMultimodeActive ) {
				viewerContainer.setAttribute( 'data-lyrics-viewer-multimode', 'true' );
			} else {
				viewerContainer.removeAttribute( 'data-lyrics-viewer-multimode' );
			}

			// 표시 변화로 가로 스크롤 생김 여부가 달라질 수 있으니 함께 갱신합니다.
			updateOverflowState( viewerContainer );
		};
	}

	// 글자 크기/글꼴/정렬/줄 간격/글자 간격 적용 함수 생성
	function createStyleApplier( viewerContainer ) {
		return function ( fontSizeValue, fontFamilyValue, alignmentValue, lineHeightValue, letterSpacingValue ) {
			// 각 가사 본문에 스타일 적용
			viewerContainer.querySelectorAll( '.ts-lyric-content' ).forEach( function ( contentElement ) {
				contentElement.style.fontSize = FONT_SIZE_MAP[ fontSizeValue ] || '';
				contentElement.style.fontFamily = FONT_FAMILY_MAP[ fontFamilyValue ] || '';
				contentElement.style.lineHeight = LINE_HEIGHT_MAP[ lineHeightValue ] || '';
				contentElement.style.letterSpacing = LETTER_SPACING_MAP[ letterSpacingValue ] || '';
				// 정렬 값에 따라 텍스트 정렬을 갱신합니다.
				if ( alignmentValue === 'left' || !alignmentValue ) {
					contentElement.style.textAlign = '';
				} else {
					contentElement.style.textAlign = alignmentValue;
				}
			} );
		};
	}

	// 표시 대상 컨테이너 수집
	function collectViewerTargets( seenContainers ) {
		var allMainMarkers = document.querySelectorAll( '[data-lyric-mainpage]' );
		var targets = [];

		allMainMarkers.forEach( function ( mainMarkerElement ) {
			var viewerContainer = mainMarkerElement.closest( '.mw--lyrics-container' );

			// 유효하지 않은 컨테이너는 건너뜁니다.
			if ( !viewerContainer ) { return; }

			// 이미 처리한 컨테이너는 건너뜁니다.
			if ( seenContainers.has( viewerContainer ) ) { return; }
			seenContainers.add( viewerContainer );

			// 마운트에 성공한 컨테이너는 건너뜁니다.
			if ( viewerContainer.dataset.lyricsViewerMounted === '1' ) { return; }

			targets.push( { mainMarkerElement: mainMarkerElement, viewerContainer: viewerContainer } );
		} );

		return targets;
	}

	// Codex 아이콘 로드 및 디버그 상태 노출
	function setupCodexIcons( Codex, iconAddRef, iconEditRef, iconSettingsRef ) {
		var CdxIcon = Codex.CdxIcon;

		var initialApiUrl = buildCodexIconsApiUrl();
		debugLog( 'list=codexicons API에서 아이콘 동적 로드:', initialApiUrl );
		fetchIconsFromApi( function ( apiIcons ) {
			// 받은 아이콘을 ref에 반영합니다.
			if ( apiIcons.cdxIconAdd ) { iconAddRef.value = apiIcons.cdxIconAdd; debugLog( 'API iconAdd 적용 완료' ); }
			if ( apiIcons.cdxIconEdit ) { iconEditRef.value = apiIcons.cdxIconEdit; debugLog( 'API iconEdit 적용 완료' ); }
			if ( apiIcons.cdxIconSettings ) { iconSettingsRef.value = apiIcons.cdxIconSettings; debugLog( 'API iconSettings 적용 완료' ); }
			try {
				window.__lyricsViewerDebug.lastApiIcons = apiIcons;
				window.__lyricsViewerDebug.lastIconAdd = iconAddRef.value;
				window.__lyricsViewerDebug.lastIconEdit = iconEditRef.value;
				window.__lyricsViewerDebug.lastIconSettings = iconSettingsRef.value;
			} catch ( error2 ) {}
		} );

		try {
			window.__lyricsViewerDebug.lastIconAdd = iconAddRef.value;
			window.__lyricsViewerDebug.lastIconEdit = iconEditRef.value;
			window.__lyricsViewerDebug.lastIconSettings = iconSettingsRef.value;
			window.__lyricsViewerDebug.getIconState = function () {
				return {
					hasIconAdd: !!iconAddRef.value,
					hasIconEdit: !!iconEditRef.value,
					hasIconSettings: !!iconSettingsRef.value,
					iconAddSource: iconAddRef.value ? 'api-list=codexicons' : 'missing',
					iconEditSource: iconEditRef.value ? 'api-list=codexicons' : 'missing',
					iconSettingsSource: iconSettingsRef.value ? 'api-list=codexicons' : 'missing',
					hasCdxIcon: !!CdxIcon
				};
			};
		} catch ( error ) {}

		// CdxIcon이 없다면 경고를 남깁니다.
		if ( !CdxIcon ) { debugWarn( 'Codex CdxIcon 컴포넌트를 찾을 수 없습니다. Codex 버전이 오래되었거나 모듈이 잘못 로드되었을 수 있습니다. Codex.CdxIcon 존재 여부를 확인하세요.' ); }

		return CdxIcon;
	}

	// 뷰어별 반응형 상태 생성
	function createViewerState( Vue ) {
		return Vue.reactive( {
			singleSelection: '',
			multiSelection: [ '' ],
			isDialogOpen: false,
			isPopoverOpen: false,
			isEditModalOpen: false,
			editTargetTitle: '',
			wikitext: '',
			originalWikitext: '',
			baseTimestamp: '',
			startTimestamp: '',
			editSummary: '',
			isFetchingWikitext: false,
			isSavingWikitext: false,
			editErrorMessage: '',
			isDiffShown: false,
			isDiffLoading: false,
			diffHtml: '',
			diffErrorMessage: ''
		} );
	}

	// 초기 표시 상태 적용
	function applyInitialViewerDisplay( viewerState, globalDisplayState, applyVariationVisibility, applyDisplayStyles ) {
		applyVariationVisibility( viewerState.singleSelection, viewerState.multiSelection, globalDisplayState.isMultimodeEnabled );
		applyDisplayStyles( globalDisplayState.fontSize, globalDisplayState.fontFamily, globalDisplayState.textAlignment, globalDisplayState.lineHeight, globalDisplayState.letterSpacing );
	}

	// 표시 상태 변경 감시
	function watchViewerDisplayChanges( Vue, viewerState, globalDisplayState, applyVariationVisibility, applyDisplayStyles ) {
		// 다중 선택 모드 변경 감시
		Vue.watch( function () { return globalDisplayState.isMultimodeEnabled; }, function ( isActive ) {
			// 다중 선택 모드가 활성화되어 있나요?
			if ( isActive ) {
				// 활성화됨 — 다중 선택이 비어 있다면 단일 선택으로 채웁니다.
				if ( !viewerState.multiSelection.length ) { viewerState.multiSelection = [ viewerState.singleSelection || '' ]; }
			} else if ( viewerState.multiSelection.length ) {
				// 비활성화됨 — 첫 선택을 단일 선택으로 복원합니다.
				viewerState.singleSelection = viewerState.multiSelection[ 0 ];
			}
			applyVariationVisibility( viewerState.singleSelection, viewerState.multiSelection, isActive );
		} );

		// 글자/글꼴/정렬/줄 간격/글자 간격 변경 감시
		Vue.watch( function () { return [ globalDisplayState.fontSize, globalDisplayState.fontFamily, globalDisplayState.textAlignment, globalDisplayState.lineHeight, globalDisplayState.letterSpacing ]; }, function () {
			applyDisplayStyles( globalDisplayState.fontSize, globalDisplayState.fontFamily, globalDisplayState.textAlignment, globalDisplayState.lineHeight, globalDisplayState.letterSpacing );
		} );
	}

	// 토글 값 바인딩 생성
	function buildToggleBinding( Vue, viewerState, globalDisplayState, applyVariationVisibility ) {
		return Vue.computed( {
			get: function () {
				return globalDisplayState.isMultimodeEnabled ? viewerState.multiSelection : viewerState.singleSelection;
			},
			set: function ( newValue ) {
				// 배열 값인가요? (다중 선택)
				if ( Array.isArray( newValue ) ) {
					// 배열 — 빈 배열이라면 무시합니다.
					if ( !newValue.length ) { return; }
					// 배열 — 다중 선택 갱신
					viewerState.multiSelection = newValue.slice();
				} else {
					// 단일 값 — 단일 선택 갱신
					viewerState.singleSelection = newValue == null ? '' : String( newValue );

					// 다중 선택이 단일 항목이라면 동기화합니다.
					if ( viewerState.multiSelection.length === 1 ) { viewerState.multiSelection = [ viewerState.singleSelection ]; }
				}
				applyVariationVisibility(
					viewerState.singleSelection,
					viewerState.multiSelection,
					globalDisplayState.isMultimodeEnabled
				);
			}
		} );
	}

	// Dialog 상태와 동작 생성
	function buildDialogBindings( Vue, viewerState, songPageTitle, existingVariationSet, openEditModal ) {
		var selectedType = Vue.ref( '' );
		var customVariationName = Vue.ref( '' );

		// 라디오 그룹 이름 — 뷰어마다 독립된 그룹이 되도록 고유한 값을 만듭니다.
		var lyricTypeRadioGroupName = 'mw-lyrics-viewer-lyric-type-' + ( ++radioGroupCounter );

		var dialogOpen = Vue.computed( {
			get: function () { return viewerState.isDialogOpen; },
			set: function ( value ) { viewerState.isDialogOpen = value; }
		} );

		// 대상 문서 제목 계산 — 유효하지 않은 제목은 null이 되어 프리뷰가 비활성화됩니다.
		var previewDocumentTitle = Vue.computed( function () {
			// 표준 유형이 선택되었다면 해당 유형 이름으로 제목을 만듭니다.
			if ( selectedType.value && selectedType.value !== '__custom' ) { return buildLyricsDocumentTitle( songPageTitle, selectedType.value ); }

			// 표준 유형이 아니라면 입력된 사용자 지정 이름으로 제목을 만듭니다.
			var trimmedCustomName = customVariationName.value.trim();
			return trimmedCustomName ? buildLyricsDocumentTitle( songPageTitle, trimmedCustomName ) : '';
		} );

		// 이미 존재하는 문서인지 계산
		var alreadyExists = Vue.computed( function () {
			// 제목이 비어 있다면 존재하지 않습니다.
			if ( !previewDocumentTitle.value ) { return false; }
			var variationName = ( previewDocumentTitle.value.split( '/' ).pop() || '' );
			return existingVariationSet.has( variationName );
		} );

		// 주 동작 버튼 정의
		var primaryAction = Vue.computed( function () {
			return {
				label: alreadyExists.value ? MESSAGES.editLabel : MESSAGES.createLabel,
				actionType: 'progressive',
				disabled: !previewDocumentTitle.value
			};
		} );
		var defaultAction = { label: MESSAGES.closeLabel };

		// 주 동작 실행 — 편집 모달 열기
		function handlePrimaryAction() {
			// 대상 문서 제목이 없다면 중단합니다.
			if ( !previewDocumentTitle.value ) { return; }
			viewerState.isDialogOpen = false;
			openEditModal( previewDocumentTitle.value );
		}

		// Dialog 닫힘 감시
		Vue.watch( function () { return viewerState.isDialogOpen; }, function ( isOpen ) {
			// 닫혔다면 선택을 초기화합니다.
			if ( !isOpen ) { selectedType.value = ''; customVariationName.value = ''; }
		} );

		return {
			selectedType: selectedType,
			customVariationName: customVariationName,
			dialogOpen: dialogOpen,
			previewDocumentTitle: previewDocumentTitle,
			lyricTypeRadioGroupName: lyricTypeRadioGroupName,
			primaryAction: primaryAction,
			defaultAction: defaultAction,
			handlePrimaryAction: handlePrimaryAction
		};
	}

	// 편집 모달 상태와 동작 생성
	function buildEditModalBindings( Vue, viewerState, lyricsRepository, currentPageTitle ) {
		var editModalOpen = Vue.computed( {
			get: function () { return viewerState.isEditModalOpen; },
			set: function ( value ) {
				// 닫기 시도인데 수정된 내용이 있다면 버릴지 브라우저 확인 대화상자로 묻습니다.
				if ( !value && viewerState.wikitext !== viewerState.originalWikitext && !window.confirm( MESSAGES.unsavedChangesMessage ) ) { return; }
				viewerState.isEditModalOpen = value;
			}
		} );

		// 편집 모달에 저장하지 않은 변경 사항이 있을 때 탭 이동/새로고침을 경고합니다.
		function handleBeforeUnload( beforeUnloadEvent ) {
			if ( !viewerState.isEditModalOpen || viewerState.wikitext === viewerState.originalWikitext ) { return; }
			beforeUnloadEvent.preventDefault();
			// Chrome은 returnValue가 설정되어야 대화상자를 표시합니다.
			beforeUnloadEvent.returnValue = '';
		}
		window.addEventListener( 'beforeunload', handleBeforeUnload );

		// 모달 내부 입력 상태를 뷰어 상태와 연결합니다.
		var editTargetTitle = Vue.computed( function () { return viewerState.editTargetTitle; } );
		var wikitext = Vue.computed( {
			get: function () { return viewerState.wikitext; },
			set: function ( value ) { viewerState.wikitext = value; }
		} );
		var editSummary = Vue.computed( {
			get: function () { return viewerState.editSummary; },
			set: function ( value ) { viewerState.editSummary = value; }
		} );

		// 저장 중에는 주 동작을 막습니다.
		var saveAction = Vue.computed( function () {
			return {
				label: MESSAGES.saveLabel,
				actionType: 'progressive',
				disabled: viewerState.isFetchingWikitext || viewerState.isSavingWikitext
			};
		} );
		var cancelAction = { label: MESSAGES.closeLabel };

		// 모달 열기 — 대상 문서의 위키텍스트를 불러옵니다.
		var fetchSequence = 0;
		function openEditModal( documentTitle ) {
			var requestSequence = ++fetchSequence;
			viewerState.editTargetTitle = documentTitle;
			viewerState.wikitext = '';
			viewerState.originalWikitext = '';
			viewerState.baseTimestamp = '';
			viewerState.startTimestamp = '';
			viewerState.editSummary = '';
			viewerState.editErrorMessage = '';
			viewerState.diffHtml = '';
			viewerState.diffErrorMessage = '';
			viewerState.isDiffShown = false;
			viewerState.isEditModalOpen = true;
			viewerState.isFetchingWikitext = true;
			lyricsRepository.fetchWikitext( documentTitle ).then( function ( fetchedRevision ) {
				// 다른 문서 요청이 시작되었다면 이전 요청의 결과를 반영하지 않습니다.
				if ( requestSequence !== fetchSequence ) { return; }
				viewerState.wikitext = fetchedRevision.text;
				viewerState.originalWikitext = fetchedRevision.text;
				viewerState.baseTimestamp = fetchedRevision.baseTimestamp;
				viewerState.startTimestamp = fetchedRevision.startTimestamp;
			}, function ( fetchError ) {
				// 다른 문서 요청이 시작되었다면 이전 요청의 결과를 반영하지 않습니다.
				if ( requestSequence !== fetchSequence ) { return; }
				viewerState.editErrorMessage = formatApiError( fetchError );
			} ).then( function () {
				// 다른 문서 요청이 시작되었다면 이전 요청의 로딩 상태를 반영하지 않습니다.
				if ( requestSequence !== fetchSequence ) { return; }
				viewerState.isFetchingWikitext = false;
			} );
		}

		// 편집 저장 — 곡 문서 캐시를 정리한 뒤 페이지를 새로고칩니다.
		function handleEditSave() {
			// 저장이 이미 진행 중이라면 중단합니다.
			if ( viewerState.isSavingWikitext ) { return; }
			viewerState.isSavingWikitext = true;
			viewerState.editErrorMessage = '';
			lyricsRepository.saveWikitext( viewerState.editTargetTitle, viewerState.wikitext, viewerState.editSummary, viewerState.baseTimestamp, viewerState.startTimestamp ).then( function ( saveSucceeded ) {
				// API 오류가 아니어도 저장이 성공하지 않았다면 새로고침하지 않고 안내합니다.
				if ( !saveSucceeded ) {
					viewerState.isSavingWikitext = false;
					viewerState.editErrorMessage = MESSAGES.saveFailedMessage;
					return;
				}
				// 트랜스클루전된 가사를 갱신하기 위해 현재 문서에 purge를 수행합니다.
				// 저장에 성공했으므로 현재 내용을 기준으로 삼아 나감 경고가 발동하지 않게 합니다.
				viewerState.originalWikitext = viewerState.wikitext;
				lyricsRepository.purgePage( currentPageTitle ).then( function ( purgeResponse ) {
					debugLog( '현재 문서 purge 완료:', currentPageTitle, purgeResponse );
					window.location.reload();
				}, function ( purgeError ) {
					// 캐시 정리에 실패해도 저장은 완료된 상태이므로 새로고침합니다.
					debugWarn( '곡 문서 캐시 정리에 실패했습니다: ' + formatApiError( purgeError ) );
					window.location.reload();
				} );
			}, function ( saveError ) {
				viewerState.isSavingWikitext = false;
				viewerState.editErrorMessage = formatApiError( saveError );
			} );
		}

		// 대상 문서 링크 URL 생성
		var editTargetUrl = Vue.computed( function () {
			try { return mw.util.getUrl( viewerState.editTargetTitle ); } catch ( error ) { return ''; }
		} );

		// 차이 보기 — 불러온 내용과 현재 내용의 차이를 요청합니다.
		var diffSequence = 0;
		function handleDiffRefresh() {
			var requestSequence = ++diffSequence;
			viewerState.isDiffShown = true;
			viewerState.diffHtml = '';
			viewerState.diffErrorMessage = '';

			// 변경 전후가 모두 비어 있다면 요청 없이 차이 없음으로 처리합니다.
			if ( !viewerState.originalWikitext && !viewerState.wikitext ) { return; }
			viewerState.isDiffLoading = true;

			// 차이 표 스타일은 mediawiki.diff.styles 모듈이 제공합니다.
			try {
				mw.loader.using( 'mediawiki.diff.styles' ).catch( function () {
					debugWarn( 'mediawiki.diff.styles 모듈을 불러오지 못했습니다.' );
				} );
			} catch ( error ) {}

			lyricsRepository.fetchWikitextDiff( viewerState.originalWikitext, viewerState.wikitext ).then( function ( diffHtml ) {
				// 다른 차이 요청이 시작되었다면 이전 요청의 결과를 반영하지 않습니다.
				if ( requestSequence !== diffSequence ) { return; }
				viewerState.diffHtml = diffHtml;
			}, function ( diffError ) {
				// 다른 차이 요청이 시작되었다면 이전 요청의 결과를 반영하지 않습니다.
				if ( requestSequence !== diffSequence ) { return; }
				viewerState.diffErrorMessage = formatApiError( diffError );
			} ).then( function () {
				// 다른 차이 요청이 시작되었다면 이전 요청의 로딩 상태를 반영하지 않습니다.
				if ( requestSequence !== diffSequence ) { return; }
				viewerState.isDiffLoading = false;
			} );
		}

		return {
			editModalOpen: editModalOpen,
			editTargetTitle: editTargetTitle,
			editTargetUrl: editTargetUrl,
			wikitext: wikitext,
			editSummary: editSummary,
			isFetchingWikitext: Vue.computed( function () { return viewerState.isFetchingWikitext; } ),
			isSavingWikitext: Vue.computed( function () { return viewerState.isSavingWikitext; } ),
			editErrorMessage: Vue.computed( function () { return viewerState.editErrorMessage; } ),
			saveAction: saveAction,
			cancelAction: cancelAction,
			openEditModal: openEditModal,
			handleEditSave: handleEditSave,
			isDiffShown: Vue.computed( function () { return viewerState.isDiffShown; } ),
			isDiffLoading: Vue.computed( function () { return viewerState.isDiffLoading; } ),
			diffHtml: Vue.computed( function () { return viewerState.diffHtml; } ),
			diffErrorMessage: Vue.computed( function () { return viewerState.diffErrorMessage; } ),
			handleDiffRefresh: handleDiffRefresh
		};
	}

	// Popover 앵커 상태 생성
	function buildPopoverAnchor( Vue ) {
		var settingsButtonAnchorRef = Vue.ref( null );
		var settingsButtonAnchorEl = Vue.computed( function () {
			var raw = settingsButtonAnchorRef.value;
			// 컴포넌트 래퍼라면 내부 엘리먼트를 반환합니다.
			if ( raw && raw.$el ) { return raw.$el; }
			// 일반 엘리먼트 — 그대로 반환합니다.
			return raw;
		} );
		return { settingsButtonAnchorRef: settingsButtonAnchorRef, settingsButtonAnchorEl: settingsButtonAnchorEl };
	}

	// 표시 설정 버튼 목록 생성
	function buildDisplaySettingButtons() {
		return {
			fontSizeButtons: [
				{ value: 'small', label: MESSAGES.fontSizeSmall },
				{ value: 'medium', label: MESSAGES.fontSizeMedium },
				{ value: 'large', label: MESSAGES.fontSizeLarge }
			],
			fontFamilyButtons: [
				{ value: 'default', label: MESSAGES.fontFamilyDefault },
				{ value: 'serif', label: MESSAGES.fontFamilySerif },
				{ value: 'sans-serif', label: MESSAGES.fontFamilySans },
				{ value: 'monospace', label: MESSAGES.fontFamilyMono }
			],
			alignmentButtons: [
				{ value: 'left', label: MESSAGES.alignLeft },
				{ value: 'center', label: MESSAGES.alignCenter },
				{ value: 'right', label: MESSAGES.alignRight }
			],
			lineHeightButtons: [
				{ value: 'narrow', label: MESSAGES.lineHeightNarrow },
				{ value: 'normal', label: MESSAGES.lineHeightNormal },
				{ value: 'wide', label: MESSAGES.lineHeightWide }
			],
			letterSpacingButtons: [
				{ value: 'narrow', label: MESSAGES.letterSpacingNarrow },
				{ value: 'normal', label: MESSAGES.letterSpacingNormal },
				{ value: 'wide', label: MESSAGES.letterSpacingWide }
			]
		};
	}

	// 표시 설정 변경 핸들러 생성
	function buildDisplaySettingHandlers( globalDisplayState ) {
		return {
			handleMultimodeChange: function ( value ) {
				globalDisplayState.isMultimodeEnabled = !!value;
				writeToStorage( STORAGE_KEYS.multimode, String( globalDisplayState.isMultimodeEnabled ) );
			},
			handleFontSizeChange: function ( value ) {
				globalDisplayState.fontSize = String( value );
				writeToStorage( STORAGE_KEYS.fontSize, globalDisplayState.fontSize );
			},
			handleFontFamilyChange: function ( value ) {
				globalDisplayState.fontFamily = String( value );
				writeToStorage( STORAGE_KEYS.fontFamily, globalDisplayState.fontFamily );
			},
			handleAlignmentChange: function ( value ) {
				globalDisplayState.textAlignment = String( value );
				writeToStorage( STORAGE_KEYS.align, globalDisplayState.textAlignment );
			},
			handleLineHeightChange: function ( value ) {
				globalDisplayState.lineHeight = String( value );
				writeToStorage( STORAGE_KEYS.lineHeight, globalDisplayState.lineHeight );
			},
			handleLetterSpacingChange: function ( value ) {
				globalDisplayState.letterSpacing = String( value );
				writeToStorage( STORAGE_KEYS.letterSpacing, globalDisplayState.letterSpacing );
			}
		};
	}

	// Settings Popover 상태와 버튼 생성
	function buildSettingsBindings( Vue, viewerState, globalDisplayState, Codex ) {
		var anchor = buildPopoverAnchor( Vue );
		var buttons = buildDisplaySettingButtons();
		var handlers = buildDisplaySettingHandlers( globalDisplayState );

		var popoverOpen = Vue.computed( {
			get: function () { return viewerState.isPopoverOpen; },
			set: function ( value ) { viewerState.isPopoverOpen = value; }
		} );

		return {
			settingsButtonAnchorRef: anchor.settingsButtonAnchorRef,
			settingsButtonAnchorEl: anchor.settingsButtonAnchorEl,
			popoverOpen: popoverOpen,
			fontSizeButtons: buttons.fontSizeButtons,
			fontFamilyButtons: buttons.fontFamilyButtons,
			alignmentButtons: buttons.alignmentButtons,
			lineHeightButtons: buttons.lineHeightButtons,
			letterSpacingButtons: buttons.letterSpacingButtons,
			handleMultimodeChange: handlers.handleMultimodeChange,
			handleFontSizeChange: handlers.handleFontSizeChange,
			handleFontFamilyChange: handlers.handleFontFamilyChange,
			handleAlignmentChange: handlers.handleAlignmentChange,
			handleLineHeightChange: handlers.handleLineHeightChange,
			handleLetterSpacingChange: handlers.handleLetterSpacingChange,
			isToggleSwitchAvailable: !!Codex.CdxToggleSwitch
		};
	}

	// Codex 컴포넌트 등록
	function registerViewerComponents( viewerApp, Codex ) {
		viewerApp.component( 'cdx-button', Codex.CdxButton );
		viewerApp.component( 'cdx-toggle-button-group', Codex.CdxToggleButtonGroup );
		viewerApp.component( 'cdx-dialog', Codex.CdxDialog );
		viewerApp.component( 'cdx-popover', Codex.CdxPopover );
		viewerApp.component( 'cdx-icon', Codex.CdxIcon );
		viewerApp.component( 'cdx-text-input', Codex.CdxTextInput );
		viewerApp.component( 'cdx-text-area', Codex.CdxTextArea );

		// Tooltip directive가 제공되면 등록합니다.
		if ( Codex.CdxTooltip ) { viewerApp.directive( 'tooltip', Codex.CdxTooltip ); }

		// 선택적 Codex 컴포넌트가 제공되면 등록합니다.
		if ( Codex.CdxToggleSwitch ) { viewerApp.component( 'cdx-toggle-switch', Codex.CdxToggleSwitch ); }
		if ( Codex.CdxMessage ) { viewerApp.component( 'cdx-message', Codex.CdxMessage ); }
		if ( Codex.CdxField ) { viewerApp.component( 'cdx-field', Codex.CdxField ); }
		if ( Codex.CdxLabel ) { viewerApp.component( 'cdx-label', Codex.CdxLabel ); }
	}

	// 단일 Viewer Vue 앱 생성 및 마운트
	function createViewerApp( viewerBar, songPageTitle, variationData, viewerState, globalDisplayState, editModalBindings, iconAddRef, iconEditRef, iconSettingsRef, applyVariationVisibility, Vue, Codex ) {
		// 토글 바인딩 생성
		var toggleValue = buildToggleBinding( Vue, viewerState, globalDisplayState, applyVariationVisibility );

		// Dialog 상태 생성
		var dialogBindings = buildDialogBindings( Vue, viewerState, songPageTitle, variationData.existingVariationSet, editModalBindings.openEditModal );

		// Settings 상태 생성
		var settingsBindings = buildSettingsBindings( Vue, viewerState, globalDisplayState, Codex );

		// 툴팁 디렉티브 사용 가능 여부
		var tooltipDirectiveAvailable = !!Codex.CdxTooltip;

		// Vue 앱 생성
		var viewerApp = Vue.createMwApp( {
			setup: function () {
				return {
					messages: MESSAGES,
					shouldShowVariationToggle: variationData.shouldShowVariationToggle,
					variationToggleButtons: variationData.variationToggleButtons,
					toggleValue: toggleValue,
					displayState: globalDisplayState,
					selectedType: dialogBindings.selectedType,
					customVariationName: dialogBindings.customVariationName,
					lyricTypeRadioGroupName: dialogBindings.lyricTypeRadioGroupName,
					dialogOpen: dialogBindings.dialogOpen,
					previewDocumentTitle: dialogBindings.previewDocumentTitle,
					primaryAction: dialogBindings.primaryAction,
					defaultAction: dialogBindings.defaultAction,
					handlePrimaryAction: dialogBindings.handlePrimaryAction,
					standardTypes: STANDARD_LYRICS_TYPES,
					songPageTitle: songPageTitle,
					iconAdd: iconAddRef,
					iconEdit: iconEditRef,
					iconSettings: iconSettingsRef,
					settingsButtonAnchorRef: settingsBindings.settingsButtonAnchorRef,
					settingsButtonAnchorEl: settingsBindings.settingsButtonAnchorEl,
					popoverOpen: settingsBindings.popoverOpen,
					fontSizeButtons: settingsBindings.fontSizeButtons,
					fontFamilyButtons: settingsBindings.fontFamilyButtons,
					alignmentButtons: settingsBindings.alignmentButtons,
					lineHeightButtons: settingsBindings.lineHeightButtons,
					letterSpacingButtons: settingsBindings.letterSpacingButtons,
					handleMultimodeChange: settingsBindings.handleMultimodeChange,
					handleFontSizeChange: settingsBindings.handleFontSizeChange,
					handleFontFamilyChange: settingsBindings.handleFontFamilyChange,
					handleAlignmentChange: settingsBindings.handleAlignmentChange,
					handleLineHeightChange: settingsBindings.handleLineHeightChange,
					handleLetterSpacingChange: settingsBindings.handleLetterSpacingChange,
					isToggleSwitchAvailable: settingsBindings.isToggleSwitchAvailable,
					tooltipDirectiveAvailable: tooltipDirectiveAvailable,
					editModalOpen: editModalBindings.editModalOpen,
					editTargetTitle: editModalBindings.editTargetTitle,
					wikitext: editModalBindings.wikitext,
					editSummary: editModalBindings.editSummary,
					isFetchingWikitext: editModalBindings.isFetchingWikitext,
					isSavingWikitext: editModalBindings.isSavingWikitext,
					editErrorMessage: editModalBindings.editErrorMessage,
					saveAction: editModalBindings.saveAction,
					cancelAction: editModalBindings.cancelAction,
					handleEditSave: editModalBindings.handleEditSave,
					editTargetUrl: editModalBindings.editTargetUrl,
					isDiffShown: editModalBindings.isDiffShown,
					isDiffLoading: editModalBindings.isDiffLoading,
					diffHtml: editModalBindings.diffHtml,
					diffErrorMessage: editModalBindings.diffErrorMessage,
					handleDiffRefresh: editModalBindings.handleDiffRefresh,
					isMessageAvailable: !!Codex.CdxMessage
				};
			},
			template: VIEWER_TEMPLATE
		} );

		// Codex 컴포넌트 등록
		registerViewerComponents( viewerApp, Codex );

		// Viewer 마운트
		viewerApp.mount( viewerBar );
		return viewerApp;
	}

	// 각 변형의 .ts-lyric-content 우상단에 편집/생성 버튼 주입
	function injectVariationEditButtons( songPageTitle, variationElements, Vue, Codex, iconAddRef, iconEditRef, openEditModal ) {
		var buttonSlots = [];
		variationElements.forEach( function ( variationElement ) {
			var variationValue = variationElement.getAttribute( 'data-lyric-variation' ) || '';
			var contentElement = variationElement.querySelector( '.ts-lyric-content' );

			// 가사 본문이 없으면 건너뜁니다.
			if ( !contentElement ) { return; }

			// 이전 초기화 시도에서 만든 마운트 지점이 있다면 재사용해 중복 생성을 막습니다.
			var mountPoint = contentElement.querySelector( ':scope > .mw--lyrics-variation-edit' );
			if ( !mountPoint ) {
				mountPoint = document.createElement( 'div' );
				mountPoint.className = 'mw--lyrics-variation-edit';
				contentElement.appendChild( mountPoint );
			}

			// 내용이 없는 변형이면 추가 버튼으로 표시합니다.
			buttonSlots.push( {
				target: mountPoint,
				variationValue: variationValue,
				isExistingDocument: !contentElement.hasAttribute( 'data-empty' )
			} );
		} );

		// 주입할 버튼이 없다면 앱을 만들지 않습니다.
		if ( !buttonSlots.length ) { return; }

		// 모든 버튼을 하나의 앱에서 렌더링해 각 마운트 지점으로 텔레포트합니다.
		var editApp = Vue.createMwApp( {
			setup: function () {
				return {
					iconAdd: iconAddRef,
					iconEdit: iconEditRef,
					messages: MESSAGES,
					tooltipAvailable: !!Codex.CdxTooltip,
					buttonSlots: buttonSlots,
					handleEdit: function ( buttonSlot ) {
						var documentTitle = buildLyricsDocumentTitle( songPageTitle, buttonSlot.variationValue );

						// 제목을 만들 수 없는 변형이라면 모달을 열지 않습니다.
						if ( documentTitle == null ) { debugWarn( '문서 제목을 만들 수 없어 편집을 시작하지 않습니다:', buttonSlot.variationValue ); return; }
						openEditModal( documentTitle );
					}
				};
			},
			template: '<teleport v-for="(buttonSlot, index) in buttonSlots" :key="index" :to="buttonSlot.target"><cdx-button weight="quiet" size="small" :aria-label="buttonSlot.isExistingDocument ? messages.editButtonAriaLabel : messages.addVariationAriaLabel" v-tooltip="tooltipAvailable ? (buttonSlot.isExistingDocument ? messages.editButtonTooltip : messages.addVariationTooltip) : null" @click="handleEdit(buttonSlot)"><cdx-icon v-if="buttonSlot.isExistingDocument ? iconEdit : iconAdd" :icon="buttonSlot.isExistingDocument ? iconEdit : iconAdd" size="small"></cdx-icon></cdx-button></teleport>'
		} );
		editApp.component( 'cdx-button', Codex.CdxButton );
		editApp.component( 'cdx-icon', Codex.CdxIcon );
		if ( Codex.CdxTooltip ) { editApp.directive( 'tooltip', Codex.CdxTooltip ); }

		// 앱 루트에는 텔레포트 앵커만 남으므로 화면에 표시되는 내용은 없습니다.
		var appRoot = document.createElement( 'div' );
		document.body.appendChild( appRoot );
		editApp.mount( appRoot );
	}

	// 단일 Viewer 인스턴스 마운트
	function mountSingleViewer( viewerContainer, mainMarkerElement, globalDisplayState, Vue, Codex, iconAddRef, iconEditRef, iconSettingsRef ) {
		var songPageTitle = ( mainMarkerElement.getAttribute( 'data-lyric-mainpage' ) || '' ).trim();

		// 변형 데이터 수집
		var variationData = collectVariationData( viewerContainer );

		// 변형 요소가 하나도 없다면 중단합니다.
		if ( !variationData.variationElements.length ) { return null; }

		// 서버 렌더링된 뷰어 바 참조
		var viewerBar = findViewerBar( viewerContainer );

		// 뷰어 바가 없다면 뷰어를 초기화하지 않습니다.
		if ( !viewerBar ) { return null; }

		// 기본 가사만 존재할 때 spacer를 앞으로 옮겨 추가/설정 버튼을 우측에 모읍니다.
		viewerBar.classList.toggle( 'mw--lyrics-viewer-bar--no-variation-toggle', !variationData.shouldShowVariationToggle );

		var applyVariationVisibility = createVariationApplier(
			viewerContainer, variationData.variationElements
		);
		var applyDisplayStyles = createStyleApplier( viewerContainer );

		// 뷰어 상태 생성
		var viewerState = createViewerState( Vue );

		// 편집 모달 상태 생성 — 에러 메시지를 화면 언어로 받기 위해 errorlang을 지정합니다.
		var editModalBindings = buildEditModalBindings( Vue, viewerState, createLyricsRepository( new mw.Api( {
			parameters: { errorlang: mw.config.get( 'wgUserLanguage' ) }
		} ) ), getCurrentPageTitle() );

		// 변형 편집/생성 버튼 주입
		injectVariationEditButtons( songPageTitle, variationData.variationElements, Vue, Codex, iconAddRef, iconEditRef, editModalBindings.openEditModal );

		// 초기 표시 반영
		applyInitialViewerDisplay( viewerState, globalDisplayState, applyVariationVisibility, applyDisplayStyles );

		// 표시 변경 감시
		watchViewerDisplayChanges( Vue, viewerState, globalDisplayState, applyVariationVisibility, applyDisplayStyles );

		// Vue 앱 생성 및 마운트
		var viewerApp = createViewerApp(
			viewerBar,
			songPageTitle,
			variationData,
			viewerState,
			globalDisplayState,
			editModalBindings,
			iconAddRef,
			iconEditRef,
			iconSettingsRef,
			applyVariationVisibility,
			Vue,
			Codex
		);

		// 마운트에 성공했으므로 재초기화되지 않게 표시합니다. 실패한 컨테이너는 다음 초기화 때 재시도됩니다.
		viewerContainer.dataset.lyricsViewerMounted = '1';

		return viewerApp;
	}

	// ResourceLoader 모듈에서 라이브러리 추출
	function extractViewerLibraries( requireFunction ) {
		var vueLibrary = null;
		var codexLibrary = null;
		try { vueLibrary = requireFunction( 'vue' ); } catch ( error ) { vueLibrary = window.Vue; }
		try { codexLibrary = requireFunction( '@wikimedia/codex' ); } catch ( error ) {
			try { codexLibrary = requireFunction( 'codex' ); } catch ( innerError ) {}
		}
		return { vueLibrary: vueLibrary, codexLibrary: codexLibrary };
	}

	// 라이브러리 확보 후 마운트 시도 — 마운트 성공 여부를 반환합니다.
	function tryMountViewers( requireFunction ) {
		var libs = extractViewerLibraries( requireFunction );
		debugLog( 'tryMount — vue:', !!libs.vueLibrary, 'codex:', !!libs.codexLibrary );

		// Vue 또는 Codex를 찾지 못했다면 경고를 남기고 실패를 반환합니다.
		if ( !libs.vueLibrary || !libs.codexLibrary ) {
			debugWarn( 'Vue 또는 Codex 모듈을 찾을 수 없습니다. ResourceLoader 모듈명이 다른지 확인하세요. mw.loader.getState 확인 필요.' );
			return false;
		}

		mountViewers( libs.vueLibrary, libs.codexLibrary );
		return true;
	}

	// 'codex' 모듈명으로 재시도 — 오래된 MediaWiki/Codex 설치를 위한 대체 경로입니다.
	function retryMountWithLegacyCodex() {
		try {
			mw.loader.using( [ 'vue', 'codex', 'mediawiki.api', 'mediawiki.Title' ] ).then( tryMountViewers ).catch( function () {
				debugWarn( 'vue와 codex 모듈을 모두 불러오지 못해 Viewer를 초기화할 수 없습니다.' );
			} );
		} catch ( error ) {}
	}

	// 진입점 — ResourceLoader로 Vue/Codex를 불러온 뒤 Viewer를 마운트
	function initializeViewers() {
		// 새로고침 전 세션의 디버그 로그를 출력합니다.
		if ( isDebugEnabled() ) { dumpBufferedDebugLogs(); }

		// ResourceLoader로 Vue/Codex 로드
		var basePromise;
		try {
			basePromise = mw.loader.using( [ 'vue', '@wikimedia/codex', 'mediawiki.api', 'mediawiki.Title' ] );
		} catch ( error ) {
			basePromise = null;
		}

		// Promise가 유효하지 않다면 구식 모듈명으로 재시도합니다.
		if ( !basePromise || typeof basePromise.then !== 'function' ) {
			retryMountWithLegacyCodex();
			return;
		}

		// 로드 성공 시 마운트, 마운트에 실패했다면 구식 모듈명으로 재시도합니다.
		basePromise.then( function ( requireFunction ) {
			if ( !tryMountViewers( requireFunction ) ) { retryMountWithLegacyCodex(); }
		} ).catch( retryMountWithLegacyCodex );
	}

	// 전체 Viewer 마운트
	function mountViewers( Vue, Codex ) {
		// 표시 대상 수집
		var seenContainers = new Set();
		var viewerTargets = collectViewerTargets( seenContainers );

		// 표시 대상이 하나도 없다면 아이콘 요청 없이 중단합니다.
		if ( !viewerTargets.length ) { return; }

		// Codex 아이콘 준비
		var iconAddRef = Vue.ref( null );
		var iconEditRef = Vue.ref( null );
		var iconSettingsRef = Vue.ref( null );
		setupCodexIcons( Codex, iconAddRef, iconEditRef, iconSettingsRef );

		// 전역 표시 상태 생성 및 동기화
		var globalDisplayState = createGlobalDisplayState( Vue );
		syncDisplayStateWithStorage( globalDisplayState );

		// 각 대상에 Viewer 마운트
		viewerTargets.forEach( function ( target ) {
			mountSingleViewer(
				target.viewerContainer,
				target.mainMarkerElement,
				globalDisplayState,
				Vue,
				Codex,
				iconAddRef,
				iconEditRef,
				iconSettingsRef
			);
		} );
	}

	// DOM 준비 상태에 따라 초기화를 분기합니다.
	if ( document.readyState === 'loading' ) {
		document.addEventListener( 'DOMContentLoaded', initializeViewers );
	} else {
		initializeViewers();
	}

	// 편집 미리보기, AJAX 치환 등으로 나중에 삽입되는 콘텐츠도 초기화합니다.
	// 훅은 초기 로딩에도 발화하지만 마운트 가드 덕분에 이미 마운트된 컨테이너는 중복 초기화되지 않습니다.
	try {
		mw.hook( 'wikipage.content' ).add( initializeViewers );
	} catch ( error ) {}
}() );