developing #10

Merged
GHMiranda merged 64 commits from developing into master 2026-01-25 00:37:11 +00:00
1 changed files with 402 additions and 10 deletions
Showing only changes of commit fa336f05d9 - Show all commits

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import React, { useCallback, useMemo, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import ReactFlow, { import ReactFlow, {
Background, Background,
BackgroundVariant, BackgroundVariant,
@ -27,20 +27,42 @@ import { createClient } from '@/lib/supabase/client'
import DialogueNode from '@/components/editor/nodes/DialogueNode' import DialogueNode from '@/components/editor/nodes/DialogueNode'
import ChoiceNode from '@/components/editor/nodes/ChoiceNode' import ChoiceNode from '@/components/editor/nodes/ChoiceNode'
import VariableNode from '@/components/editor/nodes/VariableNode' 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 ProjectSettingsModal from '@/components/editor/ProjectSettingsModal'
import ConditionEditor from '@/components/editor/ConditionEditor' import ConditionEditor from '@/components/editor/ConditionEditor'
import ExportValidationModal, { type ValidationIssue } from '@/components/editor/ExportValidationModal' import ExportValidationModal, { type ValidationIssue } from '@/components/editor/ExportValidationModal'
import { EditorProvider } from '@/components/editor/EditorContext' import { EditorProvider } from '@/components/editor/EditorContext'
import Toast from '@/components/Toast'
import { RealtimeConnection, type ConnectionState, type PresenceUser } from '@/lib/collaboration/realtime' import { RealtimeConnection, type ConnectionState, type PresenceUser } from '@/lib/collaboration/realtime'
import { CRDTManager } from '@/lib/collaboration/crdt' import { CRDTManager } from '@/lib/collaboration/crdt'
import { createClient } from '@/lib/supabase/client'
import ShareModal from '@/components/editor/ShareModal' import ShareModal from '@/components/editor/ShareModal'
import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart' 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 = { type FlowchartEditorProps = {
projectId: string projectId: string
projectName: string projectName: string
userId: string
userDisplayName: string
isOwner: boolean
initialData: FlowchartData initialData: FlowchartData
needsMigration?: boolean 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 = [ const RANDOM_COLORS = [
'#EF4444', '#F97316', '#F59E0B', '#10B981', '#EF4444', '#F97316', '#F59E0B', '#10B981',
'#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6', '#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6',
@ -212,7 +433,7 @@ function computeMigration(initialData: FlowchartData, shouldMigrate: boolean) {
} }
// Inner component that uses useReactFlow hook // 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 // Define custom node types - memoized to prevent re-renders
const nodeTypes: NodeTypes = useMemo( const nodeTypes: NodeTypes = useMemo(
() => ({ () => ({
@ -450,6 +671,8 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
const currentData: FlowchartData = { const currentData: FlowchartData = {
nodes: fromReactFlowNodes(nodes), nodes: fromReactFlowNodes(nodes),
edges: fromReactFlowEdges(edges), edges: fromReactFlowEdges(edges),
characters,
variables,
} }
saveDraft(projectId, currentData) saveDraft(projectId, currentData)
}, AUTOSAVE_DEBOUNCE_MS) }, AUTOSAVE_DEBOUNCE_MS)
@ -460,16 +683,18 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
clearTimeout(saveTimerRef.current) 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 // Calculate dirty state by comparing current data with last saved data
const isDirty = useMemo(() => { const isDirty = useMemo(() => {
const currentData: FlowchartData = { const currentData: FlowchartData = {
nodes: fromReactFlowNodes(nodes), nodes: fromReactFlowNodes(nodes),
edges: fromReactFlowEdges(edges), edges: fromReactFlowEdges(edges),
characters,
variables,
} }
return !flowchartDataEquals(currentData, lastSavedDataRef.current) return !flowchartDataEquals(currentData, lastSavedDataRef.current)
}, [nodes, edges]) }, [nodes, edges, characters, variables])
// Browser beforeunload warning when dirty // Browser beforeunload warning when dirty
useEffect(() => { useEffect(() => {
@ -589,6 +814,8 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
const flowchartData: FlowchartData = { const flowchartData: FlowchartData = {
nodes: fromReactFlowNodes(nodes), nodes: fromReactFlowNodes(nodes),
edges: fromReactFlowEdges(edges), edges: fromReactFlowEdges(edges),
characters,
variables,
} }
const { error } = await supabase const { error } = await supabase
@ -620,7 +847,7 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
} finally { } finally {
setIsSaving(false) setIsSaving(false)
} }
}, [isSaving, nodes, edges, projectId]) }, [isSaving, nodes, edges, characters, variables, projectId])
// Keep ref updated with latest handleSave // Keep ref updated with latest handleSave
handleSaveRef.current = handleSave handleSaveRef.current = handleSave
@ -630,6 +857,8 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
const flowchartData: FlowchartData = { const flowchartData: FlowchartData = {
nodes: fromReactFlowNodes(nodes), nodes: fromReactFlowNodes(nodes),
edges: fromReactFlowEdges(edges), edges: fromReactFlowEdges(edges),
characters,
variables,
} }
// Create pretty-printed JSON // Create pretty-printed JSON
@ -651,7 +880,7 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
// Cleanup // Cleanup
document.body.removeChild(link) document.body.removeChild(link)
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
}, [nodes, edges, projectName]) }, [nodes, edges, characters, variables, projectName])
const handleExportRenpy = useCallback(() => { const handleExportRenpy = useCallback(() => {
// Convert React Flow state to our flowchart types // 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' }) setToast({ message: 'Exported to Ren\'Py format successfully', type: 'success' })
}, [nodes, edges, projectName]) }, [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 // Check if current flowchart has unsaved changes
const hasUnsavedChanges = useCallback(() => { const hasUnsavedChanges = useCallback(() => {
const currentData: FlowchartData = { const currentData: FlowchartData = {
nodes: fromReactFlowNodes(nodes), nodes: fromReactFlowNodes(nodes),
edges: fromReactFlowEdges(edges), edges: fromReactFlowEdges(edges),
characters,
variables,
} }
return !flowchartDataEquals(currentData, initialData) return !flowchartDataEquals(currentData, initialData)
}, [nodes, edges, initialData]) }, [nodes, edges, characters, variables, initialData])
// Load imported data into React Flow // Load imported data into React Flow
const loadImportedData = useCallback( const loadImportedData = useCallback(
@ -804,6 +1046,134 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
[setEdges] [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 // Apply warning styles to nodes with undefined references
const styledNodes = useMemo( const styledNodes = useMemo(
() => () =>
@ -825,13 +1195,15 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
return ( return (
<EditorProvider value={editorContextValue}> <EditorProvider value={editorContextValue}>
<div className="flex h-full w-full flex-col"> <div className="flex h-screen w-full flex-col">
<Toolbar <Toolbar
onAddDialogue={handleAddDialogue} onAddDialogue={handleAddDialogue}
onAddChoice={handleAddChoice} onAddChoice={handleAddChoice}
onAddVariable={handleAddVariable} onAddVariable={handleAddVariable}
onSave={handleSave} onSave={handleSave}
isSaving={isSaving}
onExport={handleExport} onExport={handleExport}
onExportRenpy={handleExportRenpy}
onImport={handleImport} onImport={handleImport}
onProjectSettings={() => setShowSettings(true)} onProjectSettings={() => setShowSettings(true)}
onShare={() => setShowShare(true)} onShare={() => setShowShare(true)}
@ -843,11 +1215,16 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
nodes={styledNodes} nodes={styledNodes}
edges={edges} edges={edges}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onNodesChange={onNodesChange} onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange} onEdgesChange={onEdgesChange}
onEdgesDelete={onEdgesDelete} onEdgesDelete={onEdgesDelete}
onEdgeClick={onEdgeClick} onEdgeClick={onEdgeClick}
onConnect={onConnect} onConnect={onConnect}
onPaneContextMenu={onPaneContextMenu}
onNodeContextMenu={onNodeContextMenu}
onEdgeContextMenu={onEdgeContextMenu}
onEdgeDoubleClick={onEdgeDoubleClick}
deleteKeyCode={['Delete', 'Backspace']} deleteKeyCode={['Delete', 'Backspace']}
fitView fitView
> >
@ -855,6 +1232,21 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
<Controls position="bottom-right" /> <Controls position="bottom-right" />
</ReactFlow> </ReactFlow>
</div> </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 && ( {showSettings && (
<ProjectSettingsModal <ProjectSettingsModal
projectId={projectId} projectId={projectId}