add touch support for mobile and tablet
Hunter
Friday, July 17th, 2026 at 11:59 AM
99 function setFrame(i) {
100 frameIdx = ((i % frames.length) + frames.length) % frames.length;
101 chunks = frames[frameIdx];
102 + // a held pick tracks the frame under it as frames change
103 + if (picking) pickAt(curX, curY);
104 requestDraw();
105 }
106
156 // the OS does not emit mousemove events when only the camera moves.
157 let curClientX = null, curClientY = null;
158 let mouseInside = true;
159 + // brush preview fades in when it appears and out after the last touch lifts
160 + let cursorAlpha = 1;
161 + let cursorFadeFrom = 1;
162 + let cursorFadeTo = 1;
163 + let cursorFadeStart = 0;
164 + const CURSOR_FADE_IN_MS = 100;
165 + const CURSOR_FADE_OUT_MS = 400;
166 + function setCursorFadeTarget(target) {
167 + if (target === cursorFadeTo) return;
168 + cursorFadeFrom = cursorAlpha;
169 + cursorFadeTo = target;
170 + cursorFadeStart = performance.now();
171 + requestDraw();
172 + }
173 + function showCursor() {
174 + mouseInside = true;
175 + setCursorFadeTarget(1);
176 + }
177 + function hideCursorNow() {
178 + mouseInside = false;
179 + cursorAlpha = 0;
180 + cursorFadeFrom = 0;
181 + cursorFadeTo = 0;
182 + }
183
184 let r = 255, g = 255, b = 255;
185 let brush = 1;
484 vctx.restore();
485 }
486
487 + if (cursorAlpha !== cursorFadeTo) {
488 + const dur = cursorFadeTo > cursorFadeFrom ? CURSOR_FADE_IN_MS : CURSOR_FADE_OUT_MS;
489 + const t = (now - cursorFadeStart) / dur;
490 + if (t >= 1) {
491 + cursorAlpha = cursorFadeTo;
492 + if (cursorFadeTo === 0) mouseInside = false;
493 + } else {
494 + const e = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
495 + cursorAlpha = cursorFadeFrom + (cursorFadeTo - cursorFadeFrom) * e;
496 + requestDraw();
497 + }
498 + }
499 if (mouseInside) {
500 const tl = brushTopLeft(curX, curY);
501 // round to integer device pixels - camX/camY are fractional (smooth
517 }
518 const n = brush;
519
520 + vctx.save();
521 + vctx.globalAlpha = cursorAlpha;
522 vctx.fillStyle = 'rgb(' + dispR + ',' + dispG + ',' + dispB + ')';
523 if (roundness === 0) {
524 vctx.fillRect(sx, sy, n * pxD, n * pxD);
548 vctx.save();
549 if (L > 0) {
550 vctx.globalCompositeOperation = 'multiply';
- vctx.globalAlpha = L;
551 + vctx.globalAlpha = L * cursorAlpha;
552 vctx.fillStyle = 'rgb(128,128,128)';
553 drawOutline();
554 }
555 if (L < 1) {
556 vctx.globalCompositeOperation = 'screen';
- vctx.globalAlpha = 1 - L;
557 + vctx.globalAlpha = (1 - L) * cursorAlpha;
558 vctx.fillStyle = 'rgb(128,128,128)';
559 drawOutline();
560 }
561 vctx.restore();
562 + vctx.restore();
563 }
564 }
565
792 window.addEventListener('resize', resize);
793
794 window.addEventListener('mouseover', (e) => {
- mouseInside = true;
795 + showCursor();
796 curClientX = e.clientX;
797 curClientY = e.clientY;
798 const p = clientToWorld(e.clientX, e.clientY);
801 requestDraw();
802 });
803
- window.addEventListener('mousemove', (e) => {
- mouseInside = true;
- curClientX = e.clientX;
- curClientY = e.clientY;
- const p = clientToWorld(e.clientX, e.clientY);
- const nx = p.x, ny = p.y;
-
- // modifier-drag: shift/cmd/RGB held -> vertical motion from the anchor
- // drives brush size / roundness / color channels. drag up (dy negative)
- // -> increase. when a value hits its bound and the drag continues past,
- // re-anchor so reversing direction responds immediately. each drag is
- // independent so any combination can run at once.
804 + // modifier-drag: shift/cmd/RGB held -> vertical motion from the anchor
805 + // drives brush size / roundness / color channels. drag up (dy negative)
806 + // -> increase. when a value hits its bound and the drag continues past,
807 + // re-anchor so reversing direction responds immediately. each drag is
808 + // independent so any combination can run at once.
809 + function applyDrags(clientY) {
810 if (dragBrush) {
- const dyPx = (dragBrush.anchorY - e.clientY) * dpr;
811 + const dyPx = (dragBrush.anchorY - clientY) * dpr;
812 const target = dragBrush.start + Math.trunc(dyPx / BRUSH_DRAG_PX_PER_STEP);
813 const clamped = clamp(target, 1, 256);
814 brush = clamped;
815 if (target !== clamped) {
816 dragBrush.start = clamped;
- dragBrush.anchorY = e.clientY;
817 + dragBrush.anchorY = clientY;
818 }
819 }
820 if (dragRoundness) {
- const dyPx = (dragRoundness.anchorY - e.clientY) * dpr;
821 + const dyPx = (dragRoundness.anchorY - clientY) * dpr;
822 const target = dragRoundness.start - dyPx / ROUNDNESS_DRAG_FULL_PX;
823 const clamped = clamp(target, 0, 1);
824 roundness = clamped;
825 if (target !== clamped) {
826 dragRoundness.start = clamped;
- dragRoundness.anchorY = e.clientY;
827 + dragRoundness.anchorY = clientY;
828 }
829 }
830 if (dragColor) {
- const dyPx = (dragColor.anchorY - e.clientY) * dpr;
831 + const dyPx = (dragColor.anchorY - clientY) * dpr;
832 const delta = (dyPx / COLOR_DRAG_FULL_PX) * 255;
833 let nr = r, ng = g, nb = b;
834 let overshoot = 0;
850 setColor(Math.round(nr), Math.round(ng), Math.round(nb));
851 if (overshoot > 0) {
852 dragColor.r0 = nr; dragColor.g0 = ng; dragColor.b0 = nb;
- dragColor.anchorY = e.clientY;
853 + dragColor.anchorY = clientY;
854 }
855 }
856 + }
857 +
858 + window.addEventListener('mousemove', (e) => {
859 + showCursor();
860 + curClientX = e.clientX;
861 + curClientY = e.clientY;
862 + const p = clientToWorld(e.clientX, e.clientY);
863 + const nx = p.x, ny = p.y;
864 +
865 + applyDrags(e.clientY);
866
867 if (painting) {
868 if (lastPaintX !== null) {
881 });
882
883 window.addEventListener('mouseleave', () => {
- mouseInside = false;
884 + hideCursorNow();
885 requestDraw();
886 });
887
910 }
911 });
912
913 + // on hoverless screens, the finger is the only pointer, so while a modifier
914 + // is held the finger adjusts instead of marking.
915 + const hoverMQ = window.matchMedia ? matchMedia('(any-hover: hover)') : null;
916 + let sawHoverPointer = false;
917 + function hoverCapable() { return sawHoverPointer || (hoverMQ !== null && hoverMQ.matches); }
918 + window.addEventListener('pointermove', (e) => {
919 + if (e.pointerType === 'mouse' || (e.pointerType === 'pen' && e.buttons === 0)) sawHoverPointer = true;
920 + });
921 +
922 // touch: one finger paints, two fingers pan/pinch-zoom. once a second
923 // finger lands the whole touch becomes a gesture (no painting) until all
924 // fingers lift, so a stray finger during a pan never leaves a mark.
- let touchMode = null; // 'paint' | 'gesture'
925 + let touchMode = null; // 'paint' | 'adjust' | 'gesture'
926 let touchPainted = false;
927 let pinchDist = 0, pinchMidX = 0, pinchMidY = 0;
928
929 + // inertial pan: centroid velocity (css px/ms) tracked during the gesture,
930 + // thrown on release and decayed with exponential friction
931 + let panVX = 0, panVY = 0;
932 + let panLastT = 0;
933 + let inertiaRAF = null;
934 + function stopInertia() {
935 + if (inertiaRAF !== null) {
936 + cancelAnimationFrame(inertiaRAF);
937 + inertiaRAF = null;
938 + }
939 + }
940 + function startInertia() {
941 + // no throw if the finger paused before lifting or was barely moving
942 + const speed = Math.hypot(panVX, panVY);
943 + if (performance.now() - panLastT > 100 || speed < 0.05) return;
944 + // exponential decay per ms
945 + const DECEL = 0.995;
946 + const MAX_SPEED = 3; // css px/ms
947 + if (speed > MAX_SPEED) {
948 + panVX *= MAX_SPEED / speed;
949 + panVY *= MAX_SPEED / speed;
950 + }
951 + let prev = performance.now();
952 + const step = (now) => {
953 + const dt = now - prev;
954 + prev = now;
955 + camX -= panVX * dt / zoom;
956 + camY -= panVY * dt / zoom;
957 + const f = Math.pow(DECEL, dt);
958 + panVX *= f;
959 + panVY *= f;
960 + requestDraw();
961 + inertiaRAF = Math.hypot(panVX, panVY) > 0.02 ? requestAnimationFrame(step) : null;
962 + };
963 + inertiaRAF = requestAnimationFrame(step);
964 + }
965 +
966 function touchCentroid(touches) {
967 let x = 0, y = 0;
968 for (const t of touches) { x += t.clientX; y += t.clientY; }
969 return { x: x / touches.length, y: y / touches.length };
970 }
971
972 + // re-anchor active drags to a new y and re-baseline to the current values
973 + // so successive drag strokes accumulate instead of snapping back
974 + function anchorDrags(clientY) {
975 + if (dragBrush) { dragBrush.anchorY = clientY; dragBrush.start = brush; }
976 + if (dragRoundness) { dragRoundness.anchorY = clientY; dragRoundness.start = roundness; }
977 + if (dragColor) { dragColor.anchorY = clientY; dragColor.r0 = r; dragColor.g0 = g; dragColor.b0 = b; }
978 + }
979 +
980 function anchorGesture(touches) {
981 const m = touchCentroid(touches);
982 pinchMidX = m.x;
988
989 window.addEventListener('touchstart', (e) => {
990 e.preventDefault();
991 + stopInertia();
992 if (e.touches.length === 1 && touchMode === null) {
993 const t = e.touches[0];
994 curClientX = t.clientX;
995 curClientY = t.clientY;
- const p = clientToWorld(t.clientX, t.clientY);
- curX = p.x;
- curY = p.y;
- mouseInside = true;
- touchMode = 'paint';
- painting = true;
- touchPainted = false;
- lastPaintX = curX;
- lastPaintY = curY;
996 + if (!keys['c'] && !hoverCapable() && (dragBrush || dragRoundness || dragColor)) {
997 + touchMode = 'adjust';
998 + const p = clientToWorld(t.clientX, t.clientY);
999 + curX = p.x;
1000 + curY = p.y;
1001 + showCursor();
1002 + anchorDrags(t.clientY);
1003 + } else {
1004 + const p = clientToWorld(t.clientX, t.clientY);
1005 + curX = p.x;
1006 + curY = p.y;
1007 + showCursor();
1008 + touchMode = 'paint';
1009 + touchPainted = false;
1010 + if (keys['c']) {
1011 + picking = true;
1012 + } else {
1013 + painting = true;
1014 + lastPaintX = curX;
1015 + lastPaintY = curY;
1016 + }
1017 + }
1018 } else {
1019 touchMode = 'gesture';
1020 painting = false;
1021 + picking = false;
1022 lastPaintX = null;
1023 lastPaintY = null;
- mouseInside = false;
1024 + hideCursorNow();
1025 + panVX = 0;
1026 + panVY = 0;
1027 + panLastT = 0;
1028 anchorGesture(e.touches);
1029 }
1030 requestDraw();
1036 const t = e.touches[0];
1037 curClientX = t.clientX;
1038 curClientY = t.clientY;
1039 + // on hover devices held keys adjust during the stroke, like mouse
1040 + applyDrags(t.clientY);
1041 const p = clientToWorld(t.clientX, t.clientY);
- paintLine(lastPaintX, lastPaintY, p.x, p.y);
1042 + if (picking) {
1043 + pickAt(p.x, p.y);
1044 + } else if (painting) {
1045 + paintLine(lastPaintX, lastPaintY, p.x, p.y);
1046 + lastPaintX = p.x;
1047 + lastPaintY = p.y;
1048 + }
1049 touchPainted = true;
- lastPaintX = p.x;
- lastPaintY = p.y;
1050 + curX = p.x;
1051 + curY = p.y;
1052 + } else if (touchMode === 'adjust') {
1053 + const t = e.touches[0];
1054 + curClientX = t.clientX;
1055 + curClientY = t.clientY;
1056 + applyDrags(t.clientY);
1057 + const p = clientToWorld(t.clientX, t.clientY);
1058 curX = p.x;
1059 curY = p.y;
1060 } else if (touchMode === 'gesture') {
1061 const m = touchCentroid(e.touches);
- camX -= (m.x - pinchMidX) / zoom;
- camY -= (m.y - pinchMidY) / zoom;
1062 + const dxs = m.x - pinchMidX;
1063 + const dys = m.y - pinchMidY;
1064 + camX -= dxs / zoom;
1065 + camY -= dys / zoom;
1066 + const nowT = performance.now();
1067 + const dt = nowT - panLastT;
1068 + if (dt > 0 && dt < 100) {
1069 + panVX = panVX * 0.5 + (dxs / dt) * 0.5;
1070 + panVY = panVY * 0.5 + (dys / dt) * 0.5;
1071 + }
1072 + panLastT = nowT;
1073 if (e.touches.length >= 2) {
1074 const dist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
1075 if (pinchDist > 0) applyZoom(dist / pinchDist, m.x, m.y);
1086 function touchEnd(e) {
1087 e.preventDefault();
1088 if (e.touches.length === 0) {
- // a tap that never moved still paints its dot
- if (touchMode === 'paint' && !touchPainted) paintAt(curX, curY);
1089 + // a tap that never moved still paints its dot (or picks its point)
1090 + if (touchMode === 'paint' && !touchPainted) {
1091 + if (picking) pickAt(curX, curY);
1092 + else paintAt(curX, curY);
1093 + }
1094 + if (touchMode === 'gesture') startInertia();
1095 touchMode = null;
1096 painting = false;
1097 + picking = false;
1098 lastPaintX = null;
1099 lastPaintY = null;
- mouseInside = false;
1100 + // fade the preview out instead of hiding it instantly
1101 + if (mouseInside) setCursorFadeTarget(0);
1102 requestDraw();
1103 } else if (touchMode === 'gesture') {
1104 // re-anchor to the remaining fingers so the camera doesn't jump
1113 if (mode === 'brush' && !dragBrush) dragBrush = { anchorY, start: brush };
1114 else if (mode === 'roundness' && !dragRoundness) dragRoundness = { anchorY, start: roundness };
1115 else if (mode === 'color' && !dragColor) dragColor = { anchorY, r0: r, g0: g, b0: b };
1116 + // hoverless screen: a modifier pressed mid-stroke ends the stroke and
1117 + // converts the touch into an adjust drag for the rest of the touch
1118 + if (touchMode === 'paint' && !hoverCapable()) {
1119 + touchMode = 'adjust';
1120 + painting = false;
1121 + picking = false;
1122 + lastPaintX = null;
1123 + lastPaintY = null;
1124 + }
1125 }
1126 function endDragMode(mode) {
1127 if (mode === 'brush') dragBrush = null;
1162 if (k === 'x') startDragMode('roundness');
1163 else if (k === 'z') startDragMode('brush');
1164 else if (!wasDown && (k === 'r' || k === 'g' || k === 'b')) startDragMode('color');
1165 + else if (k === 'c' && !wasDown && (touchMode === 'paint' || touchMode === 'adjust')) {
1166 + touchMode = 'paint';
1167 + painting = false;
1168 + picking = true;
1169 + lastPaintX = null;
1170 + lastPaintY = null;
1171 + touchPainted = true;
1172 + pickAt(curX, curY);
1173 + requestDraw();
1174 + }
1175 }
1176 });
1177 window.addEventListener('keyup', (e) => {
1188 dragColor.r0 = r; dragColor.g0 = g; dragColor.b0 = b;
1189 }
1190 }
- // releasing c mid-pick-drag -> seamlessly switch to painting
1191 + // releasing c mid-pick-drag -> back to adjusting if a modifier is still
1192 + // held on a hoverless screen, else seamlessly switch to painting
1193 if (k === 'c' && picking) {
1194 picking = false;
- painting = true;
- lastPaintX = curX;
- lastPaintY = curY;
- paintAt(curX, curY);
1195 + if (touchMode !== null && !hoverCapable() && (dragBrush || dragRoundness || dragColor)) {
1196 + touchMode = 'adjust';
1197 + if (curClientY !== null) anchorDrags(curClientY);
1198 + } else {
1199 + painting = true;
1200 + lastPaintX = curX;
1201 + lastPaintY = curY;
1202 + paintAt(curX, curY);
1203 + }
1204 requestDraw();
1205 }
1206 });
1271
1272 window.addEventListener('wheel', (e) => {
1273 e.preventDefault();
1274 + stopInertia();
1275
1276 // re-pick mode each event from live modifiers so releasing cmd and
1277 // immediately starting a shift scroll switches over cleanly. if a