mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
feat(notes): add notes (#1898)
* Notes v1 * v2 * Lint * Consolidate into hook * Simplify workflow code * Fix hitl casing * Don't allow edges in note block and explicitly exclude from executor * Add hooks * Consolidate hook * Consolidate utils checks * Consolidate dimensions
This commit is contained in:
committed by
GitHub
parent
7c398e64dc
commit
1cce486442
@@ -0,0 +1,204 @@
|
||||
import { memo, useCallback, useMemo } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import type { NodeProps } from 'reactflow'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { useBlockCore } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import {
|
||||
BLOCK_DIMENSIONS,
|
||||
useBlockDimensions,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { ActionBar } from '../workflow-block/components'
|
||||
import type { WorkflowBlockProps } from '../workflow-block/types'
|
||||
|
||||
interface NoteBlockNodeData extends WorkflowBlockProps {}
|
||||
|
||||
/**
|
||||
* Extract string value from subblock value object or primitive
|
||||
*/
|
||||
function extractFieldValue(rawValue: unknown): string | undefined {
|
||||
if (typeof rawValue === 'string') return rawValue
|
||||
if (rawValue && typeof rawValue === 'object' && 'value' in rawValue) {
|
||||
const candidate = (rawValue as { value?: unknown }).value
|
||||
return typeof candidate === 'string' ? candidate : undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact markdown renderer for note blocks with tight spacing
|
||||
*/
|
||||
const NoteMarkdown = memo(function NoteMarkdown({ content }: { content: string }) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
p: ({ children }) => <p className='mb-0 text-[#E5E5E5] text-sm'>{children}</p>,
|
||||
h1: ({ children }) => (
|
||||
<h1 className='mt-0 mb-[-2px] font-semibold text-[#E5E5E5] text-lg'>{children}</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className='mt-0 mb-[-2px] font-semibold text-[#E5E5E5] text-base'>{children}</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className='mt-0 mb-[-2px] font-semibold text-[#E5E5E5] text-sm'>{children}</h3>
|
||||
),
|
||||
h4: ({ children }) => (
|
||||
<h4 className='mt-0 mb-[-2px] font-semibold text-[#E5E5E5] text-xs'>{children}</h4>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className='-mt-[2px] mb-0 list-disc pl-4 text-[#E5E5E5] text-sm'>{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className='-mt-[2px] mb-0 list-decimal pl-4 text-[#E5E5E5] text-sm'>{children}</ol>
|
||||
),
|
||||
li: ({ children }) => <li className='mb-0'>{children}</li>,
|
||||
code: ({ inline, children }: any) =>
|
||||
inline ? (
|
||||
<code className='rounded bg-[#393939] px-1 py-0.5 text-[#F59E0B] text-xs'>
|
||||
{children}
|
||||
</code>
|
||||
) : (
|
||||
<code className='block rounded bg-[#1A1A1A] p-2 text-[#E5E5E5] text-xs'>
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-[#33B4FF] underline-offset-2 hover:underline'
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
strong: ({ children }) => <strong className='font-semibold text-white'>{children}</strong>,
|
||||
em: ({ children }) => <em className='text-[#B8B8B8]'>{children}</em>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className='m-0 border-[#F59E0B] border-l-2 pl-3 text-[#B8B8B8] italic'>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
)
|
||||
})
|
||||
|
||||
export const NoteBlock = memo(function NoteBlock({ id, data }: NodeProps<NoteBlockNodeData>) {
|
||||
const { type, config, name } = data
|
||||
|
||||
const { activeWorkflowId, isEnabled, isFocused, handleClick, hasRing, ringStyles } = useBlockCore(
|
||||
{ blockId: id, data }
|
||||
)
|
||||
const storedValues = useSubBlockStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!activeWorkflowId) return undefined
|
||||
return state.workflowValues[activeWorkflowId]?.[id]
|
||||
},
|
||||
[activeWorkflowId, id]
|
||||
)
|
||||
)
|
||||
|
||||
const noteValues = useMemo(() => {
|
||||
if (data.isPreview && data.subBlockValues) {
|
||||
const extractedPreviewFormat = extractFieldValue(data.subBlockValues.format)
|
||||
const extractedPreviewContent = extractFieldValue(data.subBlockValues.content)
|
||||
return {
|
||||
format: typeof extractedPreviewFormat === 'string' ? extractedPreviewFormat : 'plain',
|
||||
content: typeof extractedPreviewContent === 'string' ? extractedPreviewContent : '',
|
||||
}
|
||||
}
|
||||
|
||||
const format = extractFieldValue(storedValues?.format)
|
||||
const content = extractFieldValue(storedValues?.content)
|
||||
|
||||
return {
|
||||
format: typeof format === 'string' ? format : 'plain',
|
||||
content: typeof content === 'string' ? content : '',
|
||||
}
|
||||
}, [data.isPreview, data.subBlockValues, storedValues])
|
||||
|
||||
const content = noteValues.content ?? ''
|
||||
const isEmpty = content.trim().length === 0
|
||||
const showMarkdown = noteValues.format === 'markdown' && !isEmpty
|
||||
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
/**
|
||||
* Calculate deterministic dimensions based on content structure.
|
||||
* Uses fixed width and computed height to avoid ResizeObserver jitter.
|
||||
*/
|
||||
useBlockDimensions({
|
||||
blockId: id,
|
||||
calculateDimensions: () => {
|
||||
const contentHeight = isEmpty
|
||||
? BLOCK_DIMENSIONS.NOTE_MIN_CONTENT_HEIGHT
|
||||
: BLOCK_DIMENSIONS.NOTE_BASE_CONTENT_HEIGHT
|
||||
const calculatedHeight =
|
||||
BLOCK_DIMENSIONS.HEADER_HEIGHT + BLOCK_DIMENSIONS.NOTE_CONTENT_PADDING + contentHeight
|
||||
|
||||
return { width: BLOCK_DIMENSIONS.FIXED_WIDTH, height: calculatedHeight }
|
||||
},
|
||||
dependencies: [isEmpty],
|
||||
})
|
||||
|
||||
return (
|
||||
<div className='group relative'>
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-[20] w-[250px] cursor-default select-none rounded-[8px] bg-[#232323]'
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<ActionBar blockId={id} blockType={type} disabled={!userPermissions.canEdit} />
|
||||
|
||||
<div
|
||||
className='note-drag-handle flex cursor-grab items-center justify-between border-[#393939] border-b p-[8px] [&:active]:cursor-grabbing'
|
||||
onMouseDown={(event) => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
|
||||
<div
|
||||
className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
|
||||
style={{ backgroundColor: isEnabled ? config.bgColor : 'gray' }}
|
||||
>
|
||||
<config.icon className='h-[16px] w-[16px] text-white' />
|
||||
</div>
|
||||
<span
|
||||
className={cn('font-medium text-[16px]', !isEnabled && 'truncate text-[#808080]')}
|
||||
title={name}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='relative px-[12px] pt-[6px] pb-[8px]'>
|
||||
<div className='relative whitespace-pre-wrap break-words'>
|
||||
{isEmpty ? (
|
||||
<p className='text-[#868686] text-sm italic'>Add a note...</p>
|
||||
) : showMarkdown ? (
|
||||
<NoteMarkdown content={content} />
|
||||
) : (
|
||||
<p className='whitespace-pre-wrap text-[#E5E5E5] text-sm leading-relaxed'>
|
||||
{content}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{hasRing && (
|
||||
<div
|
||||
className={cn('pointer-events-none absolute inset-0 z-40 rounded-[8px]', ringStyles)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut } from 'lucide-r
|
||||
import { Button, Duplicate, Tooltip, Trash2 } from '@/components/emcn'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { supportsHandles } from '@/executor/consts'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
@@ -146,29 +147,31 @@ export const ActionBar = memo(
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
collaborativeToggleBlockHandles(blockId)
|
||||
}
|
||||
}}
|
||||
className='h-[30px] w-[30px] rounded-[8px] bg-[#363636] p-0 text-[#868686] hover:bg-[#33B4FF] hover:text-[#1B1B1B] dark:text-[#868686] dark:hover:bg-[#33B4FF] dark:hover:text-[#1B1B1B]'
|
||||
disabled={disabled}
|
||||
>
|
||||
{horizontalHandles ? (
|
||||
<ArrowLeftRight className='h-[14px] w-[14px]' />
|
||||
) : (
|
||||
<ArrowUpDown className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='right'>
|
||||
{getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{supportsHandles(blockType) && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
collaborativeToggleBlockHandles(blockId)
|
||||
}
|
||||
}}
|
||||
className='h-[30px] w-[30px] rounded-[8px] bg-[#363636] p-0 text-[#868686] hover:bg-[#33B4FF] hover:text-[#1B1B1B] dark:text-[#868686] dark:hover:bg-[#33B4FF] dark:hover:text-[#1B1B1B]'
|
||||
disabled={disabled}
|
||||
>
|
||||
{horizontalHandles ? (
|
||||
<ArrowLeftRight className='h-[14px] w-[14px]' />
|
||||
) : (
|
||||
<ArrowUpDown className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='right'>
|
||||
{getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
{!isStarterBlock && (
|
||||
<Tooltip.Root>
|
||||
|
||||
@@ -1,29 +1,25 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow'
|
||||
import { Handle, type NodeProps, Position } from 'reactflow'
|
||||
import { Badge } from '@/components/emcn/components/badge/badge'
|
||||
import { Tooltip } from '@/components/emcn/components/tooltip/tooltip'
|
||||
import { getEnv, isTruthy } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { useBlockCore } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import {
|
||||
BLOCK_DIMENSIONS,
|
||||
useBlockDimensions,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useCredentialDisplay } from '@/hooks/use-credential-display'
|
||||
import { useDisplayName } from '@/hooks/use-display-name'
|
||||
import { usePanelEditorStore } from '@/stores/panel-new/editor/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import { useCurrentWorkflow } from '../../hooks'
|
||||
import { ActionBar, Connections } from './components'
|
||||
import {
|
||||
useBlockProperties,
|
||||
useBlockState,
|
||||
useChildWorkflow,
|
||||
useScheduleInfo,
|
||||
useWebhookInfo,
|
||||
} from './hooks'
|
||||
import { useBlockProperties, useChildWorkflow, useScheduleInfo, useWebhookInfo } from './hooks'
|
||||
import type { WorkflowBlockProps } from './types'
|
||||
import { getProviderName, shouldSkipBlockRender } from './utils'
|
||||
|
||||
@@ -267,20 +263,25 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
const { type, config, name, isPending } = data
|
||||
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const updateNodeInternals = useUpdateNodeInternals()
|
||||
|
||||
const params = useParams()
|
||||
const currentWorkflowId = params.workflowId as string
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const currentWorkflow = useCurrentWorkflow()
|
||||
const currentBlock = currentWorkflow.getBlockById(id)
|
||||
|
||||
const { isEnabled, isActive, diffStatus, isDeletedBlock } = useBlockState(
|
||||
id,
|
||||
const {
|
||||
currentWorkflow,
|
||||
data
|
||||
)
|
||||
activeWorkflowId,
|
||||
isEnabled,
|
||||
isActive,
|
||||
diffStatus,
|
||||
isDeletedBlock,
|
||||
isFocused,
|
||||
handleClick,
|
||||
hasRing,
|
||||
ringStyles,
|
||||
} = useBlockCore({ blockId: id, data, isPending })
|
||||
|
||||
const currentBlock = currentWorkflow.getBlockById(id)
|
||||
|
||||
const { horizontalHandles, blockHeight, blockWidth, displayAdvancedMode, displayTriggerMode } =
|
||||
useBlockProperties(
|
||||
@@ -368,24 +369,11 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
}
|
||||
}, [id, collaborativeSetSubblockValue])
|
||||
|
||||
const updateBlockLayoutMetrics = useWorkflowStore((state) => state.updateBlockLayoutMetrics)
|
||||
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
|
||||
const setCurrentBlockId = usePanelEditorStore((state) => state.setCurrentBlockId)
|
||||
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
|
||||
const isFocused = currentBlockId === id
|
||||
const currentStoreBlock = currentWorkflow.getBlockById(id)
|
||||
|
||||
const isStarterBlock = type === 'starter'
|
||||
const isWebhookTriggerBlock = type === 'webhook' || type === 'generic_webhook'
|
||||
|
||||
/**
|
||||
* Update node internals when handles change to ensure ReactFlow
|
||||
* correctly calculates connection points.
|
||||
*/
|
||||
useEffect(() => {
|
||||
updateNodeInternals(id)
|
||||
}, [id, horizontalHandles, updateNodeInternals])
|
||||
|
||||
/**
|
||||
* Subscribe to this block's subblock values to track changes for conditional rendering
|
||||
* of subblocks based on their conditions.
|
||||
@@ -597,59 +585,45 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
/**
|
||||
* Compute and publish deterministic layout metrics for workflow blocks.
|
||||
* This avoids ResizeObserver/animation-frame jitter and prevents initial "jump".
|
||||
*
|
||||
* Height model:
|
||||
* - Fixed header height: 40px
|
||||
* - Content padding when present: 16px (8 top + 8 bottom)
|
||||
* - Row height: 29px per rendered row (subblock rows, condition rows, plus error row if present)
|
||||
*
|
||||
* Width is a fixed 250px for workflow blocks.
|
||||
*/
|
||||
useEffect(() => {
|
||||
// Only workflow blocks (non-subflow) render here, width is constant
|
||||
const FIXED_WIDTH = 250
|
||||
const HEADER_HEIGHT = 40
|
||||
const CONTENT_PADDING = 16
|
||||
const ROW_HEIGHT = 29
|
||||
useBlockDimensions({
|
||||
blockId: id,
|
||||
calculateDimensions: () => {
|
||||
const shouldShowDefaultHandles =
|
||||
config.category !== 'triggers' && type !== 'starter' && !displayTriggerMode
|
||||
const hasContentBelowHeader = subBlockRows.length > 0 || shouldShowDefaultHandles
|
||||
|
||||
const shouldShowDefaultHandles =
|
||||
config.category !== 'triggers' && type !== 'starter' && !displayTriggerMode
|
||||
const hasContentBelowHeader = subBlockRows.length > 0 || shouldShowDefaultHandles
|
||||
// Count rows based on block type and whether default handles section is shown
|
||||
const defaultHandlesRow = shouldShowDefaultHandles ? 1 : 0
|
||||
|
||||
// Count rows based on block type and whether default handles section is shown
|
||||
const defaultHandlesRow = shouldShowDefaultHandles ? 1 : 0
|
||||
let rowsCount = 0
|
||||
if (type === 'condition') {
|
||||
rowsCount = conditionRows.length + defaultHandlesRow
|
||||
} else {
|
||||
const subblockRowCount = subBlockRows.reduce((acc, row) => acc + row.length, 0)
|
||||
rowsCount = subblockRowCount + defaultHandlesRow
|
||||
}
|
||||
|
||||
let rowsCount = 0
|
||||
if (type === 'condition') {
|
||||
rowsCount = conditionRows.length + defaultHandlesRow
|
||||
} else {
|
||||
const subblockRowCount = subBlockRows.reduce((acc, row) => acc + row.length, 0)
|
||||
rowsCount = subblockRowCount + defaultHandlesRow
|
||||
}
|
||||
const contentHeight = hasContentBelowHeader
|
||||
? BLOCK_DIMENSIONS.WORKFLOW_CONTENT_PADDING +
|
||||
rowsCount * BLOCK_DIMENSIONS.WORKFLOW_ROW_HEIGHT
|
||||
: 0
|
||||
const calculatedHeight = Math.max(
|
||||
BLOCK_DIMENSIONS.HEADER_HEIGHT + contentHeight,
|
||||
BLOCK_DIMENSIONS.MIN_HEIGHT
|
||||
)
|
||||
|
||||
const contentHeight = hasContentBelowHeader ? CONTENT_PADDING + rowsCount * ROW_HEIGHT : 0
|
||||
const calculatedHeight = Math.max(HEADER_HEIGHT + contentHeight, 100)
|
||||
|
||||
const prevHeight =
|
||||
typeof currentStoreBlock?.height === 'number' ? currentStoreBlock.height : undefined
|
||||
const prevWidth = 250 // fixed across the app for workflow blocks
|
||||
|
||||
if (prevHeight !== calculatedHeight || prevWidth !== FIXED_WIDTH) {
|
||||
updateBlockLayoutMetrics(id, { width: FIXED_WIDTH, height: calculatedHeight })
|
||||
updateNodeInternals(id)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
id,
|
||||
type,
|
||||
config.category,
|
||||
displayTriggerMode,
|
||||
subBlockRows.length,
|
||||
conditionRows.length,
|
||||
currentStoreBlock?.height,
|
||||
updateBlockLayoutMetrics,
|
||||
updateNodeInternals,
|
||||
])
|
||||
return { width: BLOCK_DIMENSIONS.FIXED_WIDTH, height: calculatedHeight }
|
||||
},
|
||||
dependencies: [
|
||||
type,
|
||||
config.category,
|
||||
displayTriggerMode,
|
||||
subBlockRows.length,
|
||||
conditionRows.length,
|
||||
horizontalHandles,
|
||||
],
|
||||
})
|
||||
|
||||
const showWebhookIndicator = (isStarterBlock || isWebhookTriggerBlock) && isWebhookConfigured
|
||||
const shouldShowScheduleBadge =
|
||||
@@ -657,35 +631,11 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const isWorkflowSelector = type === 'workflow' || type === 'workflow_input'
|
||||
|
||||
/**
|
||||
* Determine the ring styling based on block state priority:
|
||||
* 1. Active (executing) - purple ring with pulse animation
|
||||
* 2. Pending (next step) - orange ring
|
||||
* 3. Focused (selected in editor) - blue ring
|
||||
* 4. Diff status (version comparison) - green/orange/red ring
|
||||
*/
|
||||
const hasRing =
|
||||
isActive ||
|
||||
isPending ||
|
||||
isFocused ||
|
||||
diffStatus === 'new' ||
|
||||
diffStatus === 'edited' ||
|
||||
isDeletedBlock
|
||||
const ringStyles = cn(
|
||||
hasRing && 'ring-[1.75px]',
|
||||
isActive && 'ring-[#8C10FF] animate-pulse-ring',
|
||||
isPending && 'ring-[#FF6600]',
|
||||
isFocused && 'ring-[#33B4FF]',
|
||||
diffStatus === 'new' && 'ring-[#22C55F]',
|
||||
diffStatus === 'edited' && 'ring-[#FF6600]',
|
||||
isDeletedBlock && 'ring-[#EF4444]'
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='group relative'>
|
||||
<div
|
||||
ref={contentRef}
|
||||
onClick={() => setCurrentBlockId(id)}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'relative z-[20] w-[250px] cursor-default select-none rounded-[8px] bg-[#232323]'
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export { useAutoLayout } from './use-auto-layout'
|
||||
export { useBlockCore } from './use-block-core'
|
||||
export { BLOCK_DIMENSIONS, useBlockDimensions } from './use-block-dimensions'
|
||||
export { type CurrentWorkflow, useCurrentWorkflow } from './use-current-workflow'
|
||||
export { useNodeUtilities } from './use-node-utilities'
|
||||
export { useScrollManagement } from './use-scroll-management'
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { usePanelEditorStore } from '@/stores/panel-new/editor/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useBlockState } from '../components/workflow-block/hooks'
|
||||
import type { WorkflowBlockProps } from '../components/workflow-block/types'
|
||||
import { useCurrentWorkflow } from './use-current-workflow'
|
||||
|
||||
interface UseBlockCoreOptions {
|
||||
blockId: string
|
||||
data: WorkflowBlockProps
|
||||
isPending?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidated hook for core block functionality shared across all block types.
|
||||
* Combines workflow state, block state, focus, and ring styling.
|
||||
*/
|
||||
export function useBlockCore({ blockId, data, isPending = false }: UseBlockCoreOptions) {
|
||||
// Workflow context
|
||||
const currentWorkflow = useCurrentWorkflow()
|
||||
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
|
||||
|
||||
// Block state (enabled, active, diff status, deleted)
|
||||
const { isEnabled, isActive, diffStatus, isDeletedBlock } = useBlockState(
|
||||
blockId,
|
||||
currentWorkflow,
|
||||
data
|
||||
)
|
||||
|
||||
// Focus management
|
||||
const setCurrentBlockId = usePanelEditorStore((state) => state.setCurrentBlockId)
|
||||
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
|
||||
const isFocused = currentBlockId === blockId
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setCurrentBlockId(blockId)
|
||||
}, [blockId, setCurrentBlockId])
|
||||
|
||||
// Ring styling based on all states
|
||||
const { hasRing, ringStyles } = useMemo(() => {
|
||||
const hasRing =
|
||||
isActive ||
|
||||
isPending ||
|
||||
isFocused ||
|
||||
diffStatus === 'new' ||
|
||||
diffStatus === 'edited' ||
|
||||
isDeletedBlock
|
||||
|
||||
const ringStyles = cn(
|
||||
hasRing && 'ring-[1.75px]',
|
||||
isActive && 'ring-[#8C10FF] animate-pulse-ring',
|
||||
isPending && 'ring-[#FF6600]',
|
||||
isFocused && 'ring-[#33B4FF]',
|
||||
diffStatus === 'new' && 'ring-[#22C55F]',
|
||||
diffStatus === 'edited' && 'ring-[#FF6600]',
|
||||
isDeletedBlock && 'ring-[#EF4444]'
|
||||
)
|
||||
|
||||
return { hasRing, ringStyles }
|
||||
}, [isActive, isPending, isFocused, diffStatus, isDeletedBlock])
|
||||
|
||||
return {
|
||||
// Workflow context
|
||||
currentWorkflow,
|
||||
activeWorkflowId,
|
||||
|
||||
// Block state
|
||||
isEnabled,
|
||||
isActive,
|
||||
diffStatus,
|
||||
isDeletedBlock,
|
||||
|
||||
// Focus
|
||||
isFocused,
|
||||
handleClick,
|
||||
|
||||
// Ring styling
|
||||
hasRing,
|
||||
ringStyles,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useUpdateNodeInternals } from 'reactflow'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
interface BlockDimensions {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
interface UseBlockDimensionsOptions {
|
||||
blockId: string
|
||||
calculateDimensions: () => BlockDimensions
|
||||
dependencies: React.DependencyList
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared block dimension constants
|
||||
*/
|
||||
export const BLOCK_DIMENSIONS = {
|
||||
FIXED_WIDTH: 250,
|
||||
HEADER_HEIGHT: 40,
|
||||
MIN_HEIGHT: 100,
|
||||
|
||||
// Workflow blocks
|
||||
WORKFLOW_CONTENT_PADDING: 16,
|
||||
WORKFLOW_ROW_HEIGHT: 29,
|
||||
|
||||
// Note blocks
|
||||
NOTE_CONTENT_PADDING: 14,
|
||||
NOTE_MIN_CONTENT_HEIGHT: 20,
|
||||
NOTE_BASE_CONTENT_HEIGHT: 60,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Hook to manage deterministic block dimensions without ResizeObserver.
|
||||
* Calculates dimensions based on content structure and updates the store.
|
||||
*
|
||||
* @param options - Configuration for dimension calculation
|
||||
* @param options.blockId - The ID of the block
|
||||
* @param options.calculateDimensions - Function that returns current dimensions
|
||||
* @param options.dependencies - Dependencies that trigger recalculation
|
||||
*/
|
||||
export function useBlockDimensions({
|
||||
blockId,
|
||||
calculateDimensions,
|
||||
dependencies,
|
||||
}: UseBlockDimensionsOptions) {
|
||||
const updateNodeInternals = useUpdateNodeInternals()
|
||||
const updateBlockLayoutMetrics = useWorkflowStore((state) => state.updateBlockLayoutMetrics)
|
||||
const previousDimensions = useRef<BlockDimensions | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const dimensions = calculateDimensions()
|
||||
const previous = previousDimensions.current
|
||||
|
||||
if (!previous || previous.width !== dimensions.width || previous.height !== dimensions.height) {
|
||||
previousDimensions.current = dimensions
|
||||
updateBlockLayoutMetrics(blockId, dimensions)
|
||||
updateNodeInternals(blockId)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [blockId, updateBlockLayoutMetrics, updateNodeInternals, ...dependencies])
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { DiffControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/compo
|
||||
import { Chat } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat'
|
||||
import { UserAvatarStack } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/user-avatar-stack/user-avatar-stack'
|
||||
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
|
||||
import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block'
|
||||
import { Panel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/panel-new'
|
||||
import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
|
||||
import { Terminal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal'
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { useSocket } from '@/contexts/socket-context'
|
||||
import { isAnnotationOnlyBlock } from '@/executor/consts'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useStreamCleanup } from '@/hooks/use-stream-cleanup'
|
||||
import { useWorkspacePermissions } from '@/hooks/use-workspace-permissions'
|
||||
@@ -55,6 +57,7 @@ const logger = createLogger('Workflow')
|
||||
// Define custom node and edge types - memoized outside component to prevent re-creation
|
||||
const nodeTypes: NodeTypes = {
|
||||
workflowBlock: WorkflowBlock,
|
||||
noteBlock: NoteBlock,
|
||||
subflowNode: SubflowNodeComponent,
|
||||
}
|
||||
const edgeTypes: EdgeTypes = {
|
||||
@@ -1298,13 +1301,17 @@ const WorkflowContent = React.memo(() => {
|
||||
const isActive = activeBlockIds.has(block.id)
|
||||
const isPending = isDebugging && pendingBlocks.includes(block.id)
|
||||
|
||||
// Both note blocks and workflow blocks use deterministic dimensions
|
||||
const nodeType = block.type === 'note' ? 'noteBlock' : 'workflowBlock'
|
||||
const dragHandle = block.type === 'note' ? '.note-drag-handle' : '.workflow-drag-handle'
|
||||
|
||||
// Create stable node object - React Flow will handle shallow comparison
|
||||
nodeArray.push({
|
||||
id: block.id,
|
||||
type: 'workflowBlock',
|
||||
type: nodeType,
|
||||
position,
|
||||
parentId: block.data?.parentId,
|
||||
dragHandle: '.workflow-drag-handle',
|
||||
dragHandle,
|
||||
extent: (() => {
|
||||
// Clamp children to subflow body (exclude header)
|
||||
const parentId = block.data?.parentId as string | undefined
|
||||
@@ -1332,8 +1339,9 @@ const WorkflowContent = React.memo(() => {
|
||||
isPending,
|
||||
},
|
||||
// Include dynamic dimensions for container resizing calculations (must match rendered size)
|
||||
width: 250, // Standard width - matches w-[250px] in workflow-block.tsx
|
||||
height: Math.max(block.height || 100, 100), // Use actual height with minimum
|
||||
// Both note and workflow blocks calculate dimensions deterministically via useBlockDimensions
|
||||
width: 250, // Standard width for both block types
|
||||
height: Math.max(block.height || 100, 100), // Use calculated height with minimum
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1440,6 +1448,14 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
if (!sourceNode || !targetNode) return
|
||||
|
||||
// Prevent connections to/from annotation-only blocks (non-executable)
|
||||
if (
|
||||
isAnnotationOnlyBlock(sourceNode.data?.type) ||
|
||||
isAnnotationOnlyBlock(targetNode.data?.type)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent incoming connections to trigger blocks (webhook, schedule, etc.)
|
||||
if (targetNode.data?.config?.category === 'triggers') {
|
||||
return
|
||||
|
||||
@@ -15,6 +15,7 @@ import 'reactflow/dist/style.css'
|
||||
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block'
|
||||
import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
|
||||
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
|
||||
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
|
||||
@@ -39,6 +40,7 @@ interface WorkflowPreviewProps {
|
||||
// Define node types - the components now handle preview mode internally
|
||||
const nodeTypes: NodeTypes = {
|
||||
workflowBlock: WorkflowBlock,
|
||||
noteBlock: NoteBlock,
|
||||
subflowNode: SubflowNodeComponent,
|
||||
}
|
||||
|
||||
@@ -179,9 +181,11 @@ export function WorkflowPreview({
|
||||
|
||||
const subBlocksClone = block.subBlocks ? cloneDeep(block.subBlocks) : {}
|
||||
|
||||
const nodeType = block.type === 'note' ? 'noteBlock' : 'workflowBlock'
|
||||
|
||||
nodeArray.push({
|
||||
id: blockId,
|
||||
type: 'workflowBlock',
|
||||
type: nodeType,
|
||||
position: absolutePosition,
|
||||
draggable: false,
|
||||
data: {
|
||||
@@ -204,9 +208,11 @@ export function WorkflowPreview({
|
||||
const childConfig = getBlock(childBlock.type)
|
||||
|
||||
if (childConfig) {
|
||||
const childNodeType = childBlock.type === 'note' ? 'noteBlock' : 'workflowBlock'
|
||||
|
||||
nodeArray.push({
|
||||
id: childId,
|
||||
type: 'workflowBlock',
|
||||
type: childNodeType,
|
||||
position: {
|
||||
x: block.position.x + 50,
|
||||
y: block.position.y + (childBlock.position?.y || 100),
|
||||
|
||||
46
apps/sim/blocks/blocks/note.ts
Normal file
46
apps/sim/blocks/blocks/note.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { NoteIcon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
|
||||
export const NoteBlock: BlockConfig = {
|
||||
type: 'note',
|
||||
name: 'Note',
|
||||
description: 'Add contextual annotations directly onto the workflow canvas.',
|
||||
longDescription:
|
||||
'Use Note blocks to document decisions, share instructions, or leave context for collaborators directly on the workflow canvas. Notes support both plain text and Markdown rendering.',
|
||||
category: 'blocks',
|
||||
bgColor: '#F59E0B',
|
||||
icon: NoteIcon,
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'format',
|
||||
title: 'Display Format',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Plain text', id: 'plain' },
|
||||
{ label: 'Markdown', id: 'markdown' },
|
||||
],
|
||||
value: () => 'plain',
|
||||
description: 'Choose how the note should render on the canvas.',
|
||||
},
|
||||
{
|
||||
id: 'content',
|
||||
title: 'Content',
|
||||
type: 'long-input',
|
||||
rows: 8,
|
||||
placeholder: 'Add context or instructions for collaborators...',
|
||||
description: 'Write your note using plain text or Markdown depending on the selected format.',
|
||||
},
|
||||
],
|
||||
tools: { access: [] },
|
||||
inputs: {
|
||||
format: {
|
||||
type: 'string',
|
||||
description: 'Render mode for the note content.',
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
description: 'Text for the note.',
|
||||
},
|
||||
},
|
||||
outputs: {},
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import { MicrosoftTeamsBlock } from '@/blocks/blocks/microsoft_teams'
|
||||
import { MistralParseBlock } from '@/blocks/blocks/mistral_parse'
|
||||
import { MongoDBBlock } from '@/blocks/blocks/mongodb'
|
||||
import { MySQLBlock } from '@/blocks/blocks/mysql'
|
||||
import { NoteBlock } from '@/blocks/blocks/note'
|
||||
import { NotionBlock } from '@/blocks/blocks/notion'
|
||||
import { OneDriveBlock } from '@/blocks/blocks/onedrive'
|
||||
import { OpenAIBlock } from '@/blocks/blocks/openai'
|
||||
@@ -145,6 +146,7 @@ export const registry: Record<string, BlockConfig> = {
|
||||
mistral_parse: MistralParseBlock,
|
||||
mongodb: MongoDBBlock,
|
||||
mysql: MySQLBlock,
|
||||
note: NoteBlock,
|
||||
notion: NotionBlock,
|
||||
openai: OpenAIBlock,
|
||||
outlook: OutlookBlock,
|
||||
|
||||
@@ -165,6 +165,33 @@ export function ConditionalIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function NoteIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<rect
|
||||
x='4'
|
||||
y='3'
|
||||
width='16'
|
||||
height='18'
|
||||
rx='2.5'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
fill='none'
|
||||
/>
|
||||
<path d='M15 3H18C18.5523 3 19 3.44772 19 4V7L15 3Z' fill='currentColor' />
|
||||
<path d='M8 11H15.5' stroke='currentColor' strokeWidth='1.5' strokeLinecap='round' />
|
||||
<path d='M8 15H13' stroke='currentColor' strokeWidth='1.5' strokeLinecap='round' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function AirplaneIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -21,6 +21,8 @@ export enum BlockType {
|
||||
|
||||
WAIT = 'wait',
|
||||
|
||||
NOTE = 'note',
|
||||
|
||||
SENTINEL_START = 'sentinel_start',
|
||||
SENTINEL_END = 'sentinel_end',
|
||||
}
|
||||
@@ -31,7 +33,11 @@ export const TRIGGER_BLOCK_TYPES = [
|
||||
BlockType.TRIGGER,
|
||||
] as const
|
||||
|
||||
export const METADATA_ONLY_BLOCK_TYPES = [BlockType.LOOP, BlockType.PARALLEL] as const
|
||||
export const METADATA_ONLY_BLOCK_TYPES = [
|
||||
BlockType.LOOP,
|
||||
BlockType.PARALLEL,
|
||||
BlockType.NOTE,
|
||||
] as const
|
||||
|
||||
export type LoopType = 'for' | 'forEach' | 'while' | 'doWhile'
|
||||
|
||||
@@ -247,6 +253,14 @@ export function isAgentBlockType(blockType: string | undefined): boolean {
|
||||
return blockType === BlockType.AGENT
|
||||
}
|
||||
|
||||
export function isAnnotationOnlyBlock(blockType: string | undefined): boolean {
|
||||
return blockType === BlockType.NOTE
|
||||
}
|
||||
|
||||
export function supportsHandles(blockType: string | undefined): boolean {
|
||||
return !isAnnotationOnlyBlock(blockType)
|
||||
}
|
||||
|
||||
export function getDefaultTokens() {
|
||||
return {
|
||||
prompt: DEFAULTS.TOKENS.PROMPT,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
CONTAINER_PADDING_Y,
|
||||
DEFAULT_CONTAINER_HEIGHT,
|
||||
DEFAULT_CONTAINER_WIDTH,
|
||||
filterLayoutEligibleBlockIds,
|
||||
getBlocksByParent,
|
||||
prepareBlockMetrics,
|
||||
} from './utils'
|
||||
@@ -35,13 +36,14 @@ export function layoutContainers(
|
||||
|
||||
logger.debug('Processing container', { parentId, childCount: childIds.length })
|
||||
|
||||
const layoutChildIds = filterLayoutEligibleBlockIds(childIds, blocks)
|
||||
const childBlocks: Record<string, BlockState> = {}
|
||||
for (const childId of childIds) {
|
||||
for (const childId of layoutChildIds) {
|
||||
childBlocks[childId] = blocks[childId]
|
||||
}
|
||||
|
||||
const childEdges = edges.filter(
|
||||
(edge) => childIds.includes(edge.source) && childIds.includes(edge.target)
|
||||
(edge) => layoutChildIds.includes(edge.source) && layoutChildIds.includes(edge.target)
|
||||
)
|
||||
|
||||
if (Object.keys(childBlocks).length === 0) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
import type { AdjustmentOptions, Edge } from './types'
|
||||
import { boxesOverlap, createBoundingBox, getBlockMetrics } from './utils'
|
||||
import { boxesOverlap, createBoundingBox, getBlockMetrics, shouldSkipAutoLayout } from './utils'
|
||||
|
||||
const logger = createLogger('AutoLayout:Incremental')
|
||||
|
||||
@@ -19,6 +19,14 @@ export function adjustForNewBlock(
|
||||
return
|
||||
}
|
||||
|
||||
if (shouldSkipAutoLayout(newBlock)) {
|
||||
logger.debug('Skipping incremental layout for block excluded from auto layout', {
|
||||
newBlockId,
|
||||
type: newBlock.type,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const shiftSpacing = options.horizontalSpacing ?? DEFAULT_SHIFT_SPACING
|
||||
|
||||
const incomingEdges = edges.filter((e) => e.target === newBlockId)
|
||||
@@ -78,6 +86,7 @@ export function adjustForNewBlock(
|
||||
for (const [id, block] of Object.entries(blocks)) {
|
||||
if (id === newBlockId) continue
|
||||
if (block.data?.parentId) continue
|
||||
if (shouldSkipAutoLayout(block)) continue
|
||||
|
||||
if (block.position.x >= newBlock.position.x) {
|
||||
const blockMetrics = getBlockMetrics(block)
|
||||
@@ -105,7 +114,9 @@ export function adjustForNewBlock(
|
||||
}
|
||||
|
||||
export function compactHorizontally(blocks: Record<string, BlockState>, edges: Edge[]): void {
|
||||
const blockArray = Object.values(blocks).filter((b) => !b.data?.parentId)
|
||||
const blockArray = Object.values(blocks).filter(
|
||||
(b) => !b.data?.parentId && !shouldSkipAutoLayout(b)
|
||||
)
|
||||
|
||||
blockArray.sort((a, b) => a.position.x - b.position.x)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { adjustForNewBlock as adjustForNewBlockInternal, compactHorizontally } f
|
||||
import { assignLayers, groupByLayer } from './layering'
|
||||
import { calculatePositions } from './positioning'
|
||||
import type { AdjustmentOptions, Edge, LayoutOptions, LayoutResult, Loop, Parallel } from './types'
|
||||
import { getBlocksByParent, prepareBlockMetrics } from './utils'
|
||||
import { filterLayoutEligibleBlockIds, getBlocksByParent, prepareBlockMetrics } from './utils'
|
||||
|
||||
const logger = createLogger('AutoLayout')
|
||||
|
||||
@@ -28,13 +28,15 @@ export function applyAutoLayout(
|
||||
|
||||
const { root: rootBlockIds } = getBlocksByParent(blocksCopy)
|
||||
|
||||
const layoutRootIds = filterLayoutEligibleBlockIds(rootBlockIds, blocksCopy)
|
||||
|
||||
const rootBlocks: Record<string, BlockState> = {}
|
||||
for (const id of rootBlockIds) {
|
||||
for (const id of layoutRootIds) {
|
||||
rootBlocks[id] = blocksCopy[id]
|
||||
}
|
||||
|
||||
const rootEdges = edges.filter(
|
||||
(edge) => rootBlockIds.includes(edge.source) && rootBlockIds.includes(edge.target)
|
||||
(edge) => layoutRootIds.includes(edge.source) && layoutRootIds.includes(edge.target)
|
||||
)
|
||||
|
||||
if (Object.keys(rootBlocks).length > 0) {
|
||||
@@ -102,4 +104,4 @@ export function adjustForNewBlock(
|
||||
export type { LayoutOptions, LayoutResult, AdjustmentOptions, Edge, Loop, Parallel }
|
||||
export type { TargetedLayoutOptions } from './targeted'
|
||||
export { applyTargetedLayout, transferBlockHeights } from './targeted'
|
||||
export { getBlockMetrics, isContainerType } from './utils'
|
||||
export { getBlockMetrics, isContainerType, shouldSkipAutoLayout } from './utils'
|
||||
|
||||
@@ -9,12 +9,14 @@ import {
|
||||
CONTAINER_PADDING_Y,
|
||||
DEFAULT_CONTAINER_HEIGHT,
|
||||
DEFAULT_CONTAINER_WIDTH,
|
||||
filterLayoutEligibleBlockIds,
|
||||
getBlockMetrics,
|
||||
getBlocksByParent,
|
||||
isContainerType,
|
||||
prepareBlockMetrics,
|
||||
ROOT_PADDING_X,
|
||||
ROOT_PADDING_Y,
|
||||
shouldSkipAutoLayout,
|
||||
} from './utils'
|
||||
|
||||
const logger = createLogger('AutoLayout:Targeted')
|
||||
@@ -71,14 +73,23 @@ function layoutGroup(
|
||||
|
||||
const parentBlock = parentId ? blocks[parentId] : undefined
|
||||
|
||||
const requestedLayout = childIds.filter((id) => {
|
||||
const layoutEligibleChildIds = filterLayoutEligibleBlockIds(childIds, blocks)
|
||||
|
||||
if (layoutEligibleChildIds.length === 0) {
|
||||
if (parentBlock) {
|
||||
updateContainerDimensions(parentBlock, childIds, blocks)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const requestedLayout = layoutEligibleChildIds.filter((id) => {
|
||||
const block = blocks[id]
|
||||
if (!block) return false
|
||||
// Never reposition containers, only update their dimensions
|
||||
if (isContainerType(block.type)) return false
|
||||
return changedSet.has(id)
|
||||
})
|
||||
const missingPositions = childIds.filter((id) => {
|
||||
const missingPositions = layoutEligibleChildIds.filter((id) => {
|
||||
const block = blocks[id]
|
||||
if (!block) return false
|
||||
// Containers with missing positions should still get positioned
|
||||
@@ -99,14 +110,14 @@ function layoutGroup(
|
||||
|
||||
const oldPositions = new Map<string, { x: number; y: number }>()
|
||||
|
||||
for (const id of childIds) {
|
||||
for (const id of layoutEligibleChildIds) {
|
||||
const block = blocks[id]
|
||||
if (!block) continue
|
||||
oldPositions.set(id, { ...block.position })
|
||||
}
|
||||
|
||||
const layoutPositions = computeLayoutPositions(
|
||||
childIds,
|
||||
layoutEligibleChildIds,
|
||||
blocks,
|
||||
edges,
|
||||
parentBlock,
|
||||
@@ -125,7 +136,9 @@ function layoutGroup(
|
||||
let offsetX = 0
|
||||
let offsetY = 0
|
||||
|
||||
const anchorId = childIds.find((id) => !needsLayout.includes(id) && layoutPositions.has(id))
|
||||
const anchorId = layoutEligibleChildIds.find(
|
||||
(id) => !needsLayout.includes(id) && layoutPositions.has(id)
|
||||
)
|
||||
|
||||
if (anchorId) {
|
||||
const oldPos = oldPositions.get(anchorId)
|
||||
@@ -272,6 +285,9 @@ function updateContainerDimensions(
|
||||
for (const id of childIds) {
|
||||
const child = blocks[id]
|
||||
if (!child) continue
|
||||
if (shouldSkipAutoLayout(child)) {
|
||||
continue
|
||||
}
|
||||
const metrics = getBlockMetrics(child)
|
||||
|
||||
minX = Math.min(minX, child.position.x)
|
||||
|
||||
@@ -18,10 +18,28 @@ function resolveNumeric(value: number | undefined, fallback: number): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
|
||||
}
|
||||
|
||||
const AUTO_LAYOUT_EXCLUDED_TYPES = new Set(['note'])
|
||||
|
||||
export function isContainerType(blockType: string): boolean {
|
||||
return blockType === 'loop' || blockType === 'parallel'
|
||||
}
|
||||
|
||||
export function shouldSkipAutoLayout(block?: BlockState): boolean {
|
||||
if (!block) return true
|
||||
return AUTO_LAYOUT_EXCLUDED_TYPES.has(block.type)
|
||||
}
|
||||
|
||||
export function filterLayoutEligibleBlockIds(
|
||||
blockIds: string[],
|
||||
blocks: Record<string, BlockState>
|
||||
): string[] {
|
||||
return blockIds.filter((id) => {
|
||||
const block = blocks[id]
|
||||
if (!block) return false
|
||||
return !shouldSkipAutoLayout(block)
|
||||
})
|
||||
}
|
||||
|
||||
function getContainerMetrics(block: BlockState): BlockMetrics {
|
||||
const measuredWidth = block.layout?.measuredWidth
|
||||
const measuredHeight = block.layout?.measuredHeight
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getBlockOutputs } from '@/lib/workflows/block-outputs'
|
||||
import { TriggerUtils } from '@/lib/workflows/triggers'
|
||||
import { getBlock } from '@/blocks'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { isAnnotationOnlyBlock } from '@/executor/consts'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import {
|
||||
@@ -422,6 +423,14 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
},
|
||||
|
||||
addEdge: (edge: Edge) => {
|
||||
// Prevent connections to/from annotation-only blocks (non-executable)
|
||||
const sourceBlock = get().blocks[edge.source]
|
||||
const targetBlock = get().blocks[edge.target]
|
||||
|
||||
if (isAnnotationOnlyBlock(sourceBlock?.type) || isAnnotationOnlyBlock(targetBlock?.type)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check for duplicate connections
|
||||
const isDuplicate = get().edges.some(
|
||||
(existingEdge) =>
|
||||
|
||||
Reference in New Issue
Block a user