Hytale Wiki
Register
Advertisement

V jiných jazycích: DeutschEnglishEspañol한국어Português do BrasilРусский中文


Změny CSS a Javascriptu musí dodržovat pravidla designu wiki.


Poznámka: Po uložení budete možná muset obejít mezipaměť prohlížeče, abyste viděli změny.

  • Firefox / Safari: Držte Shift při kliknutí na Reload, nebo stiskněte Ctrl-F5 nebo Ctrl-R (⌘-R na Macu)
  • Google Chrome: Stiskněte Ctrl-Shift-R (⌘-Shift-R na Macu)
  • Internet Explorer: Držte Ctrl při kliknutí na Refresh, nebo stiskněte Ctrl-F5
  • Opera: Běžte do Menu → Settings (Opera → Preferences na Macu) poté do Privacy & security → Clear browsing data → Cached images and files.
( function() {
'use strict';

/** "Global" vars (preserved between editing sessions) **/
var i18n = {
	blockedNotice: 'You cannot edit this sprite as you are blocked.',
	blockedReason: 'Reason: $1',
	browserActionNotSupported: 'Not supported by your browser.',
	changesSavedNotice: 'Your changes were saved.',
	controlNewName: 'New name',
	ctxDeleteImage: 'Delete',
	ctxDownloadImage: 'Download',
	ctxReplaceImage: 'Replace',
	diffError: 'Failed to retrieve diff',
	diffErrorMissingPage: 'Failed to retrieve page',
	dupeName: 'This name already exists.',
	dupeNamesNotice: 'There are duplicate names which must be resolved prior to saving.',
	errorApi: 'API error',
	errorConnection: 'Connection error',
	errorConnectionText: 'Check your internet connection',
	errorGeneric: 'Error',
	errorHttp: 'HTTP error',
	genericError: 'Something went wrong',
	luaKeyDeprecated: 'deprecated',
	luaKeyId: 'id',
	luaKeyIds: 'ids',
	luaKeyName: 'name',
	luaKeyPos: 'pos',
	luaKeySection: 'section',
	luaKeySections: 'sections',
	luaKeySettings: 'settings',
	luaKeySettingsHeight: 'height',
	luaKeySettingsPos: 'pos',
	luaKeySettingsSize: 'size',
	luaKeySettingsSpacing: 'spacing',
	luaKeySettingsUrl: 'url',
	luaKeySettingsWidth: 'width',
	namePlaceholder: 'Type a name',
	noPermissionNotice: 'You do not have permission to edit this sprite.',
	panelChangesIdTitle: 'Data changes',
	panelChangesNoDiffFromCur: 'No changes from current revision.',
	panelChangesSheetTitle: 'Spritesheet changes',
	panelChangesTitle: 'Review your changes',
	panelCloseTip: 'Close',
	panelConflictCurText: 'Current text',
	panelConflictReview: 'Review changes',
	panelConflictSave: 'Save',
	panelConflictText: 'An edit conflict has occurred, and was not able to be resolved automatically.',
	panelConflictTitle: 'Edit conflict',
	panelConflictYourText: 'Your text',
	panelDiscardContinue: 'Keep editing',
	panelDiscardDiscard: 'Discard changes',
	panelDiscardText: 'Are you sure you wish to discard your changes?',
	panelDiscardTitle: 'Unsaved changes',
	panelEcchangesReturn: 'Return to edit conflict form',
	panelEcchangesTitle: 'Review your manual changes',
	sectionPlaceholder: 'Type a section name',
	sectionUncategorized: 'Uncategorized',
	titleEditing: 'Sprite editing $1',
	toolbarHelp: 'Help',
	toolbarHelpPage: 'Help:Sprite editor',
	toolbarNewImage: 'New image',
	toolbarNewSection: 'New section',
	toolbarRedo: 'Redo',
	toolbarReviewChanges: 'Review changes',
	toolbarSave: 'Save',
	toolbarSummaryLabelTip: 'The number of bytes remaining',
	toolbarSummaryPlaceholder: 'Summarize the changes you made',
	toolbarToolDeprecate: 'Deprecate',
	toolbarToolDeprecateTip: 'Toggle names as deprecated',
	toolbarTools: 'Tools',
	toolbarUndo: 'Undo',
};
var $root = $( document.documentElement );
var $win = $( window );
var $body = $( document.body );
var URL = window.URL || window.webkitURL;
var imageEditingSupported = !!( window.FileList &&
	window.ArrayBuffer &&
	window.Blob &&
	window.FormData &&
	window.ProgressEvent &&
	URL && URL.revokeObjectURL && URL.createObjectURL &&
	document.createElement( 'canvas' ).getContext );
// HTML pointer-events is dumb and can't be tested for
// Just check that we're not IE < 11, old Opera has too little usage to bother checking for
var pointerEventsSupported = $.client.profile().name !== 'msie' || $.client.profile().versionBase > 10;
var originalTitle = document.title;


// Start loading OOUI's icons in the background
mw.loader.load( [
	'oojs-ui.styles.icons-editing-core',
	'oojs-ui.styles.icons-editing-styling',
	'oojs-ui.styles.icons-media',
	'oojs-ui.styles.icons-moderation',
	'oojs-ui.styles.icons-interactions',
	'oojs-ui.styles.icons-movement',
	'oojs-ui.styles.icons-content',
] );

// Handle recreating the editor
$( '#ca-spriteedit' ).find( 'a' ).click( function( e ) {
	// Editor is already loaded, reload the page
	if ( $root.hasClass( 'spriteedit-loaded' ) ) {
		return;
	}
	create();
	e.preventDefault();
} );
$win.on( 'popstate', function() {
	if (
		location.search.match( '[?&]spriteaction=edit' ) &&
		!$root.hasClass( 'spriteedit-loaded' )
	) {
		create( 'history' );
	}
} );


/** Functions **/
/**
 * Entry point for the editor
 *
 * Updates the page if it has been edited since being viewed
 * and creates the editor once ready
 * Is called right at the end of the script, once all other functions
 * are defined.
 *
 * "state" is what triggered the creation (e.g. from history navigation)
 */
var create = function( state ) {
	var $doc = $( '#spritedoc' );
	var preventClose;
	var settings = {};
	var mouse = {
		moved: false,
		x: 0, y: 0
	};
	var sorting = false;
	var oldHtml;
	var spritesheet;
	var spriteSettings = JSON.parse( $doc.attr( 'data-settings' ) );
	var dataPage = $doc.data( 'datapage' );
	var changes = [];
	var undoneChanges = [];
	var usedNames = {};
	var loadingImages = [];
	var panels = {};
	var canTag = null;
	
	// Update the title to say we're editing
	document.title = i18n.titleEditing.replace( /\$1/g, originalTitle );
	
	var revisionsApi = new mw.Api( { parameters: {
		action: 'query',
		prop: 'revisions',
		rvprop: 'content',
		formatversion: 2,
	} } );
	var parseApi = new mw.Api( { parameters: {
		action: 'parse',
		prop: 'text',
		disabletoc: true,
		disablelimitreport: true,
		formatversion: 2,
	} } );
	var $headingTemplate = $( '<h3>' ).html(
		$( '<span>' )
			.addClass( 'mw-headline spriteedit-new' )
			.attr( 'data-placeholder', i18n.sectionPlaceholder )
	);
	var $boxTemplate = $( '<li>' ).addClass( 'spritedoc-box spriteedit-new' ).append(
		$( '<div>' ).addClass( 'spritedoc-image' ),
		$( '<ul>' ).addClass( 'spritedoc-names' ).append(
			$( '<li>' ).addClass( 'spritedoc-name' ).html( '<code>' )
		)
	);
	addControls( $headingTemplate, 'heading' );
	addControls( $boxTemplate, 'box' );
	
	// Pre-load modules which will be needed later
	var saveModules = mw.loader.using( [
		'mediawiki.widgets.visibleLengthLimit',
		'mediawiki.diff.styles',
	] ).fail( console.warn );
	
	$root.addClass( 'spriteedit-loaded' );
	
	if ( !state ) {
		history.pushState( {}, '', mw.util.getUrl( null, { spriteaction: 'edit' } ) );
	}
	if ( state !== 'initial' ) {
		$( '#ca-view' ).add( '#ca-spriteedit' ).toggleClass( 'selected' );
	}
	
	// Get some info about this wiki, the user's rights and
	// block status, and the last edit timestamp for the ids page
	var infoRequest = retryableRequest( function() {
		return revisionsApi.get( {
			rvprop: 'timestamp',
			titles: dataPage,
			meta: 'siteinfo|userinfo',
			uiprop: 'rights|blockinfo',
		} );
	} ).done( function( data ) {
		fixTimestamp.offset = data.query.general.timeoffset;
	} );
	
	// Check if the ids page has been edited since opening the
	// documentation page, and re-download it if necessary
	var contentRequest = infoRequest.then( function( data ) {
		var currentTimestamp = fixTimestamp( data.query.pages[0].revisions[0].timestamp );
		if ( currentTimestamp > $doc.data( 'datatimestamp' ) ) {
			var newContent = retryableRequest( function() {
				return parseApi.get( {
					title: mw.config.get( 'wgPageName' ),
					text: $( '<i>' ).html(
						$.parseHTML( $doc.attr( 'data-refreshtext' ) )
					).html()
				} );
			} ).done( function( parseData ) {
				oldHtml = parseData.parse.text;
				$doc.replaceWith( oldHtml );
				$doc = $( '#spritedoc' );
				spriteSettings = JSON.parse( $doc.attr( 'data-settings' ) );
				dataPage = $doc.data( 'datapage' );
			} );
			
			return newContent;
		} else {
			oldHtml = $doc[0].outerHTML;
		}
	} );
	
	// Check if we have permission to edit the IDs page, spritesheet
	// file, and use tags, and that the user isn't blocked
	var permissionsRequest = $.when( infoRequest, contentRequest ).then( function( data ) {
		var info = data[0].query.userinfo;
		
		var canEdit = true;
		if ( info.blockid ) {
			canEdit = false;
			var $blockNotice = $( '<p>' ).text( i18n.blockedNotice );
			var blockText;
			if ( info.blockreason ) {
				blockText = retryableRequest( function() {
					return parseApi.get( { summary: info.blockreason } );
				} ).done( function( parseData ) {
					$blockNotice.append( '<br>', i18n.blockedReason.replace( /\$1/g,
						$( '<span>' ).addClass( 'comment' ).html( parseData.parse.parsedsummary ).html()
					) );
				} );
			}
			$.when( blockText ).always( function() {
				mw.notify( $blockNotice, { type: 'error', autoHide: false } );
			} );
		} else {
			var rights = info.rights;
			$.each( [ 'data', 'sprite' ], function() {
				var requiredRights = $doc.data( this + 'protection' ).split( ',' );
				$.each( requiredRights, function() {
					if ( rights.indexOf( this ) === -1 ) {
						canEdit = false;
						return false;
					}
				} );
				
				return canEdit;
			} );
			
			if ( !canEdit ) {
				mw.notify( i18n.noPermissionNotice, { type: 'error', autoHide: false } );
			}
			
			/* User doesn't have the right to apply change tags */
			if ( rights.indexOf( 'applychangetags' ) === -1 ) {
				canTag = false;
			}
		}
		
		if ( !canEdit ) {
			sheetRequest && sheetRequest.abort();
			contentRequest.abort && contentRequest.abort();
			
			destroy( true );
		}
	} );
	
	// Replace the spritesheet with a fresh uncached one to ensure
	// we don't save over it with an old version.
	var sheetRequest;
	if ( imageEditingSupported ) {
		sheetRequest = contentRequest.then( function() {
			var $sprite = $doc.find( '.sprite' ).first();
			settings.imageWidth = spriteSettings[i18n.luaKeySettingsWidth] || spriteSettings[i18n.luaKeySettingsSize] || 16;
			settings.imageHeight = spriteSettings[i18n.luaKeySettingsHeight] || settings.imageWidth || 16;
			settings.spacing = spriteSettings[i18n.luaKeySettingsSpacing] || 0;
			settings.sheet = $doc.data( 'original-url' );
			if ( !settings.sheet ) {
				// Get a capture of the whole URL, and of the URL minus the query string
				var urlParts = $sprite.css( 'background-image' )
					.match( /^url\(["']?(([^?"]+)(?:\?[^"]+)?)["']?\)$/ );
				$doc.data( 'original-url', urlParts[1] );
				settings.sheet = urlParts[2] + '?version=' + Date.now();
				$doc.data( 'url', settings.sheet );
			}
			
			// XHR is used instead of a CORS Image so a blob URL can
			// be used for the background image, rather than the real URL.
			// This works around the image being downloaded twice, probably
			// caused by the background image not reusing the CORS request.
			return retryableRequest( function() {
				var deferred = $.Deferred();
				var requestTimeout;
				var xhr = new XMLHttpRequest();
				xhr.open( 'GET', settings.sheet, true );
				xhr.responseType = 'blob';
				xhr.onload = function() {
					clearTimeout( requestTimeout );
					if ( this.status !== 200 ) {
						deferred.reject( 'http', {
							textStatus: this.statusText ? this.status + ' ' + this.statusText : 'error'
						} );
						return;
					}
					
					spritesheet = new Image();
					spritesheet.onload = function() {
						settings.sheetWidth = this.width;
						settings.sheetHeight = this.height;
						
						overwriteSpritesheet( this.src );
						
						deferred.resolve();
					};
					spritesheet.src = URL.createObjectURL( this.response );
				};
				requestTimeout = setTimeout( function() {
					xhr.abort();
					deferred.reject( 'http', { textStatus: 'timeout' } );
				}, 30 * 1000 );
				
				xhr.onabort = xhr.onerror = function() {
					if ( deferred.state() === 'pending' ) {
						deferred.reject( 'http', { textStatus: 'error' } );
					}
				};
				xhr.send();
				
				return deferred.promise( { abort: function() {
					deferred.reject( 'http', { textStatus: 'abort' } );
					xhr.abort();
				} } );
			} ).fail( handleError );
		} );
	}
	
	$.when( contentRequest, permissionsRequest ).then( function() {
		// Make sure the editor wasn't destroyed while we were waiting
		if ( $root.hasClass( 'spriteedit-loaded' ) ) {
			enable();
		}
	}, function( code, error ) {
		// Fatal error, bail
		sheetRequest && sheetRequest.abort();
		infoRequest.abort();
		contentRequest.abort && contentRequest.abort();
		
		handleError( code, error );
		destroy( true );
	} );
	
	// Handle closing the editor on navigation
	$win.on( 'popstate.spriteEdit', function() {
		if (
			!location.search.match( '[?&]spriteaction=edit' ) &&
			$root.hasClass( 'spriteedit-loaded' )
		) {
			close( 'history' );
		}
	} );
	
	
	/**
	 * Create the editor interface
	 *
	 * Makes the necessary HTML changes to the documentation,
	 * creates the extra interface elements, and binds the events.
	 */
	var enable = function() {
		var $content = $doc.closest( '.documentation' );
		var $toolbar;
		
		if ( !$content.length ) {
			$content = $( '#content' );
		}
		
		$root.addClass( 'spriteedit-enabled' );
		if ( imageEditingSupported ) {
			$root.addClass( 'spriteedit-imageeditingenabled' );
		}
		
		$( '.mw-editsection' ).add( '.mw-editsection-like' ).css( 'display', 'none' );
		
		// Store previous element and parent to re-attach to once done
		// and the current scroll position
		var $docPrev = $doc.prev();
		var $docParent = $doc.parent();
		var initialScroll = $win.scrollTop();
		$doc.detach();
		
		$doc.find( '#toc' ).remove();
		
		$doc.append(
			$( '<div>' ).addClass( 'spriteedit-autoscroll spriteedit-autoscroll-up' ),
			$( '<div>' ).addClass( 'spriteedit-autoscroll spriteedit-autoscroll-down' )
		);
		var autoScroll = function( now ) {
			autoScroll.delta = ( now - autoScroll.timeLast ) / ( 1000 / 60 );
			autoScroll.timeLast = now;
			
			if ( mouse.y !== autoScroll.lastMouseY ) {
				var areaRect = autoScroll.area.getBoundingClientRect();
				if ( autoScroll.dir === -1 ) {
					autoScroll.speed = areaRect.bottom - mouse.y;
				} else {
					autoScroll.speed = mouse.y - areaRect.top + 1;
				}
				
				autoScroll.lastMouseY = mouse.y;
			}
			
			// Prevent overscroll
			var scrollTop = $win.scrollTop();
			if (
				autoScroll.dir === -1 && scrollTop > autoScroll.topLimit ||
				autoScroll.dir === 1 && scrollTop + $win.height() < autoScroll.bottomLimit
			) {
				scrollBy( 0, autoScroll.dir * Math.round( autoScroll.speed / 2 ) * Math.round( autoScroll.delta ) );
			}
			
			autoScroll.id = requestAnimationFrame( autoScroll );
		};
		autoScroll.start = function( area ) {
			if ( autoScroll.id ) {
				autoScroll.stop();
			}
			
			var scrollTop = $win.scrollTop();
			autoScroll.area = area;
			autoScroll.dir = $( area ).hasClass( 'spriteedit-autoscroll-up' ) ? -1 : 1;
			autoScroll.speed = 1;
			autoScroll.timeLast = window.performance ? performance.now() : Date.now();
			autoScroll.topLimit = scrollTop + $doc[0].getBoundingClientRect().top;
			autoScroll.bottomLimit = scrollTop + $doc.find( '.spritedoc-section' ).last()[0].getBoundingClientRect().bottom + 50;
			autoScroll.id = requestAnimationFrame( autoScroll );
		};
		autoScroll.stop = function() {
			cancelAnimationFrame( autoScroll.id );
			autoScroll.id = 0;
		};
		
		$doc.find( '.spriteedit-autoscroll' )
			.on( 'mouseenter.spriteEdit dragenter.spriteEdit', function() {
				autoScroll.start( this );
			} )
			.on( 'mouseleave.spriteEdit dragleave.spriteEdit', autoScroll.stop );
		
		
		addControls( $doc.find( 'h3' ), 'heading' );
		
		var $boxes = $doc.find( '.spritedoc-box' );
		$boxes.each( function() {
			var $this = $( this );
			
			var $names = $this.find( '.spritedoc-name' );
			$this.attr( 'data-sort-key', $names.first().text() );
			
			$names.find( 'code' ).each( function() {
				var $code = $( this );
				usedNames[$code.text()] = [ $code ];
			} );
		} );
		addControls( $boxes, 'box' );
		
		// Collapses and expands boxes in each section when sorting
		// sections or boxes, so it's easier to get to the right section
		var collapseBoxes = function( placeholder ) {
			var $this = $( this );
			var isBox = $this.hasClass( 'spritedoc-box' );
			var section = isBox ? $this.closest( '.spritedoc-section' )[0] : placeholder;
			var origPos = this.getBoundingClientRect();
			var origSectionPos = section.getBoundingClientRect();
			var heights = [];
			
			$doc.find( '.spritedoc-boxes' ).each( function() {
				var child = this.firstElementChild;
				if ( child ) {
					if ( $( child ).hasClass( 'spriteedit-ghost' ) ) {
						child = child.nextElementSibling || child;
					}
					heights.push( child.getBoundingClientRect().height );
				} else {
					heights.push( 0 );
				}
			} ).each( function( i ) {
				// Set styling after get loop to avoid layout thrashing
				var height = heights[i];
				if ( !height ) {
					return;
				}
				
				$( this ).css( {
					height: height,
					overflow: 'hidden',
				} );
			} );
			
			// First make sure the section is in the same place relative to the window
			scroll( 0, $win.scrollTop() + section.getBoundingClientRect().top - origSectionPos.top );
			
			// Now if we're sorting boxes, make sure the box remains inside the section
			if ( isBox ) {
				var sectionPos = section.getBoundingClientRect();
				if ( origPos.bottom > sectionPos.bottom ) {
					scroll( 0, $win.scrollTop() + sectionPos.bottom - origPos.bottom );
				}
			}
		};
		var expandBoxes = function() {
			var origPos = this.getBoundingClientRect();
			
			$doc.find( '.spritedoc-boxes' ).css( {
				height: 'auto',
				overflow: 'visible',
			} );
			
			// If we're sorting boxes, scroll so the box is near the cursor
			var $this = $( this );
			if ( $this.hasClass( 'spritedoc-box' ) ) {
				var boxPos = this.getBoundingClientRect();
				scroll( 0, $win.scrollTop() + boxPos.top + boxPos.height / 2 - mouse.y );
				
				// Flash the box so it is obvious where it was sorted to
				$this.css( 'background-color', 'yellow' );
				setTimeout( function() {
					$this.css( 'background-color', '' );
				}, 1000 );
			} else {
				// Otherwise make sure the section is in the same place relative to the window
				scroll( 0, $win.scrollTop() + this.getBoundingClientRect().top - origPos.top );
			}
		};
		makeSortable( {
			selectors: '.spritedoc-section',
			handle: 'h3',
			vertical: true,
			sortStart: collapseBoxes,
			sortEnd: expandBoxes,
		} );
		makeSortable( {
			selectors: {
				container: '.spritedoc-section',
				parent: '.spritedoc-boxes',
				elem: '.spritedoc-box',
			},
			autoSort: true,
			sortStart: collapseBoxes,
			sortEnd: expandBoxes,
		} );
		
		// Create toolbar
		var contentPadding = {
			left: $content.css( 'padding-left' ),
			right: $content.css( 'padding-right' ),
		};
		$toolbar = $( '<div>' ).addClass( 'spriteedit-toolbar' ).css( {
			paddingLeft: contentPadding.left,
			paddingRight: contentPadding.right,
			marginLeft: '-' + contentPadding.left,
			marginRight: '-' + contentPadding.right,
		} );
		var undoButton = new OO.ui.ButtonInputWidget( {
			id: 'spriteedit-undo',
			icon: 'undo',
			label: i18n.toolbarUndo,
			disabled: true,
		} );
		undoButton.$element.data( 'ooui-object', undoButton );
		
		var redoButton = new OO.ui.ButtonInputWidget( {
			id: 'spriteedit-redo',
			icon: 'redo',
			label: i18n.toolbarRedo,
			disabled: true,
		} );
		redoButton.$element.data( 'ooui-object', redoButton );
		
		var newSectionButton = new OO.ui.ButtonInputWidget( {
			id: 'spriteedit-add-section',
			icon: 'textStyle',
			label: i18n.toolbarNewSection,
		} );
		newSectionButton.$element.data( 'ooui-object', newSectionButton );
		
		var newImageButton = new OO.ui.ButtonInputWidget( {
			id: 'spriteedit-add-image',
			icon: 'imageAdd',
			label: i18n.toolbarNewImage,
		} );
		newImageButton.$element.data( 'ooui-object', newImageButton );
		if ( !imageEditingSupported ) {
			newImageButton.setDisabled( true ).$element
				.prop( 'title', i18n.browserActionNotSupported )
				.css( 'cursor', 'help' );
		}
		
		var saveButton = new OO.ui.ButtonInputWidget( {
			id: 'spriteedit-save',
			flags: [ 'progressive', 'primary' ],
			icon: 'expand',
			label: i18n.toolbarSave,
			disabled: true,
		} );
		saveButton.$element.data( 'ooui-object', saveButton ).css( 'right', contentPadding.right );
		
		var toolboxSelect = new OO.ui.DropdownWidget( {
			id: 'spriteedit-toolbox',
			label: i18n.toolbarTools,
			$overlay: $doc,
		} );
		toolboxSelect.$element.data( 'ooui-object', toolboxSelect );
		
		var helpButton = new OO.ui.ButtonWidget( {
			id: 'spriteedit-help',
			framed: false,
			icon: 'help',
			title: i18n.toolbarHelp,
			href: mw.util.getUrl( i18n.toolbarHelpPage ),
			target: '_blank',
		} );
		
		$toolbar.append(
			new OO.ui.ButtonGroupWidget( {
				items: [ undoButton, redoButton ],
			} ).$element,
			new OO.ui.ButtonGroupWidget( {
				items: [ newSectionButton, newImageButton ],
			} ).$element,
			toolboxSelect.$element,
			saveButton.$element,
			helpButton.$element
		);
		
		// Create tools
		var $toolbox = $toolbar.find( '#spriteedit-toolbox' );
		var deprecateOption = new OO.ui.MenuOptionWidget( {
			data: 'deprecate',
			label: i18n.toolbarToolDeprecate,
			icon: 'flag',
		} );
		deprecateOption.$element.prop( 'title', i18n.toolbarToolDeprecateTip );
		toolboxSelect.getMenu().addItems( [ deprecateOption ] );
		
		var $barContainer = $( '<div>' ).addClass( 'spriteedit-toolbar-container' )
			.append( $toolbar ).prependTo( $doc );
		
		// Re-attach content and reset scroll position
		if ( $docPrev.length ) {
			$doc.insertAfter( $docPrev );
		} else {
			$doc.prependTo( $docParent );
		}
		if ( $win.scrollTop() !== initialScroll ) {
			scroll( 0, initialScroll );
		}
		
		// Set height now that everything is re-attached
		var toolbarHeight = $toolbar[0].getBoundingClientRect().height;
		$barContainer.height( toolbarHeight );
		
		// Wait until everything else is done so the animation is smooth
		requestAnimationFrame( function() {
			var barTop = $barContainer[0].getBoundingClientRect().top;
			if ( barTop > 0 ) {
				$root.addClass( 'spriteedit-smoothscroll' );
				scroll( 0, barTop + $win.scrollTop() + 1 );
			}
		} );
		
		// Check the editor's change tag exists
		// Since the tag isn't important, we don't wait for the request to finish
		// If it isn't done by the time we try to save, we assume we can't tag
		if ( canTag !== false ) {
			findChangeTag( 'spriteeditor' ).then( function( result ) {
				canTag = result;
			} );
		}
		
		
		/** Bind events **/
		/* Outside interface events */
		// Prevent accidentally closing window if changes have been made
		preventClose = mw.confirmCloseWindow( {
			namespace: 'spriteEdit',
			test: function() {
				return !saveButton.isDisabled();
			},
		} );
		
		$( '#ca-view' ).find( 'a' ).on( 'click.spriteEdit', function( e ) {
			close();
			e.preventDefault();
		} );
		
		
		/* Toolbar events */
		// Manually make the toolbar sticky if position:sticky isn't supported
		if ( !supports( 'position', 'sticky' ) && !supports( 'position', '-webkit-sticky' ) ) {
			var fixedClass = 'spriteedit-toolbar-fixed';
			var contentOffset = $content.offset().left + 1;
			$win.on( 'scroll.spriteEdit', $.throttle( 32, function() {
				var fixed = $toolbar.hasClass( fixedClass ),
					scrollTop = $win.scrollTop(),
					offset = $barContainer.offset().top;
				if ( !fixed && scrollTop >= offset ) {
					$toolbar.addClass( fixedClass ).css( 'left', contentOffset );
				} else if ( fixed && scrollTop < offset ) {
					$toolbar.removeClass( fixedClass ).css( 'left', '' );
				}
			} ) );
		}
		
		$( '#spriteedit-undo' ).find( 'button' ).on( 'click.spriteEdit', function() {
			$( this ).focus().blur();
			
			// We're not meant to be editing
			if ( $root.hasClass( 'spriteedit-hidecontrols' ) ) {
				return;
			}
			
			var hist = changes.pop();
			revert( hist );
			undoneChanges.push( hist );
			redoButton.setDisabled( false );
		} );
		
		$( '#spriteedit-redo' ).find( 'button' ).on( 'click.spriteEdit', function() {
			$( this ).focus().blur();
			
			// We're not meant to be editing
			if ( $root.hasClass( 'spriteedit-hidecontrols' ) ) {
				return;
			}
			
			var hist = undoneChanges.pop();
			$.each( hist, function() {
				change( this.action, this.content, false, true );
			} );
			changes.push( hist );
			
			if ( !undoneChanges.length ) {
				redoButton.setDisabled( true );
			}
			
			$.each( [
				'#spriteedit-undo',
				'#spriteedit-save',
				'#spriteedit-summary',
				'#spriteedit-review-button',
			], function() {
				if ( $( this ).length ) {
					$( this ).data( 'ooui-object' ).setDisabled( false );
				}
			} );
		} );
		
		$( '#spriteedit-add-section' ).find( 'button' ).on( 'click.spriteEdit', function() {
			$( this ).focus().blur();
			
			// We're not meant to be editing
			if ( $root.hasClass( 'spriteedit-hidecontrols' ) ) {
				return;
			}
			
			var $newHeading = $headingTemplate.clone();
			change( 'insert', {
				$elem: $( '<div>' ).addClass( 'spritedoc-section' ).prepend(
					$newHeading,
					$( '<ul>' ).addClass( 'spritedoc-boxes' )
				),
				index: $( nearestSection() ).index() - 1,
				$parent: $doc,
			}, true );
			
			$newHeading.find( '.mw-headline' ).focus();
		} );
		
		$( '#spriteedit-add-image' ).find( 'button' ).on( 'click.spriteEdit', function() {
			$( this ).focus().blur();
			
			// We're not meant to be editing
			if ( $root.hasClass( 'spriteedit-hidecontrols' ) ) {
				return;
			}
			
			$( '<input type="file">' )
				.attr( {
					accept: 'image/*',
					multiple: true,
				} )
				.one( 'change.spriteEdit', function() {
					insertSprites( this.files );
				} ).click();
		} );
		
		// Toolbox functions
		// Modify click event to not open menu when we're not meant to be editing,
		// or a tool is already selected
		toolboxSelect.origOnClick = toolboxSelect.onClick;
		toolboxSelect.onClick = function( e ) {
			if ( $root.hasClass( 'spriteedit-hidecontrols' ) ) {
				this.$handle.blur();
				return;
			}
			
			toolboxSelect.origOnClick.call( toolboxSelect, e );
		};
		toolboxSelect.$handle.off( 'click' ).on( 'click', toolboxSelect.onClick.bind( toolboxSelect ) );
		
		toolboxSelect.on( 'labelChange', function() {
			if ( !toolboxSelect.getLabel() ) {
				toolboxSelect.setLabel( i18n.toolbarTools );
			}
		} );
		
		var toolNamespace = '.spriteEdit.spriteEditTool.spriteEditTool';
		var tool;
		// Bind events for each tool's function
		toolboxSelect.getMenu().on( 'choose', function( item ) {
			tool = item.getData();
			$root.addClass( 'spriteedit-hidecontrols spriteedit-tool spriteedit-tool-' + tool );
			
			switch ( tool ) {
				case 'deprecate':
					$doc.on( 'click' + toolNamespace + 'Deprecate', '.spritedoc-name > code', function() {
						change( 'toggle deprecation', { $elem: $( this ) } );
					} );
				break;
			}
		} );
		// Clear tool when clicking a toolbar button, the toolbox itself, or pressing escape
		var clearTool = function( e ) {
			if ( !$root.hasClass( 'spriteedit-tool' ) ) {
				return;
			}
			toolboxSelect.getMenu().selectItem();
			$doc.off( '.spriteEditTool' );
			$root.removeClass( 'spriteedit-hidecontrols spriteedit-tool spriteedit-tool-' + tool );
			tool = null;
		};
		$toolbar.on( 'mouseup.spriteEdit', 'button', function() {
			clearTool();
		} );
		$toolbox.on( 'click.spriteEdit', function() {
			clearTool();
		} );
		$( document ).on( 'keydown.spriteEdit', function( e ) {
			// Esc
			if ( e.keyCode === 27 ) {
				clearTool();
			}
		} );
		
		// Drag and drop functionality
		if ( imageEditingSupported ) {
			var dragTimeout, dragEnded;
			var endDrag = function() {
				$root.removeClass( 'spriteedit-dragging' );
				
				clearTimeout( dragTimeout );
				clearTimeout( dropTimeout );
				dragEnded = false;
				dropEnded = false;
			};
			var isFile = function( e ) {
				var types = e.originalEvent.dataTransfer.types;
				return types.indexOf ?
					types.indexOf( 'Files' ) > -1 || types.indexOf( 'application/x-moz-file' ) > -1 :
					types.contains( 'Files' );
			};
			$win.on( 'dragenter.spriteEdit', function( e ) {
				if ( !isFile( e ) ) {
					return;
				}
				
				$root.addClass( 'spriteedit-dragging' );
			} ).on( 'dragover.spriteEdit', function() {
				clearTimeout( dragTimeout );
				dragEnded = false;
			} ).on( 'dragleave.spriteEdit', function( e ) {
				if ( !isFile( e ) ) {
					return;
				}
				
				clearTimeout( dragTimeout );
				dragEnded = true;
				dragTimeout = setTimeout( function() {
					if ( dragEnded ) {
						endDrag();
					}
				}, 100 );
			} );
			
			var dropTimeout, dropEnded;
			$doc.on( 'dragenter.spriteEdit', '.spritedoc-section', function( e ) {
				if ( !isFile( e ) ) {
					return;
				}
				
				$doc.find( '.spriteedit-droptarget' ).not( this ).removeClass( 'spriteedit-droptarget' );
				$( this ).addClass( 'spriteedit-droptarget' );
			} ).on( 'dragover.spriteEdit', '.spritedoc-section', function( e ) {
				clearTimeout( dropTimeout );
				dropEnded = false;
				
				e.originalEvent.dataTransfer.dropEffect = 'copy';
				
				e.preventDefault();
			} ).on( 'dragleave.spriteEdit', '.spritedoc-section', function( e ) {
				if ( !isFile( e ) ) {
					return;
				}
				
				clearTimeout( dropTimeout );
				dropEnded = true;
				dropTimeout = setTimeout( function() {
					if ( dropEnded ) {
						$doc.find( '.spriteedit-droptarget' ).removeClass( 'spriteedit-droptarget' );
						dropEnded = false;
					}
				}, 100 );
			} ).on( 'drop.spriteEdit', '.spritedoc-section', function( e ) {
				if ( !isFile( e ) ) {
					return;
				}
				
				$doc.find( '.spriteedit-droptarget' ).removeClass( 'spriteedit-droptarget' );
				
				insertSprites( e.originalEvent.dataTransfer.files, this );
				endDrag();
				e.preventDefault();
			} );
		}
		
		$( '#spriteedit-save' ).find( 'button' ).on( 'click.spriteEdit', function() {
			var $button = $( this );
			$button.focus().blur();
			
			// Prevent saving and notify if there are duplicate names, but only
			// if there's more than one. If there's only one, it's clearly been
			// incorrectly marked as a duplicate
			if ( $doc.find( '.spriteedit-dupe' ).length > 1 ) {
				mw.notify( i18n.dupeNamesNotice, { type: 'warn', autoHide: false } );
				
				return;
			}
			
			// Prevent saving if button already pressed and still processing
			if ( $button.hasClass( 'spriteedit-processing' ) ) {
				return;
			}
			
			$button.addClass( 'spriteedit-processing' );
			
			if ( $toolbar.hasClass( 'spriteedit-saveform-open' ) ) {
				// If we know changes weren't made (by performing a diff),
				// quit out, otherwise it's likely faster to just save and
				// assume changes were made, than wait for the diff to be ready.
				// It will just be a null edit if nothing was changed.
				if ( !names.modified && !sheet.modified ) {
					destroy( true );
					
					return;
				}
				
				saveChanges( $( '#spriteedit-summary' ).data( 'ooui-object' ).getValue() );
				
				return;
			}
			
			saveModules.done( function() {
				$toolbar.addClass( 'spriteedit-saveform-open' );
				$button
					.removeClass( 'spriteedit-processing' )
					// Prevent accidental double-click saving
					.css( 'pointer-events', 'none' );
				
				$button.parent().data( 'ooui-object' ).setIcon( 'check' );
				
				if ( !$toolbar.find( '#spriteedit-saveform' ).length ) {
					var summaryInput = new OO.ui.TextInputWidget( {
						id: 'spriteedit-summary',
						name: 'wpSummary',
						spellcheck: true,
						placeholder: i18n.toolbarSummaryPlaceholder,
					} );
					summaryInput.$element.data( 'ooui-object', summaryInput );
					mw.widgets.visibleByteLimit( summaryInput, mw.config.get( 'wgCommentByteLimit' ) );
					$( '<div>' )
						.attr( 'id', 'spriteedit-saveform' )
						.css( 'margin-right', $( '#spriteedit-save' )[0].getBoundingClientRect().width )
						.append(
							summaryInput.$element,
							makeButton( i18n.toolbarReviewChanges, { id: 'spriteedit-review-button' } )
					).appendTo( $toolbar );
				}
				
				var openedToolbarHeight = $toolbar[0].getBoundingClientRect().height;
				$toolbar
					.outerHeight( toolbarHeight )
					.redraw()
					.outerHeight( openedToolbarHeight )
					.transitionEnd( function() {
						$button.css( 'pointer-events', '' );
						$barContainer.height( openedToolbarHeight );
						$( '#spriteedit-summary' ).data( 'ooui-object' ).focus();
						
						// Do this after the transition so there is no stutter
						names.getDiff();
						sheet.stash();
					} );
			} );
		} );
		
		$doc.on( 'keydown.spriteEdit', '#spriteedit-summary', function( e ) {
			// Anything but Enter
			if ( e.which !== 13 ) {
				return;
			}
			
			$( this ).blur();
			$( '#spriteedit-save' ).find( 'button' ).click();
			e.preventDefault();
		} );
		
		$doc.on( 'click.spriteEdit', '#spriteedit-review-button > button', function() {
			var $button = $( this );
			if ( $button.hasClass( 'spriteedit-processing' ) ) {
				return;
			}
			$button.focus().blur().addClass( 'spriteedit-processing' );
			
			var sheetUrl;
			var changesPanel = panels.changes || panel( 'changes', {
				title: i18n.panelChangesTitle,
				content: [
					$( '<div>' ).addClass( 'spriteedit-sheet-changes' ),
					$( '<div>' ).addClass( 'spriteedit-id-changes' ),
				],
				onClose: function() {
					URL.revokeObjectURL( sheetUrl );
				},
			} );
			var $changesText = changesPanel.$text;
			
			$.when( names.getDiff(), sheet.getData() ).then( function( diff, sheetBlob ) {
				var sheetChanges;
				if ( !diff && !sheetBlob ) {
					$changesText.text( i18n.panelChangesNoDiffFromCur );
				} else {
					if ( sheetBlob ) {
						sheetChanges = $.Deferred();
						var newSpritesheet = new Image();
						newSpritesheet.onload = function() {
							newSpritesheet.onload = null;
							$changesText.find( '.spriteedit-sheet-changes' ).append(
								$( '<div>' ).text( i18n.panelChangesSheetTitle ),
								$( '<div>' ).addClass( 'spriteedit-sheet-diff' ).append(
									$( '<span>' ).addClass( 'spriteedit-old-sheet' ).append( spritesheet ),
									$( '<span>' ).addClass( 'spriteedit-new-sheet' ).append( newSpritesheet )
								)
							);
							
							sheetChanges.resolve();
						};
						sheetUrl = URL.createObjectURL( sheetBlob );
						newSpritesheet.src = sheetUrl;
					}
					if ( diff ) {
						$changesText.find( '.spriteedit-id-changes' ).append(
							$( '<div>' ).text( i18n.panelChangesIdTitle ),
							$( '<div>' ).append( diff )
						);
					}
				}
				
				$.when( sheetChanges ).done( function() {
					$button.removeClass( 'spriteedit-processing' );
					changesPanel.show();
				} );
			}, function( code, data ) {
				$button.removeClass( 'spriteedit-processing' );
				handleError( code, data );
			} );
		} );
		
		
		/* Edit control events */
		$doc.on( 'click.spriteEdit', '.spriteedit-add-name > button', function() {
			var $names = $( this ).closest( '.spritedoc-box' ).find( '.spritedoc-name' );
			var $item = $( '<li>' ).addClass( 'spritedoc-name' );
			var $name = $( '<code>' ).addClass( 'spriteedit-new' )
				.attr( 'data-placeholder', i18n.namePlaceholder );
			addControls( $item.append( $name ), 'name' );
			
			change( 'insert', {
				$elem: $item,
				index: $names.length - 1,
				$parent: $names.first().parent(),
			}, true );
			
			$name.focus();
		} );
		
		$doc.on( 'focus.spriteEdit', '[contenteditable]', function() {
			var $this = $( this );
			if ( $root.hasClass( 'spriteedit-hidecontrols' ) ) {
				$this.blur();
				return;
			}
			
			var text = $this.text();
			$this.attr( 'data-original-text', text );
			
			if ( !changes.length ) {
				$this.one( 'keypress.spriteEdit', function() {
					$.each( [
						'#spriteedit-save',
						'#spriteedit-summary',
						'#spriteedit-review-button',
					], function() {
						if ( $( this ).length ) {
							$( this ).data( 'ooui-object' ).setDisabled( false );
						}
					} );
				} );
			}
		} );
		$doc.on( 'blur.spriteEdit', '[contenteditable]', function() {
			if ( $root.hasClass( 'spriteedit-hidecontrols' ) ) {
				return;
			}
			
			var $this = $( this );
			var text = $this.text();
			var trimmedText = text.trim().replace( /  +/g, ' ' );
			var origText = $this.attr( 'data-original-text' );
			$this.removeAttr( 'data-original-text' ).off( 'keypress.spriteEdit' );
			
			// Can't make a change if we don't know what the original text was
			// This can happen when Edge calls the blur event on elements that
			// that aren't focused, which it does when moving the highlight
			// when using the find in browser feature
			if ( origText === undefined ) {
				return;
			}
			
			if ( text !== trimmedText ) {
				text = trimmedText;
				$this.text( text );
			}
			
			if ( text === '' ) {
				var $remove, $parent;
				if ( $this.hasClass( 'mw-headline' ) ) {
					if ( $doc.find( '.spritedoc-section' ).length === 1 ) {
						text = i18n.sectionUncategorized;
						$this.text( text );
					} else {
						$remove = $this.closest( '.spritedoc-section' );
						$parent = $doc;
					}
				} else {
					var $names = $this.closest( '.spritedoc-names' );
					if ( $names.find( '.spritedoc-name' ).length > 1 ) {
						$remove = $this.parent();
						$parent = $names;
					} else {
						$remove = $names.parent();
						$parent = $remove.parent();
					}
				}
				
				if ( $remove ) {
					if ( $this.hasClass( 'spriteedit-new' ) ) {
						// Just pretend it never happened
						$remove.remove();
						change.discard();
						return;
					}
					
					// Restore original text before deleting so undo works
					$this.text( origText );
					
					change( 'delete', {
						$elem: $remove,
						index: $remove.index() - 1,
						$parent: $parent,
					} );
					return;
				}
			}
			
			if ( text === origText ) {
				if ( !changes.length ) {
					$.each( [
						'#spriteedit-save',
						'#spriteedit-summary',
						'#spriteedit-review-button',
					], function() {
						if ( $( this ).length ) {
							$( this ).data( 'ooui-object' ).setDisabled( true );
						}
					} );
				}
				
				return;
			}
			
			if ( usedNames[text] ) {
				// Wait until after edit change, as it may move the element
				// which the tooltip should be anchored to
				requestAnimationFrame( function() {
					tooltip( $this, i18n.dupeName );
				} );
			}
			
			change( 'edit', {
				$elem: $this,
				oldText: origText,
				text: text,
			} );
			
			if ( $this.hasClass( 'spriteedit-new' ) ) {
				$this.removeClass( 'spriteedit-new' ).removeAttr( 'data-placeholder' );
			}
		} );
		$doc.on( 'keypress.spriteEdit', '[contenteditable]', function( e ) {
			// Enter key
			if ( e.which === 13 ) {
				$( this ).blur();
				e.preventDefault();
			}
		} );
		// Make pastes plain text
		$doc.on( 'paste.spriteEdit', '[contenteditable]', function( e ) {
			var text = ( e.originalEvent.clipboardData || window.clipboardData ).getData( 'Text' );
			text = text.replace( /\n/g, ' ' ).trim();
			window.document.execCommand( 'insertText', false, text );
			
			e.preventDefault();
		} );
		var isText = function( e ) {
			var types = e.originalEvent.dataTransfer.types;
			return types.indexOf ? types.indexOf( 'text/plain' ) > -1 : types.contains( 'text/plain' );
		};
		$doc.on( 'dragenter.spriteEdit dragover.spriteEdit', '[contenteditable]', function( e ) {
			if ( !isText( e ) ) {
				return;
			}
			
			e.preventDefault();
		} );
		$doc.on( 'drop.spriteEdit', function( e ) {
			// Prevent default drop on anything but contenteditable
			// This prevents browsers dropping content into a nearby contenteditable, which doesn't
			// trigger any kind of drop event on the contenteditable (because you didn't actually
			// drop anything directly on it), thus making it impossible to handle it properly.
			if ( !$( e.target ).is( '[contenteditable]' ) ) {
				e.preventDefault();
				return;
			}
			
			if ( !isText( e ) ) {
				return;
			}
			
			var $this = $( e.target );
			$this.focus();
			setTimeout( function() {
				var text = $this.text().replace( /\n/g, ' ' );
				if ( $this.html() !== $( '<i>' ).text( text ).html() ) {
					$this.text( text );
				}
				
				$this.blur();
			}, 0 );
		} );
		
		if ( imageEditingSupported ) {
			$doc.on( 'click.spriteEdit', '.spritedoc-image', function() {
				var $parent = $( this );
				
				tooltip( $parent, [
					makeButton( i18n.ctxReplaceImage, {
						type: [ 'progressive', 'primary' ],
						icon: 'imageGallery',
						action: function() {
							$( '<input type="file">' )
								.attr( 'accept', 'image/*' )
								.one( 'change', function() {
									tooltip.hide();
									
									scaleImage( this.files[0] ).done( function( $img ) {
										$img.addClass( 'spriteedit-replacing-image' );
										change( 'replace image', {
											$elem: $img,
											$parent: $parent,
											$oldImg: $parent.find( 'img' ),
										} );
									} );
								} ).click();
						},
					} ),
					makeButton( i18n.ctxDownloadImage, {
						icon: 'download',
						action: function() {
							var $button = $( this );
							var data;
							var $box = $parent.parent();
							// Already an image, just pass on the object URL
							if ( $box.hasClass( 'spriteedit-new' ) ) {
								data = $parent.find( '> img' ).attr( 'src' );
							} else {
								$button.addClass( 'spriteedit-processing' );
								data = sheetRequest.then( function() {
									// Individual sprite needs to be extracted from the spritesheet
									var width = settings.imageWidth;
									var height = settings.imageHeight;
									var posPx = posToPx( $box.data( 'pos' ) );
									var imgCanv = getCanvas( 'image' );
									
									imgCanv.clear();
									imgCanv.ctx.drawImage( spritesheet,
										posPx.left, posPx.top, width, height,
										0, 0, width, height
									);
									
									var d = $.Deferred();
									imgCanv.canvas.toBlob( d.resolve );
									
									return d.promise();
								} );
							}
							
							$.when( data ).then( function( blob ) {
								$button.removeClass( 'spriteedit-processing' );
								var name = $box.data( 'sort-key' ) + '.png';
								// IE10+: (has Blob, but not a[download])
								if ( navigator.msSaveBlob ) {
									return navigator.msSaveBlob( blob, name );
								}
								
								var dlLink = $( '<a>' ).attr( {
									href: URL.createObjectURL( blob ),
									download: name,
								} ).appendTo( 'body' );
								dlLink[0].click();
								dlLink.remove();
							} );
						},
					} ),
					makeButton( i18n.ctxDeleteImage, {
						type: [ 'destructive', 'primary' ],
						icon: 'trash',
						action: function() {
							tooltip.hide( function() {
								var $box = $parent.parent();
								change( 'delete', {
									$elem: $box,
									$parent: $box.parent(),
									index: $box.index() - 1,
								} );
							} );
						},
					} ),
				], { horizontal: true, class: 'spriteedit-tooltip-controls' } );
			} );
		}
		
		
		/* Window events */
		$win.on( 'resize.spriteEdit', $.throttle( 32, function() {
			var $conflict = $( '#spriteedit-dialog-conflict' );
			if ( $conflict.length && $conflict.is( ':visible' ) ) {
				var $textarea = $conflict.find( 'textarea' );
				$textarea.css( 'max-height', (
					$conflict.find( '.spriteedit-dialog-text' ).height() - $textarea.parent()[0].offsetTop
				) + 'px' );
			}
		} ) );
		
		var updateMouse = function( e ) {
			mouse.moved = true;
			mouse.x = e.clientX;
			mouse.y = e.clientY;
		};
		// Only update mouse while sorting, dragging, or while over a handle
		$doc.on( 'mouseenter.spriteEdit mousemove.spriteEdit', '.spriteedit-handle', function( e ) {
			if ( !sorting ) {
				updateMouse( e );
			}
		} );
		$( document ).on( 'mousemove.spriteEdit', function( e ) {
			if ( sorting ) {
				updateMouse( e );
			}
		} ).on( 'dragover.spriteEdit', function( e ) {
			updateMouse( e.originalEvent );
		} );
		
		// Disable smooth scrolling once scrolling ends so it does not interfere with user scrolling.
		$win.on( 'scroll.spriteEdit', $.debounce( 250, function() {
			$root.removeClass( 'spriteedit-smoothscroll' );
		} ) );
	};
	
	
	/** Editor functions **/
	/**
	 * Closes the editor
	 *
	 * If there are no changes, destroys the editor immediately.
	 * If there are changes, opens a panel asking for confirmation first.
	 *
	 * "state" is what triggered the editor to close (e.g. from history navigation)
	 */
	var close = function( state ) {
		if ( !$root.hasClass( 'spriteedit-enabled' ) || $( '#spriteedit-save' ).data( 'ooui-object' ).isDisabled() ) {
			destroy( true, state === 'history' );
		} else {
			var discardPanel = panels.discard || panel( 'discard', {
				title: i18n.panelDiscardTitle,
				content: $( '<p>' ).text( i18n.panelDiscardText ),
				actions: { right: [
					{ text: i18n.panelDiscardContinue, config: {
						action: function() {
							discardPanel.hide();
						}
					} },
					{ text: i18n.panelDiscardDiscard, config: {
						type: [ 'destructive', 'primary' ],
						icon: 'trash',
						action: function() {
							discardPanel.hide( function() {
								destroy( true, state === 'history' );
							} );
						}
					} },
				] },
			} );
			discardPanel.show();
		}
	};
	
	/**
	 * Construct a wiki diff from an API request
	 *
	 * "data" is the API response.
	 * Returns a jQuery object containing the diff table, an error message
	 * if something went wrong, or nothing if the diff is empty.
	 */
	var makeDiff = function( data ) {
		if ( !data || !data.query || !data.query.pages ) {
			return i18n.genericError;
		}
		
		var page = data.query.pages[0];
		if ( !page ) {
			return i18n.diffErrorMissingPage;
		}
		var diff = page.revisions[0].diff.body;
		if ( diff === undefined ) {
			return i18n.diffError;
		}
		
		if ( !diff.length ) {
			return;
		}
		
		return $( '<table>' ).addClass( 'diff' ).append(
			$( '<col>' ).addClass( 'diff-marker' ),
			$( '<col>' ).addClass( 'diff-content' ),
			$( '<col>' ).addClass( 'diff-marker' ),
			$( '<col>' ).addClass( 'diff-content' ),
			$( '<tbody>' ).html( diff )
		);
	};
	
	var names = ( function() {
		var promises = {};
		/**
		 * Create a deferred object for the request type
		 *
		 * If a deferred of this type already exists, or names is not
		 * modified, returns false.
		 *
		 * Otherwise, adds the deferred's promise to the list, and returns
		 * the deferred.
		 */
		var makeDeferred = function( type ) {
			if ( promises[type] ) {
				return false;
			}
			
			var deferred = $.Deferred();
			promises[type] = deferred.promise();
			if ( !names.modified ) {
				deferred.resolve();
				return false;
			}
			
			return deferred;
		};
		
		return {
			/**
			 * Invalidates all promises and sets the modified state, if specified
			 */
			invalidate: function( modified ) {
				promises = {};
				if ( modified !== undefined ) {
					names.modified = modified;
				}
			},
			/**
			 * Returns the names object
			 */
			getObject: function() {
				var deferred = makeDeferred( 'object' );
				if ( !deferred ) {
					return promises.object;
				}
				
				sheet.updatePositions().done( function() {
					var $sections = $doc.find( '.spritedoc-section' );
					var sectionIds = [];
					var getSectionId = ( function() {
						var id = 0;
						return function() {
							if ( id < sectionIds.length ) {
								sectionIds.sort( function( a, b ) {
									return a - b;
								} );
								
								$.each( sectionIds, function( _, v ) {
									if ( v - id > 1 ) {
										return false;
									}
									id = v;
								} );
							}
							
							id++;
							
							sectionIds.push( id );
							return id;
						};
					}() );
					
					$sections.each( function() {
						var id = $( this ).data( 'section-id' );
						if ( id !== undefined ) {
							sectionIds.push( id );
						}
					} );
					
					var headingRows = [];
					var ids = [];
					$sections.each( function() {
						var $section = $( this );
						var sectionId = $section.data( 'section-id' ) || getSectionId();
						var sectionName = $section.find( '.mw-headline' ).text().replace( /\s+/g, ' ' );
						var row = {};
						row[i18n.luaKeyName] = sectionName;
						row[i18n.luaKeyId] = sectionId;
						headingRows.push( row );
						
						$section.find( '.spritedoc-box' ).each( function() {
							var $box = $( this );
							var pos = $box.data( 'pos' );
							if ( pos === undefined ) {
								pos = $box.data( 'new-pos' );
							}
							$box.find( '.spritedoc-name' ).find( 'code' ).each( function() {
								var $this = $( this );
								var id = $this.text().replace( /\s+/g, ' ' );
								ids.push( {
									sortKey: id.toLowerCase(),
									id: id,
									pos: pos,
									section: sectionId,
									deprecated: $this.hasClass( 'spritedoc-deprecated' ),
								} );
							} );
						} );
					} );
					ids.sort( function( a, b ) {
						return a.sortKey > b.sortKey ? 1 : -1;
					} );
					
					var idsRows = {};
					$.each( ids, function() {
						var idData = {};
						idData[i18n.luaKeyPos] = this.pos;
						idData[i18n.luaKeySection] = this.section;
						if ( this.deprecated ) {
							idData[i18n.luaKeyDeprecated] = this.deprecated;
						}
						idsRows[this.id] = idData;
					} );
					
					// Sort the settings object so it doesn't change order
					// everytime due to lua not supporting ordered tables
					var sortedSettings = {};
					Object.keys( spriteSettings ).sort().forEach( function( key ) {
						sortedSettings[key] = spriteSettings[key];
					} );
					
					var table = {};
					table[i18n.luaKeySettings] = sortedSettings;
					table[i18n.luaKeySections] = headingRows;
					table[i18n.luaKeyIds] = idsRows;
					
					deferred.resolve( table );
				} );
				
				return promises.object;
			},
			/**
			 * Returns the names Lua table
			 * 
			 * Updates the URL timestamp if URLs are being used and the sheet
			 * has been modified. As such, this always generates a new table if
			 * there isn't one already pending.
			 */
			getTable: function() {
				if ( promises.table && promises.table.state() === 'resolved' ) {
					promises.table = null;
				}
				var deferred = makeDeferred( 'table' );
				if ( !deferred ) {
					return promises.table;
				}
				
				names.getObject().then( function( obj ) {
					if ( spriteSettings[i18n.luaKeySettingsUrl] ) {
						var url = $doc.data( 'original-url' ).split( '?' );
						// Update the version parameter if the sheet was modified
						// or if there's no version parameter
						if ( sheet.modified || !url[1] ) {
							url[1] = 'version=' + Date.now();
							$doc.data( 'url', url.join( '?' ) );
						}
						
						obj[i18n.luaKeySettings][i18n.luaKeySettingsUrl] =
							luaTable.func( $doc.data( 'urlfunc' ).replace( /\$1/, url[1] ) );
					}
					deferred.resolve( 'return ' + luaTable.create( obj ) );
				} );
				
				return promises.table;
			},
			/**
			 * Sets the names Lua table to the specified content
			 */
			setTable: function( table ) {
				names.invalidate( true );
				promises.table = $.Deferred().resolve( table ).promise();
			},
			/**
			 * Requests a diff of the names Lua table against
			 * the current revisions content
			 */
			getDiff: function() {
				var deferred = makeDeferred( 'diff' );
				if ( !deferred ) {
					return promises.diff;
				}
				
				names.getTable().then( function( table ) {
					return retryableRequest( function() {
						return new mw.Api( {
							ajax: { contentType: 'multipart/form-data' },
						} ).post( {
							action: 'query',
							prop: 'revisions',
							titles: dataPage,
							rvprop: '',
							rvdifftotext: table,
							rvlimit: 1,
							formatversion: 2,
						} );
					} );
				} ).then( function( data ) {
					var diff = makeDiff( data );
					names.modified = !!diff;
					deferred.resolve( diff );
				}, function( code, data ) {
					deferred.reject( code, data );
					promises.diff = null;
				} );
				
				return promises.diff;
			},
			/**
			 * Saves the names table
			 *
			 * "summary" is the edit summary for the edit.
			 */
			save: function( summary, conflict ) {
				var deferred = makeDeferred( 'save' );
				if ( !deferred ) {
					return promises.save;
				}
				
				names.getTable().then( function( table ) {
					// TODO: Check if edit actually succeeded on failure or null edit
					return retryableRequest( function() {
						return new mw.Api( {
							ajax: { contentType: 'multipart/form-data' },
						} ).postWithToken( 'csrf', {
							action: 'edit',
							nocreate: true,
							title: dataPage,
							text: table,
							// If there's already been an edit conflict, just allow the edit
							// through conflict-free, as it's already annoying enough to
							// deal with one conflict.
							basetimestamp: !conflict ? $doc.data( 'datatimestamp' ) : undefined,
							summary: summary,
							tags: canTag ? 'spriteeditor' : undefined,
							formatversion: 2,
						} );
					} );
				} ).then( deferred.resolve, function( code, data ) {
					deferred.reject( code, data );
					promises.save = null;
				} );
				
				return promises.save;
			},
		};
	}() );
	
	var sheet = ( function() {
		var promises = {};
		/**
		 * Create a deferred object for the request type
		 *
		 * If a deferred of this type already exists, or the sheet is not
		 * modified, returns false.
		 *
		 * Otherwise, adds the deferred's promise to the list, and returns
		 * the deferred.
		 */
		var makeDeferred = function( type ) {
			if ( promises[type] ) {
				return false;
			}
			
			var deferred = $.Deferred();
			promises[type] = deferred.promise();
			if ( !sheet.modified ) {
				deferred.resolve();
				return false;
			}
			
			return deferred;
		};
		
		return {
			/**
			 * Invalidates all promises and sets the modified state, if specified
			 */
			invalidate: function( modified ) {
				promises = {};
				if ( modified !== undefined ) {
					sheet.modified = modified;
				}
				if ( spriteSettings[i18n.luaKeySettingsUrl] ) {
					names.invalidate( true );
				}
			},
			/**
			 * Invalidates just the stash's promise
			 */
			invalidateStash: function() {
				promises.stash = null;
			},
			/**
			 * Updates the potential position information
			 * for any new images, and resizes the canvas
			 */
			updatePositions: function() {
				var deferred = makeDeferred( 'pos' );
				if ( !deferred ) {
					return promises.pos;
				}
				
				sheetRequest.then( function() {
					var lastPos = spriteSettings[i18n.luaKeySettingsPos] || 1;
					var usedPos = {};
					usedPos[lastPos] = true;
					
					var newImgs = [];
					$doc.find( '.spritedoc-box' ).each( function() {
						var $box = $( this );
						var pos = $box.data( 'pos' );
						if ( pos === undefined ) {
							newImgs.push( $box );
						} else {
							usedPos[pos] = true;
							if ( pos > lastPos ) {
								lastPos = pos;
							}
						}
					} );
					
					if ( newImgs.length ) {
						var unusedPos = [];
						for ( var i = 1; i <= lastPos; i++ ) {
							if ( !usedPos[i] ) {
								unusedPos.push( i );
							}
						}
						
						newImgs.forEach( function( $box ) {
							$box.data( 'new-pos', unusedPos.length ? unusedPos.shift() : ++lastPos );
						} );
						
						if ( !unusedPos.length ) {
							var imagesPerRow = ( settings.sheetWidth + settings.spacing ) / ( settings.imageWidth + settings.spacing );
							settings.sheetHeight = Math.ceil( lastPos / imagesPerRow ) * ( settings.imageHeight + settings.spacing ) - settings.spacing;
							getCanvas( 'sheet' ).resize();
						}
					}
					
					deferred.resolve();
				} );
				
				return promises.pos;
			},
			/**
			 * Draws the new sheet and returns it as a data URL
			 */
			getData: function() {
				var deferred = makeDeferred( 'sheet' );
				if ( !deferred ) {
					return promises.sheet;
				}
				
				$.when( sheet.updatePositions(), $.when.apply( null, loadingImages ) ).then( function() {
					var sheetCanvas = getCanvas( 'sheet' );
					sheetCanvas.clear();
					sheetCanvas.ctx.drawImage( spritesheet, 0, 0 );
					
					// TODO: Clear deleted images so when new images fill their place
					// the original images don't show up while the old sheet is cached
					
					// Insert new images into sheet
					$doc.find( '.spriteedit-new' ).each( function() {
						var $box = $( this );
						var img = $box.find( 'img' )[0];
						var pos = $box.data( 'pos' );
						if ( pos === undefined ) {
							pos = $box.data( 'new-pos' );
						}
						
						var posPx = posToPx( pos );
						// Clear previous image including spacing, just in-case
						// someone manually uploaded an image overlapping the spacing
						sheetCanvas.ctx.clearRect(
							posPx.left - settings.spacing,
							posPx.top - settings.spacing,
							settings.imageWidth + settings.spacing * 2,
							settings.imageHeight + settings.spacing * 2
						);
						
						sheetCanvas.ctx.drawImage( img, posPx.left, posPx.top );
					} );
					sheetCanvas.canvas.toBlob( deferred.resolve );
					
					loadingImages = [];
				}, function() {
					deferred.reject();
					promises.sheet = null;
				} );
				
				return promises.sheet;
			},
			/**
			 * Stashes the sheet to the server
			 */
			stash: function() {
				var deferred = makeDeferred( 'stash' );
				if ( !deferred ) {
					return promises.stash;
				}
				
				sheet.getData().then( function( blob ) {
					return retryableRequest( function() {
						return new mw.Api( {
							ajax: { contentType: 'multipart/form-data' },
						} ).postWithToken( 'csrf', {
							action: 'upload',
							stash: true,
							ignorewarnings: true,
							filename: $doc.data( 'spritesheet' ),
							file: blob,
							formatversion: 2,
						} );
					} );
				} ).then( function( data ) {
					deferred.resolve( data.upload.filekey );
				}, function( code, data ) {
					deferred.reject( code, data );
					promises.stash = null;
				} );
				
				return promises.stash;
			},
			/**
			 * Commits the stash to the server
			 *
			 * If the request fails, will re-stash the image and commit it, once.
			 * 
			 * "summary" is the edit summary for the upload.
			 * "retried" is a boolean stating whether the upload has
			 * already been retried.
			 */
			save: function( summary, retried ) {
				var deferred = makeDeferred( 'save' );
				if ( !deferred ) {
					return promises.save;
				}
				
				sheet.stash().then( function( key ) {
					// TODO: Check if upload actually succeeded on failure
					return retryableRequest( function() {
						return new mw.Api().postWithToken( 'csrf', {
							action: 'upload',
							ignorewarnings: true,
							comment: summary,
							filename: $doc.data( 'spritesheet' ),
							filekey: key,
							tags: canTag ? 'spriteeditor' : undefined,
							formatversion: 2,
						} );
					} );
				} ).then( deferred.resolve, function( code, data ) {
					promises.save = null;
					if ( retried ) {
						if ( code === 'stashedfilenotfound' ) {
							sheet.invalidateStash();
						}
						deferred.reject( code, data );
					} else {
						sheet.invalidateStash();
						sheet.save( summary, true );
					}
				} );
				
				return promises.save;
			},
		};
	}() );
	
	/**
	 * Performs a save of the ID changes and/or spritesheet changes
	 *
	 * If there are changes and everything works out, the editor closes, the current
	 * page is purged, the timestamp is updated (for another edit in this session),
	 * and a success message is displayed.
	 * If there aren't changes, the editor will silently close, as if a null edit was performed
	 * (which if the diff wasn't ready in time, there will have been).
	 * In the event of an edit conflict, a manual resolution panel will be displayed.
	 * Otherwise, whatever error occurred will be displayed.
	 *
	 * "summary" is the text from the summary field.
	 * "refresh" is a boolean, which when true will cause the sprite documentation
	 * to be reparsed after saving (e.g. in the event of an edit conflict).
	 * "conflict" is a boolean which specifies if this is saving an edit conflict or not
	 */
	var saveChanges = function( summary, refresh, conflict ) {
		// No more editing
		$root.addClass( 'spriteedit-hidecontrols' );
		
		// Have to refresh if a new image is added to get the sprite HTML
		if ( refresh !== false ) {
			refresh = !!$( '.spriteedit-new-image' ).length;
		}
		
		sheet.save( summary ).then( function() {
			return names.save( summary, conflict );
		} ).then( function( data ) {
			if ( sheet.modified ) {
				if ( spriteSettings[i18n.luaKeySettingsUrl] ) {
					var url = $doc.data( 'url' );
					overwriteSpritesheet( url );
					$doc.data( 'original-url', url );
				} else {
					sheet.getData().then( function( blob ) {
						overwriteSpritesheet( URL.createObjectURL( blob ) );
					} );
				}
			}
			// Prevent disabling, otherwise we'd end up with the old
			// spritesheet if the editor was restarted and closed
			overwriteSpritesheet.style = null;
			
			// Null edit, nothing to do here
			if ( !data || data.edit.nochange ) {
				return;
			}
			
			$doc.data( 'datatimestamp', fixTimestamp( data.edit.newtimestamp ) );
			
			// Purge this page so the changes show up immediately
			retryableRequest( function() {
				return new mw.Api().post( {
					action: 'purge',
					pageids: mw.config.get( 'wgArticleId' ),
				} );
			} );
		} ).then( function() {
			var newContent;
			if ( refresh ) {
				newContent = retryableRequest( function() {
					return parseApi.get( {
						title: mw.config.get( 'wgPageName' ),
						text: $( '<i>' ).html(
							$.parseHTML( $doc.attr( 'data-refreshtext' ) )
						).html(),
					} );
				} );
			}
			
			$.when( newContent ).done( function( data ) {
				if ( refresh ) {
					$doc.replaceWith( data.parse.text );
				}
			} ).always( function() {
				destroy();
				
				mw.hook( 'postEdit' ).fire( { message: i18n.changesSavedNotice } );
			} );
		}, handleSaveError );
	};
	
	/**
	 * Handles special case errors that occur when saving (AKA, handleError with edit conflicts)
	 *
	 * If there's an edit conflict, this will be display a barely human-usable edit conflict
	 * panel, where the user may manually merge the raw lua table changes. Sprite edit conflict
	 * merging is not supported (because image uploading doesn't implement edit conflicts, for one).
	 * Otherwise, passes it on to handleError.
	 *
	 * "code" and "data" are the standard variables returned by a mw.Api promise rejection.
	 */
	var handleSaveError = function( code, data ) {
		// Allow editing again
		$root.removeClass( 'spriteedit-hidecontrols' );
		$( '#spriteedit-save' ).find( 'button' ).removeClass( 'spriteedit-processing' );
		
		if ( code !== 'editconflict' ) {
			handleError( code, data );
			return;
		}
		
		var conflictPanel = panels.conflict || panel( 'conflict', {
			title: i18n.panelConflictTitle,
			content: $( '<p>' ).text( i18n.panelConflictText ),
			actions: {
				left: { text: i18n.panelConflictReview, config: {
					id: 'review-conflict-changes',
					action: function() {
						var $button = $( this );
						if ( $button.hasClass( 'spriteedit-processing' ) ) {
							return;
						}
						$button.blur().addClass( 'spriteedit-processing' );
						
						var changesPanel = panels.ecchanges || panel( 'ecchanges', {
							title: i18n.panelEcchangesTitle,
							content: $( '<div>' ).addClass( 'spriteedit-id-changes' ),
							actions: { right: { text: i18n.panelEcchangesReturn, config: {
								id: 'spriteedit-return-edit',
								type: [ 'progressive', 'primary' ],
								action: function() {
									conflictPanel.show();
								},
							} } },
							onClose: function() {
								names.invalidate( true );
							},
						} );
						
						names.setTable( $( '#spriteedit-ec-curText' ).data( 'ooui-object' ).getValue() );
						names.getDiff().then( function( diff ) {
							changesPanel.clean();
							
							if ( !diff ) {
								diff = i18n.panelChangesNoDiffFromCur;
							}
							changesPanel.$text.find( '.spriteedit-id-changes' ).append( diff );
							changesPanel.show();
						}, function( code, data ) {
							$button.removeClass( 'spriteedit-processing' );
							handleError( code, data );
						} );
					}
				} },
				right: { text: i18n.panelConflictSave, config: {
					id: 'save-conflict',
					type: [ 'progressive', 'primary' ],
					action: function() {
						var $button = $( this );
						if ( $button.hasClass( 'spriteedit-processing' ) ) {
							return;
						}
						$button.blur().addClass( 'spriteedit-processing' );
						
						names.setTable( $( '#spriteedit-ec-curText' ).data( 'ooui-object' ).getValue() );
						saveChanges( $( '#spriteedit-summary' ).data( 'ooui-object' ).getValue(), true, true );
					},
				} },
			},
			onShow: function() {
				this.$actions.find( 'button' ).removeClass( 'spriteedit-processing' );
				
				var $textarea = this.$text.find( 'textarea' );
				$textarea.css( 'max-height', ( this.$text.height() - $textarea.parent()[0].offsetTop ) + 'px' );
			},
			onClose: function() {
				names.invalidate( true );
			},
		} );
		
		$.when(
			names.getTable(),
			names.getDiff(),
			retryableRequest( function() {
				return revisionsApi.get( { titles: dataPage } );
			} )
		).then( function( table, diff, curTextData ) {
			// TODO: Change to MultilineTextInputWidget on MW 1.30
			var curEditbox = new OO.ui.TextInputWidget( {
				id: 'spriteedit-ec-curText',
				multiline: true,
				value: curTextData[0].query.pages[0].revisions[0].content,
			} );
			curEditbox.$element.data( 'ooui-object', curEditbox );
			var oldEditbox = new OO.ui.TextInputWidget( {
				id: 'spriteedit-ec-oldText',
				multiline: true,
				readOnly: true,
				value: table,
			} );
			oldEditbox.$element.data( 'ooui-object', oldEditbox );
			
			var $curText = $( '<div>' ).append(
				$( '<p>' ).text( i18n.panelConflictCurText ),
				curEditbox.$element
			);
			var $oldText = $( '<div>' ).append(
				$( '<p>' ).text( i18n.panelConflictYourText ),
				oldEditbox.$element
			);
			
			conflictPanel.$text.append(
				$( '<div>' ).addClass( 'spriteedit-ec-editboxes' ).append(
					$curText,
					$oldText
				),
				diff
			);
			
			conflictPanel.show();
		}, function( code, data ) {
			$( '#spriteedit-save' ).find( 'button' ).removeClass( 'spriteedit-processing' );
			handleError( code, data );
		} );
	};
	
	/**
	 * Converts a position to pixel co-ordinates on the sheet
	 */
	var posToPx = function( pos ) {
		settings.imagesPerRow = settings.imagesPerRow ||
			( settings.sheetWidth + settings.spacing ) / ( settings.imageWidth + settings.spacing );
		pos -= 1;
		return {
			left: pos % settings.imagesPerRow * ( settings.imageWidth + settings.spacing ),
			top: Math.floor( pos / settings.imagesPerRow ) * ( settings.imageHeight + settings.spacing ),
		};
	};
	
	/**
	 * Inserts new images into the editor
	 *
	 * A box is created for the new image, with the name set to the file's name (minus extension).
	 * The box is inserted into the nearest section and then sorted to the correct location.
	 * Any file that doesn't match the image/* mime type is ignored.
	 *
	 * "files" is a "FileList" (or an array of "File" objects) from a file input or drop.
	 * "section" is an Element which is the section to place the sprites in. Defaults to
	 * the nearestSection()
	 */
	var insertSprites = function( files, section ) {
		var $parent = $( section || nearestSection() ).find( '.spritedoc-boxes' );
		$.each( files, function() {
			if ( !this.type.match( /^image\// ) ) {
				return;
			}
			
			var $newBox = $boxTemplate.clone();
			$newBox.find( 'code' ).text( this.name.trim().replace( /\.[^\.]+$/, '' ).replace( /_/g, ' ' ) );
			scaleImage( this ).done( function( $img ) {
				$img.addClass( 'spriteedit-new-image' );
				$newBox.find( '.spritedoc-image' ).html( $img );
			} );
			
			var name = $newBox.find( '.spritedoc-name' ).text();
			$newBox.attr( 'data-sort-key', name );
			
			var index = getAlphaIndex( name, undefined, $parent );
			change( 'insert', {
				$elem: $newBox,
				index: index - 1,
				$parent: $parent,
			} );
		} );
	};
	
	/**
	 * Constructs (or retrieves) a canvas for a particular purpose
	 *
	 * Either for image scaling, or spritesheet creation.
	 *
	 * Returns an object containing the canvas, its context,
	 * and some convenience functions for clearing the canvas
	 * and for updating its dimensions.
	 *
	 * "type" is the canvas type ("image" or "sheet").
	 */
	var getCanvas = ( function() {
		var canvases = {};
		return function( type ) {
			if ( canvases[type] ) {
				return canvases[type];
			}
			
			var $canvas = $( '<canvas>' ).attr( {
				width: settings[type + 'Width'],
				height: settings[type + 'Height'],
			} ).appendTo( $doc );
			var canvas = $canvas[0];
			var ctx = canvas.getContext( '2d' );
			
			var funcs = {
				canvas: canvas,
				ctx: ctx,
				resize: function() {
					$canvas.attr( {
						width: settings[type + 'Width'],
						height: settings[type + 'Height'],
					} );
				},
				clear: function() {
					ctx.clearRect( 0, 0, canvas.width, canvas.height );
				},
			};
			canvases[type] = funcs;
			return funcs;
		};
	}() );
	
	/**
	 * Scales an image down to the correct size (if necessary)
	 *
	 * Performs only a basic low quality scaling, ignoring the original image's
	 * aspect ratio.
	 * Also performs a "scale" if the image isn't a png, in essence converting it to one.
	 *
	 * Returns a promise which will contain a jQuery object containing a new image element
	 * the url of which is either a object URL or data URL of the scaled image.
	 */
	var scaleImage = ( function() {
		var scaler;
		return function( file ) {
			var deferred = $.Deferred();
			var img = new Image();
			img.onload = function() {
				if (
					file.type === 'image/png' &&
					img.width === settings.imageWidth && img.height === settings.imageHeight
				) {
					// No scaling necessary
					deferred.resolve( $( img ) );
					return;
				}
				
				if ( !scaler ) {
					scaler = getCanvas( 'image' );
				}
				scaler.clear();
				scaler.ctx.drawImage( img, 0, 0, settings.imageWidth, settings.imageHeight );
				
				URL.revokeObjectURL( img.src );
				
				var scaledImg = new Image();
				scaledImg.onload = function() {
					deferred.resolve( $( scaledImg ) );
				};
				scaler.canvas.toBlob( function( blob ) {
					scaledImg.src = URL.createObjectURL( blob );
				} );
			};
			img.src = URL.createObjectURL( file );
			
			loadingImages.push( deferred.promise() );
			return deferred.promise();
		};
	}() );
	
	/**
	 * Creates panels to display in a dialog window
	 *
	 * If this is the first panel, the dialog window is created.
	 * Panels are stored in the "panels" object, which should be checked for
	 * panel id prior to calling this function to create a new panel,
	 * so duplicates are not made.
	 * E.g: `var myPanel = panels.myPanel || panel( 'myPanel', ... );`
	 *
	 * "id" is the unique ID that identifies this panel
	 * "config" is an object of config options for this panel
	 *  "title" is the string to use for the panel's title
	 *  "content" is HTML to use for the panel's text area,
	 *   it can be in any format $().append takes (jQuery, nodes, HTML strings, array)
	 *   The panel will reset to this HTML whenever the dialog box is closed
	 *   (if "cached" isn't specified)
	 *  "actions" is an object to specify which buttons to place.
	 *   It has a "left" and "right" key to specify which side of the
	 *   dialog to place the buttons which accept an array of objects
	 *   which are passed to `makeButton`
	 *  "onShow" is a callback which is called whenever the dialog is
	 *   opened, or this panel is shown
	 *  "onHide" is a callback which is called whenever the dialog is
	 *   closed, or this panel is hidden
	 *  "onClose" is a callback which is called whenever the dialog is
	 *   closed, regardless of which panel is currently shown
	 *  "cached" is a boolean specifying if the panel's HTML should be
	 *   retained after the dialog is closed, or should be reset to the
	 *   value of "config.content"
	 *
	 * Returns the panel object for the new panel (or the currently displayed
	 * panel if called with no arguments).
	 * The panel object contains jQuery objects of the panel's parts, some of
	 * the config options, and methods for controlling the panel/dialog window.
	 * 
	 * panel.show shows the panel, opening the dialog if it isn't already
	 *  It accepts a callback function which is called once the dialog/panel
	 *  opening animation is finished
	 * panel.hide closes the dialog
	 *  It accepts a callback function which is called once the dialog closing
	 *  animation is finished
	 * panel.clean deletes the panel's text and resets it to the initial
	 *  value specified in "config.content"
	 */
	var panel = function( id, config ) {
		var $overlay;
		var $dialog = $( '.spriteedit-dialog' );
		if ( !id ) {
			return panels[$dialog.data( 'active-panel' )];
		}
		
		var thisPanel = panels[id];
		if ( thisPanel ) {
			return thisPanel;
		}
		
		if ( !$dialog.length ) {
			$overlay = $( '<div>' ).addClass( 'spriteedit-dialog-overlay' ).css( 'display', 'none' );
			$dialog = $( '<div>' ).addClass( 'spriteedit-dialog' ).append(
				makeButton( '', {
					id: 'spriteedit-dialog-close',
					icon: 'close',
					title: i18n.panelCloseTip,
					action: function() {
						var closingPanel = panel();
						closingPanel.hide();
						if ( closingPanel.onClose ) {
							closingPanel.onClose.call( closingPanel );
						}
					},
				} )
			).appendTo( $overlay );
		}
		
		if ( config.content && !Array.isArray( config.content ) ) {
			config.content = [ config.content ];
		}
		
		var $panel = $( '<div>' )
			.prop( 'id', 'spriteedit-dialog-' + id )
			.addClass( 'spriteedit-dialog-panel' );
		
		var $title = $( '<div>' ).addClass( 'spriteedit-dialog-title' ).text( config.title ).appendTo( $panel );
		
		var $text = $( '<div>' ).addClass( 'spriteedit-dialog-text' ).appendTo( $panel );
		
		if ( config.content ) {
			$text.append( config.content );
			
			// Keep content as the initial HTML for resetting
			config.content = $text.html();
		}
		
		var $actions;
		if ( config.actions ) {
			$actions = $( '<div>' ).addClass( 'spriteedit-dialog-actions' ).appendTo( $panel );
			var $leftActions = $( '<span>' ).appendTo( $actions );
			var $rightActions = $( '<span>' ).css( 'float', 'right' ).appendTo( $actions );
			var addButtons = function( buttons, right ) {
				if ( !buttons ) {
					return;
				}
				
				var $area = right ? $rightActions : $leftActions;
				if ( !Array.isArray( buttons ) ) {
					buttons = [ buttons ];
				}
				$.each( buttons, function() {
					$area.append( makeButton( this.text, this.config ) );
				} );
			};
			
			addButtons( config.actions.left );
			addButtons( config.actions.right, true );
		}
		
		$dialog.append( $panel );
		
		if ( $overlay ) {
			$body.append( $overlay );
		} else {
			$overlay = $dialog.parent();
		}
		
		$overlay.show();
		
		if ( $overlay.css( 'opacity' ) === '0' ) {
			$overlay.hide();
		}
		$panel.hide();
		
		thisPanel = panels[id] = {
			$panel: $panel,
			$title: $title,
			$text: $text,
			$actions: $actions,
			show: function( callback ) {
				$dialog.css( { transform: 'none', transition: 'none' } );
				
				var prevPanel;
				if ( $overlay.css( 'opacity' ) === '1' ) {
					prevPanel = panel();
					// Remember to cleanup previous panel when the dialog is closed
					if ( prevPanel && !prevPanel.cached ) {
						prevPanel.cleanup = true;
					}
					if ( prevPanel.onHide ) {
						prevPanel.onHide.call( prevPanel );
					}
				}
				
				var oldRect;
				if ( prevPanel ) {
					oldRect = $dialog[0].getBoundingClientRect();
					prevPanel.$panel.hide();
				}
				$overlay.css( 'display', '' );
				$panel.css( 'display', '' );
				if ( prevPanel ) {
					var newRect = $dialog[0].getBoundingClientRect();
					$dialog.css( 'transform', 'scale(1)' ).redraw().css( 'transition', '' );
					if ( oldRect.width === newRect.width && oldRect.height === newRect.height ) {
						// No transition to be made
						$dialog.trigger( 'transitionend' );
					} else {
						$panel.css( 'display', 'none' );
						$dialog.css( {
							width: oldRect.width,
							height: oldRect.height,
						} ).redraw().css( {
							width: newRect.width,
							height: newRect.height,
						} ).transitionEnd( function() {
							panelShown = true;
							$dialog.css( { width: '', height: '' } );
							$panel.css( 'display', '' );
						} );
						
						// Make sure the panel gets displayed
						var panelShown;
						setTimeout( function() {
							if ( panelShown ) {
								return;
							}
							
							$dialog.trigger( 'transitionend' );
						}, 1000 );
					}
				} else {
					$dialog.css( 'transform', '' ).redraw().css( 'transition', '' );
					$overlay.css( 'opacity', 1 );
					$dialog
						.addClass( 'spriteedit-elastic' )
						.css( 'transform', 'scale(1)' )
						.transitionEnd( function() {
							$dialog.removeClass( 'spriteedit-elastic' );
						} );
				}
				
				$dialog.data( 'active-panel', id );
				
				if ( config.onShow ) {
					config.onShow.call( thisPanel );
				}
				if ( callback ) {
					callback.call( thisPanel );
				}
				
				return this;
			},
			hide: function( callback ) {
				if ( !$overlay.is( ':visible' ) ) {
					return this;
				}
				
				if ( config.onHide ) {
					config.onHide.call( thisPanel );
				}
				
				$dialog.css( 'transform', 'scale(0)' );
				$overlay.css( 'opacity', 0 ).transitionEnd( function() {
					// Reset scrollbar BEFORE hiding
					$text.scrollLeft( 0 );
					$text.scrollTop( 0 );
					
					$overlay.css( 'display', 'none' );
					thisPanel.$panel.css( 'display', 'none' );
					
					if ( !config.cached ) {
						thisPanel.cleanup = true;
					}
					$.each( panels, function() {
						if ( this.cleanup ) {
							this.clean();
						}
					} );
					
					if ( callback ) {
						callback.call( thisPanel );
					}
				} );
				
				return this;
			},
			clean: function() {
				$text.empty();
				
				if ( config.content ) {
					$text.append( config.content );
				}
				
				thisPanel.cleanup = false;
			},
			onShow: config.onShow,
			onHide: config.onHide,
			onClose: config.onClose,
			cached: config.cached,
		};
		return thisPanel;
	};
	
	/**
	 * Creates a simple tooltip
	 *
	 * Used to create a small tooltip anchored to an element.
	 * Only a single tooltip can exist at a time (opening a new one will close the old)
	 * and clicking anywhere but the tooltip itself will close it.
	 *
	 * In the main function:
	 * "$elem" is a jQuery object which the tooltip should be anchored to.
	 * "content" is the content to go in the tooltip, and can be in whatever format can
	 * go into jQuery().append (jQuery objects, elements, HTML strings, etc.).
	 * "config" contains key-value pairs of the following configuration options:
	 *   "horizontal" is a boolean determining if the tooltip should open horizontally or vertically
	 *   relative to its anchor.
	 *   "callback" is a function called once the tooltip finishes its opening animation.
	 *   "class" is a classname to add to the tooltip
	 *
	 * In the tooltip.hide function:
	 * "callback" is a function called once the tooltip finishes its closing animation.
	 */
	var tooltip = ( function() {
		var $tooltip = $(), $anchor = $();
		
		$win.click( function( e ) {
			if (
				e.which === 1 &&
				$tooltip.length && !$tooltip.has( e.target ).length &&
				$tooltip.css( 'opacity' ) === '1'
			) {
				func.hide();
			}
		} );
		
		var func = function( $elem, content, config ) {
			config = config || {};
			if ( $tooltip.length ) {
				if ( $elem.is( $anchor ) ) {
					func.hide();
					return;
				}
				
				func.hide();
			}
			
			$anchor = $elem;
			$tooltip = $( '<div>' ).addClass( 'spriteedit-tooltip' ).append(
				$( '<div>' ).addClass( 'spriteedit-tooltip-text' ).append( content ),
				$( '<div>' ).addClass( 'spriteedit-tooltip-arrow' )
			).appendTo( document.body );
			
			if ( config.class ) {
				$tooltip.addClass( config.class );
			}
			
			var anchorPos = $anchor.offset();
			var bodyPos = $body.offset();
			if ( config.horizontal ) {
				$tooltip.addClass( 'spriteedit-tooltip-horizontal' ).css( {
					top: anchorPos.top - bodyPos.top + $anchor.outerHeight() / 2,
					left: anchorPos.left - bodyPos.left - $tooltip.outerWidth(),
				} );
			} else {
				$tooltip.css( {
					top: anchorPos.top - bodyPos.top - $tooltip.outerHeight(),
					left: anchorPos.left - bodyPos.left + $anchor.outerWidth() / 2,
				} );
			}
			
			$tooltip.addClass( 'spriteedit-elastic' ).css( {
				opacity: 1,
				transform: 'scale(1)',
			} ).transitionEnd( function() {
				$( this ).removeClass( 'spriteedit-elastic' );
				
				if ( config.callback ) {
					config.callback.call( this );
				}
			} );
		};
		func.hide = function( callback ) {
			if ( !$tooltip.length ) {
				return;
			}
			
			$tooltip.off( 'transitionend.spriteEdit' ).css( {
				opacity: 0,
				transform: 'scale(0)',
			} ).transitionEnd( function() {
				$( this ).remove();
				
				if ( callback ) {
					callback.call( this );
				}
			} );
			
			$tooltip = $anchor = $();
		};
		
		return func;
	}() );
	
	/**
	 * Makes a set of elements sortable by dragging them
	 *
	 * The elements can either be dragged around to be sorted within or between
	 * each set manually or can be sorted in each set automatically, with dragging
	 * only being used to move them between sets.
	 *
	 * The "options" object contains:
	 * "selectors" is a string containing the selector of the set of elements to enable sorting,
	 * or an object containing containing additional selections to define the sortable element's parent
	 * and the sortable elements container (which elements can be sorted between).
	 * "handle" is a string containing the selector to find the element which the handle is a child of,
	 * in relation to the sortable element. Set if the handle is not a direct child of the sortable
	 * element.
	 * "vertical" is a boolean determining if the elements should only be able to be moved vertically.
	 * "autoSort" is a boolean determining if the elements should be sorted within their container
	 * automatically, only allowing elements to be manually moved between containers.
	 * "sortStart" is a callback function called after the placeholder and ghost elements are created,
	 * but prior to sorting actually beginning. "this" is set to the ghost element, and the first
	 * argument is set to the placeholder element if there is one.
	 * "sortEnd" is a callback function called after the element has been sorted, but prior to the
	 * placeholder and ghost elements being destroyed. Variables are set the same as "sortStart".
	 */
	var makeSortable = function( options ) {
		var selectors = options.selectors;
		var handle = options.handle || '';
		var vertical = options.vertical;
		var autoSort = options.autoSort;
		var selector = selectors;
		var $ghost = $(), $placeholder = $(), $hover = $(), $hoverParent = $();
		if ( typeof selectors !== 'string' ) {
			selector = selectors.parent + ' > ' + selectors.elem;
		}
		
		if ( pointerEventsSupported ) {
			if ( autoSort ) {
				$doc.on( 'mouseenter.spriteEdit', selectors.container, function() {
					if ( $ghost.length ) {
						$hoverParent = $( this ).css( 'outline', '1px dashed #000' );
					}
				} ).on( 'mouseleave.spriteEdit', selectors.container, function() {
					if ( $ghost.length ) {
						$hoverParent.css( 'outline', '' );
						$hoverParent = $();
					}
				} );
			} else {
				$doc.on( 'mouseenter.spriteEdit', selector, function() {
					if ( $ghost.length && !$( this ).is( $placeholder ) ) {
						$hover = $( this );
					}
				} ).on( 'mouseleave.spriteEdit', selector, function() {
					if ( $ghost.length ) {
						$hover = $();
					}
				} );
			}
		}
		
		$doc.on( 'mousedown.spriteEdit', selector + ' ' + handle + ' > .spriteedit-handle', function( e ) {
			if ( e.which !== 1 ) {
				return;
			}
			
			if ( handle ) {
				$ghost = $( this ).closest( selector );
			} else {
				$ghost = $( this ).parent();
			}
			
			if ( $ghost.find( '.spriteedit-new' ).length && $ghost.text().trim() === '' ) {
				$ghost = $();
				return;
			}
			
			tooltip.hide();
			
			// Keep the documentation from getting smaller to allow for overscroll
			$doc.css( 'min-height', $doc[0].getBoundingClientRect().height );
			
			var ghostElem = $ghost[0];
			
			if ( !autoSort ) {
				// We don't want to clone all the content, just the parent element
				$placeholder = $( '<' + ghostElem.nodeName + '>' )
					.addClass( ghostElem.className + ' spriteedit-placeholder' )
					.insertAfter( $ghost );
			}
			
			// Calculate cursor offset percentage to apply
			// after the ghost is resized to its correct size
			var ghostRect = ghostElem.getBoundingClientRect();
			var cursorOffset = {
				top: ( ghostRect.top - e.clientY ) / ghostRect.height,
				left: ( ghostRect.left - e.clientX ) / ghostRect.width,
			};
			
			$ghost.addClass( 'spriteedit-ghost' ).css( {
				top: e.clientY,
				left: e.clientX,
			} );
			
			// Apply offsets
			var newGhostRect = ghostElem.getBoundingClientRect();
			$ghost.css( {
				marginTop: newGhostRect.height * cursorOffset.top,
				marginLeft: newGhostRect.width * cursorOffset.left,
			} );
			
			if ( options.sortStart ) {
				options.sortStart.call( ghostElem, $placeholder[0] );
			}
			
			// Must be set after callback for collapsing.
			if ( !autoSort ) {
				$placeholder.css( 'min-height', ghostElem.getBoundingClientRect().height );
			}
			
			$ghost.parent().mouseenter();
			
			sorting = true;
			$root.addClass( 'spriteedit-sorting spriteedit-hidecontrols' );
			
			requestAnimationFrame( mouseMove );
			
			e.preventDefault();
		} );
		
		var mouseMove = function() {
			if ( !$ghost.length ) {
				return;
			}
			requestAnimationFrame( mouseMove );
			
			if ( !mouse.moved ) {
				return;
			}
			mouse.moved = false;
			
			var pos = { top: mouse.y };
			if ( !vertical ) {
				pos.left = mouse.x;
			}
			$ghost.css( pos );
			
			if ( !pointerEventsSupported ) {
				// Emulate pointer-events:none
				$ghost.css( 'visibility', 'hidden' );
				var $nearest = $( document.elementFromPoint( mouse.x, mouse.y ) );
				if ( autoSort ) {
					$hoverParent.css( 'outline', '' );
					$hoverParent = $nearest.closest( selectors.container );
					$hoverParent.css( 'outline', '1px dashed #000' );
				} else {
					$hover = $nearest.closest( selector );
				}
				$ghost.css( 'visibility', '' );
			}
			
			if ( $hover.length ) {
				var side = 'Before';
				if ( $hover.index() > $placeholder.index() ) {
					side = 'After';
				}
				$placeholder['insert' + side]( $hover );
				$hover = $();
			}
		};
		
		$( document ).on( 'mouseup.spriteEdit', function( e ) {
			if ( e.which !== 1 || !$ghost.length ) {
				return;
			}
			
			var index = -1;
			if ( autoSort ) {
				if ( $hoverParent.length && !$ghost.closest( selectors.container ).is( $hoverParent ) ) {
					var text = $ghost.attr( 'data-sort-key' ) || $ghost.text();
					index = getAlphaIndex( text, undefined, $hoverParent.find( selectors.parent ) );
				}
			} else {
				index = $placeholder.index();
			}
			
			if (
				index > -1 && (
					index - 1 !== $ghost.index() ||
					autoSort && $hoverParent.length &&
					!$ghost.closest( selectors.container ).is( $hoverParent )
				)
			) {
				// If the last name is moved, delete its box
				if ( $ghost.hasClass( 'spritedoc-name' ) && !$ghost.siblings().length ) {
					var $box = $ghost.closest( selectors.container );
					change( 'delete', {
						$elem: $box,
						index: $box.index() - 1,
						$parent: $box.parent(),
					}, true );
				}
				
				change( 'insert', {
					$elem: $ghost,
					oldIndex: $ghost.index() - 1,
					$oldParent: $ghost.parent(),
					index: index - 1,
					$parent: $hoverParent.length && $hoverParent.find( selectors.parent ) ||
						$placeholder.parent(),
				} );
			}
			
			$ghost.removeAttr( 'style' ).removeClass( 'spriteedit-ghost' );
			$hoverParent.css( 'outline', '' );
			$doc.css( 'min-height', '' );
			
			if ( options.sortEnd ) {
				options.sortEnd.call( $ghost[0], $placeholder[0] );
			}
			
			$placeholder.remove();
			$ghost = $placeholder = $hover = $hoverParent = $();
			
			sorting = false;
			$root.removeClass( 'spriteedit-sorting spriteedit-hidecontrols' );
		} );
	};
	
	/**
	 * Allows repeatable changes to be performed, which can be undone and redone
	 *
	 * The main function performs a change of a particular type.
	 * "action" is the type of change this is:
	 * * "edit" is changes to text (anything contentEditable)
	 * * "insert" is any element being inserted, either fresh from the aether or taken
	 *   from somewhere else in the document.
	 * * "delete" is any element being removed from the document.
	 * * "replace image" is when an image is replaced with a new image.
	 * * "reset image" is when an image is reset to the original image in the sprite sheet.
	 * "content" is an object containing anything necessary to describe the change, including
	 * details to revert the change.
	 * "queueChange" is a boolean determining if the change should be queued rather than committed
	 * to history immediately. This allows multiple changes to be grouped as one history event. Note
	 * that making a change which isn't queued will commit any currently queued changes to history
	 * along with itself.
	 * "oldChange" is a boolean determining if this change shouldn't be queued. Intended mainly for
	 * undoing/redoing.
	 *
	 * The change.commit function allows queued changes to be committed to history.
	 * The change.discard function allows queued changes to be discarded, although the changes
	 * are not reverted.
	 */
	var change = ( function() {
		var queue = [];
		var func = function( action, content, queueChange, oldChange ) {
			var isBox;
			var $box;
			var $code;
			switch ( action ) {
				case 'edit':
					if ( oldChange ) {
						content.$elem.text( content.text );
					}
					
					if ( content.$elem.parent().hasClass( 'spritedoc-name' ) ) {
						updateName( content.oldText, content.text, content.$elem );
					}
					
					names.invalidate( true );
				break;
				
				case 'insert':
					var moved = content.$elem.parent().length;
					isBox = content.$elem.hasClass( 'spritedoc-box' );
					var $oldBox = !isBox && content.$elem.closest( '.spritedoc-box' );
					
					if ( content.index === -1 ) {
						content.$parent.prepend( content.$elem );
					} else {
						content.$parent.children().eq( content.index ).after( content.$elem );
					}
					
					if ( !moved && ( isBox || content.$elem.hasClass( 'spritedoc-section' ) ) ) {
						content.$elem.find( '.spritedoc-name' ).find( 'code' ).each( function() {
							updateName( undefined, $( this ).text(), $( this ) );
						} );
						if ( $doc.find( '.spriteedit-new' ).length ) {
							sheet.invalidate( true );
						}
					} else if ( content.$elem.hasClass( 'spritedoc-name' ) ) {
						if ( moved ) {
							$box = content.$elem.closest( '.spritedoc-box' );
							if ( !$box.is( $oldBox ) ) {
								updateBoxSorting( $oldBox );
							}
							updateBoxSorting( $box );
						} else {
							$code = content.$elem.find( 'code' );
							updateName( undefined, $code.text(), $code );
						}
					}
					
					requestAnimationFrame( function() {
						scrollIntoView( content.$elem );
					} );
					
					names.invalidate( true );
				break;
				
				case 'delete':
					isBox = content.$elem.hasClass( 'spritedoc-box' );
					$box = !isBox && content.$elem.closest( '.spritedoc-box' );
					
					content.$elem.detach();
					
					if ( isBox || content.$elem.hasClass( 'spritedoc-section' ) ) {
						content.$elem.find( '.spritedoc-name' ).find( 'code' ).each( function() {
							updateName( $( this ).text(), undefined, $( this ) );
						} );
						sheet.invalidate( !!$doc.find( '.spriteedit-new' ).length );
					} else if ( content.$elem.hasClass( 'spritedoc-name' ) ) {
						$code = content.$elem.find( 'code' );
						updateName( $code.text(), undefined, $code );
						updateBoxSorting( $box );
					}
					
					names.invalidate( true );
				break;
				
				case 'replace image':
					$box = content.$parent.parent();
					if ( content.$oldImg && content.$oldImg.length ) {
						content.$oldImg.detach();
					} else {
						$box.addClass( 'spriteedit-new' );
						content.$parent.find( '.sprite' ).addClass( 'spriteedit-replaced-image' );
					}
					content.$parent.append( content.$elem );
					sheet.invalidate( true );
				break;
				
				case 'reset image':
					content.$elem.detach();
					content.$parent.find( '.sprite' ).removeClass( 'spriteedit-replaced-image' );
					content.$parent.parent().removeClass( 'spriteedit-new' );
					
					if ( !$doc.find( '.spriteedit-new' ).length ) {
						sheet.invalidate( false );
					}
				break;
				
				case 'toggle deprecation':
					content.$elem.toggleClass( 'spritedoc-deprecated' );
					
					names.invalidate( true );
				break;
				
				default:
					console.error( 'Invalid action: `%s`', action );
				break;
			}
			
			var hist = { action: action, content: content };
			if ( !oldChange ) {
				queue.push( hist );
				if ( !queueChange ) {
					func.commit();
				}
			}
		};
		func.commit = function() {
			addHistory( queue );
			
			queue = [];
		};
		func.discard = function() {
			queue = [];
			
			if ( !$doc.find( '.spriteedit-new' ).length ) {
				sheet.invalidate( false );
			}
			
			if ( !changes.length ) {
				names.invalidate( false );
				
				$.each( [
					'#spriteedit-save',
					'#spriteedit-summary',
					'#spriteedit-review-button',
				], function() {
					if ( $( this ).length ) {
						$( this ).data( 'ooui-object' ).setDisabled( true );
					}
				} );
			}
		};
		
		return func;
	}() );
	
	/**
	 * Adds a change to history
	 *
	 * Handles enabling the save and undo button, disabling the redo button,
	 * releasing undone object URLs, and deleting the undone changes.
	 */
	var addHistory = function( actions ) {
		changes.push( actions );
		
		if ( undoneChanges.length ) {
			// Release now unusable image URLs
			$.each( undoneChanges, function() {
				if ( this.action === 'replace image' ) {
					URL.revokeObjectURL( this.content.$elem.attr( 'src' ) );
				}
			} );
			
			undoneChanges = [];
			
			$( '#spriteedit-redo' ).data( 'ooui-object' ).setDisabled( true );
		}
		
		$.each( [
			'#spriteedit-undo',
			'#spriteedit-save',
			'#spriteedit-summary',
			'#spriteedit-review-button',
		], function() {
			if ( $( this ).length ) {
				$( this ).data( 'ooui-object' ).setDisabled( false );
			}
		} );
	};
	
	/**
	 * Reverts a change
	 *
	 * Takes a previously stored history entry and performs the necessary change
	 * to revert it.
	 */
	var revert = function( hist ) {
		// Invert the history entry's changes to revert it
		var i = hist.length, histChange, content;
		while ( i-- ) {
			histChange = hist[i];
			content = histChange.content;
			switch( histChange.action ) {
				case 'edit':
					change( 'edit', {
						$elem: content.$elem,
						text: content.oldText,
						oldText: content.text,
					}, false, true );
				break;
				
				case 'insert':
					if ( content.$oldParent ) {
						change( 'insert', {
							$elem: content.$elem,
							index: content.oldIndex,
							$parent: content.$oldParent,
						}, false, true );
					} else {
						change( 'delete', {
							$elem: content.$elem,
							$parent: content.$parent,
						}, false, true );
					}
				break;
				
				case 'delete':
					change( 'insert', {
						$elem: content.$elem,
						index: content.index,
						$parent: content.$parent,
					}, false, true );
				break;
				
				case 'replace image':
					if ( content.$oldImg.length ) {
						change( 'replace image', {
							$elem: content.$oldImg,
							$parent: content.$parent,
							$oldImg: content.$elem,
						}, false, true );
					} else {
						change( 'reset image', content, false, true );
					}
				break;
				
				case 'reset image':
					change( 'replace image', content, false, true );
				break;
				
				case 'toggle deprecation':
					change( 'toggle deprecation', content, false, true );
				break;
			}
		}
		
		if ( !$doc.find( '.spriteedit-new' ).length ) {
			sheet.invalidate( false );
		}
		
		if ( !changes.length ) {
			names.invalidate( false );
			
			$.each( [
				'#spriteedit-undo',
				'#spriteedit-save',
				'#spriteedit-summary',
				'#spriteedit-review-button',
			], function() {
				if ( $( this ).length ) {
					$( this ).data( 'ooui-object' ).setDisabled( true );
				}
			} );
		}
	};
	
	/**
	 * Updates the list of names for duplicate detection
	 *
	 * Also sorts the names and box if necessary.
	 */
	var updateName = function( oldText, newText, $elem ) {
		if ( oldText ) {
			var oldNames = usedNames[oldText];
			if ( oldNames.length === 1 ) {
				delete usedNames[oldText];
			} else {
				$.each( oldNames, function( i ) {
					if ( $elem.is( this ) ) {
						oldNames.splice( i, 1 );
						return false;
					}
				} );
				if ( oldNames.length === 1 ) {
					oldNames[0].removeClass( 'spriteedit-dupe' );
				}
			}
		}
		
		var $item = $elem.parent();
		var oldIndex = $item.index();
		if ( newText ) {
			var newNames = usedNames[newText];
			if ( !newNames ) {
				newNames = usedNames[newText] = [];
				$elem.removeClass( 'spriteedit-dupe' );
			} else {
				if ( newNames.length === 1 ) {
					newNames[0].addClass( 'spriteedit-dupe' );
				}
				$elem.addClass( 'spriteedit-dupe' );
			}
			newNames.push( $elem );
			
			var $parent = $item.parent();
			var index = getAlphaIndex( newText, $item );
			if ( index !== oldIndex ) {
				change( 'insert', {
					$elem: $item,
					oldIndex: oldIndex - 1,
					$oldParent: $parent,
					index: index - 1,
					$parent: $parent,
				}, false, true );
			} else if ( index === 0 ) {
				updateBoxSorting( $item.closest( '.spritedoc-box' ) );
			}
		}
	};
	
	/**
	 * Updates the box's sort key and sorts it.
	 */
	var updateBoxSorting = function( $box ) {
		var name = $box.find( '.spritedoc-name' ).first().text();
		var oldName = $box.attr( 'data-sort-key' );
		if ( name === oldName ) {
			return;
		}
		
		$box.attr( 'data-sort-key', name );
		
		var $parent = $box.parent();
		var oldIndex = $box.index();
		var index = getAlphaIndex( name, $box );
		if ( index !== oldIndex ) {
			change( 'insert', {
				$elem: $box,
				oldIndex: oldIndex - 1,
				$oldParent: $parent,
				index: index - 1,
				$parent: $parent,
			}, false, true );
		}
	};
	
	/**
	 * Picks the section which is probably the section the user wants to put things
	 *
	 * Mainly based on the section closest to the top of the screen,
	 * but prefers elements which are not at all going off the screen
	 * (accounting for the space taken up by the toolbar).
	 *
	 * Returns the section element
	 */
	var nearestSection = function() {
		var offscreen, prox, elem;
		$doc.find( '.spritedoc-section' ).each( function() {
			var curPos = this.getBoundingClientRect().top - 35;
			var curProx = Math.abs( curPos );
			if ( prox && curProx > prox ) {
				// Prefer on-screen section, even if it is further from the top
				if ( offscreen ) {
					elem = this;
				}
				
				return false;
			}
			
			offscreen = curPos < 0;
			prox = curProx;
			elem = this;
		} );
		
		return elem;
	};
	
	/**
	 * Destroys the editor
	 *
	 * Removes any controls, and unbinds all events in the spriteEdit namespace, and releases
	 * object URLs.
	 *
	 * "restore" is a boolean determining if the documentation should be restored to how it was
	 * prior to opening the editor.
	 * "leaveUrl" is a boolean determining if a the page URL shouldn't be updated to remove the
	 * spriteedit action. Used for when the editor is destroyed due to history navigation.
	 */
	var destroy = function( restore, leaveUrl ) {
		document.title = originalTitle;
		
		// Disable close confirm dialog
		preventClose && preventClose.release();
		
		$win.add( document ).off( '.spriteEdit' );
		
		if ( !leaveUrl ) {
			history.pushState( {}, '', mw.util.getUrl() );
		}
		
		var enabled = $root.hasClass( 'spriteedit-enabled' );
		
		$root.removeClass( 'spriteedit-loaded spriteedit-enabled spriteedit-imageeditingenabled spriteedit-hidecontrols' );
		
		var $viewTab = $( '#ca-view' );
		$viewTab.add( '#ca-spriteedit' ).toggleClass( 'selected' );
		
		$doc.add( $viewTab.find( 'a' ) ).off( '.spriteEdit' );
		
		if ( restore ) {
			overwriteSpritesheet.disable();
		}
		
		// No further cleanup necessary
		if ( !enabled ) {
			return;
		}
		
		$( '.mw-editsection' ).add( '.mw-editsection-like' ).css( 'display', '' );
		
		// Release old image URL references
		if ( sheet.modified ) {
			$.each( changes, function() {
				if ( this.action === 'replace image' ) {
					URL.revokeObjectURL( this.content.$oldImg.attr( 'src' ) );
				}
			} );
		}
		
		if ( restore ) {
			// Release current image URL references
			if ( sheet.modified ) {
				$doc.find( '.spritedoc-image' ).find( 'img' ).each( function() {
					URL.revokeObjectURL( this.src );
				} );
			}
			
			$doc.replaceWith( oldHtml );
			return;
		}
		
		$doc.find( '.mw-headline' ).add( $doc.find( '.spritedoc-name' ).find( 'code' ) )
			.removeAttr( 'contenteditable' );
		
		$.each( [
			'.spriteedit-toolbar-container',
			'.spriteedit-handle',
			'.spriteedit-add-name',
			'.spriteedit-tooltip',
			'.spriteedit-dialog-overlay',
		], function() {
			$( this ).remove();
		} );
		
		$( '.spriteedit-new' ).removeClass( 'spriteedit-new' ).each( function() {
			var newPos = $( this ).data( 'new-pos' );
			if ( newPos !== undefined ) {
				$( this ).data( 'pos', newPos ).removeData( 'new-pos' );
			}
		} );
		
		$doc.find( '.spriteedit-replaced-image' ).removeClass( 'spriteedit-replaced-image' );
		$doc.find( '.spriteedit-replacing-image' ).remove();
	};
};

/** Utility functions **/
/**
 * Allows calling a function when a main transition ends
 *
 * This only listens to transitions that happen on the element this is
 * called on, ignoring transitions bubbling from its children.
 * Additionally, if the browser doesn't support transitions, the callback
 * will be called immediately.
 *
 * The callback is passed along the "this" and "event" object from the event.
 */
$.fn.transitionEnd = function( callback ) {
	if ( supports( 'transition' ) ) {
		this.on( 'transitionend.spriteEdit', function( e ) {
			var $elem = $( this );
			if ( !$elem.is( e.target ) ) {
				return;
			}
			
			callback.call( this, e );
			
			$elem.off( 'transitionend.spriteEdit' );
		} );
	} else {
		this.each( function() {
			callback.call( this );
		} );
	}
	
	return this;
};

/**
 * Forces the browser to redraw an element
 */
$.fn.redraw = function() {
	this.each( function() {
		this.offsetWidth;
	} );
	
	return this;
};

/**
 * Returns the index to move an element to to sort it alphabetically, ignoring case
 *
 * "text" is the string to sort by.
 * "$elem" is the jQuery object which is to be sorted
 * "$parent" is the jQuery object which is the parent of the elements which "text" will be sorted by.
 *
 * Use "$elem" when sorting an element by its siblings.
 * Use "$parent" when sorting an element in a different container.
 */
var getAlphaIndex = function( text, $elem, $parent ) {
	var index;
	var $items = $parent ? $parent.children() : $elem.siblings();
	$items.each( function() {
		var $this = $( this );
		var compare = $this.attr( 'data-sort-key' ) || $this.text();
		if ( text.toLowerCase() < compare.toLowerCase() ) {
			index = $this.index();
			return false;
		}
	} );
	if ( index === undefined ) {
		if ( $items.length ) {
			index = $items.length;
			if ( !$parent ) {
				index++;
			}
		} else {
			index = 0;
		}
	}
	
	// Account for trying to sort the element after itself
	if ( !$parent && index - 1 === $elem.index() ) {
		index--;
	}
	
	return index;
};

/**
 * Attempts to scroll an element into view
 *
 * Takes into account the portion of window obscured by the toolbar.
 * Flashes the element's background yellow for a moment to bring it to attention.
 *
 * "$elem" is the jQuery object to scroll to.
 * "instant" is a boolean determining if the scrolling should be instant, instead of smooth
 * (if the browser supports smooth scrolling in the first place, that is).
 */
var scrollIntoView = function( $elem, instant ) {
	var elemRect = $elem[0].getBoundingClientRect();
	var toolbarHeight = $( '.spriteedit-toolbar' )[0].getBoundingClientRect().height;
	var scrollPos;
	if ( elemRect.top - toolbarHeight < 10 ) {
		scrollPos = elemRect.top + $win.scrollTop() - toolbarHeight - 10;
	} else {
		var winHeight = $win.height() - 40;
		if ( elemRect.height > winHeight || elemRect.bottom < winHeight ) {
			return;
		}
		scrollPos = elemRect.bottom + $win.scrollTop() - winHeight;
	}
	
	if ( !instant ) {
		$root.addClass( 'spriteedit-smoothscroll' );
	}
	
	scroll( 0, scrollPos );
	$elem.css( 'background-color', 'yellow' );
	setTimeout( function() {
		$elem.css( 'background-color', '' );
	}, 1000 );
};

/**
 * Converts the extended ISO UTC timestamp returned by the API
 * into the MediaWiki format in the wiki's timezone
 *
 * YYYY-MM-DDTHH:MM:SSZ -> YYYYMMDDHHMMSS
 */
var fixTimestamp = function( timestamp ) {
	// Make UTC date
	var date = new Date( timestamp );
	// Convert to wiki's timezone
	date.setTime( date.getTime() + fixTimestamp.offset * 60 * 1000 );
	// Return MW timestamp format
	return date.toISOString().replace( /[\-T:]|\.\d+Z/g, '' );
};
// This will be set when the editor is created and a siteinfo request is made
fixTimestamp.offset = 0;

/**
 * Add various types of in-page controls to a set of elements
 *
 * "$elems" is a jQuery object containing the elements to add controls to.
 * "type" is the type of controls to add.
 */
var addControls = function( $elems, type ) {
	switch ( type ) {
		case 'heading':
			$elems.prepend( $( '<span>' ).addClass( 'spriteedit-handle' ) )
				.find( '.mw-headline' ).attr( 'contenteditable', true );
		break;
		case 'box':
			$elems.each( function(){
				var addNameButton = new OO.ui.ButtonInputWidget( {
					classes: [ 'spriteedit-add-name' ],
					framed: false,
					icon: 'add',
					title: i18n.controlNewName,
				} );
				addNameButton.$element.data( 'ooui-object', addNameButton );
				$( this ).prepend(
					$( '<span>' ).addClass( 'spriteedit-handle' ),
					addNameButton.$element
				);
			} );
			addControls( $elems.find( '.spritedoc-name' ), 'name' );
		break;
		case 'name':
			$elems.find( 'code' ).attr( 'contenteditable', true );
		break;
	}
};

/**
 * Create an OOUI button widget
 *
 * "text" is the string displayed on the button.
 * "config" is an object defining various properties of the button:
 * * "type" is a string or array of strings defining the OOUI flags
 *   this button should use (e.g.: progressive, destructive, primary).
 * * "id" is the id attribute applied to the button.
 * * "icon" is the OOUI icon to use
 * * "action" is a function called when the button is clicked.
 */
var makeButton = function( text, config ) {
	var button = new OO.ui.ButtonInputWidget( {
		flags: config.type,
		id: config.id,
		label: text,
		icon: config.icon,
		title: config.title,
	} );
	
	if ( config.action ) {
		button.$button.on( 'click.spriteEdit', function( e ) {
			$( this ).focus().blur();
			config.action.call( this, e );
		} );
	}
	
	button.$element.data( 'ooui-object', button );
	
	return button.$element;
};

/**
 * Check if a CSS property or value is supported by the browser
 */
var supports = function( prop, val ) {
	if ( !val ) {
		return prop in $root[0].style;
	}
	
	if ( window.CSS && CSS.supports ) {
		return CSS.supports( prop, val );
	}
	if ( window.supportsCSS ) {
		return supportsCSS( prop, val );
	}
	
	var camelProp = prop.replace( /-([a-z]|[0-9])/ig, function( _, chr ) {
		return chr.toUpperCase();
	} );
	var elStyle = document.createElement( 'i' ).style;
	elStyle.cssText = prop + ':' + val;
	return elStyle[camelProp] !== '';
};

/**
 * Retries a request once if it fails
 *
 * Always returns an abortable promise, even if the request itself isn't abortable.
 *
 * "request" is a function which will run the request,
 * and should return the request's promise.
 * "delay" is the amount of milliseconds to wait before retrying.
 * "retries" is the amount of times to retry
 */
var retryableRequest = function( request, delay, retries ) {
	var deferred = $.Deferred();
	var curRequest;
	var timeout;
	retries = retries || 1;
	var attemptRequest = function( attempt ) {
		( curRequest = request() ).then( deferred.resolve, function( code, data ) {
			if ( attempt <= retries ) {
				timeout = setTimeout( function() {
					attemptRequest( ++attempt );
				}, delay || 1000 );
			} else {
				deferred.reject( code, data );
			}
			
		} );
	};
	attemptRequest( 1 );
	
	return deferred.promise( { abort: function() {
		if ( curRequest.abort ) {
			curRequest.abort();
		}
		clearTimeout( timeout );
	} } );
};

/**
 * Handles generic API errors
 *
 * Just uselessly displays whatever error the API returns.
 * Hopefully the user can retry whatever they were doing.
 *
 * "code" and "data" are the standard variables returned by a mw.Api promise rejection.
 */
var handleError = function( code, data ) {
	var errorTitle = i18n.errorGeneric;
	var errorText;
	if ( code === 'http' ) {
		// Not an error
		if ( data.textStatus === 'abort' ) {
			return;
		}
		
		if ( data.textStatus === 'error' || data.textStatus === 'timeout' ) {
			errorTitle = i18n.errorConnection;
			errorText = i18n.errorConnectionText;
		} else {
			errorTitle = i18n.errorHttp;
			errorText = data.textStatus;
		}
	} else {
		errorTitle = i18n.errorApi;
		errorText = data.error.info;
	}
	
	mw.notify( errorText, { title: errorTitle, type: 'error', autoHide: false } );
};

/**
 * Looks for the specified change tag
 * 
 * Returns a promise which resolves to a boolean stating if the tag is active or not,
 * or null if the tag doesn't exist.
 * 
 * "tag" is the tag to search for.
 * "options" is an object of additional values to add to the request.
 */
var findChangeTag = function( tag, options ) {
	return retryableRequest( function() {
		return new mw.Api().get( $.extend( {
			action: 'query',
			list: 'tags',
			tgprop: 'active',
			tglimit: 'max',
			formatversion: 2,
		}, options || {} ) );
	} ).then( function( data ) {
		var foundActive = null;
		$.each( data.query.tags, function() {
			if ( this.name === tag ) {
				foundActive = this.active;
				return false;
			}
		} );
		
		// XXX: MW JS requires ES5 support, but JavascriptMinifier doesn't
		// support ES5, so these have to remain strings. See T96901
		if ( foundActive === null && data['continue'] ) {
			return findChangeTag( tag, data['continue'] );
		}
		
		return foundActive;
	} );
};

/**
 * Replaces the spritesheet with the provided URL
 * 
 * It's assumed the URL will be an object URL, which it handles revoking
 * if the spritesheet is replaced again or disabled.
 * 
 * The current style element can be accessed from `overwriteSpritesheet.style`.
 */
var overwriteSpritesheet = function( url ) {
	overwriteSpritesheet.disable();
	overwriteSpritesheet.style = mw.util.addCSS(
		'#spritedoc .sprite { background-image: url(' + url + ') !important }'
	);
	overwriteSpritesheet.style.url = url;
};
/**
 * Disables the current style so its styles don't apply
 * and revokes the object URL.
 */
overwriteSpritesheet.disable = function() {
	var inlineStyle = overwriteSpritesheet.style;
	if ( inlineStyle ) {
		inlineStyle.disabled = true;
		URL.revokeObjectURL( inlineStyle.url );
	}
};

var luaTable = {};
/** Recursively creates a pretty printed lua table from an object
 * 
 * Supports only the value which `luaTable.createValue` supports, and
 * only numbers and strings as keys.
 * 
 * Objects with less than 4 keys and with no sub-objects will be
 * on a single line, otherwise will be one line per value.
 */
luaTable.create = function( obj, indent ) {
	indent = indent || 1;
	var out = [ '{' ];
	var isArray = Array.isArray( obj );
	var size = isArray ? obj.length : Object.keys( obj ).length;
	var containsObject;
	$.each( obj, function( k, v ) {
		if ( typeof v === 'object' ) {
			containsObject = true;
			return false;
		}
	} );
	var multiline = containsObject || size > 3;
	
	$.each( obj, function( k, v ) {
		if ( v === null || v === undefined ) {
			return;
		}
		var key = isArray ? '' : luaTable.createKey( k ) + ' = ';
		out.push( '\t'.repeat( multiline * indent ) + key + luaTable.createValue( v, indent + 1 ) + ',' );
	} );
	// No trailing commas for single line objects
	if ( !multiline ) {
		out.push( out.pop().slice( 0, -1 ) );
	}
	out.push( '\t'.repeat( multiline * --indent ) + '}' );
	
	return out.join( multiline ? '\n' : ' ' );
};
/** Allows creating a string that should be considered a lua function
 * 
 * "funcStr" should be the exact value to be output as
 * in the table.
 * 
 * Returns a function that will be converted back in to
 * the specified string when building the table.
 */
luaTable.func = function( funcStr ) {
	var f = function() {
		return funcStr;
	};
	f.luaFunc = true;
	return f;
};
/**
 * List of reserved keywords in lua
 */
luaTable.keywords = [
	'and', 'break', 'do', 'else', 'elseif', 'end', 'false', 'for', 'function',
	'if', 'in', 'local', 'nil', 'not', 'or', 'repeat', 'return', 'then',
	'true', 'until', 'while',
];
/**
 * Returns a lua key with quotes and brackets if necessary
 * 
 * Only supports numbers and strings.
 */
luaTable.createKey = function( key ) {
	if ( key.match( /^-?\d+(\.\d+)?$/ ) ) {
		return '[' + Number( key ) + ']';
	}
	if ( luaTable.keywords.indexOf( key ) < 0 && !key.match( /^[^a-z_]|\W/i ) ) {
		return key;
	}
	
	return '[' + luaTable.createString( key ) + ']';
};
/**
 * Create a lua table value
 * 
 * Only supports the types in the switch, and only functions created with
 * `luaTable.func` are supported.
 * Will recursively resolve object values.
 */
luaTable.createValue = function( val, indent ) {
	switch ( typeof val ) {
		case 'number':
		case 'boolean':
			return val;
		case 'string':
			return luaTable.createString( val );
		case 'object':
			return luaTable.create( val, indent );
		case 'function':
			// If the function is supposed to be a lua function,
			// execute it to get the lua function string and
			// return it directly, otherwise invalid type
			if ( val.luaFunc ) {
				return val();
			}
		default:
			throw new TypeError( 'Lua table: Invalid value type: ' + typeof val );
	}
};
/**
 * Quote a string for lua table
 *
 * Uses either ' or " as the delimiter (depending on which is least used in the string),
 * then escapes \ and the chosen delimiter within the string.
 */
luaTable.createString = function( str ) {
	if ( !str ) {
		return "''";
	}
	var delim, delimRegex;
	var quotes = ( str.match( /"/g ) || '' ).length;
	var apostrophies = ( str.match( /'/g ) || '' ).length;
	if ( apostrophies > quotes ) {
		delim = '"';
		delimRegex = /"/g;
	} else {
		delim = "'";
		delimRegex = /'/g;
	}
	
	return delim + str.replace( /\\/g, '\\\\' ).replace( delimRegex, '\\' + delim ) + delim;
};

/** Polyfills **/
if ( !HTMLCanvasElement.prototype.toBlob ) {
	Object.defineProperty( HTMLCanvasElement.prototype, 'toBlob', {
		value: function( callback, type, quality ) {
			var canvas = this;
			setTimeout( function() {
				var binStr = atob( canvas.toDataURL( type, quality ).split( ',' )[1] );
				var len = binStr.length;
				var arr = new Uint8Array( len );
				
				for ( var i = 0; i < len; i++ ) {
					arr[i] = binStr.charCodeAt( i );
				}
				
				callback( new Blob( [arr], { type: type || 'image/png' } ) );
			} );
		},
	} );
}


// Finally start the editor
create( 'initial' );


}() );
Advertisement