/**
* 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 ] );
}
};
} ) );