// fc-app.jsx — App state, keyboard shortcuts, all operations const { useState, useEffect, useMemo, useRef, useCallback } = React; function TopBtn({ children, onClick, active, danger, accent, title, disabled, style: extra }) { return ( ); } // ── LoginScreen ──────────────────────────────────────────────────────────── function LoginScreen({ onLogin }) { const [projectId, setProjectId] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const recent = loadRecentProjects(); async function handleSubmit(e) { e.preventDefault(); if (!projectId.trim()) return; setLoading(true); setError(''); try { const hash = await sha256(projectId.trim().toLowerCase()); await onLogin(hash, projectId.trim()); } catch { setError('Something went wrong. Try again.'); } finally { setLoading(false); } } async function openRecent(hash) { setLoading(true); try { await onLogin(hash, null); } catch { setError('Could not open project.'); } finally { setLoading(false); } } return (
FlowChart Creator

Open your project

Enter any project ID to open or create a workspace.

setProjectId(e.target.value)} placeholder="e.g. my-project-2025" autoFocus style={{ background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 10, padding: '11px 14px', color: 'var(--text)', fontSize: 14, outline: 'none', fontFamily: 'inherit' }} />
{error &&

{error}

}
{/* Recent projects */} {recent.length > 0 && (
Recent
{recent.map(r => ( ))}
)}

