keep brush cursor under pointer while panning

Hunter
Wednesday, April 15th, 2026 at 11:28 PM

index.html+43-4

index.html
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();