// 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.
{/* 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 */}
{/* 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();