Share the ID with teammates to give them access.

); } // Three-way merge: base=last synced, local=client changes, remote=server state. // Applies non-conflicting local changes on top of remote so neither user loses work. // NODE_MERGE_KEYS must cover every persisted node field the UI can edit (see // LINKED_SYNC_FIELDS in fc-utils.js plus layout/link fields). const NODE_MERGE_KEYS = [ 'x', 'y', 'label', 'notes', 'details', 'status', 'typeId', 'collapsed', 'tags', 'poll', 'images', 'assignees', 'subChartId', 'linkGroupId', ]; const CHART_MERGE_KEYS = ['title', 'description', 'orientation', 'rootNodeId']; const EDGE_MERGE_KEYS = ['from', 'to', 'route']; function mergeField(baseVal, localVal, remoteVal) { const bv = JSON.stringify(baseVal); if (JSON.stringify(localVal) !== bv && JSON.stringify(remoteVal) === bv) return localVal; return remoteVal; } // Deep-equality via JSON. Order-sensitive, which is fine here: we only use it // to ask "did remote leave this item exactly as base did?" and our writers // preserve key/array order. function deepEqual(a, b) { return JSON.stringify(a) === JSON.stringify(b); } function mergeProjects(base, local, remote) { if (!base) return remote; const baseChartMap = new Map((base.charts || []).map(c => [c.id, c])); const localChartMap = new Map((local.charts || []).map(c => [c.id, c])); const remoteChartIds = new Set((remote.charts || []).map(c => c.id)); const mergedCharts = []; for (const remoteChart of (remote.charts || [])) { const localChart = localChartMap.get(remoteChart.id); const baseChart = baseChartMap.get(remoteChart.id); // Chart existed at base, deleted locally, and untouched on remote since // base → honour the local delete instead of resurrecting it. if (baseChart && !localChart && deepEqual(baseChart, remoteChart)) continue; if (!localChart || !baseChart) { mergedCharts.push(remoteChart); continue; } const baseNodeMap = new Map((baseChart.nodes || []).map(n => [n.id, n])); const localNodeMap = new Map((localChart.nodes || []).map(n => [n.id, n])); const remoteNodeIds = new Set((remoteChart.nodes || []).map(n => n.id)); const baseEdgeMap = new Map((baseChart.edges || []).map(e => [e.id, e])); const localEdgeMap = new Map((localChart.edges || []).map(e => [e.id, e])); const remoteEdgeIds = new Set((remoteChart.edges || []).map(e => e.id)); const mergedChart = { ...remoteChart }; for (const key of CHART_MERGE_KEYS) { mergedChart[key] = mergeField(baseChart[key], localChart[key], remoteChart[key]); } // Merge existing nodes: take local's field change when remote left it // unchanged; drop a node that was deleted locally and untouched on remote. const mergedNodes = []; for (const remoteNode of (remoteChart.nodes || [])) { const localNode = localNodeMap.get(remoteNode.id); const baseNode = baseNodeMap.get(remoteNode.id); if (baseNode && !localNode && deepEqual(baseNode, remoteNode)) continue; // deleted locally if (!localNode || !baseNode) { mergedNodes.push(remoteNode); continue; } const merged = { ...remoteNode }; for (const key of NODE_MERGE_KEYS) { merged[key] = mergeField(baseNode[key], localNode[key], remoteNode[key]); } mergedNodes.push(merged); } // Locally-added nodes (not in base, not in remote) → add for (const n of (localChart.nodes || [])) { if (!baseNodeMap.has(n.id) && !remoteNodeIds.has(n.id)) mergedNodes.push(n); } const mergedEdges = []; for (const remoteEdge of (remoteChart.edges || [])) { const localEdge = localEdgeMap.get(remoteEdge.id); const baseEdge = baseEdgeMap.get(remoteEdge.id); if (baseEdge && !localEdge && deepEqual(baseEdge, remoteEdge)) continue; // deleted locally if (!localEdge || !baseEdge) { mergedEdges.push(remoteEdge); continue; } const merged = { ...remoteEdge }; for (const key of EDGE_MERGE_KEYS) { merged[key] = mergeField(baseEdge[key], localEdge[key], remoteEdge[key]); } mergedEdges.push(merged); } // Locally-added edges (not in base, not in remote) → add const localNewEdges = (localChart.edges || []).filter( e => !baseEdgeMap.has(e.id) && !remoteEdgeIds.has(e.id) ); mergedCharts.push({ ...mergedChart, nodes: mergedNodes, edges: [...mergedEdges, ...localNewEdges] }); } // Locally-created charts (not in base, not in remote) → add for (const lc of (local.charts || [])) { if (!baseChartMap.has(lc.id) && !remoteChartIds.has(lc.id)) mergedCharts.push(lc); } // Top-level project fields: three-way merge so a local rename or a locally // added node type / status survives a conflict instead of being overwritten // wholesale by remote. const merged = { ...remote, charts: mergedCharts }; merged.name = mergeField(base.name, local.name, remote.name); merged.lastOpenedChartId = mergeField(base.lastOpenedChartId, local.lastOpenedChartId, remote.lastOpenedChartId); merged.nodeTypes = mergeField(base.nodeTypes, local.nodeTypes, remote.nodeTypes); merged.statuses = mergeField(base.statuses, local.statuses, remote.statuses); return merged; } async function fetchServerProject(hash) { try { const res = await fetch(`/api/project/${encodeURIComponent(hash)}`); if (!res.ok) return null; return await res.json(); } catch { return null; } } async function openServerProject(hash) { try { const res = await fetch('/api/open', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hash }), }); if (!res.ok) return null; return await res.json(); } catch { return null; } } async function saveServerProject(hash, project, expectedVersion) { const res = await fetch(`/api/project/${encodeURIComponent(hash)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project: { ...project, id: hash }, expectedVersion }), }); if (!res.ok) { let body = null; try { body = await res.json(); } catch {} const err = new Error(`Save failed (${res.status})`); err.status = res.status; err.body = body; throw err; } return await res.json(); } // Stack of colored avatar pills showing teammates currently in the project function PresencePills({ users, fallbackCount }) { const list = (users || []).slice(0, 5); if (list.length === 0 && fallbackCount > 0) { return ( {fallbackCount} {fallbackCount === 1 ? 'other' : 'others'} ); } if (list.length === 0) return null; return (
{list.map((u, i) => { const initials = (u.name || '??').split(/\s+/).map(s => s[0]).join('').slice(0, 2).toUpperCase(); return (
{initials}
); })} {users.length > list.length && ( +{users.length - list.length} )}
); } // ── App ──────────────────────────────────────────────────────────────────── function App({ theme, setTheme, roundedNodes }) { const [isLoggedIn, setIsLoggedIn] = useState(false); const [readOnly, setReadOnly] = useState(false); const [projectHash, setProjectHash] = useState(null); const [project, setProject] = useState(null); const [selectedChartId, setSelectedChartId] = useState(''); const [selectedNodeId, setSelectedNodeId] = useState(''); // Multi-selection: set of node IDs in the current chart that are part of // a marquee/multi-select group. Independent of `selectedNodeId` (which is // the single "primary" selection used by the Inspector). When this set has // 2+ entries, the bulk-action bar appears and operations like delete / // bulk-update target the whole set. const [selectedNodeIds, setSelectedNodeIds] = useState(() => new Set()); const [navStack, setNavStack] = useState([]); const [scale, setScale] = useState(1); const [pan, setPan] = useState({ x: 80, y: 60 }); const [connectMode, setConnectMode] = useState(false); const [connectSource, setConnectSource] = useState(null); const [showSettings, setShowSettings] = useState(false); const [showPolls, setShowPolls] = useState(false); const [toast, setToast] = useState(''); const [editingNodeId, setEditingNodeId] = useState(null); const [editingField, setEditingField] = useState(null); const [expandedNodeId, setExpandedNodeId] = useState(null); const [fitTrigger, setFitTrigger] = useState(0); const [collapsedMap, setCollapsedMap] = useState(new Map()); const [hidingAncMap, setHidingAncMap] = useState(new Map()); const [layoutAlgorithm, setLayoutAlgorithm] = useState('improved'); const DEFAULT_SPACING = { vertical: { sib: 80, depth: 120 }, horizontal: { sib: 40, depth: 180 }, }; const [layoutSpacing, setLayoutSpacing] = useState(() => { try { const stored = localStorage.getItem('fc-layout-spacing'); if (stored) { const parsed = JSON.parse(stored); return { vertical: { ...DEFAULT_SPACING.vertical, ...(parsed.vertical || {}) }, horizontal: { ...DEFAULT_SPACING.horizontal, ...(parsed.horizontal || {}) }, }; } } catch (e) {} return DEFAULT_SPACING; }); React.useEffect(() => { try { localStorage.setItem('fc-layout-spacing', JSON.stringify(layoutSpacing)); } catch (e) {} }, [layoutSpacing]); const historyRef = useRef({ past: [], future: [] }); const skipHistoryRef = useRef(false); const clipboardRef = useRef(null); // copied node data const isApplyingRemoteRef = useRef(false); const hasLocalChangesRef = useRef(false); const saveTimerRef = useRef(null); const saveInFlightRef = useRef(false); const savePendingRef = useRef(false); const saveRetryTimerRef = useRef(null); const quotaWarnedRef = useRef(false); const syncBaseRef = useRef(null); // last version both client+server agreed on (merge base) const latestProjectRef = useRef(null); // always mirrors current project (avoids stale WS closure) const wsRef = useRef(null); const wsConnectedRef = useRef(false); const [othersOnline, setOthersOnline] = useState(0); const userRef = useRef(getOrCreateUser()); // Force-rerender hook for when the user updates their own display name — // userRef.current is mutated in place, but state-bound UI (top bar, etc.) // needs to re-render. Bump this when identity changes. const [myIdentityRev, setMyIdentityRev] = useState(0); function updateMyIdentity(changes) { const next = updateUserIdentity(changes); userRef.current = next; setMyIdentityRev(r => r + 1); // Broadcast the new name to teammates so their PresencePills update too. sendWs({ type: 'cursor', userId: next.id, name: next.name, color: next.color, chartId: selectedChartId || null, nodeId: selectedNodeId || null, }); } const editingNodeIdRef = useRef(null); const editingFieldRef = useRef(null); const opThrottleRef = useRef({}); const [remoteUsers, setRemoteUsers] = useState({}); // {userId: {name, color, chartId, nodeId, ts}} function showToast(msg, dur = 2600) { setToast(msg); setTimeout(() => setToast(''), dur); } // ── WebSocket op senders ── function sendWs(msg) { const ws = wsRef.current; if (ws && ws.readyState === 1 /* OPEN */) { try { ws.send(JSON.stringify(msg)); } catch {} } } // Send move ops immediately (drag is 60 FPS, network can handle it) function sendOpMove(chartId, nodeId, x, y) { sendWs({ type: 'op', op: 'node.move', chartId, nodeId, x, y, userId: userRef.current.id }); } // Throttle text updates (merge field changes within window) function sendThrottledUpdate(chartId, nodeId, changes, ms = 100) { const key = 'u:' + nodeId; const slot = opThrottleRef.current[key] || (opThrottleRef.current[key] = { pendingFields: {} }); slot.pendingFields = { ...slot.pendingFields, ...changes }; if (slot.timer) return; const fields = slot.pendingFields; slot.pendingFields = {}; sendWs({ type: 'op', op: 'node.update', chartId, nodeId, fields, userId: userRef.current.id }); slot.timer = setTimeout(function flush() { slot.timer = null; if (Object.keys(slot.pendingFields).length) { const f = slot.pendingFields; slot.pendingFields = {}; sendWs({ type: 'op', op: 'node.update', chartId, nodeId, fields: f, userId: userRef.current.id }); slot.timer = setTimeout(flush, ms); } }, ms); } // Apply a remote op to local state without triggering save/history function applyRemoteOp(op) { if (!op || op.userId === userRef.current.id) return; if (op.op === 'node.update' && op.nodeId === editingNodeIdRef.current) { // Don't overwrite the field the user is currently typing in const ef = editingFieldRef.current; const fields = { ...op.fields }; if (ef && ef in fields) delete fields[ef]; if (Object.keys(fields).length === 0) return; op = { ...op, fields }; } setProject(prev => { if (!prev) return prev; if (op.op === 'node.move') { return { ...prev, charts: prev.charts.map(c => c.id !== op.chartId ? c : { ...c, nodes: c.nodes.map(n => n.id !== op.nodeId ? n : { ...n, x: op.x, y: op.y }), }), }; } if (op.op === 'node.update') { return { ...prev, charts: prev.charts.map(c => c.id !== op.chartId ? c : { ...c, nodes: c.nodes.map(n => n.id !== op.nodeId ? n : { ...n, ...op.fields }), }), }; } return prev; }); } // Keep editing-field refs current (consumed by applyRemoteOp closure) useEffect(() => { editingNodeIdRef.current = editingNodeId; editingFieldRef.current = editingField; }, [editingNodeId, editingField]); const collapsedSet = useMemo(() => collapsedMap.get(selectedChartId) || new Set(), [collapsedMap, selectedChartId]); const hidingAncestorSet = useMemo(() => hidingAncMap.get(selectedChartId) || new Set(), [hidingAncMap, selectedChartId]); const remoteSelections = useMemo(() => { const map = {}; for (const [id, u] of Object.entries(remoteUsers)) { if (!u || !u.nodeId || u.chartId !== selectedChartId) continue; (map[u.nodeId] = map[u.nodeId] || []).push({ id, name: u.name, color: u.color }); } return map; }, [remoteUsers, selectedChartId]); const currentChart = useMemo(() => project?.charts.find(c => c.id === selectedChartId) || project?.charts[0] || null, [project, selectedChartId]); const selectedNode = useMemo(() => currentChart?.nodes.find(n => n.id === selectedNodeId) || null, [currentChart, selectedNodeId]); const nodeTypes = useMemo(() => project?.nodeTypes || DEFAULT_NODE_TYPES, [project]); const statuses = useMemo(() => project?.statuses || DEFAULT_STATUSES, [project]); // ── Session restore ── useEffect(() => { let cancelled = false; async function restore() { // Parse `#share=…` from the URL hash. We DON'T use URLSearchParams here // because it would decode '+' in the base64 payload to a space and corrupt // it — the bug that caused pasted share links to fail. Match the share // token directly from the raw hash string instead. const rawHash = window.location.hash.replace(/^#/, ''); let shareToken = null; const m = rawHash.match(/(?:^|&)share=([^&]+)/); if (m) shareToken = m[1]; if (shareToken) { if (shareToken.startsWith('encoded_')) { try { // Accept both base64url (- _) and legacy base64 (+ /) so old links // still resolve. Pad to a multiple of 4 for atob. let b64 = shareToken.slice(8).replace(/-/g, '+').replace(/_/g, '/'); while (b64.length % 4) b64 += '='; const data = JSON.parse(decodeURIComponent(atob(b64))); if (cancelled) return; const migrated = (typeof migrateProject === 'function') ? migrateProject(data) : data; setProject(migrated); setSelectedChartId(migrated.lastOpenedChartId || migrated.charts[0]?.id || ''); setReadOnly(true); setIsLoggedIn(true); return; } catch (err) { console.warn('Share link decode failed:', err); showToast('Share link is invalid or corrupted'); } } // Fallback: try server lookup by token (works if owner has saved the // project on this domain). Quick lookup against /api/share/{token}. try { const res = await fetch(`/api/share/${encodeURIComponent(shareToken)}`); if (res.ok) { const data = await res.json(); if (!cancelled && data && data.charts) { const migrated = (typeof migrateProject === 'function') ? migrateProject(data) : data; setProject(migrated); setSelectedChartId(migrated.lastOpenedChartId || migrated.charts[0]?.id || ''); setReadOnly(true); setIsLoggedIn(true); return; } } } catch (err) { /* fall through to local scan */ } for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (!key?.startsWith('fc_v1_')) continue; try { const data = JSON.parse(localStorage.getItem(key)); if (data?.readOnlyShareToken === shareToken) { if (cancelled) return; const migrated = (typeof migrateProject === 'function') ? migrateProject(data) : data; setProject(migrated); setSelectedChartId(migrated.lastOpenedChartId || migrated.charts[0]?.id || ''); setReadOnly(true); setIsLoggedIn(true); return; } } catch {} } } const session = loadSession(); if (session?.hash) { const localProj = loadProject(session.hash); const remoteProj = await fetchServerProject(session.hash); let proj = (typeof reconcileProjectsOnLoad === 'function') ? reconcileProjectsOnLoad(localProj, remoteProj) : (localProj || remoteProj); if (proj) { const cache = saveProject(session.hash, proj); if (cache?.strippedImages) showToast('Reloaded from server — images are stored online, not in browser cache.'); } if (proj) { if (cancelled) return; proj = (typeof migrateProject === 'function') ? migrateProject(proj) : proj; syncBaseRef.current = proj; setProjectHash(session.hash); setProject(proj); setSelectedChartId(proj.lastOpenedChartId || proj.charts[0]?.id || ''); setIsLoggedIn(true); } } } restore(); return () => { cancelled = true; }; }, []); // ── Keep latestProjectRef current for stable WS callbacks ── useEffect(() => { latestProjectRef.current = project; }, [project]); // ── Persist local cache ── useEffect(() => { if (!project || !projectHash) return; const cache = saveProject(projectHash, project); if (cache?.quota && !readOnly && !quotaWarnedRef.current) { quotaWarnedRef.current = true; showToast('Browser storage full — saving to server only. Wait for save before refreshing.'); } }, [project, projectHash, readOnly]); // ── Persist to backend with optimistic concurrency + three-way merge on conflict ── const flushSave = useCallback(async () => { if (!projectHash || readOnly || !hasLocalChangesRef.current) return; if (saveInFlightRef.current) { savePendingRef.current = true; return; } const localSnapshot = latestProjectRef.current; if (!localSnapshot) return; saveInFlightRef.current = true; try { const saved = await saveServerProject(projectHash, localSnapshot, Number(localSnapshot.version || 0)); syncBaseRef.current = saved; if (latestProjectRef.current === localSnapshot) { hasLocalChangesRef.current = false; isApplyingRemoteRef.current = true; const cache = saveProject(projectHash, saved); setProject(saved); if (cache?.quota) showToast('Saved to server; browser cache skipped images (storage full).'); } else { // Edits happened during the PUT — keep local content, adopt server version. hasLocalChangesRef.current = true; setProject(prev => prev ? { ...prev, version: saved.version } : saved); saveProject(projectHash, latestProjectRef.current); } } catch (err) { if (err?.status === 409) { const serverProject = err?.body?.detail?.project; if (serverProject) { const merged = mergeProjects(syncBaseRef.current, localSnapshot, serverProject); merged.id = projectHash; syncBaseRef.current = serverProject; hasLocalChangesRef.current = true; saveProject(projectHash, merged); setProject(merged); showToast('Merged with teammate\'s changes'); } } else { hasLocalChangesRef.current = true; showToast(err?.status === 413 ? 'Project too large to save — remove some images and try again.' : 'Save failed — retrying…'); } } finally { saveInFlightRef.current = false; if (savePendingRef.current) { savePendingRef.current = false; flushSave(); } } }, [projectHash, readOnly]); useEffect(() => { if (!project || !projectHash || readOnly) return; if (isApplyingRemoteRef.current) { isApplyingRemoteRef.current = false; return; } if (!hasLocalChangesRef.current) return; if (saveTimerRef.current) clearTimeout(saveTimerRef.current); saveTimerRef.current = setTimeout(() => { flushSave(); }, 450); return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current); }; }, [project, projectHash, readOnly, flushSave]); // Retry failed saves even if the user stops typing useEffect(() => { if (!projectHash || readOnly) return; saveRetryTimerRef.current = setInterval(() => { if (hasLocalChangesRef.current && !saveInFlightRef.current) flushSave(); }, 5000); return () => { if (saveRetryTimerRef.current) clearInterval(saveRetryTimerRef.current); }; }, [projectHash, readOnly, flushSave]); // Best-effort save when the tab closes useEffect(() => { if (!projectHash || readOnly) return; function onBeforeUnload() { if (!hasLocalChangesRef.current) return; const snap = latestProjectRef.current; if (!snap) return; try { fetch(`/api/project/${encodeURIComponent(projectHash)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project: { ...snap, id: projectHash }, expectedVersion: Number(snap.version || 0), }), keepalive: true, }); } catch {} } window.addEventListener('beforeunload', onBeforeUnload); return () => window.removeEventListener('beforeunload', onBeforeUnload); }, [projectHash, readOnly]); // ── WebSocket live sync (primary): full updates + ops + cursor relay ── useEffect(() => { if (!isLoggedIn || !projectHash || readOnly) return; let alive = true; let reconnectTimer = null; let reconnectDelay = 500; function applyRemote(latest) { const remoteVersion = Number(latest.version || 0); const localVersion = Number(latestProjectRef.current?.version || 0); if (remoteVersion <= localVersion) return; if (saveInFlightRef.current) return; // in-flight save will reconcile via 409 → merge if (hasLocalChangesRef.current && syncBaseRef.current) { // Merge remote into local without losing unsaved work const merged = mergeProjects(syncBaseRef.current, latestProjectRef.current, latest); merged.id = projectHash; syncBaseRef.current = latest; saveProject(projectHash, merged); setProject(merged); return; } isApplyingRemoteRef.current = true; syncBaseRef.current = latest; saveProject(projectHash, latest); setProject(latest); } function announceCursor() { const u = userRef.current; sendWs({ type: 'cursor', userId: u.id, name: u.name, color: u.color, chartId: latestProjectRef.current?.lastOpenedChartId || null, nodeId: null, }); } function connect() { if (!alive) return; const proto = location.protocol === 'https:' ? 'wss' : 'ws'; const ws = new WebSocket(`${proto}://${location.host}/ws/${encodeURIComponent(projectHash)}`); wsRef.current = ws; ws.onopen = () => { wsConnectedRef.current = true; reconnectDelay = 500; announceCursor(); }; ws.onmessage = evt => { try { const msg = JSON.parse(evt.data); if (msg.type === 'presence') { setOthersOnline(Math.max(0, (msg.count || 1) - 1)); } else if (msg.type === 'project_update') { applyRemote(msg.project); } else if (msg.type === 'op') { applyRemoteOp(msg); } else if (msg.type === 'cursor') { if (msg.userId === userRef.current.id) return; setRemoteUsers(prev => ({ ...prev, [msg.userId]: { name: msg.name, color: msg.color, chartId: msg.chartId, nodeId: msg.nodeId, ts: Date.now() }, })); } else if (msg.type === 'leave') { if (!msg.userId) return; setRemoteUsers(prev => { const next = { ...prev }; delete next[msg.userId]; return next; }); } } catch {} }; ws.onclose = () => { wsConnectedRef.current = false; wsRef.current = null; setOthersOnline(0); setRemoteUsers({}); if (alive) { reconnectTimer = setTimeout(connect, Math.min(reconnectDelay, 8000)); reconnectDelay = Math.min(Math.round(reconnectDelay * 1.6), 8000); } }; ws.onerror = () => { try { ws.close(); } catch {} }; } connect(); return () => { alive = false; clearTimeout(reconnectTimer); wsConnectedRef.current = false; setOthersOnline(0); setRemoteUsers({}); const u = userRef.current; try { wsRef.current?.send(JSON.stringify({ type: 'leave', userId: u.id })); } catch {} if (wsRef.current) { try { wsRef.current.close(); } catch {} ; wsRef.current = null; } }; }, [isLoggedIn, projectHash, readOnly]); // ── Broadcast my cursor / focused node when selection or chart changes ── useEffect(() => { if (!isLoggedIn || readOnly || !wsConnectedRef.current) return; const u = userRef.current; sendWs({ type: 'cursor', userId: u.id, name: u.name, color: u.color, chartId: selectedChartId || null, nodeId: selectedNodeId || null, }); }, [selectedChartId, selectedNodeId, isLoggedIn, readOnly, othersOnline]); // ── Fallback polling when WebSocket is not connected ── useEffect(() => { if (!isLoggedIn || !projectHash || readOnly) return; const timer = setInterval(async () => { if (wsConnectedRef.current) return; try { const latest = await fetchServerProject(projectHash); if (!latest) return; const localVersion = Number(latestProjectRef.current?.version || 0); const remoteVersion = Number(latest.version || 0); if (remoteVersion <= localVersion) return; if (hasLocalChangesRef.current || saveInFlightRef.current) return; isApplyingRemoteRef.current = true; syncBaseRef.current = latest; saveProject(projectHash, latest); setProject(latest); showToast('Live update received'); } catch {} }, 4000); return () => clearInterval(timer); }, [isLoggedIn, projectHash, readOnly]); async function handleLogin(hash, nameHint) { const localProj = loadProject(hash); const remoteProj = await fetchServerProject(hash); let proj = (typeof reconcileProjectsOnLoad === 'function') ? reconcileProjectsOnLoad(localProj, remoteProj) : (localProj || remoteProj); let recoveredFromServer = false; let createdFresh = false; if (proj && remoteProj && proj === remoteProj && !localProj) { recoveredFromServer = true; saveProject(hash, proj); } else if (proj) { const cache = saveProject(hash, proj); if (cache?.strippedImages) showToast('Project loaded — large projects keep images on the server only.'); if (remoteProj && proj !== localProj) recoveredFromServer = true; } if (!proj) { proj = await openServerProject(hash); if (proj) saveProject(hash, proj); } if (!proj) { // Fresh project — use the typed display name as the initial project // name. The user can rename later in Settings; the ID stays the same. proj = createSeedProject(hash, nameHint); createdFresh = true; } // Migrations if (!proj.statuses) proj = { ...proj, statuses: DEFAULT_STATUSES.map(s => ({ ...s })) }; // Use migrateProject so node.linkGroupId / edge.route default to null // and old saves with missing fields still work. proj = (typeof migrateProject === 'function') ? migrateProject(proj) : proj; // Server-recovered projects without a name → backfill with the typed ID // so the chart list never shows "Unnamed project" right after login. if (!proj.name && nameHint) proj = { ...proj, name: nameHint }; hasLocalChangesRef.current = createdFresh; // flush the fresh seed to server isApplyingRemoteRef.current = !createdFresh; syncBaseRef.current = proj; setProjectHash(hash); setProject(proj); setSelectedChartId(proj.lastOpenedChartId || proj.charts[0]?.id || ''); saveSession(hash); setIsLoggedIn(true); setReadOnly(false); // Save to recents saveRecentProject(hash, proj.name || nameHint || 'Unnamed project'); if (recoveredFromServer) showToast('Recovered your previous server save.'); } // ── updateProject — central mutator with undo ── // History is pushed OUTSIDE the setProject updater so it's safe in React 18 // Strict Mode (updaters may be called twice; side-effects inside would corrupt history). function updateProject(updater) { // Read-and-reset the skip flag synchronously, before any async work. const doHistory = !skipHistoryRef.current; skipHistoryRef.current = false; if (doHistory) { // latestProjectRef is kept in sync via useEffect; use it so we always // push the true latest state, not a potentially-stale closure capture. const cur = latestProjectRef.current; if (cur) { historyRef.current.past.push(cur); if (historyRef.current.past.length > 80) historyRef.current.past.shift(); historyRef.current.future = []; } } setProject(prev => { if (!prev) return prev; const next = updater(prev); if (!isApplyingRemoteRef.current) hasLocalChangesRef.current = true; // Persisting to localStorage is handled by the project-change effect. // Doing it here too double-writes every keystroke and, under React 18 // Strict Mode, runs twice because updaters are invoked twice. return next; }); } function undo() { const h = historyRef.current; if (!h.past.length || !project) { showToast('Nothing to undo'); return; } const prev = h.past.pop(); h.future.push(project); skipHistoryRef.current = true; hasLocalChangesRef.current = true; setProject(prev); if (projectHash) saveProject(projectHash, prev); showToast('↶ Undo'); } function redo() { const h = historyRef.current; if (!h.future.length || !project) { showToast('Nothing to redo'); return; } const next = h.future.pop(); h.past.push(project); skipHistoryRef.current = true; hasLocalChangesRef.current = true; setProject(next); if (projectHash) saveProject(projectHash, next); showToast('↷ Redo'); } // ── Navigation ── function selectChart(id) { setSelectedChartId(id); setSelectedNodeId(''); setNavStack([]); setConnectMode(false); setConnectSource(null); setPan({ x: 80, y: 60 }); setScale(1); setEditingNodeId(null); setEditingField(null); updateProject(p => ({ ...p, lastOpenedChartId: id })); } function openSubChart(subChartId) { if (!subChartId || !project) return; const target = project.charts.find(c => c.id === subChartId); if (!target) { showToast('Sub-chart not found'); return; } if (!currentChart) return; setNavStack(prev => [...prev, { chartId: currentChart.id, label: currentChart.title }]); setSelectedChartId(subChartId); setSelectedNodeId(''); setConnectMode(false); setConnectSource(null); setPan({ x: 80, y: 60 }); setScale(1); setEditingNodeId(null); setEditingField(null); setExpandedNodeId(null); setTimeout(() => setFitTrigger(n => n + 1), 80); } function navigateBreadcrumb(index) { const target = navStack[index]; if (!target) return; setNavStack(prev => prev.slice(0, index)); setSelectedChartId(target.chartId); setSelectedNodeId(''); setConnectMode(false); setConnectSource(null); setPan({ x: 80, y: 60 }); setScale(1); } // ── Chart operations ── function createChart() { const rootId = uid('node'); const newChart = { id: uid('chart'), title: 'New Chart', description: '', orientation: 'vertical', rootNodeId: rootId, nodes: [mkNode(rootId, 'Start', '', 400, 80, 'generic', statuses[0]?.id || 'todo')], edges: [], }; updateProject(p => ({ ...p, charts: [...p.charts, newChart], lastOpenedChartId: newChart.id })); setSelectedChartId(newChart.id); setSelectedNodeId(newChart.rootNodeId); setNavStack([]); setTimeout(() => setFitTrigger(n => n + 1), 80); } function reorderCharts(fromId, toId) { if (!fromId || !toId || fromId === toId) return; updateProject(p => { const charts = [...p.charts]; const fromIdx = charts.findIndex(c => c.id === fromId); const toIdx = charts.findIndex(c => c.id === toId); if (fromIdx === -1 || toIdx === -1) return p; const [moved] = charts.splice(fromIdx, 1); charts.splice(toIdx, 0, moved); return { ...p, charts }; }); } // Clone an entire chart (nodes, edges, layout) as an independent copy. // Cloned nodes get fresh ids and have their linkGroupId stripped so the // clone is truly independent of the original (per user request). function cloneChart(chartId) { if (!project) return; const src = project.charts.find(c => c.id === chartId); if (!src) return; const dup = cloneChartDeep(src, { preserveLinks: false }); updateProject(p => ({ ...p, charts: [...p.charts, dup], lastOpenedChartId: dup.id })); setSelectedChartId(dup.id); setSelectedNodeId(''); setNavStack([]); setTimeout(() => setFitTrigger(n => n + 1), 80); showToast(`Cloned chart "${src.title}"`); } function deleteChart(chartId) { const remaining = project.charts.filter(c => c.id !== chartId); if (remaining.length === 0) { // If this is the only chart, replace it with a fresh empty one so the // app has something to show, but still honour the user's request to // delete the original. const rootId = uid('node'); const fresh = { id: uid('chart'), title: 'New Chart', description: '', orientation: 'vertical', rootNodeId: rootId, nodes: [mkNode(rootId, 'Start', '', 400, 80, 'generic', statuses[0]?.id || 'todo')], edges: [], }; updateProject(p => ({ ...p, charts: [fresh], lastOpenedChartId: fresh.id })); setSelectedChartId(fresh.id); setSelectedNodeId(fresh.rootNodeId); setNavStack([]); showToast('Chart deleted · fresh chart created'); return; } const newSelected = chartId === selectedChartId ? remaining[0].id : selectedChartId; updateProject(p => ({ ...p, charts: remaining, lastOpenedChartId: newSelected })); if (chartId === selectedChartId) { setSelectedChartId(newSelected); setSelectedNodeId(''); setNavStack([]); } showToast('Chart deleted'); } // ── Node operations ── function addChildNode(parentId) { if (!currentChart) return; const parent = currentChart.nodes.find(n => n.id === parentId); if (!parent) return; const vert = currentChart.orientation === 'vertical'; const preferX = parent.x + (vert ? 0 : 330); const preferY = parent.y + (vert ? 230 : 0); const pos = findFreePosition(currentChart.nodes, preferX, preferY, vert); const newNode = mkNode(uid('node'), 'New node', '', pos.x, pos.y, parent.typeId, statuses[0]?.id || 'todo'); updateProject(p => ({ ...p, charts: p.charts.map(c => c.id !== currentChart.id ? c : { ...c, nodes: [...c.nodes, newNode], edges: [...c.edges, { id: uid('edge'), from: parentId, to: newNode.id }], }), })); setSelectedNodeId(newNode.id); setTimeout(() => { setEditingNodeId(newNode.id); setEditingField('label'); }, 60); } function addNodeAt(x, y) { if (!currentChart) return; const newNode = mkNode(uid('node'), 'New node', '', x, y, 'generic', statuses[0]?.id || 'todo'); updateProject(p => ({ ...p, charts: p.charts.map(c => c.id !== currentChart.id ? c : { ...c, nodes: [...c.nodes, newNode] }) })); setSelectedNodeId(newNode.id); setTimeout(() => { setEditingNodeId(newNode.id); setEditingField('label'); }, 60); } function deleteNode(nodeId) { if (!currentChart) return; const isRoot = nodeId === currentChart.rootNodeId; updateProject(p => ({ ...p, charts: p.charts.map(c => { if (c.id !== currentChart.id) return c; const remainingNodes = c.nodes.filter(n => n.id !== nodeId); const remainingEdges = c.edges.filter(e => e.from !== nodeId && e.to !== nodeId); let newRoot = c.rootNodeId; if (isRoot) { // Pick a new root: prefer a node with no remaining parents, else first const hasParent = new Set(remainingEdges.map(e => e.to)); const noParent = remainingNodes.find(n => !hasParent.has(n.id)); newRoot = (noParent || remainingNodes[0])?.id || null; } return { ...c, rootNodeId: newRoot, nodes: remainingNodes, edges: remainingEdges }; }), })); if (selectedNodeId === nodeId) setSelectedNodeId(''); if (editingNodeId === nodeId) { setEditingNodeId(null); setEditingField(null); } if (expandedNodeId === nodeId) setExpandedNodeId(null); } function deleteEdge(edgeId) { if (!currentChart) return; updateProject(p => ({ ...p, charts: p.charts.map(c => c.id !== currentChart.id ? c : { ...c, edges: c.edges.filter(e => e.id !== edgeId), }), })); showToast('Connection removed'); } function updateNode(nodeId, changes) { if (!currentChart) return; if ('label' in changes || 'notes' in changes || 'details' in changes) { skipHistoryRef.current = true; } sendThrottledUpdate(currentChart.id, nodeId, changes); updateProject(p => { // Apply changes to the target node in its own chart. let next = { ...p, charts: p.charts.map(c => c.id !== currentChart.id ? c : { ...c, nodes: c.nodes.map(n => n.id === nodeId ? { ...n, ...changes } : n), }), }; // Then propagate to every other node sharing the same link group. // propagateLinkedFields filters changes to syncable fields only and // skips the source node itself, so position/sub-chart edits stay local. if (typeof propagateLinkedFields === 'function') { next = propagateLinkedFields(next, nodeId, changes); } return next; }); } // Link an existing node in this chart to another node anywhere in the // project. The picked target adopts THIS node's content (this node is // treated as master, per the user's spec). Existing content on the target // is overwritten. function linkExistingNodes(masterNodeId, targetChartId, targetNodeId) { if (!currentChart) return; updateProject(p => linkNodes(p, currentChart.id, masterNodeId, targetChartId, targetNodeId)); showToast('Nodes linked'); } // Drop a node out of its link group (leaves siblings untouched). function unlinkNodeFromGroup(nodeId) { updateProject(p => unlinkNode(p, nodeId)); showToast('Node unlinked'); } function duplicateNode(nodeId) { if (!currentChart) return; const orig = currentChart.nodes.find(n => n.id === nodeId); if (!orig) return; const copy = { ...orig, id: uid('node'), label: `${orig.label} (copy)`, x: orig.x + 40, y: orig.y + 40, subChartId: null, poll: null }; updateProject(p => ({ ...p, charts: p.charts.map(c => c.id !== currentChart.id ? c : { ...c, nodes: [...c.nodes, copy] }), })); setSelectedNodeId(copy.id); showToast('Node duplicated'); } function copyNode(nodeId) { if (!currentChart) return; const node = currentChart.nodes.find(n => n.id === nodeId); if (!node) return; clipboardRef.current = { ...node }; showToast('Node copied'); } function pasteNode() { if (!clipboardRef.current || !currentChart) return; const src = clipboardRef.current; const newNode = { ...src, id: uid('node'), label: src.label, x: src.x + 60, y: src.y + 60, subChartId: null, poll: null }; updateProject(p => ({ ...p, charts: p.charts.map(c => c.id !== currentChart.id ? c : { ...c, nodes: [...c.nodes, newNode] }), })); setSelectedNodeId(newNode.id); showToast('Node pasted'); } // ── Multi-select bulk operations ───────────────────────────────────────── // Each operates on the current selectedNodeIds set within the active chart. // Bulk delete: removes every selected node and any edge that touches one. // Promotes a new root if the chart's rootNodeId was in the selection. function bulkDelete() { if (!currentChart || !selectedNodeIds.size) return; const ids = new Set(selectedNodeIds); updateProject(p => ({ ...p, charts: p.charts.map(c => { if (c.id !== currentChart.id) return c; const nodes = c.nodes.filter(n => !ids.has(n.id)); const edges = c.edges.filter(e => !ids.has(e.from) && !ids.has(e.to)); let rootNodeId = c.rootNodeId; if (ids.has(rootNodeId)) { const hasParent = new Set(edges.map(e => e.to)); const noParent = nodes.find(n => !hasParent.has(n.id)); rootNodeId = (noParent || nodes[0])?.id || null; } return { ...c, nodes, edges, rootNodeId }; }), })); setSelectedNodeIds(new Set()); if (ids.has(selectedNodeId)) setSelectedNodeId(''); showToast(`Deleted ${ids.size} nodes`); } // Copy a group as JSON to a refs-only clipboard. Preserves relative // positions and any internal edges. Paste re-inserts with fresh ids. function bulkCopy() { if (!currentChart || !selectedNodeIds.size) return; const ids = new Set(selectedNodeIds); const nodes = currentChart.nodes.filter(n => ids.has(n.id)); const edges = currentChart.edges.filter(e => ids.has(e.from) && ids.has(e.to)); clipboardRef.current = { kind: 'group', nodes, edges }; showToast(`Copied ${nodes.length} nodes`); } function bulkPaste() { if (!clipboardRef.current || clipboardRef.current.kind !== 'group' || !currentChart) { pasteNode(); return; // fall through to single-node paste } const { nodes, edges } = clipboardRef.current; const idMap = new Map(); const newNodes = nodes.map(n => { const newId = uid('node'); idMap.set(n.id, newId); // Clear subChartId + linkGroupId on paste — a duplicated branch // becomes independent, matching how single-node paste behaves today. return { ...n, id: newId, x: n.x + 60, y: n.y + 60, subChartId: null, linkGroupId: null }; }); const newEdges = edges.map(e => ({ ...e, id: uid('edge'), from: idMap.get(e.from) || e.from, to: idMap.get(e.to) || e.to, })); updateProject(p => ({ ...p, charts: p.charts.map(c => c.id !== currentChart.id ? c : { ...c, nodes: [...c.nodes, ...newNodes], edges: [...c.edges, ...newEdges], }), })); setSelectedNodeIds(new Set(newNodes.map(n => n.id))); showToast(`Pasted ${newNodes.length} nodes`); } // Apply a single change patch to every selected node. Use for bulk type / // status / tag-add updates from the floating action bar. function bulkUpdate(changes) { if (!currentChart || !selectedNodeIds.size) return; const ids = new Set(selectedNodeIds); updateProject(p => ({ ...p, charts: p.charts.map(c => c.id !== currentChart.id ? c : { ...c, nodes: c.nodes.map(n => ids.has(n.id) ? { ...n, ...changes } : n), }), })); } // Bulk-add a tag (deduped per node). function bulkAddTag(tag) { if (!currentChart || !selectedNodeIds.size || !tag) return; const ids = new Set(selectedNodeIds); const t = tag.replace(/^#/, '').trim().toLowerCase(); if (!t) return; updateProject(p => ({ ...p, charts: p.charts.map(c => c.id !== currentChart.id ? c : { ...c, nodes: c.nodes.map(n => { if (!ids.has(n.id)) return n; const cur = n.tags || []; return cur.includes(t) ? n : { ...n, tags: [...cur, t] }; }), }), })); showToast(`Tag #${t} added`); } // Move every selected node by (dx, dy). Used when the user drags any // member of the multi-select group on the canvas. function bulkMove(dx, dy) { if (!currentChart || !selectedNodeIds.size) return; const ids = new Set(selectedNodeIds); skipHistoryRef.current = true; updateProject(p => ({ ...p, charts: p.charts.map(c => c.id !== currentChart.id ? c : { ...c, nodes: c.nodes.map(n => ids.has(n.id) ? { ...n, x: n.x + dx, y: n.y + dy } : n), }), })); } // Group the current multi-selection into a new sub-chart. The first // selected node (closest to the chart root) becomes the new sub-chart's // root; in the source chart, that root remains as a link node pointing at // the new sub-chart and the rest of the selected branch is removed. function bulkGroupIntoSubChart() { if (!currentChart || selectedNodeIds.size < 1) return; const ids = new Set(selectedNodeIds); const group = currentChart.nodes.filter(n => ids.has(n.id)); if (!group.length) return; // Pick branch root: prefer the chart's root if it's in the selection, // else a node with no parents inside the group, else the first. const inGroup = new Set(group.map(n => n.id)); let branchRoot = group.find(n => n.id === currentChart.rootNodeId) || group.find(n => !currentChart.edges.some(e => inGroup.has(e.from) && e.to === n.id)) || group[0]; const branchEdges = currentChart.edges.filter(e => inGroup.has(e.from) && inGroup.has(e.to)); const newChartId = uid('chart'); // Normalise positions so root lands near (400, 80). const minX = Math.min(...group.map(n => n.x)); const minY = Math.min(...group.map(n => n.y)); const offX = 400 - (branchRoot.x - minX); const offY = 80 - (branchRoot.y - minY); const normalised = group.map(n => ({ ...n, x: n.x - minX + offX, y: n.y - minY + offY })); const newChart = { id: newChartId, title: `${branchRoot.label} — Group`, description: `Grouped from "${currentChart.title}"`, orientation: currentChart.orientation, rootNodeId: branchRoot.id, nodes: normalised, edges: branchEdges.map(e => ({ ...e })), }; updateProject(p => ({ ...p, charts: [ ...p.charts.map(c => { if (c.id !== currentChart.id) return c; return { ...c, nodes: c.nodes.map(n => n.id === branchRoot.id ? { ...n, subChartId: newChartId } : n) .filter(n => !ids.has(n.id) || n.id === branchRoot.id), edges: c.edges.filter(e => !ids.has(e.from) && !ids.has(e.to) || e.from === branchRoot.id || e.to === branchRoot.id), }; }), newChart, ], })); setSelectedNodeIds(new Set()); showToast(`Grouped ${group.length} nodes into sub-chart`); } function updateNodePos(nodeId, dx, dy) { if (!currentChart) return; skipHistoryRef.current = true; // Read live position so we can emit absolute coords for the move op (send immediately, no throttle) const liveNode = latestProjectRef.current?.charts?.find(c => c.id === currentChart.id)?.nodes?.find(n => n.id === nodeId); if (liveNode) sendOpMove(currentChart.id, nodeId, liveNode.x + dx, liveNode.y + dy); updateProject(p => ({ ...p, charts: p.charts.map(c => c.id !== currentChart.id ? c : { ...c, nodes: c.nodes.map(n => n.id !== nodeId ? n : { ...n, x: n.x + dx, y: n.y + dy }), }), })); } function updateChartField(field, value) { if (!currentChart) return; if (field === 'title' || field === 'description') skipHistoryRef.current = true; updateProject(p => ({ ...p, charts: p.charts.map(c => c.id !== currentChart.id ? c : { ...c, [field]: value }), })); } // ── Sub-chart ── // When a sub-chart is created from a node, the new root is auto-linked to // the originating node via a shared linkGroupId. Editing either node's // content (label/notes/details/etc.) now syncs to the other through // propagateLinkedFields. The originating node also gets subChartId set so // navigation still works. function createSubChart(nodeId, nodeLabel) { if (!currentChart) return; const sourceNode = currentChart.nodes.find(n => n.id === nodeId); // Reuse the existing link group if the source node already has one, so // multiple sub-charts of the same node share the same content. const groupId = (sourceNode && sourceNode.linkGroupId) || ('link_' + Math.random().toString(36).slice(2,10) + Date.now().toString(36)); const rootId = uid('node'); // Build root with the same content as the source so the auto-link starts // in sync from second 1. const rootNode = sourceNode ? { ...mkNode(rootId, sourceNode.label, sourceNode.notes, 400, 80, sourceNode.typeId, sourceNode.status), details: sourceNode.details || '', tags: [...(sourceNode.tags || [])], images: [...(sourceNode.images || [])], poll: sourceNode.poll || null, linkGroupId: groupId } : { ...mkNode(rootId, nodeLabel, '', 400, 80, 'generic', statuses[0]?.id || 'todo'), linkGroupId: groupId }; const newChart = { id: uid('chart'), title: `${nodeLabel} — Detail`, description: `Sub-chart for: ${nodeLabel}`, orientation: 'vertical', rootNodeId: rootId, nodes: [rootNode], edges: [], }; updateProject(p => ({ ...p, charts: [ ...p.charts.map(c => c.id !== currentChart.id ? c : { ...c, nodes: c.nodes.map(n => n.id === nodeId ? { ...n, subChartId: newChart.id, linkGroupId: groupId } : n), }), newChart, ], })); showToast(`Sub-chart "${newChart.title}" created · root linked`); } // Split a node + its descendants into a new sub-chart. The node stays in // the current chart as a "link" node (with subChartId set), the branch is // extracted into a new chart. function splitToSubChart(nodeId) { if (!currentChart) return; const branchNode = currentChart.nodes.find(n => n.id === nodeId); if (!branchNode) return; const descendants = getDescendants(currentChart, nodeId); if (descendants.size === 0) { showToast('No descendants to split'); return; } const branchIds = new Set([nodeId, ...descendants]); const branchNodes = currentChart.nodes.filter(n => branchIds.has(n.id)); const branchEdges = currentChart.edges.filter(e => branchIds.has(e.from) && branchIds.has(e.to)); // Normalise positions so the branch starts near (400, 80) in the new chart const minX = Math.min(...branchNodes.map(n => n.x)); const minY = Math.min(...branchNodes.map(n => n.y)); const newChartId = uid('chart'); // The branch root STAYS in the source chart as a link node, so the copy in // the new chart needs a fresh id — reusing nodeId would put two nodes with // the same id in the project and break link-group/jump lookups. const newRootId = uid('node'); const normalisedNodes = branchNodes.map(n => { const moved = { ...n, x: n.x - minX + 400 - (branchNode.x - minX), y: n.y - minY + 80 }; return n.id === nodeId ? { ...moved, id: newRootId } : moved; }); const newChart = { id: newChartId, title: `${branchNode.label} — Branch`, description: `Split from "${currentChart.title}"`, orientation: currentChart.orientation, rootNodeId: newRootId, nodes: normalisedNodes, edges: branchEdges.map(e => ({ ...e, from: e.from === nodeId ? newRootId : e.from, to: e.to === nodeId ? newRootId : e.to, })), }; // In the current chart, remove the descendant nodes + their edges, keep // the branch root as a link node pointing at the new sub-chart. updateProject(p => ({ ...p, charts: [ ...p.charts.map(c => { if (c.id !== currentChart.id) return c; return { ...c, nodes: c.nodes.map(n => n.id === nodeId ? { ...n, subChartId: newChartId } : n) .filter(n => !descendants.has(n.id)), edges: c.edges.filter(e => !descendants.has(e.from) && !descendants.has(e.to)), }; }), newChart, ], })); showToast(`Split into sub-chart "${newChart.title}"`); } function promoteToRoot(nodeId) { if (!currentChart) return; updateProject(p => ({ ...p, charts: p.charts.map(c => c.id !== currentChart.id ? c : { ...c, rootNodeId: nodeId }), })); showToast('Chart root updated'); } // Attach an existing chart as the sub-chart of a node. function attachChartAsSubChart(nodeId, chartId) { if (!currentChart || !chartId || chartId === currentChart.id) return; updateProject(p => ({ ...p, charts: p.charts.map(c => c.id !== currentChart.id ? c : { ...c, nodes: c.nodes.map(n => n.id === nodeId ? { ...n, subChartId: chartId } : n), }), })); const attached = project.charts.find(c => c.id === chartId); showToast(`Linked "${attached?.title || 'chart'}" as sub-chart`); } // Detach the sub-chart link from a node (does not delete the sub-chart itself). function detachSubChart(nodeId) { if (!currentChart) return; updateProject(p => ({ ...p, charts: p.charts.map(c => c.id !== currentChart.id ? c : { ...c, nodes: c.nodes.map(n => n.id === nodeId ? { ...n, subChartId: null } : n), }), })); showToast('Sub-chart unlinked'); } // ── Images ── function addImage(nodeId, dataUrl) { updateProject(p => ({ ...p, charts: p.charts.map(c => c.id !== currentChart?.id ? c : { ...c, nodes: c.nodes.map(n => n.id === nodeId ? { ...n, images: [...(n.images || []), dataUrl] } : n), }), })); } function removeImage(nodeId, index) { updateProject(p => ({ ...p, charts: p.charts.map(c => c.id !== currentChart?.id ? c : { ...c, nodes: c.nodes.map(n => n.id === nodeId ? { ...n, images: (n.images || []).filter((_, i) => i !== index) } : n), }), })); } // Handle image drop from canvas (files array) async function handleImageDrop(nodeId, files) { let added = 0; for (const file of files) { if (!file.type.startsWith('image/')) continue; try { const dataUrl = (typeof readAndCompressImageFile === 'function') ? await readAndCompressImageFile(file) : await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = () => reject(new Error('Read failed')); reader.readAsDataURL(file); }); addImage(nodeId, dataUrl); added++; } catch (err) { if (err?.message === 'too_large') showToast('Image too large (max 4 MB)'); else showToast('Could not add image'); } } if (added) showToast(added === 1 ? 'Image added to node' : `${added} images added`); } // ── Poll ── function updatePoll(nodeId, poll) { updateNode(nodeId, { poll }); } // ── Node types ── function addNodeType(type) { updateProject(p => ({ ...p, nodeTypes: [...(p.nodeTypes || DEFAULT_NODE_TYPES), type] })); } function deleteNodeType(typeId) { if (!project) return; const allNodes = (project.charts || []).flatMap(c => c.nodes); if (allNodes.some(n => n.typeId === typeId)) { showToast('Cannot delete: type is in use'); return; } const types = (project.nodeTypes || DEFAULT_NODE_TYPES).filter(t => t.id !== typeId); if (types.length === 0) { showToast('Cannot delete the last type'); return; } updateProject(p => ({ ...p, nodeTypes: types })); } // ── Statuses ── function addStatus(status) { updateProject(p => ({ ...p, statuses: [...(p.statuses || DEFAULT_STATUSES), status] })); } function deleteStatus(statusId) { if (!project) return; const currentStatuses = project.statuses || DEFAULT_STATUSES; if (currentStatuses.length <= 1) { showToast('Cannot delete the last status'); return; } const allNodes = (project.charts || []).flatMap(c => c.nodes); if (allNodes.some(n => n.status === statusId)) { showToast('Cannot delete: status is in use'); return; } updateProject(p => ({ ...p, statuses: (p.statuses || DEFAULT_STATUSES).filter(s => s.id !== statusId) })); } // ── Connect ── function handleConnectNode(nodeId) { if (!connectSource) { setConnectSource(nodeId); return; } if (connectSource !== nodeId) { const exists = currentChart?.edges.some(e => e.from === connectSource && e.to === nodeId); if (!exists && currentChart) { updateProject(p => ({ ...p, charts: p.charts.map(c => c.id !== currentChart.id ? c : { ...c, edges: [...c.edges, { id: uid('edge'), from: connectSource, to: nodeId }], }), })); showToast('Connected'); } } setConnectMode(false); setConnectSource(null); } // ── Collapse ── function toggleCollapse(nodeId) { setCollapsedMap(prev => { const map = new Map(prev); const set = new Set(map.get(selectedChartId) || []); if (set.has(nodeId)) set.delete(nodeId); else set.add(nodeId); map.set(selectedChartId, set); return map; }); } function toggleHideAncestors(nodeId) { setHidingAncMap(prev => { const map = new Map(prev); const set = new Set(map.get(selectedChartId) || []); if (set.has(nodeId)) set.delete(nodeId); else set.add(nodeId); map.set(selectedChartId, set); return map; }); } // ── Inline editing ── function handleEditStart(nodeId, field) { setEditingNodeId(nodeId); setEditingField(field); } function handleEditChange(nodeId, field, value) { updateNode(nodeId, { [field]: value }); } function handleEditEnd() { setEditingNodeId(null); setEditingField(null); } // ── Auto-arrange (auto-fits after) ── function autoArrange() { if (!currentChart) return; const opts = { spacing: layoutSpacing }; const arranged = layoutAlgorithm === 'improved' ? arrangeImproved(currentChart, opts) : layoutAlgorithm === 'hierarchical' ? arrangeHierarchicalClassic(currentChart, opts) : layoutAlgorithm === 'radial' ? arrangeRadial(currentChart) : layoutAlgorithm === 'force' ? arrangeForceDirected(currentChart) : arrangeTree(currentChart, opts); // 'compact' fallback updateProject(p => ({ ...p, charts: p.charts.map(c => c.id === currentChart.id ? arranged : c) })); setTimeout(() => setFitTrigger(n => n + 1), 60); const label = { improved: 'Tree (RT)', hierarchical: 'Hierarchical', radial: 'Radial', force: 'Force-Directed', compact: 'Compact' }[layoutAlgorithm] || 'Tree'; showToast('Arranged · ' + label); } // ── Navigate to adjacent node ── function navigateToNode(direction) { if (!currentChart || !selectedNodeId) return; let candidates = []; if (direction === 'child') candidates = getChildIds(currentChart, selectedNodeId); if (direction === 'parent') candidates = getParentIds(currentChart, selectedNodeId); if (candidates.length > 0) setSelectedNodeId(candidates[0]); } // ── Export / Import ── function exportProject() { if (!project) return; const blob = new Blob([JSON.stringify(project, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${(project.name || 'project').replace(/\s+/g, '-')}.json`; a.click(); URL.revokeObjectURL(url); } function handleImport(e) { const file = e.target.files?.[0]; if (!file) return; file.text().then(text => { try { let parsed = JSON.parse(text); if (parsed?.charts && Array.isArray(parsed.charts)) { if (!parsed.statuses) parsed.statuses = DEFAULT_STATUSES.map(s => ({ ...s })); // Full migration (node linkGroupId, edge.route, missing node fields). parsed = (typeof migrateProject === 'function') ? migrateProject(parsed) : parsed; // Import overwrites the CURRENT project. Adopt the current project's // id + version so the next save is a clean PUT, and reset the merge // base to the imported state — otherwise a stale syncBase would make // the first conflict three-way-merge the import away. const cur = latestProjectRef.current; if (cur) { parsed.id = cur.id; parsed.version = Number(cur.version || 0); } syncBaseRef.current = parsed; hasLocalChangesRef.current = true; setProject(parsed); setSelectedChartId(parsed.lastOpenedChartId || parsed.charts[0]?.id || ''); setSelectedNodeId(''); setNavStack([]); showToast('Project imported ✓'); } else { showToast('Invalid project file'); } } catch { showToast('Invalid project file'); } }); e.target.value = ''; } // Share link generator — produces a self-contained URL containing the // whole project as base64URL-encoded JSON. The previous implementation // used plain base64, which contains '+' and '/' characters — URLSearchParams // decodes '+' to space when reading the hash, corrupting the payload and // making pasted links unusable. We now use the URL-safe base64URL alphabet // (- _) and strip padding; the reader normalises before decoding. We also // keep the legacy 'encoded_' prefix for back-compat with existing links. function copyShareLink() { if (!project) return; try { const json = JSON.stringify(project); // encodeURIComponent → percent-escapes any non-ASCII so btoa won't choke. const b64 = btoa(encodeURIComponent(json)); const b64url = b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); const link = `${window.location.origin}${window.location.pathname}#share=encoded_${b64url}`; navigator.clipboard.writeText(link) .then(() => showToast('Share link copied')) .catch(() => showToast('Could not copy link')); } catch (err) { console.warn('copyShareLink failed:', err); showToast('Could not copy link'); } } function handleAIExport() { if (!project) return; const text = generateAIExport(project); navigator.clipboard.writeText(text).catch(() => {}); const blob = new Blob([text], { type: 'text/markdown' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${(project.name || 'project').replace(/\s+/g, '-')}-design-doc.md`; a.click(); URL.revokeObjectURL(url); showToast('Design doc exported'); } // ── Keyboard shortcuts ── useEffect(() => { function onKey(e) { const tag = e.target?.tagName?.toLowerCase(); const isInput = tag === 'input' || tag === 'textarea' || e.target?.isContentEditable; if (e.key === 'Escape') { if (expandedNodeId) { setExpandedNodeId(null); return; } if (selectedNodeIds.size) { setSelectedNodeIds(new Set()); return; } setConnectMode(false); setConnectSource(null); setEditingNodeId(null); setEditingField(null); return; } // Undo / Redo if ((e.ctrlKey || e.metaKey) && !e.shiftKey && (e.key === 'z' || e.key === 'Z')) { if (isInput) return; e.preventDefault(); undo(); return; } if ((e.ctrlKey || e.metaKey) && ((e.shiftKey && (e.key === 'z' || e.key === 'Z')) || e.key === 'y')) { if (isInput) return; e.preventDefault(); redo(); return; } if (isInput) return; // Copy / Paste — group-aware when a multi-selection is active. if ((e.ctrlKey || e.metaKey) && e.key === 'c') { if (selectedNodeIds.size > 1) { e.preventDefault(); bulkCopy(); return; } if (selectedNodeId) { e.preventDefault(); copyNode(selectedNodeId); return; } } if ((e.ctrlKey || e.metaKey) && e.key === 'v') { e.preventDefault(); bulkPaste(); return; } // Duplicate if ((e.ctrlKey || e.metaKey) && (e.key === 'd' || e.key === 'D')) { if (selectedNodeId) { e.preventDefault(); duplicateNode(selectedNodeId); } return; } // Open expanded view if (e.key === 'Enter' && selectedNodeId && !expandedNodeId) { e.preventDefault(); setExpandedNodeId(selectedNodeId); return; } // Delete node(s) — group delete when multi-select is active. if ((e.key === 'Delete' || e.key === 'Backspace') && currentChart) { if (selectedNodeIds.size > 1) { e.preventDefault(); bulkDelete(); return; } if (selectedNodeId) { e.preventDefault(); deleteNode(selectedNodeId); return; } } // Single-key shortcuts below must not fire while a modifier is held — // otherwise Ctrl/Cmd+C falls through to "connect", Ctrl+A to "arrange", // Ctrl+F to "fit", etc. Those combos are handled above or by the browser. if (e.ctrlKey || e.metaKey || e.altKey) return; // Add child if (e.key === 'n' || e.key === 'N') { if (selectedNodeId) addChildNode(selectedNodeId); return; } // Connect if (e.key === 'c' || e.key === 'C') { if (!connectMode) { setConnectMode(true); setConnectSource(selectedNodeId || null); } else { setConnectMode(false); setConnectSource(null); } return; } // Fit if (e.key === 'f' || e.key === 'F') { setFitTrigger(n => n + 1); return; } // Auto-arrange if (e.key === 'a' || e.key === 'A') { autoArrange(); return; } // Navigate between nodes if (e.key === 'Tab') { e.preventDefault(); navigateToNode(e.shiftKey ? 'parent' : 'child'); return; } // Arrow key move if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.key) && selectedNodeId) { const step = e.shiftKey ? 40 : 8; const dx = e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0; const dy = e.key === 'ArrowUp' ? -step : e.key === 'ArrowDown' ? step : 0; updateNodePos(selectedNodeId, dx, dy); e.preventDefault(); } } window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [selectedNodeId, selectedNodeIds, currentChart, connectMode, editingNodeId, expandedNodeId, project, statuses, layoutAlgorithm]); if (!isLoggedIn) return ; const expandedNode = expandedNodeId ? (currentChart?.nodes.find(n => n.id === expandedNodeId) || null) : null; return (
{/* Topbar */}
FlowChart {project?.name || 'Project'} {readOnly && read-only} ({ id, ...u }))} fallbackCount={othersOnline} /> {wsConnectedRef.current === false && othersOnline === 0 && isLoggedIn && ( offline )}
{!readOnly && ( <> )} ✦ Export for AI setShowPolls(true)} title="View all polls">◉ Polls setShowSettings(true)}>Settings setTheme(t => t === 'dark' ? 'light' : 'dark')} title="Toggle theme"> {theme === 'dark' ? '☀︎' : '◑'} {!readOnly && ( { clearSession(); setIsLoggedIn(false); setProject(null); setProjectHash(null); setNavStack([]); }}> Exit )}
{/* Body */}
{ if (!currentChart) return; skipHistoryRef.current = true; updateProject(p => ({ ...p, charts: p.charts.map(c => c.id !== currentChart.id ? c : { ...c, edges: c.edges.map(e => e.id === edgeId ? { ...e, ...changes } : e), }), })); }} onToggleCollapse={toggleCollapse} onToggleHideAncestors={toggleHideAncestors} onCreateSubChart={createSubChart} onAddNodeAt={addNodeAt} onEditStart={handleEditStart} onEditChange={handleEditChange} onEditEnd={handleEditEnd} onArrange={autoArrange} onToggleConnect={(fromId) => { if (connectMode) { setConnectMode(false); setConnectSource(null); } else { setConnectMode(true); setConnectSource(fromId || selectedNodeId || null); } }} onOpenExpanded={(id) => setExpandedNodeId(id)} onDuplicateNode={duplicateNode} onImageDrop={handleImageDrop} onSplitToSubChart={splitToSubChart} onPromoteToRoot={promoteToRoot} onAttachChartAsSubChart={attachChartAsSubChart} onDetachSubChart={detachSubChart} onLinkNodes={linkExistingNodes} onUnlinkNode={unlinkNodeFromGroup} onJumpToLinkedNode={(targetChartId, targetNodeId) => { // Navigate to the linked node's chart, select it. if (targetChartId !== selectedChartId) { setSelectedChartId(targetChartId); setNavStack([]); setPan({ x: 80, y: 60 }); setScale(1); } setSelectedNodeId(targetNodeId); setTimeout(() => setFitTrigger(n => n + 1), 80); }} layoutAlgorithm={layoutAlgorithm} onLayoutAlgorithmChange={setLayoutAlgorithm} /> setExpandedNodeId(id)} onAddImage={addImage} onLinkNodes={linkExistingNodes} onUnlinkNode={unlinkNodeFromGroup} onJumpToLinkedNode={(targetChartId, targetNodeId) => { if (targetChartId !== selectedChartId) { setSelectedChartId(targetChartId); setNavStack([]); setPan({ x: 80, y: 60 }); setScale(1); } setSelectedNodeId(targetNodeId); setTimeout(() => setFitTrigger(n => n + 1), 80); }} readOnly={readOnly} />
{/* Toast */} {toast && (
{toast}
)} {showSettings && ( setShowSettings(false)} onUpdateName={name => { updateProject(p => ({ ...p, name })); if (projectHash) saveRecentProject(projectHash, name); }} onExport={exportProject} onImport={handleImport} onCopyShare={copyShareLink} onAIExport={() => { handleAIExport(); setShowSettings(false); }} layoutSpacing={layoutSpacing} onLayoutSpacingChange={setLayoutSpacing} defaultSpacing={DEFAULT_SPACING} myUser={userRef.current} onUpdateUser={updateMyIdentity} /> )} {expandedNode && ( setExpandedNodeId(null)} onUpdateNode={updateNode} onUpdatePoll={updatePoll} onDeleteNode={deleteNode} onOpenSubChart={openSubChart} onCreateSubChart={createSubChart} onAddImage={addImage} onRemoveImage={removeImage} /> )} {showPolls && ( setShowPolls(false)} onOpenNode={(chartId, nodeId) => { // Switch chart if needed, then open the node's expanded view if (chartId !== selectedChartId) { setSelectedChartId(chartId); setNavStack([]); setPan({ x: 80, y: 60 }); setScale(1); } setSelectedNodeId(nodeId); setExpandedNodeId(nodeId); setTimeout(() => setFitTrigger(n => n + 1), 100); }} /> )}
); } // ── TweaksPanel ──────────────────────────────────────────────────────────── function TweaksPanel({ visible, tweaks, onChange }) { if (!visible) return null; return (
Tweaks
{[ { label: 'Accent color', ctrl: onChange({ accentColor: e.target.value })} style={{ width: 32, height: 24, border: 'none', borderRadius: 4, cursor: 'pointer' }} /> }, { label: 'Dark mode', ctrl: onChange({ darkMode: e.target.checked })} /> }, { label: 'Rounded nodes', ctrl: onChange({ roundedNodes: e.target.checked })} /> }, ].map(({ label, ctrl }) => (
{label}{ctrl}
))}
); } // ── Root ─────────────────────────────────────────────────────────────────── const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "accentColor": "#3b82f6", "darkMode": true, "roundedNodes": true }/*EDITMODE-END*/; function Root() { const [tweaks, setTweaks] = useState(TWEAK_DEFAULTS); const [tweaksVisible, setTweaksVisible] = useState(false); const [theme, setTheme] = useState(() => localStorage.getItem('fc_theme') || 'dark'); useEffect(() => { document.documentElement.setAttribute('data-theme', theme); localStorage.setItem('fc_theme', theme); }, [theme]); useEffect(() => { document.documentElement.style.setProperty('--accent', tweaks.accentColor); document.documentElement.style.setProperty('--accent-muted', tweaks.accentColor + '22'); }, [tweaks.accentColor]); useEffect(() => { function onMsg(e) { if (e.data?.type === '__activate_edit_mode') setTweaksVisible(true); if (e.data?.type === '__deactivate_edit_mode') setTweaksVisible(false); } window.addEventListener('message', onMsg); window.parent.postMessage({ type: '__edit_mode_available' }, '*'); return () => window.removeEventListener('message', onMsg); }, []); function handleTweakChange(changes) { const next = { ...tweaks, ...changes }; setTweaks(next); if ('darkMode' in changes) setTheme(changes.darkMode ? 'dark' : 'light'); window.parent.postMessage({ type: '__edit_mode_set_keys', edits: next }, '*'); } function handleSetTheme(t) { setTheme(t); setTweaks(p => ({ ...p, darkMode: t === 'dark' })); } return ( <> ); } const root = ReactDOM.createRoot(document.getElementById('root')); root.render();