index.html (25.7 KB)


  1 <!DOCTYPE html>
  2 <html lang="en">
  3 <head>
  4 <meta charset="UTF-8">
  5 <title>paint</title>
  6 <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">
  7 <style>
  8 	html, body {
  9 		margin: 0;
 10 		padding: 0;
 11 		width: 100%;
 12 		height: 100%;
 13 		overflow: hidden;
 14 		background: #000;
 15 		cursor: none;
 16 		overscroll-behavior: none;
 17 	}
 18 	canvas {
 19 		display: block;
 20 		position: absolute;
 21 		top: 0;
 22 		left: 0;
 23 		image-rendering: pixelated;
 24 		image-rendering: crisp-edges;
 25 	}
 26 </style>
 27 </head>
 28 <body>
 29 <canvas id="view"></canvas>
 30 <script>
 31 (() => {
 32 	const view = document.getElementById('view');
 33 	const vctx = view.getContext('2d', { alpha: false });
 34 
 35 	const CHUNK = 256;
 36 	const chunks = new Map();
 37 
 38 	function chunkKey(cx, cy) { return cx + ',' + cy; }
 39 
 40 	function getOrCreateChunk(cx, cy) {
 41 		const k = chunkKey(cx, cy);
 42 		let c = chunks.get(k);
 43 		if (c) return c;
 44 		const cnv = document.createElement('canvas');
 45 		cnv.width = CHUNK;
 46 		cnv.height = CHUNK;
 47 		const cctx = cnv.getContext('2d', { alpha: false });
 48 		cctx.fillStyle = '#000';
 49 		cctx.fillRect(0, 0, CHUNK, CHUNK);
 50 		c = { canvas: cnv, ctx: cctx };
 51 		chunks.set(k, c);
 52 		return c;
 53 	}
 54 
 55 	function paintRect(wx, wy, w, h, color) {
 56 		const x0 = wx, y0 = wy, x1 = wx + w, y1 = wy + h;
 57 		const cx0 = Math.floor(x0 / CHUNK);
 58 		const cy0 = Math.floor(y0 / CHUNK);
 59 		const cx1 = Math.floor((x1 - 1) / CHUNK);
 60 		const cy1 = Math.floor((y1 - 1) / CHUNK);
 61 		for (let cy = cy0; cy <= cy1; cy++) {
 62 			for (let cx = cx0; cx <= cx1; cx++) {
 63 				const c = getOrCreateChunk(cx, cy);
 64 				const lx = Math.max(x0, cx * CHUNK) - cx * CHUNK;
 65 				const ly = Math.max(y0, cy * CHUNK) - cy * CHUNK;
 66 				const rx = Math.min(x1, (cx + 1) * CHUNK) - cx * CHUNK;
 67 				const ry = Math.min(y1, (cy + 1) * CHUNK) - cy * CHUNK;
 68 				c.ctx.fillStyle = color;
 69 				c.ctx.fillRect(lx, ly, rx - lx, ry - ly);
 70 			}
 71 		}
 72 	}
 73 
 74 	function readPixel(wx, wy) {
 75 		const cx = Math.floor(wx / CHUNK);
 76 		const cy = Math.floor(wy / CHUNK);
 77 		const k = chunkKey(cx, cy);
 78 		const c = chunks.get(k);
 79 		if (!c) return [0, 0, 0];
 80 		const lx = wx - cx * CHUNK;
 81 		const ly = wy - cy * CHUNK;
 82 		const d = c.ctx.getImageData(lx, ly, 1, 1).data;
 83 		return [d[0], d[1], d[2]];
 84 	}
 85 
 86 	let dpr = window.devicePixelRatio || 1;
 87 	let cssW = 0, cssH = 0;
 88 
 89 	// zoomF accumulates fractional pinch input; zoom is the snapped integer used for rendering.
 90 	let zoomF = 2;
 91 	let zoom = 2;
 92 	const MIN_ZOOM = 1, MAX_ZOOM = 64;
 93 
 94 	let camX = 0, camY = 0;
 95 
 96 	let curX = 0, curY = 0;
 97 	// used to keep the brush pinned under the real cursor while panning, since
 98 	// the OS does not emit mousemove events when only the camera moves.
 99 	let curClientX = null, curClientY = null;
100 	let mouseInside = true;
101 
102 	let r = 255, g = 255, b = 255;
103 	let brush = 1;
104 	let roundness = 0;
105 
106 	let brushShape = null;
107 	let brushShapeKey = '';
108 	function getBrushShape() {
109 		const key = brush + ',' + roundness + ',' + r + ',' + g + ',' + b;
110 		if (brushShapeKey === key && brushShape) return brushShape;
111 		const n = brush;
112 		const inside = new Uint8Array(n * n);
113 		const rad = roundness * (n / 2);
114 		const rad2 = rad * rad;
115 		const lo = rad - 0.5;
116 		const hi = n - 0.5 - rad;
117 		for (let dy = 0; dy < n; dy++) {
118 			for (let dx = 0; dx < n; dx++) {
119 				let qx = 0, qy = 0;
120 				if (dx < lo) qx = lo - dx;
121 				else if (dx > hi) qx = dx - hi;
122 				if (dy < lo) qy = lo - dy;
123 				else if (dy > hi) qy = dy - hi;
124 				if (qx * qx + qy * qy <= rad2 + 1e-9) inside[dy * n + dx] = 1;
125 			}
126 		}
127 		const sprite = document.createElement('canvas');
128 		sprite.width = n;
129 		sprite.height = n;
130 		const sctx = sprite.getContext('2d');
131 		const img = sctx.createImageData(n, n);
132 		const data = img.data;
133 		for (let i = 0; i < n * n; i++) {
134 			if (inside[i]) {
135 				data[i * 4] = r;
136 				data[i * 4 + 1] = g;
137 				data[i * 4 + 2] = b;
138 				data[i * 4 + 3] = 255;
139 			}
140 		}
141 		sctx.putImageData(img, 0, 0);
142 
143 		const fillRuns = [];
144 		for (let dy = 0; dy < n; dy++) {
145 			let dx = 0;
146 			while (dx < n) {
147 				if (!inside[dy * n + dx]) { dx++; continue; }
148 				let dx1 = dx + 1;
149 				while (dx1 < n && inside[dy * n + dx1]) dx1++;
150 				fillRuns.push(dx, dy, dx1 - dx);
151 				dx = dx1;
152 			}
153 		}
154 
155 		// 1-pixel outline ring: cells outside the shape that are 8-way adjacent
156 		// to any inside cell. stored as row-runs in an (n+2)x(n+2) grid with
157 		// coordinates offset by -1 so they index directly in shape-local space.
158 		const m = n + 2;
159 		const ring = new Uint8Array(m * m);
160 		const isIn = (x, y) => x >= 0 && y >= 0 && x < n && y < n && inside[y * n + x] === 1;
161 		for (let y = -1; y <= n; y++) {
162 			for (let x = -1; x <= n; x++) {
163 				if (isIn(x, y)) continue;
164 				let adj = false;
165 				for (let oy = -1; oy <= 1 && !adj; oy++) {
166 					for (let ox = -1; ox <= 1 && !adj; ox++) {
167 						if (ox === 0 && oy === 0) continue;
168 						if (isIn(x + ox, y + oy)) adj = true;
169 					}
170 				}
171 				if (adj) ring[(y + 1) * m + (x + 1)] = 1;
172 			}
173 		}
174 		const outlineRuns = [];
175 		for (let y = 0; y < m; y++) {
176 			let x = 0;
177 			while (x < m) {
178 				if (!ring[y * m + x]) { x++; continue; }
179 				let x1 = x + 1;
180 				while (x1 < m && ring[y * m + x1]) x1++;
181 				outlineRuns.push(x - 1, y - 1, x1 - x);
182 				x = x1;
183 			}
184 		}
185 
186 		brushShape = { inside, sprite, fillRuns, outlineRuns, n };
187 		brushShapeKey = key;
188 		return brushShape;
189 	}
190 
191 	// outline blend: 0 = dark color (lighten with screen), 1 = light color (darken with multiply).
192 	let outlineLightness = 1;
193 	let outlineAnimStart = 0;
194 	let outlineAnimFrom = 1;
195 	let outlineAnimTo = 1;
196 	const OUTLINE_ANIM_MS = 250;
197 	function setOutlineTarget(target) {
198 		if (target === outlineAnimTo) return;
199 		outlineAnimFrom = outlineLightness;
200 		outlineAnimTo = target;
201 		outlineAnimStart = performance.now();
202 		requestDraw();
203 	}
204 
205 	// displayed cursor fill eases toward r/g/b; painting still uses r/g/b immediately.
206 	let dispR = 255, dispG = 255, dispB = 255;
207 	let colorAnimStart = 0;
208 	let colorAnimFromR = 255, colorAnimFromG = 255, colorAnimFromB = 255;
209 	let colorAnimToR = 255, colorAnimToG = 255, colorAnimToB = 255;
210 	function setDisplayColorTarget(nr, ng, nb) {
211 		if (nr === colorAnimToR && ng === colorAnimToG && nb === colorAnimToB) return;
212 		colorAnimFromR = dispR; colorAnimFromG = dispG; colorAnimFromB = dispB;
213 		colorAnimToR = nr; colorAnimToG = ng; colorAnimToB = nb;
214 		colorAnimStart = performance.now();
215 		requestDraw();
216 	}
217 
218 	const keys = {};
219 	let painting = false;
220 	let picking = false;
221 	let lastPaintX = null, lastPaintY = null;
222 	let dirty = false;
223 
224 	function resize() {
225 		const firstResize = cssW === 0;
226 		dpr = window.devicePixelRatio || 1;
227 		cssW = window.innerWidth;
228 		cssH = window.innerHeight;
229 		view.style.width = cssW + 'px';
230 		view.style.height = cssH + 'px';
231 		view.width = Math.floor(cssW * dpr);
232 		view.height = Math.floor(cssH * dpr);
233 		if (firstResize) {
234 			curX = Math.floor(cssW / (2 * zoom));
235 			curY = Math.floor(cssH / (2 * zoom));
236 		}
237 		requestDraw();
238 	}
239 
240 	function brushTopLeft(cx, cy) {
241 		const off = Math.floor(brush / 2);
242 		return { x: cx - off, y: cy - off };
243 	}
244 
245 	function paintAt(cx, cy) {
246 		dirty = true;
247 		const tl = brushTopLeft(cx, cy);
248 		if (roundness === 0) {
249 			paintRect(tl.x, tl.y, brush, brush, 'rgb(' + r + ',' + g + ',' + b + ')');
250 			return;
251 		}
252 		const sprite = getBrushShape().sprite;
253 		const cx0 = Math.floor(tl.x / CHUNK);
254 		const cy0 = Math.floor(tl.y / CHUNK);
255 		const cx1 = Math.floor((tl.x + brush - 1) / CHUNK);
256 		const cy1 = Math.floor((tl.y + brush - 1) / CHUNK);
257 		for (let ccy = cy0; ccy <= cy1; ccy++) {
258 			for (let ccx = cx0; ccx <= cx1; ccx++) {
259 				const c = getOrCreateChunk(ccx, ccy);
260 				c.ctx.drawImage(sprite, tl.x - ccx * CHUNK, tl.y - ccy * CHUNK);
261 			}
262 		}
263 	}
264 
265 	function paintLine(x0, y0, x1, y1) {
266 		let dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
267 		let dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
268 		let err = dx + dy;
269 		let x = x0, y = y0;
270 		while (true) {
271 			paintAt(x, y);
272 			if (x === x1 && y === y1) break;
273 			const e2 = 2 * err;
274 			if (e2 >= dy) { err += dy; x += sx; }
275 			if (e2 <= dx) { err += dx; y += sy; }
276 		}
277 	}
278 
279 	const faviconEl = document.getElementById('favicon');
280 	function updateFavicon() {
281 		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>";
282 		faviconEl.href = 'data:image/svg+xml,' + encodeURIComponent(svg);
283 	}
284 
285 	function pickAt(cx, cy) {
286 		const c = readPixel(cx, cy);
287 		r = c[0]; g = c[1]; b = c[2];
288 		setOutlineTarget((r + g + b > 384) ? 1 : 0);
289 		setDisplayColorTarget(r, g, b);
290 		updateFavicon();
291 	}
292 
293 	function setColor(nr, ng, nb) {
294 		r = nr; g = ng; b = nb;
295 		setOutlineTarget((r + g + b > 384) ? 1 : 0);
296 		updateFavicon();
297 		dispR = r; dispG = g; dispB = b;
298 		colorAnimFromR = r; colorAnimFromG = g; colorAnimFromB = b;
299 		colorAnimToR = r; colorAnimToG = g; colorAnimToB = b;
300 	}
301 
302 	function clientToWorld(clientX, clientY) {
303 		const lx = Math.floor(clientX / zoom + camX);
304 		const ly = Math.floor(clientY / zoom + camY);
305 		return { x: lx, y: ly };
306 	}
307 
308 	let drawQueued = false;
309 	function requestDraw() {
310 		if (drawQueued) return;
311 		drawQueued = true;
312 		requestAnimationFrame(() => {
313 			drawQueued = false;
314 			draw();
315 		});
316 	}
317 
318 	function draw() {
319 		const now = performance.now();
320 		if (outlineLightness !== outlineAnimTo) {
321 			const t = (now - outlineAnimStart) / OUTLINE_ANIM_MS;
322 			if (t >= 1) {
323 				outlineLightness = outlineAnimTo;
324 			} else {
325 				const e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
326 				outlineLightness = outlineAnimFrom + (outlineAnimTo - outlineAnimFrom) * e;
327 				requestDraw();
328 			}
329 		}
330 		if (dispR !== colorAnimToR || dispG !== colorAnimToG || dispB !== colorAnimToB) {
331 			const t = (now - colorAnimStart) / OUTLINE_ANIM_MS;
332 			if (t >= 1) {
333 				dispR = colorAnimToR; dispG = colorAnimToG; dispB = colorAnimToB;
334 			} else {
335 				const e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
336 				dispR = Math.round(colorAnimFromR + (colorAnimToR - colorAnimFromR) * e);
337 				dispG = Math.round(colorAnimFromG + (colorAnimToG - colorAnimFromG) * e);
338 				dispB = Math.round(colorAnimFromB + (colorAnimToB - colorAnimFromB) * e);
339 				requestDraw();
340 			}
341 		}
342 
343 		const W = view.width, H = view.height;
344 		vctx.setTransform(1, 0, 0, 1, 0, 0);
345 		vctx.imageSmoothingEnabled = false;
346 		vctx.fillStyle = '#000';
347 		vctx.fillRect(0, 0, W, H);
348 
349 		// one logical pixel on screen = integer device px. we round here so
350 		// every logical pixel occupies the exact same number of device pixels:
351 		// otherwise fractional dpr (1.25/1.5/1.75) makes nearest-neighbor
352 		// resampling drop or duplicate rows, producing transparent stripes
353 		// through painted content at high zoom.
354 		const pxD = Math.max(1, Math.round(zoom * dpr));
355 
356 		const viewWLog = cssW / zoom;
357 		const viewHLog = cssH / zoom;
358 		const wx0 = camX;
359 		const wy0 = camY;
360 		const wx1 = camX + viewWLog;
361 		const wy1 = camY + viewHLog;
362 
363 		const cx0 = Math.floor(wx0 / CHUNK);
364 		const cy0 = Math.floor(wy0 / CHUNK);
365 		const cx1 = Math.floor((wx1 - 1e-9) / CHUNK);
366 		const cy1 = Math.floor((wy1 - 1e-9) / CHUNK);
367 
368 		// round destinations to integer device pixels to avoid seams between
369 		// adjacent chunks. compute right/bottom edges from the neighbor's
370 		// rounded left/top so shared edges line up exactly.
371 		const destX = (wx) => Math.round((wx - camX) * pxD);
372 		const destY = (wy) => Math.round((wy - camY) * pxD);
373 
374 		for (let cy = cy0; cy <= cy1; cy++) {
375 			for (let cx = cx0; cx <= cx1; cx++) {
376 				const c = chunks.get(chunkKey(cx, cy));
377 				if (!c) continue;
378 				const x0 = destX(cx * CHUNK);
379 				const y0 = destY(cy * CHUNK);
380 				const x1 = destX((cx + 1) * CHUNK);
381 				const y1 = destY((cy + 1) * CHUNK);
382 				vctx.drawImage(c.canvas, x0, y0, x1 - x0, y1 - y0);
383 			}
384 		}
385 
386 		if (mouseInside) {
387 			const tl = brushTopLeft(curX, curY);
388 			// round to integer device pixels - camX/camY are fractional (smooth
389 			// pan), and fractional fillRect coordinates antialias their edges,
390 			// which would leave transparent lines between adjacent row strips.
391 			// during an active pan, anchor the cursor to the real client
392 			// position (rounded only to device pixels) so it tracks smoothly
393 			// instead of jittering as the logical-pixel floor flips back and
394 			// forth under a fractional camera. when the pan ends the cursor
395 			// snaps back to the logical-pixel grid via the idle timer below.
396 			let sx, sy;
397 			if (panning && curClientX !== null) {
398 				const off = Math.floor(brush / 2);
399 				sx = Math.round((curClientX - off * zoom) * dpr);
400 				sy = Math.round((curClientY - off * zoom) * dpr);
401 			} else {
402 				sx = Math.round((tl.x - camX) * pxD);
403 				sy = Math.round((tl.y - camY) * pxD);
404 			}
405 			const n = brush;
406 
407 			vctx.fillStyle = 'rgb(' + dispR + ',' + dispG + ',' + dispB + ')';
408 			if (roundness === 0) {
409 				vctx.fillRect(sx, sy, n * pxD, n * pxD);
410 			} else {
411 				const runs = getBrushShape().fillRuns;
412 				for (let i = 0; i < runs.length; i += 3) {
413 					vctx.fillRect(sx + runs[i] * pxD, sy + runs[i + 1] * pxD, runs[i + 2] * pxD, pxD);
414 				}
415 			}
416 
417 			// 1-logical-pixel outline, cross-faded darken/lighten blend.
418 			const shape = roundness === 0 ? null : getBrushShape();
419 			const drawOutline = () => {
420 				if (shape) {
421 					const runs = shape.outlineRuns;
422 					for (let i = 0; i < runs.length; i += 3) {
423 						vctx.fillRect(sx + runs[i] * pxD, sy + runs[i + 1] * pxD, runs[i + 2] * pxD, pxD);
424 					}
425 				} else {
426 					vctx.fillRect(sx - pxD, sy - pxD, (n + 2) * pxD, pxD); // top
427 					vctx.fillRect(sx - pxD, sy + n * pxD, (n + 2) * pxD, pxD); // bottom
428 					vctx.fillRect(sx - pxD, sy, pxD, n * pxD); // left
429 					vctx.fillRect(sx + n * pxD, sy, pxD, n * pxD); // right
430 				}
431 			};
432 			const L = outlineLightness;
433 			vctx.save();
434 			if (L > 0) {
435 				vctx.globalCompositeOperation = 'multiply';
436 				vctx.globalAlpha = L;
437 				vctx.fillStyle = 'rgb(128,128,128)';
438 				drawOutline();
439 			}
440 			if (L < 1) {
441 				vctx.globalCompositeOperation = 'screen';
442 				vctx.globalAlpha = 1 - L;
443 				vctx.fillStyle = 'rgb(128,128,128)';
444 				drawOutline();
445 			}
446 			vctx.restore();
447 		}
448 	}
449 
450 	const fileInput = document.createElement('input');
451 	fileInput.type = 'file';
452 	fileInput.accept = 'image/*';
453 	fileInput.style.display = 'none';
454 	document.body.appendChild(fileInput);
455 	fileInput.addEventListener('change', (e) => {
456 		const f = e.target.files && e.target.files[0];
457 		if (f) importImage(f);
458 		fileInput.value = '';
459 	});
460 
461 	function formatTimestamp(d) {
462 		const p = (n) => String(n).padStart(2, '0');
463 		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());
464 	}
465 
466 	function exportPNG() {
467 		if (chunks.size === 0) return;
468 		let minCx = Infinity, minCy = Infinity, maxCx = -Infinity, maxCy = -Infinity;
469 		for (const k of chunks.keys()) {
470 			const [cx, cy] = k.split(',').map(Number);
471 			if (cx < minCx) minCx = cx;
472 			if (cy < minCy) minCy = cy;
473 			if (cx > maxCx) maxCx = cx;
474 			if (cy > maxCy) maxCy = cy;
475 		}
476 		const w = (maxCx - minCx + 1) * CHUNK;
477 		const h = (maxCy - minCy + 1) * CHUNK;
478 		const out = document.createElement('canvas');
479 		out.width = w;
480 		out.height = h;
481 		const octx = out.getContext('2d');
482 		octx.fillStyle = '#000';
483 		octx.fillRect(0, 0, w, h);
484 		for (const [k, c] of chunks) {
485 			const [cx, cy] = k.split(',').map(Number);
486 			octx.drawImage(c.canvas, (cx - minCx) * CHUNK, (cy - minCy) * CHUNK);
487 		}
488 		out.toBlob((blob) => {
489 			const url = URL.createObjectURL(blob);
490 			const a = document.createElement('a');
491 			a.href = url;
492 			a.download = 'paint \u00b7 ' + formatTimestamp(new Date()) + '.png';
493 			document.body.appendChild(a);
494 			a.click();
495 			document.body.removeChild(a);
496 			URL.revokeObjectURL(url);
497 		}, 'image/png');
498 		dirty = false;
499 	}
500 
501 	function importImage(file) {
502 		if (dirty && !confirm('Importing will discard the current painting. Continue?')) return;
503 		const img = new Image();
504 		img.onload = () => {
505 			chunks.clear();
506 			const w = img.width, h = img.height;
507 			const tmp = document.createElement('canvas');
508 			tmp.width = w;
509 			tmp.height = h;
510 			const tctx = tmp.getContext('2d');
511 			tctx.drawImage(img, 0, 0);
512 			const ox = -Math.floor(w / 2);
513 			const oy = -Math.floor(h / 2);
514 			const cx0 = Math.floor(ox / CHUNK);
515 			const cy0 = Math.floor(oy / CHUNK);
516 			const cx1 = Math.floor((ox + w - 1) / CHUNK);
517 			const cy1 = Math.floor((oy + h - 1) / CHUNK);
518 			for (let cy = cy0; cy <= cy1; cy++) {
519 				for (let cx = cx0; cx <= cx1; cx++) {
520 					const c = getOrCreateChunk(cx, cy);
521 					c.ctx.drawImage(tmp, ox - cx * CHUNK, oy - cy * CHUNK);
522 				}
523 			}
524 			camX = ox - (cssW / zoom - w) / 2;
525 			camY = oy - (cssH / zoom - h) / 2;
526 			dirty = false;
527 			URL.revokeObjectURL(img.src);
528 			requestDraw();
529 		};
530 		img.onerror = () => URL.revokeObjectURL(img.src);
531 		img.src = URL.createObjectURL(file);
532 	}
533 
534 	window.addEventListener('resize', resize);
535 
536 	window.addEventListener('mouseover', (e) => {
537 		mouseInside = true;
538 		curClientX = e.clientX;
539 		curClientY = e.clientY;
540 		const p = clientToWorld(e.clientX, e.clientY);
541 		curX = p.x;
542 		curY = p.y;
543 		requestDraw();
544 	});
545 
546 	window.addEventListener('mousemove', (e) => {
547 		mouseInside = true;
548 		curClientX = e.clientX;
549 		curClientY = e.clientY;
550 		const p = clientToWorld(e.clientX, e.clientY);
551 		const nx = p.x, ny = p.y;
552 
553 		// modifier-drag: shift/cmd/RGB held -> vertical motion from the anchor
554 		// drives brush size / roundness / color channels. drag up (dy negative)
555 		// -> increase. when a value hits its bound and the drag continues past,
556 		// re-anchor so reversing direction responds immediately.
557 		if (dragMode !== null) {
558 			const dyPx = (dragAnchorY - e.clientY) * dpr;
559 			if (dragMode === 'brush') {
560 				const targetSteps = Math.trunc(dyPx / BRUSH_DRAG_PX_PER_STEP);
561 				const target = dragBrushStart + targetSteps;
562 				const clamped = clamp(target, 1, 256);
563 				brush = clamped;
564 				if (target !== clamped) {
565 					dragBrushStart = clamped;
566 					dragAnchorY = e.clientY;
567 				}
568 			} else if (dragMode === 'roundness') {
569 				const target = dragRoundnessStart - dyPx / ROUNDNESS_DRAG_FULL_PX;
570 				const clamped = clamp(target, 0, 1);
571 				roundness = clamped;
572 				if (target !== clamped) {
573 					dragRoundnessStart = clamped;
574 					dragAnchorY = e.clientY;
575 				}
576 			} else if (dragMode === 'color') {
577 				const delta = (dyPx / COLOR_DRAG_FULL_PX) * 255;
578 				let nr = r, ng = g, nb = b;
579 				let overshoot = 0;
580 				if (keys['r']) {
581 					const t = dragRStart + delta;
582 					nr = clamp(t, 0, 255);
583 					if (t !== nr) overshoot = Math.max(overshoot, Math.abs(t - nr));
584 				}
585 				if (keys['g']) {
586 					const t = dragGStart + delta;
587 					ng = clamp(t, 0, 255);
588 					if (t !== ng) overshoot = Math.max(overshoot, Math.abs(t - ng));
589 				}
590 				if (keys['b']) {
591 					const t = dragBStart + delta;
592 					nb = clamp(t, 0, 255);
593 					if (t !== nb) overshoot = Math.max(overshoot, Math.abs(t - nb));
594 				}
595 				setColor(Math.round(nr), Math.round(ng), Math.round(nb));
596 				if (overshoot > 0) {
597 					dragRStart = nr; dragGStart = ng; dragBStart = nb;
598 					dragAnchorY = e.clientY;
599 				}
600 			}
601 		}
602 
603 		if (painting) {
604 			if (lastPaintX !== null) {
605 				paintLine(lastPaintX, lastPaintY, nx, ny);
606 			} else {
607 				paintAt(nx, ny);
608 			}
609 			lastPaintX = nx;
610 			lastPaintY = ny;
611 		} else if (picking) {
612 			pickAt(nx, ny);
613 		}
614 		curX = nx;
615 		curY = ny;
616 		requestDraw();
617 	});
618 
619 	window.addEventListener('mouseleave', () => {
620 		mouseInside = false;
621 		requestDraw();
622 	});
623 
624 	window.addEventListener('mousedown', (e) => {
625 		if (e.button !== 0) return;
626 		e.preventDefault();
627 		if (e.altKey) {
628 			picking = true;
629 			pickAt(curX, curY);
630 			requestDraw();
631 			return;
632 		}
633 		painting = true;
634 		lastPaintX = curX;
635 		lastPaintY = curY;
636 		paintAt(curX, curY);
637 		requestDraw();
638 	});
639 
640 	window.addEventListener('mouseup', (e) => {
641 		if (e.button === 0) {
642 			painting = false;
643 			picking = false;
644 			lastPaintX = null;
645 			lastPaintY = null;
646 		}
647 	});
648 
649 	function startDragMode(mode) {
650 		if (dragMode === mode) return;
651 		dragMode = mode;
652 		dragAnchorY = curClientY !== null ? curClientY : 0;
653 		dragBrushStart = brush;
654 		dragRoundnessStart = roundness;
655 		dragRStart = r; dragGStart = g; dragBStart = b;
656 	}
657 	function endDragMode(mode) {
658 		if (dragMode !== mode) return;
659 		dragMode = null;
660 	}
661 
662 	window.addEventListener('keydown', (e) => {
663 		if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 's') {
664 			e.preventDefault();
665 			exportPNG();
666 			return;
667 		}
668 		if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'o') {
669 			e.preventDefault();
670 			fileInput.click();
671 			return;
672 		}
673 		const k = e.key.toLowerCase();
674 		const wasDown = keys[k];
675 		keys[k] = true;
676 		if (e.key === 'Meta') { metaSeq = ++seqCounter; startDragMode('roundness'); }
677 		else if (e.key === 'Shift') { shiftSeq = ++seqCounter; startDragMode('brush'); }
678 		else if (!wasDown && (k === 'r' || k === 'g' || k === 'b')) startDragMode('color');
679 	});
680 	window.addEventListener('keyup', (e) => {
681 		const k = e.key.toLowerCase();
682 		keys[k] = false;
683 		if (e.key === 'Meta') { metaSeq = 0; endDragMode('roundness'); }
684 		else if (e.key === 'Shift') { shiftSeq = 0; endDragMode('brush'); }
685 		else if (k === 'r' || k === 'g' || k === 'b') {
686 			if (!keys['r'] && !keys['g'] && !keys['b']) endDragMode('color');
687 			else {
688 				// still holding at least one rgb key - re-anchor from current
689 				// values so the remaining keys don't jump based on released key's history
690 				dragAnchorY = curClientY !== null ? curClientY : dragAnchorY;
691 				dragRStart = r; dragGStart = g; dragBStart = b;
692 			}
693 		}
694 		// releasing alt mid-pick-drag -> seamlessly switch to painting
695 		if ((e.key === 'Alt' || e.key === 'AltGraph') && picking) {
696 			picking = false;
697 			painting = true;
698 			lastPaintX = curX;
699 			lastPaintY = curY;
700 			paintAt(curX, curY);
701 			requestDraw();
702 		}
703 	});
704 	window.addEventListener('blur', () => {
705 		for (const k in keys) keys[k] = false;
706 		metaSeq = 0; shiftSeq = 0;
707 		painting = false;
708 		picking = false;
709 		lastPaintX = null;
710 		lastPaintY = null;
711 	});
712 
713 	function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
714 
715 	// wheel gesture lock: once a wheel gesture starts in a given mode,
716 	// subsequent events in the same burst stay in that mode until the wheel
717 	// goes idle or the controlling modifier set changes. this keeps trackpad
718 	// momentum from leaking into pan after a modifier release, while still
719 	// letting the user swap between modifier-driven modes (shift <-> cmd)
720 	// mid-flick without stale state. between shift and cmd, the one pressed
721 	// most recently wins so they never apply simultaneously.
722 	let wheelMode = null;
723 	let wheelIdleTimer = null;
724 	let panning = false;
725 	let metaSeq = 0, shiftSeq = 0, seqCounter = 0;
726 
727 	const BRUSH_DRAG_PX_PER_STEP = 4;		// device px per +/- 1 brush px
728 	const ROUNDNESS_DRAG_FULL_PX = 200;		// device px to traverse 0 to 1
729 	const COLOR_DRAG_FULL_PX = 256;			// device px to traverse 0 to 255
730 	let dragMode = null;		// 'brush' | 'roundness' | 'color' | null
731 	let dragAnchorY = 0;
732 	let dragBrushStart = 1;
733 	let dragRoundnessStart = 0;
734 	let dragRStart = 0, dragGStart = 0, dragBStart = 0;
735 	function touchWheelGesture() {
736 		if (wheelIdleTimer) clearTimeout(wheelIdleTimer);
737 		wheelIdleTimer = setTimeout(() => {
738 			wheelMode = null;
739 			if (panning) {
740 				// pan ended - resync the logical-pixel cursor from the real
741 				// client position so the brush snaps onto its final cell.
742 				panning = false;
743 				if (curClientX !== null) {
744 					const p = clientToWorld(curClientX, curClientY);
745 					curX = p.x;
746 					curY = p.y;
747 				}
748 				requestDraw();
749 			}
750 		}, 150);
751 	}
752 	function pickModifierMode(e) {
753 		if (e.ctrlKey) return 'zoom';
754 		return null;
755 	}
756 
757 	window.addEventListener('wheel', (e) => {
758 		e.preventDefault();
759 
760 		// re-pick mode each event from live modifiers so releasing cmd and
761 		// immediately starting a shift scroll switches over cleanly. if a
762 		// burst started with a modifier and the modifier is then released
763 		// mid-flick, suppress rather than leaking into pan.
764 		const modMode = pickModifierMode(e);
765 		if (modMode !== null) {
766 			wheelMode = modMode;
767 		} else if (wheelMode === null) {
768 			wheelMode = 'pan';
769 		} else if (wheelMode !== 'pan') {
770 			// modifier released mid-burst: drop the remaining momentum
771 			// instead of letting it leak into pan.
772 			touchWheelGesture();
773 			return;
774 		}
775 		touchWheelGesture();
776 
777 		if (wheelMode === 'zoom') {
778 			// zoom around cursor: world point under cursor stays fixed
779 			const mx = e.clientX, my = e.clientY;
780 			const worldAtCursorX = camX + mx / zoom;
781 			const worldAtCursorY = camY + my / zoom;
782 			// exponential zoom on the float accumulator so small pinches add up
783 			const factor = Math.exp(-e.deltaY * 0.04);
784 			zoomF = clamp(zoomF * factor, MIN_ZOOM, MAX_ZOOM);
785 			// multiplicative threshold: trigger a step once zoomF has moved
786 			// the same *ratio* past the current integer in either direction.
787 			// fixed additive thresholds felt jarring at low zoom because 1 to 2
788 			// is a 2x jump while 32 to 33 is only ~1.03x - requiring equal pinch
789 			// effort for a huge perceptual leap. ratio-based thresholds make
790 			// every step cost roughly the same perceptual work.
791 			const STEP_RATIO = 1.2;
792 			let newZoom = zoom;
793 			if (zoomF > zoom * STEP_RATIO) newZoom = Math.min(MAX_ZOOM, zoom + 1);
794 			else if (zoomF < zoom / STEP_RATIO) newZoom = Math.max(MIN_ZOOM, zoom - 1);
795 			if (newZoom !== zoom) {
796 				zoom = newZoom;
797 				zoomF = zoom; // resync so next step needs the same delta
798 				camX = worldAtCursorX - mx / zoom;
799 				camY = worldAtCursorY - my / zoom;
800 			}
801 			requestDraw();
802 			return;
803 		}
804 
805 		// pan - smooth fractional camera. during the pan gesture the draw
806 		// loop anchors the brush to the real client cursor position so it
807 		// tracks the pointer smoothly; curX/curY get resynced onto the
808 		// logical-pixel grid when the wheel idle timer fires.
809 		panning = true;
810 		camX += e.deltaX / zoom;
811 		camY += e.deltaY / zoom;
812 		requestDraw();
813 	}, { passive: false });
814 
815 	window.addEventListener('beforeunload', (e) => {
816 		if (dirty) { e.preventDefault(); }
817 	});
818 
819 	window.addEventListener('contextmenu', (e) => e.preventDefault());
820 
821 	// block the OS pinch gesture events too (Safari)
822 	window.addEventListener('gesturestart', (e) => e.preventDefault());
823 	window.addEventListener('gesturechange', (e) => e.preventDefault());
824 	window.addEventListener('gestureend', (e) => e.preventDefault());
825 
826 	resize();
827 })();
828 </script>
829 </body>
830 </html>