feat: [US-049] - Node editing lock indicators

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Gustavo Henrique Santos Souza de Miranda 2026-01-24 02:43:06 -03:00
parent 5b84170a28
commit 4a85d7a64b
7 changed files with 225 additions and 10 deletions

View File

@ -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, 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 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'
@ -525,6 +526,9 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
const isRemoteUpdateRef = useRef(false) const isRemoteUpdateRef = useRef(false)
const cursorThrottleRef = useRef<number>(0) const cursorThrottleRef = useRef<number>(0)
const [collaborationNotifications, setCollaborationNotifications] = useState<CollaborationNotification[]>([]) 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 // Initialize CRDT manager and connect to Supabase Realtime channel on mount
useEffect(() => { useEffect(() => {
@ -590,6 +594,22 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
return [...prev, cursor] 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) => { onChannelSubscribed: (channel) => {
crdtManager.connectChannel(channel) crdtManager.connectChannel(channel)
}, },
@ -598,6 +618,11 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
connection.connect() connection.connect()
return () => { return () => {
// Release any held lock before disconnecting
if (localLockRef.current) {
connection.broadcastNodeLock(null)
localLockRef.current = null
}
connection.disconnect() connection.disconnect()
realtimeRef.current = null realtimeRef.current = null
crdtManager.destroy() crdtManager.destroy()
@ -658,19 +683,70 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
[screenToFlowPosition] [screenToFlowPosition]
) )
// Remove cursors for users who leave // Remove cursors and locks for users who leave
useEffect(() => { useEffect(() => {
setRemoteCursors((prev) => setRemoteCursors((prev) =>
prev.filter((cursor) => prev.filter((cursor) =>
presenceUsers.some((u) => u.userId === cursor.userId) 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]) }, [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) => { const handleDismissNotification = useCallback((id: string) => {
setCollaborationNotifications((prev) => prev.filter((n) => n.id !== id)) 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( const handleAddCharacter = useCallback(
(name: string, color: string): string => { (name: string, color: string): string => {
const id = nanoid() const id = nanoid()
@ -692,8 +768,16 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
) )
const editorContextValue = useMemo( 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( const getCharacterUsageCount = useCallback(

View File

@ -2,12 +2,18 @@
import { createContext, useContext } from 'react' import { createContext, useContext } from 'react'
import type { Character, Variable } from '@/types/flowchart' import type { Character, Variable } from '@/types/flowchart'
import type { NodeLock } from '@/lib/collaboration/realtime'
export type NodeLockInfo = NodeLock & { color: string }
type EditorContextValue = { type EditorContextValue = {
characters: Character[] characters: Character[]
onAddCharacter: (name: string, color: string) => string // returns new character id onAddCharacter: (name: string, color: string) => string // returns new character id
variables: Variable[] variables: Variable[]
onAddVariable: (name: string, type: 'numeric' | 'string' | 'boolean', initialValue: number | string | boolean) => string // returns new variable id 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>({ const EditorContext = createContext<EditorContextValue>({
@ -15,6 +21,9 @@ const EditorContext = createContext<EditorContextValue>({
onAddCharacter: () => '', onAddCharacter: () => '',
variables: [], variables: [],
onAddVariable: () => '', onAddVariable: () => '',
nodeLocks: new Map(),
onNodeFocus: () => {},
onNodeBlur: () => {},
}) })
export const EditorProvider = EditorContext.Provider export const EditorProvider = EditorContext.Provider

View File

@ -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>
)
}

View File

@ -5,6 +5,7 @@ import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import { useEditorContext } from '@/components/editor/EditorContext' import { useEditorContext } from '@/components/editor/EditorContext'
import OptionConditionEditor from '@/components/editor/OptionConditionEditor' import OptionConditionEditor from '@/components/editor/OptionConditionEditor'
import NodeLockIndicator from '@/components/editor/NodeLockIndicator'
import type { Condition } from '@/types/flowchart' import type { Condition } from '@/types/flowchart'
type ChoiceOption = { type ChoiceOption = {
@ -23,9 +24,12 @@ const MAX_OPTIONS = 6
export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) { export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
const { setNodes } = useReactFlow() 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 [editingConditionOptionId, setEditingConditionOptionId] = useState<string | null>(null)
const lockInfo = nodeLocks.get(id)
const isLockedByOther = !!lockInfo
// --- Handlers de Atualização --- // --- Handlers de Atualização ---
const updatePrompt = useCallback( const updatePrompt = useCallback(
@ -152,8 +156,26 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
[variables] [variables]
) )
const handleFocus = useCallback(() => {
onNodeFocus(id)
}, [id, onNodeFocus])
return ( 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" /> <Handle type="target" position={Position.Top} className="!bg-green-500" />
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">

View File

@ -5,6 +5,7 @@ import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
import Combobox from '@/components/editor/Combobox' import Combobox from '@/components/editor/Combobox'
import type { ComboboxItem } from '@/components/editor/Combobox' import type { ComboboxItem } from '@/components/editor/Combobox'
import { useEditorContext } from '@/components/editor/EditorContext' import { useEditorContext } from '@/components/editor/EditorContext'
import NodeLockIndicator from '@/components/editor/NodeLockIndicator'
type DialogueNodeData = { type DialogueNodeData = {
speaker?: string speaker?: string
@ -24,7 +25,10 @@ function randomColor(): string {
export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>) { export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>) {
const { setNodes } = useReactFlow() 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 [showAddForm, setShowAddForm] = useState(false)
const [newName, setNewName] = useState('') const [newName, setNewName] = useState('')
@ -96,14 +100,30 @@ export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>)
[updateNodeData] [updateNodeData]
) )
const handleFocus = useCallback(() => {
onNodeFocus(id)
}, [id, onNodeFocus])
return ( return (
<div <div
className={`min-w-[200px] rounded-lg border-2 ${ className={`relative min-w-[200px] rounded-lg border-2 ${
hasInvalidReference hasInvalidReference
? 'border-orange-500 dark:border-orange-400' ? 'border-orange-500 dark:border-orange-400'
: 'border-blue-500 dark:border-blue-400' : 'border-blue-500 dark:border-blue-400'
} bg-blue-50 p-3 shadow-md dark:bg-blue-950`} } 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 <Handle
type="target" type="target"
position={Position.Top} position={Position.Top}

View File

@ -5,6 +5,7 @@ import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
import Combobox from '@/components/editor/Combobox' import Combobox from '@/components/editor/Combobox'
import type { ComboboxItem } from '@/components/editor/Combobox' import type { ComboboxItem } from '@/components/editor/Combobox'
import { useEditorContext } from '@/components/editor/EditorContext' import { useEditorContext } from '@/components/editor/EditorContext'
import NodeLockIndicator from '@/components/editor/NodeLockIndicator'
type VariableNodeData = { type VariableNodeData = {
variableName: string variableName: string
@ -15,7 +16,10 @@ type VariableNodeData = {
export default function VariableNode({ id, data }: NodeProps<VariableNodeData>) { export default function VariableNode({ id, data }: NodeProps<VariableNodeData>) {
const { setNodes } = useReactFlow() 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 [showAddForm, setShowAddForm] = useState(false)
const [newName, setNewName] = useState('') const [newName, setNewName] = useState('')
@ -109,14 +113,30 @@ export default function VariableNode({ id, data }: NodeProps<VariableNodeData>)
// Filter operations based on selected variable type // Filter operations based on selected variable type
const isNumeric = !selectedVariable || selectedVariable.type === 'numeric' const isNumeric = !selectedVariable || selectedVariable.type === 'numeric'
const handleFocus = useCallback(() => {
onNodeFocus(id)
}, [id, onNodeFocus])
return ( return (
<div <div
className={`min-w-[200px] rounded-lg border-2 ${ className={`relative min-w-[200px] rounded-lg border-2 ${
hasInvalidReference hasInvalidReference
? 'border-orange-500 dark:border-orange-400' ? 'border-orange-500 dark:border-orange-400'
: '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`} } 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 <Handle
type="target" type="target"
position={Position.Top} position={Position.Top}

View File

@ -20,6 +20,13 @@ export type RemoteCursor = {
lastUpdated: number lastUpdated: number
} }
export type NodeLock = {
nodeId: string
userId: string
displayName: string
lockedAt: number
}
type RealtimeCallbacks = { type RealtimeCallbacks = {
onConnectionStateChange: (state: ConnectionState) => void onConnectionStateChange: (state: ConnectionState) => void
onPresenceSync?: (users: PresenceUser[]) => void onPresenceSync?: (users: PresenceUser[]) => void
@ -27,6 +34,7 @@ type RealtimeCallbacks = {
onPresenceLeave?: (user: PresenceUser) => void onPresenceLeave?: (user: PresenceUser) => void
onChannelSubscribed?: (channel: RealtimeChannel) => void onChannelSubscribed?: (channel: RealtimeChannel) => void
onCursorUpdate?: (cursor: RemoteCursor) => void onCursorUpdate?: (cursor: RemoteCursor) => void
onNodeLockUpdate?: (lock: NodeLock | null, userId: string) => void
} }
const HEARTBEAT_INTERVAL_MS = 30_000 const HEARTBEAT_INTERVAL_MS = 30_000
@ -109,6 +117,20 @@ export class RealtimeConnection {
lastUpdated: Date.now(), 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) => { .subscribe(async (status) => {
if (this.isDestroyed) return 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> { private async createSession(): Promise<void> {
try { try {
await this.supabase.from('collaboration_sessions').upsert( await this.supabase.from('collaboration_sessions').upsert(