ralph/collaboration-and-character-variables #8

Merged
GHMiranda merged 6 commits from ralph/collaboration-and-character-variables into developing 2026-01-24 00:00:47 +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'
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}