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:
parent
c046acf9ce
commit
0d7c6d08b3
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue