미디어위키:Common.js

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

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

  • 파이어폭스 / 사파리: Shift 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5 또는 Ctrl-R을 입력 (Mac에서는 ⌘-R)
  • 구글 크롬: Ctrl-Shift-R키를 입력 (Mac에서는 ⌘-Shift-R)
  • 인터넷 익스플로러 / 엣지: Ctrl 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5를 입력.
  • 오페라: Ctrl-F5를 입력.
/**
 * MediaWiki 커스텀 펼치기 / 접기 애니메이션
 *
 * 지원:
 * - mw-customtoggle-XXX
 * - mw-customcollapsible-XXX
 * - 일반 mw-collapsible-content
 * - 동일 ID 2개 교대형 구조
 * - flex 내부 여러 토글
 * - 중첩 표
 *
 * 효과:
 * - 높이 애니메이션만 적용
 * - opacity 없음
 */

( function ( $ ) {
	'use strict';

	var DURATION = 180;
	var EASING = 'swing';

	var VIEWPORT_CLASS =
		'mw-custom-collapse-viewport';

	var INNER_CLASS =
		'mw-custom-collapse-viewport-inner';

	var DATA_DISPLAY =
		'mwCustomCollapseOriginalDisplay';


	/**
	 * mw-customtoggle-XXX → XXX
	 */
	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,
				'\\"'
			);
	}


	/**
	 * 동일한 이름의 target 모두 검색
	 */
	function getTargets( name ) {
		var id =
			'mw-customcollapsible-' +
			name;

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


	/**
	 * 직계 mw-collapsible-content 찾기
	 */
	function getDirectContent( target ) {
		var children =
			target.children;

		var i;

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

		return null;
	}


	/**
	 * 현재 접힘 상태
	 */
	function isCollapsed( target ) {
		if (
			target.classList.contains(
				'mw-collapsed'
			)
		) {
			return true;
		}

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


	/**
	 * display 상태 기억
	 */
	function rememberDisplay( target ) {
		var display =
			window.getComputedStyle(
				target
			).display;

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


	/**
	 * 펼칠 때 target의 display 복원
	 */
	function getOpenDisplay( target ) {
		var saved =
			$( target ).data(
				DATA_DISPLAY
			);

		if ( saved ) {
			return saved;
		}


		/*
		 * HTML에 원래 display 값이 남아 있다면
		 * 그 값을 우선 사용
		 */
		if (
			target.style.display &&
			target.style.display !== 'none'
		) {
			return target.style.display;
		}


		/*
		 * TABLE 계열
		 */
		switch ( target.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 'LI':
				return 'list-item';
		}


		/*
		 * mw-collapsible은 대부분 DIV
		 */
		return 'block';
	}


	/**
	 * 애니메이션용 viewport 검색
	 */
	function getViewport( target ) {
		var children =
			target.children;

		var i;

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

		return null;
	}


	/**
	 * 애니메이션용 viewport 생성
	 *
	 * 내부 TABLE 자체에는 height를 적용하지 않고
	 * viewport 높이만 변화시킴.
	 */
	function ensureViewport( target ) {
		var existing =
			getViewport(
				target
			);

		if ( existing ) {
			return existing;
		}


		var viewport =
			document.createElement(
				'div'
			);

		var inner =
			document.createElement(
				'div'
			);


		viewport.className =
			VIEWPORT_CLASS;

		inner.className =
			INNER_CLASS;


		/*
		 * MediaWiki 표준 구조라면
		 * mw-collapsible-content의 내용만 이동
		 */
		var content =
			getDirectContent(
				target
			);


		if ( content ) {

			while (
				content.firstChild
			) {
				inner.appendChild(
					content.firstChild
				);
			}


			viewport.appendChild(
				inner
			);


			content.appendChild(
				viewport
			);


			return viewport;
		}


		/*
		 * mw-collapsible-content가 없는 구조
		 *
		 * target 내부 전체를 viewport 안으로 이동
		 */
		while (
			target.firstChild
		) {
			inner.appendChild(
				target.firstChild
			);
		}


		viewport.appendChild(
			inner
		);


		target.appendChild(
			viewport
		);


		return viewport;
	}


	/**
	 * viewport inner
	 */
	function getViewportInner(
		viewport
	) {
		var children =
			viewport.children;

		var i;

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

		return null;
	}


	/**
	 * target 안의 자기 토글 검색
	 *
	 * 동일 ID 2개 교대형에서 사용.
	 */
	function getOwnToggle(
		target,
		name
	) {
		var className =
			'mw-customtoggle-' +
			name;

		var elements =
			target.getElementsByClassName(
				className
			);

		if (
			elements.length
		) {
			return elements[ 0 ];
		}

		return null;
	}


	/**
	 * 펼치기
	 */
	function expand(
		target,
		name,
		multipleTargets
	) {
		var $target =
			$( target );


		/*
		 * 진행 중인 target animation 중단
		 */
		$target.stop(
			true,
			true
		);


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


		/*
		 * 바깥 target 먼저 표시
		 */
		target.style.display =
			getOpenDisplay(
				target
			);


		var viewport =
			ensureViewport(
				target
			);


		var inner =
			getViewportInner(
				viewport
			);


		if ( !inner ) {
			return;
		}


		var ownToggle =
			null;


		/*
		 * 동일 ID가 2개인 교대형 구조:
		 *
		 * 접히는 버튼과 펼쳐지는 버튼이
		 * 애니메이션 중 겹치지 않도록 처리.
		 */
		if ( multipleTargets ) {
			ownToggle =
				getOwnToggle(
					target,
					name
				);

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


		var $viewport =
			$( viewport );


		$viewport.stop(
			true,
			true
		);


		viewport.style.display =
			'block';

		viewport.style.overflow =
			'hidden';

		viewport.style.height =
			'auto';


		/*
		 * 내부 table은 건드리지 않고
		 * inner의 실제 높이를 측정
		 */
		var targetHeight =
			inner.getBoundingClientRect()
				.height;


		viewport.style.height =
			'0px';


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


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

				easing:
					EASING,

				complete:
					function () {

						viewport.style.height =
							'';

						viewport.style.overflow =
							'';


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


	/**
	 * 접기
	 */
	function collapse(
		target
	) {
		rememberDisplay(
			target
		);


		var viewport =
			ensureViewport(
				target
			);


		var inner =
			getViewportInner(
				viewport
			);


		if ( !inner ) {
			return;
		}


		var $viewport =
			$( viewport );


		$viewport.stop(
			true,
			true
		);


		var currentHeight =
			viewport.getBoundingClientRect()
				.height;


		/*
		 * 혹시 viewport 높이가 0으로 잡히는 경우
		 * inner 실제 높이 사용
		 */
		if (
			currentHeight <= 0
		) {
			currentHeight =
				inner.getBoundingClientRect()
					.height;
		}


		viewport.style.height =
			currentHeight +
			'px';

		viewport.style.overflow =
			'hidden';


		void viewport.offsetHeight;


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

				easing:
					EASING,

				complete:
					function () {

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


						target.style.display =
							'none';


						viewport.style.height =
							'';

						viewport.style.overflow =
							'';
					}
			}
		);
	}


	/**
	 * ARIA 갱신
	 */
	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'
			);
		}
	}


	/**
	 * 그룹 토글
	 */
	function toggleGroup(
		toggle
	) {
		var name =
			getToggleName(
				toggle
			);


		if ( !name ) {
			return false;
		}


		var targets =
			getTargets(
				name
			);


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


		var multipleTargets =
			targets.length > 1;


		var i;

		var opened =
			false;


		/*
		 * 클릭 직전의 상태를 먼저 기록
		 *
		 * 각 요소를 순차 수정하다가
		 * 다음 요소의 상태 판별이 꼬이지 않도록 함.
		 */
		var states =
			[];


		for (
			i = 0;
			i < targets.length;
			i++
		) {
			states.push(
				isCollapsed(
					targets[ i ]
				)
			);
		}


		/*
		 * 상태 변경
		 */
		for (
			i = 0;
			i < targets.length;
			i++
		) {

			if (
				states[ i ]
			) {
				expand(
					targets[ i ],
					name,
					multipleTargets
				);

				opened =
					true;

			} else {

				collapse(
					targets[ i ]
				);
			}
		}


		/*
		 * 일반 target 하나짜리만
		 * aria-expanded 갱신.
		 */
		if (
			targets.length === 1
		) {
			updateAria(
				name,
				opened
			);
		}


		return true;
	}


	/**
	 * 클릭
	 */
	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 상태 기억
	 */
	function initialize(
		root
	) {
		var elements =
			root.querySelectorAll(
				'.mw-collapsible'
			);


		var i;


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

			if (
				window.getComputedStyle(
					elements[ i ]
				).display !== 'none'
			) {
				rememberDisplay(
					elements[ i ]
				);
			}
		}
	}


	$( function () {
		initialize(
			document
		);
	} );


	/*
	 * MediaWiki AJAX / 동적 콘텐츠
	 */
	if (
		window.mw &&
		mw.hook
	) {
		mw.hook(
			'wikipage.content'
		).add(
			function ( $content ) {

				if (
					$content &&
					$content[ 0 ]
				) {
					initialize(
						$content[ 0 ]
					);
				}
			}
		);
	}


}( jQuery ) );