developing #10
|
|
@ -1,6 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
|
|
@ -27,20 +27,42 @@ 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 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, type PresenceUser } from '@/lib/collaboration/realtime'
|
||||
import { CRDTManager } from '@/lib/collaboration/crdt'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import ShareModal from '@/components/editor/ShareModal'
|
||||
import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, 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
|
||||
userId: string
|
||||
userDisplayName: string
|
||||
isOwner: boolean
|
||||
initialData: FlowchartData
|
||||
needsMigration?: boolean
|
||||
}
|
||||
|
|
@ -71,6 +93,205 @@ function toReactFlowEdges(edges: FlowchartEdge[]): Edge[] {
|
|||
}))
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Find the first node (node with no incoming edges)
|
||||
function findFirstNode(nodes: FlowchartNode[], edges: FlowchartEdge[]): FlowchartNode | null {
|
||||
const targetIds = new Set(edges.map((e) => e.target))
|
||||
const startNodes = nodes.filter((n) => !targetIds.has(n.id))
|
||||
return startNodes[0] || nodes[0] || null
|
||||
}
|
||||
|
||||
// Get outgoing edge from a node
|
||||
function getOutgoingEdge(nodeId: string, edges: FlowchartEdge[], sourceHandle?: string): FlowchartEdge | undefined {
|
||||
return edges.find((e) => e.source === nodeId && (sourceHandle === undefined || e.sourceHandle === sourceHandle))
|
||||
}
|
||||
|
||||
// Get all outgoing edges from a node
|
||||
function getOutgoingEdges(nodeId: string, edges: FlowchartEdge[]): FlowchartEdge[] {
|
||||
return edges.filter((e) => e.source === nodeId)
|
||||
}
|
||||
|
||||
// Convert flowchart to Ren'Py JSON format
|
||||
function convertToRenpyFormat(
|
||||
nodes: FlowchartNode[],
|
||||
edges: FlowchartEdge[],
|
||||
projectName: string
|
||||
): { projectName: string; exportedAt: string; sections: Record<string, unknown[]> } {
|
||||
const nodeMap = new Map<string, FlowchartNode>(nodes.map((n) => [n.id, n]))
|
||||
const visited = new Set<string>()
|
||||
const sections: Record<string, unknown[]> = {}
|
||||
let currentSectionName = 'start'
|
||||
let currentSection: unknown[] = []
|
||||
|
||||
const nodeLabels = new Map<string, string>()
|
||||
let labelCounter = 0
|
||||
|
||||
function getNodeLabel(nodeId: string): string {
|
||||
if (!nodeLabels.has(nodeId)) {
|
||||
const node = nodeMap.get(nodeId)
|
||||
if (node?.type === 'dialogue' && node.data.speaker) {
|
||||
nodeLabels.set(nodeId, `section_${node.data.speaker.toLowerCase().replace(/\s+/g, '_')}_${labelCounter++}`)
|
||||
} else {
|
||||
nodeLabels.set(nodeId, `section_${labelCounter++}`)
|
||||
}
|
||||
}
|
||||
return nodeLabels.get(nodeId)!
|
||||
}
|
||||
|
||||
function processNode(nodeId: string): void {
|
||||
if (visited.has(nodeId)) return
|
||||
visited.add(nodeId)
|
||||
|
||||
const node = nodeMap.get(nodeId)
|
||||
if (!node) return
|
||||
|
||||
if (node.type === 'dialogue') {
|
||||
const outgoingEdge = getOutgoingEdge(nodeId, edges)
|
||||
const renpyNode: Record<string, unknown> = {
|
||||
type: 'dialogue',
|
||||
speaker: node.data.speaker || '',
|
||||
text: node.data.text,
|
||||
}
|
||||
if (outgoingEdge) {
|
||||
renpyNode.next = visited.has(outgoingEdge.target) ? getNodeLabel(outgoingEdge.target) : outgoingEdge.target
|
||||
if (outgoingEdge.data?.condition) {
|
||||
renpyNode.condition = outgoingEdge.data.condition
|
||||
}
|
||||
}
|
||||
currentSection.push(renpyNode)
|
||||
if (outgoingEdge && !visited.has(outgoingEdge.target)) {
|
||||
processNode(outgoingEdge.target)
|
||||
}
|
||||
} else if (node.type === 'choice') {
|
||||
const outEdges = getOutgoingEdges(nodeId, edges)
|
||||
const choices = outEdges.map((edge) => {
|
||||
const choiceData: Record<string, unknown> = {
|
||||
label: edge.sourceHandle || 'Choice',
|
||||
next: edge.target,
|
||||
}
|
||||
if (edge.data?.condition) {
|
||||
choiceData.condition = edge.data.condition
|
||||
}
|
||||
return choiceData
|
||||
})
|
||||
currentSection.push({
|
||||
type: 'menu',
|
||||
prompt: node.data.prompt || '',
|
||||
choices,
|
||||
})
|
||||
// Process each choice target as a new section
|
||||
outEdges.forEach((edge) => {
|
||||
if (!visited.has(edge.target)) {
|
||||
sections[currentSectionName] = currentSection
|
||||
currentSectionName = getNodeLabel(edge.target)
|
||||
currentSection = []
|
||||
processNode(edge.target)
|
||||
}
|
||||
})
|
||||
} else if (node.type === 'variable') {
|
||||
const outgoingEdge = getOutgoingEdge(nodeId, edges)
|
||||
const renpyNode: Record<string, unknown> = {
|
||||
type: 'variable',
|
||||
name: node.data.variableName || '',
|
||||
operation: node.data.operation || 'set',
|
||||
value: node.data.value ?? 0,
|
||||
}
|
||||
if (outgoingEdge) {
|
||||
renpyNode.next = visited.has(outgoingEdge.target) ? getNodeLabel(outgoingEdge.target) : outgoingEdge.target
|
||||
if (outgoingEdge.data?.condition) {
|
||||
renpyNode.condition = outgoingEdge.data.condition
|
||||
}
|
||||
}
|
||||
currentSection.push(renpyNode)
|
||||
if (outgoingEdge && !visited.has(outgoingEdge.target)) {
|
||||
processNode(outgoingEdge.target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const startNode = findFirstNode(nodes, edges)
|
||||
if (startNode) {
|
||||
processNode(startNode.id)
|
||||
}
|
||||
sections[currentSectionName] = currentSection
|
||||
|
||||
return {
|
||||
projectName,
|
||||
exportedAt: new Date().toISOString(),
|
||||
sections,
|
||||
}
|
||||
}
|
||||
|
||||
const RANDOM_COLORS = [
|
||||
'#EF4444', '#F97316', '#F59E0B', '#10B981',
|
||||
'#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6',
|
||||
|
|
@ -212,7 +433,7 @@ function computeMigration(initialData: FlowchartData, shouldMigrate: boolean) {
|
|||
}
|
||||
|
||||
// Inner component that uses useReactFlow hook
|
||||
function FlowchartEditorInner({ projectId, initialData, needsMigration }: FlowchartEditorProps) {
|
||||
function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, isOwner, initialData, needsMigration }: FlowchartEditorProps) {
|
||||
// Define custom node types - memoized to prevent re-renders
|
||||
const nodeTypes: NodeTypes = useMemo(
|
||||
() => ({
|
||||
|
|
@ -450,6 +671,8 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
|
|||
const currentData: FlowchartData = {
|
||||
nodes: fromReactFlowNodes(nodes),
|
||||
edges: fromReactFlowEdges(edges),
|
||||
characters,
|
||||
variables,
|
||||
}
|
||||
saveDraft(projectId, currentData)
|
||||
}, AUTOSAVE_DEBOUNCE_MS)
|
||||
|
|
@ -460,16 +683,18 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
|
|||
clearTimeout(saveTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [nodes, edges, projectId, draftState.showPrompt])
|
||||
}, [nodes, edges, characters, variables, projectId, draftState.showPrompt])
|
||||
|
||||
// Calculate dirty state by comparing current data with last saved data
|
||||
const isDirty = useMemo(() => {
|
||||
const currentData: FlowchartData = {
|
||||
nodes: fromReactFlowNodes(nodes),
|
||||
edges: fromReactFlowEdges(edges),
|
||||
characters,
|
||||
variables,
|
||||
}
|
||||
return !flowchartDataEquals(currentData, lastSavedDataRef.current)
|
||||
}, [nodes, edges])
|
||||
}, [nodes, edges, characters, variables])
|
||||
|
||||
// Browser beforeunload warning when dirty
|
||||
useEffect(() => {
|
||||
|
|
@ -589,6 +814,8 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
|
|||
const flowchartData: FlowchartData = {
|
||||
nodes: fromReactFlowNodes(nodes),
|
||||
edges: fromReactFlowEdges(edges),
|
||||
characters,
|
||||
variables,
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
|
|
@ -620,7 +847,7 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
|
|||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}, [isSaving, nodes, edges, projectId])
|
||||
}, [isSaving, nodes, edges, characters, variables, projectId])
|
||||
|
||||
// Keep ref updated with latest handleSave
|
||||
handleSaveRef.current = handleSave
|
||||
|
|
@ -630,6 +857,8 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
|
|||
const flowchartData: FlowchartData = {
|
||||
nodes: fromReactFlowNodes(nodes),
|
||||
edges: fromReactFlowEdges(edges),
|
||||
characters,
|
||||
variables,
|
||||
}
|
||||
|
||||
// Create pretty-printed JSON
|
||||
|
|
@ -651,7 +880,7 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
|
|||
// Cleanup
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}, [nodes, edges, projectName])
|
||||
}, [nodes, edges, characters, variables, projectName])
|
||||
|
||||
const handleExportRenpy = useCallback(() => {
|
||||
// Convert React Flow state to our flowchart types
|
||||
|
|
@ -692,14 +921,27 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
|
|||
setToast({ message: 'Exported to Ren\'Py format successfully', type: 'success' })
|
||||
}, [nodes, edges, projectName])
|
||||
|
||||
const handleExportAnyway = useCallback(() => {
|
||||
setValidationIssues(null)
|
||||
setWarningNodeIds(new Set())
|
||||
handleExportRenpy()
|
||||
}, [handleExportRenpy])
|
||||
|
||||
const handleExportCancel = useCallback(() => {
|
||||
setValidationIssues(null)
|
||||
setWarningNodeIds(new Set())
|
||||
}, [])
|
||||
|
||||
// Check if current flowchart has unsaved changes
|
||||
const hasUnsavedChanges = useCallback(() => {
|
||||
const currentData: FlowchartData = {
|
||||
nodes: fromReactFlowNodes(nodes),
|
||||
edges: fromReactFlowEdges(edges),
|
||||
characters,
|
||||
variables,
|
||||
}
|
||||
return !flowchartDataEquals(currentData, initialData)
|
||||
}, [nodes, edges, initialData])
|
||||
}, [nodes, edges, characters, variables, initialData])
|
||||
|
||||
// Load imported data into React Flow
|
||||
const loadImportedData = useCallback(
|
||||
|
|
@ -804,6 +1046,134 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
|
|||
[setEdges]
|
||||
)
|
||||
|
||||
// Context menu handlers
|
||||
const closeContextMenu = useCallback(() => {
|
||||
setContextMenu(null)
|
||||
}, [])
|
||||
|
||||
const onPaneContextMenu = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
event.preventDefault()
|
||||
setContextMenu({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
type: 'canvas',
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const onNodeContextMenu: NodeMouseHandler = useCallback(
|
||||
(event, node) => {
|
||||
event.preventDefault()
|
||||
setContextMenu({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
type: 'node',
|
||||
nodeId: node.id,
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const onEdgeContextMenu: EdgeMouseHandler = useCallback(
|
||||
(event, edge) => {
|
||||
event.preventDefault()
|
||||
setContextMenu({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
type: 'edge',
|
||||
edgeId: edge.id,
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleAddNodeAtPosition = useCallback(
|
||||
(type: 'dialogue' | 'choice' | 'variable') => {
|
||||
if (!contextMenu) return
|
||||
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])
|
||||
setContextMenu(null)
|
||||
},
|
||||
[contextMenu, screenToFlowPosition, setNodes]
|
||||
)
|
||||
|
||||
const handleDeleteNode = useCallback(() => {
|
||||
if (!contextMenu?.nodeId) return
|
||||
setNodes((nodes) => nodes.filter((n) => n.id !== contextMenu.nodeId))
|
||||
setContextMenu(null)
|
||||
}, [contextMenu, setNodes])
|
||||
|
||||
const handleDeleteEdge = useCallback(() => {
|
||||
if (!contextMenu?.edgeId) return
|
||||
setEdges((edges) => edges.filter((e) => e.id !== contextMenu.edgeId))
|
||||
setContextMenu(null)
|
||||
}, [contextMenu, setEdges])
|
||||
|
||||
const openConditionEditor = useCallback(
|
||||
(edgeId: string) => {
|
||||
const edge = edges.find((e) => e.id === edgeId)
|
||||
if (!edge) return
|
||||
setConditionEditor({
|
||||
edgeId,
|
||||
condition: edge.data?.condition,
|
||||
})
|
||||
},
|
||||
[edges]
|
||||
)
|
||||
|
||||
const handleAddCondition = useCallback(() => {
|
||||
if (!contextMenu?.edgeId) return
|
||||
openConditionEditor(contextMenu.edgeId)
|
||||
setContextMenu(null)
|
||||
}, [contextMenu, openConditionEditor])
|
||||
|
||||
const onEdgeDoubleClick = useCallback(
|
||||
(_event: React.MouseEvent, edge: Edge) => {
|
||||
openConditionEditor(edge.id)
|
||||
},
|
||||
[openConditionEditor]
|
||||
)
|
||||
|
||||
// Apply warning styles to nodes with undefined references
|
||||
const styledNodes = useMemo(
|
||||
() =>
|
||||
|
|
@ -825,13 +1195,15 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
|
|||
|
||||
return (
|
||||
<EditorProvider value={editorContextValue}>
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="flex h-screen w-full flex-col">
|
||||
<Toolbar
|
||||
onAddDialogue={handleAddDialogue}
|
||||
onAddChoice={handleAddChoice}
|
||||
onAddVariable={handleAddVariable}
|
||||
onSave={handleSave}
|
||||
isSaving={isSaving}
|
||||
onExport={handleExport}
|
||||
onExportRenpy={handleExportRenpy}
|
||||
onImport={handleImport}
|
||||
onProjectSettings={() => setShowSettings(true)}
|
||||
onShare={() => setShowShare(true)}
|
||||
|
|
@ -843,11 +1215,16 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
|
|||
nodes={styledNodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onEdgesDelete={onEdgesDelete}
|
||||
onEdgeClick={onEdgeClick}
|
||||
onConnect={onConnect}
|
||||
onPaneContextMenu={onPaneContextMenu}
|
||||
onNodeContextMenu={onNodeContextMenu}
|
||||
onEdgeContextMenu={onEdgeContextMenu}
|
||||
onEdgeDoubleClick={onEdgeDoubleClick}
|
||||
deleteKeyCode={['Delete', 'Backspace']}
|
||||
fitView
|
||||
>
|
||||
|
|
@ -855,6 +1232,21 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
|
|||
<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}
|
||||
/>
|
||||
)}
|
||||
{showSettings && (
|
||||
<ProjectSettingsModal
|
||||
projectId={projectId}
|
||||
|
|
|
|||
Loading…
Reference in New Issue