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 }; };