commit beede1f90bfc8121def93df0f1be1de196361486
parent 3e5b177d11d8c08430c0397ce08bb351e79c0d41
Author: Hunter
Date:   Wed, 15 Apr 2026 19:03:11 -0400

fix wheel modifier handling and tune zoom/brush sensitivity

Diffstat:
Mindex.html | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
1 file changed, 56 insertions(+), 18 deletions(-)

diff --git a/index.html b/index.html @@ -531,9 +531,13 @@ window.addEventListener('keydown', (e) => { keys[e.key.toLowerCase()] = true; + if (e.key === 'Meta') metaSeq = ++seqCounter; + else if (e.key === 'Shift') shiftSeq = ++seqCounter; }); window.addEventListener('keyup', (e) => { keys[e.key.toLowerCase()] = false; + if (e.key === 'Meta') metaSeq = 0; + else if (e.key === 'Shift') shiftSeq = 0; // releasing shift mid-pick-drag -> seamlessly switch to painting if (e.key === 'Shift' && picking) { picking = false; @@ -546,6 +550,7 @@ }); window.addEventListener('blur', () => { for (const k in keys) keys[k] = false; + metaSeq = 0; shiftSeq = 0; painting = false; picking = false; lastPaintX = null; @@ -554,28 +559,48 @@ function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } - // 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. + // wheel gesture lock: once a wheel gesture starts in a given mode, + // subsequent events in the same burst stay in that mode until the wheel + // goes idle or the controlling modifier set changes. this keeps trackpad + // momentum from leaking into pan after a modifier release, while still + // letting the user swap between modifier-driven modes (shift <-> cmd) + // mid-flick without stale state. between shift and cmd, the one pressed + // most recently wins so they never apply simultaneously. let wheelMode = null; let wheelIdleTimer = null; + let brushAccum = 0; + let metaSeq = 0, shiftSeq = 0, seqCounter = 0; function touchWheelGesture() { if (wheelIdleTimer) clearTimeout(wheelIdleTimer); - wheelIdleTimer = setTimeout(() => { wheelMode = null; }, 150); + wheelIdleTimer = setTimeout(() => { wheelMode = null; brushAccum = 0; }, 150); + } + function pickModifierMode(e) { + if (e.ctrlKey) return 'zoom'; + const meta = e.metaKey, shift = e.shiftKey; + if (meta && shift) return (metaSeq >= shiftSeq) ? 'roundness' : 'brush'; + if (meta) return 'roundness'; + if (shift) return 'brush'; + if (keys['r'] || keys['g'] || keys['b']) return 'color'; + return null; } window.addEventListener('wheel', (e) => { e.preventDefault(); - // 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'; + // re-pick mode each event from live modifiers so releasing cmd and + // immediately starting a shift scroll switches over cleanly. if a + // burst started with a modifier and the modifier is then released + // mid-flick, suppress rather than leaking into pan. + const modMode = pickModifierMode(e); + if (modMode !== null) { + wheelMode = modMode; + } else if (wheelMode === null) { + wheelMode = 'pan'; + } else if (wheelMode !== 'pan') { + // modifier released mid-burst: drop the remaining momentum + // instead of letting it leak into pan. + touchWheelGesture(); + return; } touchWheelGesture(); @@ -585,11 +610,18 @@ const worldAtCursorX = camX + mx / zoom; const worldAtCursorY = camY + my / zoom; // exponential zoom on the float accumulator so small pinches add up - const factor = Math.exp(-e.deltaY * 0.02); + const factor = Math.exp(-e.deltaY * 0.04); zoomF = clamp(zoomF * factor, MIN_ZOOM, MAX_ZOOM); - const newZoom = Math.round(zoomF); + // step as soon as zoomF moves meaningfully past the current + // integer, rather than waiting until it crosses x.5. without this + // the first step of a burst feels sluggish because zoomF sits + // exactly on an integer after the previous snap. + let newZoom = zoom; + if (zoomF > zoom + 0.15) newZoom = Math.min(MAX_ZOOM, zoom + 1); + else if (zoomF < zoom - 0.15) newZoom = Math.max(MIN_ZOOM, zoom - 1); if (newZoom !== zoom) { zoom = newZoom; + zoomF = zoom; // resync so next step needs the same delta camX = worldAtCursorX - mx / zoom; camY = worldAtCursorY - my / zoom; } @@ -606,10 +638,16 @@ } if (wheelMode === 'brush') { - // inverted: scroll up = bigger, scroll down = smaller + // scroll up = smaller, scroll down = bigger. accumulate deltaY + // across events so a faster flick emits more single-pixel steps — + // one step per threshold of accumulated scroll, always ±1 at a + // time so precise nudges stay precise. const d = e.deltaY !== 0 ? e.deltaY : e.deltaX; - const dir = d < 0 ? -1 : 1; - brush = clamp(brush + dir, 1, 256); + if (Math.sign(d) !== Math.sign(brushAccum)) brushAccum = 0; + brushAccum += d; + const THRESH = 8; + while (brushAccum <= -THRESH) { brush = clamp(brush - 1, 1, 256); brushAccum += THRESH; } + while (brushAccum >= THRESH) { brush = clamp(brush + 1, 1, 256); brushAccum -= THRESH; } requestDraw(); return; }