fix: resolve TypeScript build errors for calendar and workflow
- CalendarEventShapeUtil: Fix destructuring (w,h are in props, not shape) - CalendarPanel: Prefix unused variables with underscore - YearViewPanel: Prefix unused variables with underscore - Add missing workflow files (WorkflowPropagator, WorkflowBlockShape, etc.) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
6a6a140964
commit
0f7ad6e3a1
|
|
@ -45,9 +45,9 @@ const isToday = (date: Date) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CalendarPanel({
|
export function CalendarPanel({
|
||||||
onClose,
|
onClose: _onClose,
|
||||||
onEventSelect,
|
onEventSelect,
|
||||||
shapeMode = false,
|
shapeMode: _shapeMode = false,
|
||||||
initialView = "month",
|
initialView = "month",
|
||||||
initialDate,
|
initialDate,
|
||||||
}: CalendarPanelProps) {
|
}: CalendarPanelProps) {
|
||||||
|
|
@ -255,7 +255,7 @@ export function CalendarPanel({
|
||||||
{date.getDate()}
|
{date.getDate()}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "2px" }}>
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "2px" }}>
|
||||||
{dayEvents.slice(0, 3).map((event, i) => (
|
{dayEvents.slice(0, 3).map((event) => (
|
||||||
<div
|
<div
|
||||||
key={event.id}
|
key={event.id}
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -42,9 +42,9 @@ const SHORT_MONTH_NAMES = [
|
||||||
]
|
]
|
||||||
|
|
||||||
export const YearViewPanel: React.FC<YearViewPanelProps> = ({
|
export const YearViewPanel: React.FC<YearViewPanelProps> = ({
|
||||||
onClose,
|
onClose: _onClose,
|
||||||
onMonthSelect,
|
onMonthSelect,
|
||||||
shapeMode = false,
|
shapeMode: _shapeMode = false,
|
||||||
initialYear,
|
initialYear,
|
||||||
}) => {
|
}) => {
|
||||||
const [currentYear, setCurrentYear] = useState(initialYear || new Date().getFullYear())
|
const [currentYear, setCurrentYear] = useState(initialYear || new Date().getFullYear())
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,497 @@
|
||||||
|
/**
|
||||||
|
* WorkflowPalette
|
||||||
|
*
|
||||||
|
* A sidebar panel displaying available workflow blocks organized by category.
|
||||||
|
* Users can click on blocks to enter placement mode or drag them onto the canvas.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useMemo } from 'react'
|
||||||
|
import { useEditor } from 'tldraw'
|
||||||
|
import {
|
||||||
|
getAllBlockDefinitions,
|
||||||
|
getBlocksByCategory,
|
||||||
|
} from '@/lib/workflow/blockRegistry'
|
||||||
|
import {
|
||||||
|
BlockCategory,
|
||||||
|
BlockDefinition,
|
||||||
|
CATEGORY_INFO,
|
||||||
|
} from '@/lib/workflow/types'
|
||||||
|
import { setWorkflowBlockType } from '@/tools/WorkflowBlockTool'
|
||||||
|
import { executeWorkflow, resetWorkflow } from '@/lib/workflow/executor'
|
||||||
|
import { setAutoExecute, isAutoExecuteEnabled } from '@/propagators/WorkflowPropagator'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface WorkflowPaletteProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Block Card Component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface BlockCardProps {
|
||||||
|
definition: BlockDefinition
|
||||||
|
onSelect: (blockType: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const BlockCard: React.FC<BlockCardProps> = ({ definition, onSelect }) => {
|
||||||
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
|
const categoryInfo = CATEGORY_INFO[definition.category]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={() => onSelect(definition.type)}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 12px',
|
||||||
|
backgroundColor: isHovered ? 'rgba(0, 0, 0, 0.05)' : 'transparent',
|
||||||
|
borderRadius: 6,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background-color 0.15s ease',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 10,
|
||||||
|
borderLeft: `3px solid ${categoryInfo.color}`,
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 18, lineHeight: 1 }}>{definition.icon}</span>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#1f2937',
|
||||||
|
marginBottom: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{definition.name}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#6b7280',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{definition.description}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 6,
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#9ca3af',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{definition.inputs.length} inputs</span>
|
||||||
|
<span>{definition.outputs.length} outputs</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Category Section Component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface CategorySectionProps {
|
||||||
|
category: BlockCategory
|
||||||
|
blocks: BlockDefinition[]
|
||||||
|
isExpanded: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
onSelectBlock: (blockType: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CategorySection: React.FC<CategorySectionProps> = ({
|
||||||
|
category,
|
||||||
|
blocks,
|
||||||
|
isExpanded,
|
||||||
|
onToggle,
|
||||||
|
onSelectBlock,
|
||||||
|
}) => {
|
||||||
|
const categoryInfo = CATEGORY_INFO[category]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<div
|
||||||
|
onClick={onToggle}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.02)',
|
||||||
|
borderRadius: 6,
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: categoryInfo.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#374151',
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{categoryInfo.label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#9ca3af',
|
||||||
|
marginRight: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{blocks.length}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#9ca3af',
|
||||||
|
transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||||
|
transition: 'transform 0.15s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
▶
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div style={{ marginTop: 4, paddingLeft: 4 }}>
|
||||||
|
{blocks.map((block) => (
|
||||||
|
<BlockCard
|
||||||
|
key={block.type}
|
||||||
|
definition={block}
|
||||||
|
onSelect={onSelectBlock}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Main Palette Component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const WorkflowPalette: React.FC<WorkflowPaletteProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const editor = useEditor()
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [expandedCategories, setExpandedCategories] = useState<Set<BlockCategory>>(
|
||||||
|
new Set(['trigger', 'action'])
|
||||||
|
)
|
||||||
|
const [autoExecute, setAutoExecuteState] = useState(isAutoExecuteEnabled())
|
||||||
|
const [isExecuting, setIsExecuting] = useState(false)
|
||||||
|
|
||||||
|
// Get all blocks grouped by category
|
||||||
|
const blocksByCategory = useMemo(() => {
|
||||||
|
const categories: BlockCategory[] = ['trigger', 'action', 'condition', 'transformer', 'ai', 'output']
|
||||||
|
const result: Record<BlockCategory, BlockDefinition[]> = {} as any
|
||||||
|
|
||||||
|
for (const category of categories) {
|
||||||
|
const blocks = getBlocksByCategory(category)
|
||||||
|
|
||||||
|
// Filter by search query
|
||||||
|
if (searchQuery) {
|
||||||
|
const query = searchQuery.toLowerCase()
|
||||||
|
result[category] = blocks.filter(
|
||||||
|
(b) =>
|
||||||
|
b.name.toLowerCase().includes(query) ||
|
||||||
|
b.description.toLowerCase().includes(query) ||
|
||||||
|
b.type.toLowerCase().includes(query)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
result[category] = blocks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}, [searchQuery])
|
||||||
|
|
||||||
|
// Toggle category expansion
|
||||||
|
const toggleCategory = useCallback((category: BlockCategory) => {
|
||||||
|
setExpandedCategories((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(category)) {
|
||||||
|
next.delete(category)
|
||||||
|
} else {
|
||||||
|
next.add(category)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Handle block selection
|
||||||
|
const handleSelectBlock = useCallback(
|
||||||
|
(blockType: string) => {
|
||||||
|
setWorkflowBlockType(blockType)
|
||||||
|
editor.setCurrentTool('WorkflowBlock')
|
||||||
|
},
|
||||||
|
[editor]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handle run workflow
|
||||||
|
const handleRunWorkflow = useCallback(async () => {
|
||||||
|
setIsExecuting(true)
|
||||||
|
try {
|
||||||
|
await executeWorkflow(editor, {
|
||||||
|
onProgress: (completed, total) => {
|
||||||
|
console.log(`Workflow progress: ${completed}/${total}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsExecuting(false)
|
||||||
|
}
|
||||||
|
}, [editor])
|
||||||
|
|
||||||
|
// Handle reset workflow
|
||||||
|
const handleResetWorkflow = useCallback(() => {
|
||||||
|
resetWorkflow(editor)
|
||||||
|
}, [editor])
|
||||||
|
|
||||||
|
// Toggle auto-execute
|
||||||
|
const handleToggleAutoExecute = useCallback(() => {
|
||||||
|
const newValue = !autoExecute
|
||||||
|
setAutoExecuteState(newValue)
|
||||||
|
setAutoExecute(newValue)
|
||||||
|
}, [autoExecute])
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
const categories: BlockCategory[] = ['trigger', 'action', 'condition', 'transformer', 'ai', 'output']
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 60,
|
||||||
|
left: 10,
|
||||||
|
width: 280,
|
||||||
|
maxHeight: 'calc(100vh - 120px)',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: 12,
|
||||||
|
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.15)',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
zIndex: 1000,
|
||||||
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '14px 16px',
|
||||||
|
borderBottom: '1px solid #e5e7eb',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{ fontSize: 16 }}>⚡</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#1f2937',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Workflow Blocks
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
fontSize: 18,
|
||||||
|
color: '#9ca3af',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 4,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div style={{ padding: '10px 12px', borderBottom: '1px solid #e5e7eb' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search blocks..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 12px',
|
||||||
|
fontSize: 13,
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: 6,
|
||||||
|
outline: 'none',
|
||||||
|
backgroundColor: '#f9fafb',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Execution Controls */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '10px 12px',
|
||||||
|
borderBottom: '1px solid #e5e7eb',
|
||||||
|
display: 'flex',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={handleRunWorkflow}
|
||||||
|
disabled={isExecuting}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '8px 12px',
|
||||||
|
backgroundColor: isExecuting ? '#9ca3af' : '#10b981',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: isExecuting ? 'not-allowed' : 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isExecuting ? (
|
||||||
|
<>
|
||||||
|
<span style={{ animation: 'spin 1s linear infinite' }}>⏳</span>
|
||||||
|
Running...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>▶ Run Workflow</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleResetWorkflow}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
backgroundColor: '#f3f4f6',
|
||||||
|
color: '#374151',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
title="Reset all blocks"
|
||||||
|
>
|
||||||
|
↺
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-execute Toggle */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderBottom: '1px solid #e5e7eb',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 12, color: '#6b7280' }}>
|
||||||
|
Real-time propagation
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleToggleAutoExecute}
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: 11,
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: autoExecute ? '#10b981' : '#d1d5db',
|
||||||
|
cursor: 'pointer',
|
||||||
|
position: 'relative',
|
||||||
|
transition: 'background-color 0.15s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 2,
|
||||||
|
left: autoExecute ? 20 : 2,
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.2)',
|
||||||
|
transition: 'left 0.15s ease',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Block Categories */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{categories.map((category) => {
|
||||||
|
const blocks = blocksByCategory[category]
|
||||||
|
if (blocks.length === 0 && searchQuery) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CategorySection
|
||||||
|
key={category}
|
||||||
|
category={category}
|
||||||
|
blocks={blocks}
|
||||||
|
isExpanded={expandedCategories.has(category)}
|
||||||
|
onToggle={() => toggleCategory(category)}
|
||||||
|
onSelectBlock={handleSelectBlock}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '10px 12px',
|
||||||
|
borderTop: '1px solid #e5e7eb',
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#9ca3af',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Click a block, then click on canvas to place
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WorkflowPalette
|
||||||
|
|
@ -0,0 +1,589 @@
|
||||||
|
/**
|
||||||
|
* Workflow Builder Styles
|
||||||
|
*
|
||||||
|
* Styles for the Flowy-like workflow builder system including
|
||||||
|
* workflow blocks, ports, palette, and execution states.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Workflow Block Base Styles
|
||||||
|
============================================================================= */
|
||||||
|
|
||||||
|
.workflow-block {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: box-shadow 0.2s ease, transform 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-block:hover {
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-block.selected {
|
||||||
|
box-shadow: 0 0 0 2px var(--workflow-category-color, #6366f1),
|
||||||
|
0 4px 16px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-block.executing {
|
||||||
|
animation: workflow-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes workflow-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.4),
|
||||||
|
0 4px 16px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.2),
|
||||||
|
0 4px 16px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Block Header
|
||||||
|
============================================================================= */
|
||||||
|
|
||||||
|
.workflow-block-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
background: linear-gradient(180deg, rgba(0, 0, 0, 0.02) 0%, transparent 100%);
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-block-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-block-title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-block-category {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Port Styles
|
||||||
|
============================================================================= */
|
||||||
|
|
||||||
|
.workflow-port {
|
||||||
|
position: absolute;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid;
|
||||||
|
background: white;
|
||||||
|
cursor: crosshair;
|
||||||
|
transition: transform 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-port:hover {
|
||||||
|
transform: scale(1.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-port.connected {
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-port.compatible {
|
||||||
|
box-shadow: 0 0 8px currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-port.incompatible {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Port type colors */
|
||||||
|
.workflow-port.type-text { color: #3b82f6; }
|
||||||
|
.workflow-port.type-number { color: #10b981; }
|
||||||
|
.workflow-port.type-boolean { color: #f59e0b; }
|
||||||
|
.workflow-port.type-object { color: #8b5cf6; }
|
||||||
|
.workflow-port.type-array { color: #06b6d4; }
|
||||||
|
.workflow-port.type-any { color: #6b7280; }
|
||||||
|
.workflow-port.type-file { color: #ec4899; }
|
||||||
|
.workflow-port.type-image { color: #f97316; }
|
||||||
|
|
||||||
|
/* Port labels */
|
||||||
|
.workflow-port-label {
|
||||||
|
position: absolute;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #4b5563;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-port-label.input {
|
||||||
|
left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-port-label.output {
|
||||||
|
right: 16px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-port-label-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Execution States
|
||||||
|
============================================================================= */
|
||||||
|
|
||||||
|
.workflow-execution-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-execution-badge.idle {
|
||||||
|
background: transparent;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-execution-badge.running {
|
||||||
|
background: #fef3c7;
|
||||||
|
border: 1px solid #f59e0b;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-execution-badge.success {
|
||||||
|
background: #d1fae5;
|
||||||
|
border: 1px solid #10b981;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-execution-badge.error {
|
||||||
|
background: #fee2e2;
|
||||||
|
border: 1px solid #ef4444;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Workflow Palette
|
||||||
|
============================================================================= */
|
||||||
|
|
||||||
|
.workflow-palette {
|
||||||
|
position: fixed;
|
||||||
|
top: 60px;
|
||||||
|
left: 10px;
|
||||||
|
width: 280px;
|
||||||
|
max-height: calc(100vh - 120px);
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
z-index: 1000;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-header {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #9ca3af;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-close:hover {
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-search {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-search input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
outline: none;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-search input:focus {
|
||||||
|
border-color: #6366f1;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-controls {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-run-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-run-btn:hover {
|
||||||
|
background: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-run-btn:disabled {
|
||||||
|
background: #9ca3af;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-reset-btn {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-reset-btn:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-palette-footer {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #9ca3af;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Category Sections
|
||||||
|
============================================================================= */
|
||||||
|
|
||||||
|
.workflow-category {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-category-header {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-category-header:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-category-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-category-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-category-count {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-category-arrow {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-category.expanded .workflow-category-arrow {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-category-blocks {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Block Cards in Palette
|
||||||
|
============================================================================= */
|
||||||
|
|
||||||
|
.workflow-block-card {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
border-left: 3px solid;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-block-card:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-block-card-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-block-card-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-block-card-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-block-card-description {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6b7280;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-block-card-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Connection Arrows
|
||||||
|
============================================================================= */
|
||||||
|
|
||||||
|
.workflow-arrow {
|
||||||
|
stroke: #374151;
|
||||||
|
stroke-width: 2;
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-arrow.valid {
|
||||||
|
stroke: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-arrow.invalid {
|
||||||
|
stroke: #f59e0b;
|
||||||
|
stroke-dasharray: 4 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-arrow.dragging {
|
||||||
|
stroke: #6366f1;
|
||||||
|
stroke-dasharray: 6 3;
|
||||||
|
animation: workflow-arrow-dash 0.5s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes workflow-arrow-dash {
|
||||||
|
to {
|
||||||
|
stroke-dashoffset: -9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Toggle Switch
|
||||||
|
============================================================================= */
|
||||||
|
|
||||||
|
.workflow-toggle {
|
||||||
|
width: 40px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 11px;
|
||||||
|
border: none;
|
||||||
|
background: #d1d5db;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-toggle.active {
|
||||||
|
background: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-toggle-knob {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: left 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-toggle.active .workflow-toggle-knob {
|
||||||
|
left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Animation Keyframes
|
||||||
|
============================================================================= */
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Dark Mode
|
||||||
|
============================================================================= */
|
||||||
|
|
||||||
|
.dark .workflow-block {
|
||||||
|
background: #1f2937;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .workflow-block-header {
|
||||||
|
border-bottom-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .workflow-block-title {
|
||||||
|
color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .workflow-port {
|
||||||
|
background: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .workflow-port.connected {
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .workflow-port-label {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .workflow-palette {
|
||||||
|
background: #1f2937;
|
||||||
|
border-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .workflow-palette-header {
|
||||||
|
border-bottom-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .workflow-palette-title {
|
||||||
|
color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .workflow-palette-search input {
|
||||||
|
background: #111827;
|
||||||
|
border-color: #374151;
|
||||||
|
color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .workflow-palette-controls {
|
||||||
|
border-bottom-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .workflow-category-header {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .workflow-category-header:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .workflow-category-label {
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .workflow-block-card:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .workflow-block-card-name {
|
||||||
|
color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .workflow-block-card-description {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .workflow-palette-footer {
|
||||||
|
border-top-color: #374151;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,881 @@
|
||||||
|
/**
|
||||||
|
* Block Registry
|
||||||
|
*
|
||||||
|
* Defines all available workflow blocks with their ports, configuration,
|
||||||
|
* and metadata. Blocks are organized by category for the palette UI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
BlockDefinition,
|
||||||
|
BlockCategory,
|
||||||
|
InputPort,
|
||||||
|
OutputPort,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Block Registry Storage
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const BLOCK_REGISTRY: Map<string, BlockDefinition> = new Map()
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Helper Functions for Port Creation
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
function input(
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
type: 'text' | 'number' | 'boolean' | 'object' | 'array' | 'any' | 'file' | 'image',
|
||||||
|
options: Partial<Omit<InputPort, 'id' | 'name' | 'type' | 'direction'>> = {}
|
||||||
|
): InputPort {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
direction: 'input',
|
||||||
|
required: options.required ?? false,
|
||||||
|
accepts: options.accepts ?? [type, 'any'],
|
||||||
|
description: options.description,
|
||||||
|
defaultValue: options.defaultValue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function output(
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
type: 'text' | 'number' | 'boolean' | 'object' | 'array' | 'any' | 'file' | 'image',
|
||||||
|
options: Partial<Omit<OutputPort, 'id' | 'name' | 'type' | 'direction' | 'produces'>> = {}
|
||||||
|
): OutputPort {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
direction: 'output',
|
||||||
|
produces: type,
|
||||||
|
required: false,
|
||||||
|
description: options.description,
|
||||||
|
defaultValue: options.defaultValue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// TRIGGER BLOCKS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const ManualTrigger: BlockDefinition = {
|
||||||
|
type: 'trigger.manual',
|
||||||
|
category: 'trigger',
|
||||||
|
name: 'Manual Trigger',
|
||||||
|
description: 'Start workflow with a button click',
|
||||||
|
icon: '▶️',
|
||||||
|
color: '#f59e0b',
|
||||||
|
inputs: [],
|
||||||
|
outputs: [
|
||||||
|
output('timestamp', 'Timestamp', 'number', {
|
||||||
|
description: 'Unix timestamp when triggered',
|
||||||
|
}),
|
||||||
|
output('trigger', 'Trigger Data', 'object', {
|
||||||
|
description: 'Metadata about the trigger event',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
executor: 'trigger.manual',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ScheduleTrigger: BlockDefinition = {
|
||||||
|
type: 'trigger.schedule',
|
||||||
|
category: 'trigger',
|
||||||
|
name: 'Schedule',
|
||||||
|
description: 'Run on a schedule (cron expression)',
|
||||||
|
icon: '⏰',
|
||||||
|
color: '#f59e0b',
|
||||||
|
inputs: [],
|
||||||
|
outputs: [
|
||||||
|
output('timestamp', 'Timestamp', 'number'),
|
||||||
|
output('scheduledTime', 'Scheduled Time', 'number'),
|
||||||
|
],
|
||||||
|
configSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
cron: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Cron expression (e.g., "0 * * * *" for every hour)',
|
||||||
|
default: '0 * * * *',
|
||||||
|
},
|
||||||
|
timezone: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Timezone for schedule',
|
||||||
|
default: 'UTC',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultConfig: {
|
||||||
|
cron: '0 * * * *',
|
||||||
|
timezone: 'UTC',
|
||||||
|
},
|
||||||
|
executor: 'trigger.schedule',
|
||||||
|
}
|
||||||
|
|
||||||
|
const WebhookTrigger: BlockDefinition = {
|
||||||
|
type: 'trigger.webhook',
|
||||||
|
category: 'trigger',
|
||||||
|
name: 'Webhook',
|
||||||
|
description: 'Trigger from external HTTP request',
|
||||||
|
icon: '🌐',
|
||||||
|
color: '#f59e0b',
|
||||||
|
inputs: [],
|
||||||
|
outputs: [
|
||||||
|
output('body', 'Request Body', 'object'),
|
||||||
|
output('headers', 'Headers', 'object'),
|
||||||
|
output('method', 'Method', 'text'),
|
||||||
|
output('url', 'URL', 'text'),
|
||||||
|
],
|
||||||
|
executor: 'trigger.webhook',
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ACTION BLOCKS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const HttpRequest: BlockDefinition = {
|
||||||
|
type: 'action.http',
|
||||||
|
category: 'action',
|
||||||
|
name: 'HTTP Request',
|
||||||
|
description: 'Make HTTP API calls',
|
||||||
|
icon: '🌐',
|
||||||
|
color: '#3b82f6',
|
||||||
|
inputs: [
|
||||||
|
input('url', 'URL', 'text', { required: true, description: 'Request URL' }),
|
||||||
|
input('body', 'Body', 'object', { description: 'Request body (for POST/PUT)' }),
|
||||||
|
input('headers', 'Headers', 'object', { description: 'Additional headers' }),
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
output('response', 'Response', 'object', { description: 'Parsed response body' }),
|
||||||
|
output('status', 'Status', 'number', { description: 'HTTP status code' }),
|
||||||
|
output('headers', 'Response Headers', 'object'),
|
||||||
|
],
|
||||||
|
configSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
method: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
||||||
|
default: 'GET',
|
||||||
|
},
|
||||||
|
contentType: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['application/json', 'application/x-www-form-urlencoded', 'text/plain'],
|
||||||
|
default: 'application/json',
|
||||||
|
},
|
||||||
|
timeout: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Request timeout in milliseconds',
|
||||||
|
default: 30000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultConfig: {
|
||||||
|
method: 'GET',
|
||||||
|
contentType: 'application/json',
|
||||||
|
timeout: 30000,
|
||||||
|
},
|
||||||
|
executor: 'action.http',
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateShape: BlockDefinition = {
|
||||||
|
type: 'action.create-shape',
|
||||||
|
category: 'action',
|
||||||
|
name: 'Create Shape',
|
||||||
|
description: 'Create a new shape on the canvas',
|
||||||
|
icon: '📐',
|
||||||
|
color: '#3b82f6',
|
||||||
|
inputs: [
|
||||||
|
input('type', 'Shape Type', 'text', { required: true }),
|
||||||
|
input('x', 'X Position', 'number'),
|
||||||
|
input('y', 'Y Position', 'number'),
|
||||||
|
input('props', 'Properties', 'object'),
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
output('shapeId', 'Shape ID', 'text'),
|
||||||
|
output('shape', 'Shape', 'object'),
|
||||||
|
],
|
||||||
|
executor: 'action.create-shape',
|
||||||
|
}
|
||||||
|
|
||||||
|
const UpdateShape: BlockDefinition = {
|
||||||
|
type: 'action.update-shape',
|
||||||
|
category: 'action',
|
||||||
|
name: 'Update Shape',
|
||||||
|
description: 'Update properties of an existing shape',
|
||||||
|
icon: '✏️',
|
||||||
|
color: '#3b82f6',
|
||||||
|
inputs: [
|
||||||
|
input('shapeId', 'Shape ID', 'text', { required: true }),
|
||||||
|
input('props', 'Properties', 'object', { required: true }),
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
output('success', 'Success', 'boolean'),
|
||||||
|
output('shape', 'Updated Shape', 'object'),
|
||||||
|
],
|
||||||
|
executor: 'action.update-shape',
|
||||||
|
}
|
||||||
|
|
||||||
|
const Delay: BlockDefinition = {
|
||||||
|
type: 'action.delay',
|
||||||
|
category: 'action',
|
||||||
|
name: 'Delay',
|
||||||
|
description: 'Wait for a specified duration',
|
||||||
|
icon: '⏳',
|
||||||
|
color: '#3b82f6',
|
||||||
|
inputs: [
|
||||||
|
input('input', 'Pass Through', 'any', { description: 'Data to pass through after delay' }),
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
output('output', 'Output', 'any'),
|
||||||
|
],
|
||||||
|
configSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
duration: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Delay in milliseconds',
|
||||||
|
default: 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultConfig: {
|
||||||
|
duration: 1000,
|
||||||
|
},
|
||||||
|
executor: 'action.delay',
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// CONDITION BLOCKS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const IfCondition: BlockDefinition = {
|
||||||
|
type: 'condition.if',
|
||||||
|
category: 'condition',
|
||||||
|
name: 'If/Else',
|
||||||
|
description: 'Branch based on a boolean condition',
|
||||||
|
icon: '❓',
|
||||||
|
color: '#8b5cf6',
|
||||||
|
inputs: [
|
||||||
|
input('condition', 'Condition', 'boolean', { required: true }),
|
||||||
|
input('value', 'Value', 'any', { required: true, description: 'Data to route' }),
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
output('true', 'If True', 'any', { description: 'Output when condition is true' }),
|
||||||
|
output('false', 'If False', 'any', { description: 'Output when condition is false' }),
|
||||||
|
],
|
||||||
|
executor: 'condition.if',
|
||||||
|
}
|
||||||
|
|
||||||
|
const SwitchCondition: BlockDefinition = {
|
||||||
|
type: 'condition.switch',
|
||||||
|
category: 'condition',
|
||||||
|
name: 'Switch',
|
||||||
|
description: 'Route based on value matching',
|
||||||
|
icon: '🔀',
|
||||||
|
color: '#8b5cf6',
|
||||||
|
inputs: [
|
||||||
|
input('value', 'Value', 'any', { required: true }),
|
||||||
|
input('data', 'Data', 'any', { description: 'Data to route' }),
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
output('case1', 'Case 1', 'any'),
|
||||||
|
output('case2', 'Case 2', 'any'),
|
||||||
|
output('case3', 'Case 3', 'any'),
|
||||||
|
output('default', 'Default', 'any'),
|
||||||
|
],
|
||||||
|
configSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
cases: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
|
default: ['value1', 'value2', 'value3'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultConfig: {
|
||||||
|
cases: ['value1', 'value2', 'value3'],
|
||||||
|
},
|
||||||
|
executor: 'condition.switch',
|
||||||
|
}
|
||||||
|
|
||||||
|
const Compare: BlockDefinition = {
|
||||||
|
type: 'condition.compare',
|
||||||
|
category: 'condition',
|
||||||
|
name: 'Compare',
|
||||||
|
description: 'Compare two values',
|
||||||
|
icon: '⚖️',
|
||||||
|
color: '#8b5cf6',
|
||||||
|
inputs: [
|
||||||
|
input('left', 'Left Value', 'any', { required: true }),
|
||||||
|
input('right', 'Right Value', 'any', { required: true }),
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
output('result', 'Result', 'boolean'),
|
||||||
|
],
|
||||||
|
configSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
operator: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['==', '!=', '>', '<', '>=', '<=', 'contains', 'startsWith', 'endsWith'],
|
||||||
|
default: '==',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultConfig: {
|
||||||
|
operator: '==',
|
||||||
|
},
|
||||||
|
executor: 'condition.compare',
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// TRANSFORMER BLOCKS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const JsonParse: BlockDefinition = {
|
||||||
|
type: 'transformer.json-parse',
|
||||||
|
category: 'transformer',
|
||||||
|
name: 'Parse JSON',
|
||||||
|
description: 'Parse JSON string to object',
|
||||||
|
icon: '📋',
|
||||||
|
color: '#10b981',
|
||||||
|
inputs: [
|
||||||
|
input('input', 'JSON String', 'text', { required: true }),
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
output('output', 'Object', 'object'),
|
||||||
|
],
|
||||||
|
executor: 'transformer.json-parse',
|
||||||
|
}
|
||||||
|
|
||||||
|
const JsonStringify: BlockDefinition = {
|
||||||
|
type: 'transformer.json-stringify',
|
||||||
|
category: 'transformer',
|
||||||
|
name: 'Stringify JSON',
|
||||||
|
description: 'Convert object to JSON string',
|
||||||
|
icon: '📝',
|
||||||
|
color: '#10b981',
|
||||||
|
inputs: [
|
||||||
|
input('input', 'Object', 'object', { required: true }),
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
output('output', 'JSON String', 'text'),
|
||||||
|
],
|
||||||
|
configSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
pretty: { type: 'boolean', default: false },
|
||||||
|
indent: { type: 'number', default: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultConfig: { pretty: false, indent: 2 },
|
||||||
|
executor: 'transformer.json-stringify',
|
||||||
|
}
|
||||||
|
|
||||||
|
const CodeTransformer: BlockDefinition = {
|
||||||
|
type: 'transformer.code',
|
||||||
|
category: 'transformer',
|
||||||
|
name: 'JavaScript',
|
||||||
|
description: 'Run custom JavaScript code',
|
||||||
|
icon: '💻',
|
||||||
|
color: '#10b981',
|
||||||
|
inputs: [
|
||||||
|
input('input', 'Input', 'any', { required: true }),
|
||||||
|
input('context', 'Context', 'object', { description: 'Additional context data' }),
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
output('output', 'Output', 'any'),
|
||||||
|
],
|
||||||
|
configSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
code: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'JavaScript code. Use `input` variable. Return value becomes output.',
|
||||||
|
default: 'return input;',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultConfig: {
|
||||||
|
code: 'return input;',
|
||||||
|
},
|
||||||
|
executor: 'transformer.code',
|
||||||
|
}
|
||||||
|
|
||||||
|
const Template: BlockDefinition = {
|
||||||
|
type: 'transformer.template',
|
||||||
|
category: 'transformer',
|
||||||
|
name: 'Template',
|
||||||
|
description: 'String interpolation with variables',
|
||||||
|
icon: '📄',
|
||||||
|
color: '#10b981',
|
||||||
|
inputs: [
|
||||||
|
input('data', 'Data', 'object', { required: true }),
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
output('output', 'Text', 'text'),
|
||||||
|
],
|
||||||
|
configSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
template: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Template string. Use {{key}} for interpolation.',
|
||||||
|
default: 'Hello, {{name}}!',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultConfig: {
|
||||||
|
template: 'Hello, {{name}}!',
|
||||||
|
},
|
||||||
|
executor: 'transformer.template',
|
||||||
|
}
|
||||||
|
|
||||||
|
const GetProperty: BlockDefinition = {
|
||||||
|
type: 'transformer.get-property',
|
||||||
|
category: 'transformer',
|
||||||
|
name: 'Get Property',
|
||||||
|
description: 'Extract a property from an object',
|
||||||
|
icon: '🔍',
|
||||||
|
color: '#10b981',
|
||||||
|
inputs: [
|
||||||
|
input('object', 'Object', 'object', { required: true }),
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
output('value', 'Value', 'any'),
|
||||||
|
],
|
||||||
|
configSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
path: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Property path (e.g., "data.user.name")',
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultConfig: { path: '' },
|
||||||
|
executor: 'transformer.get-property',
|
||||||
|
}
|
||||||
|
|
||||||
|
const SetProperty: BlockDefinition = {
|
||||||
|
type: 'transformer.set-property',
|
||||||
|
category: 'transformer',
|
||||||
|
name: 'Set Property',
|
||||||
|
description: 'Set a property on an object',
|
||||||
|
icon: '✏️',
|
||||||
|
color: '#10b981',
|
||||||
|
inputs: [
|
||||||
|
input('object', 'Object', 'object', { required: true }),
|
||||||
|
input('value', 'Value', 'any', { required: true }),
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
output('output', 'Object', 'object'),
|
||||||
|
],
|
||||||
|
configSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
path: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Property path to set',
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultConfig: { path: '' },
|
||||||
|
executor: 'transformer.set-property',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ArrayMap: BlockDefinition = {
|
||||||
|
type: 'transformer.array-map',
|
||||||
|
category: 'transformer',
|
||||||
|
name: 'Map Array',
|
||||||
|
description: 'Transform each item in an array',
|
||||||
|
icon: '🗂️',
|
||||||
|
color: '#10b981',
|
||||||
|
inputs: [
|
||||||
|
input('array', 'Array', 'array', { required: true }),
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
output('output', 'Array', 'array'),
|
||||||
|
],
|
||||||
|
configSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
expression: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'JavaScript expression. Use `item` and `index` variables.',
|
||||||
|
default: 'item',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultConfig: { expression: 'item' },
|
||||||
|
executor: 'transformer.array-map',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ArrayFilter: BlockDefinition = {
|
||||||
|
type: 'transformer.array-filter',
|
||||||
|
category: 'transformer',
|
||||||
|
name: 'Filter Array',
|
||||||
|
description: 'Filter array items by condition',
|
||||||
|
icon: '🔍',
|
||||||
|
color: '#10b981',
|
||||||
|
inputs: [
|
||||||
|
input('array', 'Array', 'array', { required: true }),
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
output('output', 'Array', 'array'),
|
||||||
|
],
|
||||||
|
configSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
condition: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'JavaScript condition. Use `item` and `index` variables.',
|
||||||
|
default: 'true',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultConfig: { condition: 'true' },
|
||||||
|
executor: 'transformer.array-filter',
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// AI BLOCKS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const LLMPrompt: BlockDefinition = {
|
||||||
|
type: 'ai.llm',
|
||||||
|
category: 'ai',
|
||||||
|
name: 'LLM Prompt',
|
||||||
|
description: 'Send prompt to language model',
|
||||||
|
icon: '🤖',
|
||||||
|
color: '#ec4899',
|
||||||
|
inputs: [
|
||||||
|
input('prompt', 'Prompt', 'text', { required: true }),
|
||||||
|
input('context', 'Context', 'text', { description: 'Additional context' }),
|
||||||
|
input('systemPrompt', 'System Prompt', 'text', { description: 'System instructions' }),
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
output('response', 'Response', 'text'),
|
||||||
|
output('usage', 'Usage', 'object', { description: 'Token usage stats' }),
|
||||||
|
],
|
||||||
|
configSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
model: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['llama3.1:8b', 'llama3.1:70b', 'claude-sonnet', 'gpt-4'],
|
||||||
|
default: 'llama3.1:8b',
|
||||||
|
},
|
||||||
|
temperature: {
|
||||||
|
type: 'number',
|
||||||
|
minimum: 0,
|
||||||
|
maximum: 2,
|
||||||
|
default: 0.7,
|
||||||
|
},
|
||||||
|
maxTokens: {
|
||||||
|
type: 'number',
|
||||||
|
default: 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultConfig: {
|
||||||
|
model: 'llama3.1:8b',
|
||||||
|
temperature: 0.7,
|
||||||
|
maxTokens: 1000,
|
||||||
|
},
|
||||||
|
executor: 'ai.llm',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImageGen: BlockDefinition = {
|
||||||
|
type: 'ai.image-gen',
|
||||||
|
category: 'ai',
|
||||||
|
name: 'Image Generation',
|
||||||
|
description: 'Generate image from text prompt',
|
||||||
|
icon: '🎨',
|
||||||
|
color: '#ec4899',
|
||||||
|
inputs: [
|
||||||
|
input('prompt', 'Prompt', 'text', { required: true }),
|
||||||
|
input('negativePrompt', 'Negative Prompt', 'text'),
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
output('image', 'Image', 'image'),
|
||||||
|
output('url', 'Image URL', 'text'),
|
||||||
|
],
|
||||||
|
configSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
model: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['SDXL', 'SD3', 'FLUX'],
|
||||||
|
default: 'SDXL',
|
||||||
|
},
|
||||||
|
width: { type: 'number', default: 512 },
|
||||||
|
height: { type: 'number', default: 512 },
|
||||||
|
steps: { type: 'number', default: 20 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultConfig: {
|
||||||
|
model: 'SDXL',
|
||||||
|
width: 512,
|
||||||
|
height: 512,
|
||||||
|
steps: 20,
|
||||||
|
},
|
||||||
|
executor: 'ai.image-gen',
|
||||||
|
}
|
||||||
|
|
||||||
|
const TextToSpeech: BlockDefinition = {
|
||||||
|
type: 'ai.tts',
|
||||||
|
category: 'ai',
|
||||||
|
name: 'Text to Speech',
|
||||||
|
description: 'Convert text to audio',
|
||||||
|
icon: '🔊',
|
||||||
|
color: '#ec4899',
|
||||||
|
inputs: [
|
||||||
|
input('text', 'Text', 'text', { required: true }),
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
output('audio', 'Audio', 'file'),
|
||||||
|
output('url', 'Audio URL', 'text'),
|
||||||
|
],
|
||||||
|
configSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
voice: {
|
||||||
|
type: 'string',
|
||||||
|
default: 'alloy',
|
||||||
|
},
|
||||||
|
speed: {
|
||||||
|
type: 'number',
|
||||||
|
minimum: 0.5,
|
||||||
|
maximum: 2,
|
||||||
|
default: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultConfig: { voice: 'alloy', speed: 1 },
|
||||||
|
executor: 'ai.tts',
|
||||||
|
}
|
||||||
|
|
||||||
|
const SpeechToText: BlockDefinition = {
|
||||||
|
type: 'ai.stt',
|
||||||
|
category: 'ai',
|
||||||
|
name: 'Speech to Text',
|
||||||
|
description: 'Transcribe audio to text',
|
||||||
|
icon: '🎤',
|
||||||
|
color: '#ec4899',
|
||||||
|
inputs: [
|
||||||
|
input('audio', 'Audio', 'file', { required: true }),
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
output('text', 'Text', 'text'),
|
||||||
|
output('segments', 'Segments', 'array'),
|
||||||
|
],
|
||||||
|
executor: 'ai.stt',
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// OUTPUT BLOCKS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const DisplayOutput: BlockDefinition = {
|
||||||
|
type: 'output.display',
|
||||||
|
category: 'output',
|
||||||
|
name: 'Display',
|
||||||
|
description: 'Show result on canvas',
|
||||||
|
icon: '📺',
|
||||||
|
color: '#ef4444',
|
||||||
|
inputs: [
|
||||||
|
input('value', 'Value', 'any', { required: true }),
|
||||||
|
],
|
||||||
|
outputs: [],
|
||||||
|
configSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
format: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['auto', 'json', 'text', 'markdown'],
|
||||||
|
default: 'auto',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultConfig: { format: 'auto' },
|
||||||
|
executor: 'output.display',
|
||||||
|
}
|
||||||
|
|
||||||
|
const LogOutput: BlockDefinition = {
|
||||||
|
type: 'output.log',
|
||||||
|
category: 'output',
|
||||||
|
name: 'Log',
|
||||||
|
description: 'Log value to console',
|
||||||
|
icon: '📋',
|
||||||
|
color: '#ef4444',
|
||||||
|
inputs: [
|
||||||
|
input('value', 'Value', 'any', { required: true }),
|
||||||
|
input('label', 'Label', 'text'),
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
output('passthrough', 'Pass Through', 'any'),
|
||||||
|
],
|
||||||
|
executor: 'output.log',
|
||||||
|
}
|
||||||
|
|
||||||
|
const NotifyOutput: BlockDefinition = {
|
||||||
|
type: 'output.notify',
|
||||||
|
category: 'output',
|
||||||
|
name: 'Notification',
|
||||||
|
description: 'Show browser notification',
|
||||||
|
icon: '🔔',
|
||||||
|
color: '#ef4444',
|
||||||
|
inputs: [
|
||||||
|
input('message', 'Message', 'text', { required: true }),
|
||||||
|
input('title', 'Title', 'text'),
|
||||||
|
],
|
||||||
|
outputs: [],
|
||||||
|
configSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
type: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['info', 'success', 'warning', 'error'],
|
||||||
|
default: 'info',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultConfig: { type: 'info' },
|
||||||
|
executor: 'output.notify',
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateMarkdown: BlockDefinition = {
|
||||||
|
type: 'output.markdown',
|
||||||
|
category: 'output',
|
||||||
|
name: 'Create Markdown',
|
||||||
|
description: 'Create a markdown shape on canvas',
|
||||||
|
icon: '📝',
|
||||||
|
color: '#ef4444',
|
||||||
|
inputs: [
|
||||||
|
input('content', 'Content', 'text', { required: true }),
|
||||||
|
input('x', 'X Position', 'number'),
|
||||||
|
input('y', 'Y Position', 'number'),
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
output('shapeId', 'Shape ID', 'text'),
|
||||||
|
],
|
||||||
|
executor: 'output.markdown',
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Register All Blocks
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const ALL_BLOCKS: BlockDefinition[] = [
|
||||||
|
// Triggers
|
||||||
|
ManualTrigger,
|
||||||
|
ScheduleTrigger,
|
||||||
|
WebhookTrigger,
|
||||||
|
// Actions
|
||||||
|
HttpRequest,
|
||||||
|
CreateShape,
|
||||||
|
UpdateShape,
|
||||||
|
Delay,
|
||||||
|
// Conditions
|
||||||
|
IfCondition,
|
||||||
|
SwitchCondition,
|
||||||
|
Compare,
|
||||||
|
// Transformers
|
||||||
|
JsonParse,
|
||||||
|
JsonStringify,
|
||||||
|
CodeTransformer,
|
||||||
|
Template,
|
||||||
|
GetProperty,
|
||||||
|
SetProperty,
|
||||||
|
ArrayMap,
|
||||||
|
ArrayFilter,
|
||||||
|
// AI
|
||||||
|
LLMPrompt,
|
||||||
|
ImageGen,
|
||||||
|
TextToSpeech,
|
||||||
|
SpeechToText,
|
||||||
|
// Outputs
|
||||||
|
DisplayOutput,
|
||||||
|
LogOutput,
|
||||||
|
NotifyOutput,
|
||||||
|
CreateMarkdown,
|
||||||
|
]
|
||||||
|
|
||||||
|
// Register all blocks
|
||||||
|
for (const block of ALL_BLOCKS) {
|
||||||
|
BLOCK_REGISTRY.set(block.type, block)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Registry Access Functions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a block definition by type
|
||||||
|
*/
|
||||||
|
export function getBlockDefinition(type: string): BlockDefinition {
|
||||||
|
const def = BLOCK_REGISTRY.get(type)
|
||||||
|
if (!def) {
|
||||||
|
throw new Error(`Unknown block type: ${type}`)
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a block type exists
|
||||||
|
*/
|
||||||
|
export function hasBlockDefinition(type: string): boolean {
|
||||||
|
return BLOCK_REGISTRY.has(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all registered block definitions
|
||||||
|
*/
|
||||||
|
export function getAllBlockDefinitions(): BlockDefinition[] {
|
||||||
|
return Array.from(BLOCK_REGISTRY.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get blocks filtered by category
|
||||||
|
*/
|
||||||
|
export function getBlocksByCategory(category: BlockCategory): BlockDefinition[] {
|
||||||
|
return getAllBlockDefinitions().filter(b => b.category === category)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new block definition
|
||||||
|
*/
|
||||||
|
export function registerBlock(definition: BlockDefinition): void {
|
||||||
|
if (BLOCK_REGISTRY.has(definition.type)) {
|
||||||
|
console.warn(`Block type "${definition.type}" is already registered. Overwriting.`)
|
||||||
|
}
|
||||||
|
BLOCK_REGISTRY.set(definition.type, definition)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all block categories with their blocks
|
||||||
|
*/
|
||||||
|
export function getBlocksByCategories(): Record<BlockCategory, BlockDefinition[]> {
|
||||||
|
const result: Record<BlockCategory, BlockDefinition[]> = {
|
||||||
|
trigger: [],
|
||||||
|
action: [],
|
||||||
|
condition: [],
|
||||||
|
transformer: [],
|
||||||
|
output: [],
|
||||||
|
ai: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const block of BLOCK_REGISTRY.values()) {
|
||||||
|
result[block.category].push(block)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,731 @@
|
||||||
|
/**
|
||||||
|
* Workflow Executor
|
||||||
|
*
|
||||||
|
* Executes workflow blocks either individually or as a complete workflow.
|
||||||
|
* Manages execution state, handles data propagation between blocks,
|
||||||
|
* and supports both manual and real-time execution modes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Editor, TLShapeId } from 'tldraw'
|
||||||
|
import {
|
||||||
|
ExecutionContext,
|
||||||
|
BlockExecutionResult,
|
||||||
|
WorkflowBlockProps,
|
||||||
|
ExecutionState,
|
||||||
|
} from './types'
|
||||||
|
import { getBlockDefinition, hasBlockDefinition } from './blockRegistry'
|
||||||
|
import {
|
||||||
|
getBlockInputBindings,
|
||||||
|
getBlockOutputBindings,
|
||||||
|
getExecutionOrder,
|
||||||
|
buildWorkflowGraph,
|
||||||
|
} from './portBindings'
|
||||||
|
import { validateRequiredInputs, validateWorkflow } from './validation'
|
||||||
|
import { IWorkflowBlock } from '@/shapes/WorkflowBlockShapeUtil'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Block Executors
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
type BlockExecutor = (
|
||||||
|
context: ExecutionContext,
|
||||||
|
inputs: Record<string, unknown>,
|
||||||
|
config: Record<string, unknown>
|
||||||
|
) => Promise<Record<string, unknown>>
|
||||||
|
|
||||||
|
const blockExecutors: Map<string, BlockExecutor> = new Map()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a block executor
|
||||||
|
*/
|
||||||
|
export function registerBlockExecutor(
|
||||||
|
blockType: string,
|
||||||
|
executor: BlockExecutor
|
||||||
|
): void {
|
||||||
|
blockExecutors.set(blockType, executor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Built-in Block Executors
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// Trigger: Manual
|
||||||
|
registerBlockExecutor('trigger.manual', async (context) => {
|
||||||
|
return { timestamp: Date.now() }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Trigger: Schedule
|
||||||
|
registerBlockExecutor('trigger.schedule', async (context, inputs, config) => {
|
||||||
|
return {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
scheduledTime: config.time || '00:00',
|
||||||
|
interval: config.interval || 'daily',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Trigger: Webhook
|
||||||
|
registerBlockExecutor('trigger.webhook', async (context, inputs, config) => {
|
||||||
|
return {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
method: 'POST',
|
||||||
|
body: {},
|
||||||
|
headers: {},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Action: HTTP Request
|
||||||
|
registerBlockExecutor('action.http', async (context, inputs, config) => {
|
||||||
|
const url = (inputs.url as string) || (config.url as string)
|
||||||
|
const method = (config.method as string) || 'GET'
|
||||||
|
const body = inputs.body as string | undefined
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
throw new Error('URL is required for HTTP request')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: method !== 'GET' ? body : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const responseData = await response.text()
|
||||||
|
let parsedData: unknown = responseData
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsedData = JSON.parse(responseData)
|
||||||
|
} catch {
|
||||||
|
// Keep as text if not valid JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
response: parsedData,
|
||||||
|
status: response.status,
|
||||||
|
headers: Object.fromEntries(response.headers.entries()),
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`HTTP request failed: ${(error as Error).message}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Action: Create Shape
|
||||||
|
registerBlockExecutor('action.createShape', async (context, inputs, config) => {
|
||||||
|
const shapeType = (config.shapeType as string) || 'text'
|
||||||
|
const position = (inputs.position as { x: number; y: number }) || { x: 100, y: 100 }
|
||||||
|
const content = inputs.content as string || ''
|
||||||
|
|
||||||
|
// Create shape through editor
|
||||||
|
const newShape = context.editor.createShape({
|
||||||
|
type: shapeType === 'text' ? 'text' : 'geo',
|
||||||
|
x: position.x,
|
||||||
|
y: position.y,
|
||||||
|
props: shapeType === 'text'
|
||||||
|
? { text: content }
|
||||||
|
: { text: content, w: 200, h: 100 },
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
shapeId: newShape?.id || null,
|
||||||
|
created: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Action: Update Shape
|
||||||
|
registerBlockExecutor('action.updateShape', async (context, inputs, config) => {
|
||||||
|
const shapeId = inputs.shapeId as TLShapeId
|
||||||
|
const updates = inputs.updates as Record<string, unknown>
|
||||||
|
|
||||||
|
if (!shapeId) {
|
||||||
|
throw new Error('Shape ID is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
context.editor.updateShape({
|
||||||
|
id: shapeId,
|
||||||
|
type: context.editor.getShape(shapeId)?.type || 'geo',
|
||||||
|
props: updates,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { updated: true, shapeId }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Action: Delay
|
||||||
|
registerBlockExecutor('action.delay', async (context, inputs, config) => {
|
||||||
|
const duration = (config.duration as number) || 1000
|
||||||
|
await new Promise(resolve => setTimeout(resolve, duration))
|
||||||
|
return { passthrough: inputs.input, delayed: duration }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Condition: If/Else
|
||||||
|
registerBlockExecutor('condition.if', async (context, inputs) => {
|
||||||
|
const condition = Boolean(inputs.condition)
|
||||||
|
const value = inputs.value
|
||||||
|
|
||||||
|
return condition
|
||||||
|
? { true: value }
|
||||||
|
: { false: value }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Condition: Switch
|
||||||
|
registerBlockExecutor('condition.switch', async (context, inputs, config) => {
|
||||||
|
const value = inputs.value
|
||||||
|
const cases = (config.cases as Record<string, unknown>) || {}
|
||||||
|
|
||||||
|
for (const [caseValue, output] of Object.entries(cases)) {
|
||||||
|
if (String(value) === caseValue) {
|
||||||
|
return { match: output, matched: caseValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { default: value }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Condition: Compare
|
||||||
|
registerBlockExecutor('condition.compare', async (context, inputs, config) => {
|
||||||
|
const a = inputs.a
|
||||||
|
const b = inputs.b
|
||||||
|
const operator = (config.operator as string) || 'equals'
|
||||||
|
|
||||||
|
let result: boolean
|
||||||
|
|
||||||
|
switch (operator) {
|
||||||
|
case 'equals':
|
||||||
|
result = a === b
|
||||||
|
break
|
||||||
|
case 'notEquals':
|
||||||
|
result = a !== b
|
||||||
|
break
|
||||||
|
case 'greaterThan':
|
||||||
|
result = Number(a) > Number(b)
|
||||||
|
break
|
||||||
|
case 'lessThan':
|
||||||
|
result = Number(a) < Number(b)
|
||||||
|
break
|
||||||
|
case 'contains':
|
||||||
|
result = String(a).includes(String(b))
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
result = a === b
|
||||||
|
}
|
||||||
|
|
||||||
|
return { result }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Transformer: JSON Parse
|
||||||
|
registerBlockExecutor('transformer.jsonParse', async (context, inputs) => {
|
||||||
|
const input = inputs.input as string
|
||||||
|
try {
|
||||||
|
return { output: JSON.parse(input) }
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Invalid JSON: ${(error as Error).message}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Transformer: JSON Stringify
|
||||||
|
registerBlockExecutor('transformer.jsonStringify', async (context, inputs, config) => {
|
||||||
|
const input = inputs.input
|
||||||
|
const pretty = config.pretty as boolean
|
||||||
|
|
||||||
|
return {
|
||||||
|
output: pretty
|
||||||
|
? JSON.stringify(input, null, 2)
|
||||||
|
: JSON.stringify(input),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Transformer: JavaScript Code
|
||||||
|
registerBlockExecutor('transformer.code', async (context, inputs, config) => {
|
||||||
|
const code = config.code as string
|
||||||
|
const input = inputs.input
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return { output: input }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a sandboxed function
|
||||||
|
const fn = new Function('input', 'context', `
|
||||||
|
'use strict';
|
||||||
|
${code}
|
||||||
|
`)
|
||||||
|
const result = fn(input, { timestamp: Date.now() })
|
||||||
|
return { output: result }
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Code execution failed: ${(error as Error).message}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Transformer: Template
|
||||||
|
registerBlockExecutor('transformer.template', async (context, inputs, config) => {
|
||||||
|
const template = (config.template as string) || ''
|
||||||
|
const variables = (inputs.variables as Record<string, unknown>) || {}
|
||||||
|
|
||||||
|
let result = template
|
||||||
|
for (const [key, value] of Object.entries(variables)) {
|
||||||
|
result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), String(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
return { output: result }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Transformer: Get Property
|
||||||
|
registerBlockExecutor('transformer.getProperty', async (context, inputs, config) => {
|
||||||
|
const object = inputs.object as Record<string, unknown>
|
||||||
|
const path = (config.path as string) || ''
|
||||||
|
|
||||||
|
if (!object || typeof object !== 'object') {
|
||||||
|
return { value: undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = path.split('.')
|
||||||
|
let value: unknown = object
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (value && typeof value === 'object' && part in (value as Record<string, unknown>)) {
|
||||||
|
value = (value as Record<string, unknown>)[part]
|
||||||
|
} else {
|
||||||
|
return { value: undefined }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { value }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Transformer: Set Property
|
||||||
|
registerBlockExecutor('transformer.setProperty', async (context, inputs, config) => {
|
||||||
|
const object = { ...(inputs.object as Record<string, unknown>) } || {}
|
||||||
|
const path = (config.path as string) || ''
|
||||||
|
const value = inputs.value
|
||||||
|
|
||||||
|
if (path) {
|
||||||
|
const parts = path.split('.')
|
||||||
|
let current = object as Record<string, unknown>
|
||||||
|
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
if (!(parts[i] in current)) {
|
||||||
|
current[parts[i]] = {}
|
||||||
|
}
|
||||||
|
current = current[parts[i]] as Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
current[parts[parts.length - 1]] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return { output: object }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Transformer: Array Map
|
||||||
|
registerBlockExecutor('transformer.arrayMap', async (context, inputs, config) => {
|
||||||
|
const array = inputs.array as unknown[]
|
||||||
|
const expression = (config.expression as string) || 'item'
|
||||||
|
|
||||||
|
if (!Array.isArray(array)) {
|
||||||
|
throw new Error('Input must be an array')
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn = new Function('item', 'index', `return ${expression}`)
|
||||||
|
return { output: array.map((item, index) => fn(item, index)) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Transformer: Array Filter
|
||||||
|
registerBlockExecutor('transformer.arrayFilter', async (context, inputs, config) => {
|
||||||
|
const array = inputs.array as unknown[]
|
||||||
|
const condition = (config.condition as string) || 'true'
|
||||||
|
|
||||||
|
if (!Array.isArray(array)) {
|
||||||
|
throw new Error('Input must be an array')
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn = new Function('item', 'index', `return ${condition}`)
|
||||||
|
return { output: array.filter((item, index) => fn(item, index)) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// AI: LLM Prompt (placeholder - integrates with existing AI utilities)
|
||||||
|
registerBlockExecutor('ai.llm', async (context, inputs, config) => {
|
||||||
|
const prompt = inputs.prompt as string
|
||||||
|
const systemPrompt = (config.systemPrompt as string) || ''
|
||||||
|
|
||||||
|
// TODO: Integrate with existing LLM utilities
|
||||||
|
// For now, return a placeholder
|
||||||
|
console.log('LLM Prompt:', { prompt, systemPrompt })
|
||||||
|
|
||||||
|
return {
|
||||||
|
response: `[LLM response to: "${prompt?.substring(0, 50)}..."]`,
|
||||||
|
tokens: 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// AI: Image Generation (placeholder)
|
||||||
|
registerBlockExecutor('ai.imageGen', async (context, inputs, config) => {
|
||||||
|
const prompt = inputs.prompt as string
|
||||||
|
const size = (config.size as string) || '512x512'
|
||||||
|
|
||||||
|
// TODO: Integrate with existing image generation
|
||||||
|
console.log('Image Gen:', { prompt, size })
|
||||||
|
|
||||||
|
return {
|
||||||
|
image: '[Image URL placeholder]',
|
||||||
|
prompt,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// AI: Text to Speech (placeholder)
|
||||||
|
registerBlockExecutor('ai.tts', async (context, inputs, config) => {
|
||||||
|
const text = inputs.text as string
|
||||||
|
const voice = (config.voice as string) || 'default'
|
||||||
|
|
||||||
|
console.log('TTS:', { text, voice })
|
||||||
|
|
||||||
|
return {
|
||||||
|
audio: '[Audio URL placeholder]',
|
||||||
|
duration: 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// AI: Speech to Text (placeholder)
|
||||||
|
registerBlockExecutor('ai.stt', async (context, inputs) => {
|
||||||
|
const audio = inputs.audio as string
|
||||||
|
|
||||||
|
console.log('STT:', { audio })
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: '[Transcription placeholder]',
|
||||||
|
confidence: 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Output: Display
|
||||||
|
registerBlockExecutor('output.display', async (context, inputs, config) => {
|
||||||
|
const value = inputs.value
|
||||||
|
const format = (config.format as string) || 'auto'
|
||||||
|
|
||||||
|
let displayed: string
|
||||||
|
if (format === 'json') {
|
||||||
|
displayed = JSON.stringify(value, null, 2)
|
||||||
|
} else if (format === 'text') {
|
||||||
|
displayed = String(value)
|
||||||
|
} else {
|
||||||
|
displayed = typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Display:', displayed)
|
||||||
|
return { displayed }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Output: Log
|
||||||
|
registerBlockExecutor('output.log', async (context, inputs, config) => {
|
||||||
|
const message = inputs.message
|
||||||
|
const level = (config.level as string) || 'info'
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString()
|
||||||
|
console.log(`[${level.toUpperCase()}] ${timestamp}:`, message)
|
||||||
|
|
||||||
|
return { logged: true, timestamp, level }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Output: Notify
|
||||||
|
registerBlockExecutor('output.notify', async (context, inputs, config) => {
|
||||||
|
const message = inputs.message as string
|
||||||
|
const title = (config.title as string) || 'Notification'
|
||||||
|
|
||||||
|
// Dispatch notification event
|
||||||
|
window.dispatchEvent(new CustomEvent('workflow:notify', {
|
||||||
|
detail: { title, message },
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { notified: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Output: Create Markdown
|
||||||
|
registerBlockExecutor('output.markdown', async (context, inputs, config) => {
|
||||||
|
const content = inputs.content as string
|
||||||
|
const position = (inputs.position as { x: number; y: number }) || { x: 100, y: 100 }
|
||||||
|
|
||||||
|
// Create a markdown shape
|
||||||
|
const newShape = context.editor.createShape({
|
||||||
|
type: 'Markdown',
|
||||||
|
x: position.x,
|
||||||
|
y: position.y,
|
||||||
|
props: {
|
||||||
|
w: 400,
|
||||||
|
h: 300,
|
||||||
|
text: content,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { shapeId: newShape?.id || null, created: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Execution Functions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a single workflow block
|
||||||
|
*/
|
||||||
|
export async function executeBlock(
|
||||||
|
editor: Editor,
|
||||||
|
blockId: TLShapeId,
|
||||||
|
additionalInputs: Record<string, unknown> = {}
|
||||||
|
): Promise<BlockExecutionResult> {
|
||||||
|
const shape = editor.getShape(blockId) as IWorkflowBlock | undefined
|
||||||
|
|
||||||
|
if (!shape || shape.type !== 'WorkflowBlock') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid block shape',
|
||||||
|
outputs: {},
|
||||||
|
executionTime: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { blockType, blockConfig, inputValues } = shape.props
|
||||||
|
|
||||||
|
if (!hasBlockDefinition(blockType)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Unknown block type: ${blockType}`,
|
||||||
|
outputs: {},
|
||||||
|
executionTime: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get executor
|
||||||
|
const executor = blockExecutors.get(blockType)
|
||||||
|
if (!executor) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `No executor registered for block type: ${blockType}`,
|
||||||
|
outputs: {},
|
||||||
|
executionTime: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update execution state to running
|
||||||
|
updateBlockState(editor, blockId, 'running')
|
||||||
|
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Gather inputs from upstream blocks
|
||||||
|
const inputs = await gatherBlockInputs(editor, blockId)
|
||||||
|
|
||||||
|
// Merge with additional inputs and stored input values
|
||||||
|
const mergedInputs = {
|
||||||
|
...inputValues,
|
||||||
|
...inputs,
|
||||||
|
...additionalInputs,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create execution context
|
||||||
|
const context: ExecutionContext = {
|
||||||
|
editor,
|
||||||
|
blockId,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
const outputs = await executor(context, mergedInputs, blockConfig)
|
||||||
|
|
||||||
|
// Update block with outputs
|
||||||
|
editor.updateShape<IWorkflowBlock>({
|
||||||
|
id: blockId,
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
props: {
|
||||||
|
outputValues: outputs,
|
||||||
|
executionState: 'success',
|
||||||
|
executionError: undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
outputs,
|
||||||
|
executionTime: Date.now() - startTime,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = (error as Error).message
|
||||||
|
|
||||||
|
// Update block with error
|
||||||
|
editor.updateShape<IWorkflowBlock>({
|
||||||
|
id: blockId,
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
props: {
|
||||||
|
executionState: 'error',
|
||||||
|
executionError: errorMessage,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: errorMessage,
|
||||||
|
outputs: {},
|
||||||
|
executionTime: Date.now() - startTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gather input values from upstream connected blocks
|
||||||
|
*/
|
||||||
|
async function gatherBlockInputs(
|
||||||
|
editor: Editor,
|
||||||
|
blockId: TLShapeId
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const inputs: Record<string, unknown> = {}
|
||||||
|
const bindings = getBlockInputBindings(editor, blockId)
|
||||||
|
|
||||||
|
for (const binding of bindings) {
|
||||||
|
const sourceShape = editor.getShape(binding.fromShapeId) as IWorkflowBlock | undefined
|
||||||
|
if (sourceShape && sourceShape.type === 'WorkflowBlock') {
|
||||||
|
const outputValue = sourceShape.props.outputValues?.[binding.fromPortId]
|
||||||
|
if (outputValue !== undefined) {
|
||||||
|
inputs[binding.toPortId] = outputValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return inputs
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update block execution state
|
||||||
|
*/
|
||||||
|
function updateBlockState(
|
||||||
|
editor: Editor,
|
||||||
|
blockId: TLShapeId,
|
||||||
|
state: ExecutionState,
|
||||||
|
error?: string
|
||||||
|
): void {
|
||||||
|
editor.updateShape<IWorkflowBlock>({
|
||||||
|
id: blockId,
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
props: {
|
||||||
|
executionState: state,
|
||||||
|
executionError: error,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute an entire workflow starting from trigger blocks
|
||||||
|
*/
|
||||||
|
export async function executeWorkflow(
|
||||||
|
editor: Editor,
|
||||||
|
options: {
|
||||||
|
startBlockId?: TLShapeId
|
||||||
|
signal?: AbortSignal
|
||||||
|
onProgress?: (completed: number, total: number) => void
|
||||||
|
} = {}
|
||||||
|
): Promise<{
|
||||||
|
success: boolean
|
||||||
|
results: Map<TLShapeId, BlockExecutionResult>
|
||||||
|
error?: string
|
||||||
|
}> {
|
||||||
|
const { signal, onProgress } = options
|
||||||
|
|
||||||
|
// Validate workflow first
|
||||||
|
const { blocks, connections } = buildWorkflowGraph(editor)
|
||||||
|
const validation = validateWorkflow(blocks, connections)
|
||||||
|
|
||||||
|
if (!validation.valid) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
results: new Map(),
|
||||||
|
error: validation.errors.map(e => e.message).join('; '),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get execution order
|
||||||
|
const executionOrder = getExecutionOrder(editor)
|
||||||
|
|
||||||
|
// If start block specified, only execute that subgraph
|
||||||
|
let blocksToExecute = executionOrder
|
||||||
|
if (options.startBlockId) {
|
||||||
|
const startIndex = executionOrder.indexOf(options.startBlockId)
|
||||||
|
if (startIndex >= 0) {
|
||||||
|
blocksToExecute = executionOrder.slice(startIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = new Map<TLShapeId, BlockExecutionResult>()
|
||||||
|
let completed = 0
|
||||||
|
|
||||||
|
// Reset all blocks to idle
|
||||||
|
for (const blockId of blocksToExecute) {
|
||||||
|
updateBlockState(editor, blockId, 'idle')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute blocks in order
|
||||||
|
for (const blockId of blocksToExecute) {
|
||||||
|
// Check for abort
|
||||||
|
if (signal?.aborted) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
results,
|
||||||
|
error: 'Execution aborted',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await executeBlock(editor, blockId)
|
||||||
|
results.set(blockId, result)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
// Stop on first error
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
results,
|
||||||
|
error: `Block execution failed: ${result.error}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
completed++
|
||||||
|
onProgress?.(completed, blocksToExecute.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
results,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset all workflow blocks to idle state
|
||||||
|
*/
|
||||||
|
export function resetWorkflow(editor: Editor): void {
|
||||||
|
const blocks = editor.getCurrentPageShapes().filter(s => s.type === 'WorkflowBlock')
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
editor.updateShape<IWorkflowBlock>({
|
||||||
|
id: block.id,
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
props: {
|
||||||
|
executionState: 'idle',
|
||||||
|
executionError: undefined,
|
||||||
|
outputValues: {},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Event Listener for Manual Block Execution
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup event listener for workflow:execute-block events
|
||||||
|
*/
|
||||||
|
export function setupBlockExecutionListener(editor: Editor): () => void {
|
||||||
|
const handler = async (event: CustomEvent<{ blockId: TLShapeId }>) => {
|
||||||
|
const { blockId } = event.detail
|
||||||
|
await executeBlock(editor, blockId)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('workflow:execute-block', handler as EventListener)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('workflow:execute-block', handler as EventListener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,468 @@
|
||||||
|
/**
|
||||||
|
* Port Binding Utilities
|
||||||
|
*
|
||||||
|
* Handles the connection between workflow blocks via arrows.
|
||||||
|
* Stores port metadata in arrow meta and provides utilities for
|
||||||
|
* querying connections between blocks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Editor,
|
||||||
|
TLArrowBinding,
|
||||||
|
TLArrowShape,
|
||||||
|
TLShape,
|
||||||
|
TLShapeId,
|
||||||
|
Vec,
|
||||||
|
} from 'tldraw'
|
||||||
|
import { PortBinding } from './types'
|
||||||
|
import { getBlockDefinition, hasBlockDefinition } from './blockRegistry'
|
||||||
|
import { validateConnection } from './validation'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Port Position Constants
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const PORT_SIZE = 12
|
||||||
|
const PORT_SPACING = 28
|
||||||
|
const HEADER_HEIGHT = 36
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Port Position Calculation
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the position of a port in world coordinates
|
||||||
|
*/
|
||||||
|
export function getPortWorldPosition(
|
||||||
|
editor: Editor,
|
||||||
|
shapeId: TLShapeId,
|
||||||
|
portId: string,
|
||||||
|
direction: 'input' | 'output'
|
||||||
|
): Vec | null {
|
||||||
|
const shape = editor.getShape(shapeId)
|
||||||
|
if (!shape || shape.type !== 'WorkflowBlock') return null
|
||||||
|
|
||||||
|
const props = shape.props as { w: number; blockType: string }
|
||||||
|
if (!hasBlockDefinition(props.blockType)) return null
|
||||||
|
|
||||||
|
const definition = getBlockDefinition(props.blockType)
|
||||||
|
const ports = direction === 'input' ? definition.inputs : definition.outputs
|
||||||
|
const portIndex = ports.findIndex(p => p.id === portId)
|
||||||
|
|
||||||
|
if (portIndex === -1) return null
|
||||||
|
|
||||||
|
// Calculate local position
|
||||||
|
const localX = direction === 'input' ? 0 : props.w
|
||||||
|
const localY = HEADER_HEIGHT + 12 + portIndex * PORT_SPACING + PORT_SIZE / 2
|
||||||
|
|
||||||
|
// Transform to world coordinates
|
||||||
|
const point = editor.getShapePageTransform(shapeId)?.applyToPoint({ x: localX, y: localY })
|
||||||
|
return point ? new Vec(point.x, point.y) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the closest port to a given point on a workflow block
|
||||||
|
*/
|
||||||
|
export function findClosestPort(
|
||||||
|
editor: Editor,
|
||||||
|
shapeId: TLShapeId,
|
||||||
|
point: Vec,
|
||||||
|
direction: 'input' | 'output'
|
||||||
|
): { portId: string; distance: number } | null {
|
||||||
|
const shape = editor.getShape(shapeId)
|
||||||
|
if (!shape || shape.type !== 'WorkflowBlock') return null
|
||||||
|
|
||||||
|
const props = shape.props as { blockType: string }
|
||||||
|
if (!hasBlockDefinition(props.blockType)) return null
|
||||||
|
|
||||||
|
const definition = getBlockDefinition(props.blockType)
|
||||||
|
const ports = direction === 'input' ? definition.inputs : definition.outputs
|
||||||
|
|
||||||
|
let closestPort: { portId: string; distance: number } | null = null
|
||||||
|
|
||||||
|
for (const port of ports) {
|
||||||
|
const portPos = getPortWorldPosition(editor, shapeId, port.id, direction)
|
||||||
|
if (!portPos) continue
|
||||||
|
|
||||||
|
const distance = Vec.Dist(point, portPos)
|
||||||
|
if (!closestPort || distance < closestPort.distance) {
|
||||||
|
closestPort = { portId: port.id, distance }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return closestPort
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Arrow Port Metadata
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arrow meta type for workflow connections
|
||||||
|
*/
|
||||||
|
interface WorkflowArrowMeta {
|
||||||
|
fromPortId?: string
|
||||||
|
toPortId?: string
|
||||||
|
validated?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get port binding from an arrow shape
|
||||||
|
*/
|
||||||
|
export function getPortBinding(
|
||||||
|
editor: Editor,
|
||||||
|
arrowId: TLShapeId
|
||||||
|
): PortBinding | null {
|
||||||
|
const arrow = editor.getShape(arrowId) as TLArrowShape | undefined
|
||||||
|
if (!arrow || arrow.type !== 'arrow') return null
|
||||||
|
|
||||||
|
const bindings = editor.getBindingsInvolvingShape<TLArrowBinding>(arrowId)
|
||||||
|
if (!bindings || bindings.length !== 2) return null
|
||||||
|
|
||||||
|
// Find start and end bindings
|
||||||
|
const startBinding = bindings.find(b => b.props.terminal === 'start')
|
||||||
|
const endBinding = bindings.find(b => b.props.terminal === 'end')
|
||||||
|
|
||||||
|
if (!startBinding || !endBinding) return null
|
||||||
|
|
||||||
|
// Get meta from arrow
|
||||||
|
const meta = (arrow.meta || {}) as WorkflowArrowMeta
|
||||||
|
|
||||||
|
return {
|
||||||
|
fromShapeId: startBinding.toId,
|
||||||
|
fromPortId: meta.fromPortId || 'output',
|
||||||
|
toShapeId: endBinding.toId,
|
||||||
|
toPortId: meta.toPortId || 'input',
|
||||||
|
arrowId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set port binding metadata on an arrow
|
||||||
|
*/
|
||||||
|
export function setPortBinding(
|
||||||
|
editor: Editor,
|
||||||
|
arrowId: TLShapeId,
|
||||||
|
fromPortId: string,
|
||||||
|
toPortId: string
|
||||||
|
): void {
|
||||||
|
const arrow = editor.getShape(arrowId) as TLArrowShape | undefined
|
||||||
|
if (!arrow || arrow.type !== 'arrow') return
|
||||||
|
|
||||||
|
editor.updateShape({
|
||||||
|
id: arrowId,
|
||||||
|
type: 'arrow',
|
||||||
|
meta: {
|
||||||
|
...arrow.meta,
|
||||||
|
fromPortId,
|
||||||
|
toPortId,
|
||||||
|
validated: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear port binding metadata from an arrow
|
||||||
|
*/
|
||||||
|
export function clearPortBinding(
|
||||||
|
editor: Editor,
|
||||||
|
arrowId: TLShapeId
|
||||||
|
): void {
|
||||||
|
const arrow = editor.getShape(arrowId) as TLArrowShape | undefined
|
||||||
|
if (!arrow || arrow.type !== 'arrow') return
|
||||||
|
|
||||||
|
const meta = { ...arrow.meta } as WorkflowArrowMeta
|
||||||
|
delete meta.fromPortId
|
||||||
|
delete meta.toPortId
|
||||||
|
delete meta.validated
|
||||||
|
|
||||||
|
editor.updateShape({
|
||||||
|
id: arrowId,
|
||||||
|
type: 'arrow',
|
||||||
|
meta,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Connection Queries
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all input bindings for a workflow block
|
||||||
|
*/
|
||||||
|
export function getBlockInputBindings(
|
||||||
|
editor: Editor,
|
||||||
|
shapeId: TLShapeId
|
||||||
|
): PortBinding[] {
|
||||||
|
const bindings: PortBinding[] = []
|
||||||
|
|
||||||
|
// Get all arrows ending at this shape
|
||||||
|
const arrowBindings = editor.getBindingsToShape<TLArrowBinding>(shapeId, 'arrow')
|
||||||
|
const incomingArrows = arrowBindings
|
||||||
|
.filter(b => b.props.terminal === 'end')
|
||||||
|
.map(b => b.fromId)
|
||||||
|
|
||||||
|
for (const arrowId of incomingArrows) {
|
||||||
|
const portBinding = getPortBinding(editor, arrowId)
|
||||||
|
if (portBinding) {
|
||||||
|
bindings.push(portBinding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bindings
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all output bindings from a workflow block
|
||||||
|
*/
|
||||||
|
export function getBlockOutputBindings(
|
||||||
|
editor: Editor,
|
||||||
|
shapeId: TLShapeId
|
||||||
|
): PortBinding[] {
|
||||||
|
const bindings: PortBinding[] = []
|
||||||
|
|
||||||
|
// Get all arrows starting from this shape
|
||||||
|
const arrowBindings = editor.getBindingsToShape<TLArrowBinding>(shapeId, 'arrow')
|
||||||
|
const outgoingArrows = arrowBindings
|
||||||
|
.filter(b => b.props.terminal === 'start')
|
||||||
|
.map(b => b.fromId)
|
||||||
|
|
||||||
|
for (const arrowId of outgoingArrows) {
|
||||||
|
const portBinding = getPortBinding(editor, arrowId)
|
||||||
|
if (portBinding) {
|
||||||
|
bindings.push(portBinding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bindings
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get bindings for a specific input port
|
||||||
|
*/
|
||||||
|
export function getInputPortBindings(
|
||||||
|
editor: Editor,
|
||||||
|
shapeId: TLShapeId,
|
||||||
|
portId: string
|
||||||
|
): PortBinding[] {
|
||||||
|
return getBlockInputBindings(editor, shapeId).filter(b => b.toPortId === portId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get bindings for a specific output port
|
||||||
|
*/
|
||||||
|
export function getOutputPortBindings(
|
||||||
|
editor: Editor,
|
||||||
|
shapeId: TLShapeId,
|
||||||
|
portId: string
|
||||||
|
): PortBinding[] {
|
||||||
|
return getBlockOutputBindings(editor, shapeId).filter(b => b.fromPortId === portId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a specific port is connected
|
||||||
|
*/
|
||||||
|
export function isPortConnected(
|
||||||
|
editor: Editor,
|
||||||
|
shapeId: TLShapeId,
|
||||||
|
portId: string,
|
||||||
|
direction: 'input' | 'output'
|
||||||
|
): boolean {
|
||||||
|
const bindings = direction === 'input'
|
||||||
|
? getBlockInputBindings(editor, shapeId)
|
||||||
|
: getBlockOutputBindings(editor, shapeId)
|
||||||
|
|
||||||
|
const portKey = direction === 'input' ? 'toPortId' : 'fromPortId'
|
||||||
|
return bindings.some(b => b[portKey] === portId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all connected ports for a shape
|
||||||
|
*/
|
||||||
|
export function getConnectedPorts(
|
||||||
|
editor: Editor,
|
||||||
|
shapeId: TLShapeId
|
||||||
|
): { inputs: string[]; outputs: string[] } {
|
||||||
|
const inputBindings = getBlockInputBindings(editor, shapeId)
|
||||||
|
const outputBindings = getBlockOutputBindings(editor, shapeId)
|
||||||
|
|
||||||
|
return {
|
||||||
|
inputs: [...new Set(inputBindings.map(b => b.toPortId))],
|
||||||
|
outputs: [...new Set(outputBindings.map(b => b.fromPortId))],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Workflow Graph Utilities
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get upstream blocks (blocks that feed into this one)
|
||||||
|
*/
|
||||||
|
export function getUpstreamBlocks(
|
||||||
|
editor: Editor,
|
||||||
|
shapeId: TLShapeId
|
||||||
|
): TLShapeId[] {
|
||||||
|
const inputBindings = getBlockInputBindings(editor, shapeId)
|
||||||
|
return [...new Set(inputBindings.map(b => b.fromShapeId))]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get downstream blocks (blocks that this one feeds into)
|
||||||
|
*/
|
||||||
|
export function getDownstreamBlocks(
|
||||||
|
editor: Editor,
|
||||||
|
shapeId: TLShapeId
|
||||||
|
): TLShapeId[] {
|
||||||
|
const outputBindings = getBlockOutputBindings(editor, shapeId)
|
||||||
|
return [...new Set(outputBindings.map(b => b.toShapeId))]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all workflow blocks in the editor
|
||||||
|
*/
|
||||||
|
export function getAllWorkflowBlocks(editor: Editor): TLShape[] {
|
||||||
|
return editor.getCurrentPageShapes().filter(s => s.type === 'WorkflowBlock')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all arrows connecting workflow blocks
|
||||||
|
*/
|
||||||
|
export function getWorkflowArrows(editor: Editor): TLArrowShape[] {
|
||||||
|
const workflowBlockIds = new Set(
|
||||||
|
getAllWorkflowBlocks(editor).map(s => s.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
return (editor.getCurrentPageShapes().filter(s => s.type === 'arrow') as TLArrowShape[])
|
||||||
|
.filter(arrow => {
|
||||||
|
const binding = getPortBinding(editor, arrow.id)
|
||||||
|
return binding &&
|
||||||
|
workflowBlockIds.has(binding.fromShapeId) &&
|
||||||
|
workflowBlockIds.has(binding.toShapeId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a workflow graph from the current canvas
|
||||||
|
*/
|
||||||
|
export function buildWorkflowGraph(editor: Editor): {
|
||||||
|
blocks: Array<{ id: TLShapeId; blockType: string; config: Record<string, unknown> }>
|
||||||
|
connections: PortBinding[]
|
||||||
|
} {
|
||||||
|
const blocks = getAllWorkflowBlocks(editor).map(shape => {
|
||||||
|
const props = shape.props as { blockType: string; blockConfig: Record<string, unknown> }
|
||||||
|
return {
|
||||||
|
id: shape.id,
|
||||||
|
blockType: props.blockType,
|
||||||
|
config: props.blockConfig || {},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const connections: PortBinding[] = []
|
||||||
|
for (const arrow of getWorkflowArrows(editor)) {
|
||||||
|
const binding = getPortBinding(editor, arrow.id)
|
||||||
|
if (binding) {
|
||||||
|
connections.push(binding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { blocks, connections }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get topologically sorted execution order
|
||||||
|
*/
|
||||||
|
export function getExecutionOrder(editor: Editor): TLShapeId[] {
|
||||||
|
const { blocks, connections } = buildWorkflowGraph(editor)
|
||||||
|
|
||||||
|
// Build adjacency list
|
||||||
|
const inDegree = new Map<TLShapeId, number>()
|
||||||
|
const outEdges = new Map<TLShapeId, TLShapeId[]>()
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
inDegree.set(block.id, 0)
|
||||||
|
outEdges.set(block.id, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const conn of connections) {
|
||||||
|
inDegree.set(conn.toShapeId, (inDegree.get(conn.toShapeId) || 0) + 1)
|
||||||
|
outEdges.get(conn.fromShapeId)?.push(conn.toShapeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kahn's algorithm
|
||||||
|
const queue: TLShapeId[] = []
|
||||||
|
const result: TLShapeId[] = []
|
||||||
|
|
||||||
|
for (const [id, degree] of inDegree) {
|
||||||
|
if (degree === 0) queue.push(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const node = queue.shift()!
|
||||||
|
result.push(node)
|
||||||
|
|
||||||
|
for (const neighbor of outEdges.get(node) || []) {
|
||||||
|
const newDegree = (inDegree.get(neighbor) || 1) - 1
|
||||||
|
inDegree.set(neighbor, newDegree)
|
||||||
|
if (newDegree === 0) queue.push(neighbor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Connection Validation Helpers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate if a potential connection is valid
|
||||||
|
*/
|
||||||
|
export function canCreateConnection(
|
||||||
|
editor: Editor,
|
||||||
|
fromShapeId: TLShapeId,
|
||||||
|
fromPortId: string,
|
||||||
|
toShapeId: TLShapeId,
|
||||||
|
toPortId: string
|
||||||
|
): { valid: boolean; error?: string } {
|
||||||
|
const fromShape = editor.getShape(fromShapeId)
|
||||||
|
const toShape = editor.getShape(toShapeId)
|
||||||
|
|
||||||
|
if (!fromShape || fromShape.type !== 'WorkflowBlock') {
|
||||||
|
return { valid: false, error: 'Source is not a workflow block' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!toShape || toShape.type !== 'WorkflowBlock') {
|
||||||
|
return { valid: false, error: 'Target is not a workflow block' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromProps = fromShape.props as { blockType: string }
|
||||||
|
const toProps = toShape.props as { blockType: string }
|
||||||
|
|
||||||
|
const result = validateConnection(
|
||||||
|
fromProps.blockType,
|
||||||
|
fromPortId,
|
||||||
|
toProps.blockType,
|
||||||
|
toPortId
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!result.valid) {
|
||||||
|
return { valid: false, error: result.errors[0]?.message }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get block type from a shape ID
|
||||||
|
*/
|
||||||
|
export function getBlockType(
|
||||||
|
editor: Editor,
|
||||||
|
shapeId: TLShapeId
|
||||||
|
): string | undefined {
|
||||||
|
const shape = editor.getShape(shapeId)
|
||||||
|
if (!shape || shape.type !== 'WorkflowBlock') return undefined
|
||||||
|
|
||||||
|
const props = shape.props as { blockType: string }
|
||||||
|
return props.blockType
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,659 @@
|
||||||
|
/**
|
||||||
|
* Workflow Serialization
|
||||||
|
*
|
||||||
|
* Export and import workflows as JSON for sharing, backup,
|
||||||
|
* and loading templates. Compatible with Flowy JSON format.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Editor, TLShapeId, createShapeId } from 'tldraw'
|
||||||
|
import { PortBinding, WorkflowBlockProps } from './types'
|
||||||
|
import { IWorkflowBlock } from '@/shapes/WorkflowBlockShapeUtil'
|
||||||
|
import {
|
||||||
|
buildWorkflowGraph,
|
||||||
|
getPortBinding,
|
||||||
|
setPortBinding,
|
||||||
|
getAllWorkflowBlocks,
|
||||||
|
} from './portBindings'
|
||||||
|
import { hasBlockDefinition, getBlockDefinition } from './blockRegistry'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Serialized Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialized block format
|
||||||
|
*/
|
||||||
|
interface SerializedBlock {
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
blockType: string
|
||||||
|
blockConfig: Record<string, unknown>
|
||||||
|
inputValues?: Record<string, unknown>
|
||||||
|
tags?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialized connection format
|
||||||
|
*/
|
||||||
|
interface SerializedConnection {
|
||||||
|
id: string
|
||||||
|
from: {
|
||||||
|
blockId: string
|
||||||
|
portId: string
|
||||||
|
}
|
||||||
|
to: {
|
||||||
|
blockId: string
|
||||||
|
portId: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full workflow export format
|
||||||
|
*/
|
||||||
|
export interface SerializedWorkflow {
|
||||||
|
version: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
createdAt: string
|
||||||
|
blocks: SerializedBlock[]
|
||||||
|
connections: SerializedConnection[]
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Export Functions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export a workflow to JSON format
|
||||||
|
*/
|
||||||
|
export function exportWorkflow(
|
||||||
|
editor: Editor,
|
||||||
|
options: {
|
||||||
|
name?: string
|
||||||
|
description?: string
|
||||||
|
includeInputValues?: boolean
|
||||||
|
blockIds?: TLShapeId[]
|
||||||
|
} = {}
|
||||||
|
): SerializedWorkflow {
|
||||||
|
const {
|
||||||
|
name = 'Untitled Workflow',
|
||||||
|
description,
|
||||||
|
includeInputValues = false,
|
||||||
|
blockIds,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
// Get blocks to export
|
||||||
|
let blocks = getAllWorkflowBlocks(editor) as IWorkflowBlock[]
|
||||||
|
|
||||||
|
if (blockIds && blockIds.length > 0) {
|
||||||
|
const blockIdSet = new Set(blockIds)
|
||||||
|
blocks = blocks.filter(b => blockIdSet.has(b.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize blocks
|
||||||
|
const serializedBlocks: SerializedBlock[] = blocks.map(block => ({
|
||||||
|
id: block.id,
|
||||||
|
type: block.type,
|
||||||
|
x: block.x,
|
||||||
|
y: block.y,
|
||||||
|
w: block.props.w,
|
||||||
|
h: block.props.h,
|
||||||
|
blockType: block.props.blockType,
|
||||||
|
blockConfig: block.props.blockConfig,
|
||||||
|
inputValues: includeInputValues ? block.props.inputValues : undefined,
|
||||||
|
tags: block.props.tags,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Get connections between exported blocks
|
||||||
|
const blockIdSet = new Set(blocks.map(b => b.id))
|
||||||
|
const connections: SerializedConnection[] = []
|
||||||
|
|
||||||
|
// Find all arrows connecting our blocks
|
||||||
|
const arrows = editor.getCurrentPageShapes().filter(s => s.type === 'arrow')
|
||||||
|
for (const arrow of arrows) {
|
||||||
|
const binding = getPortBinding(editor, arrow.id)
|
||||||
|
if (
|
||||||
|
binding &&
|
||||||
|
blockIdSet.has(binding.fromShapeId) &&
|
||||||
|
blockIdSet.has(binding.toShapeId)
|
||||||
|
) {
|
||||||
|
connections.push({
|
||||||
|
id: arrow.id,
|
||||||
|
from: {
|
||||||
|
blockId: binding.fromShapeId,
|
||||||
|
portId: binding.fromPortId,
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
blockId: binding.toShapeId,
|
||||||
|
portId: binding.toPortId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: '1.0.0',
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
blocks: serializedBlocks,
|
||||||
|
connections,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export workflow to JSON string
|
||||||
|
*/
|
||||||
|
export function exportWorkflowToJSON(
|
||||||
|
editor: Editor,
|
||||||
|
options?: Parameters<typeof exportWorkflow>[1]
|
||||||
|
): string {
|
||||||
|
return JSON.stringify(exportWorkflow(editor, options), null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download workflow as JSON file
|
||||||
|
*/
|
||||||
|
export function downloadWorkflow(
|
||||||
|
editor: Editor,
|
||||||
|
options?: Parameters<typeof exportWorkflow>[1]
|
||||||
|
): void {
|
||||||
|
const workflow = exportWorkflow(editor, options)
|
||||||
|
const json = JSON.stringify(workflow, null, 2)
|
||||||
|
const blob = new Blob([json], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
|
||||||
|
const filename = `${workflow.name.toLowerCase().replace(/\s+/g, '-')}-${Date.now()}.json`
|
||||||
|
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Import Functions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a workflow from JSON format
|
||||||
|
*/
|
||||||
|
export function importWorkflow(
|
||||||
|
editor: Editor,
|
||||||
|
workflow: SerializedWorkflow,
|
||||||
|
options: {
|
||||||
|
offset?: { x: number; y: number }
|
||||||
|
preserveIds?: boolean
|
||||||
|
} = {}
|
||||||
|
): {
|
||||||
|
success: boolean
|
||||||
|
blockIds: TLShapeId[]
|
||||||
|
errors: string[]
|
||||||
|
} {
|
||||||
|
const { offset = { x: 0, y: 0 }, preserveIds = false } = options
|
||||||
|
const errors: string[] = []
|
||||||
|
const blockIdMap = new Map<string, TLShapeId>()
|
||||||
|
const newBlockIds: TLShapeId[] = []
|
||||||
|
|
||||||
|
// Calculate bounds for centering
|
||||||
|
let minX = Infinity
|
||||||
|
let minY = Infinity
|
||||||
|
|
||||||
|
for (const block of workflow.blocks) {
|
||||||
|
minX = Math.min(minX, block.x)
|
||||||
|
minY = Math.min(minY, block.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create blocks
|
||||||
|
for (const block of workflow.blocks) {
|
||||||
|
// Validate block type
|
||||||
|
if (!hasBlockDefinition(block.blockType)) {
|
||||||
|
errors.push(`Unknown block type: ${block.blockType}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const definition = getBlockDefinition(block.blockType)
|
||||||
|
|
||||||
|
// Generate or preserve ID
|
||||||
|
const newId = preserveIds && block.id.startsWith('shape:')
|
||||||
|
? block.id as TLShapeId
|
||||||
|
: createShapeId()
|
||||||
|
|
||||||
|
blockIdMap.set(block.id, newId)
|
||||||
|
|
||||||
|
// Calculate position with offset
|
||||||
|
const x = block.x - minX + offset.x
|
||||||
|
const y = block.y - minY + offset.y
|
||||||
|
|
||||||
|
// Calculate height based on ports
|
||||||
|
const maxPorts = Math.max(definition.inputs.length, definition.outputs.length)
|
||||||
|
const height = Math.max(block.h, 36 + 24 + maxPorts * 28 + 60)
|
||||||
|
|
||||||
|
// Create the block
|
||||||
|
try {
|
||||||
|
editor.createShape<IWorkflowBlock>({
|
||||||
|
id: newId,
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
props: {
|
||||||
|
w: block.w,
|
||||||
|
h: height,
|
||||||
|
blockType: block.blockType,
|
||||||
|
blockConfig: block.blockConfig || {},
|
||||||
|
inputValues: block.inputValues || {},
|
||||||
|
outputValues: {},
|
||||||
|
executionState: 'idle',
|
||||||
|
tags: block.tags || ['workflow'],
|
||||||
|
pinnedToView: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
newBlockIds.push(newId)
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(`Failed to create block: ${(error as Error).message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create connections (arrows)
|
||||||
|
for (const conn of workflow.connections) {
|
||||||
|
const fromId = blockIdMap.get(conn.from.blockId)
|
||||||
|
const toId = blockIdMap.get(conn.to.blockId)
|
||||||
|
|
||||||
|
if (!fromId || !toId) {
|
||||||
|
errors.push(`Connection references missing block`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromBlock = editor.getShape(fromId) as IWorkflowBlock | undefined
|
||||||
|
const toBlock = editor.getShape(toId) as IWorkflowBlock | undefined
|
||||||
|
|
||||||
|
if (!fromBlock || !toBlock) continue
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create arrow between blocks
|
||||||
|
const arrowId = createShapeId()
|
||||||
|
|
||||||
|
// Get port positions for arrow endpoints
|
||||||
|
const fromDef = getBlockDefinition(fromBlock.props.blockType)
|
||||||
|
const toDef = getBlockDefinition(toBlock.props.blockType)
|
||||||
|
|
||||||
|
const fromPortIndex = fromDef.outputs.findIndex(p => p.id === conn.from.portId)
|
||||||
|
const toPortIndex = toDef.inputs.findIndex(p => p.id === conn.to.portId)
|
||||||
|
|
||||||
|
if (fromPortIndex === -1 || toPortIndex === -1) {
|
||||||
|
errors.push(`Invalid port in connection`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate port positions
|
||||||
|
const fromX = fromBlock.x + fromBlock.props.w
|
||||||
|
const fromY = fromBlock.y + 36 + 12 + fromPortIndex * 28 + 6
|
||||||
|
|
||||||
|
const toX = toBlock.x
|
||||||
|
const toY = toBlock.y + 36 + 12 + toPortIndex * 28 + 6
|
||||||
|
|
||||||
|
// Create arrow with bindings
|
||||||
|
editor.createShape({
|
||||||
|
id: arrowId,
|
||||||
|
type: 'arrow',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
props: {
|
||||||
|
start: { x: fromX, y: fromY },
|
||||||
|
end: { x: toX, y: toY },
|
||||||
|
color: 'black',
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
fromPortId: conn.from.portId,
|
||||||
|
toPortId: conn.to.portId,
|
||||||
|
validated: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create bindings for the arrow
|
||||||
|
editor.createBinding({
|
||||||
|
type: 'arrow',
|
||||||
|
fromId: arrowId,
|
||||||
|
toId: fromId,
|
||||||
|
props: {
|
||||||
|
terminal: 'start',
|
||||||
|
normalizedAnchor: { x: 1, y: 0.5 },
|
||||||
|
isPrecise: false,
|
||||||
|
isExact: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
editor.createBinding({
|
||||||
|
type: 'arrow',
|
||||||
|
fromId: arrowId,
|
||||||
|
toId: toId,
|
||||||
|
props: {
|
||||||
|
terminal: 'end',
|
||||||
|
normalizedAnchor: { x: 0, y: 0.5 },
|
||||||
|
isPrecise: false,
|
||||||
|
isExact: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(`Failed to create connection: ${(error as Error).message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: errors.length === 0,
|
||||||
|
blockIds: newBlockIds,
|
||||||
|
errors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import workflow from JSON string
|
||||||
|
*/
|
||||||
|
export function importWorkflowFromJSON(
|
||||||
|
editor: Editor,
|
||||||
|
json: string,
|
||||||
|
options?: Parameters<typeof importWorkflow>[2]
|
||||||
|
): ReturnType<typeof importWorkflow> {
|
||||||
|
try {
|
||||||
|
const workflow = JSON.parse(json) as SerializedWorkflow
|
||||||
|
return importWorkflow(editor, workflow, options)
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
blockIds: [],
|
||||||
|
errors: [`Invalid JSON: ${(error as Error).message}`],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load workflow from file
|
||||||
|
*/
|
||||||
|
export async function loadWorkflowFromFile(
|
||||||
|
editor: Editor,
|
||||||
|
file: File,
|
||||||
|
options?: Parameters<typeof importWorkflow>[2]
|
||||||
|
): Promise<ReturnType<typeof importWorkflow>> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const json = e.target?.result as string
|
||||||
|
resolve(importWorkflowFromJSON(editor, json, options))
|
||||||
|
}
|
||||||
|
|
||||||
|
reader.onerror = () => {
|
||||||
|
resolve({
|
||||||
|
success: false,
|
||||||
|
blockIds: [],
|
||||||
|
errors: ['Failed to read file'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
reader.readAsText(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Workflow Templates
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-built workflow templates
|
||||||
|
*/
|
||||||
|
export const WORKFLOW_TEMPLATES: Record<string, SerializedWorkflow> = {
|
||||||
|
'api-transform-display': {
|
||||||
|
version: '1.0.0',
|
||||||
|
name: 'API Transform Display',
|
||||||
|
description: 'Fetch data from an API, transform it, and display the result',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
id: 'block-1',
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
w: 220,
|
||||||
|
h: 180,
|
||||||
|
blockType: 'trigger.manual',
|
||||||
|
blockConfig: {},
|
||||||
|
tags: ['workflow', 'trigger'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-2',
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
x: 400,
|
||||||
|
y: 100,
|
||||||
|
w: 220,
|
||||||
|
h: 200,
|
||||||
|
blockType: 'action.http',
|
||||||
|
blockConfig: { url: 'https://api.example.com/data', method: 'GET' },
|
||||||
|
tags: ['workflow', 'action'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-3',
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
x: 700,
|
||||||
|
y: 100,
|
||||||
|
w: 220,
|
||||||
|
h: 180,
|
||||||
|
blockType: 'transformer.jsonParse',
|
||||||
|
blockConfig: {},
|
||||||
|
tags: ['workflow', 'transformer'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-4',
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
x: 1000,
|
||||||
|
y: 100,
|
||||||
|
w: 220,
|
||||||
|
h: 180,
|
||||||
|
blockType: 'output.display',
|
||||||
|
blockConfig: { format: 'json' },
|
||||||
|
tags: ['workflow', 'output'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connections: [
|
||||||
|
{
|
||||||
|
id: 'conn-1',
|
||||||
|
from: { blockId: 'block-1', portId: 'timestamp' },
|
||||||
|
to: { blockId: 'block-2', portId: 'trigger' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-2',
|
||||||
|
from: { blockId: 'block-2', portId: 'response' },
|
||||||
|
to: { blockId: 'block-3', portId: 'input' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-3',
|
||||||
|
from: { blockId: 'block-3', portId: 'output' },
|
||||||
|
to: { blockId: 'block-4', portId: 'value' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
'llm-chain': {
|
||||||
|
version: '1.0.0',
|
||||||
|
name: 'LLM Chain',
|
||||||
|
description: 'Chain multiple LLM prompts together',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
id: 'block-1',
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
w: 220,
|
||||||
|
h: 180,
|
||||||
|
blockType: 'trigger.manual',
|
||||||
|
blockConfig: {},
|
||||||
|
tags: ['workflow', 'trigger'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-2',
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
x: 400,
|
||||||
|
y: 100,
|
||||||
|
w: 220,
|
||||||
|
h: 200,
|
||||||
|
blockType: 'ai.llm',
|
||||||
|
blockConfig: { systemPrompt: 'You are a helpful assistant.' },
|
||||||
|
inputValues: { prompt: 'Summarize the following topic:' },
|
||||||
|
tags: ['workflow', 'ai'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-3',
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
x: 700,
|
||||||
|
y: 100,
|
||||||
|
w: 220,
|
||||||
|
h: 200,
|
||||||
|
blockType: 'ai.llm',
|
||||||
|
blockConfig: { systemPrompt: 'You are a creative writer.' },
|
||||||
|
inputValues: { prompt: 'Expand on this summary:' },
|
||||||
|
tags: ['workflow', 'ai'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-4',
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
x: 1000,
|
||||||
|
y: 100,
|
||||||
|
w: 220,
|
||||||
|
h: 180,
|
||||||
|
blockType: 'output.display',
|
||||||
|
blockConfig: { format: 'text' },
|
||||||
|
tags: ['workflow', 'output'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connections: [
|
||||||
|
{
|
||||||
|
id: 'conn-1',
|
||||||
|
from: { blockId: 'block-1', portId: 'timestamp' },
|
||||||
|
to: { blockId: 'block-2', portId: 'trigger' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-2',
|
||||||
|
from: { blockId: 'block-2', portId: 'response' },
|
||||||
|
to: { blockId: 'block-3', portId: 'context' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-3',
|
||||||
|
from: { blockId: 'block-3', portId: 'response' },
|
||||||
|
to: { blockId: 'block-4', portId: 'value' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
'conditional-branch': {
|
||||||
|
version: '1.0.0',
|
||||||
|
name: 'Conditional Branch',
|
||||||
|
description: 'Branch workflow based on a condition',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
id: 'block-1',
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
x: 100,
|
||||||
|
y: 200,
|
||||||
|
w: 220,
|
||||||
|
h: 180,
|
||||||
|
blockType: 'trigger.manual',
|
||||||
|
blockConfig: {},
|
||||||
|
tags: ['workflow', 'trigger'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-2',
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
x: 400,
|
||||||
|
y: 200,
|
||||||
|
w: 220,
|
||||||
|
h: 200,
|
||||||
|
blockType: 'condition.if',
|
||||||
|
blockConfig: {},
|
||||||
|
tags: ['workflow', 'condition'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-3',
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
x: 700,
|
||||||
|
y: 50,
|
||||||
|
w: 220,
|
||||||
|
h: 180,
|
||||||
|
blockType: 'output.log',
|
||||||
|
blockConfig: { level: 'info' },
|
||||||
|
inputValues: { message: 'Condition was TRUE' },
|
||||||
|
tags: ['workflow', 'output'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-4',
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
x: 700,
|
||||||
|
y: 300,
|
||||||
|
w: 220,
|
||||||
|
h: 180,
|
||||||
|
blockType: 'output.log',
|
||||||
|
blockConfig: { level: 'warn' },
|
||||||
|
inputValues: { message: 'Condition was FALSE' },
|
||||||
|
tags: ['workflow', 'output'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connections: [
|
||||||
|
{
|
||||||
|
id: 'conn-1',
|
||||||
|
from: { blockId: 'block-1', portId: 'timestamp' },
|
||||||
|
to: { blockId: 'block-2', portId: 'value' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-2',
|
||||||
|
from: { blockId: 'block-2', portId: 'true' },
|
||||||
|
to: { blockId: 'block-3', portId: 'message' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-3',
|
||||||
|
from: { blockId: 'block-2', portId: 'false' },
|
||||||
|
to: { blockId: 'block-4', portId: 'message' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a workflow template
|
||||||
|
*/
|
||||||
|
export function loadTemplate(
|
||||||
|
editor: Editor,
|
||||||
|
templateId: string,
|
||||||
|
options?: Parameters<typeof importWorkflow>[2]
|
||||||
|
): ReturnType<typeof importWorkflow> {
|
||||||
|
const template = WORKFLOW_TEMPLATES[templateId]
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
blockIds: [],
|
||||||
|
errors: [`Unknown template: ${templateId}`],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return importWorkflow(editor, template, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available template names
|
||||||
|
*/
|
||||||
|
export function getTemplateNames(): Array<{ id: string; name: string; description?: string }> {
|
||||||
|
return Object.entries(WORKFLOW_TEMPLATES).map(([id, template]) => ({
|
||||||
|
id,
|
||||||
|
name: template.name,
|
||||||
|
description: template.description,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,385 @@
|
||||||
|
/**
|
||||||
|
* Workflow Builder Type Definitions
|
||||||
|
*
|
||||||
|
* Core types for the Flowy-like workflow system including:
|
||||||
|
* - Port type system for typed connections
|
||||||
|
* - Block definitions for workflow nodes
|
||||||
|
* - Execution context and results
|
||||||
|
* - Serialization format
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { TLBaseShape, TLShapeId } from 'tldraw'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Port Type System
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data types that can flow through ports
|
||||||
|
*/
|
||||||
|
export type PortDataType =
|
||||||
|
| 'text' // String data
|
||||||
|
| 'number' // Numeric data
|
||||||
|
| 'boolean' // True/false
|
||||||
|
| 'object' // JSON objects
|
||||||
|
| 'array' // Arrays of any type
|
||||||
|
| 'any' // Accepts all types
|
||||||
|
| 'file' // Binary/file data
|
||||||
|
| 'image' // Image data (base64 or URL)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base port definition shared by inputs and outputs
|
||||||
|
*/
|
||||||
|
export interface PortDefinition {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: PortDataType
|
||||||
|
required: boolean
|
||||||
|
description?: string
|
||||||
|
defaultValue?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input port - receives data from connected output ports
|
||||||
|
*/
|
||||||
|
export interface InputPort extends PortDefinition {
|
||||||
|
direction: 'input'
|
||||||
|
accepts: PortDataType[] // Types this port can receive
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Output port - sends data to connected input ports
|
||||||
|
*/
|
||||||
|
export interface OutputPort extends PortDefinition {
|
||||||
|
direction: 'output'
|
||||||
|
produces: PortDataType // Type this port outputs
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Port = InputPort | OutputPort
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Block Categories and Definitions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Categories for organizing blocks in the palette
|
||||||
|
*/
|
||||||
|
export type BlockCategory =
|
||||||
|
| 'trigger' // Manual, schedule, webhook, event
|
||||||
|
| 'action' // API calls, canvas operations
|
||||||
|
| 'condition' // If/else, switch
|
||||||
|
| 'transformer' // Data manipulation
|
||||||
|
| 'output' // Display, export, notify
|
||||||
|
| 'ai' // LLM, image gen, etc.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Category display information
|
||||||
|
*/
|
||||||
|
export interface CategoryInfo {
|
||||||
|
name: string
|
||||||
|
icon: string
|
||||||
|
color: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete block definition for the registry
|
||||||
|
*/
|
||||||
|
export interface BlockDefinition {
|
||||||
|
type: string // Unique identifier (e.g., 'action.http')
|
||||||
|
category: BlockCategory
|
||||||
|
name: string // Display name
|
||||||
|
description: string
|
||||||
|
icon: string // Emoji or icon identifier
|
||||||
|
color: string // Primary color for the block
|
||||||
|
inputs: InputPort[]
|
||||||
|
outputs: OutputPort[]
|
||||||
|
configSchema?: object // JSON Schema for block configuration
|
||||||
|
defaultConfig?: object // Default configuration values
|
||||||
|
executor?: string // Name of executor function
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Shape Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props stored on WorkflowBlock shapes
|
||||||
|
*/
|
||||||
|
export interface WorkflowBlockProps {
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
blockType: string // Reference to BlockDefinition.type
|
||||||
|
blockConfig: Record<string, unknown> // User-configured values
|
||||||
|
inputValues: Record<string, unknown> // Current input port values
|
||||||
|
outputValues: Record<string, unknown> // Current output port values
|
||||||
|
executionState: ExecutionState
|
||||||
|
executionError?: string
|
||||||
|
lastExecutedAt?: number
|
||||||
|
tags: string[]
|
||||||
|
pinnedToView: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execution state for visual feedback
|
||||||
|
*/
|
||||||
|
export type ExecutionState = 'idle' | 'running' | 'success' | 'error'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The WorkflowBlock shape type for tldraw
|
||||||
|
*/
|
||||||
|
export type WorkflowBlockShape = TLBaseShape<'WorkflowBlock', WorkflowBlockProps>
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Port Binding (Arrow Connections)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a connection between two ports via an arrow
|
||||||
|
*/
|
||||||
|
export interface PortBinding {
|
||||||
|
fromShapeId: TLShapeId
|
||||||
|
fromPortId: string
|
||||||
|
toShapeId: TLShapeId
|
||||||
|
toPortId: string
|
||||||
|
arrowId: TLShapeId
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arrow metadata for storing port binding info
|
||||||
|
*/
|
||||||
|
export interface ArrowPortMeta {
|
||||||
|
fromPortId?: string
|
||||||
|
toPortId?: string
|
||||||
|
validated?: boolean
|
||||||
|
validationError?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Execution Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context passed to block executors
|
||||||
|
*/
|
||||||
|
export interface ExecutionContext {
|
||||||
|
workflowId: string
|
||||||
|
executionId: string
|
||||||
|
mode: 'manual' | 'realtime'
|
||||||
|
startTime: number
|
||||||
|
variables: Record<string, unknown>
|
||||||
|
abortSignal?: AbortSignal
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result from executing a single block
|
||||||
|
*/
|
||||||
|
export interface BlockExecutionResult {
|
||||||
|
blockId: TLShapeId
|
||||||
|
blockType: string
|
||||||
|
status: 'success' | 'error' | 'skipped'
|
||||||
|
outputs: Record<string, unknown>
|
||||||
|
error?: string
|
||||||
|
duration: number
|
||||||
|
startTime: number
|
||||||
|
endTime: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result from executing an entire workflow
|
||||||
|
*/
|
||||||
|
export interface WorkflowExecutionResult {
|
||||||
|
workflowId: string
|
||||||
|
executionId: string
|
||||||
|
status: 'success' | 'partial' | 'error' | 'aborted'
|
||||||
|
results: BlockExecutionResult[]
|
||||||
|
totalDuration: number
|
||||||
|
startTime: number
|
||||||
|
endTime: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Callbacks (Flowy-compatible events)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event callbacks for workflow interactions
|
||||||
|
*/
|
||||||
|
export interface WorkflowCallbacks {
|
||||||
|
onBlockAdd?: (block: WorkflowBlockShape) => void
|
||||||
|
onBlockRemove?: (blockId: TLShapeId) => void
|
||||||
|
onBlockUpdate?: (block: WorkflowBlockShape) => void
|
||||||
|
onConnect?: (binding: PortBinding) => void
|
||||||
|
onDisconnect?: (binding: PortBinding) => void
|
||||||
|
onValidationError?: (binding: PortBinding, error: string) => void
|
||||||
|
onExecutionStart?: (context: ExecutionContext) => void
|
||||||
|
onBlockExecute?: (result: BlockExecutionResult) => void
|
||||||
|
onExecutionComplete?: (result: WorkflowExecutionResult) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Serialization Format
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialized block for export/import
|
||||||
|
*/
|
||||||
|
export interface SerializedBlock {
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
position: { x: number; y: number }
|
||||||
|
size: { w: number; h: number }
|
||||||
|
config: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialized connection for export/import
|
||||||
|
*/
|
||||||
|
export interface SerializedConnection {
|
||||||
|
id: string
|
||||||
|
fromBlock: string
|
||||||
|
fromPort: string
|
||||||
|
toBlock: string
|
||||||
|
toPort: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete serialized workflow
|
||||||
|
*/
|
||||||
|
export interface SerializedWorkflow {
|
||||||
|
version: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
blocks: SerializedBlock[]
|
||||||
|
connections: SerializedConnection[]
|
||||||
|
metadata?: {
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
author?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Block Executor Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function signature for block executors
|
||||||
|
*/
|
||||||
|
export type BlockExecutor = (
|
||||||
|
inputs: Record<string, unknown>,
|
||||||
|
config: Record<string, unknown>,
|
||||||
|
context: ExecutionContext
|
||||||
|
) => Promise<Record<string, unknown>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry of block executors
|
||||||
|
*/
|
||||||
|
export type BlockExecutorRegistry = Record<string, BlockExecutor>
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// UI State Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State for the workflow palette UI
|
||||||
|
*/
|
||||||
|
export interface WorkflowPaletteState {
|
||||||
|
isOpen: boolean
|
||||||
|
expandedCategory: BlockCategory | null
|
||||||
|
searchQuery: string
|
||||||
|
selectedBlockType: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State for workflow execution UI
|
||||||
|
*/
|
||||||
|
export interface WorkflowExecutionState {
|
||||||
|
isRunning: boolean
|
||||||
|
currentBlockId: TLShapeId | null
|
||||||
|
executionHistory: WorkflowExecutionResult[]
|
||||||
|
realtimeEnabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Utility Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a type is compatible with another
|
||||||
|
*/
|
||||||
|
export function isTypeCompatible(
|
||||||
|
outputType: PortDataType,
|
||||||
|
inputAccepts: PortDataType[]
|
||||||
|
): boolean {
|
||||||
|
// 'any' accepts everything
|
||||||
|
if (inputAccepts.includes('any')) return true
|
||||||
|
|
||||||
|
// Direct match
|
||||||
|
if (inputAccepts.includes(outputType)) return true
|
||||||
|
|
||||||
|
// 'any' output can go to anything
|
||||||
|
if (outputType === 'any') return true
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get color for a port data type
|
||||||
|
*/
|
||||||
|
export function getPortTypeColor(type: PortDataType): string {
|
||||||
|
const colors: Record<PortDataType, string> = {
|
||||||
|
text: '#10b981', // Green
|
||||||
|
number: '#3b82f6', // Blue
|
||||||
|
boolean: '#8b5cf6', // Purple
|
||||||
|
object: '#f59e0b', // Amber
|
||||||
|
array: '#ec4899', // Pink
|
||||||
|
any: '#6b7280', // Gray
|
||||||
|
file: '#ef4444', // Red
|
||||||
|
image: '#06b6d4', // Cyan
|
||||||
|
}
|
||||||
|
return colors[type] || colors.any
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Category information for UI
|
||||||
|
*/
|
||||||
|
export const CATEGORY_INFO: Record<BlockCategory, CategoryInfo> = {
|
||||||
|
trigger: {
|
||||||
|
name: 'Triggers',
|
||||||
|
icon: '⚡',
|
||||||
|
color: '#f59e0b',
|
||||||
|
description: 'Start workflows with triggers',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
name: 'Actions',
|
||||||
|
icon: '🔧',
|
||||||
|
color: '#3b82f6',
|
||||||
|
description: 'Perform operations and API calls',
|
||||||
|
},
|
||||||
|
condition: {
|
||||||
|
name: 'Conditions',
|
||||||
|
icon: '❓',
|
||||||
|
color: '#8b5cf6',
|
||||||
|
description: 'Branch based on conditions',
|
||||||
|
},
|
||||||
|
transformer: {
|
||||||
|
name: 'Transformers',
|
||||||
|
icon: '🔄',
|
||||||
|
color: '#10b981',
|
||||||
|
description: 'Transform and manipulate data',
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
name: 'Outputs',
|
||||||
|
icon: '📤',
|
||||||
|
color: '#ef4444',
|
||||||
|
description: 'Display and export results',
|
||||||
|
},
|
||||||
|
ai: {
|
||||||
|
name: 'AI',
|
||||||
|
icon: '🤖',
|
||||||
|
color: '#ec4899',
|
||||||
|
description: 'AI and machine learning blocks',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,466 @@
|
||||||
|
/**
|
||||||
|
* Port Validation
|
||||||
|
*
|
||||||
|
* Handles type compatibility checking between ports and validates
|
||||||
|
* workflow connections to prevent invalid data flow.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
PortDataType,
|
||||||
|
InputPort,
|
||||||
|
OutputPort,
|
||||||
|
BlockDefinition,
|
||||||
|
PortBinding,
|
||||||
|
isTypeCompatible,
|
||||||
|
} from './types'
|
||||||
|
import { getBlockDefinition, hasBlockDefinition } from './blockRegistry'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Validation Result Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface ValidationResult {
|
||||||
|
valid: boolean
|
||||||
|
errors: ValidationError[]
|
||||||
|
warnings: ValidationWarning[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidationError {
|
||||||
|
type: 'type_mismatch' | 'missing_required' | 'unknown_block' | 'unknown_port' | 'cycle_detected'
|
||||||
|
message: string
|
||||||
|
blockId?: string
|
||||||
|
portId?: string
|
||||||
|
details?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidationWarning {
|
||||||
|
type: 'implicit_conversion' | 'unused_output' | 'unconnected_input'
|
||||||
|
message: string
|
||||||
|
blockId?: string
|
||||||
|
portId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Port Compatibility
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an output port can connect to an input port
|
||||||
|
*/
|
||||||
|
export function canConnect(
|
||||||
|
outputPort: OutputPort,
|
||||||
|
inputPort: InputPort
|
||||||
|
): boolean {
|
||||||
|
return isTypeCompatible(outputPort.produces, inputPort.accepts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a specific type can connect to an input port
|
||||||
|
*/
|
||||||
|
export function canConnectType(
|
||||||
|
outputType: PortDataType,
|
||||||
|
inputPort: InputPort
|
||||||
|
): boolean {
|
||||||
|
return isTypeCompatible(outputType, inputPort.accepts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all compatible ports on a target block for a given output port
|
||||||
|
*/
|
||||||
|
export function getCompatibleInputPorts(
|
||||||
|
sourceBlockType: string,
|
||||||
|
sourcePortId: string,
|
||||||
|
targetBlockType: string
|
||||||
|
): InputPort[] {
|
||||||
|
if (!hasBlockDefinition(sourceBlockType) || !hasBlockDefinition(targetBlockType)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceBlock = getBlockDefinition(sourceBlockType)
|
||||||
|
const targetBlock = getBlockDefinition(targetBlockType)
|
||||||
|
|
||||||
|
const sourcePort = sourceBlock.outputs.find(p => p.id === sourcePortId)
|
||||||
|
if (!sourcePort) return []
|
||||||
|
|
||||||
|
return targetBlock.inputs.filter(inputPort =>
|
||||||
|
canConnect(sourcePort, inputPort)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all compatible output ports on a source block for a given input port
|
||||||
|
*/
|
||||||
|
export function getCompatibleOutputPorts(
|
||||||
|
sourceBlockType: string,
|
||||||
|
targetBlockType: string,
|
||||||
|
targetPortId: string
|
||||||
|
): OutputPort[] {
|
||||||
|
if (!hasBlockDefinition(sourceBlockType) || !hasBlockDefinition(targetBlockType)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceBlock = getBlockDefinition(sourceBlockType)
|
||||||
|
const targetBlock = getBlockDefinition(targetBlockType)
|
||||||
|
|
||||||
|
const targetPort = targetBlock.inputs.find(p => p.id === targetPortId)
|
||||||
|
if (!targetPort) return []
|
||||||
|
|
||||||
|
return sourceBlock.outputs.filter(outputPort =>
|
||||||
|
canConnect(outputPort, targetPort)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Connection Validation
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a single connection between two blocks
|
||||||
|
*/
|
||||||
|
export function validateConnection(
|
||||||
|
sourceBlockType: string,
|
||||||
|
sourcePortId: string,
|
||||||
|
targetBlockType: string,
|
||||||
|
targetPortId: string
|
||||||
|
): ValidationResult {
|
||||||
|
const errors: ValidationError[] = []
|
||||||
|
const warnings: ValidationWarning[] = []
|
||||||
|
|
||||||
|
// Check source block exists
|
||||||
|
if (!hasBlockDefinition(sourceBlockType)) {
|
||||||
|
errors.push({
|
||||||
|
type: 'unknown_block',
|
||||||
|
message: `Unknown source block type: ${sourceBlockType}`,
|
||||||
|
details: { blockType: sourceBlockType },
|
||||||
|
})
|
||||||
|
return { valid: false, errors, warnings }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check target block exists
|
||||||
|
if (!hasBlockDefinition(targetBlockType)) {
|
||||||
|
errors.push({
|
||||||
|
type: 'unknown_block',
|
||||||
|
message: `Unknown target block type: ${targetBlockType}`,
|
||||||
|
details: { blockType: targetBlockType },
|
||||||
|
})
|
||||||
|
return { valid: false, errors, warnings }
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceBlock = getBlockDefinition(sourceBlockType)
|
||||||
|
const targetBlock = getBlockDefinition(targetBlockType)
|
||||||
|
|
||||||
|
// Check source port exists
|
||||||
|
const sourcePort = sourceBlock.outputs.find(p => p.id === sourcePortId)
|
||||||
|
if (!sourcePort) {
|
||||||
|
errors.push({
|
||||||
|
type: 'unknown_port',
|
||||||
|
message: `Unknown output port "${sourcePortId}" on block "${sourceBlockType}"`,
|
||||||
|
portId: sourcePortId,
|
||||||
|
details: { blockType: sourceBlockType, availablePorts: sourceBlock.outputs.map(p => p.id) },
|
||||||
|
})
|
||||||
|
return { valid: false, errors, warnings }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check target port exists
|
||||||
|
const targetPort = targetBlock.inputs.find(p => p.id === targetPortId)
|
||||||
|
if (!targetPort) {
|
||||||
|
errors.push({
|
||||||
|
type: 'unknown_port',
|
||||||
|
message: `Unknown input port "${targetPortId}" on block "${targetBlockType}"`,
|
||||||
|
portId: targetPortId,
|
||||||
|
details: { blockType: targetBlockType, availablePorts: targetBlock.inputs.map(p => p.id) },
|
||||||
|
})
|
||||||
|
return { valid: false, errors, warnings }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check type compatibility
|
||||||
|
if (!canConnect(sourcePort, targetPort)) {
|
||||||
|
errors.push({
|
||||||
|
type: 'type_mismatch',
|
||||||
|
message: `Type mismatch: "${sourcePort.produces}" cannot connect to "${targetPort.accepts.join(' | ')}"`,
|
||||||
|
details: {
|
||||||
|
sourceType: sourcePort.produces,
|
||||||
|
targetAccepts: targetPort.accepts,
|
||||||
|
sourcePort: sourcePortId,
|
||||||
|
targetPort: targetPortId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { valid: false, errors, warnings }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for implicit conversions (warning, not error)
|
||||||
|
if (sourcePort.produces !== targetPort.type && targetPort.accepts.includes('any')) {
|
||||||
|
warnings.push({
|
||||||
|
type: 'implicit_conversion',
|
||||||
|
message: `Implicit conversion from "${sourcePort.produces}" to "${targetPort.type}"`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true, errors, warnings }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a port binding
|
||||||
|
*/
|
||||||
|
export function validatePortBinding(
|
||||||
|
binding: PortBinding,
|
||||||
|
getBlockType: (shapeId: string) => string | undefined
|
||||||
|
): ValidationResult {
|
||||||
|
const sourceType = getBlockType(binding.fromShapeId as string)
|
||||||
|
const targetType = getBlockType(binding.toShapeId as string)
|
||||||
|
|
||||||
|
if (!sourceType || !targetType) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
errors: [{
|
||||||
|
type: 'unknown_block',
|
||||||
|
message: 'Could not determine block types for binding',
|
||||||
|
blockId: !sourceType ? binding.fromShapeId as string : binding.toShapeId as string,
|
||||||
|
}],
|
||||||
|
warnings: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return validateConnection(
|
||||||
|
sourceType,
|
||||||
|
binding.fromPortId,
|
||||||
|
targetType,
|
||||||
|
binding.toPortId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Block Validation
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a block's configuration
|
||||||
|
*/
|
||||||
|
export function validateBlockConfig(
|
||||||
|
blockType: string,
|
||||||
|
config: Record<string, unknown>
|
||||||
|
): ValidationResult {
|
||||||
|
const errors: ValidationError[] = []
|
||||||
|
const warnings: ValidationWarning[] = []
|
||||||
|
|
||||||
|
if (!hasBlockDefinition(blockType)) {
|
||||||
|
errors.push({
|
||||||
|
type: 'unknown_block',
|
||||||
|
message: `Unknown block type: ${blockType}`,
|
||||||
|
})
|
||||||
|
return { valid: false, errors, warnings }
|
||||||
|
}
|
||||||
|
|
||||||
|
const definition = getBlockDefinition(blockType)
|
||||||
|
|
||||||
|
// If no config schema, any config is valid
|
||||||
|
if (!definition.configSchema) {
|
||||||
|
return { valid: true, errors, warnings }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic schema validation (could use ajv for full JSON Schema validation)
|
||||||
|
const schema = definition.configSchema as { properties?: Record<string, unknown> }
|
||||||
|
if (schema.properties) {
|
||||||
|
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
||||||
|
const prop = propSchema as { type?: string; required?: boolean; enum?: unknown[] }
|
||||||
|
|
||||||
|
// Check required properties
|
||||||
|
if (prop.required && !(key in config)) {
|
||||||
|
errors.push({
|
||||||
|
type: 'missing_required',
|
||||||
|
message: `Missing required configuration: ${key}`,
|
||||||
|
details: { key },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check enum values
|
||||||
|
if (prop.enum && key in config && !prop.enum.includes(config[key])) {
|
||||||
|
errors.push({
|
||||||
|
type: 'type_mismatch',
|
||||||
|
message: `Invalid value for "${key}": must be one of ${prop.enum.join(', ')}`,
|
||||||
|
details: { key, value: config[key], allowed: prop.enum },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: errors.length === 0, errors, warnings }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a block has all required inputs satisfied
|
||||||
|
*/
|
||||||
|
export function validateRequiredInputs(
|
||||||
|
blockType: string,
|
||||||
|
inputValues: Record<string, unknown>,
|
||||||
|
connectedInputs: string[]
|
||||||
|
): ValidationResult {
|
||||||
|
const errors: ValidationError[] = []
|
||||||
|
const warnings: ValidationWarning[] = []
|
||||||
|
|
||||||
|
if (!hasBlockDefinition(blockType)) {
|
||||||
|
errors.push({
|
||||||
|
type: 'unknown_block',
|
||||||
|
message: `Unknown block type: ${blockType}`,
|
||||||
|
})
|
||||||
|
return { valid: false, errors, warnings }
|
||||||
|
}
|
||||||
|
|
||||||
|
const definition = getBlockDefinition(blockType)
|
||||||
|
|
||||||
|
for (const input of definition.inputs) {
|
||||||
|
if (input.required) {
|
||||||
|
const hasValue = input.id in inputValues && inputValues[input.id] !== undefined
|
||||||
|
const hasConnection = connectedInputs.includes(input.id)
|
||||||
|
|
||||||
|
if (!hasValue && !hasConnection) {
|
||||||
|
errors.push({
|
||||||
|
type: 'missing_required',
|
||||||
|
message: `Required input "${input.name}" is not connected or provided`,
|
||||||
|
portId: input.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn about unconnected optional inputs
|
||||||
|
for (const input of definition.inputs) {
|
||||||
|
if (!input.required) {
|
||||||
|
const hasValue = input.id in inputValues && inputValues[input.id] !== undefined
|
||||||
|
const hasConnection = connectedInputs.includes(input.id)
|
||||||
|
|
||||||
|
if (!hasValue && !hasConnection && input.defaultValue === undefined) {
|
||||||
|
warnings.push({
|
||||||
|
type: 'unconnected_input',
|
||||||
|
message: `Optional input "${input.name}" has no value or connection`,
|
||||||
|
portId: input.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: errors.length === 0, errors, warnings }
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Workflow Validation
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect cycles in a workflow graph
|
||||||
|
*/
|
||||||
|
export function detectCycles(
|
||||||
|
connections: PortBinding[]
|
||||||
|
): { hasCycle: boolean; cycleNodes?: string[] } {
|
||||||
|
// Build adjacency list
|
||||||
|
const graph = new Map<string, Set<string>>()
|
||||||
|
|
||||||
|
for (const conn of connections) {
|
||||||
|
const from = conn.fromShapeId as string
|
||||||
|
const to = conn.toShapeId as string
|
||||||
|
|
||||||
|
if (!graph.has(from)) graph.set(from, new Set())
|
||||||
|
graph.get(from)!.add(to)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DFS to detect cycles
|
||||||
|
const visited = new Set<string>()
|
||||||
|
const recursionStack = new Set<string>()
|
||||||
|
const cyclePath: string[] = []
|
||||||
|
|
||||||
|
function dfs(node: string): boolean {
|
||||||
|
visited.add(node)
|
||||||
|
recursionStack.add(node)
|
||||||
|
cyclePath.push(node)
|
||||||
|
|
||||||
|
const neighbors = graph.get(node) || new Set()
|
||||||
|
for (const neighbor of neighbors) {
|
||||||
|
if (!visited.has(neighbor)) {
|
||||||
|
if (dfs(neighbor)) return true
|
||||||
|
} else if (recursionStack.has(neighbor)) {
|
||||||
|
cyclePath.push(neighbor)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cyclePath.pop()
|
||||||
|
recursionStack.delete(node)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of graph.keys()) {
|
||||||
|
if (!visited.has(node)) {
|
||||||
|
if (dfs(node)) {
|
||||||
|
// Extract just the cycle portion
|
||||||
|
const cycleStart = cyclePath.indexOf(cyclePath[cyclePath.length - 1])
|
||||||
|
return {
|
||||||
|
hasCycle: true,
|
||||||
|
cycleNodes: cyclePath.slice(cycleStart),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { hasCycle: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an entire workflow
|
||||||
|
*/
|
||||||
|
export function validateWorkflow(
|
||||||
|
blocks: Array<{ id: string; blockType: string; config: Record<string, unknown> }>,
|
||||||
|
connections: PortBinding[]
|
||||||
|
): ValidationResult {
|
||||||
|
const errors: ValidationError[] = []
|
||||||
|
const warnings: ValidationWarning[] = []
|
||||||
|
|
||||||
|
// Validate each connection
|
||||||
|
for (const conn of connections) {
|
||||||
|
const sourceBlock = blocks.find(b => b.id === conn.fromShapeId)
|
||||||
|
const targetBlock = blocks.find(b => b.id === conn.toShapeId)
|
||||||
|
|
||||||
|
if (sourceBlock && targetBlock) {
|
||||||
|
const result = validateConnection(
|
||||||
|
sourceBlock.blockType,
|
||||||
|
conn.fromPortId,
|
||||||
|
targetBlock.blockType,
|
||||||
|
conn.toPortId
|
||||||
|
)
|
||||||
|
errors.push(...result.errors)
|
||||||
|
warnings.push(...result.warnings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for cycles
|
||||||
|
const cycleResult = detectCycles(connections)
|
||||||
|
if (cycleResult.hasCycle) {
|
||||||
|
errors.push({
|
||||||
|
type: 'cycle_detected',
|
||||||
|
message: `Cycle detected in workflow: ${cycleResult.cycleNodes?.join(' -> ')}`,
|
||||||
|
details: { cycleNodes: cycleResult.cycleNodes },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for unused outputs (optional warning)
|
||||||
|
const connectedOutputs = new Set(connections.map(c => `${c.fromShapeId}:${c.fromPortId}`))
|
||||||
|
for (const block of blocks) {
|
||||||
|
if (!hasBlockDefinition(block.blockType)) continue
|
||||||
|
const def = getBlockDefinition(block.blockType)
|
||||||
|
|
||||||
|
for (const output of def.outputs) {
|
||||||
|
if (!connectedOutputs.has(`${block.id}:${output.id}`)) {
|
||||||
|
// Only warn for non-terminal blocks
|
||||||
|
if (def.category !== 'output') {
|
||||||
|
warnings.push({
|
||||||
|
type: 'unused_output',
|
||||||
|
message: `Output "${output.name}" on block "${def.name}" is not connected`,
|
||||||
|
blockId: block.id,
|
||||||
|
portId: output.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: errors.length === 0, errors, warnings }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,404 @@
|
||||||
|
/**
|
||||||
|
* WorkflowPropagator
|
||||||
|
*
|
||||||
|
* A propagator that handles real-time data flow between workflow blocks.
|
||||||
|
* When a workflow block's output changes, it automatically propagates
|
||||||
|
* the data to connected downstream blocks and triggers their execution.
|
||||||
|
*
|
||||||
|
* Uses the 'flow' prefix for arrows (e.g., flow{ ... } in arrow text)
|
||||||
|
* to identify workflow connections.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Editor,
|
||||||
|
TLArrowBinding,
|
||||||
|
TLArrowShape,
|
||||||
|
TLShape,
|
||||||
|
TLShapeId,
|
||||||
|
} from 'tldraw'
|
||||||
|
import { getEdge, getArrowsFromShape } from './tlgraph'
|
||||||
|
import { isShapeOfType } from './utils'
|
||||||
|
import { IWorkflowBlock } from '@/shapes/WorkflowBlockShapeUtil'
|
||||||
|
import {
|
||||||
|
getPortBinding,
|
||||||
|
getBlockOutputBindings,
|
||||||
|
getDownstreamBlocks,
|
||||||
|
} from '@/lib/workflow/portBindings'
|
||||||
|
import { executeBlock } from '@/lib/workflow/executor'
|
||||||
|
import { canConnect } from '@/lib/workflow/validation'
|
||||||
|
import { getBlockDefinition, hasBlockDefinition } from '@/lib/workflow/blockRegistry'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Configuration
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to auto-execute downstream blocks when outputs change
|
||||||
|
*/
|
||||||
|
let autoExecuteEnabled = true
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounce time for propagation (ms)
|
||||||
|
*/
|
||||||
|
const PROPAGATION_DEBOUNCE = 100
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable/disable auto-execution
|
||||||
|
*/
|
||||||
|
export function setAutoExecute(enabled: boolean): void {
|
||||||
|
autoExecuteEnabled = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get auto-execution status
|
||||||
|
*/
|
||||||
|
export function isAutoExecuteEnabled(): boolean {
|
||||||
|
return autoExecuteEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Propagator State
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface PropagatorState {
|
||||||
|
editor: Editor | null
|
||||||
|
watchedBlocks: Set<TLShapeId>
|
||||||
|
pendingPropagations: Map<TLShapeId, NodeJS.Timeout>
|
||||||
|
executingBlocks: Set<TLShapeId>
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: PropagatorState = {
|
||||||
|
editor: null,
|
||||||
|
watchedBlocks: new Set(),
|
||||||
|
pendingPropagations: new Map(),
|
||||||
|
executingBlocks: new Set(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Propagator Functions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a shape is a workflow block
|
||||||
|
*/
|
||||||
|
function isWorkflowBlock(shape: TLShape | undefined): shape is IWorkflowBlock {
|
||||||
|
return shape?.type === 'WorkflowBlock'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an arrow is a workflow connection (connects two workflow blocks)
|
||||||
|
*/
|
||||||
|
function isWorkflowArrow(editor: Editor, arrowId: TLShapeId): boolean {
|
||||||
|
const arrow = editor.getShape(arrowId) as TLArrowShape | undefined
|
||||||
|
if (!arrow || arrow.type !== 'arrow') return false
|
||||||
|
|
||||||
|
const bindings = editor.getBindingsInvolvingShape<TLArrowBinding>(arrowId)
|
||||||
|
if (bindings.length !== 2) return false
|
||||||
|
|
||||||
|
const startBinding = bindings.find(b => b.props.terminal === 'start')
|
||||||
|
const endBinding = bindings.find(b => b.props.terminal === 'end')
|
||||||
|
|
||||||
|
if (!startBinding || !endBinding) return false
|
||||||
|
|
||||||
|
const startShape = editor.getShape(startBinding.toId)
|
||||||
|
const endShape = editor.getShape(endBinding.toId)
|
||||||
|
|
||||||
|
return isWorkflowBlock(startShape) && isWorkflowBlock(endShape)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Propagate output data from a block to its downstream connections
|
||||||
|
*/
|
||||||
|
async function propagateOutputs(
|
||||||
|
editor: Editor,
|
||||||
|
sourceBlockId: TLShapeId
|
||||||
|
): Promise<void> {
|
||||||
|
if (!autoExecuteEnabled) return
|
||||||
|
if (state.executingBlocks.has(sourceBlockId)) return
|
||||||
|
|
||||||
|
const sourceShape = editor.getShape(sourceBlockId) as IWorkflowBlock | undefined
|
||||||
|
if (!sourceShape || !isWorkflowBlock(sourceShape)) return
|
||||||
|
|
||||||
|
const outputBindings = getBlockOutputBindings(editor, sourceBlockId)
|
||||||
|
const downstreamBlocks = new Set<TLShapeId>()
|
||||||
|
|
||||||
|
// Collect downstream blocks and update their input values
|
||||||
|
for (const binding of outputBindings) {
|
||||||
|
const outputValue = sourceShape.props.outputValues?.[binding.fromPortId]
|
||||||
|
|
||||||
|
if (outputValue !== undefined) {
|
||||||
|
// Update the target block's input value
|
||||||
|
const targetShape = editor.getShape(binding.toShapeId) as IWorkflowBlock | undefined
|
||||||
|
if (targetShape && isWorkflowBlock(targetShape)) {
|
||||||
|
editor.updateShape<IWorkflowBlock>({
|
||||||
|
id: binding.toShapeId,
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
props: {
|
||||||
|
inputValues: {
|
||||||
|
...targetShape.props.inputValues,
|
||||||
|
[binding.toPortId]: outputValue,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
downstreamBlocks.add(binding.toShapeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute downstream blocks if auto-execute is enabled
|
||||||
|
for (const blockId of downstreamBlocks) {
|
||||||
|
// Skip blocks that are already executing
|
||||||
|
if (state.executingBlocks.has(blockId)) continue
|
||||||
|
|
||||||
|
const blockShape = editor.getShape(blockId) as IWorkflowBlock | undefined
|
||||||
|
if (!blockShape) continue
|
||||||
|
|
||||||
|
// Check if block has all required inputs satisfied
|
||||||
|
if (hasBlockDefinition(blockShape.props.blockType)) {
|
||||||
|
const definition = getBlockDefinition(blockShape.props.blockType)
|
||||||
|
const requiredInputs = definition.inputs.filter(i => i.required)
|
||||||
|
const hasAllRequired = requiredInputs.every(input => {
|
||||||
|
const inputValue = blockShape.props.inputValues?.[input.id]
|
||||||
|
return inputValue !== undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
if (hasAllRequired) {
|
||||||
|
// Debounce execution to avoid rapid-fire updates
|
||||||
|
const existingTimeout = state.pendingPropagations.get(blockId)
|
||||||
|
if (existingTimeout) clearTimeout(existingTimeout)
|
||||||
|
|
||||||
|
const timeout = setTimeout(async () => {
|
||||||
|
state.pendingPropagations.delete(blockId)
|
||||||
|
state.executingBlocks.add(blockId)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await executeBlock(editor, blockId)
|
||||||
|
} finally {
|
||||||
|
state.executingBlocks.delete(blockId)
|
||||||
|
}
|
||||||
|
}, PROPAGATION_DEBOUNCE)
|
||||||
|
|
||||||
|
state.pendingPropagations.set(blockId, timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle workflow block changes
|
||||||
|
*/
|
||||||
|
function onBlockChange(editor: Editor, shape: TLShape): void {
|
||||||
|
if (!isWorkflowBlock(shape)) return
|
||||||
|
|
||||||
|
// Check if output values changed
|
||||||
|
const oldShape = editor.store.query.record('shape', () => shape.id)
|
||||||
|
if (oldShape && isWorkflowBlock(oldShape as TLShape)) {
|
||||||
|
const oldOutputs = (oldShape as IWorkflowBlock).props.outputValues
|
||||||
|
const newOutputs = shape.props.outputValues
|
||||||
|
|
||||||
|
// Only propagate if outputs actually changed
|
||||||
|
if (JSON.stringify(oldOutputs) !== JSON.stringify(newOutputs)) {
|
||||||
|
propagateOutputs(editor, shape.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle arrow changes to update port bindings
|
||||||
|
*/
|
||||||
|
function onArrowChange(editor: Editor, arrow: TLArrowShape): void {
|
||||||
|
if (!isWorkflowArrow(editor, arrow.id)) return
|
||||||
|
|
||||||
|
const edge = getEdge(arrow, editor)
|
||||||
|
if (!edge) return
|
||||||
|
|
||||||
|
const fromShape = editor.getShape(edge.from) as IWorkflowBlock | undefined
|
||||||
|
const toShape = editor.getShape(edge.to) as IWorkflowBlock | undefined
|
||||||
|
|
||||||
|
if (!fromShape || !toShape) return
|
||||||
|
if (!isWorkflowBlock(fromShape) || !isWorkflowBlock(toShape)) return
|
||||||
|
|
||||||
|
// Determine port IDs based on arrow position or existing meta
|
||||||
|
const meta = (arrow.meta || {}) as { fromPortId?: string; toPortId?: string }
|
||||||
|
|
||||||
|
// If meta already has port IDs, validate the connection
|
||||||
|
if (meta.fromPortId && meta.toPortId) {
|
||||||
|
if (!hasBlockDefinition(fromShape.props.blockType) ||
|
||||||
|
!hasBlockDefinition(toShape.props.blockType)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromDef = getBlockDefinition(fromShape.props.blockType)
|
||||||
|
const toDef = getBlockDefinition(toShape.props.blockType)
|
||||||
|
|
||||||
|
const fromPort = fromDef.outputs.find(p => p.id === meta.fromPortId)
|
||||||
|
const toPort = toDef.inputs.find(p => p.id === meta.toPortId)
|
||||||
|
|
||||||
|
if (fromPort && toPort && canConnect(fromPort, toPort)) {
|
||||||
|
// Valid connection - update arrow color to indicate valid
|
||||||
|
editor.updateShape({
|
||||||
|
id: arrow.id,
|
||||||
|
type: 'arrow',
|
||||||
|
props: { color: 'black' },
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Invalid connection
|
||||||
|
editor.updateShape({
|
||||||
|
id: arrow.id,
|
||||||
|
type: 'arrow',
|
||||||
|
props: { color: 'orange' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Auto-detect ports based on first available compatible pair
|
||||||
|
if (!hasBlockDefinition(fromShape.props.blockType) ||
|
||||||
|
!hasBlockDefinition(toShape.props.blockType)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromDef = getBlockDefinition(fromShape.props.blockType)
|
||||||
|
const toDef = getBlockDefinition(toShape.props.blockType)
|
||||||
|
|
||||||
|
// Find first compatible port pair
|
||||||
|
for (const outputPort of fromDef.outputs) {
|
||||||
|
for (const inputPort of toDef.inputs) {
|
||||||
|
if (canConnect(outputPort, inputPort)) {
|
||||||
|
// Set port binding on arrow meta
|
||||||
|
editor.updateShape({
|
||||||
|
id: arrow.id,
|
||||||
|
type: 'arrow',
|
||||||
|
meta: {
|
||||||
|
...arrow.meta,
|
||||||
|
fromPortId: outputPort.id,
|
||||||
|
toPortId: inputPort.id,
|
||||||
|
validated: true,
|
||||||
|
},
|
||||||
|
props: { color: 'black' },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No compatible ports found
|
||||||
|
editor.updateShape({
|
||||||
|
id: arrow.id,
|
||||||
|
type: 'arrow',
|
||||||
|
props: { color: 'orange' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Registration
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the workflow propagator with the editor
|
||||||
|
*/
|
||||||
|
export function registerWorkflowPropagator(editor: Editor): () => void {
|
||||||
|
state.editor = editor
|
||||||
|
state.watchedBlocks.clear()
|
||||||
|
state.pendingPropagations.clear()
|
||||||
|
state.executingBlocks.clear()
|
||||||
|
|
||||||
|
// Initialize: find all existing workflow blocks
|
||||||
|
for (const shape of editor.getCurrentPageShapes()) {
|
||||||
|
if (isWorkflowBlock(shape)) {
|
||||||
|
state.watchedBlocks.add(shape.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register change handler
|
||||||
|
const unsubscribeChange = editor.sideEffects.registerAfterChangeHandler<'shape'>(
|
||||||
|
'shape',
|
||||||
|
(_, next) => {
|
||||||
|
// Handle workflow block changes
|
||||||
|
if (isWorkflowBlock(next)) {
|
||||||
|
state.watchedBlocks.add(next.id)
|
||||||
|
onBlockChange(editor, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle arrow changes
|
||||||
|
if (isShapeOfType<TLArrowShape>(next, 'arrow')) {
|
||||||
|
onArrowChange(editor, next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Register create handler
|
||||||
|
const unsubscribeCreate = editor.sideEffects.registerAfterCreateHandler<'shape'>(
|
||||||
|
'shape',
|
||||||
|
(shape) => {
|
||||||
|
if (isWorkflowBlock(shape)) {
|
||||||
|
state.watchedBlocks.add(shape.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Register delete handler
|
||||||
|
const unsubscribeDelete = editor.sideEffects.registerAfterDeleteHandler<'shape'>(
|
||||||
|
'shape',
|
||||||
|
(shape) => {
|
||||||
|
if (shape.type === 'WorkflowBlock') {
|
||||||
|
state.watchedBlocks.delete(shape.id)
|
||||||
|
state.pendingPropagations.delete(shape.id)
|
||||||
|
state.executingBlocks.delete(shape.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Register binding change handler for arrows
|
||||||
|
const unsubscribeBinding = editor.sideEffects.registerAfterChangeHandler<'binding'>(
|
||||||
|
'binding',
|
||||||
|
(_, binding) => {
|
||||||
|
if (binding.type !== 'arrow') return
|
||||||
|
const arrow = editor.getShape(binding.fromId)
|
||||||
|
if (arrow && isShapeOfType<TLArrowShape>(arrow, 'arrow')) {
|
||||||
|
onArrowChange(editor, arrow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Return cleanup function
|
||||||
|
return () => {
|
||||||
|
unsubscribeChange()
|
||||||
|
unsubscribeCreate()
|
||||||
|
unsubscribeDelete()
|
||||||
|
unsubscribeBinding()
|
||||||
|
|
||||||
|
// Clear pending propagations
|
||||||
|
for (const timeout of state.pendingPropagations.values()) {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.editor = null
|
||||||
|
state.watchedBlocks.clear()
|
||||||
|
state.pendingPropagations.clear()
|
||||||
|
state.executingBlocks.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually trigger propagation from a block
|
||||||
|
*/
|
||||||
|
export function triggerPropagation(
|
||||||
|
editor: Editor,
|
||||||
|
blockId: TLShapeId
|
||||||
|
): Promise<void> {
|
||||||
|
return propagateOutputs(editor, blockId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all blocks currently being watched
|
||||||
|
*/
|
||||||
|
export function getWatchedBlocks(): TLShapeId[] {
|
||||||
|
return Array.from(state.watchedBlocks)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a block is currently executing
|
||||||
|
*/
|
||||||
|
export function isBlockExecuting(blockId: TLShapeId): boolean {
|
||||||
|
return state.executingBlocks.has(blockId)
|
||||||
|
}
|
||||||
|
|
@ -93,7 +93,8 @@ export class CalendarEventShape extends BaseBoxShapeUtil<ICalendarEventShape> {
|
||||||
}
|
}
|
||||||
|
|
||||||
component(shape: ICalendarEventShape) {
|
component(shape: ICalendarEventShape) {
|
||||||
const { w, h, props } = shape
|
const { props } = shape
|
||||||
|
const { w, h } = props
|
||||||
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
|
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
|
||||||
|
|
||||||
// Detect dark mode
|
// Detect dark mode
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,539 @@
|
||||||
|
/**
|
||||||
|
* WorkflowBlockShapeUtil
|
||||||
|
*
|
||||||
|
* A visual workflow block shape with typed input/output ports.
|
||||||
|
* Supports connection to other blocks via tldraw arrows for
|
||||||
|
* building automation flows, data pipelines, and AI agent chains.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
BaseBoxShapeUtil,
|
||||||
|
Geometry2d,
|
||||||
|
HTMLContainer,
|
||||||
|
Rectangle2d,
|
||||||
|
TLBaseShape,
|
||||||
|
Vec,
|
||||||
|
} from 'tldraw'
|
||||||
|
import React, { useState, useCallback, useMemo } from 'react'
|
||||||
|
import { StandardizedToolWrapper } from '../components/StandardizedToolWrapper'
|
||||||
|
import { usePinnedToView } from '../hooks/usePinnedToView'
|
||||||
|
import { useMaximize } from '../hooks/useMaximize'
|
||||||
|
import {
|
||||||
|
WorkflowBlockProps,
|
||||||
|
ExecutionState,
|
||||||
|
getPortTypeColor,
|
||||||
|
CATEGORY_INFO,
|
||||||
|
} from '../lib/workflow/types'
|
||||||
|
import {
|
||||||
|
getBlockDefinition,
|
||||||
|
hasBlockDefinition,
|
||||||
|
} from '../lib/workflow/blockRegistry'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Shape Type Definition
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export type IWorkflowBlock = TLBaseShape<'WorkflowBlock', WorkflowBlockProps>
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Constants
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const PORT_SIZE = 12
|
||||||
|
const PORT_SPACING = 28
|
||||||
|
const HEADER_HEIGHT = 36
|
||||||
|
const MIN_WIDTH = 180
|
||||||
|
const MIN_HEIGHT = 100
|
||||||
|
const DEFAULT_WIDTH = 220
|
||||||
|
const DEFAULT_HEIGHT = 150
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Execution State Colors
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const EXECUTION_COLORS: Record<ExecutionState, { bg: string; border: string; text: string }> = {
|
||||||
|
idle: { bg: 'transparent', border: 'transparent', text: '#6b7280' },
|
||||||
|
running: { bg: '#fef3c7', border: '#f59e0b', text: '#92400e' },
|
||||||
|
success: { bg: '#d1fae5', border: '#10b981', text: '#065f46' },
|
||||||
|
error: { bg: '#fee2e2', border: '#ef4444', text: '#991b1b' },
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Port Renderer Component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface PortProps {
|
||||||
|
port: { id: string; name: string; type: string; required?: boolean }
|
||||||
|
direction: 'input' | 'output'
|
||||||
|
index: number
|
||||||
|
shapeWidth: number
|
||||||
|
isConnected?: boolean
|
||||||
|
onHover?: (portId: string | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Port: React.FC<PortProps> = ({
|
||||||
|
port,
|
||||||
|
direction,
|
||||||
|
index,
|
||||||
|
shapeWidth,
|
||||||
|
isConnected = false,
|
||||||
|
onHover,
|
||||||
|
}) => {
|
||||||
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
|
const color = getPortTypeColor(port.type as any)
|
||||||
|
|
||||||
|
const x = direction === 'input' ? -PORT_SIZE / 2 : shapeWidth - PORT_SIZE / 2
|
||||||
|
const y = HEADER_HEIGHT + 12 + index * PORT_SPACING
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
setIsHovered(true)
|
||||||
|
onHover?.(port.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
setIsHovered(false)
|
||||||
|
onHover?.(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: x,
|
||||||
|
top: y,
|
||||||
|
width: PORT_SIZE,
|
||||||
|
height: PORT_SIZE,
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: isConnected ? color : 'white',
|
||||||
|
border: `2px solid ${color}`,
|
||||||
|
cursor: 'crosshair',
|
||||||
|
transform: isHovered ? 'scale(1.3)' : 'scale(1)',
|
||||||
|
transition: 'transform 0.15s ease, background-color 0.15s ease',
|
||||||
|
zIndex: 10,
|
||||||
|
boxShadow: isHovered ? `0 0 8px ${color}` : 'none',
|
||||||
|
}}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
title={`${port.name} (${port.type})${port.required ? ' *' : ''}`}
|
||||||
|
data-port-id={port.id}
|
||||||
|
data-port-direction={direction}
|
||||||
|
data-port-type={port.type}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Port Label Component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface PortLabelProps {
|
||||||
|
port: { id: string; name: string; type: string }
|
||||||
|
direction: 'input' | 'output'
|
||||||
|
index: number
|
||||||
|
shapeWidth: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const PortLabel: React.FC<PortLabelProps> = ({ port, direction, index, shapeWidth }) => {
|
||||||
|
const y = HEADER_HEIGHT + 12 + index * PORT_SPACING
|
||||||
|
const color = getPortTypeColor(port.type as any)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: direction === 'input' ? PORT_SIZE + 4 : 'auto',
|
||||||
|
right: direction === 'output' ? PORT_SIZE + 4 : 'auto',
|
||||||
|
top: y - 3,
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#4b5563',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{direction === 'output' && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: color,
|
||||||
|
opacity: 0.6,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span>{port.name}</span>
|
||||||
|
{direction === 'input' && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: color,
|
||||||
|
opacity: 0.6,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Main Shape Util Class
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export class WorkflowBlockShapeUtil extends BaseBoxShapeUtil<IWorkflowBlock> {
|
||||||
|
static override type = 'WorkflowBlock' as const
|
||||||
|
|
||||||
|
// Workflow blocks use indigo as base, but category determines actual color
|
||||||
|
static readonly PRIMARY_COLOR = '#6366f1'
|
||||||
|
|
||||||
|
getDefaultProps(): IWorkflowBlock['props'] {
|
||||||
|
return {
|
||||||
|
w: DEFAULT_WIDTH,
|
||||||
|
h: DEFAULT_HEIGHT,
|
||||||
|
blockType: 'trigger.manual',
|
||||||
|
blockConfig: {},
|
||||||
|
inputValues: {},
|
||||||
|
outputValues: {},
|
||||||
|
executionState: 'idle',
|
||||||
|
tags: ['workflow'],
|
||||||
|
pinnedToView: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getGeometry(shape: IWorkflowBlock): Geometry2d {
|
||||||
|
return new Rectangle2d({
|
||||||
|
width: Math.max(shape.props.w, MIN_WIDTH),
|
||||||
|
height: Math.max(shape.props.h, MIN_HEIGHT),
|
||||||
|
isFilled: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the position of a port in shape-local coordinates.
|
||||||
|
* Used for arrow snapping.
|
||||||
|
*/
|
||||||
|
getPortPosition(shape: IWorkflowBlock, portId: string, direction: 'input' | 'output'): Vec {
|
||||||
|
if (!hasBlockDefinition(shape.props.blockType)) {
|
||||||
|
return new Vec(0, HEADER_HEIGHT + 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
const definition = getBlockDefinition(shape.props.blockType)
|
||||||
|
const ports = direction === 'input' ? definition.inputs : definition.outputs
|
||||||
|
const portIndex = ports.findIndex(p => p.id === portId)
|
||||||
|
|
||||||
|
if (portIndex === -1) {
|
||||||
|
return new Vec(0, HEADER_HEIGHT + 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
const x = direction === 'input' ? 0 : shape.props.w
|
||||||
|
const y = HEADER_HEIGHT + 12 + portIndex * PORT_SPACING + PORT_SIZE / 2
|
||||||
|
|
||||||
|
return new Vec(x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
component(shape: IWorkflowBlock) {
|
||||||
|
const { blockType, executionState, executionError, blockConfig } = shape.props
|
||||||
|
|
||||||
|
// Get block definition
|
||||||
|
const definition = useMemo(() => {
|
||||||
|
if (!hasBlockDefinition(blockType)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return getBlockDefinition(blockType)
|
||||||
|
}, [blockType])
|
||||||
|
|
||||||
|
// Determine colors based on category
|
||||||
|
const categoryColor = definition
|
||||||
|
? CATEGORY_INFO[definition.category].color
|
||||||
|
: WorkflowBlockShapeUtil.PRIMARY_COLOR
|
||||||
|
|
||||||
|
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
|
||||||
|
const [hoveredPort, setHoveredPort] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Pin to view functionality
|
||||||
|
usePinnedToView(this.editor, shape.id, shape.props.pinnedToView, { position: 'current' })
|
||||||
|
|
||||||
|
// Maximize functionality
|
||||||
|
const { isMaximized, toggleMaximize } = useMaximize({
|
||||||
|
editor: this.editor,
|
||||||
|
shapeId: shape.id,
|
||||||
|
currentW: shape.props.w,
|
||||||
|
currentH: shape.props.h,
|
||||||
|
shapeType: 'WorkflowBlock',
|
||||||
|
padding: 40,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
this.editor.deleteShapes([shape.id])
|
||||||
|
}, [shape.id])
|
||||||
|
|
||||||
|
const handlePinToggle = useCallback(() => {
|
||||||
|
this.editor.updateShape<IWorkflowBlock>({
|
||||||
|
id: shape.id,
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
props: { pinnedToView: !shape.props.pinnedToView },
|
||||||
|
})
|
||||||
|
}, [shape.id, shape.props.pinnedToView])
|
||||||
|
|
||||||
|
const handleTagsChange = useCallback((newTags: string[]) => {
|
||||||
|
this.editor.updateShape<IWorkflowBlock>({
|
||||||
|
id: shape.id,
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
props: { tags: newTags },
|
||||||
|
})
|
||||||
|
}, [shape.id])
|
||||||
|
|
||||||
|
const handleRunBlock = useCallback(() => {
|
||||||
|
// Trigger manual execution (will be handled by executor)
|
||||||
|
this.editor.updateShape<IWorkflowBlock>({
|
||||||
|
id: shape.id,
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
props: { executionState: 'running' },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Dispatch custom event for executor to pick up
|
||||||
|
window.dispatchEvent(new CustomEvent('workflow:execute-block', {
|
||||||
|
detail: { blockId: shape.id },
|
||||||
|
}))
|
||||||
|
}, [shape.id])
|
||||||
|
|
||||||
|
// If block type is unknown, show error state
|
||||||
|
if (!definition) {
|
||||||
|
return (
|
||||||
|
<HTMLContainer
|
||||||
|
style={{
|
||||||
|
width: shape.props.w,
|
||||||
|
height: shape.props.h,
|
||||||
|
pointerEvents: 'all',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: '#fee2e2',
|
||||||
|
border: '2px solid #ef4444',
|
||||||
|
borderRadius: 8,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#991b1b',
|
||||||
|
fontSize: 12,
|
||||||
|
padding: 16,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Unknown block type: {blockType}
|
||||||
|
</div>
|
||||||
|
</HTMLContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const executionColors = EXECUTION_COLORS[executionState]
|
||||||
|
|
||||||
|
// Calculate minimum height based on ports
|
||||||
|
const maxPorts = Math.max(definition.inputs.length, definition.outputs.length)
|
||||||
|
const calculatedHeight = Math.max(
|
||||||
|
shape.props.h,
|
||||||
|
HEADER_HEIGHT + 24 + maxPorts * PORT_SPACING + 40
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HTMLContainer
|
||||||
|
style={{
|
||||||
|
width: shape.props.w,
|
||||||
|
height: calculatedHeight,
|
||||||
|
pointerEvents: 'all',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StandardizedToolWrapper
|
||||||
|
title={definition.name}
|
||||||
|
primaryColor={categoryColor}
|
||||||
|
isSelected={isSelected}
|
||||||
|
width={shape.props.w}
|
||||||
|
height={calculatedHeight}
|
||||||
|
onClose={handleClose}
|
||||||
|
onMaximize={toggleMaximize}
|
||||||
|
isMaximized={isMaximized}
|
||||||
|
editor={this.editor}
|
||||||
|
shapeId={shape.id}
|
||||||
|
isPinnedToView={shape.props.pinnedToView}
|
||||||
|
onPinToggle={handlePinToggle}
|
||||||
|
tags={shape.props.tags}
|
||||||
|
onTagsChange={handleTagsChange}
|
||||||
|
tagsEditable={true}
|
||||||
|
headerContent={
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<span style={{ fontSize: 14 }}>{definition.icon}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Execution state indicator */}
|
||||||
|
{executionState !== 'idle' && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: HEADER_HEIGHT + 4,
|
||||||
|
right: 8,
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: executionColors.bg,
|
||||||
|
border: `1px solid ${executionColors.border}`,
|
||||||
|
color: executionColors.text,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 500,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{executionState === 'running' && '⏳ Running'}
|
||||||
|
{executionState === 'success' && '✓ Done'}
|
||||||
|
{executionState === 'error' && '✕ Error'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Block description */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#6b7280',
|
||||||
|
borderBottom: '1px solid #e5e7eb',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{definition.description}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ports container */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: maxPorts * PORT_SPACING + 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Input ports */}
|
||||||
|
{definition.inputs.map((port, index) => (
|
||||||
|
<React.Fragment key={`input-${port.id}`}>
|
||||||
|
<Port
|
||||||
|
port={port}
|
||||||
|
direction="input"
|
||||||
|
index={index}
|
||||||
|
shapeWidth={shape.props.w}
|
||||||
|
onHover={setHoveredPort}
|
||||||
|
/>
|
||||||
|
<PortLabel
|
||||||
|
port={port}
|
||||||
|
direction="input"
|
||||||
|
index={index}
|
||||||
|
shapeWidth={shape.props.w}
|
||||||
|
/>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Output ports */}
|
||||||
|
{definition.outputs.map((port, index) => (
|
||||||
|
<React.Fragment key={`output-${port.id}`}>
|
||||||
|
<Port
|
||||||
|
port={port}
|
||||||
|
direction="output"
|
||||||
|
index={index}
|
||||||
|
shapeWidth={shape.props.w}
|
||||||
|
onHover={setHoveredPort}
|
||||||
|
/>
|
||||||
|
<PortLabel
|
||||||
|
port={port}
|
||||||
|
direction="output"
|
||||||
|
index={index}
|
||||||
|
shapeWidth={shape.props.w}
|
||||||
|
/>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{executionError && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
backgroundColor: '#fee2e2',
|
||||||
|
color: '#991b1b',
|
||||||
|
fontSize: 11,
|
||||||
|
borderTop: '1px solid #fecaca',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{executionError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Run button for trigger blocks */}
|
||||||
|
{definition.category === 'trigger' && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderTop: '1px solid #e5e7eb',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={handleRunBlock}
|
||||||
|
disabled={executionState === 'running'}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '6px 12px',
|
||||||
|
backgroundColor: executionState === 'running' ? '#9ca3af' : categoryColor,
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: executionState === 'running' ? 'not-allowed' : 'pointer',
|
||||||
|
transition: 'background-color 0.15s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (executionState !== 'running') {
|
||||||
|
e.currentTarget.style.opacity = '0.9'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.opacity = '1'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{executionState === 'running' ? 'Running...' : '▶ Run'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</StandardizedToolWrapper>
|
||||||
|
</HTMLContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
indicator(shape: IWorkflowBlock) {
|
||||||
|
// Calculate height same as component
|
||||||
|
const definition = hasBlockDefinition(shape.props.blockType)
|
||||||
|
? getBlockDefinition(shape.props.blockType)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const maxPorts = definition
|
||||||
|
? Math.max(definition.inputs.length, definition.outputs.length)
|
||||||
|
: 0
|
||||||
|
const calculatedHeight = Math.max(
|
||||||
|
shape.props.h,
|
||||||
|
HEADER_HEIGHT + 24 + maxPorts * PORT_SPACING + 40
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<rect
|
||||||
|
width={Math.max(shape.props.w, MIN_WIDTH)}
|
||||||
|
height={calculatedHeight}
|
||||||
|
rx={8}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export the shape for registration
|
||||||
|
export const WorkflowBlockShape = WorkflowBlockShapeUtil
|
||||||
|
|
@ -0,0 +1,204 @@
|
||||||
|
/**
|
||||||
|
* WorkflowBlockTool
|
||||||
|
*
|
||||||
|
* A StateNode-based tool for placing workflow blocks on the canvas.
|
||||||
|
* Shows a tooltip with the block type and creates the block on click.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { StateNode, TLEventHandlers } from 'tldraw'
|
||||||
|
import { findNonOverlappingPosition } from '@/utils/shapeCollisionUtils'
|
||||||
|
import { getBlockDefinition, hasBlockDefinition } from '@/lib/workflow/blockRegistry'
|
||||||
|
import { CATEGORY_INFO } from '@/lib/workflow/types'
|
||||||
|
|
||||||
|
// Store the selected block type for creation
|
||||||
|
let selectedBlockType = 'trigger.manual'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the block type that will be created when clicking
|
||||||
|
*/
|
||||||
|
export function setWorkflowBlockType(blockType: string): void {
|
||||||
|
selectedBlockType = blockType
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the currently selected block type
|
||||||
|
*/
|
||||||
|
export function getWorkflowBlockType(): string {
|
||||||
|
return selectedBlockType
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main WorkflowBlock tool
|
||||||
|
*/
|
||||||
|
export class WorkflowBlockTool extends StateNode {
|
||||||
|
static override id = 'WorkflowBlock'
|
||||||
|
static override initial = 'idle'
|
||||||
|
static override children = () => [WorkflowBlockIdle]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idle state - shows tooltip and handles click to create block
|
||||||
|
*/
|
||||||
|
export class WorkflowBlockIdle extends StateNode {
|
||||||
|
static override id = 'idle'
|
||||||
|
|
||||||
|
tooltipElement?: HTMLDivElement
|
||||||
|
mouseMoveHandler?: (e: MouseEvent) => void
|
||||||
|
|
||||||
|
override onEnter = () => {
|
||||||
|
// Set cursor to cross
|
||||||
|
this.editor.setCursor({ type: 'cross', rotation: 0 })
|
||||||
|
|
||||||
|
// Get block info for tooltip
|
||||||
|
const blockType = getWorkflowBlockType()
|
||||||
|
const definition = hasBlockDefinition(blockType)
|
||||||
|
? getBlockDefinition(blockType)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const blockName = definition?.name || 'Workflow Block'
|
||||||
|
const categoryInfo = definition ? CATEGORY_INFO[definition.category] : null
|
||||||
|
const icon = definition?.icon || '📦'
|
||||||
|
|
||||||
|
// Create tooltip element
|
||||||
|
this.tooltipElement = document.createElement('div')
|
||||||
|
this.tooltipElement.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
color: white;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: 10000;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
`
|
||||||
|
|
||||||
|
// Add colored category indicator
|
||||||
|
if (categoryInfo) {
|
||||||
|
const indicator = document.createElement('span')
|
||||||
|
indicator.style.cssText = `
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: ${categoryInfo.color};
|
||||||
|
`
|
||||||
|
this.tooltipElement.appendChild(indicator)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add icon and text
|
||||||
|
const textSpan = document.createElement('span')
|
||||||
|
textSpan.textContent = `${icon} Click to place ${blockName}`
|
||||||
|
this.tooltipElement.appendChild(textSpan)
|
||||||
|
|
||||||
|
// Add tooltip to DOM
|
||||||
|
document.body.appendChild(this.tooltipElement)
|
||||||
|
|
||||||
|
// Update tooltip position on mouse move
|
||||||
|
this.mouseMoveHandler = (e: MouseEvent) => {
|
||||||
|
if (this.tooltipElement) {
|
||||||
|
const x = e.clientX + 15
|
||||||
|
const y = e.clientY - 40
|
||||||
|
|
||||||
|
// Keep tooltip within viewport bounds
|
||||||
|
const rect = this.tooltipElement.getBoundingClientRect()
|
||||||
|
const viewportWidth = window.innerWidth
|
||||||
|
const viewportHeight = window.innerHeight
|
||||||
|
|
||||||
|
let finalX = x
|
||||||
|
let finalY = y
|
||||||
|
|
||||||
|
// Adjust if tooltip would go off edges
|
||||||
|
if (x + rect.width > viewportWidth) {
|
||||||
|
finalX = e.clientX - rect.width - 15
|
||||||
|
}
|
||||||
|
if (y + rect.height > viewportHeight) {
|
||||||
|
finalY = e.clientY - rect.height - 15
|
||||||
|
}
|
||||||
|
|
||||||
|
finalX = Math.max(10, finalX)
|
||||||
|
finalY = Math.max(10, finalY)
|
||||||
|
|
||||||
|
this.tooltipElement.style.left = `${finalX}px`
|
||||||
|
this.tooltipElement.style.top = `${finalY}px`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', this.mouseMoveHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
override onPointerDown: TLEventHandlers['onPointerDown'] = () => {
|
||||||
|
const { currentPagePoint } = this.editor.inputs
|
||||||
|
this.createWorkflowBlock(currentPagePoint.x, currentPagePoint.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
override onExit = () => {
|
||||||
|
this.cleanupTooltip()
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanupTooltip = () => {
|
||||||
|
if (this.mouseMoveHandler) {
|
||||||
|
document.removeEventListener('mousemove', this.mouseMoveHandler)
|
||||||
|
this.mouseMoveHandler = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.tooltipElement && this.tooltipElement.parentNode) {
|
||||||
|
this.tooltipElement.parentNode.removeChild(this.tooltipElement)
|
||||||
|
this.tooltipElement = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createWorkflowBlock(clickX: number, clickY: number) {
|
||||||
|
try {
|
||||||
|
const blockType = getWorkflowBlockType()
|
||||||
|
const definition = hasBlockDefinition(blockType)
|
||||||
|
? getBlockDefinition(blockType)
|
||||||
|
: null
|
||||||
|
|
||||||
|
// Calculate size based on ports
|
||||||
|
const shapeWidth = 220
|
||||||
|
const maxPorts = definition
|
||||||
|
? Math.max(definition.inputs.length, definition.outputs.length)
|
||||||
|
: 2
|
||||||
|
const shapeHeight = Math.max(150, 36 + 24 + maxPorts * 28 + 60)
|
||||||
|
|
||||||
|
// Center the shape on click
|
||||||
|
const finalX = clickX - shapeWidth / 2
|
||||||
|
const finalY = clickY - shapeHeight / 2
|
||||||
|
|
||||||
|
// Create the shape
|
||||||
|
const shape = this.editor.createShape({
|
||||||
|
type: 'WorkflowBlock',
|
||||||
|
x: finalX,
|
||||||
|
y: finalY,
|
||||||
|
props: {
|
||||||
|
w: shapeWidth,
|
||||||
|
h: shapeHeight,
|
||||||
|
blockType: blockType,
|
||||||
|
blockConfig: definition?.defaultConfig || {},
|
||||||
|
inputValues: {},
|
||||||
|
outputValues: {},
|
||||||
|
executionState: 'idle',
|
||||||
|
tags: ['workflow', definition?.category || 'block'],
|
||||||
|
pinnedToView: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Select the new shape and switch to select tool
|
||||||
|
if (shape) {
|
||||||
|
this.editor.setSelectedShapes([shape.id])
|
||||||
|
}
|
||||||
|
this.editor.setCurrentTool('select')
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating WorkflowBlock shape:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WorkflowBlockTool
|
||||||
Loading…
Reference in New Issue