223 lines
7.6 KiB
JavaScript
223 lines
7.6 KiB
JavaScript
// px-icon: rasterizes an inline SVG (light DOM, source of truth) onto a
|
|
// shared art-pixel grid, rendered into shadow DOM (generated output).
|
|
//
|
|
// Why shadow DOM: idiomorph/Datastar morphs diff the live DOM against
|
|
// server HTML by walking childNodes, which does not include shadow trees.
|
|
// Keeping the raw <svg> in light DOM and the raster in shadow DOM means
|
|
// server-driven morphs can freely update/preserve the source icon without
|
|
// ever touching or being fought by the generated output. No ignore-morph
|
|
// markers needed.
|
|
//
|
|
// Config (ap size, downsample mode) comes from CSS custom properties so it
|
|
// cascades normally: --pxicon-ap-size, --pxicon-mode ('average' | 'point').
|
|
|
|
const DEFAULT_AP_SIZE = 8;
|
|
const DEFAULT_MODE = 'average';
|
|
const SUPERSAMPLE = 8; // per grid cell, for point-sample mode
|
|
|
|
// key -> Promise<HTMLCanvasElement> (the small pre-upscale grid raster)
|
|
const rasterCache = new Map();
|
|
|
|
function hashString(str) {
|
|
// fast non-crypto hash (FNV-1a), fine for a cache key over short svg markup
|
|
let h = 0x811c9dc5;
|
|
for (let i = 0; i < str.length; i++) {
|
|
h ^= str.charCodeAt(i);
|
|
h = Math.imul(h, 0x01000193);
|
|
}
|
|
return (h >>> 0).toString(36);
|
|
}
|
|
|
|
function snapToGrid(px, apPx) {
|
|
return Math.max(apPx, Math.round(px / apPx) * apPx);
|
|
}
|
|
|
|
function svgToImage(svgMarkup) {
|
|
return new Promise((resolve, reject) => {
|
|
const blob = new Blob([svgMarkup], { type: 'image/svg+xml' });
|
|
const url = URL.createObjectURL(blob);
|
|
const img = new Image();
|
|
img.onload = () => { URL.revokeObjectURL(url); resolve(img); };
|
|
img.onerror = (e) => { URL.revokeObjectURL(url); reject(e); };
|
|
img.src = url;
|
|
});
|
|
}
|
|
|
|
function areaAverageDownsample(img, gridW, gridH) {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = gridW;
|
|
canvas.height = gridH;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.imageSmoothingEnabled = true;
|
|
ctx.imageSmoothingQuality = 'high';
|
|
ctx.drawImage(img, 0, 0, gridW, gridH);
|
|
return canvas;
|
|
}
|
|
|
|
function pointSampleDownsample(img, gridW, gridH) {
|
|
const bufW = gridW * SUPERSAMPLE;
|
|
const bufH = gridH * SUPERSAMPLE;
|
|
|
|
const buf = document.createElement('canvas');
|
|
buf.width = bufW;
|
|
buf.height = bufH;
|
|
const bctx = buf.getContext('2d', { willReadFrequently: true });
|
|
bctx.imageSmoothingEnabled = true;
|
|
bctx.imageSmoothingQuality = 'high';
|
|
bctx.drawImage(img, 0, 0, bufW, bufH);
|
|
|
|
let srcData;
|
|
try {
|
|
srcData = bctx.getImageData(0, 0, bufW, bufH);
|
|
} catch (e) {
|
|
// tainted canvas (cross-origin source without CORS headers); caller
|
|
// falls back to area-average
|
|
throw e;
|
|
}
|
|
|
|
const gridCanvas = document.createElement('canvas');
|
|
gridCanvas.width = gridW;
|
|
gridCanvas.height = gridH;
|
|
const gctx = gridCanvas.getContext('2d');
|
|
const out = gctx.createImageData(gridW, gridH);
|
|
|
|
for (let gy = 0; gy < gridH; gy++) {
|
|
for (let gx = 0; gx < gridW; gx++) {
|
|
const sx = Math.min(bufW - 1, Math.floor((gx + 0.5) * SUPERSAMPLE));
|
|
const sy = Math.min(bufH - 1, Math.floor((gy + 0.5) * SUPERSAMPLE));
|
|
const si = (sy * bufW + sx) * 4;
|
|
const di = (gy * gridW + gx) * 4;
|
|
out.data[di] = srcData.data[si];
|
|
out.data[di + 1] = srcData.data[si + 1];
|
|
out.data[di + 2] = srcData.data[si + 2];
|
|
out.data[di + 3] = srcData.data[si + 3];
|
|
}
|
|
}
|
|
gctx.putImageData(out, 0, 0);
|
|
return gridCanvas;
|
|
}
|
|
|
|
// Returns a cached Promise<HTMLCanvasElement> for the small grid raster,
|
|
// coalescing concurrent requests for the same key.
|
|
function getGridRaster(svgMarkup, mode, gridW, gridH) {
|
|
const key = `${hashString(svgMarkup)}|${mode}|${gridW}x${gridH}`;
|
|
let entry = rasterCache.get(key);
|
|
if (entry) return entry;
|
|
|
|
entry = svgToImage(svgMarkup).then((img) => {
|
|
let raster;
|
|
if (mode === 'point') {
|
|
try {
|
|
raster = pointSampleDownsample(img, gridW, gridH);
|
|
} catch (e) {
|
|
raster = areaAverageDownsample(img, gridW, gridH);
|
|
}
|
|
} else {
|
|
raster = areaAverageDownsample(img, gridW, gridH);
|
|
}
|
|
return raster;
|
|
});
|
|
rasterCache.set(key, entry);
|
|
return entry;
|
|
}
|
|
|
|
class PxIcon extends HTMLElement {
|
|
connectedCallback() {
|
|
if (!this._upgraded) {
|
|
this._upgraded = true;
|
|
this._upgrade();
|
|
}
|
|
// (Re)start watching the light-dom source for server-driven changes,
|
|
// e.g. a morph swapping in a different icon.
|
|
this._observer ??= new MutationObserver(() => this._scheduleRender());
|
|
this._observer.observe(this, { childList: true, subtree: true, attributes: true, characterData: true });
|
|
this._scheduleRender();
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
this._observer?.disconnect();
|
|
this._resizeObserver?.disconnect();
|
|
}
|
|
|
|
_upgrade() {
|
|
const root = this.attachShadow({ mode: 'open' });
|
|
const style = document.createElement('style');
|
|
style.textContent = `
|
|
:host { display: inline-block; width: 1.5em; height: 1.5em; line-height: 0; }
|
|
canvas { display: block; width: 100%; height: 100%; image-rendering: pixelated; }
|
|
`;
|
|
root.appendChild(style);
|
|
|
|
// Progressive enhancement: keep the light-dom svg visible through a
|
|
// <slot> until the first raster is ready, so there is no flash of
|
|
// empty content while the rasterization is in flight.
|
|
const slot = document.createElement('slot');
|
|
root.appendChild(slot);
|
|
|
|
this._canvas = document.createElement('canvas');
|
|
this._shadowRoot = root;
|
|
this._slot = slot;
|
|
|
|
this._resizeObserver = new ResizeObserver(() => this._scheduleRender());
|
|
this._resizeObserver.observe(this);
|
|
}
|
|
|
|
_scheduleRender() {
|
|
// Batch bursts of mutations/resizes into a single render on the next
|
|
// frame, rather than re-rasterizing once per mutation record.
|
|
if (this._renderQueued) return;
|
|
this._renderQueued = true;
|
|
requestAnimationFrame(() => {
|
|
this._renderQueued = false;
|
|
this._render();
|
|
});
|
|
}
|
|
|
|
async _render() {
|
|
const svgEl = this.querySelector('svg');
|
|
if (!svgEl) return;
|
|
|
|
const cs = getComputedStyle(this);
|
|
const apSize = parseFloat(cs.getPropertyValue('--pxicon-ap-size')) || DEFAULT_AP_SIZE;
|
|
const mode = (cs.getPropertyValue('--pxicon-mode') || DEFAULT_MODE).trim();
|
|
|
|
const displaySize = Math.max(this.clientWidth, this.clientHeight) || 24;
|
|
const snapped = snapToGrid(displaySize, apSize);
|
|
const gridW = Math.max(1, Math.round(snapped / apSize));
|
|
const gridH = gridW;
|
|
|
|
const svgMarkup = svgEl.outerHTML;
|
|
const key = `${svgMarkup}|${mode}|${gridW}x${gridH}`;
|
|
if (this._lastKey === key) return; // nothing actually changed
|
|
this._lastKey = key;
|
|
|
|
// debug-only: --pxicon-debug-delay lets demos/tests prove the
|
|
// slot stays visible while rasterization is genuinely in flight,
|
|
// rather than faking it with a CSS transition
|
|
const debugDelay = parseFloat(cs.getPropertyValue('--pxicon-debug-delay')) || 0;
|
|
if (debugDelay > 0) await new Promise((r) => setTimeout(r, debugDelay));
|
|
|
|
const gridCanvas = await getGridRaster(svgMarkup, mode, gridW, gridH);
|
|
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const backingSize = Math.round(snapped * dpr);
|
|
this._canvas.width = backingSize;
|
|
this._canvas.height = backingSize;
|
|
const ctx = this._canvas.getContext('2d');
|
|
ctx.imageSmoothingEnabled = false;
|
|
ctx.clearRect(0, 0, backingSize, backingSize);
|
|
ctx.drawImage(gridCanvas, 0, 0, backingSize, backingSize);
|
|
|
|
if (this._slot.isConnected) {
|
|
// first successful raster: swap the placeholder slot for the
|
|
// canvas, which stops rendering the light-dom svg (no <slot>
|
|
// pointing at it anymore) and shows the raster instead
|
|
this._shadowRoot.replaceChild(this._canvas, this._slot);
|
|
}
|
|
}
|
|
}
|
|
|
|
customElements.define('px-icon', PxIcon);
|
|
|
|
export { PxIcon, rasterCache };
|