diff --git a/src/app/components/project/CanvasBlock.tsx b/src/app/components/project/CanvasBlock.tsx index 477f4d7..ca08f06 100644 --- a/src/app/components/project/CanvasBlock.tsx +++ b/src/app/components/project/CanvasBlock.tsx @@ -249,7 +249,7 @@ const CanvasBlockComponent = (props: CanvasBlockProps) => { ); const onLongPress = useCallback( - (e: React.TouchEvent | TouchEvent) => { + (e: React.PointerEvent | PointerEvent | React.TouchEvent | TouchEvent) => { handleContentContextMenu(e as unknown as React.MouseEvent); }, [handleContentContextMenu], @@ -1086,7 +1086,7 @@ const CanvasBlockComponent = (props: CanvasBlockProps) => { diff --git a/src/app/components/project/ChecklistBlock.tsx b/src/app/components/project/ChecklistBlock.tsx index ad282f1..ad3537d 100644 --- a/src/app/components/project/ChecklistBlock.tsx +++ b/src/app/components/project/ChecklistBlock.tsx @@ -169,18 +169,19 @@ const ChecklistBlock = memo(({ id, data, selected }: ChecklistBlockProps) => { const status = percentage === 100 ? "complete" : percentage > 0 ? "in-progress" : "idle"; - const onLongPress = useCallback((e: React.TouchEvent | TouchEvent) => { - const target = e.target as HTMLElement; - const event = new MouseEvent("contextmenu", { - bubbles: true, - cancelable: true, - clientX: - "touches" in e ? e.touches[0].clientX : (e as MouseEvent).clientX, - clientY: - "touches" in e ? e.touches[0].clientY : (e as MouseEvent).clientY, - }); - target.dispatchEvent(event); - }, []); + const onLongPress = useCallback( + (e: React.PointerEvent | PointerEvent | React.TouchEvent | TouchEvent) => { + const target = e.target as HTMLElement; + const event = new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: (e as PointerEvent).clientX, + clientY: (e as PointerEvent).clientY, + }); + target.dispatchEvent(event); + }, + [], + ); const touchHandlers = useTouchGestures({ onLongPress, @@ -831,7 +832,7 @@ const ChecklistBlock = memo(({ id, data, selected }: ChecklistBlockProps) => { diff --git a/src/app/components/project/ContactBlock.tsx b/src/app/components/project/ContactBlock.tsx index c9fc505..17f2657 100644 --- a/src/app/components/project/ContactBlock.tsx +++ b/src/app/components/project/ContactBlock.tsx @@ -263,7 +263,7 @@ const ContactBlock = memo(({ id, data, selected }: ContactBlockProps) => { diff --git a/src/app/components/project/FileBlock.tsx b/src/app/components/project/FileBlock.tsx index 31d9f9c..74629f3 100644 --- a/src/app/components/project/FileBlock.tsx +++ b/src/app/components/project/FileBlock.tsx @@ -402,7 +402,7 @@ const FileBlock = (props: CanvasBlockProps) => { diff --git a/src/app/components/project/GitBlock.tsx b/src/app/components/project/GitBlock.tsx index aded806..0d8c7e9 100644 --- a/src/app/components/project/GitBlock.tsx +++ b/src/app/components/project/GitBlock.tsx @@ -109,7 +109,7 @@ const GitBlock = (props: CanvasBlockProps) => { ); const onLongPress = useCallback( - (e: React.TouchEvent | TouchEvent) => { + (e: React.PointerEvent | PointerEvent | React.TouchEvent | TouchEvent) => { handleContentContextMenu(e as unknown as React.MouseEvent); }, [handleContentContextMenu], @@ -681,7 +681,7 @@ const GitBlock = (props: CanvasBlockProps) => { diff --git a/src/app/components/project/NoteBlock.tsx b/src/app/components/project/NoteBlock.tsx index 51843d7..d472f2d 100644 --- a/src/app/components/project/NoteBlock.tsx +++ b/src/app/components/project/NoteBlock.tsx @@ -733,7 +733,7 @@ const NoteBlock = memo( onChange={handleTitleChange} onFocus={() => setIsTitleEditing(true)} onBlur={() => setIsTitleEditing(false)} - className="block-title" + className="block-title nodrag" placeholder={dict.blocks.title || "..."} disabled={isReadOnly} /> diff --git a/src/app/components/project/PaletteBlock.tsx b/src/app/components/project/PaletteBlock.tsx index c3f9cff..8a27900 100644 --- a/src/app/components/project/PaletteBlock.tsx +++ b/src/app/components/project/PaletteBlock.tsx @@ -130,7 +130,11 @@ const PaletteBlock = memo(({ id, data, selected }: PaletteBlockProps) => { ); const onLongPress = useCallback( - (e: React.TouchEvent | TouchEvent, x: number, y: number) => { + ( + e: React.PointerEvent | PointerEvent | React.TouchEvent | TouchEvent, + x: number, + y: number, + ) => { if (isReadOnly) return; const target = e.target as HTMLElement; const colorItem = target.closest("[data-color-index]"); @@ -278,7 +282,7 @@ const PaletteBlock = memo(({ id, data, selected }: PaletteBlockProps) => { diff --git a/src/app/components/project/ProjectCanvas.tsx b/src/app/components/project/ProjectCanvas.tsx index c29e625..d37a1c2 100644 --- a/src/app/components/project/ProjectCanvas.tsx +++ b/src/app/components/project/ProjectCanvas.tsx @@ -85,6 +85,7 @@ import { } from "./hooks/useProjectCanvasState"; import { DEFAULT_VIEWPORT } from "./utils/constants"; import { useTouchGestures } from "./hooks/useTouchGestures"; +import { useCanvasTouchViewport } from "./hooks/useCanvasTouchViewport"; const FIXED_EXTENT: [[number, number], [number, number]] = [ [-5000, -4000], [8000, 5000], @@ -279,22 +280,9 @@ function ProjectCanvasContent({ initialProjectId }: ProjectCanvasProps) { const routerRef = useRef(router); const dictRef = useRef(dict); - const isTouchRef = useRef(false); - - useEffect(() => { - const onTouch = () => { - isTouchRef.current = true; - }; - const onMouse = () => { - isTouchRef.current = false; - }; - document.addEventListener("touchstart", onTouch, true); - document.addEventListener("mousedown", onMouse, true); - return () => { - document.removeEventListener("touchstart", onTouch, true); - document.removeEventListener("mousedown", onMouse, true); - }; - }, []); + // Track the last pointer type ("touch" | "pen" | "mouse") so we can + // suppress the browser context menu for non-mouse input. + const pointerTypeRef = useRef(""); useEffect(() => { dictRef.current = dict; @@ -761,7 +749,11 @@ function ProjectCanvasContent({ initialProjectId }: ProjectCanvasProps) { ); const onLongPress = useCallback( - (e: React.TouchEvent | TouchEvent, x: number, y: number) => { + ( + e: React.PointerEvent | PointerEvent | React.TouchEvent | TouchEvent, + x: number, + y: number, + ) => { if (isReadOnly) return; // Clear any existing selection to prevent text selection on long press @@ -854,34 +846,6 @@ function ProjectCanvasContent({ initialProjectId }: ProjectCanvasProps) { [onBlockContextMenu, originalOnPaneClick], ); - const onPinch = useCallback( - (delta: number, centerX: number, centerY: number) => { - if (isReadOnly) return; - const { x, y, zoom } = getViewport(); - - const sensitivity = 0.01; // Boosted - const factor = Math.pow(2, delta * sensitivity); - const nextZoom = Math.min(Math.max(zoom * factor, 0.1), 4); - - if (nextZoom === zoom) return; - - const rect = flowContainerRef.current?.getBoundingClientRect(); - if (!rect) return; - - const localX = centerX - rect.left; - const localY = centerY - rect.top; - - const flowX = (localX - x) / zoom; - const flowY = (localY - y) / zoom; - - const nextX = localX - flowX * nextZoom; - const nextY = localY - flowY * nextZoom; - - setViewport({ x: nextX, y: nextY, zoom: nextZoom }, { duration: 0 }); - }, - [getViewport, setViewport, isReadOnly], - ); - const touchHandlers = useTouchGestures({ onLongPress, onDoubleTap: (e, x, y) => { @@ -917,10 +881,24 @@ function ProjectCanvasContent({ initialProjectId }: ProjectCanvasProps) { clientY: y, } as unknown as React.MouseEvent); }, - onPinch, allowLongPress: false, }); + const canvasTouchViewportHandlers = useCanvasTouchViewport({ + disabled: isReadOnly, + minZoom: 0.1, + maxZoom: 4, + getViewport, + setViewport, + onPaneDoubleTap: (x, y) => { + onPaneContextMenu({ + preventDefault: () => {}, + clientX: x, + clientY: y, + } as unknown as React.MouseEvent); + }, + }); + useEffect(() => { const container = flowContainerRef.current; if (!container) return; @@ -1231,6 +1209,15 @@ function ProjectCanvasContent({ initialProjectId }: ProjectCanvasProps) { onDrop={handleExternalDrop} tabIndex={0} ref={flowContainerRef} + onPointerDownCapture={(e) => { + pointerTypeRef.current = e.pointerType; + canvasTouchViewportHandlers.onPointerDownCapture(e); + }} + onPointerMoveCapture={canvasTouchViewportHandlers.onPointerMoveCapture} + onPointerUpCapture={canvasTouchViewportHandlers.onPointerUpCapture} + onPointerCancelCapture={ + canvasTouchViewportHandlers.onPointerCancelCapture + } {...touchHandlers} > {isLoading && ( @@ -1311,7 +1298,10 @@ function ProjectCanvasContent({ initialProjectId }: ProjectCanvasProps) { onPointerMove={onPointerMove} onPointerLeave={onPointerLeave} onPaneContextMenu={(e) => { - if (isTouchRef.current) { + if ( + pointerTypeRef.current === "touch" || + pointerTypeRef.current === "pen" + ) { e.preventDefault(); return; } diff --git a/src/app/components/project/ShellBlock.tsx b/src/app/components/project/ShellBlock.tsx index 01636ce..e6395fc 100644 --- a/src/app/components/project/ShellBlock.tsx +++ b/src/app/components/project/ShellBlock.tsx @@ -329,18 +329,19 @@ const ShellBlock = memo(({ id, data, selected }: ShellBlockProps) => { const isBeingMoved = !!data.movingUserColor; const borderColor = isBeingMoved ? data.movingUserColor : "var(--border)"; - const onLongPress = useCallback((e: React.TouchEvent | TouchEvent) => { - const target = e.target as HTMLElement; - const event = new MouseEvent("contextmenu", { - bubbles: true, - cancelable: true, - clientX: - "touches" in e ? e.touches[0].clientX : (e as MouseEvent).clientX, - clientY: - "touches" in e ? e.touches[0].clientY : (e as MouseEvent).clientY, - }); - target.dispatchEvent(event); - }, []); + const onLongPress = useCallback( + (e: React.PointerEvent | PointerEvent | React.TouchEvent | TouchEvent) => { + const target = e.target as HTMLElement; + const event = new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: (e as PointerEvent).clientX, + clientY: (e as PointerEvent).clientY, + }); + target.dispatchEvent(event); + }, + [], + ); const touchHandlers = useTouchGestures({ onLongPress, @@ -422,7 +423,7 @@ const ShellBlock = memo(({ id, data, selected }: ShellBlockProps) => { diff --git a/src/app/components/project/SketchBlock.tsx b/src/app/components/project/SketchBlock.tsx index 70d750c..856bc68 100644 --- a/src/app/components/project/SketchBlock.tsx +++ b/src/app/components/project/SketchBlock.tsx @@ -260,7 +260,7 @@ const SketchBlock = memo((props: SketchBlockProps) => { diff --git a/src/app/components/project/SnippetBlock.tsx b/src/app/components/project/SnippetBlock.tsx index 54549e7..0f831e7 100644 --- a/src/app/components/project/SnippetBlock.tsx +++ b/src/app/components/project/SnippetBlock.tsx @@ -289,18 +289,19 @@ const SnippetBlock = memo(({ id, data, selected }: SnippetBlockProps) => { const isBeingMoved = !!data.movingUserColor; const borderColor = isBeingMoved ? data.movingUserColor : "var(--border)"; - const onLongPress = useCallback((e: React.TouchEvent | TouchEvent) => { - const target = e.target as HTMLElement; - const event = new MouseEvent("contextmenu", { - bubbles: true, - cancelable: true, - clientX: - "touches" in e ? e.touches[0].clientX : (e as MouseEvent).clientX, - clientY: - "touches" in e ? e.touches[0].clientY : (e as MouseEvent).clientY, - }); - target.dispatchEvent(event); - }, []); + const onLongPress = useCallback( + (e: React.PointerEvent | PointerEvent | React.TouchEvent | TouchEvent) => { + const target = e.target as HTMLElement; + const event = new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: (e as PointerEvent).clientX, + clientY: (e as PointerEvent).clientY, + }); + target.dispatchEvent(event); + }, + [], + ); const touchHandlers = useTouchGestures({ onLongPress, @@ -431,7 +432,7 @@ const SnippetBlock = memo(({ id, data, selected }: SnippetBlockProps) => { diff --git a/src/app/components/project/VercelBlock.tsx b/src/app/components/project/VercelBlock.tsx index 0e2115e..53fdc55 100644 --- a/src/app/components/project/VercelBlock.tsx +++ b/src/app/components/project/VercelBlock.tsx @@ -253,7 +253,7 @@ const VercelBlock = (props: CanvasBlockProps) => { ); const onLongPress = useCallback( - (e: React.TouchEvent | TouchEvent) => { + (e: React.PointerEvent | PointerEvent | React.TouchEvent | TouchEvent) => { handleContentContextMenu(e as unknown as React.MouseEvent); }, [handleContentContextMenu], @@ -486,7 +486,7 @@ const VercelBlock = (props: CanvasBlockProps) => { diff --git a/src/app/components/project/VideoBlock.tsx b/src/app/components/project/VideoBlock.tsx index fe5570e..b5bd359 100644 --- a/src/app/components/project/VideoBlock.tsx +++ b/src/app/components/project/VideoBlock.tsx @@ -116,7 +116,7 @@ const VideoBlock = memo(({ id, data, selected }: VideoBlockProps) => { ); const onLongPress = useCallback( - (e: React.TouchEvent | TouchEvent) => { + (e: React.PointerEvent | PointerEvent | React.TouchEvent | TouchEvent) => { handleContentContextMenu(e as unknown as React.MouseEvent); }, [handleContentContextMenu], @@ -278,7 +278,7 @@ const VideoBlock = memo(({ id, data, selected }: VideoBlockProps) => { diff --git a/src/app/components/project/hooks/useCanvasTouchViewport.test.ts b/src/app/components/project/hooks/useCanvasTouchViewport.test.ts new file mode 100644 index 0000000..fbbafd3 --- /dev/null +++ b/src/app/components/project/hooks/useCanvasTouchViewport.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { + classifyCanvasTouchTarget, + computePinchViewport, +} from "./useCanvasTouchViewport"; + +describe("useCanvasTouchViewport helpers", () => { + it("classifies the pane as canvas navigation", () => { + const pane = document.createElement("div"); + pane.className = "react-flow__pane"; + document.body.appendChild(pane); + + expect(classifyCanvasTouchTarget(pane)).toBe("pane"); + + pane.remove(); + }); + + it("classifies block content separately from block chrome", () => { + const node = document.createElement("div"); + node.className = "react-flow__node"; + const content = document.createElement("div"); + content.className = "nopan"; + node.appendChild(content); + document.body.appendChild(node); + + expect(classifyCanvasTouchTarget(node)).toBe("block"); + expect(classifyCanvasTouchTarget(content)).toBe("content"); + + node.remove(); + }); + + it("prefers block chrome over the node wrapper nopan class", () => { + const node = document.createElement("div"); + node.className = "react-flow__node nopan"; + const blockCard = document.createElement("div"); + blockCard.className = "block-card"; + const header = document.createElement("div"); + header.className = "block-header"; + blockCard.appendChild(header); + node.appendChild(blockCard); + document.body.appendChild(node); + + expect(classifyCanvasTouchTarget(header)).toBe("block"); + + node.remove(); + }); + + it("treats resize handles inside draggable nodes as block chrome", () => { + const node = document.createElement("div"); + node.className = "react-flow__node nopan"; + const handle = document.createElement("div"); + handle.className = "react-flow__resize-control top right"; + node.appendChild(handle); + document.body.appendChild(node); + + expect(classifyCanvasTouchTarget(handle)).toBe("block"); + + node.remove(); + }); + + it("keeps the pinch midpoint anchored while zooming", () => { + const viewport = computePinchViewport( + { x: 100, y: 80, zoom: 1 }, + { x: 200, y: 150 }, + { x: 220, y: 165 }, + 100, + 200, + 0.1, + 4, + ); + + expect(viewport.zoom).toBe(2); + expect(viewport.x).toBeCloseTo(20); + expect(viewport.y).toBeCloseTo(95); + }); + + it("clamps pinch zoom to the configured bounds", () => { + const viewport = computePinchViewport( + { x: 0, y: 0, zoom: 1 }, + { x: 100, y: 100 }, + { x: 100, y: 100 }, + 100, + 1000, + 0.1, + 1.5, + ); + + expect(viewport.zoom).toBe(1.5); + }); +}); diff --git a/src/app/components/project/hooks/useCanvasTouchViewport.ts b/src/app/components/project/hooks/useCanvasTouchViewport.ts new file mode 100644 index 0000000..ff1e44f --- /dev/null +++ b/src/app/components/project/hooks/useCanvasTouchViewport.ts @@ -0,0 +1,370 @@ +"use client"; + +import { Viewport } from "@xyflow/react"; +import { useCallback, useRef } from "react"; + +const PANE_SELECTOR = ".react-flow__pane"; +const CONTENT_TOUCH_SELECTOR = [ + ".nopan", + ".nowheel", + "input", + "textarea", + "[contenteditable='true']", + ".ProseMirror", +].join(", "); +const BLOCK_TOUCH_SELECTOR = [ + ".react-flow__resize-control", + ".react-flow__node", + ".block-card", + ".block-header", + ".shell-block-header", + ".handle-drag-target", +].join(", "); +const DOUBLE_TAP_DELAY = 400; +const DOUBLE_TAP_DISTANCE = 40; +const TAP_MOVE_THRESHOLD = 12; + +export type CanvasTouchIntent = "pane" | "block" | "content" | "ignore"; + +interface TouchPoint { + x: number; + y: number; +} + +interface TouchPanState { + pointerId: number; + start: TouchPoint; + viewport: Viewport; +} + +interface TouchPinchState { + pointerIds: [number, number]; + initialMidpoint: TouchPoint; + initialDistance: number; + viewport: Viewport; +} + +export interface UseCanvasTouchViewportProps { + disabled?: boolean; + minZoom: number; + maxZoom: number; + getViewport: () => Viewport; + setViewport: ( + viewport: Viewport, + options?: { duration?: number }, + ) => Promise | void; + onPaneDoubleTap?: (x: number, y: number) => void; +} + +export function classifyCanvasTouchTarget( + target: EventTarget | null, +): CanvasTouchIntent { + if (!(target instanceof HTMLElement)) return "ignore"; + + const getClosestMatch = (selector: string): HTMLElement | null => { + const match = target.closest(selector); + return match instanceof HTMLElement ? match : null; + }; + const getAncestorDistance = (ancestor: HTMLElement | null) => { + if (!ancestor) return Number.POSITIVE_INFINITY; + + let distance = 0; + let current: HTMLElement | null = target; + while (current && current !== ancestor) { + current = current.parentElement; + distance += 1; + } + + return current === ancestor ? distance : Number.POSITIVE_INFINITY; + }; + + const blockTarget = getClosestMatch(BLOCK_TOUCH_SELECTOR); + const contentTarget = getClosestMatch(CONTENT_TOUCH_SELECTOR); + const paneTarget = getClosestMatch(PANE_SELECTOR); + + if (blockTarget || contentTarget) { + const blockDistance = getAncestorDistance(blockTarget); + const contentDistance = getAncestorDistance(contentTarget); + + if (blockDistance <= contentDistance) return "block"; + return "content"; + } + + if (paneTarget) return "pane"; + return "ignore"; +} + +export function computePinchViewport( + initialViewport: Viewport, + initialMidpoint: TouchPoint, + currentMidpoint: TouchPoint, + initialDistance: number, + currentDistance: number, + minZoom: number, + maxZoom: number, +): Viewport { + const zoomFactor = + initialDistance > 0 ? currentDistance / initialDistance : 1; + const zoom = Math.min( + Math.max(initialViewport.zoom * zoomFactor, minZoom), + maxZoom, + ); + const flowX = (initialMidpoint.x - initialViewport.x) / initialViewport.zoom; + const flowY = (initialMidpoint.y - initialViewport.y) / initialViewport.zoom; + + return { + zoom, + x: currentMidpoint.x - flowX * zoom, + y: currentMidpoint.y - flowY * zoom, + }; +} + +function getDistance(first: TouchPoint, second: TouchPoint) { + return Math.hypot(second.x - first.x, second.y - first.y); +} + +function getMidpoint(first: TouchPoint, second: TouchPoint): TouchPoint { + return { + x: (first.x + second.x) / 2, + y: (first.y + second.y) / 2, + }; +} + +function getFirstTwoPointers(activePointers: Map) { + const entries = Array.from(activePointers.entries()); + if (entries.length < 2) return null; + + const [firstId, first] = entries[0]; + const [secondId, second] = entries[1]; + + if (!first || !second) return null; + + return { + pointerIds: [firstId, secondId] as [number, number], + first, + second, + }; +} + +export const useCanvasTouchViewport = ({ + disabled = false, + minZoom, + maxZoom, + getViewport, + setViewport, + onPaneDoubleTap, +}: UseCanvasTouchViewportProps) => { + const activeTouchIntentsRef = useRef>( + new Map(), + ); + const activePointersRef = useRef>(new Map()); + const panStateRef = useRef(null); + const pinchStateRef = useRef(null); + const movedRef = useRef(false); + const lastTapRef = useRef<{ time: number; x: number; y: number } | null>( + null, + ); + + const clearGestureState = useCallback(() => { + activePointersRef.current.clear(); + panStateRef.current = null; + pinchStateRef.current = null; + movedRef.current = false; + }, []); + + const releaseTrackedPointers = useCallback( + (container: HTMLDivElement) => { + activePointersRef.current.forEach((_, pointerId) => { + container.releasePointerCapture?.(pointerId); + }); + clearGestureState(); + }, + [clearGestureState], + ); + + const stopCanvasTouchEvent = useCallback( + (event: React.PointerEvent) => { + event.preventDefault(); + event.stopPropagation(); + }, + [], + ); + + const onPointerDownCapture = useCallback( + (event: React.PointerEvent) => { + if (disabled || event.pointerType !== "touch") return; + + const intent = classifyCanvasTouchTarget(event.target); + if (intent === "ignore") return; + + const container = event.currentTarget as HTMLDivElement; + activeTouchIntentsRef.current.set(event.pointerId, intent); + + if (intent !== "pane") { + releaseTrackedPointers(container); + return; + } + + const hasActiveNonPaneTouch = Array.from( + activeTouchIntentsRef.current.values(), + ).some((activeIntent) => activeIntent !== "pane"); + if (hasActiveNonPaneTouch) { + releaseTrackedPointers(container); + return; + } + + activePointersRef.current.set(event.pointerId, { + x: event.clientX, + y: event.clientY, + }); + + container.setPointerCapture?.(event.pointerId); + stopCanvasTouchEvent(event); + + if (activePointersRef.current.size === 1) { + panStateRef.current = { + pointerId: event.pointerId, + start: { x: event.clientX, y: event.clientY }, + viewport: getViewport(), + }; + pinchStateRef.current = null; + movedRef.current = false; + return; + } + + const firstTwoPointers = getFirstTwoPointers(activePointersRef.current); + if (!firstTwoPointers) return; + + const { pointerIds, first, second } = firstTwoPointers; + pinchStateRef.current = { + pointerIds, + initialMidpoint: getMidpoint(first, second), + initialDistance: getDistance(first, second), + viewport: getViewport(), + }; + panStateRef.current = null; + movedRef.current = false; + }, + [disabled, getViewport, releaseTrackedPointers, stopCanvasTouchEvent], + ); + + const onPointerMoveCapture = useCallback( + (event: React.PointerEvent) => { + if (event.pointerType !== "touch") return; + if (!activePointersRef.current.has(event.pointerId)) return; + + activePointersRef.current.set(event.pointerId, { + x: event.clientX, + y: event.clientY, + }); + + const pinchState = pinchStateRef.current; + if (pinchState) { + const first = activePointersRef.current.get(pinchState.pointerIds[0]); + const second = activePointersRef.current.get(pinchState.pointerIds[1]); + if (!first || !second) { + clearGestureState(); + return; + } + + const currentMidpoint = getMidpoint(first, second); + const currentDistance = getDistance(first, second); + const nextViewport = computePinchViewport( + pinchState.viewport, + pinchState.initialMidpoint, + currentMidpoint, + pinchState.initialDistance, + currentDistance, + minZoom, + maxZoom, + ); + + movedRef.current = true; + stopCanvasTouchEvent(event); + void setViewport(nextViewport, { duration: 0 }); + return; + } + + const panState = panStateRef.current; + if (!panState || panState.pointerId !== event.pointerId) return; + + const deltaX = event.clientX - panState.start.x; + const deltaY = event.clientY - panState.start.y; + + if (Math.hypot(deltaX, deltaY) > TAP_MOVE_THRESHOLD) { + movedRef.current = true; + lastTapRef.current = null; + } + + stopCanvasTouchEvent(event); + void setViewport( + { + ...panState.viewport, + x: panState.viewport.x + deltaX, + y: panState.viewport.y + deltaY, + }, + { duration: 0 }, + ); + }, + [clearGestureState, maxZoom, minZoom, setViewport, stopCanvasTouchEvent], + ); + + const finishTap = useCallback( + (x: number, y: number) => { + if (movedRef.current) { + lastTapRef.current = null; + return; + } + + const now = Date.now(); + const previousTap = lastTapRef.current; + if (previousTap) { + const timeDelta = now - previousTap.time; + const distance = Math.hypot(x - previousTap.x, y - previousTap.y); + if ( + timeDelta < DOUBLE_TAP_DELAY && + distance < DOUBLE_TAP_DISTANCE && + onPaneDoubleTap + ) { + onPaneDoubleTap(x, y); + lastTapRef.current = null; + return; + } + } + + lastTapRef.current = { time: now, x, y }; + }, + [onPaneDoubleTap], + ); + + const endTrackedTouch = useCallback( + (event: React.PointerEvent) => { + if (event.pointerType !== "touch") return; + activeTouchIntentsRef.current.delete(event.pointerId); + if (!activePointersRef.current.has(event.pointerId)) return; + + const container = event.currentTarget as HTMLDivElement; + container.releasePointerCapture?.(event.pointerId); + + const wasPinching = !!pinchStateRef.current; + const panState = panStateRef.current; + + if (panState && panState.pointerId === event.pointerId) { + finishTap(event.clientX, event.clientY); + } else if (wasPinching) { + lastTapRef.current = null; + } + + stopCanvasTouchEvent(event); + clearGestureState(); + }, + [clearGestureState, finishTap, stopCanvasTouchEvent], + ); + + return { + onPointerDownCapture, + onPointerMoveCapture, + onPointerUpCapture: endTrackedTouch, + onPointerCancelCapture: endTrackedTouch, + }; +}; diff --git a/src/app/components/project/hooks/useProjectCanvasState.ts b/src/app/components/project/hooks/useProjectCanvasState.ts index f3db669..9318694 100644 --- a/src/app/components/project/hooks/useProjectCanvasState.ts +++ b/src/app/components/project/hooks/useProjectCanvasState.ts @@ -1929,6 +1929,7 @@ export const useProjectCanvasState = ( return { ...block, draggable: isPreviewMode ? false : isLocked ? !!isOwner : true, + dragHandle: ".block-header, .shell-block-header, .handle-drag-target", selectable: !isPreviewMode, deletable: isPreviewMode ? false : !!canManage, data: { diff --git a/src/app/components/project/hooks/useTouchGestures.test.ts b/src/app/components/project/hooks/useTouchGestures.test.ts new file mode 100644 index 0000000..f867e42 --- /dev/null +++ b/src/app/components/project/hooks/useTouchGestures.test.ts @@ -0,0 +1,68 @@ +import { renderHook, act } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { useTouchGestures } from "./useTouchGestures"; + +function createPointerEvent( + pointerId: number, + clientX: number, + clientY: number, +): React.PointerEvent { + return { + pointerId, + clientX, + clientY, + stopPropagation: vi.fn(), + } as unknown as React.PointerEvent; +} + +describe("useTouchGestures", () => { + it("fires double tap on quick repeated pointer taps", () => { + const onLongPress = vi.fn(); + const onDoubleTap = vi.fn(); + const { result } = renderHook(() => + useTouchGestures({ + onLongPress, + onDoubleTap, + allowLongPress: false, + }), + ); + + const firstTap = createPointerEvent(1, 120, 180); + const secondTap = createPointerEvent(1, 123, 182); + + act(() => { + result.current.onPointerDown?.(firstTap); + result.current.onPointerUp?.(firstTap); + result.current.onPointerDown?.(secondTap); + }); + + expect(onDoubleTap).toHaveBeenCalledTimes(1); + expect(onLongPress).not.toHaveBeenCalled(); + }); + + it("cancels a pending long press when a second pointer appears", () => { + vi.useFakeTimers(); + + const onLongPress = vi.fn(); + const { result } = renderHook(() => + useTouchGestures({ + onLongPress, + longPressDelay: 500, + }), + ); + + const firstTouch = createPointerEvent(1, 40, 40); + const secondTouch = createPointerEvent(2, 60, 60); + + act(() => { + result.current.onPointerDown?.(firstTouch); + vi.advanceTimersByTime(200); + result.current.onPointerDown?.(secondTouch); + vi.advanceTimersByTime(400); + }); + + expect(onLongPress).not.toHaveBeenCalled(); + + vi.useRealTimers(); + }); +}); diff --git a/src/app/components/project/hooks/useTouchGestures.ts b/src/app/components/project/hooks/useTouchGestures.ts index d980526..7b0a181 100644 --- a/src/app/components/project/hooks/useTouchGestures.ts +++ b/src/app/components/project/hooks/useTouchGestures.ts @@ -2,17 +2,33 @@ import { useCallback, useRef } from "react"; +/** + * Touch and pointer gesture handler using the Pointer Events API. + * + * Why Pointer Events? + * - Unified API for touch, pen, and mouse input (one handler for all). + * - ReactFlow v12 calls preventDefault() on its pointerdown handler, which + * per the Pointer Events spec suppresses the corresponding legacy + * touchstart event. Using Pointer Events ourselves means our handlers + * fire on the same event channel that ReactFlow uses, so they are never + * suppressed. + * + * Fallback: if the browser does not support PointerEvent, we fall back to + * legacy Touch Events so the hook still works. + */ + export interface UseTouchGesturesProps { onLongPress: ( - e: React.TouchEvent | TouchEvent, + e: React.PointerEvent | PointerEvent | React.TouchEvent | TouchEvent, clientX: number, clientY: number, ) => void; onDoubleTap?: ( - e: React.TouchEvent | TouchEvent, + e: React.PointerEvent | PointerEvent | React.TouchEvent | TouchEvent, x: number, y: number, ) => void; + /** @deprecated Pinch-to-zoom is now handled natively by ReactFlow. */ onPinch?: (delta: number, centerX: number, centerY: number) => void; longPressDelay?: number; doubleTapDelay?: number; @@ -21,41 +37,155 @@ export interface UseTouchGesturesProps { allowLongPress?: boolean; } +const HAS_POINTER_EVENTS = + typeof window !== "undefined" && "PointerEvent" in window; + export const useTouchGestures = ({ onLongPress, onDoubleTap, - onPinch, longPressDelay = 500, doubleTapDelay = 400, moveThreshold = 25, stopPropagation = false, allowLongPress = true, }: UseTouchGesturesProps) => { - const timerRef = useRef(null); + const timerRef = useRef | null>(null); const startPosRef = useRef<{ x: number; y: number } | null>(null); const lastTapRef = useRef<{ time: number; x: number; y: number } | null>( null, ); - const pinchStartDistRef = useRef(null); - const touchesRef = useRef(0); - const isClickRef = useRef(true); + const isClickRef = useRef(true); + const activePointerIdRef = useRef(null); - const handleTouchStart = useCallback( - (e: React.TouchEvent | TouchEvent) => { + // ── Pointer Events path (modern browsers) ────────────────────────── + + const handlePointerDown = useCallback( + (e: React.PointerEvent | PointerEvent) => { if (stopPropagation) { e.stopPropagation(); } - touchesRef.current = e.touches.length; + // Only track the primary pointer for single-finger gestures. + if ( + activePointerIdRef.current !== null && + e.pointerId !== activePointerIdRef.current + ) { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + return; + } + + activePointerIdRef.current = e.pointerId; + const { clientX, clientY } = e; + const now = Date.now(); - if (e.touches.length === 2 && onPinch) { - const t1 = e.touches[0]; - const t2 = e.touches[1]; - pinchStartDistRef.current = Math.sqrt( - Math.pow(t2.clientX - t1.clientX, 2) + - Math.pow(t2.clientY - t1.clientY, 2), + if (onDoubleTap && lastTapRef.current) { + const timeDiff = now - lastTapRef.current.time; + const dist = Math.sqrt( + Math.pow(clientX - lastTapRef.current.x, 2) + + Math.pow(clientY - lastTapRef.current.y, 2), ); + if (timeDiff < doubleTapDelay && dist < moveThreshold * 2) { + onDoubleTap(e, clientX, clientY); + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + lastTapRef.current = null; + return; + } + } + + lastTapRef.current = { time: now, x: clientX, y: clientY }; + startPosRef.current = { x: clientX, y: clientY }; + isClickRef.current = true; + + if (!allowLongPress) return; + + timerRef.current = setTimeout(() => { + if (window.getSelection) { + window.getSelection()?.removeAllRanges(); + } + onLongPress(e, clientX, clientY); + timerRef.current = null; + }, longPressDelay); + }, + [ + onLongPress, + longPressDelay, + stopPropagation, + allowLongPress, + onDoubleTap, + doubleTapDelay, + moveThreshold, + ], + ); + + const handlePointerMove = useCallback( + (e: React.PointerEvent | PointerEvent) => { + if (e.pointerId !== activePointerIdRef.current) return; + if (!timerRef.current) return; + + const startPos = startPosRef.current; + if (!startPos) return; + + const dist = Math.sqrt( + Math.pow(e.clientX - startPos.x, 2) + + Math.pow(e.clientY - startPos.y, 2), + ); + + if (dist > moveThreshold) { + isClickRef.current = false; + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + } + }, + [moveThreshold], + ); + + const resetState = useCallback(() => { + activePointerIdRef.current = null; + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + if (!isClickRef.current) { + lastTapRef.current = null; + } + startPosRef.current = null; + isClickRef.current = true; + }, []); + + const handlePointerUp = useCallback( + (e: React.PointerEvent | PointerEvent) => { + if (e.pointerId !== activePointerIdRef.current) return; + resetState(); + }, + [resetState], + ); + + const handlePointerCancel = useCallback( + (e: React.PointerEvent | PointerEvent) => { + if (e.pointerId !== activePointerIdRef.current) return; + resetState(); + }, + [resetState], + ); + + // ── Legacy Touch Events fallback ─────────────────────────────────── + + const handleTouchStart = useCallback( + (e: React.TouchEvent | TouchEvent) => { + if (stopPropagation) { + e.stopPropagation(); + } + + if (e.touches.length === 2) { if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; @@ -69,7 +199,6 @@ export const useTouchGestures = ({ const { clientX, clientY } = touch; const now = Date.now(); - // Double tap detection if (onDoubleTap && lastTapRef.current) { const timeDiff = now - lastTapRef.current.time; const dist = Math.sqrt( @@ -78,10 +207,7 @@ export const useTouchGestures = ({ ); if (timeDiff < doubleTapDelay && dist < moveThreshold * 2) { - // It's a double tap onDoubleTap(e, clientX, clientY); - - // Cancel long press if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; @@ -97,9 +223,7 @@ export const useTouchGestures = ({ if (!allowLongPress) return; - // Start long press timer timerRef.current = setTimeout(() => { - // Clear selection to prevent text highlighting on long press if (window.getSelection) { window.getSelection()?.removeAllRanges(); } @@ -115,35 +239,14 @@ export const useTouchGestures = ({ onDoubleTap, doubleTapDelay, moveThreshold, - onPinch, ], ); const handleTouchMove = useCallback( (e: React.TouchEvent | TouchEvent) => { - if (e.touches.length === 2 && onPinch && pinchStartDistRef.current) { - if (e.cancelable) e.preventDefault(); - const t1 = e.touches[0]; - const t2 = e.touches[1]; - const dist = Math.sqrt( - Math.pow(t2.clientX - t1.clientX, 2) + - Math.pow(t2.clientY - t1.clientY, 2), - ); - - const delta = dist - pinchStartDistRef.current; - const centerX = (t1.clientX + t2.clientX) / 2; - const centerY = (t1.clientY + t2.clientY) / 2; - - onPinch(delta, centerX, centerY); - pinchStartDistRef.current = dist; - return; - } - if (!timerRef.current) return; - const touch = e.touches[0]; if (!touch) return; - const startPos = startPosRef.current; if (!startPos) return; @@ -160,12 +263,11 @@ export const useTouchGestures = ({ } } }, - [moveThreshold, onPinch], + [moveThreshold], ); const handleTouchEnd = useCallback(() => { - touchesRef.current = 0; - pinchStartDistRef.current = null; + activePointerIdRef.current = null; if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; @@ -177,6 +279,17 @@ export const useTouchGestures = ({ isClickRef.current = true; }, []); + // ── Return the appropriate handlers ──────────────────────────────── + + if (HAS_POINTER_EVENTS) { + return { + onPointerDown: handlePointerDown, + onPointerMove: handlePointerMove, + onPointerUp: handlePointerUp, + onPointerCancel: handlePointerCancel, + }; + } + return { onTouchStart: handleTouchStart, onTouchMove: handleTouchMove, diff --git a/src/app/styles/editor.css b/src/app/styles/editor.css index 2579446..1e3265f 100644 --- a/src/app/styles/editor.css +++ b/src/app/styles/editor.css @@ -115,12 +115,38 @@ background-color: white; user-select: none; -webkit-touch-callout: none; + overscroll-behavior: contain; } .project-canvas { transition: filter 0.16s ease; } +.project-canvas .react-flow__pane { + touch-action: none; +} + +.project-canvas .block-header, +.project-canvas .shell-block-header, +.project-canvas .handle-drag-target { + touch-action: none; +} + +.project-canvas .checklist-block-container, +.project-canvas .contact-block-container, +.project-canvas .palette-block-container, +.project-canvas .snippet-block-container, +.project-canvas .kb-tasks, +.project-canvas .block-content.overflow-y-auto, +.project-canvas .block-description, +.preview-mode .block-content { + touch-action: pan-y; +} + +.project-canvas .kb-scroll { + touch-action: pan-x; +} + .project-canvas-container.drop-active .project-canvas { filter: blur(2px); }