replace modifier+scroll with modifier+vertical drag for brush, roundness, rgb

Hunter
Friday, April 17th, 2026 at 9:45 AM

index.html+96-49
readme.md+3-3

index.html
504  		curClientY = e.clientY;
505  		const p = clientToWorld(e.clientX, e.clientY);
506  		const nx = p.x, ny = p.y;
507 +
508 +		// modifier-drag: shift/cmd/RGB held -> vertical motion from the anchor
509 +		// drives brush size / roundness / color channels. drag up (dy negative)
510 +		// -> increase. when a value hits its bound and the drag continues past,
511 +		// re-anchor so reversing direction responds immediately.
512 +		if (dragMode !== null) {
513 +			const dyPx = (dragAnchorY - e.clientY) * dpr;
514 +			if (dragMode === 'brush') {
515 +				const targetSteps = Math.trunc(dyPx / BRUSH_DRAG_PX_PER_STEP);
516 +				const target = dragBrushStart + targetSteps;
517 +				const clamped = clamp(target, 1, 256);
518 +				brush = clamped;
519 +				if (target !== clamped) {
520 +					dragBrushStart = clamped;
521 +					dragAnchorY = e.clientY;
522 +				}
523 +			} else if (dragMode === 'roundness') {
524 +				const target = dragRoundnessStart + dyPx / ROUNDNESS_DRAG_FULL_PX;
525 +				const clamped = clamp(target, 0, 1);
526 +				roundness = clamped;
527 +				if (target !== clamped) {
528 +					dragRoundnessStart = clamped;
529 +					dragAnchorY = e.clientY;
530 +				}
531 +			} else if (dragMode === 'color') {
532 +				const delta = (dyPx / COLOR_DRAG_FULL_PX) * 255;
533 +				let nr = r, ng = g, nb = b;
534 +				let overshoot = 0;
535 +				if (keys['r']) {
536 +					const t = dragRStart + delta;
537 +					nr = clamp(t, 0, 255);
538 +					if (t !== nr) overshoot = Math.max(overshoot, Math.abs(t - nr));
539 +				}
540 +				if (keys['g']) {
541 +					const t = dragGStart + delta;
542 +					ng = clamp(t, 0, 255);
543 +					if (t !== ng) overshoot = Math.max(overshoot, Math.abs(t - ng));
544 +				}
545 +				if (keys['b']) {
546 +					const t = dragBStart + delta;
547 +					nb = clamp(t, 0, 255);
548 +					if (t !== nb) overshoot = Math.max(overshoot, Math.abs(t - nb));
549 +				}
550 +				setColor(Math.round(nr), Math.round(ng), Math.round(nb));
551 +				if (overshoot > 0) {
552 +					dragRStart = nr; dragGStart = ng; dragBStart = nb;
553 +					dragAnchorY = e.clientY;
554 +				}
555 +			}
556 +		}
557 +
558  		if (painting) {
559  			if (lastPaintX !== null) {
560  				paintLine(lastPaintX, lastPaintY, nx, ny);

602  		}
603  	});
604  
605 +	function startDragMode(mode) {
606 +		if (dragMode === mode) return;
607 +		dragMode = mode;
608 +		dragAnchorY = curClientY !== null ? curClientY : 0;
609 +		dragBrushStart = brush;
610 +		dragRoundnessStart = roundness;
611 +		dragRStart = r; dragGStart = g; dragBStart = b;
612 +	}
613 +	function endDragMode(mode) {
614 +		if (dragMode !== mode) return;
615 +		dragMode = null;
616 +	}
617 +
618  	window.addEventListener('keydown', (e) => {
    -		keys[e.key.toLowerCase()] = true;
    -		if (e.key === 'Meta') metaSeq = ++seqCounter;
    -		else if (e.key === 'Shift') shiftSeq = ++seqCounter;
619 +		const k = e.key.toLowerCase();
620 +		const wasDown = keys[k];
621 +		keys[k] = true;
622 +		if (e.key === 'Meta') { metaSeq = ++seqCounter; startDragMode('roundness'); }
623 +		else if (e.key === 'Shift') { shiftSeq = ++seqCounter; startDragMode('brush'); }
624 +		else if (!wasDown && (k === 'r' || k === 'g' || k === 'b')) startDragMode('color');
625  	});
626  	window.addEventListener('keyup', (e) => {
    -		keys[e.key.toLowerCase()] = false;
    -		if (e.key === 'Meta') metaSeq = 0;
    -		else if (e.key === 'Shift') shiftSeq = 0;
627 +		const k = e.key.toLowerCase();
628 +		keys[k] = false;
629 +		if (e.key === 'Meta') { metaSeq = 0; endDragMode('roundness'); }
630 +		else if (e.key === 'Shift') { shiftSeq = 0; endDragMode('brush'); }
631 +		else if (k === 'r' || k === 'g' || k === 'b') {
632 +			if (!keys['r'] && !keys['g'] && !keys['b']) endDragMode('color');
633 +			else {
634 +				// still holding at least one rgb key — re-anchor from current
635 +				// values so the remaining keys don't jump based on released key's history
636 +				dragAnchorY = curClientY !== null ? curClientY : dragAnchorY;
637 +				dragRStart = r; dragGStart = g; dragBStart = b;
638 +			}
639 +		}
640  		// releasing shift mid-pick-drag -> seamlessly switch to painting
641  		if (e.key === 'Shift' && picking) {
642  			picking = false;

668  	let wheelMode = null;
669  	let wheelIdleTimer = null;
670  	let panning = false;
    -	let brushAccum = 0;
671  	let metaSeq = 0, shiftSeq = 0, seqCounter = 0;
672 +
673 +	// modifier-drag: while shift/cmd is held, vertical pointer motion from the
674 +	// anchor point modulates brush size or roundness. anchor is the client y
675 +	// where the modifier first went down; snapshot is the value at that moment.
676 +	// drag up (y decreases) => increase, drag down => decrease.
677 +	const BRUSH_DRAG_PX_PER_STEP = 4;		// device px of drag per ±1 brush px
678 +	const ROUNDNESS_DRAG_FULL_PX = 200;		// device px to traverse 0→1
679 +	const COLOR_DRAG_FULL_PX = 256;			// device px to traverse 0→255
680 +	let dragMode = null;		// 'brush' | 'roundness' | 'color' | null
681 +	let dragAnchorY = 0;
682 +	let dragBrushStart = 1;
683 +	let dragRoundnessStart = 0;
684 +	let dragRStart = 0, dragGStart = 0, dragBStart = 0;
685  	function touchWheelGesture() {
686  		if (wheelIdleTimer) clearTimeout(wheelIdleTimer);
687  		wheelIdleTimer = setTimeout(() => {
688  			wheelMode = null;
    -			brushAccum = 0;
689  			if (panning) {
690  				// pan ended — resync the logical-pixel cursor from the real
691  				// client position so the brush snaps onto its final cell.

701  	}
702  	function pickModifierMode(e) {
703  		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';
704  		return null;
705  	}
706  

749  			return;
750  		}
751  
    -		if (wheelMode === 'roundness') {
    -			// scroll up = squarer, scroll down = rounder
    -			const d = e.deltaY !== 0 ? e.deltaY : e.deltaX;
    -			roundness = clamp(roundness + d * 0.01, 0, 1);
    -			requestDraw();
    -			return;
    -		}
    -
    -		if (wheelMode === 'brush') {
    -			// 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;
    -			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;
    -		}
    -
    -		if (wheelMode === 'color') {
    -			// inverted: scroll up = less, scroll down = more
    -			const dir = e.deltaY < 0 ? -1 : 1;
    -			const step = 8 * dir;
    -			let nr = r, ng = g, nb = b;
    -			if (keys['r']) nr = clamp(r + step, 0, 255);
    -			if (keys['g']) ng = clamp(g + step, 0, 255);
    -			if (keys['b']) nb = clamp(b + step, 0, 255);
    -			setColor(nr, ng, nb);
    -			requestDraw();
    -			return;
    -		}
    -
752  		// pan — smooth fractional camera. during the pan gesture the draw
753  		// loop anchors the brush to the real client cursor position so it
754  		// tracks the pointer smoothly; curX/curY get resynced onto the
readme.md
 4  
 5   ## controls
 6   - click and drag to make marks
   - - hold `shift` and scroll to resize the brush
   - - hold `⌘` and scroll to change the shape of the brush
 7 + - hold `shift` and move up/down to resize the brush
 8 + - hold `⌘` and move up/down to change the shape of the brush
 9   - hold any of the ${{\color{Red}{\textsf{R}}}}\$, ${{\color{Green}{\textsf{G}}}}\$, and/or ${{\color{CornflowerBlue}{\textsf{B}}}}\$
   - keys and scroll to change the ${{\color{Red}{\textsf{Redness}}}}\$, ${{\color{Green}{\textsf{Greenness}}}}\$, and/or ${{\color{CornflowerBlue}{\textsf{Blueness}}}}\$ of the active color
10 + keys and move up/down to change the ${{\color{Red}{\textsf{Redness}}}}\$, ${{\color{Green}{\textsf{Greenness}}}}\$, and/or ${{\color{CornflowerBlue}{\textsf{Blueness}}}}\$ of the active color
11   - hold `shift` and click anywhere to pick up the color underneath the brush
12    	- you can also hold `shift` then click and drag to scrub for colors
13   - pinch with two fingers to zoom in or out