User:Yair rand/HistoryView.js

/** * Display history in a more readable manner. * * To enable, add importScript( 'User:Yair_rand/HistoryView.js' ); to your Special:MyPage/common.js * * * @author Yair Rand (User:Yair rand) * @version 0.1.4 */

// Important todos: // * Finish selectRows. // ** Temporarily just set to exit row select when panning or zooming.

// Things I am very unsure about: // * Display of reverts. The gradient thing might be unclear and/or ugly. // * Whether there should be gaps for unedited lines in the display. // *

// TODO: Move logs. (Depends on T10731.) // TODO: Bot flag icons. (Depends on T13181.)

// Tags are absent, but not available as DOM via API. (No phab task open afaict.) TODO: Figure something out.

// TODO: This is broken for certain non-English languages, as numbers in 'Line XX' aren't arabic numerals.

// PROBLEM: If zooming to one col, then pan to protect, there are no columns. // Logs' edits are removed, but still stored in revisions in. Necessary, bc // otherwise would be inconsistent with edit count. // Maybe modify pan to skip in those situations?

// TODO: Ask someone if old protect logs are incomplete. (MW Main Page, Brion's 2007 protect has no params or details.)

// Idea: Maybe have locked "Line ##" above the diffHolder, updating on scroll? // Idea: An "expand" icon between groups of context lines, to fill in from other // changes. // Idea: An option to show ORES score?

