px-icon: initial commit
This commit is contained in:
+222
@@ -0,0 +1,222 @@
|
||||
// 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 };
|
||||
Reference in New Issue
Block a user