Features

Flow Visualization

Interactive graph and DAG visualization for collections using Vue Flow
Status: Beta - Feature-complete but may have minor API changes

Add interactive graph visualizations and DAG rendering to your Nuxt Crouton collections with the @friendlyinternet/nuxt-crouton-flow package. Perfect for workflow builders, decision trees, entity relationships, and any data with parent-child relationships.

Quick Start

Installation

pnpm add @friendlyinternet/nuxt-crouton-flow

Configuration

Add the layer to your nuxt.config.ts:

export default defineNuxtConfig({
  extends: [
    '@friendlyinternet/nuxt-crouton',
    '@friendlyinternet/nuxt-crouton-flow'
  ]
})

Basic Usage

<script setup>
const { data: decisions } = await useCollectionQuery('decisions')
</script>

<template>
  <CroutonFlowFlow
    :rows="decisions"
    collection="decisions"
    parent-field="parentId"
    position-field="position"
  />
</template>

Features

  • Automatic edge generation from parentId field (tree/DAG structures)
  • Dagre auto-layout for initial positioning
  • Drag-and-drop node positioning with persistence
  • Custom node components per collection
  • Controls, minimap, and background built-in
  • Dark mode support
  • Real-time collaboration with Yjs CRDTs (multiplayer sync)
  • Presence indicators showing other users' cursors and selections

Components

CroutonFlowFlow

The main wrapper component that renders your collection as an interactive graph.

Props

PropTypeDefaultDescription
rowsRecord<string, unknown>[]requiredCollection data to display
collectionstringrequiredCollection name for mutations and component resolution
parentFieldstring'parentId'Field containing parent ID for edges
positionFieldstring'position'Field storing node { x, y } position
labelFieldstring'title'Field to use as node label
controlsbooleantrueShow zoom/pan controls
minimapbooleanfalseShow minimap overlay
backgroundbooleantrueShow background pattern
backgroundPattern'dots' | 'lines''dots'Background pattern type
draggablebooleantrueAllow node dragging
fitViewOnMountbooleantrueFit graph to viewport on mount
flowConfigFlowConfigundefinedAdvanced flow configuration
syncbooleanfalseEnable real-time multiplayer sync
flowIdstringundefinedUnique ID for the sync room (required when sync is true)

Events

EventPayloadDescription
nodeClick(nodeId: string, data: Record<string, unknown>)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

Example

<template>
  <CroutonFlowFlow
    :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>

CroutonFlowNode

The default node component used when no custom node exists. Displays the item's title/name in a styled card.

Props

PropTypeDefaultDescription
dataRecord<string, unknown>requiredThe collection item data
selectedbooleanfalseWhether node is selected
draggingbooleanfalseWhether node is being dragged
labelstring''Override label text

Custom Node Components

You can create custom node components for each collection. The flow layer will automatically detect and use them.

Convention

Create a component named [Collection]Node.vue in your app's components directory:

app/components/
└── DecisionsNode.vue    ← Custom node for "decisions" collection

Example Custom Node

<!-- 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-heroicons-light-bulb',
  insight: 'i-heroicons-eye',
  decision: 'i-heroicons-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-heroicons-star-solid"
        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>

Real-time Collaboration

Multiplayer Mode: Enable real-time collaboration where multiple users can edit the same flow simultaneously with live cursor tracking and presence indicators.

Overview

The flow layer supports real-time multiplayer editing using:

  • Yjs CRDTs for conflict-free collaborative editing
  • Cloudflare Durable Objects for WebSocket-based sync
  • Presence awareness showing other users' cursors and selections

Enabling Sync Mode

Add the sync and flowId props to enable multiplayer:

<script setup>
const projectId = useRoute().params.id
</script>

<template>
  <CroutonFlowFlow
    collection="decisions"
    sync
    :flow-id="projectId"
  />
</template>

When sync is enabled:

  • Node positions sync in real-time across all connected clients
  • Node CRUD operations are broadcast to all users
  • User presence (cursors, selections) is visible to everyone
  • Changes persist automatically to the database

How It Works

