|
|
|
@ -139,6 +139,9 @@ function isLocalKeyboardTarget(target: EventTarget | null): boolean { |
|
|
|
|
|
|
|
// Let fitView zoom out for large workflows, but never zoom in a single new node.
|
|
|
|
const FIT_VIEW_OPTIONS = { maxZoom: 1 }; |
|
|
|
const CANVAS_MIN_ZOOM = 0.1; |
|
|
|
const CANVAS_MAX_ZOOM = 4; |
|
|
|
const WHEEL_ZOOM_FACTOR = 1.15; |
|
|
|
|
|
|
|
// Connection validation rules
|
|
|
|
// - Image handles can only connect to image handles
|
|
|
|
@ -439,7 +442,7 @@ export function WorkflowCanvas() { |
|
|
|
const canUndo = useWorkflowStore((state) => state.canUndo); |
|
|
|
const canRedo = useWorkflowStore((state) => state.canRedo); |
|
|
|
const openAnnotationModal = useAnnotationStore((state) => state.openModal); |
|
|
|
const { screenToFlowPosition, getViewport, zoomIn, zoomOut, setViewport, setCenter, fitView } = useReactFlow(); |
|
|
|
const { screenToFlowPosition, getViewport, setViewport, setCenter, fitView } = useReactFlow(); |
|
|
|
const { show: showToast } = useToast(); |
|
|
|
const [isDragOver, setIsDragOver] = useState(false); |
|
|
|
const [dropType, setDropType] = useState<"image" | "video" | "audio" | "workflow" | "node" | "text" | null>(null); |
|
|
|
@ -455,27 +458,59 @@ export function WorkflowCanvas() { |
|
|
|
const miniMapNodeColor = "var(--text-disabled)"; |
|
|
|
const boxSelectionActiveRef = useRef(false); |
|
|
|
const boxSelectionEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
|
|
|
const reactFlowWrapper = useRef<HTMLDivElement>(null); |
|
|
|
const tutorialViewportSet = useRef(false); |
|
|
|
|
|
|
|
const syncZoomPercent = useCallback(() => { |
|
|
|
setZoomPercent(Math.round(getViewport().zoom * 100)); |
|
|
|
}, [getViewport]); |
|
|
|
|
|
|
|
const handleZoomPercentChange = useCallback((value: number) => { |
|
|
|
const getCanvasScreenCenter = useCallback(() => { |
|
|
|
const bounds = reactFlowWrapper.current?.getBoundingClientRect(); |
|
|
|
if (!bounds) { |
|
|
|
return { x: window.innerWidth / 2, y: window.innerHeight / 2 }; |
|
|
|
} |
|
|
|
|
|
|
|
return { |
|
|
|
x: bounds.left + bounds.width / 2, |
|
|
|
y: bounds.top + bounds.height / 2, |
|
|
|
}; |
|
|
|
}, []); |
|
|
|
|
|
|
|
const zoomAtScreenPoint = useCallback(( |
|
|
|
point: { x: number; y: number }, |
|
|
|
zoom: number, |
|
|
|
options?: { duration?: number } |
|
|
|
) => { |
|
|
|
const viewport = getViewport(); |
|
|
|
const nextZoom = value / 100; |
|
|
|
const centerX = window.innerWidth / 2; |
|
|
|
const centerY = window.innerHeight / 2; |
|
|
|
const flowCenterX = (centerX - viewport.x) / viewport.zoom; |
|
|
|
const flowCenterY = (centerY - viewport.y) / viewport.zoom; |
|
|
|
const currentZoom = Number.isFinite(viewport.zoom) && viewport.zoom > 0 ? viewport.zoom : 1; |
|
|
|
const nextZoom = Math.min(CANVAS_MAX_ZOOM, Math.max(CANVAS_MIN_ZOOM, zoom)); |
|
|
|
const flowX = (point.x - viewport.x) / currentZoom; |
|
|
|
const flowY = (point.y - viewport.y) / currentZoom; |
|
|
|
|
|
|
|
setZoomPercent(value); |
|
|
|
setZoomPercent(Math.round(nextZoom * 100)); |
|
|
|
setViewport({ |
|
|
|
x: centerX - flowCenterX * nextZoom, |
|
|
|
y: centerY - flowCenterY * nextZoom, |
|
|
|
x: point.x - flowX * nextZoom, |
|
|
|
y: point.y - flowY * nextZoom, |
|
|
|
zoom: nextZoom, |
|
|
|
}); |
|
|
|
}, options); |
|
|
|
}, [getViewport, setViewport]); |
|
|
|
|
|
|
|
const getCanvasFlowCenter = useCallback(() => { |
|
|
|
const viewport = getViewport(); |
|
|
|
const center = getCanvasScreenCenter(); |
|
|
|
const zoom = Number.isFinite(viewport.zoom) && viewport.zoom > 0 ? viewport.zoom : 1; |
|
|
|
|
|
|
|
return { |
|
|
|
centerX: (center.x - viewport.x) / zoom, |
|
|
|
centerY: (center.y - viewport.y) / zoom, |
|
|
|
}; |
|
|
|
}, [getCanvasScreenCenter, getViewport]); |
|
|
|
|
|
|
|
const handleZoomPercentChange = useCallback((value: number) => { |
|
|
|
zoomAtScreenPoint(getCanvasScreenCenter(), value / 100); |
|
|
|
}, [getCanvasScreenCenter, zoomAtScreenPoint]); |
|
|
|
|
|
|
|
const handleFitViewFromToolbar = useCallback(() => { |
|
|
|
void fitView({ ...FIT_VIEW_OPTIONS, padding: 0.2, duration: 300 }); |
|
|
|
}, [fitView]); |
|
|
|
@ -519,9 +554,6 @@ export function WorkflowCanvas() { |
|
|
|
}); |
|
|
|
}, [canvasNavigationSettings, updateCanvasNavigationSettings]); |
|
|
|
|
|
|
|
const reactFlowWrapper = useRef<HTMLDivElement>(null); |
|
|
|
const tutorialViewportSet = useRef(false); |
|
|
|
|
|
|
|
// FTUX tutorial state (client-side only to avoid SSR hydration issues)
|
|
|
|
const [tutorialActive, setTutorialActive] = useState(false); |
|
|
|
const [lockedFeatures, setLockedFeatures] = useState(false); |
|
|
|
@ -1868,15 +1900,16 @@ export function WorkflowCanvas() { |
|
|
|
if (scrollableElement) return; |
|
|
|
|
|
|
|
event.preventDefault(); |
|
|
|
if (event.deltaY < 0) zoomIn(); |
|
|
|
else zoomOut(); |
|
|
|
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); |
|
|
|
}; |
|
|
|
|
|
|
|
wrapper.addEventListener('wheel', handleWheelNonPassive, { passive: false }); |
|
|
|
return () => { |
|
|
|
wrapper.removeEventListener('wheel', handleWheelNonPassive); |
|
|
|
}; |
|
|
|
}, [isModalOpen, zoomIn, zoomOut]); |
|
|
|
}, [getViewport, isModalOpen, zoomAtScreenPoint]); |
|
|
|
|
|
|
|
// Keyboard shortcuts for copy/paste and stacking selected nodes
|
|
|
|
const handleKeyDown = useCallback((event: KeyboardEvent) => { |
|
|
|
@ -1890,15 +1923,15 @@ export function WorkflowCanvas() { |
|
|
|
if (isZoomInKey || isZoomOutKey || isZoomResetKey) { |
|
|
|
event.preventDefault(); |
|
|
|
event.stopPropagation(); |
|
|
|
const center = getCanvasScreenCenter(); |
|
|
|
const viewport = getViewport(); |
|
|
|
if (isZoomInKey) { |
|
|
|
zoomIn(); |
|
|
|
zoomAtScreenPoint(center, viewport.zoom * WHEEL_ZOOM_FACTOR, { duration: 160 }); |
|
|
|
} else if (isZoomOutKey) { |
|
|
|
zoomOut(); |
|
|
|
zoomAtScreenPoint(center, viewport.zoom / WHEEL_ZOOM_FACTOR, { duration: 160 }); |
|
|
|
} else { |
|
|
|
const viewport = getViewport(); |
|
|
|
setViewport({ ...viewport, zoom: 1 }, { duration: 160 }); |
|
|
|
zoomAtScreenPoint(center, 1, { duration: 160 }); |
|
|
|
} |
|
|
|
syncZoomPercent(); |
|
|
|
return; |
|
|
|
} |
|
|
|
} |
|
|
|
@ -1943,10 +1976,7 @@ export function WorkflowCanvas() { |
|
|
|
|
|
|
|
// Helper to get viewport center position in flow coordinates
|
|
|
|
const getViewportCenter = () => { |
|
|
|
const viewport = getViewport(); |
|
|
|
const centerX = (-viewport.x + window.innerWidth / 2) / viewport.zoom; |
|
|
|
const centerY = (-viewport.y + window.innerHeight / 2) / viewport.zoom; |
|
|
|
return { centerX, centerY }; |
|
|
|
return getCanvasFlowCenter(); |
|
|
|
}; |
|
|
|
|
|
|
|
// Handle node creation hotkeys (Shift + key)
|
|
|
|
@ -2056,9 +2086,7 @@ export function WorkflowCanvas() { |
|
|
|
}); |
|
|
|
} else { |
|
|
|
// No imageInput node selected - create a new one at viewport center
|
|
|
|
const viewport = getViewport(); |
|
|
|
const centerX = (-viewport.x + window.innerWidth / 2) / viewport.zoom; |
|
|
|
const centerY = (-viewport.y + window.innerHeight / 2) / viewport.zoom; |
|
|
|
const { centerX, centerY } = getCanvasFlowCenter(); |
|
|
|
|
|
|
|
// ImageInput node default dimensions: 300x280
|
|
|
|
const nodeId = addNode("imageInput", { x: centerX - 150, y: centerY - 140 }); |
|
|
|
@ -2076,9 +2104,7 @@ export function WorkflowCanvas() { |
|
|
|
const blob = await item.getType('text/plain'); |
|
|
|
const text = await blob.text(); |
|
|
|
if (text.trim()) { |
|
|
|
const viewport = getViewport(); |
|
|
|
const centerX = (-viewport.x + window.innerWidth / 2) / viewport.zoom; |
|
|
|
const centerY = (-viewport.y + window.innerHeight / 2) / viewport.zoom; |
|
|
|
const { centerX, centerY } = getCanvasFlowCenter(); |
|
|
|
const nodeId = addNode("smartText", { x: centerX - 160, y: centerY - 110 }); |
|
|
|
updateNodeData(nodeId, { manualLocked: true, manualText: text }); |
|
|
|
return; // Exit after handling text
|
|
|
|
@ -2182,7 +2208,7 @@ export function WorkflowCanvas() { |
|
|
|
]); |
|
|
|
}); |
|
|
|
} |
|
|
|
}, [nodes, onNodesChange, copySelectedNodes, pasteNodes, clearClipboard, clipboard, getViewport, addNode, updateNodeData, executeWorkflow, setShortcutsDialogOpen, undo, redo, isModalOpen, zoomIn, zoomOut, setViewport, syncZoomPercent]); |
|
|
|
}, [nodes, onNodesChange, copySelectedNodes, pasteNodes, clearClipboard, clipboard, getViewport, getCanvasScreenCenter, getCanvasFlowCenter, addNode, updateNodeData, executeWorkflow, setShortcutsDialogOpen, undo, redo, isModalOpen, zoomAtScreenPoint]); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
window.addEventListener("keydown", handleKeyDown, { capture: true }); |
|
|
|
@ -2722,8 +2748,8 @@ export function WorkflowCanvas() { |
|
|
|
zoomOnScroll={tutorialActive ? false : false} |
|
|
|
zoomOnPinch={tutorialActive ? false : !isModalOpen} |
|
|
|
zoomOnDoubleClick={false} |
|
|
|
minZoom={0.1} |
|
|
|
maxZoom={4} |
|
|
|
minZoom={CANVAS_MIN_ZOOM} |
|
|
|
maxZoom={CANVAS_MAX_ZOOM} |
|
|
|
defaultViewport={{ x: 0, y: 0, zoom: 1 }} |
|
|
|
panActivationKeyCode={null} |
|
|
|
nodesDraggable={!isModalOpen} |
|
|
|
|