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