commit 457878b543ab44abb3f2e6e650731b9dffa70d9b
parent 2dd7d44d941749d7ab0db19f8450b2cbfee81c6d
Author: Hunter
Date:   Fri, 17 Jul 2026 11:59:07 -0400

add touch support for mobile and tablet

Diffstat:
Mindex.html | 264+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
1 file changed, 218 insertions(+), 46 deletions(-)

diff --git a/index.html b/index.html @@ -99,6 +99,8 @@ function setFrame(i) { frameIdx = ((i % frames.length) + frames.length) % frames.length; chunks = frames[frameIdx]; + // a held pick tracks the frame under it as frames change + if (picking) pickAt(curX, curY); requestDraw(); } @@ -154,6 +156,30 @@ // the OS does not emit mousemove events when only the camera moves. let curClientX = null, curClientY = null; let mouseInside = true; + // brush preview fades in when it appears and out after the last touch lifts + let cursorAlpha = 1; + let cursorFadeFrom = 1; + let cursorFadeTo = 1; + let cursorFadeStart = 0; + const CURSOR_FADE_IN_MS = 100; + const CURSOR_FADE_OUT_MS = 400; + function setCursorFadeTarget(target) { + if (target === cursorFadeTo) return; + cursorFadeFrom = cursorAlpha; + cursorFadeTo = target; + cursorFadeStart = performance.now(); + requestDraw(); + } + function showCursor() { + mouseInside = true; + setCursorFadeTarget(1); + } + function hideCursorNow() { + mouseInside = false; + cursorAlpha = 0; + cursorFadeFrom = 0; + cursorFadeTo = 0; + } let r = 255, g = 255, b = 255; let brush = 1; @@ -458,6 +484,18 @@ vctx.restore(); } + if (cursorAlpha !== cursorFadeTo) { + const dur = cursorFadeTo > cursorFadeFrom ? CURSOR_FADE_IN_MS : CURSOR_FADE_OUT_MS; + const t = (now - cursorFadeStart) / dur; + if (t >= 1) { + cursorAlpha = cursorFadeTo; + if (cursorFadeTo === 0) mouseInside = false; + } else { + const e = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; + cursorAlpha = cursorFadeFrom + (cursorFadeTo - cursorFadeFrom) * e; + requestDraw(); + } + } if (mouseInside) { const tl = brushTopLeft(curX, curY); // round to integer device pixels - camX/camY are fractional (smooth @@ -479,6 +517,8 @@ } const n = brush; + vctx.save(); + vctx.globalAlpha = cursorAlpha; vctx.fillStyle = 'rgb(' + dispR + ',' + dispG + ',' + dispB + ')'; if (roundness === 0) { vctx.fillRect(sx, sy, n * pxD, n * pxD); @@ -508,17 +548,18 @@ vctx.save(); if (L > 0) { vctx.globalCompositeOperation = 'multiply'; - vctx.globalAlpha = L; + vctx.globalAlpha = L * cursorAlpha; vctx.fillStyle = 'rgb(128,128,128)'; drawOutline(); } if (L < 1) { vctx.globalCompositeOperation = 'screen'; - vctx.globalAlpha = 1 - L; + vctx.globalAlpha = (1 - L) * cursorAlpha; vctx.fillStyle = 'rgb(128,128,128)'; drawOutline(); } vctx.restore(); + vctx.restore(); } } @@ -751,7 +792,7 @@ window.addEventListener('resize', resize); window.addEventListener('mouseover', (e) => { - mouseInside = true; + showCursor(); curClientX = e.clientX; curClientY = e.clientY; const p = clientToWorld(e.clientX, e.clientY); @@ -760,40 +801,34 @@ requestDraw(); }); - 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. + // 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. + function applyDrags(clientY) { if (dragBrush) { - const dyPx = (dragBrush.anchorY - e.clientY) * dpr; + const dyPx = (dragBrush.anchorY - clientY) * dpr; const target = dragBrush.start + Math.trunc(dyPx / BRUSH_DRAG_PX_PER_STEP); const clamped = clamp(target, 1, 256); brush = clamped; if (target !== clamped) { dragBrush.start = clamped; - dragBrush.anchorY = e.clientY; + dragBrush.anchorY = clientY; } } if (dragRoundness) { - const dyPx = (dragRoundness.anchorY - e.clientY) * dpr; + const dyPx = (dragRoundness.anchorY - clientY) * dpr; const target = dragRoundness.start - dyPx / ROUNDNESS_DRAG_FULL_PX; const clamped = clamp(target, 0, 1); roundness = clamped; if (target !== clamped) { dragRoundness.start = clamped; - dragRoundness.anchorY = e.clientY; + dragRoundness.anchorY = clientY; } } if (dragColor) { - const dyPx = (dragColor.anchorY - e.clientY) * dpr; + const dyPx = (dragColor.anchorY - clientY) * dpr; const delta = (dyPx / COLOR_DRAG_FULL_PX) * 255; let nr = r, ng = g, nb = b; let overshoot = 0; @@ -815,9 +850,19 @@ setColor(Math.round(nr), Math.round(ng), Math.round(nb)); if (overshoot > 0) { dragColor.r0 = nr; dragColor.g0 = ng; dragColor.b0 = nb; - dragColor.anchorY = e.clientY; + dragColor.anchorY = clientY; } } + } + + window.addEventListener('mousemove', (e) => { + showCursor(); + curClientX = e.clientX; + curClientY = e.clientY; + const p = clientToWorld(e.clientX, e.clientY); + const nx = p.x, ny = p.y; + + applyDrags(e.clientY); if (painting) { if (lastPaintX !== null) { @@ -836,7 +881,7 @@ }); window.addEventListener('mouseleave', () => { - mouseInside = false; + hideCursorNow(); requestDraw(); }); @@ -865,19 +910,73 @@ } }); + // 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; + }); + // touch: one finger paints, two fingers pan/pinch-zoom. once a second // finger lands the whole touch becomes a gesture (no painting) until all // fingers lift, so a stray finger during a pan never leaves a mark. - let touchMode = null; // 'paint' | 'gesture' + let touchMode = null; // 'paint' | 'adjust' | 'gesture' let touchPainted = false; let pinchDist = 0, pinchMidX = 0, pinchMidY = 0; + // inertial pan: centroid velocity (css px/ms) tracked during the gesture, + // thrown on release and decayed with exponential friction + let panVX = 0, panVY = 0; + let panLastT = 0; + let inertiaRAF = null; + function stopInertia() { + if (inertiaRAF !== null) { + cancelAnimationFrame(inertiaRAF); + inertiaRAF = null; + } + } + function startInertia() { + // no throw if the finger paused before lifting or was barely moving + const speed = Math.hypot(panVX, panVY); + if (performance.now() - panLastT > 100 || speed < 0.05) return; + // exponential decay per ms + const DECEL = 0.995; + const MAX_SPEED = 3; // css px/ms + if (speed > MAX_SPEED) { + panVX *= MAX_SPEED / speed; + panVY *= MAX_SPEED / speed; + } + let prev = performance.now(); + const step = (now) => { + const dt = now - prev; + prev = now; + camX -= panVX * dt / zoom; + camY -= panVY * dt / zoom; + const f = Math.pow(DECEL, dt); + panVX *= f; + panVY *= f; + requestDraw(); + inertiaRAF = Math.hypot(panVX, panVY) > 0.02 ? requestAnimationFrame(step) : null; + }; + inertiaRAF = requestAnimationFrame(step); + } + function touchCentroid(touches) { let x = 0, y = 0; for (const t of touches) { x += t.clientX; y += t.clientY; } return { x: x / touches.length, y: y / touches.length }; } + // re-anchor active drags to a new y and re-baseline to the current values + // so successive drag strokes accumulate instead of snapping back + function anchorDrags(clientY) { + if (dragBrush) { dragBrush.anchorY = clientY; dragBrush.start = brush; } + if (dragRoundness) { dragRoundness.anchorY = clientY; dragRoundness.start = roundness; } + if (dragColor) { dragColor.anchorY = clientY; dragColor.r0 = r; dragColor.g0 = g; dragColor.b0 = b; } + } + function anchorGesture(touches) { const m = touchCentroid(touches); pinchMidX = m.x; @@ -889,25 +988,43 @@ window.addEventListener('touchstart', (e) => { e.preventDefault(); + stopInertia(); if (e.touches.length === 1 && touchMode === null) { const t = e.touches[0]; curClientX = t.clientX; 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; + if (!keys['c'] && !hoverCapable() && (dragBrush || dragRoundness || dragColor)) { + touchMode = 'adjust'; + const p = clientToWorld(t.clientX, t.clientY); + curX = p.x; + curY = p.y; + showCursor(); + anchorDrags(t.clientY); + } else { + const p = clientToWorld(t.clientX, t.clientY); + curX = p.x; + curY = p.y; + showCursor(); + touchMode = 'paint'; + touchPainted = false; + if (keys['c']) { + picking = true; + } else { + painting = true; + lastPaintX = curX; + lastPaintY = curY; + } + } } else { touchMode = 'gesture'; painting = false; + picking = false; lastPaintX = null; lastPaintY = null; - mouseInside = false; + hideCursorNow(); + panVX = 0; + panVY = 0; + panLastT = 0; anchorGesture(e.touches); } requestDraw(); @@ -919,17 +1036,40 @@ const t = e.touches[0]; curClientX = t.clientX; curClientY = t.clientY; + // on hover devices held keys adjust during the stroke, like mouse + applyDrags(t.clientY); const p = clientToWorld(t.clientX, t.clientY); - paintLine(lastPaintX, lastPaintY, p.x, p.y); + if (picking) { + pickAt(p.x, p.y); + } else if (painting) { + paintLine(lastPaintX, lastPaintY, p.x, p.y); + lastPaintX = p.x; + lastPaintY = p.y; + } touchPainted = true; - lastPaintX = p.x; - lastPaintY = p.y; + curX = p.x; + curY = p.y; + } else if (touchMode === 'adjust') { + const t = e.touches[0]; + curClientX = t.clientX; + curClientY = t.clientY; + applyDrags(t.clientY); + const p = clientToWorld(t.clientX, t.clientY); curX = p.x; curY = p.y; } else if (touchMode === 'gesture') { const m = touchCentroid(e.touches); - camX -= (m.x - pinchMidX) / zoom; - camY -= (m.y - pinchMidY) / zoom; + const dxs = m.x - pinchMidX; + const dys = m.y - pinchMidY; + camX -= dxs / zoom; + camY -= dys / zoom; + const nowT = performance.now(); + const dt = nowT - panLastT; + if (dt > 0 && dt < 100) { + panVX = panVX * 0.5 + (dxs / dt) * 0.5; + panVY = panVY * 0.5 + (dys / dt) * 0.5; + } + 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); if (pinchDist > 0) applyZoom(dist / pinchDist, m.x, m.y); @@ -946,13 +1086,19 @@ function touchEnd(e) { e.preventDefault(); if (e.touches.length === 0) { - // a tap that never moved still paints its dot - if (touchMode === 'paint' && !touchPainted) paintAt(curX, curY); + // a tap that never moved still paints its dot (or picks its point) + if (touchMode === 'paint' && !touchPainted) { + if (picking) pickAt(curX, curY); + else paintAt(curX, curY); + } + if (touchMode === 'gesture') startInertia(); touchMode = null; painting = false; + picking = false; lastPaintX = null; lastPaintY = null; - mouseInside = false; + // fade the preview out instead of hiding it instantly + if (mouseInside) setCursorFadeTarget(0); requestDraw(); } else if (touchMode === 'gesture') { // re-anchor to the remaining fingers so the camera doesn't jump @@ -967,6 +1113,15 @@ if (mode === 'brush' && !dragBrush) dragBrush = { anchorY, start: brush }; else if (mode === 'roundness' && !dragRoundness) dragRoundness = { anchorY, start: roundness }; 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'; + painting = false; + picking = false; + lastPaintX = null; + lastPaintY = null; + } } function endDragMode(mode) { if (mode === 'brush') dragBrush = null; @@ -1007,6 +1162,16 @@ if (k === 'x') startDragMode('roundness'); else if (k === 'z') startDragMode('brush'); else if (!wasDown && (k === 'r' || k === 'g' || k === 'b')) startDragMode('color'); + else if (k === 'c' && !wasDown && (touchMode === 'paint' || touchMode === 'adjust')) { + touchMode = 'paint'; + painting = false; + picking = true; + lastPaintX = null; + lastPaintY = null; + touchPainted = true; + pickAt(curX, curY); + requestDraw(); + } } }); window.addEventListener('keyup', (e) => { @@ -1023,13 +1188,19 @@ dragColor.r0 = r; dragColor.g0 = g; dragColor.b0 = b; } } - // releasing c mid-pick-drag -> seamlessly switch to painting + // releasing c mid-pick-drag -> back to adjusting if a modifier is still + // held on a hoverless screen, else seamlessly switch to painting if (k === 'c' && picking) { picking = false; - painting = true; - lastPaintX = curX; - lastPaintY = curY; - paintAt(curX, curY); + if (touchMode !== null && !hoverCapable() && (dragBrush || dragRoundness || dragColor)) { + touchMode = 'adjust'; + if (curClientY !== null) anchorDrags(curClientY); + } else { + painting = true; + lastPaintX = curX; + lastPaintY = curY; + paintAt(curX, curY); + } requestDraw(); } }); @@ -1100,6 +1271,7 @@ window.addEventListener('wheel', (e) => { e.preventDefault(); + stopInertia(); // re-pick mode each event from live modifiers so releasing cmd and // immediately starting a shift scroll switches over cleanly. if a