@fluritheme/spider
Version:
A tiny, expressive spider for your website. Delicate webs that follow DOM layers, blinking eyes, and playful pointer reactions.
156 lines • 6.56 kB
JavaScript
import { elementMatrix } from './objects.js';
import { silkWhite } from './silk-paint.js';
const ids = new WeakMap();
let nextId = 1;
const identity = (el) => {
if (!ids.has(el))
ids.set(el, nextId++);
return ids.get(el);
};
const outlines = new Map();
/** Sample the actual font's ink contour rather than attaching to an empty text bounding box. */
function letterContour(doc, letter, width, height, style) {
const key = [
letter,
width.toFixed(1),
height.toFixed(1),
style.fontFamily,
style.fontWeight,
style.fontStyle,
style.fontSize,
doc.fonts.status,
].join('|');
const cached = outlines.get(key);
if (cached)
return cached;
const scale = Math.min(1, 84 / height), canvas = doc.createElement('canvas');
canvas.width = Math.max(4, Math.ceil(width * scale) + 4);
canvas.height = Math.max(4, Math.ceil(height * scale) + 4);
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const result = [];
if (ctx) {
const size = parseFloat(style.fontSize) * scale;
ctx.font = `${style.fontStyle} ${style.fontWeight} ${size}px ${style.fontFamily}`;
ctx.fillStyle = '#fff';
ctx.textBaseline = 'alphabetic';
const metrics = ctx.measureText(letter);
const ascent = metrics.fontBoundingBoxAscent || size * 0.8, descent = metrics.fontBoundingBoxDescent || size * 0.2;
const baseline = (height * scale - ascent - descent) / 2 + ascent + 2;
const advance = metrics.width || width * scale;
ctx.save();
ctx.translate(2, 0);
ctx.scale((width * scale) / advance, 1);
ctx.fillText(letter, 0, baseline);
ctx.restore();
const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
const ink = (x, y) => (pixels[(y * canvas.width + x) * 4 + 3] ?? 0) > 110;
for (const fraction of [0.22, 0.5, 0.78]) {
const x = Math.min(canvas.width - 2, Math.max(1, Math.round(2 + width * scale * fraction)));
let top = -1, bottom = -1;
for (let y = 1; y < canvas.height - 1; y++)
if (ink(x, y)) {
top = y;
break;
}
for (let y = canvas.height - 2; y > 0; y--)
if (ink(x, y)) {
bottom = y;
break;
}
if (top >= 0)
result.push({ x: (x - 2) / scale, y: (top - 2) / scale, side: 'top' });
if (bottom >= 0 && bottom - top > 3)
result.push({ x: (x - 2) / scale, y: (bottom - 2) / scale, side: 'bottom' });
}
}
if (result.length < 2)
result.push({ x: width * 0.5, y: height * 0.2, side: 'top' }, { x: width * 0.5, y: height * 0.82, side: 'bottom' });
if (outlines.size > 160)
outlines.clear();
outlines.set(key, result);
return result;
}
export function measureNetwork(root, targets, previous) {
const anchors = [], doc = root.ownerDocument ?? root, win = doc.defaultView;
for (const heading of [...root.querySelectorAll('h1,h2,[data-spider-text]')].slice(0, 4)) {
if (heading.closest('[data-spider-ignore]') ||
win.getComputedStyle(heading).visibility !== 'visible')
continue;
const walker = doc.createTreeWalker(heading, NodeFilter.SHOW_TEXT);
let node, count = 0;
while ((node = walker.nextNode()) && count < 48) {
const text = node.textContent ?? '', style = win.getComputedStyle(node.parentElement ?? heading);
for (let i = 0; i < text.length && count < 48; i++) {
const letter = text[i];
if (/\s/.test(letter))
continue;
const range = doc.createRange();
range.setStart(node, i);
range.setEnd(node, i + 1);
const r = range.getBoundingClientRect();
if (r.width < 2 || r.height < 20)
continue;
const box = {
x: r.left + win.scrollX,
y: r.top + win.scrollY,
width: r.width,
height: r.height,
}, ink = silkWhite;
letterContour(doc, letter, r.width, r.height, style).forEach((p, index) => anchors.push({
id: `t${identity(heading)}:${count}:${index}`,
element: heading,
glyph: count,
side: p.side,
box,
ink,
x: box.x + p.x,
y: box.y + p.y,
}));
count++;
}
}
}
for (const element of targets.slice(0, 12)) {
if (!element.isConnected || element.closest('[data-spider-ignore]'))
continue;
const r = element.getBoundingClientRect(), style = win.getComputedStyle(element);
if (r.width < 40 || r.height < 40 || style.visibility !== 'visible' || style.display === 'none')
continue;
const inset = Math.max(4, (parseFloat(style.borderRadius) || 0) * 0.5), ink = silkWhite;
const matrix = elementMatrix(element, win), width = element.offsetWidth || r.width, height = element.offsetHeight || r.height;
[
[inset, inset],
[width - inset, inset],
[width - inset, height - inset],
[inset, height - inset],
].forEach(([x, y], index) => {
const p = new DOMPoint(x, y).matrixTransform(matrix);
anchors.push({
id: `o${identity(element)}:${index}`,
element,
ink,
x: p.x + win.scrollX,
y: p.y + win.scrollY,
});
});
}
const retained = previous.retained;
for (const [id, anchor] of retained)
if (!anchor.element.isConnected)
retained.delete(id);
const active = [];
for (const anchor of anchors.slice(0, 640)) {
const same = retained.get(anchor.id);
if (same) {
Object.assign(same, anchor);
active.push(same);
}
else if (retained.size < 8192) {
retained.set(anchor.id, anchor);
active.push(anchor);
}
}
// Hidden/reflowed elements keep their old identities, so returning supports reconnect to the same knots.
return { anchors: active, retained };
}
//# sourceMappingURL=silk.js.map