미디어위키:Common.js

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

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

  • 파이어폭스 / 사파리: Shift 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5 또는 Ctrl-R을 입력 (Mac에서는 ⌘-R)
  • 구글 크롬: Ctrl-Shift-R키를 입력 (Mac에서는 ⌘-Shift-R)
  • 인터넷 익스플로러 / 엣지: Ctrl 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5를 입력.
  • 오페라: Ctrl-F5를 입력.
/**
 * MediaWiki 커스텀 펼치기 / 접기 애니메이션
 *
 * 지원:
 * - 일반 mw-collapsible-content
 * - mw-collapsible 자체가 display:none 되는 구조
 * - 동일 ID 2개 교대형 구조
 * - flex 형태 복수 토글
 * - 중첩 table / rowspan / infobox
 *
 * 특징:
 * - opacity 없음
 * - 내부 table 자체의 height는 변경하지 않음
 * - wrapper의 height + clip-path만 애니메이션
 */

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

	var DURATION = 180;

	var EASING =
		'cubic-bezier(0.4, 0, 0.2, 1)';

	var DATA_DISPLAY =
		'mwCustomCollapseDisplay';

	var DATA_ANIMATION =
		'mwCustomCollapseAnimation';


	/**
	 * 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;
	}


	/**
	 * 실제 애니메이션 요소
	 *
	 * mw-collapsible-content가 있으면 그것,
	 * 없으면 target 자체.
	 *
	 * 이번 방식에서는 DOM wrapper를 새로 만들지 않음.
	 */
	function getAnimatedElement( target ) {
		var content =
			getDirectContent(
				target
			);

		if ( content ) {
			return content;
		}

		return target;
	}


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

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

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


	/**
	 * 현재 display 기억
	 */
	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;
		}


		if (
			element.style.display &&
			element.style.display !== 'none'
		) {
			return element.style.display;
		}


		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 'LI':
				return 'list-item';
		}


		/*
		 * 현재 제공하신 정보상자에서는
		 * mw-customcollapsible-1이 div이므로 block.
		 */
		return 'block';
	}


	/**
	 * 진행 중인 Web Animation 취소
	 */
	function cancelAnimation( element ) {
		var animation =
			$( element ).data(
				DATA_ANIMATION
			);

		if ( animation ) {
			try {
				animation.cancel();
			} catch ( e ) {
				// 무시
			}

			$( element ).removeData(
				DATA_ANIMATION
			);
		}
	}


	/**
	 * target 내부의 해당 토글 찾기
	 */
	function getOwnToggle(
		target,
		name
	) {
		var className =
			'mw-customtoggle-' +
			name;

		var toggles =
			target.getElementsByClassName(
				className
			);

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

		return null;
	}


	/**
	 * 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 expand(
		target,
		element,
		name,
		hideOwnToggle
	) {
		cancelAnimation(
			element
		);


		var ownToggle =
			null;


		/*
		 * 동일 ID 2개 교대형에서
		 * 두 토글 글자가 겹치는 현상 방지
		 */
		if ( hideOwnToggle ) {
			ownToggle =
				getOwnToggle(
					target,
					name
				);

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


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


		/*
		 * target 자체가 display:none이면
		 * 먼저 화면에 복구
		 */
		if (
			window.getComputedStyle(
				target
			).display === 'none'
		) {
			target.style.display =
				getOpenDisplay(
					target
				);
		}


		/*
		 * content 자체가 display:none인 경우
		 */
		if (
			window.getComputedStyle(
				element
			).display === 'none'
		) {
			element.style.display =
				getOpenDisplay(
					element
				);
		}


		/*
		 * 실제 콘텐츠 레이아웃을 먼저 완성.
		 *
		 * 중요:
		 * 내부 TABLE 높이는 건드리지 않음.
		 */
		element.style.height =
			'auto';

		element.style.overflow =
			'hidden';

		element.style.clipPath =
			'inset(0 0 0 0)';


		var endHeight =
			element.scrollHeight;


		/*
		 * wrapper의 레이아웃 높이만 0으로 설정.
		 *
		 * 내부 table은 온전한 크기로 계산된 상태.
		 */
		element.style.height =
			'0px';

		element.style.clipPath =
			'inset(0 0 100% 0)';


		void element.offsetHeight;


		var animation =
			element.animate(
				[
					{
						height:
							'0px',

						clipPath:
							'inset(0 0 100% 0)'
					},
					{
						height:
							endHeight +
							'px',

						clipPath:
							'inset(0 0 0% 0)'
					}
				],
				{
					duration:
						DURATION,

					easing:
						EASING,

					fill:
						'forwards'
				}
			);


		$( element ).data(
			DATA_ANIMATION,
			animation
		);


		animation.onfinish =
			function () {

				$( element ).removeData(
					DATA_ANIMATION
				);


				/*
				 * 최종 상태는 CSS/HTML에 맡김
				 */
				element.style.height =
					'';

				element.style.overflow =
					'';

				element.style.clipPath =
					'';


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


				try {
					animation.cancel();
				} catch ( e ) {
					// 무시
				}
			};
	}


	/**
	 * 접기
	 */
	function collapse(
		target,
		element
	) {
		cancelAnimation(
			element
		);


		rememberDisplay(
			target
		);

		rememberDisplay(
			element
		);


		var startHeight =
			element.getBoundingClientRect()
				.height;


		/*
		 * 내부 table 자체는 그대로 유지.
		 */
		element.style.height =
			startHeight +
			'px';

		element.style.overflow =
			'hidden';

		element.style.clipPath =
			'inset(0 0 0% 0)';


		void element.offsetHeight;


		var animation =
			element.animate(
				[
					{
						height:
							startHeight +
							'px',

						clipPath:
							'inset(0 0 0% 0)'
					},
					{
						height:
							'0px',

						clipPath:
							'inset(0 0 100% 0)'
					}
				],
				{
					duration:
						DURATION,

					easing:
						EASING,

					fill:
						'forwards'
				}
			);


		$( element ).data(
			DATA_ANIMATION,
			animation
		);


		animation.onfinish =
			function () {

				$( element ).removeData(
					DATA_ANIMATION
				);


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


				/*
				 * target 자체가 content인 구조
				 */
				if (
					element === target
				) {
					target.style.display =
						'none';

				} else {

					/*
					 * 일반
					 * .mw-collapsible-content
					 */
					element.style.display =
						'none';
				}


				element.style.height =
					'';

				element.style.overflow =
					'';

				element.style.clipPath =
					'';


				try {
					animation.cancel();
				} catch ( e ) {
					// 무시
				}
			};
	}


	/**
	 * 그룹 토글
	 */
	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 opened =
			false;

		var i;


		for (
			i = 0;
			i < targets.length;
			i++
		) {
			var target =
				targets[ i ];

			var element =
				getAnimatedElement(
					target
				);

			var collapsed =
				isCollapsed(
					target,
					element
				);


			if ( collapsed ) {
				expand(
					target,
					element,
					name,
					multipleTargets
				);

				opened =
					true;

			} else {
				collapse(
					target,
					element
				);
			}
		}


		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 collapsibles =
			root.querySelectorAll(
				'.mw-collapsible'
			);

		var i;


		for (
			i = 0;
			i < collapsibles.length;
			i++
		) {
			rememberDisplay(
				collapsibles[ i ]
			);
		}
	}


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


	if (
		mw &&
		mw.hook
	) {
		mw.hook(
			'wikipage.content'
		).add(
			function ( $content ) {
				if (
					$content &&
					$content[ 0 ]
				) {
					initialize(
						$content[ 0 ]
					);
				}
			}
		);
	}


}( jQuery, mediaWiki ) );