add touch support for mobile and tablet

Hunter
Friday, July 17th, 2026 at 11:59 AM

index.html+218-46

index.html
  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