commit 3d846cf7c55ed0396b2225869cfa53516a1b0fac
Author: Hunter
Date: Wed, 15 Apr 2026 15:19:51 -0400
initial commit
Diffstat:
| A | index.html | | | 494 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
1 file changed, 494 insertions(+), 0 deletions(-)
diff --git a/index.html b/index.html
@@ -0,0 +1,494 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<title>paint</title>
+<style>
+ html, body {
+ margin: 0;
+ padding: 0;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ background: #000;
+ cursor: none;
+ overscroll-behavior: none;
+ }
+ canvas {
+ display: block;
+ position: absolute;
+ top: 0;
+ left: 0;
+ image-rendering: pixelated;
+ image-rendering: crisp-edges;
+ }
+</style>
+</head>
+<body>
+<canvas id="view"></canvas>
+<script>
+(() => {
+ const view = document.getElementById('view');
+ const vctx = view.getContext('2d', { alpha: false });
+
+ /* world: chunked tiles of logical pixels */
+ const CHUNK = 256; // logical pixels per tile side
+ const chunks = new Map(); // key "cx,cy" -> { canvas, ctx }
+
+ function chunkKey(cx, cy) { return cx + ',' + cy; }
+
+ function getOrCreateChunk(cx, cy) {
+ const k = chunkKey(cx, cy);
+ let c = chunks.get(k);
+ if (c) return c;
+ const cnv = document.createElement('canvas');
+ cnv.width = CHUNK;
+ cnv.height = CHUNK;
+ const cctx = cnv.getContext('2d', { alpha: false });
+ cctx.fillStyle = '#000';
+ cctx.fillRect(0, 0, CHUNK, CHUNK);
+ c = { canvas: cnv, ctx: cctx };
+ chunks.set(k, c);
+ return c;
+ }
+
+ function paintRect(wx, wy, w, h, color) {
+ // fill a rect of logical pixels in world coords, writing to chunks
+ const x0 = wx, y0 = wy, x1 = wx + w, y1 = wy + h;
+ const cx0 = Math.floor(x0 / CHUNK);
+ const cy0 = Math.floor(y0 / CHUNK);
+ const cx1 = Math.floor((x1 - 1) / CHUNK);
+ const cy1 = Math.floor((y1 - 1) / CHUNK);
+ for (let cy = cy0; cy <= cy1; cy++) {
+ for (let cx = cx0; cx <= cx1; cx++) {
+ const c = getOrCreateChunk(cx, cy);
+ const lx = Math.max(x0, cx * CHUNK) - cx * CHUNK;
+ const ly = Math.max(y0, cy * CHUNK) - cy * CHUNK;
+ const rx = Math.min(x1, (cx + 1) * CHUNK) - cx * CHUNK;
+ const ry = Math.min(y1, (cy + 1) * CHUNK) - cy * CHUNK;
+ c.ctx.fillStyle = color;
+ c.ctx.fillRect(lx, ly, rx - lx, ry - ly);
+ }
+ }
+ }
+
+ function readPixel(wx, wy) {
+ const cx = Math.floor(wx / CHUNK);
+ const cy = Math.floor(wy / CHUNK);
+ const k = chunkKey(cx, cy);
+ const c = chunks.get(k);
+ if (!c) return [0, 0, 0];
+ const lx = wx - cx * CHUNK;
+ const ly = wy - cy * CHUNK;
+ const d = c.ctx.getImageData(lx, ly, 1, 1).data;
+ return [d[0], d[1], d[2]];
+ }
+
+ // ------- view / camera -------
+ let dpr = window.devicePixelRatio || 1;
+ let cssW = 0, cssH = 0;
+
+ // zoom: CSS pixels per logical pixel. integer >= 1 keeps pixels tangible.
+ // zoomF accumulates fractional pinch input; zoom is the snapped integer used for rendering.
+ let zoomF = 2;
+ let zoom = 2;
+ const MIN_ZOOM = 1, MAX_ZOOM = 64;
+
+ // camera: top-left of view in world (logical pixel) coords — can be fractional for smooth pan
+ let camX = 0, camY = 0;
+
+ // cursor in world coords (logical pixels)
+ let curX = 0, curY = 0;
+ // show cursor immediately on load; hidden only after an explicit mouseleave
+ let mouseInside = true;
+
+ // color / brush
+ let r = 255, g = 255, b = 255;
+ let brush = 1;
+
+ // outline light/dark blend: animates between 0 (dark color -> lighten)
+ // and 1 (light color -> darken). cross-fades over 0.25s.
+ let outlineLightness = 1; // starts white selected
+ let outlineAnimStart = 0;
+ let outlineAnimFrom = 1;
+ let outlineAnimTo = 1;
+ const OUTLINE_ANIM_MS = 250;
+ function setOutlineTarget(target) {
+ if (target === outlineAnimTo) return;
+ outlineAnimFrom = outlineLightness;
+ outlineAnimTo = target;
+ outlineAnimStart = performance.now();
+ requestDraw();
+ }
+
+ // displayed cursor fill color eases toward the real r/g/b over 0.25s.
+ // the real r/g/b is used for painting immediately; this is purely visual.
+ let dispR = 255, dispG = 255, dispB = 255;
+ let colorAnimStart = 0;
+ let colorAnimFromR = 255, colorAnimFromG = 255, colorAnimFromB = 255;
+ let colorAnimToR = 255, colorAnimToG = 255, colorAnimToB = 255;
+ function setDisplayColorTarget(nr, ng, nb) {
+ if (nr === colorAnimToR && ng === colorAnimToG && nb === colorAnimToB) return;
+ colorAnimFromR = dispR; colorAnimFromG = dispG; colorAnimFromB = dispB;
+ colorAnimToR = nr; colorAnimToG = ng; colorAnimToB = nb;
+ colorAnimStart = performance.now();
+ requestDraw();
+ }
+
+ const keys = {};
+ let painting = false;
+ let picking = false;
+ let lastPaintX = null, lastPaintY = null;
+
+ function resize() {
+ const firstResize = cssW === 0;
+ dpr = window.devicePixelRatio || 1;
+ cssW = window.innerWidth;
+ cssH = window.innerHeight;
+ view.style.width = cssW + 'px';
+ view.style.height = cssH + 'px';
+ view.width = Math.floor(cssW * dpr);
+ view.height = Math.floor(cssH * dpr);
+ if (firstResize) {
+ // default cursor to center of viewport until first real mouse event
+ curX = Math.floor(cssW / (2 * zoom));
+ curY = Math.floor(cssH / (2 * zoom));
+ }
+ requestDraw();
+ }
+
+ function brushTopLeft(cx, cy) {
+ const off = Math.floor(brush / 2);
+ return { x: cx - off, y: cy - off };
+ }
+
+ function paintAt(cx, cy) {
+ const tl = brushTopLeft(cx, cy);
+ const color = 'rgb(' + r + ',' + g + ',' + b + ')';
+ paintRect(tl.x, tl.y, brush, brush, color);
+ }
+
+ function paintLine(x0, y0, x1, y1) {
+ // Bresenham between logical pixel coords, stamping brush at each step
+ let dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
+ let dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
+ let err = dx + dy;
+ let x = x0, y = y0;
+ while (true) {
+ paintAt(x, y);
+ if (x === x1 && y === y1) break;
+ const e2 = 2 * err;
+ if (e2 >= dy) { err += dy; x += sx; }
+ if (e2 <= dx) { err += dx; y += sy; }
+ }
+ }
+
+ function pickAt(cx, cy) {
+ const c = readPixel(cx, cy);
+ r = c[0]; g = c[1]; b = c[2];
+ setOutlineTarget((r + g + b > 384) ? 1 : 0);
+ setDisplayColorTarget(r, g, b);
+ }
+
+ function setColor(nr, ng, nb) {
+ r = nr; g = ng; b = nb;
+ setOutlineTarget((r + g + b > 384) ? 1 : 0);
+ // direct color edits snap the display immediately (no transition)
+ dispR = r; dispG = g; dispB = b;
+ colorAnimFromR = r; colorAnimFromG = g; colorAnimFromB = b;
+ colorAnimToR = r; colorAnimToG = g; colorAnimToB = b;
+ }
+
+ function clientToWorld(clientX, clientY) {
+ // logical pixel = floor((clientPixel / zoom) + cam)
+ const lx = Math.floor(clientX / zoom + camX);
+ const ly = Math.floor(clientY / zoom + camY);
+ return { x: lx, y: ly };
+ }
+
+ // ------- rendering -------
+ let drawQueued = false;
+ function requestDraw() {
+ if (drawQueued) return;
+ drawQueued = true;
+ requestAnimationFrame(() => {
+ drawQueued = false;
+ draw();
+ });
+ }
+
+ function draw() {
+ const now = performance.now();
+ // tick outline lightness animation
+ if (outlineLightness !== outlineAnimTo) {
+ const t = (now - outlineAnimStart) / OUTLINE_ANIM_MS;
+ if (t >= 1) {
+ outlineLightness = outlineAnimTo;
+ } else {
+ const e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
+ outlineLightness = outlineAnimFrom + (outlineAnimTo - outlineAnimFrom) * e;
+ requestDraw();
+ }
+ }
+ // tick displayed cursor color animation
+ if (dispR !== colorAnimToR || dispG !== colorAnimToG || dispB !== colorAnimToB) {
+ const t = (now - colorAnimStart) / OUTLINE_ANIM_MS;
+ if (t >= 1) {
+ dispR = colorAnimToR; dispG = colorAnimToG; dispB = colorAnimToB;
+ } else {
+ const e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
+ dispR = Math.round(colorAnimFromR + (colorAnimToR - colorAnimFromR) * e);
+ dispG = Math.round(colorAnimFromG + (colorAnimToG - colorAnimFromG) * e);
+ dispB = Math.round(colorAnimFromB + (colorAnimToB - colorAnimFromB) * e);
+ requestDraw();
+ }
+ }
+
+ const W = view.width, H = view.height;
+ // clear to black
+ vctx.setTransform(1, 0, 0, 1, 0, 0);
+ vctx.imageSmoothingEnabled = false;
+ vctx.fillStyle = '#000';
+ vctx.fillRect(0, 0, W, H);
+
+ // one logical pixel on screen = zoom CSS px = zoom*dpr device px
+ const pxD = zoom * dpr;
+
+ // visible logical-pixel range
+ const viewWLog = cssW / zoom;
+ const viewHLog = cssH / zoom;
+ const wx0 = camX;
+ const wy0 = camY;
+ const wx1 = camX + viewWLog;
+ const wy1 = camY + viewHLog;
+
+ // visible chunk range
+ const cx0 = Math.floor(wx0 / CHUNK);
+ const cy0 = Math.floor(wy0 / CHUNK);
+ const cx1 = Math.floor((wx1 - 1e-9) / CHUNK);
+ const cy1 = Math.floor((wy1 - 1e-9) / CHUNK);
+
+ // round destinations to integer device pixels to avoid seams between
+ // adjacent chunks. compute right/bottom edges from the neighbor's
+ // rounded left/top so shared edges line up exactly.
+ const destX = (wx) => Math.round((wx - camX) * pxD);
+ const destY = (wy) => Math.round((wy - camY) * pxD);
+
+ for (let cy = cy0; cy <= cy1; cy++) {
+ for (let cx = cx0; cx <= cx1; cx++) {
+ const c = chunks.get(chunkKey(cx, cy));
+ if (!c) continue;
+ const x0 = destX(cx * CHUNK);
+ const y0 = destY(cy * CHUNK);
+ const x1 = destX((cx + 1) * CHUNK);
+ const y1 = destY((cy + 1) * CHUNK);
+ vctx.drawImage(c.canvas, x0, y0, x1 - x0, y1 - y0);
+ }
+ }
+
+ if (mouseInside) {
+ const tl = brushTopLeft(curX, curY);
+ const sx = (tl.x - camX) * pxD;
+ const sy = (tl.y - camY) * pxD;
+ const sSize = brush * pxD;
+
+ // fill first (uses eased display color so pick transitions smoothly)
+ vctx.fillStyle = 'rgb(' + dispR + ',' + dispG + ',' + dispB + ')';
+ vctx.fillRect(sx, sy, sSize, sSize);
+
+ // 1-logical-pixel outline with cross-faded darken/lighten blend.
+ // light weight -> multiply (darken); dark weight -> screen (lighten).
+ const ow = pxD;
+ const drawOutline = () => {
+ vctx.fillRect(sx - ow, sy - ow, sSize + 2 * ow, ow); // top
+ vctx.fillRect(sx - ow, sy + sSize, sSize + 2 * ow, ow); // bottom
+ vctx.fillRect(sx - ow, sy, ow, sSize); // left
+ vctx.fillRect(sx + sSize, sy, ow, sSize); // right
+ };
+ const L = outlineLightness;
+ vctx.save();
+ if (L > 0) {
+ vctx.globalCompositeOperation = 'multiply';
+ vctx.globalAlpha = L;
+ vctx.fillStyle = 'rgb(128,128,128)';
+ drawOutline();
+ }
+ if (L < 1) {
+ vctx.globalCompositeOperation = 'screen';
+ vctx.globalAlpha = 1 - L;
+ vctx.fillStyle = 'rgb(128,128,128)';
+ drawOutline();
+ }
+ vctx.restore();
+ }
+ }
+
+ // ------- input -------
+ window.addEventListener('resize', resize);
+
+ window.addEventListener('mouseover', (e) => {
+ mouseInside = true;
+ const p = clientToWorld(e.clientX, e.clientY);
+ curX = p.x;
+ curY = p.y;
+ requestDraw();
+ });
+
+ window.addEventListener('mousemove', (e) => {
+ mouseInside = true;
+ const p = clientToWorld(e.clientX, e.clientY);
+ const nx = p.x, ny = p.y;
+ if (painting) {
+ if (lastPaintX !== null) {
+ paintLine(lastPaintX, lastPaintY, nx, ny);
+ } else {
+ paintAt(nx, ny);
+ }
+ lastPaintX = nx;
+ lastPaintY = ny;
+ } else if (picking) {
+ // shift + click + drag -> live color pick under cursor
+ pickAt(nx, ny);
+ }
+ curX = nx;
+ curY = ny;
+ requestDraw();
+ });
+
+ window.addEventListener('mouseleave', () => {
+ mouseInside = false;
+ requestDraw();
+ });
+
+ window.addEventListener('mousedown', (e) => {
+ if (e.button !== 0) return;
+ e.preventDefault();
+ if (e.shiftKey) {
+ picking = true;
+ pickAt(curX, curY);
+ requestDraw();
+ return;
+ }
+ painting = true;
+ lastPaintX = curX;
+ lastPaintY = curY;
+ paintAt(curX, curY);
+ requestDraw();
+ });
+
+ window.addEventListener('mouseup', (e) => {
+ if (e.button === 0) {
+ painting = false;
+ picking = false;
+ lastPaintX = null;
+ lastPaintY = null;
+ }
+ });
+
+ window.addEventListener('keydown', (e) => {
+ keys[e.key.toLowerCase()] = true;
+ });
+ window.addEventListener('keyup', (e) => {
+ keys[e.key.toLowerCase()] = false;
+ // releasing shift mid-pick-drag -> seamlessly switch to painting
+ if (e.key === 'Shift' && picking) {
+ picking = false;
+ painting = true;
+ lastPaintX = curX;
+ lastPaintY = curY;
+ paintAt(curX, curY);
+ requestDraw();
+ }
+ });
+ window.addEventListener('blur', () => {
+ for (const k in keys) keys[k] = false;
+ painting = false;
+ picking = false;
+ lastPaintX = null;
+ lastPaintY = null;
+ });
+
+ function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
+
+ // wheel gesture lock: once a wheel gesture starts in a given mode
+ // (zoom / brush / color / pan), subsequent events in the same burst
+ // stay in that mode until the wheel goes idle. this prevents trackpad
+ // momentum from leaking into a different mode if the user releases a
+ // modifier key mid-flick.
+ let wheelMode = null;
+ let wheelIdleTimer = null;
+ function touchWheelGesture() {
+ if (wheelIdleTimer) clearTimeout(wheelIdleTimer);
+ wheelIdleTimer = setTimeout(() => { wheelMode = null; }, 150);
+ }
+
+ window.addEventListener('wheel', (e) => {
+ e.preventDefault();
+
+ // determine the mode this gesture belongs to; lock it for the duration
+ if (wheelMode === null) {
+ if (e.ctrlKey) wheelMode = 'zoom';
+ else if (e.shiftKey) wheelMode = 'brush';
+ else if (keys['r'] || keys['g'] || keys['b']) wheelMode = 'color';
+ else wheelMode = 'pan';
+ }
+ touchWheelGesture();
+
+ if (wheelMode === 'zoom') {
+ // zoom around cursor: world point under cursor stays fixed
+ const mx = e.clientX, my = e.clientY;
+ const worldAtCursorX = camX + mx / zoom;
+ const worldAtCursorY = camY + my / zoom;
+ // exponential zoom on the float accumulator so small pinches add up
+ const factor = Math.exp(-e.deltaY * 0.02);
+ zoomF = clamp(zoomF * factor, MIN_ZOOM, MAX_ZOOM);
+ const newZoom = Math.round(zoomF);
+ if (newZoom !== zoom) {
+ zoom = newZoom;
+ camX = worldAtCursorX - mx / zoom;
+ camY = worldAtCursorY - my / zoom;
+ }
+ requestDraw();
+ return;
+ }
+
+ if (wheelMode === 'brush') {
+ // inverted: scroll up = bigger, scroll down = smaller
+ const d = e.deltaY !== 0 ? e.deltaY : e.deltaX;
+ const dir = d < 0 ? -1 : 1;
+ brush = clamp(brush + dir, 1, 256);
+ requestDraw();
+ return;
+ }
+
+ if (wheelMode === 'color') {
+ // inverted: scroll up = less, scroll down = more
+ const dir = e.deltaY < 0 ? -1 : 1;
+ const step = 8 * dir;
+ let nr = r, ng = g, nb = b;
+ if (keys['r']) nr = clamp(r + step, 0, 255);
+ if (keys['g']) ng = clamp(g + step, 0, 255);
+ if (keys['b']) nb = clamp(b + step, 0, 255);
+ setColor(nr, ng, nb);
+ requestDraw();
+ return;
+ }
+
+ // pan — smooth fractional camera
+ camX += e.deltaX / zoom;
+ camY += e.deltaY / zoom;
+ requestDraw();
+ }, { passive: false });
+
+ window.addEventListener('contextmenu', (e) => e.preventDefault());
+
+ // block the OS pinch gesture events too (Safari)
+ window.addEventListener('gesturestart', (e) => e.preventDefault());
+ window.addEventListener('gesturechange', (e) => e.preventDefault());
+ window.addEventListener('gestureend', (e) => e.preventDefault());
+
+ resize();
+})();
+</script>
+</body>
+</html>