/**
* EditCheckGutterSectionIconWidget
*
* The icon shown in the edit check gutter for one section of the document.
*
* A section can be much taller than the viewport, so the icon doesn't just sit at
* its top: it's kept in view as the section scrolls past, in three states.
*
* - `top`: the section's top hasn't reached the sticky line, so the icon rests at
* the top of the section.
* - `stuck`: the section straddles the line, so the icon is pinned to the line.
* - `bottom`: the section's bottom has caught up with the pinned icon, so the icon
* rides the bottom out of view.
*
* That's what `position: sticky` does, and this is shaped for handing over to it:
* `$element` is the element that would be sticky, and the observer and sentinel are
* emulation to be deleted with it. Sticky is blocked for now by ancestors of the
* gutter which are themselves scroll containers, trapping it short of the scroller.
*
* @class
* @extends OO.ui.Widget
*
* @param {Object} config Configuration options
* @param {jQuery} config.$section Element covering the whole section this icon
* belongs to, observed to work out where the icon should be
*/
mw.editcheck.EditCheckGutterSectionIconWidget = function MWEditCheckGutterSectionIconWidget( config ) {
// Parent constructor
mw.editcheck.EditCheckGutterSectionIconWidget.super.call( this, config );
this.$section = config.$section;
this.icon = new OO.ui.IconWidget();
this.countLabel = new OO.ui.LabelWidget();
this.actionButton = new OO.ui.ButtonWidget( {
icon: 'check',
flags: [ 'invert' ],
label: 'act',
invisibleLabel: true,
framed: false
} );
this.actionButton.toggle( false );
// Sticky emulation state, see #setStickyMetrics
this.state = null;
this.line = null;
this.observer = null;
this.rootMargin = null;
this.sentinelHeight = null;
this.sectionAboveLine = false;
this.sentinelAboveLine = false;
this.onIntersection = this.onIntersection.bind( this );
this.$element
.addClass( 've-ui-editCheck-gutter-action-sticky' )
.append( this.icon.$element, this.countLabel.$element, this.actionButton.$element );
// Hidden icon-tall band at the section's bottom, see #setStickyMetrics. It sits
// on the section rather than on $element, which moves, so #teardown removes it.
this.$sentinel = $( '<div>' )
.addClass( 've-ui-editCheck-gutter-action-sticky-sentinel' )
.appendTo( this.$section );
};
/* Inheritance */
OO.inheritClass( mw.editcheck.EditCheckGutterSectionIconWidget, OO.ui.Widget );
/* Static methods */
/**
* Collect the metrics the sticky emulation needs. They're the same for every
* section, so they're gathered once per update and shared between the icons.
*
* @param {ve.init.mw.Target} target
* @return {Object|null} `{ root, rootTop, rootHeight, line }` in viewport
* coordinates, or null if IntersectionObserver is unsupported
*/
mw.editcheck.EditCheckGutterSectionIconWidget.static.getStickyMetrics = function ( target ) {
if ( !window.IntersectionObserver ) {
return null;
}
const scrollContainer = target.$scrollContainer[ 0 ];
const viewportRoot = !scrollContainer ||
scrollContainer === document.documentElement ||
scrollContainer === document.body;
const rootEl = viewportRoot ? document.documentElement : scrollContainer;
const rootTop = viewportRoot ? 0 : rootEl.getBoundingClientRect().top;
// Sticky line: the toolbar bottom (it floats over the surface) plus a 4px gap
const toolbarPadding = target.getToolbarSurfacePadding();
return {
root: viewportRoot ? null : scrollContainer,
rootTop,
rootHeight: rootEl.clientHeight,
line: rootTop + ( toolbarPadding ? toolbarPadding.top + 4 : 0 )
};
};
/* Methods */
/**
* Update the icon to represent an action
*
* @param {mw.editcheck.EditCheckAction} action Primary action of the section
* @param {number} count Number of actions in the section
* @param {boolean} allExperimentalSuggestions If each action in the section is an experimental suggestion
*/
mw.editcheck.EditCheckGutterSectionIconWidget.prototype.setAction = function ( action, count, allExperimentalSuggestions ) {
const quickAction = !!action.gutterQuickAction;
if ( !quickAction ) {
if ( allExperimentalSuggestions ) {
this.icon.setIcon( 'lightbulbDashed' );
} else {
this.icon.setIcon( mw.editcheck.EditCheckActionWidget.static.iconMap[ action.getType() ] || 'notice' );
}
this.icon.clearFlags().setFlags( action.getType() );
this.countLabel.setLabel( count.toString() );
this.countLabel.setInvisibleLabel( count === 1 );
}
this.icon.toggle( !quickAction );
this.countLabel.toggle( !quickAction );
this.actionButton.toggle( quickAction );
};
/**
* Set whether the quick action is currently being performed
*
* @param {boolean} acting
*/
mw.editcheck.EditCheckGutterSectionIconWidget.prototype.setActing = function ( acting ) {
this.actionButton.setDisabled( acting );
};
/**
* Keep the icon in view as its section scrolls past.
*
* One observer watches the region of the scroll root above the line, asking two
* elements the same question. The section overlaps that region once its top passes
* the line, i.e. once the icon's resting place is out of view; the sentinel does so
* once the section's bottom reaches where a pinned icon sits, which is when the icon
* must be released. A region rather than a line, so a fast scroll can't skip it.
*
* @param {Object|null} metrics Metrics from .static.getStickyMetrics, or null to
* leave the icon resting at the top of its section
* @param {Element|null} metrics.root Scroll root to observe against, null for the viewport
* @param {number} metrics.rootTop Root top, in viewport coordinates
* @param {number} metrics.rootHeight Root height
* @param {number} metrics.line Line to pin the icon at, in viewport coordinates
*/
mw.editcheck.EditCheckGutterSectionIconWidget.prototype.setStickyMetrics = function ( metrics ) {
const sectionRect = this.$section[ 0 ].getBoundingClientRect();
const iconHeight = this.$element[ 0 ].offsetHeight;
// Under two icon-heights there's no room for a stuck phase, so pinning would
// only make the icon jitter
if ( !metrics || sectionRect.height < iconHeight * 2 ) {
this.disconnectObserver();
this.setState( 'top' );
return;
}
if ( metrics.line !== this.line ) {
// The line has moved (a resize can change the toolbar height), so an
// already-pinned icon needs re-pinning
this.line = metrics.line;
this.state = null;
}
// Only written when it actually changes, so the common scroll-driven update
// stays read-only and doesn't force a layout
if ( this.sentinelHeight !== iconHeight ) {
this.$sentinel.css( 'height', iconHeight );
this.sentinelHeight = iconHeight;
}
// A negative bottom inset shrinks the root to the region above the line. It
// depends on neither the section nor the icon, so it rarely needs rebuilding.
const rootMargin = '0px 0px ' +
( metrics.line - ( metrics.rootTop + metrics.rootHeight ) ) + 'px 0px';
if ( rootMargin !== this.rootMargin ) {
this.disconnectObserver();
this.observer = new IntersectionObserver( this.onIntersection, {
root: metrics.root,
rootMargin
} );
this.observer.observe( this.$section[ 0 ] );
this.observer.observe( this.$sentinel[ 0 ] );
this.rootMargin = rootMargin;
}
// The observer reports back asynchronously, so seed both flags to avoid a flash
const isAboveLine = ( top, bottom ) => top < metrics.line && bottom > metrics.rootTop;
this.sectionAboveLine = isAboveLine( sectionRect.top, sectionRect.bottom );
this.sentinelAboveLine = isAboveLine( sectionRect.bottom - iconHeight, sectionRect.bottom );
this.updateState();
};
/**
* Handle the section or the sentinel crossing the line
*
* @private
* @param {IntersectionObserverEntry[]} entries
*/
mw.editcheck.EditCheckGutterSectionIconWidget.prototype.onIntersection = function ( entries ) {
entries.forEach( ( entry ) => {
if ( entry.target === this.$sentinel[ 0 ] ) {
this.sentinelAboveLine = entry.isIntersecting;
} else {
this.sectionAboveLine = entry.isIntersecting;
}
} );
this.updateState();
};
/**
* Move the icon to whichever state the observed flags imply
*
* @private
*/
mw.editcheck.EditCheckGutterSectionIconWidget.prototype.updateState = function () {
if ( !this.sectionAboveLine ) {
this.setState( 'top' );
} else {
this.setState( this.sentinelAboveLine ? 'bottom' : 'stuck' );
}
};
/**
* Position the icon for a sticky state
*
* @private
* @param {string} state One of 'top', 'stuck' or 'bottom'
*/
mw.editcheck.EditCheckGutterSectionIconWidget.prototype.setState = function ( state ) {
if ( state === this.state ) {
return;
}
const pinning = state === 'stuck';
if ( pinning ) {
// Pinning uses position: fixed, which drops the width and the horizontal
// position, so take both from the box as it stands. Measuring the distance
// to the viewport's inline-end edge is border-immune and direction-correct.
const rect = this.$element[ 0 ].getBoundingClientRect();
const rtl = this.$element.css( 'direction' ) === 'rtl';
this.$element.css( {
top: this.line,
width: rect.width,
insetInlineEnd: rtl ? rect.left : document.documentElement.clientWidth - rect.right
} );
} else {
this.$element.css( { top: '', width: '', insetInlineEnd: '' } );
}
this.state = state;
this.$element
.toggleClass( 've-ui-editCheck-gutter-action-sticky-stuck', pinning )
.toggleClass( 've-ui-editCheck-gutter-action-sticky-bottom', state === 'bottom' );
};
/**
* Disconnect the observer, clearing the cached margin so it can be rebuilt
*
* @private
*/
mw.editcheck.EditCheckGutterSectionIconWidget.prototype.disconnectObserver = function () {
if ( this.observer ) {
this.observer.disconnect();
this.observer = null;
}
this.rootMargin = null;
};
/**
* Teardown the widget
*/
mw.editcheck.EditCheckGutterSectionIconWidget.prototype.teardown = function () {
this.disconnectObserver();
this.$sentinel.remove();
};