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

809 lines
23 KiB
TypeScript

'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import ReactFlow, {
Background,
BackgroundVariant,
Controls,
useNodesState,
useEdgesState,
useReactFlow,
ReactFlowProvider,
addEdge,
Connection,
Node,
Edge,
NodeTypes,
EdgeTypes,
MarkerType,
NodeMouseHandler,
EdgeMouseHandler,
} from 'reactflow'
import { nanoid } from 'nanoid'
import 'reactflow/dist/style.css'
import Toolbar from '@/components/editor/Toolbar'
import Toast from '@/components/Toast'
import { createClient } from '@/lib/supabase/client'
import DialogueNode from '@/components/editor/nodes/DialogueNode'
import ChoiceNode from '@/components/editor/nodes/ChoiceNode'
import VariableNode from '@/components/editor/nodes/VariableNode'
import ConditionalEdge from '@/components/editor/edges/ConditionalEdge'
import ContextMenu, { ContextMenuType } from '@/components/editor/ContextMenu'
import ConditionEditor from '@/components/editor/ConditionEditor'
import type { FlowchartData, FlowchartNode, FlowchartEdge, Condition } from '@/types/flowchart'
// LocalStorage key prefix for draft saves
const DRAFT_KEY_PREFIX = 'vnwrite-draft-'
// Debounce delay in ms
const AUTOSAVE_DEBOUNCE_MS = 1000
type ContextMenuState = {
x: number
y: number
type: ContextMenuType
nodeId?: string
edgeId?: string
} | null
type ConditionEditorState = {
edgeId: string
condition?: Condition
} | null
type FlowchartEditorProps = {
projectId: string
projectName: string
initialData: FlowchartData
}
// 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: 'conditional',
markerEnd: {
type: MarkerType.ArrowClosed,
},
}))
}
// Convert React Flow Node type back to our FlowchartNode type
function fromReactFlowNodes(nodes: Node[]): FlowchartNode[] {
return nodes.map((node) => ({
id: node.id,
type: node.type as 'dialogue' | 'choice' | 'variable',
position: node.position,
data: node.data,
})) as FlowchartNode[]
}
// Convert React Flow Edge type back to our FlowchartEdge type
function fromReactFlowEdges(edges: Edge[]): FlowchartEdge[] {
return edges.map((edge) => ({
id: edge.id,
source: edge.source,
sourceHandle: edge.sourceHandle ?? undefined,
target: edge.target,
targetHandle: edge.targetHandle ?? undefined,
data: edge.data,
}))
}
// Get LocalStorage key for a project
function getDraftKey(projectId: string): string {
return `${DRAFT_KEY_PREFIX}${projectId}`
}
// Save draft to LocalStorage
function saveDraft(projectId: string, data: FlowchartData): void {
try {
localStorage.setItem(getDraftKey(projectId), JSON.stringify(data))
} catch (error) {
console.error('Failed to save draft to LocalStorage:', error)
}
}
// Load draft from LocalStorage
function loadDraft(projectId: string): FlowchartData | null {
try {
const draft = localStorage.getItem(getDraftKey(projectId))
if (!draft) return null
return JSON.parse(draft) as FlowchartData
} catch (error) {
console.error('Failed to load draft from LocalStorage:', error)
return null
}
}
// Clear draft from LocalStorage
export function clearDraft(projectId: string): void {
try {
localStorage.removeItem(getDraftKey(projectId))
} catch (error) {
console.error('Failed to clear draft from LocalStorage:', error)
}
}
// Compare two FlowchartData objects for equality
function flowchartDataEquals(a: FlowchartData, b: FlowchartData): boolean {
return JSON.stringify(a) === JSON.stringify(b)
}
// Validate imported flowchart data structure
function isValidFlowchartData(data: unknown): data is FlowchartData {
if (!data || typeof data !== 'object') return false
const obj = data as Record<string, unknown>
if (!Array.isArray(obj.nodes)) return false
if (!Array.isArray(obj.edges)) return false
return true
}
// Inner component that uses useReactFlow hook
function FlowchartEditorInner({ projectId, projectName, initialData }: FlowchartEditorProps) {
// Define custom node types - memoized to prevent re-renders
const nodeTypes: NodeTypes = useMemo(
() => ({
dialogue: DialogueNode,
choice: ChoiceNode,
variable: VariableNode,
}),
[]
)
// Define custom edge types - memoized to prevent re-renders
const edgeTypes: EdgeTypes = useMemo(
() => ({
conditional: ConditionalEdge,
}),
[]
)
const { getViewport, screenToFlowPosition } = useReactFlow()
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null)
const [conditionEditor, setConditionEditor] = useState<ConditionEditorState>(null)
const [isSaving, setIsSaving] = useState(false)
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null)
const [importConfirmDialog, setImportConfirmDialog] = useState<{
pendingData: FlowchartData
} | null>(null)
// Ref for hidden file input
const fileInputRef = useRef<HTMLInputElement>(null)
// Check for saved draft on initial render (lazy initialization)
const [draftState, setDraftState] = useState<{
showPrompt: boolean
savedDraft: FlowchartData | null
}>(() => {
// This runs only once on initial render (client-side)
if (typeof window === 'undefined') {
return { showPrompt: false, savedDraft: null }
}
const draft = loadDraft(projectId)
if (draft && !flowchartDataEquals(draft, initialData)) {
return { showPrompt: true, savedDraft: draft }
}
return { showPrompt: false, savedDraft: null }
})
const [nodes, setNodes, onNodesChange] = useNodesState(
toReactFlowNodes(initialData.nodes)
)
const [edges, setEdges, onEdgesChange] = useEdgesState(
toReactFlowEdges(initialData.edges)
)
// Track debounce timer
const saveTimerRef = useRef<NodeJS.Timeout | null>(null)
// Debounced auto-save to LocalStorage
useEffect(() => {
// Don't save while draft prompt is showing
if (draftState.showPrompt) return
// Clear existing timer
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current)
}
// Set new timer
saveTimerRef.current = setTimeout(() => {
const currentData: FlowchartData = {
nodes: fromReactFlowNodes(nodes),
edges: fromReactFlowEdges(edges),
}
saveDraft(projectId, currentData)
}, AUTOSAVE_DEBOUNCE_MS)
// Cleanup on unmount
return () => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current)
}
}
}, [nodes, edges, projectId, draftState.showPrompt])
// Handle restoring draft
const handleRestoreDraft = useCallback(() => {
if (draftState.savedDraft) {
setNodes(toReactFlowNodes(draftState.savedDraft.nodes))
setEdges(toReactFlowEdges(draftState.savedDraft.edges))
}
setDraftState({ showPrompt: false, savedDraft: null })
}, [draftState.savedDraft, setNodes, setEdges])
// Handle discarding draft
const handleDiscardDraft = useCallback(() => {
clearDraft(projectId)
setDraftState({ showPrompt: false, savedDraft: null })
}, [projectId])
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: 'conditional',
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(async () => {
if (isSaving) return
setIsSaving(true)
try {
const supabase = createClient()
// Convert React Flow state to FlowchartData
const flowchartData: FlowchartData = {
nodes: fromReactFlowNodes(nodes),
edges: fromReactFlowEdges(edges),
}
const { error } = await supabase
.from('projects')
.update({
flowchart_data: flowchartData,
updated_at: new Date().toISOString(),
})
.eq('id', projectId)
if (error) {
throw error
}
// Clear LocalStorage draft after successful save
clearDraft(projectId)
setToast({ message: 'Project saved successfully', type: 'success' })
} catch (error) {
console.error('Failed to save project:', error)
setToast({ message: 'Failed to save project. Please try again.', type: 'error' })
} finally {
setIsSaving(false)
}
}, [isSaving, nodes, edges, projectId])
const handleExport = useCallback(() => {
// Convert React Flow state to FlowchartData
const flowchartData: FlowchartData = {
nodes: fromReactFlowNodes(nodes),
edges: fromReactFlowEdges(edges),
}
// Create pretty-printed JSON
const jsonContent = JSON.stringify(flowchartData, null, 2)
// Create blob with JSON content
const blob = new Blob([jsonContent], { type: 'application/json' })
// Create download URL
const url = URL.createObjectURL(blob)
// Create temporary link element and trigger download
const link = document.createElement('a')
link.href = url
link.download = `${projectName}.vnflow`
document.body.appendChild(link)
link.click()
// Cleanup
document.body.removeChild(link)
URL.revokeObjectURL(url)
}, [nodes, edges, projectName])
// Check if current flowchart has unsaved changes
const hasUnsavedChanges = useCallback(() => {
const currentData: FlowchartData = {
nodes: fromReactFlowNodes(nodes),
edges: fromReactFlowEdges(edges),
}
return !flowchartDataEquals(currentData, initialData)
}, [nodes, edges, initialData])
// Load imported data into React Flow
const loadImportedData = useCallback(
(data: FlowchartData) => {
setNodes(toReactFlowNodes(data.nodes))
setEdges(toReactFlowEdges(data.edges))
setToast({ message: 'Project imported successfully', type: 'success' })
},
[setNodes, setEdges]
)
// Handle file selection from file picker
const handleFileSelect = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
// Reset file input so same file can be selected again
event.target.value = ''
const reader = new FileReader()
reader.onload = (e) => {
try {
const content = e.target?.result as string
const parsedData = JSON.parse(content)
// Validate the imported data
if (!isValidFlowchartData(parsedData)) {
setToast({
message: 'Invalid file format. File must contain nodes and edges arrays.',
type: 'error',
})
return
}
// Check if current project has unsaved changes
if (hasUnsavedChanges()) {
// Show confirmation dialog
setImportConfirmDialog({ pendingData: parsedData })
} else {
// Load data directly
loadImportedData(parsedData)
}
} catch {
setToast({
message: 'Failed to parse file. Please ensure it is valid JSON.',
type: 'error',
})
}
}
reader.onerror = () => {
setToast({ message: 'Failed to read file.', type: 'error' })
}
reader.readAsText(file)
},
[hasUnsavedChanges, loadImportedData]
)
// Handle import button click - opens file picker
const handleImport = useCallback(() => {
fileInputRef.current?.click()
}, [])
// Confirm import (discard unsaved changes)
const handleConfirmImport = useCallback(() => {
if (importConfirmDialog?.pendingData) {
loadImportedData(importConfirmDialog.pendingData)
}
setImportConfirmDialog(null)
}, [importConfirmDialog, loadImportedData])
// Cancel import
const handleCancelImport = useCallback(() => {
setImportConfirmDialog(null)
}, [])
// 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))
}, [])
// Context menu handlers
const closeContextMenu = useCallback(() => {
setContextMenu(null)
}, [])
// Handle right-click on canvas (pane)
const onPaneContextMenu = useCallback(
(event: React.MouseEvent) => {
event.preventDefault()
setContextMenu({
x: event.clientX,
y: event.clientY,
type: 'canvas',
})
},
[]
)
// Handle right-click on node
const onNodeContextMenu: NodeMouseHandler = useCallback(
(event, node) => {
event.preventDefault()
setContextMenu({
x: event.clientX,
y: event.clientY,
type: 'node',
nodeId: node.id,
})
},
[]
)
// Handle right-click on edge
const onEdgeContextMenu: EdgeMouseHandler = useCallback(
(event, edge) => {
event.preventDefault()
setContextMenu({
x: event.clientX,
y: event.clientY,
type: 'edge',
edgeId: edge.id,
})
},
[]
)
// Add node at specific position (for context menu)
const handleAddNodeAtPosition = useCallback(
(type: 'dialogue' | 'choice' | 'variable') => {
if (!contextMenu) return
// Convert screen position to flow position
const position = screenToFlowPosition({
x: contextMenu.x,
y: contextMenu.y,
})
let newNode: Node
if (type === 'dialogue') {
newNode = {
id: nanoid(),
type: 'dialogue',
position,
data: { speaker: '', text: '' },
}
} else if (type === 'choice') {
newNode = {
id: nanoid(),
type: 'choice',
position,
data: {
prompt: '',
options: [
{ id: nanoid(), label: '' },
{ id: nanoid(), label: '' },
],
},
}
} else {
newNode = {
id: nanoid(),
type: 'variable',
position,
data: {
variableName: '',
operation: 'set',
value: 0,
},
}
}
setNodes((nodes) => [...nodes, newNode])
},
[contextMenu, screenToFlowPosition, setNodes]
)
// Delete selected node from context menu
const handleDeleteNode = useCallback(() => {
if (!contextMenu?.nodeId) return
setNodes((nodes) => nodes.filter((n) => n.id !== contextMenu.nodeId))
}, [contextMenu, setNodes])
// Delete selected edge from context menu
const handleDeleteEdge = useCallback(() => {
if (!contextMenu?.edgeId) return
setEdges((edges) => edges.filter((e) => e.id !== contextMenu.edgeId))
}, [contextMenu, setEdges])
// Open condition editor for an edge
const openConditionEditor = useCallback(
(edgeId: string) => {
const edge = edges.find((e) => e.id === edgeId)
if (!edge) return
setConditionEditor({
edgeId,
condition: edge.data?.condition,
})
},
[edges]
)
// Add condition to edge (opens ConditionEditor modal)
const handleAddCondition = useCallback(() => {
if (!contextMenu?.edgeId) return
openConditionEditor(contextMenu.edgeId)
}, [contextMenu, openConditionEditor])
// Handle double-click on edge to open condition editor
const onEdgeDoubleClick = useCallback(
(_event: React.MouseEvent, edge: Edge) => {
openConditionEditor(edge.id)
},
[openConditionEditor]
)
// Save condition to edge
const handleSaveCondition = useCallback(
(edgeId: string, condition: Condition) => {
setEdges((eds) =>
eds.map((edge) =>
edge.id === edgeId
? { ...edge, data: { ...edge.data, condition } }
: edge
)
)
setConditionEditor(null)
},
[setEdges]
)
// Remove condition from edge
const handleRemoveCondition = useCallback(
(edgeId: string) => {
setEdges((eds) =>
eds.map((edge) => {
if (edge.id !== edgeId) return edge
// Remove condition from data
const newData = { ...edge.data }
delete newData.condition
return { ...edge, data: newData }
})
)
setConditionEditor(null)
},
[setEdges]
)
// Close condition editor
const closeConditionEditor = useCallback(() => {
setConditionEditor(null)
}, [])
return (
<div className="flex h-full w-full flex-col">
<Toolbar
onAddDialogue={handleAddDialogue}
onAddChoice={handleAddChoice}
onAddVariable={handleAddVariable}
onSave={handleSave}
onExport={handleExport}
onImport={handleImport}
isSaving={isSaving}
/>
<div className="flex-1">
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onEdgesDelete={onEdgesDelete}
onConnect={onConnect}
onPaneContextMenu={onPaneContextMenu}
onNodeContextMenu={onNodeContextMenu}
onEdgeContextMenu={onEdgeContextMenu}
onEdgeDoubleClick={onEdgeDoubleClick}
deleteKeyCode={['Delete', 'Backspace']}
fitView
>
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
<Controls position="bottom-right" />
</ReactFlow>
</div>
{contextMenu && (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
type={contextMenu.type}
onClose={closeContextMenu}
onAddDialogue={() => handleAddNodeAtPosition('dialogue')}
onAddChoice={() => handleAddNodeAtPosition('choice')}
onAddVariable={() => handleAddNodeAtPosition('variable')}
onDelete={
contextMenu.type === 'node' ? handleDeleteNode : handleDeleteEdge
}
onAddCondition={handleAddCondition}
/>
)}
{conditionEditor && (
<ConditionEditor
edgeId={conditionEditor.edgeId}
condition={conditionEditor.condition}
onSave={handleSaveCondition}
onRemove={handleRemoveCondition}
onCancel={closeConditionEditor}
/>
)}
{/* Draft restoration prompt */}
{draftState.showPrompt && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-zinc-900">
<h2 className="mb-2 text-lg font-semibold text-zinc-900 dark:text-white">
Unsaved Draft Found
</h2>
<p className="mb-4 text-sm text-zinc-600 dark:text-zinc-400">
A local draft was found that differs from the saved version. Would
you like to restore it or discard it?
</p>
<div className="flex gap-3">
<button
onClick={handleRestoreDraft}
className="flex-1 rounded-md bg-blue-500 px-4 py-2 text-sm font-medium text-white hover:bg-blue-600"
>
Restore Draft
</button>
<button
onClick={handleDiscardDraft}
className="flex-1 rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700"
>
Discard
</button>
</div>
</div>
</div>
)}
{/* Import confirmation dialog */}
{importConfirmDialog && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-zinc-900">
<h2 className="mb-2 text-lg font-semibold text-zinc-900 dark:text-white">
Unsaved Changes
</h2>
<p className="mb-4 text-sm text-zinc-600 dark:text-zinc-400">
You have unsaved changes. Importing a new file will discard your
current work. Are you sure you want to continue?
</p>
<div className="flex gap-3">
<button
onClick={handleConfirmImport}
className="flex-1 rounded-md bg-red-500 px-4 py-2 text-sm font-medium text-white hover:bg-red-600"
>
Discard &amp; Import
</button>
<button
onClick={handleCancelImport}
className="flex-1 rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700"
>
Cancel
</button>
</div>
</div>
</div>
)}
{/* Hidden file input for import */}
<input
ref={fileInputRef}
type="file"
accept=".vnflow,.json"
onChange={handleFileSelect}
className="hidden"
/>
{/* Toast notification */}
{toast && (
<Toast
message={toast.message}
type={toast.type}
onClose={() => setToast(null)}
/>
)}
</div>
)
}
// Outer wrapper component with ReactFlowProvider
export default function FlowchartEditor(props: FlowchartEditorProps) {
return (
<ReactFlowProvider>
<FlowchartEditorInner {...props} />
</ReactFlowProvider>
)
}