diff --git a/lib/folk-shape.ts b/lib/folk-shape.ts index 53cd4db..3f076f4 100644 --- a/lib/folk-shape.ts +++ b/lib/folk-shape.ts @@ -235,6 +235,13 @@ export class FolkShape extends FolkElement { #lastTouchPos: Point | null = null; #isTouchDragging = false; #dragOffset: Point | null = null; + #touchStartPos: Point | null = null; + #longPressTimer: ReturnType | null = null; + #extraLongPressTimer: ReturnType | 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() { 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) { // In feed mode, suppress all drag/resize interactions 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 if (event.touches.length >= 2) { - if (this.#isTouchDragging) { - this.#lastTouchPos = null; - this.#isTouchDragging = false; - this.#internals.states.delete("move"); - } + this.#clearLongPressTimers(); + this.#lastTouchPos = null; + this.#isTouchDragging = false; + this.#touchDidLongPress = false; + this.#internals.states.delete("move"); return; } @@ -526,16 +538,59 @@ export class FolkShape extends FolkElement { return; } + this.#touchStartPos = { x: touch.clientX, y: touch.clientY }; this.#lastTouchPos = { x: touch.clientX, y: touch.clientY }; - this.#isTouchDragging = true; - this.#internals.states.add("move"); + this.#isTouchDragging = false; + 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(); return; } - if (event.type === "touchmove" && this.#isTouchDragging && event.touches.length === 1) { + if (event.type === "touchmove" && event.touches.length === 1) { 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 moveDelta = { x: (touch.clientX - this.#lastTouchPos.x) / zoom, @@ -548,13 +603,18 @@ export class FolkShape extends FolkElement { this.#rect.y += moveDelta.y; this.requestUpdate(); this.#dispatchTransformEvent(); + } else { + this.#lastTouchPos = { x: touch.clientX, y: touch.clientY }; } return; } if (event.type === "touchend") { + this.#clearLongPressTimers(); this.#lastTouchPos = null; + this.#touchStartPos = null; this.#isTouchDragging = false; + this.#touchDidLongPress = false; this.#internals.states.delete("move"); return; } diff --git a/website/canvas.html b/website/canvas.html index caf15e7..08dd91e 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -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 + // On touch devices, first touch moves; selection comes from long-press (touch-select event) shape.addEventListener("pointerdown", (e) => { + if (e.pointerType === "touch") return; // handled by touch-select if (e.shiftKey || e.metaKey || e.ctrlKey) { - // Additive toggle if (selectedShapeIds.has(shape.id)) { selectedShapeIds.delete(shape.id); } else { @@ -3882,6 +3883,16 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest 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 shape.addEventListener("close", () => { const did = getLocalDID();