feat: [US-049] - Node editing lock indicators
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
5b84170a28
commit
4a85d7a64b
|
|
@ -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, type RemoteCursor } from '@/lib/collaboration/realtime'
|
||||
import { RealtimeConnection, type ConnectionState, type PresenceUser, type RemoteCursor, type NodeLock } from '@/lib/collaboration/realtime'
|
||||
import type { NodeLockInfo } from '@/components/editor/EditorContext'
|
||||
import RemoteCursors from '@/components/editor/RemoteCursors'
|
||||
import { CRDTManager } from '@/lib/collaboration/crdt'
|
||||
import ShareModal from '@/components/editor/ShareModal'
|
||||
|
|
@ -525,6 +526,9 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
|
|||
const isRemoteUpdateRef = useRef(false)
|
||||
const cursorThrottleRef = useRef<number>(0)
|
||||
const [collaborationNotifications, setCollaborationNotifications] = useState<CollaborationNotification[]>([])
|
||||
const [nodeLocks, setNodeLocks] = useState<Map<string, NodeLockInfo>>(new Map())
|
||||
const localLockRef = useRef<string | null>(null) // node ID currently locked by this user
|
||||
const lockExpiryTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
// Initialize CRDT manager and connect to Supabase Realtime channel on mount
|
||||
useEffect(() => {
|
||||
|
|
@ -590,6 +594,22 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
|
|||
return [...prev, cursor]
|
||||
})
|
||||
},
|
||||
onNodeLockUpdate: (lock: NodeLock | null, lockUserId: string) => {
|
||||
setNodeLocks((prev) => {
|
||||
const next = new Map(prev)
|
||||
if (lock) {
|
||||
next.set(lock.nodeId, { ...lock, color: getUserColor(lock.userId) })
|
||||
} else {
|
||||
// Remove any lock held by this user
|
||||
for (const [nodeId, info] of next) {
|
||||
if (info.userId === lockUserId) {
|
||||
next.delete(nodeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
onChannelSubscribed: (channel) => {
|
||||
crdtManager.connectChannel(channel)
|
||||
},
|
||||
|
|
@ -598,6 +618,11 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
|
|||
connection.connect()
|
||||
|
||||
return () => {
|
||||
// Release any held lock before disconnecting
|
||||
if (localLockRef.current) {
|
||||
connection.broadcastNodeLock(null)
|
||||
localLockRef.current = null
|
||||
}
|
||||
connection.disconnect()
|
||||
realtimeRef.current = null
|
||||
crdtManager.destroy()
|
||||
|
|
@ -658,19 +683,70 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
|
|||
[screenToFlowPosition]
|
||||
)
|
||||
|
||||
// Remove cursors for users who leave
|
||||
// Remove cursors and locks for users who leave
|
||||
useEffect(() => {
|
||||
setRemoteCursors((prev) =>
|
||||
prev.filter((cursor) =>
|
||||
presenceUsers.some((u) => u.userId === cursor.userId)
|
||||
)
|
||||
)
|
||||
setNodeLocks((prev) => {
|
||||
const next = new Map(prev)
|
||||
let changed = false
|
||||
for (const [nodeId, info] of next) {
|
||||
if (!presenceUsers.some((u) => u.userId === info.userId)) {
|
||||
next.delete(nodeId)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? next : prev
|
||||
})
|
||||
}, [presenceUsers])
|
||||
|
||||
// Lock auto-expiry: expire locks after 60 seconds of inactivity
|
||||
useEffect(() => {
|
||||
const LOCK_EXPIRY_MS = 60_000
|
||||
lockExpiryTimerRef.current = setInterval(() => {
|
||||
const now = Date.now()
|
||||
setNodeLocks((prev) => {
|
||||
const next = new Map(prev)
|
||||
let changed = false
|
||||
for (const [nodeId, info] of next) {
|
||||
if (now - info.lockedAt > LOCK_EXPIRY_MS) {
|
||||
next.delete(nodeId)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? next : prev
|
||||
})
|
||||
}, 5000) // Check every 5 seconds
|
||||
|
||||
return () => {
|
||||
if (lockExpiryTimerRef.current) {
|
||||
clearInterval(lockExpiryTimerRef.current)
|
||||
lockExpiryTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDismissNotification = useCallback((id: string) => {
|
||||
setCollaborationNotifications((prev) => prev.filter((n) => n.id !== id))
|
||||
}, [])
|
||||
|
||||
const handleNodeFocus = useCallback((nodeId: string) => {
|
||||
// Broadcast lock for this node
|
||||
localLockRef.current = nodeId
|
||||
realtimeRef.current?.broadcastNodeLock(nodeId)
|
||||
}, [])
|
||||
|
||||
const handleNodeBlur = useCallback(() => {
|
||||
// Release lock
|
||||
if (localLockRef.current) {
|
||||
localLockRef.current = null
|
||||
realtimeRef.current?.broadcastNodeLock(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleAddCharacter = useCallback(
|
||||
(name: string, color: string): string => {
|
||||
const id = nanoid()
|
||||
|
|
@ -692,8 +768,16 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
|
|||
)
|
||||
|
||||
const editorContextValue = useMemo(
|
||||
() => ({ characters, onAddCharacter: handleAddCharacter, variables, onAddVariable: handleAddVariableDefinition }),
|
||||
[characters, handleAddCharacter, variables, handleAddVariableDefinition]
|
||||
() => ({
|
||||
characters,
|
||||
onAddCharacter: handleAddCharacter,
|
||||
variables,
|
||||
onAddVariable: handleAddVariableDefinition,
|
||||
nodeLocks,
|
||||
onNodeFocus: handleNodeFocus,
|
||||
onNodeBlur: handleNodeBlur,
|
||||
}),
|
||||
[characters, handleAddCharacter, variables, handleAddVariableDefinition, nodeLocks, handleNodeFocus, handleNodeBlur]
|
||||
)
|
||||
|
||||
const getCharacterUsageCount = useCallback(
|
||||
|
|
|
|||
|
|
@ -2,12 +2,18 @@
|
|||
|
||||
import { createContext, useContext } from 'react'
|
||||
import type { Character, Variable } from '@/types/flowchart'
|
||||
import type { NodeLock } from '@/lib/collaboration/realtime'
|
||||
|
||||
export type NodeLockInfo = NodeLock & { color: string }
|
||||
|
||||
type EditorContextValue = {
|
||||
characters: Character[]
|
||||
onAddCharacter: (name: string, color: string) => string // returns new character id
|
||||
variables: Variable[]
|
||||
onAddVariable: (name: string, type: 'numeric' | 'string' | 'boolean', initialValue: number | string | boolean) => string // returns new variable id
|
||||
nodeLocks: Map<string, NodeLockInfo> // nodeId -> lock info
|
||||
onNodeFocus: (nodeId: string) => void
|
||||
onNodeBlur: () => void
|
||||
}
|
||||
|
||||
const EditorContext = createContext<EditorContextValue>({
|
||||
|
|
@ -15,6 +21,9 @@ const EditorContext = createContext<EditorContextValue>({
|
|||
onAddCharacter: () => '',
|
||||
variables: [],
|
||||
onAddVariable: () => '',
|
||||
nodeLocks: new Map(),
|
||||
onNodeFocus: () => {},
|
||||
onNodeBlur: () => {},
|
||||
})
|
||||
|
||||
export const EditorProvider = EditorContext.Provider
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
'use client'
|
||||
|
||||
import type { NodeLock } from '@/lib/collaboration/realtime'
|
||||
|
||||
type NodeLockIndicatorProps = {
|
||||
lock: NodeLock
|
||||
color: string
|
||||
}
|
||||
|
||||
export default function NodeLockIndicator({ lock, color }: NodeLockIndicatorProps) {
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-10 rounded-lg border-2"
|
||||
style={{ borderColor: color }}
|
||||
>
|
||||
<div
|
||||
className="absolute -top-5 left-1/2 -translate-x-1/2 whitespace-nowrap rounded px-1.5 py-0.5 text-[10px] font-medium text-white"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
{lock.displayName}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
|
|||
import { nanoid } from 'nanoid'
|
||||
import { useEditorContext } from '@/components/editor/EditorContext'
|
||||
import OptionConditionEditor from '@/components/editor/OptionConditionEditor'
|
||||
import NodeLockIndicator from '@/components/editor/NodeLockIndicator'
|
||||
import type { Condition } from '@/types/flowchart'
|
||||
|
||||
type ChoiceOption = {
|
||||
|
|
@ -23,9 +24,12 @@ const MAX_OPTIONS = 6
|
|||
|
||||
export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
||||
const { setNodes } = useReactFlow()
|
||||
const { variables } = useEditorContext() // Puxa as variáveis globais para validar condições
|
||||
const { variables, nodeLocks, onNodeFocus, onNodeBlur } = useEditorContext()
|
||||
const [editingConditionOptionId, setEditingConditionOptionId] = useState<string | null>(null)
|
||||
|
||||
const lockInfo = nodeLocks.get(id)
|
||||
const isLockedByOther = !!lockInfo
|
||||
|
||||
// --- Handlers de Atualização ---
|
||||
|
||||
const updatePrompt = useCallback(
|
||||
|
|
@ -152,8 +156,26 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
|||
[variables]
|
||||
)
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
onNodeFocus(id)
|
||||
}, [id, onNodeFocus])
|
||||
|
||||
return (
|
||||
<div className="min-w-[240px] rounded-lg border-2 border-green-500 bg-white p-3 shadow-md dark:border-green-400 dark:bg-zinc-900">
|
||||
<div
|
||||
className="relative min-w-[240px] rounded-lg border-2 border-green-500 bg-white p-3 shadow-md dark:border-green-400 dark:bg-zinc-900"
|
||||
onFocus={handleFocus}
|
||||
onBlur={onNodeBlur}
|
||||
>
|
||||
{isLockedByOther && (
|
||||
<NodeLockIndicator lock={lockInfo} color={lockInfo.color} />
|
||||
)}
|
||||
{isLockedByOther && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center rounded-lg bg-black/10">
|
||||
<span className="rounded bg-black/70 px-2 py-1 text-xs text-white">
|
||||
Being edited by {lockInfo.displayName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Handle type="target" position={Position.Top} className="!bg-green-500" />
|
||||
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
|
|||
import Combobox from '@/components/editor/Combobox'
|
||||
import type { ComboboxItem } from '@/components/editor/Combobox'
|
||||
import { useEditorContext } from '@/components/editor/EditorContext'
|
||||
import NodeLockIndicator from '@/components/editor/NodeLockIndicator'
|
||||
|
||||
type DialogueNodeData = {
|
||||
speaker?: string
|
||||
|
|
@ -24,7 +25,10 @@ function randomColor(): string {
|
|||
|
||||
export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>) {
|
||||
const { setNodes } = useReactFlow()
|
||||
const { characters, onAddCharacter } = useEditorContext()
|
||||
const { characters, onAddCharacter, nodeLocks, onNodeFocus, onNodeBlur } = useEditorContext()
|
||||
|
||||
const lockInfo = nodeLocks.get(id)
|
||||
const isLockedByOther = !!lockInfo
|
||||
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
|
|
@ -96,14 +100,30 @@ export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>)
|
|||
[updateNodeData]
|
||||
)
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
onNodeFocus(id)
|
||||
}, [id, onNodeFocus])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[200px] rounded-lg border-2 ${
|
||||
className={`relative min-w-[200px] rounded-lg border-2 ${
|
||||
hasInvalidReference
|
||||
? 'border-orange-500 dark:border-orange-400'
|
||||
: 'border-blue-500 dark:border-blue-400'
|
||||
} bg-blue-50 p-3 shadow-md dark:bg-blue-950`}
|
||||
onFocus={handleFocus}
|
||||
onBlur={onNodeBlur}
|
||||
>
|
||||
{isLockedByOther && (
|
||||
<NodeLockIndicator lock={lockInfo} color={lockInfo.color} />
|
||||
)}
|
||||
{isLockedByOther && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center rounded-lg bg-black/10">
|
||||
<span className="rounded bg-black/70 px-2 py-1 text-xs text-white">
|
||||
Being edited by {lockInfo.displayName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
|
|||
import Combobox from '@/components/editor/Combobox'
|
||||
import type { ComboboxItem } from '@/components/editor/Combobox'
|
||||
import { useEditorContext } from '@/components/editor/EditorContext'
|
||||
import NodeLockIndicator from '@/components/editor/NodeLockIndicator'
|
||||
|
||||
type VariableNodeData = {
|
||||
variableName: string
|
||||
|
|
@ -15,7 +16,10 @@ type VariableNodeData = {
|
|||
|
||||
export default function VariableNode({ id, data }: NodeProps<VariableNodeData>) {
|
||||
const { setNodes } = useReactFlow()
|
||||
const { variables, onAddVariable } = useEditorContext()
|
||||
const { variables, onAddVariable, nodeLocks, onNodeFocus, onNodeBlur } = useEditorContext()
|
||||
|
||||
const lockInfo = nodeLocks.get(id)
|
||||
const isLockedByOther = !!lockInfo
|
||||
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
|
|
@ -109,14 +113,30 @@ export default function VariableNode({ id, data }: NodeProps<VariableNodeData>)
|
|||
// Filter operations based on selected variable type
|
||||
const isNumeric = !selectedVariable || selectedVariable.type === 'numeric'
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
onNodeFocus(id)
|
||||
}, [id, onNodeFocus])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[200px] rounded-lg border-2 ${
|
||||
className={`relative min-w-[200px] rounded-lg border-2 ${
|
||||
hasInvalidReference
|
||||
? 'border-orange-500 dark:border-orange-400'
|
||||
: 'border-orange-500 dark:border-orange-400'
|
||||
} bg-orange-50 p-3 shadow-md dark:bg-orange-950`}
|
||||
onFocus={handleFocus}
|
||||
onBlur={onNodeBlur}
|
||||
>
|
||||
{isLockedByOther && (
|
||||
<NodeLockIndicator lock={lockInfo} color={lockInfo.color} />
|
||||
)}
|
||||
{isLockedByOther && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center rounded-lg bg-black/10">
|
||||
<span className="rounded bg-black/70 px-2 py-1 text-xs text-white">
|
||||
Being edited by {lockInfo.displayName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,13 @@ export type RemoteCursor = {
|
|||
lastUpdated: number
|
||||
}
|
||||
|
||||
export type NodeLock = {
|
||||
nodeId: string
|
||||
userId: string
|
||||
displayName: string
|
||||
lockedAt: number
|
||||
}
|
||||
|
||||
type RealtimeCallbacks = {
|
||||
onConnectionStateChange: (state: ConnectionState) => void
|
||||
onPresenceSync?: (users: PresenceUser[]) => void
|
||||
|
|
@ -27,6 +34,7 @@ type RealtimeCallbacks = {
|
|||
onPresenceLeave?: (user: PresenceUser) => void
|
||||
onChannelSubscribed?: (channel: RealtimeChannel) => void
|
||||
onCursorUpdate?: (cursor: RemoteCursor) => void
|
||||
onNodeLockUpdate?: (lock: NodeLock | null, userId: string) => void
|
||||
}
|
||||
|
||||
const HEARTBEAT_INTERVAL_MS = 30_000
|
||||
|
|
@ -109,6 +117,20 @@ export class RealtimeConnection {
|
|||
lastUpdated: Date.now(),
|
||||
})
|
||||
})
|
||||
.on('broadcast', { event: 'node-lock' }, ({ payload }) => {
|
||||
if (payload.userId === this.userId) return
|
||||
if (payload.nodeId) {
|
||||
this.callbacks.onNodeLockUpdate?.({
|
||||
nodeId: payload.nodeId,
|
||||
userId: payload.userId,
|
||||
displayName: payload.displayName,
|
||||
lockedAt: payload.lockedAt,
|
||||
}, payload.userId)
|
||||
} else {
|
||||
// null nodeId means unlock
|
||||
this.callbacks.onNodeLockUpdate?.(null, payload.userId)
|
||||
}
|
||||
})
|
||||
.subscribe(async (status) => {
|
||||
if (this.isDestroyed) return
|
||||
|
||||
|
|
@ -167,6 +189,20 @@ export class RealtimeConnection {
|
|||
})
|
||||
}
|
||||
|
||||
broadcastNodeLock(nodeId: string | null): void {
|
||||
if (!this.channel) return
|
||||
this.channel.send({
|
||||
type: 'broadcast',
|
||||
event: 'node-lock',
|
||||
payload: {
|
||||
userId: this.userId,
|
||||
displayName: this.displayName,
|
||||
nodeId,
|
||||
lockedAt: Date.now(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private async createSession(): Promise<void> {
|
||||
try {
|
||||
await this.supabase.from('collaboration_sessions').upsert(
|
||||
|
|
|
|||
Loading…
Reference in New Issue