ralph/collaboration-and-character-variables #9
|
|
@ -33,7 +33,8 @@ 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 { RealtimeConnection, type ConnectionState, type PresenceUser } from '@/lib/collaboration/realtime'
|
||||
import { RealtimeConnection, type ConnectionState, type PresenceUser, type RemoteCursor } from '@/lib/collaboration/realtime'
|
||||
import RemoteCursors from '@/components/editor/RemoteCursors'
|
||||
import { CRDTManager } from '@/lib/collaboration/crdt'
|
||||
import ShareModal from '@/components/editor/ShareModal'
|
||||
import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart'
|
||||
|
|
@ -508,9 +509,11 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
|
|||
const [warningNodeIds, setWarningNodeIds] = useState<Set<string>>(new Set())
|
||||
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected')
|
||||
const [presenceUsers, setPresenceUsers] = useState<PresenceUser[]>([])
|
||||
const [remoteCursors, setRemoteCursors] = useState<RemoteCursor[]>([])
|
||||
const realtimeRef = useRef<RealtimeConnection | null>(null)
|
||||
const crdtRef = useRef<CRDTManager | null>(null)
|
||||
const isRemoteUpdateRef = useRef(false)
|
||||
const cursorThrottleRef = useRef<number>(0)
|
||||
|
||||
// Initialize CRDT manager and connect to Supabase Realtime channel on mount
|
||||
useEffect(() => {
|
||||
|
|
@ -553,6 +556,17 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
|
|||
const connection = new RealtimeConnection(projectId, userId, userDisplayName, {
|
||||
onConnectionStateChange: setConnectionState,
|
||||
onPresenceSync: setPresenceUsers,
|
||||
onCursorUpdate: (cursor) => {
|
||||
setRemoteCursors((prev) => {
|
||||
const existing = prev.findIndex((c) => c.userId === cursor.userId)
|
||||
if (existing >= 0) {
|
||||
const updated = [...prev]
|
||||
updated[existing] = cursor
|
||||
return updated
|
||||
}
|
||||
return [...prev, cursor]
|
||||
})
|
||||
},
|
||||
onChannelSubscribed: (channel) => {
|
||||
crdtManager.connectChannel(channel)
|
||||
},
|
||||
|
|
@ -600,6 +614,36 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
|
|||
crdtRef.current?.updateEdges(edgesForCRDT)
|
||||
}, [edgesForCRDT])
|
||||
|
||||
// Broadcast cursor position on mouse move (throttled to 50ms)
|
||||
const handleMouseMove = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
const now = Date.now()
|
||||
if (now - cursorThrottleRef.current < 50) return
|
||||
cursorThrottleRef.current = now
|
||||
|
||||
const connection = realtimeRef.current
|
||||
if (!connection) return
|
||||
|
||||
// Convert screen position to flow coordinates
|
||||
const bounds = event.currentTarget.getBoundingClientRect()
|
||||
const screenX = event.clientX - bounds.left
|
||||
const screenY = event.clientY - bounds.top
|
||||
const flowPosition = screenToFlowPosition({ x: screenX, y: screenY })
|
||||
|
||||
connection.broadcastCursor(flowPosition)
|
||||
},
|
||||
[screenToFlowPosition]
|
||||
)
|
||||
|
||||
// Remove cursors for users who leave
|
||||
useEffect(() => {
|
||||
setRemoteCursors((prev) =>
|
||||
prev.filter((cursor) =>
|
||||
presenceUsers.some((u) => u.userId === cursor.userId)
|
||||
)
|
||||
)
|
||||
}, [presenceUsers])
|
||||
|
||||
const handleAddCharacter = useCallback(
|
||||
(name: string, color: string): string => {
|
||||
const id = nanoid()
|
||||
|
|
@ -1210,7 +1254,7 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
|
|||
connectionState={connectionState}
|
||||
presenceUsers={presenceUsers}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="relative flex-1" onMouseMove={handleMouseMove}>
|
||||
<ReactFlow
|
||||
nodes={styledNodes}
|
||||
edges={edges}
|
||||
|
|
@ -1231,6 +1275,7 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
|
|||
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
||||
<Controls position="bottom-right" />
|
||||
</ReactFlow>
|
||||
<RemoteCursors cursors={remoteCursors} />
|
||||
</div>
|
||||
{contextMenu && (
|
||||
<ContextMenu
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ export default async function EditorPage({ params }: PageProps) {
|
|||
|
||||
// If not the owner, check if the user is a collaborator
|
||||
if (!project) {
|
||||
// RLS on projects allows collaborators to SELECT shared projects
|
||||
const { data: collab } = await supabase
|
||||
.from('project_collaborators')
|
||||
.select('id, role')
|
||||
|
|
@ -49,58 +48,42 @@ export default async function EditorPage({ params }: PageProps) {
|
|||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!collab) {
|
||||
notFound()
|
||||
if (collab) {
|
||||
const { data: sharedProject } = await supabase
|
||||
.from('projects')
|
||||
.select('id, name, flowchart_data')
|
||||
.eq('id', projectId)
|
||||
.single()
|
||||
|
||||
project = sharedProject
|
||||
isOwner = false
|
||||
}
|
||||
|
||||
const { data: sharedProject } = await supabase
|
||||
.from('projects')
|
||||
.select('id, name, flowchart_data')
|
||||
.eq('id', projectId)
|
||||
.single()
|
||||
|
||||
if (!sharedProject) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
project = sharedProject
|
||||
isOwner = false
|
||||
}
|
||||
|
||||
const rawData = project.flowchart_data || {}
|
||||
const flowchartData: FlowchartData = {
|
||||
nodes: rawData.nodes || [],
|
||||
edges: rawData.edges || [],
|
||||
characters: rawData.characters || [],
|
||||
variables: rawData.variables || [],
|
||||
}
|
||||
|
||||
// Migration flag: if the raw data doesn't have characters/variables arrays,
|
||||
// the project was created before these features existed and may need auto-migration
|
||||
const needsMigration = !rawData.characters && !rawData.variables
|
||||
|
||||
return (<>
|
||||
<div className="flex h-screen flex-col">
|
||||
<header className="flex items-center justify-between border-b border-zinc-200 bg-white px-4 py-3 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
|
||||
aria-label="Back to dashboard"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<header className="flex items-center justify-between border-b border-zinc-200 bg-white px-4 py-3 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
|
||||
aria-label="Back to dashboard"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex flex-1 items-center justify-center bg-zinc-100 dark:bg-zinc-800">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
|
|
@ -131,32 +114,31 @@ export default async function EditorPage({ params }: PageProps) {
|
|||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1">
|
||||
<FlowchartEditor
|
||||
projectId={project.id}
|
||||
userId={user.id}
|
||||
userDisplayName={userDisplayName}
|
||||
isOwner={isOwner}
|
||||
initialData={flowchartData}
|
||||
needsMigration={needsMigration}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const flowchartData = (project.flowchart_data || {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
}) as FlowchartData
|
||||
const rawData = project.flowchart_data || {}
|
||||
const flowchartData: FlowchartData = {
|
||||
nodes: rawData.nodes || [],
|
||||
edges: rawData.edges || [],
|
||||
characters: rawData.characters || [],
|
||||
variables: rawData.variables || [],
|
||||
}
|
||||
|
||||
// Migration flag: if the raw data doesn't have characters/variables arrays,
|
||||
// the project was created before these features existed and may need auto-migration
|
||||
const needsMigration = !rawData.characters && !rawData.variables
|
||||
|
||||
return (
|
||||
<FlowchartEditor
|
||||
projectId={project.id}
|
||||
projectName={project.name}
|
||||
userId={user.id}
|
||||
userDisplayName={userDisplayName}
|
||||
isOwner={isOwner}
|
||||
initialData={flowchartData}
|
||||
needsMigration={needsMigration}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useViewport } from 'reactflow'
|
||||
import type { RemoteCursor } from '@/lib/collaboration/realtime'
|
||||
|
||||
type RemoteCursorsProps = {
|
||||
cursors: RemoteCursor[]
|
||||
}
|
||||
|
||||
const FADE_TIMEOUT_MS = 5000
|
||||
|
||||
// Generate a consistent color from a user ID hash (same algorithm as PresenceAvatars)
|
||||
function getUserColor(userId: string): string {
|
||||
let hash = 0
|
||||
for (let i = 0; i < userId.length; i++) {
|
||||
hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0
|
||||
}
|
||||
const colors = [
|
||||
'#EF4444', '#F97316', '#F59E0B', '#10B981',
|
||||
'#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6',
|
||||
'#6366F1', '#F43F5E', '#84CC16', '#06B6D4',
|
||||
]
|
||||
return colors[Math.abs(hash) % colors.length]
|
||||
}
|
||||
|
||||
export default function RemoteCursors({ cursors }: RemoteCursorsProps) {
|
||||
const viewport = useViewport()
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
// Tick every second to update fade states
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNow(Date.now()), 1000)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 z-40 overflow-hidden">
|
||||
{cursors.map((cursor) => {
|
||||
const timeSinceUpdate = now - cursor.lastUpdated
|
||||
const isFaded = timeSinceUpdate > FADE_TIMEOUT_MS
|
||||
if (isFaded) return null
|
||||
|
||||
const opacity = timeSinceUpdate > FADE_TIMEOUT_MS - 1000
|
||||
? Math.max(0, 1 - (timeSinceUpdate - (FADE_TIMEOUT_MS - 1000)) / 1000)
|
||||
: 1
|
||||
|
||||
// Convert flow coordinates to screen coordinates
|
||||
const screenX = cursor.position.x * viewport.zoom + viewport.x
|
||||
const screenY = cursor.position.y * viewport.zoom + viewport.y
|
||||
|
||||
const color = getUserColor(cursor.userId)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={cursor.userId}
|
||||
className="absolute left-0 top-0"
|
||||
style={{
|
||||
transform: `translate(${screenX}px, ${screenY}px)`,
|
||||
opacity,
|
||||
transition: 'transform 80ms linear, opacity 300ms ease',
|
||||
}}
|
||||
>
|
||||
{/* Cursor arrow SVG */}
|
||||
<svg
|
||||
width="16"
|
||||
height="20"
|
||||
viewBox="0 0 16 20"
|
||||
fill="none"
|
||||
style={{ filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.3))' }}
|
||||
>
|
||||
<path
|
||||
d="M0 0L16 12L8 12L6 20L0 0Z"
|
||||
fill={color}
|
||||
/>
|
||||
<path
|
||||
d="M0 0L16 12L8 12L6 20L0 0Z"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{/* User name label */}
|
||||
<div
|
||||
className="absolute left-4 top-4 whitespace-nowrap rounded px-1.5 py-0.5 text-[10px] font-medium text-white shadow-sm"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
{cursor.displayName}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -8,10 +8,23 @@ export type PresenceUser = {
|
|||
displayName: string
|
||||
}
|
||||
|
||||
export type CursorPosition = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export type RemoteCursor = {
|
||||
userId: string
|
||||
displayName: string
|
||||
position: CursorPosition
|
||||
lastUpdated: number
|
||||
}
|
||||
|
||||
type RealtimeCallbacks = {
|
||||
onConnectionStateChange: (state: ConnectionState) => void
|
||||
onPresenceSync?: (users: PresenceUser[]) => void
|
||||
onChannelSubscribed?: (channel: RealtimeChannel) => void
|
||||
onCursorUpdate?: (cursor: RemoteCursor) => void
|
||||
}
|
||||
|
||||
const HEARTBEAT_INTERVAL_MS = 30_000
|
||||
|
|
@ -63,6 +76,15 @@ export class RealtimeConnection {
|
|||
this.callbacks.onPresenceSync?.(users)
|
||||
}
|
||||
})
|
||||
.on('broadcast', { event: 'cursor' }, ({ payload }) => {
|
||||
if (payload.userId === this.userId) return
|
||||
this.callbacks.onCursorUpdate?.({
|
||||
userId: payload.userId,
|
||||
displayName: payload.displayName,
|
||||
position: { x: payload.x, y: payload.y },
|
||||
lastUpdated: Date.now(),
|
||||
})
|
||||
})
|
||||
.subscribe(async (status) => {
|
||||
if (this.isDestroyed) return
|
||||
|
||||
|
|
@ -107,6 +129,20 @@ export class RealtimeConnection {
|
|||
return this.channel
|
||||
}
|
||||
|
||||
broadcastCursor(position: CursorPosition): void {
|
||||
if (!this.channel) return
|
||||
this.channel.send({
|
||||
type: 'broadcast',
|
||||
event: 'cursor',
|
||||
payload: {
|
||||
userId: this.userId,
|
||||
displayName: this.displayName,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private async createSession(): Promise<void> {
|
||||
try {
|
||||
await this.supabase.from('collaboration_sessions').upsert(
|
||||
|
|
|
|||
Loading…
Reference in New Issue