WebVNWrite/src/app/editor/[projectId]/FlowchartEditor.tsx

609 lines
19 KiB
TypeScript

'use client'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import ReactFlow, {
Background,
BackgroundVariant,
Controls,
useNodesState,
useEdgesState,
useReactFlow,
ReactFlowProvider,
addEdge,
Connection,
Node,
Edge,
NodeTypes,
MarkerType,
} from 'reactflow'
import { nanoid } from 'nanoid'
import 'reactflow/dist/style.css'
import Toolbar from '@/components/editor/Toolbar'
import DialogueNode from '@/components/editor/nodes/DialogueNode'
import ChoiceNode from '@/components/editor/nodes/ChoiceNode'
import VariableNode from '@/components/editor/nodes/VariableNode'
import ProjectSettingsModal from '@/components/editor/ProjectSettingsModal'
import ConditionEditor from '@/components/editor/ConditionEditor'
import ExportValidationModal, { type ValidationIssue } from '@/components/editor/ExportValidationModal'
import { EditorProvider } from '@/components/editor/EditorContext'
import Toast from '@/components/Toast'
import { RealtimeConnection, type ConnectionState } from '@/lib/collaboration/realtime'
import ShareModal from '@/components/editor/ShareModal'
import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart'
type FlowchartEditorProps = {
projectId: string
userId: string
isOwner: boolean
initialData: FlowchartData
needsMigration?: boolean
}
// Convert our FlowchartNode type to React Flow Node type
function toReactFlowNodes(nodes: FlowchartNode[]): Node[] {
return nodes.map((node) => ({
id: node.id,
type: node.type,
position: node.position,
data: node.data,
}))
}
// Convert our FlowchartEdge type to React Flow Edge type
function toReactFlowEdges(edges: FlowchartEdge[]): Edge[] {
return edges.map((edge) => ({
id: edge.id,
source: edge.source,
sourceHandle: edge.sourceHandle,
target: edge.target,
targetHandle: edge.targetHandle,
data: edge.data,
type: 'smoothstep',
markerEnd: {
type: MarkerType.ArrowClosed,
},
}))
}
const RANDOM_COLORS = [
'#EF4444', '#F97316', '#F59E0B', '#10B981',
'#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6',
'#6366F1', '#F43F5E', '#84CC16', '#06B6D4',
]
function randomHexColor(): string {
return RANDOM_COLORS[Math.floor(Math.random() * RANDOM_COLORS.length)]
}
// Compute auto-migration of existing free-text values to character/variable definitions
function computeMigration(initialData: FlowchartData, shouldMigrate: boolean) {
if (!shouldMigrate) {
return {
characters: initialData.characters,
variables: initialData.variables,
nodes: initialData.nodes,
edges: initialData.edges,
toastMessage: null as string | null,
}
}
// Collect unique speaker names from dialogue nodes
const speakerNames = new Set<string>()
initialData.nodes.forEach((node) => {
if (node.type === 'dialogue' && node.data?.speaker) {
speakerNames.add(node.data.speaker)
}
})
// Create character definitions from unique speaker names
const newCharacters: Character[] = []
const speakerToCharacterId = new Map<string, string>()
speakerNames.forEach((name) => {
const id = nanoid()
newCharacters.push({ id, name, color: randomHexColor() })
speakerToCharacterId.set(name, id)
})
// Collect unique variable names from variable nodes, edge conditions, and choice option conditions
const variableNames = new Set<string>()
initialData.nodes.forEach((node) => {
if (node.type === 'variable' && node.data.variableName) {
variableNames.add(node.data.variableName)
}
if (node.type === 'choice' && node.data.options) {
node.data.options.forEach((opt) => {
if (opt.condition?.variableName) {
variableNames.add(opt.condition.variableName)
}
})
}
})
initialData.edges.forEach((edge) => {
if (edge.data?.condition?.variableName) {
variableNames.add(edge.data.condition.variableName)
}
})
// Create variable definitions from unique variable names
const newVariables: Variable[] = []
const varNameToId = new Map<string, string>()
variableNames.forEach((name) => {
const id = nanoid()
newVariables.push({ id, name, type: 'numeric', initialValue: 0 })
varNameToId.set(name, id)
})
// If nothing to migrate, return original data
if (newCharacters.length === 0 && newVariables.length === 0) {
return {
characters: initialData.characters,
variables: initialData.variables,
nodes: initialData.nodes,
edges: initialData.edges,
toastMessage: null as string | null,
}
}
// Update nodes with characterId/variableId references
const migratedNodes = initialData.nodes.map((node) => {
if (node.type === 'dialogue' && node.data.speaker) {
const characterId = speakerToCharacterId.get(node.data.speaker)
if (characterId) {
return { ...node, data: { ...node.data, characterId } }
}
}
if (node.type === 'variable' && node.data.variableName) {
const variableId = varNameToId.get(node.data.variableName)
if (variableId) {
return { ...node, data: { ...node.data, variableId } }
}
}
if (node.type === 'choice' && node.data.options) {
const updatedOptions = node.data.options.map((opt) => {
if (opt.condition?.variableName) {
const variableId = varNameToId.get(opt.condition.variableName)
if (variableId) {
return { ...opt, condition: { ...opt.condition, variableId } }
}
}
return opt
})
return { ...node, data: { ...node.data, options: updatedOptions } }
}
return node
}) as typeof initialData.nodes
// Update edges with variableId references
const migratedEdges = initialData.edges.map((edge) => {
if (edge.data?.condition?.variableName) {
const variableId = varNameToId.get(edge.data.condition.variableName)
if (variableId) {
return {
...edge,
data: { ...edge.data, condition: { ...edge.data.condition, variableId } },
}
}
}
return edge
})
// Build toast message
const parts: string[] = []
if (newCharacters.length > 0) {
parts.push(`${newCharacters.length} character${newCharacters.length > 1 ? 's' : ''}`)
}
if (newVariables.length > 0) {
parts.push(`${newVariables.length} variable${newVariables.length > 1 ? 's' : ''}`)
}
return {
characters: newCharacters,
variables: newVariables,
nodes: migratedNodes,
edges: migratedEdges,
toastMessage: `Auto-imported ${parts.join(' and ')} from existing data`,
}
}
// Inner component that uses useReactFlow hook
function FlowchartEditorInner({ projectId, userId, isOwner, initialData, needsMigration }: FlowchartEditorProps) {
// Define custom node types - memoized to prevent re-renders
const nodeTypes: NodeTypes = useMemo(
() => ({
dialogue: DialogueNode,
choice: ChoiceNode,
variable: VariableNode,
}),
[]
)
const { getViewport } = useReactFlow()
// Compute migrated data once on first render using a lazy state initializer
const [migratedData] = useState(() => computeMigration(initialData, !!needsMigration))
const [nodes, setNodes, onNodesChange] = useNodesState(
toReactFlowNodes(migratedData.nodes)
)
const [edges, setEdges, onEdgesChange] = useEdgesState(
toReactFlowEdges(migratedData.edges)
)
const [characters, setCharacters] = useState<Character[]>(migratedData.characters)
const [variables, setVariables] = useState<Variable[]>(migratedData.variables)
const [showSettings, setShowSettings] = useState(false)
const [showShare, setShowShare] = useState(false)
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null)
const [toastMessage, setToastMessage] = useState<string | null>(migratedData.toastMessage)
const [validationIssues, setValidationIssues] = useState<ValidationIssue[] | null>(null)
const [warningNodeIds, setWarningNodeIds] = useState<Set<string>>(new Set())
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected')
const realtimeRef = useRef<RealtimeConnection | null>(null)
// Connect to Supabase Realtime channel on mount, disconnect on unmount
useEffect(() => {
const connection = new RealtimeConnection(projectId, userId, {
onConnectionStateChange: setConnectionState,
})
realtimeRef.current = connection
connection.connect()
return () => {
connection.disconnect()
realtimeRef.current = null
}
}, [projectId, userId])
const handleAddCharacter = useCallback(
(name: string, color: string): string => {
const id = nanoid()
const newCharacter: Character = { id, name, color }
setCharacters((prev) => [...prev, newCharacter])
return id
},
[]
)
const handleAddVariableDefinition = useCallback(
(name: string, type: 'numeric' | 'string' | 'boolean', initialValue: number | string | boolean): string => {
const id = nanoid()
const newVariable: Variable = { id, name, type, initialValue }
setVariables((prev) => [...prev, newVariable])
return id
},
[]
)
const editorContextValue = useMemo(
() => ({ characters, onAddCharacter: handleAddCharacter, variables, onAddVariable: handleAddVariableDefinition }),
[characters, handleAddCharacter, variables, handleAddVariableDefinition]
)
const getCharacterUsageCount = useCallback(
(characterId: string) => {
return nodes.filter((n) => n.type === 'dialogue' && n.data?.characterId === characterId).length
},
[nodes]
)
const getVariableUsageCount = useCallback(
(variableId: string) => {
const nodeCount = nodes.filter(
(n) => n.type === 'variable' && n.data?.variableId === variableId
).length
const edgeCount = edges.filter(
(e) => e.data?.condition?.variableId === variableId
).length
const choiceOptionCount = nodes.filter(
(n) => n.type === 'choice'
).reduce((count, n) => {
const options = n.data?.options || []
return count + options.filter(
(opt: { condition?: { variableId?: string } }) => opt.condition?.variableId === variableId
).length
}, 0)
return nodeCount + edgeCount + choiceOptionCount
},
[nodes, edges]
)
const onConnect = useCallback(
(params: Connection) => {
if (!params.source || !params.target) return
const newEdge: Edge = {
id: nanoid(),
source: params.source,
target: params.target,
sourceHandle: params.sourceHandle,
targetHandle: params.targetHandle,
type: 'smoothstep',
markerEnd: {
type: MarkerType.ArrowClosed,
},
}
setEdges((eds) => addEdge(newEdge, eds))
},
[setEdges]
)
// Get center position of current viewport for placing new nodes
const getViewportCenter = useCallback(() => {
const viewport = getViewport()
// Calculate center based on viewport dimensions (assume ~800x600 visible area)
// Adjust based on zoom level
const centerX = (-viewport.x + 400) / viewport.zoom
const centerY = (-viewport.y + 300) / viewport.zoom
return { x: centerX, y: centerY }
}, [getViewport])
// Add dialogue node at viewport center
const handleAddDialogue = useCallback(() => {
const position = getViewportCenter()
const newNode: Node = {
id: nanoid(),
type: 'dialogue',
position,
data: { speaker: '', text: '' },
}
setNodes((nodes) => [...nodes, newNode])
}, [getViewportCenter, setNodes])
const handleAddChoice = useCallback(() => {
const position = getViewportCenter()
const newNode: Node = {
id: nanoid(),
type: 'choice',
position,
data: {
prompt: '',
options: [
{ id: nanoid(), label: '' },
{ id: nanoid(), label: '' },
],
},
}
setNodes((nodes) => [...nodes, newNode])
}, [getViewportCenter, setNodes])
const handleAddVariable = useCallback(() => {
const position = getViewportCenter()
const newNode: Node = {
id: nanoid(),
type: 'variable',
position,
data: {
variableName: '',
operation: 'set',
value: 0,
},
}
setNodes((nodes) => [...nodes, newNode])
}, [getViewportCenter, setNodes])
const handleSave = useCallback(() => {
// TODO: Implement in US-034
}, [])
const performExport = useCallback(() => {
// TODO: Actual export logic in US-035
setValidationIssues(null)
setWarningNodeIds(new Set())
}, [])
const handleExport = useCallback(() => {
const issues: ValidationIssue[] = []
const characterIds = new Set(characters.map((c) => c.id))
const variableIds = new Set(variables.map((v) => v.id))
// Scan nodes for undefined references
nodes.forEach((node) => {
if (node.type === 'dialogue' && node.data?.characterId) {
if (!characterIds.has(node.data.characterId)) {
issues.push({
nodeId: node.id,
nodeType: 'dialogue',
contentSnippet: node.data.text
? `"${node.data.text.slice(0, 40)}${node.data.text.length > 40 ? '...' : ''}"`
: 'Empty dialogue',
undefinedReference: node.data.characterId,
referenceType: 'character',
})
}
}
if (node.type === 'variable' && node.data?.variableId) {
if (!variableIds.has(node.data.variableId)) {
issues.push({
nodeId: node.id,
nodeType: 'variable',
contentSnippet: node.data.variableName
? `Variable: ${node.data.variableName}`
: 'Variable node',
undefinedReference: node.data.variableId,
referenceType: 'variable',
})
}
}
if (node.type === 'choice' && node.data?.options) {
node.data.options.forEach((opt: { id: string; label: string; condition?: { variableId?: string; variableName?: string } }) => {
if (opt.condition?.variableId && !variableIds.has(opt.condition.variableId)) {
issues.push({
nodeId: node.id,
nodeType: 'choice',
contentSnippet: opt.label
? `Option: "${opt.label.slice(0, 30)}${opt.label.length > 30 ? '...' : ''}"`
: 'Choice option',
undefinedReference: opt.condition.variableId,
referenceType: 'variable',
})
}
})
}
})
// Scan edges for undefined variable references in conditions
edges.forEach((edge) => {
if (edge.data?.condition?.variableId && !variableIds.has(edge.data.condition.variableId)) {
issues.push({
nodeId: edge.id,
nodeType: 'edge',
contentSnippet: edge.data.condition.variableName
? `Condition on: ${edge.data.condition.variableName}`
: 'Edge condition',
undefinedReference: edge.data.condition.variableId,
referenceType: 'variable',
})
}
})
if (issues.length === 0) {
performExport()
} else {
setValidationIssues(issues)
setWarningNodeIds(new Set(issues.map((i) => i.nodeId)))
}
}, [nodes, edges, characters, variables, performExport])
const handleExportAnyway = useCallback(() => {
performExport()
}, [performExport])
const handleExportCancel = useCallback(() => {
setValidationIssues(null)
setWarningNodeIds(new Set())
}, [])
const handleImport = useCallback(() => {
// TODO: Implement in US-036
}, [])
// Handle edge deletion via keyboard (Delete/Backspace)
const onEdgesDelete = useCallback((deletedEdges: Edge[]) => {
// Edges are already removed from state by onEdgesChange
// This callback can be used for additional logic like logging or dirty state
console.log('Deleted edges:', deletedEdges.map((e) => e.id))
}, [])
// Handle edge click to open condition editor
const onEdgeClick = useCallback((_event: React.MouseEvent, edge: Edge) => {
setSelectedEdgeId(edge.id)
}, [])
// Handle condition change from ConditionEditor
const handleConditionChange = useCallback(
(edgeId: string, condition: Condition | undefined) => {
setEdges((eds) =>
eds.map((edge) =>
edge.id === edgeId
? { ...edge, data: condition ? { condition } : undefined }
: edge
)
)
},
[setEdges]
)
// Apply warning styles to nodes with undefined references
const styledNodes = useMemo(
() =>
warningNodeIds.size === 0
? nodes
: nodes.map((node) =>
warningNodeIds.has(node.id)
? { ...node, className: 'export-warning-node' }
: node
),
[nodes, warningNodeIds]
)
// Get the selected edge's condition data
const selectedEdge = useMemo(
() => (selectedEdgeId ? edges.find((e) => e.id === selectedEdgeId) : null),
[selectedEdgeId, edges]
)
return (
<EditorProvider value={editorContextValue}>
<div className="flex h-full w-full flex-col">
<Toolbar
onAddDialogue={handleAddDialogue}
onAddChoice={handleAddChoice}
onAddVariable={handleAddVariable}
onSave={handleSave}
onExport={handleExport}
onImport={handleImport}
onProjectSettings={() => setShowSettings(true)}
onShare={() => setShowShare(true)}
connectionState={connectionState}
/>
<div className="flex-1">
<ReactFlow
nodes={styledNodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onEdgesDelete={onEdgesDelete}
onEdgeClick={onEdgeClick}
onConnect={onConnect}
deleteKeyCode={['Delete', 'Backspace']}
fitView
>
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
<Controls position="bottom-right" />
</ReactFlow>
</div>
{showSettings && (
<ProjectSettingsModal
projectId={projectId}
characters={characters}
variables={variables}
onCharactersChange={setCharacters}
onVariablesChange={setVariables}
onClose={() => setShowSettings(false)}
getCharacterUsageCount={getCharacterUsageCount}
getVariableUsageCount={getVariableUsageCount}
/>
)}
{showShare && (
<ShareModal
projectId={projectId}
isOwner={isOwner}
onClose={() => setShowShare(false)}
/>
)}
{selectedEdge && (
<ConditionEditor
edgeId={selectedEdge.id}
condition={selectedEdge.data?.condition}
onChange={handleConditionChange}
onClose={() => setSelectedEdgeId(null)}
/>
)}
{validationIssues && (
<ExportValidationModal
issues={validationIssues}
onExportAnyway={handleExportAnyway}
onCancel={handleExportCancel}
/>
)}
{toastMessage && (
<Toast
message={toastMessage}
type="success"
onClose={() => setToastMessage(null)}
/>
)}
</div>
</EditorProvider>
)
}
// Outer wrapper component with ReactFlowProvider
export default function FlowchartEditor(props: FlowchartEditorProps) {
return (
<ReactFlowProvider>
<FlowchartEditorInner {...props} />
</ReactFlowProvider>
)
}