601 lines
17 KiB
TypeScript
601 lines
17 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 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
|
|
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)
|
|
}
|
|
|
|
// Inner component that uses useReactFlow hook
|
|
function FlowchartEditorInner({ projectId, 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)
|
|
|
|
// 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(() => {
|
|
// TODO: Implement in US-034
|
|
}, [])
|
|
|
|
const handleExport = useCallback(() => {
|
|
// TODO: Implement in US-035
|
|
}, [])
|
|
|
|
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))
|
|
}, [])
|
|
|
|
// 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}
|
|
/>
|
|
<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>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Outer wrapper component with ReactFlowProvider
|
|
export default function FlowchartEditor(props: FlowchartEditorProps) {
|
|
return (
|
|
<ReactFlowProvider>
|
|
<FlowchartEditorInner {...props} />
|
|
</ReactFlowProvider>
|
|
)
|
|
}
|