canvas-website/src/lib/workflow/portBindings.ts

465 lines
11 KiB
TypeScript

/**
* Port Binding Utilities
*
* Manages port-to-port connections between workflow blocks via tldraw arrows.
* Stores binding info in arrow metadata and provides utilities for querying.
*/
import { Editor, TLShapeId, TLArrowShape, TLShape, TLArrowShapeArrowheadStyle, JsonObject } from 'tldraw'
import { PortBinding } from './types'
import { IWorkflowBlock } from '@/shapes/WorkflowBlockShapeUtil'
// =============================================================================
// Arrow Metadata Types
// =============================================================================
export interface WorkflowArrowMeta extends JsonObject {
isWorkflowBinding?: boolean
fromPortId?: string
toPortId?: string
validated?: boolean
dataType?: string
}
// Type guard for arrow binding terminal
interface ArrowBindingTerminal {
type: 'binding'
boundShapeId: TLShapeId
normalizedAnchor: { x: number; y: number }
isExact: boolean
isPrecise: boolean
}
function isArrowBinding(terminal: unknown): terminal is ArrowBindingTerminal {
return (
terminal !== null &&
typeof terminal === 'object' &&
'type' in terminal &&
(terminal as { type: string }).type === 'binding' &&
'boundShapeId' in terminal
)
}
// =============================================================================
// Binding Extraction
// =============================================================================
/**
* Extract port binding info 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 meta = arrow.meta as WorkflowArrowMeta
if (!meta?.isWorkflowBinding) return null
const startBinding = arrow.props.start
const endBinding = arrow.props.end
if (!isArrowBinding(startBinding) || !isArrowBinding(endBinding)) {
return null
}
return {
fromShapeId: startBinding.boundShapeId,
fromPortId: meta.fromPortId || 'output',
toShapeId: endBinding.boundShapeId,
toPortId: meta.toPortId || 'input',
arrowId: arrowId,
}
}
/**
* Get all port bindings in the editor
*/
export function getAllBindings(editor: Editor): PortBinding[] {
const arrows = editor.getCurrentPageShapes().filter(
(s): s is TLArrowShape => s.type === 'arrow'
)
const bindings: PortBinding[] = []
for (const arrow of arrows) {
const binding = getPortBinding(editor, arrow.id)
if (binding) {
bindings.push(binding)
}
}
return bindings
}
/**
* Get all arrows connected to workflow blocks (even without metadata)
*/
export function getWorkflowArrows(editor: Editor): TLArrowShape[] {
const arrows = editor.getCurrentPageShapes().filter(
(s): s is TLArrowShape => s.type === 'arrow'
)
return arrows.filter(arrow => {
const start = arrow.props.start
const end = arrow.props.end
if (!isArrowBinding(start) || !isArrowBinding(end)) {
return false
}
const startShape = editor.getShape(start.boundShapeId)
const endShape = editor.getShape(end.boundShapeId)
return (
startShape?.type === 'WorkflowBlock' ||
endShape?.type === 'WorkflowBlock'
)
})
}
// =============================================================================
// Block-specific Queries
// =============================================================================
/**
* Get all input bindings for a workflow block
*/
export function getBlockInputBindings(
editor: Editor,
blockId: TLShapeId
): PortBinding[] {
return getAllBindings(editor).filter(b => b.toShapeId === blockId)
}
/**
* Get all output bindings for a workflow block
*/
export function getBlockOutputBindings(
editor: Editor,
blockId: TLShapeId
): PortBinding[] {
return getAllBindings(editor).filter(b => b.fromShapeId === blockId)
}
/**
* Get all connected block IDs for a given block
*/
export function getConnectedBlocks(
editor: Editor,
blockId: TLShapeId
): { upstream: TLShapeId[]; downstream: TLShapeId[] } {
const bindings = getAllBindings(editor)
const upstream = bindings
.filter(b => b.toShapeId === blockId)
.map(b => b.fromShapeId)
const downstream = bindings
.filter(b => b.fromShapeId === blockId)
.map(b => b.toShapeId)
return { upstream, downstream }
}
/**
* Get the binding for a specific input port
*/
export function getInputPortBinding(
editor: Editor,
blockId: TLShapeId,
portId: string
): PortBinding | null {
const bindings = getBlockInputBindings(editor, blockId)
return bindings.find(b => b.toPortId === portId) || null
}
/**
* Get all bindings for a specific output port
*/
export function getOutputPortBindings(
editor: Editor,
blockId: TLShapeId,
portId: string
): PortBinding[] {
return getBlockOutputBindings(editor, blockId).filter(
b => b.fromPortId === portId
)
}
// =============================================================================
// Binding Creation & Updates
// =============================================================================
/**
* Mark an arrow as a workflow binding with port metadata
*/
export function setArrowBinding(
editor: Editor,
arrowId: TLShapeId,
fromPortId: string,
toPortId: string,
dataType?: string
): void {
editor.updateShape({
id: arrowId,
type: 'arrow',
meta: {
isWorkflowBinding: true,
fromPortId,
toPortId,
validated: true,
dataType,
} as WorkflowArrowMeta,
})
}
/**
* Clear workflow binding metadata from an arrow
*/
export function clearArrowBinding(editor: Editor, arrowId: TLShapeId): void {
editor.updateShape({
id: arrowId,
type: 'arrow',
meta: {
isWorkflowBinding: false,
fromPortId: undefined,
toPortId: undefined,
validated: false,
dataType: undefined,
} as WorkflowArrowMeta,
})
}
/**
* Remove a binding (delete the arrow)
*/
export function removeBinding(editor: Editor, binding: PortBinding): void {
editor.deleteShape(binding.arrowId)
}
// =============================================================================
// Port Position Helpers
// =============================================================================
/**
* Calculate the world position of a port on a workflow block
*/
export function getPortWorldPosition(
editor: Editor,
blockId: TLShapeId,
portId: string,
direction: 'input' | 'output'
): { x: number; y: number } | null {
const shape = editor.getShape(blockId) as IWorkflowBlock | undefined
if (!shape || shape.type !== 'WorkflowBlock') return null
// Get the shape's transform
const point = editor.getShapePageTransform(blockId)?.point()
if (!point) return null
// Import dynamically to avoid circular deps
const { getBlockDefinition, hasBlockDefinition } = require('./blockRegistry')
if (!hasBlockDefinition(shape.props.blockType)) {
return null
}
const definition = getBlockDefinition(shape.props.blockType)
const ports = direction === 'input' ? definition.inputs : definition.outputs
const portIndex = ports.findIndex((p: { id: string }) => p.id === portId)
if (portIndex === -1) return null
const PORT_SIZE = 12
const PORT_SPACING = 28
const HEADER_HEIGHT = 36
const x = direction === 'input' ? 0 : shape.props.w
const y = HEADER_HEIGHT + 12 + portIndex * PORT_SPACING + PORT_SIZE / 2
return {
x: point.x + x,
y: point.y + y,
}
}
// =============================================================================
// Connection Validation Helpers
// =============================================================================
/**
* Check if a connection already exists between two ports
*/
export function connectionExists(
editor: Editor,
fromBlockId: TLShapeId,
fromPortId: string,
toBlockId: TLShapeId,
toPortId: string
): boolean {
const bindings = getAllBindings(editor)
return bindings.some(
b =>
b.fromShapeId === fromBlockId &&
b.fromPortId === fromPortId &&
b.toShapeId === toBlockId &&
b.toPortId === toPortId
)
}
/**
* Check if an input port already has a connection
*/
export function inputPortHasConnection(
editor: Editor,
blockId: TLShapeId,
portId: string
): boolean {
return getInputPortBinding(editor, blockId, portId) !== null
}
/**
* Get the list of connected input port IDs for a block
*/
export function getConnectedInputPorts(
editor: Editor,
blockId: TLShapeId
): string[] {
return getBlockInputBindings(editor, blockId).map(b => b.toPortId)
}
/**
* Get the list of connected output port IDs for a block
*/
export function getConnectedOutputPorts(
editor: Editor,
blockId: TLShapeId
): string[] {
const bindings = getBlockOutputBindings(editor, blockId)
return [...new Set(bindings.map(b => b.fromPortId))]
}
// =============================================================================
// Graph Traversal
// =============================================================================
/**
* Get all workflow blocks in topological order (for execution)
*/
export function getExecutionOrder(
editor: Editor,
startBlockId?: TLShapeId
): TLShapeId[] {
const bindings = getAllBindings(editor)
const blocks = editor
.getCurrentPageShapes()
.filter((s): s is IWorkflowBlock => s.type === 'WorkflowBlock')
.map(s => s.id)
// Build adjacency list
const graph = new Map<TLShapeId, Set<TLShapeId>>()
const inDegree = new Map<TLShapeId, number>()
for (const blockId of blocks) {
graph.set(blockId, new Set())
inDegree.set(blockId, 0)
}
for (const binding of bindings) {
if (blocks.includes(binding.fromShapeId) && blocks.includes(binding.toShapeId)) {
graph.get(binding.fromShapeId)?.add(binding.toShapeId)
inDegree.set(
binding.toShapeId,
(inDegree.get(binding.toShapeId) || 0) + 1
)
}
}
// Kahn's algorithm for topological sort
const queue: TLShapeId[] = []
const result: TLShapeId[] = []
// Start from specified block or all roots
if (startBlockId && blocks.includes(startBlockId)) {
queue.push(startBlockId)
} else {
for (const [blockId, degree] of inDegree) {
if (degree === 0) {
queue.push(blockId)
}
}
}
while (queue.length > 0) {
const current = queue.shift()!
result.push(current)
for (const neighbor of graph.get(current) || []) {
const newDegree = (inDegree.get(neighbor) || 1) - 1
inDegree.set(neighbor, newDegree)
if (newDegree === 0) {
queue.push(neighbor)
}
}
}
return result
}
/**
* Get all blocks downstream from a given block
*/
export function getDownstreamBlocks(
editor: Editor,
blockId: TLShapeId
): TLShapeId[] {
const visited = new Set<TLShapeId>()
const result: TLShapeId[] = []
function dfs(current: TLShapeId) {
if (visited.has(current)) return
visited.add(current)
const downstream = getBlockOutputBindings(editor, current).map(
b => b.toShapeId
)
for (const next of downstream) {
result.push(next)
dfs(next)
}
}
dfs(blockId)
return result
}
/**
* Get all blocks upstream from a given block
*/
export function getUpstreamBlocks(
editor: Editor,
blockId: TLShapeId
): TLShapeId[] {
const visited = new Set<TLShapeId>()
const result: TLShapeId[] = []
function dfs(current: TLShapeId) {
if (visited.has(current)) return
visited.add(current)
const upstream = getBlockInputBindings(editor, current).map(
b => b.fromShapeId
)
for (const prev of upstream) {
result.push(prev)
dfs(prev)
}
}
dfs(blockId)
return result
}