Jump to content

User:Yair rand/HistoryView.js

From Wikipedia, the free encyclopedia
Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
/**
 * 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 {{Mediawiki:}} 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 "<tr>" 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 class="diff diff-contentalign-left" data-mw="interface">' +
              '<colgroup><col class="diff-marker"><col class="diff-content"><col class="diff-marker"><col class="diff-content"></colgroup><tbody></tbody></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><tbody><tr><td></td></tr></tbody></table>' );
      $table.find( 'td' ).text( mw.msg( 'HV-DeletedRev') );
      return $table;
    }
    
    /**
     * @return {jQuery}
     */
    function createDeletionLogElement( deleteLog ) {
      return $( '<div>' ).append(
        $( '<h3>' ).text( mw.msg( 'dellogpage' ) ),
        $( '<ul>' ).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 $( '<li>' ).append(
            // Date
            $( '<span>' ).text( formatTimestamp( new Date( log.timestamp ) ) ),
            ' ',
            // User link
            $( '<a>' ).text( log.user ).attr( 'href', new mw.Title( log.user, 2 ).getUrl() ),
            ' - ',
            $( '<span>' ).text( types.join( ', ' ) ),
            ' ',
            // Log summary
            log.parsedcomment && $( '<span>' ).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://wiki.riteme.site/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, '<del>' ], [ $iDels, '<ins>' ] ].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://wiki.riteme.site/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 <tr> 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 => {
            // <year> ', ' <month> ' ' <day>
            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.
              $( '<p>' ).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 ) => `<tr>
          <td colspan="2" class="diff-lineno">Line ${ n1 }:</td>
          <td colspan="2" class="diff-lineno">Line ${ n2 || n1 }:</td>
        </tr>`,
        addLine = ( addText = 'ADDED_TEXT' ) => `<tr>
          <td colspan="2" class="diff-empty">&nbsp;</td>
          <td class="diff-marker">+</td>
          <td class="diff-addedline"><div>${ addText }</div></td>
        </tr>`,
        removeLine = ( delText = 'REMOVED_TEXT' ) => `<tr>
          <td class="diff-marker">−</td>
          <td class="diff-deletedline"><div>${ delText }</div></td>
          <td colspan="2" class="diff-empty">&nbsp;</td>
        </tr>`,
        modLine = ( delText = 'X-X-X', addText = 'X-Y-X' ) => `<tr>
          <td class="diff-marker">−</td>
          <td class="diff-deletedline"><div>${ delText.replace( /-([^-])+-/g, '<del class="diffchange diffchange-inline">$1</del>' ) }</div></td>
          <td class="diff-marker">+</td>
          <td class="diff-addedline"><div>${ addText.replace( /-([^-])+-/g, '<ins class="diffchange diffchange-inline">$1</ins>' ) }</div></td>
        </tr>`,
        contextLine = ( context = 'CONTEXT' ) => `<tr>
          <td class="diff-marker">&nbsp;</td>
          <td class="diff-context"><div>${ context }</div></td>
          <td class="diff-marker">&nbsp;</td>
          <td class="diff-context"><div>${ context }</div></td>
        </tr>`,
        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();
} );