diff --git a/src/components/AppSettingsModal.tsx b/src/components/AppSettingsModal.tsx
index 30b50d0c..9456eccd 100644
--- a/src/components/AppSettingsModal.tsx
+++ b/src/components/AppSettingsModal.tsx
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { Button, Select, Slider, Switch } from "antd";
import { useWorkflowStore } from "@/store/workflowStore";
import { NodeDefaultsConfig, LLMProvider, LLMModelType } from "@/types";
-import { CanvasNavigationSettings } from "@/types/canvas";
+import { CanvasInputMode, CanvasNavigationSettings } from "@/types/canvas";
import { loadNodeDefaults, saveNodeDefaults } from "@/store/utils/localStorage";
import { useInlineParameters } from "@/hooks/useInlineParameters";
import { LANGUAGE_OPTIONS, useI18n } from "@/i18n";
@@ -366,11 +366,38 @@ export function AppSettingsModal({ isOpen, onClose }: AppSettingsModalProps) {
{activeTab === "canvas" && (
{t("project.canvasHelp")}
+
+
+ {t("project.inputMode")}
+
+
+ {localCanvasSettings.inputMode === "trackpad"
+ ? t("project.inputModeTrackpad")
+ : t("project.inputModeMouse")}
+
+
+
{t("project.panMode")}
-
{t("project.panAlways")}
+
+ {localCanvasSettings.inputMode === "trackpad"
+ ? t("project.panTrackpad")
+ : t("project.panAlways")}
+
@@ -379,7 +406,11 @@ export function AppSettingsModal({ isOpen, onClose }: AppSettingsModalProps) {
{t("project.zoomMode")}
-
{t("project.zoomScroll")}
+
+ {localCanvasSettings.inputMode === "trackpad"
+ ? t("project.zoomTrackpad")
+ : t("project.zoomScroll")}
+
@@ -388,7 +419,11 @@ export function AppSettingsModal({ isOpen, onClose }: AppSettingsModalProps) {
{t("project.selectionMode")}
-
{t("project.selectRightSpace")}
+
+ {localCanvasSettings.inputMode === "trackpad"
+ ? t("project.selectTrackpad")
+ : t("project.selectRightSpace")}
+
diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx
index 276a30bf..6a50e5fc 100644
--- a/src/components/WorkflowCanvas.tsx
+++ b/src/components/WorkflowCanvas.tsx
@@ -127,6 +127,12 @@ type FlowRect = {
height: number;
};
+type WebKitGestureEvent = Event & {
+ scale: number;
+ clientX?: number;
+ clientY?: number;
+};
+
const CANVAS_LOCAL_INTERACTION_SELECTOR = [
".nodrag",
".nopan",
@@ -623,6 +629,7 @@ export function WorkflowCanvas() {
const boxSelectionEndPointRef = useRef(null);
const reactFlowWrapper = useRef(null);
const tutorialViewportSet = useRef(false);
+ const gestureZoomStartRef = useRef<{ zoom: number } | null>(null);
const [boxSelectionRect, setBoxSelectionRect] = useState(null);
const onNodesChange = useCallback((changes: NodeChange[]) => {
@@ -761,7 +768,9 @@ export function WorkflowCanvas() {
return unsubscribe;
}, []);
- const canvasPanDisabled = tutorialActive || isModalOpen;
+ const canvasInputMode = canvasNavigationSettings.inputMode;
+ const isTrackpadInputMode = canvasInputMode === "trackpad";
+ const canvasPanDisabled = tutorialActive || isModalOpen || isTrackpadInputMode;
const canvasPanOnDrag = useMemo(
() => getCanvasPanOnDrag(canvasPanDisabled),
[canvasPanDisabled]
@@ -2155,15 +2164,68 @@ export function WorkflowCanvas() {
event.preventDefault();
const viewport = getViewport();
- const factor = event.deltaY < 0 ? WHEEL_ZOOM_FACTOR : 1 / WHEEL_ZOOM_FACTOR;
- zoomAtScreenPoint({ x: event.clientX, y: event.clientY }, viewport.zoom * factor);
+ const shouldZoom = !isTrackpadInputMode || event.ctrlKey || event.metaKey;
+ if (shouldZoom) {
+ const factor = event.deltaY < 0 ? WHEEL_ZOOM_FACTOR : 1 / WHEEL_ZOOM_FACTOR;
+ zoomAtScreenPoint({ x: event.clientX, y: event.clientY }, viewport.zoom * factor);
+ return;
+ }
+
+ setViewport({
+ x: viewport.x - event.deltaX,
+ y: viewport.y - event.deltaY,
+ zoom: viewport.zoom,
+ });
};
wrapper.addEventListener('wheel', handleWheelNonPassive, { passive: false });
return () => {
wrapper.removeEventListener('wheel', handleWheelNonPassive);
};
- }, [getViewport, isModalOpen, zoomAtScreenPoint]);
+ }, [getViewport, isModalOpen, isTrackpadInputMode, setViewport, zoomAtScreenPoint]);
+
+ useEffect(() => {
+ if (!isTrackpadInputMode) return;
+
+ const wrapper = reactFlowWrapper.current;
+ if (!wrapper) return;
+
+ const getGesturePoint = (event: WebKitGestureEvent) => {
+ if (typeof event.clientX === "number" && typeof event.clientY === "number") {
+ return { x: event.clientX, y: event.clientY };
+ }
+ return getCanvasScreenCenter();
+ };
+
+ const handleGestureStart = (event: Event) => {
+ event.preventDefault();
+ gestureZoomStartRef.current = { zoom: getViewport().zoom };
+ };
+
+ const handleGestureChange = (event: Event) => {
+ event.preventDefault();
+ const gestureEvent = event as WebKitGestureEvent;
+ const start = gestureZoomStartRef.current ?? { zoom: getViewport().zoom };
+ gestureZoomStartRef.current = start;
+ zoomAtScreenPoint(getGesturePoint(gestureEvent), start.zoom * gestureEvent.scale);
+ };
+
+ const handleGestureEnd = (event: Event) => {
+ event.preventDefault();
+ gestureZoomStartRef.current = null;
+ };
+
+ wrapper.addEventListener("gesturestart", handleGestureStart, { passive: false });
+ wrapper.addEventListener("gesturechange", handleGestureChange, { passive: false });
+ wrapper.addEventListener("gestureend", handleGestureEnd, { passive: false });
+
+ return () => {
+ wrapper.removeEventListener("gesturestart", handleGestureStart);
+ wrapper.removeEventListener("gesturechange", handleGestureChange);
+ wrapper.removeEventListener("gestureend", handleGestureEnd);
+ gestureZoomStartRef.current = null;
+ };
+ }, [getCanvasScreenCenter, getViewport, isTrackpadInputMode, zoomAtScreenPoint]);
// Keyboard shortcuts for copy/paste and stacking selected nodes
const handleKeyDown = useCallback((event: KeyboardEvent) => {
@@ -3004,7 +3066,7 @@ export function WorkflowCanvas() {
fitViewOptions={FIT_VIEW_OPTIONS}
deleteKeyCode={["Backspace", "Delete"]}
multiSelectionKeyCode="Shift"
- selectionOnDrag={false}
+ selectionOnDrag={isTrackpadInputMode}
selectionKeyCode={["Space", "Control", "Meta"]}
panOnDrag={canvasPanOnDrag}
selectNodesOnDrag={false}
diff --git a/src/components/__tests__/Header.test.tsx b/src/components/__tests__/Header.test.tsx
index 26dbb220..ce689e0d 100644
--- a/src/components/__tests__/Header.test.tsx
+++ b/src/components/__tests__/Header.test.tsx
@@ -64,6 +64,7 @@ const createDefaultState = (overrides = {}) => ({
markAsSaved: mockMarkAsSaved,
canvasNavigationSettings: {
theme: "dark",
+ inputMode: "mouse",
panMode: "space",
zoomMode: "altScroll",
selectionMode: "click",
diff --git a/src/components/__tests__/WorkflowCanvas.test.tsx b/src/components/__tests__/WorkflowCanvas.test.tsx
index 8f1de88f..0093727f 100644
--- a/src/components/__tests__/WorkflowCanvas.test.tsx
+++ b/src/components/__tests__/WorkflowCanvas.test.tsx
@@ -169,7 +169,7 @@ const createDefaultState = (overrides = {}) => ({
getNodesWithComments: vi.fn(() => []),
getConnectedInputs: vi.fn(() => ({ images: [], videos: [], audio: [], text: null })),
markCommentViewed: vi.fn(),
- canvasNavigationSettings: { theme: "dark", panMode: "space", zoomMode: "altScroll", selectionMode: "click", assistantEnabled: false },
+ canvasNavigationSettings: { theme: "dark", inputMode: "mouse", panMode: "space", zoomMode: "altScroll", selectionMode: "click", assistantEnabled: false },
updateCanvasNavigationSettings: mockUpdateCanvasNavigationSettings,
dimmedNodeIds: new Set(),
skippedNodeIds: new Set(),
@@ -279,6 +279,7 @@ describe("WorkflowCanvas", () => {
return selector(createDefaultState({
canvasNavigationSettings: {
theme: "dark",
+ inputMode: "mouse",
panMode: "space",
zoomMode: "altScroll",
selectionMode: "click",
@@ -301,6 +302,7 @@ describe("WorkflowCanvas", () => {
const enabledSettings = {
panMode: "space" as const,
theme: "dark" as const,
+ inputMode: "mouse" as const,
zoomMode: "altScroll" as const,
selectionMode: "click" as const,
assistantEnabled: true,
@@ -1124,6 +1126,52 @@ describe("WorkflowCanvas", () => {
expect(document.querySelector(".react-flow")).toBeInTheDocument();
});
+ it("should zoom on wheel in mouse input mode", () => {
+ render(
+
+
+
+ );
+
+ const canvas = document.querySelector(".canvas-default-cursor") as HTMLElement;
+ fireEvent.wheel(canvas, { deltaX: 0, deltaY: -100, clientX: 50, clientY: 60 });
+
+ const [viewport] = mockSetViewport.mock.calls[0];
+ expect(viewport.x).toBeCloseTo(-7.5);
+ expect(viewport.y).toBeCloseTo(-9);
+ expect(viewport.zoom).toBeCloseTo(1.15);
+ });
+
+ it("should pan on wheel in trackpad input mode", () => {
+ mockUseWorkflowStore.mockImplementation((selector) => {
+ return selector(createDefaultState({
+ canvasNavigationSettings: {
+ theme: "dark",
+ inputMode: "trackpad",
+ panMode: "space",
+ zoomMode: "altScroll",
+ selectionMode: "click",
+ assistantEnabled: false,
+ },
+ }));
+ });
+
+ render(
+
+
+
+ );
+
+ const canvas = document.querySelector(".canvas-default-cursor") as HTMLElement;
+ fireEvent.wheel(canvas, { deltaX: 12, deltaY: 30, clientX: 50, clientY: 60 });
+
+ expect(mockSetViewport).toHaveBeenCalledWith({
+ x: -12,
+ y: -30,
+ zoom: 1,
+ });
+ });
+
it("should use editable as default edge type", () => {
render(
diff --git a/src/i18n/index.tsx b/src/i18n/index.tsx
index f5320ce1..497a9384 100644
--- a/src/i18n/index.tsx
+++ b/src/i18n/index.tsx
@@ -838,6 +838,11 @@ const en = {
"project.executionHelp": "Maximum number of nodes to execute in parallel during workflow execution. Higher values may improve speed but increase API rate limit risk.",
"project.defaultsHelp": "These defaults are applied when creating nodes via keyboard shortcuts (Shift+G, Shift+L, etc).",
"project.canvasHelp": "Canvas navigation and selection behavior.",
+ "project.inputMode": "Input Mode",
+ "project.inputModeMouse": "Mouse mode keeps left drag for panning and scroll for zoom.",
+ "project.inputModeTrackpad": "Trackpad mode uses one-finger drag for box select, two-finger scroll for panning, and pinch for zoom.",
+ "project.inputModeMouseLabel": "Mouse",
+ "project.inputModeTrackpadLabel": "Trackpad",
"project.theme": "Theme",
"project.themeDark": "Black canvas with dark panels",
"project.themeLight": "Light canvas with neutral panels",
@@ -849,6 +854,7 @@ const en = {
"project.panSpace": "Left drag pans; Space/Ctrl + left drag box-selects nodes",
"project.panMiddle": "Left drag pans the canvas",
"project.panAlways": "Left drag pans the canvas",
+ "project.panTrackpad": "Two-finger scroll pans the canvas",
"project.panSpaceLabel": "Left Pan + Box Select",
"project.panMiddleLabel": "Left Pan",
"project.panAlwaysLabel": "Always On",
@@ -856,11 +862,13 @@ const en = {
"project.zoomAlt": "Hold Alt and scroll to zoom",
"project.zoomCtrl": "Hold Ctrl/Cmd and scroll to zoom",
"project.zoomScroll": "Scroll to zoom without modifier keys",
+ "project.zoomTrackpad": "Pinch to zoom, or Ctrl/Cmd + scroll",
"project.zoomAltLabel": "Alt + Scroll",
"project.zoomCtrlLabel": "Ctrl + Scroll",
"project.zoomScrollLabel": "Scroll",
"project.selectionMode": "Selection",
"project.selectRightSpace": "Space + left drag or Ctrl/Cmd + left drag box-selects nodes",
+ "project.selectTrackpad": "One-finger drag box-selects nodes; Space/Ctrl/Cmd + drag still works",
"project.selectClick": "Click to select nodes",
"project.selectAlt": "Hold Alt and drag to select",
"project.selectShift": "Hold Shift and drag to select",
@@ -1863,6 +1871,11 @@ const zhCN: Dictionary = {
"project.executionHelp": "工作流执行时最多并行运行的节点数量。数值更高可能更快,但也更容易触发 API 限流。",
"project.defaultsHelp": "这些默认值会在通过快捷键创建节点时应用(Shift+G、Shift+L 等)。",
"project.canvasHelp": "画布导航和选择操作。",
+ "project.inputMode": "输入模式",
+ "project.inputModeMouse": "鼠标模式保留左键拖拽移动画布、滚动缩放。",
+ "project.inputModeTrackpad": "触控板模式使用单指拖拽框选、双指滑动移动画布、捏合缩放。",
+ "project.inputModeMouseLabel": "鼠标",
+ "project.inputModeTrackpadLabel": "触控板",
"project.theme": "主题",
"project.themeDark": "黑色画布和深色面板",
"project.themeLight": "浅色画布和中性面板",
@@ -1874,6 +1887,7 @@ const zhCN: Dictionary = {
"project.panSpace": "左键拖拽移动画布;Space + 左键或 Ctrl + 左键框选节点",
"project.panMiddle": "左键拖拽移动画布",
"project.panAlways": "左键拖拽移动画布",
+ "project.panTrackpad": "双指滑动移动画布",
"project.panSpaceLabel": "左键平移 + 框选",
"project.panMiddleLabel": "左键平移",
"project.panAlwaysLabel": "始终开启",
@@ -1881,11 +1895,13 @@ const zhCN: Dictionary = {
"project.zoomAlt": "按住 Alt 并滚动缩放",
"project.zoomCtrl": "按住 Ctrl/Cmd 并滚动缩放",
"project.zoomScroll": "直接滚动缩放",
+ "project.zoomTrackpad": "双指捏合缩放,或 Ctrl/Cmd + 滚动",
"project.zoomAltLabel": "Alt + 滚动",
"project.zoomCtrlLabel": "Ctrl + 滚动",
"project.zoomScrollLabel": "滚动",
"project.selectionMode": "选择节点",
"project.selectRightSpace": "Space + 左键或 Ctrl + 左键框选节点",
+ "project.selectTrackpad": "单指拖拽框选节点;Space/Ctrl/Cmd + 拖拽仍可用",
"project.selectClick": "点击选择节点",
"project.selectAlt": "按住 Alt 并拖拽选择",
"project.selectShift": "按住 Shift 并拖拽选择",
@@ -2639,6 +2655,11 @@ const zhTW: Dictionary = {
"project.executionSettings": "執行設定",
"project.maxParallel": "最大平行呼叫:{count}",
"project.canvasHelp": "設定畫布導覽和互動方式。",
+ "project.inputMode": "輸入模式",
+ "project.inputModeMouse": "滑鼠模式保留左鍵拖曳移動畫布、滾動縮放。",
+ "project.inputModeTrackpad": "觸控板模式使用單指拖曳框選、雙指滑動移動畫布、捏合縮放。",
+ "project.inputModeMouseLabel": "滑鼠",
+ "project.inputModeTrackpadLabel": "觸控板",
"project.theme": "主題",
"project.themeDark": "黑色畫布和深色面板",
"project.themeLight": "淺色畫布和中性面板",
@@ -2647,8 +2668,11 @@ const zhTW: Dictionary = {
"project.canvasAssistant": "畫布助手",
"project.canvasAssistantShort": "助手",
"project.panMode": "平移模式",
+ "project.panTrackpad": "雙指滑動移動畫布",
"project.zoomMode": "縮放模式",
+ "project.zoomTrackpad": "雙指捏合縮放,或 Ctrl/Cmd + 滾動",
"project.selectionMode": "選擇模式",
+ "project.selectTrackpad": "單指拖曳框選節點;Space/Ctrl/Cmd + 拖曳仍可用",
"project.validating": "驗證中...",
"project.errorChooseLocalFolder": "請從瀏覽器選擇本機資料夾,這樣專案會儲存在目前操作者的裝置上。",
"modelSearch.search": "搜尋模型...",
@@ -3383,6 +3407,11 @@ const ja: Dictionary = {
"project.executionHelp": "ワークフロー実行時に並列実行するノード数の上限です。値を上げると速くなる場合がありますが、API レート制限のリスクも上がります。",
"project.defaultsHelp": "これらの既定値はキーボードショートカット(Shift+G、Shift+L など)でノードを作成するときに適用されます。",
"project.canvasHelp": "キャンバスの移動と選択操作。",
+ "project.inputMode": "入力モード",
+ "project.inputModeMouse": "マウスモードでは左ドラッグでキャンバス移動、スクロールでズームします。",
+ "project.inputModeTrackpad": "トラックパッドモードでは1本指ドラッグで範囲選択、2本指スクロールでキャンバス移動、ピンチでズームします。",
+ "project.inputModeMouseLabel": "マウス",
+ "project.inputModeTrackpadLabel": "トラックパッド",
"project.theme": "テーマ",
"project.themeDark": "黒いキャンバスとダークパネル",
"project.themeLight": "明るいキャンバスとニュートラルパネル",
@@ -3394,6 +3423,7 @@ const ja: Dictionary = {
"project.panSpace": "左ドラッグでキャンバス移動、Space/Ctrl + 左ドラッグで範囲選択",
"project.panMiddle": "左ドラッグでキャンバスを移動",
"project.panAlways": "左ドラッグでキャンバスを移動",
+ "project.panTrackpad": "2本指スクロールでキャンバスを移動",
"project.panSpaceLabel": "左パン + 範囲選択",
"project.panMiddleLabel": "左パン",
"project.panAlwaysLabel": "常時オン",
@@ -3401,11 +3431,13 @@ const ja: Dictionary = {
"project.zoomAlt": "Alt を押しながらスクロールでズーム",
"project.zoomCtrl": "Ctrl/Cmd を押しながらスクロールでズーム",
"project.zoomScroll": "修飾キーなしでスクロールズーム",
+ "project.zoomTrackpad": "ピンチでズーム、または Ctrl/Cmd + スクロール",
"project.zoomAltLabel": "Alt + スクロール",
"project.zoomCtrlLabel": "Ctrl + スクロール",
"project.zoomScrollLabel": "スクロール",
"project.selectionMode": "選択",
"project.selectRightSpace": "Space + 左ドラッグ、または Ctrl/Cmd + 左ドラッグでノードを範囲選択",
+ "project.selectTrackpad": "1本指ドラッグでノードを範囲選択。Space/Ctrl/Cmd + ドラッグも引き続き使えます",
"project.selectClick": "クリックしてノードを選択",
"project.selectAlt": "Alt を押しながらドラッグして選択",
"project.selectShift": "Shift を押しながらドラッグして選択",
diff --git a/src/store/__tests__/workflowStore.integration.test.ts b/src/store/__tests__/workflowStore.integration.test.ts
index f151d0d4..509f374e 100644
--- a/src/store/__tests__/workflowStore.integration.test.ts
+++ b/src/store/__tests__/workflowStore.integration.test.ts
@@ -2804,6 +2804,7 @@ describe("workflowStore integration tests", () => {
const store = useWorkflowStore.getState();
store.updateCanvasNavigationSettings({
theme: "light",
+ inputMode: "trackpad",
panMode: "always",
zoomMode: "scroll",
selectionMode: "altDrag",
@@ -2813,6 +2814,7 @@ describe("workflowStore integration tests", () => {
const state = useWorkflowStore.getState();
expect(state.canvasNavigationSettings).toEqual({
theme: "light",
+ inputMode: "trackpad",
panMode: "always",
zoomMode: "scroll",
selectionMode: "altDrag",
@@ -2824,6 +2826,7 @@ describe("workflowStore integration tests", () => {
const store = useWorkflowStore.getState();
const settings = {
theme: "light" as const,
+ inputMode: "trackpad" as const,
panMode: "middleMouse" as const,
zoomMode: "ctrlScroll" as const,
selectionMode: "click" as const,
diff --git a/src/store/utils/__tests__/localStorage.test.ts b/src/store/utils/__tests__/localStorage.test.ts
index 7c47e78a..e26f6e8b 100644
--- a/src/store/utils/__tests__/localStorage.test.ts
+++ b/src/store/utils/__tests__/localStorage.test.ts
@@ -344,6 +344,7 @@ describe("localStorage utilities", () => {
it("returns stored settings when data exists", () => {
const customSettings = {
theme: "light",
+ inputMode: "trackpad",
panMode: "always",
zoomMode: "scroll",
selectionMode: "altDrag",
@@ -367,6 +368,7 @@ describe("localStorage utilities", () => {
expect(result).toEqual({
...legacySettings,
theme: "light",
+ inputMode: "mouse",
assistantEnabled: false,
});
});
@@ -383,6 +385,7 @@ describe("localStorage utilities", () => {
it("persists settings to localStorage", () => {
const settings = {
theme: "light" as const,
+ inputMode: "trackpad" as const,
panMode: "middleMouse" as const,
zoomMode: "ctrlScroll" as const,
selectionMode: "altDrag" as const,
diff --git a/src/types/canvas.ts b/src/types/canvas.ts
index 2e1983e6..d0628dab 100644
--- a/src/types/canvas.ts
+++ b/src/types/canvas.ts
@@ -7,10 +7,12 @@
export type PanMode = "space" | "middleMouse" | "always";
export type ZoomMode = "scroll" | "altScroll" | "ctrlScroll";
export type SelectionMode = "rightSpace" | "click" | "altDrag" | "shiftDrag";
+export type CanvasInputMode = "mouse" | "trackpad";
export type CanvasTheme = "dark" | "light";
export interface CanvasNavigationSettings {
theme: CanvasTheme;
+ inputMode: CanvasInputMode;
panMode: PanMode;
zoomMode: ZoomMode;
selectionMode: SelectionMode;
@@ -19,6 +21,7 @@ export interface CanvasNavigationSettings {
export const defaultCanvasNavigationSettings: CanvasNavigationSettings = {
theme: "light",
+ inputMode: "mouse",
panMode: "always",
zoomMode: "scroll",
selectionMode: "rightSpace",