mirror of
https://gerrit.wikimedia.org/r/mediawiki/skins/Vector.git
synced 2024-12-04 12:09:30 +00:00
768a07ec6c
Can be disabled via &vectorstickyheaderedit=0 or configuration change. This will allow us to fine tune the edit features without blocking deploying the existing feature. Bug: T294383 Change-Id: Ic282ea4f2ff0108eeaa154c8a77e4e5fd30daeae
394 lines
11 KiB
JavaScript
394 lines
11 KiB
JavaScript
const
|
|
STICKY_HEADER_ID = 'vector-sticky-header',
|
|
initSearchToggle = require( './searchToggle.js' ),
|
|
STICKY_HEADER_APPENDED_ID = '-sticky-header',
|
|
STICKY_HEADER_VISIBLE_CLASS = 'vector-sticky-header-visible',
|
|
STICKY_HEADER_USER_MENU_CONTAINER_CLASS = 'vector-sticky-header-icon-end',
|
|
FIRST_HEADING_ID = 'firstHeading',
|
|
USER_MENU_ID = 'p-personal',
|
|
VECTOR_USER_LINKS_SELECTOR = '.vector-user-links',
|
|
SEARCH_TOGGLE_SELECTOR = '.vector-sticky-header-search-toggle';
|
|
|
|
/**
|
|
* Copies attribute from an element to another.
|
|
*
|
|
* @param {Element} from
|
|
* @param {Element} to
|
|
* @param {string} attribute
|
|
*/
|
|
function copyAttribute( from, to, attribute ) {
|
|
const fromAttr = from.getAttribute( attribute );
|
|
if ( fromAttr ) {
|
|
to.setAttribute( attribute, fromAttr );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Copies attribute from an element to another.
|
|
*
|
|
* @param {Element} from
|
|
* @param {Element} to
|
|
*/
|
|
function copyButtonAttributes( from, to ) {
|
|
copyAttribute( from, to, 'href' );
|
|
copyAttribute( from, to, 'title' );
|
|
}
|
|
|
|
/**
|
|
* Suffixes an attribute with a value that indicates it
|
|
* relates to the sticky header to support click tracking instrumentation.
|
|
*
|
|
* @param {Element} node
|
|
* @param {string} attribute
|
|
*/
|
|
function suffixStickyAttribute( node, attribute ) {
|
|
const value = node.getAttribute( attribute );
|
|
if ( value ) {
|
|
node.setAttribute( attribute, value + STICKY_HEADER_APPENDED_ID );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Makes a node trackable by our click tracking instrumentation.
|
|
*
|
|
* @param {Element} node
|
|
*/
|
|
function makeNodeTrackable( node ) {
|
|
suffixStickyAttribute( node, 'id' );
|
|
suffixStickyAttribute( node, 'data-event-name' );
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {null|HTMLElement|Node|EventTarget} node
|
|
* @return {HTMLElement}
|
|
*/
|
|
function toHTMLElement( node ) {
|
|
// @ts-ignore
|
|
return node;
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLElement} node
|
|
*/
|
|
function removeNode( node ) {
|
|
toHTMLElement( node.parentNode ).removeChild( node );
|
|
}
|
|
|
|
/**
|
|
* @param {NodeList} nodes
|
|
* @param {string} className
|
|
*/
|
|
function removeClassFromNodes( nodes, className ) {
|
|
Array.prototype.forEach.call( nodes, function ( node ) {
|
|
// eslint-disable-next-line mediawiki/class-doc
|
|
node.classList.remove( className );
|
|
} );
|
|
}
|
|
|
|
/**
|
|
* Makes sticky header icons functional for modern Vector.
|
|
*
|
|
* @param {HTMLElement} header
|
|
* @param {HTMLElement|null} history
|
|
* @param {HTMLElement|null} talk
|
|
*/
|
|
function prepareIcons( header, history, talk ) {
|
|
const historySticky = header.querySelector( '#ca-history-sticky-header' ),
|
|
talkSticky = header.querySelector( '#ca-talk-sticky-header' );
|
|
|
|
if ( !historySticky || !talkSticky ) {
|
|
throw new Error( 'Sticky header has unexpected HTML' );
|
|
}
|
|
|
|
if ( history ) {
|
|
copyButtonAttributes( history, historySticky );
|
|
} else {
|
|
// @ts-ignore
|
|
historySticky.parentNode.removeChild( historySticky );
|
|
}
|
|
if ( talk ) {
|
|
copyButtonAttributes( talk, talkSticky );
|
|
} else {
|
|
// @ts-ignore
|
|
talkSticky.parentNode.removeChild( talkSticky );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render sticky header edit or protected page icons for modern Vector.
|
|
*
|
|
* @param {HTMLElement} header
|
|
* @param {HTMLElement|null} primaryEdit
|
|
* @param {boolean} isProtected
|
|
* @param {HTMLElement|null} secondaryEdit
|
|
* @param {Function} disableStickyHeader function to call to disable the sticky
|
|
* header.
|
|
*/
|
|
function prepareEditIcons(
|
|
header,
|
|
primaryEdit,
|
|
isProtected,
|
|
secondaryEdit,
|
|
disableStickyHeader
|
|
) {
|
|
const
|
|
primaryEditStickyElement = header.querySelector(
|
|
'#ca-ve-edit-sticky-header'
|
|
),
|
|
primaryEditSticky = primaryEditStickyElement ? toHTMLElement(
|
|
header.querySelector(
|
|
'#ca-ve-edit-sticky-header'
|
|
)
|
|
) : null,
|
|
protectedSticky = toHTMLElement(
|
|
header.querySelector(
|
|
'#ca-viewsource-sticky-header'
|
|
)
|
|
),
|
|
wikitextSticky = toHTMLElement(
|
|
header.querySelector(
|
|
'#ca-edit-sticky-header'
|
|
)
|
|
);
|
|
|
|
// If no primary edit icon is present the feature is disabled.
|
|
if ( !primaryEditSticky ) {
|
|
return;
|
|
}
|
|
if ( !primaryEdit ) {
|
|
removeNode( protectedSticky );
|
|
removeNode( wikitextSticky );
|
|
removeNode( primaryEditSticky );
|
|
return;
|
|
} else if ( isProtected ) {
|
|
removeNode( wikitextSticky );
|
|
removeNode( primaryEditSticky );
|
|
copyButtonAttributes( primaryEdit, protectedSticky );
|
|
} else {
|
|
removeNode( protectedSticky );
|
|
copyButtonAttributes( primaryEdit, primaryEditSticky );
|
|
|
|
primaryEditSticky.addEventListener( 'click', function ( ev ) {
|
|
const target = toHTMLElement( ev.target );
|
|
const $ve = $( primaryEdit );
|
|
|
|
if ( target && $ve.length ) {
|
|
const event = $.Event( 'click' );
|
|
$ve.trigger( event );
|
|
// The link has been progressively enhanced.
|
|
if ( event.isDefaultPrevented() ) {
|
|
disableStickyHeader();
|
|
ev.preventDefault();
|
|
}
|
|
}
|
|
} );
|
|
if ( secondaryEdit ) {
|
|
copyButtonAttributes( secondaryEdit, wikitextSticky );
|
|
wikitextSticky.addEventListener( 'click', function ( ev ) {
|
|
const target = toHTMLElement( ev.target );
|
|
if ( target ) {
|
|
const $edit = $( secondaryEdit );
|
|
if ( $edit.length ) {
|
|
const event = $.Event( 'click' );
|
|
$edit.trigger( event );
|
|
// The link has been progressively enhanced.
|
|
if ( event.isDefaultPrevented() ) {
|
|
disableStickyHeader();
|
|
ev.preventDefault();
|
|
}
|
|
}
|
|
}
|
|
} );
|
|
} else {
|
|
removeNode( wikitextSticky );
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if element is in viewport.
|
|
*
|
|
* @param {HTMLElement} element
|
|
* @return {boolean}
|
|
*/
|
|
function isInViewport( element ) {
|
|
const rect = element.getBoundingClientRect();
|
|
return (
|
|
rect.top >= 0 &&
|
|
rect.left >= 0 &&
|
|
rect.bottom <= ( window.innerHeight || document.documentElement.clientHeight ) &&
|
|
rect.right <= ( window.innerWidth || document.documentElement.clientWidth )
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Makes sticky header functional for modern Vector.
|
|
*
|
|
* @param {HTMLElement} header
|
|
* @param {HTMLElement} stickyIntersection
|
|
* @param {HTMLElement} userMenu
|
|
* @param {Element} userMenuStickyContainer
|
|
*/
|
|
function makeStickyHeaderFunctional(
|
|
header,
|
|
stickyIntersection,
|
|
userMenu,
|
|
userMenuStickyContainer
|
|
) {
|
|
const
|
|
/* eslint-disable-next-line compat/compat */
|
|
stickyObserver = new IntersectionObserver( function ( entries ) {
|
|
if ( !entries[ 0 ].isIntersecting && entries[ 0 ].boundingClientRect.top < 0 ) {
|
|
// Viewport has crossed the bottom edge of firstHeading so show sticky header.
|
|
// eslint-disable-next-line mediawiki/class-doc
|
|
header.classList.add( STICKY_HEADER_VISIBLE_CLASS );
|
|
} else {
|
|
// Viewport is above the bottom edge of firstHeading so hide sticky header.
|
|
// eslint-disable-next-line mediawiki/class-doc
|
|
header.classList.remove( STICKY_HEADER_VISIBLE_CLASS );
|
|
}
|
|
} ),
|
|
// Type declaration needed because of https://github.com/Microsoft/TypeScript/issues/3734#issuecomment-118934518
|
|
userMenuClone = /** @type {HTMLElement} */( userMenu.cloneNode( true ) ),
|
|
userMenuStickyElementsWithIds = userMenuClone.querySelectorAll( '[ id ], [ data-event-name ]' ),
|
|
userMenuStickyContainerInner = userMenuStickyContainer
|
|
.querySelector( VECTOR_USER_LINKS_SELECTOR );
|
|
|
|
// Update all ids of the cloned user menu to make them unique.
|
|
makeNodeTrackable( userMenuClone );
|
|
userMenuStickyElementsWithIds.forEach( makeNodeTrackable );
|
|
|
|
// Remove portlet links added by gadgets using mw.util.addPortletLink, T291426
|
|
const gadgetLinks = userMenuClone.querySelector( 'mw-list-item-js' );
|
|
if ( gadgetLinks ) {
|
|
gadgetLinks.remove();
|
|
}
|
|
removeClassFromNodes(
|
|
userMenuClone.querySelectorAll( '.user-links-collapsible-item' ),
|
|
'user-links-collapsible-item'
|
|
);
|
|
|
|
// Prevents user menu from being focusable, T290201
|
|
const userMenuCheckbox = userMenuClone.querySelector( 'input' );
|
|
if ( userMenuCheckbox ) {
|
|
userMenuCheckbox.setAttribute( 'tabindex', '-1' );
|
|
}
|
|
|
|
// Clone the updated user menu to the sticky header.
|
|
if ( userMenuStickyContainerInner ) {
|
|
userMenuStickyContainerInner.appendChild( userMenuClone );
|
|
}
|
|
|
|
prepareIcons( header,
|
|
document.querySelector( '#ca-history a' ),
|
|
document.querySelector( '#ca-talk a' )
|
|
);
|
|
|
|
const veEdit = document.querySelector( '#ca-ve-edit a' );
|
|
const ceEdit = document.querySelector( '#ca-edit a' );
|
|
const protectedEdit = document.querySelector( '#ca-viewsource a' );
|
|
const isProtected = !!protectedEdit;
|
|
const primaryEdit = protectedEdit || ( veEdit || ceEdit );
|
|
const secondaryEdit = veEdit ? ceEdit : null;
|
|
const disableStickyHeader = () => {
|
|
// eslint-disable-next-line mediawiki/class-doc
|
|
header.classList.remove( STICKY_HEADER_VISIBLE_CLASS );
|
|
stickyObserver.unobserve( stickyIntersection );
|
|
};
|
|
|
|
prepareEditIcons(
|
|
header,
|
|
toHTMLElement( primaryEdit ),
|
|
isProtected,
|
|
toHTMLElement( secondaryEdit ),
|
|
disableStickyHeader
|
|
);
|
|
|
|
stickyObserver.observe( stickyIntersection );
|
|
|
|
// When Visual Editor is activated, hide sticky header.
|
|
mw.hook( 've.activate' ).add( disableStickyHeader );
|
|
|
|
// When Visual Editor is deactivated, by cliking "read" tab at top of page, show sticky header.
|
|
mw.hook( 've.deactivationComplete' ).add( () => {
|
|
stickyObserver.observe( stickyIntersection );
|
|
} );
|
|
|
|
// After saving edits, re-apply the sticky header if the target is not in the viewport.
|
|
mw.hook( 'postEdit.afterRemoval' ).add( () => {
|
|
if ( !isInViewport( stickyIntersection ) ) {
|
|
// eslint-disable-next-line mediawiki/class-doc
|
|
header.classList.add( STICKY_HEADER_VISIBLE_CLASS );
|
|
stickyObserver.observe( stickyIntersection );
|
|
}
|
|
} );
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLElement} header
|
|
*/
|
|
function setupSearchIfNeeded( header ) {
|
|
const
|
|
searchToggle = header.querySelector( SEARCH_TOGGLE_SELECTOR );
|
|
|
|
if ( !document.body.classList.contains( 'skin-vector-search-vue' ) ) {
|
|
return;
|
|
}
|
|
|
|
if ( searchToggle ) {
|
|
initSearchToggle( searchToggle );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Determines if sticky header should be visible for a given namespace.
|
|
*
|
|
* @param {number} namespaceNumber
|
|
* @return {boolean}
|
|
*/
|
|
function isAllowedNamespace( namespaceNumber ) {
|
|
// Corresponds to Main, User, Wikipedia, Template, Help, Category, Portal, Module.
|
|
const allowedNamespaceNumbers = [ 0, 2, 4, 10, 12, 14, 100, 828 ];
|
|
return allowedNamespaceNumbers.indexOf( namespaceNumber ) > -1;
|
|
}
|
|
|
|
/**
|
|
* Determines if sticky header should be visible for a given action.
|
|
*
|
|
* @param {string} action
|
|
* @return {boolean}
|
|
*/
|
|
function isAllowedAction( action ) {
|
|
const disallowedActions = [ 'history', 'edit' ],
|
|
hasDiffId = mw.config.get( 'wgDiffOldId' );
|
|
return disallowedActions.indexOf( action ) < 0 && !hasDiffId;
|
|
}
|
|
|
|
module.exports = function initStickyHeader() {
|
|
const header = document.getElementById( STICKY_HEADER_ID ),
|
|
stickyIntersection = document.getElementById(
|
|
FIRST_HEADING_ID
|
|
),
|
|
userMenu = document.getElementById( USER_MENU_ID ),
|
|
userMenuStickyContainer = document.getElementsByClassName(
|
|
STICKY_HEADER_USER_MENU_CONTAINER_CLASS
|
|
)[ 0 ],
|
|
allowedNamespace = isAllowedNamespace( mw.config.get( 'wgNamespaceNumber' ) ),
|
|
allowedAction = isAllowedAction( mw.config.get( 'wgAction' ) );
|
|
|
|
if ( !(
|
|
header &&
|
|
stickyIntersection &&
|
|
userMenu &&
|
|
userMenuStickyContainer &&
|
|
allowedNamespace &&
|
|
allowedAction &&
|
|
'IntersectionObserver' in window ) ) {
|
|
return;
|
|
}
|
|
|
|
makeStickyHeaderFunctional( header, stickyIntersection, userMenu, userMenuStickyContainer );
|
|
setupSearchIfNeeded( header );
|
|
};
|