Note: After saving, changes may not occur immediately. Click here to learn how to bypass your browser's cache.
  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (Cmd-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (Cmd-Shift-R on a Mac)
  • Internet Explorer: Hold Ctrl while clicking Refresh, or press Ctrl-F5
  • Opera: Clear the cache in Tools → Preferences

For details and instructions about other browsers, see Wikipedia:Bypass your cache.

/**
 * MiniPane
 *
 * Script to make the proofreading interface a bit less horrible...maybe
 *
 * Changelog:
 */

'use strict';

// IIFE used when including as a user script (to allow debug or config)
// Default gadget use will get an IIFE wrapper as well
( function () {
	const gadgetName = 'mini_pane',

		MiniPane = {
			dbName: 'enws-gadget-' + gadgetName,
			enabled: true,
			configured: false,
			pageEntryExpiry: 48 * 60 * 60 * 1000, // 48h
			indexEntryExpiry: 28 * 24 * 60 * 60 * 1000, // one month
			scrollStep: 25,
			zoomStep: 20,
			paneHeight: 18, // in em
			followGap: 45, // px
			followMouse: true
		};

	function getTitle() {
		return mw.config.get( 'wgTitle' );
	}

	function getIndex() {
		return getTitle().replace( /\/[0-9]+$/, '' );
	}

	function imageMutated( mutations ) {
		const mut = mutations[ mutations.length - 1 ];

		const $tgt = $( mut.target );

		const title = getTitle();

		const loc = {
			title: title,
			left: $tgt.css( 'left' ),
			top: $tgt.css( 'top' ),
			width: $tgt.css( 'width' ),
			lastUsed: new Date().getTime()
		};

		const pagePosStoreTrans = MiniPane.db
			.transaction( [ 'pages', 'indexes' ], 'readwrite' );

		pagePosStoreTrans.objectStore( 'pages' ).put( loc );

		// and update the index
		loc.title = getIndex();
		loc.top = 0;

		pagePosStoreTrans.objectStore( 'indexes' ).put( loc );
	}

	/* Trim old entries from the IndexedDB */
	// https://github.com/whatwg/storage/issues/11
	function tidyDb() {
		const now = new Date().getTime();

		const expireEntries = function ( index, expiry ) {
			index.onsuccess = function ( event ) {
				const cursor = event.target.result;

				if ( cursor && ( cursor.value.lastUsed - now ) > expiry ) {
					cursor.delete();
				}

				cursor.continue();
			};
		};

		const pagePosStoreTrans = MiniPane.db
			.transaction( [ 'pages', 'indexes' ], 'readwrite' );

		let index = pagePosStoreTrans
			.objectStore( 'pages' )
			.index( 'lastUsed' );

		expireEntries( index, MiniPane.pageEntryExpiry );

		index = pagePosStoreTrans
			.objectStore( 'indexes' )
			.index( 'lastUsed' );

		expireEntries( index, MiniPane.indexEntryExpiry );
	}

	function afterDB() {

		// eslint-disable-next-line no-jquery/no-global-selector
		const $pageImg = $( '.prp-page-image' );
		const $ppImg = $pageImg.find( 'img' );
		let enabled = false;

		const $imgLoupe = $( '<div>' )
			.addClass( 'ws-js-minipane' )
			.addClass( 'ws-js-minipane prp-page-image-follower' )
			.attr( 'id', 'ws-userjs-imgloupe' )
			.css( {
				position: 'absolute',
				overflow: 'hidden',
				height: MiniPane.paneHeight + 'em',
				width: '50%',
				'margin-bottom': '1.5em',
				'z-index': 1000,
				border: '1px solid grey',
				'border-radius': '4px',
				'box-shadow': 'rgba(50, 50, 93, 0.25) 0 6px 12px -2px, rgba(0, 0, 0, 0.3) 0 3px 7px -3px',
				opacity: '90%',
				top: 0
			} )
			.hide()
			.insertAfter( '.prp-page-edit-header' );

		const $img = $( '<img>' )
			.attr( 'src', $ppImg.attr( 'src' ) )
			.css( {
				position: 'absolute',
				width: '100%'
			} )
			.appendTo( $imgLoupe );

		$( '<style>' )
			.append( '.ws-js-minipane img:hover{ opacity: 80%; }' )
			.appendTo( 'head' );

		const observer = new MutationObserver( imageMutated );

		// Start observing the target node for configured mutations
		observer.observe( $img[ 0 ], {
			attributes: true,
			childList: false,
			subtree: false
		} );

		const updateImagePos = function ( data ) {
			if ( data ) {
				$img.css( {
					top: data.top,
					left: data.left,
					width: data.width
				} );
			}
		};

		const pagePosStoreTrans = MiniPane.db
			.transaction( [ 'pages', 'indexes' ], 'readonly' );

		let pagePosStoreReq = pagePosStoreTrans
			.objectStore( 'pages' )
			.get( getTitle() );

		pagePosStoreReq.onsuccess = function ( event ) {
			const data = event.target.result;

			if ( data ) {
				updateImagePos( event.target.result );
			} else {
				pagePosStoreReq = pagePosStoreTrans
					.objectStore( 'indexes' )
					.get( getIndex() );

				pagePosStoreReq.onsuccess = function ( ievent ) {
					updateImagePos( ievent.target.result );
				};
			}
		};

		let $textBox;

		const preventOverscoll = function ( newTop, newLeft ) {
			const maxLeft = $img.parent()[ 0 ].clientWidth - $img[ 0 ].clientWidth;
			if ( newLeft ) {
				if ( maxLeft > 0 ) {
					// image smaller than box
					newLeft = Math.min( maxLeft, Math.max( 0, newLeft ) );
				} else if ( newLeft >= 0 ) {
					newLeft = 0;
				} else if ( newLeft < maxLeft ) {
					newLeft = maxLeft;
				}
				$img.css( 'left', newLeft );
			}

			if ( newTop ) {
				const maxTop = $img.parent()[ 0 ].clientHeight - $img[ 0 ].clientHeight;
				if ( maxTop > 0 ) {
					newTop = Math.min( maxTop, Math.max( 0, newTop ) );
				} else if ( newTop >= 0 ) {
					newTop = 0;
				} else if ( newTop < maxTop ) {
					newTop = maxTop;
				}
				$img.css( 'top', newTop );
			}
		};

		const zoom = function ( zoomStep ) {
			$img.css( {
				width: '+=' + zoomStep,
				left: '-=' + zoomStep / 2
			} );
			const pos = $img.position();
			preventOverscoll( pos.top, pos.left );
		};

		const setTop = function ( newTop ) {
			preventOverscoll( newTop, undefined );
		};

		const setLeft = function ( newLeft ) {
			preventOverscoll( undefined, newLeft );
		};

		const scrollSideways = function ( step ) {
			const pos = $img.position();
			setLeft( pos.left + step );
		};

		const scrollUpDown = function ( step ) {
			const pos = $img.position();
			setTop( pos.top + step );
		};

		const centreAt = function ( x, y ) {

			// var top = ( 0.5 - y ) * $img[ 0 ].clientHeight;
			// var left = ( 0.5 - x ) * $img[ 0 ].clientWidth;

			const distFromEdgeX = ( x * $img[ 0 ].clientWidth );
			const distFromEdgeY = ( y * $img[ 0 ].clientHeight );

			const left = ( $imgLoupe[ 0 ].clientWidth / 2 ) - distFromEdgeX;
			const top = ( $imgLoupe[ 0 ].clientHeight / 2 ) - distFromEdgeY;

			setTop( top );
			setLeft( left );
		};

		const handleKeypress = function ( evt ) {

			let prop = false;

			if ( evt.code === 'Numpad2' ) {
				scrollUpDown( -MiniPane.scrollStep );
			} else if ( evt.code === 'Numpad8' ) {
				scrollUpDown( MiniPane.scrollStep );
			} else if ( evt.code === 'Numpad4' ) {
				scrollSideways( MiniPane.scrollStep );
			} else if ( evt.code === 'Numpad6' ) {
				scrollSideways( -MiniPane.scrollStep );
			} else if ( evt.code === 'NumpadAdd' ) {
				zoom( MiniPane.zoomStep );
			} else if ( evt.code === 'NumpadSubtract' ) {
				zoom( -MiniPane.zoomStep );
			} else if ( evt.shiftKey && evt.code === 'Enter' ) {
				scrollUpDown( -MiniPane.scrollStep );
				$textBox.scrollTop( $textBox.scrollTop() + 16 );
			} else if ( evt.ctrlKey && evt.code === 'Enter' ) {
				scrollUpDown( MiniPane.scrollStep );
				$textBox.scrollTop( $textBox.scrollTop() - 16 );
			} else if ( evt.code === 'Numpad3' ) {
				scrollUpDown( ( -0.8 * MiniPane.paneHeight ) + 'em' );
			} else if ( evt.code === 'Numpad9' ) {
				scrollUpDown( ( 0.8 * MiniPane.paneHeight ) + 'em' );
			} else {
				prop = true;
			}

			if ( !prop ) {
				evt.preventDefault();
			}
		};

		// eslint-disable-next-line no-jquery/no-global-selector
		$( 'body' ).on( 'keypress', handleKeypress );

		const handleScroll = function ( event ) {
			const down = event.originalEvent.deltaY < 0;

			if ( event.shiftKey ) {
				scrollSideways( ( down ? -1 : 1 ) * MiniPane.scrollStep );
			} else if ( event.ctrlKey ) {
				zoom( ( down ? 1 : -1 ) * MiniPane.zoomStep );
			} else {
				scrollUpDown( ( down ? 1 : -1 ) * MiniPane.scrollStep );
			}

			event.preventDefault();
		};

		const indirectScroll = function ( event ) {
			if ( event.shiftKey || event.ctrlKey ) {
				if ( !event.ctrlKey ) {
					event.shiftKey = false;
				}
				handleScroll( event );
			}
		};

		// scrolling on image
		$imgLoupe.on( 'wheel', handleScroll );
		$pageImg.on( 'wheel', indirectScroll );

		let lastTop = 0;

		function getTextBox() {
			// eslint-disable-next-line no-jquery/no-global-selector
			let $foundTextBox = $( '.CodeMirror' );
			if ( $foundTextBox.length === 0 ) {
				// eslint-disable-next-line no-jquery/no-global-selector
				$foundTextBox = $( '#wpTextbox1' );
			}

			return $foundTextBox;
		}

		function showLoupe() {
			if ( enabled ) {
				$imgLoupe.show();
			}
		}

		function hideLoupe() {
			$imgLoupe.hide();
		}

		const followMouse = function ( event ) {

			const $relativeToElement = $imgLoupe.parents( '.wikiEditor-ui-bottom' );

			const tbrect = $textBox[ 0 ].getBoundingClientRect();

			if ( event.clientY - tbrect.top < 0 ) {
				// went above the text box
				hideLoupe();
			} else {
				const rect = $relativeToElement[ 0 ].getBoundingClientRect();

				showLoupe();

				lastTop = event.clientY - rect.top -
					$imgLoupe[ 0 ].clientHeight - MiniPane.followGap;

				$imgLoupe.css( {
					top: lastTop
				} );
			}
		};

		let boundOnce = false;

		function bindTextboxHandlers() {

			// first unbind the old handlers
			$textBox.off( 'wheel', indirectScroll );
			$textBox.off( 'focus', showLoupe );
			$textBox.off( 'focusout mouseout', hideLoupe );
			$textBox.off( 'mousemove', followMouse );

			// update what the textbox is
			$textBox = getTextBox();

			$textBox.on( 'wheel', indirectScroll );

			if ( MiniPane.followMouse ) {
				$textBox.on( 'mousemove', followMouse );
			} else {
				// follow caret mode

				// make image draggable
				if ( !boundOnce ) {
					$img.drags();

					$imgLoupe.css( {
						transition: 'top 0.5s ease 0s'
					} );
				}

				// eslint-disable-next-line no-jquery/no-global-selector
				const prpBody = $( '.prp-page-edit-body' )[ 0 ];

				$textBox.on( 'keydown click focus scroll', function () {
					const pos = getCaretCoordinates( this, this.selectionStart );

					showLoupe();
					if ( pos.top !== lastTop ) {

						lastTop = pos.top + prpBody.offsetTop -
							$imgLoupe[ 0 ].clientHeight - this.scrollTop;
						$imgLoupe.css( {
							top: lastTop
						} );
					}
				} );
			}

			$textBox.on( 'focus', showLoupe );
			$textBox.on( 'focusout mouseout', hideLoupe );

			boundOnce = true;
		}

		$imgLoupe.on( 'mousemove', followMouse );
		$pageImg.on( 'mousemove', followMouse );
		$pageImg.on( 'mousemove', showLoupe );
		$pageImg.on( 'mouseout', hideLoupe );
		$imgLoupe.on( 'draglessClick', hideLoupe );

		// handl highres loads
		mw.hook( 'JumpToFile.highres_set' ).add( function ( hires ) {
			$img.attr( 'src', hires.href );
		} );

		$pageImg.on( 'draglessClick', function ( event ) {

			if ( event.ctrlKey ) {
				const pos = $ppImg[ 0 ].getBoundingClientRect();
				const x = ( event.clientX - pos.left ) / pos.width;
				const y = ( event.clientY - pos.top ) / pos.height;
				centreAt( x, y );
			}
		} );

		/* rebind the handlers on codemirror creation/destruction */
		mw.hook( 'ext.CodeMirror.switch' ).add( function () {
			bindTextboxHandlers();
		} );

		// bind to the current TB
		$textBox = getTextBox();
		bindTextboxHandlers();

		function setUpEnableToggle() {
			enabled = mw.storage.get( 'ext.gadget.minipane.enable' ) === '1';

			const getText = function ( active ) {
				return ( active ? 'Disable' : 'Enable' ) + ' MiniPane.js';
			};

			const toggleLink = mw.util.addPortletLink(
				'p-tb',
				'#',
				getText( enabled ),
				'n-enableMiniPane',
				'Enable/disable MiniPane gadget'
			);

			toggleLink.onclick = function ( event ) {
				enabled = !enabled;
				mw.storage.set( 'ext.gadget.minipane.enable', enabled ? '1' : '0' );

				// eslint-disable-next-line no-jquery/no-global-selector
				$( '#n-enableMiniPane' ).find( 'a' ).text( getText( enabled ) );

				if ( enabled ) {
					showLoupe();
				} else {
					hideLoupe();
				}
				event.preventDefault();
			};

			if ( enabled ) {
				showLoupe();
			}
		}

		setUpEnableToggle();

		// finally, clean old entries
		tidyDb();
	}

	function init() {
		console.log( 'Init ' + gadgetName );

		const request = window.indexedDB.open( MiniPane.dbName, 1 );

		request.onerror = function ( event ) {
			console.error( event );
			alert( 'IndexedDB error: ' + request.errorCode );
		};
		request.onsuccess = function ( event ) {
			MiniPane.db = event.target.result;

			MiniPane.db.onerror = function ( errEvent ) {
				console.error( 'Database error: ' + errEvent.target.errorCode );
			};

			afterDB();
		};

		request.onupgradeneeded = function ( event ) {
			const db = event.target.result;

			// Create an objectStore for this database
			let objectStore = db.createObjectStore( 'pages', { keyPath: 'title' } );
			objectStore.createIndex( 'title', 'title', { unique: true } );
			objectStore.createIndex( 'top', 'top', { unique: false } );
			objectStore.createIndex( 'left', 'left', { unique: false } );
			objectStore.createIndex( 'width', 'width', { unique: false } );
			objectStore.createIndex( 'lastUsed', 'lastUsed', { unique: false } );

			objectStore = db.createObjectStore( 'indexes', { keyPath: 'title' } );
			objectStore.createIndex( 'title', 'title', { unique: true } );
			objectStore.createIndex( 'top', 'top', { unique: false } );
			objectStore.createIndex( 'left', 'left', { unique: false } );
			objectStore.createIndex( 'width', 'width', { unique: false } );
			objectStore.createIndex( 'lastUsed', 'lastUsed', { unique: false } );

			objectStore.transaction.oncomplete = function () {
				// afterDB();
			};
		};
	}

	mw.loader.load( 'mediawiki.storage' );

	$( function () {
		if ( mw.config.get( 'wgCanonicalNamespace' ) === 'Page' &&
				[ 'edit', 'submit' ].indexOf( mw.config.get( 'wgAction' ) ) !== -1 ) {
			init();
		}
	} );
}() );

( function ( $ ) {
	$.fn.drags = function ( opt ) {

		opt = $.extend( { handle: '', cursor: 'move' }, opt );

		if ( opt.handle === '' ) {
			var $el = this;
		} else {
			var $el = this.find( opt.handle );
		}

		return $el.css( 'cursor', opt.cursor ).on( 'mousedown', function ( e ) {
			if ( opt.handle === '' ) {
				var $drag = $( this ).addClass( 'draggable' );
			} else {
				var $drag = $( this ).addClass( 'active-handle' ).parent().addClass( 'draggable' );
			}
			const z_idx = $drag.css( 'z-index' ),
				drg_h = $drag.outerHeight(),
				drg_w = $drag.outerWidth(),
				pos_y = $drag.offset().top + drg_h - e.pageY,
				pos_x = $drag.offset().left + drg_w - e.pageX;

			$drag.css( 'z-index', 1000 ).parents().on( 'mousemove', function ( e ) {
				$( '.draggable' ).offset( {
					top: e.pageY + pos_y - drg_h,
					left: e.pageX + pos_x - drg_w
				} ).on( 'mouseup', function () {
					$( this ).removeClass( 'draggable' ).css( 'z-index', z_idx );
				} );
			} );
			e.preventDefault(); // disable selection
		} ).on( 'mouseup mouseout', function () {
			if ( opt.handle === '' ) {
				$( this ).removeClass( 'draggable' );
			} else {
				$( this ).removeClass( 'active-handle' ).parent().removeClass( 'draggable' );
			}
		} );

	};
}( jQuery ) );
/* jshint browser: true */

( function () {

	// We'll copy the properties below into the mirror div.
	// Note that some browsers, such as Firefox, do not concatenate properties
	// into their shorthand (e.g. padding-top, padding-bottom etc. -> padding),
	// so we have to list every single property explicitly.
	const properties = [
		'direction', // RTL support
		'boxSizing',
		'width', // on Chrome and IE, exclude the scrollbar, so the mirror div wraps exactly as the textarea does
		'height',
		'overflowX',
		'overflowY', // copy the scrollbar for IE

		'borderTopWidth',
		'borderRightWidth',
		'borderBottomWidth',
		'borderLeftWidth',
		'borderStyle',

		'paddingTop',
		'paddingRight',
		'paddingBottom',
		'paddingLeft',

		// https://developer.mozilla.org/en-US/docs/Web/CSS/font
		'fontStyle',
		'fontVariant',
		'fontWeight',
		'fontStretch',
		'fontSize',
		'fontSizeAdjust',
		'lineHeight',
		'fontFamily',

		'textAlign',
		'textTransform',
		'textIndent',
		'textDecoration', // might not make a difference, but better be safe

		'letterSpacing',
		'wordSpacing',

		'tabSize',
		'MozTabSize'

	];

	const isBrowser = ( typeof window !== 'undefined' );
	const isFirefox = ( isBrowser && window.mozInnerScreenX != null );

	function getCaretCoordinates( element, position, options ) {
		if ( !isBrowser ) {
			throw new Error( 'textarea-caret-position#getCaretCoordinates should only be called in a browser' );
		}

		const debug = options && options.debug || false;
		if ( debug ) {
			const el = document.querySelector( '#input-textarea-caret-position-mirror-div' );
			if ( el ) { el.parentNode.removeChild( el ); }
		}

		// The mirror div will replicate the textarea's style
		const div = document.createElement( 'div' );
		div.id = 'input-textarea-caret-position-mirror-div';
		document.body.appendChild( div );

		const style = div.style;
		const computed = window.getComputedStyle ? window.getComputedStyle( element ) : element.currentStyle; // currentStyle for IE < 9
		const isInput = element.nodeName === 'INPUT';

		// Default textarea styles
		style.whiteSpace = 'pre-wrap';
		if ( !isInput ) { style.wordWrap = 'break-word'; } // only for textarea-s

		// Position off-screen
		style.position = 'absolute'; // required to return coordinates properly
		if ( !debug ) { style.visibility = 'hidden'; } // not 'display: none' because we want rendering

		// Transfer the element's properties to the div
		properties.forEach( function ( prop ) {
			if ( isInput && prop === 'lineHeight' ) {
				// Special case for <input>s because text is rendered centered and line height may be != height
				if ( computed.boxSizing === 'border-box' ) {
					const height = parseInt( computed.height );
					const outerHeight =
          parseInt( computed.paddingTop ) +
          parseInt( computed.paddingBottom ) +
          parseInt( computed.borderTopWidth ) +
          parseInt( computed.borderBottomWidth );
					const targetHeight = outerHeight + parseInt( computed.lineHeight );
					if ( height > targetHeight ) {
						style.lineHeight = height - outerHeight + 'px';
					} else if ( height === targetHeight ) {
						style.lineHeight = computed.lineHeight;
					} else {
						style.lineHeight = 0;
					}
				} else {
					style.lineHeight = computed.height;
				}
			} else {
				style[ prop ] = computed[ prop ];
			}
		} );

		if ( isFirefox ) {
			// Firefox lies about the overflow property for textareas: https://bugzilla.mozilla.org/show_bug.cgi?id=984275
			if ( element.scrollHeight > parseInt( computed.height ) ) { style.overflowY = 'scroll'; }
		} else {
			style.overflow = 'hidden'; // for Chrome to not render a scrollbar; IE keeps overflowY = 'scroll'
		}

		div.textContent = element.value.substring( 0, position );
		// The second special handling for input type="text" vs textarea:
		// spaces need to be replaced with non-breaking spaces - http://stackoverflow.com/a/13402035/1269037
		if ( isInput ) { div.textContent = div.textContent.replace( /\s/g, '\u00a0' ); }

		const span = document.createElement( 'span' );
		// Wrapping must be replicated *exactly*, including when a long word gets
		// onto the next line, with whitespace at the end of the line before (#7).
		// The  *only* reliable way to do that is to copy the *entire* rest of the
		// textarea's content into the <span> created at the caret position.
		// For inputs, just '.' would be enough, but no need to bother.
		span.textContent = element.value.substring( position ) || '.'; // || because a completely empty faux span doesn't render at all
		div.appendChild( span );

		const coordinates = {
			top: span.offsetTop + parseInt( computed.borderTopWidth ),
			left: span.offsetLeft + parseInt( computed.borderLeftWidth ),
			height: parseInt( computed.lineHeight )
		};

		if ( debug ) {
			span.style.backgroundColor = '#aaa';
		} else {
			document.body.removeChild( div );
		}

		return coordinates;
	}

	if ( typeof module !== 'undefined' && typeof module.exports !== 'undefined' ) {
		module.exports = getCaretCoordinates;
	} else if ( isBrowser ) {
		window.getCaretCoordinates = getCaretCoordinates;
	}

}() );

/**
 * Better jQuery click event that's not invoked when you drag or select text
 *
 * Copyright (C) 2018 Jakub T. Jankiewicz <https://jcubic.pl/me>
 * Released under MIT license
 *
 * solution based on this SO question
 * https://stackoverflow.com/a/21851799/387194
 */
/* global jQuery, setTimeout, clearTimeout, define, module, exports */
( function ( factory ) {
	if ( typeof define === 'function' && define.amd ) {
		// AMD. Register as an anonymous module.
		define( [ 'jquery' ], factory );
	} else if ( typeof exports === 'object' ) {
		// Node/CommonJS style for Browserify
		module.exports = factory;
	} else {
		// Browser globals
		factory( jQuery );
	}
}( function ( $ ) {
	$.event.special.draglessClick = {
		setup: function () {
			console.log( 'setup' );
			const $element = $( this );
			const callbacks = $.Callbacks();
			let isDragging = false;
			let timer;
			var handlers = {
				move: function mousemove() {
					isDragging = true;
					$( window ).off( 'mousemove', handlers.move );
				},
				down: function () {
					isDragging = false;
					// there is wierd issue where move is triggerd just
					// after mousedown even without moving the cursor
					timer = setTimeout( function () {
						$( window ).on( 'mousemove', handlers.move );
					}, 100 );
				},
				up: function () {
					clearTimeout( timer );
					$( window ).off( 'mousemove', handlers.move );
				},
				click: function ( e ) {
					const wasDragging = isDragging;
					isDragging = false;
					if ( !wasDragging ) {
						callbacks.fireWith( this, [ e ] );
					}
				}
			};
			$element
				.data( 'handlers', handlers )
				.data( 'callbacks', callbacks )
				.on( 'mousedown', handlers.down )
				.on( 'mouseup', handlers.up )
				.on( 'click', handlers.click );
		},
		teardown: function () {
			const $element = $( this );
			const callbacks = $element.data( 'callbacks' );
			callbacks.empty();
			$element.removeData( 'callbacks' );
			const handlers = $element.data( 'handlers' );
			if ( handlers ) {
				$( window ).off( 'mousemove', handlers.move );
			}
			$element
				.off( 'mousedown', handlers.down )
				.off( 'mouseup', handlers.up )
				.off( 'click', handlers.click );
		},
		add: function ( handlerObject ) {
			$( this ).data( 'callbacks' ).add( handlerObject.handler );
		},
		remove: function ( handlerObject ) {
			$( this ).data( 'callbacks' ).remove( handlerObject.handler );
		},
		trigger: function ( e, data ) {
			const event = $.Event( 'click' );
			$( this ).data( 'callbacks' ).fireWith( this, [ event ] );
		}
	};
} ) );