px-icon: initial commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
.DS_Store
|
||||
*.swp
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Mark
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,66 @@
|
||||
# px-icon
|
||||
|
||||
A tiny, dependency-free `<px-icon>` custom element that rasterizes an inline
|
||||
`<svg>` onto a shared art-pixel grid. The source `<svg>` stays untouched in
|
||||
light DOM (so server-driven morphs, e.g. Idiomorph/Datastar, can freely
|
||||
update it); the raster is rendered into shadow DOM.
|
||||
|
||||
No build step, no npm, no dependencies. It's one ES module: `px-icon.js`.
|
||||
|
||||
## Usage
|
||||
|
||||
Serve `px-icon.js` from wherever you host static files (your CDN, gitea raw
|
||||
URL, whatever) and import it as a module:
|
||||
|
||||
```html
|
||||
<script type="module" src="https://your-cdn.example/px-icon/v1.0.0/px-icon.js"></script>
|
||||
|
||||
<px-icon>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path fill="#4a90d9" d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
</px-icon>
|
||||
```
|
||||
|
||||
Or import it directly in your own module and let bundling/copying be
|
||||
someone else's problem:
|
||||
|
||||
```html
|
||||
<script type="module">
|
||||
import 'https://your-cdn.example/px-icon/v1.0.0/px-icon.js';
|
||||
</script>
|
||||
```
|
||||
|
||||
### Config
|
||||
|
||||
Set via CSS custom properties, so they cascade normally:
|
||||
|
||||
| Property | Default | Values |
|
||||
|-------------------------|-----------|----------------------------|
|
||||
| `--pxicon-ap-size` | `8` (px) | art-pixel grid cell size |
|
||||
| `--pxicon-mode` | `average` | `average` \| `point` |
|
||||
| `--pxicon-debug-delay` | `0` (ms) | artificial render delay, for testing progressive enhancement |
|
||||
|
||||
```css
|
||||
:root {
|
||||
--pxicon-ap-size: 6px;
|
||||
--pxicon-mode: point;
|
||||
}
|
||||
```
|
||||
|
||||
## Demo
|
||||
|
||||
See [`examples/index.html`](examples/index.html) — open it directly in a
|
||||
browser (served over http, not `file://`, since it uses ES modules and
|
||||
`Blob` URLs) or point your dev server at the repo root.
|
||||
|
||||
## Versioning
|
||||
|
||||
No package.json, no registry. Releases are plain git tags (`v1.0.0`, ...)
|
||||
on the file as it exists at that commit. Point your CDN/proxy at a tag
|
||||
(or commit SHA) for a pinned, immutable URL; point at `main` only if you
|
||||
want to live dangerously.
|
||||
|
||||
## License
|
||||
|
||||
MIT, see [LICENSE](LICENSE).
|
||||
@@ -0,0 +1,250 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>px-icon demo</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #1b1712;
|
||||
--panel-bg: #2a241c;
|
||||
--ink: #e8dcc4;
|
||||
--accent: #c9a15a;
|
||||
--ok: #7fae6a;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: ui-monospace, "SF Mono", Consolas, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
h1 { font-size: 15px; font-weight: 600; margin: 28px 0 4px; }
|
||||
h1:first-child { margin-top: 0; }
|
||||
.sub { color: #948a76; margin: 0 0 16px; line-height: 1.5; max-width: 640px; }
|
||||
.controls {
|
||||
display: flex; gap: 24px; flex-wrap: wrap; align-items: center;
|
||||
margin-bottom: 16px; padding: 12px 16px; background: var(--panel-bg);
|
||||
border: 1px solid #3a3327;
|
||||
}
|
||||
.control { display: flex; align-items: center; gap: 8px; }
|
||||
.control label { color: #b8ac93; }
|
||||
input[type="range"] { width: 120px; }
|
||||
.control .val { color: var(--accent); min-width: 40px; display: inline-block; }
|
||||
select {
|
||||
background: var(--bg); color: var(--ink); border: 1px solid #3a3327;
|
||||
padding: 3px 6px; font-family: inherit;
|
||||
}
|
||||
button {
|
||||
background: var(--bg); color: var(--ink); border: 1px solid var(--accent);
|
||||
padding: 6px 12px; font-family: inherit; cursor: pointer;
|
||||
}
|
||||
button:hover { background: #241f18; }
|
||||
.icon-row {
|
||||
display: flex; gap: 20px; align-items: flex-end; flex-wrap: wrap;
|
||||
margin-bottom: 8px; padding: 16px; background: var(--panel-bg);
|
||||
border: 1px solid #3a3327;
|
||||
}
|
||||
.icon-wrap { display: flex; flex-direction: column; align-items: center; gap: 6px; }
|
||||
.icon-wrap px-icon { width: 64px; height: 64px; }
|
||||
.icon-label { color: #948a76; font-size: 11px; }
|
||||
.log {
|
||||
background: #14110d; border: 1px solid #3a3327; padding: 10px 12px;
|
||||
max-height: 180px; overflow-y: auto; line-height: 1.6; margin-top: 8px;
|
||||
}
|
||||
.log .ok { color: var(--ok); }
|
||||
.log .t { color: #6b6152; }
|
||||
code { color: var(--accent); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>px-icon</h1>
|
||||
<p class="sub">
|
||||
Standard <svg> markup, authored inline as normal. The library rasterizes each one onto a shared
|
||||
art-pixel grid into its shadow DOM, while the original <svg> stays untouched in the light DOM.
|
||||
</p>
|
||||
|
||||
<div class="controls">
|
||||
<div class="control">
|
||||
<label for="apSize">art-pixel size (px)</label>
|
||||
<input type="range" id="apSize" min="2" max="20" value="6">
|
||||
<span class="val" id="apSizeVal">6</span>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label for="mode">downsample</label>
|
||||
<select id="mode">
|
||||
<option value="average">area average</option>
|
||||
<option value="point">hard point sample</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="icon-row" id="mainRow">
|
||||
<div class="icon-wrap">
|
||||
<px-icon><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<polygon points="60,50 140,50 170,90 100,180 30,90" fill="#8e1f3a" stroke="#3a0d17" stroke-width="4"/>
|
||||
<polygon points="60,50 140,50 100,80" fill="#c9385c"/>
|
||||
<polygon points="60,50 30,90 100,80" fill="#a02648"/>
|
||||
<polygon points="140,50 170,90 100,80" fill="#a02648"/>
|
||||
<polygon points="30,90 100,80 100,180" fill="#6e1730"/>
|
||||
<polygon points="170,90 100,80 100,180" fill="#5c1329"/>
|
||||
</svg></px-icon>
|
||||
<div class="icon-label">ruby (own set)</div>
|
||||
</div>
|
||||
<div class="icon-wrap">
|
||||
<px-icon><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<rect x="10" y="10" width="180" height="180" rx="14" fill="#7a5a34" stroke="#3f2c18" stroke-width="6"/>
|
||||
<rect x="26" y="26" width="148" height="148" fill="none" stroke="#c9a15a" stroke-width="3" stroke-dasharray="10 8"/>
|
||||
</svg></px-icon>
|
||||
<div class="icon-label">box (own set)</div>
|
||||
</div>
|
||||
<div class="icon-wrap">
|
||||
<px-icon><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<defs>
|
||||
<linearGradient id="pxg1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#e8c887"/>
|
||||
<stop offset="45%" stop-color="#c9a15a"/>
|
||||
<stop offset="100%" stop-color="#6b4423"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="100" cy="100" r="90" fill="url(#pxg1)"/>
|
||||
</svg></px-icon>
|
||||
<div class="icon-label">gradient (own set)</div>
|
||||
</div>
|
||||
<div class="icon-wrap">
|
||||
<px-icon><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path fill="#4a90d9" stroke="#1f5b96" stroke-width="1" d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
|
||||
</svg></px-icon>
|
||||
<div class="icon-label">layers (pasted-in external set)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1>datastar morph safety</h1>
|
||||
<p class="sub">
|
||||
This icon lives in a container Idiomorph can morph, the same engine Datastar uses for server-driven
|
||||
DOM patches. The buttons below simulate the server re-sending HTML for this fragment: once identical,
|
||||
once with a different icon. The raster survives both without flashing back to raw SVG, because the
|
||||
morph only ever touches the light-dom <svg>, never the shadow-dom canvas.
|
||||
</p>
|
||||
<div class="icon-row" id="morphRow">
|
||||
<div class="icon-wrap">
|
||||
<div id="morphTarget"><px-icon><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<polygon points="60,50 140,50 170,90 100,180 30,90" fill="#8e1f3a" stroke="#3a0d17" stroke-width="4"/>
|
||||
<polygon points="60,50 140,50 100,80" fill="#c9385c"/>
|
||||
<polygon points="60,50 30,90 100,80" fill="#a02648"/>
|
||||
<polygon points="140,50 170,90 100,80" fill="#a02648"/>
|
||||
<polygon points="30,90 100,80 100,180" fill="#6e1730"/>
|
||||
<polygon points="170,90 100,80 100,180" fill="#5c1329"/>
|
||||
</svg></px-icon></div>
|
||||
<div class="icon-label">morph target</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button id="morphSame">simulate server morph (identical markup)</button>
|
||||
<button id="morphSwap">simulate server morph (swap to different icon)</button>
|
||||
</div>
|
||||
<div class="log" id="morphLog"></div>
|
||||
|
||||
<h1>progressive enhancement</h1>
|
||||
<p class="sub">
|
||||
With a debug delay set on the render pipeline, the raw <svg> (via the shadow-root <slot>) stays
|
||||
visible the whole time rasterization is genuinely in flight, then the canvas swaps in when it's ready.
|
||||
No JS, or a slow one, degrades to a plain icon rather than nothing.
|
||||
</p>
|
||||
<div class="controls">
|
||||
<div class="control">
|
||||
<label for="peDelay">artificial render delay (ms)</label>
|
||||
<input type="range" id="peDelay" min="0" max="3000" step="250" value="0">
|
||||
<span class="val" id="peDelayVal">0</span>
|
||||
</div>
|
||||
<button id="peRerender">re-mount icon to see the delay</button>
|
||||
</div>
|
||||
<div class="icon-row" id="peRow">
|
||||
<div class="icon-wrap" id="peWrap">
|
||||
<px-icon><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path fill="#4a90d9" stroke="#1f5b96" stroke-width="1" d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
|
||||
</svg></px-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import '../px-icon.js';
|
||||
|
||||
const apSizeInput = document.getElementById('apSize');
|
||||
const apSizeVal = document.getElementById('apSizeVal');
|
||||
const modeSelect = document.getElementById('mode');
|
||||
|
||||
function applyConfig() {
|
||||
const ap = apSizeInput.value;
|
||||
apSizeVal.textContent = ap;
|
||||
document.documentElement.style.setProperty('--pxicon-ap-size', ap + 'px');
|
||||
document.documentElement.style.setProperty('--pxicon-mode', modeSelect.value);
|
||||
}
|
||||
apSizeInput.addEventListener('input', applyConfig);
|
||||
modeSelect.addEventListener('change', applyConfig);
|
||||
applyConfig();
|
||||
|
||||
// progressive enhancement delay control
|
||||
const peDelay = document.getElementById('peDelay');
|
||||
const peDelayVal = document.getElementById('peDelayVal');
|
||||
const peWrap = document.getElementById('peWrap');
|
||||
const peRerender = document.getElementById('peRerender');
|
||||
const peIconMarkup = peWrap.innerHTML;
|
||||
|
||||
peDelay.addEventListener('input', () => {
|
||||
peDelayVal.textContent = peDelay.value;
|
||||
document.documentElement.style.setProperty('--pxicon-debug-delay', peDelay.value);
|
||||
});
|
||||
peRerender.addEventListener('click', () => {
|
||||
// re-mount so connectedCallback runs again with the current delay
|
||||
peWrap.innerHTML = '';
|
||||
requestAnimationFrame(() => { peWrap.innerHTML = peIconMarkup; });
|
||||
});
|
||||
</script>
|
||||
|
||||
<script src="https://unpkg.com/idiomorph@0.7.4/dist/idiomorph.min.js"></script>
|
||||
<script type="module">
|
||||
const morphTarget = document.getElementById('morphTarget');
|
||||
const log = document.getElementById('morphLog');
|
||||
|
||||
const originalMarkup = morphTarget.innerHTML;
|
||||
const swappedMarkup = `<px-icon><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path fill="#4a90d9" stroke="#1f5b96" stroke-width="1" d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
|
||||
</svg></px-icon>`;
|
||||
|
||||
function logLine(text, ok) {
|
||||
const line = document.createElement('div');
|
||||
const t = new Date().toLocaleTimeString();
|
||||
line.innerHTML = `<span class="t">${t}</span> ${ok ? '<span class="ok">✓</span> ' : ''}${text}`;
|
||||
log.prepend(line);
|
||||
}
|
||||
|
||||
function currentCanvas() {
|
||||
const el = morphTarget.querySelector('px-icon');
|
||||
return el && el.shadowRoot ? el.shadowRoot.querySelector('canvas') : null;
|
||||
}
|
||||
|
||||
document.getElementById('morphSame').addEventListener('click', () => {
|
||||
const before = currentCanvas();
|
||||
Idiomorph.morph(morphTarget, `<div id="morphTarget">${originalMarkup}</div>`);
|
||||
const after = currentCanvas();
|
||||
logLine(
|
||||
before && after === before
|
||||
? 'identical morph applied, same <canvas> node survived untouched'
|
||||
: 'canvas node changed (unexpected, check console)',
|
||||
before && after === before
|
||||
);
|
||||
});
|
||||
|
||||
document.getElementById('morphSwap').addEventListener('click', () => {
|
||||
Idiomorph.morph(morphTarget, `<div id="morphTarget">${swappedMarkup}</div>`);
|
||||
logLine('server sent a different icon, light-dom <svg> morphed, watch it re-rasterize above', true);
|
||||
});
|
||||
|
||||
logLine('ready. click a button to simulate a server-driven morph.', false);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+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