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 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 (!isValidDragTarget) return;

View File

@ -2778,11 +2778,17 @@
if (memoryPanel.classList.contains("open")) renderMemoryPanel();
});
// Close context menu on click elsewhere
// Close context menu on click/touch elsewhere
document.addEventListener("click", () => {
shapeContextMenu.classList.remove("open");
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
const memoryPanel = document.getElementById("memory-panel");
@ -3100,6 +3106,7 @@
// Touch gesture handling for two-finger pan + pinch-to-zoom
let lastTouchCenter = null;
let lastTouchDist = null;
let isTouchPanning = false;
function getTouchCenter(touches) {
return {
@ -3117,9 +3124,17 @@
canvas.addEventListener("touchstart", (e) => {
if (e.touches.length === 2) {
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;
panPointerId = null;
interactionMode = "none";
clearTimeout(holdTimer);
holdTimer = null;
canvas.style.cursor = "";
lastTouchCenter = getTouchCenter(e.touches);
lastTouchDist = getTouchDist(e.touches);
@ -3127,7 +3142,7 @@
}, { passive: false });
canvas.addEventListener("touchmove", (e) => {
if (e.touches.length === 2) {
if (e.touches.length === 2 && isTouchPanning) {
e.preventDefault();
const currentCenter = getTouchCenter(e.touches);
@ -3163,6 +3178,7 @@
if (e.touches.length < 2) {
lastTouchCenter = null;
lastTouchDist = null;
isTouchPanning = false;
}
});
@ -3242,6 +3258,7 @@
const PAN_THRESHOLD = 4; // px movement to confirm pan intent
canvas.addEventListener("pointerdown", (e) => {
if (isTouchPanning) return; // two-finger gesture owns the canvas
if (e.target !== canvas && e.target !== canvasContent) return;
if (connectMode) return;