mw.config.get( 'wgAction' ) === 'history' && mw.config.get( 'wgPageContentModel' ) === 'wikitext' && Promise.all( [ Promise.resolve( $.ready ),  mw.loader.using( [ 'mediawiki.api', 'mediawiki.Title', 'oojs-ui-core', 'oojs-ui-widgets' ] ) ] ).then( function {  var    // Height of the canvas element.    fullHeight = 300,    // Height of the vertical bars dividing changes from each other.    barsHeight = 250,    // The area that includes the changes themselves    changeAreaHeight = 170,    // ...and at the bottom of the bars area, the usernames. (There's a 5px gap // between the changes and usernames: 250 - 170 - 75 = 5. )   userNameHeight = 75,    // Height of the "diffHolder" element which holds the visible diff tables.    spaceHeight = 300,    // Width of the content area.    fullWidth,    changeRows = [],    changeCols = [],    logIcons = [],    settings = ( ( settingsString ) => { return settingsString ? JSON.parse( settingsString ) : {}; } )( mw.user.options.get( 'userjs-historyview-settings' ) ),   canvas = document.createElement( 'canvas' ),    canvasDisplay,    domHandler,    onWatchlist = !!document.querySelector( '#ca-unwatch' ),    i18n = {      en: {        'HV-Loading':      'Loading...',        'HV-Position':     '$1 - $2 of $3',        'HV-ShowEarliest': 'Show earliest changes',        'HV-ShowEarlier':  'Show earlier changes',        'HV-ShowLater':    'Show more recent changes',        'HV-ShowLatest':   'Show most recent changes',        'HV-ZoomIn':       'Zoom in',        'HV-ZoomOut':      'Zoom out',        'HV-Disable':      'Disable HistoryView.js',        'HV-Enable':       'Enable HistoryView.js',        'HV-DisableTT':    'Return to basic history view',        'HV-EnableTT':     '', // TODO        'HV-ViewLogs':     'View logs',        'HV-SelectDate':   'Select date',        'HV-FilterTags':   'Filter by tags', 'HV-LastVisited': 'You last visited this page before $1.', 'HV-MultiRev':    'Showing multiple revisions', 'HV-ProtectLog':  '$1 protected the page.', 'HV-UnprotectLog': '$1 unprotected the page.', 'HV-DeleteLog':   '$1 deleted the page.', 'HV-RestoreLog':  '$1 restored the page.', 'HV-MoveLog':     '$1 moved the page.', 'HV-DeletedRev':  '(deleted)', 'HV-DeletedUser': '(removed)', 'HV-RemovedUser': '(Username or IP removed)' }   },    icons = { // Icons 'lock', 'unlock', 'trash', 'undo', 'exchange-alt', 'times', 'eye' from FontAwesome. // * Font Awesome Free 5.1.0 by @fontawesome - https://fontawesome.com // * License - https://fontawesome.com/license (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)

'protect': [ // 'lock' 448, 512,       `M400 224h-24v-72C376 68.2 307.8 0 224 0S72 68.2 72 152v72H48c-26.5 0-48 21.5-48 48v192c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V272c0-26.5-21.5-48-48-48zm-104 0H152v-72c0-39.7 32.3-72 72-72s72 32.3 72 72v72z` ],     'unprotect': [ // 'unlock' 448, 512,       `M400 256H152V152.9c0-39.6 31.7-72.5 71.3-72.9 40-.4 72.7 32.1 72.7 72v16c0 13.3 10.7 24 24 24h32c13.3 0 24-10.7 24-24v-16C376 68 307.5-.3 223.5 0 139.5.3 72 69.5 72 153.5V256H48c-26.5 0-48 21.5-48 48v160c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V304c0-26.5-21.5-48-48-48z` ],     'delete': [ // 'trash' 448, 512,       `M0 84V56c0-13.3 10.7-24 24-24h112l9.4-18.7c4-8.2 12.3-13.3 21.4-13.3h114.3c9.1 0 17.4 5.1 21.5 13.3L312 32h112c13.3 0 24 10.7 24 24v28c0 6.6-5.4 12-12 12H12C5.4 96 0 90.6 0 84zm415.2 56.7L394.8 467c-1.6 25.3-22.6 45-47.9 45H101.1c-25.3 0-46.3-19.7-47.9-45L32.8 140.7c-.4-6.9 5.1-12.7 12-12.7h358.5c6.8 0 12.3 5.8 11.9 12.7z` ],     // Currently using simple "undo" icon. 'restore': [ 512, 512,       `M212.333 224.333H12c-6.627 0-12-5.373-12-12V12C0 5.373 5.373 0 12 0h48c6.627 0 12 5.373 12 12v78.112C117.773 39.279 184.26 7.47 258.175 8.007c136.906.994 246.448 111.623 246.157 248.532C504.041 393.258 393.12 504 256.333 504c-64.089 0-122.496-24.313-166.51-64.215-5.099-4.622-5.334-12.554-.467-17.42l33.967-33.967c4.474-4.474 11.662-4.717 16.401-.525C170.76 415.336 211.58 432 256.333 432c97.268 0 176-78.716 176-176 0-97.267-78.716-176-176-176-58.496 0-110.28 28.476-142.274       72.333h98.274c6.627 0 12 5.373 12 12v48c0 6.627-5.373 12-12 12z` ],     'move': [ // 'exchange-alt' 512, 512,       `M0 168v-16c0-13.255 10.745-24 24-24h360V80c0-21.367 25.899-32.042 40.971-16.971l80 80c9.372 9.373 9.372 24.569 0 33.941l-80 80C409.956 271.982 384 261.456 384 240v-48H24c-13.255 0-24-10.745-24-24zm488 152H128v-48c0-21.314-25.862-32.08-40.971-16.971l-80 80c-9.372 9.373-9.372 24.569 0 33.941l80 80C102.057 463.997 128 453.437 128 432v-48h360c13.255 0 24-10.745 24-24v-16c0-13.255-10.745-24-24-24z` ],     'revdeleted': [ // 'times' 352, 512,       `M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z` ],     'lastSeen': [ // 'eye' 576, 512,       `M569.354 231.631C512.969 135.949 407.81 72 288 72 168.14 72 63.004 135.994 6.646 231.631a47.999 47.999 0 0 0 0 48.739C63.031 376.051 168.19 440 288 440c119.86 0 224.996-63.994 281.354-159.631a47.997 47.997 0 0 0 0-48.738zM288 392c-75.162 0-136-60.827-136-136 0-75.162 60.826-136 136-136 75.162 0 136 60.826 136 136 0 75.162-60.826 136-136       136zm104-136c0 57.438-46.562 104-104 104s-104-46.562-104-104c0-17.708 4.431-34.379 12.236-48.973l-.001.032c0 23.651 19.173 42.823 42.824 42.823s42.824-19.173 42.824-42.823c0-23.651-19.173-42.824-42.824-42.824l-.032.001C253.621 156.431 270.292 152 288 152c57.438 0 104 46.562 104 104z` ]     // TODO: 'Merge' icon. // For users, maybe: block (hand?), unblock, merge, userrights, usercreate... }; /**   * Format a date to "00:00 1 January 2018" style. * @param {Date} timestamp */ function formatTimestamp( timestamp ) { return timestamp.getUTCHours.toString.padStart( 2, 0 ) + ':' + timestamp.getUTCMinutes.toString.padStart( 2, 0 ) + ', ' + mw.language.months.names[ timestamp.getUTCMonth ] + ' ' + timestamp.getUTCDate + ' ' + timestamp.getUTCFullYear; } /**   * For managing API requests and such. */ var apiHandler = (  => {    // This uses no external vars other than mw and onWatchlist.    /**     * Set up a cache of linear API results of a particular type.     *     * @param {string} type     * @param {('revid'/'logid')} idType     * @param {'rvcontinue'/'lecontinue'} continueTokenType     * @return {Object}     */    function resultsCache( type, idType, continueTokenType ) {      /**       *       */      function setToEdge( dir ) {        cache.active = lists[ dir === 1 ? 'start' : 'end' ];      }      /**       * Check if the entries in cache.start and cache.end have any overlap, and       * if they do, extend both to include all data from each other.       */      function attemptLinkUp {        // Check two arrays for overlap, to link up. ALso set completion status if        // linked up from end to end. TODO: Also, check links for mid-range arrays, like from date ranges. // NOTE: Don't lose cached elements in merging. // Wait, is anything cached in the lists? Or only in the compares, which // don't have this issue anyway? // NOTE: Being linked implies being completed, for the mains. // Linking can happen from completing either side, or by finding overlap. var { start, end } = lists, lastEntryInEnd = end.list.slice( -1 )[ 0 ], // The lists are nicely ordered, so we only need to check edges of each // for matches. matchPoint = start.list.findIndex( entry => entry[ idType ] === lastEntryInEnd[ idType ] ); // TODO: Refactor. Less duplication between two sections. // Mutate the existing arrays, don't assign new ones, so that references // from .active don't break. // If one side has the whole list, just use that for both. if ( cache.completed ) { // Use the completed one to fill in both sides. // We assume that .active is the complete one, because that's what was // just extended, and attemptLinkUp is only called (atm) during extending. if ( cache.active === start ) { end.list.push( ...start.list.slice( 0, -end.list.length || undefined ).reverse ); } else { start.list.push( ...end.list.slice( 0, -start.list.length || undefined ).reverse ); }       } else if ( matchPoint !== -1 ) { cache.completed = true; end.list.push( ...start.list.slice( 0, matchPoint || undefined ).reverse ); // start.list = end.list.slice( 0 ).reverse; start.list.push( ...end.list.slice( 0, -start.list.length ).reverse ); } else { return false; }       return true; }     /**       * Add new results from the API to the cache. */     function addResults( apiResult, entries ) { cache.active.list.push( ...entries ); cache.completed = !( 'continue' in apiResult ); cache.active.continueToken = apiResult.continue && apiResult.continue[ continueTokenType ]; attemptLinkUp; }     var // TODO: midLists also need continueTokens...       // Midlists needs many lists. Need to store anything other than the token and the list itself? lists = { // List changes from the start/earliest changes. start: { list: [], continueToken: undefined },         // List from the end/most recent changes. end: { list: [], continueToken: undefined }       },        cache = { // Each set of stored results can simultaneously have different sets of         // results from different time periods, with no way of cross-indexing them. // We might be running from the earliest, or latest, or some date in the middle. // The "active" set is whichever we're currently navigating from. active: lists.end, completed: false, type, setToEdge, addResults };     return cache; }   var busy = false, // Cached API results. stored = { revisions: resultsCache( 'revision', 'revid', 'rvcontinue' ), protectLogs: resultsCache( 'protect', 'logid', 'lecontinue' ), deleteLogs: resultsCache( 'delete', 'logid', 'lecontinue' ), lastSeen: null, compares: {} },     // Offset from the edge of whatever set of edits we're navigating. offset = 0, // Are we navigating from the most recent edits, or earliest? fromRecent = true, // How many edits to return at once? rangeSize = 50, // There are two issues where we need to record multiple rangeSize vars. // * When filtering to rows. // * (Also maybe when col filtering?) preRowFilterRangeSize = false, // Number of edits made to the page since creation. editcount, revDeleteLogs = {}, loadedInitData = false; // Consider merging this with the logs things. Or at least less duplication. /**    * Fetch revisions from the API and cache them, or get cached revisions. */   function getRevisions { var { revisions, revisions: { active: { list, continueToken } } } = stored; // console.log( 'gr', offset, revisions ); if ( revisions.completed || offset + rangeSize <= list.length ) { // Revisions are already cached. let foundRevisions = list.slice( offset, offset + rangeSize ); if ( !fromRecent ) { // Revisions offset from the start are stored in reverse order. foundRevisions.reverse; }       return Promise.resolve( foundRevisions ); } else { // Fetch revisions. busy = true; return ( new mw.Api ).get( {         action: 'query',          prop: 'revisions',          titles: mw.config.get( 'wgPageName' ),          // Should this just always grab 50?          rvlimit: offset + rangeSize - list.length,          rvprop: 'ids|user|flags|timestamp|sha1|tags',          rvdir: fromRecent ? 'older' : 'newer',          rvcontinue: continueToken        } ).then( result => {          revisions.addResults( result, Object.values( result.query.pages )[ 0 ].revisions );         return getRevisions;        } ); }   }    function processCompare( compare, revision ) { }   /**     *     */    function getCompare( isEmptyDiff, fromrev, torev ) { var fromrevid = fromrev.revid, torevid = torev && torev.revid, key = torev ? fromrevid + '-' + torevid : fromrevid; if ( stored.compares[ key ] ) { return Promise.resolve( stored.compares[ key ] ); } else { var options = { action: 'compare', fromrev: fromrevid, prop: 'diff|user|ids|comment|parsedcomment', maxage: 60 * 60 * 24 * 30, smaxage: 60 * 60 * 24 * 30 };       if ( torev ) { options.torev = torevid; } else { options.torelative = 'prev'; }       return ( isEmptyDiff ? Promise.resolve( {} ) : ( new mw.Api ).get( options ) ) .catch( ( error, ...y ) => {           console.log( 445, error, y );            // TODO: Show different things for actual errors than for deleted content.            // y has error messages.            return {              missingcontent: true,              error,              user: 'userhidden' in fromrev ? '?' : fromrev.user,              timestamp: new Date( fromrev.timestamp ),              compare: { '*': '(empty)' }            };          } ) .then( compare => {           // Extend compare result with result from revision.            compare.timestamp = new Date( fromrev.timestamp );            compare.sha1 = 'sha1hidden' in fromrev ? '?' : fromrev.sha1;            compare.user = 'userhidden' in fromrev ? '?' : fromrev.user;            compare.minor = 'minor' in fromrev;            compare.bot = 'bot' in fromrev;            compare.anon = 'anon' in fromrev;            compare.userhidden = 'userhidden' in fromrev;            compare.revid = fromrev.revid;            stored.compares[ key ] = compare;            return compare;          } ); }   }    /**     * Get diffs for a set of revisions. */   function getCompares( revisions ) { // TODO: If rev.sha1hidden or sha1 matches prior, it might be possible to     // avoid fetching. // Probably requires changing getRevisions to include edit summary. return Promise.all( revisions.map( ( rev, i ) => { var priorRev = revisions[ i + ( fromRecent ? 1 : -1 ) ], isEmptyDiff = !!( priorRev && priorRev.sha1 === rev.sha1 && rev.sha1hidden === undefined ); // TODO: Also don't retrieve deleted edits, but do fill in the deleted edit data.. return getCompare( isEmptyDiff, rev ); } ) ).then( compares => compares.filter( x => x.compare && x.compare[ '*' ] ) ); }   /**     * Fetch logs via the API and store them, or get cached logs if available. */   function getLogs( cache ) { // console.log( 'gl2', cache ); return ( new mw.Api ).get( {       action: 'query',        list: 'logevents',        letitle: mw.config.get( 'wgPageName' ),        lelimit: 5,        letype: cache.type,        leprop: 'type|user|timestamp|comment|parsedcomment|details|ids',        ledir: fromRecent ? 'older' : 'newer',        lecontinue: cache.active.continueToken      } ).then( logResult => {        let newLogs = logResult.query.logevents;        cache.addResults( logResult, newLogs );        // Process revdelete logs.        newLogs.forEach( log => { if ( log.action === 'revision' && log.type === 'delete' ) { log.params.ids.forEach( revid => {             revDeleteLogs[ revid ] = revDeleteLogs[ revid ] || [];              // Avoid duplicates.              if ( revDeleteLogs[ revid ].every( dLog => dLog.logid !== log.logid ) ) {                revDeleteLogs[ revid ].push( log );              }            } ); }       } );      } );    }    // TODO: List log types. // Protect, Unprotect, Delete (garbage can? ooui has "trash" and "unTrash"), undelete, merge. // For users, maybe: block (hand? ooui has "block"/"unBlock"), unblock, merge, userrights, usercreate... //   // In practise, for right now this should be just [un]protect and [un]delete. /**    * Get log entries relevant to a specific time range. *    * @param {Object} cache Set of logs (from stored). * @param {String|undefined} start UTC date string, representing the earliest date allowed in the range * @param {String|false} end */   function getLogsForRange( cache, start, end ) { // console.log( 'gl', start, end, cache, cache.completed || cache.active.list.length && cache.active.list.slice( -1 )[ 0 ].timestamp ); // For other: Resolve when at least one before start. var [ lastLog ] = cache.active.list.slice( -1 ); // Check if the cached logs already contain the range. if ( cache.completed || lastLog && ( fromRecent ? lastLog.timestamp < start : lastLog.timestamp > end ) ) { // TODO: Clean up comments here. return Promise.resolve( cache.active.list.filter( ( log, i, allLogs ) => // Filter for timestamp. (           !start || log.timestamp > start ||            ( // For protect logs, include log if expires before start, even if             // protection starts before start time. log.type === 'protect' && log.action !== 'unprotect' && // Expires after start ( ( log.params && log.params.details && log.params.details[ 0 ].expiry !== 'infinite' ) ?                 log.params.details[ 0 ].expiry > start :                  // No explicit expiry given, or indefinite. Assume that it will                  // only expire at the next change.                  ( !allLogs[ fromRecent ? i - 1 : i + 1 ] || allLogs[ fromRecent ? i - 1 : i + 1 ].timestamp > start )               ) )         ) &&          ( !end || log.timestamp < end ) ) );     } else { // We don't have enough log data available. Get some from the API, then // try again. return getLogs( cache ).then( => {          return getLogsForRange( cache, start, end );        } ); }   }    /**     * If the page is on the user's watchlist, find out when the user's last * visit to the page was. */   function getLastSeen { if ( onWatchlist ) { if ( stored.lastSeen ) { return stored.lastSeen; } else { return ( new mw.Api ).get( {           prop: 'info',            inprop: 'notificationtimestamp',            titles: mw.config.get( 'wgPageName' )          } ).then( result => {            var lastSeen = Object.values( result.query.pages )[ 0 ].notificationtimestamp,              log = {                type: 'lastSeen',                timestamp: lastSeen              };            stored.lastSeen = lastSeen ? [ log ] : [];            return stored.lastSeen;          } ); }     } else { return Promise.resolve( [] ); }   }    function getTimeStamp( continueToken ) { return continueToken && continueToken.split( '|' )[ 0 ].replace(       /(....)(..)(..)(..)(..)(..)/,        '$1-$2-$3T$4:$5:$6Z'      ); }   /**     * Get edits and logs for the current range. * @return {Promise} */   function getData { // Logs should start running at the same time as revisions, not afterwards. var revisionsPromise = getRevisions; return Promise.all( [       revisionsPromise          .then( revs => getCompares( revs ) ),        revisionsPromise          .then( revs => { // This might be incomprehensible... TODO: Cleanup. var { active, completed } = stored.revisions, lowerRev = active.list[ offset - 1 ], higherRev = active.list[ offset + rangeSize ], priorRev = fromRecent ? higherRev : lowerRev, followingRev = fromRecent ? lowerRev : higherRev, // If the next revision isn't available, use the timestamp from // the continue token, which is the same. // However, if the list is complete, the continue token is no             // longer valid. continueTimestamp = !completed && getTimeStamp( active.continueToken ), priorTimestamp = priorRev ? priorRev.timestamp : ( fromRecent && continueTimestamp ), followingTimestamp = followingRev ? followingRev.timestamp : ( !fromRecent && continueTimestamp ); return Promise.all( [             getLogsForRange( stored.protectLogs, priorTimestamp, followingTimestamp ),              getLogsForRange( stored.deleteLogs, priorTimestamp, followingTimestamp )                .then( deleteLogs => deleteLogs // Don't show deletions of individual revisions in the history. .filter( log => log.action !== 'revision' ) ),             getLastSeen            ] ).then( ( [ protectLogs, deleteLogs, lastSeen ] ) => {              return protectLogs                .concat( deleteLogs )                .concat( lastSeen )                // Sort chronologically.                .sort( ( log1, log2 ) => log1.timestamp < log2.timestamp ? 1 : -1 );           } );          } ),        /*        // Add revision tags.        // Doesn't work. Tags are inaccessible without running repeated action:parses or scraping the history html.        // mw.message.parse doesn't work for  transclusions, and loadMessagesIfMissing doesn't         // get dependencies anyway        // Ideally there would just be a parsedtags option in action=revisions...        revisionsPromise          .then( revs => { var allTags = []; revs.forEach( rev => rev.tags && rev.tags.forEach( tag => allTags.indexOf( tag ) === -1 && allTags.push( tag ) ) );           console.log( allTags, 33 ) return new mw.Api.loadMessagesIfMissing(             allTags.map( tag => 'tag-' + tag )            ); } ),       */        // If the basics and dependencies haven't yet been loaded, load them.        loadedInitData || getInitData      ] ).then( ( [ compares, logs ] ) => {        compares.forEach( compare => { compare.deleteLog = revDeleteLogs[ compare.revid ]; } );       busy = false;        return [ compares, logs ];      } ); }   /**     * Get dependencies, messages, and page's edit count. * @return {Promise} */   function getInitData { return Promise.all( [       // Get edit count.        // Doing this is a mess, involving scraping action=info. See T19993 for making a proper API for it.        // NOTE: If date of first edit is needed, can be reached from #mw-pageinfo-firsttime here (as English date string).        fetch( new mw.Title( mw.config.get( 'wgPageName' ) ).getUrl( { action:'info', uselang: 'en' } ) )          .then( x => x.text )          .then( html => { var frag = ( new DOMParser ).parseFromString( html, 'text/html' ); editcount = +frag.querySelector( '#mw-pageinfo-edits td + td' ).innerText.replace( /,/g, '' ); } ),       // Load dependencies        mw.loader.using( [ 'mediawiki.diff.styles', 'mediawiki.language.months' ] ),       // Load Mediawiki messages        ( new mw.Api ).get( { action: 'query', meta: 'allmessages', amlang: mw.config.get( 'wgUserLanguage' ), ammessages: [ 'minoreditletter', 'boteditletter', 'recentchanges-label-minor', 'recentchanges-label-bot', 'talkpagelinktext', 'contribslink', 'editundo', 'tooltip-undo', 'thanks-thank', 'thanks-thank-tooltip', 'rev-deleted-user', 'dellogpage', 'revdelete-content-hid', 'revdelete-summary-hid', 'revdelete-uname-hid', 'revdelete-content-unhid', 'revdelete-summary-unhid', 'revdelete-uname-unhid', 'tag-list-wrapper', 'tag-canned_edit_summary', 'diff-paragraph-moved-toold', 'diff-paragraph-moved-tonew' ].join( '|' ), maxage: 60 * 60 * 24 * 30, smaxage: 60 * 60 * 24 * 30 } ).then( messages => { messages.query.allmessages.forEach( message => {           if ( 'missing' in message ) {              // TODO: Something? Not sure. Absence of certain messages on some wikis can cause problems...              mw.messages.set( message.name, '<' + message.name + '>' );            } else {              mw.messages.set( message.name, message[ '*' ] );            }          } ); } )     ] ).then(  => {        loadedInitData = true;      } ); }   /**     * Determine whether there is room to pan a certain direction. * Negative is later/more recent, positive is earlier/further back. *    * @param {Number} dir Which direction to check. 1 -> earlier, -1 -> more recent. * @return {Boolean} */   function canPan( dir ) { return !busy && ( dir === 1 ?       fromRecent ?          offset + rangeSize < editcount :          offset > 0 :        fromRecent ?          offset > 0 :          offset + rangeSize < editcount ); }   /**     * @param {Number} dir 1 -> earlier, -1 -> more recent */   function pan( dir ) { var _dir = fromRecent ? dir : -dir; if ( _dir === 1 ) { offset = Math.max( offset + rangeSize * _dir, 0 ); cancelShift; } else { cancelShift; offset = Math.max( offset + rangeSize * _dir, 0 ); }   }    /**     * Pan all the way to the oldest or most recent edit. * @param {Number} dir Which edge to pan to. 1 -> earliest, -1 -> most recent. */   function panToEdge( dir ) { offset = 0; fromRecent = dir === -1; cancelShift; stored.revisions.setToEdge( dir ); stored.protectLogs.setToEdge( dir ); stored.deleteLogs.setToEdge( dir ); }   /**     * Zoom in or out, to display more or fewer edits at once. * @param {Number} dir Whether to zoom in or out. 1 -> zoom in, -1 -> zoom out. */   function zoom( dir ) { cancelShift; rangeSize = Math.min( Math.floor( rangeSize * ( dir === 1 ? 0.5 : 2 ) ) || 1, 500 ); }   function canZoom( type ) { return !busy && ( type === 1 ? rangeSize !== 1 : rangeSize < 500 && rangeSize < editcount ); }   /**     * When showing only specific rows, display some more edits. */   function displayMore { var amount = 50; // Should there be different "actual (backend-used) rangeSize" / "user-apparent rangeSize"? if ( rangeSize >= 500 ) { return false; } else if ( offset + rangeSize >= editcount ) { // Ran into the edge. Can we expand in the other direction? if ( offset === 0 ) { return false; } {         offset = Math.max( 0, offset - amount ); }     } else { rangeSize += amount; }     return true; }   // TODO. function selectRows( g1, g2 ) { // There are two ways to identify rows. // 1. Record column and index. // 2. Record element, if cached properly. (Or column and element, to speed up performance.) // Store and return " " elements generated from particular compares, used // as boundaries. // When expanding based on row selection, keep going until either we hit the // max, or everything in the range has its first change type = 'add'. // Does this need to be more than one function? Could have one arg, some encapsulated data...     // Need to know whether there's room to scroll left/right, though... Give through return params? if ( g1 || g2 ) { preRowFilterRangeSize = rangeSize; } else { cancelShift; }   }    function cancelShift { if ( preRowFilterRangeSize ) { rangeSize = preRowFilterRangeSize; preRowFilterRangeSize = false; }   }    /**     * Set the range to between two particular edits, and return the diff * between them. * @return {Promise} */   function selectCols( rev1, rev2 ) { var list = stored.revisions.active.list, [ i1, i2 ] = [ rev1, rev2 ].map( revid => list.findIndex( revision => revision.revid === revid ) ); offset = fromRecent ? i2 : i1; rangeSize = fromRecent ? i1 - i2 + 1 : i2 - i1 + 1; console.log( i1, i2 ); return getCompare( false, list[ i1 ], list[ i2 ] ); }   /**     * "1 - 50 of 123" * @return {String} */   function getPosition { return mw.msg(       'HV-Position',        fromRecent ?          offset + 1 :          Math.max( 1, editcount - offset + 1 - rangeSize ),        fromRecent ?           ( editcount ? Math.min( offset + rangeSize, editcount ) : '?' ) :         ( editcount - offset ),        ( editcount || '?' )      ); }   return { getData, pan, panToEdge, zoom, selectRows, selectCols, canPan, canZoom, displayMore, isBusy: => busy, getPosition }; } );  /**   * @param {Object} compare   * @param {HTMLTableElement} [revertedFrom] If this edit reverted the edit   * immediately before it, the diff of the reverted edit.   *   * @return {jQuery} return.$table The diff table   * @return {jQuery} return.$trs   * @return {jQuery} return.$delLogElem   */  function getCompareElement( compare, revertedFrom ) {    function getElements {      var $table,        $trs,        $delLogElem,        missingcontent = compare.missingcontent,        isRevert = !!revertedFrom;      if ( isRevert ) {        if ( compare.$rvTable ) {          ( { $rvTable: $table, $rvTrs: $trs } = compare );        } else {          $table = compare.$rvTable = createRevertElement( $( revertedFrom ) );          $trs = compare.$rvTrs = $table.find( 'tr' );        }      } else {        if ( compare.$table ) {          // Get cached element.          ( { $table, $trs } = compare ); } else { if ( missingcontent ) { $table = createEmptyTable; $trs = $( [] ); } else { $table = $( ' '           ); $table.find( 'tbody' ).html( compare.compare[ '*' ] ); $trs = $table.find( 'tr' ); }         // Cache compare.$table = $table; compare.$trs = $trs; }     }      // Add revision deletion log. if ( compare.$delLogElem ) { // From cache $delLogElem = compare.$delLogElem; } else if ( compare.deleteLog ) { // Build log element. $delLogElem = compare.$delLogElem = createDeletionLogElement( compare.deleteLog ); }     return { $table, $trs, $delLogElem }; }   function createEmptyTable { var $table = $( ' ' ); $table.find( 'td' ).text( mw.msg( 'HV-DeletedRev') ); return $table; }   /**     * @return {jQuery} */   function createDeletionLogElement( deleteLog ) { return $( ' ' ).append(       $( ' ' ).text( mw.msg( 'dellogpage' ) ),        $( '' ).append( deleteLog.map( log => {          var types = [];          // Find types of visibility changes, eg hidden content, username          [            [ 'content', 'content' ],            [ 'comment', 'summary' ],            [ 'user', 'uname' ]          ].forEach( ( [ paramKey, messageKey ] ) => { var visibilityChangeMessage = paramKey in log.params.new ? messageKey + '-hid' : paramKey in log.params.old ? messageKey + '-unhid' : ''; if ( visibilityChangeMessage ) { types.push( mw.msg( 'revdelete-' + visibilityChangeMessage ) ); }         } );          return $( '' ).append( // Date $( ' ' ).text( formatTimestamp( new Date( log.timestamp ) ) ), ' ',           // User link $( '' ).text( log.user ).attr( 'href', new mw.Title( log.user, 2 ).getUrl ), ' - ',           $( ' ' ).text( types.join( ', ' ) ), ' ',           // Log summary log.parsedcomment && $( ' ' ).addClass( 'comment' ).html( '(' + log.parsedcomment + ')' ) );       } ) )      );    }    /**     * Build a diff table equivalent to a revert of another edit. *    * Revert diffs made by the normal diff engine are, unfortunately, not * always simmetrical from the original diff. See * https://en.wikipedia.org/w/index.php?diff=355931478 and predecessor * for example. So, we build the mirror diff right here. *    * @param {jQuery} $oldElem * @return {jQuery} */   function createRevertElement( $oldElem ) { var $elem = $oldElem.clone( true ), addClass = 'diff-addedline', delClass = 'diff-deletedline', mtClass = 'mw-diff-movedpara-left', mfClass = 'mw-diff-movedpara-right', $adds = $elem.find( '.diff-addedline' ), $dels = $elem.find( '.diff-deletedline' ), $iAdds = $elem.find( 'ins' ), $iDels = $elem.find( 'del' ), $lineNos = $elem.find( '.diff-lineno + .diff-lineno' ), $mods = $elem.find( '.' + delClass + ' ~ .' + addClass ), $empties = $elem.find( '.diff-empty' ), markerText = { del: '−', add: '+', mt: '⚫', mf: '⚫' }; // Swap "added" and "deleted" classes. $adds.removeClass( addClass ).addClass( delClass ); $dels.removeClass( delClass ).addClass( addClass ); // Replace ins's with del's and vice-versa. [ [ $iAdds, ' ' ], [ $iDels, ' ' ] ].forEach( ( [ $group, tag ] ) => {       $group.each( ( i, inlineChange ) => { var $inlineChange = $( inlineChange ); $inlineChange.replaceWith(           // Duplicate original element, but with a different tag name.            $( tag )              .append( $inlineChange.contents )              .addClass( inlineChange.className )          ); } );     } );      // Swap line numbers. $lineNos.each( ( i, lineNo ) => {       lineNo.parentNode.appendChild( lineNo.previousElementSibling );      } ); //      $empties.each( ( i, empty ) => {        var parent = empty.parentNode,          $marker = $( parent ).find( '.diff-marker' ),          $move = $marker.find( 'a' );        if ( empty.nextElementSibling ) {          // Add -> Del          parent.appendChild( empty );          if ( $move.length ) {            $move.attr( 'title', mw.msg( 'diff-paragraph-moved-tonew' ) );            $move.removeClass( mfClass ).addClass( mtClass );            $move.text( markerText.mt );          } else {            $marker.text( markerText.del );          }        } else {          // Del -> Add          parent.insertBefore( empty, parent.firstChild );          if ( $move.length ) {            $move.attr( 'title', mw.msg( 'diff-paragraph-moved-toold' ) );            $move.removeClass( mtClass ).addClass( mfClass );            $move.text( markerText.mf );          } else {            $marker.text( markerText.add ); }       }      } );      // For modified lines, swap the old and new versions, then replace dels with ins's.      $mods.each( ( i, mod ) => { var parent = mod.parentNode, children = parent.children; parent.insertBefore( children[ 3 ], children[ 1 ] ); parent.appendChild( children[ 2 ] ); } );     return $elem;    }    return getElements( compare );  }  /**   * Process the API results into a more usable format, ordered by rows and columns.   *   * @param {Array} compares List of 'compares', from the action=compare API.   * @return {Array} return.changeCols Each changeCol representing a single edit.   * @return {Array} return.changeRows Each changeRow representing a line on the page.   */  function processDiffs( [ ...compares ], filterRowSettings ) {    var      /**       * List of all rows/lines. These are 1-indexed.       *       * @property {Array} changeRows[].changes List of changes to the row/line.       * @property {Array} changeRows[].headers List of time periods in which       * the row/line has contained a header, along with the text of the header.       * @property {string} changeRows[].headers[].text Contents of the header.       * @property {number} changeRows[].headers[].start Index of the first edit * in which the header appeared. * @property {number} changeRows[].headers[].end Index of the edit in      * which the header was deleted. * @property {number} changeRows[].height Height in pixels of the row, as it appears on      * the canvas. * @property {number} changeRows[].Y Distance, in pixels, between the row and the top * of the canvas. */     changeRows = [], /**      * All columns/edits. (0-indexed.) *      * @property {Array} changeCols[].changes List of changes in this edit. * @property {string} changeCols[].user Username of the author of the edit. * @property {number} changeCols[].width * @property {number} changeCols[].X      * */     changeCols = [], // Map of which edits are reverts to earlier edits, or reverted by later edits.. reverts = compares.map( => ({}) ); /**    * Returns true if the last change in the row is a deletion. * @param {Object} changeRow * @param {Number} [upToColumn] Don't count this column as part of the row. */   function endsInDeletion( changeRow, upToColumn ) { if ( !changeRow ) { return false; }     var changes = changeRow.changes, lastChange = changes[ changes.length - 1 ]; if ( lastChange && lastChange.type === 'del' && lastChange.col !== upToColumn ) { return true; } else { return false; }   }    /**     * Insert change into changeRows, in the appropriate row. (Also set certain    * properties of the change.) * @param {Number} row * @param {Object} change */   function addChange( row, change ) { var rChanges, lastChange, newRow = { changes: [] }; row = skipDeletedRows( row, change.col, change.type === 'add' ); if ( change.type === 'add' ) { // This is a new row. Insert, don't modify an existing row's history, // unless there's an empty gap (deletion) available on the same spot. if ( changeRows.length < row ) { // Insert. Splice stops at the end of an array, so use direct assignment. changeRows[ row ] = newRow; } else if ( endsInDeletion( changeRows[ row ] ) ) { // There's a gap. Add to the end. // If there are several empty insertion points, and one had // content matching the new addition, prioritize that line. for ( let i = row, lastChange; endsInDeletion( changeRows[ i ] ); i++ ) { lastChange = changeRows[ i ].changes.slice( -1 )[ 0 ]; if ( change.addText && lastChange.delText === change.addText ) { // Content matches. looks like a clean revert of a prior deletion. row = i;             lastChange.reverted = true; change.revert = true; break; }         }        } else { // Insert. changeRows.splice( row, 0, newRow ); }     } else { // Create row if not yet created. rChanges = ( changeRows[ row ] = changeRows[ row ] || newRow ).changes; // Check reverts in mods lastChange = rChanges[ rChanges.length - 1 ]; if ( lastChange ) { // If the change is the reverse of the previous change to this row, // mark the changes as revert/reverted. if (           lastChange.type === 'mod' && change.type === 'mod'            //   ||            // // Unsure of whether to count this. All add->dels are "reverts", sort of.            // lastChange.type === 'add' && change.type === 'del'          ) { if ( lastChange.delText === change.addText ) { lastChange.reverted = true; change.revert = true; // lastChange.revertX = change; }         }        }      }      // Add change to row. changeRows[ row ].changes.push( change ); change.changeRow = changeRows[ row ]; }   /**     * Skip over rows that have been removed in a prior edits, to maintain line * number consistency. *    * @param {Number} row Line within the current version of the page. * @return {Number} row Equivalent line of changeRows, after skipping those * rows since deleted. */   function skipDeletedRows( row, upToColumn, allowEndOnEmpty ) { changeRows.forEach( ( changeRow, i ) => {       if ( i < row && endsInDeletion( changeRow ) ) {          row++;        }      } ); // ?     if ( allowEndOnEmpty ) { while ( endsInDeletion( changeRows[ row ] ) ) { // This is endsInDeletion's only use of the second arg... TODO: Simplify. if ( endsInDeletion( changeRows[ row ], upToColumn ) ) { // Go on top of the old deleted row, instead of splicing new row in. // TODO: This isn't always working. See [test]'s giant revert, not matching up. // Issue: It can't actually tell they're the same lines. // words1   words1    words1    words1 // words2 ->       ->        -> words3 <- WRONG SPACE, simple revert shuffles rows // words3 -> words3 ->       -> // words4   words4    words4    words4 //            // Basically, have to make special exception for reverts. // Can search prior deletions for a row that begins with the right text? // How about: { [ text ]: col / columnNumber } ? // Note: Sometimes mods are split by mw into del > add, in that order. return row; } else { // This row is occupied by a deletion from the same edit. Skip past it. row++; }       }      } else { while ( endsInDeletion( changeRows[ row ] ) ) { row++; }     }      return row; }   /**     * @param {String} text * @return {String|undefined} */   function getHeaderText( text ) { var match = text.match( /^==\s*([^=].*)==$/ ); // Trim whitespace and strip links return match && match[ 1 ].trimRight.replace( /\[\[(?:[^\|]*\|)?([^\]]+)\]\]/g, '$1' ); }   // TODO: Reverse in apiHandler instead. (Without breaking the logs.) compares = compares.reverse; if ( compares.length === 0 ) { // throw new Error( 'HistoryView - processDiffs missing argument length ' ); // ...shift to the side? return { changeRows, changeCols }; }   // Track reverts, by checking for all edits that match sha1s with an earlier edit. ( => {      // Loop through compares, from most recent to oldest.      for ( var i = compares.length - 1, shaList = compares.map( x => x.sha1 ); i > 0; i-- ) {        const sha = shaList[ i ],          // Search for latest prior duplicate.          matchingShaIndex = shaList.lastIndexOf( sha, i - 1 );        if ( sha !== '?' && matchingShaIndex !== -1 ) {          // Up to but not including matchingShaIndex are reverted.          for ( let ii = matchingShaIndex + 1; ii < i; ii++ ) {            reverts[ ii ].revertedBy = i;          }          reverts[ i ].revert = true;          reverts[ i ].revertTo = matchingShaIndex + 1;          // Skip to dup.          i = matchingShaIndex + 1;        }      }    } ); // Build changeCols, changeRows compares.forEach( ( compare, i ) => {     var row = 0,        { $table, $trs, $delLogElem } = getCompareElement( compare, // Check if immediate revert to prior edit reverts[ i ].revertTo === i - 1 && // ...and that the revision wasn't deleted. !compare.missingcontent && // Check again on the current edit. (This can be necessary bc of caching issues.) !changeCols[ i - 1 ].missingcontent && // Pass the element to flip, if revert. changeCols[ i - 1 ].elem ),       // List of changes that occur in this edit/column.        colGroup = [],        lastColGroup = i && changeCols[ i - 1 ].changes,        // Used by matchMovedParagraphs.        movedParagraphsIds = {},        // List of paragraph moves, populated by matchMovedParagraphs.        movedParagraphs = [];      changeCols.push( { changes: colGroup, user: compare.user, anon: compare.anon, revid: compare.revid, priorrevid: compare.compare.fromrevid, comment: compare.compare.tocomment, parsedcomment: compare.compare.toparsedcomment, minor: compare.minor, bot: compare.bot, userhidden: compare.userhidden, missingcontent: compare.missingcontent, // TODO: Consider renaming to revertedBy. reverted: reverts[ i ].revertedBy, revert: reverts[ i ].revert, revertTo: reverts[ i ].revertTo, movedParagraphs, X: null, // Defined later on. baseWidth: 1, // Defined later on. width: null, // Defined later on. elem: $table[ 0 ], delLogElem: $delLogElem && $delLogElem[ 0 ], timestamp: compare.timestamp } );     /**       * Populate movedParagraphs with data about which lines were moved where       * during this edit.       *        * @param {HTMLTableCellElement} moveBlock The cell containing the moved       * content.       * @param {HTMLAnchorElement} moveLink The link pointing to the source or       * target of the moved paragraph.       * @param {Object} change       * @param {Boolean} to True if the line was moved here, false if it was       * moved from here to somewhere else.       */      function matchMovedParagraphs( moveBlock, moveLink, change, to ) {        var moveId = moveBlock.firstChild.firstChild.name,          moveTarget = moveLink.firstChild.getAttribute( 'href' ).substr( 1 ),          otherChange = movedParagraphsIds[ moveTarget ];        if ( otherChange ) {          var move = to ? { from: [ otherChange ], to: [ change ] } : { from: [ change ], to: [ otherChange ] }; move.from[ 0 ].moveTo = move; move.to[ 0 ].moveFrom = move; movedParagraphs.push( move ); } else { movedParagraphsIds[ moveId ] = change; }     }      /**       * @param {HTMLTableRowElement} tr       * @return {Object} change * @return {'linenumber'/'context'/'add'/'del'/'mod'} return.type * For edited lines (type = 'add', 'del', 'mod'): * @return {string} return.addText The text content of this line after the edit. * @return {string} return.delText The text content of this line before the edit. * @return {number} return.add Number of characters of added text. * @return {number} return.del Number of characters of deleted text. * For context lines (type = 'context'): * @return {string} return.cText The text conten of this line. * For line number lines (type = 'linenumber'): * @return {number} return.line Line number */     function extractChangeFromDom( tr ) { var change = { elem: tr }; if ( tr.firstElementChild.className === 'diff-lineno' ) { // LINE NUMBER change.type = 'linenumber'; // Can't just match \d. Big numbers have commas. // Note that this doesn't work for languages that don't use Hindu-Arabic numerals. change.line = +tr.lastElementChild.innerText.match( /[\d,]+/g )[ 0 ].replace( /,/g, '' ); } else if ( tr.lastElementChild.className === 'diff-context' ) { // CONTEXT - NO CHANGE TO ROW change.type = 'context'; change.contextText = tr.lastElementChild.innerText; } else if ( tr.firstElementChild.className === 'diff-empty' ) { // ADDED LINE change.type = 'add'; change.addText = tr.lastElementChild.innerText; change.add = change.addText.length || 1; change.del = 0; if ( tr.children[ 1 ].firstChild.className === 'mw-diff-movedpara-right' ) { matchMovedParagraphs(             tr.lastElementChild,              tr.children[ 1 ],              change,              true            ); }       } else if ( tr.lastElementChild.className === 'diff-empty' ) { // REMOVED LINE change.type = 'del'; change.delText = tr.children[ 1 ].innerText; change.add = 0; change.del = change.delText.length || 1; if ( tr.firstElementChild.firstChild.className === 'mw-diff-movedpara-left' ) { matchMovedParagraphs(             tr.children[ 1 ],              tr.firstElementChild,              change,              false            ); }       } else { // MODIFIED LINE change.type = 'mod'; change.delText = tr.children[ 1 ].innerText; change.addText = tr.children[ 3 ].innerText; change.add = Array.from( tr.querySelectorAll( 'ins' ) ).reduce( ( acc, el ) => acc + el.innerText.length, 0 ); change.del = Array.from( tr.querySelectorAll( 'del' ) ).reduce( ( acc, el ) => acc + el.innerText.length, 0 ); }       return change; }     /**       * Add changes in headers to the headers array. */     function addHeaderData( change, row, col ) { var oldHeader, newHeader, // Only available for changes to the content changeRow = change.changeRow; // All L2 headers ("==Content==") are recorded, including their // location, contents and time of addition and removal. if ( change.type === 'context' ) { var headerText = getHeaderText( change.contextText ); if ( headerText ) { var nRow = skipDeletedRows( row, col, false ); // If this row hasn't been seen before, fill in header data. changeRows[ nRow ] = changeRows[ nRow ] || { changes: [], headers: [ { start: 0, text: headerText } ] }; }       } else { if ( change.type !== 'add' ) { oldHeader = getHeaderText( change.delText ); }         if ( change.type !== 'del' ) { newHeader = getHeaderText( change.addText ); }         if ( oldHeader ) { if ( !changeRow.headers ) { // We have no prior record of this (now-removed) header's existence. // It must have been added prior to the first revision shown here. // Add removed header data. changeRow.headers = [ { text: oldHeader, start: 0 } ]; }           if ( oldHeader !== newHeader ) { // Unless perfectly matching the new header (eg, whitespace-only             // change), the old header has now ended. changeRow.headers.slice( -1 )[ 0 ].end = col; }         }          // Update for added headers. if ( newHeader && newHeader !== oldHeader ) { changeRow.headers = changeRow.headers || []; let lastHeader = changeRow.headers.slice( -1 )[ 0 ]; if ( !oldHeader && lastHeader && lastHeader.end === col - 1 && lastHeader.text === newHeader ) { // Re-adding a header that was just deleted last edit. // Consider this the same header, and continue it. delete lastHeader.end; } else { // Adding a new header. changeRow.headers.push( { text: newHeader, start: col } ); }         }        }      }      $trs.each( ( trI, tr ) => {        var change = extractChangeFromDom( tr );        if ( change.type === 'linenumber' ) {          // LINE NUMBER          // The row contains the text "Line [some number]:".          // Skip to the line shown.          row = change.line;        } else if ( change.type === 'context' ) {          // CONTEXT - NO CHANGE TO ROW          // If the line contains a header, deal with that.          addHeaderData( change, row, i );          row++;        } else {          // The row has been changed in some way, either an addition, a          // deletion, or a change in existing content.          var isImmediateRevert = reverts[ i ].revertTo === i - 1;          change.col = i;          // Check if revert          if ( isImmediateRevert ) {            // Move to addChange?            let matchingChange = lastColGroup[ colGroup.length ]; // Reverts are, unfortunately, not always simmetrical. See // https://en.wikipedia.org/w/index.php?diff=355931478 and predecessor // for exaample. // Also, X\nY->Y\nX is X moving two rows down, but the revert is Y           // moving two rows down. // To solve this, in these cases the element for the revert is built // by createRevertElement to be a mirror of the element for the // reverted edit. if ( matchingChange ) { // Is this redundant? change.revert = true; matchingChange.reverted = true; change.changeRow = matchingChange.changeRow; matchingChange.changeRow.changes.push( change ); } else { // The chart will almost certainly be messed up somewhat. Not fixable. // TODO: Give up matching for the rest of the column. Otherwise // the unsyncing breaks things. //             // ...Is this still possible, since the reverts are now constructed? addChange( row, change ); }         } else { addChange( row, change ); }         // If a header has been added, deleted, or modified, deal with that. addHeaderData( change, row, i ); if ( change.type !== 'del' ) { // Continue to next row. row++; }         colGroup.push( change ); }     } );    } );    // For selectRows if ( filterRowSettings ) { // Filtering out all rows outside a range specified by filterRowSettings. // This is set by selectAreas. // The top and bottom rows are the first rows outside the shown content. console.log( 99, filterRowSettings ); // filterRowSettings stores boundaries as the elements in the diffs. // Find those elements, mark the boundaries, and remove everything outside them. // TODO: Should work for from top to bottom. // TODO: Maintain row filter during zoom and even scroll, ideally. Certainly during further select-filter. // These boundaries are to be the first rows outside the shown content. // The boundary rows themselves will not be shown. let upperBoundary = !filterRowSettings.top && -1, lowerBoundary = false; changeRows.forEach( ( { changes }, row ) => {       if ( upperBoundary === false ) {          // We're above the upper boundary. Remove from the columns.          changes.forEach( change => { changeCols[ change.col ].changes.shift; } );         // Check if we've arrived at the upper boundary.          upperBoundary = changes.some( change => change.elem === filterRowSettings.top ) && row;        } else {          if ( lowerBoundary === false ) {            // Did we arrive at the lower boundary?            lowerBoundary = changes.some( change => change.elem === filterRowSettings.bottom ) && row;          }          if ( lowerBoundary !== false ) {            // We're past the lower boundary. Remove changes from their changeCols.            // (Might technically not be the same change, but so long as we have // the right amount removed from the end it amounts to the same thing.)           changes.forEach( change => { changeCols[ change.col ].changes.pop; } );         }        }      } );      // Remove all rows outside the boundaries. changeRows = changeRows.slice( upperBoundary + 1, lowerBoundary || changeRows.length ); while ( changeRows.length && changeRows[ changeRows.length - 1 ] === undefined ) { changeRows.pop; }     // We probably have a bunch of empty changeCols now. Hide them. // changeCols = changeCols.filter( changeCol => changeCol.changes.length ); changeCols.forEach( changeCol => {       if ( changeCol.changes.length === 0 ) {          changeCol.hidden = true;        }      } ); }   return { changeRows, changeCols }; } /**   * Format rows and columns for display purposes. * Set visible sizes and positions. */ function formatDiffs( changeRows, changeCols ) { // Set row heights, Y positions, etc.   ( Y => {      var totalHeight,        heightPerBit,        // rows are 1-indexed        lastRow = 1,        lastChangeRow,        minRowHeight = 0;//20,      changeRows.forEach( ( changeRow, row ) => { // TODO: Fill in gaps, with standard length rows for "untouched". // How large is the largest change in this row? var maxChange = changeRow.changes.reduce( ( acc, change ) => ( // Don't expand lines on account of reverted changes. changeCols[ change.col ].revert || changeCols[ change.col ].reverted || change.revert || change.reverted ) ? acc || 1 : Math.max( acc, change.add + change.del ), 0 ); // Test. Unsure. // maxChange = Math.min( maxChange, 2000 ); // maxChange = Math.min( maxChange, 1200 ); // Resize everything vertically to fit into the row, for shrunken rows. changeRow.changes.forEach( change => {         if ( change.add + change.del > maxChange ) {            var shrinkFactor = ( change.add + change.del ) / maxChange;            change.add = change.add / shrinkFactor;            change.del = change.del / shrinkFactor;          }        } ); // Height - Largest change to occur in this row, in any column. changeRow.height = maxChange; Y += Math.max( ( row - lastRow ) * minRowHeight, lastChangeRow ? lastChangeRow.height : 0 ); changeRow.Y = Y;       lastChangeRow = changeRow; lastRow = row; // Y += maxChange; } );     totalHeight = changeRows.length ?        changeRows[ changeRows.length - 1 ].Y + changeRows[ changeRows.length - 1 ].height :        1;      heightPerBit = changeAreaHeight / totalHeight;      changeRows.forEach( changeRow => { changeRow.Y *= heightPerBit; changeRow.height *= heightPerBit; changeRow.changes.forEach( change => {         change.add *= heightPerBit;          change.del *= heightPerBit;        } ); } );   } )( 0 );    // Set column widths, X positions. ( X => {     var lastChangeCol,        totalWidth,        colsWithSameUser = [];      // Process movedParagraphs to group adjacent moves that have similarly      // adjacent targets.      function groupAdjacentMoves( changeCol ) {        function getRow( change ) {          return changeRows.indexOf( change.changeRow );        }        let { movedParagraphs } = changeCol;        for ( let i = 1; i < movedParagraphs.length; i++ ) {          let move = movedParagraphs[ i ],            lastMove = movedParagraphs[ i - 1 ];          if ( lastMove && [ 'from', 'to' ].every( dir => {             var last = lastMove[ dir ].slice( -1 )[ 0 ],                cur = move[ dir ][ 0 ],                [ lastIndex, curIndex ] = [ last, cur ].map( change => changeCol.changes.indexOf( change ) ),                [ lastRow, curRow ] = [ last, cur ].map( getRow ),                interveningRowsCount = curRow - lastRow - 1,                interveningBlankRows = 0;              if ( curRow > lastRow && changeCol.changes.slice( lastIndex + 1, curIndex ).every( change => {                if ( !change.addText && !change.delText ) {                  interveningBlankRows++;                  // Allow blanks in between the rows.                  return true;                } else {                  // There's a row in between that has actual content in it.                  // Don't group.                  return false;                }              } ) ) { return interveningRowsCount === interveningBlankRows; }           } )          ) {            // Merge the paragraph move blocks. move.from[ 0 ].moveTo = lastMove; move.to[ 0 ].moveFrom = lastMove; lastMove.from.push( move.from[ 0 ] ); lastMove.to.push( move.to[ 0 ] ); movedParagraphs.splice( i--, 1 ); }       }      }      // TODO: Should also shrink bot edits? // TODO: Shrink sequence of reverted edits to min size per user. // How to handle a sequence of edits by one user, only some of which are reverted? Shrink the reverted group down to min?     changeCols.forEach( changeCol => {        // Shrink minor edits.        if ( changeCol.minor || changeCol.reverted || changeCol.revert ) {          // changeCol.baseWidth /= 2;          changeCol.baseWidth = 0.5;        }      } ); // Shrink and lighten bar when multiple edits by same user. changeCols.forEach( changeCol => {       if ( changeCol.hidden ) {          changeCol.baseWidth = 0;        } else {          var isRevert = changeCol.reverted || changeCol.revert || changeCol.missingcontent,            // Different user than previous edit.            newUser = changeCol.user !== ( lastChangeCol || {} ).user;          if ( !newUser ) {            changeCol.baseWidth = lastChangeCol.baseWidth = 0.5;            // Make dividing bars lighter between multiple edits by same user.            changeCol.barColor = '#EEEEEE';          } else {            changeCol.showUser = true;            changeCol.barColor = '#DDDDDD';          }          if ( !isRevert || newUser ) {            if ( colsWithSameUser.length ) {              colsWithSameUser.forEach( col => { // Problem: A revert alone doesn't even count as minor with this, does it? // Other problem: Can be reset to 0.5 by lastChangeCol above. col.baseWidth /= colsWithSameUser.length; } );             colsWithSameUser.splice( 0 );            }          }          if ( isRevert ) {            colsWithSameUser.push( changeCol );          }          lastChangeCol = changeCol;        }      } ); totalWidth = changeCols.reduce( ( acc, changeCol ) => acc + changeCol.baseWidth, 0 ); changeCols.forEach( changeCol => {       changeCol.X = X;        X +=           changeCol.width = fullWidth * changeCol.baseWidth / totalWidth;        groupAdjacentMoves( changeCol );        // Set position of paragraph move arrows.        var mostOverlappingArrows = 0;        /**         * Check whether any part of the two arrows covers the same vertical         * area, such that one would need to be pushed to the side for the         * arrows to be legible.         * @return {Boolean}         */        function hasOverlap( arrow1, arrow2 ) {          return [            arrow1.fromY > arrow2.fromY,            arrow1.fromY > arrow2.toY,            arrow1.toY > arrow2.fromY,            arrow1.toY > arrow2.toY          ].some( ( comp, i, all ) => comp !== all[ 0 ] );        }        changeCol.movedParagraphs.forEach( ( movedParagraph, i, allMoves ) => { // Set Y positions for move arrows. [ movedParagraph.fromY, movedParagraph.toY ] = [ movedParagraph.from, movedParagraph.to ].map( group => {           var lastChange = group.slice( -1 )[ 0 ];            return ( group[ 0 ].changeRow.Y + lastChange.changeRow.Y + lastChange.add + lastChange.del ) / 2;          } ); // Set lanes for move arrows, which will be used to determine // X positions later on. var overlapping = allMoves.slice( 0, i ).filter( move => hasOverlap( movedParagraph, move ) ); // Keep checking lanes until we find one that isn't also occupied by         // a vertically-overlapping arrow, then insert this arrow there. for ( var ii = 1; true; ii++ ) { if ( overlapping.every( move => move.lane !== ii ) ) { //              movedParagraph.lane = ii; if ( ii > mostOverlappingArrows ) { mostOverlappingArrows = ii; }             break; }         }        } );        changeCol.movedParagraphs.forEach( movedParagraph => { movedParagraph.X = changeCol.width * movedParagraph.lane / ( mostOverlappingArrows + 1 ); movedParagraph.maxArrowWidth = changeCol.width / mostOverlappingArrows; } );     } );    } )( 0 );    changeCols.forEach( changeCol => { var { timestamp } = changeCol, date = { X: changeCol.X,         year: timestamp.getUTCFullYear, month: mw.language.months.abbrev[ timestamp.getUTCMonth ], day: timestamp.getUTCDate, barColor: changeCol.barColor };     changeCol.date = date; } ); }  /**   * @return {Array} logIcons   */  function processLogs( logs ) {    function createIconElement( [ iconWidth, iconHeight, iconSvgCode ], color ) {      var scale = 0.1,        svg = document.createElementNS( 'http://www.w3.org/2000/svg', 'svg' ),        path = document.createElementNS( 'http://www.w3.org/2000/svg', 'path' );      svg.setAttribute( 'version', '1.1' );      svg.setAttribute( 'width', iconWidth * scale );      svg.setAttribute( 'height', iconHeight * scale );      path.setAttribute( 'd', iconSvgCode );      path.setAttribute( 'fill', color );      path.setAttribute( 'transform', `scale(${ scale })` );      svg.appendChild( path );      return svg;    }    var logIcons = [];    // Build logIcons    logs.reverse.forEach( log => { var isUnprotect = log.action === 'unprotect', iconType = [ 'unprotect', 'restore' ].includes( log.action ) ? log.action : log.type; if ( log.type === 'protect' || log.type === 'delete' || log.type === 'lastSeen' ) { var lastProtectLog = {}, timestamp = new Date( log.timestamp ), details = log.params && log.params.details, // Should this use whichever expiry is latest? expiryTS = details ? +new Date( details[ 0 ].expiry ) : +new Date, // If no expiry given, that means indefinite. { X = fullWidth } = changeCols.length ? changeCols.find( col => +col.timestamp >= +timestamp ) || {} : { X: fullWidth / 2 }, end = changeCols.find( col => +col.timestamp > expiryTS ), // To display in place of a diff. Should there be something here? // Maybe a giant lock icon, to at least avoid a giant blank space? elem = document.createElement( 'div' ), Y = barsHeight / 2; // TODO: Just store lastProtectLog instead, I think. for ( let i = logIcons.length - 1; logIcons[ i ]; i-- ) { if ( logIcons[ i ].type === 'protect' ) { lastProtectLog = logIcons[ i ]; break; }       }        if ( end !== changeCols[ 0 ] || !changeCols.length ) { var color = ( isUnprotect ?             lastProtectLog && lastProtectLog.color :              details && [                // There are clearer colors at File:Move_protect.svg. TODO.                // '#0088FF', // Cascade                '#9dd7d8',                // '#CCCC00', // Full-protect                '#beac77',                '#999999', // Semi-protect                // '#006adc', // Extendedprotect                '#429eff',                '#f9dada', // Template protect                // '#00FF00', // Move-protect                // '#abc86e',                '#d6eaae',                // '#16b73b', // From svg                '#666666' // Everything else?                // The MW main page is actually breaking here. TODO: Fix.              ][                Math.min( ...details.map( detail => {                  var x = 'cascade' in detail ? 0 :                    detail.type === 'edit' && detail.level === 'sysop' ? 1 :                   detail.type === 'edit' && detail.level === 'autoconfirmed' ? 2 :                   detail.type === 'edit' && detail.level === 'extendedconfirmed' ? 3 :                   detail.type === 'edit' && detail.level === 'templateeditor' ? 4 :                   detail.type === 'move' ? 5 : 6;                 return x;                } ) ) ]           ) || ( {              'delete': '#9f3333', 'lastSeen': '#38d300' }[ log.type ] ) ||           '#999999',            { params = {} } = log;          elem.style.textAlign = 'center';          elem.title = {            'protect': mw.msg( 'HV-ProtectLog', log.user ),            'unprotect': mw.msg( 'HV-UnprotectLog', log.user ),            'delete': mw.msg( 'HV-DeleteLog', log.user ),            'restore': mw.msg( 'HV-RestoreLog', log.user ),            'move': mw.msg( 'HV-MoveLog', log.user ),            'lastSeen': mw.msg( 'HV-LastVisited', log.timestamp )          }[ iconType ];          elem.appendChild( createIconElement( icons[ iconType ], color ) );          // Vertically position icons.          for ( let i = logIcons.length - 1; logIcons[ i ] && logIcons[ i ].X === X; i-- ) {            logIcons[ i ].Y -= 15;            Y += 15;          }          // Does deletion cancel protection? I think sometimes?          if ( log.type === 'protect' && lastProtectLog.X + lastProtectLog.expiryX > X ) { lastProtectLog.expiryX = X - lastProtectLog.X;         } logIcons.push( {           X,            Y,            expiryX: log.type === 'protect' && !isUnprotect && ( ( end ? end.X : fullWidth ) - X ),            type: log.type,            user: log.user,            // This isn't as clear as the revision parsedcomment.            // Should comments be retrieved from action=revisions instead of by            // compare, and the logs matched up to revisions? Could work, maybe.            // TODO: Look into this.            // How to mark a log edit?            // * No change to page. Same sha.            // * Timestamp, username.            // Problem with that idea: Sometimes we don't have a log's revision.            // If started early, but expired after start, we have the log but not revision.            parsedcomment:              log.parsedcomment &&              ( log.parsedcomment + ( params.description ? ' ' + params.description : '' ) ),           details: params.details,            color,            elem,            iconType,            timestamp,            isLog: true          } ); }     }    } );    return logIcons;  }  canvasDisplay = (  => { var displayContext = canvas.getContext( '2d' ), backgroundCanvas = document.createElement( 'canvas' ), backgroundContext = backgroundCanvas.getContext( '2d' ), foregroundCanvas = document.createElement( 'canvas' ), foregroundContext = foregroundCanvas.getContext( '2d' ), // Whichever context is currently being edited. context = displayContext, measureCache = {}; /**    * @return {number} width in pixels */   function measure( text ) { var font = context.font, cache = measureCache[ font ] || ( measureCache[ font ] = {} ), cachedLength = cache[ text ]; if ( cachedLength ) { return cachedLength; } else { return cache[ text ] = context.measureText( text ).width; }   }    // TODO: Cache the slice results somewhere. // Should only calculate once per text/size/width combo. /**    * Draw text within a certain width, shrinking up to 15% and clipping with * an ellipse if necessary. * @param {string} text The text to draw * @param {number} maxWidth Maximum allowed width * @param {number} X    * @param {number} Y     */ function drawClippedText( text, maxWidth, X, Y ) { var length = text.length, textWidth = measure( text ), // ellipse = '…', ellipse = '...', ellipsisWidth, maxShrink = 0.85; if ( textWidth * maxShrink <= maxWidth ) { context.fillText( text, X, Y, maxWidth ); } else if ( text.length > 1 ) { ellipsisWidth = measure( ellipse ); // If the ellipse alone is too large, there's nothing we can do. if ( ellipsisWidth >= maxWidth ) { return; }       // Slice the text just enough so that it fits. var slicedText = text.substr( 0, ( function t( min, max ) { // Binary search if ( max - min < 2 ) { return min; }         var testLength = ( max + min ) >> 1, textWidth = measure( text.substr( 0, testLength ) ), tooBig = ( textWidth + ellipsisWidth ) * maxShrink > maxWidth; return tooBig ? t( min, testLength ) : t( testLength, max ); } )( 0, length ) ); if ( slicedText ) { // Use fillText's built-in scaling. context.fillText( slicedText + ellipse, X, Y, maxWidth ); }     }    }    /**     * Show "Loading..." text on canvas. */   function showLoading { // canvas.width = canvas.width; foregroundContext.save; foregroundContext.fillStyle = 'rgba( 255, 255, 255, 0.5 )'; foregroundContext.fillRect( 0, 0, fullWidth, fullHeight ); foregroundContext.textAlign = 'center'; foregroundContext.baseLine = 'middle'; foregroundContext.font = '30px sans-serif'; foregroundContext.fillStyle = 'black'; foregroundContext.fillText( mw.msg( 'HV-Loading' ), fullWidth / 2, fullHeight / 2 ); foregroundContext.restore; displayContext.drawImage( foregroundCanvas, 0, 0 ); }   /**     * Paint a line of an edit. * @param {Object} change * @param {Number} Y Y-position of the change's row. * @param {Boolean} highlight Whether this change should be shown in a    * bolder coloring. */   function paintChange( change, Y, highlight ) { var col = change.col, changeCol = changeCols[ change.col ], X = changeCol.X,       width = changeCol.width, skipped = 0; // I'm uncertain whether 'mod's should have deletions and insertions vertically or horizontally separate. // Idea: Reverts could be denoted by a fading gradient to the right. // TODO: Add revert chain, alternating. ( change.revert ? [ 'del', 'add' ] : [ 'add', 'del' ] ).forEach( t => {       if ( change[ t ] ) {          var height = change[ t ] + 1,            colors = highlight ? { del: '#ffcf4d', add: '#57aeff' } : { del: '#ffe49c', add: '#a3d3ff' };          if ( change.revert || change.reverted ) {            // Show reverts as gradients.            // Unsure whether this is a good way to do things. Maybe have an icon instead?            // File:Echo revert icon.svg is a revert icon.            let gradientStartX = change.reverted ? X : changeCols[ col - 1 ].X,              gradientWidth = width + changeCols[ change.reverted ? col + 1 : col - 1 ].width,              gradient = context.createLinearGradient( gradientStartX, 0, gradientStartX + gradientWidth, 0 ),              startType = change.reverted ? t : t === 'add' ? 'del' : 'add';            gradient.addColorStop( 0, colors[ startType ] ); gradient.addColorStop( 1, colors[ startType === 'add' ? 'del' : 'add' ] ); context.fillStyle = gradient; context.fillRect( X, Y + skipped, width, height ); } else { context.fillStyle = colors[ t ]; context.fillRect( X, Y + skipped, width, height ); }         skipped = height; }     } );      // Yeah, I don't like this. Stick with the gradients, maybe.      // if ( change.revert && !changeCol.revert ) {      //   let startX = X - 5,      //     yPos = Y + ( change.add + change.del ) / 2,      //     arrowEnd = changeCol.X + changeCol.width / 2,      //     arrowHeadSize = Math.min( 3, arrowEnd - startX / 2 );      //   context.strokeStyle = 'purple';      //   // Considering...      //   context.lineWidth = 1;      //   context.beginPath;      //     context.moveTo( startX, yPos );      //     context.lineTo( arrowEnd, yPos );      //     context.moveTo( startX + arrowHeadSize, yPos - arrowHeadSize );      //     context.lineTo( startX, yPos );      //     context.lineTo( startX + arrowHeadSize, yPos + arrowHeadSize );      //   context.stroke;      // }    }    /**     * Paint a vertical arrow representing a paragraph move.     */    function paintParagraphMove( changeCol, movedParagraph, highlight ) { var generalMaxArrowWidth = 10, { fromY, toY, maxArrowWidth } = movedParagraph, pointDown = toY > fromY, top = pointDown ? fromY : toY, bottom = pointDown ? toY : fromY, X = changeCol.X + movedParagraph.X,       arrowEdgeWidth = Math.min( maxArrowWidth, bottom - top, generalMaxArrowWidth ) / 2, arrowHeadDirection = pointDown ? -1 : 1;     if ( arrowEdgeWidth > 1 ) { context.strokeStyle = highlight ? 'black' : '#555555'; context.beginPath; context.moveTo( X, top ); context.lineTo( X, bottom ); context.moveTo( X - arrowEdgeWidth, toY + arrowEdgeWidth * arrowHeadDirection ); context.lineTo( X, toY ); context.lineTo( X + arrowEdgeWidth, toY + arrowEdgeWidth * arrowHeadDirection ); context.stroke; }   }    /**     * @param {String} [highlight] "FOCUS" for bolded lines, "SIMPLE" for basic black. */   function paintColumnOutline( changeCol, highlight, isRightEdge ) { // Rounding is necessary to deal with floating point errors. var isLastCol = Math.round( changeCol.X + changeCol.width ) === Math.round( fullWidth ), outlineHeight = ( changeCol.showUser || highlight ) ? barsHeight : barsHeight - userNameHeight; // Show lines between changes, darker around focused change. context.fillStyle = highlight ? '#000000' : changeCol.barColor; context.fillRect( changeCol.X || 0, 0, 1, outlineHeight ); // Add bar at the end. if ( isLastCol && ( !highlight || !isRightEdge ) ) { context.fillRect( fullWidth - 1, 0, 1, barsHeight ); }     if ( highlight === 'FOCUS' ) { context.fillStyle = 'rgba( 0, 0, 0, 0.5 )'; context.fillRect( changeCol.X + ( isRightEdge ? 1 : -1 ), 0, 1, outlineHeight ); }   }    function paintUsername( changeCol, highlight ) { var { user, userhidden } = changeCol, displayUser = userhidden ? mw.msg( 'HV-DeletedUser' ) : user, minFontSize = 10, maxFontSize = 14, maxXOffset = 4, minXOffset = 1, // The next column with a different username. ALl the space before that // column is space we can use to fit the username. rightBoundaryCol = changeCols.slice( changeCols.indexOf( changeCol ) ).find( compare => {           return !compare.hidden && compare.user !== user;          } ), // Width available for printing the username. availWidth = ( rightBoundaryCol ? rightBoundaryCol.X : fullWidth ) - changeCol.X,       // Clamp these two values between min and max, and split the extra between the two. //       // Pixels between the bar and the username. (Try to have the same amount       // of buffer also available before the next line.) xOffset = Math.max( minXOffset, Math.min( maxXOffset, minXOffset + ( availWidth - minXOffset * 2 - minFontSize ) / 4 ) ), // Displayed font size of the username. fontSize = Math.max( minFontSize, Math.min( maxFontSize, minFontSize + ( availWidth - minXOffset * 2 - minFontSize ) / 2 ) ); context.save; context.fillStyle = 'black'; context.textAlign = 'end'; context.shadowBlur = highlight ? 0.05 : 0;     context.shadowColor = 'black'; context.translate( changeCol.X + xOffset, barsHeight ); // Write it vertically. context.rotate( Math.PI / 2 ); // Show own username in different color. context.fillStyle = user === mw.config.get( 'wgUserName' ) ? '#000066' : '#000000';     context.font = fontSize + 'px sans-serif'; // console.log( user, availWidth, xOffset, fontSize, xOffset * 2 + fontSize ); // context.font = ( highlight ? 'bold' : 'normal' ) + ' 14px sans-serif'; // context.fillStyle = highlight ? '#333333' : 'black'; drawClippedText( displayUser, userNameHeight, 0, 0 ); context.restore; }   function paintRevertArrow( changeCol ) { // Backward-pointing arrows represent reverts. let startX = changeCols[ changeCol.revertTo ].X + changeCols[ changeCol.revertTo ].width / 4, yPos = barsHeight / 2, arrowEnd = changeCol.X + changeCol.width / 2, radius = Math.min( 10, arrowEnd - startX ), arrowHeadSize = Math.min( 5, arrowEnd - startX - radius / 2 ); if ( startX !== arrowEnd ) { context.strokeStyle = 'purple'; // Considering... // context.lineWidth = 2; context.beginPath; context.moveTo( startX, yPos ); context.lineTo( arrowEnd - radius, yPos ); context.arc( arrowEnd - radius + 1, yPos + radius, radius, Math.PI * 1.5, Math.PI * 2.1 ); context.moveTo( startX + arrowHeadSize, yPos - arrowHeadSize ); context.lineTo( startX, yPos ); context.lineTo( startX + arrowHeadSize, yPos + arrowHeadSize ); context.stroke; }   }    function fitDate( changeCol, overrideFollowingDates ) { // How to handle? //     | 2017, Feb 1 // | 2017, Jan //        2017, Feb 2 // Does this need a way to walk back to previous dates, giving more space, after areas where there's no room? // Consider: // | 2016     //   | 2017      //     | 2018      // (Current behaviour is 2016, I think.) // TODO: Document all this. /**      * Measure how much room there is before a date that doesn't match compareFn. *      * @param {Function} compareFn * @return {Number} Number of pixels available. */     function getMatchesWidth( compareFn ) { for ( var i = index, width = 0; i < changeCols.length && ( compareFn( changeCols[ i ].date ) ); i++ ) { width += changeCol.width; }       return width; }     var date = changeCol.date, index = changeCols.indexOf( changeCol ), prevDate = ( changeCols.slice( 0, index ).reverse.find( changeCol => changeCol.date.cache && changeCol.date.cache.isVisible ) || {} ).date, allUnits = [ 'day', 'month', 'year' ], cache = {}; allUnits.some( ( unit, i ) => {       var largerUnits = allUnits.slice( i ),          // Units that are different than the previous date.          relevantUnits = largerUnits.filter( ( unit, ii ) => !prevDate || largerUnits.slice( ii ).some( unit => prevDate[ unit ] !== date[ unit ] ) );        if ( relevantUnits.length === 0 ) {          return true;        }        //         var bufferSpace = 5,          idealAvailWidth = getMatchesWidth( nextDate => largerUnits.every( compareUnit => nextDate[ compareUnit ] === date[ compareUnit ] ) ) - bufferSpace,          // Don't go into this for optional things like showing month name before date, but...          // Also don't use this when squishing can be used to avoid it.          actualAvailWidth = getMatchesWidth( nextDate => largerUnits.slice( 1 ).every( compareUnit => nextDate[ compareUnit ] === date[ compareUnit ] ) ) - bufferSpace; // TODO: Consider extending bar different amounts, depending on unit shown. // ALso consider changing colors. Maybe gradients or something. Should be a way // to find year markers. cache.showBar = true; return [ idealAvailWidth, actualAvailWidth ].some( availWidth => {         // For days in a month, prefer to show the month name even if same as previous change.          return ( relevantUnits.length === 1 && unit === 'day' ? [ [ 'day', 'month' ], [ 'day' ] ] : [ relevantUnits ] ).some( relevantUnits => { // ', ' ' '            var text = relevantUnits.reduceRight( ( acc, unit ) => acc + ( acc && { month: ', ', day: ' ' }[ unit ] ) + date[ unit ], '' ), textWidth = measure( text ); // Check if the text fits. Squash the text as far as 85%, if necessary. if ( textWidth * 0.85 <= availWidth || overrideFollowingDates ) { Object.assign( cache, {                text,                availWidth,                textWidth,                isVisible: true,                width: Math.min( textWidth, availWidth ) + bufferSpace              } ); return true; }         } );        } );      } );      return cache;    }    function fitDates {      // TODO: Much of this should be in preparation for presentation layer, in      // processDiffs. Should be moved there, maybe add "dateText" to each colGroup.      var lastDateX = 0;      changeCols.forEach( changeCol => { var date = changeCol.date; context.font = '12px sans-serif'; // RULES: // For start: // Y M D > Y M > Y       // If followed by same day, allow pushing into it. // If followed by same month, push in with only Y M if necessary. // Don't squish too much. Prioritize greater units. // If smooshed by lastDateX, do nothing. // M D is prefered to D even if same M as previous. if ( date.cache === undefined ) { date.cache = {}; if ( lastDateX > date.X ) { // If this space has already been written on, don't overwrite. return; }         Object.assign( date.cache, fitDate( changeCol ) ); if ( date.cache.isVisible ) { lastDateX = date.X + date.cache.width; }       }      } );    }    function paintDate( changeCol, highlight = false ) {      var date = changeCol.date,        cache = date.cache,        alreadyVisible = cache.isVisible;      context.save;      context.font = '12px sans-serif';      // TODO: Clean up.      if ( !alreadyVisible && highlight ) {        cache = fitDate( changeCol, true );        // Use a fading transparent-to-white-to-transparent gradient behind        // the highlighted date, to blend with the surrounding dates.        var blendArea = 10,          gradient = context.createLinearGradient( date.X + 3 - blendArea, 0, date.X + 3 + cache.textWidth + blendArea, 0 );       gradient.addColorStop( 0, 'rgba( 255, 255, 255, 0 )' );        gradient.addColorStop( 0.1, 'rgba( 255, 255, 255, 1 )' );        gradient.addColorStop( 0.9, 'rgba( 255, 255, 255, 1 )' );        gradient.addColorStop( 1, 'rgba( 255, 255, 255, 0 )' );        context.fillStyle = gradient;        context.fillRect( date.X + 3 - blendArea, barsHeight, cache.textWidth + blendArea * 2, 30 );      }      // Display text      if ( alreadyVisible || highlight ) {        context.fillStyle = 'black';        context.shadowBlur = highlight ? 0.05 : 0;        context.shadowColor = 'black';        context.fillText( cache.text, date.X + 3, barsHeight + 25, alreadyVisible ? cache.availWidth : cache.textWidth );     }      context.restore;    }    /*    // Some ideas for displaying edit summaries somewhere. (Not implemented.)    function parseComment( changeCol ) {      // TODO: Clean up. And move somewhere else.      if ( changeCol.parsedcomment ) {        var dF = document.createElement( 'span' ),          aC;        dF.innerHTML = changeCol.parsedcomment;        aC = dF.querySelector( '.autocomment' );        if ( aC ) {          aC.parentNode.removeChild( aC );          dF.removeChild( dF.firstChild );        }        changeCol.textComment = dF.innerText && dF.innerText.replace( String.fromCharCode( 8206 ), '' ).trim;      }    }    function paintComment( changeCol, lastComment, nextCommentCol, index ) {      if ( changeCol.textComment ) {        var comment = changeCol.textComment,          nextChangeCol = changeCols[ index + 1 ],          upper = index % 2 === 0, Y = fullHeight - ( upper ? 10 : 0 ) - 1, buffer = 3; if ( measure( comment ) < changeCol.width || true ) { // Probably move above dates. // Also maybe increase the font size. Certainly at least set it. // Not at all sure that including comments on-canvas is a good idea. context.fontStyle = '10px sans-serif'; context.fillStyle = changeCol.barColor; context.fillRect( changeCol.X, Y - 10 - ( upper ? 10 : 0 ), 1, 10 + ( upper ? 10 : 0 ) );         context.fillStyle = 'black'; // context.fillText( comment, changeCol.X, fullHeight - 10 ); drawClippedText( comment,           ( nextCommentCol ? nextCommentCol.X : fullWidth ) - changeCol.X - buffer * 2,           changeCol.X + buffer,            Y          ); }     }    }    // */    function paintIcon( X, Y, [ iconWidth, iconHeight, iconSvgCode ], iconScale ) { context.save; context.translate( X - iconWidth * iconScale / 2, Y - iconHeight * iconScale / 2 ); context.scale( iconScale, iconScale ); context.fill( new Path2D( iconSvgCode ) ); context.restore; }   function paintLog( log, highlight ) { var { X, Y, expiryX, color } = log; if ( expiryX ) { // Paint protected area background. context.save; context.globalAlpha = 0.11; context.fillStyle = color; //'rgba( 0, 255, 0, 0.06 )'; // context.fillStyle = color + '1C'; //'rgba( 0, 255, 0, 0.06 )'; context.fillRect( X, 0, expiryX, barsHeight ); context.restore; }     // Paint the vertical line. context.fillStyle = highlight ? 'red' : color; context.fillRect( X, 0, 1, barsHeight ); paintIcon( X, Y, icons[ log.iconType ], 0.03 ); // context.strokeRect( X - iconWidth * iconScale / 2, Y - iconHeight * iconScale / 2, iconWidth * iconScale, iconHeight * iconScale ); // context.strokeRect( 8, 0, 16, barsHeight ) // context.strokeRect( 8, barsHeight / 2, 500, 1 ) }   function paintHeaders { // Record the Y position of the lowest header so far in each column, to     // avoid overlap. var lastHeaderInColumn = [], minHeaderHeight = 9, maxHeaderWidth = 300; context.fillStyle = 'black'; context.font = '9px sans-serif'; context.textBaseline = 'bottom'; changeRows.forEach( changeRow => {       var Y = changeRow.Y;        changeRow.headers && changeRow.headers.forEach( header => { var { text: headerText, start, end } = header, textWidth = Math.min( measure( headerText ), maxHeaderWidth ), // firstAvailStart = changeCols.slice( _start ).findIndex( ( changeCol, i ) => {           //   var lastHeader = lastHeaderInColumn[ _start + i ] || 0;            //   return !changeCol.hidden && lastHeader < Y - minHeaderHeight;            // } ), // start = firstAvailStart !== -1 ? _start + firstAvailStart : _start, xStart = changeCols[ start ].X,           xEnd = ( changeCols[ end ] || { X: fullWidth } ).X,            lastInColumn = lastHeaderInColumn[ start ] || 0, blockingColumn = lastInColumn > Y - minHeaderHeight ? // Blocked by space constraints from even starting. 0 :             //               changeCols .slice( start ) .findIndex( ( changeCol, i ) => {                 var col = start + i,                    lastHeader = lastHeaderInColumn[ col ] || 0,                    isBlocked = lastHeader > Y - minHeaderHeight,                    outOfLength = xStart + textWidth < changeCol.X,                    headerDeleted = end === col;                  return isBlocked || headerDeleted || outOfLength;                } ), interruptX = blockingColumn === -1 ? fullWidth : changeCols[ start + blockingColumn ].X;         context.save; if ( blockingColumn !== 0 ) { for ( let i = start; i < start + blockingColumn; i++ ) { lastHeaderInColumn[ i ] = Y;           } // Draw the text, with a translucent white background and shadow. context.fillStyle = 'rgba( 255, 255, 255, 0.3 )'; // context.fillStyle = 'blue'; context.fillRect( xStart, Y - minHeaderHeight, Math.min( interruptX - xStart, textWidth ), minHeaderHeight ); context.shadowBlur = 1; context.shadowColor = 'white'; context.fillStyle = 'black'; drawClippedText( headerText, interruptX - xStart, xStart, Y ); }         // To consider: When hovering over a line or change to the line, show // the header even if blocked by other headers. // Underline // The line should either be translucent or the colors should go on top of it. // Test article: "Knuckles' Chaotix": TarkusAB's change is not at all visible, hidden behind the line I think. // TODO: Consider forcing a 1px minimum for header lines, even if no changes in them. context.fillStyle = 'rgba( 170, 170, 170, 0.4 )'; context.fillRect( xStart, Y, xEnd - xStart, 1 ); context.restore; } );     } );    }    function paintBackground { backgroundCanvas.width = backgroundCanvas.width; context = backgroundContext; // Display changes changeRows.forEach( changeRow => {       var Y = changeRow.Y;        changeRow.changes.forEach( change => { paintChange( change, Y ); } );     } );      // Display user names, dates, dividers between columns. context.font = '14px sans-serif'; changeCols.forEach( changeCol => {       if ( changeCol.hidden ) {          return;        }        // USERNAMES        if ( changeCol.showUser ) {          paintUsername( changeCol, false );        }        // Show lines between changes, darker around focused change.        paintColumnOutline( changeCol, false );      } ); // Display dates below changes fitDates; changeCols.forEach( changeCol => {       var date = changeCol.date;        if ( date && date.cache !== undefined ) {          // Extend the divider line to reach down to the date.          if ( date.cache.showBar ) {            context.fillStyle = date.barColor;            context.fillRect( date.X, barsHeight, 1, 20 );          }          // Display the date.          // TODO: Consider bolding the currently highlighted date, or something.          paintDate( changeCol );        }      } ); // Show protection log. // TODO: Should the highlights be behind the diffs themselves? logIcons.forEach( log => {       paintLog( log, false );      } ); }   function paintForeground { foregroundCanvas.width = foregroundCanvas.width; context = foregroundContext; // Show headers paintHeaders; // Draw revert arrows, X icons for deleted revisions. changeCols.forEach( changeCol => {       if ( changeCol.revertTo !== undefined ) {          paintRevertArrow( changeCol );          // Should there be mini-arrows for partial reverts, or reverts of a single          // row several edits later? Seems problematic to leave them out...          //          // Should only be when no big arrow is also present.          // Maybe smaller stroke width?        }        if ( changeCol.missingcontent ) {          context.fillStyle = '#666666';          paintIcon( changeCol.X + changeCol.width / 2, barsHeight / 2, icons.revdeleted, 0.02 );        }      } ); /*     var lastComment; changeCols.forEach( parseComment ); changeCols.filter( changeCol => changeCol.textComment ).forEach( ( changeCol, i, allCommentedCols ) => {       // Maybe show full comment when highlighted, with white background?        // Would mean moving this to paintBackground and adding bit to paint.        lastComment = paintComment( changeCol, lastComment, allCommentedCols[ i + 2 ], i % 2 );      } ); // */   }    /**     * Draw the changes on the canvas. *    * @param {Object} [focusConfig] * @param {Object} focusConfig.changeCol A specific column/edit to highlight. * @param {Array} focusConfig.changes A set of changes within the column * to be highlighted. * @param {Object} focusConfig.extraHighlight */   function paint( { changeCol: focusChangeCol, changes: focusChanges = [], locked: extraHighlight } = {} ) { // if ( !changeCols ) { //  // Not yet loaded. //  return; // }     var focusChange = focusChanges[ 0 ], focusAll = !focusChangeCol && focusChanges.length === 0; // Reset - blank the canvas. canvas.width = canvas.width; context = displayContext; // The "background" has everything that doesn't need to be updated // frequently, but can also be behind the "active" parts. displayContext.drawImage( backgroundCanvas, 0, 0 ); // Display changes changeRows.forEach( changeRow => {       changeRow.changes.forEach( change => { // The non-focused changes are already displayed via the backgroundCanvas. // Here we're just repainting the focused changes over them. if ( focusAll || focusChanges.includes( change ) ) { paintChange( change, changeRow.Y, focusAll || focusChanges.includes( change ) ); }       } );      } );      // Display user names, dates, dividers between columns. context.font = '14px sans-serif'; var prevCol; changeCols.forEach( ( changeCol, i ) => {       if ( changeCol.hidden ) {          return;        }        if ( focusChangeCol ) {          // USERNAMES          if ( changeCol.showUser && focusChangeCol.user === changeCol.user ) {            // Bold any username matching the author of the highlighted edit.            paintUsername( changeCol, true );          }          // Show lines between changes, darker around focused change.          if ( [ changeCol, prevCol ].includes( focusChangeCol ) ) {            paintColumnOutline( changeCol, extraHighlight ? 'FOCUS' : 'SIMPLE', prevCol === focusChangeCol );         }        }        // Draw vertical arrows representing paragraph moves, darker when focused.        changeCol.movedParagraphs.forEach( movedParagraph => { paintParagraphMove( changeCol, movedParagraph,           focusChanges.some( focusChange => [ ...movedParagraph.to, ...movedParagraph.from ].includes( focusChange ) )          ); } );       prevCol = changeCol;      } ); // Highlight the date of the focused edit. focusChangeCol && ( => {        var dateMatch = changeCols.find( changeCol => { return changeCol.date && changeCol.date.cache && changeCol.date.cache.isVisible && [ 'year', 'month', 'day' ].every( unit => {           return changeCol.date[ unit ] === focusChangeCol.date[ unit ];          } ); } );       if ( dateMatch ) {          // Date is already visible.          paintDate( dateMatch, true );        } else if ( changeCols.includes( focusChangeCol ) ) {          // Date is not currently visible. Insert.          paintDate( focusChangeCol, true );        }      } ); // Show focused logs highlighted in red. // TODO: Should the highlights be behind the diffs themselves? logIcons.forEach( log => {       if ( focusChange === log ) {          paintLog( log, true );        }      } ); // Things that don't need updating, and are in front of the other stuff: // headers, revert arrows. displayContext.drawImage( foregroundCanvas, 0, 0 ); }   // Should the left/right edges of this box be gray? function outlineRows( group1, group2 ) { displayContext.strokeStyle = 'black'; displayContext.strokeRect( 0, group1.Y, fullWidth, group2.Y + group2.height - group1.Y ); }   function outlineCols( group1, group2 ) { displayContext.strokeStyle = 'black'; displayContext.strokeRect( group1.X, 0, group2.X + group2.width - group1.X, fullHeight ); }   function init { // This, along with a reset of fullWidth and formatDiffs and part of     // processLogs, should be redone on every window resize. TODO. backgroundCanvas.width = foregroundCanvas.width = fullWidth; backgroundCanvas.height = foregroundCanvas.height = fullHeight; }   return { paint, showLoading, outlineRows, outlineCols, // NOTE: If necessary, this could set a local version of changeCols/changeRows/logs. newData { paintBackground; paintForeground; paint; },     init }; } );  domHandler = (  => { var container = document.createElement( 'div' ), // (Only used in displayDiff.) // Holds a summary of the highlighted edit, including author, edit summary, timestamp, etc.     summary = document.createElement( 'div' ), // Holds the diff table itself, below the summary. // (Currently exposed by domHandler, to attach event handler and scroll     // position. TODO: Fix.) diffHolder = document.createElement( 'div' ), // Navigation buttons. (Constructed later by createButtons.) buttons = {}, contentText = document.querySelector( '#mw-content-text' ), contentSub = document.querySelector( '#contentSub' ), // Stores the default display, in case we need to put it back if the user // disables HistoryView. normalHistoryFrag = document.createDocumentFragment, initializedDomHandler = false, // Set to true after init is called. initializedDisplay = false; container.appendChild( canvas ); container.appendChild( summary ); container.appendChild( diffHolder ); // TODO: Only update DOM if there's been an actual change. /**    * Display the HTML of a diff, and its associated author info and summary. *    * @param {Object} [diff] Either a changeCol (for an edit) or a log, to be     * displayed. (If omitted, just blank the area.) */   function displayDiff( diff ) { function createInfoSpan { // TODO: Redlinks function addLink( text, page, title ) { var link = diffInfoSpan.appendChild( document.createElement( 'a' ) ); link.innerText = text; page && ( link.href = mw.config.get( 'wgArticlePath' ).replace( /\$1/, page ) ); title && ( link.title = title ); return link; }       let title = new mw.Title( mw.config.get( 'wgPageName' ) ), { anon, user, userhidden, timestamp, parsedcomment, revid, priorrevid, isLog } = diff, formattedTimestamp = timestamp && formatTimestamp( timestamp ), diffInfoSpan = document.createElement( 'span' ); // Show user name, talk link, contribs if ( user ) { if ( !userhidden ) { addLink( user, ( anon ? 'Special:Contributions/' : mw.config.get( 'wgFormattedNamespaces' )[ 2 ] + ':' ) + user ); diffInfoSpan.appendChild( document.createTextNode( ' (' ) );           addLink( mw.msg( 'talkpagelinktext' ), mw.config.get( 'wgFormattedNamespaces' )[ 3 ] + ':' + user );            if ( !anon ) {              diffInfoSpan.appendChild( document.createTextNode( ' | ' ) );              addLink( mw.msg( 'contribslink' ), 'Special:Contributions/' + user );            }            diffInfoSpan.appendChild( document.createTextNode( ') ' ) ); } else { let delUser = diffInfoSpan.appendChild( document.createElement( 'span' ) ); delUser.className = 'history-deleted'; delUser.appendChild( document.createTextNode( mw.msg( 'HV-RemovedUser' ) ) ); diffInfoSpan.appendChild( document.createTextNode( ' ' ) ); }       }        // Flags [ 'minor', 'bot' ].forEach( type => {         if ( diff[ type ] ) {            var abbr = diffInfoSpan.appendChild( document.createElement( 'abbr' ) );            abbr.innerText = mw.msg( type + 'editletter' );            abbr.className = type + 'edit';            abbr.title = mw.msg( 'recentchanges-label-' + type );          }        } ); if ( isLog ) { if ( diff.type === 'lastSeen' ) { diffInfoSpan.appendChild( document.createTextNode( mw.msg( 'HV-LastVisited', formatTimestamp( diff.timestamp ) ) ) ); }       }        if ( diff.isMultipleRevisions ) { diffInfoSpan.appendChild( document.createTextNode( mw.msg( 'HV-MultiRev' ) ) ); }       // Edit summary if ( parsedcomment ) { let summarySpan = diffInfoSpan.appendChild( document.createElement( 'span' ) ); summarySpan.className = 'comment'; summarySpan.innerHTML = ' (' + parsedcomment + ') '; }       // Timestamp if ( formattedTimestamp && diff.type !== 'lastSeen' ) { if ( revid ) { let dateElem = diffInfoSpan.appendChild( document.createElement( 'span' ) ), dateLink; if ( diff.missingcontent ) { dateElem.className = 'history-deleted'; dateElem.appendChild( document.createTextNode( formattedTimestamp ) ); } else { dateLink = dateElem.appendChild( document.createElement( 'a' ) ); dateLink.appendChild( document.createTextNode( formattedTimestamp ) ); dateLink.href = title.getUrl( { oldid: revid } ); }         } else { diffInfoSpan.appendChild( document.createTextNode( formattedTimestamp ) ); }       }        // Undo/thank buttons. if ( !isLog && !diff.missingcontent ) { diffInfoSpan.appendChild( document.createTextNode( ' (' ) );         addLink( mw.msg( 'editundo' ), null, mw.msg( 'tooltip-undo' ) ).href = title.getUrl( { action: 'edit', undoafter: priorrevid, undo: revid } );          if ( diff.user ) {            diffInfoSpan.appendChild( document.createTextNode( ' | ' ) );            addLink( mw.msg( 'thanks-thank' ), 'Special:Thanks/' + revid, mw.msg( 'thanks-thank-tooltip' ) );          }          diffInfoSpan.appendChild( document.createTextNode( ')' ) ); }       return diffInfoSpan; }     // Clear diff table for (diffHolder.firstChild; ) { diffHolder.removeChild( diffHolder.firstChild ); }     // ...and summary block if ( summary.firstChild ) { summary.removeChild( summary.firstChild ); }     if ( diff ) { let { elem, delLogElem } = diff; diffHolder.appendChild( elem ); delLogElem && diffHolder.appendChild( delLogElem ); if ( diff.cachedInfoElem ) { summary.appendChild( diff.cachedInfoElem ); } else { diff.cachedInfoElem = summary.appendChild( createInfoSpan ); }     }    }    /**     * If the change is not already visible, scroll to it. */   function scrollChangeIntoView( change ) { var inlineChanges, minInlineChangeOffset; if ( change ) { if ( change.type === 'mod' ) { // We want to scroll to the earliest inline change, if it's not // already visible. if ( change.minInlineChangeOffset !== undefined ) { // Cached offset. minInlineChangeOffset = change.minInlineChangeOffset; } else { inlineChanges = change.elem.querySelectorAll( '.diffchange-inline' ); if ( inlineChanges.length ) { // inlineChangeOffsets = [ ...change.elem.querySelectorAll( '.diffchange-inline:first-of-type' ) ].map( x => x.offsetTop ); minInlineChangeOffset = Math.min( ...[ ...inlineChanges ].map( x => x.offsetTop ) ); change.minInlineChangeOffset = minInlineChangeOffset; }         }        }        if ( minInlineChangeOffset && minInlineChangeOffset > diffHolder.offsetHeight ) { diffHolder.scrollTop = change.elem.offsetTop + minInlineChangeOffset; } else { diffHolder.scrollTop = change.elem.offsetTop; }     }    }    /**     *      */    function setUpDisplay { initializedDisplay = true; fullWidth = contentText.offsetWidth; // HTML/CSS canvas.height = fullHeight; canvas.width = fullWidth; diffHolder.style.height = spaceHeight +'px'; diffHolder.style.overflow = 'auto'; diffHolder.style.clear = 'both'; contentText.insertAdjacentElement( 'afterbegin', container ); // Remove normal history page. for (container.nextSibling; ) { normalHistoryFrag.appendChild( container.nextSibling ); }     // Hide "View logs for this page". contentSub.style.display = 'none'; }   function shutDownDisplay { container.parentNode.replaceChild( normalHistoryFrag, container ); contentSub.style.display = 'block'; }   /**     * Create the pan and zoom buttons and such, and attach click handlers. */   function createButtons { // TODO: Maybe also buttons for moving by one change? // Create buttons. [       [ 'start', '|<-', 'first', mw.msg( 'HV-ShowEarliest' ) ], // TODO. [ 'prev', '<-', 'previous', mw.msg( 'HV-ShowEarlier' ) ], // Eh, maybe not. // Maybe a button separate from the group, w/ text? // [ 'zoomout', 'a', 'exitFullscreen' ], [ 'next', '->', 'next', mw.msg( 'HV-ShowLater' ) ], [ 'end', '->|', 'last', mw.msg( 'HV-ShowLatest' ) ] ].forEach( ( [ key, label, icon, title ] ) => {       buttons[ key ] = new OO.ui.ButtonWidget( { icon, title } );     } );      [        [ 'zoomin', '(+)', 'add', mw.msg( 'HV-ZoomIn' ), '' ], [ 'zoomout', '(-)', 'subtract', mw.msg( 'HV-ZoomOut' ), '' ] ].forEach( ( [ key, label, icon, title ] ) => {       buttons[ key ] = new OO.ui.ButtonWidget( { // This should ideally use magnifying +/- icons, but there aren't any in ooui. // File:VisualEditor - Icon - Zoom+.svg // There's also +/- for zoom in/out... icon, title, // label: title } );     } );      buttons.rowFilter = new OO.ui.ButtonWidget( {        label: 'Showing specific rows',        indicator: 'clear',        classes: [ 'yr-historyview-rowFilterButton' ],      } ); // Not strictly a button, but goes in the button area. buttons.posText = new OO.ui.Element( {       text: apiHandler.getPosition,        classes: [ 'yr-historyview-posText' ]      } ); toggleRowFilterButton( false ); // Insert buttons. [       [          buttons.start, buttons.prev, buttons.posText, buttons.rowFilter, buttons.next, buttons.end ],       [          buttons.zoomin, buttons.zoomout ]     ].forEach( buttonsInGroup => {        container.insertBefore( new OO.ui.ButtonGroupWidget( {          items: buttonsInGroup,          classes: [ 'yr-historyview-buttongroup' ]        } ).$element[ 0 ], summary );      } ); updateButtonDisplay; }   function updateButtonDisplay { [ 'start', 'prev', 'next', 'end' ].forEach( ( type, i ) => {       buttons[ type ].setDisabled( apiHandler.canPan( i > 1 ? -1 : 1 ) === false );      } ); [ 'zoomin', 'zoomout' ].forEach( type => {       buttons[ type ].setDisabled( apiHandler.canZoom( type === 'zoomin' ? 1 : -1 ) === false );      } ); buttons.posText.$element.text( apiHandler.getPosition ); }   function toggleRowFilterButton( show ) { buttons.rowFilter.$element.toggle( show ); }   // TODO function showError( err ) { summary.innerText = 'ERROR: ' + err; }   function createSettingsMenu { var settingsButton, disableButton, disableText = mw.msg( 'HV-Disable' ), enableText = mw.msg( 'HV-Enable' ), disableTT = mw.msg( 'HV-DisableTT' ), enableTT = mw.msg( 'HV-EnableTT' ), indicators = document.querySelector( '.mw-indicators' ); function saveSetting( type, value ) { settings[ type ] = value; ( new mw.Api ).saveOption( 'userjs-historyview-settings', JSON.stringify( settings ) ); }

// Temporary, until T262510 is fixed indicators.style.zIndex = 1; settingsButton = indicators.appendChild(       new OO.ui.PopupButtonWidget( { framed: false, icon: 'advanced', title: 'History settings', popup: { label: 'Settings', padded: true, $content: // The settings menu. $( ' ' ).append(               ( disableButton = new OO.ui.ButtonWidget( {                  framed: false,                  label: settings.disabled ? enableText : disableText,                  classes: [ 'yr-historyview-settingsbutton' ],                  title: disableTT                } ).on( 'click', function  {                  // Disable or enable HistoryView.                  saveSetting( 'disabled', !settings.disabled );                  if ( !settings.disabled ) {                    // Enable                    if ( initializedDisplay ) {                      setUpDisplay;                      canvasDisplay.paint;                    } else {                      init;                    }                  } else {                    // Disable                    shutDownDisplay;                  }                  disableButton.setLabel( settings.disabled ? enableText : disableText );                 disableButton.setTitle( settings.disabled ? enableTT : disableTT );               } ) ).$element,                // Link to the logs                new OO.ui.ButtonWidget( { framed: false, label: mw.msg( 'HV-ViewLogs' ), href: new mw.Title( 'Special:Log' ).getUrl( { page: mw.config.get( 'wgPageName' ) } ), classes: [ 'yr-historyview-settingsbutton' ] } ).$element,               // Select date. (Not yet available.)                // Actually, maybe this should be done by clicking on the "1 - 59 of ..." area.                new OO.ui.ButtonWidget( { framed: false, label: mw.msg( 'HV-SelectDate' ), classes: [ 'yr-historyview-settingsbutton' ], title: '(Not yet available.)', disabled: true // Until implemented } ).$element,               // Filter by tags. (Use rvtag in prop=revisions. TODO.)               new OO.ui.ButtonWidget( { framed: false, label: mw.msg( 'HV-FilterTags' ), classes: [ 'yr-historyview-settingsbutton' ], title: '(Not yet available.)', disabled: true // Until implemented } ).$element             ), head: true, width: 250, }       } ).$element[ 0 ]      ); }   function initDomHandler { if ( initializedDomHandler ) { return false; }     initializedDomHandler = true; mw.util.addCSS( `       .yr-historyview-buttongroup {          float: right;        }        .yr-historyview-posText {          display: inline-block;          margin: 0 1em;        }        .yr-historyview-rowFilterButton {          margin-right: 10px;        }        .yr-historyview-settingsbutton {          display: block;        }      `); createSettingsMenu; }   return { init: initDomHandler, setUpDisplay, displayDiff, showError, createButtons, buttons, updateButtonDisplay, toggleRowFilterButton, // TODO: Remove when possible. diffHolder, scrollChangeIntoView }; } );  /**   * Attach event listeners, user interactions.   */  function buildInteractions {    var changesInView = {        changeCol: null,        // Changes that are scrolled-to/visible within the diffHolder.        changes: [],        // Last change focused via the canvas.        change: null,        locked: false      },      mouseIsDown = false,      mouseDownStartPosition;    // TODO: Consider deprecating and reworking things.    /**     * Show diff corresponding with the passed coordinates on the canvas, and     * scroll to the row.     * @return {Object} focusChange     */    function focusPosition( X, Y ) {      var newFocus = findChangesFromPosition( X, Y );      // Only refresh the display if something has changed.      if ( changesInView.change !== newFocus.change || changesInView.changeCol !== newFocus.changeCol ) {        Object.assign( changesInView, newFocus ); showChange( newFocus ); }     return newFocus.change; }   /**     * Display a change in the diff area, updating the canvas as necessary. */   function showChange( { changeCol, change } ) { if ( change && change.isLog ) { domHandler.displayDiff( change ); } else { // An actual edit, not a log. domHandler.displayDiff( changeCol ); // Scroll to the focused lines. domHandler.scrollChangeIntoView( change ); }     highlightFocus; }   /**     * Find change(s) corresponding with the X/Y coordinates on the canvas. * (Return value assigned to changesInView.) *    * @return {Object} return.change A row of an edit, or a log, matching the position. * @return {Object} [return.changeCol] */   function findChangesFromPosition( X, Y ) { var changeCol = changeCols.find( changeCol => changeCol.X <= X && changeCol.X + changeCol.width > X ), index = changeCol && changeCol.changes[ 0 ] && changeCol.changes[ 0 ].col, minDistance = 1000, focusChange, focusLog = logIcons.find( log => {         return X > log.X - 10 && X < log.X + 10 && Y > log.Y - 10 && Y < log.Y + 10;        } ); if ( focusLog ) { return { change: focusLog, changes: [ focusLog ], changeCol: null }; }     changeRows.forEach( changeRow => changeRow.changes.forEach( change => { if ( change.col === index ) { let top = changeRow.Y - Y,           bottom = changeRow.Y + ( change.add + change.del ) - Y,            distance = ( top < 0 && bottom > 0 ) ? // Cursor is between the top and bottom of the change. 0 :             // Cursor is outside the change. Check actual distance to closest // part of the change. Math.min( Math.abs( top ), Math.abs( bottom ) ); if ( distance < minDistance || !focusChange ) { minDistance = distance; focusChange = change; }       }      } ) );      return { change: focusChange, changes: undefined, changeCol }; }   // This is called once, by highlightFocus. // TODO: Move some of this to domHandler. Need to move diffHolder refs. // Maybe move the whole thing to domHandler? // Also, does this need an argument passed? (changesInView.changeCol) /**    * Find all changes that have visible (within scroll area) rows. * @return {Array} inView List of changes in view. */   function findChangesInView( changeCol ) { var inView = [], scroll = domHandler.diffHolder.scrollTop; // TODO: Improve performance. changeCol.changes.forEach( change => {       var { elem } = change,          // Should this be cached? Unsure. (If this is done, need to reset on resize.)          // top = change.offsetTop || ( change.offsetTop = elem.offsetTop ),          top = elem.offsetTop,          bottom = top + elem.offsetHeight;        if ( top < scroll + spaceHeight && bottom > scroll ) {          inView.push( change );        }      } ); return inView; }   /**     * Highlight the areas of the canvas representing changes that are currently * visible and within the scrolled-to area of the diffHolder element. */   function highlightFocus { if ( changesInView.changeCol ) { changesInView.changes = findChangesInView( changesInView.changeCol ); } else { // canvasDisplay.paint( { changes: [ change ] } ); }     canvasDisplay.paint( changesInView ); }   function findSelectedAreas( { X: X1, Y: Y1 }, { X: X2, Y: Y2 } ) { var type = Math.abs( Y1 - Y2 ) > Math.abs( X1 - X2 ) ? 'row' : 'col'; const [ findSelectedRows, findSelectedCols ] = [ [ changeRows, 'Y', 'height' ], [ changeCols, 'X', 'width' ] ].map( ( [ changeGroups, position, size ] ) => ( d1, d2 ) => {         var first = Math.min( d1, d2 ),            second = Math.max( d1, d2 ),            firstGroup = changeGroups.find( changeGroup => { return changeGroup && changeGroup[ position ] + changeGroup[ size ] > first; } ) || changeGroups.find( x => x ),           secondGroup = changeGroups.find( changeGroup => { return changeGroup && changeGroup[ position ] <= second && changeGroup[ position ] + changeGroup[ size ] > second; } ) || changeGroups[ changeGroups.length - 1 ];         return [ firstGroup, secondGroup ];        } ); return { type, groups: type === 'row' ? findSelectedRows( Y1, Y2 ) : findSelectedCols( X1, X2 ) };   }    function selectAreas( from, to ) { // Only show selected rows/columns. var { type, groups: [ group1, group2 ] } = findSelectedAreas( from, to ); if ( type === 'row' ) { // Filter for specific rows. // The boundary is a row just outside of the selected area, so that // newly-inserted rows from not-yet-loaded columns are shown if        // outside the outermost row but not past the row that was previously // just outside the range. var filteredChangeRows = changeRows.filter( x => x.changes && x.changes.length ), group1Outside = filteredChangeRows[ filteredChangeRows.indexOf( group1 ) - 1 ], group2Outside = filteredChangeRows[ filteredChangeRows.indexOf( group2 ) + 1 ]; // This should save the elems, I think. // Something needs to store the edges data, for repeated filters. // (Theoretically, that could be done here. Not recommended.) // Also needs to be some way to navigate from filtered rows...       // Also something needs to manage the extra requests, skipping blank cols. // Loop if less than min, unless retrieved more than max (500). // I'm starting to think that having selectRows attached to data is a bad idea. apiHandler.selectRows( group1, group2 ); // apiHandler.selectRows( group1, group2, ( [ results, logs ] ) => {       //         // } ); // Okay, notes on this: // * The filter data block can include whatever stuff I like. It gets passed on. // * buildInteractions has everything necessary to include local variables //  that can be accessed from all relevant parts except inside apiHandler. // * Button handlers can be modified in here, including pan. // * Can include equivalent row numbers at beginning and end? Would that work? canvasDisplay.showLoading; showData( {         top: group1Outside && group1Outside.changes[ 0 ].elem,          bottom: group2Outside && group2Outside.changes[ 0 ].elem        } ); domHandler.toggleRowFilterButton( true ); console.log( group1, group2 ); } else { if ( group1 !== group2 ) { apiHandler.selectCols( group1.revid, group2.revid ).then( compare => {           // Not sure what to do in the author info field, and such.            // I don't like just showing the last user. Not clear enough.            var { $table: $elem } = getCompareElement( compare );            // changesInView = { locked: true };            changesInView.locked = true;            showData;            domHandler.displayDiff( { elem: $elem[ 0 ], revid: group2.revid, priorrevid: group1.priorrevid, isMultipleRevisions: true } );         } );        }      }      console.log( 'SELECTED', group1, group2 ); }   // Add event handlers canvas.onmousemove = e => { if ( apiHandler.isBusy ) { // Haven't finished loading yet. return; }     var { offsetX, offsetY } = e;      if ( !mouseIsDown ) { if ( !changesInView.locked ) { focusPosition( offsetX, offsetY ); }     } else { // Show selection canvasDisplay.paint; if ( changeCols.length ) { var { type, groups: [ group1, group2 ] } = findSelectedAreas( mouseDownStartPosition, { X: offsetX, Y: offsetY } ); if ( type === 'row' ) { canvasDisplay.outlineRows( group1, group2 ); // context.strokeRect( 0, group1.Y, fullWidth, group2.Y + group2.height - group1.Y ); } else { canvasDisplay.outlineCols( group1, group2 ); // context.strokeRect( group1.X, 0, group2.X + group2.width - group1.X, fullHeight ); }       }      }    };    // paintColumnOutline canvas.onclick = e => { var { offsetX, offsetY } = e,       { change: focus } = findChangesFromPosition( offsetX, offsetY ), // This causes an error when only visible change is icon. TODO: Fix. lockedAlreadyInView = changesInView.locked && changesInView.changes && changesInView.changes.includes( focus ); if ( lockedAlreadyInView ) { // Unlock changesInView.locked = false; } else { changesInView.locked = true; focusPosition( offsetX, offsetY ); }     highlightFocus; };   canvas.onmouseenter = function  { changesInView.locked = false; highlightFocus; };   canvas.onmousedown = function ( e ) { var { offsetX, offsetY } = e;     mouseIsDown = true; mouseDownStartPosition = { X: offsetX, Y: offsetY }; };   canvas.onmouseup = function ( e ) { var { offsetX, offsetY } = e;     if ( !apiHandler.isBusy && changeCols.length ) { if ( mouseDownStartPosition.X !== offsetX || mouseDownStartPosition.Y !== offsetY ) { selectAreas( mouseDownStartPosition, { X: offsetX, Y: offsetY } ); }     }      mouseIsDown = false; };   // Update highlighted changes to match current scroll position domHandler.diffHolder.addEventListener( 'scroll', function {      // Only update when focusing an actual change, not a protect log      if ( changesInView.changeCol ) {        highlightFocus;      }    }, { passive: true } ); [     [ 'prev',  => apiHandler.pan( 1 ) ], [ 'next', => apiHandler.pan( -1 ) ], [ 'start', => apiHandler.panToEdge( 1 ) ], [ 'end', => apiHandler.panToEdge( -1 ) ], [ 'zoomin', => apiHandler.zoom( 1 ) ], [ 'zoomout', => apiHandler.zoom( -1 ) ], [ 'rowFilter', => { // TODO. apiHandler.selectRows; } ]   ].forEach( ( [ type, fn ] ) => {      domHandler.buttons[ type ].on( 'click',  => { canvasDisplay.showLoading; fn; showData; // Should these be after .then? domHandler.toggleRowFilterButton( false ); domHandler.displayDiff( false ); } );   } );  }  // TODO: Better name. /**  * @param {Object} [filterRows] */ function showData( filterRows ) { // TODO: Add showLoading here? var promise = apiHandler.getData .then( ( [ diffs, logs ] ) => {       ( { changeRows, changeCols } = processDiffs( diffs, filterRows ) );        // TODO: Extra apiHandler action must be taken here if insufficient number of        // diffs after row filtering.        if ( filterRows && changeCols.filter( changeCol => !changeCol.hidden ).length < ( filterRows.cols || ( filterRows.cols = diffs.length ) ) ) {          if ( apiHandler.displayMore ) {            return showData( filterRows );          }        }        formatDiffs( changeRows, changeCols );        logIcons = processLogs( logs );        domHandler.updateButtonDisplay;        canvasDisplay.newData;        console.log( changeRows, changeCols, logIcons, logs );      } ) .catch( e => {       // Show error, preferably in the summary area, I think.        // Also log it.        domHandler.showError( e );        console.error( e );      } ); // Buttons should be disabled during loading. apiHandler.isBusy && domHandler.updateButtonDisplay; return promise; } function init { domHandler.init; if ( !settings.disabled ) { domHandler.setUpDisplay; canvasDisplay.init; canvasDisplay.showLoading; // Run before? domHandler.createButtons; buildInteractions; showData; console.log( 'Initializing HistoryView.js...' ); } else { //    }  }  mw.messages.set( i18n[ mw.config.get( 'wgUserLanguage' ) ] || i18n.en ); init; return; // For testing. // TODO: Move these somewhere else. Also document, expand, add names, etc. window._hvtests_={ b:n=>{ apiHandler.getData( n ).then( ( [ results, logs ] ) => {       ( { changeRows, changeCols } = processDiffs( results ) );        canvasDisplay.newData;      } ); },   createTestEnvironment { const lineNumber = ( n1, n2 ) => ` Line ${ n1 }: Line ${ n2 || n1 }: `,       addLine = ( addText = 'ADDED_TEXT' ) => `  +  ${ addText } `,       removeLine = ( delText = 'REMOVED_TEXT' ) => ` −  ${ delText }  `,       modLine = ( delText = 'X-X-X', addText = 'X-Y-X' ) => ` −  ${ delText.replace( /-([^-])+-/g, '$1 ' ) } +  ${ addText.replace( /-([^-])+-/g, '<ins class="diffchange diffchange-inline">$1 ' ) } `,       contextLine = ( context = 'CONTEXT' ) => ` <td class="diff-marker"> <td class="diff-context"> ${ context } <td class="diff-marker"> <td class="diff-context"> ${ context } `,       buildDiff = t => { var l1 = 1, l2 = 1, cCount = 3, t = t.split( '' ); var r = { compare: { ['*']: t.map( ( c, i ) => {               // TODO: Deal with line number at start.                var lb = , r = ;                if ( i === 0 && !( t[ i + 1 ] === 'c' && t[ i + 2 ] === 'c' ) ) {                  r = lineNumber( l1, l2 );                }                if ( c === 'c' ) {                  cCount++;                  if ( cCount > 2 && ( !t[ i + 1 ] || t[ i + 1 ] === 'c' ) && ( !t[ i + 2 ] || t[ i + 2 ] === 'c' ) ) {                    if ( !t[ i + 3 ] || t[ i + 3 ] === 'c' ) {                      // Skip over unchanged line                    } else {                      // Show line header for the following line                      r += lineNumber( l1 + 1, l2 + 1 );                    }                  } else {                    // Show context line                    r += contextLine;                  }                } else {                  cCount = 0; r += { 'a': addLine, 'r': removeLine, 'm': modLine }[ c ]; }               l1 += c !== 'a'; l2 += c !== 'r'; return r;             } ).join``            },            user: Math.random + ,            timestamp: new Date          };          return r;        },        runDiffTest = t => {          var r = t.map( buildDiff );          ( { changeCols, changeRows } = processDiffs( r.reverse ) );          console.log( changeRows, changeCols );          canvasDisplay.newData;        },        diffTest = t => {          var rows = t.replace(/^\n+|\n+$/g,).split('\n'),            // Array of arrays of two-char strings            diffs = rows[ 0 ].split``.map( (_,i) => rows.map( l=> l[i] + l[i+1] ) );          diffs.pop;          var fDiffs = diffs.map( diff => diff.map( ( [ c1, c2 ] ) => {            var blank1 = c1 === ' ',              blank2 = c2 === ' ',              noChange = c1 === c2;            return ( noChange && blank1 ) ? '' :              noChange ? 'c' : blank1 ? 'a' : blank2 ? 'r' : 'm'; } ).join`` ); runDiffTest( fDiffs ); },       allDiffTests =  => { // NOTE: All rows are 1-indexed, as in mw. Cols are 0-indexed, with 0 // being the difference between first and second cols in diffTest. // Basic diffTest( `qq\nqa` ); console.log( 'TEST1', changeRows[ 2 ].changes.length === 1 ); console.log( 'TEST1', changeCols[ 0 ].changes.length === 1 ); // Accurate line measurement diffTest( `qq\nqq\nqq\nqa` ); console.log( 'TEST2', changeRows[ 4 ].changes.length === 1 ); // Accurate even after removal of a line. diffTest( `q \nqq\nqq\nqq\nqq\nqq\nqa` ); console.log( 'TEST3', changeRows[ 7 ].changes.length === 1 ); // ...or an addition. diffTest( ` q\nqq\nqq\nqq\nqq\nqq\nqa` ); console.log( 'TEST4', changeRows[ 7 ].changes.length === 1 ); // Both of these fail: // Remove then add. (Either put in same row, or different rows, but don't lose track of number o intervening rows.) // There should be 5 rows in between the first set of changes and the last. (Currently only 4, for both: [1,2,7].) diffTest( `q \n q\nqq\nqq\nqq\nqq\nqq\nqa` ); console.log( changeRows ); console.log( 'TEST5', changeRows[ 1 ].changes.length === 1 && changeRows[ 8 ].changes.length === 1 ); diffTest( ` q\nq \nqq\nqq\nqq\nqq\nqq\nqa` ); console.log( 'TEST6', changeRows[ 8 ].changes.length === 1 ); // Re-add back into old row slot, don't expand. diffTest( `q q\nqqq\nqqq\nqqq\nqqq\nqqq\nqaa` ); console.log( 'TEST7', changeRows[ 7 ].changes.length === 1 ); // Removal line, without gap. diffTest( `q \nqq\nqa` ); console.log( 'TEST8', changeRows[ 3 ].changes.length === 1 ); // Addition, without gap. diffTest( ` q\nqq\nqa` ); console.log( 'TEST9', changeRows[ 3 ].changes.length === 1 ); console.log( 'TEST9', changeCols[ 0 ].changes.length === 2 ); // TODO: Test headers. // TODO: Test line moves. // TODO: Test reverts. //          // _hvtests_.u(`          // qqqqq          // qq qq          // qq  q          // qq qq          // qcqqq          // qqqqq          // qqqqq          // qqqqq          // qqqqq          // qdefq`)

// Broken: col[ 2 ]'s last change is two rows up from where it should be. // _hvtests_.u(`         // qqq q          // qq qq          // qq  q          // qq qq          // qqq q          // qqqqq          // qqqqq          // qqqqq          // qqqqq          // qqqqq          // qdefq`) },       protectTest =  => { // This can only be run when there are enough diffs. logIcons = processLogs( [           [ {                "type": "move",                "level": "sysop",            } ],            [ {                "type": "edit",                "level": "autoconfirmed",            } ],            [ {                "type": "edit",                "level": "sysop",            } ],            [ {                "type": "edit",                "level": "extendedconfirmed",            } ],            [ {                "type": "edit",                "level": "sysop",                "cascade": "cascade"            } ],            [ {                "type": "edit",                "level": "staff",            } ],            [ {              "type": "edit",              "level": "templateeditor"            } ]            // TODO: Add unprotect at the end.          ].map( ( a, i ) => ( {            "params": {                "description": "\u200e[move=sysop] (expires 00:00, 29 May 2018 (UTC))", "details": a           }, "type": "protect", "action": "protect", "user": "Protector", "timestamp": changeCols[ i * 3 ].timestamp, //"2018-04-23T17:00:50Z", "parsedcomment": "TEST" + i         } ) ).reverse );          canvasDisplay.newData;        };

// _hvtests_.createTestEnvironment(['lcacla','lmrclm','lcrlr']) // runDiffTest( j ); // o( j ); return { buildDiff, diffTest, protectTest, allDiffTests };   }  };  // _hvtests_.createTestEnvironment.diffTest( `q \n q\nqq\nqq\nqq\nqq\nqq\nqa` ); // _hvtests_.createTestEnvironment.allDiffTests; // _hvtests_.createTestEnvironment.protectTest; } );