fix wheel modifier handling and tune zoom/brush sensitivity
Hunter
Wednesday, April 15th, 2026 at 7:03 PM
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 }