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:
| M | index.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;
}