← back to CelebritySignatures

public/assets/signature-preview.js

109 lines

import {imageSource} from './signature-ink.js';
const cache=new Map(), tokens=new WeakMap(), queue=[];
// "clean" = colorized ink on white (the requirement); "fallback" = CORS-denied
// source shown uncolored (the escape hatch). Expose the tally so "ALL signatures
// are color on white" is a measurable number, not a claim — read
// window.signatureRenderStats in the console after browsing.
export const renderStats=(typeof window!=='undefined'?(window.signatureRenderStats={clean:0,fallback:0,error:0}):{clean:0,fallback:0,error:0});
function tallyRender(mode){ if(mode in renderStats)renderStats[mode]++; }
const resolvedSources=new Map();
let running=0;
function schedule(job,priority) {
  return new Promise((resolve,reject)=>{const item={job,resolve,reject};priority?queue.unshift(item):queue.push(item);pump();});
}
function pump() {
  while(running<4 && queue.length){const {job,resolve,reject}=queue.shift();running++;Promise.resolve().then(job).then(resolve,reject).finally(()=>{running--;pump();});}
}
function load(url,cors=true) {
  return new Promise((resolve,reject)=>{
    const img=new Image();if(cors)img.crossOrigin='anonymous';img.referrerPolicy='no-referrer';
    const timer=setTimeout(()=>finish(new Error('Image timed out')),12000);
    function finish(error){clearTimeout(timer);img.onload=img.onerror=null;error?reject(error):resolve(img);}
    img.onload=()=>finish();img.onerror=()=>finish(new Error('Image unavailable'));img.src=url;
  });
}
async function resolveSource(url) {
  const u=new URL(url),match=u.pathname.match(/^\/wiki\/(?:Special:FilePath\/|File:)(.+)$/i);
  if(u.hostname!=='commons.wikimedia.org'||!match)return url;
  const title='File:'+decodeURIComponent(match[1]);
  if(!resolvedSources.has(title)){
    const request=(async()=>{
      const params=new URLSearchParams({action:'query',format:'json',origin:'*',prop:'imageinfo',iiprop:'url',iiurlwidth:'2000',titles:title});
      const response=await fetch('https://commons.wikimedia.org/w/api.php?'+params,{signal:AbortSignal.timeout(10000)});
      if(!response.ok)throw new Error('Source lookup unavailable');
      const data=await response.json(),info=Object.values(data.query?.pages||{})[0]?.imageinfo?.[0];
      // High-res: serve the ORIGINAL file — an SVG renders as VECTOR (crisp at any
      // size), a raster at its full native resolution — not a downsampled thumb.
      // Signature files are small, so full-res is cheap; the thumb is a fallback.
      const direct=imageSource(info?.url||info?.thumburl);
      if(!direct||!['upload.wikimedia.org','thumb.wikimedia.org'].includes(new URL(direct).hostname))throw new Error('No source image');
      return direct;
    })();
    resolvedSources.set(title,request);request.catch(()=>resolvedSources.delete(title));
    if(resolvedSources.size>192)resolvedSources.delete(resolvedSources.keys().next().value);
  }
  return resolvedSources.get(title);
}
async function convert(source) {
  let url=source;try{url=await resolveSource(source);}catch{}
  // Show the ORIGINAL signature art directly — crisp (an SVG stays vector at any
  // size) and in its TRUE source colors, on the white ground. No canvas
  // recolor/binarize: that rasterized (blurred) the art and flattened every
  // signature to one invented color. A plain <img> needs no CORS, so more
  // sources load cleanly too.
  try { await load(url,false); return {url,mode:'clean'}; }
  catch { return {url,mode:'fallback'}; }
}
function preview(url,priority,retry) {
  const key=url;
  if(retry)cache.delete(key);
  if(cache.has(key))return cache.get(key);
  const p=schedule(()=>convert(url),priority);cache.set(key,p);
  p.catch(()=>{if(cache.get(key)===p)cache.delete(key);});
  // Bound retained pixel previews, including long gallery browsing sessions.
  if(cache.size>96)cache.delete(cache.keys().next().value);
  return p;
}
export async function renderInto(target,source,{priority=false,retry=false,label='Signature',tintKey=null}={}) {
  const token={};tokens.set(target,token);
  target.dataset.phase='loading';target.replaceChildren();
  const waiting=document.createElement('span');waiting.className='signature-message';waiting.textContent='Loading signature…';target.append(waiting);
  const url=imageSource(source,location.href);
  try {
    if(!url)throw new Error('Unsupported image source');
    const result=await preview(url,priority,retry);
    if(tokens.get(target)!==token || !target.isConnected)return;
    const img=new Image();img.alt=label;img.className='signature-image';img.src=result.url;
    await img.decode();
    if(tokens.get(target)!==token || !target.isConnected)return;
    target.replaceChildren(img);target.dataset.phase=result.mode;target.dataset.source=url;
    if(result.mode==='fallback'){
      img.classList.add('signature-fallback');
      target.title='High-contrast source preview. Automatic cleanup is unavailable for this source.';
    } else target.removeAttribute('title');
    tallyRender(result.mode);
    target.dispatchEvent(new CustomEvent('signaturepreviewchange',{bubbles:true}));return result.mode;
  } catch {
    if(tokens.get(target)!==token || !target.isConnected)return;
    tallyRender('error');
    target.dataset.phase='error';target.removeAttribute('data-source');
    const box=document.createElement('span');box.className='signature-message';box.textContent='Preview unavailable. ';
    // Do not nest buttons inside the variant selector buttons.
    if(!target.closest('button')){
      const button=document.createElement('button');button.type='button';button.textContent='Try again';
      button.onclick=e=>{e.stopPropagation();renderInto(target,source,{priority:true,retry:true,label,tintKey:target.dataset.signatureTintKey});};box.append(button);
    } else box.append('Select to retry.');
    target.replaceChildren(box);target.dispatchEvent(new CustomEvent('signaturepreviewchange',{bubbles:true}));return 'error';
  }
}
const observer=new IntersectionObserver(entries=>{
  for(const e of entries)if(e.isIntersecting){observer.unobserve(e.target);if(e.target.isConnected)renderInto(e.target,e.target.dataset.signatureSrc,{label:e.target.dataset.signatureLabel,tintKey:e.target.dataset.signatureTintKey});}
},{rootMargin:'120px'});
export function watch(root=document) {
  for(const el of root.querySelectorAll('[data-signature-src]:not([data-preview-watched])')){el.dataset.previewWatched='true';observer.observe(el);}
}
watch();
new MutationObserver(records=>{for(const r of records)for(const node of r.addedNodes)if(node.nodeType===1){if(node.matches('[data-signature-src]')){node.dataset.previewWatched='true';observer.observe(node);}watch(node);}
  for(const r of records)for(const node of r.removedNodes)if(node.nodeType===1){observer.unobserve(node);node.querySelectorAll('[data-signature-src]').forEach(el=>observer.unobserve(el));}
}).observe(document.body,{childList:true,subtree:true});