initial commit
Hunter
Wednesday, April 15th, 2026 at 3:19 PM
1 +<!DOCTYPE html>
2 +<html lang="en">
3 +<head>
4 +<meta charset="UTF-8">
5 +<title>paint</title>
6 +<style>
7 + html, body {
8 + margin: 0;
9 + padding: 0;
10 + width: 100%;
11 + height: 100%;
12 + overflow: hidden;
13 + background: #000;
14 + cursor: none;
15 + overscroll-behavior: none;
16 + }
17 + canvas {
18 + display: block;
19 + position: absolute;
20 + top: 0;
21 + left: 0;
22 + image-rendering: pixelated;
23 + image-rendering: crisp-edges;
24 + }
25 +</style>
26 +</head>
27 +<body>
28 +<canvas id="view"></canvas>
29 +<script>
30 +(() => {
31 + const view = document.getElementById('view');
32 + const vctx = view.getContext('2d', { alpha: false });
33 +
34 + /* world: chunked tiles of logical pixels */
35 + const CHUNK = 256; // logical pixels per tile side
36 + const chunks = new Map(); // key "cx,cy" -> { canvas, ctx }
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 + // fill a rect of logical pixels in world coords, writing to chunks
57 + const x0 = wx, y0 = wy, x1 = wx + w, y1 = wy + h;
58 + const cx0 = Math.floor(x0 / CHUNK);
59 + const cy0 = Math.floor(y0 / CHUNK);
60 + const cx1 = Math.floor((x1 - 1) / CHUNK);
61 + const cy1 = Math.floor((y1 - 1) / CHUNK);
62 + for (let cy = cy0; cy <= cy1; cy++) {
63 + for (let cx = cx0; cx <= cx1; cx++) {
64 + const c = getOrCreateChunk(cx, cy);
65 + const lx = Math.max(x0, cx * CHUNK) - cx * CHUNK;
66 + const ly = Math.max(y0, cy * CHUNK) - cy * CHUNK;
67 + const rx = Math.min(x1, (cx + 1) * CHUNK) - cx * CHUNK;
68 + const ry = Math.min(y1, (cy + 1) * CHUNK) - cy * CHUNK;
69 + c.ctx.fillStyle = color;
70 + c.ctx.fillRect(lx, ly, rx - lx, ry - ly);
71 + }
72 + }
73 + }
74 +
75 + function readPixel(wx, wy) {
76 + const cx = Math.floor(wx / CHUNK);
77 + const cy = Math.floor(wy / CHUNK);
78 + const k = chunkKey(cx, cy);
79 + const c = chunks.get(k);
80 + if (!c) return [0, 0, 0];
81 + const lx = wx - cx * CHUNK;
82 + const ly = wy - cy * CHUNK;
83 + const d = c.ctx.getImageData(lx, ly, 1, 1).data;
84 + return [d[0], d[1], d[2]];
85 + }
86 +
87 + // ------- view / camera -------
88 + let dpr = window.devicePixelRatio || 1;
89 + let cssW = 0, cssH = 0;
90 +
91 + // zoom: CSS pixels per logical pixel. integer >= 1 keeps pixels tangible.
92 + // zoomF accumulates fractional pinch input; zoom is the snapped integer used for rendering.
93 + let zoomF = 2;
94 + let zoom = 2;
95 + const MIN_ZOOM = 1, MAX_ZOOM = 64;
96 +
97 + // camera: top-left of view in world (logical pixel) coords — can be fractional for smooth pan
98 + let camX = 0, camY = 0;
99 +
100 + // cursor in world coords (logical pixels)
101 + let curX = 0, curY = 0;
102 + // show cursor immediately on load; hidden only after an explicit mouseleave
103 + let mouseInside = true;
104 +
105 + // color / brush
106 + let r = 255, g = 255, b = 255;
107 + let brush = 1;
108 +
109 + // outline light/dark blend: animates between 0 (dark color -> lighten)
110 + // and 1 (light color -> darken). cross-fades over 0.25s.
111 + let outlineLightness = 1; // starts white selected
112 + let outlineAnimStart = 0;
113 + let outlineAnimFrom = 1;
114 + let outlineAnimTo = 1;
115 + const OUTLINE_ANIM_MS = 250;
116 + function setOutlineTarget(target) {
117 + if (target === outlineAnimTo) return;
118 + outlineAnimFrom = outlineLightness;
119 + outlineAnimTo = target;
120 + outlineAnimStart = performance.now();
121 + requestDraw();
122 + }
123 +
124 + // displayed cursor fill color eases toward the real r/g/b over 0.25s.
125 + // the real r/g/b is used for painting immediately; this is purely visual.
126 + let dispR = 255, dispG = 255, dispB = 255;
127 + let colorAnimStart = 0;
128 + let colorAnimFromR = 255, colorAnimFromG = 255, colorAnimFromB = 255;
129 + let colorAnimToR = 255, colorAnimToG = 255, colorAnimToB = 255;
130 + function setDisplayColorTarget(nr, ng, nb) {
131 + if (nr === colorAnimToR && ng === colorAnimToG && nb === colorAnimToB) return;
132 + colorAnimFromR = dispR; colorAnimFromG = dispG; colorAnimFromB = dispB;
133 + colorAnimToR = nr; colorAnimToG = ng; colorAnimToB = nb;
134 + colorAnimStart = performance.now();
135 + requestDraw();
136 + }
137 +
138 + const keys = {};
139 + let painting = false;
140 + let picking = false;
141 + let lastPaintX = null, lastPaintY = null;
142 +
143 + function resize() {
144 + const firstResize = cssW === 0;
145 + dpr = window.devicePixelRatio || 1;
146 + cssW = window.innerWidth;
147 + cssH = window.innerHeight;
148 + view.style.width = cssW + 'px';
149 + view.style.height = cssH + 'px';
150 + view.width = Math.floor(cssW * dpr);
151 + view.height = Math.floor(cssH * dpr);
152 + if (firstResize) {
153 + // default cursor to center of viewport until first real mouse event
154 + curX = Math.floor(cssW / (2 * zoom));
155 + curY = Math.floor(cssH / (2 * zoom));
156 + }
157 + requestDraw();
158 + }
159 +
160 + function brushTopLeft(cx, cy) {
161 + const off = Math.floor(brush / 2);
162 + return { x: cx - off, y: cy - off };
163 + }
164 +
165 + function paintAt(cx, cy) {
166 + const tl = brushTopLeft(cx, cy);
167 + const color = 'rgb(' + r + ',' + g + ',' + b + ')';
168 + paintRect(tl.x, tl.y, brush, brush, color);
169 + }
170 +
171 + function paintLine(x0, y0, x1, y1) {
172 + // Bresenham between logical pixel coords, stamping brush at each step
173 + let dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
174 + let dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
175 + let err = dx + dy;
176 + let x = x0, y = y0;
177 + while (true) {
178 + paintAt(x, y);
179 + if (x === x1 && y === y1) break;
180 + const e2 = 2 * err;
181 + if (e2 >= dy) { err += dy; x += sx; }
182 + if (e2 <= dx) { err += dx; y += sy; }
183 + }
184 + }
185 +
186 + function pickAt(cx, cy) {
187 + const c = readPixel(cx, cy);
188 + r = c[0]; g = c[1]; b = c[2];
189 + setOutlineTarget((r + g + b > 384) ? 1 : 0);
190 + setDisplayColorTarget(r, g, b);
191 + }
192 +
193 + function setColor(nr, ng, nb) {
194 + r = nr; g = ng; b = nb;
195 + setOutlineTarget((r + g + b > 384) ? 1 : 0);
196 + // direct color edits snap the display immediately (no transition)
197 + dispR = r; dispG = g; dispB = b;
198 + colorAnimFromR = r; colorAnimFromG = g; colorAnimFromB = b;
199 + colorAnimToR = r; colorAnimToG = g; colorAnimToB = b;
200 + }
201 +
202 + function clientToWorld(clientX, clientY) {
203 + // logical pixel = floor((clientPixel / zoom) + cam)
204 + const lx = Math.floor(clientX / zoom + camX);
205 + const ly = Math.floor(clientY / zoom + camY);
206 + return { x: lx, y: ly };
207 + }
208 +
209 + // ------- rendering -------
210 + let drawQueued = false;
211 + function requestDraw() {
212 + if (drawQueued) return;
213 + drawQueued = true;
214 + requestAnimationFrame(() => {
215 + drawQueued = false;
216 + draw();
217 + });
218 + }
219 +
220 + function draw() {
221 + const now = performance.now();
222 + // tick outline lightness animation
223 + if (outlineLightness !== outlineAnimTo) {
224 + const t = (now - outlineAnimStart) / OUTLINE_ANIM_MS;
225 + if (t >= 1) {
226 + outlineLightness = outlineAnimTo;
227 + } else {
228 + const e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
229 + outlineLightness = outlineAnimFrom + (outlineAnimTo - outlineAnimFrom) * e;
230 + requestDraw();
231 + }
232 + }
233 + // tick displayed cursor color animation
234 + if (dispR !== colorAnimToR || dispG !== colorAnimToG || dispB !== colorAnimToB) {
235 + const t = (now - colorAnimStart) / OUTLINE_ANIM_MS;
236 + if (t >= 1) {
237 + dispR = colorAnimToR; dispG = colorAnimToG; dispB = colorAnimToB;
238 + } else {
239 + const e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
240 + dispR = Math.round(colorAnimFromR + (colorAnimToR - colorAnimFromR) * e);
241 + dispG = Math.round(colorAnimFromG + (colorAnimToG - colorAnimFromG) * e);
242 + dispB = Math.round(colorAnimFromB + (colorAnimToB - colorAnimFromB) * e);
243 + requestDraw();
244 + }
245 + }
246 +
247 + const W = view.width, H = view.height;
248 + // clear to black
249 + vctx.setTransform(1, 0, 0, 1, 0, 0);
250 + vctx.imageSmoothingEnabled = false;
251 + vctx.fillStyle = '#000';
252 + vctx.fillRect(0, 0, W, H);
253 +
254 + // one logical pixel on screen = zoom CSS px = zoom*dpr device px
255 + const pxD = zoom * dpr;
256 +
257 + // visible logical-pixel range
258 + const viewWLog = cssW / zoom;
259 + const viewHLog = cssH / zoom;
260 + const wx0 = camX;
261 + const wy0 = camY;
262 + const wx1 = camX + viewWLog;
263 + const wy1 = camY + viewHLog;
264 +
265 + // visible chunk range
266 + const cx0 = Math.floor(wx0 / CHUNK);
267 + const cy0 = Math.floor(wy0 / CHUNK);
268 + const cx1 = Math.floor((wx1 - 1e-9) / CHUNK);
269 + const cy1 = Math.floor((wy1 - 1e-9) / CHUNK);
270 +
271 + // round destinations to integer device pixels to avoid seams between
272 + // adjacent chunks. compute right/bottom edges from the neighbor's
273 + // rounded left/top so shared edges line up exactly.
274 + const destX = (wx) => Math.round((wx - camX) * pxD);
275 + const destY = (wy) => Math.round((wy - camY) * pxD);
276 +
277 + for (let cy = cy0; cy <= cy1; cy++) {
278 + for (let cx = cx0; cx <= cx1; cx++) {
279 + const c = chunks.get(chunkKey(cx, cy));
280 + if (!c) continue;
281 + const x0 = destX(cx * CHUNK);
282 + const y0 = destY(cy * CHUNK);
283 + const x1 = destX((cx + 1) * CHUNK);
284 + const y1 = destY((cy + 1) * CHUNK);
285 + vctx.drawImage(c.canvas, x0, y0, x1 - x0, y1 - y0);
286 + }
287 + }
288 +
289 + if (mouseInside) {
290 + const tl = brushTopLeft(curX, curY);
291 + const sx = (tl.x - camX) * pxD;
292 + const sy = (tl.y - camY) * pxD;
293 + const sSize = brush * pxD;
294 +
295 + // fill first (uses eased display color so pick transitions smoothly)
296 + vctx.fillStyle = 'rgb(' + dispR + ',' + dispG + ',' + dispB + ')';
297 + vctx.fillRect(sx, sy, sSize, sSize);
298 +
299 + // 1-logical-pixel outline with cross-faded darken/lighten blend.
300 + // light weight -> multiply (darken); dark weight -> screen (lighten).
301 + const ow = pxD;
302 + const drawOutline = () => {
303 + vctx.fillRect(sx - ow, sy - ow, sSize + 2 * ow, ow); // top
304 + vctx.fillRect(sx - ow, sy + sSize, sSize + 2 * ow, ow); // bottom
305 + vctx.fillRect(sx - ow, sy, ow, sSize); // left
306 + vctx.fillRect(sx + sSize, sy, ow, sSize); // right
307 + };
308 + const L = outlineLightness;
309 + vctx.save();
310 + if (L > 0) {
311 + vctx.globalCompositeOperation = 'multiply';
312 + vctx.globalAlpha = L;
313 + vctx.fillStyle = 'rgb(128,128,128)';
314 + drawOutline();
315 + }
316 + if (L < 1) {
317 + vctx.globalCompositeOperation = 'screen';
318 + vctx.globalAlpha = 1 - L;
319 + vctx.fillStyle = 'rgb(128,128,128)';
320 + drawOutline();
321 + }
322 + vctx.restore();
323 + }
324 + }
325 +
326 + // ------- input -------
327 + window.addEventListener('resize', resize);
328 +
329 + window.addEventListener('mouseover', (e) => {
330 + mouseInside = true;
331 + const p = clientToWorld(e.clientX, e.clientY);
332 + curX = p.x;
333 + curY = p.y;
334 + requestDraw();
335 + });
336 +
337 + window.addEventListener('mousemove', (e) => {
338 + mouseInside = true;
339 + const p = clientToWorld(e.clientX, e.clientY);
340 + const nx = p.x, ny = p.y;
341 + if (painting) {
342 + if (lastPaintX !== null) {
343 + paintLine(lastPaintX, lastPaintY, nx, ny);
344 + } else {
345 + paintAt(nx, ny);
346 + }
347 + lastPaintX = nx;
348 + lastPaintY = ny;
349 + } else if (picking) {
350 + // shift + click + drag -> live color pick under cursor
351 + pickAt(nx, ny);
352 + }
353 + curX = nx;
354 + curY = ny;
355 + requestDraw();
356 + });
357 +
358 + window.addEventListener('mouseleave', () => {
359 + mouseInside = false;
360 + requestDraw();
361 + });
362 +
363 + window.addEventListener('mousedown', (e) => {
364 + if (e.button !== 0) return;
365 + e.preventDefault();
366 + if (e.shiftKey) {
367 + picking = true;
368 + pickAt(curX, curY);
369 + requestDraw();
370 + return;
371 + }
372 + painting = true;
373 + lastPaintX = curX;
374 + lastPaintY = curY;
375 + paintAt(curX, curY);
376 + requestDraw();
377 + });
378 +
379 + window.addEventListener('mouseup', (e) => {
380 + if (e.button === 0) {
381 + painting = false;
382 + picking = false;
383 + lastPaintX = null;
384 + lastPaintY = null;
385 + }
386 + });
387 +
388 + window.addEventListener('keydown', (e) => {
389 + keys[e.key.toLowerCase()] = true;
390 + });
391 + window.addEventListener('keyup', (e) => {
392 + keys[e.key.toLowerCase()] = false;
393 + // releasing shift mid-pick-drag -> seamlessly switch to painting
394 + if (e.key === 'Shift' && picking) {
395 + picking = false;
396 + painting = true;
397 + lastPaintX = curX;
398 + lastPaintY = curY;
399 + paintAt(curX, curY);
400 + requestDraw();
401 + }
402 + });
403 + window.addEventListener('blur', () => {
404 + for (const k in keys) keys[k] = false;
405 + painting = false;
406 + picking = false;
407 + lastPaintX = null;
408 + lastPaintY = null;
409 + });
410 +
411 + function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
412 +
413 + // wheel gesture lock: once a wheel gesture starts in a given mode
414 + // (zoom / brush / color / pan), subsequent events in the same burst
415 + // stay in that mode until the wheel goes idle. this prevents trackpad
416 + // momentum from leaking into a different mode if the user releases a
417 + // modifier key mid-flick.
418 + let wheelMode = null;
419 + let wheelIdleTimer = null;
420 + function touchWheelGesture() {
421 + if (wheelIdleTimer) clearTimeout(wheelIdleTimer);
422 + wheelIdleTimer = setTimeout(() => { wheelMode = null; }, 150);
423 + }
424 +
425 + window.addEventListener('wheel', (e) => {
426 + e.preventDefault();
427 +
428 + // determine the mode this gesture belongs to; lock it for the duration
429 + if (wheelMode === null) {
430 + if (e.ctrlKey) wheelMode = 'zoom';
431 + else if (e.shiftKey) wheelMode = 'brush';
432 + else if (keys['r'] || keys['g'] || keys['b']) wheelMode = 'color';
433 + else wheelMode = 'pan';
434 + }
435 + touchWheelGesture();
436 +
437 + if (wheelMode === 'zoom') {
438 + // zoom around cursor: world point under cursor stays fixed
439 + const mx = e.clientX, my = e.clientY;
440 + const worldAtCursorX = camX + mx / zoom;
441 + const worldAtCursorY = camY + my / zoom;
442 + // exponential zoom on the float accumulator so small pinches add up
443 + const factor = Math.exp(-e.deltaY * 0.02);
444 + zoomF = clamp(zoomF * factor, MIN_ZOOM, MAX_ZOOM);
445 + const newZoom = Math.round(zoomF);
446 + if (newZoom !== zoom) {
447 + zoom = newZoom;
448 + camX = worldAtCursorX - mx / zoom;
449 + camY = worldAtCursorY - my / zoom;
450 + }
451 + requestDraw();
452 + return;
453 + }
454 +
455 + if (wheelMode === 'brush') {
456 + // inverted: scroll up = bigger, scroll down = smaller
457 + const d = e.deltaY !== 0 ? e.deltaY : e.deltaX;
458 + const dir = d < 0 ? -1 : 1;
459 + brush = clamp(brush + dir, 1, 256);
460 + requestDraw();
461 + return;
462 + }
463 +
464 + if (wheelMode === 'color') {
465 + // inverted: scroll up = less, scroll down = more
466 + const dir = e.deltaY < 0 ? -1 : 1;
467 + const step = 8 * dir;
468 + let nr = r, ng = g, nb = b;
469 + if (keys['r']) nr = clamp(r + step, 0, 255);
470 + if (keys['g']) ng = clamp(g + step, 0, 255);
471 + if (keys['b']) nb = clamp(b + step, 0, 255);
472 + setColor(nr, ng, nb);
473 + requestDraw();
474 + return;
475 + }
476 +
477 + // pan — smooth fractional camera
478 + camX += e.deltaX / zoom;
479 + camY += e.deltaY / zoom;
480 + requestDraw();
481 + }, { passive: false });
482 +
483 + window.addEventListener('contextmenu', (e) => e.preventDefault());
484 +
485 + // block the OS pinch gesture events too (Safari)
486 + window.addEventListener('gesturestart', (e) => e.preventDefault());
487 + window.addEventListener('gesturechange', (e) => e.preventDefault());
488 + window.addEventListener('gestureend', (e) => e.preventDefault());
489 +
490 + resize();
491 +})();
492 +</script>
493 +</body>
494 +</html>