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>