FlowWeb / src /features /canvas /hooks /useCanvasLogic.js
danylokhodus's picture
Completely rewrite ImageNode scaling with pure pointer-event mathematical calculations and direct React Flow store updates
b4d6f7f
Raw
History Blame Contribute Delete
69.1 kB
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
};
};