commit 627b3aeb050604206c41cc26cf8e6a2b6235a5b1
parent 20cc9a3c2939125fc9eec128caee45c88cbb542a
Author: Hunter
Date: Thu, 23 Jul 2026 23:31:42 -0400
add multiplayer via serve.py
Diffstat:
| A | .gitignore | | | 2 | ++ |
| M | index.html | | | 1402 | +------------------------------------------------------------------------------ |
| M | readme.md | | | 9 | ++++++--- |
| A | resources/brush.js | | | 141 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | resources/canvas.js | | | 256 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | resources/input.js | | | 576 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | resources/io.js | | | 269 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | resources/net.js | | | 245 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | resources/render.js | | | 237 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | resources/styles.css | | | 22 | ++++++++++++++++++++++ |
| A | resources/view.js | | | 93 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | serve.py | | | 477 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
12 files changed, 2333 insertions(+), 1396 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,2 @@
+__pycache__/
+.DS_Store
diff --git a/index.html b/index.html
@@ -5,1401 +5,17 @@
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<title>animus</title>
<link id="favicon" rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Crect width='1' height='1' fill='white'/%3E%3C/svg%3E">
-<style>
- html, body {
- margin: 0;
- padding: 0;
- width: 100%;
- height: 100%;
- overflow: hidden;
- background: #000;
- cursor: none;
- overscroll-behavior: none;
- touch-action: none;
- -webkit-user-select: none;
- user-select: none;
- -webkit-touch-callout: none;
- }
- canvas {
- display: block;
- position: absolute;
- top: 0;
- left: 0;
- image-rendering: pixelated;
- image-rendering: crisp-edges;
- }
-</style>
+<link rel="stylesheet" href="resources/styles.css">
</head>
<body>
<canvas id="view"></canvas>
-<script>
-(() => {
- const view = document.getElementById('view');
- const vctx = view.getContext('2d', { alpha: false });
-
- const CHUNK = 256;
- let frames = [new Map()];
- let frameIdx = 0;
- let chunks = frames[0];
- let dir = 1; // playback direction: 1 forward, -1 reverse
- let onionskin = false;
- let playing = false;
- let playTimer = null;
- let frameInterval = 250;
- let lastTap = 0;
-
- function chunkKey(cx, cy) { return cx + ',' + cy; }
-
- function getOrCreateChunk(cx, cy, map = chunks) {
- const k = chunkKey(cx, cy);
- let c = map.get(k);
- if (c) return c;
- const cnv = document.createElement('canvas');
- cnv.width = CHUNK;
- cnv.height = CHUNK;
- const cctx = cnv.getContext('2d', { alpha: false });
- cctx.fillStyle = '#000';
- cctx.fillRect(0, 0, CHUNK, CHUNK);
- c = { canvas: cnv, ctx: cctx };
- map.set(k, c);
- return c;
- }
-
- function paintRect(wx, wy, w, h, color) {
- const x0 = wx, y0 = wy, x1 = wx + w, y1 = wy + h;
- const cx0 = Math.floor(x0 / CHUNK);
- const cy0 = Math.floor(y0 / CHUNK);
- const cx1 = Math.floor((x1 - 1) / CHUNK);
- const cy1 = Math.floor((y1 - 1) / CHUNK);
- for (let cy = cy0; cy <= cy1; cy++) {
- for (let cx = cx0; cx <= cx1; cx++) {
- const c = getOrCreateChunk(cx, cy);
- const lx = Math.max(x0, cx * CHUNK) - cx * CHUNK;
- const ly = Math.max(y0, cy * CHUNK) - cy * CHUNK;
- const rx = Math.min(x1, (cx + 1) * CHUNK) - cx * CHUNK;
- const ry = Math.min(y1, (cy + 1) * CHUNK) - cy * CHUNK;
- c.ctx.fillStyle = color;
- c.ctx.fillRect(lx, ly, rx - lx, ry - ly);
- }
- }
- }
-
- function readPixel(wx, wy) {
- const cx = Math.floor(wx / CHUNK);
- const cy = Math.floor(wy / CHUNK);
- const k = chunkKey(cx, cy);
- const c = chunks.get(k);
- if (!c) return [0, 0, 0];
- const lx = wx - cx * CHUNK;
- const ly = wy - cy * CHUNK;
- const d = c.ctx.getImageData(lx, ly, 1, 1).data;
- return [d[0], d[1], d[2]];
- }
-
- function setFrame(i) {
- frameIdx = ((i % frames.length) + frames.length) % frames.length;
- chunks = frames[frameIdx];
- // a held pick tracks the frame under it as frames change
- if (picking) pickAt(curX, curY);
- requestDraw();
- }
-
- function addFrame() {
- const at = dir === 1 ? frameIdx + 1 : frameIdx;
- frames.splice(at, 0, new Map());
- setFrame(at);
- }
-
- function deleteFrame() {
- if (frames.length === 1) return;
- frames.splice(frameIdx, 1);
- if (playing && frames.length === 1) stopPlayback();
- setFrame(Math.min(frameIdx, frames.length - 1));
- }
-
- function stopPlayback() {
- playing = false;
- clearTimeout(playTimer);
- requestDraw();
- }
-
- function startPlayback() {
- if (frames.length === 1) return;
- playing = true;
- playTimer = setTimeout(function step() {
- setFrame(frameIdx + dir);
- playTimer = setTimeout(step, frameInterval);
- }, frameInterval);
- }
-
- function tapArrow(d) {
- dir = d;
- const now = performance.now();
- if (lastTap && now - lastTap <= 10000) frameInterval = now - lastTap;
- lastTap = now;
- if (playing) { stopPlayback(); return; }
- setFrame(frameIdx + d);
- }
-
- let dpr = window.devicePixelRatio || 1;
- let cssW = 0, cssH = 0;
-
- const ZOOM_STOPS = [2, 3, 4, 5, 6, 8, 11, 15, 21, 30];
- const MIN_ZOOM = ZOOM_STOPS[0];
- const ZOOM_SENSITIVITY = 3.5; // fractional levels per unit log(pinch factor)
- let zoomIdx = 0;
- let zoom = MIN_ZOOM;
-
- let camX = 0, camY = 0;
-
- let curX = 0, curY = 0;
- // used to keep the brush pinned under the real cursor while panning, since
- // the OS does not emit pointermove events when only the camera moves.
- let curClientX = null, curClientY = null;
- let mouseInside = false;
- let cursorAlpha = 0;
- let cursorFadeFrom = 0;
- let cursorFadeTo = 0;
- let cursorFadeStart = 0;
- const CURSOR_FADE_IN_MS = 100;
- const CURSOR_FADE_OUT_MS = 400;
- function setCursorFadeTarget(target) {
- if (target === cursorFadeTo) return;
- cursorFadeFrom = cursorAlpha;
- cursorFadeTo = target;
- cursorFadeStart = performance.now();
- requestDraw();
- }
- function showCursor() {
- mouseInside = true;
- setCursorFadeTarget(1);
- }
- function hideCursorNow() {
- mouseInside = false;
- cursorAlpha = 0;
- cursorFadeFrom = 0;
- cursorFadeTo = 0;
- }
-
- let r = 255, g = 255, b = 255;
- let brush = 1;
- let roundness = 0;
-
- let brushShape = null;
- let brushShapeKey = '';
- function getBrushShape() {
- const key = brush + ',' + roundness + ',' + r + ',' + g + ',' + b;
- if (brushShapeKey === key && brushShape) return brushShape;
- const n = brush;
- const inside = new Uint8Array(n * n);
- const rad = roundness * (n / 2);
- const rad2 = rad * rad;
- const lo = rad - 0.5;
- const hi = n - 0.5 - rad;
- for (let dy = 0; dy < n; dy++) {
- for (let dx = 0; dx < n; dx++) {
- let qx = 0, qy = 0;
- if (dx < lo) qx = lo - dx;
- else if (dx > hi) qx = dx - hi;
- if (dy < lo) qy = lo - dy;
- else if (dy > hi) qy = dy - hi;
- if (qx * qx + qy * qy <= rad2 + 1e-9) inside[dy * n + dx] = 1;
- }
- }
- const sprite = document.createElement('canvas');
- sprite.width = n;
- sprite.height = n;
- const sctx = sprite.getContext('2d');
- const img = sctx.createImageData(n, n);
- const data = img.data;
- for (let i = 0; i < n * n; i++) {
- if (inside[i]) {
- data[i * 4] = r;
- data[i * 4 + 1] = g;
- data[i * 4 + 2] = b;
- data[i * 4 + 3] = 255;
- }
- }
- sctx.putImageData(img, 0, 0);
-
- const fillRuns = [];
- for (let dy = 0; dy < n; dy++) {
- let dx = 0;
- while (dx < n) {
- if (!inside[dy * n + dx]) { dx++; continue; }
- let dx1 = dx + 1;
- while (dx1 < n && inside[dy * n + dx1]) dx1++;
- fillRuns.push(dx, dy, dx1 - dx);
- dx = dx1;
- }
- }
-
- // 1-pixel outline ring: cells outside the shape that are 8-way adjacent
- // to any inside cell. stored as row-runs in an (n+2)x(n+2) grid with
- // coordinates offset by -1 so they index directly in shape-local space.
- const m = n + 2;
- const ring = new Uint8Array(m * m);
- const isIn = (x, y) => x >= 0 && y >= 0 && x < n && y < n && inside[y * n + x] === 1;
- for (let y = -1; y <= n; y++) {
- for (let x = -1; x <= n; x++) {
- if (isIn(x, y)) continue;
- let adj = false;
- for (let oy = -1; oy <= 1 && !adj; oy++) {
- for (let ox = -1; ox <= 1 && !adj; ox++) {
- if (ox === 0 && oy === 0) continue;
- if (isIn(x + ox, y + oy)) adj = true;
- }
- }
- if (adj) ring[(y + 1) * m + (x + 1)] = 1;
- }
- }
- const outlineRuns = [];
- for (let y = 0; y < m; y++) {
- let x = 0;
- while (x < m) {
- if (!ring[y * m + x]) { x++; continue; }
- let x1 = x + 1;
- while (x1 < m && ring[y * m + x1]) x1++;
- outlineRuns.push(x - 1, y - 1, x1 - x);
- x = x1;
- }
- }
-
- brushShape = { inside, sprite, fillRuns, outlineRuns, n };
- brushShapeKey = key;
- return brushShape;
- }
-
- // outline blend: 0 = dark color (lighten with screen), 1 = light color (darken with multiply).
- let outlineLightness = 1;
- let outlineAnimStart = 0;
- let outlineAnimFrom = 1;
- let outlineAnimTo = 1;
- const OUTLINE_ANIM_MS = 250;
- function setOutlineTarget(target) {
- if (target === outlineAnimTo) return;
- outlineAnimFrom = outlineLightness;
- outlineAnimTo = target;
- outlineAnimStart = performance.now();
- requestDraw();
- }
-
- // displayed cursor fill eases toward r/g/b; painting still uses r/g/b immediately.
- let dispR = 255, dispG = 255, dispB = 255;
- let colorAnimStart = 0;
- let colorAnimFromR = 255, colorAnimFromG = 255, colorAnimFromB = 255;
- let colorAnimToR = 255, colorAnimToG = 255, colorAnimToB = 255;
- function setDisplayColorTarget(nr, ng, nb) {
- if (nr === colorAnimToR && ng === colorAnimToG && nb === colorAnimToB) return;
- colorAnimFromR = dispR; colorAnimFromG = dispG; colorAnimFromB = dispB;
- colorAnimToR = nr; colorAnimToG = ng; colorAnimToB = nb;
- colorAnimStart = performance.now();
- requestDraw();
- }
-
- const keys = {};
- let painting = false;
- let picking = false;
- let lastPaintX = null, lastPaintY = null;
- let dirty = false;
-
- function resize() {
- const firstResize = cssW === 0;
- dpr = window.devicePixelRatio || 1;
- cssW = window.innerWidth;
- cssH = window.innerHeight;
- view.style.width = cssW + 'px';
- view.style.height = cssH + 'px';
- view.width = Math.floor(cssW * dpr);
- view.height = Math.floor(cssH * dpr);
- if (firstResize) {
- curX = Math.floor(cssW / (2 * zoom));
- curY = Math.floor(cssH / (2 * zoom));
- }
- requestDraw();
- }
-
- function brushTopLeft(cx, cy) {
- const off = Math.floor(brush / 2);
- return { x: cx - off, y: cy - off };
- }
-
- function paintAt(cx, cy) {
- dirty = true;
- const tl = brushTopLeft(cx, cy);
- if (roundness === 0) {
- paintRect(tl.x, tl.y, brush, brush, 'rgb(' + r + ',' + g + ',' + b + ')');
- return;
- }
- const sprite = getBrushShape().sprite;
- const cx0 = Math.floor(tl.x / CHUNK);
- const cy0 = Math.floor(tl.y / CHUNK);
- const cx1 = Math.floor((tl.x + brush - 1) / CHUNK);
- const cy1 = Math.floor((tl.y + brush - 1) / CHUNK);
- for (let ccy = cy0; ccy <= cy1; ccy++) {
- for (let ccx = cx0; ccx <= cx1; ccx++) {
- const c = getOrCreateChunk(ccx, ccy);
- c.ctx.drawImage(sprite, tl.x - ccx * CHUNK, tl.y - ccy * CHUNK);
- }
- }
- }
-
- function paintLine(x0, y0, x1, y1) {
- let dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
- let dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
- let err = dx + dy;
- let x = x0, y = y0;
- while (true) {
- paintAt(x, y);
- if (x === x1 && y === y1) break;
- const e2 = 2 * err;
- if (e2 >= dy) { err += dy; x += sx; }
- if (e2 <= dx) { err += dx; y += sy; }
- }
- }
-
- const faviconEl = document.getElementById('favicon');
- function updateFavicon() {
- const svg = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'><rect width='1' height='1' fill='rgb(" + r + "," + g + "," + b + ")'/></svg>";
- faviconEl.href = 'data:image/svg+xml,' + encodeURIComponent(svg);
- }
-
- function pickAt(cx, cy) {
- const c = readPixel(cx, cy);
- r = c[0]; g = c[1]; b = c[2];
- setOutlineTarget((r + g + b > 384) ? 1 : 0);
- setDisplayColorTarget(r, g, b);
- updateFavicon();
- }
-
- function setColor(nr, ng, nb) {
- r = nr; g = ng; b = nb;
- setOutlineTarget((r + g + b > 384) ? 1 : 0);
- updateFavicon();
- dispR = r; dispG = g; dispB = b;
- colorAnimFromR = r; colorAnimFromG = g; colorAnimFromB = b;
- colorAnimToR = r; colorAnimToG = g; colorAnimToB = b;
- }
-
- function clientToWorld(clientX, clientY) {
- const lx = Math.floor(clientX / zoom + camX);
- const ly = Math.floor(clientY / zoom + camY);
- return { x: lx, y: ly };
- }
-
- let drawQueued = false;
- function requestDraw() {
- if (drawQueued) return;
- drawQueued = true;
- requestAnimationFrame(() => {
- drawQueued = false;
- draw();
- });
- }
-
- function draw() {
- const now = performance.now();
- if (outlineLightness !== outlineAnimTo) {
- const t = (now - outlineAnimStart) / OUTLINE_ANIM_MS;
- if (t >= 1) {
- outlineLightness = outlineAnimTo;
- } else {
- const e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
- outlineLightness = outlineAnimFrom + (outlineAnimTo - outlineAnimFrom) * e;
- requestDraw();
- }
- }
- if (dispR !== colorAnimToR || dispG !== colorAnimToG || dispB !== colorAnimToB) {
- const t = (now - colorAnimStart) / OUTLINE_ANIM_MS;
- if (t >= 1) {
- dispR = colorAnimToR; dispG = colorAnimToG; dispB = colorAnimToB;
- } else {
- const e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
- dispR = Math.round(colorAnimFromR + (colorAnimToR - colorAnimFromR) * e);
- dispG = Math.round(colorAnimFromG + (colorAnimToG - colorAnimFromG) * e);
- dispB = Math.round(colorAnimFromB + (colorAnimToB - colorAnimFromB) * e);
- requestDraw();
- }
- }
-
- const W = view.width, H = view.height;
- vctx.setTransform(1, 0, 0, 1, 0, 0);
- vctx.imageSmoothingEnabled = false;
- vctx.fillStyle = '#000';
- vctx.fillRect(0, 0, W, H);
-
- // one logical pixel on screen = integer device px. we round here so
- // every logical pixel occupies the exact same number of device pixels:
- // otherwise fractional dpr (1.25/1.5/1.75) makes nearest-neighbor
- // resampling drop or duplicate rows, producing transparent stripes
- // through painted content at high zoom.
- const pxD = Math.max(1, Math.round(zoom * dpr));
-
- const viewWLog = cssW / zoom;
- const viewHLog = cssH / zoom;
- const wx0 = camX;
- const wy0 = camY;
- const wx1 = camX + viewWLog;
- const wy1 = camY + viewHLog;
-
- const cx0 = Math.floor(wx0 / CHUNK);
- const cy0 = Math.floor(wy0 / CHUNK);
- const cx1 = Math.floor((wx1 - 1e-9) / CHUNK);
- const cy1 = Math.floor((wy1 - 1e-9) / CHUNK);
-
- // round destinations to integer device pixels to avoid seams between
- // adjacent chunks. compute right/bottom edges from the neighbor's
- // rounded left/top so shared edges line up exactly.
- const destX = (wx) => Math.round((wx - camX) * pxD);
- const destY = (wy) => Math.round((wy - camY) * pxD);
-
- for (let cy = cy0; cy <= cy1; cy++) {
- for (let cx = cx0; cx <= cx1; cx++) {
- const c = chunks.get(chunkKey(cx, cy));
- if (!c) continue;
- const x0 = destX(cx * CHUNK);
- const y0 = destY(cy * CHUNK);
- const x1 = destX((cx + 1) * CHUNK);
- const y1 = destY((cy + 1) * CHUNK);
- vctx.drawImage(c.canvas, x0, y0, x1 - x0, y1 - y0);
- }
- }
-
- // onionskin: ghost the frame behind us in playback order, screen-blended
- // so black contributes nothing over the opaque chunks
- if (onionskin && !playing && frames.length > 1) {
- const ghost = frames[(frameIdx - dir + frames.length) % frames.length];
- vctx.save();
- vctx.globalAlpha = 0.35;
- vctx.globalCompositeOperation = 'screen';
- for (let cy = cy0; cy <= cy1; cy++) {
- for (let cx = cx0; cx <= cx1; cx++) {
- const c = ghost.get(chunkKey(cx, cy));
- if (!c) continue;
- const x0 = destX(cx * CHUNK);
- const y0 = destY(cy * CHUNK);
- vctx.drawImage(c.canvas, x0, y0, destX((cx + 1) * CHUNK) - x0, destY((cy + 1) * CHUNK) - y0);
- }
- }
- vctx.restore();
- }
-
- if (cursorAlpha !== cursorFadeTo) {
- const dur = cursorFadeTo > cursorFadeFrom ? CURSOR_FADE_IN_MS : CURSOR_FADE_OUT_MS;
- const t = (now - cursorFadeStart) / dur;
- if (t >= 1) {
- cursorAlpha = cursorFadeTo;
- if (cursorFadeTo === 0) mouseInside = false;
- } else {
- const e = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
- cursorAlpha = cursorFadeFrom + (cursorFadeTo - cursorFadeFrom) * e;
- requestDraw();
- }
- }
- if (mouseInside) {
- const tl = brushTopLeft(curX, curY);
- // round to integer device pixels - camX/camY are fractional (smooth
- // pan), and fractional fillRect coordinates antialias their edges,
- // which would leave transparent lines between adjacent row strips.
- // during an active pan, anchor the cursor to the real client
- // position (rounded only to device pixels) so it tracks smoothly
- // instead of jittering as the logical-pixel floor flips back and
- // forth under a fractional camera. when the pan ends the cursor
- // snaps back to the logical-pixel grid via the idle timer below.
- let sx, sy;
- if (panning && curClientX !== null) {
- const off = Math.floor(brush / 2);
- sx = Math.round((curClientX - off * zoom) * dpr);
- sy = Math.round((curClientY - off * zoom) * dpr);
- } else {
- sx = Math.round((tl.x - camX) * pxD);
- sy = Math.round((tl.y - camY) * pxD);
- }
- const n = brush;
-
- vctx.save();
- vctx.globalAlpha = cursorAlpha;
- vctx.fillStyle = 'rgb(' + dispR + ',' + dispG + ',' + dispB + ')';
- if (roundness === 0) {
- vctx.fillRect(sx, sy, n * pxD, n * pxD);
- } else {
- const runs = getBrushShape().fillRuns;
- for (let i = 0; i < runs.length; i += 3) {
- vctx.fillRect(sx + runs[i] * pxD, sy + runs[i + 1] * pxD, runs[i + 2] * pxD, pxD);
- }
- }
-
- // 1-logical-pixel outline, cross-faded darken/lighten blend.
- const shape = roundness === 0 ? null : getBrushShape();
- const drawOutline = () => {
- if (shape) {
- const runs = shape.outlineRuns;
- for (let i = 0; i < runs.length; i += 3) {
- vctx.fillRect(sx + runs[i] * pxD, sy + runs[i + 1] * pxD, runs[i + 2] * pxD, pxD);
- }
- } else {
- vctx.fillRect(sx - pxD, sy - pxD, (n + 2) * pxD, pxD); // top
- vctx.fillRect(sx - pxD, sy + n * pxD, (n + 2) * pxD, pxD); // bottom
- vctx.fillRect(sx - pxD, sy, pxD, n * pxD); // left
- vctx.fillRect(sx + n * pxD, sy, pxD, n * pxD); // right
- }
- };
- const L = outlineLightness;
- vctx.save();
- if (L > 0) {
- vctx.globalCompositeOperation = 'multiply';
- vctx.globalAlpha = L * cursorAlpha;
- vctx.fillStyle = 'rgb(128,128,128)';
- drawOutline();
- }
- if (L < 1) {
- vctx.globalCompositeOperation = 'screen';
- vctx.globalAlpha = (1 - L) * cursorAlpha;
- vctx.fillStyle = 'rgb(128,128,128)';
- drawOutline();
- }
- vctx.restore();
- vctx.restore();
- }
- }
-
- const fileInput = document.createElement('input');
- fileInput.type = 'file';
- fileInput.accept = 'image/*';
- fileInput.style.display = 'none';
- document.body.appendChild(fileInput);
- fileInput.addEventListener('change', (e) => {
- const f = e.target.files && e.target.files[0];
- if (f) importFile(f);
- fileInput.value = '';
- });
-
- function formatTimestamp(d) {
- const p = (n) => String(n).padStart(2, '0');
- return p(d.getFullYear() % 100) + '\u00b7' + p(d.getMonth() + 1) + '\u00b7' + p(d.getDate()) + '\u00b7' + p(d.getHours()) + '\u00b7' + p(d.getMinutes()) + '\u00b7' + p(d.getSeconds());
- }
-
- // union of painted chunk bounds across all frames, so every exported
- // frame shares the canvas of the biggest one
- function frameBounds() {
- let any = false, minCx = Infinity, minCy = Infinity, maxCx = -Infinity, maxCy = -Infinity;
- for (const f of frames) {
- for (const k of f.keys()) {
- any = true;
- const [cx, cy] = k.split(',').map(Number);
- if (cx < minCx) minCx = cx;
- if (cy < minCy) minCy = cy;
- if (cx > maxCx) maxCx = cx;
- if (cy > maxCy) maxCy = cy;
- }
- }
- return any ? { minCx, minCy, maxCx, maxCy } : null;
- }
-
- function renderFrame(f, b, w, h) {
- const out = document.createElement('canvas');
- out.width = w;
- out.height = h;
- const octx = out.getContext('2d');
- octx.fillStyle = '#000';
- octx.fillRect(0, 0, w, h);
- for (const [k, c] of f) {
- const [cx, cy] = k.split(',').map(Number);
- octx.drawImage(c.canvas, (cx - b.minCx) * CHUNK, (cy - b.minCy) * CHUNK);
- }
- return octx.getImageData(0, 0, w, h);
- }
-
- function lzwEncode(minCode, data, lookup, out) {
- const clear = 1 << minCode, eoi = clear + 1;
- let codeSize = minCode + 1, next = eoi + 1;
- let dict = new Map();
- let acc = 0, accBits = 0;
- let block = [];
- const flushBlock = () => {
- if (block.length) { out.push(block.length, ...block); block = []; }
- };
- const emit = (code) => {
- acc |= code << accBits;
- accBits += codeSize;
- while (accBits >= 8) {
- block.push(acc & 255);
- acc >>= 8;
- accBits -= 8;
- if (block.length === 255) flushBlock();
- }
- };
- const idx = (i) => lookup((data[i] << 16) | (data[i + 1] << 8) | data[i + 2]);
- emit(clear);
- let prev = idx(0);
- for (let i = 4; i < data.length; i += 4) {
- const k = idx(i);
- const key = prev * 256 + k;
- if (dict.has(key)) { prev = dict.get(key); continue; }
- emit(prev);
- if (next === 4096) {
- emit(clear);
- dict = new Map();
- next = eoi + 1;
- codeSize = minCode + 1;
- } else {
- if (next >= (1 << codeSize)) codeSize++;
- dict.set(key, next++);
- }
- prev = k;
- }
- emit(prev);
- emit(eoi);
- if (accBits) block.push(acc & 255);
- flushBlock();
- }
-
- // minimal GIF89a encoder: exact global palette when <=256 colors,
- // else uniform 6x6x6 quantization
- function encodeGIF(images, w, h, delayMs) {
- const colorIdx = new Map();
- let over = false;
- for (const img of images) {
- const d = img.data;
- for (let i = 0; i < d.length && !over; i += 4) {
- const c = (d[i] << 16) | (d[i + 1] << 8) | d[i + 2];
- if (!colorIdx.has(c)) {
- if (colorIdx.size === 256) over = true;
- else colorIdx.set(c, colorIdx.size);
- }
- }
- if (over) break;
- }
- if (over) {
- colorIdx.clear();
- for (let i = 0; i < 216; i++) {
- colorIdx.set(((Math.floor(i / 36) * 51) << 16) | ((Math.floor(i / 6) % 6 * 51) << 8) | (i % 6 * 51), i);
- }
- }
- const lookup = (c) => over
- ? Math.round(((c >> 16) & 255) / 51) * 36 + Math.round(((c >> 8) & 255) / 51) * 6 + Math.round((c & 255) / 51)
- : colorIdx.get(c);
- let bits = 2;
- while ((1 << bits) < colorIdx.size) bits++;
- const out = [];
- const u16 = (v) => { out.push(v & 255, (v >> 8) & 255); };
- out.push(71, 73, 70, 56, 57, 97); // "GIF89a"
- u16(w); u16(h);
- out.push(0x80 | ((bits - 1) << 4) | (bits - 1), 0, 0);
- const pal = [...colorIdx.keys()];
- for (let i = 0; i < (1 << bits); i++) {
- const c = pal[i] || 0;
- out.push((c >> 16) & 255, (c >> 8) & 255, c & 255);
- }
- const animated = images.length > 1;
- if (animated) {
- // NETSCAPE2.0 loop forever
- out.push(0x21, 0xff, 11, 78, 69, 84, 83, 67, 65, 80, 69, 50, 46, 48, 3, 1, 0, 0, 0);
- }
- const delay = clamp(Math.round(delayMs / 10), 2, 65535);
- for (const img of images) {
- if (animated) out.push(0x21, 0xf9, 4, 0, delay & 255, (delay >> 8) & 255, 0, 0);
- out.push(0x2c);
- u16(0); u16(0); u16(w); u16(h);
- out.push(0);
- const minCode = Math.max(2, bits);
- out.push(minCode);
- lzwEncode(minCode, img.data, lookup, out);
- out.push(0);
- }
- out.push(0x3b);
- return new Uint8Array(out);
- }
-
- function exportGIF() {
- const b = frameBounds();
- if (!b) return;
- const w = (b.maxCx - b.minCx + 1) * CHUNK;
- const h = (b.maxCy - b.minCy + 1) * CHUNK;
- const imgs = frames.map((f) => renderFrame(f, b, w, h));
- if (dir === -1) imgs.reverse();
- const blob = new Blob([encodeGIF(imgs, w, h, frameInterval)], { type: 'image/gif' });
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = 'animus \u00b7 ' + formatTimestamp(new Date()) + '.gif';
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
- dirty = false;
- }
-
- function imageToFrame(src, w, h) {
- const f = new Map();
- const ox = -Math.floor(w / 2);
- const oy = -Math.floor(h / 2);
- const cx0 = Math.floor(ox / CHUNK);
- const cy0 = Math.floor(oy / CHUNK);
- const cx1 = Math.floor((ox + w - 1) / CHUNK);
- const cy1 = Math.floor((oy + h - 1) / CHUNK);
- for (let cy = cy0; cy <= cy1; cy++) {
- for (let cx = cx0; cx <= cx1; cx++) {
- getOrCreateChunk(cx, cy, f).ctx.drawImage(src, ox - cx * CHUNK, oy - cy * CHUNK);
- }
- }
- return { f, w, h, ox, oy };
- }
-
- function finishImport(newFrames, w, h, ox, oy) {
- frames = newFrames;
- dir = 1;
- setFrame(0);
- camX = ox - (cssW / zoom - w) / 2;
- camY = oy - (cssH / zoom - h) / 2;
- dirty = false;
- requestDraw();
- }
-
- async function importFile(file) {
- if (dirty && !confirm('Importing will discard the current ' + (frames.length > 1 ? 'animation' : 'painting') + '. Continue?')) return;
- if (playing) stopPlayback();
- // ImageDecoder gives us every frame of an animated gif; fall back to
- // single-image import where unsupported
- if (typeof ImageDecoder !== 'undefined') {
- try {
- const dec = new ImageDecoder({ data: await file.arrayBuffer(), type: file.type });
- await dec.tracks.ready;
- const count = dec.tracks.selectedTrack.frameCount;
- const newFrames = [];
- let r = null;
- for (let i = 0; i < count; i++) {
- const { image } = await dec.decode({ frameIndex: i });
- if (i === 0 && image.duration) frameInterval = Math.max(20, image.duration / 1000);
- r = imageToFrame(image, image.displayWidth, image.displayHeight);
- newFrames.push(r.f);
- image.close();
- }
- finishImport(newFrames, r.w, r.h, r.ox, r.oy);
- return;
- } catch (err) {}
- }
- const img = new Image();
- img.onload = () => {
- const r = imageToFrame(img, img.width, img.height);
- finishImport([r.f], r.w, r.h, r.ox, r.oy);
- URL.revokeObjectURL(img.src);
- };
- img.onerror = () => URL.revokeObjectURL(img.src);
- img.src = URL.createObjectURL(file);
- }
-
- window.addEventListener('resize', resize);
-
- // mouse and pen share the pointer-event path; finger touches are handled
- // by the touch-event path below
- function hoverPointer(e) { return e.pointerType === 'mouse' || e.pointerType === 'pen'; }
-
- window.addEventListener('pointerover', (e) => {
- if (!hoverPointer(e)) return;
- showCursor();
- curClientX = e.clientX;
- curClientY = e.clientY;
- const p = clientToWorld(e.clientX, e.clientY);
- curX = p.x;
- curY = p.y;
- requestDraw();
- });
-
- // brush resize curve: linear (fine, precise) up to BRUSH_LINEAR_MAX, then a
- // power-law ramp beyond, tuned by BRUSH_ACCEL.
- function brushFromDrag(start, dyPx) {
- const A = BRUSH_DRAG_PX_PER_STEP, T = BRUSH_LINEAR_MAX, c = BRUSH_ACCEL;
- const seam = T * A; // drag distance from size 0 to the seam
- const brushToPos = (b) => b <= T ? b * A : seam + (seam / c) * (Math.pow(b / T, c) - 1);
- const posToBrush = (p) => p <= seam ? p / A : T * Math.pow(1 + c * (p - seam) / seam, 1 / c);
- return posToBrush(brushToPos(start) + dyPx);
- }
-
- // modifier-drag: shift/cmd/RGB held -> vertical motion from the anchor
- // drives brush size / roundness / color channels. drag up (dy negative)
- // -> increase. when a value hits its bound and the drag continues past,
- // re-anchor so reversing direction responds immediately. each drag is
- // independent so any combination can run at once.
- function applyDrags(clientY) {
- if (dragBrush) {
- const dyPx = (dragBrush.anchorY - clientY) * dpr;
- const target = brushFromDrag(dragBrush.start, dyPx);
- const clamped = clamp(Math.round(target), 1, MAX_BRUSH);
- brush = clamped;
- if (target < 1 || target > MAX_BRUSH) {
- dragBrush.start = clamped;
- dragBrush.anchorY = clientY;
- }
- }
- if (dragRoundness) {
- const dyPx = (dragRoundness.anchorY - clientY) * dpr;
- const target = dragRoundness.start - dyPx / ROUNDNESS_DRAG_FULL_PX;
- const clamped = clamp(target, 0, 1);
- roundness = clamped;
- if (target !== clamped) {
- dragRoundness.start = clamped;
- dragRoundness.anchorY = clientY;
- }
- }
- if (dragColor) {
- const dyPx = (dragColor.anchorY - clientY) * dpr;
- const delta = (dyPx / COLOR_DRAG_FULL_PX) * 255;
- let nr = r, ng = g, nb = b;
- let overshoot = 0;
- if (keys['r']) {
- const t = dragColor.r0 + delta;
- nr = clamp(t, 0, 255);
- if (t !== nr) overshoot = Math.max(overshoot, Math.abs(t - nr));
- }
- if (keys['g']) {
- const t = dragColor.g0 + delta;
- ng = clamp(t, 0, 255);
- if (t !== ng) overshoot = Math.max(overshoot, Math.abs(t - ng));
- }
- if (keys['b']) {
- const t = dragColor.b0 + delta;
- nb = clamp(t, 0, 255);
- if (t !== nb) overshoot = Math.max(overshoot, Math.abs(t - nb));
- }
- setColor(Math.round(nr), Math.round(ng), Math.round(nb));
- if (overshoot > 0) {
- dragColor.r0 = nr; dragColor.g0 = ng; dragColor.b0 = nb;
- dragColor.anchorY = clientY;
- }
- }
- }
-
- window.addEventListener('pointermove', (e) => {
- if (!hoverPointer(e)) return;
- // a pen only proves it can hover by moving while lifted. without hover
- // it gets the finger treatment: modifiers adjust instead of marking
- if (e.pointerType === 'pen' && e.buttons === 0) penHover = true;
- showCursor();
- curClientX = e.clientX;
- curClientY = e.clientY;
- const p = clientToWorld(e.clientX, e.clientY);
- const nx = p.x, ny = p.y;
-
- applyDrags(e.clientY);
-
- if (painting) {
- if (lastPaintX !== null) {
- paintLine(lastPaintX, lastPaintY, nx, ny);
- } else {
- paintAt(nx, ny);
- }
- lastPaintX = nx;
- lastPaintY = ny;
- } else if (picking) {
- pickAt(nx, ny);
- }
- curX = nx;
- curY = ny;
- requestDraw();
- });
-
- window.addEventListener('pointerleave', (e) => {
- if (!hoverPointer(e)) return;
- hideCursorNow();
- requestDraw();
- });
-
- // a pen without hover support sends no move events before contact, so the
- // down event must place the cursor itself
- let penDown = false;
- let penHover = false;
- window.addEventListener('pointerdown', (e) => {
- if (!hoverPointer(e)) return;
- if (e.button !== 0) return;
- e.preventDefault();
- stopInertia();
- if (e.pointerType === 'pen') penDown = true;
- showCursor();
- curClientX = e.clientX;
- curClientY = e.clientY;
- const p = clientToWorld(e.clientX, e.clientY);
- curX = p.x;
- curY = p.y;
- // hoverless pen + modifier held -> the contact adjusts, like a finger
- if (e.pointerType === 'pen' && !penHover && !keys['c'] && (dragBrush || dragRoundness || dragColor)) {
- anchorDrags(e.clientY);
- requestDraw();
- return;
- }
- if (keys['c']) {
- picking = true;
- pickAt(curX, curY);
- requestDraw();
- return;
- }
- painting = true;
- lastPaintX = curX;
- lastPaintY = curY;
- paintAt(curX, curY);
- requestDraw();
- });
-
- function pointerUp(e) {
- if (!hoverPointer(e)) return;
- if (e.button === 0 || e.type === 'pointercancel') {
- painting = false;
- picking = false;
- lastPaintX = null;
- lastPaintY = null;
- if (e.pointerType === 'pen') {
- penDown = false;
- // hoverless pens get no more events after lift, so fade like touch
- if (mouseInside) setCursorFadeTarget(0);
- requestDraw();
- }
- }
- }
- window.addEventListener('pointerup', pointerUp);
- window.addEventListener('pointercancel', pointerUp);
-
- // touch: one finger paints, two fingers pan/pinch-zoom. once a second
- // finger lands the whole touch becomes a gesture (no painting) until all
- // fingers lift, so a stray finger during a pan never leaves a mark.
- // stylus touches are excluded here
- let touchMode = null; // 'paint' | 'adjust' | 'gesture'
- let touchPainted = false;
- let pinchDist = 0, pinchMidX = 0, pinchMidY = 0;
-
- // inertial pan: centroid velocity (css px/ms) tracked during the gesture,
- // thrown on release and decayed with exponential friction
- let panVX = 0, panVY = 0;
- let panLastT = 0;
- let inertiaRAF = null;
- function stopInertia() {
- if (inertiaRAF !== null) {
- cancelAnimationFrame(inertiaRAF);
- inertiaRAF = null;
- }
- }
- function startInertia() {
- // no throw if the finger paused before lifting or was barely moving
- const speed = Math.hypot(panVX, panVY);
- if (performance.now() - panLastT > 100 || speed < 0.05) return;
- // exponential decay per ms
- const DECEL = 0.995;
- const MAX_SPEED = 3; // css px/ms
- if (speed > MAX_SPEED) {
- panVX *= MAX_SPEED / speed;
- panVY *= MAX_SPEED / speed;
- }
- let prev = performance.now();
- const step = (now) => {
- const dt = now - prev;
- prev = now;
- camX -= panVX * dt / zoom;
- camY -= panVY * dt / zoom;
- const f = Math.pow(DECEL, dt);
- panVX *= f;
- panVY *= f;
- requestDraw();
- inertiaRAF = Math.hypot(panVX, panVY) > 0.02 ? requestAnimationFrame(step) : null;
- };
- inertiaRAF = requestAnimationFrame(step);
- }
-
- function touchCentroid(touches) {
- let x = 0, y = 0;
- for (const t of touches) { x += t.clientX; y += t.clientY; }
- return { x: x / touches.length, y: y / touches.length };
- }
-
- function fingerTouches(list) {
- const out = [];
- for (const t of list) if (t.touchType !== 'stylus') out.push(t);
- return out;
- }
-
- // re-anchor active drags to a new y and re-baseline to the current values
- // so successive drag strokes accumulate instead of snapping back
- function anchorDrags(clientY) {
- if (dragBrush) { dragBrush.anchorY = clientY; dragBrush.start = brush; }
- if (dragRoundness) { dragRoundness.anchorY = clientY; dragRoundness.start = roundness; }
- if (dragColor) { dragColor.anchorY = clientY; dragColor.r0 = r; dragColor.g0 = g; dragColor.b0 = b; }
- }
-
- function anchorGesture(touches) {
- const m = touchCentroid(touches);
- pinchMidX = m.x;
- pinchMidY = m.y;
- pinchDist = touches.length >= 2
- ? Math.hypot(touches[0].clientX - touches[1].clientX, touches[0].clientY - touches[1].clientY)
- : 0;
- }
-
- window.addEventListener('touchstart', (e) => {
- e.preventDefault();
- const fingers = fingerTouches(e.touches);
- // fingers are ignored while the pen is down so a resting hand can't
- // hijack or extend the pen's stroke
- if (fingers.length === 0 || penDown) return;
- stopInertia();
- if (fingers.length === 1 && touchMode === null) {
- const t = fingers[0];
- curClientX = t.clientX;
- curClientY = t.clientY;
- if (!keys['c'] && (dragBrush || dragRoundness || dragColor)) {
- touchMode = 'adjust';
- const p = clientToWorld(t.clientX, t.clientY);
- curX = p.x;
- curY = p.y;
- showCursor();
- anchorDrags(t.clientY);
- } else {
- const p = clientToWorld(t.clientX, t.clientY);
- curX = p.x;
- curY = p.y;
- showCursor();
- touchMode = 'paint';
- touchPainted = false;
- if (keys['c']) {
- picking = true;
- } else {
- painting = true;
- lastPaintX = curX;
- lastPaintY = curY;
- }
- }
- } else {
- touchMode = 'gesture';
- painting = false;
- picking = false;
- lastPaintX = null;
- lastPaintY = null;
- hideCursorNow();
- panVX = 0;
- panVY = 0;
- panLastT = 0;
- anchorGesture(fingers);
- }
- requestDraw();
- }, { passive: false });
-
- window.addEventListener('touchmove', (e) => {
- e.preventDefault();
- const fingers = fingerTouches(e.touches);
- if (fingers.length === 0) return;
- if (touchMode === 'paint') {
- const t = fingers[0];
- curClientX = t.clientX;
- curClientY = t.clientY;
- // on hover devices held keys adjust during the stroke, like mouse
- applyDrags(t.clientY);
- const p = clientToWorld(t.clientX, t.clientY);
- if (picking) {
- pickAt(p.x, p.y);
- } else if (painting) {
- paintLine(lastPaintX, lastPaintY, p.x, p.y);
- lastPaintX = p.x;
- lastPaintY = p.y;
- }
- touchPainted = true;
- curX = p.x;
- curY = p.y;
- } else if (touchMode === 'adjust') {
- const t = fingers[0];
- curClientX = t.clientX;
- curClientY = t.clientY;
- applyDrags(t.clientY);
- const p = clientToWorld(t.clientX, t.clientY);
- curX = p.x;
- curY = p.y;
- } else if (touchMode === 'gesture') {
- const m = touchCentroid(fingers);
- const dxs = m.x - pinchMidX;
- const dys = m.y - pinchMidY;
- camX -= dxs / zoom;
- camY -= dys / zoom;
- const nowT = performance.now();
- const dt = nowT - panLastT;
- if (dt > 0 && dt < 100) {
- panVX = panVX * 0.5 + (dxs / dt) * 0.5;
- panVY = panVY * 0.5 + (dys / dt) * 0.5;
- }
- panLastT = nowT;
- if (fingers.length >= 2) {
- const dist = Math.hypot(fingers[0].clientX - fingers[1].clientX, fingers[0].clientY - fingers[1].clientY);
- if (pinchDist > 0) applyZoom(dist / pinchDist, m.x, m.y);
- pinchDist = dist;
- }
- pinchMidX = m.x;
- pinchMidY = m.y;
- } else {
- return;
- }
- requestDraw();
- }, { passive: false });
-
- function touchEnd(e) {
- e.preventDefault();
- if (touchMode === null) return;
- const fingers = fingerTouches(e.touches);
- if (fingers.length === 0) {
- // a tap that never moved still paints its dot (or picks its point)
- if (touchMode === 'paint' && !touchPainted) {
- if (picking) pickAt(curX, curY);
- else paintAt(curX, curY);
- }
- if (touchMode === 'gesture') startInertia();
- touchMode = null;
- painting = false;
- picking = false;
- lastPaintX = null;
- lastPaintY = null;
- // fade the preview out instead of hiding it instantly
- if (mouseInside) setCursorFadeTarget(0);
- requestDraw();
- } else if (touchMode === 'gesture') {
- // re-anchor to the remaining fingers so the camera doesn't jump
- anchorGesture(fingers);
- }
- }
- window.addEventListener('touchend', touchEnd, { passive: false });
- window.addEventListener('touchcancel', touchEnd, { passive: false });
-
- function startDragMode(mode) {
- const anchorY = curClientY !== null ? curClientY : 0;
- if (mode === 'brush' && !dragBrush) dragBrush = { anchorY, start: brush };
- else if (mode === 'roundness' && !dragRoundness) dragRoundness = { anchorY, start: roundness };
- else if (mode === 'color' && !dragColor) dragColor = { anchorY, r0: r, g0: g, b0: b };
- // hoverless input: a modifier pressed mid-stroke ends the stroke and
- // the rest of the contact becomes an adjust drag. hover-capable pens
- // keep drawing (they can adjust while lifted instead)
- if (touchMode === 'paint' || (penDown && !penHover)) {
- if (touchMode === 'paint') touchMode = 'adjust';
- painting = false;
- picking = false;
- lastPaintX = null;
- lastPaintY = null;
- }
- }
- function endDragMode(mode) {
- if (mode === 'brush') dragBrush = null;
- else if (mode === 'roundness') dragRoundness = null;
- else if (mode === 'color') dragColor = null;
- }
-
- window.addEventListener('keydown', (e) => {
- if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 's') {
- e.preventDefault();
- exportGIF();
- return;
- }
- if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'o') {
- e.preventDefault();
- fileInput.click();
- return;
- }
- if ((e.metaKey || e.ctrlKey) && !e.altKey) {
- if (e.key === '=' || e.key === '+') { e.preventDefault(); stepZoom(1); return; }
- if (e.key === '-' || e.key === '_') { e.preventDefault(); stepZoom(-1); return; }
- if (e.key === '0') { e.preventDefault(); resetZoom(); return; }
- }
- if (!e.metaKey && !e.ctrlKey && !e.altKey && !e.repeat) {
- const lk = e.key.toLowerCase();
- if (e.key === 'ArrowLeft') { e.preventDefault(); tapArrow(-1); return; }
- if (e.key === 'ArrowRight') { e.preventDefault(); tapArrow(1); return; }
- if (e.key === ' ') { e.preventDefault(); playing ? stopPlayback() : startPlayback(); return; }
- if (lk === 'a') { addFrame(); return; }
- if (lk === 'd') { deleteFrame(); return; }
- if (lk === 'o') {
- if (playing) { stopPlayback(); onionskin = true; }
- else onionskin = !onionskin;
- requestDraw();
- return;
- }
- }
- const k = e.key.toLowerCase();
- const wasDown = keys[k];
- keys[k] = true;
- if (!e.metaKey && !e.ctrlKey) {
- if (k === 'z' || k === 'x' || k === 'c' || k === 'r' || k === 'g' || k === 'b') e.preventDefault();
- if (k === 'x') startDragMode('roundness');
- else if (k === 'z') startDragMode('brush');
- else if (!wasDown && (k === 'r' || k === 'g' || k === 'b')) startDragMode('color');
- else if (k === 'c' && !wasDown && (touchMode === 'paint' || touchMode === 'adjust')) {
- touchMode = 'paint';
- painting = false;
- picking = true;
- lastPaintX = null;
- lastPaintY = null;
- touchPainted = true;
- pickAt(curX, curY);
- requestDraw();
- }
- }
- });
- window.addEventListener('keyup', (e) => {
- const k = e.key.toLowerCase();
- keys[k] = false;
- if (k === 'x') endDragMode('roundness');
- else if (k === 'z') endDragMode('brush');
- else if (k === 'r' || k === 'g' || k === 'b') {
- if (!keys['r'] && !keys['g'] && !keys['b']) endDragMode('color');
- else if (dragColor) {
- // still holding at least one rgb key - re-anchor from current
- // values so the remaining keys don't jump based on released key's history
- dragColor.anchorY = curClientY !== null ? curClientY : dragColor.anchorY;
- dragColor.r0 = r; dragColor.g0 = g; dragColor.b0 = b;
- }
- }
- // releasing c mid-pick-drag -> back to adjusting if a modifier is still
- // held on a hoverless contact, else seamlessly switch to painting
- if (k === 'c' && picking) {
- picking = false;
- const hoverless = touchMode !== null || (penDown && !penHover);
- if (hoverless && (dragBrush || dragRoundness || dragColor)) {
- if (touchMode !== null) touchMode = 'adjust';
- painting = false;
- if (curClientY !== null) anchorDrags(curClientY);
- } else {
- painting = true;
- lastPaintX = curX;
- lastPaintY = curY;
- paintAt(curX, curY);
- }
- requestDraw();
- }
- });
- window.addEventListener('blur', () => {
- for (const k in keys) keys[k] = false;
- dragBrush = null; dragRoundness = null; dragColor = null;
- painting = false;
- picking = false;
- penDown = false;
- lastPaintX = null;
- lastPaintY = null;
- });
-
- function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
-
- function applyZoom(factor, ax, ay) {
- const worldAtAnchorX = camX + ax / zoom;
- const worldAtAnchorY = camY + ay / zoom;
- zoomIdx = clamp(zoomIdx + Math.log(factor) * ZOOM_SENSITIVITY, 0, ZOOM_STOPS.length - 1);
- const newZoom = ZOOM_STOPS[Math.round(zoomIdx)];
- if (newZoom !== zoom) {
- zoom = newZoom;
- camX = worldAtAnchorX - ax / zoom;
- camY = worldAtAnchorY - ay / zoom;
- }
- }
-
- function zoomTo(newZoom) {
- if (newZoom === zoom) return;
- const ax = cssW / 2, ay = cssH / 2;
- const worldAtAnchorX = camX + ax / zoom;
- const worldAtAnchorY = camY + ay / zoom;
- zoom = newZoom;
- zoomIdx = ZOOM_STOPS.indexOf(zoom);
- camX = worldAtAnchorX - ax / zoom;
- camY = worldAtAnchorY - ay / zoom;
- requestDraw();
- }
-
- function stepZoom(dir) {
- const i = ZOOM_STOPS.indexOf(zoom);
- zoomTo(ZOOM_STOPS[clamp(i + dir, 0, ZOOM_STOPS.length - 1)]);
- }
- function resetZoom() { zoomTo(MIN_ZOOM); }
-
- // wheel gesture lock: once a wheel gesture starts in a given mode,
- // subsequent events in the same burst stay in that mode until the wheel
- // goes idle or the controlling modifier set changes. this keeps trackpad
- // momentum from leaking into pan after a modifier release, while still
- // letting the user swap between modes mid-flick without stale state.
- let wheelMode = null;
- let wheelIdleTimer = null;
- let panning = false;
-
- const BRUSH_DRAG_PX_PER_STEP = 25; // device px per +/- 1 brush px (below the seam)
- const BRUSH_LINEAR_MAX = 6; // brush px below which resize stays linear/fine
- const BRUSH_ACCEL = 0.5;
- const MAX_BRUSH = 150;
- const ROUNDNESS_DRAG_FULL_PX = 200;
- const COLOR_DRAG_FULL_PX = 256;
- // each drag carries its own anchor so any combination can run at once
- let dragBrush = null; // {anchorY, start}
- let dragRoundness = null; // {anchorY, start}
- let dragColor = null; // {anchorY, r0, g0, b0}
- function touchWheelGesture() {
- if (wheelIdleTimer) clearTimeout(wheelIdleTimer);
- wheelIdleTimer = setTimeout(() => {
- wheelMode = null;
- if (panning) {
- // pan ended - resync the logical-pixel cursor from the real
- // client position so the brush snaps onto its final cell.
- panning = false;
- if (curClientX !== null) {
- const p = clientToWorld(curClientX, curClientY);
- curX = p.x;
- curY = p.y;
- }
- requestDraw();
- }
- }, 150);
- }
- function pickModifierMode(e) {
- if (e.ctrlKey) return 'zoom';
- return null;
- }
-
- window.addEventListener('wheel', (e) => {
- e.preventDefault();
- stopInertia();
-
- // re-pick mode each event from live modifiers so releasing cmd and
- // immediately starting a shift scroll switches over cleanly. if a
- // burst started with a modifier and the modifier is then released
- // mid-flick, suppress rather than leaking into pan.
- const modMode = pickModifierMode(e);
- if (modMode !== null) {
- wheelMode = modMode;
- } else if (wheelMode === null) {
- wheelMode = 'pan';
- } else if (wheelMode !== 'pan') {
- // modifier released mid-burst: drop the remaining momentum
- // instead of letting it leak into pan.
- touchWheelGesture();
- return;
- }
- touchWheelGesture();
-
- if (wheelMode === 'zoom') {
- // exponential zoom on the float accumulator so small pinches add up
- applyZoom(Math.exp(-e.deltaY * 0.02), e.clientX, e.clientY);
- requestDraw();
- return;
- }
-
- // pan - smooth fractional camera. during the pan gesture the draw
- // loop anchors the brush to the real client cursor position so it
- // tracks the pointer smoothly; curX/curY get resynced onto the
- // logical-pixel grid when the wheel idle timer fires.
- panning = true;
- camX += e.deltaX / zoom;
- camY += e.deltaY / zoom;
- requestDraw();
- }, { passive: false });
-
- window.addEventListener('beforeunload', (e) => {
- if (dirty) { e.preventDefault(); }
- });
-
- window.addEventListener('contextmenu', (e) => e.preventDefault());
-
- // block the OS pinch gesture events too (Safari)
- window.addEventListener('gesturestart', (e) => e.preventDefault());
- window.addEventListener('gesturechange', (e) => e.preventDefault());
- window.addEventListener('gestureend', (e) => e.preventDefault());
-
- resize();
-})();
-</script>
+<script src="resources/canvas.js"></script>
+<script src="resources/brush.js"></script>
+<script src="resources/view.js"></script>
+<script src="resources/render.js"></script>
+<script src="resources/io.js"></script>
+<script src="resources/input.js"></script>
+<script src="resources/net.js"></script>
+<script>resize(); netConnect();</script>
</body>
</html>
diff --git a/readme.md b/readme.md
@@ -2,7 +2,7 @@
an infinite canvas for radical digital painting.
-<!-- > inspired by KidPix, MS Paint, Mario Paint, and Flipnote Studio. -->
+<!-- > inspired by Kid Pix, Mario Paint, Flipnote Studio, and iScribble. -->
<p align="center">
<img src="readme_images/doodle.gif" width=300>
@@ -42,4 +42,7 @@ an infinite canvas for radical digital painting.
### importing / exporting
- hold `⌘` (or `ctrl`) and press `S` to save your painting as a .GIF file
-- hold `⌘` (or `ctrl`) and press `O` to open an existing painting from your filesystem (will clear the current canvas)
-\ No newline at end of file
+- hold `⌘` (or `ctrl`) and press `O` to open an existing painting from your filesystem (will clear the current canvas)
+
+### multiplayer
+run `./serve.py` and share the printed URL with anyone on your local network.
+\ No newline at end of file
diff --git a/resources/brush.js b/resources/brush.js
@@ -0,0 +1,141 @@
+let r = 255, g = 255, b = 255;
+let brush = 1;
+let roundness = 0;
+
+// keyed cache: the local brush and every peer's brush pull from the same one
+const brushShapes = new Map();
+const BRUSH_CACHE_MAX = 64;
+function getBrushShape() { return brushShapeFor(brush, roundness, r, g, b); }
+function brushShapeFor(n, round, sr, sg, sb) {
+ const key = n + ',' + round + ',' + sr + ',' + sg + ',' + sb;
+ const hit = brushShapes.get(key);
+ if (hit) return hit;
+ if (brushShapes.size >= BRUSH_CACHE_MAX) brushShapes.clear();
+ const inside = new Uint8Array(n * n);
+ const rad = round * (n / 2);
+ const rad2 = rad * rad;
+ const lo = rad - 0.5;
+ const hi = n - 0.5 - rad;
+ for (let dy = 0; dy < n; dy++) {
+ for (let dx = 0; dx < n; dx++) {
+ let qx = 0, qy = 0;
+ if (dx < lo) qx = lo - dx;
+ else if (dx > hi) qx = dx - hi;
+ if (dy < lo) qy = lo - dy;
+ else if (dy > hi) qy = dy - hi;
+ if (qx * qx + qy * qy <= rad2 + 1e-9) inside[dy * n + dx] = 1;
+ }
+ }
+ const sprite = document.createElement('canvas');
+ sprite.width = n;
+ sprite.height = n;
+ const sctx = sprite.getContext('2d');
+ const img = sctx.createImageData(n, n);
+ const data = img.data;
+ for (let i = 0; i < n * n; i++) {
+ if (inside[i]) {
+ data[i * 4] = sr;
+ data[i * 4 + 1] = sg;
+ data[i * 4 + 2] = sb;
+ data[i * 4 + 3] = 255;
+ }
+ }
+ sctx.putImageData(img, 0, 0);
+
+ const fillRuns = [];
+ for (let dy = 0; dy < n; dy++) {
+ let dx = 0;
+ while (dx < n) {
+ if (!inside[dy * n + dx]) { dx++; continue; }
+ let dx1 = dx + 1;
+ while (dx1 < n && inside[dy * n + dx1]) dx1++;
+ fillRuns.push(dx, dy, dx1 - dx);
+ dx = dx1;
+ }
+ }
+
+ // 1-pixel outline ring: cells outside the shape that are 8-way adjacent
+ // to any inside cell. stored as row-runs in an (n+2)x(n+2) grid with
+ // coordinates offset by -1 so they index directly in shape-local space.
+ const m = n + 2;
+ const ring = new Uint8Array(m * m);
+ const isIn = (x, y) => x >= 0 && y >= 0 && x < n && y < n && inside[y * n + x] === 1;
+ for (let y = -1; y <= n; y++) {
+ for (let x = -1; x <= n; x++) {
+ if (isIn(x, y)) continue;
+ let adj = false;
+ for (let oy = -1; oy <= 1 && !adj; oy++) {
+ for (let ox = -1; ox <= 1 && !adj; ox++) {
+ if (ox === 0 && oy === 0) continue;
+ if (isIn(x + ox, y + oy)) adj = true;
+ }
+ }
+ if (adj) ring[(y + 1) * m + (x + 1)] = 1;
+ }
+ }
+ const outlineRuns = [];
+ for (let y = 0; y < m; y++) {
+ let x = 0;
+ while (x < m) {
+ if (!ring[y * m + x]) { x++; continue; }
+ let x1 = x + 1;
+ while (x1 < m && ring[y * m + x1]) x1++;
+ outlineRuns.push(x - 1, y - 1, x1 - x);
+ x = x1;
+ }
+ }
+
+ const shape = { inside, sprite, fillRuns, outlineRuns, n };
+ brushShapes.set(key, shape);
+ return shape;
+}
+
+// outline blend: 0 = dark color (lighten with screen), 1 = light color (darken with multiply).
+let outlineLightness = 1;
+let outlineAnimStart = 0;
+let outlineAnimFrom = 1;
+let outlineAnimTo = 1;
+const OUTLINE_ANIM_MS = 250;
+function setOutlineTarget(target) {
+ if (target === outlineAnimTo) return;
+ outlineAnimFrom = outlineLightness;
+ outlineAnimTo = target;
+ outlineAnimStart = performance.now();
+ requestDraw();
+}
+
+// displayed cursor fill eases toward r/g/b; painting still uses r/g/b immediately.
+let dispR = 255, dispG = 255, dispB = 255;
+let colorAnimStart = 0;
+let colorAnimFromR = 255, colorAnimFromG = 255, colorAnimFromB = 255;
+let colorAnimToR = 255, colorAnimToG = 255, colorAnimToB = 255;
+function setDisplayColorTarget(nr, ng, nb) {
+ if (nr === colorAnimToR && ng === colorAnimToG && nb === colorAnimToB) return;
+ colorAnimFromR = dispR; colorAnimFromG = dispG; colorAnimFromB = dispB;
+ colorAnimToR = nr; colorAnimToG = ng; colorAnimToB = nb;
+ colorAnimStart = performance.now();
+ requestDraw();
+}
+
+const faviconEl = document.getElementById('favicon');
+function updateFavicon() {
+ const svg = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'><rect width='1' height='1' fill='rgb(" + r + "," + g + "," + b + ")'/></svg>";
+ faviconEl.href = 'data:image/svg+xml,' + encodeURIComponent(svg);
+}
+
+function pickAt(cx, cy) {
+ const c = readPixel(cx, cy);
+ r = c[0]; g = c[1]; b = c[2];
+ setOutlineTarget((r + g + b > 384) ? 1 : 0);
+ setDisplayColorTarget(r, g, b);
+ updateFavicon();
+}
+
+function setColor(nr, ng, nb) {
+ r = nr; g = ng; b = nb;
+ setOutlineTarget((r + g + b > 384) ? 1 : 0);
+ updateFavicon();
+ dispR = r; dispG = g; dispB = b;
+ colorAnimFromR = r; colorAnimFromG = g; colorAnimFromB = b;
+ colorAnimToR = r; colorAnimToG = g; colorAnimToB = b;
+}
diff --git a/resources/canvas.js b/resources/canvas.js
@@ -0,0 +1,256 @@
+const view = document.getElementById('view');
+const vctx = view.getContext('2d', { alpha: false });
+
+const CHUNK = 256;
+let frameSeq = 0;
+// frames carry a stable id so collaborators can name the same frame even
+// as the list shifts under inserts and deletes
+function newFrameMap(id) {
+ const m = new Map();
+ m.id = id || ('l' + (++frameSeq));
+ return m;
+}
+let frames = [newFrameMap()];
+let frameIdx = 0;
+let chunks = frames[0];
+let dir = 1; // playback direction: 1 forward, -1 reverse
+let onionskin = false;
+// how anything belonging to another frame reads: onionskinned content and
+// the brushes of painters standing on it
+const GHOST_ALPHA = 0.35;
+let playing = false;
+let playTimer = null;
+let frameInterval = 250;
+let lastTap = 0;
+
+function chunkKey(cx, cy) { return cx + ',' + cy; }
+
+// remote strokes redirect every paint write into the frame they belong to
+let paintMap = null;
+
+function getOrCreateChunk(cx, cy, map) {
+ map = map || paintMap || chunks;
+ const k = chunkKey(cx, cy);
+ let c = map.get(k);
+ if (c) return c;
+ const cnv = document.createElement('canvas');
+ cnv.width = CHUNK;
+ cnv.height = CHUNK;
+ // overlay chunks stay transparent so they composite over the frame
+ const cctx = cnv.getContext('2d', { alpha: !!map.overlay });
+ if (!map.overlay) {
+ cctx.fillStyle = '#000';
+ cctx.fillRect(0, 0, CHUNK, CHUNK);
+ }
+ c = { canvas: cnv, ctx: cctx };
+ map.set(k, c);
+ return c;
+}
+
+// unacked local strokes paint here, on top of the frame, until their echo
+// commits them into it in the room's order
+function overlayFor(fid) {
+ let m = net.overlays.get(fid);
+ if (!m) {
+ m = new Map();
+ m.id = fid;
+ m.overlay = true;
+ net.overlays.set(fid, m);
+ }
+ return m;
+}
+
+function paintRect(wx, wy, w, h, color) {
+ const x0 = wx, y0 = wy, x1 = wx + w, y1 = wy + h;
+ const cx0 = Math.floor(x0 / CHUNK);
+ const cy0 = Math.floor(y0 / CHUNK);
+ const cx1 = Math.floor((x1 - 1) / CHUNK);
+ const cy1 = Math.floor((y1 - 1) / CHUNK);
+ for (let cy = cy0; cy <= cy1; cy++) {
+ for (let cx = cx0; cx <= cx1; cx++) {
+ const c = getOrCreateChunk(cx, cy);
+ const lx = Math.max(x0, cx * CHUNK) - cx * CHUNK;
+ const ly = Math.max(y0, cy * CHUNK) - cy * CHUNK;
+ const rx = Math.min(x1, (cx + 1) * CHUNK) - cx * CHUNK;
+ const ry = Math.min(y1, (cy + 1) * CHUNK) - cy * CHUNK;
+ c.ctx.fillStyle = color;
+ c.ctx.fillRect(lx, ly, rx - lx, ry - ly);
+ }
+ }
+}
+
+function readPixel(wx, wy) {
+ const cx = Math.floor(wx / CHUNK);
+ const cy = Math.floor(wy / CHUNK);
+ const k = chunkKey(cx, cy);
+ const lx = wx - cx * CHUNK;
+ const ly = wy - cy * CHUNK;
+ // an unacked stroke sits on the overlay; pick what's actually on screen
+ const ov = net.overlays.get(chunks.id);
+ const oc = ov && ov.get(k);
+ if (oc) {
+ const d = oc.ctx.getImageData(lx, ly, 1, 1).data;
+ if (d[3] === 255) return [d[0], d[1], d[2]];
+ }
+ const c = chunks.get(k);
+ if (!c) return [0, 0, 0];
+ const d = c.ctx.getImageData(lx, ly, 1, 1).data;
+ return [d[0], d[1], d[2]];
+}
+
+function setFrame(i) {
+ frameIdx = ((i % frames.length) + frames.length) % frames.length;
+ chunks = frames[frameIdx];
+ // a held pick tracks the frame under it as frames change
+ if (picking) pickAt(curX, curY);
+ requestDraw();
+}
+
+// frame structure changes round-trip through the server when connected, so
+// every painter ends up with the same list in the same order
+function addFrame() {
+ const at = dir === 1 ? frameIdx + 1 : frameIdx;
+ if (net.on) { net.send({ t: 'af', at }); return; }
+ applyAddFrame(at, null, true);
+}
+
+function applyAddFrame(at, id, mine) {
+ at = clamp(at, 0, frames.length);
+ const cur = chunks;
+ frames.splice(at, 0, newFrameMap(id));
+ setFrame(mine ? at : frames.indexOf(cur));
+}
+
+function deleteFrame() {
+ if (frames.length === 1) return;
+ if (net.on) { net.send({ t: 'df', id: chunks.id }); return; }
+ applyDeleteFrame(chunks.id);
+}
+
+function applyDeleteFrame(id) {
+ if (frames.length === 1) return;
+ const i = frames.findIndex((f) => f.id === id);
+ if (i < 0) return;
+ const cur = chunks;
+ frames.splice(i, 1);
+ if (playing && frames.length === 1) stopPlayback();
+ const j = frames.indexOf(cur);
+ setFrame(j >= 0 ? j : Math.min(i, frames.length - 1));
+}
+
+function stopPlayback() {
+ playing = false;
+ clearTimeout(playTimer);
+ requestDraw();
+}
+
+function startPlayback() {
+ if (frames.length === 1) return;
+ playing = true;
+ playTimer = setTimeout(function step() {
+ setFrame(frameIdx + dir);
+ playTimer = setTimeout(step, frameInterval);
+ }, frameInterval);
+}
+
+function tapArrow(d) {
+ dir = d;
+ const now = performance.now();
+ if (lastTap && now - lastTap <= 10000) {
+ frameInterval = now - lastTap;
+ // stored, not applied to anyone live: whoever joins next inherits it
+ net.send({ t: 'interval', v: Math.round(frameInterval) });
+ }
+ lastTap = now;
+ if (playing) { stopPlayback(); return; }
+ setFrame(frameIdx + d);
+}
+
+const keys = {};
+let painting = false;
+let picking = false;
+let lastPaintX = null, lastPaintY = null;
+let dirty = false;
+
+function brushTopLeft(cx, cy) {
+ const off = Math.floor(brush / 2);
+ return { x: cx - off, y: cy - off };
+}
+
+function paintAt(cx, cy) {
+ dirty = true;
+ const tl = brushTopLeft(cx, cy);
+ if (roundness === 0) {
+ paintRect(tl.x, tl.y, brush, brush, 'rgb(' + r + ',' + g + ',' + b + ')');
+ return;
+ }
+ const sprite = getBrushShape().sprite;
+ const cx0 = Math.floor(tl.x / CHUNK);
+ const cy0 = Math.floor(tl.y / CHUNK);
+ const cx1 = Math.floor((tl.x + brush - 1) / CHUNK);
+ const cy1 = Math.floor((tl.y + brush - 1) / CHUNK);
+ for (let ccy = cy0; ccy <= cy1; ccy++) {
+ for (let ccx = cx0; ccx <= cx1; ccx++) {
+ const c = getOrCreateChunk(ccx, ccy);
+ c.ctx.drawImage(sprite, tl.x - ccx * CHUNK, tl.y - ccy * CHUNK);
+ }
+ }
+}
+
+// every mark the local user makes goes out as a segment; a dot
+// is just a zero-length one. in a room the pixels land on the frame's
+// overlay - the frame itself only takes strokes in the server's order
+function localPaintAt(x, y) {
+ if (net.on) paintMap = overlayFor(chunks.id);
+ try {
+ paintAt(x, y);
+ } finally {
+ paintMap = null;
+ }
+ net.paint(x, y, x, y);
+}
+
+function localPaintLine(x0, y0, x1, y1) {
+ if (net.on) paintMap = overlayFor(chunks.id);
+ try {
+ paintLine(x0, y0, x1, y1);
+ } finally {
+ paintMap = null;
+ }
+ net.paint(x0, y0, x1, y1);
+}
+
+// replay someone else's segment into whichever frame it belongs to, with
+// their brush, without disturbing the local one
+function applyPaintOp(op) {
+ const f = frames.find((fr) => fr.id === op.f);
+ if (!f || !op.o || !op.c) return;
+ const pm = paintMap, pr = r, pg = g, pb = b, pbrush = brush, pround = roundness;
+ paintMap = f;
+ r = op.c[0]; g = op.c[1]; b = op.c[2];
+ brush = op.b; roundness = op.s;
+ try {
+ for (let i = 0; i + 3 < op.o.length; i += 4) {
+ paintLine(op.o[i], op.o[i + 1], op.o[i + 2], op.o[i + 3]);
+ }
+ } finally {
+ paintMap = pm;
+ r = pr; g = pg; b = pb;
+ brush = pbrush; roundness = pround;
+ }
+ if (f === chunks) requestDraw();
+}
+
+function paintLine(x0, y0, x1, y1) {
+ let dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
+ let dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
+ let err = dx + dy;
+ let x = x0, y = y0;
+ while (true) {
+ paintAt(x, y);
+ if (x === x1 && y === y1) break;
+ const e2 = 2 * err;
+ if (e2 >= dy) { err += dy; x += sx; }
+ if (e2 <= dx) { err += dx; y += sy; }
+ }
+}
diff --git a/resources/input.js b/resources/input.js
@@ -0,0 +1,576 @@
+window.addEventListener('resize', resize);
+
+// mouse and pen share the pointer-event path; finger touches are handled
+// by the touch-event path below
+function hoverPointer(e) { return e.pointerType === 'mouse' || e.pointerType === 'pen'; }
+
+window.addEventListener('pointerover', (e) => {
+ if (!hoverPointer(e)) return;
+ showCursor();
+ curClientX = e.clientX;
+ curClientY = e.clientY;
+ const p = clientToWorld(e.clientX, e.clientY);
+ curX = p.x;
+ curY = p.y;
+ requestDraw();
+});
+
+// brush resize curve: linear (fine, precise) up to BRUSH_LINEAR_MAX, then a
+// power-law ramp beyond, tuned by BRUSH_ACCEL.
+function brushFromDrag(start, dyPx) {
+ const A = BRUSH_DRAG_PX_PER_STEP, T = BRUSH_LINEAR_MAX, c = BRUSH_ACCEL;
+ const seam = T * A; // drag distance from size 0 to the seam
+ const brushToPos = (b) => b <= T ? b * A : seam + (seam / c) * (Math.pow(b / T, c) - 1);
+ const posToBrush = (p) => p <= seam ? p / A : T * Math.pow(1 + c * (p - seam) / seam, 1 / c);
+ return posToBrush(brushToPos(start) + dyPx);
+}
+
+// modifier-drag: shift/cmd/RGB held -> vertical motion from the anchor
+// drives brush size / roundness / color channels. drag up (dy negative)
+// -> increase. when a value hits its bound and the drag continues past,
+// re-anchor so reversing direction responds immediately. each drag is
+// independent so any combination can run at once.
+function applyDrags(clientY) {
+ if (dragBrush) {
+ const dyPx = (dragBrush.anchorY - clientY) * dpr;
+ const target = brushFromDrag(dragBrush.start, dyPx);
+ const clamped = clamp(Math.round(target), 1, MAX_BRUSH);
+ brush = clamped;
+ if (target < 1 || target > MAX_BRUSH) {
+ dragBrush.start = clamped;
+ dragBrush.anchorY = clientY;
+ }
+ }
+ if (dragRoundness) {
+ const dyPx = (dragRoundness.anchorY - clientY) * dpr;
+ const target = dragRoundness.start - dyPx / ROUNDNESS_DRAG_FULL_PX;
+ const clamped = clamp(target, 0, 1);
+ roundness = clamped;
+ if (target !== clamped) {
+ dragRoundness.start = clamped;
+ dragRoundness.anchorY = clientY;
+ }
+ }
+ if (dragColor) {
+ const dyPx = (dragColor.anchorY - clientY) * dpr;
+ const delta = (dyPx / COLOR_DRAG_FULL_PX) * 255;
+ let nr = r, ng = g, nb = b;
+ let overshoot = 0;
+ if (keys['r']) {
+ const t = dragColor.r0 + delta;
+ nr = clamp(t, 0, 255);
+ if (t !== nr) overshoot = Math.max(overshoot, Math.abs(t - nr));
+ }
+ if (keys['g']) {
+ const t = dragColor.g0 + delta;
+ ng = clamp(t, 0, 255);
+ if (t !== ng) overshoot = Math.max(overshoot, Math.abs(t - ng));
+ }
+ if (keys['b']) {
+ const t = dragColor.b0 + delta;
+ nb = clamp(t, 0, 255);
+ if (t !== nb) overshoot = Math.max(overshoot, Math.abs(t - nb));
+ }
+ setColor(Math.round(nr), Math.round(ng), Math.round(nb));
+ if (overshoot > 0) {
+ dragColor.r0 = nr; dragColor.g0 = ng; dragColor.b0 = nb;
+ dragColor.anchorY = clientY;
+ }
+ }
+}
+
+window.addEventListener('pointermove', (e) => {
+ if (!hoverPointer(e)) return;
+ // a pen only proves it can hover by moving while lifted. without hover
+ // it gets the finger treatment: modifiers adjust instead of marking
+ if (e.pointerType === 'pen' && e.buttons === 0) penHover = true;
+ showCursor();
+ curClientX = e.clientX;
+ curClientY = e.clientY;
+ const p = clientToWorld(e.clientX, e.clientY);
+ const nx = p.x, ny = p.y;
+
+ applyDrags(e.clientY);
+
+ if (painting) {
+ if (lastPaintX !== null) {
+ localPaintLine(lastPaintX, lastPaintY, nx, ny);
+ } else {
+ localPaintAt(nx, ny);
+ }
+ lastPaintX = nx;
+ lastPaintY = ny;
+ } else if (picking) {
+ pickAt(nx, ny);
+ }
+ curX = nx;
+ curY = ny;
+ requestDraw();
+});
+
+window.addEventListener('pointerleave', (e) => {
+ if (!hoverPointer(e)) return;
+ hideCursorNow();
+ requestDraw();
+});
+
+// a pen without hover support sends no move events before contact, so the
+// down event must place the cursor itself
+let penDown = false;
+let penHover = false;
+window.addEventListener('pointerdown', (e) => {
+ if (!hoverPointer(e)) return;
+ if (e.button !== 0) return;
+ e.preventDefault();
+ stopInertia();
+ if (e.pointerType === 'pen') penDown = true;
+ showCursor();
+ curClientX = e.clientX;
+ curClientY = e.clientY;
+ const p = clientToWorld(e.clientX, e.clientY);
+ curX = p.x;
+ curY = p.y;
+ // hoverless pen + modifier held -> the contact adjusts, like a finger
+ if (e.pointerType === 'pen' && !penHover && !keys['c'] && (dragBrush || dragRoundness || dragColor)) {
+ anchorDrags(e.clientY);
+ requestDraw();
+ return;
+ }
+ if (keys['c']) {
+ picking = true;
+ pickAt(curX, curY);
+ requestDraw();
+ return;
+ }
+ painting = true;
+ lastPaintX = curX;
+ lastPaintY = curY;
+ localPaintAt(curX, curY);
+ requestDraw();
+});
+
+function pointerUp(e) {
+ if (!hoverPointer(e)) return;
+ if (e.button === 0 || e.type === 'pointercancel') {
+ painting = false;
+ picking = false;
+ lastPaintX = null;
+ lastPaintY = null;
+ if (e.pointerType === 'pen') {
+ penDown = false;
+ // hoverless pens get no more events after lift, so fade like touch
+ if (mouseInside) setCursorFadeTarget(0);
+ requestDraw();
+ }
+ }
+}
+window.addEventListener('pointerup', pointerUp);
+window.addEventListener('pointercancel', pointerUp);
+
+// touch: one finger paints, two fingers pan/pinch-zoom. once a second
+// finger lands the whole touch becomes a gesture (no painting) until all
+// fingers lift, so a stray finger during a pan never leaves a mark.
+// stylus touches are excluded here
+let touchMode = null; // 'paint' | 'adjust' | 'gesture'
+let touchPainted = false;
+let pinchDist = 0, pinchMidX = 0, pinchMidY = 0;
+
+// inertial pan: centroid velocity (css px/ms) tracked during the gesture,
+// thrown on release and decayed with exponential friction
+let panVX = 0, panVY = 0;
+let panLastT = 0;
+let inertiaRAF = null;
+function stopInertia() {
+ if (inertiaRAF !== null) {
+ cancelAnimationFrame(inertiaRAF);
+ inertiaRAF = null;
+ }
+}
+function startInertia() {
+ // no throw if the finger paused before lifting or was barely moving
+ const speed = Math.hypot(panVX, panVY);
+ if (performance.now() - panLastT > 100 || speed < 0.05) return;
+ // exponential decay per ms
+ const DECEL = 0.995;
+ const MAX_SPEED = 3; // css px/ms
+ if (speed > MAX_SPEED) {
+ panVX *= MAX_SPEED / speed;
+ panVY *= MAX_SPEED / speed;
+ }
+ let prev = performance.now();
+ const step = (now) => {
+ const dt = now - prev;
+ prev = now;
+ camX -= panVX * dt / zoom;
+ camY -= panVY * dt / zoom;
+ const f = Math.pow(DECEL, dt);
+ panVX *= f;
+ panVY *= f;
+ requestDraw();
+ inertiaRAF = Math.hypot(panVX, panVY) > 0.02 ? requestAnimationFrame(step) : null;
+ };
+ inertiaRAF = requestAnimationFrame(step);
+}
+
+function touchCentroid(touches) {
+ let x = 0, y = 0;
+ for (const t of touches) { x += t.clientX; y += t.clientY; }
+ return { x: x / touches.length, y: y / touches.length };
+}
+
+function fingerTouches(list) {
+ const out = [];
+ for (const t of list) if (t.touchType !== 'stylus') out.push(t);
+ return out;
+}
+
+// re-anchor active drags to a new y and re-baseline to the current values
+// so successive drag strokes accumulate instead of snapping back
+function anchorDrags(clientY) {
+ if (dragBrush) { dragBrush.anchorY = clientY; dragBrush.start = brush; }
+ if (dragRoundness) { dragRoundness.anchorY = clientY; dragRoundness.start = roundness; }
+ if (dragColor) { dragColor.anchorY = clientY; dragColor.r0 = r; dragColor.g0 = g; dragColor.b0 = b; }
+}
+
+function anchorGesture(touches) {
+ const m = touchCentroid(touches);
+ pinchMidX = m.x;
+ pinchMidY = m.y;
+ pinchDist = touches.length >= 2
+ ? Math.hypot(touches[0].clientX - touches[1].clientX, touches[0].clientY - touches[1].clientY)
+ : 0;
+}
+
+window.addEventListener('touchstart', (e) => {
+ e.preventDefault();
+ const fingers = fingerTouches(e.touches);
+ // fingers are ignored while the pen is down so a resting hand can't
+ // hijack or extend the pen's stroke
+ if (fingers.length === 0 || penDown) return;
+ stopInertia();
+ if (fingers.length === 1 && touchMode === null) {
+ const t = fingers[0];
+ curClientX = t.clientX;
+ curClientY = t.clientY;
+ if (!keys['c'] && (dragBrush || dragRoundness || dragColor)) {
+ touchMode = 'adjust';
+ const p = clientToWorld(t.clientX, t.clientY);
+ curX = p.x;
+ curY = p.y;
+ showCursor();
+ anchorDrags(t.clientY);
+ } else {
+ const p = clientToWorld(t.clientX, t.clientY);
+ curX = p.x;
+ curY = p.y;
+ showCursor();
+ touchMode = 'paint';
+ touchPainted = false;
+ if (keys['c']) {
+ picking = true;
+ } else {
+ painting = true;
+ lastPaintX = curX;
+ lastPaintY = curY;
+ }
+ }
+ } else {
+ touchMode = 'gesture';
+ painting = false;
+ picking = false;
+ lastPaintX = null;
+ lastPaintY = null;
+ hideCursorNow();
+ panVX = 0;
+ panVY = 0;
+ panLastT = 0;
+ anchorGesture(fingers);
+ }
+ requestDraw();
+}, { passive: false });
+
+window.addEventListener('touchmove', (e) => {
+ e.preventDefault();
+ const fingers = fingerTouches(e.touches);
+ if (fingers.length === 0) return;
+ if (touchMode === 'paint') {
+ const t = fingers[0];
+ curClientX = t.clientX;
+ curClientY = t.clientY;
+ // on hover devices held keys adjust during the stroke, like mouse
+ applyDrags(t.clientY);
+ const p = clientToWorld(t.clientX, t.clientY);
+ if (picking) {
+ pickAt(p.x, p.y);
+ } else if (painting) {
+ localPaintLine(lastPaintX, lastPaintY, p.x, p.y);
+ lastPaintX = p.x;
+ lastPaintY = p.y;
+ }
+ touchPainted = true;
+ curX = p.x;
+ curY = p.y;
+ } else if (touchMode === 'adjust') {
+ const t = fingers[0];
+ curClientX = t.clientX;
+ curClientY = t.clientY;
+ applyDrags(t.clientY);
+ const p = clientToWorld(t.clientX, t.clientY);
+ curX = p.x;
+ curY = p.y;
+ } else if (touchMode === 'gesture') {
+ const m = touchCentroid(fingers);
+ const dxs = m.x - pinchMidX;
+ const dys = m.y - pinchMidY;
+ camX -= dxs / zoom;
+ camY -= dys / zoom;
+ const nowT = performance.now();
+ const dt = nowT - panLastT;
+ if (dt > 0 && dt < 100) {
+ panVX = panVX * 0.5 + (dxs / dt) * 0.5;
+ panVY = panVY * 0.5 + (dys / dt) * 0.5;
+ }
+ panLastT = nowT;
+ if (fingers.length >= 2) {
+ const dist = Math.hypot(fingers[0].clientX - fingers[1].clientX, fingers[0].clientY - fingers[1].clientY);
+ if (pinchDist > 0) applyZoom(dist / pinchDist, m.x, m.y);
+ pinchDist = dist;
+ }
+ pinchMidX = m.x;
+ pinchMidY = m.y;
+ } else {
+ return;
+ }
+ requestDraw();
+}, { passive: false });
+
+function touchEnd(e) {
+ e.preventDefault();
+ if (touchMode === null) return;
+ const fingers = fingerTouches(e.touches);
+ if (fingers.length === 0) {
+ // a tap that never moved still paints its dot (or picks its point)
+ if (touchMode === 'paint' && !touchPainted) {
+ if (picking) pickAt(curX, curY);
+ else localPaintAt(curX, curY);
+ }
+ if (touchMode === 'gesture') startInertia();
+ touchMode = null;
+ painting = false;
+ picking = false;
+ lastPaintX = null;
+ lastPaintY = null;
+ // fade the preview out instead of hiding it instantly
+ if (mouseInside) setCursorFadeTarget(0);
+ requestDraw();
+ } else if (touchMode === 'gesture') {
+ // re-anchor to the remaining fingers so the camera doesn't jump
+ anchorGesture(fingers);
+ }
+}
+window.addEventListener('touchend', touchEnd, { passive: false });
+window.addEventListener('touchcancel', touchEnd, { passive: false });
+
+function startDragMode(mode) {
+ const anchorY = curClientY !== null ? curClientY : 0;
+ if (mode === 'brush' && !dragBrush) dragBrush = { anchorY, start: brush };
+ else if (mode === 'roundness' && !dragRoundness) dragRoundness = { anchorY, start: roundness };
+ else if (mode === 'color' && !dragColor) dragColor = { anchorY, r0: r, g0: g, b0: b };
+ // hoverless input: a modifier pressed mid-stroke ends the stroke and
+ // the rest of the contact becomes an adjust drag. hover-capable pens
+ // keep drawing (they can adjust while lifted instead)
+ if (touchMode === 'paint' || (penDown && !penHover)) {
+ if (touchMode === 'paint') touchMode = 'adjust';
+ painting = false;
+ picking = false;
+ lastPaintX = null;
+ lastPaintY = null;
+ }
+}
+function endDragMode(mode) {
+ if (mode === 'brush') dragBrush = null;
+ else if (mode === 'roundness') dragRoundness = null;
+ else if (mode === 'color') dragColor = null;
+}
+
+window.addEventListener('keydown', (e) => {
+ if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 's') {
+ e.preventDefault();
+ exportGIF();
+ return;
+ }
+ if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'o') {
+ e.preventDefault();
+ fileInput.click();
+ return;
+ }
+ if ((e.metaKey || e.ctrlKey) && !e.altKey) {
+ if (e.key === '=' || e.key === '+') { e.preventDefault(); stepZoom(1); return; }
+ if (e.key === '-' || e.key === '_') { e.preventDefault(); stepZoom(-1); return; }
+ if (e.key === '0') { e.preventDefault(); resetZoom(); return; }
+ }
+ if (!e.metaKey && !e.ctrlKey && !e.altKey && !e.repeat) {
+ const lk = e.key.toLowerCase();
+ if (e.key === 'ArrowLeft') { e.preventDefault(); tapArrow(-1); return; }
+ if (e.key === 'ArrowRight') { e.preventDefault(); tapArrow(1); return; }
+ if (e.key === ' ') { e.preventDefault(); playing ? stopPlayback() : startPlayback(); return; }
+ if (lk === 'a') { addFrame(); return; }
+ if (lk === 'd') { deleteFrame(); return; }
+ if (lk === 'o') {
+ if (playing) { stopPlayback(); onionskin = true; }
+ else onionskin = !onionskin;
+ requestDraw();
+ return;
+ }
+ }
+ const k = e.key.toLowerCase();
+ const wasDown = keys[k];
+ keys[k] = true;
+ if (!e.metaKey && !e.ctrlKey) {
+ if (k === 'z' || k === 'x' || k === 'c' || k === 'r' || k === 'g' || k === 'b') e.preventDefault();
+ if (k === 'x') startDragMode('roundness');
+ else if (k === 'z') startDragMode('brush');
+ else if (!wasDown && (k === 'r' || k === 'g' || k === 'b')) startDragMode('color');
+ else if (k === 'c' && !wasDown && (touchMode === 'paint' || touchMode === 'adjust')) {
+ touchMode = 'paint';
+ painting = false;
+ picking = true;
+ lastPaintX = null;
+ lastPaintY = null;
+ touchPainted = true;
+ pickAt(curX, curY);
+ requestDraw();
+ }
+ }
+});
+window.addEventListener('keyup', (e) => {
+ const k = e.key.toLowerCase();
+ keys[k] = false;
+ if (k === 'x') endDragMode('roundness');
+ else if (k === 'z') endDragMode('brush');
+ else if (k === 'r' || k === 'g' || k === 'b') {
+ if (!keys['r'] && !keys['g'] && !keys['b']) endDragMode('color');
+ else if (dragColor) {
+ // still holding at least one rgb key - re-anchor from current
+ // values so the remaining keys don't jump based on released key's history
+ dragColor.anchorY = curClientY !== null ? curClientY : dragColor.anchorY;
+ dragColor.r0 = r; dragColor.g0 = g; dragColor.b0 = b;
+ }
+ }
+ // releasing c mid-pick-drag -> back to adjusting if a modifier is still
+ // held on a hoverless contact, else seamlessly switch to painting
+ if (k === 'c' && picking) {
+ picking = false;
+ const hoverless = touchMode !== null || (penDown && !penHover);
+ if (hoverless && (dragBrush || dragRoundness || dragColor)) {
+ if (touchMode !== null) touchMode = 'adjust';
+ painting = false;
+ if (curClientY !== null) anchorDrags(curClientY);
+ } else {
+ painting = true;
+ lastPaintX = curX;
+ lastPaintY = curY;
+ localPaintAt(curX, curY);
+ }
+ requestDraw();
+ }
+});
+window.addEventListener('blur', () => {
+ for (const k in keys) keys[k] = false;
+ dragBrush = null; dragRoundness = null; dragColor = null;
+ painting = false;
+ picking = false;
+ penDown = false;
+ lastPaintX = null;
+ lastPaintY = null;
+});
+
+// wheel gesture lock: once a wheel gesture starts in a given mode,
+// subsequent events in the same burst stay in that mode until the wheel
+// goes idle or the controlling modifier set changes. this keeps trackpad
+// momentum from leaking into pan after a modifier release, while still
+// letting the user swap between modes mid-flick without stale state.
+let wheelMode = null;
+let wheelIdleTimer = null;
+let panning = false;
+
+const BRUSH_DRAG_PX_PER_STEP = 25; // device px per +/- 1 brush px (below the seam)
+const BRUSH_LINEAR_MAX = 6; // brush px below which resize stays linear/fine
+const BRUSH_ACCEL = 0.5;
+const MAX_BRUSH = 150;
+const ROUNDNESS_DRAG_FULL_PX = 200;
+const COLOR_DRAG_FULL_PX = 256;
+// each drag carries its own anchor so any combination can run at once
+let dragBrush = null; // {anchorY, start}
+let dragRoundness = null; // {anchorY, start}
+let dragColor = null; // {anchorY, r0, g0, b0}
+function touchWheelGesture() {
+ if (wheelIdleTimer) clearTimeout(wheelIdleTimer);
+ wheelIdleTimer = setTimeout(() => {
+ wheelMode = null;
+ if (panning) {
+ // pan ended - resync the logical-pixel cursor from the real
+ // client position so the brush snaps onto its final cell.
+ panning = false;
+ if (curClientX !== null) {
+ const p = clientToWorld(curClientX, curClientY);
+ curX = p.x;
+ curY = p.y;
+ }
+ requestDraw();
+ }
+ }, 150);
+}
+function pickModifierMode(e) {
+ if (e.ctrlKey) return 'zoom';
+ return null;
+}
+
+window.addEventListener('wheel', (e) => {
+ e.preventDefault();
+ stopInertia();
+
+ // re-pick mode each event from live modifiers so releasing cmd and
+ // immediately starting a shift scroll switches over cleanly. if a
+ // burst started with a modifier and the modifier is then released
+ // mid-flick, suppress rather than leaking into pan.
+ const modMode = pickModifierMode(e);
+ if (modMode !== null) {
+ wheelMode = modMode;
+ } else if (wheelMode === null) {
+ wheelMode = 'pan';
+ } else if (wheelMode !== 'pan') {
+ // modifier released mid-burst: drop the remaining momentum
+ // instead of letting it leak into pan.
+ touchWheelGesture();
+ return;
+ }
+ touchWheelGesture();
+
+ if (wheelMode === 'zoom') {
+ // exponential zoom on the float accumulator so small pinches add up
+ applyZoom(Math.exp(-e.deltaY * 0.02), e.clientX, e.clientY);
+ requestDraw();
+ return;
+ }
+
+ // pan - smooth fractional camera. during the pan gesture the draw
+ // loop anchors the brush to the real client cursor position so it
+ // tracks the pointer smoothly; curX/curY get resynced onto the
+ // logical-pixel grid when the wheel idle timer fires.
+ panning = true;
+ camX += e.deltaX / zoom;
+ camY += e.deltaY / zoom;
+ requestDraw();
+}, { passive: false });
+
+window.addEventListener('beforeunload', (e) => {
+ if (dirty) { e.preventDefault(); }
+});
+
+window.addEventListener('contextmenu', (e) => e.preventDefault());
+
+// block the OS pinch gesture events too (Safari)
+window.addEventListener('gesturestart', (e) => e.preventDefault());
+window.addEventListener('gesturechange', (e) => e.preventDefault());
+window.addEventListener('gestureend', (e) => e.preventDefault());
diff --git a/resources/io.js b/resources/io.js
@@ -0,0 +1,269 @@
+const fileInput = document.createElement('input');
+fileInput.type = 'file';
+fileInput.accept = 'image/*';
+fileInput.style.display = 'none';
+document.body.appendChild(fileInput);
+fileInput.addEventListener('change', (e) => {
+ const f = e.target.files && e.target.files[0];
+ if (f) importFile(f);
+ fileInput.value = '';
+});
+
+function formatTimestamp(d) {
+ const p = (n) => String(n).padStart(2, '0');
+ return p(d.getFullYear() % 100) + '\u00b7' + p(d.getMonth() + 1) + '\u00b7' + p(d.getDate()) + '\u00b7' + p(d.getHours()) + '\u00b7' + p(d.getMinutes()) + '\u00b7' + p(d.getSeconds());
+}
+
+// union of painted chunk bounds across all frames, so every exported
+// frame shares the canvas of the biggest one
+function frameBounds() {
+ let any = false, minCx = Infinity, minCy = Infinity, maxCx = -Infinity, maxCy = -Infinity;
+ for (const f of frames) {
+ for (const k of f.keys()) {
+ any = true;
+ const [cx, cy] = k.split(',').map(Number);
+ if (cx < minCx) minCx = cx;
+ if (cy < minCy) minCy = cy;
+ if (cx > maxCx) maxCx = cx;
+ if (cy > maxCy) maxCy = cy;
+ }
+ }
+ return any ? { minCx, minCy, maxCx, maxCy } : null;
+}
+
+function renderFrame(f, b, w, h) {
+ const out = document.createElement('canvas');
+ out.width = w;
+ out.height = h;
+ const octx = out.getContext('2d');
+ octx.fillStyle = '#000';
+ octx.fillRect(0, 0, w, h);
+ for (const [k, c] of f) {
+ const [cx, cy] = k.split(',').map(Number);
+ octx.drawImage(c.canvas, (cx - b.minCx) * CHUNK, (cy - b.minCy) * CHUNK);
+ }
+ return octx.getImageData(0, 0, w, h);
+}
+
+function lzwEncode(minCode, data, lookup, out) {
+ const clear = 1 << minCode, eoi = clear + 1;
+ let codeSize = minCode + 1, next = eoi + 1;
+ let dict = new Map();
+ let acc = 0, accBits = 0;
+ let block = [];
+ const flushBlock = () => {
+ if (block.length) { out.push(block.length, ...block); block = []; }
+ };
+ const emit = (code) => {
+ acc |= code << accBits;
+ accBits += codeSize;
+ while (accBits >= 8) {
+ block.push(acc & 255);
+ acc >>= 8;
+ accBits -= 8;
+ if (block.length === 255) flushBlock();
+ }
+ };
+ const idx = (i) => lookup((data[i] << 16) | (data[i + 1] << 8) | data[i + 2]);
+ emit(clear);
+ let prev = idx(0);
+ for (let i = 4; i < data.length; i += 4) {
+ const k = idx(i);
+ const key = prev * 256 + k;
+ if (dict.has(key)) { prev = dict.get(key); continue; }
+ emit(prev);
+ if (next === 4096) {
+ emit(clear);
+ dict = new Map();
+ next = eoi + 1;
+ codeSize = minCode + 1;
+ } else {
+ if (next >= (1 << codeSize)) codeSize++;
+ dict.set(key, next++);
+ }
+ prev = k;
+ }
+ emit(prev);
+ emit(eoi);
+ if (accBits) block.push(acc & 255);
+ flushBlock();
+}
+
+// minimal GIF89a encoder: exact global palette when <=256 colors,
+// else uniform 6x6x6 quantization
+function encodeGIF(images, w, h, delayMs) {
+ const colorIdx = new Map();
+ let over = false;
+ for (const img of images) {
+ const d = img.data;
+ for (let i = 0; i < d.length && !over; i += 4) {
+ const c = (d[i] << 16) | (d[i + 1] << 8) | d[i + 2];
+ if (!colorIdx.has(c)) {
+ if (colorIdx.size === 256) over = true;
+ else colorIdx.set(c, colorIdx.size);
+ }
+ }
+ if (over) break;
+ }
+ if (over) {
+ colorIdx.clear();
+ for (let i = 0; i < 216; i++) {
+ colorIdx.set(((Math.floor(i / 36) * 51) << 16) | ((Math.floor(i / 6) % 6 * 51) << 8) | (i % 6 * 51), i);
+ }
+ }
+ const lookup = (c) => over
+ ? Math.round(((c >> 16) & 255) / 51) * 36 + Math.round(((c >> 8) & 255) / 51) * 6 + Math.round((c & 255) / 51)
+ : colorIdx.get(c);
+ let bits = 2;
+ while ((1 << bits) < colorIdx.size) bits++;
+ const out = [];
+ const u16 = (v) => { out.push(v & 255, (v >> 8) & 255); };
+ out.push(71, 73, 70, 56, 57, 97); // "GIF89a"
+ u16(w); u16(h);
+ out.push(0x80 | ((bits - 1) << 4) | (bits - 1), 0, 0);
+ const pal = [...colorIdx.keys()];
+ for (let i = 0; i < (1 << bits); i++) {
+ const c = pal[i] || 0;
+ out.push((c >> 16) & 255, (c >> 8) & 255, c & 255);
+ }
+ const animated = images.length > 1;
+ if (animated) {
+ // NETSCAPE2.0 loop forever
+ out.push(0x21, 0xff, 11, 78, 69, 84, 83, 67, 65, 80, 69, 50, 46, 48, 3, 1, 0, 0, 0);
+ }
+ const delay = clamp(Math.round(delayMs / 10), 2, 65535);
+ for (const img of images) {
+ if (animated) out.push(0x21, 0xf9, 4, 0, delay & 255, (delay >> 8) & 255, 0, 0);
+ out.push(0x2c);
+ u16(0); u16(0); u16(w); u16(h);
+ out.push(0);
+ const minCode = Math.max(2, bits);
+ out.push(minCode);
+ lzwEncode(minCode, img.data, lookup, out);
+ out.push(0);
+ }
+ out.push(0x3b);
+ return new Uint8Array(out);
+}
+
+function exportGIF() {
+ const b = frameBounds();
+ if (!b) return;
+ const w = (b.maxCx - b.minCx + 1) * CHUNK;
+ const h = (b.maxCy - b.minCy + 1) * CHUNK;
+ const imgs = frames.map((f) => renderFrame(f, b, w, h));
+ if (dir === -1) imgs.reverse();
+ const blob = new Blob([encodeGIF(imgs, w, h, frameInterval)], { type: 'image/gif' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = 'animus \u00b7 ' + formatTimestamp(new Date()) + '.gif';
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ dirty = false;
+}
+
+function imageToFrame(src, w, h) {
+ const ox = -Math.floor(w / 2);
+ const oy = -Math.floor(h / 2);
+ const f = newFrameMap();
+ drawIntoFrame(f, src, w, h, ox, oy);
+ return { f, w, h, ox, oy };
+}
+
+function drawIntoFrame(f, src, w, h, ox, oy) {
+ const cx0 = Math.floor(ox / CHUNK);
+ const cy0 = Math.floor(oy / CHUNK);
+ const cx1 = Math.floor((ox + w - 1) / CHUNK);
+ const cy1 = Math.floor((oy + h - 1) / CHUNK);
+ for (let cy = cy0; cy <= cy1; cy++) {
+ for (let cx = cx0; cx <= cx1; cx++) {
+ getOrCreateChunk(cx, cy, f).ctx.drawImage(src, ox - cx * CHUNK, oy - cy * CHUNK);
+ }
+ }
+}
+
+function finishImport(newFrames, w, h, ox, oy) {
+ frames = newFrames;
+ dir = 1;
+ setFrame(0);
+ camX = ox - (cssW / zoom - w) / 2;
+ camY = oy - (cssH / zoom - h) / 2;
+ dirty = false;
+ requestDraw();
+}
+
+function loadImage(src) {
+ return new Promise((resolve, reject) => {
+ const img = new Image();
+ img.onload = () => resolve(img);
+ img.onerror = reject;
+ img.src = src;
+ });
+}
+
+// decoded frames of the file as {src, w, h, close}
+async function decodeSources(file) {
+ // ImageDecoder gives us every frame of an animated gif; fall back to
+ // single-image import where unsupported
+ if (typeof ImageDecoder !== 'undefined') {
+ try {
+ const dec = new ImageDecoder({ data: await file.arrayBuffer(), type: file.type });
+ await dec.tracks.ready;
+ const count = dec.tracks.selectedTrack.frameCount;
+ const srcs = [];
+ let interval = 0;
+ for (let i = 0; i < count; i++) {
+ const { image } = await dec.decode({ frameIndex: i });
+ if (i === 0 && image.duration) interval = Math.max(20, image.duration / 1000);
+ srcs.push({ src: image, w: image.displayWidth, h: image.displayHeight, close: () => image.close() });
+ }
+ return { srcs, interval };
+ } catch (err) {}
+ }
+ const url = URL.createObjectURL(file);
+ try {
+ const img = await loadImage(url);
+ return { srcs: [{ src: img, w: img.width, h: img.height }], interval: 0 };
+ } catch (err) {
+ return null;
+ } finally {
+ URL.revokeObjectURL(url);
+ }
+}
+
+function applyImport(srcs) {
+ const newFrames = [];
+ let last = null;
+ for (const s of srcs) {
+ last = imageToFrame(s.src, s.w, s.h);
+ newFrames.push(last.f);
+ }
+ if (last) finishImport(newFrames, last.w, last.h, last.ox, last.oy);
+}
+
+// flat png per frame, positioned in world space, so an import can be handed
+// to everyone else in the room (and to whoever joins later)
+function sourcesToBases(srcs) {
+ const cnv = document.createElement('canvas');
+ const cctx = cnv.getContext('2d');
+ return srcs.map((s) => {
+ cnv.width = s.w;
+ cnv.height = s.h;
+ cctx.drawImage(s.src, 0, 0);
+ return { img: cnv.toDataURL('image/png'), ox: -Math.floor(s.w / 2), oy: -Math.floor(s.h / 2), w: s.w, h: s.h };
+ });
+}
+
+async function importFile(file) {
+ if (dirty && !confirm('Importing will discard the current ' + (frames.length > 1 ? 'animation' : 'painting') + '. Continue?' + (net.on ? ' (for everyone)' : ''))) return;
+ if (playing) stopPlayback();
+ const dec = await decodeSources(file);
+ if (!dec || !dec.srcs.length) return;
+ if (dec.interval) frameInterval = dec.interval;
+ if (net.on) net.send({ t: 'reset', frames: sourcesToBases(dec.srcs), interval: frameInterval });
+ else applyImport(dec.srcs);
+ for (const s of dec.srcs) if (s.close) s.close();
+}
diff --git a/resources/net.js b/resources/net.js
@@ -0,0 +1,245 @@
+// painting together: mirrors strokes, frames, and brushes between
+// everyone in the room
+
+const net = {
+ on: false,
+ id: null,
+ ws: null,
+ peers: new Map(),
+ joined: false, // has this page ever been in a room
+ queue: null, // messages held while a snapshot loads
+ lastCursor: '',
+ batch: null, // segments painted this frame, not yet sent
+ key: 0,
+ pending: [], // own strokes still waiting for their echo
+ overlays: new Map(), // frame id -> chunk map of those unacked strokes
+};
+
+net.send = function (msg) {
+ if (!net.on) return;
+ try {
+ net.ws.send(JSON.stringify(msg));
+ } catch (err) {}
+};
+
+// a fast pen reports far more moves than anyone can see, so segments pile up
+// into one message per animation frame. a new frame, color, or brush starts
+// a new batch, since a batch carries a single style
+net.paint = function (x0, y0, x1, y1) {
+ if (!net.on) return;
+ const cur = net.batch;
+ if (cur && cur.f === chunks.id && cur.b === brush && cur.s === roundness
+ && cur.c[0] === r && cur.c[1] === g && cur.c[2] === b) {
+ cur.o.push(x0, y0, x1, y1);
+ return;
+ }
+ net.flush();
+ net.batch = { t: 'p', k: ++net.key, f: chunks.id, o: [x0, y0, x1, y1], c: [r, g, b], b: brush, s: roundness };
+ // tracked from the first segment, so the overlay it painted onto is held
+ // until the echo commits it to the frame proper
+ net.pending.push({ k: net.batch.k, f: chunks.id });
+};
+
+net.flush = function () {
+ const out = net.batch;
+ if (!out) return;
+ net.batch = null;
+ net.send(out);
+};
+
+// an overlay lives exactly as long as some stroke on it awaits its echo
+function clearOverlayIfIdle(fid) {
+ if (net.pending.some((p) => p.f === fid)) return;
+ if (net.overlays.delete(fid)) requestDraw();
+}
+
+// called at the end of every draw, so the brush other people see moves
+// exactly when ours does, and never more often than a frame
+net.sendCursor = function () {
+ if (!net.on) return;
+ // the target, not the current alpha: everyone else runs the same fade
+ // from the moment it starts, so a slow fade-out never reads as a blink
+ const v = cursorFadeTo > 0;
+ const key = v ? curX + ',' + curY + ',' + brush + ',' + roundness + ',' + r + ',' + g + ',' + b + ',' + chunks.id : '0';
+ if (key === net.lastCursor) return;
+ net.lastCursor = key;
+ net.send({ t: 'c', x: curX, y: curY, b: brush, s: roundness, c: [r, g, b], f: chunks.id, v });
+};
+
+function frameSnapshot(f) {
+ let any = false, minCx = Infinity, minCy = Infinity, maxCx = -Infinity, maxCy = -Infinity;
+ for (const k of f.keys()) {
+ any = true;
+ const [cx, cy] = k.split(',').map(Number);
+ if (cx < minCx) minCx = cx;
+ if (cy < minCy) minCy = cy;
+ if (cx > maxCx) maxCx = cx;
+ if (cy > maxCy) maxCy = cy;
+ }
+ if (!any) return { id: f.id, img: null };
+ const w = (maxCx - minCx + 1) * CHUNK;
+ const h = (maxCy - minCy + 1) * CHUNK;
+ const cnv = document.createElement('canvas');
+ cnv.width = w;
+ cnv.height = h;
+ const cctx = cnv.getContext('2d');
+ cctx.fillStyle = '#000';
+ cctx.fillRect(0, 0, w, h);
+ for (const [k, c] of f) {
+ const [cx, cy] = k.split(',').map(Number);
+ cctx.drawImage(c.canvas, (cx - minCx) * CHUNK, (cy - minCy) * CHUNK);
+ }
+ return { id: f.id, img: cnv.toDataURL('image/png'), ox: minCx * CHUNK, oy: minCy * CHUNK, w, h };
+}
+
+// rebuild frames from flat images the server is holding
+async function framesFromBases(list) {
+ const out = [];
+ for (const e of list) {
+ const f = newFrameMap(e.id);
+ if (e.img) {
+ try {
+ const img = await loadImage(e.img);
+ drawIntoFrame(f, img, e.w || img.width, e.h || img.height, e.ox || 0, e.oy || 0);
+ } catch (err) {}
+ }
+ out.push(f);
+ }
+ return out;
+}
+
+// hold live messages until the frames they refer to exist
+function netDefer(msg) {
+ if (!net.queue) return false;
+ net.queue.push(msg);
+ return true;
+}
+
+function netFlush() {
+ const q = net.queue;
+ net.queue = null;
+ if (!q) return;
+ for (const msg of q) netHandle(msg);
+}
+
+// swap in a server-supplied frame list, replaying anything that arrived
+// while the images were decoding only after `after` has caught us up
+async function netAdopt(list, after) {
+ net.queue = [];
+ const built = await framesFromBases(list);
+ frames = built;
+ setFrame(0);
+ if (after) after();
+ netFlush();
+ requestDraw();
+}
+
+function netHandle(msg) {
+ if (msg.t === 'init') {
+ net.id = msg.id;
+ net.batch = null;
+ net.pending.length = 0;
+ net.overlays.clear();
+ if (msg.interval) frameInterval = msg.interval;
+ netAdopt(msg.frames, () => {
+ for (const e of msg.frames) {
+ for (const op of e.ops || []) applyPaintOp({ ...op, f: e.id });
+ }
+ dirty = false;
+ });
+ return;
+ }
+ if (netDefer(msg)) return;
+ if (msg.t === 'p') {
+ // strokes land in the frame strictly in the order the server sent
+ // them - ours included. until now our own stroke existed only on the
+ // overlay; this echo is what commits it, so every painter's frame is
+ // the same ops in the same order
+ applyPaintOp(msg);
+ if (msg.by === net.id) {
+ const i = net.pending.findIndex((p) => p.k === msg.k);
+ if (i < 0) return;
+ // earlier entries can only be strokes the server dropped (their
+ // frame was deleted); sweep them along
+ const gone = net.pending.splice(0, i + 1);
+ for (const p of gone) clearOverlayIfIdle(p.f);
+ }
+ } else if (msg.t === 'c') {
+ const p = net.peers.get(msg.id) || { alpha: 0, fadeFrom: 0, fadeTo: 0, fadeStart: 0 };
+ const target = msg.v ? 1 : 0;
+ if (target !== p.fadeTo) {
+ p.fadeFrom = p.alpha;
+ p.fadeTo = target;
+ p.fadeStart = performance.now();
+ }
+ p.x = msg.x; p.y = msg.y; p.b = msg.b; p.s = msg.s; p.c = msg.c; p.f = msg.f;
+ net.peers.set(msg.id, p);
+ requestDraw();
+ } else if (msg.t === 'bye') {
+ const p = net.peers.get(msg.id);
+ if (p && p.fadeTo !== 0) {
+ p.fadeFrom = p.alpha;
+ p.fadeTo = 0;
+ p.fadeStart = performance.now();
+ }
+ requestDraw();
+ } else if (msg.t === 'af') {
+ applyAddFrame(msg.at, msg.id, msg.by === net.id);
+ } else if (msg.t === 'df') {
+ // the server drops strokes aimed at a deleted frame, so their echoes
+ // never come; the frame is going away, and its overlay with it
+ net.pending = net.pending.filter((p) => p.f !== msg.id);
+ net.overlays.delete(msg.id);
+ applyDeleteFrame(msg.id);
+ } else if (msg.t === 'reset') {
+ if (playing) stopPlayback();
+ net.pending.length = 0;
+ net.overlays.clear();
+ if (msg.interval) frameInterval = msg.interval;
+ netAdopt(msg.frames, () => {
+ const first = msg.frames[0] || {};
+ dir = 1;
+ camX = (first.ox || 0) - (cssW / zoom - (first.w || 0)) / 2;
+ camY = (first.oy || 0) - (cssH / zoom - (first.h || 0)) / 2;
+ dirty = false;
+ });
+ } else if (msg.t === 'snap') {
+ // the server's stroke log got long - hand it flat images instead so
+ // the next person to join doesn't have to replay the whole session
+ net.send({ t: 'snapshot', upto: msg.upto, frames: frames.map(frameSnapshot) });
+ }
+}
+
+function netConnect() {
+ if (location.protocol !== 'http:' && location.protocol !== 'https:') return;
+ const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws');
+ ws.onopen = () => {
+ net.ws = ws;
+ net.on = true;
+ net.joined = true;
+ net.lastCursor = '';
+ };
+ ws.onmessage = (e) => {
+ let msg = null;
+ try { msg = JSON.parse(e.data); } catch (err) { return; }
+ netHandle(msg);
+ };
+ ws.onclose = () => {
+ const wasOn = net.on;
+ net.on = false;
+ net.ws = null;
+ net.peers.clear();
+ net.queue = null;
+ // whatever was in flight is moot: the server's state wins on the
+ // way back in
+ net.batch = null;
+ net.pending.length = 0;
+ net.overlays.clear();
+ if (wasOn) requestDraw();
+ // a page that was never in a room (opened from a file, or from a
+ // static host) stays solo; one that was keeps reaching for the
+ // server, which may just be restarting
+ if (net.joined) setTimeout(netConnect, 1500);
+ };
+ ws.onerror = () => ws.close();
+}
diff --git a/resources/render.js b/resources/render.js
@@ -0,0 +1,237 @@
+let drawQueued = false;
+function requestDraw() {
+ if (drawQueued) return;
+ drawQueued = true;
+ requestAnimationFrame(() => {
+ drawQueued = false;
+ draw();
+ });
+}
+
+function draw() {
+ const now = performance.now();
+ if (outlineLightness !== outlineAnimTo) {
+ const t = (now - outlineAnimStart) / OUTLINE_ANIM_MS;
+ if (t >= 1) {
+ outlineLightness = outlineAnimTo;
+ } else {
+ const e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
+ outlineLightness = outlineAnimFrom + (outlineAnimTo - outlineAnimFrom) * e;
+ requestDraw();
+ }
+ }
+ if (dispR !== colorAnimToR || dispG !== colorAnimToG || dispB !== colorAnimToB) {
+ const t = (now - colorAnimStart) / OUTLINE_ANIM_MS;
+ if (t >= 1) {
+ dispR = colorAnimToR; dispG = colorAnimToG; dispB = colorAnimToB;
+ } else {
+ const e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
+ dispR = Math.round(colorAnimFromR + (colorAnimToR - colorAnimFromR) * e);
+ dispG = Math.round(colorAnimFromG + (colorAnimToG - colorAnimFromG) * e);
+ dispB = Math.round(colorAnimFromB + (colorAnimToB - colorAnimFromB) * e);
+ requestDraw();
+ }
+ }
+
+ const W = view.width, H = view.height;
+ vctx.setTransform(1, 0, 0, 1, 0, 0);
+ vctx.imageSmoothingEnabled = false;
+ vctx.fillStyle = '#000';
+ vctx.fillRect(0, 0, W, H);
+
+ // one logical pixel on screen = integer device px. we round here so
+ // every logical pixel occupies the exact same number of device pixels:
+ // otherwise fractional dpr (1.25/1.5/1.75) makes nearest-neighbor
+ // resampling drop or duplicate rows, producing transparent stripes
+ // through painted content at high zoom.
+ const pxD = Math.max(1, Math.round(zoom * dpr));
+
+ const viewWLog = cssW / zoom;
+ const viewHLog = cssH / zoom;
+ const wx0 = camX;
+ const wy0 = camY;
+ const wx1 = camX + viewWLog;
+ const wy1 = camY + viewHLog;
+
+ const cx0 = Math.floor(wx0 / CHUNK);
+ const cy0 = Math.floor(wy0 / CHUNK);
+ const cx1 = Math.floor((wx1 - 1e-9) / CHUNK);
+ const cy1 = Math.floor((wy1 - 1e-9) / CHUNK);
+
+ // round destinations to integer device pixels to avoid seams between
+ // adjacent chunks. compute right/bottom edges from the neighbor's
+ // rounded left/top so shared edges line up exactly.
+ const destX = (wx) => Math.round((wx - camX) * pxD);
+ const destY = (wy) => Math.round((wy - camY) * pxD);
+
+ for (let cy = cy0; cy <= cy1; cy++) {
+ for (let cx = cx0; cx <= cx1; cx++) {
+ const c = chunks.get(chunkKey(cx, cy));
+ if (!c) continue;
+ const x0 = destX(cx * CHUNK);
+ const y0 = destY(cy * CHUNK);
+ const x1 = destX((cx + 1) * CHUNK);
+ const y1 = destY((cy + 1) * CHUNK);
+ vctx.drawImage(c.canvas, x0, y0, x1 - x0, y1 - y0);
+ }
+ }
+
+ // our own strokes still waiting for their echo, composited over the frame
+ const overlay = net.overlays.get(chunks.id);
+ if (overlay) {
+ for (let cy = cy0; cy <= cy1; cy++) {
+ for (let cx = cx0; cx <= cx1; cx++) {
+ const c = overlay.get(chunkKey(cx, cy));
+ if (!c) continue;
+ const x0 = destX(cx * CHUNK);
+ const y0 = destY(cy * CHUNK);
+ vctx.drawImage(c.canvas, x0, y0, destX((cx + 1) * CHUNK) - x0, destY((cy + 1) * CHUNK) - y0);
+ }
+ }
+ }
+
+ // onionskin: ghost the frame behind us in playback order, screen-blended
+ // so black contributes nothing over the opaque chunks
+ if (onionskin && !playing && frames.length > 1) {
+ const ghost = frames[(frameIdx - dir + frames.length) % frames.length];
+ const ghostOverlay = net.overlays.get(ghost.id);
+ vctx.save();
+ vctx.globalAlpha = GHOST_ALPHA;
+ vctx.globalCompositeOperation = 'screen';
+ for (const layer of ghostOverlay ? [ghost, ghostOverlay] : [ghost]) {
+ for (let cy = cy0; cy <= cy1; cy++) {
+ for (let cx = cx0; cx <= cx1; cx++) {
+ const c = layer.get(chunkKey(cx, cy));
+ if (!c) continue;
+ const x0 = destX(cx * CHUNK);
+ const y0 = destY(cy * CHUNK);
+ vctx.drawImage(c.canvas, x0, y0, destX((cx + 1) * CHUNK) - x0, destY((cy + 1) * CHUNK) - y0);
+ }
+ }
+ }
+ vctx.restore();
+ }
+
+ if (cursorAlpha !== cursorFadeTo) {
+ const dur = cursorFadeTo > cursorFadeFrom ? CURSOR_FADE_IN_MS : CURSOR_FADE_OUT_MS;
+ const t = (now - cursorFadeStart) / dur;
+ if (t >= 1) {
+ cursorAlpha = cursorFadeTo;
+ if (cursorFadeTo === 0) mouseInside = false;
+ } else {
+ const e = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
+ cursorAlpha = cursorFadeFrom + (cursorFadeTo - cursorFadeFrom) * e;
+ requestDraw();
+ }
+ }
+ // peers first, so the local brush always sits on top of theirs. their
+ // fades run here rather than arriving over the wire, so a brush leaving
+ // a phone still eases out on everyone else's screen
+ for (const [id, p] of net.peers) {
+ if (p.alpha !== p.fadeTo) {
+ const dur = p.fadeTo > p.fadeFrom ? CURSOR_FADE_IN_MS : CURSOR_FADE_OUT_MS;
+ const t = (now - p.fadeStart) / dur;
+ if (t >= 1) {
+ p.alpha = p.fadeTo;
+ } else {
+ const e = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
+ p.alpha = p.fadeFrom + (p.fadeTo - p.fadeFrom) * e;
+ requestDraw();
+ }
+ }
+ if (p.alpha <= 0) {
+ if (p.fadeTo === 0) net.peers.delete(id);
+ continue;
+ }
+ if (!p.b) continue;
+ const off = Math.floor(p.b / 2);
+ const sx = Math.round((p.x - off - camX) * pxD);
+ const sy = Math.round((p.y - off - camY) * pxD);
+ if (sx > W || sy > H || sx + p.b * pxD < 0 || sy + p.b * pxD < 0) continue;
+ // someone standing on another frame is drawn the way that frame's
+ // own marks would be: ghosted, so their brush can't read as
+ // something on the frame you're actually painting
+ const ghost = p.f !== chunks.id;
+ const a = p.alpha * (ghost ? GHOST_ALPHA : 1);
+ drawCursorShape(sx, sy, p.b, p.s, p.c, p.c[0] + p.c[1] + p.c[2] > 384 ? 1 : 0, a, pxD, ghost);
+ }
+
+ if (mouseInside) {
+ const tl = brushTopLeft(curX, curY);
+ // round to integer device pixels - camX/camY are fractional (smooth
+ // pan), and fractional fillRect coordinates antialias their edges,
+ // which would leave transparent lines between adjacent row strips.
+ // during an active pan, anchor the cursor to the real client
+ // position (rounded only to device pixels) so it tracks smoothly
+ // instead of jittering as the logical-pixel floor flips back and
+ // forth under a fractional camera. when the pan ends the cursor
+ // snaps back to the logical-pixel grid via the idle timer below.
+ let sx, sy;
+ if (panning && curClientX !== null) {
+ const off = Math.floor(brush / 2);
+ sx = Math.round((curClientX - off * zoom) * dpr);
+ sy = Math.round((curClientY - off * zoom) * dpr);
+ } else {
+ sx = Math.round((tl.x - camX) * pxD);
+ sy = Math.round((tl.y - camY) * pxD);
+ }
+ drawCursorShape(sx, sy, brush, roundness, [dispR, dispG, dispB], outlineLightness, cursorAlpha, pxD);
+ }
+
+ net.flush();
+ net.sendCursor();
+}
+
+// a brush preview: solid fill in its own color, wrapped in a
+// 1-logical-pixel outline cross-faded between darken and lighten. as a
+// ghost it drops the outline and screens like onionskinned content, since
+// that is exactly what it is - a brush on some other frame
+function drawCursorShape(sx, sy, n, round, col, lightness, alpha, pxD, ghost) {
+ if (alpha <= 0) return;
+ const shape = round === 0 ? null : brushShapeFor(n, round, col[0], col[1], col[2]);
+ vctx.save();
+ vctx.globalAlpha = alpha;
+ if (ghost) vctx.globalCompositeOperation = 'screen';
+ vctx.fillStyle = 'rgb(' + col[0] + ',' + col[1] + ',' + col[2] + ')';
+ if (!shape) {
+ vctx.fillRect(sx, sy, n * pxD, n * pxD);
+ } else {
+ const runs = shape.fillRuns;
+ for (let i = 0; i < runs.length; i += 3) {
+ vctx.fillRect(sx + runs[i] * pxD, sy + runs[i + 1] * pxD, runs[i + 2] * pxD, pxD);
+ }
+ }
+ if (ghost) {
+ vctx.restore();
+ return;
+ }
+
+ const drawOutline = () => {
+ if (shape) {
+ const runs = shape.outlineRuns;
+ for (let i = 0; i < runs.length; i += 3) {
+ vctx.fillRect(sx + runs[i] * pxD, sy + runs[i + 1] * pxD, runs[i + 2] * pxD, pxD);
+ }
+ } else {
+ vctx.fillRect(sx - pxD, sy - pxD, (n + 2) * pxD, pxD); // top
+ vctx.fillRect(sx - pxD, sy + n * pxD, (n + 2) * pxD, pxD); // bottom
+ vctx.fillRect(sx - pxD, sy, pxD, n * pxD); // left
+ vctx.fillRect(sx + n * pxD, sy, pxD, n * pxD); // right
+ }
+ };
+ vctx.save();
+ if (lightness > 0) {
+ vctx.globalCompositeOperation = 'multiply';
+ vctx.globalAlpha = lightness * alpha;
+ vctx.fillStyle = 'rgb(128,128,128)';
+ drawOutline();
+ }
+ if (lightness < 1) {
+ vctx.globalCompositeOperation = 'screen';
+ vctx.globalAlpha = (1 - lightness) * alpha;
+ vctx.fillStyle = 'rgb(128,128,128)';
+ drawOutline();
+ }
+ vctx.restore();
+ vctx.restore();
+}
diff --git a/resources/styles.css b/resources/styles.css
@@ -0,0 +1,22 @@
+html, body {
+ margin: 0;
+ padding: 0;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ background: #000;
+ cursor: none;
+ overscroll-behavior: none;
+ touch-action: none;
+ -webkit-user-select: none;
+ user-select: none;
+ -webkit-touch-callout: none;
+}
+canvas {
+ display: block;
+ position: absolute;
+ top: 0;
+ left: 0;
+ image-rendering: pixelated;
+ image-rendering: crisp-edges;
+}
diff --git a/resources/view.js b/resources/view.js
@@ -0,0 +1,93 @@
+let dpr = window.devicePixelRatio || 1;
+let cssW = 0, cssH = 0;
+
+const ZOOM_STOPS = [2, 3, 4, 5, 6, 8, 11, 15, 21, 30];
+const MIN_ZOOM = ZOOM_STOPS[0];
+const ZOOM_SENSITIVITY = 3.5; // fractional levels per unit log(pinch factor)
+let zoomIdx = 0;
+let zoom = MIN_ZOOM;
+
+let camX = 0, camY = 0;
+
+let curX = 0, curY = 0;
+// used to keep the brush pinned under the real cursor while panning, since
+// the OS does not emit pointermove events when only the camera moves.
+let curClientX = null, curClientY = null;
+let mouseInside = false;
+let cursorAlpha = 0;
+let cursorFadeFrom = 0;
+let cursorFadeTo = 0;
+let cursorFadeStart = 0;
+const CURSOR_FADE_IN_MS = 100;
+const CURSOR_FADE_OUT_MS = 400;
+function setCursorFadeTarget(target) {
+ if (target === cursorFadeTo) return;
+ cursorFadeFrom = cursorAlpha;
+ cursorFadeTo = target;
+ cursorFadeStart = performance.now();
+ requestDraw();
+}
+function showCursor() {
+ mouseInside = true;
+ setCursorFadeTarget(1);
+}
+function hideCursorNow() {
+ mouseInside = false;
+ cursorAlpha = 0;
+ cursorFadeFrom = 0;
+ cursorFadeTo = 0;
+}
+
+function resize() {
+ const firstResize = cssW === 0;
+ dpr = window.devicePixelRatio || 1;
+ cssW = window.innerWidth;
+ cssH = window.innerHeight;
+ view.style.width = cssW + 'px';
+ view.style.height = cssH + 'px';
+ view.width = Math.floor(cssW * dpr);
+ view.height = Math.floor(cssH * dpr);
+ if (firstResize) {
+ curX = Math.floor(cssW / (2 * zoom));
+ curY = Math.floor(cssH / (2 * zoom));
+ }
+ requestDraw();
+}
+
+function clientToWorld(clientX, clientY) {
+ const lx = Math.floor(clientX / zoom + camX);
+ const ly = Math.floor(clientY / zoom + camY);
+ return { x: lx, y: ly };
+}
+
+function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
+
+function applyZoom(factor, ax, ay) {
+ const worldAtAnchorX = camX + ax / zoom;
+ const worldAtAnchorY = camY + ay / zoom;
+ zoomIdx = clamp(zoomIdx + Math.log(factor) * ZOOM_SENSITIVITY, 0, ZOOM_STOPS.length - 1);
+ const newZoom = ZOOM_STOPS[Math.round(zoomIdx)];
+ if (newZoom !== zoom) {
+ zoom = newZoom;
+ camX = worldAtAnchorX - ax / zoom;
+ camY = worldAtAnchorY - ay / zoom;
+ }
+}
+
+function zoomTo(newZoom) {
+ if (newZoom === zoom) return;
+ const ax = cssW / 2, ay = cssH / 2;
+ const worldAtAnchorX = camX + ax / zoom;
+ const worldAtAnchorY = camY + ay / zoom;
+ zoom = newZoom;
+ zoomIdx = ZOOM_STOPS.indexOf(zoom);
+ camX = worldAtAnchorX - ax / zoom;
+ camY = worldAtAnchorY - ay / zoom;
+ requestDraw();
+}
+
+function stepZoom(dir) {
+ const i = ZOOM_STOPS.indexOf(zoom);
+ zoomTo(ZOOM_STOPS[clamp(i + dir, 0, ZOOM_STOPS.length - 1)]);
+}
+function resetZoom() { zoomTo(MIN_ZOOM); }
diff --git a/serve.py b/serve.py
@@ -0,0 +1,477 @@
+#!/usr/bin/env python3
+"""serve animus over the local network with a shared canvas.
+
+usage: ./serve.py [port]
+"""
+
+import base64
+import hashlib
+import json
+import os
+import queue
+import socket
+import struct
+import sys
+import threading
+import time
+from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
+
+ROOT = os.path.dirname(os.path.abspath(__file__))
+DEFAULT_PORT = 8000
+# per-painter outbound backlog. a stroke burst is a few hundred small messages,
+# so this is generous; past it the connection is too far behind to be correct
+SEND_QUEUE_MAX = 4096
+# rfc 6455 fixes this string
+WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
+MAX_PAYLOAD = 64 * 1024 * 1024
+
+# once the stored stroke log for the room passes this, ask a painter for flat
+# images of the frames so joiners don't have to replay an entire session
+SNAPSHOT_AFTER_OPS = 4000
+SNAPSHOT_COOLDOWN = 15.0
+
+# the ways a connection ends when the other side simply walks off
+DEAD_SOCKET = (ConnectionError, BrokenPipeError, TimeoutError, socket.timeout)
+
+
+def read_exact(rfile, n):
+ buf = b''
+ while len(buf) < n:
+ chunk = rfile.read(n - len(buf))
+ if not chunk:
+ return None
+ buf += chunk
+ return buf
+
+
+def unmask(payload, mask):
+ if not payload:
+ return payload
+ n = len(payload)
+ # xor the whole payload as one big int: the repeated key is trimmed back
+ # down to n bytes so byte i lines up with mask[i % 4]
+ rep = mask * (n // 4 + 1)
+ key = int.from_bytes(rep, 'big') >> (8 * (len(rep) - n))
+ return (int.from_bytes(payload, 'big') ^ key).to_bytes(n, 'big')
+
+
+def ws_recv(rfile, on_control=None):
+ """next complete data message as (opcode, payload), or None when the peer
+ closes. control frames are answered inline so they can't interrupt a
+ message that arrives in fragments."""
+ data = b''
+ msg_op = None
+ while True:
+ hdr = read_exact(rfile, 2)
+ if hdr is None:
+ return None
+ fin = hdr[0] & 0x80
+ op = hdr[0] & 0x0f
+ masked = hdr[1] & 0x80
+ length = hdr[1] & 0x7f
+ if length == 126:
+ ext = read_exact(rfile, 2)
+ if ext is None:
+ return None
+ length = struct.unpack('>H', ext)[0]
+ elif length == 127:
+ ext = read_exact(rfile, 8)
+ if ext is None:
+ return None
+ length = struct.unpack('>Q', ext)[0]
+ if length > MAX_PAYLOAD:
+ return None
+ mask = read_exact(rfile, 4) if masked else b''
+ if masked and mask is None:
+ return None
+ payload = read_exact(rfile, length) if length else b''
+ if payload is None:
+ return None
+ if masked:
+ payload = unmask(payload, mask)
+ if op & 0x8:
+ if op == 0x8:
+ return None
+ if op == 0x9 and on_control:
+ on_control(0xa, payload)
+ continue
+ if op != 0:
+ msg_op = op
+ data = payload
+ else:
+ data += payload
+ if fin:
+ return msg_op or 1, data
+
+
+def ws_frame(payload, opcode=1):
+ head = bytearray([0x80 | opcode])
+ n = len(payload)
+ if n < 126:
+ head.append(n)
+ elif n < 65536:
+ head.append(126)
+ head += struct.pack('>H', n)
+ else:
+ head.append(127)
+ head += struct.pack('>Q', n)
+ return bytes(head) + payload
+
+
+class Conn:
+ """one painter. writes go through a queue and a dedicated thread so a
+ painter on bad wifi can never stall the room: their backlog is theirs."""
+
+ _seq = 0
+ _seq_lock = threading.Lock()
+
+ def __init__(self, sock):
+ self.sock = sock
+ self.alive = True
+ self.joined = time.time()
+ self.q = queue.Queue(maxsize=SEND_QUEUE_MAX)
+ with Conn._seq_lock:
+ Conn._seq += 1
+ self.id = 'c%d' % Conn._seq
+ self.writer = threading.Thread(target=self._pump, daemon=True)
+ self.writer.start()
+
+ def _pump(self):
+ while True:
+ item = self.q.get()
+ if item is None:
+ return
+ payload, opcode = item
+ try:
+ self.sock.sendall(ws_frame(payload, opcode))
+ except OSError:
+ self.kill()
+ return
+
+ def kill(self):
+ """drop the connection; the page reconnects and re-syncs from scratch"""
+ if not self.alive:
+ return
+ self.alive = False
+ try:
+ self.sock.shutdown(socket.SHUT_RDWR)
+ except OSError:
+ pass
+ try:
+ self.q.put_nowait(None)
+ except queue.Full:
+ pass
+
+ def raw(self, payload, opcode=1, droppable=False):
+ if not self.alive:
+ return
+ try:
+ self.q.put_nowait((payload, opcode))
+ except queue.Full:
+ # a stale brush position is worth nothing, so let it go. a lost
+ # stroke would desync this painter for good, so cut them loose
+ # instead and let the reconnect hand them the room again
+ if not droppable:
+ self.kill()
+
+ def send(self, obj, droppable=False):
+ self.raw(json.dumps(obj, separators=(',', ':')).encode('utf-8'), droppable=droppable)
+
+
+class Room:
+ def __init__(self):
+ self.lock = threading.RLock()
+ self.clients = []
+ self.frame_seq = 0
+ self.op_seq = 0
+ self.op_count = 0 # stored strokes, tracked rather than recounted
+ self.frames = [self.blank_frame()]
+ self.index = {f['id']: f for f in self.frames}
+ self.interval = 250
+ self.snap_pending = False
+ self.snap_at = 0.0
+
+ def blank_frame(self):
+ self.frame_seq += 1
+ return {'id': 'f%d' % self.frame_seq, 'img': None, 'ox': 0, 'oy': 0, 'w': 0, 'h': 0, 'ops': []}
+
+ def base_frame(self, src):
+ f = self.blank_frame()
+ if isinstance(src, dict) and src.get('img'):
+ f['img'] = src['img']
+ f['ox'] = int(src.get('ox', 0))
+ f['oy'] = int(src.get('oy', 0))
+ f['w'] = int(src.get('w', 0))
+ f['h'] = int(src.get('h', 0))
+ return f
+
+ def frame(self, fid):
+ return self.index.get(fid)
+
+ def wire_frames(self, with_ops=True):
+ out = []
+ for f in self.frames:
+ e = {'id': f['id'], 'img': f['img'], 'ox': f['ox'], 'oy': f['oy'], 'w': f['w'], 'h': f['h']}
+ if with_ops:
+ e['ops'] = f['ops']
+ out.append(e)
+ return out
+
+ def broadcast(self, msg, skip=None, droppable=False):
+ payload = json.dumps(msg, separators=(',', ':')).encode('utf-8')
+ with self.lock:
+ targets = [c for c in self.clients if c is not skip]
+ for c in targets:
+ c.raw(payload, droppable=droppable)
+
+ def join(self, conn):
+ with self.lock:
+ self.clients.append(conn)
+ conn.send({
+ 't': 'init',
+ 'id': conn.id,
+ 'frames': self.wire_frames(),
+ 'interval': self.interval,
+ })
+ print(' + %s joined (%d painting)' % (conn.id, len(self.clients)))
+
+ def leave(self, conn):
+ with self.lock:
+ if conn in self.clients:
+ self.clients.remove(conn)
+ # the painter we asked for a flat copy may be the one leaving; let
+ # the next stroke ask someone else
+ self.snap_pending = False
+ print(' - %s left (%d painting)' % (conn.id, len(self.clients)))
+ self.broadcast({'t': 'bye', 'id': conn.id})
+
+ def maybe_snapshot(self):
+ """ask one painter to flatten the room so the stroke log stays bounded"""
+ now = time.time()
+ if self.snap_pending or now - self.snap_at < SNAPSHOT_COOLDOWN:
+ return
+ if self.op_count < SNAPSHOT_AFTER_OPS or not self.clients:
+ return
+ self.snap_pending = True
+ self.snap_at = now
+ self.clients[0].send({'t': 'snap', 'upto': self.op_seq})
+
+ def set_frames(self, frames):
+ self.frames = frames
+ self.index = {f['id']: f for f in frames}
+ self.op_count = sum(len(f['ops']) for f in frames)
+
+ def handle(self, conn, msg):
+ t = msg.get('t')
+ if t == 'p':
+ with self.lock:
+ f = self.frame(msg.get('f'))
+ if f is None:
+ return
+ self.op_seq += 1
+ op = {
+ 'n': self.op_seq,
+ 'o': msg.get('o'),
+ 'c': msg.get('c'),
+ 'b': msg.get('b'),
+ 's': msg.get('s'),
+ }
+ f['ops'].append(op)
+ self.op_count += 1
+ self.maybe_snapshot()
+ out = dict(op)
+ out['t'] = 'p'
+ out['f'] = f['id']
+ out['by'] = conn.id
+ # the painter's own tag for this batch, so they can recognise it
+ # coming back; not stored, it means nothing to anyone else
+ if msg.get('k') is not None:
+ out['k'] = msg['k']
+ # broadcast under the lock so wire order matches sequence order;
+ # sends only enqueue, so nothing slow happens in here. echoed to
+ # the painter too: the ack is what commits their stroke
+ self.broadcast(out)
+ elif t == 'c':
+ msg['id'] = conn.id
+ self.broadcast(msg, skip=conn, droppable=True)
+ elif t == 'af':
+ with self.lock:
+ at = max(0, min(int(msg.get('at', len(self.frames))), len(self.frames)))
+ f = self.blank_frame()
+ self.frames.insert(at, f)
+ self.index[f['id']] = f
+ self.broadcast({'t': 'af', 'at': at, 'id': f['id'], 'by': conn.id})
+ elif t == 'df':
+ with self.lock:
+ if len(self.frames) <= 1:
+ return
+ f = self.frame(msg.get('id'))
+ if f is None:
+ return
+ self.frames.remove(f)
+ del self.index[f['id']]
+ self.op_count -= len(f['ops'])
+ self.broadcast({'t': 'df', 'id': f['id'], 'by': conn.id})
+ elif t == 'reset':
+ srcs = msg.get('frames') or []
+ if not srcs:
+ return
+ with self.lock:
+ self.set_frames([self.base_frame(s) for s in srcs])
+ self.interval = int(msg.get('interval') or self.interval)
+ self.snap_pending = False
+ self.broadcast({
+ 't': 'reset',
+ 'frames': self.wire_frames(with_ops=False),
+ 'interval': self.interval,
+ 'by': conn.id,
+ })
+ elif t == 'snapshot':
+ upto = int(msg.get('upto') or 0)
+ with self.lock:
+ self.snap_pending = False
+ for src in msg.get('frames') or []:
+ f = self.frame(src.get('id'))
+ if f is None:
+ continue
+ f['img'] = src.get('img')
+ f['ox'] = int(src.get('ox', 0))
+ f['oy'] = int(src.get('oy', 0))
+ f['w'] = int(src.get('w', 0))
+ f['h'] = int(src.get('h', 0))
+ # strokes newer than the snapshot are kept; a stroke that made it
+ # into the image and also survives here just paints itself twice
+ kept = 0
+ for f in self.frames:
+ f['ops'] = [o for o in f['ops'] if o['n'] > upto]
+ kept += len(f['ops'])
+ self.op_count = kept
+ elif t == 'interval':
+ with self.lock:
+ self.interval = int(msg.get('v') or self.interval)
+
+
+room = Room()
+
+
+class Server(ThreadingHTTPServer):
+ daemon_threads = True
+ # a whole request thread can still unwind on a dropped socket, outside any
+ # handler we control. same story, same silence
+ def handle_error(self, request, client_address):
+ if isinstance(sys.exc_info()[1], DEAD_SOCKET):
+ return
+ super().handle_error(request, client_address)
+
+
+class Handler(SimpleHTTPRequestHandler):
+ protocol_version = 'HTTP/1.1'
+ server_version = 'animus'
+ # tiny realtime frames must go out now, not sit in nagle's buffer waiting
+ # on a watcher's delayed acks
+ disable_nagle_algorithm = True
+
+ def __init__(self, *a, **kw):
+ super().__init__(*a, directory=ROOT, **kw)
+
+ def log_message(self, fmt, *args):
+ pass
+
+ def handle(self):
+ try:
+ super().handle()
+ except DEAD_SOCKET:
+ self.close_connection = True
+
+ def finish(self):
+ try:
+ super().finish()
+ except DEAD_SOCKET:
+ pass
+
+ def end_headers(self):
+ self.send_header('Cache-Control', 'no-store')
+ super().end_headers()
+
+ def do_GET(self):
+ if self.path.split('?')[0] == '/ws':
+ self.do_ws()
+ return
+ super().do_GET()
+
+ def do_ws(self):
+ key = self.headers.get('Sec-WebSocket-Key')
+ if not key or 'websocket' not in (self.headers.get('Upgrade') or '').lower():
+ self.send_error(400, 'expected a websocket upgrade')
+ return
+ accept = base64.b64encode(hashlib.sha1((key + WS_GUID).encode()).digest()).decode()
+ self.close_connection = True
+ self.send_response(101, 'Switching Protocols')
+ self.send_header('Upgrade', 'websocket')
+ self.send_header('Connection', 'Upgrade')
+ self.send_header('Sec-WebSocket-Accept', accept)
+ self.end_headers()
+ self.wfile.flush()
+
+ conn = Conn(self.connection)
+ room.join(conn)
+ try:
+ pong = lambda code, data: conn.raw(data, code)
+ while conn.alive:
+ msg = ws_recv(self.rfile, pong)
+ if msg is None:
+ break
+ op, payload = msg
+ if op != 0x1:
+ continue
+ try:
+ data = json.loads(payload.decode('utf-8'))
+ except (ValueError, UnicodeDecodeError):
+ continue
+ if isinstance(data, dict):
+ room.handle(conn, data)
+ except OSError:
+ pass
+ finally:
+ conn.kill()
+ room.leave(conn)
+
+
+def lan_ip():
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ try:
+ s.connect(('10.255.255.255', 1))
+ return s.getsockname()[0]
+ except OSError:
+ return '127.0.0.1'
+ finally:
+ s.close()
+
+
+def main():
+ port = int(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_PORT
+ for attempt in range(20):
+ try:
+ httpd = Server(('0.0.0.0', port + attempt), Handler)
+ break
+ except OSError:
+ continue
+ else:
+ print('no free port near %d' % port)
+ return 1
+ port = httpd.server_address[1]
+
+ print('\nPress Ctrl+C to stop the server.\n')
+ print('http://%s:%d\n' % (lan_ip(), port))
+
+
+ try:
+ httpd.serve_forever()
+ except KeyboardInterrupt:
+ print('\n\nShutting down server...')
+ return 0
+
+
+if __name__ == '__main__':
+ sys.stdout.reconfigure(line_buffering=True)
+ sys.exit(main())