keep brush cursor under pointer while panning
Hunter
Wednesday, April 15th, 2026 at 11:28 PM
100
101 // cursor in world coords (logical pixels)
102 let curX = 0, curY = 0;
103 + // last known cursor position in client (CSS) pixels — used to keep the
104 + // brush pinned under the real cursor while panning, since the OS does
105 + // not emit mousemove events when only the camera moves.
106 + let curClientX = null, curClientY = null;
107 // show cursor immediately on load; hidden only after an explicit mouseleave
108 let mouseInside = true;
109
424 // round to integer device pixels — camX/camY are fractional (smooth
425 // pan), and fractional fillRect coordinates antialias their edges,
426 // which would leave transparent lines between adjacent row strips.
- const sx = Math.round((tl.x - camX) * pxD);
- const sy = Math.round((tl.y - camY) * pxD);
427 + // during an active pan, anchor the cursor to the real client
428 + // position (rounded only to device pixels) so it tracks smoothly
429 + // instead of jittering as the logical-pixel floor flips back and
430 + // forth under a fractional camera. when the pan ends the cursor
431 + // snaps back to the logical-pixel grid via the idle timer below.
432 + let sx, sy;
433 + if (panning && curClientX !== null) {
434 + const off = Math.floor(brush / 2);
435 + sx = Math.round((curClientX - off * zoom) * dpr);
436 + sy = Math.round((curClientY - off * zoom) * dpr);
437 + } else {
438 + sx = Math.round((tl.x - camX) * pxD);
439 + sy = Math.round((tl.y - camY) * pxD);
440 + }
441 const n = brush;
442
443 vctx.fillStyle = 'rgb(' + dispR + ',' + dispG + ',' + dispB + ')';
488
489 window.addEventListener('mouseover', (e) => {
490 mouseInside = true;
491 + curClientX = e.clientX;
492 + curClientY = e.clientY;
493 const p = clientToWorld(e.clientX, e.clientY);
494 curX = p.x;
495 curY = p.y;
498
499 window.addEventListener('mousemove', (e) => {
500 mouseInside = true;
501 + curClientX = e.clientX;
502 + curClientY = e.clientY;
503 const p = clientToWorld(e.clientX, e.clientY);
504 const nx = p.x, ny = p.y;
505 if (painting) {
588 // most recently wins so they never apply simultaneously.
589 let wheelMode = null;
590 let wheelIdleTimer = null;
591 + let panning = false;
592 let brushAccum = 0;
593 let metaSeq = 0, shiftSeq = 0, seqCounter = 0;
594 function touchWheelGesture() {
595 if (wheelIdleTimer) clearTimeout(wheelIdleTimer);
- wheelIdleTimer = setTimeout(() => { wheelMode = null; brushAccum = 0; }, 150);
596 + wheelIdleTimer = setTimeout(() => {
597 + wheelMode = null;
598 + brushAccum = 0;
599 + if (panning) {
600 + // pan ended — resync the logical-pixel cursor from the real
601 + // client position so the brush snaps onto its final cell.
602 + panning = false;
603 + if (curClientX !== null) {
604 + const p = clientToWorld(curClientX, curClientY);
605 + curX = p.x;
606 + curY = p.y;
607 + }
608 + requestDraw();
609 + }
610 + }, 150);
611 }
612 function pickModifierMode(e) {
613 if (e.ctrlKey) return 'zoom';
700 return;
701 }
702
- // pan — smooth fractional camera
703 + // pan — smooth fractional camera. during the pan gesture the draw
704 + // loop anchors the brush to the real client cursor position so it
705 + // tracks the pointer smoothly; curX/curY get resynced onto the
706 + // logical-pixel grid when the wheel idle timer fires.
707 + panning = true;
708 camX += e.deltaX / zoom;
709 camY += e.deltaY / zoom;
710 requestDraw();