┌─────────────────────────────────────────────────────────────┐
│                        Clients                              │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐                  │
│  │ Client A │  │ Client B │  │ Client C │                  │
│  └────┬─────┘  └────┬─────┘  └────┬─────┘                  │
│       └─────────────┼─────────────┘                         │
│                     │ WebSocket                             │
│                     ▼                                       │
│  ┌─────────────────────────────────────────────────────┐   │
│  │           Cloudflare Durable Object                 │   │
│  │                 (FlowRoom)                          │   │
│  │  - Manages Yjs Y.Doc per flow                       │   │
│  │  - Merges updates from all clients                  │   │
│  │  - Persists to D1 on changes                        │   │
│  └──────────────────────┬──────────────────────────────┘   │
│                         │                                   │
│                         ▼                                   │
│  ┌─────────────────────────────────────────────────────┐   │
│  │                 D1 (SQLite)                         │   │
│  │  yjs_flow_states (Yjs blob) + collection tables     │   │
│  └─────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘

Sync Mode vs Standard Mode

FeatureStandard ModeSync Mode
Position persistenceDebounced API callsReal-time via Yjs
Multi-user editingNot supportedFull support
Presence indicatorsNot availableUsers, cursors, selections
Offline supportCrouton cacheYjs CRDT merge on reconnect
InfrastructureStandard APIDurable Objects + WebSocket

Infrastructure Setup

Sync mode requires Cloudflare Durable Objects. Add to your wrangler.toml:

[[durable_objects.bindings]]
name = "FLOW_ROOMS"
class_name = "FlowRoom"

[[migrations]]
tag = "v1"
new_classes = ["FlowRoom"]

Run the D1 migration to create the state table:

CREATE TABLE IF NOT EXISTS yjs_flow_states (
  flow_id TEXT PRIMARY KEY,
  collection_name TEXT NOT NULL,
  state BLOB NOT NULL,
  version INTEGER DEFAULT 1,
  created_at INTEGER DEFAULT (unixepoch()),
  updated_at INTEGER DEFAULT (unixepoch())
);

Enable WebSocket in your Nuxt config:

export default defineNuxtConfig({
  nitro: {
    experimental: {
      websocket: true
    }
  }
})

Sync Mode Components

FlowPresence

Displays presence indicators for other connected users.

<template>
  <FlowPresence
    :users="users"
    :current-user-id="currentUserId"
  />
</template>

Props

PropTypeDescription
usersYjsAwarenessState[]Array of connected users
currentUserIdstringCurrent user's ID (to exclude from display)

Features:

  • User avatars in top-right corner with initials and colors
  • Live cursor positions on canvas
  • Cursor labels with usernames

FlowConnectionStatus

Shows the current sync connection state.

<template>
  <FlowConnectionStatus
    :connected="connected"
    :synced="synced"
    :error="error"
  />
</template>

Props

PropTypeDescription
connectedbooleanWebSocket connected
syncedbooleanInitial sync complete
errorError | nullConnection error if any

States:

  • Green (connected + synced): Fully operational
  • Yellow (connecting): WebSocket connected, waiting for initial sync
  • Red (disconnected): Connection lost, will auto-reconnect

Composables

useFlowData

Converts collection rows into Vue Flow nodes and edges.

const { nodes, edges, getNode, getItem } = useFlowData(
  computed(() => rows),
  {
    parentField: 'parentId',
    positionField: 'position',
    labelField: 'title'
  }
)

Options

OptionTypeDefaultDescription
parentFieldstring'parentId'Field containing parent ID
positionFieldstring'position'Field containing { x, y }
labelFieldstring'title'Field for node label

Returns

PropertyTypeDescription
nodesComputedRef<Node[]>Vue Flow nodes
edgesComputedRef<Edge[]>Vue Flow edges
getNode(id: string) => NodeGet node by ID
getItem(id: string) => TGet original item by ID

useFlowLayout

Provides dagre-based automatic layout for graphs.

const { applyLayout, 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)
}

useFlowMutation

Handles position persistence via the crouton mutation system.

const { updatePosition, pending, error } = useFlowMutation(
  'decisions',
  'position'  // Position field name
)

// Update a node's position
await updatePosition('node-123', { x: 100, y: 200 })

useDebouncedPositionUpdate

Debounced version for drag operations (prevents excessive API calls).

const { debouncedUpdate, pending } = useDebouncedPositionUpdate(
  'decisions',
  'position',
  500  // Debounce delay in ms
)

// Called on each drag event
debouncedUpdate(nodeId, { x, y })

