User:Inductiveload/MiniPane.js

/** * 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 = $( ' ' ) .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 = $( ' ' ) .attr( 'src', $ppImg.attr( 'src' ) ) .css( {				position: 'absolute',				width: '100%'			} ) .appendTo( $imgLoupe );

$( ' ' )			.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 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 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  * 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 ] ); }	}; } ) );