fix: prevent pointer events from hijacking two-finger touch pan

On touch devices, both pointer and touch events fire. When a second
finger was added, the pointer handler re-captured the interaction,
fighting the touch-based pan/pinch. Now the touch handler releases
pointer captures and sets a flag that blocks the pointer handler
during two-finger gestures. Also cancels shape drag on multi-touch
and closes the context menu on touchstart for reliable mobile dismiss.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-01 18:02:53 -08:00
parent 658eb966d6
commit 5e0f30567a
2 changed files with 30 additions and 3 deletions

View File

@ -438,6 +438,16 @@ export class FolkShape extends FolkElement {
const isDragHandle = target?.closest?.(".header, [data-drag]") !== null; const isDragHandle = target?.closest?.(".header, [data-drag]") !== null;
const isValidDragTarget = target === this || isDragHandle; const isValidDragTarget = target === this || isDragHandle;
// Two-finger gesture → cancel shape drag so canvas pan takes over
if (event.touches.length >= 2) {
if (this.#isTouchDragging) {
this.#lastTouchPos = null;
this.#isTouchDragging = false;
this.#internals.states.delete("move");
}
return;
}
if (event.type === "touchstart" && event.touches.length === 1) { if (event.type === "touchstart" && event.touches.length === 1) {
if (!isValidDragTarget) return; if (!isValidDragTarget) return;

View File

@ -2778,11 +2778,17 @@
if (memoryPanel.classList.contains("open")) renderMemoryPanel(); if (memoryPanel.classList.contains("open")) renderMemoryPanel();
}); });
// Close context menu on click elsewhere // Close context menu on click/touch elsewhere
document.addEventListener("click", () => { document.addEventListener("click", () => {
shapeContextMenu.classList.remove("open"); shapeContextMenu.classList.remove("open");
contextShapeId = null; contextShapeId = null;
}); });
document.addEventListener("touchstart", (e) => {
if (!e.target.closest("#shape-context-menu")) {
shapeContextMenu.classList.remove("open");
contextShapeId = null;
}
});
// Memory panel — browse and remember forgotten shapes // Memory panel — browse and remember forgotten shapes
const memoryPanel = document.getElementById("memory-panel"); const memoryPanel = document.getElementById("memory-panel");
@ -3100,6 +3106,7 @@
// Touch gesture handling for two-finger pan + pinch-to-zoom // Touch gesture handling for two-finger pan + pinch-to-zoom
let lastTouchCenter = null; let lastTouchCenter = null;
let lastTouchDist = null; let lastTouchDist = null;
let isTouchPanning = false;
function getTouchCenter(touches) { function getTouchCenter(touches) {
return { return {
@ -3117,9 +3124,17 @@
canvas.addEventListener("touchstart", (e) => { canvas.addEventListener("touchstart", (e) => {
if (e.touches.length === 2) { if (e.touches.length === 2) {
e.preventDefault(); e.preventDefault();
// Cancel any single-finger pan to avoid conflict isTouchPanning = true;
// Release any captured pointer so pointer events stop competing
if (panPointerId !== null) {
try { canvas.releasePointerCapture(panPointerId); } catch {}
}
// Cancel pointer-based pan state completely
isPanning = false; isPanning = false;
panPointerId = null; panPointerId = null;
interactionMode = "none";
clearTimeout(holdTimer);
holdTimer = null;
canvas.style.cursor = ""; canvas.style.cursor = "";
lastTouchCenter = getTouchCenter(e.touches); lastTouchCenter = getTouchCenter(e.touches);
lastTouchDist = getTouchDist(e.touches); lastTouchDist = getTouchDist(e.touches);
@ -3127,7 +3142,7 @@
}, { passive: false }); }, { passive: false });
canvas.addEventListener("touchmove", (e) => { canvas.addEventListener("touchmove", (e) => {
if (e.touches.length === 2) { if (e.touches.length === 2 && isTouchPanning) {
e.preventDefault(); e.preventDefault();
const currentCenter = getTouchCenter(e.touches); const currentCenter = getTouchCenter(e.touches);
@ -3163,6 +3178,7 @@
if (e.touches.length < 2) { if (e.touches.length < 2) {
lastTouchCenter = null; lastTouchCenter = null;
lastTouchDist = null; lastTouchDist = null;
isTouchPanning = false;
} }
}); });
@ -3242,6 +3258,7 @@
const PAN_THRESHOLD = 4; // px movement to confirm pan intent const PAN_THRESHOLD = 4; // px movement to confirm pan intent
canvas.addEventListener("pointerdown", (e) => { canvas.addEventListener("pointerdown", (e) => {
if (isTouchPanning) return; // two-finger gesture owns the canvas
if (e.target !== canvas && e.target !== canvasContent) return; if (e.target !== canvas && e.target !== canvasContent) return;
if (connectMode) return; if (connectMode) return;