미디어위키:Common.js

신사 (토론 | 기여)님의 2026년 8월 20일 (목) 14:15 판

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

  • 파이어폭스 / 사파리: Shift 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5 또는 Ctrl-R을 입력 (Mac에서는 ⌘-R)
  • 구글 크롬: Ctrl-Shift-R키를 입력 (Mac에서는 ⌘-Shift-R)
  • 인터넷 익스플로러 / 엣지: Ctrl 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5를 입력.
  • 오페라: Ctrl-F5를 입력.
/**
 * MediaWiki 커스텀 펼치기 / 접기 애니메이션
 *
 * 지원:
 *
 * 1. 일반 구조
 *
 * .mw-customtoggle-XXX
 * #mw-customcollapsible-XXX
 * └─ .mw-collapsible-content
 *
 *
 * 2. 동일 ID 2개를 사용하는 교대형 구조
 *
 * #mw-customcollapsible-XXX
 * └─ [ 버튼 ]
 *
 * #mw-customcollapsible-XXX.mw-collapsed
 * ├─ [ 버튼 ]
 * └─ 실제 내용
 *
 *
 * 3. 여러 독립 토글
 *
 * TWICE_KOR
 * TWICE_OVS
 * TWICE_ETC
 * TWICE_REL
 *
 *
 * 애니메이션:
 * height만 사용
 * opacity 사용하지 않음
 */

