미디어위키:가사.js
창팝위키
더 많은 작업
참고: 설정을 저장한 후에 바뀐 점을 확인하기 위해서는 브라우저의 캐시를 새로 고쳐야 합니다.
- 파이어폭스 / 사파리: 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 ) {}
}() );