// fc-components.jsx — shared primitives, NodeCard, FcCanvas, ChartSidebar
const { useState, useRef, useEffect, useMemo, useCallback } = React;
const NODE_W = 230;
const NODE_H_EST = 100;
// ── Shared primitives ──────────────────────────────────────────────────────
const fcInputStyle = {
background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 8,
padding: '7px 10px', color: 'var(--text)', fontSize: 12, width: '100%',
outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box',
};
const fcSelectStyle = { ...fcInputStyle, cursor: 'pointer' };
const fcBtnStyle = {
background: 'transparent', border: '1px solid var(--border)', borderRadius: 8,
padding: '6px 12px', fontSize: 11, cursor: 'pointer', color: 'var(--text)',
fontFamily: 'inherit', fontWeight: 500, transition: 'all 0.12s',
};
function FcLabel({ children }) {
return
{children}
;
}
function FcInput(props) { return ; }
function FcTextarea(props) { return ; }
function FcSelect({ children, ...props }) { return {children} ; }
function FcBtn({ children, onClick, danger, primary, subtle, disabled, title, style: extra }) {
return (
{children}
);
}
function ISection({ label, children }) {
return (
);
}
function IField({ label, children }) {
return {label} {children}
;
}
function HudBtn({ children, onClick, title, active, disabled }) {
return (
{children}
);
}
function HudDiv() { return ; }
// ── NodeCard ───────────────────────────────────────────────────────────────
function NodeCard({ node, nodeTypes, statuses, isSelected, isMultiSelected, isConnectSource, connectMode, roundedNodes,
isEditing, editingField, collapsedCount, hidingAncestorCount, searchMatch, remotePresence,
levels, linkedCount,
onBodyPointerDown, onBodyClick, onBodyContextMenu, onLabelClick, onNotesClick, onOpenExpanded,
onEditChange, onEditEnd, onImageDrop, onChainClick }) {
const type = nodeTypes.find(t => t.id === node.typeId) || nodeTypes[0] || { name: 'Generic', color: '#94a3b8' };
const sm = getStatus(statuses, node.status);
const radius = roundedNodes !== false ? 14 : 6;
const editingLabel = isEditing && editingField === 'label';
const editingNotes = isEditing && editingField === 'notes';
const rawThumb = (node.images || []).length > 0 ? node.images[0] : null;
// This value is interpolated into a CSS `url("…")`. Project data can come
// from an untrusted #share= link or imported JSON, so only allow well-formed
// image sources with no characters that could break out of the url() and
// inject extra CSS (or smuggle in a beacon URL).
const thumb = (typeof rawThumb === 'string'
&& /^(data:image\/|https?:\/\/|blob:)/i.test(rawThumb)
&& !/["'()\\\s]/.test(rawThumb)) ? rawThumb : null;
const [dragOver, setDragOver] = useState(false);
// When selected, show the full summary instead of the 2-line clamp
const showFullNotes = isSelected;
const hasDetails = (node.details || '').trim().length > 0;
const imageCount = (node.images || []).length;
function handleDragOver(e) {
if (e.dataTransfer.types.includes('Files')) { e.preventDefault(); setDragOver(true); }
}
function handleDragLeave() { setDragOver(false); }
function handleDrop(e) {
e.preventDefault(); e.stopPropagation(); setDragOver(false);
const files = [...(e.dataTransfer.files || [])].filter(f => f.type.startsWith('image/'));
if (files.length && onImageDrop) onImageDrop(node.id, files);
}
// Multi-select highlight uses the accent color with a dashed outline so it
// visually distinguishes from the primary selection (which is the node's
// own type color). When BOTH apply (this is the primary node within a
// multi-select), the primary style wins on the inner border + glow.
const baseBorderColor = isSelected ? type.color
: isConnectSource ? '#22c55e'
: isMultiSelected ? 'var(--accent)'
: searchMatch ? type.color + '88'
: 'var(--border)';
const accentBorder = dragOver ? `1.5px solid #22c55e` : `1.5px solid ${baseBorderColor}`;
return (
{ e.stopPropagation(); onOpenExpanded && onOpenExpanded(); }}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
style={{
position: 'absolute', left: node.x, top: node.y, width: NODE_W,
background: dragOver ? 'var(--accent-muted)' : 'var(--surface)',
border: accentBorder,
borderTop: `3px solid ${type.color}`,
borderRadius: radius,
padding: '10px 12px 11px',
cursor: connectMode ? 'crosshair' : editingLabel || editingNotes ? 'text' : 'grab',
userSelect: editingLabel || editingNotes ? 'text' : 'none',
boxShadow: isSelected
? `0 0 0 3px ${type.color}28, 0 12px 32px rgba(0,0,0,0.28)`
: isMultiSelected ? '0 0 0 3px var(--accent-muted), 0 6px 20px rgba(0,0,0,0.22)'
: isConnectSource ? '0 0 0 3px #22c55e40, 0 8px 24px rgba(0,0,0,0.2)'
: searchMatch ? `0 0 0 2px ${type.color}44, 0 4px 16px rgba(0,0,0,0.2)`
: '0 2px 10px rgba(0,0,0,0.16)',
transition: 'box-shadow 0.13s, border-color 0.13s, background 0.1s',
overflow: 'visible',
opacity: searchMatch === false ? 0.3 : 1,
}}
>
{/* Linked-nodes chain icon (top-right). Shows count of siblings in the
same link group. Click to open a popover that lists them and lets
the user jump to each. */}
{linkedCount > 0 && (
{ e.stopPropagation(); onChainClick && onChainClick(); }}
onPointerDown={e => e.stopPropagation()}
title={`Linked to ${linkedCount} other node${linkedCount !== 1 ? 's' : ''} — click to view`}
style={{
position: 'absolute', top: -8,
right: (remotePresence && remotePresence.length > 0) ? 56 : -8,
background: 'var(--accent)',
color: '#fff', border: '2px solid var(--surface)',
borderRadius: 99, padding: '2px 7px', fontSize: 9, fontWeight: 800,
cursor: 'pointer', lineHeight: 1.2, letterSpacing: '0.02em',
boxShadow: '0 2px 6px rgba(0,0,0,0.25)',
display: 'inline-flex', alignItems: 'center', gap: 3,
fontFamily: 'inherit', zIndex: 5,
}}>
🔗 {linkedCount}
)}
{/* Remote presence stack (top-right corner) */}
{remotePresence && remotePresence.length > 0 && (
{remotePresence.slice(0, 3).map((u, i) => (
{(u.name || '?')[0]}
))}
)}
{/* Type + status row. Level pill (depth from root) lives inline with
the type label so it's INSIDE the card. Numbers only — no "L"
prefix — per the user's spec. Multi-path nodes show all reachable
depths comma-separated. */}
{Array.isArray(levels) && levels.length > 0 && (
1
? `Reachable at depth ${levels.join(', ')} from root`
: `Depth ${levels[0]} from root`}
style={{
background: levelColor(levels[0]),
color: '#0b1220',
fontSize: 9, fontWeight: 800,
padding: '1px 6px',
borderRadius: 99, lineHeight: 1.35, letterSpacing: '0.02em',
flexShrink: 0,
}}>
{levels.join(', ')}
)}
{type.name}
{sm.label}
{/* Thumbnail */}
{thumb && !editingLabel && !editingNotes && (
{ e.stopPropagation(); onOpenExpanded && onOpenExpanded(); }}
style={{
width: '100%', height: 78, borderRadius: 6, overflow: 'hidden',
background: `center/cover no-repeat url("${thumb}"), var(--bg2)`,
marginBottom: 8, border: '1px solid var(--border)', position: 'relative', cursor: 'zoom-in',
}}
>
{node.images.length > 1 && (
+{node.images.length - 1}
)}
)}
{/* Label */}
{editingLabel ? (
onEditChange('label', e.target.value)}
onBlur={onEditEnd}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); onEditEnd(); } if (e.key === 'Escape') onEditEnd(); }}
onPointerDown={e => e.stopPropagation()}
onClick={e => e.stopPropagation()}
style={{ ...fcInputStyle, fontSize: 13, fontWeight: 700, padding: '3px 6px', marginBottom: 4, background: 'var(--bg2)' }}
/>
) : (
{ e.stopPropagation(); onLabelClick && onLabelClick(); }}
onDoubleClick={e => { e.stopPropagation(); onOpenExpanded && onOpenExpanded(); }}
title="Click to edit title · Double-click to expand"
style={{
display: 'block', fontSize: 13.5, fontWeight: 700, lineHeight: 1.3,
color: 'var(--text)', marginBottom: 4,
cursor: 'text', padding: '3px 5px', margin: '0 -5px 4px',
borderRadius: 5, transition: 'background 0.1s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg2)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
{node.label || 'Untitled'}
)}
{/* Notes */}
{editingNotes ? (
);
}
// ── Menu primitives (hoisted so React doesn't unmount/remount them on
// every parent state change like mouseenter→setHov, which was eating clicks)
function MenuItem({ icon, label, onClick, onClose, red, accent, disabled, shortcut }) {
const [hov, setHov] = useState(false);
return (
{ e.stopPropagation(); }}
onPointerDown={e => e.stopPropagation()}
onClick={e => {
e.stopPropagation();
if (disabled) return;
onClick && onClick();
onClose && onClose();
}}
onMouseEnter={() => setHov(true)}
onMouseLeave={() => setHov(false)}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
background: hov && !disabled ? 'var(--bg2)' : 'transparent',
border: 'none', borderRadius: 7, padding: '7px 10px', cursor: disabled ? 'not-allowed' : 'pointer',
color: disabled ? 'var(--text2)' : red ? '#f87171' : accent ? 'var(--accent)' : 'var(--text)',
fontSize: 12, fontFamily: 'inherit', textAlign: 'left', opacity: disabled ? 0.45 : 1,
transition: 'background 0.08s',
}}>
{icon}
{label}
{shortcut && {shortcut} }
);
}
function MenuDivider() { return
; }
// ── Node Context Menu ──────────────────────────────────────────────────────
function NodeContextMenu({ node, x, y, chart, isRoot, collapsedCount, hidingAncestorCount,
onClose, onAddChild, onStartConnect, onToggleCollapse, onToggleHideAncestors,
onOpenSubChart, onCreateSubChart, onDelete, onOpenExpanded, onDuplicate,
onSplitToSubChart, onPromoteToRoot,
onAttachChartAsSubChart, onDetachSubChart, allCharts, currentChartId, readOnly,
onLinkNodes, onUnlinkNode, project }) {
const isCollapsed = collapsedCount > 0;
const isHidingAnc = hidingAncestorCount > 0;
const descendantsCnt = getDescendants(chart, node.id).size;
const ancestorsCnt = getAncestors(chart, node.id).size;
const [showLinkMenu, setShowLinkMenu] = useState(false);
const [showLinkNodeMenu, setShowLinkNodeMenu] = useState(false);
const [nodeQuery, setNodeQuery] = useState('');
const linkableCharts = (allCharts || []).filter(c => c.id !== currentChartId && c.id !== node.subChartId);
const linkedChart = node.subChartId && (allCharts || []).find(c => c.id === node.subChartId);
const linkedSiblings = (typeof findLinkedNodes === 'function' && node.linkGroupId && project)
? findLinkedNodes(project, node.linkGroupId, node.id) : [];
// Build a flat list of pickable nodes for "Link existing node" — every
// node in the project EXCEPT this one and any already in the same link
// group. Filtered by query (label/notes/chart-title).
const pickableNodes = (allCharts || []).flatMap(c =>
(c.nodes || []).filter(n => {
if (c.id === currentChartId && n.id === node.id) return false;
if (node.linkGroupId && n.linkGroupId === node.linkGroupId) return false;
if (!nodeQuery.trim()) return true;
const q = nodeQuery.toLowerCase();
return (n.label || '').toLowerCase().includes(q)
|| (n.notes || '').toLowerCase().includes(q)
|| (c.title || '').toLowerCase().includes(q);
}).map(n => ({ chart: c, node: n }))
).slice(0, 30);
const menuRef = useRef(null);
// Close on click outside / Escape — avoids the fragility of a fullscreen
// overlay (which can eat clicks under some stacking-context / transform
// scenarios even when z-index "should" win).
useEffect(() => {
function onDocDown(e) {
if (menuRef.current && !menuRef.current.contains(e.target)) onClose();
}
function onKey(e) { if (e.key === 'Escape') onClose(); }
// Use capture so we see the event before any other handler can swallow it.
document.addEventListener('mousedown', onDocDown, true);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDocDown, true);
document.removeEventListener('keydown', onKey);
};
}, [onClose]);
return (
e.stopPropagation()}
onPointerDown={e => e.stopPropagation()}
onContextMenu={e => e.preventDefault()}>
{!readOnly &&
}
{!readOnly &&
}
{!readOnly &&
}
{node.subChartId
?
onOpenSubChart(node.subChartId, node.label)} accent />
: (!readOnly && onCreateSubChart(node.id, node.label)} />)}
{!readOnly && !node.subChartId && linkableCharts.length > 0 && !showLinkMenu && (
{ e.stopPropagation(); setShowLinkMenu(true); }}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
background: 'transparent', border: 'none', borderRadius: 7, padding: '7px 10px',
cursor: 'pointer', color: 'var(--text)', fontSize: 12, fontFamily: 'inherit', textAlign: 'left',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg2)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
⇢
Link existing chart as sub-chart…
▸
)}
{!readOnly && !node.subChartId && showLinkMenu && (
Pick a chart to link
{linkableCharts.map(c => (
onAttachChartAsSubChart(c.id)} />
))}
setShowLinkMenu(false)} />
)}
{!readOnly && node.subChartId && (
)}
{!readOnly && !node.subChartId && descendantsCnt > 0 && (
)}
{!readOnly && !isRoot && (
)}
{/* ── Linked-node section ─────────────────────────────────────────
Lets the user join this node to another existing node anywhere
in the project so their content stays in sync. Bidirectional —
editing either side propagates to all members of the group. */}
{!readOnly && }
{!readOnly && linkedSiblings.length > 0 && (
🔗 Linked to {linkedSiblings.length} other node{linkedSiblings.length !== 1 ? 's' : ''}
)}
{!readOnly && linkedSiblings.length > 0 && (
onUnlinkNode && onUnlinkNode(node.id)} />
)}
{!readOnly && !showLinkNodeMenu && (
{ e.stopPropagation(); setShowLinkNodeMenu(true); setShowLinkMenu(false); }}
onMouseDown={e => e.stopPropagation()}
onPointerDown={e => e.stopPropagation()}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
background: 'transparent', border: 'none', borderRadius: 7, padding: '7px 10px',
cursor: 'pointer', color: 'var(--text)', fontSize: 12, fontFamily: 'inherit', textAlign: 'left',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg2)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
🔗
Link to existing node…
▸
)}
{!readOnly && showLinkNodeMenu && (
e.stopPropagation()}
onPointerDown={e => e.stopPropagation()}>
Pick a node to link
setNodeQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Escape') { setShowLinkNodeMenu(false); setNodeQuery(''); } }}
placeholder="Search by title, notes, chart…"
style={fcInputStyle}
/>
Editing any linked node updates them all. Picking a target will overwrite its content with this node's.
{pickableNodes.length === 0 ? (
No matching nodes.
) : pickableNodes.map(({ chart: c, node: cand }) => (
{
if (onLinkNodes) onLinkNodes(node.id, c.id, cand.id);
setShowLinkNodeMenu(false); setNodeQuery(''); onClose && onClose();
}}
onMouseDown={e => e.stopPropagation()}
onPointerDown={e => e.stopPropagation()}
style={{
textAlign: 'left', background: 'transparent', border: '1px solid transparent',
borderRadius: 5, padding: '5px 7px', cursor: 'pointer',
color: 'var(--text)', fontFamily: 'inherit', fontSize: 11,
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg)'; e.currentTarget.style.borderColor = 'var(--accent)'; }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.borderColor = 'transparent'; }}
>
{cand.label || 'Untitled'}
{c.title}
))}
{ setShowLinkNodeMenu(false); setNodeQuery(''); }}
onMouseDown={e => e.stopPropagation()}
onPointerDown={e => e.stopPropagation()}
style={{ ...fcBtnStyle, alignSelf: 'flex-end', fontSize: 10, padding: '3px 8px' }}>
Cancel
)}
{!readOnly && (
)}
{!readOnly && (
)}
{!readOnly && }
{!readOnly && }
);
}
// ── Edge Route Menu (right-click on a connection line) ────────────────────
// SidePicker / StylePicker are defined OUTSIDE the menu component on purpose:
// nested components get unmounted/remounted on every parent render (new
// function identity), which can swallow real user clicks. Hoisting them and
// stopPropagation'ing pointerdown on each button (mirroring MenuItem) keeps
// clicks reliable.
function FcRoutePill({ active, onPick, children, title }) {
return (
e.stopPropagation()}
onMouseDown={e => e.stopPropagation()}
onClick={e => { e.stopPropagation(); onPick && onPick(); }}
style={{
flex: 1, fontSize: 10, fontWeight: 600,
background: active ? 'var(--accent)' : 'var(--bg)',
color: active ? '#fff' : 'var(--text)',
border: `1px solid ${active ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 6, padding: '3px 6px', cursor: 'pointer',
fontFamily: 'inherit', textTransform: 'capitalize',
}}>{children}
);
}
function FcSidePicker({ label, valueKey, route, onChange }) {
const val = (route && route[valueKey]) || 'auto';
const sides = ['auto','top','right','bottom','left'];
return (
{label}
{sides.map(s => (
onChange({ [valueKey]: s })}>{s}
))}
);
}
function EdgeRouteMenu({ edge, x, y, onClose, onChange, onClearBend }) {
const menuRef = useRef(null);
useEffect(() => {
function onDocDown(e) { if (menuRef.current && !menuRef.current.contains(e.target)) onClose(); }
function onKey(e) { if (e.key === 'Escape') onClose(); }
document.addEventListener('mousedown', onDocDown, true);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDocDown, true);
document.removeEventListener('keydown', onKey);
};
}, [onClose]);
if (!edge) return null;
const route = edge.route || {};
const styles = [
{ id: 'curved', label: 'Curved' },
{ id: 'straight', label: 'Straight' },
{ id: 'orthogonal', label: 'Right-angle' },
];
const left = Math.min(x, (typeof window !== 'undefined' ? window.innerWidth : 1200) - 270);
const top = Math.min(y, (typeof window !== 'undefined' ? window.innerHeight : 800) - 220);
return (
e.preventDefault()}
onMouseDown={e => e.stopPropagation()}
onPointerDown={e => e.stopPropagation()}>
Connection routing
Style
{styles.map(s => {
const active = (route.style || 'curved') === s.id;
return (
onChange({ style: s.id })}>{s.label}
);
})}
Right-drag the line's mid-handle to bend it freely.
{route.bend && (
↺ Clear bend
)}
);
}
// ── Linked-nodes popover (chain icon click) ───────────────────────────────
function LinkedNodesPopover({ x, y, node, siblings, onClose, onJump, onUnlink, readOnly }) {
const menuRef = useRef(null);
useEffect(() => {
function onDocDown(e) { if (menuRef.current && !menuRef.current.contains(e.target)) onClose(); }
function onKey(e) { if (e.key === 'Escape') onClose(); }
document.addEventListener('mousedown', onDocDown, true);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDocDown, true);
document.removeEventListener('keydown', onKey);
};
}, [onClose]);
return (
e.preventDefault()}
onMouseDown={e => e.stopPropagation()}
onPointerDown={e => e.stopPropagation()}>
🔗 Linked nodes
×
{siblings.length === 0 ? (
No other linked nodes.
) : (
{siblings.map(({ chartId, chartTitle, node: ln }) => (
onJump(chartId, ln.id)}
style={{
textAlign: 'left', background: 'var(--bg)', border: '1px solid var(--border)',
borderRadius: 7, padding: '6px 8px', cursor: 'pointer',
color: 'var(--text)', fontFamily: 'inherit', fontSize: 11,
}}
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--accent)'}
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border)'}
>
{ln.label || 'Untitled'}
{chartTitle}
))}
)}
{!readOnly && siblings.length > 0 && (
Unlink this node
)}
);
}
// ── Bulk action toolbar (visible when ≥2 nodes are multi-selected) ────────
function BulkActionBar({ count, nodeTypes, statuses, onClear, onDelete, onCopy, onPaste, onUpdate, onAddTag, onGroup }) {
const [openMenu, setOpenMenu] = useState(null);
const [tagDraft, setTagDraft] = useState('');
const wrapRef = useRef(null);
useEffect(() => {
function onDocDown(e) { if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpenMenu(null); }
document.addEventListener('mousedown', onDocDown, true);
return () => document.removeEventListener('mousedown', onDocDown, true);
}, []);
function btnStyle(extra) {
return Object.assign({
background: 'transparent', border: '1px solid var(--border)',
borderRadius: 8, padding: '5px 10px', fontSize: 11, fontWeight: 600,
cursor: 'pointer', color: 'var(--text)', fontFamily: 'inherit',
}, extra || {});
}
function popupBox() {
return {
position: 'absolute', top: 'calc(100% + 4px)', left: 0, minWidth: 160,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 8, padding: 4, boxShadow: '0 8px 24px rgba(0,0,0,0.28)',
display: 'flex', flexDirection: 'column', gap: 2, zIndex: 5,
};
}
function rowStyle() {
return {
display: 'flex', alignItems: 'center', textAlign: 'left',
background: 'transparent', border: 'none', borderRadius: 5,
padding: '5px 8px', fontSize: 11, color: 'var(--text)',
cursor: 'pointer', fontFamily: 'inherit',
};
}
return (
{count} selected
⧉ Copy
⎘ Paste
↳ Group sub-chart
setOpenMenu(m => m === 'type' ? null : 'type')} style={btnStyle()}>● Type ▾
{openMenu === 'type' && (
{nodeTypes.map(t => (
{ onUpdate({ typeId: t.id }); setOpenMenu(null); }} style={rowStyle()}>
{t.name}
))}
)}
setOpenMenu(m => m === 'status' ? null : 'status')} style={btnStyle()}>◐ Status ▾
{openMenu === 'status' && (
{statuses.map(s => (
{ onUpdate({ status: s.id }); setOpenMenu(null); }} style={rowStyle()}>
{s.label}
))}
)}
setOpenMenu(m => m === 'tag' ? null : 'tag')} style={btnStyle()}># Tag ▾
{openMenu === 'tag' && (
setTagDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
if (tagDraft.trim()) onAddTag(tagDraft.trim());
setTagDraft(''); setOpenMenu(null);
}
if (e.key === 'Escape') setOpenMenu(null);
}}
placeholder="tag, Enter to add"
style={{ ...fcInputStyle, padding: '4px 8px', fontSize: 11 }}
/>
)}
✕ Delete
↺
);
}
// ── Canvas Context Menu (right-click empty space) ──────────────────────────
function CanvasContextMenu({ x, y, onClose, onAddNode, onFit, onArrange, readOnly }) {
const menuRef = useRef(null);
useEffect(() => {
function onDocDown(e) { if (menuRef.current && !menuRef.current.contains(e.target)) onClose(); }
function onKey(e) { if (e.key === 'Escape') onClose(); }
document.addEventListener('mousedown', onDocDown, true);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDocDown, true);
document.removeEventListener('keydown', onKey);
};
}, [onClose]);
return (
e.stopPropagation()}
onPointerDown={e => e.stopPropagation()}
onContextMenu={e => e.preventDefault()}>
{!readOnly && }
{onArrange && }
);
}
// ── Breadcrumb ─────────────────────────────────────────────────────────────
function Breadcrumb({ navStack, currentChart, onNavigate }) {
if (!navStack.length) return null;
const crumbs = [...navStack, { chartId: currentChart?.id, label: currentChart?.title || '…' }];
return (
{crumbs.map((crumb, i) => (
{i < crumbs.length - 1
? onNavigate(i)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--accent)', fontSize: 11, fontWeight: 600, padding: '0 2px' }}>{crumb.label}
: {crumb.label} }
{i < crumbs.length - 1 && › }
))}
);
}
// ── Minimap ────────────────────────────────────────────────────────────────
function Minimap({ nodes, edges, pan, scale, containerW, containerH, onNavigate }) {
const W = 152, H = 96;
if (!nodes.length) return null;
const xs = nodes.map(n => n.x), ys = nodes.map(n => n.y);
const minX = Math.min(...xs) - 60, maxX = Math.max(...xs) + NODE_W + 60;
const minY = Math.min(...ys) - 60, maxY = Math.max(...ys) + NODE_H_EST + 60;
const bw = Math.max(maxX - minX, 1), bh = Math.max(maxY - minY, 1);
const mmScale = Math.min(W / bw, H / bh, 0.35);
function toMM(wx, wy) {
return [(wx - minX) * mmScale + (W - bw * mmScale) / 2, (wy - minY) * mmScale + (H - bh * mmScale) / 2];
}
const vpX = -pan.x / scale, vpY = -pan.y / scale;
const vpW = containerW / scale, vpH = containerH / scale;
const [vpMX, vpMY] = toMM(vpX, vpY);
function handleClick(e) {
const rect = e.currentTarget.getBoundingClientRect();
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
// mx = (wx - minX)*mmScale + offset → wx = (mx - offset)/mmScale + minX
const offX = (W - bw * mmScale) / 2, offY = (H - bh * mmScale) / 2;
const wx = (mx - offX) / mmScale + minX;
const wy = (my - offY) / mmScale + minY;
onNavigate && onNavigate(wx, wy);
}
return (
{edges.map(edge => {
const from = nodes.find(n => n.id === edge.from);
const to = nodes.find(n => n.id === edge.to);
if (!from || !to) return null;
const [x1, y1] = toMM(from.x + NODE_W / 2, from.y + NODE_H_EST / 2);
const [x2, y2] = toMM(to.x + NODE_W / 2, to.y + NODE_H_EST / 2);
return ;
})}
{nodes.map(n => {
const [mx, my] = toMM(n.x, n.y);
const nw = Math.max(NODE_W * mmScale, 5), nh = Math.max(NODE_H_EST * mmScale, 4);
return ;
})}
);
}
// ── FcCanvas ───────────────────────────────────────────────────────────────
function FcCanvas({ chart, project, allCharts, nodeTypes, statuses, selectedNodeId,
selectedNodeIds, onSelectedNodeIdsChange,
connectSource, connectMode, navStack,
scale, pan, roundedNodes, collapsedSet, hidingAncestorSet, editingNodeId, editingField,
fitTrigger, onSelectNode, onConnectNode, onUpdateNodePos, onBulkMove,
onBulkDelete, onBulkCopy, onBulkPaste, onBulkUpdate, onBulkAddTag, onBulkGroupIntoSubChart,
onOpenSubChart, onScaleChange, onPanChange,
onNavigate, onAddChild, onDeleteNode, onDeleteEdge, onUpdateEdge,
onToggleCollapse, onToggleHideAncestors,
onCreateSubChart, onAddNodeAt, onEditStart, onEditChange, onEditEnd, onArrange, onToggleConnect,
onOpenExpanded, onDuplicateNode, onImageDrop, onSplitToSubChart, onPromoteToRoot,
onAttachChartAsSubChart, onDetachSubChart,
onLinkNodes, onUnlinkNode, onJumpToLinkedNode,
readOnly, remoteSelections,
layoutAlgorithm, onLayoutAlgorithmChange }) {
const containerRef = useRef(null);
// ── Drag/marquee state ────────────────────────────────────────────────
// mode values:
// 'pan' — left/middle drag on empty canvas (camera pan)
// 'node' — left drag on a node (move single or multi-selection)
// 'marquee' — right drag on empty canvas (rubber-band node selection)
// 'bend' — right drag on an edge midpoint (warp the curve)
const dragStateRef = useRef({ mode: null, nodeId: null, startX: 0, startY: 0, moved: false, pointerId: null });
const [, forceRender] = useState(0);
const [popup, setPopup] = useState(null); // { nodeId, x, y } for node context menu
const [canvasMenu, setCanvasMenu] = useState(null); // { x, y, worldX, worldY }
const [edgeMenu, setEdgeMenu] = useState(null); // { edgeId, x, y } for edge routing popover
const [linkedPopup, setLinkedPopup] = useState(null); // { nodeId, x, y } for chain-icon click
const [selectedEdgeId, setSelectedEdgeId] = useState(null);
const [showSearch, setShowSearch] = useState(false);
const [searchQ, setSearchQ] = useState('');
const [showMinimap, setShowMinimap] = useState(true);
// Marquee rect in WORLD coords. null when not active.
const [marquee, setMarquee] = useState(null); // { x1, y1, x2, y2 }
const searchRef = useRef(null);
const handleFitRef = useRef(null);
// Normalise selectedNodeIds as a Set even if parent passes null.
const selectedSet = useMemo(() => {
if (selectedNodeIds instanceof Set) return selectedNodeIds;
if (Array.isArray(selectedNodeIds)) return new Set(selectedNodeIds);
return new Set();
}, [selectedNodeIds]);
const setSelected = useCallback((next) => {
if (onSelectedNodeIdsChange) onSelectedNodeIdsChange(next instanceof Set ? next : new Set(next || []));
}, [onSelectedNodeIdsChange]);
useEffect(() => { if (fitTrigger > 0 && handleFitRef.current) handleFitRef.current(); }, [fitTrigger]);
// Open search with Ctrl+F
useEffect(() => {
function onKey(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault();
setShowSearch(v => { if (!v) setTimeout(() => searchRef.current?.focus(), 50); return !v; });
}
if (e.key === 'Escape') { setShowSearch(false); setSearchQ(''); setSelectedEdgeId(null); }
// Delete selected edge
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedEdgeId && !editingNodeId) {
const tag = e.target?.tagName?.toLowerCase();
if (tag !== 'input' && tag !== 'textarea') {
onDeleteEdge && onDeleteEdge(selectedEdgeId);
setSelectedEdgeId(null);
}
}
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [selectedEdgeId, editingNodeId, onDeleteEdge]);
const { visibleNodes, visibleEdges, collapsedInfo, hidingAncInfo } = useMemo(() => {
if (!chart) return { visibleNodes: [], visibleEdges: [], collapsedInfo: new Map(), hidingAncInfo: new Map() };
const hiddenByCollapse = new Set();
const cInfo = new Map();
for (const nodeId of (collapsedSet || [])) {
const desc = getDescendants(chart, nodeId);
cInfo.set(nodeId, desc.size);
desc.forEach(id => hiddenByCollapse.add(id));
}
const hiddenByAnc = new Set();
const hInfo = new Map();
for (const nodeId of (hidingAncestorSet || [])) {
const anc = getAncestors(chart, nodeId);
hInfo.set(nodeId, anc.size);
anc.forEach(id => hiddenByAnc.add(id));
}
const allHidden = new Set([...hiddenByCollapse, ...hiddenByAnc]);
return {
visibleNodes: chart.nodes.filter(n => !allHidden.has(n.id)),
visibleEdges: chart.edges.filter(e => !allHidden.has(e.from) && !allHidden.has(e.to)),
collapsedInfo: cInfo, hidingAncInfo: hInfo,
};
}, [chart, collapsedSet, hidingAncestorSet]);
// Per-node depth from the chart root (1-indexed). Multiple paths → array
// of distinct levels per node. Recomputed whenever the chart structure
// changes. Passed into NodeCard as `levels` for the badge rendering.
const nodeLevels = useMemo(() => {
if (!chart) return new Map();
return (typeof computeNodeLevels === 'function') ? computeNodeLevels(chart) : new Map();
}, [chart]);
// Number of OTHER nodes that share this node's linkGroupId, anywhere in
// the project. Used by NodeCard to draw the chain icon + count.
const linkedCounts = useMemo(() => {
const counts = new Map();
if (!chart || !project) return counts;
const groupCounts = new Map();
(project.charts || []).forEach(c => (c.nodes || []).forEach(n => {
if (n.linkGroupId) groupCounts.set(n.linkGroupId, (groupCounts.get(n.linkGroupId) || 0) + 1);
}));
chart.nodes.forEach(n => {
if (n.linkGroupId) counts.set(n.id, Math.max(0, (groupCounts.get(n.linkGroupId) || 0) - 1));
});
return counts;
}, [chart, project]);
// Search matches: null = no search, true/false per node id
const searchMatchMap = useMemo(() => {
if (!searchQ.trim()) return null;
const q = searchQ.toLowerCase();
const map = new Map();
visibleNodes.forEach(n => {
const hit = n.label?.toLowerCase().includes(q) || n.notes?.toLowerCase().includes(q) || (n.tags||[]).some(t => t.toLowerCase().includes(q));
map.set(n.id, hit);
});
return map;
}, [searchQ, visibleNodes]);
function handleFit() {
if (!visibleNodes.length || !containerRef.current) return;
const cw = containerRef.current.offsetWidth, ch = containerRef.current.offsetHeight;
const pad = 100;
const xs = visibleNodes.map(n => n.x), ys = visibleNodes.map(n => n.y);
const minX = Math.min(...xs), maxX = Math.max(...xs) + NODE_W;
const minY = Math.min(...ys), maxY = Math.max(...ys) + NODE_H_EST;
const bw = Math.max(maxX - minX, 1), bh = Math.max(maxY - minY, 1);
const newScale = Math.min((cw - pad * 2) / bw, (ch - pad * 2) / bh, 1.5);
onScaleChange(newScale);
onPanChange({ x: cw / 2 - (minX + bw / 2) * newScale, y: ch / 2 - (minY + bh / 2) * newScale });
}
handleFitRef.current = handleFit;
function handleWheel(e) {
e.preventDefault();
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
const factor = e.deltaY > 0 ? 0.9 : 1.1;
const prevScale = scale;
const nextScale = Math.max(0.05, Math.min(3, prevScale * factor));
onScaleChange(nextScale);
onPanChange(p => ({ x: mx - (mx - p.x) * (nextScale / prevScale), y: my - (my - p.y) * (nextScale / prevScale) }));
}
// Convert a clientX/Y pair to world coords (cancels pan + zoom).
function clientToWorld(clientX, clientY) {
if (!containerRef.current) return { x: 0, y: 0 };
const rect = containerRef.current.getBoundingClientRect();
return {
x: (clientX - rect.left - pan.x) / scale,
y: (clientY - rect.top - pan.y) / scale,
};
}
// ── Pointer capture approach — no window listeners needed ──────────────
function handleCanvasPointerDown(e) {
const t = e.target;
// Bail on interactive UI / nodes — they handle their own events.
// Note: `svg` is intentionally NOT in this list. Empty SVG areas should
// pass clicks through to the canvas so right-click marquee / canvas
// context menu work near nodes. Edge groups stopPropagation in their
// own onPointerDown so canvas doesn't double-react to edge clicks.
if (t && t.closest('[data-node-id], button, input, textarea, select, a, [data-fc-no-pan], [data-fc-edge], [data-fc-edge-menu]')) return;
// Right button on empty canvas → start a candidate marquee. Distinguish
// from a quick right-click (which should open the canvas context menu) by
// waiting for movement past a 4px threshold. The browser's contextmenu
// event fires on pointerup, so we use a ref to swallow it when the user
// actually dragged.
if (e.button === 2) {
const world = clientToWorld(e.clientX, e.clientY);
dragStateRef.current = {
mode: 'marquee-pending', nodeId: null,
startX: e.clientX, startY: e.clientY,
originX: e.clientX, originY: e.clientY,
worldStart: world,
moved: false, pointerId: e.pointerId, captured: false,
};
setPopup(null); setEdgeMenu(null); setLinkedPopup(null);
forceRender(n => n + 1);
return;
}
if (e.button !== 0 && e.button !== 1) return;
setSelectedEdgeId(null);
dragStateRef.current = {
mode: 'pan', nodeId: null,
startX: e.clientX, startY: e.clientY,
originX: e.clientX, originY: e.clientY,
moved: false, pointerId: e.pointerId, captured: false,
};
setPopup(null); setEdgeMenu(null); setLinkedPopup(null);
// Plain (non-shift/ctrl/cmd) click on empty canvas clears all selection.
if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
if (selectedSet.size > 0) setSelected(new Set());
if (selectedNodeId) onSelectNode('');
}
forceRender(n => n + 1);
}
function handleCanvasPointerMove(e) {
const st = dragStateRef.current;
if (!st.mode) return;
// Marquee-pending → marquee once we cross the threshold.
if (st.mode === 'marquee-pending') {
const totalDx = e.clientX - st.originX, totalDy = e.clientY - st.originY;
if (Math.hypot(totalDx, totalDy) < 4) return;
// Promote to active marquee. Capture so we keep getting move events
// even if the cursor leaves the container.
try { containerRef.current?.setPointerCapture(st.pointerId); } catch {}
st.captured = true;
st.mode = 'marquee';
forceRender(n => n + 1);
}
if (st.mode === 'marquee') {
const world = clientToWorld(e.clientX, e.clientY);
setMarquee({ x1: st.worldStart.x, y1: st.worldStart.y, x2: world.x, y2: world.y });
return;
}
if (st.mode === 'edge-bend') {
// Drag-bend an edge: store an offset from the natural midpoint so the
// bezier follows the cursor. We push every mousemove through onUpdateEdge
// for live feedback; skipHistory keeps undo clean.
const world = clientToWorld(e.clientX, e.clientY);
const dx = world.x - st.midNatX;
const dy = world.y - st.midNatY;
if (onUpdateEdge) {
onUpdateEdge(st.edgeId, { route: { ...(st.baseRoute || {}), bend: { dx, dy } } });
}
st.moved = true;
return;
}
if (st.mode === 'pan') {
const dx = e.clientX - st.startX, dy = e.clientY - st.startY;
st.startX = e.clientX; st.startY = e.clientY;
if (Math.sqrt(dx * dx + dy * dy) > 0.5) st.moved = true;
if (st.moved && !st.captured) {
try { containerRef.current?.setPointerCapture(st.pointerId); } catch {}
st.captured = true;
try { window.getSelection()?.removeAllRanges(); } catch {}
}
onPanChange(prev => ({ x: prev.x + dx, y: prev.y + dy }));
} else if (st.mode === 'node') {
if (st.originX === undefined) { st.originX = st.startX; st.originY = st.startY; }
const totalDx = e.clientX - st.originX;
const totalDy = e.clientY - st.originY;
if (!st.moved && Math.hypot(totalDx, totalDy) < 4) return; // threshold
if (!st.captured) {
try { containerRef.current?.setPointerCapture(st.pointerId); } catch {}
st.captured = true;
try { window.getSelection()?.removeAllRanges(); } catch {}
}
const dx = (e.clientX - st.startX) / scale, dy = (e.clientY - st.startY) / scale;
st.startX = e.clientX; st.startY = e.clientY;
st.moved = true;
// Move the entire multi-selection if the dragged node is part of it.
if (st.groupDrag && onBulkMove) {
onBulkMove(dx, dy);
} else {
onUpdateNodePos(st.nodeId, dx, dy);
}
}
}
function handleCanvasPointerUp(e) {
const st = dragStateRef.current;
try { containerRef.current?.releasePointerCapture(e.pointerId); } catch {}
if (st.mode === 'marquee') {
// Finalise the marquee selection. Any visible node whose centre lies
// inside the world-space rect joins selectedNodeIds. Shift held →
// append to existing selection; otherwise replace.
if (marquee && setSelected) {
const x1 = Math.min(marquee.x1, marquee.x2);
const y1 = Math.min(marquee.y1, marquee.y2);
const x2 = Math.max(marquee.x1, marquee.x2);
const y2 = Math.max(marquee.y1, marquee.y2);
const next = e.shiftKey ? new Set(selectedSet) : new Set();
visibleNodes.forEach(n => {
const cx = n.x + NODE_W / 2, cy = n.y + NODE_H_EST / 2;
if (cx >= x1 && cx <= x2 && cy >= y1 && cy <= y2) next.add(n.id);
});
setSelected(next);
if (next.size === 1) onSelectNode([...next][0]);
}
setMarquee(null);
}
if (st.mode === 'marquee-pending') {
// No movement → treat as a normal right-click → open canvas context menu.
if (containerRef.current && !readOnly && !connectMode) {
const rect = containerRef.current.getBoundingClientRect();
const worldX = (st.originX - rect.left - pan.x) / scale;
const worldY = (st.originY - rect.top - pan.y) / scale;
setCanvasMenu({
x: st.originX - rect.left,
y: st.originY - rect.top,
worldX: worldX - NODE_W / 2,
worldY: worldY - NODE_H_EST / 2,
});
}
}
dragStateRef.current = { mode: null, nodeId: null, startX: 0, startY: 0, moved: st.moved, pointerId: null };
forceRender(n => n + 1);
}
// Suppress the native contextmenu event everywhere — we open menus
// ourselves on pointerup so we can disambiguate quick-click from drag.
function handleContextMenu(e) {
e.preventDefault();
}
function handleCanvasDragOver(e) {
if (e.dataTransfer.types.includes('Files')) e.preventDefault();
}
function handleCanvasDrop(e) {
e.preventDefault();
const files = [...(e.dataTransfer.files || [])].filter(f => f.type.startsWith('image/'));
if (!files.length) return;
// Find nearest node to drop point
const rect = containerRef.current.getBoundingClientRect();
const wx = (e.clientX - rect.left - pan.x) / scale;
const wy = (e.clientY - rect.top - pan.y) / scale;
let nearest = null, nearDist = 9999;
visibleNodes.forEach(n => {
const cx = n.x + NODE_W / 2, cy = n.y + NODE_H_EST / 2;
const d = Math.hypot(wx - cx, wy - cy);
if (d < nearDist) { nearDist = d; nearest = n; }
});
if (nearest && nearDist < NODE_W && onImageDrop) {
onImageDrop(nearest.id, files);
} else {
// No close node — create one then add image
onAddNodeAt && onAddNodeAt(wx - NODE_W / 2, wy - NODE_H_EST / 2);
}
}
function openPopup(nodeId, nodeX, nodeY) {
if (!containerRef.current) return;
const cw = containerRef.current.offsetWidth, ch = containerRef.current.offsetHeight;
const estW = 232;
let popX = pan.x + (nodeX + NODE_W + 10) * scale;
let popY = pan.y + nodeY * scale;
if (popX + estW > cw) popX = pan.x + nodeX * scale - estW - 10;
setPopup({ nodeId, x: Math.max(4, Math.min(popX, cw - estW - 4)), y: Math.max(4, Math.min(popY, ch - 300)) });
}
function handleMinimapNavigate(wx, wy) {
if (!containerRef.current) return;
const cw = containerRef.current.offsetWidth, ch = containerRef.current.offsetHeight;
onPanChange({ x: cw / 2 - wx * scale, y: ch / 2 - wy * scale });
}
// Compute bezier endpoints/handles/midpoint for an edge. Honors:
// - edge.route.fromSide / toSide ('top'|'right'|'bottom'|'left'|'auto')
// to override the auto-picked anchor side.
// - edge.route.bend = { dx, dy } in world units, dragged offset applied to
// the bezier's t=0.5 midpoint so the user can warp the curve.
// - edge.route.style 'straight' | 'orthogonal' | 'curved' (default curved)
function edgeMidpoint(from, to, vert, layout, route) {
const useHierarchical = layout === 'improved' || layout === 'hierarchical' || layout === 'compact' || !layout;
// Resolve sides — explicit route values win, otherwise we pick the side
// that points at the other node.
function anchor(node, side) {
const cx = node.x + NODE_W / 2, cy = node.y + NODE_H_EST / 2;
if (side === 'right') return { x: node.x + NODE_W, y: cy, nx: 1, ny: 0 };
if (side === 'left') return { x: node.x, y: cy, nx: -1, ny: 0 };
if (side === 'bottom') return { x: cx, y: node.y + NODE_H_EST, nx: 0, ny: 1 };
return { x: cx, y: node.y, nx: 0, ny: -1 };
}
function autoSides() {
if (useHierarchical) {
return vert ? { fromSide: 'bottom', toSide: 'top' } : { fromSide: 'right', toSide: 'left' };
}
const fcx = from.x + NODE_W / 2, fcy = from.y + NODE_H_EST / 2;
const tcx = to.x + NODE_W / 2, tcy = to.y + NODE_H_EST / 2;
const dx = tcx - fcx, dy = tcy - fcy;
const ax = Math.abs(dx) / (NODE_W / 2), ay = Math.abs(dy) / (NODE_H_EST / 2);
if (ax >= ay) return { fromSide: dx >= 0 ? 'right' : 'left', toSide: dx >= 0 ? 'left' : 'right' };
return { fromSide: dy >= 0 ? 'bottom' : 'top', toSide: dy >= 0 ? 'top' : 'bottom' };
}
const auto = autoSides();
const fromSide = (route && route.fromSide && route.fromSide !== 'auto') ? route.fromSide : auto.fromSide;
const toSide = (route && route.toSide && route.toSide !== 'auto') ? route.toSide : auto.toSide;
const a = anchor(from, fromSide);
const b = anchor(to, toSide);
const sx = a.x, sy = a.y, ex = b.x, ey = b.y;
// Handle length scales with distance, so short edges still curve nicely
// and long ones get a soft swoop.
const dist = Math.hypot(ex - sx, ey - sy);
const handle = Math.min(140, Math.max(40, dist * 0.4));
let c1x = sx + a.nx * handle, c1y = sy + a.ny * handle;
let c2x = ex + b.nx * handle, c2y = ey + b.ny * handle;
// Bend offset — apply equally to both control points so the curve's
// mid-region tracks the dragged offset. This is intentionally not a
// proper bezier-from-mid solve (which is fiddly) — a uniform offset gives
// an intuitive "the line follows where I dragged" feel.
if (route && route.bend && (route.bend.dx || route.bend.dy)) {
c1x += route.bend.dx; c1y += route.bend.dy;
c2x += route.bend.dx; c2y += route.bend.dy;
}
// t=0.5 on cubic bezier
const t = 0.5, mt = 1 - t;
const mx = mt*mt*mt*sx + 3*mt*mt*t*c1x + 3*mt*t*t*c2x + t*t*t*ex;
const my = mt*mt*mt*sy + 3*mt*mt*t*c1y + 3*mt*t*t*c2y + t*t*t*ey;
const style = (route && route.style) || 'curved';
let d;
if (style === 'straight') {
d = `M${sx},${sy} L${ex},${ey}`;
} else if (style === 'orthogonal') {
// Right-angle routing: step out from each endpoint along its outward
// normal, then connect with two axis-aligned segments. Bend offset
// shifts the corner.
const step = Math.max(30, dist * 0.25);
const mx2 = (route && route.bend) ? (sx + ex) / 2 + (route.bend.dx || 0) : (sx + ex) / 2;
const my2 = (route && route.bend) ? (sy + ey) / 2 + (route.bend.dy || 0) : (sy + ey) / 2;
if (a.nx !== 0) {
// Horizontal first
d = `M${sx},${sy} L${mx2},${sy} L${mx2},${ey} L${ex},${ey}`;
} else {
// Vertical first
d = `M${sx},${sy} L${sx},${my2} L${ex},${my2} L${ex},${ey}`;
}
} else {
d = `M${sx},${sy} C${c1x},${c1y} ${c2x},${c2y} ${ex},${ey}`;
}
return { sx, sy, ex, ey, c1x, c1y, c2x, c2y, mx, my, d, fromSide, toSide, style };
}
if (!chart) {
return (
◈
No chart selected
Create a chart from the sidebar.
);
}
const popupNode = popup ? visibleNodes.find(n => n.id === popup.nodeId) : null;
const vert = chart.orientation === 'vertical';
const cw = containerRef.current?.offsetWidth || 800;
const ch = containerRef.current?.offsetHeight || 600;
return (
{/* Connect mode banner */}
{connectMode && (
{connectSource ? 'Click the target node · Esc to cancel' : 'Click the source node · Esc to cancel'}
)}
{/* Search bar */}
{showSearch && (
🔍
setSearchQ(e.target.value)}
placeholder="Search nodes…"
onKeyDown={e => { if (e.key === 'Escape') { setShowSearch(false); setSearchQ(''); } }}
style={{ background: 'none', border: 'none', outline: 'none', fontSize: 12, color: 'var(--text)', fontFamily: 'inherit', width: 160 }}
/>
{searchQ && (
setSearchQ('')} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text2)', fontSize: 14, lineHeight: 1, padding: 0 }}>×
)}
{searchMatchMap && (
{[...searchMatchMap.values()].filter(Boolean).length} found
)}
)}
{/* Hint */}
Click label/notes to edit · Double-click to expand · Right-click node for menu
Right-click canvas to add · Drag node to move · Scroll to zoom
N: child · C: connect · F: fit · ⌘D: dup · ⌘Z: undo · ⌘F: search
{/* Stage */}
{/* Edges SVG */}
{visibleEdges.map(edge => {
const from = visibleNodes.find(n => n.id === edge.from);
const to = visibleNodes.find(n => n.id === edge.to);
if (!from || !to) return null;
const route = edge.route || null;
const m = edgeMidpoint(from, to, vert, layoutAlgorithm, route);
const { sx, sy, ex, ey, mx, my, d } = m;
const isSelEdge = selectedEdgeId === edge.id;
// Right-button on midpoint area: hold + drag → bend the line so
// the section under the pointer follows the cursor. Quick
// right-click → open the routing menu. ONLY attached to the
// visible mid-handle circle (shown when the edge is selected) —
// not the wide invisible hit path — otherwise right-clicks near
// nodes get eaten by the edge band.
function onMidPointerDown(ev) {
if (readOnly) return;
if (ev.button !== 2) return;
ev.stopPropagation(); ev.preventDefault();
// Natural midpoint excluding any current bend, so the dragged
// offset is computed relative to the unbent curve.
const baseRoute = { ...(edge.route || {}) };
const natural = edgeMidpoint(from, to, vert, layoutAlgorithm,
{ ...baseRoute, bend: null });
dragStateRef.current = {
mode: 'edge-bend',
edgeId: edge.id,
pointerId: ev.pointerId,
midNatX: natural.mx, midNatY: natural.my,
baseRoute,
startX: ev.clientX, startY: ev.clientY,
originX: ev.clientX, originY: ev.clientY,
moved: false, captured: false,
};
try { containerRef.current?.setPointerCapture(ev.pointerId); } catch {}
forceRender(n => n + 1);
}
function onMidPointerUp(ev) {
const st = dragStateRef.current;
if (st.mode === 'edge-bend' && st.edgeId === edge.id) {
// If the user didn't actually move, treat it as a quick
// right-click → open the routing menu instead.
if (!st.moved && containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
setEdgeMenu({
edgeId: edge.id,
x: ev.clientX - rect.left,
y: ev.clientY - rect.top,
});
}
dragStateRef.current = { mode: null, edgeId: null, pointerId: null, moved: false };
try { containerRef.current?.releasePointerCapture(ev.pointerId); } catch {}
}
}
// Right-click on the wide hit path opens the edge routing menu
// directly (no drag detection). Bending requires the mid-handle.
// This stops short right-clicks near nodes from being eaten as
// failed-bend attempts.
function onWideContextMenu(ev) {
if (readOnly) return;
ev.preventDefault();
ev.stopPropagation();
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
setEdgeMenu({
edgeId: edge.id,
x: ev.clientX - rect.left,
y: ev.clientY - rect.top,
});
}
return (
{
// Only swallow when the pointer is actually on a line/handle
// (path or circle). Empty SVG areas fall through to the
// canvas div so right-click marquee / context menu work.
const tag = (e.target && e.target.tagName || '').toLowerCase();
if (tag === 'path' || tag === 'circle') e.stopPropagation();
}}
onClick={e => {
e.stopPropagation();
if (isSelEdge) {
onDeleteEdge && onDeleteEdge(edge.id);
setSelectedEdgeId(null);
} else {
setSelectedEdgeId(edge.id);
setPopup(null);
}
}}
onContextMenu={e => { e.preventDefault(); e.stopPropagation(); }}
>
{/* Narrow invisible hit area — only catches clicks directly
on the line. Right-click opens the routing menu; no bend
starts from here (use the mid-handle circle below). */}
{/* Visible edge */}
{/* Mid-handle: visible bend grip when the edge is selected.
Right-drag on it warps the line so the user can route it
by hand. Right-click without drag opens the routing menu. */}
{(isSelEdge || (route && route.bend)) && !readOnly && (
)}
{/* Delete button on selected edge */}
{isSelEdge && !readOnly && (
{ e.stopPropagation(); onDeleteEdge && onDeleteEdge(edge.id); setSelectedEdgeId(null); }}
style={{ cursor: 'pointer' }}>
×
)}
);
})}
{/* Marquee rectangle — in world coords inside the same transformed
stage. Rendered as a translucent accent-coloured box. */}
{marquee && (() => {
const x = Math.min(marquee.x1, marquee.x2);
const y = Math.min(marquee.y1, marquee.y2);
const w = Math.abs(marquee.x2 - marquee.x1);
const h = Math.abs(marquee.y2 - marquee.y1);
return (
);
})()}
{/* Nodes */}
{visibleNodes.map(node => (
1}
isConnectSource={node.id === connectSource}
connectMode={connectMode} roundedNodes={roundedNodes}
isEditing={node.id === editingNodeId} editingField={editingField}
collapsedCount={collapsedInfo.get(node.id) || 0}
hidingAncestorCount={hidingAncInfo.get(node.id) || 0}
searchMatch={searchMatchMap ? (searchMatchMap.get(node.id) ?? false) : null}
remotePresence={remoteSelections ? (remoteSelections[node.id] || null) : null}
levels={nodeLevels.get(node.id) || null}
linkedCount={linkedCounts.get(node.id) || 0}
onEditChange={(field, val) => onEditChange(node.id, field, val)}
onEditEnd={onEditEnd}
onImageDrop={onImageDrop}
onChainClick={() => {
// Open the linked-nodes popover anchored near the chain icon.
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const px = pan.x + (node.x + NODE_W - 4) * scale - rect.left;
const py = pan.y + (node.y - 4) * scale - rect.top;
setLinkedPopup({ nodeId: node.id, x: Math.min(Math.max(8, px), rect.width - 260), y: Math.max(8, py) });
}}
onLabelClick={() => {
if (!connectMode && !readOnly) { onSelectNode(node.id); onEditStart(node.id, 'label'); setPopup(null); setSelectedEdgeId(null); }
else if (connectMode) { onConnectNode(node.id); }
else { onSelectNode(node.id); }
}}
onNotesClick={() => {
if (!connectMode && !readOnly) { onSelectNode(node.id); onEditStart(node.id, 'notes'); setPopup(null); setSelectedEdgeId(null); }
else if (connectMode) { onConnectNode(node.id); }
else { onSelectNode(node.id); }
}}
onOpenExpanded={() => { if (!connectMode) { onSelectNode(node.id); onOpenExpanded(node.id); setPopup(null); } }}
onBodyPointerDown={e => {
if (connectMode || node.id === editingNodeId) return;
if (e.button !== 0) return; // only left button starts drag
const t = e.target;
if (t && (t.closest('input, textarea, select, button, a, [contenteditable="true"]'))) return;
// Shift / Ctrl / Cmd click adds/removes from multi-selection
// without starting a drag. Ctrl/Cmd matches OS-native multi-
// pick behaviour, Shift mirrors marquee-extend semantics.
if (e.shiftKey || e.ctrlKey || e.metaKey) {
e.preventDefault();
const next = new Set(selectedSet);
if (next.has(node.id)) next.delete(node.id); else next.add(node.id);
// Seed with the current primary selection so the first
// additive click promotes it into a group of two, not one.
if (next.size && selectedNodeId && !next.has(selectedNodeId)) {
next.add(selectedNodeId);
}
setSelected(next);
// Promote the just-touched node to primary so the Inspector
// shows it.
if (next.has(node.id)) onSelectNode(node.id);
return;
}
dragStateRef.current = {
mode: 'node', nodeId: node.id,
startX: e.clientX, startY: e.clientY,
originX: e.clientX, originY: e.clientY,
moved: false, pointerId: e.pointerId, captured: false,
// Drag the whole multi-selection when the user grabs a member
// of it. Otherwise just this node.
groupDrag: selectedSet.has(node.id) && selectedSet.size > 1,
};
forceRender(n => n + 1);
}}
onBodyClick={e => {
if (dragStateRef.current.moved) { dragStateRef.current.moved = false; return; }
setSelectedEdgeId(null);
setPopup(null);
if (connectMode) { onConnectNode(node.id); return; }
if (node.id === editingNodeId) return;
onSelectNode(node.id);
// Clicking outside the multi-selection collapses it to just this node.
if (!selectedSet.has(node.id) && selectedSet.size > 0) setSelected(new Set([node.id]));
}}
onBodyContextMenu={e => {
e.preventDefault();
e.stopPropagation();
if (connectMode) return;
onSelectNode(node.id);
setSelectedEdgeId(null);
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const cw = containerRef.current.offsetWidth, ch = containerRef.current.offsetHeight;
const estW = 240, estH = 360;
let px = e.clientX - rect.left;
let py = e.clientY - rect.top;
if (px + estW > cw) px = cw - estW - 6;
if (py + estH > ch) py = ch - estH - 6;
setPopup({ nodeId: node.id, x: Math.max(4, px), y: Math.max(4, py) });
}}
/>
))}
{/* Canvas context menu (right-click empty space) */}
{canvasMenu && (
setCanvasMenu(null)}
onAddNode={() => { onAddNodeAt && onAddNodeAt(canvasMenu.worldX, canvasMenu.worldY); }}
onFit={handleFit}
onArrange={!readOnly ? onArrange : null}
readOnly={readOnly}
/>
)}
{edgeMenu && (
e.id === edgeMenu.edgeId)}
x={edgeMenu.x} y={edgeMenu.y}
onClose={() => setEdgeMenu(null)}
onChange={changes => {
const e = chart.edges.find(x => x.id === edgeMenu.edgeId);
if (!e || !onUpdateEdge) return;
onUpdateEdge(e.id, { route: { ...(e.route || {}), ...changes } });
}}
onClearBend={() => {
const e = chart.edges.find(x => x.id === edgeMenu.edgeId);
if (!e || !onUpdateEdge) return;
onUpdateEdge(e.id, { route: { ...(e.route || {}), bend: null } });
}}
/>
)}
{linkedPopup && (() => {
const n = chart.nodes.find(nn => nn.id === linkedPopup.nodeId);
if (!n || !n.linkGroupId) return null;
const sibs = (typeof findLinkedNodes === 'function')
? findLinkedNodes(project, n.linkGroupId, n.id) : [];
return (
setLinkedPopup(null)}
onJump={(cid, nid) => { setLinkedPopup(null); onJumpToLinkedNode && onJumpToLinkedNode(cid, nid); }}
onUnlink={() => { setLinkedPopup(null); onUnlinkNode && onUnlinkNode(n.id); }}
readOnly={readOnly}
/>
);
})()}
{selectedSet.size > 1 && !readOnly && (
setSelected(new Set())}
onDelete={onBulkDelete}
onCopy={onBulkCopy}
onPaste={onBulkPaste}
onUpdate={onBulkUpdate}
onAddTag={onBulkAddTag}
onGroup={onBulkGroupIntoSubChart}
/>
)}
{/* Context popup */}
{popup && popupNode && (
setPopup(null)}
onAddChild={() => onAddChild(popupNode.id)}
onStartConnect={() => { onSelectNode(popupNode.id); onToggleConnect(popupNode.id); }}
onToggleCollapse={() => onToggleCollapse(popupNode.id)}
onToggleHideAncestors={() => onToggleHideAncestors(popupNode.id)}
onOpenSubChart={onOpenSubChart}
onCreateSubChart={onCreateSubChart}
onDelete={() => onDeleteNode(popupNode.id)}
onOpenExpanded={() => onOpenExpanded(popupNode.id)}
onDuplicate={() => onDuplicateNode(popupNode.id)}
onSplitToSubChart={() => onSplitToSubChart && onSplitToSubChart(popupNode.id)}
onPromoteToRoot={() => onPromoteToRoot && onPromoteToRoot(popupNode.id)}
onAttachChartAsSubChart={(chartId) => onAttachChartAsSubChart && onAttachChartAsSubChart(popupNode.id, chartId)}
onDetachSubChart={() => onDetachSubChart && onDetachSubChart(popupNode.id)}
onLinkNodes={onLinkNodes}
onUnlinkNode={onUnlinkNode}
project={project}
allCharts={allCharts}
currentChartId={chart.id}
readOnly={readOnly}
/>
)}
{/* Minimap */}
{showMinimap && visibleNodes.length > 1 && (
)}
{/* HUD */}
onScaleChange(s => Math.max(0.05, s - 0.15))} title="Zoom out (−)">−
{Math.round(scale * 100)}%
onScaleChange(s => Math.min(3, s + 0.15))} title="Zoom in (+)">+
⊡ Fit
{!readOnly && (
<>
⟳ Arrange
onLayoutAlgorithmChange && onLayoutAlgorithmChange(e.target.value)}
title="Layout algorithm"
style={{
background: 'transparent', border: '1px solid var(--border)', borderRadius: 6,
color: 'var(--text)', fontSize: 10, fontFamily: 'inherit', fontWeight: 500,
cursor: 'pointer', padding: '2px 5px', outline: 'none', maxWidth: 100,
}}
>
Tree (Reingold-Tilford)
Hierarchical (Original)
Compact Hierarchical
Radial
Force-Directed
>
)}
{!readOnly && (
onToggleConnect()}>
{connectMode ? '✕ Cancel' : '⟶ Connect'}
)}
{ setShowSearch(v => { if (!v) setTimeout(() => searchRef.current?.focus(), 50); return !v; }); }}
title="Search nodes (Ctrl+F)"
>⌕
setShowMinimap(v => !v)}
title="Toggle minimap"
>⊞
);
}
// ── ChartSidebar ───────────────────────────────────────────────────────────
function ChartSidebar({ charts, selectedChartId, onSelect, onCreate, onDelete, onClone, onReorder, readOnly,
remoteUsers, myUser, mySelectedChartId }) {
const [hoverId, setHoverId] = useState(null);
const [dragId, setDragId] = useState(null);
const [dragOverId, setDragOverId] = useState(null);
const [chartMenu, setChartMenu] = useState(null); // { chartId, x, y }
const canDelete = charts.length > 1;
// Group remote users by the chart they're currently viewing. We include
// the local user in their currently-selected chart so the user can see
// themselves too. Each entry: chartId → [{ id, name, color }]
const usersByChart = useMemo(() => {
const map = {};
Object.entries(remoteUsers || {}).forEach(([id, u]) => {
if (!u || !u.chartId) return;
(map[u.chartId] = map[u.chartId] || []).push({ id, name: u.name, color: u.color });
});
if (myUser && mySelectedChartId) {
(map[mySelectedChartId] = map[mySelectedChartId] || []).push({
id: myUser.id + '_self', name: (myUser.name || 'You') + ' (you)', color: myUser.color, self: true,
});
}
return map;
}, [remoteUsers, myUser, mySelectedChartId]);
return (
Charts
{!readOnly && (
+
)}
{charts.map(chart => {
const total = chart.nodes.length, done = chart.nodes.filter(n => n.status === 'done').length;
const active = chart.id === selectedChartId;
const isHov = hoverId === chart.id;
const isDrag = dragId === chart.id;
const isOver = dragOverId === chart.id && dragOverId !== dragId;
const users = usersByChart[chart.id] || [];
return (
1}
onDragStart={e => { e.dataTransfer.effectAllowed = 'move'; setDragId(chart.id); }}
onDragEnd={() => { setDragId(null); setDragOverId(null); }}
onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDragOverId(chart.id); }}
onDragLeave={() => setDragOverId(null)}
onDrop={e => {
e.preventDefault();
if (dragId && dragId !== chart.id && onReorder) onReorder(dragId, chart.id);
setDragId(null); setDragOverId(null);
}}
onContextMenu={e => {
if (readOnly) return;
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
setChartMenu({ chartId: chart.id, x: e.clientX, y: e.clientY });
}}
onMouseEnter={() => setHoverId(chart.id)}
onMouseLeave={() => setHoverId(null)}
style={{
position: 'relative',
opacity: isDrag ? 0.4 : 1,
transition: 'opacity 0.15s',
borderTop: isOver ? '2px solid var(--accent)' : '2px solid transparent',
}}
>
{/* drag handle — visible on hover */}
{!readOnly && charts.length > 1 && (isHov || isDrag) && (
⠿
)}
onSelect(chart.id)} style={{
textAlign: 'left', background: active ? 'var(--accent-muted)' : isHov ? 'var(--bg2)' : 'transparent',
border: `1px solid ${active ? 'var(--accent)' : 'transparent'}`,
borderRadius: 10, padding: '9px 10px', cursor: 'pointer', color: 'var(--text)',
display: 'flex', flexDirection: 'column', gap: 6, transition: 'all 0.1s', width: '100%',
paddingLeft: (!readOnly && charts.length > 1) ? 18 : 10,
paddingRight: isHov && !readOnly && canDelete ? 30 : 10,
}}>
{chart.title}
{/* Per-chart presence stack — shows which teammates (and you,
if active) are currently viewing this chart. */}
{users.length > 0 && (
)}
{total > 0 ? (
<>
{done}/{total}
>
) :
empty }
{isHov && !readOnly && canDelete && (
{ e.stopPropagation(); onDelete(chart.id); }}
title="Delete chart"
style={{
position: 'absolute', top: '50%', right: 6, transform: 'translateY(-50%)',
background: '#f87171', border: 'none', borderRadius: 6, width: 20, height: 20,
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', color: '#fff', fontSize: 11, fontWeight: 700, zIndex: 2,
}}
>×
)}
);
})}
{charts.length === 0 && (
No charts yet. Click + to create one.
)}
{/* Right-click context menu for charts — fixed-position relative to
the viewport so it doesn't get clipped by the sidebar's overflow. */}
{chartMenu && (
setChartMenu(null)}
onClone={() => onClone && onClone(chartMenu.chartId)}
onDelete={canDelete ? () => onDelete(chartMenu.chartId) : null}
/>
)}
);
}
// Small presence-pill stack tuned for the sidebar (16px circles, capped at 3).
function SidebarPresence({ users }) {
const list = (users || []).slice(0, 3);
if (!list.length) 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}
)}
);
}
// Right-click menu for chart rows: clone, delete, etc.
function ChartSidebarMenu({ x, y, onClose, onClone, onDelete }) {
const menuRef = useRef(null);
useEffect(() => {
function onDocDown(e) { if (menuRef.current && !menuRef.current.contains(e.target)) onClose(); }
function onKey(e) { if (e.key === 'Escape') onClose(); }
document.addEventListener('mousedown', onDocDown, true);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDocDown, true);
document.removeEventListener('keydown', onKey);
};
}, [onClose]);
// Clamp to viewport so the menu doesn't overflow the right edge.
const left = Math.min(x, (typeof window !== 'undefined' ? window.innerWidth : 1200) - 200);
const top = Math.min(y, (typeof window !== 'undefined' ? window.innerHeight : 800) - 120);
return (
e.preventDefault()}>
{onDelete && }
{onDelete && }
);
}
// ── Export ─────────────────────────────────────────────────────────────────
Object.assign(window, {
NodeCard, NodeContextMenu, Breadcrumb, Minimap, FcCanvas, ChartSidebar,
SidebarPresence, ChartSidebarMenu, EdgeRouteMenu, LinkedNodesPopover, BulkActionBar,
MenuItem, MenuDivider,
FcBtn, FcInput, FcTextarea, FcSelect, FcLabel, HudBtn, HudDiv,
ISection, IField,
fcBtnStyle, fcInputStyle, fcSelectStyle,
NODE_W, NODE_H_EST,
});