diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/long-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/long-input.tsx index 2cd64bddcd..54175ec896 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/long-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/long-input.tsx @@ -88,6 +88,7 @@ export function LongInput({ const [cursorPosition, setCursorPosition] = useState(0) const textareaRef = useRef(null) const overlayRef = useRef(null) + const overlayInnerRef = useRef(null) const [activeSourceBlockId, setActiveSourceBlockId] = useState(null) const containerRef = useRef(null) @@ -140,6 +141,35 @@ export function LongInput({ } }, [rows]) + // Set overlay width to match textarea clientWidth + useLayoutEffect(() => { + if (!textareaRef.current || !overlayRef.current) return + const textarea = textareaRef.current + const overlay = overlayRef.current + + const applyWidth = () => { + // Match overlay content width to the inner content area of the textarea + overlay.style.width = `${textarea.clientWidth}px` + } + + applyWidth() + + const resizeObserver = new ResizeObserver(() => { + applyWidth() + }) + resizeObserver.observe(textarea) + + return () => { + resizeObserver.disconnect() + } + }, []) + + // Initialize overlay transform to current scroll + useLayoutEffect(() => { + // Initialize overlay transform to current scroll + syncScrollPositions() + }, []) + // Handle input changes const handleChange = (e: React.ChangeEvent) => { // Don't allow changes if disabled or streaming @@ -172,19 +202,21 @@ export function LongInput({ // Sync scroll position between textarea and overlay const handleScroll = (e: React.UIEvent) => { - if (overlayRef.current) { - overlayRef.current.scrollTop = e.currentTarget.scrollTop - overlayRef.current.scrollLeft = e.currentTarget.scrollLeft - } + if (!overlayInnerRef.current) return + const { scrollTop, scrollLeft } = e.currentTarget + overlayInnerRef.current.style.transform = `translate(${-scrollLeft}px, ${-scrollTop}px)` + } + + // Force synchronize scroll positions + const syncScrollPositions = () => { + if (!textareaRef.current || !overlayInnerRef.current) return + const { scrollTop, scrollLeft } = textareaRef.current + overlayInnerRef.current.style.transform = `translate(${-scrollLeft}px, ${-scrollTop}px)` } // Ensure overlay updates when content changes useEffect(() => { - if (textareaRef.current && overlayRef.current) { - // Ensure scrolling is synchronized - overlayRef.current.scrollTop = textareaRef.current.scrollTop - overlayRef.current.scrollLeft = textareaRef.current.scrollLeft - } + syncScrollPositions() }, [value]) // Handle resize functionality @@ -208,6 +240,8 @@ export function LongInput({ if (containerRef.current) { containerRef.current.style.height = `${newHeight}px` } + // Keep overlay aligned with textarea scroll during live resize + syncScrollPositions() } } @@ -220,6 +254,8 @@ export function LongInput({ isResizing.current = false document.removeEventListener('mousemove', handleMouseMove) document.removeEventListener('mouseup', handleMouseUp) + // After resizing completes, re-sync to ensure caret at end remains visually aligned + syncScrollPositions() } document.addEventListener('mousemove', handleMouseMove) @@ -335,9 +371,7 @@ export function LongInput({ } // For regular scrolling (without Ctrl/Cmd), let the default behavior happen - if (overlayRef.current) { - overlayRef.current.scrollTop = e.currentTarget.scrollTop - } + // No overlay scroll; overlay position is synced via transform on scroll handler } return ( @@ -365,6 +399,7 @@ export function LongInput({ ref={textareaRef} className={cn( 'allow-scroll min-h-full w-full resize-none text-transparent caret-foreground placeholder:text-muted-foreground/50', + '!text-[14px]', // Force override any responsive text sizes from Textarea component isConnecting && config?.connectionDroppable !== false && 'ring-2 ring-blue-500 ring-offset-2 focus-visible:ring-blue-500', @@ -391,25 +426,69 @@ export function LongInput({ }} disabled={isPreview || disabled} style={{ - fontFamily: 'inherit', - lineHeight: 'inherit', + // Explicit font properties for perfect alignment + fontFamily: + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', + fontSize: '14px', + fontWeight: '400', + // Match the fixed pixel line-height used on the textarea + lineHeight: '21px', + letterSpacing: 'normal', height: `${height}px`, + // Text wrapping properties wordBreak: 'break-word', whiteSpace: 'pre-wrap', + overflowWrap: 'break-word', + // Box sizing to ensure padding is calculated correctly + boxSizing: 'border-box', + // Remove text rendering optimizations that can affect layout + textRendering: 'auto', }} />
- {formatDisplayText(value?.toString() ?? '', true)} +
+ {formatDisplayText(value?.toString() ?? '', true)} +
{/* Wand Button */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index abf5e1a34c..4df709aeea 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -383,6 +383,12 @@ export function WorkflowBlock({ id, data }: NodeProps) { if (height !== blockHeight) { updateBlockHeight(id, height) updateNodeInternals(id) + try { + const evt = new CustomEvent('workflow-node-resized', { + detail: { id, height }, + }) + window.dispatchEvent(evt) + } catch {} } }, 100) @@ -925,6 +931,12 @@ export function WorkflowBlock({ id, data }: NodeProps) { variant='ghost' size='sm' onClick={() => { + try { + const evt = new CustomEvent('workflow-layout-change', { + detail: { reason: 'wide-toggle', blockId: id }, + }) + window.dispatchEvent(evt) + } catch {} if (currentWorkflow.isDiffMode) { setDiffIsWide((prev) => !prev) } else if (userPermissions.canEdit) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index de6a14878c..bebf0c8f37 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -1,6 +1,6 @@ 'use client' -import React, { useCallback, useEffect, useMemo, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useParams, useRouter } from 'next/navigation' import ReactFlow, { Background, @@ -9,6 +9,7 @@ import ReactFlow, { type EdgeTypes, type NodeTypes, ReactFlowProvider, + type Node as RFNode, useReactFlow, } from 'reactflow' import 'reactflow/dist/style.css' @@ -66,6 +67,8 @@ interface BlockData { const WorkflowContent = React.memo(() => { // State const [isWorkflowReady, setIsWorkflowReady] = useState(false) + const prevContentMaxYRef = useRef(Number.NEGATIVE_INFINITY) + const lastRefreshAtRef = useRef(0) // State for tracking node dragging const [draggedNodeId, setDraggedNodeId] = useState(null) @@ -78,7 +81,7 @@ const WorkflowContent = React.memo(() => { // Hooks const params = useParams() const router = useRouter() - const { project, getNodes, fitView } = useReactFlow() + const { project, getNodes, getViewport, setViewport } = useReactFlow() // Get workspace ID from the params const workspaceId = params.workspaceId as string @@ -278,10 +281,32 @@ const WorkflowContent = React.memo(() => { [getNodes] ) + // Helper to detect if the user is actively editing inside an input/editor + const isEditableElementFocused = useCallback((): boolean => { + if (typeof document === 'undefined') return false + const activeElement = document.activeElement as Element | null + if (!activeElement) return false + return ( + activeElement instanceof HTMLInputElement || + activeElement instanceof HTMLTextAreaElement || + activeElement.hasAttribute('contenteditable') + ) + }, []) + + // Temporary suppression of auto-pan (e.g., around wide-mode toggles) + const suppressAutoPanUntilRef = useRef(0) + useEffect(() => { + const handleLayoutChange = () => { + suppressAutoPanUntilRef.current = Date.now() + 400 // brief suppression window + } + window.addEventListener('workflow-layout-change', handleLayoutChange) + return () => window.removeEventListener('workflow-layout-change', handleLayoutChange) + }, []) + // Compute the absolute position of a node's source anchor (right-middle) const getNodeAnchorPosition = useCallback( (nodeId: string): { x: number; y: number } => { - const node = getNodes().find((n) => n.id === nodeId) + const node = getNodes().find((n: RFNode) => n.id === nodeId) const absPos = getNodeAbsolutePositionWrapper(nodeId) if (!node) { @@ -407,12 +432,12 @@ const WorkflowContent = React.memo(() => { (newNodePosition: { x: number; y: number }): BlockData | null => { // Determine if drop is inside a container; if not, exclude child nodes from candidates const containerAtPoint = isPointInLoopNodeWrapper(newNodePosition) - const nodeIndex = new Map(getNodes().map((n) => [n.id, n])) + const nodeIndex = new Map(getNodes().map((n: RFNode) => [n.id, n])) const candidates = Object.entries(blocks) .filter(([id, block]) => { if (!block.enabled) return false - const node = nodeIndex.get(id) + const node = nodeIndex.get(id as string) if (!node) return false // If dropping outside containers, ignore blocks that are inside a container @@ -758,7 +783,7 @@ const WorkflowContent = React.memo(() => { } } else { // No existing children: connect from the container's start handle - const containerNode = getNodes().find((n) => n.id === containerInfo.loopId) + const containerNode = getNodes().find((n: RFNode) => n.id === containerInfo.loopId) const startSourceHandle = (containerNode?.data as any)?.kind === 'loop' ? 'loop-start-source' @@ -843,7 +868,7 @@ const WorkflowContent = React.memo(() => { const containerElement = document.querySelector(`[data-id="${containerInfo.loopId}"]`) if (containerElement) { // Determine the type of container node for appropriate styling - const containerNode = getNodes().find((n) => n.id === containerInfo.loopId) + const containerNode = getNodes().find((n: RFNode) => n.id === containerInfo.loopId) if ( containerNode?.type === 'subflowNode' && (containerNode.data as any)?.kind === 'loop' @@ -1059,13 +1084,145 @@ const WorkflowContent = React.memo(() => { useEffect(() => { // Skip during initial render when nodes aren't loaded yet if (nodes.length === 0) return + // Skip adjustments while user is dragging a node to avoid jitter + if (draggedNodeId) return + // Avoid viewport jumps while user is editing text unless content grows + const editingFocused = isEditableElementFocused() // Resize all loops to fit their children resizeLoopNodesWrapper() + // Defer measurement to the next frames so ReactFlow/dom layout settles + requestAnimationFrame(() => { + requestAnimationFrame(() => { + try { + const containerEl = document.querySelector('.workflow-container') as HTMLElement | null + if (!containerEl) return + + const rfNodes = getNodes() + if (!rfNodes || rfNodes.length === 0) return + + // Compute maximum Y of content + let maxY = Number.NEGATIVE_INFINITY + rfNodes.forEach((n: RFNode) => { + const abs = getNodeAbsolutePositionWrapper(n.id) + const isSubflow = n.type === 'subflowNode' + const nodeHeight = isSubflow + ? ((n.data as any)?.height ?? 300) + : typeof n.height === 'number' + ? n.height + : 100 + if (abs.y + nodeHeight > maxY) maxY = abs.y + nodeHeight + }) + + if (!Number.isFinite(maxY)) return + + // Compare with visible bottom in flow-coordinates + const { height: containerHeight } = containerEl.getBoundingClientRect() + const { x: vpX, y: vpY, zoom } = getViewport() + const visibleBottomFlow = (containerHeight - vpY) / zoom + + const paddingFlow = 120 + const overflowBottom = maxY + paddingFlow - visibleBottomFlow + const previousMaxY = prevContentMaxYRef.current + // Update previous max Y for next cycle + prevContentMaxYRef.current = maxY + const now = Date.now() + // Throttle refresh to avoid rapid consecutive calls + if (now - lastRefreshAtRef.current < 120) return + + // Only act when we are actually overflowing the visible bottom + const overflowThreshold = 10 + // Only pan when content grows beyond what is visible, + // or when not focused in an editable element + const didContentGrow = maxY > previousMaxY + const isSuppressed = Date.now() < suppressAutoPanUntilRef.current + if ( + overflowBottom > overflowThreshold && + (didContentGrow || !editingFocused) && + !isSuppressed + ) { + // Pan just enough to include the overflow, and force a repaint with an epsilon jiggle + const targetY = vpY - overflowBottom * zoom + const epsilon = 0.5 + setViewport({ x: vpX, y: targetY + epsilon, zoom }, { duration: 0 }) + requestAnimationFrame(() => { + setViewport({ x: vpX, y: targetY, zoom }, { duration: 0 }) + }) + } + + lastRefreshAtRef.current = now + } catch {} + }) + }) + // No need for cleanup with direct function return () => {} - }, [nodes, resizeLoopNodesWrapper]) + }, [ + nodes, + resizeLoopNodesWrapper, + getViewport, + setViewport, + getNodes, + getNodeAbsolutePositionWrapper, + isEditableElementFocused, + draggedNodeId, + ]) + + // Also react explicitly when any node reports a height change (post-paste expansion) + useEffect(() => { + const handleNodeResized = () => { + // Defer to allow DOM/layout to settle after ReactFlow updates + requestAnimationFrame(() => { + requestAnimationFrame(() => { + try { + const containerEl = document.querySelector('.workflow-container') as HTMLElement | null + if (!containerEl) return + + const rfNodes = getNodes() + if (!rfNodes || rfNodes.length === 0) return + + let maxY = Number.NEGATIVE_INFINITY + rfNodes.forEach((n: RFNode) => { + const abs = getNodeAbsolutePositionWrapper(n.id) + const isSubflow = n.type === 'subflowNode' + const nodeHeight = isSubflow + ? ((n.data as any)?.height ?? 300) + : typeof n.height === 'number' + ? n.height + : 100 + if (abs.y + nodeHeight > maxY) maxY = abs.y + nodeHeight + }) + if (!Number.isFinite(maxY)) return + + const { height: containerHeight } = containerEl.getBoundingClientRect() + const { x: vpX, y: vpY, zoom } = getViewport() + const visibleBottomFlow = (containerHeight - vpY) / zoom + + const paddingFlow = 120 + const overflowBottom = maxY + paddingFlow - visibleBottomFlow + const previousMaxY = prevContentMaxYRef.current + // Update previous max Y for next cycle + prevContentMaxYRef.current = maxY + const editingFocused = isEditableElementFocused() + const didContentGrow = maxY > previousMaxY + const isSuppressed = Date.now() < suppressAutoPanUntilRef.current + if (overflowBottom > 10 && (didContentGrow || !editingFocused) && !isSuppressed) { + const targetY = vpY - overflowBottom * zoom + const epsilon = 0.5 + setViewport({ x: vpX, y: targetY + epsilon, zoom }, { duration: 0 }) + requestAnimationFrame(() => { + setViewport({ x: vpX, y: targetY, zoom }, { duration: 0 }) + }) + } + } catch {} + }) + }) + } + + window.addEventListener('workflow-node-resized', handleNodeResized) + return () => window.removeEventListener('workflow-node-resized', handleNodeResized) + }, [getNodes, getNodeAbsolutePositionWrapper, getViewport, setViewport, isEditableElementFocused]) // Special effect to handle cleanup after node deletion useEffect(() => { @@ -1098,11 +1255,6 @@ const WorkflowContent = React.memo(() => { validateNestedSubflows() }, [blocks, validateNestedSubflows]) - // Validate nested subflows whenever blocks change - useEffect(() => { - validateNestedSubflows() - }, [blocks, validateNestedSubflows]) - // Update edges const onEdgesChange = useCallback( (changes: any) => { @@ -1125,8 +1277,8 @@ const WorkflowContent = React.memo(() => { } // Check if connecting nodes across container boundaries - const sourceNode = getNodes().find((n) => n.id === connection.source) - const targetNode = getNodes().find((n) => n.id === connection.target) + const sourceNode = getNodes().find((n: RFNode) => n.id === connection.source) + const targetNode = getNodes().find((n: RFNode) => n.id === connection.target) if (!sourceNode || !targetNode) return @@ -1235,7 +1387,7 @@ const WorkflowContent = React.memo(() => { // Find intersections with container nodes using absolute coordinates const intersectingNodes = getNodes() - .filter((n) => { + .filter((n: RFNode) => { // Only consider container nodes that aren't the dragged node if (n.type !== 'subflowNode' || n.id === node.id) return false @@ -1295,7 +1447,7 @@ const WorkflowContent = React.memo(() => { ) }) // Add more information for sorting - .map((n) => ({ + .map((n: RFNode) => ({ container: n, depth: getNodeDepthWrapper(n.id), // Calculate size for secondary sorting @@ -1305,14 +1457,19 @@ const WorkflowContent = React.memo(() => { // Update potential parent if there's at least one intersecting container node if (intersectingNodes.length > 0) { // Sort by depth first (deepest/most nested containers first), then by size if same depth - const sortedContainers = intersectingNodes.sort((a, b) => { - // First try to compare by hierarchy depth - if (a.depth !== b.depth) { - return b.depth - a.depth // Higher depth (more nested) comes first + const sortedContainers = intersectingNodes.sort( + ( + a: { container: RFNode; depth: number; size: number }, + b: { container: RFNode; depth: number; size: number } + ) => { + // First try to compare by hierarchy depth + if (a.depth !== b.depth) { + return b.depth - a.depth // Higher depth (more nested) comes first + } + // If same depth, use size as secondary criterion + return a.size - b.size // Smaller container takes precedence } - // If same depth, use size as secondary criterion - return a.size - b.size // Smaller container takes precedence - }) + ) // Use the most appropriate container (deepest or smallest at same depth) const bestContainerMatch = sortedContainers[0] @@ -1473,7 +1630,7 @@ const WorkflowContent = React.memo(() => { } } else { // No children: connect from the container's start handle to the moved node - const containerNode = getNodes().find((n) => n.id === potentialParentId) + const containerNode = getNodes().find((n: RFNode) => n.id === potentialParentId) const startSourceHandle = (containerNode?.data as any)?.kind === 'loop' ? 'loop-start-source' @@ -1520,8 +1677,8 @@ const WorkflowContent = React.memo(() => { event.stopPropagation() // Prevent bubbling // Determine if edge is inside a loop by checking its source/target nodes - const sourceNode = getNodes().find((n) => n.id === edge.source) - const targetNode = getNodes().find((n) => n.id === edge.target) + const sourceNode = getNodes().find((n: RFNode) => n.id === edge.source) + const targetNode = getNodes().find((n: RFNode) => n.id === edge.target) // An edge is inside a loop if either source or target has a parent // If source and target have different parents, prioritize source's parent @@ -1542,8 +1699,8 @@ const WorkflowContent = React.memo(() => { // Transform edges to include improved selection state const edgesWithSelection = edgesForDisplay.map((edge) => { // Check if this edge connects nodes inside a loop - const sourceNode = getNodes().find((n) => n.id === edge.source) - const targetNode = getNodes().find((n) => n.id === edge.target) + const sourceNode = getNodes().find((n: RFNode) => n.id === edge.source) + const targetNode = getNodes().find((n: RFNode) => n.id === edge.target) const parentLoopId = sourceNode?.parentId || targetNode?.parentId const isInsideLoop = Boolean(parentLoopId) @@ -1690,6 +1847,18 @@ const WorkflowContent = React.memo(() => { elevateNodesOnSelect={true} autoPanOnConnect={effectivePermissions.canEdit} autoPanOnNodeDrag={effectivePermissions.canEdit} + onMoveEnd={(_event, viewport) => { + try { + const epsilon = 0.5 + setViewport( + { x: viewport.x, y: viewport.y + epsilon, zoom: viewport.zoom }, + { duration: 0 } + ) + requestAnimationFrame(() => { + setViewport({ x: viewport.x, y: viewport.y, zoom: viewport.zoom }, { duration: 0 }) + }) + } catch {} + }} >