← back to Particle Text
index.html
628 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Particle Text</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%2305060a'/%3E%3Ccircle cx='16' cy='16' r='6.5' fill='%238fd3ff'/%3E%3Ccircle cx='16' cy='16' r='11' fill='none' stroke='%23b7a6ff' stroke-width='1.2' opacity='.6'/%3E%3C/svg%3E">
<link rel="apple-touch-icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%2305060a'/%3E%3Ccircle cx='16' cy='16' r='7' fill='%238fd3ff'/%3E%3C/svg%3E">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; overflow: hidden; background: #05060a; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: #e8ecf4;
-webkit-user-select: none; user-select: none;
touch-action: none;
}
#stage { position: fixed; inset: 0; }
canvas { display: block; width: 100%; height: 100%; }
.ui {
position: fixed; left: 50%; bottom: 30px; transform: translateX(-50%);
display: flex; gap: 8px; align-items: center;
padding: 9px 10px;
background: rgba(18, 22, 34, 0.55);
border: 1px solid rgba(255,255,255,0.10);
border-radius: 16px;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
box-shadow: 0 12px 40px rgba(0,0,0,0.45);
z-index: 10;
max-width: calc(100vw - 20px);
flex-wrap: wrap; justify-content: center;
}
.ui input[type="text"] {
width: min(46vw, 300px);
padding: 11px 14px;
font-size: 15px;
color: #fff;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 11px;
outline: none;
transition: border-color .2s, background .2s;
}
.ui input[type="text"]:focus {
border-color: rgba(120,180,255,0.6);
background: rgba(255,255,255,0.10);
}
.ui input::placeholder { color: rgba(255,255,255,0.35); }
.btn {
padding: 11px 16px;
font-size: 14px; font-weight: 600;
color: #071018;
background: linear-gradient(135deg, #8fd3ff, #b7a6ff);
border: none; border-radius: 11px;
cursor: pointer;
transition: transform .12s, filter .2s;
white-space: nowrap;
}
.btn:hover { filter: brightness(1.08); }
.btn:active { transform: scale(0.95); }
.btn.ghost {
color: #cdd6e6;
background: rgba(255,255,255,0.07);
border: 1px solid rgba(255,255,255,0.12);
font-weight: 500;
}
.btn.ghost:hover { background: rgba(255,255,255,0.13); }
.hint {
position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
font-size: 12.5px; letter-spacing: .3px;
color: rgba(255,255,255,0.42);
z-index: 10; text-align: center; pointer-events: none;
width: 100%; padding: 0 12px;
transition: opacity .6s;
}
.badge {
position: fixed; top: 18px; right: 16px;
font-size: 11px; font-variant-numeric: tabular-nums;
color: rgba(255,255,255,0.30);
z-index: 10; pointer-events: none;
letter-spacing: .4px;
}
.btn:disabled { opacity: .5; cursor: default; filter: none; }
.toast {
position: fixed; left: 50%; bottom: 92px; transform: translateX(-50%);
padding: 9px 15px; border-radius: 11px;
background: rgba(18,22,34,0.82); border: 1px solid rgba(255,255,255,0.14);
-webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
color: #e8ecf4; font-size: 13px; letter-spacing: .2px;
z-index: 20; opacity: 0; pointer-events: none;
transition: opacity .25s; white-space: nowrap;
}
</style>
</head>
<body>
<div id="stage"><canvas id="c"></canvas></div>
<div class="hint" id="hint">Type a word · move through the letters to scatter · click for a burst</div>
<div class="badge" id="badge"></div>
<div class="ui">
<input id="word" type="text" value="HELLO" maxlength="24" spellcheck="false" autocomplete="off" placeholder="type a word…">
<button class="btn" id="go">Form</button>
<button class="btn ghost" id="theme" title="Cycle color theme">Theme</button>
<button class="btn ghost" id="rand" title="Random word">Shuffle</button>
<button class="btn ghost" id="gif" title="Save this motion graphic as an animated GIF">GIF</button>
<button class="btn ghost" id="code" title="Copy the single-file source code">Code</button>
</div>
<div class="toast" id="toast" aria-live="polite"></div>
<script>
(function () {
"use strict";
var canvas = document.getElementById("c");
var ctx = canvas.getContext("2d", { alpha: false });
var input = document.getElementById("word");
var goBtn = document.getElementById("go");
var themeBtn = document.getElementById("theme");
var randBtn = document.getElementById("rand");
var hintEl = document.getElementById("hint");
var badgeEl = document.getElementById("badge");
var reduceMotion = window.matchMedia &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var DPR = Math.min(window.devicePixelRatio || 1, 2);
var W = 0, H = 0;
var particles = [];
var mouse = { x: -9999, y: -9999, active: false, r: 90 };
// Offscreen canvas for sampling text pixels
var sampler = document.createElement("canvas");
var sctx = sampler.getContext("2d", { willReadFrequently: true });
// ---- color themes: t is normalized 0..1 across the text width ----
var themes = [
{ name: "Aurora", fn: function (t) { return "hsl(" + (188 + t * 96) + ",90%,66%)"; } },
{ name: "Sunset", fn: function (t) { return "hsl(" + (8 + t * 52) + ",95%,62%)"; } },
{ name: "Neon", fn: function (t) { return "hsl(" + (t * 320) + ",92%,63%)"; } },
{ name: "Emerald", fn: function (t) { return "hsl(" + (135 + t * 60) + ",80%,58%)"; } },
{ name: "Ice", fn: function (t) { return "hsl(205,70%," + (62 + t * 26) + "%)"; } }
];
var themeIdx = 0;
var WORDS = ["HELLO", "PARTICLES", "CANVAS", "MAGIC", "REFORM", "SCATTER",
"GALAXY", "AURORA", "TYPE ME", "PHYSICS", "SWARM", "GLOW"];
var FONT = "800 %spx -apple-system, 'Segoe UI', Roboto, Arial, sans-serif";
function resize() {
W = window.innerWidth;
H = window.innerHeight;
canvas.width = Math.floor(W * DPR);
canvas.height = Math.floor(H * DPR);
canvas.style.width = W + "px";
canvas.style.height = H + "px";
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
mouse.r = Math.max(70, Math.min(W, H) * 0.11);
}
// Greedy word-wrap at a given font size (font must already be set on sctx)
function wrapLines(words, maxW) {
var lines = [];
var cur = "";
for (var i = 0; i < words.length; i++) {
var test = cur ? cur + " " + words[i] : words[i];
if (cur && sctx.measureText(test).width > maxW) {
lines.push(cur);
cur = words[i];
} else {
cur = test;
}
}
if (cur) lines.push(cur);
return lines;
}
// Fit text: choose the largest size where wrapped lines fit the box.
function fitText(text, maxW, maxH) {
var words = text.split(/\s+/).filter(Boolean);
if (!words.length) words = [text];
var size = Math.min(H * 0.30, 340);
var minSize = 24;
var best = null;
while (size >= minSize) {
sctx.font = FONT.replace("%s", size);
var lines = wrapLines(words, maxW);
var widest = 0;
for (var i = 0; i < lines.length; i++) {
var w = sctx.measureText(lines[i]).width;
if (w > widest) widest = w;
}
var lineH = size * 1.08;
var totalH = lines.length * lineH;
if (widest <= maxW && totalH <= maxH) {
best = { size: size, lines: lines, lineH: lineH };
break;
}
size -= 6;
}
if (!best) {
size = minSize;
sctx.font = FONT.replace("%s", size);
best = { size: size, lines: wrapLines(words, maxW), lineH: size * 1.08 };
}
return best;
}
// Sample target points from rendered (possibly multi-line) text
function targetsForText(text) {
text = (text || "").trim();
if (!text) return [];
sampler.width = W;
sampler.height = H;
sctx.clearRect(0, 0, W, H);
sctx.fillStyle = "#fff";
sctx.textAlign = "center";
sctx.textBaseline = "middle";
var fit = fitText(text, W * 0.86, H * 0.60);
sctx.font = FONT.replace("%s", fit.size);
var n = fit.lines.length;
var blockH = n * fit.lineH;
var startY = H / 2 - blockH / 2 + fit.lineH / 2;
for (var i = 0; i < n; i++) {
sctx.fillText(fit.lines[i], W / 2, startY + i * fit.lineH);
}
var img = sctx.getImageData(0, 0, W, H).data;
// gap scales so particle count stays in a pleasing few-thousand range
var area = W * H;
var gap = area > 2200000 ? 5 : (area > 1100000 ? 4 : 3);
var pts = [];
for (var y = 0; y < H; y += gap) {
for (var x = 0; x < W; x += gap) {
if (img[(y * W + x) * 4 + 3] > 128) {
// slight jitter so the grid never looks mechanical
pts.push({ x: x + (Math.random() - 0.5) * gap, y: y + (Math.random() - 0.5) * gap });
}
}
}
return pts;
}
function color(t) { return themes[themeIdx].fn(Math.max(0, Math.min(1, t))); }
function build(text) {
var pts = targetsForText(text);
// shuffle for organic fly-in
for (var i = pts.length - 1; i > 0; i--) {
var j = (Math.random() * (i + 1)) | 0;
var tmp = pts[i]; pts[i] = pts[j]; pts[j] = tmp;
}
var need = pts.length;
while (particles.length < need) {
particles.push({
x: Math.random() * W, y: Math.random() * H,
vx: 0, vy: 0, tx: 0, ty: 0,
size: 1.6, color: "#8fd3ff", twk: Math.random() * 6.283
});
}
if (particles.length > need) particles.length = need;
for (var k = 0; k < need; k++) {
var p = particles[k];
p.tx = pts[k].x;
p.ty = pts[k].y;
p.color = color(pts[k].x / W);
p.size = 1.4 + Math.random() * 1.7;
// a gentle inward kick so formation reads as a "fly-in"
p.vx += (Math.random() - 0.5) * 2;
p.vy += (Math.random() - 0.5) * 2;
}
badgeEl.textContent = need.toLocaleString() + " particles · " + themes[themeIdx].name;
}
// click / tap = radial burst impulse from the point
function burst(x, y) {
var R = Math.min(W, H) * 0.45, R2 = R * R;
for (var i = 0; i < particles.length; i++) {
var p = particles[i];
var dx = p.x - x, dy = p.y - y;
var d2 = dx * dx + dy * dy;
if (d2 < R2) {
var d = Math.sqrt(d2) || 1;
var f = (1 - d / R) * 22;
p.vx += (dx / d) * f;
p.vy += (dy / d) * f;
}
}
}
function tick() {
// fade trail
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = "rgba(5, 6, 10, 0.30)";
ctx.fillRect(0, 0, W, H);
// additive glow for the particles
ctx.globalCompositeOperation = "lighter";
var mr = mouse.r, mr2 = mr * mr;
var spring = reduceMotion ? 0.10 : 0.020;
var friction = reduceMotion ? 0.75 : 0.88;
for (var i = 0; i < particles.length; i++) {
var p = particles[i];
var dx = p.tx - p.x, dy = p.ty - p.y;
p.vx += dx * spring;
p.vy += dy * spring;
if (mouse.active) {
var mx = p.x - mouse.x, my = p.y - mouse.y;
var d2 = mx * mx + my * my;
if (d2 < mr2 && d2 > 0.0001) {
var d = Math.sqrt(d2);
var force = (mr - d) / mr;
var f = force * 5.4;
p.vx += (mx / d) * f;
p.vy += (my / d) * f;
}
}
p.vx *= friction;
p.vy *= friction;
p.x += p.vx;
p.y += p.vy;
// subtle twinkle in size when at rest
var s = p.size;
if (!reduceMotion) {
p.twk += 0.05;
s += Math.sin(p.twk) * 0.25;
}
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, s, 0, 6.2832);
ctx.fill();
}
requestAnimationFrame(tick);
}
// ---- events ----
function moveHandler(x, y) { mouse.x = x; mouse.y = y; mouse.active = true; }
window.addEventListener("mousemove", function (e) { moveHandler(e.clientX, e.clientY); });
window.addEventListener("mouseout", function () { mouse.active = false; });
window.addEventListener("touchmove", function (e) {
if (e.touches[0]) moveHandler(e.touches[0].clientX, e.touches[0].clientY);
}, { passive: true });
window.addEventListener("touchend", function () { mouse.active = false; });
// burst on click (ignore clicks on the UI)
function isUI(t) { return t && t.closest && t.closest(".ui"); }
window.addEventListener("pointerdown", function (e) {
if (isUI(e.target)) return;
burst(e.clientX, e.clientY);
});
var resizeTimer;
window.addEventListener("resize", function () {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(function () { resize(); build(input.value); }, 150);
});
// live rebuild while typing (debounced)
var typeTimer;
input.addEventListener("input", function () {
clearTimeout(typeTimer);
typeTimer = setTimeout(function () { build(input.value); }, 260);
});
input.addEventListener("keydown", function (e) {
if (e.key === "Enter") { clearTimeout(typeTimer); build(input.value); input.blur(); }
});
goBtn.addEventListener("click", function () { build(input.value); input.blur(); });
themeBtn.addEventListener("click", function () {
themeIdx = (themeIdx + 1) % themes.length;
build(input.value);
});
randBtn.addEventListener("click", function () {
var w;
do { w = WORDS[(Math.random() * WORDS.length) | 0]; } while (w === input.value);
input.value = w;
build(w);
});
// ================= Save GIF + Copy code (dependency-free) =================
var gifBtn = document.getElementById("gif");
var codeBtn = document.getElementById("code");
var toastEl = document.getElementById("toast");
var recording = false;
function toast(msg, ms) {
toastEl.textContent = msg; toastEl.style.opacity = "1";
clearTimeout(toast._t); toast._t = setTimeout(function () { toastEl.style.opacity = "0"; }, ms || 1600);
}
// Re-scatter so the captured clip shows a full murmuration-into-text formation.
function scatterAll() {
for (var i = 0; i < particles.length; i++) {
var p = particles[i];
p.x = Math.random() * W; p.y = Math.random() * H;
p.vx = (Math.random() - 0.5) * 4; p.vy = (Math.random() - 0.5) * 4;
}
}
// --- median-cut quantizer -> 256-color palette (cols: packed 0xRRGGBB ints) ---
function buildPalette(cols) {
var boxes = [{ lo: 0, hi: cols.length }];
function ranges(box) {
var rmn = 255, rmx = 0, gmn = 255, gmx = 0, bmn = 255, bmx = 0;
for (var i = box.lo; i < box.hi; i++) {
var c = cols[i], r = (c >> 16) & 255, g = (c >> 8) & 255, b = c & 255;
if (r < rmn) rmn = r; if (r > rmx) rmx = r;
if (g < gmn) gmn = g; if (g > gmx) gmx = g;
if (b < bmn) bmn = b; if (b > bmx) bmx = b;
}
return { r: rmx - rmn, g: gmx - gmn, b: bmx - bmn };
}
while (boxes.length < 256) {
var bi = -1, brange = -1, bch = 16;
for (var i = 0; i < boxes.length; i++) {
var bx = boxes[i]; if (bx.hi - bx.lo < 2) continue;
var rg = ranges(bx), m = Math.max(rg.r, rg.g, rg.b);
if (m > brange) { brange = m; bi = i; bch = (rg.r >= rg.g && rg.r >= rg.b) ? 16 : (rg.g >= rg.b ? 8 : 0); }
}
if (bi < 0) break;
var box = boxes[bi];
var sub = cols.slice(box.lo, box.hi);
var sh = bch;
sub.sort(function (a, b) { return ((a >> sh) & 255) - ((b >> sh) & 255); });
for (var k = 0; k < sub.length; k++) cols[box.lo + k] = sub[k];
var mid = (box.lo + box.hi) >> 1;
boxes.splice(bi, 1, { lo: box.lo, hi: mid }, { lo: mid, hi: box.hi });
}
var pal = [];
for (var i2 = 0; i2 < boxes.length; i2++) {
var b2 = boxes[i2], rs = 0, gs = 0, bs = 0, n = b2.hi - b2.lo || 1;
for (var j = b2.lo; j < b2.hi; j++) { var c2 = cols[j]; rs += (c2 >> 16) & 255; gs += (c2 >> 8) & 255; bs += c2 & 255; }
pal.push([Math.round(rs / n), Math.round(gs / n), Math.round(bs / n)]);
}
while (pal.length < 256) pal.push([0, 0, 0]);
return pal;
}
function nearest(pal, r, g, b, cache) {
var key = ((r >> 2) << 12) | ((g >> 2) << 6) | (b >> 2);
var v = cache[key]; if (v >= 0) return v;
var best = 0, bd = 1e9;
for (var i = 0; i < 256; i++) {
var p = pal[i], dr = r - p[0], dg = g - p[1], db = b - p[2], d = dr * dr + dg * dg + db * db;
if (d < bd) { bd = d; best = i; if (d === 0) break; }
}
cache[key] = best; return best;
}
// --- standard GIF LZW encoder (variable code size, clear/EOI, dict reset at 4096) ---
function lzwEncode(minCode, idx) {
var out = [], cur = 0, bits = 0;
function put(code, size) { cur |= code << bits; bits += size; while (bits >= 8) { out.push(cur & 255); cur = cur >>> 8; bits -= 8; } }
var clear = 1 << minCode, eoi = clear + 1;
var size, next, dict;
function init() { size = minCode + 1; next = eoi + 1; dict = new Map(); }
init();
put(clear, size);
if (idx.length) {
var key = idx[0]; // running code (starts as the first literal)
for (var i = 1; i < idx.length; i++) {
var k = idx[i], ck = (key << 8) | k;
if (dict.has(ck)) { key = dict.get(ck); }
else {
put(key, size);
if (next === 4096) { put(clear, size); init(); } // dict full → reset
else { if (next >= (1 << size)) size++; dict.set(ck, next++); } // grow size BEFORE assigning
key = k;
}
}
put(key, size);
}
put(eoi, size);
if (bits > 0) out.push(cur & 255);
return out;
}
// --- GIF89a writer (256-color global table, infinite loop) ---
function GifWriter(w, h, pal) {
var out = [];
function byte(v) { out.push(v & 255); }
function word(v) { out.push(v & 255, (v >> 8) & 255); }
function str(s) { for (var i = 0; i < s.length; i++) byte(s.charCodeAt(i)); }
str("GIF89a"); word(w); word(h); byte(0xF7); byte(0); byte(0);
for (var i = 0; i < 256; i++) { var c = pal[i] || [0, 0, 0]; byte(c[0]); byte(c[1]); byte(c[2]); }
byte(0x21); byte(0xFF); byte(0x0B); str("NETSCAPE2.0"); byte(0x03); byte(0x01); word(0); byte(0); // loop forever
return {
addFrame: function (idx, delayCs) {
byte(0x21); byte(0xF9); byte(0x04); byte(0x00); word(delayCs); byte(0); byte(0); // graphic control
byte(0x2C); word(0); word(0); word(w); word(h); byte(0); // image descriptor
var MIN = 8; byte(MIN); // LZW min code size (8-bit palette)
var buf = lzwEncode(MIN, idx);
for (var p = 0; p < buf.length;) { var n = Math.min(255, buf.length - p); byte(n); for (var q = 0; q < n; q++) byte(buf[p + q]); p += n; }
byte(0); // block terminator
},
finish: function () { byte(0x3B); return new Uint8Array(out); }
};
}
function recordGif() {
if (recording) return;
recording = true; gifBtn.disabled = true; codeBtn.disabled = true;
var scale = Math.min(1, 480 / W);
var gw = Math.max(2, Math.round(W * scale)), gh = Math.max(2, Math.round(H * scale));
gw -= gw % 2; gh -= gh % 2;
var off = document.createElement("canvas"); off.width = gw; off.height = gh;
var octx = off.getContext("2d");
var sx = gw / W, sy = gh / H; // map particle coords into the GIF frame
var dotR = Math.max(1, Math.min(gw, gh) / 230); // crisp, visible dot at GIF scale
var frames = [], TOTAL = 40, DELAY = 6; // ~16fps, ~2.4s
octx.fillStyle = "#05060a"; octx.fillRect(0, 0, gw, gh);
scatterAll();
var f = 0;
function endRec(msg) { try { clearInterval(iv); } catch (e) {} recording = false; gifBtn.disabled = false; codeBtn.disabled = false; if (msg) toast(msg); }
var iv = setInterval(function () {
try {
// render particles straight at GIF resolution (preserves brightness + additive glow)
octx.globalCompositeOperation = "source-over";
octx.fillStyle = "rgba(5,6,10,0.34)"; octx.fillRect(0, 0, gw, gh); // motion trail
octx.globalCompositeOperation = "lighter";
for (var pi = 0; pi < particles.length; pi++) {
var pp = particles[pi];
octx.fillStyle = pp.color;
octx.beginPath();
octx.arc(pp.x * sx, pp.y * sy, dotR, 0, 6.2832);
octx.fill();
}
frames.push(octx.getImageData(0, 0, gw, gh).data.slice());
if (++f >= TOTAL) { clearInterval(iv); encode(); }
else toast("Recording GIF… " + Math.round(f / TOTAL * 100) + "%", 900);
} catch (e) { console.log(e); endRec("GIF capture failed — try again"); }
}, DELAY * 10);
function encode() {
toast("Encoding GIF…", 6000);
setTimeout(function () {
try {
// Sample for the palette: keep EVERY bright particle pixel (they're sparse but are the
// whole point), and only a thin slice of the dark background — otherwise median-cut spends
// all 256 slots on near-black and quantizes the glow away.
var cols = [], dc = 0;
for (var fi = 0; fi < frames.length; fi += 2) {
var d = frames[fi];
for (var i = 0; i < d.length; i += 4) {
var r = d[i], g = d[i + 1], b = d[i + 2];
var bright = r > g ? r : g; if (b > bright) bright = b;
if (bright > 40) cols.push((r << 16) | (g << 8) | b);
else if ((dc++ & 15) === 0) cols.push((r << 16) | (g << 8) | b);
}
}
if (cols.length < 512) for (var i = 0; i < 512; i++) cols.push(0x05060a);
var pal = buildPalette(cols);
var cache = new Int16Array(262144); for (var i = 0; i < cache.length; i++) cache[i] = -1;
var gif = GifWriter(gw, gh, pal);
for (var fi2 = 0; fi2 < frames.length; fi2++) {
var d2 = frames[fi2], idx = new Uint8Array(d2.length / 4);
for (var i2 = 0, j = 0; i2 < d2.length; i2 += 4, j++) idx[j] = nearest(pal, d2[i2], d2[i2 + 1], d2[i2 + 2], cache);
gif.addFrame(idx, DELAY);
}
var bytes = gif.finish();
var blob = new Blob([bytes], { type: "image/gif" });
var url = URL.createObjectURL(blob);
var a = document.createElement("a");
a.href = url;
a.download = "starling-" + ((input.value || "particles").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 30) || "particles") + ".gif";
document.body.appendChild(a); a.click(); a.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 4000);
recording = false; gifBtn.disabled = false; codeBtn.disabled = false;
toast("GIF saved · " + ((bytes.length / 1024) | 0) + " KB", 2400);
} catch (e) { console.log(e); endRec("GIF encode failed — try again"); }
}, 60);
}
}
function copyCode() {
function done() { toast("Source code copied to clipboard"); }
function fallback(src) {
try {
var ta = document.createElement("textarea");
ta.value = src; ta.style.position = "fixed"; ta.style.opacity = "0";
document.body.appendChild(ta); ta.select(); document.execCommand("copy"); ta.remove(); done();
} catch (e) { toast("Copy failed — see console"); console.log(src); }
}
function put(src) {
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(src).then(done, function () { fallback(src); });
else fallback(src);
}
// Prefer the on-disk source; fall back to the live DOM (works on file://).
fetch(location.href).then(function (r) { return r.text(); })
.then(put)
.catch(function () { put("<!DOCTYPE html>\n" + document.documentElement.outerHTML); });
}
gifBtn.addEventListener("click", recordGif);
codeBtn.addEventListener("click", copyCode);
// fade the hint after a bit
setTimeout(function () { hintEl.style.opacity = "0.35"; }, 6000);
// ---- init ----
resize();
ctx.fillStyle = "#05060a";
ctx.fillRect(0, 0, W, H);
build(input.value);
requestAnimationFrame(tick);
})();
</script>
</body>
</html>