Browse Source

feat: add customizable canvas navigation settings (#65)

* feat: add customizable canvas navigation settings

Add a new settings modal that allows users to configure canvas navigation preferences:

- Pan Mode: Space + Drag (default), Middle Mouse, or Always On (ComfyUI-style)
- Zoom Mode: Alt + Scroll (default), Ctrl + Scroll, or Scroll (no modifier)
- Selection Mode: Click (default) or Alt + Drag

Features:
- Settings persist in localStorage
- New canvas settings button in header (monitor icon)
- Modal UI with radio button groups for each setting
- React Flow props dynamically configured based on user preferences
- Updated wheel event handler to respect zoom mode settings

This addresses user requests for ComfyUI/TouchDesigner-style navigation where
panning and zooming work without holding modifier keys.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: PR #65 review — altDrag selection bug, icon, dedup, tests

- Fix altDrag selection mode: use selectionKeyCode="Alt" instead of
  selectionOnDrag=true which made drag-select work without any key
- Replace misleading video camera icon with arrows-pointing-out for
  canvas navigation settings button
- Extract duplicated settings buttons JSX into settingsButtons variable
  shared by both configured/unconfigured project branches
- Add defensive useEffect to sync CanvasSettingsModal state on open
- Add missing semicolon in types/index.ts canvas re-export
- Add localStorage tests for getCanvasNavigationSettings and
  saveCanvasNavigationSettings
- Add integration tests for updateCanvasNavigationSettings
- Fix WorkflowCanvas test mocks to include canvasNavigationSettings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: move canvas navigation settings into Project Settings modal

Consolidate canvas settings from standalone CanvasSettingsModal into a
new "Canvas" tab in ProjectSetupModal. Remove "like ComfyUI" text, add
Shift+Drag selection mode, and fix save button to bottom of dialog.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove double semicolon in types/index.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
handoff-20260429-1057
Willie 5 months ago
committed by GitHub
parent
commit
895c848eaf
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 8
      CLAUDE.md
  2. 81
      src/components/Header.tsx
  3. 155
      src/components/ProjectSetupModal.tsx
  4. 92
      src/components/WorkflowCanvas.tsx
  5. 4
      src/components/__tests__/WorkflowCanvas.test.tsx
  6. 33
      src/store/__tests__/workflowStore.integration.test.ts
  7. 45
      src/store/utils/__tests__/localStorage.test.ts
  8. 22
      src/store/utils/localStorage.ts
  9. 18
      src/store/workflowStore.ts
  10. 21
      src/types/canvas.ts
  11. 1
      src/types/index.ts

8
CLAUDE.md

@ -210,6 +210,14 @@ All routes in `src/app/api/`:
- `node-banana-workflow-costs` - Cost tracking per workflow
- `node-banana-nanoBanana-defaults` - Sticky generation settings
## Git Workflow
- The primary development branch is `develop`, NOT `main` or `master`
- Always checkout `develop` before creating feature branches: `git checkout develop`
- Create feature branches from `develop` using: `feature/<short-description>` or `fix/<short-description>`
- All PRs MUST target `develop`: use `gh pr create --base develop`
- Never push directly to `main`, `master`, or `develop`
## Commits
- The .planning directory is untracked, do not attempt to commit any changes to the files in this directory.

81
src/components/Header.tsx

@ -171,6 +171,35 @@ export function Header() {
}
}, [revertToSnapshot]);
const settingsButtons = (
<div className="flex items-center gap-0.5 ml-1 pl-1 border-l border-neutral-700/50">
<button
onClick={handleOpenSettings}
className="p-1.5 text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800 rounded transition-colors"
title="Project settings"
>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</button>
</div>
);
return (
<>
<ProjectSetupModal
@ -267,31 +296,7 @@ export function Header() {
</button>
</div>
{/* Settings - separated */}
<button
onClick={handleOpenSettings}
className="p-1.5 text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800 rounded transition-colors ml-1"
title="Project settings"
>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</button>
{settingsButtons}
</>
) : (
<>
@ -340,31 +345,7 @@ export function Header() {
</button>
</div>
{/* Settings - separated */}
<button
onClick={handleOpenSettings}
className="p-1.5 text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800 rounded transition-colors ml-1"
title="Project settings"
>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</button>
{settingsButtons}
</>
)}
</div>

155
src/components/ProjectSetupModal.tsx

@ -3,6 +3,7 @@
import { useState, useEffect } from "react";
import { generateWorkflowId, useWorkflowStore } from "@/store/workflowStore";
import { ProviderType, ProviderSettings, NodeDefaultsConfig, LLMProvider, LLMModelType } from "@/types";
import { CanvasNavigationSettings, PanMode, ZoomMode, SelectionMode } from "@/types/canvas";
import { EnvStatusResponse } from "@/app/api/env-status/route";
import { loadNodeDefaults, saveNodeDefaults } from "@/store/utils/localStorage";
import { ProviderModel } from "@/lib/providers/types";
@ -94,10 +95,12 @@ export function ProjectSetupModal({
toggleProvider,
maxConcurrentCalls,
setMaxConcurrentCalls,
canvasNavigationSettings,
updateCanvasNavigationSettings,
} = useWorkflowStore();
// Tab state
const [activeTab, setActiveTab] = useState<"project" | "providers" | "nodeDefaults">("project");
const [activeTab, setActiveTab] = useState<"project" | "providers" | "nodeDefaults" | "canvas">("project");
// Project tab state
const [name, setName] = useState("");
@ -132,6 +135,9 @@ export function ProjectSetupModal({
const [showImageModelDialog, setShowImageModelDialog] = useState(false);
const [showVideoModelDialog, setShowVideoModelDialog] = useState(false);
// Canvas tab state
const [localCanvasSettings, setLocalCanvasSettings] = useState<CanvasNavigationSettings>(canvasNavigationSettings);
// Pre-fill when opening in settings mode
useEffect(() => {
if (isOpen) {
@ -169,13 +175,16 @@ export function ProjectSetupModal({
setShowImageModelDialog(false);
setShowVideoModelDialog(false);
// Sync canvas settings
setLocalCanvasSettings(canvasNavigationSettings);
// Fetch env status
fetch("/api/env-status")
.then((res) => res.json())
.then((data: EnvStatusResponse) => setEnvStatus(data))
.catch(() => setEnvStatus(null));
}
}, [isOpen, mode, workflowName, saveDirectoryPath, useExternalImageStorage, providerSettings]);
}, [isOpen, mode, workflowName, saveDirectoryPath, useExternalImageStorage, providerSettings, canvasNavigationSettings]);
const handleBrowse = async () => {
setIsBrowsing(true);
@ -285,11 +294,18 @@ export function ProjectSetupModal({
onClose();
};
const handleSaveCanvas = () => {
updateCanvasNavigationSettings(localCanvasSettings);
onClose();
};
const handleSave = () => {
if (activeTab === "project") {
handleSaveProject();
} else if (activeTab === "providers") {
handleSaveProviders();
} else if (activeTab === "canvas") {
handleSaveCanvas();
} else {
handleSaveNodeDefaults();
}
@ -324,15 +340,16 @@ export function ProjectSetupModal({
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50">
<div
className="bg-neutral-800 rounded-lg p-6 w-[480px] border border-neutral-700 shadow-xl"
className="bg-neutral-800 rounded-lg w-[480px] border border-neutral-700 shadow-xl flex flex-col max-h-[80vh]"
onKeyDown={handleKeyDown}
>
<h2 className="text-lg font-semibold text-neutral-100 mb-4">
{mode === "new" ? "New Project" : "Project Settings"}
</h2>
<div className="px-6 pt-6 pb-0 shrink-0">
<h2 className="text-lg font-semibold text-neutral-100 mb-4">
{mode === "new" ? "New Project" : "Project Settings"}
</h2>
{/* Tab Bar */}
<div className="flex gap-4 border-b border-neutral-700 mb-4">
{/* Tab Bar */}
<div className="flex gap-4 border-b border-neutral-700">
<button
onClick={() => setActiveTab("project")}
className={`pb-2 text-sm ${activeTab === "project" ? "text-neutral-100 border-b-2 border-white" : "text-neutral-400"}`}
@ -351,8 +368,18 @@ export function ProjectSetupModal({
>
Node Defaults
</button>
<button
onClick={() => setActiveTab("canvas")}
className={`pb-2 text-sm ${activeTab === "canvas" ? "text-neutral-100 border-b-2 border-white" : "text-neutral-400"}`}
>
Canvas
</button>
</div>
</div>
{/* Scrollable tab content area */}
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-4">
{/* Project Tab Content */}
{activeTab === "project" && (
<div className="space-y-4">
@ -961,7 +988,117 @@ export function ProjectSetupModal({
</div>
)}
<div className="flex justify-end gap-2 mt-6">
{/* Canvas Tab Content */}
{activeTab === "canvas" && (
<div className="space-y-5">
{/* Pan Mode */}
<div>
<h3 className="text-sm font-medium text-neutral-200 mb-2">Pan Mode</h3>
<div className="space-y-1.5">
{([
{ value: "space" as PanMode, label: "Space + Drag", description: "Hold Space and drag to pan (default)" },
{ value: "middleMouse" as PanMode, label: "Middle Mouse", description: "Click and drag with middle mouse button" },
{ value: "always" as PanMode, label: "Always On", description: "Pan without holding any keys" },
] as const).map((option) => (
<label
key={option.value}
className={`flex items-start p-2.5 rounded-lg border cursor-pointer transition-colors ${
localCanvasSettings.panMode === option.value
? "border-blue-500 bg-blue-500/10"
: "border-neutral-700 hover:border-neutral-600 bg-neutral-900/50"
}`}
>
<input
type="radio"
name="panMode"
value={option.value}
checked={localCanvasSettings.panMode === option.value}
onChange={(e) => setLocalCanvasSettings({ ...localCanvasSettings, panMode: e.target.value as PanMode })}
className="mt-0.5 mr-3"
/>
<div className="flex-1">
<div className="text-sm font-medium text-neutral-200">{option.label}</div>
<div className="text-xs text-neutral-400 mt-0.5">{option.description}</div>
</div>
</label>
))}
</div>
</div>
{/* Zoom Mode */}
<div>
<h3 className="text-sm font-medium text-neutral-200 mb-2">Zoom Mode</h3>
<div className="space-y-1.5">
{([
{ value: "altScroll" as ZoomMode, label: "Alt + Scroll", description: "Hold Alt and scroll to zoom (default)" },
{ value: "ctrlScroll" as ZoomMode, label: "Ctrl + Scroll", description: "Hold Ctrl/Cmd and scroll to zoom" },
{ value: "scroll" as ZoomMode, label: "Scroll", description: "Scroll to zoom without holding any keys" },
] as const).map((option) => (
<label
key={option.value}
className={`flex items-start p-2.5 rounded-lg border cursor-pointer transition-colors ${
localCanvasSettings.zoomMode === option.value
? "border-blue-500 bg-blue-500/10"
: "border-neutral-700 hover:border-neutral-600 bg-neutral-900/50"
}`}
>
<input
type="radio"
name="zoomMode"
value={option.value}
checked={localCanvasSettings.zoomMode === option.value}
onChange={(e) => setLocalCanvasSettings({ ...localCanvasSettings, zoomMode: e.target.value as ZoomMode })}
className="mt-0.5 mr-3"
/>
<div className="flex-1">
<div className="text-sm font-medium text-neutral-200">{option.label}</div>
<div className="text-xs text-neutral-400 mt-0.5">{option.description}</div>
</div>
</label>
))}
</div>
</div>
{/* Selection Mode */}
<div>
<h3 className="text-sm font-medium text-neutral-200 mb-2">Selection Mode</h3>
<div className="space-y-1.5">
{([
{ value: "click" as SelectionMode, label: "Click", description: "Click to select nodes (default)" },
{ value: "altDrag" as SelectionMode, label: "Alt + Drag", description: "Hold Alt and drag to select multiple nodes" },
{ value: "shiftDrag" as SelectionMode, label: "Shift + Drag", description: "Hold Shift and drag to select multiple nodes" },
] as const).map((option) => (
<label
key={option.value}
className={`flex items-start p-2.5 rounded-lg border cursor-pointer transition-colors ${
localCanvasSettings.selectionMode === option.value
? "border-blue-500 bg-blue-500/10"
: "border-neutral-700 hover:border-neutral-600 bg-neutral-900/50"
}`}
>
<input
type="radio"
name="selectionMode"
value={option.value}
checked={localCanvasSettings.selectionMode === option.value}
onChange={(e) => setLocalCanvasSettings({ ...localCanvasSettings, selectionMode: e.target.value as SelectionMode })}
className="mt-0.5 mr-3"
/>
<div className="flex-1">
<div className="text-sm font-medium text-neutral-200">{option.label}</div>
<div className="text-xs text-neutral-400 mt-0.5">{option.description}</div>
</div>
</label>
))}
</div>
</div>
</div>
)}
</div>
{/* Fixed footer */}
<div className="flex justify-end gap-2 px-6 py-4 border-t border-neutral-700 shrink-0">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-100 transition-colors"

92
src/components/WorkflowCanvas.tsx

@ -219,7 +219,7 @@ const findScrollableAncestor = (target: HTMLElement, deltaX: number, deltaY: num
};
export function WorkflowCanvas() {
const { nodes, edges, groups, onNodesChange, onEdgesChange, onConnect, addNode, updateNodeData, loadWorkflow, getNodeById, addToGlobalHistory, setNodeGroupId, executeWorkflow, isModalOpen, showQuickstart, setShowQuickstart, navigationTarget, setNavigationTarget, captureSnapshot, applyEditOperations, setWorkflowMetadata, setShortcutsDialogOpen } =
const { nodes, edges, groups, onNodesChange, onEdgesChange, onConnect, addNode, updateNodeData, loadWorkflow, getNodeById, addToGlobalHistory, setNodeGroupId, executeWorkflow, isModalOpen, showQuickstart, setShowQuickstart, navigationTarget, setNavigationTarget, captureSnapshot, applyEditOperations, setWorkflowMetadata, canvasNavigationSettings, setShortcutsDialogOpen } =
useWorkflowStore();
const { screenToFlowPosition, getViewport, zoomIn, zoomOut, setViewport, setCenter } = useReactFlow();
const { show: showToast } = useToast();
@ -952,8 +952,16 @@ export function WorkflowCanvas() {
const scrollableElement = findScrollableAncestor(target, event.deltaX, event.deltaY);
if (scrollableElement) return;
// Pinch gesture (ctrlKey) always zooms
if (event.ctrlKey) {
const { zoomMode } = canvasNavigationSettings;
// Check if zoom should be triggered based on settings
const shouldZoom =
zoomMode === "scroll" ||
(zoomMode === "altScroll" && event.altKey) ||
(zoomMode === "ctrlScroll" && (event.ctrlKey || event.metaKey));
// Pinch gesture (ctrlKey + trackpad) always zooms regardless of settings
if (event.ctrlKey && !event.altKey) {
event.preventDefault();
if (event.deltaY < 0) zoomIn();
else zoomOut();
@ -963,34 +971,46 @@ export function WorkflowCanvas() {
// On macOS, differentiate trackpad from mouse
if (isMacOS) {
if (isMouseWheel(event)) {
// Mouse wheel → zoom
event.preventDefault();
if (event.deltaY < 0) zoomIn();
else zoomOut();
// Mouse wheel → zoom if settings allow
if (shouldZoom) {
event.preventDefault();
if (event.deltaY < 0) zoomIn();
else zoomOut();
}
} else {
// Trackpad scroll → pan (also prevent horizontal swipe navigation)
event.preventDefault();
const viewport = getViewport();
setViewport({
x: viewport.x - event.deltaX,
y: viewport.y - event.deltaY,
zoom: viewport.zoom,
});
// Trackpad scroll
if (shouldZoom) {
// Zoom
event.preventDefault();
if (event.deltaY < 0) zoomIn();
else zoomOut();
} else {
// Pan (also prevent horizontal swipe navigation)
event.preventDefault();
const viewport = getViewport();
setViewport({
x: viewport.x - event.deltaX,
y: viewport.y - event.deltaY,
zoom: viewport.zoom,
});
}
}
return;
}
// Non-macOS: default zoom behavior
event.preventDefault();
if (event.deltaY < 0) zoomIn();
else zoomOut();
// Non-macOS
if (shouldZoom) {
event.preventDefault();
if (event.deltaY < 0) zoomIn();
else zoomOut();
}
};
wrapper.addEventListener('wheel', handleWheelNonPassive, { passive: false });
return () => {
wrapper.removeEventListener('wheel', handleWheelNonPassive);
};
}, [isModalOpen, zoomIn, zoomOut, getViewport, setViewport]);
}, [isModalOpen, zoomIn, zoomOut, getViewport, setViewport, canvasNavigationSettings]);
// Keyboard shortcuts for copy/paste and stacking selected nodes
const handleKeyDown = useCallback((event: KeyboardEvent) => {
@ -1574,8 +1594,28 @@ export function WorkflowCanvas() {
fitView
deleteKeyCode={["Backspace", "Delete"]}
multiSelectionKeyCode="Shift"
selectionOnDrag={isMacOS && !isModalOpen}
panOnDrag={!isMacOS && !isModalOpen}
selectionOnDrag={
canvasNavigationSettings.selectionMode === "altDrag" || canvasNavigationSettings.selectionMode === "shiftDrag"
? false
: canvasNavigationSettings.panMode === "always"
? false
: isMacOS && !isModalOpen
}
selectionKeyCode={
isModalOpen ? null
: canvasNavigationSettings.selectionMode === "altDrag" ? "Alt"
: canvasNavigationSettings.selectionMode === "shiftDrag" ? "Shift"
: "Shift"
}
panOnDrag={
isModalOpen
? false
: canvasNavigationSettings.panMode === "always"
? true
: canvasNavigationSettings.panMode === "middleMouse"
? [2]
: !isMacOS
}
selectNodesOnDrag={false}
nodeDragThreshold={5}
zoomOnScroll={false}
@ -1583,7 +1623,13 @@ export function WorkflowCanvas() {
minZoom={0.1}
maxZoom={4}
defaultViewport={{ x: 0, y: 0, zoom: 1 }}
panActivationKeyCode={isModalOpen ? null : "Space"}
panActivationKeyCode={
isModalOpen
? null
: canvasNavigationSettings.panMode === "space"
? "Space"
: null
}
nodesDraggable={!isModalOpen}
nodesConnectable={!isModalOpen}
elementsSelectable={!isModalOpen}

4
src/components/__tests__/WorkflowCanvas.test.tsx

@ -138,6 +138,10 @@ const createDefaultState = (overrides = {}) => ({
setNavigationTarget: vi.fn(),
getNodesWithComments: vi.fn(() => []),
markCommentViewed: vi.fn(),
canvasNavigationSettings: { panMode: "space", zoomMode: "altScroll", selectionMode: "click" },
captureSnapshot: vi.fn(),
applyEditOperations: vi.fn(() => ({ applied: 0, skipped: [] })),
setWorkflowMetadata: vi.fn(),
...overrides,
});

33
src/store/__tests__/workflowStore.integration.test.ts

@ -2439,6 +2439,39 @@ describe("workflowStore integration tests", () => {
});
});
describe("Canvas navigation settings", () => {
it("updateCanvasNavigationSettings updates store state", () => {
const store = useWorkflowStore.getState();
store.updateCanvasNavigationSettings({
panMode: "always",
zoomMode: "scroll",
selectionMode: "altDrag",
});
const state = useWorkflowStore.getState();
expect(state.canvasNavigationSettings).toEqual({
panMode: "always",
zoomMode: "scroll",
selectionMode: "altDrag",
});
});
it("updateCanvasNavigationSettings persists to localStorage", () => {
const store = useWorkflowStore.getState();
const settings = {
panMode: "middleMouse" as const,
zoomMode: "ctrlScroll" as const,
selectionMode: "click" as const,
};
store.updateCanvasNavigationSettings(settings);
// Verify persistence by reading back from localStorage
const stored = localStorage.getItem("node-banana-canvas-navigation");
expect(stored).not.toBeNull();
expect(JSON.parse(stored!)).toEqual(settings);
});
});
describe("Group operations with non-standard node types", () => {
describe("createGroup bounding box calculation", () => {
it("should correctly calculate bounding box for easeCurve nodes (340x480)", () => {

45
src/store/utils/__tests__/localStorage.test.ts

@ -5,6 +5,7 @@ import {
GENERATE_IMAGE_DEFAULTS_KEY,
PROVIDER_SETTINGS_KEY,
NODE_DEFAULTS_KEY,
CANVAS_NAVIGATION_KEY,
loadSaveConfigs,
saveSaveConfig,
loadWorkflowCostData,
@ -20,7 +21,10 @@ import {
getGenerateImageDefaults,
getGenerateVideoDefaults,
getLLMDefaults,
getCanvasNavigationSettings,
saveCanvasNavigationSettings,
} from "../localStorage";
import { defaultCanvasNavigationSettings } from "@/types";
// Mock localStorage
const localStorageMock = (() => {
@ -393,4 +397,45 @@ describe("localStorage utilities", () => {
expect(result).toEqual(config.llm);
});
});
describe("getCanvasNavigationSettings", () => {
it("returns defaults when localStorage is empty", () => {
const result = getCanvasNavigationSettings();
expect(result).toEqual(defaultCanvasNavigationSettings);
});
it("returns stored settings when data exists", () => {
const customSettings = {
panMode: "always",
zoomMode: "scroll",
selectionMode: "altDrag",
};
localStorageMock.setItem(CANVAS_NAVIGATION_KEY, JSON.stringify(customSettings));
const result = getCanvasNavigationSettings();
expect(result).toEqual(customSettings);
});
it("returns defaults on invalid JSON", () => {
localStorageMock.setItem(CANVAS_NAVIGATION_KEY, "not valid json");
const result = getCanvasNavigationSettings();
expect(result).toEqual(defaultCanvasNavigationSettings);
});
});
describe("saveCanvasNavigationSettings", () => {
it("persists settings to localStorage", () => {
const settings = {
panMode: "middleMouse" as const,
zoomMode: "ctrlScroll" as const,
selectionMode: "altDrag" as const,
};
saveCanvasNavigationSettings(settings);
const stored = JSON.parse(localStorageMock.getItem(CANVAS_NAVIGATION_KEY)!);
expect(stored).toEqual(settings);
});
});
});

22
src/store/utils/localStorage.ts

@ -7,6 +7,8 @@ import {
GenerateImageNodeDefaults,
GenerateVideoNodeDefaults,
LLMNodeDefaults,
CanvasNavigationSettings,
defaultCanvasNavigationSettings,
} from "@/types";
// Storage keys
@ -16,6 +18,7 @@ export const GENERATE_IMAGE_DEFAULTS_KEY = "node-banana-nanoBanana-defaults";
export const PROVIDER_SETTINGS_KEY = "node-banana-provider-settings";
export const RECENT_MODELS_KEY = "node-banana-recent-models";
export const NODE_DEFAULTS_KEY = "node-banana-node-defaults";
export const CANVAS_NAVIGATION_KEY = "node-banana-canvas-navigation";
// Maximum recent models to store (show 4 in UI, keep 8 for persistence)
export const MAX_RECENT_MODELS = 8;
@ -191,6 +194,25 @@ export const getLLMDefaults = (): LLMNodeDefaults | undefined => {
return config.llm;
};
// Canvas navigation settings helpers
export const getCanvasNavigationSettings = (): CanvasNavigationSettings => {
if (typeof window === "undefined") return defaultCanvasNavigationSettings;
const stored = localStorage.getItem(CANVAS_NAVIGATION_KEY);
if (stored) {
try {
return JSON.parse(stored) as CanvasNavigationSettings;
} catch {
return defaultCanvasNavigationSettings;
}
}
return defaultCanvasNavigationSettings;
};
export const saveCanvasNavigationSettings = (settings: CanvasNavigationSettings): void => {
if (typeof window === "undefined") return;
localStorage.setItem(CANVAS_NAVIGATION_KEY, JSON.stringify(settings));
};
// Workflow ID generator
export const generateWorkflowId = (): string =>
`wf_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;

18
src/store/workflowStore.ts

@ -22,6 +22,7 @@ import {
ProviderType,
ProviderSettings,
RecentModel,
CanvasNavigationSettings,
} from "@/types";
import { useToast } from "@/components/Toast";
import { logger } from "@/utils/logger";
@ -39,6 +40,8 @@ import {
saveRecentModels,
MAX_RECENT_MODELS,
generateWorkflowId,
getCanvasNavigationSettings,
saveCanvasNavigationSettings,
} from "./utils/localStorage";
import {
createDefaultNodeData,
@ -251,6 +254,12 @@ interface WorkflowStore {
clearSnapshot: () => void;
incrementManualChangeCount: () => void;
applyEditOperations: (operations: EditOperation[]) => { applied: number; skipped: string[] };
// Canvas navigation settings state
canvasNavigationSettings: CanvasNavigationSettings;
// Canvas navigation settings actions
updateCanvasNavigationSettings: (settings: CanvasNavigationSettings) => void;
}
let nodeIdCounter = 0;
@ -340,6 +349,9 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
previousWorkflowSnapshot: null,
manualChangeCount: 0,
// Canvas navigation settings initial state
canvasNavigationSettings: getCanvasNavigationSettings(),
setEdgeStyle: (style: EdgeStyle) => {
set({ edgeStyle: style });
},
@ -1736,6 +1748,12 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
return { applied: result.applied, skipped: result.skipped };
},
// Canvas navigation settings actions
updateCanvasNavigationSettings: (settings: CanvasNavigationSettings) => {
set({ canvasNavigationSettings: settings });
saveCanvasNavigationSettings(settings);
},
}));
/**

21
src/types/canvas.ts

@ -0,0 +1,21 @@
/**
* Canvas Navigation Types
*
* Defines types for canvas navigation and interaction preferences.
*/
export type PanMode = "space" | "middleMouse" | "always";
export type ZoomMode = "scroll" | "altScroll" | "ctrlScroll";
export type SelectionMode = "click" | "altDrag" | "shiftDrag";
export interface CanvasNavigationSettings {
panMode: PanMode;
zoomMode: ZoomMode;
selectionMode: SelectionMode;
}
export const defaultCanvasNavigationSettings: CanvasNavigationSettings = {
panMode: "space",
zoomMode: "altScroll",
selectionMode: "click",
};

1
src/types/index.ts

@ -14,3 +14,4 @@ export * from "./workflow";
export * from "./api";
export * from "./quickstart";
export * from "./chat";
export * from "./canvas";

Loading…
Cancel
Save