diff --git a/src/components/CalendarPanel.tsx b/src/components/CalendarPanel.tsx
index 160f909..add6495 100644
--- a/src/components/CalendarPanel.tsx
+++ b/src/components/CalendarPanel.tsx
@@ -45,9 +45,9 @@ const isToday = (date: Date) => {
}
export function CalendarPanel({
- onClose,
+ onClose: _onClose,
onEventSelect,
- shapeMode = false,
+ shapeMode: _shapeMode = false,
initialView = "month",
initialDate,
}: CalendarPanelProps) {
@@ -255,7 +255,7 @@ export function CalendarPanel({
{date.getDate()}
- {dayEvents.slice(0, 3).map((event, i) => (
+ {dayEvents.slice(0, 3).map((event) => (
= ({
- onClose,
+ onClose: _onClose,
onMonthSelect,
- shapeMode = false,
+ shapeMode: _shapeMode = false,
initialYear,
}) => {
const [currentYear, setCurrentYear] = useState(initialYear || new Date().getFullYear())
diff --git a/src/components/workflow/WorkflowPalette.tsx b/src/components/workflow/WorkflowPalette.tsx
new file mode 100644
index 0000000..eba50fd
--- /dev/null
+++ b/src/components/workflow/WorkflowPalette.tsx
@@ -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
= ({ definition, onSelect }) => {
+ const [isHovered, setIsHovered] = useState(false)
+ const categoryInfo = CATEGORY_INFO[definition.category]
+
+ return (
+ 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,
+ }}
+ >
+
{definition.icon}
+
+
+ {definition.name}
+
+
+ {definition.description}
+
+
+ {definition.inputs.length} inputs
+ {definition.outputs.length} outputs
+
+
+
+ )
+}
+
+// =============================================================================
+// Category Section Component
+// =============================================================================
+
+interface CategorySectionProps {
+ category: BlockCategory
+ blocks: BlockDefinition[]
+ isExpanded: boolean
+ onToggle: () => void
+ onSelectBlock: (blockType: string) => void
+}
+
+const CategorySection: React.FC = ({
+ category,
+ blocks,
+ isExpanded,
+ onToggle,
+ onSelectBlock,
+}) => {
+ const categoryInfo = CATEGORY_INFO[category]
+
+ return (
+
+
+
+
+ {categoryInfo.label}
+
+
+ {blocks.length}
+
+
+ ▶
+
+
+
+ {isExpanded && (
+
+ {blocks.map((block) => (
+
+ ))}
+
+ )}
+
+ )
+}
+
+// =============================================================================
+// Main Palette Component
+// =============================================================================
+
+export const WorkflowPalette: React.FC = ({
+ isOpen,
+ onClose,
+}) => {
+ const editor = useEditor()
+ const [searchQuery, setSearchQuery] = useState('')
+ const [expandedCategories, setExpandedCategories] = useState>(
+ 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 = {} 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 (
+
+ {/* Header */}
+
+
+ ⚡
+
+ Workflow Blocks
+
+
+
+
+
+ {/* Search */}
+
+ setSearchQuery(e.target.value)}
+ style={{
+ width: '100%',
+ padding: '8px 12px',
+ fontSize: 13,
+ border: '1px solid #e5e7eb',
+ borderRadius: 6,
+ outline: 'none',
+ backgroundColor: '#f9fafb',
+ }}
+ />
+
+
+ {/* Execution Controls */}
+
+
+
+
+
+ {/* Auto-execute Toggle */}
+
+
+ Real-time propagation
+
+
+
+
+ {/* Block Categories */}
+
+ {categories.map((category) => {
+ const blocks = blocksByCategory[category]
+ if (blocks.length === 0 && searchQuery) return null
+
+ return (
+ toggleCategory(category)}
+ onSelectBlock={handleSelectBlock}
+ />
+ )
+ })}
+
+
+ {/* Footer */}
+
+ Click a block, then click on canvas to place
+
+
+ )
+}
+
+export default WorkflowPalette
diff --git a/src/css/workflow.css b/src/css/workflow.css
new file mode 100644
index 0000000..f3bd429
--- /dev/null
+++ b/src/css/workflow.css
@@ -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;
+}
diff --git a/src/lib/workflow/blockRegistry.ts b/src/lib/workflow/blockRegistry.ts
new file mode 100644
index 0000000..89fd177
--- /dev/null
+++ b/src/lib/workflow/blockRegistry.ts
@@ -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 = new Map()
+
+// =============================================================================
+// Helper Functions for Port Creation
+// =============================================================================
+
+function input(
+ id: string,
+ name: string,
+ type: 'text' | 'number' | 'boolean' | 'object' | 'array' | 'any' | 'file' | 'image',
+ options: Partial> = {}
+): 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> = {}
+): 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 {
+ const result: Record = {
+ trigger: [],
+ action: [],
+ condition: [],
+ transformer: [],
+ output: [],
+ ai: [],
+ }
+
+ for (const block of BLOCK_REGISTRY.values()) {
+ result[block.category].push(block)
+ }
+
+ return result
+}
diff --git a/src/lib/workflow/executor.ts b/src/lib/workflow/executor.ts
new file mode 100644
index 0000000..a9aedc4
--- /dev/null
+++ b/src/lib/workflow/executor.ts
@@ -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,
+ config: Record
+) => Promise>
+
+const blockExecutors: Map = 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
+
+ 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) || {}
+
+ 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) || {}
+
+ 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
+ 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)) {
+ value = (value as Record)[part]
+ } else {
+ return { value: undefined }
+ }
+ }
+
+ return { value }
+})
+
+// Transformer: Set Property
+registerBlockExecutor('transformer.setProperty', async (context, inputs, config) => {
+ const object = { ...(inputs.object as Record) } || {}
+ const path = (config.path as string) || ''
+ const value = inputs.value
+
+ if (path) {
+ const parts = path.split('.')
+ let current = object as Record
+
+ for (let i = 0; i < parts.length - 1; i++) {
+ if (!(parts[i] in current)) {
+ current[parts[i]] = {}
+ }
+ current = current[parts[i]] as Record
+ }
+
+ 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 = {}
+): Promise {
+ 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({
+ 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({
+ 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> {
+ const inputs: Record = {}
+ 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({
+ 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
+ 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()
+ 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({
+ 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)
+ }
+}
diff --git a/src/lib/workflow/portBindings.ts b/src/lib/workflow/portBindings.ts
new file mode 100644
index 0000000..a80485a
--- /dev/null
+++ b/src/lib/workflow/portBindings.ts
@@ -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(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(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(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 }>
+ connections: PortBinding[]
+} {
+ const blocks = getAllWorkflowBlocks(editor).map(shape => {
+ const props = shape.props as { blockType: string; blockConfig: Record }
+ 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()
+ const outEdges = new Map()
+
+ 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
+}
diff --git a/src/lib/workflow/serialization.ts b/src/lib/workflow/serialization.ts
new file mode 100644
index 0000000..84ee495
--- /dev/null
+++ b/src/lib/workflow/serialization.ts
@@ -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
+ inputValues?: Record
+ 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
+}
+
+// =============================================================================
+// 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[1]
+): string {
+ return JSON.stringify(exportWorkflow(editor, options), null, 2)
+}
+
+/**
+ * Download workflow as JSON file
+ */
+export function downloadWorkflow(
+ editor: Editor,
+ options?: Parameters[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()
+ 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({
+ 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[2]
+): ReturnType {
+ 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[2]
+): Promise> {
+ 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 = {
+ '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[2]
+): ReturnType {
+ 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,
+ }))
+}
diff --git a/src/lib/workflow/types.ts b/src/lib/workflow/types.ts
new file mode 100644
index 0000000..acec0f8
--- /dev/null
+++ b/src/lib/workflow/types.ts
@@ -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 // User-configured values
+ inputValues: Record // Current input port values
+ outputValues: Record // 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
+ abortSignal?: AbortSignal
+}
+
+/**
+ * Result from executing a single block
+ */
+export interface BlockExecutionResult {
+ blockId: TLShapeId
+ blockType: string
+ status: 'success' | 'error' | 'skipped'
+ outputs: Record
+ 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
+}
+
+/**
+ * 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,
+ config: Record,
+ context: ExecutionContext
+) => Promise>
+
+/**
+ * Registry of block executors
+ */
+export type BlockExecutorRegistry = Record
+
+// =============================================================================
+// 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 = {
+ 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 = {
+ 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',
+ },
+}
diff --git a/src/lib/workflow/validation.ts b/src/lib/workflow/validation.ts
new file mode 100644
index 0000000..b970961
--- /dev/null
+++ b/src/lib/workflow/validation.ts
@@ -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
+}
+
+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
+): 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 }
+ 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,
+ 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>()
+
+ 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()
+ const recursionStack = new Set()
+ 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 }>,
+ 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 }
+}
diff --git a/src/propagators/WorkflowPropagator.ts b/src/propagators/WorkflowPropagator.ts
new file mode 100644
index 0000000..cbdd2ce
--- /dev/null
+++ b/src/propagators/WorkflowPropagator.ts
@@ -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
+ pendingPropagations: Map
+ executingBlocks: Set
+}
+
+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(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 {
+ 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()
+
+ // 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({
+ 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(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(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 {
+ 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)
+}
diff --git a/src/shapes/CalendarEventShapeUtil.tsx b/src/shapes/CalendarEventShapeUtil.tsx
index f4a0c14..f6bc4eb 100644
--- a/src/shapes/CalendarEventShapeUtil.tsx
+++ b/src/shapes/CalendarEventShapeUtil.tsx
@@ -93,7 +93,8 @@ export class CalendarEventShape extends BaseBoxShapeUtil {
}
component(shape: ICalendarEventShape) {
- const { w, h, props } = shape
+ const { props } = shape
+ const { w, h } = props
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
// Detect dark mode
diff --git a/src/shapes/WorkflowBlockShapeUtil.tsx b/src/shapes/WorkflowBlockShapeUtil.tsx
new file mode 100644
index 0000000..e32c3a3
--- /dev/null
+++ b/src/shapes/WorkflowBlockShapeUtil.tsx
@@ -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 = {
+ 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 = ({
+ 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 (
+
+ )
+}
+
+// =============================================================================
+// Port Label Component
+// =============================================================================
+
+interface PortLabelProps {
+ port: { id: string; name: string; type: string }
+ direction: 'input' | 'output'
+ index: number
+ shapeWidth: number
+}
+
+const PortLabel: React.FC = ({ port, direction, index, shapeWidth }) => {
+ const y = HEADER_HEIGHT + 12 + index * PORT_SPACING
+ const color = getPortTypeColor(port.type as any)
+
+ return (
+
+ {direction === 'output' && (
+
+ )}
+ {port.name}
+ {direction === 'input' && (
+
+ )}
+
+ )
+}
+
+// =============================================================================
+// Main Shape Util Class
+// =============================================================================
+
+export class WorkflowBlockShapeUtil extends BaseBoxShapeUtil {
+ 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(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({
+ id: shape.id,
+ type: 'WorkflowBlock',
+ props: { pinnedToView: !shape.props.pinnedToView },
+ })
+ }, [shape.id, shape.props.pinnedToView])
+
+ const handleTagsChange = useCallback((newTags: string[]) => {
+ this.editor.updateShape({
+ id: shape.id,
+ type: 'WorkflowBlock',
+ props: { tags: newTags },
+ })
+ }, [shape.id])
+
+ const handleRunBlock = useCallback(() => {
+ // Trigger manual execution (will be handled by executor)
+ this.editor.updateShape({
+ 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 (
+
+
+ Unknown block type: {blockType}
+
+
+ )
+ }
+
+ 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 (
+
+
+ {definition.icon}
+
+ }
+ >
+ {/* Execution state indicator */}
+ {executionState !== 'idle' && (
+
+ {executionState === 'running' && '⏳ Running'}
+ {executionState === 'success' && '✓ Done'}
+ {executionState === 'error' && '✕ Error'}
+
+ )}
+
+ {/* Block description */}
+
+ {definition.description}
+
+
+ {/* Ports container */}
+
+ {/* Input ports */}
+ {definition.inputs.map((port, index) => (
+
+
+
+
+ ))}
+
+ {/* Output ports */}
+ {definition.outputs.map((port, index) => (
+
+
+
+
+ ))}
+
+
+ {/* Error message */}
+ {executionError && (
+
+ {executionError}
+
+ )}
+
+ {/* Run button for trigger blocks */}
+ {definition.category === 'trigger' && (
+
+
+
+ )}
+
+
+ )
+ }
+
+ 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 (
+
+ )
+ }
+}
+
+// Export the shape for registration
+export const WorkflowBlockShape = WorkflowBlockShapeUtil
diff --git a/src/tools/WorkflowBlockTool.ts b/src/tools/WorkflowBlockTool.ts
new file mode 100644
index 0000000..1af80b0
--- /dev/null
+++ b/src/tools/WorkflowBlockTool.ts
@@ -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