Spaces:
Running
Running
Completely rewrite ImageNode scaling with pure pointer-event mathematical calculations and direct React Flow store updates
b4d6f7f | import { useState, useCallback, useRef, useEffect } from 'react'; | |
| import axios from 'axios'; | |
| import { useTranslation } from 'react-i18next'; | |
| import { useAchievementNotification } from '../../../context/AchievementNotificationContext'; | |
| import { useNavigate } from 'react-router-dom'; | |
| import { | |
| useNodesState, | |
| useEdgesState, | |
| addEdge, | |
| MarkerType, | |
| getOutgoers, | |
| getIncomers, | |
| useReactFlow, | |
| } from '@xyflow/react'; | |
| import { useSignalR } from './useSignalR'; | |
| export const getConditionSourceHandle = (sourceNode, condition) => { | |
| if (!sourceNode) return null; | |
| const isCondition = sourceNode.type === 'condition' || sourceNode.type === 'conditional'; | |
| if (!isCondition) return null; | |
| let opts = []; | |
| // 1. Try to get options from data.options (React Flow format) | |
| if (sourceNode.data && sourceNode.data.options) { | |
| opts = sourceNode.data.options; | |
| } | |
| // 2. Try to get options from node.options (raw backend format) | |
| else if (sourceNode.options) { | |
| opts = sourceNode.options; | |
| } | |
| // 3. Try to get options from decision JSON string | |
| else if (sourceNode.decision && sourceNode.decision.startsWith('{')) { | |
| try { | |
| const parsed = JSON.parse(sourceNode.decision); | |
| opts = parsed.options || []; | |
| } catch (err) { | |
| console.error(err); | |
| } | |
| } | |
| if (!opts || opts.length === 0) { | |
| opts = [ | |
| { id: 'opt-yes', label: 'Так' }, | |
| { id: 'opt-no', label: 'Ні' } | |
| ]; | |
| } | |
| const condStr = condition; | |
| // A. Check if the condition is already a valid option ID in the options array | |
| const matchingOpt = opts.find(o => o.id === condStr); | |
| if (matchingOpt) { | |
| return matchingOpt.id; | |
| } | |
| // B. Fallback to legacy index-based mappings | |
| let targetIndex = -1; | |
| if (condStr === 'True' || condStr === '0') { | |
| targetIndex = 0; | |
| } else if (condStr === 'False' || condStr === '1') { | |
| targetIndex = 1; | |
| } else if (condStr === 'Blocked' || condStr === '2') { | |
| targetIndex = 2; | |
| } else { | |
| const parsedInt = parseInt(condStr, 10); | |
| if (!isNaN(parsedInt)) { | |
| targetIndex = parsedInt; | |
| } | |
| } | |
| if (targetIndex >= 0 && targetIndex < opts.length) { | |
| return opts[targetIndex].id; | |
| } | |
| return condStr === 'False' ? 'opt-no' : 'opt-yes'; | |
| }; | |
| export const isResetDue = (lastResetTime, rule) => { | |
| if (!lastResetTime) return false; | |
| const lastReset = new Date(lastResetTime); | |
| const now = new Date(); | |
| if (rule?.type === 'daily') { | |
| const [hour, minute] = (rule.time || '00:00').split(':').map(Number); | |
| const rDate = new Date(lastReset); | |
| rDate.setHours(hour, minute, 0, 0); | |
| if (lastReset.getTime() < rDate.getTime() && now.getTime() >= rDate.getTime()) { | |
| return true; | |
| } | |
| const tomorrowDate = new Date(rDate); | |
| tomorrowDate.setDate(tomorrowDate.getDate() + 1); | |
| if (lastReset.getTime() < tomorrowDate.getTime() && now.getTime() >= tomorrowDate.getTime()) { | |
| return true; | |
| } | |
| } else if (rule?.type === 'weekly') { | |
| const dayOfWeek = rule.day !== undefined ? Number(rule.day) : 1; | |
| const [hour, minute] = (rule.time || '00:00').split(':').map(Number); | |
| const rDate = new Date(lastReset); | |
| rDate.setHours(hour, minute, 0, 0); | |
| let nextReset = rDate; | |
| while (nextReset.getDay() !== dayOfWeek || nextReset.getTime() <= lastReset.getTime()) { | |
| nextReset.setDate(nextReset.getDate() + 1); | |
| } | |
| if (now.getTime() >= nextReset.getTime()) { | |
| return true; | |
| } | |
| } else if (rule?.type === 'hourly') { | |
| const hours = Number(rule.hours || 24); | |
| const nextReset = new Date(lastReset.getTime() + hours * 60 * 60 * 1000); | |
| if (now.getTime() >= nextReset.getTime()) { | |
| return true; | |
| } | |
| } else if (rule?.type === 'minutely') { | |
| const minutes = Number(rule.minutes || 5); | |
| const nextReset = new Date(lastReset.getTime() + minutes * 60 * 1000); | |
| if (now.getTime() >= nextReset.getTime()) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| }; | |
| const getConsistentColor = (id) => { | |
| const colors = [ | |
| '#EF4444', '#F97316', '#F59E0B', '#10B981', | |
| '#06B6D4', '#3B82F6', '#6366F1', '#8B5CF6', '#EC4899' | |
| ]; | |
| let hash = 0; | |
| for (let i = 0; i < id.length; i++) { | |
| hash = id.charCodeAt(i) + ((hash << 5) - hash); | |
| } | |
| const index = Math.abs(hash) % colors.length; | |
| return colors[index]; | |
| }; | |
| export const useCanvasLogic = (projectId, user) => { | |
| const navigate = useNavigate(); | |
| const { screenToFlowPosition } = useReactFlow(); | |
| const [nodes, setNodes, onNodesChange] = useNodesState([]); | |
| const [edges, setEdges, onEdgesChange] = useEdgesState([]); | |
| const { triggerNotification } = useAchievementNotification(); | |
| const { t } = useTranslation(); | |
| const [cursors, setCursors] = useState({}); | |
| const [clipboardVersion, setClipboardVersion] = useState(0); | |
| const [isAIActive, setIsAIActive] = useState(false); | |
| const [aiLoading, setAiLoading] = useState(false); | |
| const defaultThread = { | |
| id: 'default', | |
| title: 'Основний чат', | |
| messages: [ | |
| { | |
| id: 'welcome', | |
| sender: 'agent', | |
| text: 'Привіт! Я твій ШІ-стратег. Опиши, що ти хочеш додати на доску або як хочеш змінити поточний план. Я можу будувати підзадачі, умови та звички, позначаючи всі зв\'язки.' | |
| } | |
| ] | |
| }; | |
| const [chatThreads, setChatThreads] = useState(() => { | |
| if (!projectId) return [defaultThread]; | |
| const saved = localStorage.getItem(`flow_chats_${projectId}`); | |
| if (saved) { | |
| try { | |
| return JSON.parse(saved); | |
| } catch (e) { | |
| console.error(e); | |
| } | |
| } | |
| return [defaultThread]; | |
| }); | |
| const [activeThreadId, setActiveThreadId] = useState(() => { | |
| if (!projectId) return 'default'; | |
| const savedActive = localStorage.getItem(`flow_chats_active_${projectId}`); | |
| return savedActive || 'default'; | |
| }); | |
| useEffect(() => { | |
| if (!projectId) return; | |
| const saved = localStorage.getItem(`flow_chats_${projectId}`); | |
| let loadedThreads = []; | |
| if (saved) { | |
| try { | |
| loadedThreads = JSON.parse(saved); | |
| } catch (e) { | |
| console.error(e); | |
| } | |
| } | |
| if (!loadedThreads || loadedThreads.length === 0) { | |
| loadedThreads = [defaultThread]; | |
| } | |
| setChatThreads(loadedThreads); | |
| const savedActive = localStorage.getItem(`flow_chats_active_${projectId}`); | |
| if (savedActive && loadedThreads.some(t => t.id === savedActive)) { | |
| setActiveThreadId(savedActive); | |
| } else { | |
| setActiveThreadId(loadedThreads[0].id); | |
| } | |
| }, [projectId]); | |
| useEffect(() => { | |
| if (!projectId || chatThreads.length === 0) return; | |
| localStorage.setItem(`flow_chats_${projectId}`, JSON.stringify(chatThreads)); | |
| }, [chatThreads, projectId]); | |
| useEffect(() => { | |
| if (!projectId) return; | |
| localStorage.setItem(`flow_chats_active_${projectId}`, activeThreadId); | |
| }, [activeThreadId, projectId]); | |
| const activeThread = chatThreads.find(t => t.id === activeThreadId) || chatThreads[0] || defaultThread; | |
| const chatMessages = activeThread.messages || []; | |
| const setChatMessages = (updater) => { | |
| setChatThreads(prevThreads => { | |
| return prevThreads.map(thread => { | |
| if (thread.id === activeThreadId) { | |
| const newMessages = typeof updater === 'function' ? updater(thread.messages) : updater; | |
| return { ...thread, messages: newMessages }; | |
| } | |
| return thread; | |
| }); | |
| }); | |
| }; | |
| const createNewThread = (title = '') => { | |
| const newId = crypto.randomUUID(); | |
| const newThread = { | |
| id: newId, | |
| title: title.trim() || `Чат ${chatThreads.length + 1}`, | |
| messages: [ | |
| { | |
| id: 'welcome', | |
| sender: 'agent', | |
| text: 'Привіт! Я твій ШІ-стратег. Опиши, що ти хочеш додати на доску або як хочеш змінити поточний план. Я можу будувати підзадачі, умови та звички, позначаючи всі зв\'язки.' | |
| } | |
| ], | |
| createdAt: new Date().toISOString() | |
| }; | |
| setChatThreads(prev => [...prev, newThread]); | |
| setActiveThreadId(newId); | |
| return newId; | |
| }; | |
| const deleteThread = (threadId) => { | |
| const remaining = chatThreads.filter(t => t.id !== threadId); | |
| if (remaining.length === 0) { | |
| setChatThreads([defaultThread]); | |
| setActiveThreadId('default'); | |
| return; | |
| } | |
| setChatThreads(remaining); | |
| if (activeThreadId === threadId) { | |
| setActiveThreadId(remaining[0].id); | |
| } | |
| }; | |
| const renameThread = (threadId, newTitle) => { | |
| if (!newTitle.trim()) return; | |
| setChatThreads(prev => prev.map(t => t.id === threadId ? { ...t, title: newTitle.trim() } : t)); | |
| }; | |
| const [showPaywall, setShowPaywall] = useState(false); | |
| const [isSaving, setIsSaving] = useState(false); | |
| const [isManualSaving, setIsManualSaving] = useState(false); | |
| const [saveStatus, setSaveStatus] = useState('saved'); // 'saved', 'saving', 'unsaved' | |
| const [projectData, setProjectData] = useState(null); | |
| const [collaborators, setCollaborators] = useState([]); | |
| const [isLoading, setIsLoading] = useState(true); | |
| const activeUsersRef = useRef([]); | |
| const isRemoteChangeRef = useRef(false); | |
| const remoteTimeoutRef = useRef(null); | |
| const broadcastMessageRef = useRef(null); | |
| // Cleanup WebRTC remote change timeout on unmount | |
| useEffect(() => { | |
| return () => { | |
| if (remoteTimeoutRef.current) clearTimeout(remoteTimeoutRef.current); | |
| }; | |
| }, []); | |
| const isInitialLoadRef = useRef(true); | |
| // --- Undo/Redo History Stack Subsystem --- | |
| const pastRef = useRef([]); | |
| const futureRef = useRef([]); | |
| const nodesRef = useRef(nodes); | |
| const edgesRef = useRef(edges); | |
| const lastSnapshotTimeRef = useRef(0); | |
| // Sync state to refs for safe, loop-free snapshotting | |
| useEffect(() => { | |
| nodesRef.current = nodes; | |
| edgesRef.current = edges; | |
| }, [nodes, edges]); | |
| const takeHistorySnapshot = useCallback(() => { | |
| if (isInitialLoadRef.current) return; | |
| const permission = projectData?.permission || 'Owner'; | |
| if (permission === 'Read') return; | |
| const now = Date.now(); | |
| // Time-based debounce (50ms) to group synchronous batch mutations (loops, cut, paste, etc.) | |
| if (now - lastSnapshotTimeRef.current < 50) return; | |
| lastSnapshotTimeRef.current = now; | |
| const currentNodes = nodesRef.current; | |
| const currentEdges = edgesRef.current; | |
| const nodesSnapshot = currentNodes.map(n => ({ | |
| ...n, | |
| position: { ...n.position }, | |
| data: { ...n.data } | |
| })); | |
| const edgesSnapshot = currentEdges.map(e => ({ ...e })); | |
| pastRef.current.push({ nodes: nodesSnapshot, edges: edgesSnapshot }); | |
| if (pastRef.current.length > 50) { | |
| pastRef.current.shift(); | |
| } | |
| futureRef.current = []; // Clear redo stack on new action | |
| }, [projectData]); | |
| // Surgical state diffing for real-time peer-to-peer SignalR sync during undo/redo actions | |
| const syncStateChanges = useCallback((oldNodes, oldEdges, newNodes, newEdges) => { | |
| if (!broadcastMessageRef.current) return; | |
| const oldNodesMap = new Map(oldNodes.map(n => [n.id, n])); | |
| const newNodesMap = new Map(newNodes.map(n => [n.id, n])); | |
| const oldEdgesMap = new Map(oldEdges.map(e => [e.id, e])); | |
| const newEdgesMap = new Map(newEdges.map(e => [e.id, e])); | |
| // 1. Deleted nodes | |
| oldNodes.forEach(node => { | |
| if (!newNodesMap.has(node.id)) { | |
| broadcastMessageRef.current({ | |
| type: 'NODE_DELETE', | |
| payload: { id: node.id } | |
| }); | |
| } | |
| }); | |
| // 2. Added nodes | |
| newNodes.forEach(node => { | |
| if (!oldNodesMap.has(node.id)) { | |
| const { updateNodeData, ...restData } = node.data; | |
| broadcastMessageRef.current({ | |
| type: 'NODE_ADD', | |
| payload: { | |
| ...node, | |
| data: restData | |
| } | |
| }); | |
| } | |
| }); | |
| // 3. Updated / Moved nodes | |
| newNodes.forEach(node => { | |
| const oldNode = oldNodesMap.get(node.id); | |
| if (oldNode) { | |
| if (oldNode.position.x !== node.position.x || oldNode.position.y !== node.position.y) { | |
| broadcastMessageRef.current({ | |
| type: 'NODE_MOVE', | |
| payload: { | |
| id: node.id, | |
| position: node.position | |
| } | |
| }); | |
| } | |
| const oldData = oldNode.data; | |
| const newData = node.data; | |
| let dataChanged = false; | |
| const keysToCheck = [ | |
| 'label', 'status', 'options', 'selectedOptions', 'multiSelect', | |
| 'resetRule', 'description', 'progress', 'showProgress', 'decision' | |
| ]; | |
| for (let key of keysToCheck) { | |
| if (JSON.stringify(oldData[key]) !== JSON.stringify(newData[key])) { | |
| dataChanged = true; | |
| break; | |
| } | |
| } | |
| if (dataChanged) { | |
| const { updateNodeData, ...restData } = newData; | |
| broadcastMessageRef.current({ | |
| type: 'NODE_DATA', | |
| payload: { | |
| id: node.id, | |
| newData: restData | |
| } | |
| }); | |
| } | |
| } | |
| }); | |
| // 4. Deleted edges | |
| oldEdges.forEach(edge => { | |
| if (!newEdgesMap.has(edge.id)) { | |
| broadcastMessageRef.current({ | |
| type: 'EDGE_DELETE', | |
| payload: { id: edge.id } | |
| }); | |
| } | |
| }); | |
| // 5. Added edges | |
| newEdges.forEach(edge => { | |
| if (!oldEdgesMap.has(edge.id)) { | |
| broadcastMessageRef.current({ | |
| type: 'EDGE_ADD', | |
| payload: edge | |
| }); | |
| } | |
| }); | |
| }, []); | |
| const updateNodeDataCallback = useCallback((nodeId, newData) => { | |
| takeHistorySnapshot(); | |
| setNodes((nds) => { | |
| return nds.map((n) => { | |
| if (n.id === nodeId) { | |
| // Automatic status and progress coupling | |
| let coupledData = { ...newData }; | |
| const currentStatus = n.data?.status || 'Todo'; | |
| const currentProgress = n.data?.progress !== undefined ? n.data.progress : 0; | |
| if (coupledData.status !== undefined || coupledData.progress !== undefined) { | |
| const nextStatus = coupledData.status !== undefined ? coupledData.status : currentStatus; | |
| const nextProgress = coupledData.progress !== undefined ? Number(coupledData.progress) : currentProgress; | |
| if (coupledData.status !== undefined && coupledData.progress === undefined) { | |
| // Status changed, progress was not | |
| if (nextStatus === 'Done') { | |
| coupledData.progress = 100; | |
| } else if (nextStatus === 'Todo' || nextStatus === 'Pending') { | |
| coupledData.progress = 0; | |
| } else if (nextStatus === 'In Progress' && (currentProgress === 0 || currentProgress === 100)) { | |
| coupledData.progress = 50; | |
| } else if (nextStatus === 'Review' && (currentProgress === 0 || currentProgress === 100)) { | |
| coupledData.progress = 90; | |
| } | |
| } else if (coupledData.progress !== undefined && coupledData.status === undefined) { | |
| // Progress changed, status was not | |
| if (nextProgress === 100) { | |
| coupledData.status = 'Done'; | |
| } else if (nextProgress === 0) { | |
| coupledData.status = 'Todo'; | |
| } else if (currentStatus === 'Done' || currentStatus === 'Todo' || currentStatus === 'Pending') { | |
| coupledData.status = 'In Progress'; | |
| } | |
| } else if (coupledData.progress !== undefined && coupledData.status !== undefined) { | |
| // Both changed, align them | |
| if (coupledData.status === 'Done') { | |
| coupledData.progress = 100; | |
| } else if (coupledData.status === 'Todo' || coupledData.status === 'Pending') { | |
| coupledData.progress = 0; | |
| } else if (coupledData.progress === 100) { | |
| coupledData.status = 'Done'; | |
| } else if (coupledData.progress === 0) { | |
| coupledData.status = 'Todo'; | |
| } | |
| } | |
| } | |
| if (coupledData.url !== undefined || coupledData.path !== undefined) { | |
| if (!coupledData.isLoadedFromBackend) { | |
| coupledData.isLargeDataDirty = true; | |
| } | |
| if (coupledData.url || coupledData.path) { | |
| coupledData.hasLargeData = true; | |
| } | |
| } | |
| delete coupledData.isLoadedFromBackend; | |
| const updated = { ...n, data: { ...n.data, ...coupledData } }; | |
| if (coupledData.width !== undefined) { | |
| updated.width = coupledData.width; | |
| updated.style = { ...updated.style, width: coupledData.width }; | |
| } | |
| if (coupledData.height !== undefined) { | |
| updated.height = coupledData.height; | |
| updated.style = { ...updated.style, height: coupledData.height }; | |
| } | |
| if (coupledData.x !== undefined && coupledData.y !== undefined) { | |
| updated.position = { x: coupledData.x, y: coupledData.y }; | |
| } | |
| if (coupledData.isPinned !== undefined) { | |
| updated.draggable = !coupledData.isPinned; | |
| } | |
| return updated; | |
| } | |
| return n; | |
| }); | |
| }); | |
| if (broadcastMessageRef.current) { | |
| broadcastMessageRef.current({ | |
| type: 'NODE_DATA', | |
| payload: { | |
| id: nodeId, | |
| newData | |
| } | |
| }); | |
| } | |
| }, [setNodes, takeHistorySnapshot]); | |
| const undo = useCallback(() => { | |
| const permission = projectData?.permission || 'Owner'; | |
| if (permission === 'Read') return; | |
| if (pastRef.current.length === 0) return; | |
| const previousState = pastRef.current.pop(); | |
| const currentNodes = nodesRef.current; | |
| const currentEdges = edgesRef.current; | |
| // Save current state to redo stack | |
| const currentNodesSnapshot = currentNodes.map(n => ({ | |
| ...n, | |
| position: { ...n.position }, | |
| data: { ...n.data } | |
| })); | |
| const currentEdgesSnapshot = currentEdges.map(e => ({ ...e })); | |
| futureRef.current.push({ nodes: currentNodesSnapshot, edges: currentEdgesSnapshot }); | |
| // Restore previous state with re-attached updates callback | |
| const restoredNodes = previousState.nodes.map(n => ({ | |
| ...n, | |
| data: { | |
| ...n.data, | |
| updateNodeData: updateNodeDataCallback | |
| } | |
| })); | |
| setNodes(restoredNodes); | |
| setEdges(previousState.edges); | |
| // Sync modifications to collaborative peers | |
| syncStateChanges(currentNodes, currentEdges, restoredNodes, previousState.edges); | |
| }, [setNodes, setEdges, updateNodeDataCallback, syncStateChanges, projectData]); | |
| const redo = useCallback(() => { | |
| const permission = projectData?.permission || 'Owner'; | |
| if (permission === 'Read') return; | |
| if (futureRef.current.length === 0) return; | |
| const nextState = futureRef.current.pop(); | |
| const currentNodes = nodesRef.current; | |
| const currentEdges = edgesRef.current; | |
| // Save current state to undo stack | |
| const currentNodesSnapshot = currentNodes.map(n => ({ | |
| ...n, | |
| position: { ...n.position }, | |
| data: { ...n.data } | |
| })); | |
| const currentEdgesSnapshot = currentEdges.map(e => ({ ...e })); | |
| pastRef.current.push({ nodes: currentNodesSnapshot, edges: currentEdgesSnapshot }); | |
| // Restore next state with re-attached updates callback | |
| const restoredNodes = nextState.nodes.map(n => ({ | |
| ...n, | |
| data: { | |
| ...n.data, | |
| updateNodeData: updateNodeDataCallback | |
| } | |
| })); | |
| setNodes(restoredNodes); | |
| setEdges(nextState.edges); | |
| // Sync modifications to collaborative peers | |
| syncStateChanges(currentNodes, currentEdges, restoredNodes, nextState.edges); | |
| }, [setNodes, setEdges, updateNodeDataCallback, syncStateChanges, projectData]); | |
| // Receive remote WebRTC events | |
| const onRemoteMessage = useCallback((peerId, message) => { | |
| if (message.type !== 'CURSOR_MOVE') { | |
| isRemoteChangeRef.current = true; | |
| if (remoteTimeoutRef.current) clearTimeout(remoteTimeoutRef.current); | |
| remoteTimeoutRef.current = setTimeout(() => { | |
| isRemoteChangeRef.current = false; | |
| }, 100); | |
| } | |
| switch (message.type) { | |
| case 'NODE_MOVE': { | |
| const { id, position } = message.payload; | |
| setNodes(nds => nds.map(n => n.id === id ? { ...n, position } : n)); | |
| break; | |
| } | |
| case 'NODE_DATA': { | |
| const { id, newData } = message.payload; | |
| setNodes(nds => nds.map(n => { | |
| if (n.id === id) { | |
| const updated = { ...n, data: { ...n.data, ...newData } }; | |
| if (newData.width !== undefined) { | |
| updated.width = newData.width; | |
| updated.style = { ...updated.style, width: newData.width }; | |
| } | |
| if (newData.height !== undefined) { | |
| updated.height = newData.height; | |
| updated.style = { ...updated.style, height: newData.height }; | |
| } | |
| if (newData.x !== undefined && newData.y !== undefined) { | |
| updated.position = { x: newData.x, y: newData.y }; | |
| } | |
| return updated; | |
| } | |
| return n; | |
| })); | |
| break; | |
| } | |
| case 'EDGE_ADD': { | |
| const edge = message.payload; | |
| setEdges(eds => { | |
| if (eds.some(e => e.id === edge.id)) return eds; | |
| // Determine if it should be a sticker edge visually | |
| const isSticker = edge.type === 'sticker'; | |
| return addEdge({ | |
| ...edge, | |
| type: isSticker ? 'sticker' : undefined, | |
| animated: !isSticker, | |
| markerEnd: isSticker ? null : { type: MarkerType.ArrowClosed, color: '#31363F' } | |
| }, eds); | |
| }); | |
| break; | |
| } | |
| case 'EDGE_DELETE': { | |
| const { id } = message.payload; | |
| setEdges(eds => eds.filter(e => e.id !== id)); | |
| break; | |
| } | |
| case 'NODE_DELETE': { | |
| const { id } = message.payload; | |
| setNodes(nds => nds.filter(n => n.id !== id)); | |
| setEdges(eds => eds.filter(e => e.source !== id && e.target !== id)); | |
| break; | |
| } | |
| case 'NODE_ADD': { | |
| const newNode = message.payload; | |
| const nodeWithCallback = { | |
| ...newNode, | |
| data: { | |
| ...newNode.data, | |
| updateNodeData: updateNodeDataCallback | |
| } | |
| }; | |
| setNodes(nds => { | |
| if (nds.some(n => n.id === newNode.id)) return nds; | |
| return nds.concat(nodeWithCallback); | |
| }); | |
| break; | |
| } | |
| case 'CURSOR_MOVE': { | |
| const { x, y } = message.payload; | |
| const peerInfo = activeUsersRef.current.find(u => u.userId === peerId); | |
| const name = peerInfo?.name || 'Collaborator'; | |
| const email = peerInfo?.email || ''; | |
| setCursors(prev => ({ | |
| ...prev, | |
| [peerId]: { | |
| x, | |
| y, | |
| name, | |
| email, | |
| color: getConsistentColor(peerId) | |
| } | |
| })); | |
| break; | |
| } | |
| default: | |
| break; | |
| } | |
| }, [setNodes, setEdges, updateNodeDataCallback]); | |
| // Hook up SignalR Hub connection for real-time collaboration | |
| const { peers, activeUsers, broadcastMessage } = useSignalR(projectId, user, onRemoteMessage); | |
| // Sync activeUsers to the Ref for safe use inside onRemoteMessage callbacks without dependency cycles | |
| useEffect(() => { | |
| activeUsersRef.current = activeUsers; | |
| }, [activeUsers]); | |
| // Clean up cursors of users that went offline | |
| useEffect(() => { | |
| const activeUserIds = new Set(activeUsers.map(u => u.userId)); | |
| setCursors(prev => { | |
| let changed = false; | |
| const updated = { ...prev }; | |
| Object.keys(updated).forEach(peerId => { | |
| if (!activeUserIds.has(peerId)) { | |
| delete updated[peerId]; | |
| changed = true; | |
| } | |
| }); | |
| return changed ? updated : prev; | |
| }); | |
| }, [activeUsers]); | |
| // Sync broadcastMessage to the Ref to break dependency cycle | |
| useEffect(() => { | |
| broadcastMessageRef.current = broadcastMessage; | |
| }, [broadcastMessage]); | |
| const onNodesChangeWithBroadcast = useCallback((changes) => { | |
| onNodesChange(changes); | |
| // Broadcast node position changes when dragging | |
| changes.forEach(change => { | |
| if (change.type === 'position' && change.position) { | |
| if (broadcastMessageRef.current) { | |
| broadcastMessageRef.current({ | |
| type: 'NODE_MOVE', | |
| payload: { | |
| id: change.id, | |
| position: change.position | |
| } | |
| }); | |
| } | |
| } | |
| }); | |
| }, [onNodesChange]); | |
| const API_URL = import.meta.env.VITE_API_URL; | |
| const fetchCollaborators = useCallback(async () => { | |
| if (!projectId) return; | |
| try { | |
| const res = await axios.get(`${API_URL}/graphs/${projectId}/shares`); | |
| setCollaborators(res.data || []); | |
| } catch (err) { | |
| console.error('Error fetching collaborators', err); | |
| } | |
| }, [projectId, API_URL]); | |
| const longPressTimer = useRef(null); | |
| // --- Load Project Data --- | |
| useEffect(() => { | |
| if (projectId) { | |
| const fetchProject = async () => { | |
| try { | |
| setIsLoading(true); | |
| isInitialLoadRef.current = true; | |
| setSaveStatus('saved'); | |
| const res = await axios.get(`${API_URL}/graphs/${projectId}/full`); | |
| const data = res.data; | |
| setProjectData(data); | |
| fetchCollaborators(); | |
| // Map backend nodes to React Flow format | |
| const mappedNodes = data.nodes.map(n => { | |
| let options = []; | |
| let selectedOptions = []; | |
| let multiSelect = false; | |
| let resetRule = { type: 'daily', time: '00:00' }; | |
| let lastResetTime = undefined; | |
| let createdAt = n.createdAt; | |
| let description = n.description || ''; | |
| let progress = 0; | |
| let showProgress = false; | |
| let decision = n.decision || (n.type === 'condition' ? 'True' : 'bottom'); | |
| let color = n.color; | |
| let isPinned = !!n.isPinned; | |
| let width = n.width; | |
| let height = n.height; | |
| let nodeData = n.data || ''; | |
| let assignees = []; | |
| let tags = []; | |
| let status = n.state === 1 ? 'Done' : 'Todo'; | |
| if (n.type === 'condition') { | |
| if (n.decision && n.decision.startsWith('{')) { | |
| try { | |
| const parsed = JSON.parse(n.decision); | |
| options = parsed.options || []; | |
| selectedOptions = parsed.selected || []; | |
| multiSelect = !!parsed.multiSelect; | |
| decision = undefined; | |
| } catch (e) { | |
| console.error('Failed to parse decision JSON', e); | |
| options = [ | |
| { id: 'opt-yes', label: 'Так' }, | |
| { id: 'opt-no', label: 'Ні' } | |
| ]; | |
| selectedOptions = n.decision === 'True' ? ['opt-yes'] : (n.decision === 'False' ? ['opt-no'] : []); | |
| } | |
| } else { | |
| options = [ | |
| { id: 'opt-yes', label: 'Так' }, | |
| { id: 'opt-no', label: 'Ні' } | |
| ]; | |
| selectedOptions = n.decision === 'True' ? ['opt-yes'] : (n.decision === 'False' ? ['opt-no'] : []); | |
| } | |
| } else if (n.type === 'habit') { | |
| if (n.decision && n.decision.startsWith('{')) { | |
| try { | |
| const parsed = JSON.parse(n.decision); | |
| resetRule = parsed.resetRule || { type: 'daily', time: '00:00' }; | |
| lastResetTime = parsed.lastResetTime; | |
| createdAt = parsed.createdAt || createdAt; | |
| description = parsed.description || description; | |
| tags = parsed.tags || []; | |
| status = parsed.status || (n.state === 1 ? 'Done' : 'Todo'); | |
| decision = undefined; | |
| } catch (e) { | |
| console.error('Failed to parse habit decision JSON', e); | |
| } | |
| } | |
| } else if (n.type === 'sketch' || n.type === 'comment' || n.type === 'image' || n.type === 'text') { | |
| if (n.decision && n.decision.startsWith('{')) { | |
| try { | |
| const parsed = JSON.parse(n.decision); | |
| description = parsed.description || description; | |
| progress = parsed.progress || 0; | |
| showProgress = !!parsed.showProgress; | |
| decision = parsed.decision || 'bottom'; | |
| color = parsed.color || color; | |
| isPinned = parsed.isPinned !== undefined ? !!parsed.isPinned : isPinned; | |
| width = parsed.width || width; | |
| height = parsed.height || height; | |
| tags = parsed.tags || []; | |
| status = parsed.status || (n.state === 1 ? 'Done' : 'Todo'); | |
| if (parsed.assignees && Array.isArray(parsed.assignees)) { | |
| assignees = parsed.assignees; | |
| } else if (parsed.assignee) { | |
| assignees = [parsed.assignee]; | |
| } | |
| } catch (e) { | |
| console.error('Failed to parse node decision JSON', e); | |
| } | |
| } | |
| } | |
| const dataObj = { | |
| label: n.label, | |
| status: status, | |
| tags: tags, | |
| decision, | |
| options, | |
| selectedOptions, | |
| multiSelect, | |
| resetRule, | |
| lastResetTime, | |
| createdAt, | |
| description: description || '', | |
| progress: progress, | |
| showProgress: showProgress, | |
| assignees, | |
| color, | |
| isPinned, | |
| width, | |
| height, | |
| url: n.type === 'image' ? (n.hasLargeData ? '' : (nodeData || decision)) : undefined, | |
| path: n.type === 'path' ? (n.hasLargeData ? '' : (nodeData || decision)) : undefined, | |
| hasLargeData: n.hasLargeData, | |
| isLargeDataDirty: false, | |
| updateNodeData: updateNodeDataCallback | |
| }; | |
| return { | |
| id: n.id, | |
| type: n.type || 'sketch', | |
| position: { x: n.posX, y: n.posY }, | |
| draggable: !isPinned, | |
| width: width, | |
| height: height, | |
| style: (width && height) ? { width, height } : undefined, | |
| data: dataObj | |
| }; | |
| }); | |
| const mappedEdges = data.edges.map(e => { | |
| const sourceNode = data.nodes.find(n => n.id === e.fromNodeId); | |
| const targetNode = data.nodes.find(n => n.id === e.toNodeId); | |
| let sourceHandle = getConditionSourceHandle(sourceNode, e.condition); | |
| let targetHandle = undefined; | |
| if (sourceNode && sourceNode.type === 'sketch') { | |
| if ( | |
| e.condition === 'SubgoalLeft' || | |
| e.condition === 'subgoals-source-left' || | |
| e.condition === 'SubgoalRight' || | |
| e.condition === 'subgoals-source-right' | |
| ) { | |
| sourceHandle = (e.condition === 'SubgoalLeft' || e.condition === 'subgoals-source-left') | |
| ? 'subgoals-source-left' | |
| : 'subgoals-source-right'; | |
| targetHandle = 'main-target'; | |
| } else { | |
| sourceHandle = 'main-source'; | |
| // Only use 'main-target' for nodes that actually have that handle ID (task nodes) | |
| targetHandle = (targetNode && ['sketch', 'habit', 'condition'].includes(targetNode.type)) | |
| ? 'main-target' | |
| : null; | |
| } | |
| } else if (sourceNode && (sourceNode.type === 'habit' || sourceNode.type === 'condition')) { | |
| // Habits and Conditions also connect to target's main-target if it's a task node | |
| targetHandle = (targetNode && ['sketch', 'habit', 'condition'].includes(targetNode.type)) | |
| ? 'main-target' | |
| : null; | |
| } | |
| // Re-fetch from mappedNodes for proper type detection | |
| const mappedSource = mappedNodes.find(n => n.id === e.fromNodeId); | |
| const mappedTarget = mappedNodes.find(n => n.id === e.toNodeId); | |
| const isStickerType = (type) => ['comment', 'image', 'text', 'path'].includes(type); | |
| const isStickerEdge = isStickerType(mappedSource?.type) || isStickerType(mappedTarget?.type); | |
| return { | |
| id: e.id, | |
| source: e.fromNodeId, | |
| target: e.toNodeId, | |
| sourceHandle: sourceHandle, | |
| targetHandle: targetHandle, | |
| type: isStickerEdge ? 'sticker' : undefined, | |
| animated: !isStickerEdge && false, // Default loading behavior | |
| markerEnd: isStickerEdge ? null : { type: MarkerType.ArrowClosed, color: '#31363F10' } | |
| }; | |
| }); | |
| setNodes(mappedNodes); | |
| setEdges(mappedEdges); | |
| setTimeout(() => { | |
| isInitialLoadRef.current = false; | |
| setIsLoading(false); | |
| }, 1000); | |
| } catch (err) { | |
| console.error('Error loading project', err); | |
| setIsLoading(false); | |
| if (err.response && (err.response.status === 401 || err.response.status === 403 || err.response.status === 404)) { | |
| alert("У вас немає прав для доступу до цієї дошки або вона не існує."); | |
| navigate('/dashboard'); | |
| } | |
| } | |
| }; | |
| fetchProject(); | |
| } | |
| }, [projectId]); | |
| const updateProjectMetadata = useCallback((title, description) => { | |
| setProjectData((prev) => { | |
| if (!prev) return null; | |
| return { | |
| ...prev, | |
| title: title !== undefined ? title : prev.title, | |
| description: description !== undefined ? description : prev.description | |
| }; | |
| }); | |
| setSaveStatus('unsaved'); | |
| }, []); | |
| // --- Save Project Data --- | |
| const saveCanvas = useCallback(async (isManual = false) => { | |
| if (!projectId) return; | |
| const permission = projectData?.permission || 'Owner'; | |
| if (permission === 'Read') { | |
| console.log('Skipping saveCanvas: read-only permission'); | |
| return; | |
| } | |
| if (isManual) { | |
| setIsManualSaving(true); | |
| } | |
| setIsSaving(true); | |
| setSaveStatus('saving'); | |
| try { | |
| const payload = { | |
| id: projectId, | |
| userId: user.id, | |
| title: projectData?.title || 'My Project', | |
| description: projectData?.description || '', | |
| nodes: nodes.map(n => ({ | |
| id: n.id, | |
| label: n.data.label || '', | |
| posX: n.position.x, | |
| posY: n.position.y, | |
| type: n.type, | |
| state: n.data.status === 'Done' ? 1 : 0, | |
| description: n.data.description || '', | |
| color: n.data.color || n.data.strokeColor || '', | |
| isPinned: !!n.data.isPinned, | |
| width: n.width || n.style?.width || (n.measured ? n.measured.width : undefined) || n.data.width, | |
| height: n.height || n.style?.height || (n.measured ? n.measured.height : undefined) || n.data.height, | |
| data: ((n.type === 'image' || n.type === 'path') && n.data.hasLargeData && !n.data.isLargeDataDirty) | |
| ? null | |
| : (n.data.url || n.data.path || n.data.content || ''), | |
| decision: n.type === 'condition' | |
| ? JSON.stringify({ | |
| options: n.data.options || [], | |
| selected: n.data.selectedOptions || [], | |
| multiSelect: !!n.data.multiSelect | |
| }) | |
| : n.type === 'habit' | |
| ? JSON.stringify({ | |
| resetRule: n.data.resetRule || { type: 'daily', time: '00:00' }, | |
| lastResetTime: n.data.lastResetTime, | |
| createdAt: n.data.createdAt || new Date().toISOString(), | |
| description: n.data.description || '', | |
| tags: n.data.tags || [], | |
| status: n.data.status || 'Todo', | |
| progress: n.data.progress || 0 | |
| }) | |
| : n.type === 'sketch' | |
| ? JSON.stringify({ | |
| description: n.data.description || '', | |
| progress: n.data.progress || 0, | |
| showProgress: !!n.data.showProgress, | |
| decision: n.data.decision || 'bottom', | |
| assignees: n.data.assignees || [], | |
| color: n.data.color, | |
| isPinned: !!n.data.isPinned, | |
| tags: n.data.tags || [], | |
| status: n.data.status || 'Todo' | |
| }) | |
| : n.type === 'comment' | |
| ? JSON.stringify({ | |
| description: n.data.description || '', | |
| assignees: n.data.assignees || [], | |
| color: n.data.color || 'yellow', | |
| isPinned: !!n.data.isPinned, | |
| width: n.width || n.style?.width || (n.measured ? n.measured.width : undefined) || n.data.width, | |
| height: n.height || n.style?.height || (n.measured ? n.measured.height : undefined) || n.data.height | |
| }) | |
| : n.data.decision | |
| })), | |
| edges: edges.map(e => { | |
| let cond = e.sourceHandle; | |
| if (cond === 'main-source') { | |
| cond = null; | |
| } else if (cond === 'subgoals-source-left') { | |
| cond = 'SubgoalLeft'; | |
| } else if (cond === 'subgoals-source-right') { | |
| cond = 'SubgoalRight'; | |
| } | |
| const sourceNode = nodes.find(n => n.id === e.source); | |
| if (sourceNode && sourceNode.type === 'condition') { | |
| const opts = sourceNode.data.options || [ | |
| { id: 'opt-yes', label: 'Так' }, | |
| { id: 'opt-no', label: 'Ні' } | |
| ]; | |
| const optIndex = opts.findIndex(o => o.id === e.sourceHandle); | |
| if (optIndex === 0) { | |
| cond = 'True'; | |
| } else if (optIndex === 1) { | |
| cond = 'False'; | |
| } else if (optIndex === 2) { | |
| cond = 'Blocked'; | |
| } else if (optIndex >= 3) { | |
| cond = String(optIndex); | |
| } | |
| } | |
| return { | |
| fromNodeId: e.source, | |
| toNodeId: e.target, | |
| condition: cond || 'True' | |
| }; | |
| }) | |
| }; | |
| await axios.post(`${API_URL}/graphs/${projectId}/full`, payload); | |
| console.log('Project saved successfully'); | |
| setSaveStatus('saved'); | |
| } catch (err) { | |
| console.error('Error saving project', err); | |
| setSaveStatus('unsaved'); | |
| } finally { | |
| setIsSaving(false); | |
| setIsManualSaving(false); | |
| } | |
| }, [projectId, user, projectData, nodes, edges]); | |
| // Set unsaved status when changes occur | |
| useEffect(() => { | |
| if (isInitialLoadRef.current) return; | |
| if (isRemoteChangeRef.current) { | |
| return; | |
| } | |
| const permission = projectData?.permission || 'Owner'; | |
| if (permission === 'Read') return; | |
| setSaveStatus('unsaved'); | |
| }, [nodes, edges, projectData]); | |
| // Debounced Autosave effect | |
| useEffect(() => { | |
| if (isInitialLoadRef.current || !projectId || nodes.length === 0) return; | |
| const permission = projectData?.permission || 'Owner'; | |
| if (permission === 'Read') return; | |
| const timer = setTimeout(() => { | |
| saveCanvas(); | |
| }, 2000); | |
| return () => clearTimeout(timer); | |
| }, [nodes, edges, projectId, saveCanvas, projectData]); | |
| // Force save on unmount and before unload | |
| const saveCanvasRef = useRef(saveCanvas); | |
| const saveStatusRef = useRef(saveStatus); | |
| useEffect(() => { | |
| saveCanvasRef.current = saveCanvas; | |
| saveStatusRef.current = saveStatus; | |
| }, [saveCanvas, saveStatus]); | |
| useEffect(() => { | |
| const handleBeforeUnload = (e) => { | |
| if (saveStatusRef.current === 'unsaved') { | |
| saveCanvasRef.current(false); | |
| } | |
| }; | |
| window.addEventListener('beforeunload', handleBeforeUnload); | |
| return () => { | |
| window.removeEventListener('beforeunload', handleBeforeUnload); | |
| if (saveStatusRef.current === 'unsaved' && saveCanvasRef.current) { | |
| saveCanvasRef.current(false); | |
| } | |
| }; | |
| }, []); | |
| // --- Signal Flow Logic --- | |
| useEffect(() => { | |
| const activeNodeIds = new Set(); | |
| const activeEdgeIds = new Set(); | |
| // Helper to check if a node itself and all its downstream tree is completed | |
| const isNodeAndTreeCompleted = (nodeId, ndsList) => { | |
| const queue = [nodeId]; | |
| const visited = new Set(queue); | |
| while (queue.length > 0) { | |
| const currentId = queue.shift(); | |
| const currentNode = ndsList.find(node => node.id === currentId); | |
| if (!currentNode) continue; | |
| // If it is a task node (sketch or habit), it must be 'Done' | |
| if (currentNode.type === 'sketch' || currentNode.type === 'habit') { | |
| if (currentNode.data.status !== 'Done') { | |
| return false; | |
| } | |
| } | |
| const outEdges = edges.filter(e => e.source === currentId); | |
| outEdges.forEach(edge => { | |
| if (!visited.has(edge.target)) { | |
| // If the current node is a condition node, we ONLY follow selected options! | |
| if (currentNode.type === 'condition') { | |
| const selected = currentNode.data.selectedOptions || (currentNode.data.decision === 'True' ? ['opt-yes'] : (currentNode.data.decision === 'False' ? ['opt-no'] : [])); | |
| if (!selected.includes(edge.sourceHandle)) { | |
| return; // Skip this edge, it's an inactive path | |
| } | |
| } | |
| visited.add(edge.target); | |
| queue.push(edge.target); | |
| } | |
| }); | |
| } | |
| return true; | |
| }; | |
| // Helper to get stats (total tasks and done tasks) in the active downstream tree of a node | |
| const getSubgoalBranchStats = (nodeId, ndsList) => { | |
| let total = 0; | |
| let done = 0; | |
| const queue = [nodeId]; | |
| const visited = new Set(queue); | |
| while (queue.length > 0) { | |
| const currentId = queue.shift(); | |
| const currentNode = ndsList.find(node => node.id === currentId); | |
| if (!currentNode) continue; | |
| // If it is a task node, count it | |
| if (currentNode.type === 'sketch' || currentNode.type === 'habit') { | |
| total++; | |
| if (currentNode.data.status === 'Done') { | |
| done++; | |
| } | |
| } | |
| const outEdges = edges.filter(e => e.source === currentId); | |
| outEdges.forEach(edge => { | |
| if (!visited.has(edge.target)) { | |
| // If the current node is a condition node, we ONLY follow selected options! | |
| if (currentNode.type === 'condition') { | |
| const selected = currentNode.data.selectedOptions || (currentNode.data.decision === 'True' ? ['opt-yes'] : (currentNode.data.decision === 'False' ? ['opt-no'] : [])); | |
| if (!selected.includes(edge.sourceHandle)) { | |
| return; // Skip this edge, it's an inactive path | |
| } | |
| } | |
| visited.add(edge.target); | |
| queue.push(edge.target); | |
| } | |
| }); | |
| } | |
| return { total, done }; | |
| }; | |
| const queue = []; | |
| nodes.forEach(node => { | |
| const incomers = getIncomers(node, nodes, edges); | |
| if (incomers.length === 0) { | |
| queue.push(node.id); | |
| } | |
| }); | |
| while (queue.length > 0) { | |
| const nodeId = queue.shift(); | |
| if (activeNodeIds.has(nodeId)) continue; | |
| activeNodeIds.add(nodeId); | |
| const node = nodes.find(n => n.id === nodeId); | |
| if (!node) continue; | |
| const outEdges = edges.filter(e => e.source === nodeId); | |
| outEdges.forEach(edge => { | |
| let shouldPropagate = false; | |
| if (node.type === 'sketch') { | |
| if (edge.sourceHandle && edge.sourceHandle.startsWith('subgoals-source')) { | |
| const targetNode = nodes.find(n => n.id === edge.target); | |
| const isBranchDone = targetNode && isNodeAndTreeCompleted(targetNode.id, nodes); | |
| shouldPropagate = !isBranchDone; | |
| } else { | |
| shouldPropagate = node.data.status === 'Done'; | |
| } | |
| } else if (node.type === 'condition') { | |
| const selected = node.data.selectedOptions || (node.data.decision === 'True' ? ['opt-yes'] : (node.data.decision === 'False' ? ['opt-no'] : [])); | |
| shouldPropagate = selected.includes(edge.sourceHandle); | |
| } else if (node.type === 'habit') { | |
| shouldPropagate = node.data.status !== 'Done'; | |
| } | |
| if (shouldPropagate) { | |
| activeEdgeIds.add(edge.id); | |
| queue.push(edge.target); | |
| } | |
| }); | |
| } | |
| setEdges(eds => eds.map(e => { | |
| if (e.type === 'sticker') { | |
| return e; // Keep sticker edges as they are, they have their own component-level custom styling! | |
| } | |
| const isAnimated = activeEdgeIds.has(e.id); | |
| const isSubgoalEdge = e.sourceHandle && e.sourceHandle.startsWith('subgoals-source'); | |
| const strokeColor = isSubgoalEdge | |
| ? (isAnimated ? '#EAB308' : 'rgba(234, 179, 8, 0.55)') | |
| : (isAnimated ? '#22C55E' : 'rgba(34, 197, 94, 0.35)'); | |
| const strokeWidth = isAnimated ? 3 : 2; | |
| const dashArray = '6 4'; | |
| if ( | |
| e.animated === isAnimated && | |
| e.style?.stroke === strokeColor && | |
| e.style?.strokeWidth === strokeWidth && | |
| e.style?.strokeDasharray === dashArray && | |
| e.markerEnd?.color === strokeColor | |
| ) { | |
| return e; | |
| } | |
| return { | |
| ...e, | |
| animated: isAnimated, | |
| style: { | |
| ...e.style, | |
| stroke: strokeColor, | |
| strokeWidth: strokeWidth, | |
| strokeDasharray: dashArray | |
| }, | |
| markerEnd: { | |
| ...e.markerEnd, | |
| type: MarkerType.ArrowClosed, | |
| color: strokeColor | |
| } | |
| }; | |
| })); | |
| setNodes(nds => nds.map(n => { | |
| const isReached = activeNodeIds.has(n.id); | |
| const isNextStep = isReached && n.data.status !== 'Done'; | |
| const subgoalEdges = edges.filter(e => e.source === n.id && e.sourceHandle && e.sourceHandle.startsWith('subgoals-source')); | |
| const connectedSubgoals = subgoalEdges | |
| .map(e => nds.find(node => node.id === e.target)) | |
| .filter(Boolean); | |
| let totalTasks = 0; | |
| let doneTasks = 0; | |
| connectedSubgoals.forEach(node => { | |
| const stats = getSubgoalBranchStats(node.id, nds); | |
| totalTasks += stats.total; | |
| doneTasks += stats.done; | |
| }); | |
| const subgoalsData = { | |
| total: totalTasks, | |
| done: doneTasks | |
| }; | |
| const currentSubgoals = n.data.subgoals || { total: 0, done: 0 }; | |
| const subgoalsChanged = currentSubgoals.total !== subgoalsData.total || currentSubgoals.done !== subgoalsData.done; | |
| if ( | |
| n.data.isReached === isReached && | |
| n.data.isNextStep === isNextStep && | |
| !subgoalsChanged | |
| ) { | |
| return n; | |
| } | |
| return { | |
| ...n, | |
| data: { | |
| ...n.data, | |
| isReached, | |
| isNextStep, | |
| subgoals: subgoalsData | |
| } | |
| }; | |
| })); | |
| }, [ | |
| nodes.map(n => `${n.id}-${n.data.status}-${n.data.decision}-${(n.data.selectedOptions || []).join('-')}`).join(','), | |
| edges.map(e => `${e.id}-${e.source}-${e.target}-${e.sourceHandle || ''}`).join(','), | |
| setEdges, | |
| setNodes | |
| ]); | |
| const isValidConnection = useCallback( | |
| (connection) => { | |
| return true; | |
| }, | |
| [] | |
| ); | |
| const onConnect = useCallback( | |
| (params) => { | |
| takeHistorySnapshot(); | |
| // Determine if this connection involves a sticker (comment, image, text, etc.) | |
| const sourceNode = nodes.find(n => n.id === params.source); | |
| const targetNode = nodes.find(n => n.id === params.target); | |
| const isStickerType = (type) => ['comment', 'image', 'text', 'path'].includes(type); | |
| const isStickerConnection = isStickerType(sourceNode?.type) || isStickerType(targetNode?.type); | |
| setEdges((eds) => addEdge({ | |
| ...params, | |
| type: isStickerConnection ? 'sticker' : undefined, | |
| animated: !isStickerConnection, // Stickers use static "threads" | |
| markerEnd: isStickerConnection ? null : { type: MarkerType.ArrowClosed, color: '#31363F' } | |
| }, eds)); | |
| if (broadcastMessageRef.current) { | |
| broadcastMessageRef.current({ | |
| type: 'EDGE_ADD', | |
| payload: { | |
| ...params, | |
| type: isStickerConnection ? 'sticker' : undefined | |
| } | |
| }); | |
| } | |
| }, | |
| [nodes, setEdges, takeHistorySnapshot] | |
| ); | |
| const onEdgesDelete = useCallback( | |
| (edgesToDelete) => { | |
| takeHistorySnapshot(); | |
| setEdges((eds) => eds.filter((e) => !edgesToDelete.some((del) => del.id === e.id))); | |
| edgesToDelete.forEach(edge => { | |
| if (broadcastMessageRef.current) { | |
| broadcastMessageRef.current({ | |
| type: 'EDGE_DELETE', | |
| payload: { id: edge.id } | |
| }); | |
| } | |
| }); | |
| }, | |
| [setEdges, takeHistorySnapshot] | |
| ); | |
| const addNewNodeAt = useCallback((x, y, type = 'sketch', initialData = {}) => { | |
| takeHistorySnapshot(); | |
| let position; | |
| if (initialData.customPos) { | |
| position = initialData.customPos; | |
| } else { | |
| const finalX = x !== undefined ? x : window.innerWidth / 2; | |
| const finalY = y !== undefined ? y : window.innerHeight / 2; | |
| position = screenToFlowPosition({ x: finalX, y: finalY }); | |
| } | |
| const id = crypto.randomUUID(); | |
| const nodeData = { | |
| label: '', | |
| status: 'Todo', | |
| description: '', | |
| tags: [], | |
| ...initialData | |
| }; | |
| if (type === 'condition') { | |
| nodeData.options = initialData.options || [ | |
| { id: 'opt-yes', label: 'Так' }, | |
| { id: 'opt-no', label: 'Ні' } | |
| ]; | |
| nodeData.selectedOptions = initialData.selectedOptions || []; | |
| nodeData.multiSelect = initialData.multiSelect !== undefined ? initialData.multiSelect : false; | |
| } else if (type === 'habit') { | |
| nodeData.resetRule = initialData.resetRule || { type: 'daily', time: '00:00' }; | |
| nodeData.createdAt = initialData.createdAt || new Date().toISOString(); | |
| nodeData.lastResetTime = initialData.lastResetTime || new Date().toISOString(); | |
| } else if (type === 'comment') { | |
| nodeData.color = initialData.color || 'yellow'; | |
| nodeData.assignees = initialData.assignees || []; | |
| nodeData.isPinned = initialData.isPinned !== undefined ? initialData.isPinned : false; | |
| } else if (type === 'image') { | |
| nodeData.url = initialData.url || ''; | |
| nodeData.width = initialData.width || 300; | |
| nodeData.height = initialData.height || 'auto'; | |
| } else if (type === 'text') { | |
| nodeData.label = initialData.label || ''; | |
| nodeData.fontSize = initialData.fontSize || 18; | |
| nodeData.textAlign = initialData.textAlign || 'left'; | |
| } else if (type === 'path') { | |
| nodeData.path = initialData.path || ''; | |
| nodeData.strokeColor = initialData.strokeColor; // Use theme default if undefined | |
| nodeData.strokeWidth = initialData.strokeWidth || 3; | |
| } else if (type === 'sketch') { | |
| nodeData.decision = initialData.decision || 'bottom'; | |
| nodeData.color = initialData.color; | |
| nodeData.assignees = initialData.assignees || []; | |
| nodeData.isPinned = initialData.isPinned !== undefined ? initialData.isPinned : false; | |
| } else { | |
| nodeData.decision = initialData.decision || 'bottom'; | |
| } | |
| const newNode = { | |
| id, | |
| type, | |
| position, | |
| data: { | |
| ...nodeData, | |
| updateNodeData: updateNodeDataCallback | |
| }, | |
| }; | |
| setNodes((nds) => nds.concat(newNode)); | |
| if (broadcastMessageRef.current) { | |
| broadcastMessageRef.current({ | |
| type: 'NODE_ADD', | |
| payload: newNode | |
| }); | |
| } | |
| return id; | |
| }, [setNodes, screenToFlowPosition, updateNodeDataCallback, takeHistorySnapshot]); | |
| const handleCopy = useCallback(() => { | |
| const selectedNodes = nodes.filter((n) => n.selected); | |
| const selectedEdges = edges.filter((e) => e.selected); | |
| if (selectedNodes.length === 0) return; | |
| const clipboardPayload = { | |
| nodes: selectedNodes.map(n => ({ | |
| ...n, | |
| originalPosition: { ...n.position } | |
| })), | |
| edges: selectedEdges | |
| }; | |
| window.__flowClipboard = clipboardPayload; | |
| setClipboardVersion(v => v + 1); | |
| try { | |
| navigator.clipboard.writeText('FLOWWEB_NODES_DATA:' + JSON.stringify(clipboardPayload)).catch(() => {}); | |
| } catch (err) { | |
| console.warn("Failed to write to system clipboard", err); | |
| } | |
| }, [nodes, edges]); | |
| const handleCut = useCallback(() => { | |
| if (projectData?.permission === 'Read') return; | |
| const selectedNodes = nodes.filter((n) => n.selected); | |
| const selectedEdges = edges.filter((e) => e.selected); | |
| if (selectedNodes.length === 0) return; | |
| takeHistorySnapshot(); | |
| const clipboardPayload = { | |
| nodes: selectedNodes.map(n => ({ | |
| ...n, | |
| originalPosition: { ...n.position } | |
| })), | |
| edges: selectedEdges | |
| }; | |
| window.__flowClipboard = clipboardPayload; | |
| setClipboardVersion(v => v + 1); | |
| try { | |
| navigator.clipboard.writeText('FLOWWEB_NODES_DATA:' + JSON.stringify(clipboardPayload)).catch(() => {}); | |
| } catch (err) { | |
| console.warn("Failed to write to system clipboard", err); | |
| } | |
| selectedNodes.forEach(node => { | |
| setNodes((nds) => nds.filter((n) => n.id !== node.id)); | |
| if (broadcastMessageRef.current) { | |
| broadcastMessageRef.current({ | |
| type: 'NODE_DELETE', | |
| payload: { id: node.id } | |
| }); | |
| } | |
| }); | |
| selectedEdges.forEach(edge => { | |
| setEdges((eds) => eds.filter((e) => e.id !== edge.id)); | |
| if (broadcastMessageRef.current) { | |
| broadcastMessageRef.current({ | |
| type: 'EDGE_DELETE', | |
| payload: { id: edge.id } | |
| }); | |
| } | |
| }); | |
| }, [nodes, edges, setNodes, setEdges, projectData, takeHistorySnapshot]); | |
| const handlePaste = useCallback((screenCoords = null) => { | |
| if (projectData?.permission === 'Read') return; | |
| if (!window.__flowClipboard || !window.__flowClipboard.nodes || window.__flowClipboard.nodes.length === 0) return; | |
| takeHistorySnapshot(); | |
| const { nodes: copiedNodes, edges: copiedEdges } = window.__flowClipboard; | |
| // De-select all current nodes/edges | |
| setNodes(nds => nds.map(n => n.selected ? { ...n, selected: false } : n)); | |
| setEdges(eds => eds.map(e => e.selected ? { ...e, selected: false } : e)); | |
| const idMap = {}; | |
| const newNodes = []; | |
| const newEdges = []; | |
| let getPosition; | |
| if (screenCoords) { | |
| const flowMousePos = screenToFlowPosition({ x: screenCoords.x, y: screenCoords.y }); | |
| let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; | |
| copiedNodes.forEach(n => { | |
| if (n.position.x < minX) minX = n.position.x; | |
| if (n.position.y < minY) minY = n.position.y; | |
| if (n.position.x > maxX) maxX = n.position.x; | |
| if (n.position.y > maxY) maxY = n.position.y; | |
| }); | |
| const centerX = (minX + maxX) / 2; | |
| const centerY = (minY + maxY) / 2; | |
| getPosition = (n) => ({ | |
| x: flowMousePos.x + (n.position.x - centerX), | |
| y: flowMousePos.y + (n.position.y - centerY) | |
| }); | |
| } else { | |
| const offset = 40; | |
| getPosition = (n) => ({ | |
| x: n.position.x + offset, | |
| y: n.position.y + offset | |
| }); | |
| } | |
| copiedNodes.forEach(node => { | |
| const newId = crypto.randomUUID(); | |
| idMap[node.id] = newId; | |
| const pastedNode = { | |
| ...node, | |
| id: newId, | |
| selected: true, | |
| position: getPosition(node), | |
| data: { | |
| ...node.data, | |
| updateNodeData: updateNodeDataCallback | |
| } | |
| }; | |
| delete pastedNode.originalPosition; | |
| newNodes.push(pastedNode); | |
| }); | |
| copiedEdges.forEach(edge => { | |
| const newSource = idMap[edge.source]; | |
| const newTarget = idMap[edge.target]; | |
| if (newSource && newTarget) { | |
| const newEdgeId = `edge-${crypto.randomUUID()}`; | |
| const pastedEdge = { | |
| ...edge, | |
| id: newEdgeId, | |
| source: newSource, | |
| target: newTarget, | |
| selected: true | |
| }; | |
| newEdges.push(pastedEdge); | |
| } | |
| }); | |
| setNodes(nds => nds.concat(newNodes)); | |
| setEdges(eds => eds.concat(newEdges)); | |
| if (broadcastMessageRef.current) { | |
| newNodes.forEach(n => { | |
| broadcastMessageRef.current({ | |
| type: 'NODE_ADD', | |
| payload: n | |
| }); | |
| }); | |
| newEdges.forEach(e => { | |
| broadcastMessageRef.current({ | |
| type: 'EDGE_ADD', | |
| payload: e | |
| }); | |
| }); | |
| } | |
| // Update window.__flowClipboard positions so subsequent pastes cascade shift! | |
| window.__flowClipboard.nodes = copiedNodes.map((n, idx) => ({ | |
| ...n, | |
| position: newNodes[idx].position | |
| })); | |
| }, [setNodes, setEdges, screenToFlowPosition, updateNodeDataCallback, projectData, takeHistorySnapshot]); | |
| const submitAICanvasPrompt = async (promptText) => { | |
| if (!promptText || !projectId) return false; | |
| const historyPayload = chatMessages | |
| .filter(msg => msg.id !== 'welcome' && !msg.isError) | |
| .map(msg => ({ | |
| role: msg.sender === 'user' ? 'user' : 'model', | |
| text: msg.text | |
| })); | |
| // Append user message | |
| const userMsg = { | |
| id: crypto.randomUUID(), | |
| sender: 'user', | |
| text: promptText, | |
| timestamp: new Date() | |
| }; | |
| setChatMessages(prev => [...prev, userMsg]); | |
| setAiLoading(true); | |
| try { | |
| const res = await axios.post(`${API_URL}/AI/assist`, { | |
| graphId: projectId, | |
| userPrompt: promptText, | |
| history: historyPayload | |
| }); | |
| const { newNodes, newEdges, explanation } = res.data; | |
| if (newNodes && newNodes.length > 0) { | |
| const mappedNewNodes = newNodes.map((n) => { | |
| let options = []; | |
| let selectedOptions = []; | |
| let multiSelect = false; | |
| let resetRule = { type: 'daily', time: '00:00' }; | |
| let lastResetTime = undefined; | |
| let createdAt = undefined; | |
| let description = n.description || ''; | |
| let decision = n.decision || (n.type === 'condition' ? 'True' : 'bottom'); | |
| if (n.type === 'condition') { | |
| if (n.decision && n.decision.startsWith('{')) { | |
| try { | |
| const parsed = JSON.parse(n.decision); | |
| options = parsed.options || []; | |
| selectedOptions = parsed.selected || []; | |
| multiSelect = !!parsed.multiSelect; | |
| decision = undefined; | |
| } catch (e) { | |
| console.error('Failed to parse dynamic options JSON in AI response', e); | |
| options = [ | |
| { id: 'opt-yes', label: 'Так' }, | |
| { id: 'opt-no', label: 'Ні' } | |
| ]; | |
| selectedOptions = n.decision === 'True' ? ['opt-yes'] : (n.decision === 'False' ? ['opt-no'] : []); | |
| } | |
| } else { | |
| options = [ | |
| { id: 'opt-yes', label: 'Так' }, | |
| { id: 'opt-no', label: 'Ні' } | |
| ]; | |
| selectedOptions = n.decision === 'True' ? ['opt-yes'] : (n.decision === 'False' ? ['opt-no'] : []); | |
| } | |
| } else if (n.type === 'habit') { | |
| resetRule = { type: 'daily', time: '00:00' }; | |
| createdAt = new Date().toISOString(); | |
| lastResetTime = new Date().toISOString(); | |
| } | |
| return { | |
| id: n.id, | |
| type: n.type || 'sketch', | |
| position: { x: n.posX, y: n.posY }, | |
| data: { | |
| label: n.label, | |
| status: n.state === 1 ? 'Done' : 'Todo', | |
| tags: [], | |
| decision, | |
| options, | |
| selectedOptions, | |
| multiSelect, | |
| resetRule, | |
| lastResetTime, | |
| createdAt, | |
| description: description || '', | |
| updateNodeData: updateNodeDataCallback | |
| } | |
| }; | |
| }); | |
| const mappedNewEdges = newEdges.map((e) => { | |
| const sourceNode = [...nodes, ...mappedNewNodes].find(nodeItem => nodeItem.id.toLowerCase() === e.fromNodeId.toLowerCase()); | |
| const targetNode = [...nodes, ...mappedNewNodes].find(nodeItem => nodeItem.id.toLowerCase() === e.toNodeId.toLowerCase()); | |
| let sourceHandle = getConditionSourceHandle(sourceNode, e.condition); | |
| let targetHandle = undefined; | |
| if (sourceNode && sourceNode.type === 'sketch') { | |
| if ( | |
| e.condition === 'SubgoalLeft' || | |
| e.condition === 'subgoals-source-left' || | |
| e.condition === 'SubgoalRight' || | |
| e.condition === 'subgoals-source-right' | |
| ) { | |
| sourceHandle = (e.condition === 'SubgoalLeft' || e.condition === 'subgoals-source-left') | |
| ? 'subgoals-source-left' | |
| : 'subgoals-source-right'; | |
| targetHandle = 'main-target'; | |
| } else { | |
| sourceHandle = 'main-source'; | |
| targetHandle = (targetNode && ['sketch', 'habit', 'condition'].includes(targetNode.type)) | |
| ? 'main-target' | |
| : null; | |
| } | |
| } else if (sourceNode && (sourceNode.type === 'habit' || sourceNode.type === 'condition')) { | |
| targetHandle = (targetNode && ['sketch', 'habit', 'condition'].includes(targetNode.type)) | |
| ? 'main-target' | |
| : null; | |
| } | |
| return { | |
| id: e.id, | |
| source: e.fromNodeId, | |
| target: e.toNodeId, | |
| sourceHandle: sourceHandle, | |
| targetHandle: targetHandle, | |
| animated: false | |
| }; | |
| }); | |
| setNodes((nds) => nds.concat(mappedNewNodes)); | |
| setEdges((eds) => eds.concat(mappedNewEdges)); | |
| } | |
| // Append agent message | |
| const agentMsg = { | |
| id: crypto.randomUUID(), | |
| sender: 'agent', | |
| text: explanation || 'Я оновив твою доску та додав необхідні елементи.', | |
| timestamp: new Date() | |
| }; | |
| setChatMessages(prev => [...prev, agentMsg]); | |
| return true; | |
| } catch (err) { | |
| console.error('AI assistant error:', err); | |
| const errorMsg = { | |
| id: crypto.randomUUID(), | |
| sender: 'agent', | |
| text: 'Помилка: ' + (err.response?.data?.message || 'Не вдалося зв\'язатися з ШІ-стратегом.'), | |
| isError: true, | |
| timestamp: new Date() | |
| }; | |
| setChatMessages(prev => [...prev, errorMsg]); | |
| return false; | |
| } finally { | |
| setAiLoading(false); | |
| } | |
| }; | |
| const handleAIRequest = () => { | |
| // Open access for regular users for now | |
| setIsAIActive(true); | |
| }; | |
| const handleMouseDown = (event) => { | |
| if (event.button !== 0 || !event.target.classList.contains('react-flow__pane')) return; | |
| if (event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return; | |
| const clientX = event.clientX; | |
| const clientY = event.clientY; | |
| longPressTimer.current = setTimeout(() => { | |
| addNewNodeAt(clientX, clientY); | |
| longPressTimer.current = null; | |
| }, 600); | |
| }; | |
| const handleMouseUp = () => { | |
| if (longPressTimer.current) { | |
| clearTimeout(longPressTimer.current); | |
| longPressTimer.current = null; | |
| } | |
| }; | |
| // BFS Downstream Resetter | |
| const resetDownstreamNodes = useCallback((startNodeId) => { | |
| setNodes((currentNodes) => { | |
| const visited = new Set(); | |
| const queue = [startNodeId]; | |
| while (queue.length > 0) { | |
| const currentId = queue.shift(); | |
| const outEdges = edges.filter(e => e.source === currentId); | |
| outEdges.forEach(edge => { | |
| if (!visited.has(edge.target)) { | |
| visited.add(edge.target); | |
| queue.push(edge.target); | |
| } | |
| }); | |
| } | |
| return currentNodes.map(node => { | |
| if (visited.has(node.id)) { | |
| if (node.type === 'sketch' || node.type === 'habit') { | |
| return { | |
| ...node, | |
| data: { | |
| ...node.data, | |
| status: 'Todo' | |
| } | |
| }; | |
| } | |
| } | |
| return node; | |
| }); | |
| }); | |
| }, [edges, setNodes]); | |
| // Handle custom trigger reset event from Habit Node | |
| useEffect(() => { | |
| const handleManualReset = (e) => { | |
| resetDownstreamNodes(e.detail.nodeId); | |
| }; | |
| window.addEventListener('trigger-habit-reset', handleManualReset); | |
| return () => window.removeEventListener('trigger-habit-reset', handleManualReset); | |
| }, [resetDownstreamNodes]); | |
| // Auto-reset background check every 10 seconds | |
| useEffect(() => { | |
| const checkHabitResets = () => { | |
| nodes.forEach(node => { | |
| if (node.type === 'habit' && node.data?.status === 'Done') { | |
| const lastResetTime = node.data.lastResetTime || node.data.createdAt || new Date().toISOString(); | |
| const rule = node.data.resetRule || { type: 'daily', time: '00:00' }; | |
| if (isResetDue(lastResetTime, rule)) { | |
| console.log(`Auto-reset triggered for Habit node ${node.id}`); | |
| // Trigger browser-side toast and audio notification instantly! | |
| triggerNotification({ | |
| id: 'habit-recharge-' + Date.now() + '-' + Math.random(), | |
| icon: 'Flame', | |
| title: t('habits.recharge_notification_title', 'Habit Recharged! ⚡'), | |
| desc: t('habits.recharge_notification_desc', `Habit "${node.data.label}" is ready to be performed again!`, { name: node.data.label }), | |
| color: 'bg-orange-500/20', | |
| headerText: t('habits.recharge_notification_header', 'ЗВИЧКА ГОТОВА') | |
| }); | |
| resetDownstreamNodes(node.id); | |
| setNodes(currentNodes => | |
| currentNodes.map(n => | |
| n.id === node.id | |
| ? { | |
| ...n, | |
| data: { | |
| ...n.data, | |
| status: 'Todo', | |
| lastResetTime: new Date().toISOString() | |
| } | |
| } | |
| : n | |
| ) | |
| ); | |
| } | |
| } | |
| }); | |
| }; | |
| const interval = setInterval(checkHabitResets, 10000); | |
| return () => clearInterval(interval); | |
| }, [nodes, resetDownstreamNodes, setNodes, triggerNotification, t]); | |
| return { | |
| setNodes, | |
| nodes, | |
| edges, | |
| onNodesChange: onNodesChangeWithBroadcast, | |
| onEdgesChange, | |
| onConnect, | |
| onEdgesDelete, | |
| deleteEdge: (id) => { | |
| takeHistorySnapshot(); | |
| setEdges((eds) => eds.filter((e) => e.id !== id)); | |
| if (broadcastMessageRef.current) { | |
| broadcastMessageRef.current({ | |
| type: 'EDGE_DELETE', | |
| payload: { id } | |
| }); | |
| } | |
| }, | |
| deleteNode: (id) => { | |
| takeHistorySnapshot(); | |
| setNodes((nds) => nds.filter((n) => n.id !== id)); | |
| setEdges((eds) => eds.filter((e) => e.source !== id && e.target !== id)); | |
| if (broadcastMessageRef.current) { | |
| broadcastMessageRef.current({ | |
| type: 'NODE_DELETE', | |
| payload: { id } | |
| }); | |
| } | |
| }, | |
| updateNodeData: updateNodeDataCallback, | |
| isAIActive, | |
| setIsAIActive, | |
| aiLoading, | |
| submitAICanvasPrompt, | |
| chatMessages, | |
| setChatMessages, | |
| showPaywall, | |
| setShowPaywall, | |
| handleAIRequest, | |
| addNewNodeAt, | |
| handleCopy, | |
| handleCut, | |
| handlePaste, | |
| handleMouseDown, | |
| handleMouseUp, | |
| saveCanvas, | |
| isSaving, | |
| isManualSaving, | |
| saveStatus, | |
| projectTitle: projectData?.title, | |
| permission: projectData?.permission || 'Owner', | |
| isShared: !!projectData?.isShared, | |
| projectData, | |
| peers, | |
| activeUsers, | |
| cursors, | |
| collaborators, | |
| fetchCollaborators, | |
| broadcastMessage, | |
| hasClipboardContent: !!(window.__flowClipboard && window.__flowClipboard.nodes && window.__flowClipboard.nodes.length > 0), | |
| triggerClipboardUpdate: () => setClipboardVersion(v => v + 1), | |
| undo, | |
| redo, | |
| canUndo: pastRef.current.length > 0, | |
| canRedo: futureRef.current.length > 0, | |
| takeHistorySnapshot, | |
| updateProjectMetadata, | |
| chatThreads, | |
| activeThreadId, | |
| setActiveThreadId, | |
| createNewThread, | |
| deleteThread, | |
| renameThread, | |
| isLoading | |
| }; | |
| }; | |