( function ( $, mw ) {
	'use strict';

	var DURATION = 300;
	var EASING = 'swing';

	var DATA_DISPLAY =
		'mwCustomCollapseDisplay';


	/**
	 * 토글 클래스에서 식별자 추출
	 *
	 * mw-customtoggle-Flora_LACTEA_01
	 * →
	 * Flora_LACTEA_01
	 */
	function getToggleName( toggle ) {
		var prefix =
			'mw-customtoggle-';

		var classes =
			toggle.classList;

		var i;

		for (
			i = 0;
			i < classes.length;
			i++
		) {
			if (
				classes[ i ].indexOf(
					prefix
				) === 0
			) {
				return classes[ i ].substring(
					prefix.length
				);
			}
		}

		return null;
	}


	/**
	 * attribute selector escape
	 */
	function escapeAttribute( value ) {
		return String( value )
			.replace(
				/\\/g,
				'\\\\'
			)
			.replace(
				/"/g,
				'\\"'
			);
	}


	/**
	 * 같은 name에 해당하는 모든 target 검색
	 *
	 * 동일 ID가 여러 개 있어도 전부 가져옴.
	 */
	function getTargets( name ) {
		if ( !name ) {
			return [];
		}

		var id =
			'mw-customcollapsible-' +
			name;

		return document.querySelectorAll(
			'[id="' +
			escapeAttribute( id ) +
			'"]'
		);
	}


	/**
	 * target의 직계
	 * .mw-collapsible-content 검색
	 *
	 * 중첩된 collapsible의 content를
	 * 잘못 잡지 않도록 children만 검사.
	 */
	function getDirectContent( target ) {
		var children =
			target.children;

		var i;

		for (
			i = 0;
			i < children.length;
			i++
		) {
			if (
				children[ i ]
					.classList
					.contains(
						'mw-collapsible-content'
					)
			) {
				return children[ i ];
			}
		}

		return null;
	}


	/**
	 * 실제 애니메이션할 요소
	 *
	 * 일반 MediaWiki:
	 * .mw-collapsible-content
	 *
	 * 교대형:
	 * target 자체
	 */
	function getAnimatedElement( target ) {
		var content =
			getDirectContent(
				target
			);

		if ( content ) {
			return content;
		}

		return target;
	}


	/**
	 * target 자신의 직계 자식 중
	 * 해당 name의 토글 찾기
	 *
	 * 교대형 구조에서 펼쳐지는 쪽의
	 * 버튼 텍스트를 잠시 숨기기 위해 사용.
	 */
	function getOwnToggle(
		target,
		name
	) {
		var className =
			'mw-customtoggle-' +
			name;

		var children =
			target.children;

		var i;

		for (
			i = 0;
			i < children.length;
			i++
		) {
			if (
				children[ i ].classList &&
				children[ i ].classList.contains(
					className
				)
			) {
				return children[ i ];
			}
		}

		return null;
	}


	/**
	 * 현재 접힌 상태인지 확인
	 */
	function isCollapsed(
		target,
		element
	) {
		if (
			target.classList.contains(
				'mw-collapsed'
			)
		) {
			return true;
		}

		return (
			window
				.getComputedStyle(
					element
				)
				.display === 'none'
		);
	}


	/**
	 * 현재 display값 기억
	 *
	 * inline-block 등을 다시 복원하기 위해 사용.
	 */
	function rememberDisplay( element ) {
		var display =
			window
				.getComputedStyle(
					element
				)
				.display;

		if (
			display &&
			display !== 'none'
		) {
			$( element ).data(
				DATA_DISPLAY,
				display
			);
		}
	}


	/**
	 * 펼칠 때 사용할 display 계산
	 */
	function getOpenDisplay( element ) {

		var stored =
			$( element ).data(
				DATA_DISPLAY
			);

		if ( stored ) {
			return stored;
		}


		/*
		 * 유효한 인라인 display가 있으면 사용
		 */
		if (
			element.style.display &&
			element.style.display !== 'none'
		) {
			return element.style.display;
		}


		var computed =
			window
				.getComputedStyle(
					element
				)
				.display;


		if (
			computed &&
			computed !== 'none'
		) {
			return computed;
		}


		switch ( element.tagName ) {

			case 'TABLE':
				return 'table';

			case 'TBODY':
				return 'table-row-group';

			case 'THEAD':
				return 'table-header-group';

			case 'TFOOT':
				return 'table-footer-group';

			case 'TR':
				return 'table-row';

			case 'TD':
			case 'TH':
				return 'table-cell';

			case 'COLGROUP':
				return 'table-column-group';

			case 'COL':
				return 'table-column';

			case 'CAPTION':
				return 'table-caption';

			case 'LI':
				return 'list-item';
		}


		/*
		 * mw-collapsible 자체인 경우
		 *
		 * 100% 폭이면 block,
		 * 그 외에는 inline-block 우선.
		 */
		if (
			element.classList.contains(
				'mw-collapsible'
			)
		) {

			if (
				element.style.width === '100%' ||
				element.style.width === '100.0%'
			) {
				return 'block';
			}

			return 'inline-block';
		}


		return 'block';
	}


	/**
	 * 같은 name을 가진 모든 toggle의
	 * aria-expanded 변경
	 */
	function updateAria(
		name,
		expanded
	) {
		var className =
			'mw-customtoggle-' +
			name;

		var toggles =
			document.getElementsByClassName(
				className
			);

		var i;

		for (
			i = 0;
			i < toggles.length;
			i++
		) {
			toggles[ i ].setAttribute(
				'aria-expanded',
				expanded ?
					'true' :
					'false'
			);
		}
	}


	/**
	 * 펼치기
	 *
	 * hideOwnToggle이 true인 경우
	 * 펼쳐지는 쪽의 버튼 텍스트는
	 * 애니메이션 종료까지 visibility:hidden.
	 *
	 * 공간 자체는 유지되므로
	 * 레이아웃이 갑자기 변하지 않음.
	 */
	function expand(
		target,
		element,
		name,
		hideOwnToggle
	) {
		var $element =
			$( element );

		var ownToggle =
			null;


		if (
			hideOwnToggle &&
			element === target
		) {
			ownToggle =
				getOwnToggle(
					target,
					name
				);

			if ( ownToggle ) {
				ownToggle.style.visibility =
					'hidden';
			}
		}


		$element.stop(
			true,
			false
		);


		target.classList.remove(
			'mw-collapsed'
		);


		/**
		 * 일반 MediaWiki 구조
		 *
		 * target
		 * └─ .mw-collapsible-content
		 */
		if (
			element !== target
		) {

			$element
				.stop(
					true,
					false
				)
				.slideDown(
					{
						duration:
							DURATION,

						easing:
							EASING,

						complete:
							function () {

								$element.css(
									{
										height:
											'',

										overflow:
											''
									}
								);


								if (
									ownToggle
								) {
									ownToggle.style.visibility =
										'';
								}
							}
					}
				);

			return;
		}


		/**
		 * target 자체가 display:none인 구조
		 */
		var display =
			getOpenDisplay(
				element
			);


		$element.css(
			{
				display:
					display,

				height:
					'auto',

				overflow:
					'hidden'
			}
		);


		var targetHeight =
			element.scrollHeight;


		$element.css(
			{
				height:
					'0px'
			}
		);


		/*
		 * 강제 reflow
		 */
		void element.offsetHeight;


		$element.animate(
			{
				height:
					targetHeight
			},
			{
				duration:
					DURATION,

				easing:
					EASING,

				complete:
					function () {

						$element.css(
							{
								height:
									'',

								overflow:
									''
							}
						);


						/*
						 * 펼침이 완전히 끝난 뒤
						 * 내부 버튼 표시
						 */
						if (
							ownToggle
						) {
							ownToggle.style.visibility =
								'';
						}
					}
			}
		);
	}


	/**
	 * 접기
	 */
	function collapse(
		target,
		element
	) {
		var $element =
			$( element );


		$element.stop(
			true,
			false
		);


		rememberDisplay(
			element
		);


		/**
		 * 일반 MediaWiki 구조
		 */
		if (
			element !== target
		) {

			$element.slideUp(
				{
					duration:
						DURATION,

					easing:
						EASING,

					complete:
						function () {

							target.classList.add(
								'mw-collapsed'
							);


							$element.css(
								{
									height:
										'',

									overflow:
										''
								}
							);
						}
				}
			);

			return;
		}


		/**
		 * target 자체가 접히는 구조
		 */
		var startHeight =
			element
				.getBoundingClientRect()
				.height;


		$element.css(
			{
				height:
					startHeight +
					'px',

				overflow:
					'hidden'
			}
		);


		void element.offsetHeight;


		$element.animate(
			{
				height:
					0
			},
			{
				duration:
					DURATION,

				easing:
					EASING,

				complete:
					function () {

						target.classList.add(
							'mw-collapsed'
						);


						$element.css(
							{
								display:
									'none',

								height:
									'',

								overflow:
									''
							}
						);
					}
			}
		);
	}


	/**
	 * 그룹 전체 토글
	 *
	 * target이 하나라면 일반 토글.
	 *
	 * 동일 ID target이 여러 개라면
	 * 각 요소를 현재 상태 반대로 변경.
	 *
	 * LACTEA:
	 *
	 * A = 상단 버튼
	 * B = 실제 내용
	 *
	 * 클릭 전:
	 *
	 * A 열림
	 * B 닫힘
	 *
	 * 클릭 후:
	 *
	 * A 닫힘
	 * B 열림
	 */
	function toggleGroup(
		toggle
	) {
		var name =
			getToggleName(
				toggle
			);


		if ( !name ) {
			return false;
		}


		var targets =
			getTargets(
				name
			);


		if (
			!targets ||
			!targets.length
		) {
			return false;
		}


		var multipleTargets =
			targets.length > 1;


		var i;
		var openedAny =
			false;


		for (
			i = 0;
			i < targets.length;
			i++
		) {

			var target =
				targets[ i ];


			var element =
				getAnimatedElement(
					target
				);


			var collapsed =
				isCollapsed(
					target,
					element
				);


			if ( collapsed ) {

				/*
				 * 동일 ID가 여러 개인 경우
				 * 새로 펼쳐지는 쪽 토글을
				 * 애니메이션 도중 숨김.
				 *
				 * 따라서
				 *
				 * [ 음반 목록 ]
				 * [ 음반 목록 ]
				 *
				 * 처럼 겹쳐 보이지 않음.
				 */
				expand(
					target,
					element,
					name,
					multipleTargets
				);


				openedAny =
					true;

			} else {

				collapse(
					target,
					element
				);
			}
		}


		/*
		 * 일반적인 target 1개 구조만
		 * ARIA 상태를 일괄 갱신.
		 *
		 * 교대형은 두 target의 상태가
		 * 서로 반대이므로 제외.
		 */
		if (
			targets.length === 1
		) {

			updateAria(
				name,
				openedAny
			);
		}


		return true;
	}


	/**
	 * 클릭
	 *
	 * capture=true를 사용해서
	 * MediaWiki 기본 customtoggle보다
	 * 먼저 처리.
	 */
	document.addEventListener(
		'click',
		function ( event ) {

			var toggle =
				event.target.closest(
					'.mw-customtoggle'
				);


			if ( !toggle ) {
				return;
			}


			if (
				!getToggleName(
					toggle
				)
			) {
				return;
			}


			event.preventDefault();

			event.stopPropagation();

			event.stopImmediatePropagation();


			toggleGroup(
				toggle
			);
		},
		true
	);


	/**
	 * Enter / Space
	 */
	document.addEventListener(
		'keydown',
		function ( event ) {

			if (
				event.key !== 'Enter' &&
				event.key !== ' '
			) {
				return;
			}


			var toggle =
				event.target.closest(
					'.mw-customtoggle'
				);


			if ( !toggle ) {
				return;
			}


			if (
				!getToggleName(
					toggle
				)
			) {
				return;
			}


			event.preventDefault();

			event.stopPropagation();

			event.stopImmediatePropagation();


			toggleGroup(
				toggle
			);
		},
		true
	);


	/**
	 * 최초 display 상태 저장
	 *
	 * display:inline-block 같은 값을
	 * 나중에 정확히 복원하기 위한 처리.
	 */
	function initializeDisplays(
		root
	) {
		var collapsibles =
			root.querySelectorAll(
				'.mw-collapsible'
			);

		var i;


		for (
			i = 0;
			i < collapsibles.length;
			i++
		) {

			var element =
				collapsibles[ i ];


			var display =
				window
					.getComputedStyle(
						element
					)
					.display;


			if (
				display &&
				display !== 'none'
			) {

				$( element ).data(
					DATA_DISPLAY,
					display
				);
			}
		}
	}


	/**
	 * 최초 페이지 로드
	 */
	$( function () {

		initializeDisplays(
			document
		);

	} );


	/**
	 * MediaWiki 동적 콘텐츠 대응
	 *
	 * AJAX / Lua / 확장기능 등으로
	 * 나중에 들어온 콘텐츠도 처리.
	 */
	if (
		mw &&
		mw.hook
	) {

		mw.hook(
			'wikipage.content'
		).add(
			function ( $content ) {

				if (
					$content &&
					$content[ 0 ]
				) {

					initializeDisplays(
						$content[ 0 ]
					);
				}
			}
		);
	}


}( jQuery, mediaWiki ) );