Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
1a5dda6
improvement(canvas): add multi-block select, add batch handle, enable…
waleedlatif1 Jan 9, 2026
dc0040d
feat(i18n): update translations (#2732)
waleedlatif1 Jan 9, 2026
e054cef
don't allow flip handles for subflows
waleedlatif1 Jan 9, 2026
757343d
ack PR comments
waleedlatif1 Jan 9, 2026
e1036e3
more
waleedlatif1 Jan 9, 2026
a8fb76b
fix missing handler
waleedlatif1 Jan 9, 2026
0568704
remove dead subflow-specific ops
waleedlatif1 Jan 9, 2026
1029ba0
remove unused code
waleedlatif1 Jan 9, 2026
20d66b9
fixed subflow ops
waleedlatif1 Jan 9, 2026
6e7f3da
keep edges on subflow actions intact
waleedlatif1 Jan 9, 2026
4fa6cb8
fix subflow resizing
icecrasher321 Jan 9, 2026
ee7a561
Merge branch 'fix/select' of github.com:simstudioai/sim into fix/select
icecrasher321 Jan 9, 2026
b41d17c
fix remove from subflow bulk
icecrasher321 Jan 9, 2026
0f515c3
improvement(canvas): add multi-block select, add batch handle, enable…
waleedlatif1 Jan 9, 2026
aca9579
don't allow flip handles for subflows
waleedlatif1 Jan 9, 2026
b11f1cb
ack PR comments
waleedlatif1 Jan 9, 2026
06c007f
more
waleedlatif1 Jan 9, 2026
abf64aa
fix missing handler
waleedlatif1 Jan 9, 2026
8af27a7
remove dead subflow-specific ops
waleedlatif1 Jan 9, 2026
3b69707
remove unused code
waleedlatif1 Jan 9, 2026
abf46da
fixed subflow ops
waleedlatif1 Jan 9, 2026
24b918a
fix subflow resizing
icecrasher321 Jan 9, 2026
60e25fd
keep edges on subflow actions intact
waleedlatif1 Jan 9, 2026
3f37b5c
fixed copy from inside subflow
waleedlatif1 Jan 9, 2026
ea8c710
Merge branch 'fix/select' of github.com:simstudioai/sim into fix/select
icecrasher321 Jan 9, 2026
7022b4c
types improvement, preview fixes
waleedlatif1 Jan 9, 2026
c97bc69
fetch varible data in deploy modal
waleedlatif1 Jan 9, 2026
b085942
moved remove from subflow one position to the right
waleedlatif1 Jan 9, 2026
98493de
fix subflow issues
icecrasher321 Jan 9, 2026
e7705d5
address greptile comment
icecrasher321 Jan 9, 2026
7f312cb
fix test
icecrasher321 Jan 9, 2026
37c13c8
improvement(preview): ui/ux
emir-karabeg Jan 9, 2026
f00a7a0
fix(preview): subflows
emir-karabeg Jan 9, 2026
b24f119
added batch add edges
waleedlatif1 Jan 9, 2026
687733d
removed recovery
waleedlatif1 Jan 9, 2026
80e98dd
use consolidated consts for sockets operations
waleedlatif1 Jan 9, 2026
9026952
more
waleedlatif1 Jan 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/sim/app/(landing)/privacy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -767,7 +767,7 @@ export default function PrivacyPolicy() {
[email protected]
</Link>
</li>
<li>Mailing Address: Sim, 80 Langton St, San Francisco, CA 94133, USA</li>
<li>Mailing Address: Sim, 80 Langton St, San Francisco, CA 94103, USA</li>
</ul>
<p>We will respond to your request within a reasonable timeframe.</p>
</section>
Expand Down
32 changes: 32 additions & 0 deletions apps/sim/app/_styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,38 @@
animation: dash-animation 1.5s linear infinite !important;
}

/**
* React Flow selection box styling
* Uses brand-secondary color for selection highlighting
*/
.react-flow__selection {
background: rgba(51, 180, 255, 0.08) !important;
border: 1px solid var(--brand-secondary) !important;
}

.react-flow__nodesselection-rect {
background: transparent !important;
border: none !important;
}

/**
* Selected node ring indicator
* Uses a pseudo-element overlay to match the original behavior (absolute inset-0 z-40)
*/
.react-flow__node.selected > div > div {
position: relative;
}

.react-flow__node.selected > div > div::after {
content: "";
position: absolute;
inset: 0;
z-index: 40;
border-radius: 8px;
box-shadow: 0 0 0 1.75px var(--brand-secondary);
pointer-events: none;
}

/**
* Color tokens - single source of truth for all colors
* Light mode: Warm theme
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import {
useFloatBoundarySync,
useFloatDrag,
useFloatResize,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-float'
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/float'
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
import type { BlockLog, ExecutionResult } from '@/executor/types'
import { getChatPosition, useChatStore } from '@/stores/chat/store'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ export function createDragPreview(info: DragItemInfo): HTMLElement {
z-index: 9999;
`

// Create icon container
const iconContainer = document.createElement('div')
iconContainer.style.cssText = `
width: 24px;
Expand All @@ -45,7 +44,6 @@ export function createDragPreview(info: DragItemInfo): HTMLElement {
flex-shrink: 0;
`

// Clone the actual icon if provided
if (info.iconElement) {
const clonedIcon = info.iconElement.cloneNode(true) as HTMLElement
clonedIcon.style.width = '16px'
Expand All @@ -55,11 +53,10 @@ export function createDragPreview(info: DragItemInfo): HTMLElement {
iconContainer.appendChild(clonedIcon)
}

// Create text element
const text = document.createElement('span')
text.textContent = info.name
text.style.cssText = `
color: #FFFFFF;
color: var(--text-primary);
font-size: 16px;
font-weight: 500;
white-space: nowrap;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1489,9 +1489,7 @@ export function Terminal() {
variant='ghost'
className={clsx(
'px-[8px] py-[6px] text-[12px]',
!showInput &&
hasInputData &&
'!text-[var(--text-primary)] dark:!text-[var(--text-primary)]'
!showInput ? '!text-[var(--text-primary)]' : '!text-[var(--text-tertiary)]'
)}
onClick={(e) => {
e.stopPropagation()
Expand All @@ -1509,7 +1507,7 @@ export function Terminal() {
variant='ghost'
className={clsx(
'px-[8px] py-[6px] text-[12px]',
showInput && '!text-[var(--text-primary)]'
showInput ? '!text-[var(--text-primary)]' : '!text-[var(--text-tertiary)]'
)}
onClick={(e) => {
e.stopPropagation()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ export const ActionBar = memo(
const {
collaborativeBatchAddBlocks,
collaborativeBatchRemoveBlocks,
collaborativeToggleBlockEnabled,
collaborativeToggleBlockHandles,
collaborativeBatchToggleBlockEnabled,
collaborativeBatchToggleBlockHandles,
} = useCollaborativeWorkflow()
const { activeWorkflowId } = useWorkflowRegistry()
const blocks = useWorkflowStore((state) => state.blocks)
Expand Down Expand Up @@ -121,7 +121,7 @@ export const ActionBar = memo(
onClick={(e) => {
e.stopPropagation()
if (!disabled) {
collaborativeToggleBlockEnabled(blockId)
collaborativeBatchToggleBlockEnabled([blockId])
}
}}
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]'
Expand Down Expand Up @@ -192,7 +192,7 @@ export const ActionBar = memo(
onClick={(e) => {
e.stopPropagation()
if (!disabled) {
collaborativeToggleBlockHandles(blockId)
collaborativeBatchToggleBlockHandles([blockId])
}
}}
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
export {
clearDragHighlights,
computeClampedPositionUpdates,
getClampedPositionForNode,
isInEditableElement,
selectNodesDeferred,
validateTriggerPaste,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers'
export { useFloatBoundarySync, useFloatDrag, useFloatResize } from './float'
export { useAutoLayout } from './use-auto-layout'
export { BLOCK_DIMENSIONS, useBlockDimensions } from './use-block-dimensions'
export { useBlockVisual } from './use-block-visual'
export { type CurrentWorkflow, useCurrentWorkflow } from './use-current-workflow'
export { useFloatBoundarySync, useFloatDrag, useFloatResize } from './use-float'
export { useNodeUtilities } from './use-node-utilities'
export { usePreventZoom } from './use-prevent-zoom'
export { useScrollManagement } from './use-scroll-management'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ interface UseBlockVisualProps {

/**
* Provides visual state and interaction handlers for workflow blocks.
* Computes ring styling based on execution, focus, diff, and run path states.
* Computes ring styling based on execution, diff, deletion, and run path states.
* In preview mode, all interactive and execution-related visual states are disabled.
*
* @param props - The hook properties
Expand All @@ -46,8 +46,6 @@ export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVis
const runPathStatus = isPreview ? undefined : lastRunPath.get(blockId)

const setCurrentBlockId = usePanelEditorStore((state) => state.setCurrentBlockId)
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
const isFocused = isPreview ? false : currentBlockId === blockId

const handleClick = useCallback(() => {
if (!isPreview) {
Expand All @@ -60,12 +58,11 @@ export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVis
getBlockRingStyles({
isActive,
isPending: isPreview ? false : isPending,
isFocused,
isDeletedBlock: isPreview ? false : isDeletedBlock,
diffStatus: isPreview ? undefined : diffStatus,
runPathStatus,
}),
[isActive, isPending, isFocused, isDeletedBlock, diffStatus, runPathStatus, isPreview]
[isActive, isPending, isDeletedBlock, diffStatus, runPathStatus, isPreview]
)

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,24 @@ export type BlockRunPathStatus = 'success' | 'error' | undefined
export interface BlockRingOptions {
isActive: boolean
isPending: boolean
isFocused: boolean
isDeletedBlock: boolean
diffStatus: BlockDiffStatus
runPathStatus: BlockRunPathStatus
}

/**
* Derives visual ring visibility and class names for workflow blocks
* based on execution, focus, diff, deletion, and run-path states.
* based on execution, diff, deletion, and run-path states.
*/
export function getBlockRingStyles(options: BlockRingOptions): {
hasRing: boolean
ringClassName: string
} {
const { isActive, isPending, isFocused, isDeletedBlock, diffStatus, runPathStatus } = options
const { isActive, isPending, isDeletedBlock, diffStatus, runPathStatus } = options

const hasRing =
isActive ||
isPending ||
isFocused ||
diffStatus === 'new' ||
diffStatus === 'edited' ||
isDeletedBlock ||
Expand All @@ -39,34 +37,28 @@ export function getBlockRingStyles(options: BlockRingOptions): {
!isActive && hasRing && 'ring-[1.75px]',
// Pending state: warning ring
!isActive && isPending && 'ring-[var(--warning)]',
// Focused (selected) state: brand ring
!isActive && !isPending && isFocused && 'ring-[var(--brand-secondary)]',
// Deleted state (highest priority after active/pending/focused)
!isActive && !isPending && !isFocused && isDeletedBlock && 'ring-[var(--text-error)]',
// Deleted state (highest priority after active/pending)
!isActive && !isPending && isDeletedBlock && 'ring-[var(--text-error)]',
// Diff states
!isActive &&
!isPending &&
!isFocused &&
!isDeletedBlock &&
diffStatus === 'new' &&
'ring-[var(--brand-tertiary)]',
!isActive &&
!isPending &&
!isFocused &&
!isDeletedBlock &&
diffStatus === 'edited' &&
'ring-[var(--warning)]',
// Run path states (lowest priority - only show if no other states active)
!isActive &&
!isPending &&
!isFocused &&
!isDeletedBlock &&
!diffStatus &&
runPathStatus === 'success' &&
'ring-[var(--border-success)]',
!isActive &&
!isPending &&
!isFocused &&
!isDeletedBlock &&
!diffStatus &&
runPathStatus === 'error' &&
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import type { Node } from 'reactflow'
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { clampPositionToContainer } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities'
import type { BlockState } from '@/stores/workflows/workflow/types'

/**
* Checks if the currently focused element is an editable input.
* Returns true if the user is typing in an input, textarea, or contenteditable element.
*/
export function isInEditableElement(): boolean {
const activeElement = document.activeElement
return (
activeElement instanceof HTMLInputElement ||
activeElement instanceof HTMLTextAreaElement ||
activeElement?.hasAttribute('contenteditable') === true
)
}

interface TriggerValidationResult {
isValid: boolean
message?: string
}

/**
* Validates that pasting/duplicating trigger blocks won't violate constraints.
* Returns validation result with error message if invalid.
*/
export function validateTriggerPaste(
blocksToAdd: Array<{ type: string }>,
existingBlocks: Record<string, BlockState>,
action: 'paste' | 'duplicate'
): TriggerValidationResult {
for (const block of blocksToAdd) {
if (TriggerUtils.isAnyTriggerType(block.type)) {
const issue = TriggerUtils.getTriggerAdditionIssue(existingBlocks, block.type)
if (issue) {
const actionText = action === 'paste' ? 'paste' : 'duplicate'
const message =
issue.issue === 'legacy'
? `Cannot ${actionText} trigger blocks when a legacy Start block exists.`
: `A workflow can only have one ${issue.triggerName} trigger block. ${action === 'paste' ? 'Please remove the existing one before pasting.' : 'Cannot duplicate.'}`
return { isValid: false, message }
}
}
}
return { isValid: true }
}

/**
* Clears drag highlight classes and resets cursor state.
* Used when drag operations end or are cancelled.
*/
export function clearDragHighlights(): void {
document.querySelectorAll('.loop-node-drag-over, .parallel-node-drag-over').forEach((el) => {
el.classList.remove('loop-node-drag-over', 'parallel-node-drag-over')
})
document.body.style.cursor = ''
}

/**
* Selects nodes by their IDs after paste/duplicate operations.
* Defers selection to next animation frame to allow displayNodes to sync from store first.
* This is necessary because the component uses controlled state (nodes={displayNodes})
* and newly added blocks need time to propagate through the store → derivedNodes → displayNodes cycle.
*/
export function selectNodesDeferred(
nodeIds: string[],
setDisplayNodes: (updater: (nodes: Node[]) => Node[]) => void
): void {
const idsSet = new Set(nodeIds)
requestAnimationFrame(() => {
setDisplayNodes((nodes) =>
nodes.map((node) => ({
...node,
selected: idsSet.has(node.id),
}))
)
})
}

interface BlockData {
height?: number
data?: {
parentId?: string
width?: number
height?: number
}
}

/**
* Calculates the final position for a node, clamping it to parent container if needed.
* Returns the clamped position suitable for persistence.
*/
export function getClampedPositionForNode(
nodeId: string,
nodePosition: { x: number; y: number },
blocks: Record<string, BlockData>,
allNodes: Node[]
): { x: number; y: number } {
const currentBlock = blocks[nodeId]
const currentParentId = currentBlock?.data?.parentId

if (!currentParentId) {
return nodePosition
}

const parentNode = allNodes.find((n) => n.id === currentParentId)
if (!parentNode) {
return nodePosition
}

const containerDimensions = {
width: parentNode.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: parentNode.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
}
const blockDimensions = {
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
height: Math.max(
currentBlock?.height || BLOCK_DIMENSIONS.MIN_HEIGHT,
BLOCK_DIMENSIONS.MIN_HEIGHT
),
}

return clampPositionToContainer(nodePosition, containerDimensions, blockDimensions)
}

/**
* Computes position updates for multiple nodes, clamping each to its parent container.
* Used for batch position updates after multi-node drag or selection drag.
*/
export function computeClampedPositionUpdates(
nodes: Node[],
blocks: Record<string, BlockData>,
allNodes: Node[]
): Array<{ id: string; position: { x: number; y: number } }> {
return nodes.map((node) => ({
id: node.id,
position: getClampedPositionForNode(node.id, node.position, blocks, allNodes),
}))
}
Loading