tune brush and zoom gesture sensitivity
Hunter
Wednesday, July 22nd, 2026 at 11:46 PM
144 let dpr = window.devicePixelRatio || 1;
145 let cssW = 0, cssH = 0;
146
- // zoomF accumulates fractional pinch input; zoom is the snapped integer used for rendering.
- let zoomF = 2;
- let zoom = 2;
- const MIN_ZOOM = 1, MAX_ZOOM = 64;
147 + const ZOOM_STOPS = [2, 3, 4, 5, 6, 8, 11, 15, 21, 30];
148 + const MIN_ZOOM = ZOOM_STOPS[0];
149 + const ZOOM_SENSITIVITY = 3.5; // fractional levels per unit log(pinch factor)
150 + let zoomIdx = 0;
151 + let zoom = MIN_ZOOM;
152
153 let camX = 0, camY = 0;
154
807 requestDraw();
808 });
809
810 + // brush resize curve: linear (fine, precise) up to BRUSH_LINEAR_MAX, then a
811 + // power-law ramp beyond, tuned by BRUSH_ACCEL.
812 + function brushFromDrag(start, dyPx) {
813 + const A = BRUSH_DRAG_PX_PER_STEP, T = BRUSH_LINEAR_MAX, c = BRUSH_ACCEL;
814 + const seam = T * A; // drag distance from size 0 to the seam
815 + const brushToPos = (b) => b <= T ? b * A : seam + (seam / c) * (Math.pow(b / T, c) - 1);
816 + const posToBrush = (p) => p <= seam ? p / A : T * Math.pow(1 + c * (p - seam) / seam, 1 / c);
817 + return posToBrush(brushToPos(start) + dyPx);
818 + }
819 +
820 // modifier-drag: shift/cmd/RGB held -> vertical motion from the anchor
821 // drives brush size / roundness / color channels. drag up (dy negative)
822 // -> increase. when a value hits its bound and the drag continues past,
825 function applyDrags(clientY) {
826 if (dragBrush) {
827 const dyPx = (dragBrush.anchorY - clientY) * dpr;
- const target = dragBrush.start + Math.trunc(dyPx / BRUSH_DRAG_PX_PER_STEP);
- const clamped = clamp(target, 1, 256);
828 + const target = brushFromDrag(dragBrush.start, dyPx);
829 + const clamped = clamp(Math.round(target), 1, MAX_BRUSH);
830 brush = clamped;
- if (target !== clamped) {
831 + if (target < 1 || target > MAX_BRUSH) {
832 dragBrush.start = clamped;
833 dragBrush.anchorY = clientY;
834 }
1196 fileInput.click();
1197 return;
1198 }
1199 + if ((e.metaKey || e.ctrlKey) && !e.altKey) {
1200 + if (e.key === '=' || e.key === '+') { e.preventDefault(); stepZoom(1); return; }
1201 + if (e.key === '-' || e.key === '_') { e.preventDefault(); stepZoom(-1); return; }
1202 + if (e.key === '0') { e.preventDefault(); resetZoom(); return; }
1203 + }
1204 if (!e.metaKey && !e.ctrlKey && !e.altKey && !e.repeat) {
1205 const lk = e.key.toLowerCase();
1206 if (e.key === 'ArrowLeft') { e.preventDefault(); tapArrow(-1); return; }
1282 function applyZoom(factor, ax, ay) {
1283 const worldAtAnchorX = camX + ax / zoom;
1284 const worldAtAnchorY = camY + ay / zoom;
- zoomF = clamp(zoomF * factor, MIN_ZOOM, MAX_ZOOM);
- const STEP_RATIO = 1.2;
- let newZoom = zoom;
- if (zoomF > zoom * STEP_RATIO) newZoom = Math.min(MAX_ZOOM, zoom + 1);
- else if (zoomF < zoom / STEP_RATIO) newZoom = Math.max(MIN_ZOOM, zoom - 1);
1285 + zoomIdx = clamp(zoomIdx + Math.log(factor) * ZOOM_SENSITIVITY, 0, ZOOM_STOPS.length - 1);
1286 + const newZoom = ZOOM_STOPS[Math.round(zoomIdx)];
1287 if (newZoom !== zoom) {
1288 zoom = newZoom;
- zoomF = zoom; // resync so next step needs the same delta
1289 camX = worldAtAnchorX - ax / zoom;
1290 camY = worldAtAnchorY - ay / zoom;
1291 }
1292 }
1293
1294 + function zoomTo(newZoom) {
1295 + if (newZoom === zoom) return;
1296 + const ax = cssW / 2, ay = cssH / 2;
1297 + const worldAtAnchorX = camX + ax / zoom;
1298 + const worldAtAnchorY = camY + ay / zoom;
1299 + zoom = newZoom;
1300 + zoomIdx = ZOOM_STOPS.indexOf(zoom);
1301 + camX = worldAtAnchorX - ax / zoom;
1302 + camY = worldAtAnchorY - ay / zoom;
1303 + requestDraw();
1304 + }
1305 +
1306 + function stepZoom(dir) {
1307 + const i = ZOOM_STOPS.indexOf(zoom);
1308 + zoomTo(ZOOM_STOPS[clamp(i + dir, 0, ZOOM_STOPS.length - 1)]);
1309 + }
1310 + function resetZoom() { zoomTo(MIN_ZOOM); }
1311 +
1312 // wheel gesture lock: once a wheel gesture starts in a given mode,
1313 // subsequent events in the same burst stay in that mode until the wheel
1314 // goes idle or the controlling modifier set changes. this keeps trackpad
1318 let wheelIdleTimer = null;
1319 let panning = false;
1320
- const BRUSH_DRAG_PX_PER_STEP = 4; // device px per +/- 1 brush px
- const ROUNDNESS_DRAG_FULL_PX = 200; // device px to traverse 0 to 1
- const COLOR_DRAG_FULL_PX = 256; // device px to traverse 0 to 255
1321 + const BRUSH_DRAG_PX_PER_STEP = 25; // device px per +/- 1 brush px (below the seam)
1322 + const BRUSH_LINEAR_MAX = 6; // brush px below which resize stays linear/fine
1323 + const BRUSH_ACCEL = 0.5;
1324 + const MAX_BRUSH = 150;
1325 + const ROUNDNESS_DRAG_FULL_PX = 200;
1326 + const COLOR_DRAG_FULL_PX = 256;
1327 // each drag carries its own anchor so any combination can run at once
1328 let dragBrush = null; // {anchorY, start}
1329 let dragRoundness = null; // {anchorY, start}
1373
1374 if (wheelMode === 'zoom') {
1375 // exponential zoom on the float accumulator so small pinches add up
- applyZoom(Math.exp(-e.deltaY * 0.04), e.clientX, e.clientY);
1376 + applyZoom(Math.exp(-e.deltaY * 0.02), e.clientX, e.clientY);
1377 requestDraw();
1378 return;
1379 }
25 - hold any of the , , and/or  keys and move your cursor up/down to change the , , and/or  of the active color
26 - hold any combination of the above to change size, shape, and/or color at the same time
27 - hold `C` and click anywhere to pick up the color underneath your brush
-- pinch with two fingers to zoom in/out
28 +- pinch with two fingers to zoom in / out (or use `⌘ +` / `⌘ -`)
29 - drag with two fingers to pan around the canvas
30
31 ### animation