fix(canvas): mobile touch — first touch moves, long-press selects, extra-long opens context menu

Three-tier touch interaction on mobile canvas:
- Immediate drag on finger movement (8px threshold)
- Long press (500ms) selects shape with haptic feedback
- Extra long press (1000ms) opens context menu
- Cancel timers on movement or two-finger gesture

Skip pointerdown selection for touch events in canvas.html,
handle via touch-select custom event from folk-shape instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-25 16:58:11 -07:00
parent c046acf9ce
commit 0d7c6d08b3
2 changed files with 81 additions and 10 deletions

View File

@ -235,6 +235,13 @@ export class FolkShape extends FolkElement {
#lastTouchPos: Point | null = null; #lastTouchPos: Point | null = null;
#isTouchDragging = false; #isTouchDragging = false;
#dragOffset: Point | null = null; #dragOffset: Point | null = null;
#touchStartPos: Point | null = null;
#longPressTimer: ReturnType<typeof setTimeout> | null = null;
#extraLongPressTimer: ReturnType<typeof setTimeout> | null = null;
#touchDidLongPress = false;
static LONG_PRESS_MS = 500;
static EXTRA_LONG_PRESS_MS = 1000;
static TOUCH_MOVE_THRESHOLD = 8; // px before considered a drag
get x() { get x() {
return this.#rect.x; return this.#rect.x;
@ -485,6 +492,11 @@ export class FolkShape extends FolkElement {
}; };
} }
#clearLongPressTimers() {
if (this.#longPressTimer) { clearTimeout(this.#longPressTimer); this.#longPressTimer = null; }
if (this.#extraLongPressTimer) { clearTimeout(this.#extraLongPressTimer); this.#extraLongPressTimer = null; }
}
handleEvent(event: PointerEvent | KeyboardEvent | TouchEvent) { handleEvent(event: PointerEvent | KeyboardEvent | TouchEvent) {
// In feed mode, suppress all drag/resize interactions // In feed mode, suppress all drag/resize interactions
if (this.closest('#canvas.feed-mode')) return; if (this.closest('#canvas.feed-mode')) return;
@ -506,11 +518,11 @@ export class FolkShape extends FolkElement {
// Two-finger gesture → cancel shape drag so canvas pan takes over // Two-finger gesture → cancel shape drag so canvas pan takes over
if (event.touches.length >= 2) { if (event.touches.length >= 2) {
if (this.#isTouchDragging) { this.#clearLongPressTimers();
this.#lastTouchPos = null; this.#lastTouchPos = null;
this.#isTouchDragging = false; this.#isTouchDragging = false;
this.#touchDidLongPress = false;
this.#internals.states.delete("move"); this.#internals.states.delete("move");
}
return; return;
} }
@ -526,16 +538,59 @@ export class FolkShape extends FolkElement {
return; return;
} }
this.#touchStartPos = { x: touch.clientX, y: touch.clientY };
this.#lastTouchPos = { x: touch.clientX, y: touch.clientY }; this.#lastTouchPos = { x: touch.clientX, y: touch.clientY };
this.#isTouchDragging = true; this.#isTouchDragging = false;
this.#internals.states.add("move"); this.#touchDidLongPress = false;
// Long press → select shape
this.#longPressTimer = setTimeout(() => {
this.#longPressTimer = null;
this.#touchDidLongPress = true;
// Stop any drag — switch to selected state
this.#isTouchDragging = false;
this.#internals.states.delete("move");
navigator?.vibrate?.(30);
this.dispatchEvent(new CustomEvent("touch-select", {
bubbles: true, composed: true,
detail: { shapeId: this.id },
}));
}, FolkShape.LONG_PRESS_MS);
// Extra long press → context menu
this.#extraLongPressTimer = setTimeout(() => {
this.#extraLongPressTimer = null;
navigator?.vibrate?.(50);
const cmEvent = new MouseEvent("contextmenu", {
bubbles: true, cancelable: true,
clientX: touch.clientX, clientY: touch.clientY,
});
this.dispatchEvent(cmEvent);
}, FolkShape.EXTRA_LONG_PRESS_MS);
this.focus(); this.focus();
return; return;
} }
if (event.type === "touchmove" && this.#isTouchDragging && event.touches.length === 1) { if (event.type === "touchmove" && event.touches.length === 1) {
const touch = event.touches[0]; const touch = event.touches[0];
if (this.#lastTouchPos) {
// Check if finger moved past threshold to start drag
if (!this.#isTouchDragging && this.#touchStartPos) {
const dx = touch.clientX - this.#touchStartPos.x;
const dy = touch.clientY - this.#touchStartPos.y;
if (Math.abs(dx) > FolkShape.TOUCH_MOVE_THRESHOLD ||
Math.abs(dy) > FolkShape.TOUCH_MOVE_THRESHOLD) {
// Movement → cancel long press timers, begin drag
this.#clearLongPressTimers();
if (!this.#touchDidLongPress) {
this.#isTouchDragging = true;
this.#internals.states.add("move");
}
}
}
if (this.#isTouchDragging && this.#lastTouchPos) {
const zoom = (window.visualViewport?.scale ?? 1) * this.#getParentScale(); const zoom = (window.visualViewport?.scale ?? 1) * this.#getParentScale();
const moveDelta = { const moveDelta = {
x: (touch.clientX - this.#lastTouchPos.x) / zoom, x: (touch.clientX - this.#lastTouchPos.x) / zoom,
@ -548,13 +603,18 @@ export class FolkShape extends FolkElement {
this.#rect.y += moveDelta.y; this.#rect.y += moveDelta.y;
this.requestUpdate(); this.requestUpdate();
this.#dispatchTransformEvent(); this.#dispatchTransformEvent();
} else {
this.#lastTouchPos = { x: touch.clientX, y: touch.clientY };
} }
return; return;
} }
if (event.type === "touchend") { if (event.type === "touchend") {
this.#clearLongPressTimers();
this.#lastTouchPos = null; this.#lastTouchPos = null;
this.#touchStartPos = null;
this.#isTouchDragging = false; this.#isTouchDragging = false;
this.#touchDidLongPress = false;
this.#internals.states.delete("move"); this.#internals.states.delete("move");
return; return;
} }

View File

@ -3866,9 +3866,10 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
}); });
// Track selection for MI bridge — supports Shift/Ctrl+click multi-select // Track selection for MI bridge — supports Shift/Ctrl+click multi-select
// On touch devices, first touch moves; selection comes from long-press (touch-select event)
shape.addEventListener("pointerdown", (e) => { shape.addEventListener("pointerdown", (e) => {
if (e.pointerType === "touch") return; // handled by touch-select
if (e.shiftKey || e.metaKey || e.ctrlKey) { if (e.shiftKey || e.metaKey || e.ctrlKey) {
// Additive toggle
if (selectedShapeIds.has(shape.id)) { if (selectedShapeIds.has(shape.id)) {
selectedShapeIds.delete(shape.id); selectedShapeIds.delete(shape.id);
} else { } else {
@ -3882,6 +3883,16 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
updateSelectionVisuals(); updateSelectionVisuals();
}); });
// Mobile long-press → select shape
shape.addEventListener("touch-select", (e) => {
if (!selectedShapeIds.has(shape.id)) {
selectedShapeIds.clear();
selectedShapeIds.add(shape.id);
}
selectedShapeId = shape.id;
updateSelectionVisuals();
});
// Close button — forget (fade) instead of remove // Close button — forget (fade) instead of remove
shape.addEventListener("close", () => { shape.addEventListener("close", () => {
const did = getLocalDID(); const did = getLocalDID();