index.html (43.5 KB)
1 <!DOCTYPE html> 2 <html lang="en"> 3 <head> 4 <meta charset="UTF-8"> 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"> 8 <style> 9 html, body { 10 margin: 0; 11 padding: 0; 12 width: 100%; 13 height: 100%; 14 overflow: hidden; 15 background: #000; 16 cursor: none; 17 overscroll-behavior: none; 18 touch-action: none; 19 -webkit-user-select: none; 20 user-select: none; 21 -webkit-touch-callout: none; 22 } 23 canvas { 24 display: block; 25 position: absolute; 26 top: 0; 27 left: 0; 28 image-rendering: pixelated; 29 image-rendering: crisp-edges; 30 } 31 </style> 32 </head> 33 <body> 34 <canvas id="view"></canvas> 35 <script> 36 (() => { 37 const view = document.getElementById('view'); 38 const vctx = view.getContext('2d', { alpha: false }); 39 40 const CHUNK = 256; 41 let frames = [new Map()]; 42 let frameIdx = 0; 43 let chunks = frames[0]; 44 let dir = 1; // playback direction: 1 forward, -1 reverse 45 let onionskin = false; 46 let playing = false; 47 let playTimer = null; 48 let frameInterval = 250; 49 let lastTap = 0; 50 51 function chunkKey(cx, cy) { return cx + ',' + cy; } 52 53 function getOrCreateChunk(cx, cy, map = chunks) { 54 const k = chunkKey(cx, cy); 55 let c = map.get(k); 56 if (c) return c; 57 const cnv = document.createElement('canvas'); 58 cnv.width = CHUNK; 59 cnv.height = CHUNK; 60 const cctx = cnv.getContext('2d', { alpha: false }); 61 cctx.fillStyle = '#000'; 62 cctx.fillRect(0, 0, CHUNK, CHUNK); 63 c = { canvas: cnv, ctx: cctx }; 64 map.set(k, c); 65 return c; 66 } 67 68 function paintRect(wx, wy, w, h, color) { 69 const x0 = wx, y0 = wy, x1 = wx + w, y1 = wy + h; 70 const cx0 = Math.floor(x0 / CHUNK); 71 const cy0 = Math.floor(y0 / CHUNK); 72 const cx1 = Math.floor((x1 - 1) / CHUNK); 73 const cy1 = Math.floor((y1 - 1) / CHUNK); 74 for (let cy = cy0; cy <= cy1; cy++) { 75 for (let cx = cx0; cx <= cx1; cx++) { 76 const c = getOrCreateChunk(cx, cy); 77 const lx = Math.max(x0, cx * CHUNK) - cx * CHUNK; 78 const ly = Math.max(y0, cy * CHUNK) - cy * CHUNK; 79 const rx = Math.min(x1, (cx + 1) * CHUNK) - cx * CHUNK; 80 const ry = Math.min(y1, (cy + 1) * CHUNK) - cy * CHUNK; 81 c.ctx.fillStyle = color; 82 c.ctx.fillRect(lx, ly, rx - lx, ry - ly); 83 } 84 } 85 } 86 87 function readPixel(wx, wy) { 88 const cx = Math.floor(wx / CHUNK); 89 const cy = Math.floor(wy / CHUNK); 90 const k = chunkKey(cx, cy); 91 const c = chunks.get(k); 92 if (!c) return [0, 0, 0]; 93 const lx = wx - cx * CHUNK; 94 const ly = wy - cy * CHUNK; 95 const d = c.ctx.getImageData(lx, ly, 1, 1).data; 96 return [d[0], d[1], d[2]]; 97 } 98 99 function setFrame(i) { 100 frameIdx = ((i % frames.length) + frames.length) % frames.length; 101 chunks = frames[frameIdx]; 102 // a held pick tracks the frame under it as frames change 103 if (picking) pickAt(curX, curY); 104 requestDraw(); 105 } 106 107 function addFrame() { 108 const at = dir === 1 ? frameIdx + 1 : frameIdx; 109 frames.splice(at, 0, new Map()); 110 setFrame(at); 111 } 112 113 function deleteFrame() { 114 if (frames.length === 1) return; 115 frames.splice(frameIdx, 1); 116 if (playing && frames.length === 1) stopPlayback(); 117 setFrame(Math.min(frameIdx, frames.length - 1)); 118 } 119 120 function stopPlayback() { 121 playing = false; 122 clearTimeout(playTimer); 123 requestDraw(); 124 } 125 126 function startPlayback() { 127 if (frames.length === 1) return; 128 playing = true; 129 playTimer = setTimeout(function step() { 130 setFrame(frameIdx + dir); 131 playTimer = setTimeout(step, frameInterval); 132 }, frameInterval); 133 } 134 135 function tapArrow(d) { 136 dir = d; 137 const now = performance.now(); 138 if (lastTap && now - lastTap <= 10000) frameInterval = now - lastTap; 139 lastTap = now; 140 if (playing) { stopPlayback(); return; } 141 setFrame(frameIdx + d); 142 } 143 144 let dpr = window.devicePixelRatio || 1; 145 let cssW = 0, cssH = 0; 146 147 const ZOOM_STOPS = [2, 3, 4, 5, 6, 8, 11, 15, 21, 30]; 148 const MIN_ZOOM = ZOOM_STOPS[0]; 149 const ZOOM_SENSITIVITY = 3.5; // fractional levels per unit log(pinch factor) 150 let zoomIdx = 0; 151 let zoom = MIN_ZOOM; 152 153 let camX = 0, camY = 0; 154 155 let curX = 0, curY = 0; 156 // used to keep the brush pinned under the real cursor while panning, since 157 // the OS does not emit pointermove events when only the camera moves. 158 let curClientX = null, curClientY = null; 159 let mouseInside = false; 160 let cursorAlpha = 0; 161 let cursorFadeFrom = 0; 162 let cursorFadeTo = 0; 163 let cursorFadeStart = 0; 164 const CURSOR_FADE_IN_MS = 100; 165 const CURSOR_FADE_OUT_MS = 400; 166 function setCursorFadeTarget(target) { 167 if (target === cursorFadeTo) return; 168 cursorFadeFrom = cursorAlpha; 169 cursorFadeTo = target; 170 cursorFadeStart = performance.now(); 171 requestDraw(); 172 } 173 function showCursor() { 174 mouseInside = true; 175 setCursorFadeTarget(1); 176 } 177 function hideCursorNow() { 178 mouseInside = false; 179 cursorAlpha = 0; 180 cursorFadeFrom = 0; 181 cursorFadeTo = 0; 182 } 183 184 let r = 255, g = 255, b = 255; 185 let brush = 1; 186 let roundness = 0; 187 188 let brushShape = null; 189 let brushShapeKey = ''; 190 function getBrushShape() { 191 const key = brush + ',' + roundness + ',' + r + ',' + g + ',' + b; 192 if (brushShapeKey === key && brushShape) return brushShape; 193 const n = brush; 194 const inside = new Uint8Array(n * n); 195 const rad = roundness * (n / 2); 196 const rad2 = rad * rad; 197 const lo = rad - 0.5; 198 const hi = n - 0.5 - rad; 199 for (let dy = 0; dy < n; dy++) { 200 for (let dx = 0; dx < n; dx++) { 201 let qx = 0, qy = 0; 202 if (dx < lo) qx = lo - dx; 203 else if (dx > hi) qx = dx - hi; 204 if (dy < lo) qy = lo - dy; 205 else if (dy > hi) qy = dy - hi; 206 if (qx * qx + qy * qy <= rad2 + 1e-9) inside[dy * n + dx] = 1; 207 } 208 } 209 const sprite = document.createElement('canvas'); 210 sprite.width = n; 211 sprite.height = n; 212 const sctx = sprite.getContext('2d'); 213 const img = sctx.createImageData(n, n); 214 const data = img.data; 215 for (let i = 0; i < n * n; i++) { 216 if (inside[i]) { 217 data[i * 4] = r; 218 data[i * 4 + 1] = g; 219 data[i * 4 + 2] = b; 220 data[i * 4 + 3] = 255; 221 } 222 } 223 sctx.putImageData(img, 0, 0); 224 225 const fillRuns = []; 226 for (let dy = 0; dy < n; dy++) { 227 let dx = 0; 228 while (dx < n) { 229 if (!inside[dy * n + dx]) { dx++; continue; } 230 let dx1 = dx + 1; 231 while (dx1 < n && inside[dy * n + dx1]) dx1++; 232 fillRuns.push(dx, dy, dx1 - dx); 233 dx = dx1; 234 } 235 } 236 237 // 1-pixel outline ring: cells outside the shape that are 8-way adjacent 238 // to any inside cell. stored as row-runs in an (n+2)x(n+2) grid with 239 // coordinates offset by -1 so they index directly in shape-local space. 240 const m = n + 2; 241 const ring = new Uint8Array(m * m); 242 const isIn = (x, y) => x >= 0 && y >= 0 && x < n && y < n && inside[y * n + x] === 1; 243 for (let y = -1; y <= n; y++) { 244 for (let x = -1; x <= n; x++) { 245 if (isIn(x, y)) continue; 246 let adj = false; 247 for (let oy = -1; oy <= 1 && !adj; oy++) { 248 for (let ox = -1; ox <= 1 && !adj; ox++) { 249 if (ox === 0 && oy === 0) continue; 250 if (isIn(x + ox, y + oy)) adj = true; 251 } 252 } 253 if (adj) ring[(y + 1) * m + (x + 1)] = 1; 254 } 255 } 256 const outlineRuns = []; 257 for (let y = 0; y < m; y++) { 258 let x = 0; 259 while (x < m) { 260 if (!ring[y * m + x]) { x++; continue; } 261 let x1 = x + 1; 262 while (x1 < m && ring[y * m + x1]) x1++; 263 outlineRuns.push(x - 1, y - 1, x1 - x); 264 x = x1; 265 } 266 } 267 268 brushShape = { inside, sprite, fillRuns, outlineRuns, n }; 269 brushShapeKey = key; 270 return brushShape; 271 } 272 273 // outline blend: 0 = dark color (lighten with screen), 1 = light color (darken with multiply). 274 let outlineLightness = 1; 275 let outlineAnimStart = 0; 276 let outlineAnimFrom = 1; 277 let outlineAnimTo = 1; 278 const OUTLINE_ANIM_MS = 250; 279 function setOutlineTarget(target) { 280 if (target === outlineAnimTo) return; 281 outlineAnimFrom = outlineLightness; 282 outlineAnimTo = target; 283 outlineAnimStart = performance.now(); 284 requestDraw(); 285 } 286 287 // displayed cursor fill eases toward r/g/b; painting still uses r/g/b immediately. 288 let dispR = 255, dispG = 255, dispB = 255; 289 let colorAnimStart = 0; 290 let colorAnimFromR = 255, colorAnimFromG = 255, colorAnimFromB = 255; 291 let colorAnimToR = 255, colorAnimToG = 255, colorAnimToB = 255; 292 function setDisplayColorTarget(nr, ng, nb) { 293 if (nr === colorAnimToR && ng === colorAnimToG && nb === colorAnimToB) return; 294 colorAnimFromR = dispR; colorAnimFromG = dispG; colorAnimFromB = dispB; 295 colorAnimToR = nr; colorAnimToG = ng; colorAnimToB = nb; 296 colorAnimStart = performance.now(); 297 requestDraw(); 298 } 299 300 const keys = {}; 301 let painting = false; 302 let picking = false; 303 let lastPaintX = null, lastPaintY = null; 304 let dirty = false; 305 306 function resize() { 307 const firstResize = cssW === 0; 308 dpr = window.devicePixelRatio || 1; 309 cssW = window.innerWidth; 310 cssH = window.innerHeight; 311 view.style.width = cssW + 'px'; 312 view.style.height = cssH + 'px'; 313 view.width = Math.floor(cssW * dpr); 314 view.height = Math.floor(cssH * dpr); 315 if (firstResize) { 316 curX = Math.floor(cssW / (2 * zoom)); 317 curY = Math.floor(cssH / (2 * zoom)); 318 } 319 requestDraw(); 320 } 321 322 function brushTopLeft(cx, cy) { 323 const off = Math.floor(brush / 2); 324 return { x: cx - off, y: cy - off }; 325 } 326 327 function paintAt(cx, cy) { 328 dirty = true; 329 const tl = brushTopLeft(cx, cy); 330 if (roundness === 0) { 331 paintRect(tl.x, tl.y, brush, brush, 'rgb(' + r + ',' + g + ',' + b + ')'); 332 return; 333 } 334 const sprite = getBrushShape().sprite; 335 const cx0 = Math.floor(tl.x / CHUNK); 336 const cy0 = Math.floor(tl.y / CHUNK); 337 const cx1 = Math.floor((tl.x + brush - 1) / CHUNK); 338 const cy1 = Math.floor((tl.y + brush - 1) / CHUNK); 339 for (let ccy = cy0; ccy <= cy1; ccy++) { 340 for (let ccx = cx0; ccx <= cx1; ccx++) { 341 const c = getOrCreateChunk(ccx, ccy); 342 c.ctx.drawImage(sprite, tl.x - ccx * CHUNK, tl.y - ccy * CHUNK); 343 } 344 } 345 } 346 347 function paintLine(x0, y0, x1, y1) { 348 let dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1; 349 let dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1; 350 let err = dx + dy; 351 let x = x0, y = y0; 352 while (true) { 353 paintAt(x, y); 354 if (x === x1 && y === y1) break; 355 const e2 = 2 * err; 356 if (e2 >= dy) { err += dy; x += sx; } 357 if (e2 <= dx) { err += dx; y += sy; } 358 } 359 } 360 361 const faviconEl = document.getElementById('favicon'); 362 function updateFavicon() { 363 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>"; 364 faviconEl.href = 'data:image/svg+xml,' + encodeURIComponent(svg); 365 } 366 367 function pickAt(cx, cy) { 368 const c = readPixel(cx, cy); 369 r = c[0]; g = c[1]; b = c[2]; 370 setOutlineTarget((r + g + b > 384) ? 1 : 0); 371 setDisplayColorTarget(r, g, b); 372 updateFavicon(); 373 } 374 375 function setColor(nr, ng, nb) { 376 r = nr; g = ng; b = nb; 377 setOutlineTarget((r + g + b > 384) ? 1 : 0); 378 updateFavicon(); 379 dispR = r; dispG = g; dispB = b; 380 colorAnimFromR = r; colorAnimFromG = g; colorAnimFromB = b; 381 colorAnimToR = r; colorAnimToG = g; colorAnimToB = b; 382 } 383 384 function clientToWorld(clientX, clientY) { 385 const lx = Math.floor(clientX / zoom + camX); 386 const ly = Math.floor(clientY / zoom + camY); 387 return { x: lx, y: ly }; 388 } 389 390 let drawQueued = false; 391 function requestDraw() { 392 if (drawQueued) return; 393 drawQueued = true; 394 requestAnimationFrame(() => { 395 drawQueued = false; 396 draw(); 397 }); 398 } 399 400 function draw() { 401 const now = performance.now(); 402 if (outlineLightness !== outlineAnimTo) { 403 const t = (now - outlineAnimStart) / OUTLINE_ANIM_MS; 404 if (t >= 1) { 405 outlineLightness = outlineAnimTo; 406 } else { 407 const e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; 408 outlineLightness = outlineAnimFrom + (outlineAnimTo - outlineAnimFrom) * e; 409 requestDraw(); 410 } 411 } 412 if (dispR !== colorAnimToR || dispG !== colorAnimToG || dispB !== colorAnimToB) { 413 const t = (now - colorAnimStart) / OUTLINE_ANIM_MS; 414 if (t >= 1) { 415 dispR = colorAnimToR; dispG = colorAnimToG; dispB = colorAnimToB; 416 } else { 417 const e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; 418 dispR = Math.round(colorAnimFromR + (colorAnimToR - colorAnimFromR) * e); 419 dispG = Math.round(colorAnimFromG + (colorAnimToG - colorAnimFromG) * e); 420 dispB = Math.round(colorAnimFromB + (colorAnimToB - colorAnimFromB) * e); 421 requestDraw(); 422 } 423 } 424 425 const W = view.width, H = view.height; 426 vctx.setTransform(1, 0, 0, 1, 0, 0); 427 vctx.imageSmoothingEnabled = false; 428 vctx.fillStyle = '#000'; 429 vctx.fillRect(0, 0, W, H); 430 431 // one logical pixel on screen = integer device px. we round here so 432 // every logical pixel occupies the exact same number of device pixels: 433 // otherwise fractional dpr (1.25/1.5/1.75) makes nearest-neighbor 434 // resampling drop or duplicate rows, producing transparent stripes 435 // through painted content at high zoom. 436 const pxD = Math.max(1, Math.round(zoom * dpr)); 437 438 const viewWLog = cssW / zoom; 439 const viewHLog = cssH / zoom; 440 const wx0 = camX; 441 const wy0 = camY; 442 const wx1 = camX + viewWLog; 443 const wy1 = camY + viewHLog; 444 445 const cx0 = Math.floor(wx0 / CHUNK); 446 const cy0 = Math.floor(wy0 / CHUNK); 447 const cx1 = Math.floor((wx1 - 1e-9) / CHUNK); 448 const cy1 = Math.floor((wy1 - 1e-9) / CHUNK); 449 450 // round destinations to integer device pixels to avoid seams between 451 // adjacent chunks. compute right/bottom edges from the neighbor's 452 // rounded left/top so shared edges line up exactly. 453 const destX = (wx) => Math.round((wx - camX) * pxD); 454 const destY = (wy) => Math.round((wy - camY) * pxD); 455 456 for (let cy = cy0; cy <= cy1; cy++) { 457 for (let cx = cx0; cx <= cx1; cx++) { 458 const c = chunks.get(chunkKey(cx, cy)); 459 if (!c) continue; 460 const x0 = destX(cx * CHUNK); 461 const y0 = destY(cy * CHUNK); 462 const x1 = destX((cx + 1) * CHUNK); 463 const y1 = destY((cy + 1) * CHUNK); 464 vctx.drawImage(c.canvas, x0, y0, x1 - x0, y1 - y0); 465 } 466 } 467 468 // onionskin: ghost the frame behind us in playback order, screen-blended 469 // so black contributes nothing over the opaque chunks 470 if (onionskin && !playing && frames.length > 1) { 471 const ghost = frames[(frameIdx - dir + frames.length) % frames.length]; 472 vctx.save(); 473 vctx.globalAlpha = 0.35; 474 vctx.globalCompositeOperation = 'screen'; 475 for (let cy = cy0; cy <= cy1; cy++) { 476 for (let cx = cx0; cx <= cx1; cx++) { 477 const c = ghost.get(chunkKey(cx, cy)); 478 if (!c) continue; 479 const x0 = destX(cx * CHUNK); 480 const y0 = destY(cy * CHUNK); 481 vctx.drawImage(c.canvas, x0, y0, destX((cx + 1) * CHUNK) - x0, destY((cy + 1) * CHUNK) - y0); 482 } 483 } 484 vctx.restore(); 485 } 486 487 if (cursorAlpha !== cursorFadeTo) { 488 const dur = cursorFadeTo > cursorFadeFrom ? CURSOR_FADE_IN_MS : CURSOR_FADE_OUT_MS; 489 const t = (now - cursorFadeStart) / dur; 490 if (t >= 1) { 491 cursorAlpha = cursorFadeTo; 492 if (cursorFadeTo === 0) mouseInside = false; 493 } else { 494 const e = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; 495 cursorAlpha = cursorFadeFrom + (cursorFadeTo - cursorFadeFrom) * e; 496 requestDraw(); 497 } 498 } 499 if (mouseInside) { 500 const tl = brushTopLeft(curX, curY); 501 // round to integer device pixels - camX/camY are fractional (smooth 502 // pan), and fractional fillRect coordinates antialias their edges, 503 // which would leave transparent lines between adjacent row strips. 504 // during an active pan, anchor the cursor to the real client 505 // position (rounded only to device pixels) so it tracks smoothly 506 // instead of jittering as the logical-pixel floor flips back and 507 // forth under a fractional camera. when the pan ends the cursor 508 // snaps back to the logical-pixel grid via the idle timer below. 509 let sx, sy; 510 if (panning && curClientX !== null) { 511 const off = Math.floor(brush / 2); 512 sx = Math.round((curClientX - off * zoom) * dpr); 513 sy = Math.round((curClientY - off * zoom) * dpr); 514 } else { 515 sx = Math.round((tl.x - camX) * pxD); 516 sy = Math.round((tl.y - camY) * pxD); 517 } 518 const n = brush; 519 520 vctx.save(); 521 vctx.globalAlpha = cursorAlpha; 522 vctx.fillStyle = 'rgb(' + dispR + ',' + dispG + ',' + dispB + ')'; 523 if (roundness === 0) { 524 vctx.fillRect(sx, sy, n * pxD, n * pxD); 525 } else { 526 const runs = getBrushShape().fillRuns; 527 for (let i = 0; i < runs.length; i += 3) { 528 vctx.fillRect(sx + runs[i] * pxD, sy + runs[i + 1] * pxD, runs[i + 2] * pxD, pxD); 529 } 530 } 531 532 // 1-logical-pixel outline, cross-faded darken/lighten blend. 533 const shape = roundness === 0 ? null : getBrushShape(); 534 const drawOutline = () => { 535 if (shape) { 536 const runs = shape.outlineRuns; 537 for (let i = 0; i < runs.length; i += 3) { 538 vctx.fillRect(sx + runs[i] * pxD, sy + runs[i + 1] * pxD, runs[i + 2] * pxD, pxD); 539 } 540 } else { 541 vctx.fillRect(sx - pxD, sy - pxD, (n + 2) * pxD, pxD); // top 542 vctx.fillRect(sx - pxD, sy + n * pxD, (n + 2) * pxD, pxD); // bottom 543 vctx.fillRect(sx - pxD, sy, pxD, n * pxD); // left 544 vctx.fillRect(sx + n * pxD, sy, pxD, n * pxD); // right 545 } 546 }; 547 const L = outlineLightness; 548 vctx.save(); 549 if (L > 0) { 550 vctx.globalCompositeOperation = 'multiply'; 551 vctx.globalAlpha = L * cursorAlpha; 552 vctx.fillStyle = 'rgb(128,128,128)'; 553 drawOutline(); 554 } 555 if (L < 1) { 556 vctx.globalCompositeOperation = 'screen'; 557 vctx.globalAlpha = (1 - L) * cursorAlpha; 558 vctx.fillStyle = 'rgb(128,128,128)'; 559 drawOutline(); 560 } 561 vctx.restore(); 562 vctx.restore(); 563 } 564 } 565 566 const fileInput = document.createElement('input'); 567 fileInput.type = 'file'; 568 fileInput.accept = 'image/*'; 569 fileInput.style.display = 'none'; 570 document.body.appendChild(fileInput); 571 fileInput.addEventListener('change', (e) => { 572 const f = e.target.files && e.target.files[0]; 573 if (f) importFile(f); 574 fileInput.value = ''; 575 }); 576 577 function formatTimestamp(d) { 578 const p = (n) => String(n).padStart(2, '0'); 579 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()); 580 } 581 582 // union of painted chunk bounds across all frames, so every exported 583 // frame shares the canvas of the biggest one 584 function frameBounds() { 585 let any = false, minCx = Infinity, minCy = Infinity, maxCx = -Infinity, maxCy = -Infinity; 586 for (const f of frames) { 587 for (const k of f.keys()) { 588 any = true; 589 const [cx, cy] = k.split(',').map(Number); 590 if (cx < minCx) minCx = cx; 591 if (cy < minCy) minCy = cy; 592 if (cx > maxCx) maxCx = cx; 593 if (cy > maxCy) maxCy = cy; 594 } 595 } 596 return any ? { minCx, minCy, maxCx, maxCy } : null; 597 } 598 599 function renderFrame(f, b, w, h) { 600 const out = document.createElement('canvas'); 601 out.width = w; 602 out.height = h; 603 const octx = out.getContext('2d'); 604 octx.fillStyle = '#000'; 605 octx.fillRect(0, 0, w, h); 606 for (const [k, c] of f) { 607 const [cx, cy] = k.split(',').map(Number); 608 octx.drawImage(c.canvas, (cx - b.minCx) * CHUNK, (cy - b.minCy) * CHUNK); 609 } 610 return octx.getImageData(0, 0, w, h); 611 } 612 613 function lzwEncode(minCode, data, lookup, out) { 614 const clear = 1 << minCode, eoi = clear + 1; 615 let codeSize = minCode + 1, next = eoi + 1; 616 let dict = new Map(); 617 let acc = 0, accBits = 0; 618 let block = []; 619 const flushBlock = () => { 620 if (block.length) { out.push(block.length, ...block); block = []; } 621 }; 622 const emit = (code) => { 623 acc |= code << accBits; 624 accBits += codeSize; 625 while (accBits >= 8) { 626 block.push(acc & 255); 627 acc >>= 8; 628 accBits -= 8; 629 if (block.length === 255) flushBlock(); 630 } 631 }; 632 const idx = (i) => lookup((data[i] << 16) | (data[i + 1] << 8) | data[i + 2]); 633 emit(clear); 634 let prev = idx(0); 635 for (let i = 4; i < data.length; i += 4) { 636 const k = idx(i); 637 const key = prev * 256 + k; 638 if (dict.has(key)) { prev = dict.get(key); continue; } 639 emit(prev); 640 if (next === 4096) { 641 emit(clear); 642 dict = new Map(); 643 next = eoi + 1; 644 codeSize = minCode + 1; 645 } else { 646 if (next >= (1 << codeSize)) codeSize++; 647 dict.set(key, next++); 648 } 649 prev = k; 650 } 651 emit(prev); 652 emit(eoi); 653 if (accBits) block.push(acc & 255); 654 flushBlock(); 655 } 656 657 // minimal GIF89a encoder: exact global palette when <=256 colors, 658 // else uniform 6x6x6 quantization 659 function encodeGIF(images, w, h, delayMs) { 660 const colorIdx = new Map(); 661 let over = false; 662 for (const img of images) { 663 const d = img.data; 664 for (let i = 0; i < d.length && !over; i += 4) { 665 const c = (d[i] << 16) | (d[i + 1] << 8) | d[i + 2]; 666 if (!colorIdx.has(c)) { 667 if (colorIdx.size === 256) over = true; 668 else colorIdx.set(c, colorIdx.size); 669 } 670 } 671 if (over) break; 672 } 673 if (over) { 674 colorIdx.clear(); 675 for (let i = 0; i < 216; i++) { 676 colorIdx.set(((Math.floor(i / 36) * 51) << 16) | ((Math.floor(i / 6) % 6 * 51) << 8) | (i % 6 * 51), i); 677 } 678 } 679 const lookup = (c) => over 680 ? Math.round(((c >> 16) & 255) / 51) * 36 + Math.round(((c >> 8) & 255) / 51) * 6 + Math.round((c & 255) / 51) 681 : colorIdx.get(c); 682 let bits = 2; 683 while ((1 << bits) < colorIdx.size) bits++; 684 const out = []; 685 const u16 = (v) => { out.push(v & 255, (v >> 8) & 255); }; 686 out.push(71, 73, 70, 56, 57, 97); // "GIF89a" 687 u16(w); u16(h); 688 out.push(0x80 | ((bits - 1) << 4) | (bits - 1), 0, 0); 689 const pal = [...colorIdx.keys()]; 690 for (let i = 0; i < (1 << bits); i++) { 691 const c = pal[i] || 0; 692 out.push((c >> 16) & 255, (c >> 8) & 255, c & 255); 693 } 694 const animated = images.length > 1; 695 if (animated) { 696 // NETSCAPE2.0 loop forever 697 out.push(0x21, 0xff, 11, 78, 69, 84, 83, 67, 65, 80, 69, 50, 46, 48, 3, 1, 0, 0, 0); 698 } 699 const delay = clamp(Math.round(delayMs / 10), 2, 65535); 700 for (const img of images) { 701 if (animated) out.push(0x21, 0xf9, 4, 0, delay & 255, (delay >> 8) & 255, 0, 0); 702 out.push(0x2c); 703 u16(0); u16(0); u16(w); u16(h); 704 out.push(0); 705 const minCode = Math.max(2, bits); 706 out.push(minCode); 707 lzwEncode(minCode, img.data, lookup, out); 708 out.push(0); 709 } 710 out.push(0x3b); 711 return new Uint8Array(out); 712 } 713 714 function exportGIF() { 715 const b = frameBounds(); 716 if (!b) return; 717 const w = (b.maxCx - b.minCx + 1) * CHUNK; 718 const h = (b.maxCy - b.minCy + 1) * CHUNK; 719 const imgs = frames.map((f) => renderFrame(f, b, w, h)); 720 if (dir === -1) imgs.reverse(); 721 const blob = new Blob([encodeGIF(imgs, w, h, frameInterval)], { type: 'image/gif' }); 722 const url = URL.createObjectURL(blob); 723 const a = document.createElement('a'); 724 a.href = url; 725 a.download = 'animus \u00b7 ' + formatTimestamp(new Date()) + '.gif'; 726 document.body.appendChild(a); 727 a.click(); 728 document.body.removeChild(a); 729 URL.revokeObjectURL(url); 730 dirty = false; 731 } 732 733 function imageToFrame(src, w, h) { 734 const f = new Map(); 735 const ox = -Math.floor(w / 2); 736 const oy = -Math.floor(h / 2); 737 const cx0 = Math.floor(ox / CHUNK); 738 const cy0 = Math.floor(oy / CHUNK); 739 const cx1 = Math.floor((ox + w - 1) / CHUNK); 740 const cy1 = Math.floor((oy + h - 1) / CHUNK); 741 for (let cy = cy0; cy <= cy1; cy++) { 742 for (let cx = cx0; cx <= cx1; cx++) { 743 getOrCreateChunk(cx, cy, f).ctx.drawImage(src, ox - cx * CHUNK, oy - cy * CHUNK); 744 } 745 } 746 return { f, w, h, ox, oy }; 747 } 748 749 function finishImport(newFrames, w, h, ox, oy) { 750 frames = newFrames; 751 dir = 1; 752 setFrame(0); 753 camX = ox - (cssW / zoom - w) / 2; 754 camY = oy - (cssH / zoom - h) / 2; 755 dirty = false; 756 requestDraw(); 757 } 758 759 async function importFile(file) { 760 if (dirty && !confirm('Importing will discard the current ' + (frames.length > 1 ? 'animation' : 'painting') + '. Continue?')) return; 761 if (playing) stopPlayback(); 762 // ImageDecoder gives us every frame of an animated gif; fall back to 763 // single-image import where unsupported 764 if (typeof ImageDecoder !== 'undefined') { 765 try { 766 const dec = new ImageDecoder({ data: await file.arrayBuffer(), type: file.type }); 767 await dec.tracks.ready; 768 const count = dec.tracks.selectedTrack.frameCount; 769 const newFrames = []; 770 let r = null; 771 for (let i = 0; i < count; i++) { 772 const { image } = await dec.decode({ frameIndex: i }); 773 if (i === 0 && image.duration) frameInterval = Math.max(20, image.duration / 1000); 774 r = imageToFrame(image, image.displayWidth, image.displayHeight); 775 newFrames.push(r.f); 776 image.close(); 777 } 778 finishImport(newFrames, r.w, r.h, r.ox, r.oy); 779 return; 780 } catch (err) {} 781 } 782 const img = new Image(); 783 img.onload = () => { 784 const r = imageToFrame(img, img.width, img.height); 785 finishImport([r.f], r.w, r.h, r.ox, r.oy); 786 URL.revokeObjectURL(img.src); 787 }; 788 img.onerror = () => URL.revokeObjectURL(img.src); 789 img.src = URL.createObjectURL(file); 790 } 791 792 window.addEventListener('resize', resize); 793 794 // mouse and pen share the pointer-event path; finger touches are handled 795 // by the touch-event path below 796 function hoverPointer(e) { return e.pointerType === 'mouse' || e.pointerType === 'pen'; } 797 798 window.addEventListener('pointerover', (e) => { 799 if (!hoverPointer(e)) return; 800 showCursor(); 801 curClientX = e.clientX; 802 curClientY = e.clientY; 803 const p = clientToWorld(e.clientX, e.clientY); 804 curX = p.x; 805 curY = p.y; 806 requestDraw(); 807 }); 808 809 // brush resize curve: linear (fine, precise) up to BRUSH_LINEAR_MAX, then a 810 // power-law ramp beyond, tuned by BRUSH_ACCEL. 811 function brushFromDrag(start, dyPx) { 812 const A = BRUSH_DRAG_PX_PER_STEP, T = BRUSH_LINEAR_MAX, c = BRUSH_ACCEL; 813 const seam = T * A; // drag distance from size 0 to the seam 814 const brushToPos = (b) => b <= T ? b * A : seam + (seam / c) * (Math.pow(b / T, c) - 1); 815 const posToBrush = (p) => p <= seam ? p / A : T * Math.pow(1 + c * (p - seam) / seam, 1 / c); 816 return posToBrush(brushToPos(start) + dyPx); 817 } 818 819 // modifier-drag: shift/cmd/RGB held -> vertical motion from the anchor 820 // drives brush size / roundness / color channels. drag up (dy negative) 821 // -> increase. when a value hits its bound and the drag continues past, 822 // re-anchor so reversing direction responds immediately. each drag is 823 // independent so any combination can run at once. 824 function applyDrags(clientY) { 825 if (dragBrush) { 826 const dyPx = (dragBrush.anchorY - clientY) * dpr; 827 const target = brushFromDrag(dragBrush.start, dyPx); 828 const clamped = clamp(Math.round(target), 1, MAX_BRUSH); 829 brush = clamped; 830 if (target < 1 || target > MAX_BRUSH) { 831 dragBrush.start = clamped; 832 dragBrush.anchorY = clientY; 833 } 834 } 835 if (dragRoundness) { 836 const dyPx = (dragRoundness.anchorY - clientY) * dpr; 837 const target = dragRoundness.start - dyPx / ROUNDNESS_DRAG_FULL_PX; 838 const clamped = clamp(target, 0, 1); 839 roundness = clamped; 840 if (target !== clamped) { 841 dragRoundness.start = clamped; 842 dragRoundness.anchorY = clientY; 843 } 844 } 845 if (dragColor) { 846 const dyPx = (dragColor.anchorY - clientY) * dpr; 847 const delta = (dyPx / COLOR_DRAG_FULL_PX) * 255; 848 let nr = r, ng = g, nb = b; 849 let overshoot = 0; 850 if (keys['r']) { 851 const t = dragColor.r0 + delta; 852 nr = clamp(t, 0, 255); 853 if (t !== nr) overshoot = Math.max(overshoot, Math.abs(t - nr)); 854 } 855 if (keys['g']) { 856 const t = dragColor.g0 + delta; 857 ng = clamp(t, 0, 255); 858 if (t !== ng) overshoot = Math.max(overshoot, Math.abs(t - ng)); 859 } 860 if (keys['b']) { 861 const t = dragColor.b0 + delta; 862 nb = clamp(t, 0, 255); 863 if (t !== nb) overshoot = Math.max(overshoot, Math.abs(t - nb)); 864 } 865 setColor(Math.round(nr), Math.round(ng), Math.round(nb)); 866 if (overshoot > 0) { 867 dragColor.r0 = nr; dragColor.g0 = ng; dragColor.b0 = nb; 868 dragColor.anchorY = clientY; 869 } 870 } 871 } 872 873 window.addEventListener('pointermove', (e) => { 874 if (!hoverPointer(e)) return; 875 // a pen only proves it can hover by moving while lifted. without hover 876 // it gets the finger treatment: modifiers adjust instead of marking 877 if (e.pointerType === 'pen' && e.buttons === 0) penHover = true; 878 showCursor(); 879 curClientX = e.clientX; 880 curClientY = e.clientY; 881 const p = clientToWorld(e.clientX, e.clientY); 882 const nx = p.x, ny = p.y; 883 884 applyDrags(e.clientY); 885 886 if (painting) { 887 if (lastPaintX !== null) { 888 paintLine(lastPaintX, lastPaintY, nx, ny); 889 } else { 890 paintAt(nx, ny); 891 } 892 lastPaintX = nx; 893 lastPaintY = ny; 894 } else if (picking) { 895 pickAt(nx, ny); 896 } 897 curX = nx; 898 curY = ny; 899 requestDraw(); 900 }); 901 902 window.addEventListener('pointerleave', (e) => { 903 if (!hoverPointer(e)) return; 904 hideCursorNow(); 905 requestDraw(); 906 }); 907 908 // a pen without hover support sends no move events before contact, so the 909 // down event must place the cursor itself 910 let penDown = false; 911 let penHover = false; 912 window.addEventListener('pointerdown', (e) => { 913 if (!hoverPointer(e)) return; 914 if (e.button !== 0) return; 915 e.preventDefault(); 916 stopInertia(); 917 if (e.pointerType === 'pen') penDown = true; 918 showCursor(); 919 curClientX = e.clientX; 920 curClientY = e.clientY; 921 const p = clientToWorld(e.clientX, e.clientY); 922 curX = p.x; 923 curY = p.y; 924 // hoverless pen + modifier held -> the contact adjusts, like a finger 925 if (e.pointerType === 'pen' && !penHover && !keys['c'] && (dragBrush || dragRoundness || dragColor)) { 926 anchorDrags(e.clientY); 927 requestDraw(); 928 return; 929 } 930 if (keys['c']) { 931 picking = true; 932 pickAt(curX, curY); 933 requestDraw(); 934 return; 935 } 936 painting = true; 937 lastPaintX = curX; 938 lastPaintY = curY; 939 paintAt(curX, curY); 940 requestDraw(); 941 }); 942 943 function pointerUp(e) { 944 if (!hoverPointer(e)) return; 945 if (e.button === 0 || e.type === 'pointercancel') { 946 painting = false; 947 picking = false; 948 lastPaintX = null; 949 lastPaintY = null; 950 if (e.pointerType === 'pen') { 951 penDown = false; 952 // hoverless pens get no more events after lift, so fade like touch 953 if (mouseInside) setCursorFadeTarget(0); 954 requestDraw(); 955 } 956 } 957 } 958 window.addEventListener('pointerup', pointerUp); 959 window.addEventListener('pointercancel', pointerUp); 960 961 // touch: one finger paints, two fingers pan/pinch-zoom. once a second 962 // finger lands the whole touch becomes a gesture (no painting) until all 963 // fingers lift, so a stray finger during a pan never leaves a mark. 964 // stylus touches are excluded here 965 let touchMode = null; // 'paint' | 'adjust' | 'gesture' 966 let touchPainted = false; 967 let pinchDist = 0, pinchMidX = 0, pinchMidY = 0; 968 969 // inertial pan: centroid velocity (css px/ms) tracked during the gesture, 970 // thrown on release and decayed with exponential friction 971 let panVX = 0, panVY = 0; 972 let panLastT = 0; 973 let inertiaRAF = null; 974 function stopInertia() { 975 if (inertiaRAF !== null) { 976 cancelAnimationFrame(inertiaRAF); 977 inertiaRAF = null; 978 } 979 } 980 function startInertia() { 981 // no throw if the finger paused before lifting or was barely moving 982 const speed = Math.hypot(panVX, panVY); 983 if (performance.now() - panLastT > 100 || speed < 0.05) return; 984 // exponential decay per ms 985 const DECEL = 0.995; 986 const MAX_SPEED = 3; // css px/ms 987 if (speed > MAX_SPEED) { 988 panVX *= MAX_SPEED / speed; 989 panVY *= MAX_SPEED / speed; 990 } 991 let prev = performance.now(); 992 const step = (now) => { 993 const dt = now - prev; 994 prev = now; 995 camX -= panVX * dt / zoom; 996 camY -= panVY * dt / zoom; 997 const f = Math.pow(DECEL, dt); 998 panVX *= f; 999 panVY *= f; 1000 requestDraw(); 1001 inertiaRAF = Math.hypot(panVX, panVY) > 0.02 ? requestAnimationFrame(step) : null; 1002 }; 1003 inertiaRAF = requestAnimationFrame(step); 1004 } 1005 1006 function touchCentroid(touches) { 1007 let x = 0, y = 0; 1008 for (const t of touches) { x += t.clientX; y += t.clientY; } 1009 return { x: x / touches.length, y: y / touches.length }; 1010 } 1011 1012 function fingerTouches(list) { 1013 const out = []; 1014 for (const t of list) if (t.touchType !== 'stylus') out.push(t); 1015 return out; 1016 } 1017 1018 // re-anchor active drags to a new y and re-baseline to the current values 1019 // so successive drag strokes accumulate instead of snapping back 1020 function anchorDrags(clientY) { 1021 if (dragBrush) { dragBrush.anchorY = clientY; dragBrush.start = brush; } 1022 if (dragRoundness) { dragRoundness.anchorY = clientY; dragRoundness.start = roundness; } 1023 if (dragColor) { dragColor.anchorY = clientY; dragColor.r0 = r; dragColor.g0 = g; dragColor.b0 = b; } 1024 } 1025 1026 function anchorGesture(touches) { 1027 const m = touchCentroid(touches); 1028 pinchMidX = m.x; 1029 pinchMidY = m.y; 1030 pinchDist = touches.length >= 2 1031 ? Math.hypot(touches[0].clientX - touches[1].clientX, touches[0].clientY - touches[1].clientY) 1032 : 0; 1033 } 1034 1035 window.addEventListener('touchstart', (e) => { 1036 e.preventDefault(); 1037 const fingers = fingerTouches(e.touches); 1038 // fingers are ignored while the pen is down so a resting hand can't 1039 // hijack or extend the pen's stroke 1040 if (fingers.length === 0 || penDown) return; 1041 stopInertia(); 1042 if (fingers.length === 1 && touchMode === null) { 1043 const t = fingers[0]; 1044 curClientX = t.clientX; 1045 curClientY = t.clientY; 1046 if (!keys['c'] && (dragBrush || dragRoundness || dragColor)) { 1047 touchMode = 'adjust'; 1048 const p = clientToWorld(t.clientX, t.clientY); 1049 curX = p.x; 1050 curY = p.y; 1051 showCursor(); 1052 anchorDrags(t.clientY); 1053 } else { 1054 const p = clientToWorld(t.clientX, t.clientY); 1055 curX = p.x; 1056 curY = p.y; 1057 showCursor(); 1058 touchMode = 'paint'; 1059 touchPainted = false; 1060 if (keys['c']) { 1061 picking = true; 1062 } else { 1063 painting = true; 1064 lastPaintX = curX; 1065 lastPaintY = curY; 1066 } 1067 } 1068 } else { 1069 touchMode = 'gesture'; 1070 painting = false; 1071 picking = false; 1072 lastPaintX = null; 1073 lastPaintY = null; 1074 hideCursorNow(); 1075 panVX = 0; 1076 panVY = 0; 1077 panLastT = 0; 1078 anchorGesture(fingers); 1079 } 1080 requestDraw(); 1081 }, { passive: false }); 1082 1083 window.addEventListener('touchmove', (e) => { 1084 e.preventDefault(); 1085 const fingers = fingerTouches(e.touches); 1086 if (fingers.length === 0) return; 1087 if (touchMode === 'paint') { 1088 const t = fingers[0]; 1089 curClientX = t.clientX; 1090 curClientY = t.clientY; 1091 // on hover devices held keys adjust during the stroke, like mouse 1092 applyDrags(t.clientY); 1093 const p = clientToWorld(t.clientX, t.clientY); 1094 if (picking) { 1095 pickAt(p.x, p.y); 1096 } else if (painting) { 1097 paintLine(lastPaintX, lastPaintY, p.x, p.y); 1098 lastPaintX = p.x; 1099 lastPaintY = p.y; 1100 } 1101 touchPainted = true; 1102 curX = p.x; 1103 curY = p.y; 1104 } else if (touchMode === 'adjust') { 1105 const t = fingers[0]; 1106 curClientX = t.clientX; 1107 curClientY = t.clientY; 1108 applyDrags(t.clientY); 1109 const p = clientToWorld(t.clientX, t.clientY); 1110 curX = p.x; 1111 curY = p.y; 1112 } else if (touchMode === 'gesture') { 1113 const m = touchCentroid(fingers); 1114 const dxs = m.x - pinchMidX; 1115 const dys = m.y - pinchMidY; 1116 camX -= dxs / zoom; 1117 camY -= dys / zoom; 1118 const nowT = performance.now(); 1119 const dt = nowT - panLastT; 1120 if (dt > 0 && dt < 100) { 1121 panVX = panVX * 0.5 + (dxs / dt) * 0.5; 1122 panVY = panVY * 0.5 + (dys / dt) * 0.5; 1123 } 1124 panLastT = nowT; 1125 if (fingers.length >= 2) { 1126 const dist = Math.hypot(fingers[0].clientX - fingers[1].clientX, fingers[0].clientY - fingers[1].clientY); 1127 if (pinchDist > 0) applyZoom(dist / pinchDist, m.x, m.y); 1128 pinchDist = dist; 1129 } 1130 pinchMidX = m.x; 1131 pinchMidY = m.y; 1132 } else { 1133 return; 1134 } 1135 requestDraw(); 1136 }, { passive: false }); 1137 1138 function touchEnd(e) { 1139 e.preventDefault(); 1140 if (touchMode === null) return; 1141 const fingers = fingerTouches(e.touches); 1142 if (fingers.length === 0) { 1143 // a tap that never moved still paints its dot (or picks its point) 1144 if (touchMode === 'paint' && !touchPainted) { 1145 if (picking) pickAt(curX, curY); 1146 else paintAt(curX, curY); 1147 } 1148 if (touchMode === 'gesture') startInertia(); 1149 touchMode = null; 1150 painting = false; 1151 picking = false; 1152 lastPaintX = null; 1153 lastPaintY = null; 1154 // fade the preview out instead of hiding it instantly 1155 if (mouseInside) setCursorFadeTarget(0); 1156 requestDraw(); 1157 } else if (touchMode === 'gesture') { 1158 // re-anchor to the remaining fingers so the camera doesn't jump 1159 anchorGesture(fingers); 1160 } 1161 } 1162 window.addEventListener('touchend', touchEnd, { passive: false }); 1163 window.addEventListener('touchcancel', touchEnd, { passive: false }); 1164 1165 function startDragMode(mode) { 1166 const anchorY = curClientY !== null ? curClientY : 0; 1167 if (mode === 'brush' && !dragBrush) dragBrush = { anchorY, start: brush }; 1168 else if (mode === 'roundness' && !dragRoundness) dragRoundness = { anchorY, start: roundness }; 1169 else if (mode === 'color' && !dragColor) dragColor = { anchorY, r0: r, g0: g, b0: b }; 1170 // hoverless input: a modifier pressed mid-stroke ends the stroke and 1171 // the rest of the contact becomes an adjust drag. hover-capable pens 1172 // keep drawing (they can adjust while lifted instead) 1173 if (touchMode === 'paint' || (penDown && !penHover)) { 1174 if (touchMode === 'paint') touchMode = 'adjust'; 1175 painting = false; 1176 picking = false; 1177 lastPaintX = null; 1178 lastPaintY = null; 1179 } 1180 } 1181 function endDragMode(mode) { 1182 if (mode === 'brush') dragBrush = null; 1183 else if (mode === 'roundness') dragRoundness = null; 1184 else if (mode === 'color') dragColor = null; 1185 } 1186 1187 window.addEventListener('keydown', (e) => { 1188 if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 's') { 1189 e.preventDefault(); 1190 exportGIF(); 1191 return; 1192 } 1193 if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'o') { 1194 e.preventDefault(); 1195 fileInput.click(); 1196 return; 1197 } 1198 if ((e.metaKey || e.ctrlKey) && !e.altKey) { 1199 if (e.key === '=' || e.key === '+') { e.preventDefault(); stepZoom(1); return; } 1200 if (e.key === '-' || e.key === '_') { e.preventDefault(); stepZoom(-1); return; } 1201 if (e.key === '0') { e.preventDefault(); resetZoom(); return; } 1202 } 1203 if (!e.metaKey && !e.ctrlKey && !e.altKey && !e.repeat) { 1204 const lk = e.key.toLowerCase(); 1205 if (e.key === 'ArrowLeft') { e.preventDefault(); tapArrow(-1); return; } 1206 if (e.key === 'ArrowRight') { e.preventDefault(); tapArrow(1); return; } 1207 if (e.key === ' ') { e.preventDefault(); playing ? stopPlayback() : startPlayback(); return; } 1208 if (lk === 'a') { addFrame(); return; } 1209 if (lk === 'd') { deleteFrame(); return; } 1210 if (lk === 'o') { 1211 if (playing) { stopPlayback(); onionskin = true; } 1212 else onionskin = !onionskin; 1213 requestDraw(); 1214 return; 1215 } 1216 } 1217 const k = e.key.toLowerCase(); 1218 const wasDown = keys[k]; 1219 keys[k] = true; 1220 if (!e.metaKey && !e.ctrlKey) { 1221 if (k === 'z' || k === 'x' || k === 'c' || k === 'r' || k === 'g' || k === 'b') e.preventDefault(); 1222 if (k === 'x') startDragMode('roundness'); 1223 else if (k === 'z') startDragMode('brush'); 1224 else if (!wasDown && (k === 'r' || k === 'g' || k === 'b')) startDragMode('color'); 1225 else if (k === 'c' && !wasDown && (touchMode === 'paint' || touchMode === 'adjust')) { 1226 touchMode = 'paint'; 1227 painting = false; 1228 picking = true; 1229 lastPaintX = null; 1230 lastPaintY = null; 1231 touchPainted = true; 1232 pickAt(curX, curY); 1233 requestDraw(); 1234 } 1235 } 1236 }); 1237 window.addEventListener('keyup', (e) => { 1238 const k = e.key.toLowerCase(); 1239 keys[k] = false; 1240 if (k === 'x') endDragMode('roundness'); 1241 else if (k === 'z') endDragMode('brush'); 1242 else if (k === 'r' || k === 'g' || k === 'b') { 1243 if (!keys['r'] && !keys['g'] && !keys['b']) endDragMode('color'); 1244 else if (dragColor) { 1245 // still holding at least one rgb key - re-anchor from current 1246 // values so the remaining keys don't jump based on released key's history 1247 dragColor.anchorY = curClientY !== null ? curClientY : dragColor.anchorY; 1248 dragColor.r0 = r; dragColor.g0 = g; dragColor.b0 = b; 1249 } 1250 } 1251 // releasing c mid-pick-drag -> back to adjusting if a modifier is still 1252 // held on a hoverless contact, else seamlessly switch to painting 1253 if (k === 'c' && picking) { 1254 picking = false; 1255 const hoverless = touchMode !== null || (penDown && !penHover); 1256 if (hoverless && (dragBrush || dragRoundness || dragColor)) { 1257 if (touchMode !== null) touchMode = 'adjust'; 1258 painting = false; 1259 if (curClientY !== null) anchorDrags(curClientY); 1260 } else { 1261 painting = true; 1262 lastPaintX = curX; 1263 lastPaintY = curY; 1264 paintAt(curX, curY); 1265 } 1266 requestDraw(); 1267 } 1268 }); 1269 window.addEventListener('blur', () => { 1270 for (const k in keys) keys[k] = false; 1271 dragBrush = null; dragRoundness = null; dragColor = null; 1272 painting = false; 1273 picking = false; 1274 penDown = false; 1275 lastPaintX = null; 1276 lastPaintY = null; 1277 }); 1278 1279 function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } 1280 1281 function applyZoom(factor, ax, ay) { 1282 const worldAtAnchorX = camX + ax / zoom; 1283 const worldAtAnchorY = camY + ay / zoom; 1284 zoomIdx = clamp(zoomIdx + Math.log(factor) * ZOOM_SENSITIVITY, 0, ZOOM_STOPS.length - 1); 1285 const newZoom = ZOOM_STOPS[Math.round(zoomIdx)]; 1286 if (newZoom !== zoom) { 1287 zoom = newZoom; 1288 camX = worldAtAnchorX - ax / zoom; 1289 camY = worldAtAnchorY - ay / zoom; 1290 } 1291 } 1292 1293 function zoomTo(newZoom) { 1294 if (newZoom === zoom) return; 1295 const ax = cssW / 2, ay = cssH / 2; 1296 const worldAtAnchorX = camX + ax / zoom; 1297 const worldAtAnchorY = camY + ay / zoom; 1298 zoom = newZoom; 1299 zoomIdx = ZOOM_STOPS.indexOf(zoom); 1300 camX = worldAtAnchorX - ax / zoom; 1301 camY = worldAtAnchorY - ay / zoom; 1302 requestDraw(); 1303 } 1304 1305 function stepZoom(dir) { 1306 const i = ZOOM_STOPS.indexOf(zoom); 1307 zoomTo(ZOOM_STOPS[clamp(i + dir, 0, ZOOM_STOPS.length - 1)]); 1308 } 1309 function resetZoom() { zoomTo(MIN_ZOOM); } 1310 1311 // wheel gesture lock: once a wheel gesture starts in a given mode, 1312 // subsequent events in the same burst stay in that mode until the wheel 1313 // goes idle or the controlling modifier set changes. this keeps trackpad 1314 // momentum from leaking into pan after a modifier release, while still 1315 // letting the user swap between modes mid-flick without stale state. 1316 let wheelMode = null; 1317 let wheelIdleTimer = null; 1318 let panning = false; 1319 1320 const BRUSH_DRAG_PX_PER_STEP = 25; // device px per +/- 1 brush px (below the seam) 1321 const BRUSH_LINEAR_MAX = 6; // brush px below which resize stays linear/fine 1322 const BRUSH_ACCEL = 0.5; 1323 const MAX_BRUSH = 150; 1324 const ROUNDNESS_DRAG_FULL_PX = 200; 1325 const COLOR_DRAG_FULL_PX = 256; 1326 // each drag carries its own anchor so any combination can run at once 1327 let dragBrush = null; // {anchorY, start} 1328 let dragRoundness = null; // {anchorY, start} 1329 let dragColor = null; // {anchorY, r0, g0, b0} 1330 function touchWheelGesture() { 1331 if (wheelIdleTimer) clearTimeout(wheelIdleTimer); 1332 wheelIdleTimer = setTimeout(() => { 1333 wheelMode = null; 1334 if (panning) { 1335 // pan ended - resync the logical-pixel cursor from the real 1336 // client position so the brush snaps onto its final cell. 1337 panning = false; 1338 if (curClientX !== null) { 1339 const p = clientToWorld(curClientX, curClientY); 1340 curX = p.x; 1341 curY = p.y; 1342 } 1343 requestDraw(); 1344 } 1345 }, 150); 1346 } 1347 function pickModifierMode(e) { 1348 if (e.ctrlKey) return 'zoom'; 1349 return null; 1350 } 1351 1352 window.addEventListener('wheel', (e) => { 1353 e.preventDefault(); 1354 stopInertia(); 1355 1356 // re-pick mode each event from live modifiers so releasing cmd and 1357 // immediately starting a shift scroll switches over cleanly. if a 1358 // burst started with a modifier and the modifier is then released 1359 // mid-flick, suppress rather than leaking into pan. 1360 const modMode = pickModifierMode(e); 1361 if (modMode !== null) { 1362 wheelMode = modMode; 1363 } else if (wheelMode === null) { 1364 wheelMode = 'pan'; 1365 } else if (wheelMode !== 'pan') { 1366 // modifier released mid-burst: drop the remaining momentum 1367 // instead of letting it leak into pan. 1368 touchWheelGesture(); 1369 return; 1370 } 1371 touchWheelGesture(); 1372 1373 if (wheelMode === 'zoom') { 1374 // exponential zoom on the float accumulator so small pinches add up 1375 applyZoom(Math.exp(-e.deltaY * 0.02), e.clientX, e.clientY); 1376 requestDraw(); 1377 return; 1378 } 1379 1380 // pan - smooth fractional camera. during the pan gesture the draw 1381 // loop anchors the brush to the real client cursor position so it 1382 // tracks the pointer smoothly; curX/curY get resynced onto the 1383 // logical-pixel grid when the wheel idle timer fires. 1384 panning = true; 1385 camX += e.deltaX / zoom; 1386 camY += e.deltaY / zoom; 1387 requestDraw(); 1388 }, { passive: false }); 1389 1390 window.addEventListener('beforeunload', (e) => { 1391 if (dirty) { e.preventDefault(); } 1392 }); 1393 1394 window.addEventListener('contextmenu', (e) => e.preventDefault()); 1395 1396 // block the OS pinch gesture events too (Safari) 1397 window.addEventListener('gesturestart', (e) => e.preventDefault()); 1398 window.addEventListener('gesturechange', (e) => e.preventDefault()); 1399 window.addEventListener('gestureend', (e) => e.preventDefault()); 1400 1401 resize(); 1402 })(); 1403 </script> 1404 </body> 1405 </html>