Add interactive graph visualizations and DAG rendering to your Nuxt Crouton collections with the @fyit/crouton-flow package. Perfect for workflow builders, decision trees, entity relationships, and any data with parent-child relationships.
pnpm add @fyit/crouton-flow
@fyit/crouton-flow extends @fyit/crouton-collab for real-time collaboration features (presence, sync, Durable Objects). When using sync mode, ensure @fyit/crouton-collab is also installed.Add the layer to your nuxt.config.ts:
export default defineNuxtConfig({
extends: [
'@fyit/crouton',
'@fyit/crouton-flow'
]
})
<script setup>
const { data: decisions } = await useCollectionQuery('decisions')
</script>
<template>
<CroutonFlow
:rows="decisions"
collection="decisions"
parent-field="parentId"
position-field="position"
/>
</template>
parentId field (tree/DAG structures)The main wrapper component that renders your collection as an interactive graph.
| Prop | Type | Default | Description |
|---|---|---|---|
rows | Record<string, unknown>[] | [] | Collection data to display (not required when sync is enabled) |
collection | string | required | Collection name for mutations and component resolution |
parentField | string | 'parentId' | Field containing parent ID for edges |
positionField | string | 'position' | Field storing node { x, y } position |
labelField | string | 'title' | Field to use as node label |
controls | boolean | true | Show zoom/pan controls |
minimap | boolean | false | Show minimap overlay |
background | boolean | true | Show background pattern |
backgroundPattern | 'dots' | 'lines' | 'dots' | Background pattern type |
draggable | boolean | true | Allow node dragging |
fitViewOnMount | boolean | true | Fit graph to viewport on mount |
flowConfig | FlowConfig | undefined | Advanced flow configuration |
sync | boolean | false | Enable real-time multiplayer sync |
flowId | string | undefined | Unique ID for the sync room (required when sync is true) |
allowDrop | boolean | false | Allow external items to be dropped onto the canvas |
allowedCollections | string[] | [] | Collections allowed to be dropped (empty = all allowed) |
autoCreateOnDrop | boolean | true | Auto-create nodes when items are dropped (sync mode only) |
| Event | Payload | Description |
|---|---|---|
nodeClick | (nodeId: string, data: Record<string, unknown>, event: MouseEvent) | Node was clicked |
nodeDblClick | (nodeId: string, data: Record<string, unknown>) | Node was double-clicked |
nodeMove | (nodeId: string, position: { x: number, y: number }) | Node was dragged to new position |
edgeClick | (edgeId: string) | Edge was clicked |
selectionChange | (selectedNodeIds: string[]) | Selected nodes changed (use with v-model:selected) |
nodeDrop | (item: Record<string, unknown>, position: { x, y }, collection: string) | An external item was dropped onto the canvas (requires allowDrop) |
<template>
<CroutonFlow
:rows="decisions"
collection="decisions"
parent-field="parentId"
position-field="position"
:controls="true"
:minimap="true"
@nodeClick="handleNodeClick"
@nodeDblClick="openNodeDetail"
/>
</template>
<script setup>
const handleNodeClick = (nodeId, data) => {
console.log('Clicked:', data.title)
}
const openNodeDetail = (nodeId, data) => {
// Open slideover or modal
useCrouton().open('update', 'decisions', [nodeId])
}
</script>
The default node component used when no custom node exists. Displays the item's title/name in a styled card.
| Prop | Type | Default | Description |
|---|---|---|---|
data | Record<string, unknown> | required | The collection item data |
collection | string | undefined | Collection name for component resolution |
selected | boolean | false | Whether node is selected |
dragging | boolean | false | Whether node is being dragged |
label | string | '' | Override label text |
You can create custom node components for each collection. The flow layer will automatically detect and use them.
Create a component named [Collection]Node.vue in your app's components directory:
app/components/
└── DecisionsNode.vue ← Custom node for "decisions" collection
<!-- app/components/DecisionsNode.vue -->
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core'
interface Props {
data: {
id: string
content: string
type: 'idea' | 'insight' | 'decision'
starred: boolean
}
selected?: boolean
dragging?: boolean
}
const props = defineProps<Props>()
const typeIcons = {
idea: 'i-lucide-lightbulb',
insight: 'i-lucide-eye',
decision: 'i-lucide-check-circle'
}
</script>
<template>
<div
class="decision-node"
:class="{
'decision-node--selected': selected,
'decision-node--starred': data.starred
}"
>
<Handle type="target" :position="Position.Top" />
<div class="flex items-center gap-2">
<UIcon :name="typeIcons[data.type]" class="w-4 h-4" />
<span class="font-medium truncate">{{ data.content }}</span>
<UIcon
v-if="data.starred"
name="i-lucide-star"
class="w-4 h-4 text-yellow-500"
/>
</div>
<Handle type="source" :position="Position.Bottom" />
</div>
</template>
<style scoped>
.decision-node {
@apply px-4 py-2 rounded-lg border bg-white dark:bg-neutral-900;
@apply border-neutral-200 dark:border-neutral-700;
@apply shadow-sm min-w-[150px] max-w-[250px];
}
.decision-node--selected {
@apply border-primary-500 ring-2 ring-primary-500/20;
}
.decision-node--starred {
@apply bg-yellow-50 dark:bg-yellow-900/20;
}
</style>
The flow layer supports real-time multiplayer editing using:
Add the sync and flowId props to enable multiplayer:
<script setup>
const projectId = useRoute().params.id
</script>
<template>
<CroutonFlow
collection="decisions"
sync
:flow-id="projectId"
/>
</template>
When sync is enabled:
┌─────────────────────────────────────────────────────────────┐
│ Clients │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Client A │ │ Client B │ │ Client C │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ └─────────────┼─────────────┘ │
│ │ WebSocket │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Cloudflare Durable Object │ │
│ │ (CollabRoom) │ │
│ │ - Manages Yjs Y.Doc per flow │ │
│ │ - Merges updates from all clients │ │
│ │ - Persists to D1 on changes │ │
│ └──────────────────────┬──────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ D1 (SQLite) │ │
│ │ yjs_collab_states (Yjs blob) + collection tables │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
| Feature | Standard Mode | Sync Mode |
|---|---|---|
| Position persistence | Debounced API calls | Real-time via Yjs |
| Multi-user editing | Not supported | Full support |
| Presence indicators | Not available | Users, cursors, selections |
| Offline support | Crouton cache | Yjs CRDT merge on reconnect |
| Infrastructure | Standard API | Durable Objects + WebSocket |
Sync mode requires Cloudflare Durable Objects. Add to your wrangler.toml:
[[durable_objects.bindings]]
name = "COLLAB_ROOMS"
class_name = "CollabRoom"
[[migrations]]
tag = "collab-v1"
new_classes = ["CollabRoom"]
Run the D1 migration to create the state table:
CREATE TABLE IF NOT EXISTS yjs_collab_states (
room_type TEXT NOT NULL,
room_id TEXT NOT NULL,
state BLOB NOT NULL,
version INTEGER DEFAULT 1,
created_at INTEGER DEFAULT (unixepoch()),
updated_at INTEGER DEFAULT (unixepoch()),
PRIMARY KEY (room_type, room_id)
);
Enable WebSocket in your Nuxt config:
export default defineNuxtConfig({
nitro: {
experimental: {
websocket: true
}
}
})
Displays presence indicators for other connected users. Provided by @fyit/crouton-collab.
<template>
<CollabPresence
:users="users"
:max-visible="4"
size="sm"
/>
</template>
| Prop | Type | Default | Description |
|---|---|---|---|
users | CollabAwarenessState[] | required | Array of connected users |
maxVisible | number | 5 | Max avatars before showing +N overflow |
size | 'xs' | 'sm' | 'md' | 'sm' | Avatar size |
showTooltip | boolean | true | Show user name on hover |
Features:
maxVisibleShows the current sync connection state. Provided by @fyit/crouton-collab.
<template>
<CollabStatus
:connected="connected"
:synced="synced"
:error="error"
:show-label="true"
/>
</template>
| Prop | Type | Default | Description |
|---|---|---|---|
connected | boolean | required | WebSocket connected |
synced | boolean | required | Initial sync complete |
error | Error | null | null | Connection error if any |
showLabel | boolean | true | Show text label next to dot |
States:
Converts collection rows into Vue Flow nodes and edges.
const { nodes, edges, getNode, getItem } = useFlowData(
computed(() => rows),
{
parentField: 'parentId',
positionField: 'position',
labelField: 'title'
}
)
| Option | Type | Default | Description |
|---|---|---|---|
parentField | string | 'parentId' | Field containing parent ID |
positionField | string | 'position' | Field containing { x, y } |
labelField | string | 'title' | Field for node label |
| Property | Type | Description |
|---|---|---|
nodes | ComputedRef<Node[]> | Vue Flow nodes |
edges | ComputedRef<Edge[]> | Vue Flow edges |
getNode | (id: string) => Node | Get node by ID |
getItem | (id: string) => T | Get original item by ID |
Provides dagre-based automatic layout for graphs.
const { applyLayout, applyLayoutToNew, needsLayout } = useFlowLayout({
direction: 'TB', // TB, LR, BT, RL
nodeSpacing: 50, // Horizontal spacing
rankSpacing: 100 // Vertical spacing
})
// Apply layout if needed
if (needsLayout(nodes)) {
const layoutedNodes = applyLayout(nodes, edges)
}
| Property | Type | Description |
|---|---|---|
applyLayout | (nodes: Node[], edges: Edge[]) => Node[] | Apply dagre layout to all nodes |
applyLayoutToNew | (nodes: Node[], edges: Edge[]) => Node[] | Apply layout only to nodes without existing positions |
needsLayout | (nodes: Node[]) => boolean | Check if any nodes need layout (missing positions) |
Handles position persistence via the crouton mutation system.
const { updatePosition, updatePositions, pending, error } = useFlowMutation(
'decisions',
'position' // Position field name
)
// Update a node's position
await updatePosition('node-123', { x: 100, y: 200 })
Debounced version for drag operations (prevents excessive API calls).
const { debouncedUpdate, pending, error } = useDebouncedPositionUpdate(
'decisions',
'position',
500 // Debounce delay in ms
)
// Called on each drag event
debouncedUpdate(nodeId, { x, y })
Real-time flow synchronization via Yjs. Handles WebSocket connection, node CRUD, and presence.
const {
// State (readonly)
nodes, // Ref<YjsFlowNode[]>
connected, // ComputedRef<boolean>
synced, // ComputedRef<boolean>
error, // ComputedRef<Error | null>
users, // ComputedRef<CollabAwarenessState[]>
user, // Current user info
// Node operations
createNode,
updateNode,
updatePosition,
deleteNode,
getNode,
// Presence
updateCursor,
selectNode,
updateGhostNode,
clearGhostNode,
// Connection
connect,
disconnect,
// Advanced (Yjs internals)
ydoc,
nodesMap
} = useFlowSync({
flowId: 'flow-123',
collection: 'decisions'
})
| Option | Type | Required | Description |
|---|---|---|---|
flowId | string | Yes | Unique identifier for the sync room |
collection | string | Yes | Collection name for persistence |
State
| Property | Type | Description |
|---|---|---|
nodes | Readonly<Ref<YjsFlowNode[]>> | All nodes in the flow |
connected | ComputedRef<boolean> | WebSocket connected |
synced | ComputedRef<boolean> | Initial sync complete |
error | ComputedRef<Error | null> | Connection error |
users | ComputedRef<CollabAwarenessState[]> | Connected users |
user | ComputedRef<{id, name, color} | null> | Current user (from session) |
Node Operations
| Method | Signature | Description |
|---|---|---|
createNode | (data: Partial<YjsFlowNode>) => string | Create node, returns ID |
updateNode | (id: string, updates: Partial<YjsFlowNode>) => void | Update node fields |
updatePosition | (id: string, position: {x, y}) => void | Update node position |
deleteNode | (id: string) => void | Delete node |
getNode | (id: string) => YjsFlowNode | undefined | Get node by ID |
Presence
| Method | Signature | Description |
|---|---|---|
updateCursor | (cursor: {x, y} | null) => void | Update cursor position |
selectNode | (nodeId: string | null) => void | Broadcast node selection |
updateGhostNode | (ghost: YjsGhostNode | null) => void | Show drag preview to others |
clearGhostNode | () => void | Clear ghost node |
<script setup>
const {
nodes,
connected,
synced,
users,
createNode,
deleteNode
} = useFlowSync({
flowId: props.flowId,
collection: 'decisions'
})
const addNode = () => {
createNode({
title: 'New Decision',
position: { x: 100, y: 100 }
})
}
</script>
<template>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1">
<span
class="w-2 h-2 rounded-full"
:class="connected && synced ? 'bg-green-500' : 'bg-yellow-500'"
/>
<span class="text-sm">{{ users.length }} online</span>
</div>
<UButton @click="addNode">Add Node</UButton>
</div>
</template>
Helper composable for presence UI. Provides utilities for rendering user indicators.
const {
otherUsers, // Other connected users (excludes current)
getUsersSelectingNode, // Get users selecting a specific node
getNodePresenceStyle // Get style for node presence border
} = useFlowPresence({
users: computed(() => syncState.users),
currentUserId: currentUser.id
})
| Option | Type | Description |
|---|---|---|
users | Ref<CollabAwarenessState[]> | Connected users from useFlowSync |
currentUserId | string | Current user's ID to exclude |
| Property | Type | Description |
|---|---|---|
otherUsers | ComputedRef<CollabAwarenessState[]> | Users excluding current |
getUserColor | (userId: string) => string | Get assigned color for a user |
getUsersSelectingNode | (nodeId: string) => ComputedRef<CollabAwarenessState[]> | Users selecting a node |
getNodePresenceStyle | (nodeId: string) => ComputedRef<CSSProperties> | Presence border style |
<script setup>
const { getNodePresenceStyle } = useFlowPresence({
users: users,
currentUserId: currentUser.id
})
// Get style for a specific node
const nodeStyle = getNodePresenceStyle('node-123')
</script>
<template>
<div :style="nodeStyle">
<!-- Node content with presence border -->
</div>
</template>
Add a position field to your collection schema to persist node positions:
{
"position": {
"type": "json",
"meta": {
"description": "Node position for flow visualization"
}
}
}
The position is stored as:
{
position: {
x: 150,
y: 200
}
}
The flow layer builds edges from parentId relationships:
{
"parentId": {
"type": "string",
"refTarget": "decisions",
"meta": {
"description": "Parent decision for tree structure"
}
}
}
For advanced configuration, use the flowConfig prop:
<CroutonFlow
:rows="decisions"
collection="decisions"
:flow-config="{
direction: 'LR', // Left to right layout
nodeSpacing: 80,
rankSpacing: 150,
autoLayout: 'dagre'
}"
/>
| Option | Type | Default | Description |
|---|---|---|---|
direction | 'TB' | 'LR' | 'BT' | 'RL' | 'TB' | Layout direction |
nodeSpacing | number | 50 | Horizontal spacing between nodes |
rankSpacing | number | 100 | Vertical spacing between ranks |
autoLayout | 'dagre' | 'none' | 'dagre' | Auto-layout algorithm |
positionField | string | 'position' | Position storage field |
<script setup>
const { data: decisions } = await useCollectionQuery('decisions', {
sort: { field: 'createdAt', direction: 'asc' }
})
</script>
<template>
<div class="h-[600px]">
<CroutonFlow
:rows="decisions"
collection="decisions"
parent-field="parentId"
:minimap="true"
@nodeDblClick="(id) => useCrouton().open('update', 'decisions', [id])"
/>
</div>
</template>
<script setup>
const { data: steps } = await useCollectionQuery('workflowSteps')
const handleConnect = async (sourceId: string, targetId: string) => {
// Create edge relationship
await useCollectionMutation('workflowSteps').update(targetId, {
parentId: sourceId
})
}
</script>
<template>
<CroutonFlow
:rows="steps"
collection="workflowSteps"
:flow-config="{ direction: 'LR' }"
:controls="true"
>
<!-- Custom slot content -->
<template #default>
<div class="absolute top-4 right-4">
<UButton @click="addStep">Add Step</UButton>
</div>
</template>
</CroutonFlow>
</template>
Real-time collaborative editing with presence:
<script setup>
const route = useRoute()
const { user: currentUser } = useUserSession()
// Use sync mode for real-time collaboration
const {
nodes,
connected,
synced,
users,
error,
createNode
} = useFlowSync({
flowId: route.params.id,
collection: 'decisions'
})
// Presence helpers
const { otherUsers } = useFlowPresence({
users,
currentUserId: currentUser.value?.id
})
const addDecision = () => {
createNode({
title: 'New Decision',
position: { x: Math.random() * 400, y: Math.random() * 300 }
})
}
</script>
<template>
<div class="h-screen relative">
<!-- Main flow with sync enabled -->
<CroutonFlow
collection="decisions"
sync
:flow-id="route.params.id"
:minimap="true"
/>
<!-- Connection status -->
<CollabStatus
:connected="connected"
:synced="synced"
:error="error"
/>
<!-- Presence overlay -->
<CollabPresence
:users="otherUsers"
/>
<!-- Toolbar -->
<div class="absolute top-4 left-4 flex items-center gap-4">
<div class="flex items-center gap-2 bg-white/80 dark:bg-neutral-900/80 px-3 py-1.5 rounded-lg">
<div
class="w-2 h-2 rounded-full"
:class="connected && synced ? 'bg-green-500' : 'bg-yellow-500'"
/>
<span class="text-sm">{{ otherUsers.length + 1 }} online</span>
</div>
<UButton @click="addDecision" icon="i-lucide-plus">
Add Decision
</UButton>
</div>
</div>
</template>
The flow component supports dark mode automatically. Override styles using CSS:
/* Custom node styles */
:deep(.vue-flow__node) {
/* Your styles */
}
/* Custom edge styles */
:deep(.vue-flow__edge-path) {
stroke: theme('colors.primary.500');
stroke-width: 2px;
}
/* Selected state */
:deep(.vue-flow__node.selected) {
/* Selected node styles */
}