useFlowSync

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<YjsAwarenessState[]>
  user,         // Current user info

  // Node operations
  createNode,
  updateNode,
  updatePosition,
  deleteNode,
  getNode,

  // Presence
  updateCursor,
  selectNode,
  updateGhostNode,
  clearGhostNode,

  // Advanced (Yjs internals)
  ydoc,
  nodesMap
} = useFlowSync({
  flowId: 'flow-123',
  collection: 'decisions'
})

Options

OptionTypeRequiredDescription
flowIdstringYesUnique identifier for the sync room
collectionstringYesCollection name for persistence

Returns

State

PropertyTypeDescription
nodesReadonly<Ref<YjsFlowNode[]>>All nodes in the flow
connectedComputedRef<boolean>WebSocket connected
syncedComputedRef<boolean>Initial sync complete
errorComputedRef<Error | null>Connection error
usersComputedRef<YjsAwarenessState[]>Connected users
userComputedRef<{id, name, color} | null>Current user (from session)

Node Operations

MethodSignatureDescription
createNode(data: Partial<YjsFlowNode>) => stringCreate node, returns ID
updateNode(id: string, updates: Partial<YjsFlowNode>) => voidUpdate node fields
updatePosition(id: string, position: {x, y}) => voidUpdate node position
deleteNode(id: string) => voidDelete node
getNode(id: string) => YjsFlowNode | undefinedGet node by ID

Presence

MethodSignatureDescription
updateCursor(cursor: {x, y} | null) => voidUpdate cursor position
selectNode(nodeId: string | null) => voidBroadcast node selection
updateGhostNode(ghost: YjsGhostNode | null) => voidShow drag preview to others
clearGhostNode() => voidClear ghost node

Example: Custom Sync Controls

<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>

useFlowPresence

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
})

Options

OptionTypeDescription
usersRef<YjsAwarenessState[]>Connected users from useFlowSync
currentUserIdstringCurrent user's ID to exclude

Returns

PropertyTypeDescription
otherUsersComputedRef<YjsAwarenessState[]>Users excluding current
getUsersSelectingNode(nodeId: string) => ComputedRef<YjsAwarenessState[]>Users selecting a node
getNodePresenceStyle(nodeId: string) => ComputedRef<CSSProperties>Presence border style

Example: Node Presence Indicator

<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>

Data Model

Position Field

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
  }
}

Parent Field

The flow layer builds edges from parentId relationships:

{
  "parentId": {
    "type": "string",
    "refTarget": "decisions",
    "meta": {
      "description": "Parent decision for tree structure"
    }
  }
}

Flow Configuration

For advanced configuration, use the flowConfig prop:

<CroutonFlowFlow
  :rows="decisions"
  collection="decisions"
  :flow-config="{
    direction: 'LR',      // Left to right layout
    nodeSpacing: 80,
    rankSpacing: 150,
    autoLayout: 'dagre'
  }"
/>
OptionTypeDefaultDescription
direction'TB' | 'LR' | 'BT' | 'RL''TB'Layout direction
nodeSpacingnumber50Horizontal spacing between nodes
rankSpacingnumber100Vertical spacing between ranks
autoLayout'dagre' | 'none''dagre'Auto-layout algorithm
positionFieldstring'position'Position storage field

Examples

Decision Tree

<script setup>
const { data: decisions } = await useCollectionQuery('decisions', {
  sort: { field: 'createdAt', direction: 'asc' }
})
</script>

<template>
  <div class="h-[600px]">
    <CroutonFlowFlow
      :rows="decisions"
      collection="decisions"
      parent-field="parentId"
      :minimap="true"
      @nodeDblClick="(id) => useCrouton().open('update', 'decisions', [id])"
    />
  </div>
</template>

Workflow Builder

<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>
  <CroutonFlowFlow
    :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>
  </CroutonFlowFlow>
</template>

Multiplayer Flow

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 -->
    <CroutonFlowFlow
      collection="decisions"
      sync
      :flow-id="route.params.id"
      :minimap="true"
    />

    <!-- Connection status -->
    <FlowConnectionStatus
      :connected="connected"
      :synced="synced"
      :error="error"
    />

    <!-- Presence overlay -->
    <FlowPresence
      :users="users"
      :current-user-id="currentUser?.id"
    />

    <!-- 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-heroicons-plus">
        Add Decision
      </UButton>
    </div>
  </div>
</template>

Styling

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 */
}