fix wheel modifier handling and tune zoom/brush sensitivity

Hunter
Wednesday, April 15th, 2026 at 7:03 PM

index.html+56-18

index.html
531  
532  	window.addEventListener('keydown', (e) => {
533  		keys[e.key.toLowerCase()] = true;
534 +		if (e.key === 'Meta') metaSeq = ++seqCounter;
535 +		else if (e.key === 'Shift') shiftSeq = ++seqCounter;
536  	});
537  	window.addEventListener('keyup', (e) => {
538  		keys[e.key.toLowerCase()] = false;
539 +		if (e.key === 'Meta') metaSeq = 0;
540 +		else if (e.key === 'Shift') shiftSeq = 0;
541  		// releasing shift mid-pick-drag -> seamlessly switch to painting
542  		if (e.key === 'Shift' && picking) {
543  			picking = false;

550  	});
551  	window.addEventListener('blur', () => {
552  		for (const k in keys) keys[k] = false;
553 +		metaSeq = 0; shiftSeq = 0;
554  		painting = false;
555  		picking = false;
556  		lastPaintX = null;

559  
560  	function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
561  
    -	// 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.
562 +	// wheel gesture lock: once a wheel gesture starts in a given mode,
563 +	// subsequent events in the same burst stay in that mode until the wheel
564 +	// goes idle or the controlling modifier set changes. this keeps trackpad
565 +	// momentum from leaking into pan after a modifier release, while still
566 +	// letting the user swap between modifier-driven modes (shift <-> cmd)
567 +	// mid-flick without stale state. between shift and cmd, the one pressed
568 +	// most recently wins so they never apply simultaneously.
569  	let wheelMode = null;
570  	let wheelIdleTimer = null;
571 +	let brushAccum = 0;
572 +	let metaSeq = 0, shiftSeq = 0, seqCounter = 0;
573  	function touchWheelGesture() {
574  		if (wheelIdleTimer) clearTimeout(wheelIdleTimer);
    -		wheelIdleTimer = setTimeout(() => { wheelMode = null; }, 150);
575 +		wheelIdleTimer = setTimeout(() => { wheelMode = null; brushAccum = 0; }, 150);
576 +	}
577 +	function pickModifierMode(e) {
578 +		if (e.ctrlKey) return 'zoom';
579 +		const meta = e.metaKey, shift = e.shiftKey;
580 +		if (meta && shift) return (metaSeq >= shiftSeq) ? 'roundness' : 'brush';
581 +		if (meta) return 'roundness';
582 +		if (shift) return 'brush';
583 +		if (keys['r'] || keys['g'] || keys['b']) return 'color';
584 +		return null;
585  	}
586  
587  	window.addEventListener('wheel', (e) => {
588  		e.preventDefault();
589  
    -		// determine the mode this gesture belongs to; lock it for the duration
    -		if (wheelMode === null) {
    -			if (e.ctrlKey) wheelMode = 'zoom';
    -			else if (e.metaKey) wheelMode = 'roundness';
    -			else if (e.shiftKey) wheelMode = 'brush';
    -			else if (keys['r'] || keys['g'] || keys['b']) wheelMode = 'color';
    -			else wheelMode = 'pan';
590 +		// re-pick mode each event from live modifiers so releasing cmd and
591 +		// immediately starting a shift scroll switches over cleanly. if a
592 +		// burst started with a modifier and the modifier is then released
593 +		// mid-flick, suppress rather than leaking into pan.
594 +		const modMode = pickModifierMode(e);
595 +		if (modMode !== null) {
596 +			wheelMode = modMode;
597 +		} else if (wheelMode === null) {
598 +			wheelMode = 'pan';
599 +		} else if (wheelMode !== 'pan') {
600 +			// modifier released mid-burst: drop the remaining momentum
601 +			// instead of letting it leak into pan.
602 +			touchWheelGesture();
603 +			return;
604  		}
605  		touchWheelGesture();
606  

610  			const worldAtCursorX = camX + mx / zoom;
611  			const worldAtCursorY = camY + my / zoom;
612  			// exponential zoom on the float accumulator so small pinches add up
    -			const factor = Math.exp(-e.deltaY * 0.02);
613 +			const factor = Math.exp(-e.deltaY * 0.04);
614  			zoomF = clamp(zoomF * factor, MIN_ZOOM, MAX_ZOOM);
    -			const newZoom = Math.round(zoomF);
615 +			// step as soon as zoomF moves meaningfully past the current
616 +			// integer, rather than waiting until it crosses x.5. without this
617 +			// the first step of a burst feels sluggish because zoomF sits
618 +			// exactly on an integer after the previous snap.
619 +			let newZoom = zoom;
620 +			if (zoomF > zoom + 0.15) newZoom = Math.min(MAX_ZOOM, zoom + 1);
621 +			else if (zoomF < zoom - 0.15) newZoom = Math.max(MIN_ZOOM, zoom - 1);
622  			if (newZoom !== zoom) {
623  				zoom = newZoom;
624 +				zoomF = zoom; // resync so next step needs the same delta
625  				camX = worldAtCursorX - mx / zoom;
626  				camY = worldAtCursorY - my / zoom;
627  			}

638  		}
639  
640  		if (wheelMode === 'brush') {
    -			// inverted: scroll up = bigger, scroll down = smaller
641 +			// scroll up = smaller, scroll down = bigger. accumulate deltaY
642 +			// across events so a faster flick emits more single-pixel steps —
643 +			// one step per threshold of accumulated scroll, always ±1 at a
644 +			// time so precise nudges stay precise.
645  			const d = e.deltaY !== 0 ? e.deltaY : e.deltaX;
    -			const dir = d < 0 ? -1 : 1;
    -			brush = clamp(brush + dir, 1, 256);
646 +			if (Math.sign(d) !== Math.sign(brushAccum)) brushAccum = 0;
647 +			brushAccum += d;
648 +			const THRESH = 8;
649 +			while (brushAccum <= -THRESH) { brush = clamp(brush - 1, 1, 256); brushAccum += THRESH; }
650 +			while (brushAccum >= THRESH) { brush = clamp(brush + 1, 1, 256); brushAccum -= THRESH; }
651  			requestDraw();
652  			return;
653  		}