input.js (17.9 KB)
1 window.addEventListener('resize', resize); 2 3 // mouse and pen share the pointer-event path; finger touches are handled 4 // by the touch-event path below 5 function hoverPointer(e) { return e.pointerType === 'mouse' || e.pointerType === 'pen'; } 6 7 window.addEventListener('pointerover', (e) => { 8 if (!hoverPointer(e)) return; 9 showCursor(); 10 curClientX = e.clientX; 11 curClientY = e.clientY; 12 const p = clientToWorld(e.clientX, e.clientY); 13 curX = p.x; 14 curY = p.y; 15 requestDraw(); 16 }); 17 18 // brush resize curve: linear (fine, precise) up to BRUSH_LINEAR_MAX, then a 19 // power-law ramp beyond, tuned by BRUSH_ACCEL. 20 function brushFromDrag(start, dyPx) { 21 const A = BRUSH_DRAG_PX_PER_STEP, T = BRUSH_LINEAR_MAX, c = BRUSH_ACCEL; 22 const seam = T * A; // drag distance from size 0 to the seam 23 const brushToPos = (b) => b <= T ? b * A : seam + (seam / c) * (Math.pow(b / T, c) - 1); 24 const posToBrush = (p) => p <= seam ? p / A : T * Math.pow(1 + c * (p - seam) / seam, 1 / c); 25 return posToBrush(brushToPos(start) + dyPx); 26 } 27 28 // modifier-drag: shift/cmd/RGB held -> vertical motion from the anchor 29 // drives brush size / roundness / color channels. drag up (dy negative) 30 // -> increase. when a value hits its bound and the drag continues past, 31 // re-anchor so reversing direction responds immediately. each drag is 32 // independent so any combination can run at once. 33 function applyDrags(clientY) { 34 if (dragBrush) { 35 const dyPx = (dragBrush.anchorY - clientY) * dpr; 36 const target = brushFromDrag(dragBrush.start, dyPx); 37 const clamped = clamp(Math.round(target), 1, MAX_BRUSH); 38 brush = clamped; 39 if (target < 1 || target > MAX_BRUSH) { 40 dragBrush.start = clamped; 41 dragBrush.anchorY = clientY; 42 } 43 } 44 if (dragRoundness) { 45 const dyPx = (dragRoundness.anchorY - clientY) * dpr; 46 const target = dragRoundness.start - dyPx / ROUNDNESS_DRAG_FULL_PX; 47 const clamped = clamp(target, 0, 1); 48 roundness = clamped; 49 if (target !== clamped) { 50 dragRoundness.start = clamped; 51 dragRoundness.anchorY = clientY; 52 } 53 } 54 if (dragColor) { 55 const dyPx = (dragColor.anchorY - clientY) * dpr; 56 const delta = (dyPx / COLOR_DRAG_FULL_PX) * 255; 57 let nr = r, ng = g, nb = b; 58 let overshoot = 0; 59 if (keys['r']) { 60 const t = dragColor.r0 + delta; 61 nr = clamp(t, 0, 255); 62 if (t !== nr) overshoot = Math.max(overshoot, Math.abs(t - nr)); 63 } 64 if (keys['g']) { 65 const t = dragColor.g0 + delta; 66 ng = clamp(t, 0, 255); 67 if (t !== ng) overshoot = Math.max(overshoot, Math.abs(t - ng)); 68 } 69 if (keys['b']) { 70 const t = dragColor.b0 + delta; 71 nb = clamp(t, 0, 255); 72 if (t !== nb) overshoot = Math.max(overshoot, Math.abs(t - nb)); 73 } 74 setColor(Math.round(nr), Math.round(ng), Math.round(nb)); 75 if (overshoot > 0) { 76 dragColor.r0 = nr; dragColor.g0 = ng; dragColor.b0 = nb; 77 dragColor.anchorY = clientY; 78 } 79 } 80 } 81 82 window.addEventListener('pointermove', (e) => { 83 if (!hoverPointer(e)) return; 84 // a pen only proves it can hover by moving while lifted. without hover 85 // it gets the finger treatment: modifiers adjust instead of marking 86 if (e.pointerType === 'pen' && e.buttons === 0) penHover = true; 87 showCursor(); 88 curClientX = e.clientX; 89 curClientY = e.clientY; 90 const p = clientToWorld(e.clientX, e.clientY); 91 const nx = p.x, ny = p.y; 92 93 applyDrags(e.clientY); 94 95 if (painting) { 96 if (lastPaintX !== null) { 97 localPaintLine(lastPaintX, lastPaintY, nx, ny); 98 } else { 99 localPaintAt(nx, ny); 100 } 101 lastPaintX = nx; 102 lastPaintY = ny; 103 } else if (picking) { 104 pickAt(nx, ny); 105 } 106 curX = nx; 107 curY = ny; 108 requestDraw(); 109 }); 110 111 window.addEventListener('pointerleave', (e) => { 112 if (!hoverPointer(e)) return; 113 hideCursorNow(); 114 requestDraw(); 115 }); 116 117 // a pen without hover support sends no move events before contact, so the 118 // down event must place the cursor itself 119 let penDown = false; 120 let penHover = false; 121 window.addEventListener('pointerdown', (e) => { 122 if (!hoverPointer(e)) return; 123 if (e.button !== 0) return; 124 e.preventDefault(); 125 stopInertia(); 126 if (e.pointerType === 'pen') penDown = true; 127 showCursor(); 128 curClientX = e.clientX; 129 curClientY = e.clientY; 130 const p = clientToWorld(e.clientX, e.clientY); 131 curX = p.x; 132 curY = p.y; 133 // hoverless pen + modifier held -> the contact adjusts, like a finger 134 if (e.pointerType === 'pen' && !penHover && !keys['c'] && (dragBrush || dragRoundness || dragColor)) { 135 anchorDrags(e.clientY); 136 requestDraw(); 137 return; 138 } 139 if (keys['c']) { 140 picking = true; 141 pickAt(curX, curY); 142 requestDraw(); 143 return; 144 } 145 painting = true; 146 lastPaintX = curX; 147 lastPaintY = curY; 148 localPaintAt(curX, curY); 149 requestDraw(); 150 }); 151 152 function pointerUp(e) { 153 if (!hoverPointer(e)) return; 154 if (e.button === 0 || e.type === 'pointercancel') { 155 painting = false; 156 picking = false; 157 lastPaintX = null; 158 lastPaintY = null; 159 if (e.pointerType === 'pen') { 160 penDown = false; 161 // hoverless pens get no more events after lift, so fade like touch 162 if (mouseInside) setCursorFadeTarget(0); 163 requestDraw(); 164 } 165 } 166 } 167 window.addEventListener('pointerup', pointerUp); 168 window.addEventListener('pointercancel', pointerUp); 169 170 // touch: one finger paints, two fingers pan/pinch-zoom. once a second 171 // finger lands the whole touch becomes a gesture (no painting) until all 172 // fingers lift, so a stray finger during a pan never leaves a mark. 173 // stylus touches are excluded here 174 let touchMode = null; // 'paint' | 'adjust' | 'gesture' 175 let touchPainted = false; 176 let pinchDist = 0, pinchMidX = 0, pinchMidY = 0; 177 178 // inertial pan: centroid velocity (css px/ms) tracked during the gesture, 179 // thrown on release and decayed with exponential friction 180 let panVX = 0, panVY = 0; 181 let panLastT = 0; 182 let inertiaRAF = null; 183 function stopInertia() { 184 if (inertiaRAF !== null) { 185 cancelAnimationFrame(inertiaRAF); 186 inertiaRAF = null; 187 } 188 } 189 function startInertia() { 190 // no throw if the finger paused before lifting or was barely moving 191 const speed = Math.hypot(panVX, panVY); 192 if (performance.now() - panLastT > 100 || speed < 0.05) return; 193 // exponential decay per ms 194 const DECEL = 0.995; 195 const MAX_SPEED = 3; // css px/ms 196 if (speed > MAX_SPEED) { 197 panVX *= MAX_SPEED / speed; 198 panVY *= MAX_SPEED / speed; 199 } 200 let prev = performance.now(); 201 const step = (now) => { 202 const dt = now - prev; 203 prev = now; 204 camX -= panVX * dt / zoom; 205 camY -= panVY * dt / zoom; 206 const f = Math.pow(DECEL, dt); 207 panVX *= f; 208 panVY *= f; 209 requestDraw(); 210 inertiaRAF = Math.hypot(panVX, panVY) > 0.02 ? requestAnimationFrame(step) : null; 211 }; 212 inertiaRAF = requestAnimationFrame(step); 213 } 214 215 function touchCentroid(touches) { 216 let x = 0, y = 0; 217 for (const t of touches) { x += t.clientX; y += t.clientY; } 218 return { x: x / touches.length, y: y / touches.length }; 219 } 220 221 function fingerTouches(list) { 222 const out = []; 223 for (const t of list) if (t.touchType !== 'stylus') out.push(t); 224 return out; 225 } 226 227 // re-anchor active drags to a new y and re-baseline to the current values 228 // so successive drag strokes accumulate instead of snapping back 229 function anchorDrags(clientY) { 230 if (dragBrush) { dragBrush.anchorY = clientY; dragBrush.start = brush; } 231 if (dragRoundness) { dragRoundness.anchorY = clientY; dragRoundness.start = roundness; } 232 if (dragColor) { dragColor.anchorY = clientY; dragColor.r0 = r; dragColor.g0 = g; dragColor.b0 = b; } 233 } 234 235 function anchorGesture(touches) { 236 const m = touchCentroid(touches); 237 pinchMidX = m.x; 238 pinchMidY = m.y; 239 pinchDist = touches.length >= 2 240 ? Math.hypot(touches[0].clientX - touches[1].clientX, touches[0].clientY - touches[1].clientY) 241 : 0; 242 } 243 244 window.addEventListener('touchstart', (e) => { 245 e.preventDefault(); 246 const fingers = fingerTouches(e.touches); 247 // fingers are ignored while the pen is down so a resting hand can't 248 // hijack or extend the pen's stroke 249 if (fingers.length === 0 || penDown) return; 250 stopInertia(); 251 if (fingers.length === 1 && touchMode === null) { 252 const t = fingers[0]; 253 curClientX = t.clientX; 254 curClientY = t.clientY; 255 if (!keys['c'] && (dragBrush || dragRoundness || dragColor)) { 256 touchMode = 'adjust'; 257 const p = clientToWorld(t.clientX, t.clientY); 258 curX = p.x; 259 curY = p.y; 260 showCursor(); 261 anchorDrags(t.clientY); 262 } else { 263 const p = clientToWorld(t.clientX, t.clientY); 264 curX = p.x; 265 curY = p.y; 266 showCursor(); 267 touchMode = 'paint'; 268 touchPainted = false; 269 if (keys['c']) { 270 picking = true; 271 } else { 272 painting = true; 273 lastPaintX = curX; 274 lastPaintY = curY; 275 } 276 } 277 } else { 278 touchMode = 'gesture'; 279 painting = false; 280 picking = false; 281 lastPaintX = null; 282 lastPaintY = null; 283 hideCursorNow(); 284 panVX = 0; 285 panVY = 0; 286 panLastT = 0; 287 anchorGesture(fingers); 288 } 289 requestDraw(); 290 }, { passive: false }); 291 292 window.addEventListener('touchmove', (e) => { 293 e.preventDefault(); 294 const fingers = fingerTouches(e.touches); 295 if (fingers.length === 0) return; 296 if (touchMode === 'paint') { 297 const t = fingers[0]; 298 curClientX = t.clientX; 299 curClientY = t.clientY; 300 // on hover devices held keys adjust during the stroke, like mouse 301 applyDrags(t.clientY); 302 const p = clientToWorld(t.clientX, t.clientY); 303 if (picking) { 304 pickAt(p.x, p.y); 305 } else if (painting) { 306 localPaintLine(lastPaintX, lastPaintY, p.x, p.y); 307 lastPaintX = p.x; 308 lastPaintY = p.y; 309 } 310 touchPainted = true; 311 curX = p.x; 312 curY = p.y; 313 } else if (touchMode === 'adjust') { 314 const t = fingers[0]; 315 curClientX = t.clientX; 316 curClientY = t.clientY; 317 applyDrags(t.clientY); 318 const p = clientToWorld(t.clientX, t.clientY); 319 curX = p.x; 320 curY = p.y; 321 } else if (touchMode === 'gesture') { 322 const m = touchCentroid(fingers); 323 const dxs = m.x - pinchMidX; 324 const dys = m.y - pinchMidY; 325 camX -= dxs / zoom; 326 camY -= dys / zoom; 327 const nowT = performance.now(); 328 const dt = nowT - panLastT; 329 if (dt > 0 && dt < 100) { 330 panVX = panVX * 0.5 + (dxs / dt) * 0.5; 331 panVY = panVY * 0.5 + (dys / dt) * 0.5; 332 } 333 panLastT = nowT; 334 if (fingers.length >= 2) { 335 const dist = Math.hypot(fingers[0].clientX - fingers[1].clientX, fingers[0].clientY - fingers[1].clientY); 336 if (pinchDist > 0) applyZoom(dist / pinchDist, m.x, m.y); 337 pinchDist = dist; 338 } 339 pinchMidX = m.x; 340 pinchMidY = m.y; 341 } else { 342 return; 343 } 344 requestDraw(); 345 }, { passive: false }); 346 347 function touchEnd(e) { 348 e.preventDefault(); 349 if (touchMode === null) return; 350 const fingers = fingerTouches(e.touches); 351 if (fingers.length === 0) { 352 // a tap that never moved still paints its dot (or picks its point) 353 if (touchMode === 'paint' && !touchPainted) { 354 if (picking) pickAt(curX, curY); 355 else localPaintAt(curX, curY); 356 } 357 if (touchMode === 'gesture') startInertia(); 358 touchMode = null; 359 painting = false; 360 picking = false; 361 lastPaintX = null; 362 lastPaintY = null; 363 // fade the preview out instead of hiding it instantly 364 if (mouseInside) setCursorFadeTarget(0); 365 requestDraw(); 366 } else if (touchMode === 'gesture') { 367 // re-anchor to the remaining fingers so the camera doesn't jump 368 anchorGesture(fingers); 369 } 370 } 371 window.addEventListener('touchend', touchEnd, { passive: false }); 372 window.addEventListener('touchcancel', touchEnd, { passive: false }); 373 374 function startDragMode(mode) { 375 const anchorY = curClientY !== null ? curClientY : 0; 376 if (mode === 'brush' && !dragBrush) dragBrush = { anchorY, start: brush }; 377 else if (mode === 'roundness' && !dragRoundness) dragRoundness = { anchorY, start: roundness }; 378 else if (mode === 'color' && !dragColor) dragColor = { anchorY, r0: r, g0: g, b0: b }; 379 // hoverless input: a modifier pressed mid-stroke ends the stroke and 380 // the rest of the contact becomes an adjust drag. hover-capable pens 381 // keep drawing (they can adjust while lifted instead) 382 if (touchMode === 'paint' || (penDown && !penHover)) { 383 if (touchMode === 'paint') touchMode = 'adjust'; 384 painting = false; 385 picking = false; 386 lastPaintX = null; 387 lastPaintY = null; 388 } 389 } 390 function endDragMode(mode) { 391 if (mode === 'brush') dragBrush = null; 392 else if (mode === 'roundness') dragRoundness = null; 393 else if (mode === 'color') dragColor = null; 394 } 395 396 window.addEventListener('keydown', (e) => { 397 if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 's') { 398 e.preventDefault(); 399 exportGIF(); 400 return; 401 } 402 if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'o') { 403 e.preventDefault(); 404 fileInput.click(); 405 return; 406 } 407 if ((e.metaKey || e.ctrlKey) && !e.altKey) { 408 if (e.key === '=' || e.key === '+') { e.preventDefault(); stepZoom(1); return; } 409 if (e.key === '-' || e.key === '_') { e.preventDefault(); stepZoom(-1); return; } 410 if (e.key === '0') { e.preventDefault(); resetZoom(); return; } 411 } 412 if (!e.metaKey && !e.ctrlKey && !e.altKey && !e.repeat) { 413 const lk = e.key.toLowerCase(); 414 if (e.key === 'ArrowLeft') { e.preventDefault(); tapArrow(-1); return; } 415 if (e.key === 'ArrowRight') { e.preventDefault(); tapArrow(1); return; } 416 if (e.key === ' ') { e.preventDefault(); playing ? stopPlayback() : startPlayback(); return; } 417 if (lk === 'a') { addFrame(); return; } 418 if (lk === 'd') { deleteFrame(); return; } 419 if (lk === 'o') { 420 if (playing) { stopPlayback(); onionskin = true; } 421 else onionskin = !onionskin; 422 requestDraw(); 423 return; 424 } 425 } 426 const k = e.key.toLowerCase(); 427 const wasDown = keys[k]; 428 keys[k] = true; 429 if (!e.metaKey && !e.ctrlKey) { 430 if (k === 'z' || k === 'x' || k === 'c' || k === 'r' || k === 'g' || k === 'b') e.preventDefault(); 431 if (k === 'x') startDragMode('roundness'); 432 else if (k === 'z') startDragMode('brush'); 433 else if (!wasDown && (k === 'r' || k === 'g' || k === 'b')) startDragMode('color'); 434 else if (k === 'c' && !wasDown && (touchMode === 'paint' || touchMode === 'adjust')) { 435 touchMode = 'paint'; 436 painting = false; 437 picking = true; 438 lastPaintX = null; 439 lastPaintY = null; 440 touchPainted = true; 441 pickAt(curX, curY); 442 requestDraw(); 443 } 444 } 445 }); 446 window.addEventListener('keyup', (e) => { 447 const k = e.key.toLowerCase(); 448 keys[k] = false; 449 if (k === 'x') endDragMode('roundness'); 450 else if (k === 'z') endDragMode('brush'); 451 else if (k === 'r' || k === 'g' || k === 'b') { 452 if (!keys['r'] && !keys['g'] && !keys['b']) endDragMode('color'); 453 else if (dragColor) { 454 // still holding at least one rgb key - re-anchor from current 455 // values so the remaining keys don't jump based on released key's history 456 dragColor.anchorY = curClientY !== null ? curClientY : dragColor.anchorY; 457 dragColor.r0 = r; dragColor.g0 = g; dragColor.b0 = b; 458 } 459 } 460 // releasing c mid-pick-drag -> back to adjusting if a modifier is still 461 // held on a hoverless contact, else seamlessly switch to painting 462 if (k === 'c' && picking) { 463 picking = false; 464 const hoverless = touchMode !== null || (penDown && !penHover); 465 if (hoverless && (dragBrush || dragRoundness || dragColor)) { 466 if (touchMode !== null) touchMode = 'adjust'; 467 painting = false; 468 if (curClientY !== null) anchorDrags(curClientY); 469 } else { 470 painting = true; 471 lastPaintX = curX; 472 lastPaintY = curY; 473 localPaintAt(curX, curY); 474 } 475 requestDraw(); 476 } 477 }); 478 window.addEventListener('blur', () => { 479 for (const k in keys) keys[k] = false; 480 dragBrush = null; dragRoundness = null; dragColor = null; 481 painting = false; 482 picking = false; 483 penDown = false; 484 lastPaintX = null; 485 lastPaintY = null; 486 }); 487 488 // wheel gesture lock: once a wheel gesture starts in a given mode, 489 // subsequent events in the same burst stay in that mode until the wheel 490 // goes idle or the controlling modifier set changes. this keeps trackpad 491 // momentum from leaking into pan after a modifier release, while still 492 // letting the user swap between modes mid-flick without stale state. 493 let wheelMode = null; 494 let wheelIdleTimer = null; 495 let panning = false; 496 497 const BRUSH_DRAG_PX_PER_STEP = 25; // device px per +/- 1 brush px (below the seam) 498 const BRUSH_LINEAR_MAX = 6; // brush px below which resize stays linear/fine 499 const BRUSH_ACCEL = 0.5; 500 const MAX_BRUSH = 150; 501 const ROUNDNESS_DRAG_FULL_PX = 200; 502 const COLOR_DRAG_FULL_PX = 256; 503 // each drag carries its own anchor so any combination can run at once 504 let dragBrush = null; // {anchorY, start} 505 let dragRoundness = null; // {anchorY, start} 506 let dragColor = null; // {anchorY, r0, g0, b0} 507 function touchWheelGesture() { 508 if (wheelIdleTimer) clearTimeout(wheelIdleTimer); 509 wheelIdleTimer = setTimeout(() => { 510 wheelMode = null; 511 if (panning) { 512 // pan ended - resync the logical-pixel cursor from the real 513 // client position so the brush snaps onto its final cell. 514 panning = false; 515 if (curClientX !== null) { 516 const p = clientToWorld(curClientX, curClientY); 517 curX = p.x; 518 curY = p.y; 519 } 520 requestDraw(); 521 } 522 }, 150); 523 } 524 function pickModifierMode(e) { 525 if (e.ctrlKey) return 'zoom'; 526 return null; 527 } 528 529 window.addEventListener('wheel', (e) => { 530 e.preventDefault(); 531 stopInertia(); 532 533 // re-pick mode each event from live modifiers so releasing cmd and 534 // immediately starting a shift scroll switches over cleanly. if a 535 // burst started with a modifier and the modifier is then released 536 // mid-flick, suppress rather than leaking into pan. 537 const modMode = pickModifierMode(e); 538 if (modMode !== null) { 539 wheelMode = modMode; 540 } else if (wheelMode === null) { 541 wheelMode = 'pan'; 542 } else if (wheelMode !== 'pan') { 543 // modifier released mid-burst: drop the remaining momentum 544 // instead of letting it leak into pan. 545 touchWheelGesture(); 546 return; 547 } 548 touchWheelGesture(); 549 550 if (wheelMode === 'zoom') { 551 // exponential zoom on the float accumulator so small pinches add up 552 applyZoom(Math.exp(-e.deltaY * 0.02), e.clientX, e.clientY); 553 requestDraw(); 554 return; 555 } 556 557 // pan - smooth fractional camera. during the pan gesture the draw 558 // loop anchors the brush to the real client cursor position so it 559 // tracks the pointer smoothly; curX/curY get resynced onto the 560 // logical-pixel grid when the wheel idle timer fires. 561 panning = true; 562 camX += e.deltaX / zoom; 563 camY += e.deltaY / zoom; 564 requestDraw(); 565 }, { passive: false }); 566 567 window.addEventListener('beforeunload', (e) => { 568 if (dirty) { e.preventDefault(); } 569 }); 570 571 window.addEventListener('contextmenu', (e) => e.preventDefault()); 572 573 // block the OS pinch gesture events too (Safari) 574 window.addEventListener('gesturestart', (e) => e.preventDefault()); 575 window.addEventListener('gesturechange', (e) => e.preventDefault()); 576 window.addEventListener('gestureend', (e) => e.preventDefault());