Jump to content

MediaWiki:IconFinder.js

From EverQuest Legends Wiki
Revision as of 01:38, 10 August 2026 by Maintenance script (talk | contribs) (Add editor Icon Finder)
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/**
 * EQL Wiki - Icon Finder
 *
 * Lazy-loaded from MediaWiki:BlueprintLoader.js.
 *
 * Matching workflow:
 * 1. User pastes a screen capture containing JUST the icon.
 * 2. Read the current image list from [[Icon List]].
 * 3. Resolve only existing uploaded files through imageinfo.
 * 4. Build compact perceptual fingerprints in the browser.
 * 5. Cache that fingerprint index in IndexedDB keyed to the Icon List revision.
 * 6. Rank possible matches by perceptual/pixel similarity.
 *
 * No icon-catalog requests or image downloads happen until the user actually
 * pastes an image into the Icon Finder.
 */
( function ( mw, $ ) {
	'use strict';

	var ICON_LIST_PAGE = 'Icon List';
	var DB_NAME = 'eql-icon-finder';
	var DB_VERSION = 1;
	var STORE_NAME = 'indexes';
	var ALGO_VERSION = 3;
	var COLOR_GRID = 12;
	var IMAGEINFO_BATCH = 50;
	var DOWNLOAD_CONCURRENCY = 8;
	var RESULT_COUNT = 10;

	var initializedHost = null;
	var memoryIndex = null;
	var currentPreviewUrl = null;

	function addStyles() {
		if ( document.getElementById( 'eql-icon-finder-styles' ) ) {
			return;
		}

		$( '<style>', {
			id: 'eql-icon-finder-styles',
			text:
				'.eql-iconfinder-shell{' +
					'display:grid;' +
					'grid-template-columns:minmax(230px,.62fr) minmax(0,1.38fr);' +
					'gap:1rem;' +
					'align-items:start;' +
				'}' +

				'.eql-iconfinder-paste{' +
					'min-height:160px;' +
					'display:flex;' +
					'flex-direction:column;' +
					'align-items:center;' +
					'justify-content:center;' +
					'gap:.45rem;' +
					'padding:1rem;' +
					'background:#080d13;' +
					'border:2px dashed rgba(216,183,92,.45);' +
					'border-radius:8px;' +
					'color:#dfe7ef;' +
					'text-align:center;' +
					'cursor:paste;' +
					'outline:none;' +
				'}' +

				'.eql-iconfinder-paste:focus{' +
					'border-color:#d8b75c;' +
					'box-shadow:0 0 0 2px rgba(216,183,92,.13);' +
				'}' +

				'.eql-iconfinder-instruction{' +
					'color:#fff0b5;' +
					'font-family:Georgia,Times New Roman,serif;' +
					'font-size:1.05rem;' +
					'font-weight:700;' +
				'}' +

				'.eql-iconfinder-subinstruction{' +
					'color:#9faaba;' +
					'font-size:.88rem;' +
					'line-height:1.35;' +
				'}' +

				'.eql-iconfinder-preview{' +
					'display:none;' +
					'width:72px;' +
					'height:72px;' +
					'object-fit:contain;' +
					'image-rendering:auto;' +
					'background:#000;' +
					'border:1px solid rgba(255,255,255,.14);' +
					'border-radius:4px;' +
				'}' +

				'.eql-iconfinder-preview.is-visible{display:block;}' +

				'.eql-iconfinder-status{' +
					'margin-top:.65rem;' +
					'color:#aeb8c7;' +
					'font-size:.86rem;' +
					'line-height:1.4;' +
				'}' +

				'.eql-iconfinder-results-title{' +
					'margin:0 0 .55rem;' +
					'color:#d8b75c;' +
					'font-family:Georgia,Times New Roman,serif;' +
					'font-size:1rem;' +
					'font-weight:700;' +
				'}' +

				'.eql-iconfinder-results-empty{' +
					'padding:.9rem;' +
					'background:rgba(255,255,255,.025);' +
					'border:1px solid rgba(255,255,255,.08);' +
					'border-radius:7px;' +
					'color:#96a2b2;' +
					'font-size:.9rem;' +
				'}' +

				'.eql-iconfinder-results-grid{' +
					'display:grid;' +
					'grid-template-columns:repeat(auto-fill,minmax(185px,1fr));' +
					'gap:.65rem;' +
				'}' +

				'.eql-iconfinder-result{' +
					'display:grid;' +
					'grid-template-columns:54px minmax(0,1fr);' +
					'gap:.65rem;' +
					'align-items:center;' +
					'padding:.65rem;' +
					'background:rgba(255,255,255,.03);' +
					'border:1px solid rgba(255,255,255,.09);' +
					'border-radius:7px;' +
				'}' +

				'.eql-iconfinder-result img{' +
					'width:52px;' +
					'height:52px;' +
					'object-fit:contain;' +
					'background:#000;' +
					'border:1px solid rgba(255,255,255,.1);' +
					'border-radius:4px;' +
				'}' +

				'.eql-iconfinder-result-name{' +
					'display:block;' +
					'margin-bottom:.18rem;' +
					'color:#b9c8ff;' +
					'font-size:.86rem;' +
					'line-height:1.2;' +
					'word-break:break-word;' +
				'}' +

				'.eql-iconfinder-result-score{' +
					'display:block;' +
					'margin-bottom:.28rem;' +
					'color:#d8b75c;' +
					'font-size:.8rem;' +
					'font-variant-numeric:tabular-nums;' +
				'}' +

				'.eql-iconfinder-result-meta{' +
					'display:block;' +
					'color:#8f9bac;' +
					'font-size:.76rem;' +
					'line-height:1.25;' +
				'}' +

				'.eql-iconfinder-result-id{' +
					'display:block;' +
					'margin:.2rem 0 .35rem;' +
					'color:#fff0b5;' +
					'font-size:1.05rem;' +
					'font-weight:800;' +
					'font-variant-numeric:tabular-nums;' +
				'}' +

				'.eql-iconfinder-copy-row{' +
					'display:flex;' +
					'flex-wrap:wrap;' +
					'gap:.3rem;' +
					'margin-top:.38rem;' +
				'}' +

				'.eql-iconfinder-copy{' +
					'min-height:1.8rem!important;' +
					'padding:.15rem .45rem!important;' +
					'font-size:.72rem!important;' +
				'}' +

				'.eql-iconfinder-note{' +
					'margin-top:.7rem;' +
					'color:#7f8b9b;' +
					'font-size:.78rem;' +
					'line-height:1.35;' +
				'}' +

				'@media(max-width:850px){' +
					'.eql-iconfinder-shell{grid-template-columns:1fr;}' +
				'}'
		} ).appendTo( document.head );
	}

	function openDb() {
		return new Promise( function ( resolve, reject ) {
			var request;

			if ( !window.indexedDB ) {
				reject( new Error( 'IndexedDB unavailable.' ) );
				return;
			}

			request = indexedDB.open( DB_NAME, DB_VERSION );

			request.onupgradeneeded = function () {
				var db = request.result;

				if ( !db.objectStoreNames.contains( STORE_NAME ) ) {
					db.createObjectStore( STORE_NAME, {
						keyPath: 'key'
					} );
				}
			};

			request.onsuccess = function () {
				resolve( request.result );
			};

			request.onerror = function () {
				reject( request.error || new Error( 'IndexedDB open failed.' ) );
			};
		} );
	}

	function idbGet( key ) {
		return openDb().then( function ( db ) {
			return new Promise( function ( resolve, reject ) {
				var tx = db.transaction( STORE_NAME, 'readonly' );
				var store = tx.objectStore( STORE_NAME );
				var request = store.get( key );

				request.onsuccess = function () {
					resolve( request.result || null );
				};

				request.onerror = function () {
					reject( request.error || new Error( 'IndexedDB read failed.' ) );
				};

				tx.oncomplete = function () {
					db.close();
				};
			} );
		} );
	}

	function idbPutAndPrune( value ) {
		return openDb().then( function ( db ) {
			return new Promise( function ( resolve, reject ) {
				var tx = db.transaction( STORE_NAME, 'readwrite' );
				var store = tx.objectStore( STORE_NAME );
				var cursorRequest;

				store.put( value );

				cursorRequest = store.openCursor();

				cursorRequest.onsuccess = function () {
					var cursor = cursorRequest.result;

					if ( !cursor ) {
						return;
					}

					if ( cursor.key !== value.key ) {
						cursor.delete();
					}

					cursor.continue();
				};

				tx.oncomplete = function () {
					db.close();
					resolve();
				};

				tx.onerror = function () {
					db.close();
					reject( tx.error || new Error( 'IndexedDB write failed.' ) );
				};
			} );
		} );
	}

	function normalizeFileTitle( title ) {
		title = String( title || '' ).trim();

		if ( !title ) {
			return '';
		}

		return /^File:/i.test( title )
			? title
			: 'File:' + title;
	}

	function fetchCatalog( api ) {
		return api.get( {
			action: 'parse',
			page: ICON_LIST_PAGE,
			prop: 'images|revid',
			formatversion: 2
		} ).then( function ( data ) {
			var parse = data && data.parse ? data.parse : {};
			var titles = Array.isArray( parse.images ) ? parse.images : [];

			titles = titles
				.map( normalizeFileTitle )
				.filter( Boolean )
				.filter( function ( title, index, list ) {
					return list.indexOf( title ) === index;
				} );

			return {
				revid: Number( parse.revid ) || 0,
				titles: titles
			};
		} );
	}

	function chunk( list, size ) {
		var out = [];
		var i;

		for ( i = 0; i < list.length; i += size ) {
			out.push( list.slice( i, i + size ) );
		}

		return out;
	}

	function fetchImageInfo( api, titles, setStatus ) {
		var batches = chunk( titles, IMAGEINFO_BATCH );
		var records = [];
		var chain = Promise.resolve();

		batches.forEach( function ( batch, batchIndex ) {
			chain = chain.then( function () {
				if ( setStatus ) {
					setStatus(
						'Reading uploaded icon files… ' +
						Math.min( batchIndex * IMAGEINFO_BATCH, titles.length ) +
						' / ' + titles.length
					);
				}

				return api.get( {
					action: 'query',
					prop: 'imageinfo',
					titles: batch.join( '|' ),
					iiprop: 'url|size|sha1',
					formatversion: 2
				} ).then( function ( data ) {
					var pages =
						data &&
						data.query &&
						Array.isArray( data.query.pages )
							? data.query.pages
							: [];

					pages.forEach( function ( page ) {
						var info =
							page &&
							Array.isArray( page.imageinfo ) &&
							page.imageinfo.length
								? page.imageinfo[ 0 ]
								: null;

						if ( !info || !info.url ) {
							return;
						}

						records.push( {
							title: normalizeFileTitle( page.title ),
							url: String( info.url ),
							sha1: String( info.sha1 || '' ),
							width: Number( info.width ) || 0,
							height: Number( info.height ) || 0
						} );
					} );
				} );
			} );
		} );

		return chain.then( function () {
			return records;
		} );
	}

	function blobToDrawable( blob ) {
		if ( window.createImageBitmap ) {
			return createImageBitmap( blob );
		}

		return new Promise( function ( resolve, reject ) {
			var url = URL.createObjectURL( blob );
			var img = new Image();

			img.onload = function () {
				URL.revokeObjectURL( url );
				resolve( img );
			};

			img.onerror = function () {
				URL.revokeObjectURL( url );
				reject( new Error( 'Image decode failed.' ) );
			};

			img.src = url;
		} );
	}

	function fetchDrawable( url ) {
		return fetch( url, {
			credentials: 'same-origin',
			cache: 'force-cache'
		} )
			.then( function ( response ) {
				if ( !response.ok ) {
					throw new Error( 'HTTP ' + response.status );
				}

				return response.blob();
			} )
			.then( blobToDrawable );
	}

	function closeDrawable( drawable ) {
		if ( drawable && typeof drawable.close === 'function' ) {
			drawable.close();
		}
	}

	function drawScaled( drawable, width, height, cropFraction ) {
		var canvas = document.createElement( 'canvas' );
		var ctx = canvas.getContext( '2d', {
			willReadFrequently: true
		} );
		var sourceWidth = Number( drawable.width || drawable.naturalWidth ) || 1;
		var sourceHeight = Number( drawable.height || drawable.naturalHeight ) || 1;
		var cropX = Math.round( sourceWidth * cropFraction );
		var cropY = Math.round( sourceHeight * cropFraction );
		var cropW = Math.max( 1, sourceWidth - cropX * 2 );
		var cropH = Math.max( 1, sourceHeight - cropY * 2 );

		canvas.width = width;
		canvas.height = height;

		ctx.imageSmoothingEnabled = true;

		if ( 'imageSmoothingQuality' in ctx ) {
			ctx.imageSmoothingQuality = 'high';
		}

		/*
		 * Transparent pixels in uploaded PNG icons normally render over a dark
		 * game/UI background in screenshots. Normalizing them to black keeps
		 * the comparison stable.
		 */
		ctx.fillStyle = '#000';
		ctx.fillRect( 0, 0, width, height );

		ctx.drawImage(
			drawable,
			cropX,
			cropY,
			cropW,
			cropH,
			0,
			0,
			width,
			height
		);

		return ctx.getImageData( 0, 0, width, height );
	}

	function luminance( r, g, b ) {
		return 0.2126 * r + 0.7152 * g + 0.0722 * b;
	}

	function computeDHash( drawable, cropFraction ) {
		var imageData = drawScaled( drawable, 9, 8, cropFraction );
		var data = imageData.data;
		var hi = 0;
		var lo = 0;
		var bit = 0;
		var y;
		var x;
		var left;
		var right;
		var li;
		var ri;

		for ( y = 0; y < 8; y++ ) {
			for ( x = 0; x < 8; x++ ) {
				li = ( y * 9 + x ) * 4;
				ri = ( y * 9 + x + 1 ) * 4;

				left = luminance(
					data[ li ],
					data[ li + 1 ],
					data[ li + 2 ]
				);

				right = luminance(
					data[ ri ],
					data[ ri + 1 ],
					data[ ri + 2 ]
				);

				if ( left > right ) {
					if ( bit < 32 ) {
						lo = ( lo | ( 1 << bit ) ) >>> 0;
					} else {
						hi = ( hi | ( 1 << ( bit - 32 ) ) ) >>> 0;
					}
				}

				bit++;
			}
		}

		return {
			hi: hi >>> 0,
			lo: lo >>> 0
		};
	}

	function computeColorGrid( drawable, cropFraction ) {
		var imageData = drawScaled(
			drawable,
			COLOR_GRID,
			COLOR_GRID,
			cropFraction
		);
		var data = imageData.data;
		var out = new Uint8Array( COLOR_GRID * COLOR_GRID * 3 );
		var source = 0;
		var target = 0;

		while ( source < data.length ) {
			out[ target++ ] = data[ source ];
			out[ target++ ] = data[ source + 1 ];
			out[ target++ ] = data[ source + 2 ];
			source += 4;
		}

		return out;
	}

	function fingerprintDrawable( drawable, cropFraction ) {
		var hash = computeDHash( drawable, cropFraction );

		return {
			hashHi: hash.hi,
			hashLo: hash.lo,
			rgb: computeColorGrid( drawable, cropFraction )
		};
	}

	function popcount32( value ) {
		value = value >>> 0;
		value = value - ( ( value >>> 1 ) & 0x55555555 );
		value = ( value & 0x33333333 ) + ( ( value >>> 2 ) & 0x33333333 );
		return (
			(
				(
					value + ( value >>> 4 )
				) & 0x0F0F0F0F
			) * 0x01010101
		) >>> 24;
	}

	function fingerprintDistance( a, b ) {
		var hashBits =
			popcount32( ( a.hashHi ^ b.hashHi ) >>> 0 ) +
			popcount32( ( a.hashLo ^ b.hashLo ) >>> 0 );

		var hashDistance = hashBits / 64;
		var length = Math.min( a.rgb.length, b.rgb.length );
		var sum = 0;
		var i;
		var delta;
		var colorDistance;

		for ( i = 0; i < length; i++ ) {
			delta = a.rgb[ i ] - b.rgb[ i ];
			sum += delta * delta;
		}

		colorDistance = Math.sqrt(
			sum / Math.max( 1, length )
		) / 255;

		/*
		 * dHash is resilient to light rescaling/compression.
		 * The small RGB grid breaks ties between similarly-shaped icons.
		 */
		return 0.58 * hashDistance + 0.42 * colorDistance;
	}

	function mapLimit( list, limit, worker ) {
		var nextIndex = 0;
		var workers = [];
		var i;

		function runWorker() {
			var index;

			if ( nextIndex >= list.length ) {
				return Promise.resolve();
			}

			index = nextIndex++;
			return Promise.resolve( worker( list[ index ], index ) )
				.then( runWorker );
		}

		for ( i = 0; i < Math.min( limit, list.length ); i++ ) {
			workers.push( runWorker() );
		}

		return Promise.all( workers );
	}

	function buildFingerprintIndex( records, setStatus ) {
		var complete = 0;
		var successful = [];
		var grouped = Object.create( null );
		var groups = [];

		/*
		 * If several icon filenames are byte-identical, imageinfo gives them
		 * the same SHA-1. Fingerprint that image only once and fan the result
		 * back out to every filename. This can substantially reduce the first
		 * build's static-file traffic without changing the visible results.
		 */
		records.forEach( function ( record ) {
			var key = record.sha1 || record.url;

			if ( !grouped[ key ] ) {
				grouped[ key ] = {
					key: key,
					representative: record,
					records: []
				};
				groups.push( grouped[ key ] );
			}

			grouped[ key ].records.push( record );
		} );

		return mapLimit(
			groups,
			DOWNLOAD_CONCURRENCY,
			function ( group ) {
				return fetchDrawable( group.representative.url )
					.then( function ( drawable ) {
						var fp = fingerprintDrawable( drawable, 0 );

						closeDrawable( drawable );

						group.records.forEach( function ( record ) {
							successful.push( {
								title: record.title,
								url: record.url,
								sha1: record.sha1,
								width: record.width,
								height: record.height,
								hashHi: fp.hashHi,
								hashLo: fp.hashLo,
								rgb: fp.rgb
							} );
						} );
					} )
					.catch( function () {
						/*
						 * A missing/corrupt/non-raster file should not prevent
						 * the rest of the icon index from becoming usable.
						 */
					} )
					.then( function () {
						complete++;

						if (
							setStatus &&
							(
								complete === groups.length ||
								complete % 25 === 0
							)
						) {
							setStatus(
								'Building pixel index… ' +
									complete + ' / ' + groups.length +
									' unique images (' +
									records.length + ' filenames)'
							);
						}
					} );
			}
		).then( function () {
			return successful;
		} );
	}

	function cacheKey( revid ) {
		return [
			'icon-list',
			ALGO_VERSION,
			revid
		].join( ':' );
	}

	function getOrBuildIndex( api, setStatus ) {
		if ( memoryIndex ) {
			return Promise.resolve( memoryIndex );
		}

		setStatus( 'Reading Icon List…' );

		return fetchCatalog( api ).then( function ( catalog ) {
			var key = cacheKey( catalog.revid );

			return idbGet( key )
				.catch( function () {
					return null;
				} )
				.then( function ( cached ) {
					if (
						cached &&
						Array.isArray( cached.records ) &&
						cached.records.length
					) {
						memoryIndex = cached.records;
						setStatus(
							'Icon index ready: ' +
								memoryIndex.length +
								' uploaded icons (cached locally).'
						);
						return memoryIndex;
					}

					return fetchImageInfo(
						api,
						catalog.titles,
						setStatus
					).then( function ( imageRecords ) {
						setStatus(
							'Found ' + imageRecords.length +
								' uploaded icons. Building pixel index…'
						);

						return buildFingerprintIndex(
							imageRecords,
							setStatus
						);
					} ).then( function ( records ) {
						memoryIndex = records;

						setStatus(
							'Icon index ready: ' +
								records.length +
								' uploaded icons.'
						);

						return idbPutAndPrune( {
							key: key,
							revid: catalog.revid,
							algorithm: ALGO_VERSION,
							builtAt: Date.now(),
							records: records
						} )
							.catch( function () {
								/*
								 * Matching still works for this session even if
								 * the browser blocks persistent storage.
								 */
							} )
							.then( function () {
								return records;
							} );
					} );
				} );
		} );
	}

	function fingerprintPastedImage( drawable ) {
		/*
		 * Small centered crop variants make the matcher forgiving of a
		 * 1-2 pixel screen-capture border while still preferring an exact crop.
		 */
		return [
			fingerprintDrawable( drawable, 0 ),
			fingerprintDrawable( drawable, 0.035 ),
			fingerprintDrawable( drawable, 0.07 )
		];
	}

	function rankMatches( targetFingerprints, records ) {
		var ranked = records.map( function ( record ) {
			var candidate = {
				hashHi: record.hashHi,
				hashLo: record.hashLo,
				rgb: record.rgb
			};

			var distance = Infinity;

			targetFingerprints.forEach( function ( target ) {
				distance = Math.min(
					distance,
					fingerprintDistance( target, candidate )
				);
			} );

			return {
				record: record,
				distance: distance,
				similarity: Math.max(
					0,
					Math.min( 100, ( 1 - distance ) * 100 )
				)
			};
		} );

		ranked.sort( function ( a, b ) {
			return a.distance - b.distance;
		} );

		return ranked.slice( 0, RESULT_COUNT );
	}

	function cleanDisplayTitle( title ) {
		return String( title || '' ).replace( /^File:/i, '' );
	}

	function iconMetadata( title ) {
		var clean = cleanDisplayTitle( title );
		var itemMatch = clean.match( /^Item\s+(\d+)\.png$/i );
		var spellMatch = clean.match( /^Spellicon_([^.]*)\.png$/i );
		var id = '';
		var kind = 'Icon file';
		var parameter = '';

		if ( itemMatch ) {
			id = itemMatch[ 1 ];
			kind = 'Item icon';
			parameter = '|lucy_img_ID = ' + id;
		} else if ( spellMatch ) {
			id = spellMatch[ 1 ];
			kind = 'Spell icon';
			parameter = '|spellicon = ' + id;
		}

		return {
			kind: kind,
			id: id,
			filename: clean,
			parameter: parameter,
			rawWiki: '[[File:' + clean + ']]',
			staticWiki: '[[File:' + clean + '|32x32px]]',
			scaledWiki: '[[File:' + clean + '|frameless|upright=1.5]]'
		};
	}

	function copyText( text, button ) {
		var original = button.textContent;

		function done() {
			button.textContent = 'Copied';
			window.setTimeout( function () {
				button.textContent = original;
			}, 1000 );
		}

		if (
			navigator.clipboard &&
			typeof navigator.clipboard.writeText === 'function'
		) {
			navigator.clipboard.writeText( text )
				.then( done )
				.catch( function () {
					window.prompt( 'Copy:', text );
				} );
			return;
		}

		window.prompt( 'Copy:', text );
	}

	function makeCopyButton( label, value ) {
		var button = document.createElement( 'button' );

		button.className = 'eql-iconfinder-copy';
		button.type = 'button';
		button.textContent = label;

		button.addEventListener( 'click', function () {
			copyText( value, button );
		} );

		return button;
	}

	function renderMatches( container, matches ) {
		container.innerHTML = '';

		var title = document.createElement( 'div' );
		title.className = 'eql-iconfinder-results-title';
		title.textContent = 'Possible Matches';
		container.appendChild( title );

		var grid = document.createElement( 'div' );
		grid.className = 'eql-iconfinder-results-grid';
		container.appendChild( grid );

		matches.forEach( function ( match ) {
			var record = match.record;
			var meta = iconMetadata( record.title );
			var card = document.createElement( 'div' );
			var img = document.createElement( 'img' );
			var textWrap = document.createElement( 'div' );
			var link = document.createElement( 'a' );
			var score = document.createElement( 'span' );
			var iconId = document.createElement( 'span' );
			var metadata = document.createElement( 'span' );
			var copyRow = document.createElement( 'div' );

			card.className = 'eql-iconfinder-result';

			img.src = record.url;
			img.alt = meta.filename;
			img.loading = 'lazy';

			link.className = 'eql-iconfinder-result-name';
			link.href = mw.util.getUrl( record.title );
			link.target = '_blank';
			link.rel = 'noopener';
			link.textContent = meta.filename;

			score.className = 'eql-iconfinder-result-score';
			score.textContent =
				match.similarity.toFixed( 1 ) + '% similarity';

			iconId.className = 'eql-iconfinder-result-id';
			iconId.textContent = meta.id
				? 'Icon ' + meta.id
				: meta.filename;

			metadata.className = 'eql-iconfinder-result-meta';
			metadata.textContent = meta.kind;

			copyRow.className = 'eql-iconfinder-copy-row';

			if ( meta.id ) {
				copyRow.appendChild(
					makeCopyButton( 'Copy ID', meta.id )
				);
			}

			if ( meta.parameter ) {
				copyRow.appendChild(
					makeCopyButton( 'Copy Parameter', meta.parameter )
				);
			}

			copyRow.appendChild(
				makeCopyButton( 'Raw Wikicode', meta.rawWiki )
			);

			copyRow.appendChild(
				makeCopyButton( '32×32', meta.staticWiki )
			);

			copyRow.appendChild(
				makeCopyButton( 'Scaled', meta.scaledWiki )
			);

			textWrap.appendChild( link );
			textWrap.appendChild( iconId );
			textWrap.appendChild( score );
			textWrap.appendChild( metadata );
			textWrap.appendChild( copyRow );

			card.appendChild( img );
			card.appendChild( textWrap );
			grid.appendChild( card );
		} );

		var note = document.createElement( 'div' );
		note.className = 'eql-iconfinder-note';
		note.textContent =
			'The tool only identifies likely matches and copies values/wikicode to the clipboard; it never changes the editor. ' +
			'For the best result, capture only the square icon with as little surrounding UI as possible.';
		container.appendChild( note );
	}

	function imageFromClipboardEvent( event ) {
		var clipboard = event.clipboardData;
		var items;
		var i;

		if ( !clipboard || !clipboard.items ) {
			return null;
		}

		items = clipboard.items;

		for ( i = 0; i < items.length; i++ ) {
			if (
				items[ i ].kind === 'file' &&
				/^image\//i.test( items[ i ].type )
			) {
				return items[ i ].getAsFile();
			}
		}

		return null;
	}

	function setPreview( img, blob ) {
		if ( currentPreviewUrl ) {
			URL.revokeObjectURL( currentPreviewUrl );
		}

		currentPreviewUrl = URL.createObjectURL( blob );
		img.src = currentPreviewUrl;
		img.classList.add( 'is-visible' );
	}

	function processBlob( blob, preview, status, results, api ) {
		var setStatus = function ( text ) {
			status.textContent = text;
		};

		setPreview( preview, blob );
		results.innerHTML =
			'<div class="eql-iconfinder-results-empty">' +
				'Analyzing pasted icon…' +
			'</div>';

		return blobToDrawable( blob )
			.then( function ( drawable ) {
				var targets = fingerprintPastedImage( drawable );
				closeDrawable( drawable );

				return getOrBuildIndex( api, setStatus )
					.then( function ( index ) {
						var matches;

						if ( !index.length ) {
							throw new Error( 'No uploaded icons were found.' );
						}

						setStatus(
							'Comparing against ' +
								index.length +
								' uploaded icons…'
						);

						/*
						 * Yield once so the status message can paint before
						 * ranking several thousand in-memory fingerprints.
						 */
						return new Promise( function ( resolve ) {
							window.setTimeout( function () {
								matches = rankMatches( targets, index );
								resolve( matches );
							}, 0 );
						} );
					} );
			} )
			.then( function ( matches ) {
				renderMatches( results, matches );

				setStatus(
					'Done. Showing the ' +
						matches.length +
						' closest matches.'
				);
			} )
			.catch( function ( error ) {
				if ( window.console && console.error ) {
					console.error( 'EQL Icon Finder:', error );
				}

				results.innerHTML =
					'<div class="eql-iconfinder-results-empty">' +
						'The icon could not be matched.' +
					'</div>';

				setStatus(
					'Icon Finder error: ' +
						( error && error.message ? error.message : 'Unknown error' )
				);
			} );
	}

	function buildUi( host ) {
		var api = new mw.Api();

		host.innerHTML = '';

		var shell = document.createElement( 'div' );
		shell.className = 'eql-iconfinder-shell';

		var left = document.createElement( 'div' );
		var paste = document.createElement( 'div' );
		var instruction = document.createElement( 'div' );
		var subinstruction = document.createElement( 'div' );
		var preview = document.createElement( 'img' );
		var status = document.createElement( 'div' );

		paste.className = 'eql-iconfinder-paste';
		paste.tabIndex = 0;
		paste.setAttribute( 'role', 'button' );
		paste.setAttribute(
			'aria-label',
			'Paste a screen capture of an EverQuest icon'
		);

		instruction.className = 'eql-iconfinder-instruction';
		instruction.textContent =
			'Screen capture and paste JUST the Icon';

		subinstruction.className = 'eql-iconfinder-subinstruction';
		subinstruction.textContent =
			'Click this box, then press Ctrl+V. You can also drag an image here.';

		preview.className = 'eql-iconfinder-preview';
		preview.alt = 'Pasted icon preview';

		status.className = 'eql-iconfinder-status';
		status.setAttribute( 'aria-live', 'polite' );
		status.textContent =
			'Nothing is loaded from Icon List until you paste an image.';

		paste.appendChild( instruction );
		paste.appendChild( subinstruction );
		paste.appendChild( preview );

		left.appendChild( paste );
		left.appendChild( status );

		var right = document.createElement( 'div' );
		var resultsTitle = document.createElement( 'div' );
		var results = document.createElement( 'div' );

		resultsTitle.className = 'eql-iconfinder-results-title';
		resultsTitle.textContent = 'Possible Matches';

		results.innerHTML =
			'<div class="eql-iconfinder-results-empty">' +
				'Paste an icon to search the uploaded images on Icon List.' +
			'</div>';

		right.appendChild( resultsTitle );
		right.appendChild( results );

		shell.appendChild( left );
		shell.appendChild( right );
		host.appendChild( shell );

		paste.addEventListener( 'click', function () {
			paste.focus();
		} );

		paste.addEventListener( 'paste', function ( event ) {
			var file = imageFromClipboardEvent( event );

			if ( !file ) {
				status.textContent =
					'Clipboard does not contain an image. Screen capture the icon, then paste again.';
				return;
			}

			event.preventDefault();

			processBlob(
				file,
				preview,
				status,
				results,
				api
			);
		} );

		paste.addEventListener( 'dragover', function ( event ) {
			event.preventDefault();
		} );

		paste.addEventListener( 'drop', function ( event ) {
			var files;

			event.preventDefault();
			files = event.dataTransfer && event.dataTransfer.files;

			if (
				!files ||
				!files.length ||
				!/^image\//i.test( files[ 0 ].type )
			) {
				status.textContent = 'Drop an image file here.';
				return;
			}

			processBlob(
				files[ 0 ],
				preview,
				status,
				results,
				api
			);
		} );

		/*
		 * Focus the paste target immediately when the panel first opens so
		 * the normal workflow is: click Icon Finder -> Ctrl+V.
		 */
		window.setTimeout( function () {
			paste.focus();
		}, 0 );
	}

	window.EQLIconFinder = {
		init: function ( host ) {
			if ( !host ) {
				return Promise.reject(
					new Error( 'Icon Finder host element is missing.' )
				);
			}

			addStyles();

			if ( initializedHost !== host ) {
				buildUi( host );
				initializedHost = host;
			} else {
				var paste = host.querySelector( '.eql-iconfinder-paste' );

				if ( paste ) {
					window.setTimeout( function () {
						paste.focus();
					}, 0 );
				}
			}

			return Promise.resolve();
		}
	};

}( mediaWiki, jQuery ) );