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>