MediaWiki:IconFinder.js
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 ) );