add multiplayer via serve.py Hunter Thursday, July 23rd, 2026 at 11:31 PM
1 +__pycache__/ 2 +.DS_Store
5 <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"> 6 <title>animus</title> 7 <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> 8 +<link rel="stylesheet" href="resources/styles.css"> 9 </head> 10 <body> 11 <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> 12 +<script src="resources/canvas.js"></script> 13 +<script src="resources/brush.js"></script> 14 +<script src="resources/view.js"></script> 15 +<script src="resources/render.js"></script> 16 +<script src="resources/io.js"></script> 17 +<script src="resources/input.js"></script> 18 +<script src="resources/net.js"></script> 19 +<script>resize(); netConnect();</script> 20 </body> 21 </html>
2 3 an infinite canvas for radical digital painting. 4 -<!-- > inspired by KidPix, MS Paint, Mario Paint, and Flipnote Studio. --> 5 +<!-- > inspired by Kid Pix, Mario Paint, Flipnote Studio, and iScribble. --> 6 7 <p align="center"> 8 <img src="readme_images/doodle.gif" width=300> 42 43 ### importing / exporting 44 - 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) 45 +- hold `⌘` (or `ctrl`) and press `O` to open an existing painting from your filesystem (will clear the current canvas) 46 + 47 +### multiplayer 48 +run `./serve.py` and share the printed URL with anyone on your local network.
1 +let r = 255, g = 255, b = 255; 2 +let brush = 1; 3 +let roundness = 0; 4 + 5 +// keyed cache: the local brush and every peer's brush pull from the same one 6 +const brushShapes = new Map(); 7 +const BRUSH_CACHE_MAX = 64; 8 +function getBrushShape() { return brushShapeFor(brush, roundness, r, g, b); } 9 +function brushShapeFor(n, round, sr, sg, sb) { 10 + const key = n + ',' + round + ',' + sr + ',' + sg + ',' + sb; 11 + const hit = brushShapes.get(key); 12 + if (hit) return hit; 13 + if (brushShapes.size >= BRUSH_CACHE_MAX) brushShapes.clear(); 14 + const inside = new Uint8Array(n * n); 15 + const rad = round * (n / 2); 16 + const rad2 = rad * rad; 17 + const lo = rad - 0.5; 18 + const hi = n - 0.5 - rad; 19 + for (let dy = 0; dy < n; dy++) { 20 + for (let dx = 0; dx < n; dx++) { 21 + let qx = 0, qy = 0; 22 + if (dx < lo) qx = lo - dx; 23 + else if (dx > hi) qx = dx - hi; 24 + if (dy < lo) qy = lo - dy; 25 + else if (dy > hi) qy = dy - hi; 26 + if (qx * qx + qy * qy <= rad2 + 1e-9) inside[dy * n + dx] = 1; 27 + } 28 + } 29 + const sprite = document.createElement('canvas'); 30 + sprite.width = n; 31 + sprite.height = n; 32 + const sctx = sprite.getContext('2d'); 33 + const img = sctx.createImageData(n, n); 34 + const data = img.data; 35 + for (let i = 0; i < n * n; i++) { 36 + if (inside[i]) { 37 + data[i * 4] = sr; 38 + data[i * 4 + 1] = sg; 39 + data[i * 4 + 2] = sb; 40 + data[i * 4 + 3] = 255; 41 + } 42 + } 43 + sctx.putImageData(img, 0, 0); 44 + 45 + const fillRuns = []; 46 + for (let dy = 0; dy < n; dy++) { 47 + let dx = 0; 48 + while (dx < n) { 49 + if (!inside[dy * n + dx]) { dx++; continue; } 50 + let dx1 = dx + 1; 51 + while (dx1 < n && inside[dy * n + dx1]) dx1++; 52 + fillRuns.push(dx, dy, dx1 - dx); 53 + dx = dx1; 54 + } 55 + } 56 + 57 + // 1-pixel outline ring: cells outside the shape that are 8-way adjacent 58 + // to any inside cell. stored as row-runs in an (n+2)x(n+2) grid with 59 + // coordinates offset by -1 so they index directly in shape-local space. 60 + const m = n + 2; 61 + const ring = new Uint8Array(m * m); 62 + const isIn = (x, y) => x >= 0 && y >= 0 && x < n && y < n && inside[y * n + x] === 1; 63 + for (let y = -1; y <= n; y++) { 64 + for (let x = -1; x <= n; x++) { 65 + if (isIn(x, y)) continue; 66 + let adj = false; 67 + for (let oy = -1; oy <= 1 && !adj; oy++) { 68 + for (let ox = -1; ox <= 1 && !adj; ox++) { 69 + if (ox === 0 && oy === 0) continue; 70 + if (isIn(x + ox, y + oy)) adj = true; 71 + } 72 + } 73 + if (adj) ring[(y + 1) * m + (x + 1)] = 1; 74 + } 75 + } 76 + const outlineRuns = []; 77 + for (let y = 0; y < m; y++) { 78 + let x = 0; 79 + while (x < m) { 80 + if (!ring[y * m + x]) { x++; continue; } 81 + let x1 = x + 1; 82 + while (x1 < m && ring[y * m + x1]) x1++; 83 + outlineRuns.push(x - 1, y - 1, x1 - x); 84 + x = x1; 85 + } 86 + } 87 + 88 + const shape = { inside, sprite, fillRuns, outlineRuns, n }; 89 + brushShapes.set(key, shape); 90 + return shape; 91 +} 92 + 93 +// outline blend: 0 = dark color (lighten with screen), 1 = light color (darken with multiply). 94 +let outlineLightness = 1; 95 +let outlineAnimStart = 0; 96 +let outlineAnimFrom = 1; 97 +let outlineAnimTo = 1; 98 +const OUTLINE_ANIM_MS = 250; 99 +function setOutlineTarget(target) { 100 + if (target === outlineAnimTo) return; 101 + outlineAnimFrom = outlineLightness; 102 + outlineAnimTo = target; 103 + outlineAnimStart = performance.now(); 104 + requestDraw(); 105 +} 106 + 107 +// displayed cursor fill eases toward r/g/b; painting still uses r/g/b immediately. 108 +let dispR = 255, dispG = 255, dispB = 255; 109 +let colorAnimStart = 0; 110 +let colorAnimFromR = 255, colorAnimFromG = 255, colorAnimFromB = 255; 111 +let colorAnimToR = 255, colorAnimToG = 255, colorAnimToB = 255; 112 +function setDisplayColorTarget(nr, ng, nb) { 113 + if (nr === colorAnimToR && ng === colorAnimToG && nb === colorAnimToB) return; 114 + colorAnimFromR = dispR; colorAnimFromG = dispG; colorAnimFromB = dispB; 115 + colorAnimToR = nr; colorAnimToG = ng; colorAnimToB = nb; 116 + colorAnimStart = performance.now(); 117 + requestDraw(); 118 +} 119 + 120 +const faviconEl = document.getElementById('favicon'); 121 +function updateFavicon() { 122 + 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>"; 123 + faviconEl.href = 'data:image/svg+xml,' + encodeURIComponent(svg); 124 +} 125 + 126 +function pickAt(cx, cy) { 127 + const c = readPixel(cx, cy); 128 + r = c[0]; g = c[1]; b = c[2]; 129 + setOutlineTarget((r + g + b > 384) ? 1 : 0); 130 + setDisplayColorTarget(r, g, b); 131 + updateFavicon(); 132 +} 133 + 134 +function setColor(nr, ng, nb) { 135 + r = nr; g = ng; b = nb; 136 + setOutlineTarget((r + g + b > 384) ? 1 : 0); 137 + updateFavicon(); 138 + dispR = r; dispG = g; dispB = b; 139 + colorAnimFromR = r; colorAnimFromG = g; colorAnimFromB = b; 140 + colorAnimToR = r; colorAnimToG = g; colorAnimToB = b; 141 +}
1 +const view = document.getElementById('view'); 2 +const vctx = view.getContext('2d', { alpha: false }); 3 + 4 +const CHUNK = 256; 5 +let frameSeq = 0; 6 +// frames carry a stable id so collaborators can name the same frame even 7 +// as the list shifts under inserts and deletes 8 +function newFrameMap(id) { 9 + const m = new Map(); 10 + m.id = id || ('l' + (++frameSeq)); 11 + return m; 12 +} 13 +let frames = [newFrameMap()]; 14 +let frameIdx = 0; 15 +let chunks = frames[0]; 16 +let dir = 1; // playback direction: 1 forward, -1 reverse 17 +let onionskin = false; 18 +// how anything belonging to another frame reads: onionskinned content and 19 +// the brushes of painters standing on it 20 +const GHOST_ALPHA = 0.35; 21 +let playing = false; 22 +let playTimer = null; 23 +let frameInterval = 250; 24 +let lastTap = 0; 25 + 26 +function chunkKey(cx, cy) { return cx + ',' + cy; } 27 + 28 +// remote strokes redirect every paint write into the frame they belong to 29 +let paintMap = null; 30 + 31 +function getOrCreateChunk(cx, cy, map) { 32 + map = map || paintMap || chunks; 33 + const k = chunkKey(cx, cy); 34 + let c = map.get(k); 35 + if (c) return c; 36 + const cnv = document.createElement('canvas'); 37 + cnv.width = CHUNK; 38 + cnv.height = CHUNK; 39 + // overlay chunks stay transparent so they composite over the frame 40 + const cctx = cnv.getContext('2d', { alpha: !!map.overlay }); 41 + if (!map.overlay) { 42 + cctx.fillStyle = '#000'; 43 + cctx.fillRect(0, 0, CHUNK, CHUNK); 44 + } 45 + c = { canvas: cnv, ctx: cctx }; 46 + map.set(k, c); 47 + return c; 48 +} 49 + 50 +// unacked local strokes paint here, on top of the frame, until their echo 51 +// commits them into it in the room's order 52 +function overlayFor(fid) { 53 + let m = net.overlays.get(fid); 54 + if (!m) { 55 + m = new Map(); 56 + m.id = fid; 57 + m.overlay = true; 58 + net.overlays.set(fid, m); 59 + } 60 + return m; 61 +} 62 + 63 +function paintRect(wx, wy, w, h, color) { 64 + const x0 = wx, y0 = wy, x1 = wx + w, y1 = wy + h; 65 + const cx0 = Math.floor(x0 / CHUNK); 66 + const cy0 = Math.floor(y0 / CHUNK); 67 + const cx1 = Math.floor((x1 - 1) / CHUNK); 68 + const cy1 = Math.floor((y1 - 1) / CHUNK); 69 + for (let cy = cy0; cy <= cy1; cy++) { 70 + for (let cx = cx0; cx <= cx1; cx++) { 71 + const c = getOrCreateChunk(cx, cy); 72 + const lx = Math.max(x0, cx * CHUNK) - cx * CHUNK; 73 + const ly = Math.max(y0, cy * CHUNK) - cy * CHUNK; 74 + const rx = Math.min(x1, (cx + 1) * CHUNK) - cx * CHUNK; 75 + const ry = Math.min(y1, (cy + 1) * CHUNK) - cy * CHUNK; 76 + c.ctx.fillStyle = color; 77 + c.ctx.fillRect(lx, ly, rx - lx, ry - ly); 78 + } 79 + } 80 +} 81 + 82 +function readPixel(wx, wy) { 83 + const cx = Math.floor(wx / CHUNK); 84 + const cy = Math.floor(wy / CHUNK); 85 + const k = chunkKey(cx, cy); 86 + const lx = wx - cx * CHUNK; 87 + const ly = wy - cy * CHUNK; 88 + // an unacked stroke sits on the overlay; pick what's actually on screen 89 + const ov = net.overlays.get(chunks.id); 90 + const oc = ov && ov.get(k); 91 + if (oc) { 92 + const d = oc.ctx.getImageData(lx, ly, 1, 1).data; 93 + if (d[3] === 255) return [d[0], d[1], d[2]]; 94 + } 95 + const c = chunks.get(k); 96 + if (!c) return [0, 0, 0]; 97 + const d = c.ctx.getImageData(lx, ly, 1, 1).data; 98 + return [d[0], d[1], d[2]]; 99 +} 100 + 101 +function setFrame(i) { 102 + frameIdx = ((i % frames.length) + frames.length) % frames.length; 103 + chunks = frames[frameIdx]; 104 + // a held pick tracks the frame under it as frames change 105 + if (picking) pickAt(curX, curY); 106 + requestDraw(); 107 +} 108 + 109 +// frame structure changes round-trip through the server when connected, so 110 +// every painter ends up with the same list in the same order 111 +function addFrame() { 112 + const at = dir === 1 ? frameIdx + 1 : frameIdx; 113 + if (net.on) { net.send({ t: 'af', at }); return; } 114 + applyAddFrame(at, null, true); 115 +} 116 + 117 +function applyAddFrame(at, id, mine) { 118 + at = clamp(at, 0, frames.length); 119 + const cur = chunks; 120 + frames.splice(at, 0, newFrameMap(id)); 121 + setFrame(mine ? at : frames.indexOf(cur)); 122 +} 123 + 124 +function deleteFrame() { 125 + if (frames.length === 1) return; 126 + if (net.on) { net.send({ t: 'df', id: chunks.id }); return; } 127 + applyDeleteFrame(chunks.id); 128 +} 129 + 130 +function applyDeleteFrame(id) { 131 + if (frames.length === 1) return; 132 + const i = frames.findIndex((f) => f.id === id); 133 + if (i < 0) return; 134 + const cur = chunks; 135 + frames.splice(i, 1); 136 + if (playing && frames.length === 1) stopPlayback(); 137 + const j = frames.indexOf(cur); 138 + setFrame(j >= 0 ? j : Math.min(i, frames.length - 1)); 139 +} 140 + 141 +function stopPlayback() { 142 + playing = false; 143 + clearTimeout(playTimer); 144 + requestDraw(); 145 +} 146 + 147 +function startPlayback() { 148 + if (frames.length === 1) return; 149 + playing = true; 150 + playTimer = setTimeout(function step() { 151 + setFrame(frameIdx + dir); 152 + playTimer = setTimeout(step, frameInterval); 153 + }, frameInterval); 154 +} 155 + 156 +function tapArrow(d) { 157 + dir = d; 158 + const now = performance.now(); 159 + if (lastTap && now - lastTap <= 10000) { 160 + frameInterval = now - lastTap; 161 + // stored, not applied to anyone live: whoever joins next inherits it 162 + net.send({ t: 'interval', v: Math.round(frameInterval) }); 163 + } 164 + lastTap = now; 165 + if (playing) { stopPlayback(); return; } 166 + setFrame(frameIdx + d); 167 +} 168 + 169 +const keys = {}; 170 +let painting = false; 171 +let picking = false; 172 +let lastPaintX = null, lastPaintY = null; 173 +let dirty = false; 174 + 175 +function brushTopLeft(cx, cy) { 176 + const off = Math.floor(brush / 2); 177 + return { x: cx - off, y: cy - off }; 178 +} 179 + 180 +function paintAt(cx, cy) { 181 + dirty = true; 182 + const tl = brushTopLeft(cx, cy); 183 + if (roundness === 0) { 184 + paintRect(tl.x, tl.y, brush, brush, 'rgb(' + r + ',' + g + ',' + b + ')'); 185 + return; 186 + } 187 + const sprite = getBrushShape().sprite; 188 + const cx0 = Math.floor(tl.x / CHUNK); 189 + const cy0 = Math.floor(tl.y / CHUNK); 190 + const cx1 = Math.floor((tl.x + brush - 1) / CHUNK); 191 + const cy1 = Math.floor((tl.y + brush - 1) / CHUNK); 192 + for (let ccy = cy0; ccy <= cy1; ccy++) { 193 + for (let ccx = cx0; ccx <= cx1; ccx++) { 194 + const c = getOrCreateChunk(ccx, ccy); 195 + c.ctx.drawImage(sprite, tl.x - ccx * CHUNK, tl.y - ccy * CHUNK); 196 + } 197 + } 198 +} 199 + 200 +// every mark the local user makes goes out as a segment; a dot 201 +// is just a zero-length one. in a room the pixels land on the frame's 202 +// overlay - the frame itself only takes strokes in the server's order 203 +function localPaintAt(x, y) { 204 + if (net.on) paintMap = overlayFor(chunks.id); 205 + try { 206 + paintAt(x, y); 207 + } finally { 208 + paintMap = null; 209 + } 210 + net.paint(x, y, x, y); 211 +} 212 + 213 +function localPaintLine(x0, y0, x1, y1) { 214 + if (net.on) paintMap = overlayFor(chunks.id); 215 + try { 216 + paintLine(x0, y0, x1, y1); 217 + } finally { 218 + paintMap = null; 219 + } 220 + net.paint(x0, y0, x1, y1); 221 +} 222 + 223 +// replay someone else's segment into whichever frame it belongs to, with 224 +// their brush, without disturbing the local one 225 +function applyPaintOp(op) { 226 + const f = frames.find((fr) => fr.id === op.f); 227 + if (!f || !op.o || !op.c) return; 228 + const pm = paintMap, pr = r, pg = g, pb = b, pbrush = brush, pround = roundness; 229 + paintMap = f; 230 + r = op.c[0]; g = op.c[1]; b = op.c[2]; 231 + brush = op.b; roundness = op.s; 232 + try { 233 + for (let i = 0; i + 3 < op.o.length; i += 4) { 234 + paintLine(op.o[i], op.o[i + 1], op.o[i + 2], op.o[i + 3]); 235 + } 236 + } finally { 237 + paintMap = pm; 238 + r = pr; g = pg; b = pb; 239 + brush = pbrush; roundness = pround; 240 + } 241 + if (f === chunks) requestDraw(); 242 +} 243 + 244 +function paintLine(x0, y0, x1, y1) { 245 + let dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1; 246 + let dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1; 247 + let err = dx + dy; 248 + let x = x0, y = y0; 249 + while (true) { 250 + paintAt(x, y); 251 + if (x === x1 && y === y1) break; 252 + const e2 = 2 * err; 253 + if (e2 >= dy) { err += dy; x += sx; } 254 + if (e2 <= dx) { err += dx; y += sy; } 255 + } 256 +}
1 +window.addEventListener('resize', resize); 2 + 3 +// mouse and pen share the pointer-event path; finger touches are handled 4 +// by the touch-event path below 5 +function hoverPointer(e) { return e.pointerType === 'mouse' || e.pointerType === 'pen'; } 6 + 7 +window.addEventListener('pointerover', (e) => { 8 + if (!hoverPointer(e)) return; 9 + showCursor(); 10 + curClientX = e.clientX; 11 + curClientY = e.clientY; 12 + const p = clientToWorld(e.clientX, e.clientY); 13 + curX = p.x; 14 + curY = p.y; 15 + requestDraw(); 16 +}); 17 + 18 +// brush resize curve: linear (fine, precise) up to BRUSH_LINEAR_MAX, then a 19 +// power-law ramp beyond, tuned by BRUSH_ACCEL. 20 +function brushFromDrag(start, dyPx) { 21 + const A = BRUSH_DRAG_PX_PER_STEP, T = BRUSH_LINEAR_MAX, c = BRUSH_ACCEL; 22 + const seam = T * A; // drag distance from size 0 to the seam 23 + const brushToPos = (b) => b <= T ? b * A : seam + (seam / c) * (Math.pow(b / T, c) - 1); 24 + const posToBrush = (p) => p <= seam ? p / A : T * Math.pow(1 + c * (p - seam) / seam, 1 / c); 25 + return posToBrush(brushToPos(start) + dyPx); 26 +} 27 + 28 +// modifier-drag: shift/cmd/RGB held -> vertical motion from the anchor 29 +// drives brush size / roundness / color channels. drag up (dy negative) 30 +// -> increase. when a value hits its bound and the drag continues past, 31 +// re-anchor so reversing direction responds immediately. each drag is 32 +// independent so any combination can run at once. 33 +function applyDrags(clientY) { 34 + if (dragBrush) { 35 + const dyPx = (dragBrush.anchorY - clientY) * dpr; 36 + const target = brushFromDrag(dragBrush.start, dyPx); 37 + const clamped = clamp(Math.round(target), 1, MAX_BRUSH); 38 + brush = clamped; 39 + if (target < 1 || target > MAX_BRUSH) { 40 + dragBrush.start = clamped; 41 + dragBrush.anchorY = clientY; 42 + } 43 + } 44 + if (dragRoundness) { 45 + const dyPx = (dragRoundness.anchorY - clientY) * dpr; 46 + const target = dragRoundness.start - dyPx / ROUNDNESS_DRAG_FULL_PX; 47 + const clamped = clamp(target, 0, 1); 48 + roundness = clamped; 49 + if (target !== clamped) { 50 + dragRoundness.start = clamped; 51 + dragRoundness.anchorY = clientY; 52 + } 53 + } 54 + if (dragColor) { 55 + const dyPx = (dragColor.anchorY - clientY) * dpr; 56 + const delta = (dyPx / COLOR_DRAG_FULL_PX) * 255; 57 + let nr = r, ng = g, nb = b; 58 + let overshoot = 0; 59 + if (keys['r']) { 60 + const t = dragColor.r0 + delta; 61 + nr = clamp(t, 0, 255); 62 + if (t !== nr) overshoot = Math.max(overshoot, Math.abs(t - nr)); 63 + } 64 + if (keys['g']) { 65 + const t = dragColor.g0 + delta; 66 + ng = clamp(t, 0, 255); 67 + if (t !== ng) overshoot = Math.max(overshoot, Math.abs(t - ng)); 68 + } 69 + if (keys['b']) { 70 + const t = dragColor.b0 + delta; 71 + nb = clamp(t, 0, 255); 72 + if (t !== nb) overshoot = Math.max(overshoot, Math.abs(t - nb)); 73 + } 74 + setColor(Math.round(nr), Math.round(ng), Math.round(nb)); 75 + if (overshoot > 0) { 76 + dragColor.r0 = nr; dragColor.g0 = ng; dragColor.b0 = nb; 77 + dragColor.anchorY = clientY; 78 + } 79 + } 80 +} 81 + 82 +window.addEventListener('pointermove', (e) => { 83 + if (!hoverPointer(e)) return; 84 + // a pen only proves it can hover by moving while lifted. without hover 85 + // it gets the finger treatment: modifiers adjust instead of marking 86 + if (e.pointerType === 'pen' && e.buttons === 0) penHover = true; 87 + showCursor(); 88 + curClientX = e.clientX; 89 + curClientY = e.clientY; 90 + const p = clientToWorld(e.clientX, e.clientY); 91 + const nx = p.x, ny = p.y; 92 + 93 + applyDrags(e.clientY); 94 + 95 + if (painting) { 96 + if (lastPaintX !== null) { 97 + localPaintLine(lastPaintX, lastPaintY, nx, ny); 98 + } else { 99 + localPaintAt(nx, ny); 100 + } 101 + lastPaintX = nx; 102 + lastPaintY = ny; 103 + } else if (picking) { 104 + pickAt(nx, ny); 105 + } 106 + curX = nx; 107 + curY = ny; 108 + requestDraw(); 109 +}); 110 + 111 +window.addEventListener('pointerleave', (e) => { 112 + if (!hoverPointer(e)) return; 113 + hideCursorNow(); 114 + requestDraw(); 115 +}); 116 + 117 +// a pen without hover support sends no move events before contact, so the 118 +// down event must place the cursor itself 119 +let penDown = false; 120 +let penHover = false; 121 +window.addEventListener('pointerdown', (e) => { 122 + if (!hoverPointer(e)) return; 123 + if (e.button !== 0) return; 124 + e.preventDefault(); 125 + stopInertia(); 126 + if (e.pointerType === 'pen') penDown = true; 127 + showCursor(); 128 + curClientX = e.clientX; 129 + curClientY = e.clientY; 130 + const p = clientToWorld(e.clientX, e.clientY); 131 + curX = p.x; 132 + curY = p.y; 133 + // hoverless pen + modifier held -> the contact adjusts, like a finger 134 + if (e.pointerType === 'pen' && !penHover && !keys['c'] && (dragBrush || dragRoundness || dragColor)) { 135 + anchorDrags(e.clientY); 136 + requestDraw(); 137 + return; 138 + } 139 + if (keys['c']) { 140 + picking = true; 141 + pickAt(curX, curY); 142 + requestDraw(); 143 + return; 144 + } 145 + painting = true; 146 + lastPaintX = curX; 147 + lastPaintY = curY; 148 + localPaintAt(curX, curY); 149 + requestDraw(); 150 +}); 151 + 152 +function pointerUp(e) { 153 + if (!hoverPointer(e)) return; 154 + if (e.button === 0 || e.type === 'pointercancel') { 155 + painting = false; 156 + picking = false; 157 + lastPaintX = null; 158 + lastPaintY = null; 159 + if (e.pointerType === 'pen') { 160 + penDown = false; 161 + // hoverless pens get no more events after lift, so fade like touch 162 + if (mouseInside) setCursorFadeTarget(0); 163 + requestDraw(); 164 + } 165 + } 166 +} 167 +window.addEventListener('pointerup', pointerUp); 168 +window.addEventListener('pointercancel', pointerUp); 169 + 170 +// touch: one finger paints, two fingers pan/pinch-zoom. once a second 171 +// finger lands the whole touch becomes a gesture (no painting) until all 172 +// fingers lift, so a stray finger during a pan never leaves a mark. 173 +// stylus touches are excluded here 174 +let touchMode = null; // 'paint' | 'adjust' | 'gesture' 175 +let touchPainted = false; 176 +let pinchDist = 0, pinchMidX = 0, pinchMidY = 0; 177 + 178 +// inertial pan: centroid velocity (css px/ms) tracked during the gesture, 179 +// thrown on release and decayed with exponential friction 180 +let panVX = 0, panVY = 0; 181 +let panLastT = 0; 182 +let inertiaRAF = null; 183 +function stopInertia() { 184 + if (inertiaRAF !== null) { 185 + cancelAnimationFrame(inertiaRAF); 186 + inertiaRAF = null; 187 + } 188 +} 189 +function startInertia() { 190 + // no throw if the finger paused before lifting or was barely moving 191 + const speed = Math.hypot(panVX, panVY); 192 + if (performance.now() - panLastT > 100 || speed < 0.05) return; 193 + // exponential decay per ms 194 + const DECEL = 0.995; 195 + const MAX_SPEED = 3; // css px/ms 196 + if (speed > MAX_SPEED) { 197 + panVX *= MAX_SPEED / speed; 198 + panVY *= MAX_SPEED / speed; 199 + } 200 + let prev = performance.now(); 201 + const step = (now) => { 202 + const dt = now - prev; 203 + prev = now; 204 + camX -= panVX * dt / zoom; 205 + camY -= panVY * dt / zoom; 206 + const f = Math.pow(DECEL, dt); 207 + panVX *= f; 208 + panVY *= f; 209 + requestDraw(); 210 + inertiaRAF = Math.hypot(panVX, panVY) > 0.02 ? requestAnimationFrame(step) : null; 211 + }; 212 + inertiaRAF = requestAnimationFrame(step); 213 +} 214 + 215 +function touchCentroid(touches) { 216 + let x = 0, y = 0; 217 + for (const t of touches) { x += t.clientX; y += t.clientY; } 218 + return { x: x / touches.length, y: y / touches.length }; 219 +} 220 + 221 +function fingerTouches(list) { 222 + const out = []; 223 + for (const t of list) if (t.touchType !== 'stylus') out.push(t); 224 + return out; 225 +} 226 + 227 +// re-anchor active drags to a new y and re-baseline to the current values 228 +// so successive drag strokes accumulate instead of snapping back 229 +function anchorDrags(clientY) { 230 + if (dragBrush) { dragBrush.anchorY = clientY; dragBrush.start = brush; } 231 + if (dragRoundness) { dragRoundness.anchorY = clientY; dragRoundness.start = roundness; } 232 + if (dragColor) { dragColor.anchorY = clientY; dragColor.r0 = r; dragColor.g0 = g; dragColor.b0 = b; } 233 +} 234 + 235 +function anchorGesture(touches) { 236 + const m = touchCentroid(touches); 237 + pinchMidX = m.x; 238 + pinchMidY = m.y; 239 + pinchDist = touches.length >= 2 240 + ? Math.hypot(touches[0].clientX - touches[1].clientX, touches[0].clientY - touches[1].clientY) 241 + : 0; 242 +} 243 + 244 +window.addEventListener('touchstart', (e) => { 245 + e.preventDefault(); 246 + const fingers = fingerTouches(e.touches); 247 + // fingers are ignored while the pen is down so a resting hand can't 248 + // hijack or extend the pen's stroke 249 + if (fingers.length === 0 || penDown) return; 250 + stopInertia(); 251 + if (fingers.length === 1 && touchMode === null) { 252 + const t = fingers[0]; 253 + curClientX = t.clientX; 254 + curClientY = t.clientY; 255 + if (!keys['c'] && (dragBrush || dragRoundness || dragColor)) { 256 + touchMode = 'adjust'; 257 + const p = clientToWorld(t.clientX, t.clientY); 258 + curX = p.x; 259 + curY = p.y; 260 + showCursor(); 261 + anchorDrags(t.clientY); 262 + } else { 263 + const p = clientToWorld(t.clientX, t.clientY); 264 + curX = p.x; 265 + curY = p.y; 266 + showCursor(); 267 + touchMode = 'paint'; 268 + touchPainted = false; 269 + if (keys['c']) { 270 + picking = true; 271 + } else { 272 + painting = true; 273 + lastPaintX = curX; 274 + lastPaintY = curY; 275 + } 276 + } 277 + } else { 278 + touchMode = 'gesture'; 279 + painting = false; 280 + picking = false; 281 + lastPaintX = null; 282 + lastPaintY = null; 283 + hideCursorNow(); 284 + panVX = 0; 285 + panVY = 0; 286 + panLastT = 0; 287 + anchorGesture(fingers); 288 + } 289 + requestDraw(); 290 +}, { passive: false }); 291 + 292 +window.addEventListener('touchmove', (e) => { 293 + e.preventDefault(); 294 + const fingers = fingerTouches(e.touches); 295 + if (fingers.length === 0) return; 296 + if (touchMode === 'paint') { 297 + const t = fingers[0]; 298 + curClientX = t.clientX; 299 + curClientY = t.clientY; 300 + // on hover devices held keys adjust during the stroke, like mouse 301 + applyDrags(t.clientY); 302 + const p = clientToWorld(t.clientX, t.clientY); 303 + if (picking) { 304 + pickAt(p.x, p.y); 305 + } else if (painting) { 306 + localPaintLine(lastPaintX, lastPaintY, p.x, p.y); 307 + lastPaintX = p.x; 308 + lastPaintY = p.y; 309 + } 310 + touchPainted = true; 311 + curX = p.x; 312 + curY = p.y; 313 + } else if (touchMode === 'adjust') { 314 + const t = fingers[0]; 315 + curClientX = t.clientX; 316 + curClientY = t.clientY; 317 + applyDrags(t.clientY); 318 + const p = clientToWorld(t.clientX, t.clientY); 319 + curX = p.x; 320 + curY = p.y; 321 + } else if (touchMode === 'gesture') { 322 + const m = touchCentroid(fingers); 323 + const dxs = m.x - pinchMidX; 324 + const dys = m.y - pinchMidY; 325 + camX -= dxs / zoom; 326 + camY -= dys / zoom; 327 + const nowT = performance.now(); 328 + const dt = nowT - panLastT; 329 + if (dt > 0 && dt < 100) { 330 + panVX = panVX * 0.5 + (dxs / dt) * 0.5; 331 + panVY = panVY * 0.5 + (dys / dt) * 0.5; 332 + } 333 + panLastT = nowT; 334 + if (fingers.length >= 2) { 335 + const dist = Math.hypot(fingers[0].clientX - fingers[1].clientX, fingers[0].clientY - fingers[1].clientY); 336 + if (pinchDist > 0) applyZoom(dist / pinchDist, m.x, m.y); 337 + pinchDist = dist; 338 + } 339 + pinchMidX = m.x; 340 + pinchMidY = m.y; 341 + } else { 342 + return; 343 + } 344 + requestDraw(); 345 +}, { passive: false }); 346 + 347 +function touchEnd(e) { 348 + e.preventDefault(); 349 + if (touchMode === null) return; 350 + const fingers = fingerTouches(e.touches); 351 + if (fingers.length === 0) { 352 + // a tap that never moved still paints its dot (or picks its point) 353 + if (touchMode === 'paint' && !touchPainted) { 354 + if (picking) pickAt(curX, curY); 355 + else localPaintAt(curX, curY); 356 + } 357 + if (touchMode === 'gesture') startInertia(); 358 + touchMode = null; 359 + painting = false; 360 + picking = false; 361 + lastPaintX = null; 362 + lastPaintY = null; 363 + // fade the preview out instead of hiding it instantly 364 + if (mouseInside) setCursorFadeTarget(0); 365 + requestDraw(); 366 + } else if (touchMode === 'gesture') { 367 + // re-anchor to the remaining fingers so the camera doesn't jump 368 + anchorGesture(fingers); 369 + } 370 +} 371 +window.addEventListener('touchend', touchEnd, { passive: false }); 372 +window.addEventListener('touchcancel', touchEnd, { passive: false }); 373 + 374 +function startDragMode(mode) { 375 + const anchorY = curClientY !== null ? curClientY : 0; 376 + if (mode === 'brush' && !dragBrush) dragBrush = { anchorY, start: brush }; 377 + else if (mode === 'roundness' && !dragRoundness) dragRoundness = { anchorY, start: roundness }; 378 + else if (mode === 'color' && !dragColor) dragColor = { anchorY, r0: r, g0: g, b0: b }; 379 + // hoverless input: a modifier pressed mid-stroke ends the stroke and 380 + // the rest of the contact becomes an adjust drag. hover-capable pens 381 + // keep drawing (they can adjust while lifted instead) 382 + if (touchMode === 'paint' || (penDown && !penHover)) { 383 + if (touchMode === 'paint') touchMode = 'adjust'; 384 + painting = false; 385 + picking = false; 386 + lastPaintX = null; 387 + lastPaintY = null; 388 + } 389 +} 390 +function endDragMode(mode) { 391 + if (mode === 'brush') dragBrush = null; 392 + else if (mode === 'roundness') dragRoundness = null; 393 + else if (mode === 'color') dragColor = null; 394 +} 395 + 396 +window.addEventListener('keydown', (e) => { 397 + if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 's') { 398 + e.preventDefault(); 399 + exportGIF(); 400 + return; 401 + } 402 + if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'o') { 403 + e.preventDefault(); 404 + fileInput.click(); 405 + return; 406 + } 407 + if ((e.metaKey || e.ctrlKey) && !e.altKey) { 408 + if (e.key === '=' || e.key === '+') { e.preventDefault(); stepZoom(1); return; } 409 + if (e.key === '-' || e.key === '_') { e.preventDefault(); stepZoom(-1); return; } 410 + if (e.key === '0') { e.preventDefault(); resetZoom(); return; } 411 + } 412 + if (!e.metaKey && !e.ctrlKey && !e.altKey && !e.repeat) { 413 + const lk = e.key.toLowerCase(); 414 + if (e.key === 'ArrowLeft') { e.preventDefault(); tapArrow(-1); return; } 415 + if (e.key === 'ArrowRight') { e.preventDefault(); tapArrow(1); return; } 416 + if (e.key === ' ') { e.preventDefault(); playing ? stopPlayback() : startPlayback(); return; } 417 + if (lk === 'a') { addFrame(); return; } 418 + if (lk === 'd') { deleteFrame(); return; } 419 + if (lk === 'o') { 420 + if (playing) { stopPlayback(); onionskin = true; } 421 + else onionskin = !onionskin; 422 + requestDraw(); 423 + return; 424 + } 425 + } 426 + const k = e.key.toLowerCase(); 427 + const wasDown = keys[k]; 428 + keys[k] = true; 429 + if (!e.metaKey && !e.ctrlKey) { 430 + if (k === 'z' || k === 'x' || k === 'c' || k === 'r' || k === 'g' || k === 'b') e.preventDefault(); 431 + if (k === 'x') startDragMode('roundness'); 432 + else if (k === 'z') startDragMode('brush'); 433 + else if (!wasDown && (k === 'r' || k === 'g' || k === 'b')) startDragMode('color'); 434 + else if (k === 'c' && !wasDown && (touchMode === 'paint' || touchMode === 'adjust')) { 435 + touchMode = 'paint'; 436 + painting = false; 437 + picking = true; 438 + lastPaintX = null; 439 + lastPaintY = null; 440 + touchPainted = true; 441 + pickAt(curX, curY); 442 + requestDraw(); 443 + } 444 + } 445 +}); 446 +window.addEventListener('keyup', (e) => { 447 + const k = e.key.toLowerCase(); 448 + keys[k] = false; 449 + if (k === 'x') endDragMode('roundness'); 450 + else if (k === 'z') endDragMode('brush'); 451 + else if (k === 'r' || k === 'g' || k === 'b') { 452 + if (!keys['r'] && !keys['g'] && !keys['b']) endDragMode('color'); 453 + else if (dragColor) { 454 + // still holding at least one rgb key - re-anchor from current 455 + // values so the remaining keys don't jump based on released key's history 456 + dragColor.anchorY = curClientY !== null ? curClientY : dragColor.anchorY; 457 + dragColor.r0 = r; dragColor.g0 = g; dragColor.b0 = b; 458 + } 459 + } 460 + // releasing c mid-pick-drag -> back to adjusting if a modifier is still 461 + // held on a hoverless contact, else seamlessly switch to painting 462 + if (k === 'c' && picking) { 463 + picking = false; 464 + const hoverless = touchMode !== null || (penDown && !penHover); 465 + if (hoverless && (dragBrush || dragRoundness || dragColor)) { 466 + if (touchMode !== null) touchMode = 'adjust'; 467 + painting = false; 468 + if (curClientY !== null) anchorDrags(curClientY); 469 + } else { 470 + painting = true; 471 + lastPaintX = curX; 472 + lastPaintY = curY; 473 + localPaintAt(curX, curY); 474 + } 475 + requestDraw(); 476 + } 477 +}); 478 +window.addEventListener('blur', () => { 479 + for (const k in keys) keys[k] = false; 480 + dragBrush = null; dragRoundness = null; dragColor = null; 481 + painting = false; 482 + picking = false; 483 + penDown = false; 484 + lastPaintX = null; 485 + lastPaintY = null; 486 +}); 487 + 488 +// wheel gesture lock: once a wheel gesture starts in a given mode, 489 +// subsequent events in the same burst stay in that mode until the wheel 490 +// goes idle or the controlling modifier set changes. this keeps trackpad 491 +// momentum from leaking into pan after a modifier release, while still 492 +// letting the user swap between modes mid-flick without stale state. 493 +let wheelMode = null; 494 +let wheelIdleTimer = null; 495 +let panning = false; 496 + 497 +const BRUSH_DRAG_PX_PER_STEP = 25; // device px per +/- 1 brush px (below the seam) 498 +const BRUSH_LINEAR_MAX = 6; // brush px below which resize stays linear/fine 499 +const BRUSH_ACCEL = 0.5; 500 +const MAX_BRUSH = 150; 501 +const ROUNDNESS_DRAG_FULL_PX = 200; 502 +const COLOR_DRAG_FULL_PX = 256; 503 +// each drag carries its own anchor so any combination can run at once 504 +let dragBrush = null; // {anchorY, start} 505 +let dragRoundness = null; // {anchorY, start} 506 +let dragColor = null; // {anchorY, r0, g0, b0} 507 +function touchWheelGesture() { 508 + if (wheelIdleTimer) clearTimeout(wheelIdleTimer); 509 + wheelIdleTimer = setTimeout(() => { 510 + wheelMode = null; 511 + if (panning) { 512 + // pan ended - resync the logical-pixel cursor from the real 513 + // client position so the brush snaps onto its final cell. 514 + panning = false; 515 + if (curClientX !== null) { 516 + const p = clientToWorld(curClientX, curClientY); 517 + curX = p.x; 518 + curY = p.y; 519 + } 520 + requestDraw(); 521 + } 522 + }, 150); 523 +} 524 +function pickModifierMode(e) { 525 + if (e.ctrlKey) return 'zoom'; 526 + return null; 527 +} 528 + 529 +window.addEventListener('wheel', (e) => { 530 + e.preventDefault(); 531 + stopInertia(); 532 + 533 + // re-pick mode each event from live modifiers so releasing cmd and 534 + // immediately starting a shift scroll switches over cleanly. if a 535 + // burst started with a modifier and the modifier is then released 536 + // mid-flick, suppress rather than leaking into pan. 537 + const modMode = pickModifierMode(e); 538 + if (modMode !== null) { 539 + wheelMode = modMode; 540 + } else if (wheelMode === null) { 541 + wheelMode = 'pan'; 542 + } else if (wheelMode !== 'pan') { 543 + // modifier released mid-burst: drop the remaining momentum 544 + // instead of letting it leak into pan. 545 + touchWheelGesture(); 546 + return; 547 + } 548 + touchWheelGesture(); 549 + 550 + if (wheelMode === 'zoom') { 551 + // exponential zoom on the float accumulator so small pinches add up 552 + applyZoom(Math.exp(-e.deltaY * 0.02), e.clientX, e.clientY); 553 + requestDraw(); 554 + return; 555 + } 556 + 557 + // pan - smooth fractional camera. during the pan gesture the draw 558 + // loop anchors the brush to the real client cursor position so it 559 + // tracks the pointer smoothly; curX/curY get resynced onto the 560 + // logical-pixel grid when the wheel idle timer fires. 561 + panning = true; 562 + camX += e.deltaX / zoom; 563 + camY += e.deltaY / zoom; 564 + requestDraw(); 565 +}, { passive: false }); 566 + 567 +window.addEventListener('beforeunload', (e) => { 568 + if (dirty) { e.preventDefault(); } 569 +}); 570 + 571 +window.addEventListener('contextmenu', (e) => e.preventDefault()); 572 + 573 +// block the OS pinch gesture events too (Safari) 574 +window.addEventListener('gesturestart', (e) => e.preventDefault()); 575 +window.addEventListener('gesturechange', (e) => e.preventDefault()); 576 +window.addEventListener('gestureend', (e) => e.preventDefault());
1 +const fileInput = document.createElement('input'); 2 +fileInput.type = 'file'; 3 +fileInput.accept = 'image/*'; 4 +fileInput.style.display = 'none'; 5 +document.body.appendChild(fileInput); 6 +fileInput.addEventListener('change', (e) => { 7 + const f = e.target.files && e.target.files[0]; 8 + if (f) importFile(f); 9 + fileInput.value = ''; 10 +}); 11 + 12 +function formatTimestamp(d) { 13 + const p = (n) => String(n).padStart(2, '0'); 14 + 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()); 15 +} 16 + 17 +// union of painted chunk bounds across all frames, so every exported 18 +// frame shares the canvas of the biggest one 19 +function frameBounds() { 20 + let any = false, minCx = Infinity, minCy = Infinity, maxCx = -Infinity, maxCy = -Infinity; 21 + for (const f of frames) { 22 + for (const k of f.keys()) { 23 + any = true; 24 + const [cx, cy] = k.split(',').map(Number); 25 + if (cx < minCx) minCx = cx; 26 + if (cy < minCy) minCy = cy; 27 + if (cx > maxCx) maxCx = cx; 28 + if (cy > maxCy) maxCy = cy; 29 + } 30 + } 31 + return any ? { minCx, minCy, maxCx, maxCy } : null; 32 +} 33 + 34 +function renderFrame(f, b, w, h) { 35 + const out = document.createElement('canvas'); 36 + out.width = w; 37 + out.height = h; 38 + const octx = out.getContext('2d'); 39 + octx.fillStyle = '#000'; 40 + octx.fillRect(0, 0, w, h); 41 + for (const [k, c] of f) { 42 + const [cx, cy] = k.split(',').map(Number); 43 + octx.drawImage(c.canvas, (cx - b.minCx) * CHUNK, (cy - b.minCy) * CHUNK); 44 + } 45 + return octx.getImageData(0, 0, w, h); 46 +} 47 + 48 +function lzwEncode(minCode, data, lookup, out) { 49 + const clear = 1 << minCode, eoi = clear + 1; 50 + let codeSize = minCode + 1, next = eoi + 1; 51 + let dict = new Map(); 52 + let acc = 0, accBits = 0; 53 + let block = []; 54 + const flushBlock = () => { 55 + if (block.length) { out.push(block.length, ...block); block = []; } 56 + }; 57 + const emit = (code) => { 58 + acc |= code << accBits; 59 + accBits += codeSize; 60 + while (accBits >= 8) { 61 + block.push(acc & 255); 62 + acc >>= 8; 63 + accBits -= 8; 64 + if (block.length === 255) flushBlock(); 65 + } 66 + }; 67 + const idx = (i) => lookup((data[i] << 16) | (data[i + 1] << 8) | data[i + 2]); 68 + emit(clear); 69 + let prev = idx(0); 70 + for (let i = 4; i < data.length; i += 4) { 71 + const k = idx(i); 72 + const key = prev * 256 + k; 73 + if (dict.has(key)) { prev = dict.get(key); continue; } 74 + emit(prev); 75 + if (next === 4096) { 76 + emit(clear); 77 + dict = new Map(); 78 + next = eoi + 1; 79 + codeSize = minCode + 1; 80 + } else { 81 + if (next >= (1 << codeSize)) codeSize++; 82 + dict.set(key, next++); 83 + } 84 + prev = k; 85 + } 86 + emit(prev); 87 + emit(eoi); 88 + if (accBits) block.push(acc & 255); 89 + flushBlock(); 90 +} 91 + 92 +// minimal GIF89a encoder: exact global palette when <=256 colors, 93 +// else uniform 6x6x6 quantization 94 +function encodeGIF(images, w, h, delayMs) { 95 + const colorIdx = new Map(); 96 + let over = false; 97 + for (const img of images) { 98 + const d = img.data; 99 + for (let i = 0; i < d.length && !over; i += 4) { 100 + const c = (d[i] << 16) | (d[i + 1] << 8) | d[i + 2]; 101 + if (!colorIdx.has(c)) { 102 + if (colorIdx.size === 256) over = true; 103 + else colorIdx.set(c, colorIdx.size); 104 + } 105 + } 106 + if (over) break; 107 + } 108 + if (over) { 109 + colorIdx.clear(); 110 + for (let i = 0; i < 216; i++) { 111 + colorIdx.set(((Math.floor(i / 36) * 51) << 16) | ((Math.floor(i / 6) % 6 * 51) << 8) | (i % 6 * 51), i); 112 + } 113 + } 114 + const lookup = (c) => over 115 + ? Math.round(((c >> 16) & 255) / 51) * 36 + Math.round(((c >> 8) & 255) / 51) * 6 + Math.round((c & 255) / 51) 116 + : colorIdx.get(c); 117 + let bits = 2; 118 + while ((1 << bits) < colorIdx.size) bits++; 119 + const out = []; 120 + const u16 = (v) => { out.push(v & 255, (v >> 8) & 255); }; 121 + out.push(71, 73, 70, 56, 57, 97); // "GIF89a" 122 + u16(w); u16(h); 123 + out.push(0x80 | ((bits - 1) << 4) | (bits - 1), 0, 0); 124 + const pal = [...colorIdx.keys()]; 125 + for (let i = 0; i < (1 << bits); i++) { 126 + const c = pal[i] || 0; 127 + out.push((c >> 16) & 255, (c >> 8) & 255, c & 255); 128 + } 129 + const animated = images.length > 1; 130 + if (animated) { 131 + // NETSCAPE2.0 loop forever 132 + out.push(0x21, 0xff, 11, 78, 69, 84, 83, 67, 65, 80, 69, 50, 46, 48, 3, 1, 0, 0, 0); 133 + } 134 + const delay = clamp(Math.round(delayMs / 10), 2, 65535); 135 + for (const img of images) { 136 + if (animated) out.push(0x21, 0xf9, 4, 0, delay & 255, (delay >> 8) & 255, 0, 0); 137 + out.push(0x2c); 138 + u16(0); u16(0); u16(w); u16(h); 139 + out.push(0); 140 + const minCode = Math.max(2, bits); 141 + out.push(minCode); 142 + lzwEncode(minCode, img.data, lookup, out); 143 + out.push(0); 144 + } 145 + out.push(0x3b); 146 + return new Uint8Array(out); 147 +} 148 + 149 +function exportGIF() { 150 + const b = frameBounds(); 151 + if (!b) return; 152 + const w = (b.maxCx - b.minCx + 1) * CHUNK; 153 + const h = (b.maxCy - b.minCy + 1) * CHUNK; 154 + const imgs = frames.map((f) => renderFrame(f, b, w, h)); 155 + if (dir === -1) imgs.reverse(); 156 + const blob = new Blob([encodeGIF(imgs, w, h, frameInterval)], { type: 'image/gif' }); 157 + const url = URL.createObjectURL(blob); 158 + const a = document.createElement('a'); 159 + a.href = url; 160 + a.download = 'animus \u00b7 ' + formatTimestamp(new Date()) + '.gif'; 161 + document.body.appendChild(a); 162 + a.click(); 163 + document.body.removeChild(a); 164 + URL.revokeObjectURL(url); 165 + dirty = false; 166 +} 167 + 168 +function imageToFrame(src, w, h) { 169 + const ox = -Math.floor(w / 2); 170 + const oy = -Math.floor(h / 2); 171 + const f = newFrameMap(); 172 + drawIntoFrame(f, src, w, h, ox, oy); 173 + return { f, w, h, ox, oy }; 174 +} 175 + 176 +function drawIntoFrame(f, src, w, h, ox, oy) { 177 + const cx0 = Math.floor(ox / CHUNK); 178 + const cy0 = Math.floor(oy / CHUNK); 179 + const cx1 = Math.floor((ox + w - 1) / CHUNK); 180 + const cy1 = Math.floor((oy + h - 1) / CHUNK); 181 + for (let cy = cy0; cy <= cy1; cy++) { 182 + for (let cx = cx0; cx <= cx1; cx++) { 183 + getOrCreateChunk(cx, cy, f).ctx.drawImage(src, ox - cx * CHUNK, oy - cy * CHUNK); 184 + } 185 + } 186 +} 187 + 188 +function finishImport(newFrames, w, h, ox, oy) { 189 + frames = newFrames; 190 + dir = 1; 191 + setFrame(0); 192 + camX = ox - (cssW / zoom - w) / 2; 193 + camY = oy - (cssH / zoom - h) / 2; 194 + dirty = false; 195 + requestDraw(); 196 +} 197 + 198 +function loadImage(src) { 199 + return new Promise((resolve, reject) => { 200 + const img = new Image(); 201 + img.onload = () => resolve(img); 202 + img.onerror = reject; 203 + img.src = src; 204 + }); 205 +} 206 + 207 +// decoded frames of the file as {src, w, h, close} 208 +async function decodeSources(file) { 209 + // ImageDecoder gives us every frame of an animated gif; fall back to 210 + // single-image import where unsupported 211 + if (typeof ImageDecoder !== 'undefined') { 212 + try { 213 + const dec = new ImageDecoder({ data: await file.arrayBuffer(), type: file.type }); 214 + await dec.tracks.ready; 215 + const count = dec.tracks.selectedTrack.frameCount; 216 + const srcs = []; 217 + let interval = 0; 218 + for (let i = 0; i < count; i++) { 219 + const { image } = await dec.decode({ frameIndex: i }); 220 + if (i === 0 && image.duration) interval = Math.max(20, image.duration / 1000); 221 + srcs.push({ src: image, w: image.displayWidth, h: image.displayHeight, close: () => image.close() }); 222 + } 223 + return { srcs, interval }; 224 + } catch (err) {} 225 + } 226 + const url = URL.createObjectURL(file); 227 + try { 228 + const img = await loadImage(url); 229 + return { srcs: [{ src: img, w: img.width, h: img.height }], interval: 0 }; 230 + } catch (err) { 231 + return null; 232 + } finally { 233 + URL.revokeObjectURL(url); 234 + } 235 +} 236 + 237 +function applyImport(srcs) { 238 + const newFrames = []; 239 + let last = null; 240 + for (const s of srcs) { 241 + last = imageToFrame(s.src, s.w, s.h); 242 + newFrames.push(last.f); 243 + } 244 + if (last) finishImport(newFrames, last.w, last.h, last.ox, last.oy); 245 +} 246 + 247 +// flat png per frame, positioned in world space, so an import can be handed 248 +// to everyone else in the room (and to whoever joins later) 249 +function sourcesToBases(srcs) { 250 + const cnv = document.createElement('canvas'); 251 + const cctx = cnv.getContext('2d'); 252 + return srcs.map((s) => { 253 + cnv.width = s.w; 254 + cnv.height = s.h; 255 + cctx.drawImage(s.src, 0, 0); 256 + return { img: cnv.toDataURL('image/png'), ox: -Math.floor(s.w / 2), oy: -Math.floor(s.h / 2), w: s.w, h: s.h }; 257 + }); 258 +} 259 + 260 +async function importFile(file) { 261 + if (dirty && !confirm('Importing will discard the current ' + (frames.length > 1 ? 'animation' : 'painting') + '. Continue?' + (net.on ? ' (for everyone)' : ''))) return; 262 + if (playing) stopPlayback(); 263 + const dec = await decodeSources(file); 264 + if (!dec || !dec.srcs.length) return; 265 + if (dec.interval) frameInterval = dec.interval; 266 + if (net.on) net.send({ t: 'reset', frames: sourcesToBases(dec.srcs), interval: frameInterval }); 267 + else applyImport(dec.srcs); 268 + for (const s of dec.srcs) if (s.close) s.close(); 269 +}
1 +// painting together: mirrors strokes, frames, and brushes between 2 +// everyone in the room 3 + 4 +const net = { 5 + on: false, 6 + id: null, 7 + ws: null, 8 + peers: new Map(), 9 + joined: false, // has this page ever been in a room 10 + queue: null, // messages held while a snapshot loads 11 + lastCursor: '', 12 + batch: null, // segments painted this frame, not yet sent 13 + key: 0, 14 + pending: [], // own strokes still waiting for their echo 15 + overlays: new Map(), // frame id -> chunk map of those unacked strokes 16 +}; 17 + 18 +net.send = function (msg) { 19 + if (!net.on) return; 20 + try { 21 + net.ws.send(JSON.stringify(msg)); 22 + } catch (err) {} 23 +}; 24 + 25 +// a fast pen reports far more moves than anyone can see, so segments pile up 26 +// into one message per animation frame. a new frame, color, or brush starts 27 +// a new batch, since a batch carries a single style 28 +net.paint = function (x0, y0, x1, y1) { 29 + if (!net.on) return; 30 + const cur = net.batch; 31 + if (cur && cur.f === chunks.id && cur.b === brush && cur.s === roundness 32 + && cur.c[0] === r && cur.c[1] === g && cur.c[2] === b) { 33 + cur.o.push(x0, y0, x1, y1); 34 + return; 35 + } 36 + net.flush(); 37 + net.batch = { t: 'p', k: ++net.key, f: chunks.id, o: [x0, y0, x1, y1], c: [r, g, b], b: brush, s: roundness }; 38 + // tracked from the first segment, so the overlay it painted onto is held 39 + // until the echo commits it to the frame proper 40 + net.pending.push({ k: net.batch.k, f: chunks.id }); 41 +}; 42 + 43 +net.flush = function () { 44 + const out = net.batch; 45 + if (!out) return; 46 + net.batch = null; 47 + net.send(out); 48 +}; 49 + 50 +// an overlay lives exactly as long as some stroke on it awaits its echo 51 +function clearOverlayIfIdle(fid) { 52 + if (net.pending.some((p) => p.f === fid)) return; 53 + if (net.overlays.delete(fid)) requestDraw(); 54 +} 55 + 56 +// called at the end of every draw, so the brush other people see moves 57 +// exactly when ours does, and never more often than a frame 58 +net.sendCursor = function () { 59 + if (!net.on) return; 60 + // the target, not the current alpha: everyone else runs the same fade 61 + // from the moment it starts, so a slow fade-out never reads as a blink 62 + const v = cursorFadeTo > 0; 63 + const key = v ? curX + ',' + curY + ',' + brush + ',' + roundness + ',' + r + ',' + g + ',' + b + ',' + chunks.id : '0'; 64 + if (key === net.lastCursor) return; 65 + net.lastCursor = key; 66 + net.send({ t: 'c', x: curX, y: curY, b: brush, s: roundness, c: [r, g, b], f: chunks.id, v }); 67 +}; 68 + 69 +function frameSnapshot(f) { 70 + let any = false, minCx = Infinity, minCy = Infinity, maxCx = -Infinity, maxCy = -Infinity; 71 + for (const k of f.keys()) { 72 + any = true; 73 + const [cx, cy] = k.split(',').map(Number); 74 + if (cx < minCx) minCx = cx; 75 + if (cy < minCy) minCy = cy; 76 + if (cx > maxCx) maxCx = cx; 77 + if (cy > maxCy) maxCy = cy; 78 + } 79 + if (!any) return { id: f.id, img: null }; 80 + const w = (maxCx - minCx + 1) * CHUNK; 81 + const h = (maxCy - minCy + 1) * CHUNK; 82 + const cnv = document.createElement('canvas'); 83 + cnv.width = w; 84 + cnv.height = h; 85 + const cctx = cnv.getContext('2d'); 86 + cctx.fillStyle = '#000'; 87 + cctx.fillRect(0, 0, w, h); 88 + for (const [k, c] of f) { 89 + const [cx, cy] = k.split(',').map(Number); 90 + cctx.drawImage(c.canvas, (cx - minCx) * CHUNK, (cy - minCy) * CHUNK); 91 + } 92 + return { id: f.id, img: cnv.toDataURL('image/png'), ox: minCx * CHUNK, oy: minCy * CHUNK, w, h }; 93 +} 94 + 95 +// rebuild frames from flat images the server is holding 96 +async function framesFromBases(list) { 97 + const out = []; 98 + for (const e of list) { 99 + const f = newFrameMap(e.id); 100 + if (e.img) { 101 + try { 102 + const img = await loadImage(e.img); 103 + drawIntoFrame(f, img, e.w || img.width, e.h || img.height, e.ox || 0, e.oy || 0); 104 + } catch (err) {} 105 + } 106 + out.push(f); 107 + } 108 + return out; 109 +} 110 + 111 +// hold live messages until the frames they refer to exist 112 +function netDefer(msg) { 113 + if (!net.queue) return false; 114 + net.queue.push(msg); 115 + return true; 116 +} 117 + 118 +function netFlush() { 119 + const q = net.queue; 120 + net.queue = null; 121 + if (!q) return; 122 + for (const msg of q) netHandle(msg); 123 +} 124 + 125 +// swap in a server-supplied frame list, replaying anything that arrived 126 +// while the images were decoding only after `after` has caught us up 127 +async function netAdopt(list, after) { 128 + net.queue = []; 129 + const built = await framesFromBases(list); 130 + frames = built; 131 + setFrame(0); 132 + if (after) after(); 133 + netFlush(); 134 + requestDraw(); 135 +} 136 + 137 +function netHandle(msg) { 138 + if (msg.t === 'init') { 139 + net.id = msg.id; 140 + net.batch = null; 141 + net.pending.length = 0; 142 + net.overlays.clear(); 143 + if (msg.interval) frameInterval = msg.interval; 144 + netAdopt(msg.frames, () => { 145 + for (const e of msg.frames) { 146 + for (const op of e.ops || []) applyPaintOp({ ...op, f: e.id }); 147 + } 148 + dirty = false; 149 + }); 150 + return; 151 + } 152 + if (netDefer(msg)) return; 153 + if (msg.t === 'p') { 154 + // strokes land in the frame strictly in the order the server sent 155 + // them - ours included. until now our own stroke existed only on the 156 + // overlay; this echo is what commits it, so every painter's frame is 157 + // the same ops in the same order 158 + applyPaintOp(msg); 159 + if (msg.by === net.id) { 160 + const i = net.pending.findIndex((p) => p.k === msg.k); 161 + if (i < 0) return; 162 + // earlier entries can only be strokes the server dropped (their 163 + // frame was deleted); sweep them along 164 + const gone = net.pending.splice(0, i + 1); 165 + for (const p of gone) clearOverlayIfIdle(p.f); 166 + } 167 + } else if (msg.t === 'c') { 168 + const p = net.peers.get(msg.id) || { alpha: 0, fadeFrom: 0, fadeTo: 0, fadeStart: 0 }; 169 + const target = msg.v ? 1 : 0; 170 + if (target !== p.fadeTo) { 171 + p.fadeFrom = p.alpha; 172 + p.fadeTo = target; 173 + p.fadeStart = performance.now(); 174 + } 175 + p.x = msg.x; p.y = msg.y; p.b = msg.b; p.s = msg.s; p.c = msg.c; p.f = msg.f; 176 + net.peers.set(msg.id, p); 177 + requestDraw(); 178 + } else if (msg.t === 'bye') { 179 + const p = net.peers.get(msg.id); 180 + if (p && p.fadeTo !== 0) { 181 + p.fadeFrom = p.alpha; 182 + p.fadeTo = 0; 183 + p.fadeStart = performance.now(); 184 + } 185 + requestDraw(); 186 + } else if (msg.t === 'af') { 187 + applyAddFrame(msg.at, msg.id, msg.by === net.id); 188 + } else if (msg.t === 'df') { 189 + // the server drops strokes aimed at a deleted frame, so their echoes 190 + // never come; the frame is going away, and its overlay with it 191 + net.pending = net.pending.filter((p) => p.f !== msg.id); 192 + net.overlays.delete(msg.id); 193 + applyDeleteFrame(msg.id); 194 + } else if (msg.t === 'reset') { 195 + if (playing) stopPlayback(); 196 + net.pending.length = 0; 197 + net.overlays.clear(); 198 + if (msg.interval) frameInterval = msg.interval; 199 + netAdopt(msg.frames, () => { 200 + const first = msg.frames[0] || {}; 201 + dir = 1; 202 + camX = (first.ox || 0) - (cssW / zoom - (first.w || 0)) / 2; 203 + camY = (first.oy || 0) - (cssH / zoom - (first.h || 0)) / 2; 204 + dirty = false; 205 + }); 206 + } else if (msg.t === 'snap') { 207 + // the server's stroke log got long - hand it flat images instead so 208 + // the next person to join doesn't have to replay the whole session 209 + net.send({ t: 'snapshot', upto: msg.upto, frames: frames.map(frameSnapshot) }); 210 + } 211 +} 212 + 213 +function netConnect() { 214 + if (location.protocol !== 'http:' && location.protocol !== 'https:') return; 215 + const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws'); 216 + ws.onopen = () => { 217 + net.ws = ws; 218 + net.on = true; 219 + net.joined = true; 220 + net.lastCursor = ''; 221 + }; 222 + ws.onmessage = (e) => { 223 + let msg = null; 224 + try { msg = JSON.parse(e.data); } catch (err) { return; } 225 + netHandle(msg); 226 + }; 227 + ws.onclose = () => { 228 + const wasOn = net.on; 229 + net.on = false; 230 + net.ws = null; 231 + net.peers.clear(); 232 + net.queue = null; 233 + // whatever was in flight is moot: the server's state wins on the 234 + // way back in 235 + net.batch = null; 236 + net.pending.length = 0; 237 + net.overlays.clear(); 238 + if (wasOn) requestDraw(); 239 + // a page that was never in a room (opened from a file, or from a 240 + // static host) stays solo; one that was keeps reaching for the 241 + // server, which may just be restarting 242 + if (net.joined) setTimeout(netConnect, 1500); 243 + }; 244 + ws.onerror = () => ws.close(); 245 +}
1 +let drawQueued = false; 2 +function requestDraw() { 3 + if (drawQueued) return; 4 + drawQueued = true; 5 + requestAnimationFrame(() => { 6 + drawQueued = false; 7 + draw(); 8 + }); 9 +} 10 + 11 +function draw() { 12 + const now = performance.now(); 13 + if (outlineLightness !== outlineAnimTo) { 14 + const t = (now - outlineAnimStart) / OUTLINE_ANIM_MS; 15 + if (t >= 1) { 16 + outlineLightness = outlineAnimTo; 17 + } else { 18 + const e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; 19 + outlineLightness = outlineAnimFrom + (outlineAnimTo - outlineAnimFrom) * e; 20 + requestDraw(); 21 + } 22 + } 23 + if (dispR !== colorAnimToR || dispG !== colorAnimToG || dispB !== colorAnimToB) { 24 + const t = (now - colorAnimStart) / OUTLINE_ANIM_MS; 25 + if (t >= 1) { 26 + dispR = colorAnimToR; dispG = colorAnimToG; dispB = colorAnimToB; 27 + } else { 28 + const e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; 29 + dispR = Math.round(colorAnimFromR + (colorAnimToR - colorAnimFromR) * e); 30 + dispG = Math.round(colorAnimFromG + (colorAnimToG - colorAnimFromG) * e); 31 + dispB = Math.round(colorAnimFromB + (colorAnimToB - colorAnimFromB) * e); 32 + requestDraw(); 33 + } 34 + } 35 + 36 + const W = view.width, H = view.height; 37 + vctx.setTransform(1, 0, 0, 1, 0, 0); 38 + vctx.imageSmoothingEnabled = false; 39 + vctx.fillStyle = '#000'; 40 + vctx.fillRect(0, 0, W, H); 41 + 42 + // one logical pixel on screen = integer device px. we round here so 43 + // every logical pixel occupies the exact same number of device pixels: 44 + // otherwise fractional dpr (1.25/1.5/1.75) makes nearest-neighbor 45 + // resampling drop or duplicate rows, producing transparent stripes 46 + // through painted content at high zoom. 47 + const pxD = Math.max(1, Math.round(zoom * dpr)); 48 + 49 + const viewWLog = cssW / zoom; 50 + const viewHLog = cssH / zoom; 51 + const wx0 = camX; 52 + const wy0 = camY; 53 + const wx1 = camX + viewWLog; 54 + const wy1 = camY + viewHLog; 55 + 56 + const cx0 = Math.floor(wx0 / CHUNK); 57 + const cy0 = Math.floor(wy0 / CHUNK); 58 + const cx1 = Math.floor((wx1 - 1e-9) / CHUNK); 59 + const cy1 = Math.floor((wy1 - 1e-9) / CHUNK); 60 + 61 + // round destinations to integer device pixels to avoid seams between 62 + // adjacent chunks. compute right/bottom edges from the neighbor's 63 + // rounded left/top so shared edges line up exactly. 64 + const destX = (wx) => Math.round((wx - camX) * pxD); 65 + const destY = (wy) => Math.round((wy - camY) * pxD); 66 + 67 + for (let cy = cy0; cy <= cy1; cy++) { 68 + for (let cx = cx0; cx <= cx1; cx++) { 69 + const c = chunks.get(chunkKey(cx, cy)); 70 + if (!c) continue; 71 + const x0 = destX(cx * CHUNK); 72 + const y0 = destY(cy * CHUNK); 73 + const x1 = destX((cx + 1) * CHUNK); 74 + const y1 = destY((cy + 1) * CHUNK); 75 + vctx.drawImage(c.canvas, x0, y0, x1 - x0, y1 - y0); 76 + } 77 + } 78 + 79 + // our own strokes still waiting for their echo, composited over the frame 80 + const overlay = net.overlays.get(chunks.id); 81 + if (overlay) { 82 + for (let cy = cy0; cy <= cy1; cy++) { 83 + for (let cx = cx0; cx <= cx1; cx++) { 84 + const c = overlay.get(chunkKey(cx, cy)); 85 + if (!c) continue; 86 + const x0 = destX(cx * CHUNK); 87 + const y0 = destY(cy * CHUNK); 88 + vctx.drawImage(c.canvas, x0, y0, destX((cx + 1) * CHUNK) - x0, destY((cy + 1) * CHUNK) - y0); 89 + } 90 + } 91 + } 92 + 93 + // onionskin: ghost the frame behind us in playback order, screen-blended 94 + // so black contributes nothing over the opaque chunks 95 + if (onionskin && !playing && frames.length > 1) { 96 + const ghost = frames[(frameIdx - dir + frames.length) % frames.length]; 97 + const ghostOverlay = net.overlays.get(ghost.id); 98 + vctx.save(); 99 + vctx.globalAlpha = GHOST_ALPHA; 100 + vctx.globalCompositeOperation = 'screen'; 101 + for (const layer of ghostOverlay ? [ghost, ghostOverlay] : [ghost]) { 102 + for (let cy = cy0; cy <= cy1; cy++) { 103 + for (let cx = cx0; cx <= cx1; cx++) { 104 + const c = layer.get(chunkKey(cx, cy)); 105 + if (!c) continue; 106 + const x0 = destX(cx * CHUNK); 107 + const y0 = destY(cy * CHUNK); 108 + vctx.drawImage(c.canvas, x0, y0, destX((cx + 1) * CHUNK) - x0, destY((cy + 1) * CHUNK) - y0); 109 + } 110 + } 111 + } 112 + vctx.restore(); 113 + } 114 + 115 + if (cursorAlpha !== cursorFadeTo) { 116 + const dur = cursorFadeTo > cursorFadeFrom ? CURSOR_FADE_IN_MS : CURSOR_FADE_OUT_MS; 117 + const t = (now - cursorFadeStart) / dur; 118 + if (t >= 1) { 119 + cursorAlpha = cursorFadeTo; 120 + if (cursorFadeTo === 0) mouseInside = false; 121 + } else { 122 + const e = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; 123 + cursorAlpha = cursorFadeFrom + (cursorFadeTo - cursorFadeFrom) * e; 124 + requestDraw(); 125 + } 126 + } 127 + // peers first, so the local brush always sits on top of theirs. their 128 + // fades run here rather than arriving over the wire, so a brush leaving 129 + // a phone still eases out on everyone else's screen 130 + for (const [id, p] of net.peers) { 131 + if (p.alpha !== p.fadeTo) { 132 + const dur = p.fadeTo > p.fadeFrom ? CURSOR_FADE_IN_MS : CURSOR_FADE_OUT_MS; 133 + const t = (now - p.fadeStart) / dur; 134 + if (t >= 1) { 135 + p.alpha = p.fadeTo; 136 + } else { 137 + const e = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; 138 + p.alpha = p.fadeFrom + (p.fadeTo - p.fadeFrom) * e; 139 + requestDraw(); 140 + } 141 + } 142 + if (p.alpha <= 0) { 143 + if (p.fadeTo === 0) net.peers.delete(id); 144 + continue; 145 + } 146 + if (!p.b) continue; 147 + const off = Math.floor(p.b / 2); 148 + const sx = Math.round((p.x - off - camX) * pxD); 149 + const sy = Math.round((p.y - off - camY) * pxD); 150 + if (sx > W || sy > H || sx + p.b * pxD < 0 || sy + p.b * pxD < 0) continue; 151 + // someone standing on another frame is drawn the way that frame's 152 + // own marks would be: ghosted, so their brush can't read as 153 + // something on the frame you're actually painting 154 + const ghost = p.f !== chunks.id; 155 + const a = p.alpha * (ghost ? GHOST_ALPHA : 1); 156 + drawCursorShape(sx, sy, p.b, p.s, p.c, p.c[0] + p.c[1] + p.c[2] > 384 ? 1 : 0, a, pxD, ghost); 157 + } 158 + 159 + if (mouseInside) { 160 + const tl = brushTopLeft(curX, curY); 161 + // round to integer device pixels - camX/camY are fractional (smooth 162 + // pan), and fractional fillRect coordinates antialias their edges, 163 + // which would leave transparent lines between adjacent row strips. 164 + // during an active pan, anchor the cursor to the real client 165 + // position (rounded only to device pixels) so it tracks smoothly 166 + // instead of jittering as the logical-pixel floor flips back and 167 + // forth under a fractional camera. when the pan ends the cursor 168 + // snaps back to the logical-pixel grid via the idle timer below. 169 + let sx, sy; 170 + if (panning && curClientX !== null) { 171 + const off = Math.floor(brush / 2); 172 + sx = Math.round((curClientX - off * zoom) * dpr); 173 + sy = Math.round((curClientY - off * zoom) * dpr); 174 + } else { 175 + sx = Math.round((tl.x - camX) * pxD); 176 + sy = Math.round((tl.y - camY) * pxD); 177 + } 178 + drawCursorShape(sx, sy, brush, roundness, [dispR, dispG, dispB], outlineLightness, cursorAlpha, pxD); 179 + } 180 + 181 + net.flush(); 182 + net.sendCursor(); 183 +} 184 + 185 +// a brush preview: solid fill in its own color, wrapped in a 186 +// 1-logical-pixel outline cross-faded between darken and lighten. as a 187 +// ghost it drops the outline and screens like onionskinned content, since 188 +// that is exactly what it is - a brush on some other frame 189 +function drawCursorShape(sx, sy, n, round, col, lightness, alpha, pxD, ghost) { 190 + if (alpha <= 0) return; 191 + const shape = round === 0 ? null : brushShapeFor(n, round, col[0], col[1], col[2]); 192 + vctx.save(); 193 + vctx.globalAlpha = alpha; 194 + if (ghost) vctx.globalCompositeOperation = 'screen'; 195 + vctx.fillStyle = 'rgb(' + col[0] + ',' + col[1] + ',' + col[2] + ')'; 196 + if (!shape) { 197 + vctx.fillRect(sx, sy, n * pxD, n * pxD); 198 + } else { 199 + const runs = shape.fillRuns; 200 + for (let i = 0; i < runs.length; i += 3) { 201 + vctx.fillRect(sx + runs[i] * pxD, sy + runs[i + 1] * pxD, runs[i + 2] * pxD, pxD); 202 + } 203 + } 204 + if (ghost) { 205 + vctx.restore(); 206 + return; 207 + } 208 + 209 + const drawOutline = () => { 210 + if (shape) { 211 + const runs = shape.outlineRuns; 212 + for (let i = 0; i < runs.length; i += 3) { 213 + vctx.fillRect(sx + runs[i] * pxD, sy + runs[i + 1] * pxD, runs[i + 2] * pxD, pxD); 214 + } 215 + } else { 216 + vctx.fillRect(sx - pxD, sy - pxD, (n + 2) * pxD, pxD); // top 217 + vctx.fillRect(sx - pxD, sy + n * pxD, (n + 2) * pxD, pxD); // bottom 218 + vctx.fillRect(sx - pxD, sy, pxD, n * pxD); // left 219 + vctx.fillRect(sx + n * pxD, sy, pxD, n * pxD); // right 220 + } 221 + }; 222 + vctx.save(); 223 + if (lightness > 0) { 224 + vctx.globalCompositeOperation = 'multiply'; 225 + vctx.globalAlpha = lightness * alpha; 226 + vctx.fillStyle = 'rgb(128,128,128)'; 227 + drawOutline(); 228 + } 229 + if (lightness < 1) { 230 + vctx.globalCompositeOperation = 'screen'; 231 + vctx.globalAlpha = (1 - lightness) * alpha; 232 + vctx.fillStyle = 'rgb(128,128,128)'; 233 + drawOutline(); 234 + } 235 + vctx.restore(); 236 + vctx.restore(); 237 +}
1 +html, body { 2 + margin: 0; 3 + padding: 0; 4 + width: 100%; 5 + height: 100%; 6 + overflow: hidden; 7 + background: #000; 8 + cursor: none; 9 + overscroll-behavior: none; 10 + touch-action: none; 11 + -webkit-user-select: none; 12 + user-select: none; 13 + -webkit-touch-callout: none; 14 +} 15 +canvas { 16 + display: block; 17 + position: absolute; 18 + top: 0; 19 + left: 0; 20 + image-rendering: pixelated; 21 + image-rendering: crisp-edges; 22 +}
1 +let dpr = window.devicePixelRatio || 1; 2 +let cssW = 0, cssH = 0; 3 + 4 +const ZOOM_STOPS = [2, 3, 4, 5, 6, 8, 11, 15, 21, 30]; 5 +const MIN_ZOOM = ZOOM_STOPS[0]; 6 +const ZOOM_SENSITIVITY = 3.5; // fractional levels per unit log(pinch factor) 7 +let zoomIdx = 0; 8 +let zoom = MIN_ZOOM; 9 + 10 +let camX = 0, camY = 0; 11 + 12 +let curX = 0, curY = 0; 13 +// used to keep the brush pinned under the real cursor while panning, since 14 +// the OS does not emit pointermove events when only the camera moves. 15 +let curClientX = null, curClientY = null; 16 +let mouseInside = false; 17 +let cursorAlpha = 0; 18 +let cursorFadeFrom = 0; 19 +let cursorFadeTo = 0; 20 +let cursorFadeStart = 0; 21 +const CURSOR_FADE_IN_MS = 100; 22 +const CURSOR_FADE_OUT_MS = 400; 23 +function setCursorFadeTarget(target) { 24 + if (target === cursorFadeTo) return; 25 + cursorFadeFrom = cursorAlpha; 26 + cursorFadeTo = target; 27 + cursorFadeStart = performance.now(); 28 + requestDraw(); 29 +} 30 +function showCursor() { 31 + mouseInside = true; 32 + setCursorFadeTarget(1); 33 +} 34 +function hideCursorNow() { 35 + mouseInside = false; 36 + cursorAlpha = 0; 37 + cursorFadeFrom = 0; 38 + cursorFadeTo = 0; 39 +} 40 + 41 +function resize() { 42 + const firstResize = cssW === 0; 43 + dpr = window.devicePixelRatio || 1; 44 + cssW = window.innerWidth; 45 + cssH = window.innerHeight; 46 + view.style.width = cssW + 'px'; 47 + view.style.height = cssH + 'px'; 48 + view.width = Math.floor(cssW * dpr); 49 + view.height = Math.floor(cssH * dpr); 50 + if (firstResize) { 51 + curX = Math.floor(cssW / (2 * zoom)); 52 + curY = Math.floor(cssH / (2 * zoom)); 53 + } 54 + requestDraw(); 55 +} 56 + 57 +function clientToWorld(clientX, clientY) { 58 + const lx = Math.floor(clientX / zoom + camX); 59 + const ly = Math.floor(clientY / zoom + camY); 60 + return { x: lx, y: ly }; 61 +} 62 + 63 +function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } 64 + 65 +function applyZoom(factor, ax, ay) { 66 + const worldAtAnchorX = camX + ax / zoom; 67 + const worldAtAnchorY = camY + ay / zoom; 68 + zoomIdx = clamp(zoomIdx + Math.log(factor) * ZOOM_SENSITIVITY, 0, ZOOM_STOPS.length - 1); 69 + const newZoom = ZOOM_STOPS[Math.round(zoomIdx)]; 70 + if (newZoom !== zoom) { 71 + zoom = newZoom; 72 + camX = worldAtAnchorX - ax / zoom; 73 + camY = worldAtAnchorY - ay / zoom; 74 + } 75 +} 76 + 77 +function zoomTo(newZoom) { 78 + if (newZoom === zoom) return; 79 + const ax = cssW / 2, ay = cssH / 2; 80 + const worldAtAnchorX = camX + ax / zoom; 81 + const worldAtAnchorY = camY + ay / zoom; 82 + zoom = newZoom; 83 + zoomIdx = ZOOM_STOPS.indexOf(zoom); 84 + camX = worldAtAnchorX - ax / zoom; 85 + camY = worldAtAnchorY - ay / zoom; 86 + requestDraw(); 87 +} 88 + 89 +function stepZoom(dir) { 90 + const i = ZOOM_STOPS.indexOf(zoom); 91 + zoomTo(ZOOM_STOPS[clamp(i + dir, 0, ZOOM_STOPS.length - 1)]); 92 +} 93 +function resetZoom() { zoomTo(MIN_ZOOM); }
1 +#!/usr/bin/env python3 2 +"""serve animus over the local network with a shared canvas. 3 + 4 +usage: ./serve.py [port] 5 +""" 6 + 7 +import base64 8 +import hashlib 9 +import json 10 +import os 11 +import queue 12 +import socket 13 +import struct 14 +import sys 15 +import threading 16 +import time 17 +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer 18 + 19 +ROOT = os.path.dirname(os.path.abspath(__file__)) 20 +DEFAULT_PORT = 8000 21 +# per-painter outbound backlog. a stroke burst is a few hundred small messages, 22 +# so this is generous; past it the connection is too far behind to be correct 23 +SEND_QUEUE_MAX = 4096 24 +# rfc 6455 fixes this string 25 +WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11' 26 +MAX_PAYLOAD = 64 * 1024 * 1024 27 + 28 +# once the stored stroke log for the room passes this, ask a painter for flat 29 +# images of the frames so joiners don't have to replay an entire session 30 +SNAPSHOT_AFTER_OPS = 4000 31 +SNAPSHOT_COOLDOWN = 15.0 32 + 33 +# the ways a connection ends when the other side simply walks off 34 +DEAD_SOCKET = (ConnectionError, BrokenPipeError, TimeoutError, socket.timeout) 35 + 36 + 37 +def read_exact(rfile, n): 38 + buf = b'' 39 + while len(buf) < n: 40 + chunk = rfile.read(n - len(buf)) 41 + if not chunk: 42 + return None 43 + buf += chunk 44 + return buf 45 + 46 + 47 +def unmask(payload, mask): 48 + if not payload: 49 + return payload 50 + n = len(payload) 51 + # xor the whole payload as one big int: the repeated key is trimmed back 52 + # down to n bytes so byte i lines up with mask[i % 4] 53 + rep = mask * (n // 4 + 1) 54 + key = int.from_bytes(rep, 'big') >> (8 * (len(rep) - n)) 55 + return (int.from_bytes(payload, 'big') ^ key).to_bytes(n, 'big') 56 + 57 + 58 +def ws_recv(rfile, on_control=None): 59 + """next complete data message as (opcode, payload), or None when the peer 60 + closes. control frames are answered inline so they can't interrupt a 61 + message that arrives in fragments.""" 62 + data = b'' 63 + msg_op = None 64 + while True: 65 + hdr = read_exact(rfile, 2) 66 + if hdr is None: 67 + return None 68 + fin = hdr[0] & 0x80 69 + op = hdr[0] & 0x0f 70 + masked = hdr[1] & 0x80 71 + length = hdr[1] & 0x7f 72 + if length == 126: 73 + ext = read_exact(rfile, 2) 74 + if ext is None: 75 + return None 76 + length = struct.unpack('>H', ext)[0] 77 + elif length == 127: 78 + ext = read_exact(rfile, 8) 79 + if ext is None: 80 + return None 81 + length = struct.unpack('>Q', ext)[0] 82 + if length > MAX_PAYLOAD: 83 + return None 84 + mask = read_exact(rfile, 4) if masked else b'' 85 + if masked and mask is None: 86 + return None 87 + payload = read_exact(rfile, length) if length else b'' 88 + if payload is None: 89 + return None 90 + if masked: 91 + payload = unmask(payload, mask) 92 + if op & 0x8: 93 + if op == 0x8: 94 + return None 95 + if op == 0x9 and on_control: 96 + on_control(0xa, payload) 97 + continue 98 + if op != 0: 99 + msg_op = op 100 + data = payload 101 + else: 102 + data += payload 103 + if fin: 104 + return msg_op or 1, data 105 + 106 + 107 +def ws_frame(payload, opcode=1): 108 + head = bytearray([0x80 | opcode]) 109 + n = len(payload) 110 + if n < 126: 111 + head.append(n) 112 + elif n < 65536: 113 + head.append(126) 114 + head += struct.pack('>H', n) 115 + else: 116 + head.append(127) 117 + head += struct.pack('>Q', n) 118 + return bytes(head) + payload 119 + 120 + 121 +class Conn: 122 + """one painter. writes go through a queue and a dedicated thread so a 123 + painter on bad wifi can never stall the room: their backlog is theirs.""" 124 + 125 + _seq = 0 126 + _seq_lock = threading.Lock() 127 + 128 + def __init__(self, sock): 129 + self.sock = sock 130 + self.alive = True 131 + self.joined = time.time() 132 + self.q = queue.Queue(maxsize=SEND_QUEUE_MAX) 133 + with Conn._seq_lock: 134 + Conn._seq += 1 135 + self.id = 'c%d' % Conn._seq 136 + self.writer = threading.Thread(target=self._pump, daemon=True) 137 + self.writer.start() 138 + 139 + def _pump(self): 140 + while True: 141 + item = self.q.get() 142 + if item is None: 143 + return 144 + payload, opcode = item 145 + try: 146 + self.sock.sendall(ws_frame(payload, opcode)) 147 + except OSError: 148 + self.kill() 149 + return 150 + 151 + def kill(self): 152 + """drop the connection; the page reconnects and re-syncs from scratch""" 153 + if not self.alive: 154 + return 155 + self.alive = False 156 + try: 157 + self.sock.shutdown(socket.SHUT_RDWR) 158 + except OSError: 159 + pass 160 + try: 161 + self.q.put_nowait(None) 162 + except queue.Full: 163 + pass 164 + 165 + def raw(self, payload, opcode=1, droppable=False): 166 + if not self.alive: 167 + return 168 + try: 169 + self.q.put_nowait((payload, opcode)) 170 + except queue.Full: 171 + # a stale brush position is worth nothing, so let it go. a lost 172 + # stroke would desync this painter for good, so cut them loose 173 + # instead and let the reconnect hand them the room again 174 + if not droppable: 175 + self.kill() 176 + 177 + def send(self, obj, droppable=False): 178 + self.raw(json.dumps(obj, separators=(',', ':')).encode('utf-8'), droppable=droppable) 179 + 180 + 181 +class Room: 182 + def __init__(self): 183 + self.lock = threading.RLock() 184 + self.clients = [] 185 + self.frame_seq = 0 186 + self.op_seq = 0 187 + self.op_count = 0 # stored strokes, tracked rather than recounted 188 + self.frames = [self.blank_frame()] 189 + self.index = {f['id']: f for f in self.frames} 190 + self.interval = 250 191 + self.snap_pending = False 192 + self.snap_at = 0.0 193 + 194 + def blank_frame(self): 195 + self.frame_seq += 1 196 + return {'id': 'f%d' % self.frame_seq, 'img': None, 'ox': 0, 'oy': 0, 'w': 0, 'h': 0, 'ops': []} 197 + 198 + def base_frame(self, src): 199 + f = self.blank_frame() 200 + if isinstance(src, dict) and src.get('img'): 201 + f['img'] = src['img'] 202 + f['ox'] = int(src.get('ox', 0)) 203 + f['oy'] = int(src.get('oy', 0)) 204 + f['w'] = int(src.get('w', 0)) 205 + f['h'] = int(src.get('h', 0)) 206 + return f 207 + 208 + def frame(self, fid): 209 + return self.index.get(fid) 210 + 211 + def wire_frames(self, with_ops=True): 212 + out = [] 213 + for f in self.frames: 214 + e = {'id': f['id'], 'img': f['img'], 'ox': f['ox'], 'oy': f['oy'], 'w': f['w'], 'h': f['h']} 215 + if with_ops: 216 + e['ops'] = f['ops'] 217 + out.append(e) 218 + return out 219 + 220 + def broadcast(self, msg, skip=None, droppable=False): 221 + payload = json.dumps(msg, separators=(',', ':')).encode('utf-8') 222 + with self.lock: 223 + targets = [c for c in self.clients if c is not skip] 224 + for c in targets: 225 + c.raw(payload, droppable=droppable) 226 + 227 + def join(self, conn): 228 + with self.lock: 229 + self.clients.append(conn) 230 + conn.send({ 231 + 't': 'init', 232 + 'id': conn.id, 233 + 'frames': self.wire_frames(), 234 + 'interval': self.interval, 235 + }) 236 + print(' + %s joined (%d painting)' % (conn.id, len(self.clients))) 237 + 238 + def leave(self, conn): 239 + with self.lock: 240 + if conn in self.clients: 241 + self.clients.remove(conn) 242 + # the painter we asked for a flat copy may be the one leaving; let 243 + # the next stroke ask someone else 244 + self.snap_pending = False 245 + print(' - %s left (%d painting)' % (conn.id, len(self.clients))) 246 + self.broadcast({'t': 'bye', 'id': conn.id}) 247 + 248 + def maybe_snapshot(self): 249 + """ask one painter to flatten the room so the stroke log stays bounded""" 250 + now = time.time() 251 + if self.snap_pending or now - self.snap_at < SNAPSHOT_COOLDOWN: 252 + return 253 + if self.op_count < SNAPSHOT_AFTER_OPS or not self.clients: 254 + return 255 + self.snap_pending = True 256 + self.snap_at = now 257 + self.clients[0].send({'t': 'snap', 'upto': self.op_seq}) 258 + 259 + def set_frames(self, frames): 260 + self.frames = frames 261 + self.index = {f['id']: f for f in frames} 262 + self.op_count = sum(len(f['ops']) for f in frames) 263 + 264 + def handle(self, conn, msg): 265 + t = msg.get('t') 266 + if t == 'p': 267 + with self.lock: 268 + f = self.frame(msg.get('f')) 269 + if f is None: 270 + return 271 + self.op_seq += 1 272 + op = { 273 + 'n': self.op_seq, 274 + 'o': msg.get('o'), 275 + 'c': msg.get('c'), 276 + 'b': msg.get('b'), 277 + 's': msg.get('s'), 278 + } 279 + f['ops'].append(op) 280 + self.op_count += 1 281 + self.maybe_snapshot() 282 + out = dict(op) 283 + out['t'] = 'p' 284 + out['f'] = f['id'] 285 + out['by'] = conn.id 286 + # the painter's own tag for this batch, so they can recognise it 287 + # coming back; not stored, it means nothing to anyone else 288 + if msg.get('k') is not None: 289 + out['k'] = msg['k'] 290 + # broadcast under the lock so wire order matches sequence order; 291 + # sends only enqueue, so nothing slow happens in here. echoed to 292 + # the painter too: the ack is what commits their stroke 293 + self.broadcast(out) 294 + elif t == 'c': 295 + msg['id'] = conn.id 296 + self.broadcast(msg, skip=conn, droppable=True) 297 + elif t == 'af': 298 + with self.lock: 299 + at = max(0, min(int(msg.get('at', len(self.frames))), len(self.frames))) 300 + f = self.blank_frame() 301 + self.frames.insert(at, f) 302 + self.index[f['id']] = f 303 + self.broadcast({'t': 'af', 'at': at, 'id': f['id'], 'by': conn.id}) 304 + elif t == 'df': 305 + with self.lock: 306 + if len(self.frames) <= 1: 307 + return 308 + f = self.frame(msg.get('id')) 309 + if f is None: 310 + return 311 + self.frames.remove(f) 312 + del self.index[f['id']] 313 + self.op_count -= len(f['ops']) 314 + self.broadcast({'t': 'df', 'id': f['id'], 'by': conn.id}) 315 + elif t == 'reset': 316 + srcs = msg.get('frames') or [] 317 + if not srcs: 318 + return 319 + with self.lock: 320 + self.set_frames([self.base_frame(s) for s in srcs]) 321 + self.interval = int(msg.get('interval') or self.interval) 322 + self.snap_pending = False 323 + self.broadcast({ 324 + 't': 'reset', 325 + 'frames': self.wire_frames(with_ops=False), 326 + 'interval': self.interval, 327 + 'by': conn.id, 328 + }) 329 + elif t == 'snapshot': 330 + upto = int(msg.get('upto') or 0) 331 + with self.lock: 332 + self.snap_pending = False 333 + for src in msg.get('frames') or []: 334 + f = self.frame(src.get('id')) 335 + if f is None: 336 + continue 337 + f['img'] = src.get('img') 338 + f['ox'] = int(src.get('ox', 0)) 339 + f['oy'] = int(src.get('oy', 0)) 340 + f['w'] = int(src.get('w', 0)) 341 + f['h'] = int(src.get('h', 0)) 342 + # strokes newer than the snapshot are kept; a stroke that made it 343 + # into the image and also survives here just paints itself twice 344 + kept = 0 345 + for f in self.frames: 346 + f['ops'] = [o for o in f['ops'] if o['n'] > upto] 347 + kept += len(f['ops']) 348 + self.op_count = kept 349 + elif t == 'interval': 350 + with self.lock: 351 + self.interval = int(msg.get('v') or self.interval) 352 + 353 + 354 +room = Room() 355 + 356 + 357 +class Server(ThreadingHTTPServer): 358 + daemon_threads = True 359 + # a whole request thread can still unwind on a dropped socket, outside any 360 + # handler we control. same story, same silence 361 + def handle_error(self, request, client_address): 362 + if isinstance(sys.exc_info()[1], DEAD_SOCKET): 363 + return 364 + super().handle_error(request, client_address) 365 + 366 + 367 +class Handler(SimpleHTTPRequestHandler): 368 + protocol_version = 'HTTP/1.1' 369 + server_version = 'animus' 370 + # tiny realtime frames must go out now, not sit in nagle's buffer waiting 371 + # on a watcher's delayed acks 372 + disable_nagle_algorithm = True 373 + 374 + def __init__(self, *a, **kw): 375 + super().__init__(*a, directory=ROOT, **kw) 376 + 377 + def log_message(self, fmt, *args): 378 + pass 379 + 380 + def handle(self): 381 + try: 382 + super().handle() 383 + except DEAD_SOCKET: 384 + self.close_connection = True 385 + 386 + def finish(self): 387 + try: 388 + super().finish() 389 + except DEAD_SOCKET: 390 + pass 391 + 392 + def end_headers(self): 393 + self.send_header('Cache-Control', 'no-store') 394 + super().end_headers() 395 + 396 + def do_GET(self): 397 + if self.path.split('?')[0] == '/ws': 398 + self.do_ws() 399 + return 400 + super().do_GET() 401 + 402 + def do_ws(self): 403 + key = self.headers.get('Sec-WebSocket-Key') 404 + if not key or 'websocket' not in (self.headers.get('Upgrade') or '').lower(): 405 + self.send_error(400, 'expected a websocket upgrade') 406 + return 407 + accept = base64.b64encode(hashlib.sha1((key + WS_GUID).encode()).digest()).decode() 408 + self.close_connection = True 409 + self.send_response(101, 'Switching Protocols') 410 + self.send_header('Upgrade', 'websocket') 411 + self.send_header('Connection', 'Upgrade') 412 + self.send_header('Sec-WebSocket-Accept', accept) 413 + self.end_headers() 414 + self.wfile.flush() 415 + 416 + conn = Conn(self.connection) 417 + room.join(conn) 418 + try: 419 + pong = lambda code, data: conn.raw(data, code) 420 + while conn.alive: 421 + msg = ws_recv(self.rfile, pong) 422 + if msg is None: 423 + break 424 + op, payload = msg 425 + if op != 0x1: 426 + continue 427 + try: 428 + data = json.loads(payload.decode('utf-8')) 429 + except (ValueError, UnicodeDecodeError): 430 + continue 431 + if isinstance(data, dict): 432 + room.handle(conn, data) 433 + except OSError: 434 + pass 435 + finally: 436 + conn.kill() 437 + room.leave(conn) 438 + 439 + 440 +def lan_ip(): 441 + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 442 + try: 443 + s.connect(('10.255.255.255', 1)) 444 + return s.getsockname()[0] 445 + except OSError: 446 + return '127.0.0.1' 447 + finally: 448 + s.close() 449 + 450 + 451 +def main(): 452 + port = int(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_PORT 453 + for attempt in range(20): 454 + try: 455 + httpd = Server(('0.0.0.0', port + attempt), Handler) 456 + break 457 + except OSError: 458 + continue 459 + else: 460 + print('no free port near %d' % port) 461 + return 1 462 + port = httpd.server_address[1] 463 + 464 + print('\nPress Ctrl+C to stop the server.\n') 465 + print('http://%s:%d\n' % (lan_ip(), port)) 466 + 467 + 468 + try: 469 + httpd.serve_forever() 470 + except KeyboardInterrupt: 471 + print('\n\nShutting down server...') 472 + return 0 473 + 474 + 475 +if __name__ == '__main__': 476 + sys.stdout.reconfigure(line_buffering=True) 477 + sys.exit(main())