input.js (17.9 KB)


  1 window.addEventListener('resize', resize);
  2 
  3 // mouse and pen share the pointer-event path; finger touches are handled
  4 // by the touch-event path below
  5 function hoverPointer(e) { return e.pointerType === 'mouse' || e.pointerType === 'pen'; }
  6 
  7 window.addEventListener('pointerover', (e) => {
  8 	if (!hoverPointer(e)) return;
  9 	showCursor();
 10 	curClientX = e.clientX;
 11 	curClientY = e.clientY;
 12 	const p = clientToWorld(e.clientX, e.clientY);
 13 	curX = p.x;
 14 	curY = p.y;
 15 	requestDraw();
 16 });
 17 
 18 // brush resize curve: linear (fine, precise) up to BRUSH_LINEAR_MAX, then a
 19 // power-law ramp beyond, tuned by BRUSH_ACCEL.
 20 function brushFromDrag(start, dyPx) {
 21 	const A = BRUSH_DRAG_PX_PER_STEP, T = BRUSH_LINEAR_MAX, c = BRUSH_ACCEL;
 22 	const seam = T * A; // drag distance from size 0 to the seam
 23 	const brushToPos = (b) => b <= T ? b * A : seam + (seam / c) * (Math.pow(b / T, c) - 1);
 24 	const posToBrush = (p) => p <= seam ? p / A : T * Math.pow(1 + c * (p - seam) / seam, 1 / c);
 25 	return posToBrush(brushToPos(start) + dyPx);
 26 }
 27 
 28 // modifier-drag: shift/cmd/RGB held -> vertical motion from the anchor
 29 // drives brush size / roundness / color channels. drag up (dy negative)
 30 // -> increase. when a value hits its bound and the drag continues past,
 31 // re-anchor so reversing direction responds immediately. each drag is
 32 // independent so any combination can run at once.
 33 function applyDrags(clientY) {
 34 	if (dragBrush) {
 35 		const dyPx = (dragBrush.anchorY - clientY) * dpr;
 36 		const target = brushFromDrag(dragBrush.start, dyPx);
 37 		const clamped = clamp(Math.round(target), 1, MAX_BRUSH);
 38 		brush = clamped;
 39 		if (target < 1 || target > MAX_BRUSH) {
 40 			dragBrush.start = clamped;
 41 			dragBrush.anchorY = clientY;
 42 		}
 43 	}
 44 	if (dragRoundness) {
 45 		const dyPx = (dragRoundness.anchorY - clientY) * dpr;
 46 		const target = dragRoundness.start - dyPx / ROUNDNESS_DRAG_FULL_PX;
 47 		const clamped = clamp(target, 0, 1);
 48 		roundness = clamped;
 49 		if (target !== clamped) {
 50 			dragRoundness.start = clamped;
 51 			dragRoundness.anchorY = clientY;
 52 		}
 53 	}
 54 	if (dragColor) {
 55 		const dyPx = (dragColor.anchorY - clientY) * dpr;
 56 		const delta = (dyPx / COLOR_DRAG_FULL_PX) * 255;
 57 		let nr = r, ng = g, nb = b;
 58 		let overshoot = 0;
 59 		if (keys['r']) {
 60 			const t = dragColor.r0 + delta;
 61 			nr = clamp(t, 0, 255);
 62 			if (t !== nr) overshoot = Math.max(overshoot, Math.abs(t - nr));
 63 		}
 64 		if (keys['g']) {
 65 			const t = dragColor.g0 + delta;
 66 			ng = clamp(t, 0, 255);
 67 			if (t !== ng) overshoot = Math.max(overshoot, Math.abs(t - ng));
 68 		}
 69 		if (keys['b']) {
 70 			const t = dragColor.b0 + delta;
 71 			nb = clamp(t, 0, 255);
 72 			if (t !== nb) overshoot = Math.max(overshoot, Math.abs(t - nb));
 73 		}
 74 		setColor(Math.round(nr), Math.round(ng), Math.round(nb));
 75 		if (overshoot > 0) {
 76 			dragColor.r0 = nr; dragColor.g0 = ng; dragColor.b0 = nb;
 77 			dragColor.anchorY = clientY;
 78 		}
 79 	}
 80 }
 81 
 82 window.addEventListener('pointermove', (e) => {
 83 	if (!hoverPointer(e)) return;
 84 	// a pen only proves it can hover by moving while lifted. without hover
 85 	// it gets the finger treatment: modifiers adjust instead of marking
 86 	if (e.pointerType === 'pen' && e.buttons === 0) penHover = true;
 87 	showCursor();
 88 	curClientX = e.clientX;
 89 	curClientY = e.clientY;
 90 	const p = clientToWorld(e.clientX, e.clientY);
 91 	const nx = p.x, ny = p.y;
 92 
 93 	applyDrags(e.clientY);
 94 
 95 	if (painting) {
 96 		if (lastPaintX !== null) {
 97 			localPaintLine(lastPaintX, lastPaintY, nx, ny);
 98 		} else {
 99 			localPaintAt(nx, ny);
100 		}
101 		lastPaintX = nx;
102 		lastPaintY = ny;
103 	} else if (picking) {
104 		pickAt(nx, ny);
105 	}
106 	curX = nx;
107 	curY = ny;
108 	requestDraw();
109 });
110 
111 window.addEventListener('pointerleave', (e) => {
112 	if (!hoverPointer(e)) return;
113 	hideCursorNow();
114 	requestDraw();
115 });
116 
117 // a pen without hover support sends no move events before contact, so the
118 // down event must place the cursor itself
119 let penDown = false;
120 let penHover = false;
121 window.addEventListener('pointerdown', (e) => {
122 	if (!hoverPointer(e)) return;
123 	if (e.button !== 0) return;
124 	e.preventDefault();
125 	stopInertia();
126 	if (e.pointerType === 'pen') penDown = true;
127 	showCursor();
128 	curClientX = e.clientX;
129 	curClientY = e.clientY;
130 	const p = clientToWorld(e.clientX, e.clientY);
131 	curX = p.x;
132 	curY = p.y;
133 	// hoverless pen + modifier held -> the contact adjusts, like a finger
134 	if (e.pointerType === 'pen' && !penHover && !keys['c'] && (dragBrush || dragRoundness || dragColor)) {
135 		anchorDrags(e.clientY);
136 		requestDraw();
137 		return;
138 	}
139 	if (keys['c']) {
140 		picking = true;
141 		pickAt(curX, curY);
142 		requestDraw();
143 		return;
144 	}
145 	painting = true;
146 	lastPaintX = curX;
147 	lastPaintY = curY;
148 	localPaintAt(curX, curY);
149 	requestDraw();
150 });
151 
152 function pointerUp(e) {
153 	if (!hoverPointer(e)) return;
154 	if (e.button === 0 || e.type === 'pointercancel') {
155 		painting = false;
156 		picking = false;
157 		lastPaintX = null;
158 		lastPaintY = null;
159 		if (e.pointerType === 'pen') {
160 			penDown = false;
161 			// hoverless pens get no more events after lift, so fade like touch
162 			if (mouseInside) setCursorFadeTarget(0);
163 			requestDraw();
164 		}
165 	}
166 }
167 window.addEventListener('pointerup', pointerUp);
168 window.addEventListener('pointercancel', pointerUp);
169 
170 // touch: one finger paints, two fingers pan/pinch-zoom. once a second
171 // finger lands the whole touch becomes a gesture (no painting) until all
172 // fingers lift, so a stray finger during a pan never leaves a mark.
173 // stylus touches are excluded here
174 let touchMode = null; // 'paint' | 'adjust' | 'gesture'
175 let touchPainted = false;
176 let pinchDist = 0, pinchMidX = 0, pinchMidY = 0;
177 
178 // inertial pan: centroid velocity (css px/ms) tracked during the gesture,
179 // thrown on release and decayed with exponential friction
180 let panVX = 0, panVY = 0;
181 let panLastT = 0;
182 let inertiaRAF = null;
183 function stopInertia() {
184 	if (inertiaRAF !== null) {
185 		cancelAnimationFrame(inertiaRAF);
186 		inertiaRAF = null;
187 	}
188 }
189 function startInertia() {
190 	// no throw if the finger paused before lifting or was barely moving
191 	const speed = Math.hypot(panVX, panVY);
192 	if (performance.now() - panLastT > 100 || speed < 0.05) return;
193 	// exponential decay per ms
194 	const DECEL = 0.995;
195 	const MAX_SPEED = 3; // css px/ms
196 	if (speed > MAX_SPEED) {
197 		panVX *= MAX_SPEED / speed;
198 		panVY *= MAX_SPEED / speed;
199 	}
200 	let prev = performance.now();
201 	const step = (now) => {
202 		const dt = now - prev;
203 		prev = now;
204 		camX -= panVX * dt / zoom;
205 		camY -= panVY * dt / zoom;
206 		const f = Math.pow(DECEL, dt);
207 		panVX *= f;
208 		panVY *= f;
209 		requestDraw();
210 		inertiaRAF = Math.hypot(panVX, panVY) > 0.02 ? requestAnimationFrame(step) : null;
211 	};
212 	inertiaRAF = requestAnimationFrame(step);
213 }
214 
215 function touchCentroid(touches) {
216 	let x = 0, y = 0;
217 	for (const t of touches) { x += t.clientX; y += t.clientY; }
218 	return { x: x / touches.length, y: y / touches.length };
219 }
220 
221 function fingerTouches(list) {
222 	const out = [];
223 	for (const t of list) if (t.touchType !== 'stylus') out.push(t);
224 	return out;
225 }
226 
227 // re-anchor active drags to a new y and re-baseline to the current values
228 // so successive drag strokes accumulate instead of snapping back
229 function anchorDrags(clientY) {
230 	if (dragBrush) { dragBrush.anchorY = clientY; dragBrush.start = brush; }
231 	if (dragRoundness) { dragRoundness.anchorY = clientY; dragRoundness.start = roundness; }
232 	if (dragColor) { dragColor.anchorY = clientY; dragColor.r0 = r; dragColor.g0 = g; dragColor.b0 = b; }
233 }
234 
235 function anchorGesture(touches) {
236 	const m = touchCentroid(touches);
237 	pinchMidX = m.x;
238 	pinchMidY = m.y;
239 	pinchDist = touches.length >= 2
240 		? Math.hypot(touches[0].clientX - touches[1].clientX, touches[0].clientY - touches[1].clientY)
241 		: 0;
242 }
243 
244 window.addEventListener('touchstart', (e) => {
245 	e.preventDefault();
246 	const fingers = fingerTouches(e.touches);
247 	// fingers are ignored while the pen is down so a resting hand can't
248 	// hijack or extend the pen's stroke
249 	if (fingers.length === 0 || penDown) return;
250 	stopInertia();
251 	if (fingers.length === 1 && touchMode === null) {
252 		const t = fingers[0];
253 		curClientX = t.clientX;
254 		curClientY = t.clientY;
255 		if (!keys['c'] && (dragBrush || dragRoundness || dragColor)) {
256 			touchMode = 'adjust';
257 			const p = clientToWorld(t.clientX, t.clientY);
258 			curX = p.x;
259 			curY = p.y;
260 			showCursor();
261 			anchorDrags(t.clientY);
262 		} else {
263 			const p = clientToWorld(t.clientX, t.clientY);
264 			curX = p.x;
265 			curY = p.y;
266 			showCursor();
267 			touchMode = 'paint';
268 			touchPainted = false;
269 			if (keys['c']) {
270 				picking = true;
271 			} else {
272 				painting = true;
273 				lastPaintX = curX;
274 				lastPaintY = curY;
275 			}
276 		}
277 	} else {
278 		touchMode = 'gesture';
279 		painting = false;
280 		picking = false;
281 		lastPaintX = null;
282 		lastPaintY = null;
283 		hideCursorNow();
284 		panVX = 0;
285 		panVY = 0;
286 		panLastT = 0;
287 		anchorGesture(fingers);
288 	}
289 	requestDraw();
290 }, { passive: false });
291 
292 window.addEventListener('touchmove', (e) => {
293 	e.preventDefault();
294 	const fingers = fingerTouches(e.touches);
295 	if (fingers.length === 0) return;
296 	if (touchMode === 'paint') {
297 		const t = fingers[0];
298 		curClientX = t.clientX;
299 		curClientY = t.clientY;
300 		// on hover devices held keys adjust during the stroke, like mouse
301 		applyDrags(t.clientY);
302 		const p = clientToWorld(t.clientX, t.clientY);
303 		if (picking) {
304 			pickAt(p.x, p.y);
305 		} else if (painting) {
306 			localPaintLine(lastPaintX, lastPaintY, p.x, p.y);
307 			lastPaintX = p.x;
308 			lastPaintY = p.y;
309 		}
310 		touchPainted = true;
311 		curX = p.x;
312 		curY = p.y;
313 	} else if (touchMode === 'adjust') {
314 		const t = fingers[0];
315 		curClientX = t.clientX;
316 		curClientY = t.clientY;
317 		applyDrags(t.clientY);
318 		const p = clientToWorld(t.clientX, t.clientY);
319 		curX = p.x;
320 		curY = p.y;
321 	} else if (touchMode === 'gesture') {
322 		const m = touchCentroid(fingers);
323 		const dxs = m.x - pinchMidX;
324 		const dys = m.y - pinchMidY;
325 		camX -= dxs / zoom;
326 		camY -= dys / zoom;
327 		const nowT = performance.now();
328 		const dt = nowT - panLastT;
329 		if (dt > 0 && dt < 100) {
330 			panVX = panVX * 0.5 + (dxs / dt) * 0.5;
331 			panVY = panVY * 0.5 + (dys / dt) * 0.5;
332 		}
333 		panLastT = nowT;
334 		if (fingers.length >= 2) {
335 			const dist = Math.hypot(fingers[0].clientX - fingers[1].clientX, fingers[0].clientY - fingers[1].clientY);
336 			if (pinchDist > 0) applyZoom(dist / pinchDist, m.x, m.y);
337 			pinchDist = dist;
338 		}
339 		pinchMidX = m.x;
340 		pinchMidY = m.y;
341 	} else {
342 		return;
343 	}
344 	requestDraw();
345 }, { passive: false });
346 
347 function touchEnd(e) {
348 	e.preventDefault();
349 	if (touchMode === null) return;
350 	const fingers = fingerTouches(e.touches);
351 	if (fingers.length === 0) {
352 		// a tap that never moved still paints its dot (or picks its point)
353 		if (touchMode === 'paint' && !touchPainted) {
354 			if (picking) pickAt(curX, curY);
355 			else localPaintAt(curX, curY);
356 		}
357 		if (touchMode === 'gesture') startInertia();
358 		touchMode = null;
359 		painting = false;
360 		picking = false;
361 		lastPaintX = null;
362 		lastPaintY = null;
363 		// fade the preview out instead of hiding it instantly
364 		if (mouseInside) setCursorFadeTarget(0);
365 		requestDraw();
366 	} else if (touchMode === 'gesture') {
367 		// re-anchor to the remaining fingers so the camera doesn't jump
368 		anchorGesture(fingers);
369 	}
370 }
371 window.addEventListener('touchend', touchEnd, { passive: false });
372 window.addEventListener('touchcancel', touchEnd, { passive: false });
373 
374 function startDragMode(mode) {
375 	const anchorY = curClientY !== null ? curClientY : 0;
376 	if (mode === 'brush' && !dragBrush) dragBrush = { anchorY, start: brush };
377 	else if (mode === 'roundness' && !dragRoundness) dragRoundness = { anchorY, start: roundness };
378 	else if (mode === 'color' && !dragColor) dragColor = { anchorY, r0: r, g0: g, b0: b };
379 	// hoverless input: a modifier pressed mid-stroke ends the stroke and
380 	// the rest of the contact becomes an adjust drag. hover-capable pens
381 	// keep drawing (they can adjust while lifted instead)
382 	if (touchMode === 'paint' || (penDown && !penHover)) {
383 		if (touchMode === 'paint') touchMode = 'adjust';
384 		painting = false;
385 		picking = false;
386 		lastPaintX = null;
387 		lastPaintY = null;
388 	}
389 }
390 function endDragMode(mode) {
391 	if (mode === 'brush') dragBrush = null;
392 	else if (mode === 'roundness') dragRoundness = null;
393 	else if (mode === 'color') dragColor = null;
394 }
395 
396 window.addEventListener('keydown', (e) => {
397 	if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 's') {
398 		e.preventDefault();
399 		exportGIF();
400 		return;
401 	}
402 	if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'o') {
403 		e.preventDefault();
404 		fileInput.click();
405 		return;
406 	}
407 	if ((e.metaKey || e.ctrlKey) && !e.altKey) {
408 		if (e.key === '=' || e.key === '+') { e.preventDefault(); stepZoom(1); return; }
409 		if (e.key === '-' || e.key === '_') { e.preventDefault(); stepZoom(-1); return; }
410 		if (e.key === '0') { e.preventDefault(); resetZoom(); return; }
411 	}
412 	if (!e.metaKey && !e.ctrlKey && !e.altKey && !e.repeat) {
413 		const lk = e.key.toLowerCase();
414 		if (e.key === 'ArrowLeft') { e.preventDefault(); tapArrow(-1); return; }
415 		if (e.key === 'ArrowRight') { e.preventDefault(); tapArrow(1); return; }
416 		if (e.key === ' ') { e.preventDefault(); playing ? stopPlayback() : startPlayback(); return; }
417 		if (lk === 'a') { addFrame(); return; }
418 		if (lk === 'd') { deleteFrame(); return; }
419 		if (lk === 'o') {
420 			if (playing) { stopPlayback(); onionskin = true; }
421 			else onionskin = !onionskin;
422 			requestDraw();
423 			return;
424 		}
425 	}
426 	const k = e.key.toLowerCase();
427 	const wasDown = keys[k];
428 	keys[k] = true;
429 	if (!e.metaKey && !e.ctrlKey) {
430 		if (k === 'z' || k === 'x' || k === 'c' || k === 'r' || k === 'g' || k === 'b') e.preventDefault();
431 		if (k === 'x') startDragMode('roundness');
432 		else if (k === 'z') startDragMode('brush');
433 		else if (!wasDown && (k === 'r' || k === 'g' || k === 'b')) startDragMode('color');
434 		else if (k === 'c' && !wasDown && (touchMode === 'paint' || touchMode === 'adjust')) {
435 			touchMode = 'paint';
436 			painting = false;
437 			picking = true;
438 			lastPaintX = null;
439 			lastPaintY = null;
440 			touchPainted = true;
441 			pickAt(curX, curY);
442 			requestDraw();
443 		}
444 	}
445 });
446 window.addEventListener('keyup', (e) => {
447 	const k = e.key.toLowerCase();
448 	keys[k] = false;
449 	if (k === 'x') endDragMode('roundness');
450 	else if (k === 'z') endDragMode('brush');
451 	else if (k === 'r' || k === 'g' || k === 'b') {
452 		if (!keys['r'] && !keys['g'] && !keys['b']) endDragMode('color');
453 		else if (dragColor) {
454 			// still holding at least one rgb key - re-anchor from current
455 			// values so the remaining keys don't jump based on released key's history
456 			dragColor.anchorY = curClientY !== null ? curClientY : dragColor.anchorY;
457 			dragColor.r0 = r; dragColor.g0 = g; dragColor.b0 = b;
458 		}
459 	}
460 	// releasing c mid-pick-drag -> back to adjusting if a modifier is still
461 	// held on a hoverless contact, else seamlessly switch to painting
462 	if (k === 'c' && picking) {
463 		picking = false;
464 		const hoverless = touchMode !== null || (penDown && !penHover);
465 		if (hoverless && (dragBrush || dragRoundness || dragColor)) {
466 			if (touchMode !== null) touchMode = 'adjust';
467 			painting = false;
468 			if (curClientY !== null) anchorDrags(curClientY);
469 		} else {
470 			painting = true;
471 			lastPaintX = curX;
472 			lastPaintY = curY;
473 			localPaintAt(curX, curY);
474 		}
475 		requestDraw();
476 	}
477 });
478 window.addEventListener('blur', () => {
479 	for (const k in keys) keys[k] = false;
480 	dragBrush = null; dragRoundness = null; dragColor = null;
481 	painting = false;
482 	picking = false;
483 	penDown = false;
484 	lastPaintX = null;
485 	lastPaintY = null;
486 });
487 
488 // wheel gesture lock: once a wheel gesture starts in a given mode,
489 // subsequent events in the same burst stay in that mode until the wheel
490 // goes idle or the controlling modifier set changes. this keeps trackpad
491 // momentum from leaking into pan after a modifier release, while still
492 // letting the user swap between modes mid-flick without stale state.
493 let wheelMode = null;
494 let wheelIdleTimer = null;
495 let panning = false;
496 
497 const BRUSH_DRAG_PX_PER_STEP = 25;	// device px per +/- 1 brush px (below the seam)
498 const BRUSH_LINEAR_MAX = 6;			// brush px below which resize stays linear/fine
499 const BRUSH_ACCEL = 0.5;
500 const MAX_BRUSH = 150;
501 const ROUNDNESS_DRAG_FULL_PX = 200;
502 const COLOR_DRAG_FULL_PX = 256;
503 // each drag carries its own anchor so any combination can run at once
504 let dragBrush = null;		// {anchorY, start}
505 let dragRoundness = null;	// {anchorY, start}
506 let dragColor = null;		// {anchorY, r0, g0, b0}
507 function touchWheelGesture() {
508 	if (wheelIdleTimer) clearTimeout(wheelIdleTimer);
509 	wheelIdleTimer = setTimeout(() => {
510 		wheelMode = null;
511 		if (panning) {
512 			// pan ended - resync the logical-pixel cursor from the real
513 			// client position so the brush snaps onto its final cell.
514 			panning = false;
515 			if (curClientX !== null) {
516 				const p = clientToWorld(curClientX, curClientY);
517 				curX = p.x;
518 				curY = p.y;
519 			}
520 			requestDraw();
521 		}
522 	}, 150);
523 }
524 function pickModifierMode(e) {
525 	if (e.ctrlKey) return 'zoom';
526 	return null;
527 }
528 
529 window.addEventListener('wheel', (e) => {
530 	e.preventDefault();
531 	stopInertia();
532 
533 	// re-pick mode each event from live modifiers so releasing cmd and
534 	// immediately starting a shift scroll switches over cleanly. if a
535 	// burst started with a modifier and the modifier is then released
536 	// mid-flick, suppress rather than leaking into pan.
537 	const modMode = pickModifierMode(e);
538 	if (modMode !== null) {
539 		wheelMode = modMode;
540 	} else if (wheelMode === null) {
541 		wheelMode = 'pan';
542 	} else if (wheelMode !== 'pan') {
543 		// modifier released mid-burst: drop the remaining momentum
544 		// instead of letting it leak into pan.
545 		touchWheelGesture();
546 		return;
547 	}
548 	touchWheelGesture();
549 
550 	if (wheelMode === 'zoom') {
551 		// exponential zoom on the float accumulator so small pinches add up
552 		applyZoom(Math.exp(-e.deltaY * 0.02), e.clientX, e.clientY);
553 		requestDraw();
554 		return;
555 	}
556 
557 	// pan - smooth fractional camera. during the pan gesture the draw
558 	// loop anchors the brush to the real client cursor position so it
559 	// tracks the pointer smoothly; curX/curY get resynced onto the
560 	// logical-pixel grid when the wheel idle timer fires.
561 	panning = true;
562 	camX += e.deltaX / zoom;
563 	camY += e.deltaY / zoom;
564 	requestDraw();
565 }, { passive: false });
566 
567 window.addEventListener('beforeunload', (e) => {
568 	if (dirty) { e.preventDefault(); }
569 });
570 
571 window.addEventListener('contextmenu', (e) => e.preventDefault());
572 
573 // block the OS pinch gesture events too (Safari)
574 window.addEventListener('gesturestart', (e) => e.preventDefault());
575 window.addEventListener('gesturechange', (e) => e.preventDefault());
576 window.addEventListener('gestureend', (e) => e.preventDefault());