iPad/Apple Pencil tweaks

Hunter
Wednesday, July 22nd, 2026 at 2:55 PM

index.html+83-35

index.html
 153  
 154  	let curX = 0, curY = 0;
 155  	// used to keep the brush pinned under the real cursor while panning, since
     -	// the OS does not emit mousemove events when only the camera moves.
 156 +	// the OS does not emit pointermove 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

 791  
 792  	window.addEventListener('resize', resize);
 793  
     -	window.addEventListener('mouseover', (e) => {
 794 +	// mouse and pen share the pointer-event path; finger touches are handled
 795 +	// by the touch-event path below
 796 +	function hoverPointer(e) { return e.pointerType === 'mouse' || e.pointerType === 'pen'; }
 797 +
 798 +	window.addEventListener('pointerover', (e) => {
 799 +		if (!hoverPointer(e)) return;
 800  		showCursor();
 801  		curClientX = e.clientX;
 802  		curClientY = e.clientY;

 860  		}
 861  	}
 862  
     -	window.addEventListener('mousemove', (e) => {
 863 +	window.addEventListener('pointermove', (e) => {
 864 +		if (!hoverPointer(e)) return;
 865 +		// a pen only proves it can hover by moving while lifted. without hover
 866 +		// it gets the finger treatment: modifiers adjust instead of marking
 867 +		if (e.pointerType === 'pen' && e.buttons === 0) penHover = true;
 868  		showCursor();
 869  		curClientX = e.clientX;
 870  		curClientY = e.clientY;

 889  		requestDraw();
 890  	});
 891  
     -	window.addEventListener('mouseleave', () => {
 892 +	window.addEventListener('pointerleave', (e) => {
 893 +		if (!hoverPointer(e)) return;
 894  		hideCursorNow();
 895  		requestDraw();
 896  	});
 897  
     -	window.addEventListener('mousedown', (e) => {
 898 +	// a pen without hover support sends no move events before contact, so the
 899 +	// down event must place the cursor itself
 900 +	let penDown = false;
 901 +	let penHover = false;
 902 +	window.addEventListener('pointerdown', (e) => {
 903 +		if (!hoverPointer(e)) return;
 904  		if (e.button !== 0) return;
 905  		e.preventDefault();
 906 +		stopInertia();
 907 +		if (e.pointerType === 'pen') penDown = true;
 908 +		showCursor();
 909 +		curClientX = e.clientX;
 910 +		curClientY = e.clientY;
 911 +		const p = clientToWorld(e.clientX, e.clientY);
 912 +		curX = p.x;
 913 +		curY = p.y;
 914 +		// hoverless pen + modifier held -> the contact adjusts, like a finger
 915 +		if (e.pointerType === 'pen' && !penHover && !keys['c'] && (dragBrush || dragRoundness || dragColor)) {
 916 +			anchorDrags(e.clientY);
 917 +			requestDraw();
 918 +			return;
 919 +		}
 920  		if (keys['c']) {
 921  			picking = true;
 922  			pickAt(curX, curY);

 930  		requestDraw();
 931  	});
 932  
     -	window.addEventListener('mouseup', (e) => {
     -		if (e.button === 0) {
 933 +	function pointerUp(e) {
 934 +		if (!hoverPointer(e)) return;
 935 +		if (e.button === 0 || e.type === 'pointercancel') {
 936  			painting = false;
 937  			picking = false;
 938  			lastPaintX = null;
 939  			lastPaintY = null;
 940 +			if (e.pointerType === 'pen') {
 941 +				penDown = false;
 942 +				// hoverless pens get no more events after lift, so fade like touch
 943 +				if (mouseInside) setCursorFadeTarget(0);
 944 +				requestDraw();
 945 +			}
 946  		}
     -	});
     -
     -	// on hoverless screens, the finger is the only pointer, so while a modifier
     -	// is held the finger adjusts instead of marking.
     -	const hoverMQ = window.matchMedia ? matchMedia('(any-hover: hover)') : null;
     -	let sawHoverPointer = false;
     -	function hoverCapable() { return sawHoverPointer || (hoverMQ !== null && hoverMQ.matches); }
     -	window.addEventListener('pointermove', (e) => {
     -		if (e.pointerType === 'mouse' || (e.pointerType === 'pen' && e.buttons === 0)) sawHoverPointer = true;
     -	});
 947 +	}
 948 +	window.addEventListener('pointerup', pointerUp);
 949 +	window.addEventListener('pointercancel', pointerUp);
 950  
 951  	// touch: one finger paints, two fingers pan/pinch-zoom. once a second
 952  	// finger lands the whole touch becomes a gesture (no painting) until all
 953  	// fingers lift, so a stray finger during a pan never leaves a mark.
 954 +	// stylus touches are excluded here
 955  	let touchMode = null; // 'paint' | 'adjust' | 'gesture'
 956  	let touchPainted = false;
 957  	let pinchDist = 0, pinchMidX = 0, pinchMidY = 0;

 999  		return { x: x / touches.length, y: y / touches.length };
1000  	}
1001  
1002 +	function fingerTouches(list) {
1003 +		const out = [];
1004 +		for (const t of list) if (t.touchType !== 'stylus') out.push(t);
1005 +		return out;
1006 +	}
1007 +
1008  	// re-anchor active drags to a new y and re-baseline to the current values
1009  	// so successive drag strokes accumulate instead of snapping back
1010  	function anchorDrags(clientY) {

1024  
1025  	window.addEventListener('touchstart', (e) => {
1026  		e.preventDefault();
1027 +		const fingers = fingerTouches(e.touches);
1028 +		// fingers are ignored while the pen is down so a resting hand can't
1029 +		// hijack or extend the pen's stroke
1030 +		if (fingers.length === 0 || penDown) return;
1031  		stopInertia();
     -		if (e.touches.length === 1 && touchMode === null) {
     -			const t = e.touches[0];
1032 +		if (fingers.length === 1 && touchMode === null) {
1033 +			const t = fingers[0];
1034  			curClientX = t.clientX;
1035  			curClientY = t.clientY;
     -			if (!keys['c'] && !hoverCapable() && (dragBrush || dragRoundness || dragColor)) {
1036 +			if (!keys['c'] && (dragBrush || dragRoundness || dragColor)) {
1037  				touchMode = 'adjust';
1038  				const p = clientToWorld(t.clientX, t.clientY);
1039  				curX = p.x;

1065  			panVX = 0;
1066  			panVY = 0;
1067  			panLastT = 0;
     -			anchorGesture(e.touches);
1068 +			anchorGesture(fingers);
1069  		}
1070  		requestDraw();
1071  	}, { passive: false });
1072  
1073  	window.addEventListener('touchmove', (e) => {
1074  		e.preventDefault();
1075 +		const fingers = fingerTouches(e.touches);
1076 +		if (fingers.length === 0) return;
1077  		if (touchMode === 'paint') {
     -			const t = e.touches[0];
1078 +			const t = fingers[0];
1079  			curClientX = t.clientX;
1080  			curClientY = t.clientY;
1081  			// on hover devices held keys adjust during the stroke, like mouse

1092  			curX = p.x;
1093  			curY = p.y;
1094  		} else if (touchMode === 'adjust') {
     -			const t = e.touches[0];
1095 +			const t = fingers[0];
1096  			curClientX = t.clientX;
1097  			curClientY = t.clientY;
1098  			applyDrags(t.clientY);

1100  			curX = p.x;
1101  			curY = p.y;
1102  		} else if (touchMode === 'gesture') {
     -			const m = touchCentroid(e.touches);
1103 +			const m = touchCentroid(fingers);
1104  			const dxs = m.x - pinchMidX;
1105  			const dys = m.y - pinchMidY;
1106  			camX -= dxs / zoom;

1112  				panVY = panVY * 0.5 + (dys / dt) * 0.5;
1113  			}
1114  			panLastT = nowT;
     -			if (e.touches.length >= 2) {
     -				const dist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
1115 +			if (fingers.length >= 2) {
1116 +				const dist = Math.hypot(fingers[0].clientX - fingers[1].clientX, fingers[0].clientY - fingers[1].clientY);
1117  				if (pinchDist > 0) applyZoom(dist / pinchDist, m.x, m.y);
1118  				pinchDist = dist;
1119  			}

1127  
1128  	function touchEnd(e) {
1129  		e.preventDefault();
     -		if (e.touches.length === 0) {
1130 +		if (touchMode === null) return;
1131 +		const fingers = fingerTouches(e.touches);
1132 +		if (fingers.length === 0) {
1133  			// a tap that never moved still paints its dot (or picks its point)
1134  			if (touchMode === 'paint' && !touchPainted) {
1135  				if (picking) pickAt(curX, curY);

1146  			requestDraw();
1147  		} else if (touchMode === 'gesture') {
1148  			// re-anchor to the remaining fingers so the camera doesn't jump
     -			anchorGesture(e.touches);
1149 +			anchorGesture(fingers);
1150  		}
1151  	}
1152  	window.addEventListener('touchend', touchEnd, { passive: false });

1157  		if (mode === 'brush' && !dragBrush) dragBrush = { anchorY, start: brush };
1158  		else if (mode === 'roundness' && !dragRoundness) dragRoundness = { anchorY, start: roundness };
1159  		else if (mode === 'color' && !dragColor) dragColor = { anchorY, r0: r, g0: g, b0: b };
     -		// hoverless screen: a modifier pressed mid-stroke ends the stroke and
     -		// converts the touch into an adjust drag for the rest of the touch
     -		if (touchMode === 'paint' && !hoverCapable()) {
     -			touchMode = 'adjust';
1160 +		// hoverless input: a modifier pressed mid-stroke ends the stroke and
1161 +		// the rest of the contact becomes an adjust drag. hover-capable pens
1162 +		// keep drawing (they can adjust while lifted instead)
1163 +		if (touchMode === 'paint' || (penDown && !penHover)) {
1164 +			if (touchMode === 'paint') touchMode = 'adjust';
1165  			painting = false;
1166  			picking = false;
1167  			lastPaintX = null;

1234  			}
1235  		}
1236  		// releasing c mid-pick-drag -> back to adjusting if a modifier is still
     -		// held on a hoverless screen, else seamlessly switch to painting
1237 +		// held on a hoverless contact, else seamlessly switch to painting
1238  		if (k === 'c' && picking) {
1239  			picking = false;
     -			if (touchMode !== null && !hoverCapable() && (dragBrush || dragRoundness || dragColor)) {
     -				touchMode = 'adjust';
1240 +			const hoverless = touchMode !== null || (penDown && !penHover);
1241 +			if (hoverless && (dragBrush || dragRoundness || dragColor)) {
1242 +				if (touchMode !== null) touchMode = 'adjust';
1243 +				painting = false;
1244  				if (curClientY !== null) anchorDrags(curClientY);
1245  			} else {
1246  				painting = true;

1256  		dragBrush = null; dragRoundness = null; dragColor = null;
1257  		painting = false;
1258  		picking = false;
1259 +		penDown = false;
1260  		lastPaintX = null;
1261  		lastPaintY = null;
1262  	});