From 4a85d7a64bc35005dde9a41f80bcdfb3ed725f75 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Sat, 24 Jan 2026 02:43:06 -0300 Subject: [PATCH] feat: [US-049] - Node editing lock indicators Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 92 ++++++++++++++++++- src/components/editor/EditorContext.tsx | 9 ++ src/components/editor/NodeLockIndicator.tsx | 24 +++++ src/components/editor/nodes/ChoiceNode.tsx | 26 +++++- src/components/editor/nodes/DialogueNode.tsx | 24 ++++- src/components/editor/nodes/VariableNode.tsx | 24 ++++- src/lib/collaboration/realtime.ts | 36 ++++++++ 7 files changed, 225 insertions(+), 10 deletions(-) create mode 100644 src/components/editor/NodeLockIndicator.tsx diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index c353a3c..83657a7 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -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(0) const [collaborationNotifications, setCollaborationNotifications] = useState([]) + const [nodeLocks, setNodeLocks] = useState>(new Map()) + const localLockRef = useRef(null) // node ID currently locked by this user + const lockExpiryTimerRef = useRef | 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( diff --git a/src/components/editor/EditorContext.tsx b/src/components/editor/EditorContext.tsx index 39a921d..177c119 100644 --- a/src/components/editor/EditorContext.tsx +++ b/src/components/editor/EditorContext.tsx @@ -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 // nodeId -> lock info + onNodeFocus: (nodeId: string) => void + onNodeBlur: () => void } const EditorContext = createContext({ @@ -15,6 +21,9 @@ const EditorContext = createContext({ onAddCharacter: () => '', variables: [], onAddVariable: () => '', + nodeLocks: new Map(), + onNodeFocus: () => {}, + onNodeBlur: () => {}, }) export const EditorProvider = EditorContext.Provider diff --git a/src/components/editor/NodeLockIndicator.tsx b/src/components/editor/NodeLockIndicator.tsx new file mode 100644 index 0000000..8cde517 --- /dev/null +++ b/src/components/editor/NodeLockIndicator.tsx @@ -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 ( +
+
+ {lock.displayName} +
+
+ ) +} diff --git a/src/components/editor/nodes/ChoiceNode.tsx b/src/components/editor/nodes/ChoiceNode.tsx index 47f54e4..523ffac 100644 --- a/src/components/editor/nodes/ChoiceNode.tsx +++ b/src/components/editor/nodes/ChoiceNode.tsx @@ -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) { 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(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) { [variables] ) + const handleFocus = useCallback(() => { + onNodeFocus(id) + }, [id, onNodeFocus]) + return ( -
+
+ {isLockedByOther && ( + + )} + {isLockedByOther && ( +
+ + Being edited by {lockInfo.displayName} + +
+ )}
diff --git a/src/components/editor/nodes/DialogueNode.tsx b/src/components/editor/nodes/DialogueNode.tsx index 10afe97..0ae4956 100644 --- a/src/components/editor/nodes/DialogueNode.tsx +++ b/src/components/editor/nodes/DialogueNode.tsx @@ -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) { 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) [updateNodeData] ) + const handleFocus = useCallback(() => { + onNodeFocus(id) + }, [id, onNodeFocus]) + return (
+ {isLockedByOther && ( + + )} + {isLockedByOther && ( +
+ + Being edited by {lockInfo.displayName} + +
+ )} ) { 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) // Filter operations based on selected variable type const isNumeric = !selectedVariable || selectedVariable.type === 'numeric' + const handleFocus = useCallback(() => { + onNodeFocus(id) + }, [id, onNodeFocus]) + return (
+ {isLockedByOther && ( + + )} + {isLockedByOther && ( +
+ + Being edited by {lockInfo.displayName} + +
+ )} 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 { try { await this.supabase.from('collaboration_sessions').upsert(