developing #10
|
|
@ -33,7 +33,8 @@ 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 { 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 { CRDTManager } from '@/lib/collaboration/crdt'
|
||||||
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'
|
||||||
|
|
@ -508,9 +509,11 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
|
||||||
const [warningNodeIds, setWarningNodeIds] = useState<Set<string>>(new Set())
|
const [warningNodeIds, setWarningNodeIds] = useState<Set<string>>(new Set())
|
||||||
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected')
|
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected')
|
||||||
const [presenceUsers, setPresenceUsers] = useState<PresenceUser[]>([])
|
const [presenceUsers, setPresenceUsers] = useState<PresenceUser[]>([])
|
||||||
|
const [remoteCursors, setRemoteCursors] = useState<RemoteCursor[]>([])
|
||||||
const realtimeRef = useRef<RealtimeConnection | null>(null)
|
const realtimeRef = useRef<RealtimeConnection | null>(null)
|
||||||
const crdtRef = useRef<CRDTManager | null>(null)
|
const crdtRef = useRef<CRDTManager | null>(null)
|
||||||
const isRemoteUpdateRef = useRef(false)
|
const isRemoteUpdateRef = useRef(false)
|
||||||
|
const cursorThrottleRef = useRef<number>(0)
|
||||||
|
|
||||||
// Initialize CRDT manager and connect to Supabase Realtime channel on mount
|
// Initialize CRDT manager and connect to Supabase Realtime channel on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -553,6 +556,17 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
|
||||||
const connection = new RealtimeConnection(projectId, userId, userDisplayName, {
|
const connection = new RealtimeConnection(projectId, userId, userDisplayName, {
|
||||||
onConnectionStateChange: setConnectionState,
|
onConnectionStateChange: setConnectionState,
|
||||||
onPresenceSync: setPresenceUsers,
|
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) => {
|
onChannelSubscribed: (channel) => {
|
||||||
crdtManager.connectChannel(channel)
|
crdtManager.connectChannel(channel)
|
||||||
},
|
},
|
||||||
|
|
@ -600,6 +614,36 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
|
||||||
crdtRef.current?.updateEdges(edgesForCRDT)
|
crdtRef.current?.updateEdges(edgesForCRDT)
|
||||||
}, [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(
|
const handleAddCharacter = useCallback(
|
||||||
(name: string, color: string): string => {
|
(name: string, color: string): string => {
|
||||||
const id = nanoid()
|
const id = nanoid()
|
||||||
|
|
@ -1210,7 +1254,7 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
|
||||||
connectionState={connectionState}
|
connectionState={connectionState}
|
||||||
presenceUsers={presenceUsers}
|
presenceUsers={presenceUsers}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1">
|
<div className="relative flex-1" onMouseMove={handleMouseMove}>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={styledNodes}
|
nodes={styledNodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
|
|
@ -1231,6 +1275,7 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
|
||||||
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
||||||
<Controls position="bottom-right" />
|
<Controls position="bottom-right" />
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
|
<RemoteCursors cursors={remoteCursors} />
|
||||||
</div>
|
</div>
|
||||||
{contextMenu && (
|
{contextMenu && (
|
||||||
<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 not the owner, check if the user is a collaborator
|
||||||
if (!project) {
|
if (!project) {
|
||||||
// RLS on projects allows collaborators to SELECT shared projects
|
|
||||||
const { data: collab } = await supabase
|
const { data: collab } = await supabase
|
||||||
.from('project_collaborators')
|
.from('project_collaborators')
|
||||||
.select('id, role')
|
.select('id, role')
|
||||||
|
|
@ -49,58 +48,42 @@ export default async function EditorPage({ params }: PageProps) {
|
||||||
.eq('user_id', user.id)
|
.eq('user_id', user.id)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (!collab) {
|
if (collab) {
|
||||||
notFound()
|
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 || {}
|
if (!project) {
|
||||||
const flowchartData: FlowchartData = {
|
return (
|
||||||
nodes: rawData.nodes || [],
|
<div className="flex h-screen flex-col">
|
||||||
edges: rawData.edges || [],
|
<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">
|
||||||
characters: rawData.characters || [],
|
<div className="flex items-center gap-4">
|
||||||
variables: rawData.variables || [],
|
<Link
|
||||||
}
|
href="/dashboard"
|
||||||
|
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
|
||||||
// Migration flag: if the raw data doesn't have characters/variables arrays,
|
aria-label="Back to dashboard"
|
||||||
// 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"
|
|
||||||
>
|
>
|
||||||
<path
|
<svg
|
||||||
fillRule="evenodd"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
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"
|
className="h-5 w-5"
|
||||||
clipRule="evenodd"
|
viewBox="0 0 20 20"
|
||||||
/>
|
fill="currentColor"
|
||||||
</svg>
|
>
|
||||||
</Link>
|
<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>
|
</header>
|
||||||
<div className="flex flex-1 items-center justify-center bg-zinc-100 dark:bg-zinc-800">
|
<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">
|
<div className="flex flex-col items-center gap-4 text-center">
|
||||||
|
|
@ -131,32 +114,31 @@ export default async function EditorPage({ params }: PageProps) {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="flex-1">
|
|
||||||
<FlowchartEditor
|
|
||||||
projectId={project.id}
|
|
||||||
userId={user.id}
|
|
||||||
userDisplayName={userDisplayName}
|
|
||||||
isOwner={isOwner}
|
|
||||||
initialData={flowchartData}
|
|
||||||
needsMigration={needsMigration}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const flowchartData = (project.flowchart_data || {
|
const rawData = project.flowchart_data || {}
|
||||||
nodes: [],
|
const flowchartData: FlowchartData = {
|
||||||
edges: [],
|
nodes: rawData.nodes || [],
|
||||||
}) as FlowchartData
|
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 (
|
return (
|
||||||
<FlowchartEditor
|
<FlowchartEditor
|
||||||
projectId={project.id}
|
projectId={project.id}
|
||||||
projectName={project.name}
|
projectName={project.name}
|
||||||
|
userId={user.id}
|
||||||
|
userDisplayName={userDisplayName}
|
||||||
|
isOwner={isOwner}
|
||||||
initialData={flowchartData}
|
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
|
displayName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CursorPosition = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RemoteCursor = {
|
||||||
|
userId: string
|
||||||
|
displayName: string
|
||||||
|
position: CursorPosition
|
||||||
|
lastUpdated: number
|
||||||
|
}
|
||||||
|
|
||||||
type RealtimeCallbacks = {
|
type RealtimeCallbacks = {
|
||||||
onConnectionStateChange: (state: ConnectionState) => void
|
onConnectionStateChange: (state: ConnectionState) => void
|
||||||
onPresenceSync?: (users: PresenceUser[]) => void
|
onPresenceSync?: (users: PresenceUser[]) => void
|
||||||
onChannelSubscribed?: (channel: RealtimeChannel) => void
|
onChannelSubscribed?: (channel: RealtimeChannel) => void
|
||||||
|
onCursorUpdate?: (cursor: RemoteCursor) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const HEARTBEAT_INTERVAL_MS = 30_000
|
const HEARTBEAT_INTERVAL_MS = 30_000
|
||||||
|
|
@ -63,6 +76,15 @@ export class RealtimeConnection {
|
||||||
this.callbacks.onPresenceSync?.(users)
|
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) => {
|
.subscribe(async (status) => {
|
||||||
if (this.isDestroyed) return
|
if (this.isDestroyed) return
|
||||||
|
|
||||||
|
|
@ -107,6 +129,20 @@ export class RealtimeConnection {
|
||||||
return this.channel
|
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> {
|
private async createSession(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.supabase.from('collaboration_sessions').upsert(
|
await this.supabase.from('collaboration_sessions').upsert(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue