편집 요약 없음 |
편집 요약 없음 |
||
| 3번째 줄: | 3번째 줄: | ||
* | * | ||
* 지원: | * 지원: | ||
* - 일반 mw-collapsible-content | |||
* - mw-collapsible 자체가 display:none 되는 구조 | |||
* - 동일 ID 2개 교대형 구조 | |||
* - flex 형태 복수 토글 | |||
* - 중첩 table / rowspan / infobox | |||
* | * | ||
* | * 특징: | ||
* - opacity 없음 | |||
* | * - 내부 table 자체의 height는 변경하지 않음 | ||
* | * - wrapper의 height + clip-path만 애니메이션 | ||
* 애니메이션 | |||
*/ | */ | ||
| 44번째 줄: | 18번째 줄: | ||
'use strict'; | 'use strict'; | ||
var DURATION = | var DURATION = 180; | ||
var EASING = ' | |||
var EASING = | |||
'cubic-bezier(0.4, 0, 0.2, 1)'; | |||
var DATA_DISPLAY = | var DATA_DISPLAY = | ||
'mwCustomCollapseDisplay'; | 'mwCustomCollapseDisplay'; | ||
var | var DATA_ANIMATION = | ||
' | 'mwCustomCollapseAnimation'; | ||
/** | /** | ||
* mw-customtoggle-XXX | |||
* mw-customtoggle- | |||
* → | * → | ||
* | * XXX | ||
*/ | */ | ||
function getToggleName( toggle ) { | function getToggleName( toggle ) { | ||
| 110번째 줄: | 81번째 줄: | ||
/** | /** | ||
* 같은 | * 같은 이름의 모든 target | ||
*/ | */ | ||
function getTargets( name ) { | function getTargets( name ) { | ||
var id = | var id = | ||
'mw-customcollapsible-' + | 'mw-customcollapsible-' + | ||
| 130번째 줄: | 97번째 줄: | ||
/** | /** | ||
* | * 직계 mw-collapsible-content 검색 | ||
*/ | */ | ||
function getDirectContent( target ) { | function getDirectContent( target ) { | ||
| 161번째 줄: | 125번째 줄: | ||
/** | /** | ||
* 실제 애니메이션 요소 | |||
* 실제 애니메이션 요소 | |||
* | * | ||
* | * mw-collapsible-content가 있으면 그것, | ||
* . | * 없으면 target 자체. | ||
* | * | ||
* | * 이번 방식에서는 DOM wrapper를 새로 만들지 않음. | ||
*/ | */ | ||
function getAnimatedElement( target ) { | function getAnimatedElement( target ) { | ||
| 262번째 줄: | 142번째 줄: | ||
} | } | ||
return target; | |||
return | |||
} | } | ||
/** | /** | ||
* 현재 접힌 | * 현재 접힌 상태 | ||
*/ | */ | ||
function isCollapsed( | function isCollapsed( | ||
| 351번째 줄: | 160번째 줄: | ||
return true; | return true; | ||
} | } | ||
if ( | if ( | ||
window | window.getComputedStyle( | ||
target | |||
).display === 'none' | |||
) { | ) { | ||
return true; | return true; | ||
} | } | ||
return ( | return ( | ||
window | window.getComputedStyle( | ||
element | |||
).display === 'none' | |||
); | ); | ||
} | } | ||
| 375번째 줄: | 178번째 줄: | ||
/** | /** | ||
* display | * 현재 display 기억 | ||
*/ | */ | ||
function rememberDisplay( element ) { | function rememberDisplay( element ) { | ||
var display = | var display = | ||
window | window.getComputedStyle( | ||
element | |||
).display; | |||
if ( | if ( | ||
| 398번째 줄: | 199번째 줄: | ||
/** | /** | ||
* 펼칠 때 display 결정 | |||
* 펼칠 때 | |||
*/ | */ | ||
function getOpenDisplay( element ) { | function getOpenDisplay( element ) { | ||
| 441번째 줄: | 217번째 줄: | ||
) { | ) { | ||
return element.style.display; | return element.style.display; | ||
} | } | ||
switch ( element.tagName ) { | switch ( element.tagName ) { | ||
case 'TABLE': | case 'TABLE': | ||
return 'table'; | return 'table'; | ||
| 481번째 줄: | 240번째 줄: | ||
return 'table-cell'; | return 'table-cell'; | ||
case ' | case 'LI': | ||
return ' | return 'list-item'; | ||
} | |||
/* | |||
* 현재 제공하신 정보상자에서는 | |||
* mw-customcollapsible-1이 div이므로 block. | |||
*/ | |||
return 'block'; | |||
} | |||
/** | |||
* 진행 중인 Web Animation 취소 | |||
*/ | |||
function cancelAnimation( element ) { | |||
var animation = | |||
$( element ).data( | |||
DATA_ANIMATION | |||
); | |||
if ( | 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 | return null; | ||
} | } | ||
/** | /** | ||
* | * ARIA 상태 | ||
*/ | */ | ||
function updateAria( | function updateAria( | ||
| 557번째 줄: | 342번째 줄: | ||
hideOwnToggle | hideOwnToggle | ||
) { | ) { | ||
cancelAnimation( | |||
element | |||
); | |||
var ownToggle = | var ownToggle = | ||
| 568번째 줄: | 352번째 줄: | ||
/* | /* | ||
* | * 동일 ID 2개 교대형에서 | ||
* | * 두 토글 글자가 겹치는 현상 방지 | ||
*/ | */ | ||
if ( hideOwnToggle ) { | if ( hideOwnToggle ) { | ||
| 586번째 줄: | 369번째 줄: | ||
target.classList.remove( | |||
'mw-collapsed' | |||
); | ); | ||
/* | /* | ||
* target 자체가 display: | * target 자체가 display:none이면 | ||
* 먼저 | * 먼저 화면에 복구 | ||
*/ | */ | ||
if ( | if ( | ||
window | window.getComputedStyle( | ||
. | target | ||
).display === 'none' | |||
) { | |||
target.style.display = | |||
getOpenDisplay( | |||
target | target | ||
) | ); | ||
.display === 'none' | } | ||
/* | |||
* content 자체가 display:none인 경우 | |||
*/ | |||
if ( | |||
window.getComputedStyle( | |||
element | |||
).display === 'none' | |||
) { | ) { | ||
element.style.display = | |||
getOpenDisplay( | 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: | ||
| 635번째 줄: | 466번째 줄: | ||
EASING, | EASING, | ||
fill: | |||
function () { | '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 ) { | |||
// 무시 | |||
} | } | ||
}; | |||
} | } | ||
| 668번째 줄: | 521번째 줄: | ||
element | element | ||
) { | ) { | ||
cancelAnimation( | |||
element | |||
); | ); | ||
rememberDisplay( | |||
target | target | ||
); | ); | ||
| 688번째 줄: | 533번째 줄: | ||
element | 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( | function toggleGroup( toggle ) { | ||
var name = | var name = | ||
getToggleName( | getToggleName( | ||
| 781번째 줄: | 679번째 줄: | ||
targets.length > 1; | targets.length > 1; | ||
var opened = | |||
var | |||
false; | false; | ||
| 795번째 줄: | 692번째 줄: | ||
var target = | var target = | ||
targets[ i ]; | targets[ i ]; | ||
var element = | var element = | ||
| 801번째 줄: | 697번째 줄: | ||
target | target | ||
); | ); | ||
var collapsed = | var collapsed = | ||
| 811번째 줄: | 706번째 줄: | ||
if ( collapsed ) { | if ( collapsed ) { | ||
expand( | expand( | ||
target, | target, | ||
| 819번째 줄: | 713번째 줄: | ||
); | ); | ||
opened = | |||
true; | true; | ||
} else { | } else { | ||
collapse( | collapse( | ||
target, | target, | ||
| 837번째 줄: | 730번째 줄: | ||
updateAria( | updateAria( | ||
name, | name, | ||
opened | |||
); | ); | ||
} | } | ||
| 847번째 줄: | 740번째 줄: | ||
/** | /** | ||
* 클릭 | * 클릭 | ||
*/ | */ | ||
document.addEventListener( | document.addEventListener( | ||
| 937번째 줄: | 830번째 줄: | ||
/** | /** | ||
* 초기 | * 초기 display 저장 | ||
*/ | */ | ||
function | function initialize( | ||
root | root | ||
) { | ) { | ||
| 955번째 줄: | 848번째 줄: | ||
i++ | i++ | ||
) { | ) { | ||
rememberDisplay( | |||
collapsibles[ i ] | collapsibles[ i ] | ||
); | |||
} | } | ||
} | } | ||
$( function () { | $( function () { | ||
initialize( | |||
document | document | ||
); | ); | ||
| 999번째 줄: | 862번째 줄: | ||
if ( | if ( | ||
mw && | mw && | ||
| 1,014번째 줄: | 874번째 줄: | ||
$content[ 0 ] | $content[ 0 ] | ||
) { | ) { | ||
initialize( | |||
$content[ 0 ] | $content[ 0 ] | ||
); | ); | ||
2026년 8월 20일 (목) 14:23 판
/**
* 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 ) );