replace modifier+scroll with modifier+vertical drag for brush, roundness, rgb
Hunter
Friday, April 17th, 2026 at 9:45 AM
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
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