Skip to content

Custom Cards

By default every node renders as a FieldCard — a labelled panel with editable rows for each port. Implement NodeCanvasFactory to give a node type a completely custom appearance.


The factory contract

interface NodeCanvasFactory {
    @Composable
    fun NodeCanvas(context: NodeCanvasContext)

    val nodeWidth: Int   get() = 280   // dp
    val nodeHeight: Int  get() = 180   // dp
    val nodeShape: NodeShape get() = NodeShape.Rectangle

    fun portAnchorY(portIndex: Int, isInput: Boolean, spec: NodeSpec): Int
}

The editor uses nodeWidth, nodeHeight, and portAnchorY to draw connection wires and the minimap. Override all three to match your card's actual layout — mismatched values result in wires that miss their ports.


NodeCanvasContext

Your composable receives a NodeCanvasContext at render time:

data class NodeCanvasContext(
    val node: NodeRef,                  // current node (id, type, config)
    val spec: NodeSpec,                 // spec for this node type
    val selected: Boolean,              // whether the node is selected
    val executionStatus: NodeExecutionStatus,
    val onSelect: () -> Unit,           // call to select this node
    val onMove: (IntOffset) -> Unit,    // call to move, delta in dp
    val onConfigChange: (key: String, value: WorkflowValue) -> Unit,
    val contentColor: Color,            // canvas surface text colour
)

onConfigChange is the only way to persist config edits. The editor dispatches GraphynEditorIntent.UpdateNodeConfig in response, which feeds into undo/redo.


Minimal example — circle trigger card

class TriggerCardFactory : NodeCanvasFactory {

    override val nodeWidth  = 64
    override val nodeHeight = 64
    override val nodeShape  = NodeShape.Circle

    override fun portAnchorY(portIndex: Int, isInput: Boolean, spec: NodeSpec) =
        nodeHeight / 2  // single port centred vertically

    @Composable
    override fun NodeCanvas(context: NodeCanvasContext) {
        val border = if (context.selected) Color(0xFF7AA2FF) else Color(0xFF3D3E52)

        Box(
            modifier = Modifier
                .size(nodeWidth.dp, nodeHeight.dp)
                .clip(CircleShape)
                .background(Color(0xFF2D2E3D))
                .border(2.dp, border, CircleShape)
                .clickable { context.onSelect() }
                .graphynDragGesture(context.onMove),   // helper from editor-api
            contentAlignment = Alignment.Center,
        ) {
            BasicText(
                context.spec.label,
                style = TextStyle(color = Color(0xFFE0E0E6), fontSize = 10.sp),
            )
        }
    }
}

Drag gesture

The graphynDragGesture modifier (from editor-api) translates pointer events to IntOffset deltas in dp and calls context.onMove. Use it on your card's drag handle:

Modifier.graphynDragGesture(context.onMove)

If your card has a header and a body, apply the gesture only to the header so the body can receive its own click/scroll events:

// Header — draggable
Box(Modifier.graphynDragGesture(context.onMove)) { ... }

// Body — receives its own events
Box(Modifier.clickable { /* something */ }) { ... }

Reading and writing config

Read config values from context.node.config:

val text = (context.node.config["text"] as? WorkflowValue.StringValue)?.value ?: ""

Write back via context.onConfigChange:

BasicTextField(
    value = text,
    onValueChange = { context.onConfigChange("text", WorkflowValue.StringValue(it)) },
)

Config changes are debounced by the editor's intent dispatch — you don't need to debounce them yourself.


Registering the factory

In your GraphynEditorPlugin:

override fun register(registrar: GraphynEditorPluginRegistrar) {
    registrar.registerCanvasCard("myplugin.trigger", TriggerCardFactory())
}

Port anchor alignment

The editor draws connection wires from (nodeX + nodeWidth, portAnchorY) for outputs and (nodeX, portAnchorY) for inputs. If your card draws port dots at custom positions, override portAnchorY to match:

override fun portAnchorY(portIndex: Int, isInput: Boolean, spec: NodeSpec): Int {
    val headerHeight = 24
    val rowHeight    = 32
    val rowGap       = 8
    return headerHeight + rowHeight / 2 + portIndex * (rowHeight + rowGap)
}

Port index is zero-based, ordered top-to-bottom as declared in the NodeSpec.


Execution status badge

Show NodeStatusBadge from editor-api on your card to display Running / Success / Error state:

Box {
    // ... your card content
    NodeStatusBadge(
        status = context.executionStatus,
        modifier = Modifier.align(Alignment.TopEnd).padding(4.dp),
    )
}

See NodeStatusBadge KDoc for badge appearance and placement guidance.