// fc-inspector.jsx — Inspector panel + SettingsModal
const { useState, useRef } = React;
// ── Inspector ──────────────────────────────────────────────────────────────
function Inspector({ node, chart, project, allCharts, nodeTypes, statuses, onUpdateNode, onUpdateChart,
onAddChild, onDeleteNode, onAddNodeType, onDeleteNodeType,
onAddStatus, onDeleteStatus,
onOpenSubChart, onCreateSubChart, onOpenExpanded, onAddImage,
onLinkNodes, onUnlinkNode, onJumpToLinkedNode, readOnly }) {
const [showTypeForm, setShowTypeForm] = useState(false);
const [newType, setNewType] = useState({ name: '', color: '#60a5fa' });
const [showStatusForm, setShowStatusForm] = useState(false);
const [newStatus, setNewStatus] = useState({ label: '', color: '#60a5fa' });
const [assigneeInput, setAssigneeInput] = useState('');
const [tagInput, setTagInput] = useState('');
const [showLinkPicker, setShowLinkPicker] = useState(false);
const imgRef = useRef(null);
function upNode(changes) { if (node) onUpdateNode(node.id, changes); }
function addAssignee(e) {
if (e.key !== 'Enter') return;
const val = assigneeInput.trim();
if (!val || !node) return;
const cur = node.assignees || [];
if (!cur.includes(val)) onUpdateNode(node.id, { assignees: [...cur, val] });
setAssigneeInput('');
}
function addTag(e) {
if (e.key !== 'Enter' && e.key !== ',') return;
e.preventDefault();
const val = tagInput.trim().replace(/^#/, '').toLowerCase();
if (!val || !node) return;
const cur = node.tags || [];
if (!cur.includes(val)) onUpdateNode(node.id, { tags: [...cur, val] });
setTagInput('');
}
function removeTag(tag) {
if (!node) return;
onUpdateNode(node.id, { tags: (node.tags || []).filter(t => t !== tag) });
}
function submitNewType() {
if (!newType.name.trim()) return;
onAddNodeType({ id: uid('type'), name: newType.name.trim(), color: newType.color });
setNewType({ name: '', color: '#60a5fa' });
setShowTypeForm(false);
}
function submitNewStatus() {
if (!newStatus.label.trim()) return;
onAddStatus({ id: uid('status'), label: newStatus.label.trim(), color: newStatus.color });
setNewStatus({ label: '', color: '#60a5fa' });
setShowStatusForm(false);
}
async function handleImgFiles(fileList) {
const files = [...(fileList || [])];
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);
});
onAddImage && onAddImage(node.id, dataUrl);
} catch (err) {
if (err?.message === 'too_large') alert(`"${file.name}" too large (max 4 MB)`);
}
}
}
const childCount = node && chart ? chart.edges.filter(e => e.from === node.id).length : 0;
const parentCount = node && chart ? chart.edges.filter(e => e.to === node.id).length : 0;
return (
Inspector
{/* ── Chart section ── */}
{chart && (
onUpdateChart('title', e.target.value)} />
onUpdateChart('description', e.target.value)} />
onUpdateChart('orientation', e.target.value)}>
Top → Bottom
Left → Right
)}
{/* ── Node section ── */}
{node ? (
<>
{/* Connection summary */}
{(node.images || []).length}
images
upNode({ label: e.target.value })} />
upNode({ notes: e.target.value })} />
upNode({ typeId: e.target.value })}>
{nodeTypes.map(t => {t.name} )}
upNode({ status: e.target.value })}>
{statuses.map(s => {s.label} )}
{/* Tags */}
{!readOnly && (
setTagInput(e.target.value)}
onKeyDown={addTag}
placeholder="tag name, press Enter"
/>
)}
{(node.tags || []).length > 0 && (
{(node.tags || []).map((tag, i) => {
const tc = (nodeTypes.find(t => t.id === node.typeId) || nodeTypes[0])?.color || '#94a3b8';
return (
#{tag}
{!readOnly && (
removeTag(tag)} style={{ background: 'none', border: 'none', padding: 0, cursor: 'pointer', color: tc, fontSize: 13, lineHeight: 1, opacity: 0.7 }}>×
)}
);
})}
)}
{/* Assignees */}
{!readOnly && (
setAssigneeInput(e.target.value)}
onKeyDown={addAssignee}
placeholder="Name, press Enter"
/>
)}
{(node.assignees || []).length > 0 && (
{(node.assignees || []).map((a, i) => (
{a}
{!readOnly && (
upNode({ assignees: (node.assignees || []).filter(x => x !== a) })}
style={{ background: 'none', border: 'none', padding: 0, cursor: 'pointer', color: 'var(--text2)', fontSize: 13, lineHeight: 1 }}>×
)}
))}
)}
{/* Images quick upload */}
{!readOnly && (
imgRef.current?.click()} style={{ flex: 1, textAlign: 'center' }}>
+ Add image
{(node.images || []).length > 0 && (
{node.images.length} file{node.images.length !== 1 ? 's' : ''}
)}
{ handleImgFiles(e.target.files); e.target.value = ''; }} />
{(node.images || []).length > 0 && (
{node.images.slice(0, 6).map((src, i) => (
))}
)}
)}
{/* Linked Nodes (cross-chart content sync) */}
{/* Sub-chart */}
{node.subChartId ? (
onOpenSubChart(node.subChartId, node.label)} style={{ flex: 1 }}>Open →
{!readOnly && upNode({ subChartId: null })} title="Unlink sub-chart">Unlink }
) : (
!readOnly && onCreateSubChart(node.id, node.label)} style={{ width: '100%', textAlign: 'center' }}>+ Create sub-chart
)}
{/* Actions */}
{!readOnly && (
onAddChild(node.id)} style={{ flex: 1 }}>+ Child
onDeleteNode(node.id)}>Delete
)}
onOpenExpanded && onOpenExpanded(node.id)} style={{ width: '100%', textAlign: 'center' }}>
⛶ Open expanded view
>
) : (
Click a node to inspect it. Right-click the canvas to add a node.
)}
{/* ── Node Types section ── */}
{nodeTypes.map(t => (
onDeleteNodeType(t.id)} />
))}
{!readOnly && (
<>
setShowTypeForm(v => !v)} style={{ width: '100%', textAlign: 'center' }}>
{showTypeForm ? 'Cancel' : '+ Add node type'}
{showTypeForm && (
)}
>
)}
{/* ── Statuses section ── */}
{statuses.map(s => (
1} onDelete={() => onDeleteStatus(s.id)} />
))}
{!readOnly && (
<>
setShowStatusForm(v => !v)} style={{ width: '100%', textAlign: 'center' }}>
{showStatusForm ? 'Cancel' : '+ Add status'}
{showStatusForm && (
)}
>
)}
);
}
// ── TypeRow ────────────────────────────────────────────────────────────────
function TypeRow({ type, readOnly, onDelete }) {
const [hov, setHov] = useState(false);
return (
setHov(true)} onMouseLeave={() => setHov(false)}
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 6px', borderRadius: 6, background: hov ? 'var(--bg2)' : 'transparent', transition: 'background 0.1s' }}
>
{type.name}
{!readOnly && hov && (
×
)}
);
}
// ── StatusRow ──────────────────────────────────────────────────────────────
function StatusRow({ status, readOnly, canDelete, onDelete }) {
const [hov, setHov] = useState(false);
return (
setHov(true)} onMouseLeave={() => setHov(false)}
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 6px', borderRadius: 6, background: hov ? 'var(--bg2)' : 'transparent', transition: 'background 0.1s' }}
>
{status.label}
{!readOnly && hov && canDelete && (
×
)}
);
}
// ── SettingsModal ──────────────────────────────────────────────────────────
function SettingsModal({ project, readOnly, onClose, onUpdateName, onExport, onImport, onCopyShare, onAIExport, layoutSpacing, onLayoutSpacingChange, defaultSpacing, myUser, onUpdateUser }) {
const [nameVal, setNameVal] = useState(project?.name || '');
// Local mirror so the input stays responsive even while we debounce the
// broadcast. The actual broadcast happens onBlur / on color change.
const [myName, setMyName] = useState(myUser?.name || '');
const [myColor, setMyColor] = useState(myUser?.color || '#3b82f6');
const fileRef = useRef(null);
const updateSpacing = (orient, key, value) => {
if (!onLayoutSpacingChange) return;
onLayoutSpacingChange({
...layoutSpacing,
[orient]: { ...layoutSpacing[orient], [key]: value },
});
};
const resetSpacing = () => {
if (onLayoutSpacingChange && defaultSpacing) onLayoutSpacingChange(defaultSpacing);
};
return (
{ if (e.target === e.currentTarget) onClose(); }}
>
Project Settings
✕
{!readOnly && (
Project Name
setNameVal(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { onUpdateName(nameVal); onClose(); } }}
style={{ flex: 1 }}
/>
{ onUpdateName(nameVal); onClose(); }}>Save
)}
{/* ── Your Display Name (per-browser identity, broadcast to teammates) ── */}
{myUser && onUpdateUser && (
Your Display Name
How teammates see you in presence pills. Stored only in this browser — not in the project.
)}
Data & Sharing
Export for AI generates a structured design doc you can paste into ChatGPT or Claude.
{ onAIExport(); onClose(); }} style={{ fontWeight: 700 }}>✦ Export for AI
Copy share link
{!readOnly && Export JSON }
{!readOnly && (
<>
fileRef.current?.click()}>Import JSON
>
)}
{/* Layout spacing controls — applied next time you Auto-arrange */}
{layoutSpacing && (
Layout Spacing
Reset to defaults
Tune gaps used by Tree, Hierarchical, and Compact layouts. Sibling = space between sibling subtrees. Depth = space between parent and child levels.
{[
{ orient: 'vertical', label: 'Vertical (Top → Bottom)' },
{ orient: 'horizontal', label: 'Horizontal (Left → Right)' },
].map(({ orient, label }) => (
{label}
{[
{ key: 'sib', name: 'Sibling gap', min: 0, max: 400, step: 5 },
{ key: 'depth', name: 'Depth gap', min: 40, max: 500, step: 5 },
].map(({ key, name, min, max, step }) => {
const value = layoutSpacing[orient][key];
return (
{name}
updateSpacing(orient, key, Number(e.target.value))}
style={{ flex: 1 }}
/>
updateSpacing(orient, key, Number(e.target.value))}
style={{ width: 56, fontSize: 11, padding: '3px 6px', background: 'var(--surface)', color: 'var(--text)', border: '1px solid var(--border)', borderRadius: 6 }}
/>
px
);
})}
))}
)}
{/* Keyboard shortcuts reference */}
Keyboard Shortcuts
{[
['N', 'Add child node'],
['C', 'Connect mode'],
['F', 'Fit to view'],
['A', 'Auto-arrange'],
['⌘Z / ⌘Y', 'Undo / Redo'],
['⌘D', 'Duplicate node'],
['⌘C / ⌘V', 'Copy / Paste'],
['Enter', 'Expand node'],
['⌘F', 'Search nodes'],
['Del / ⌫', 'Delete node/edge'],
['Arrows', 'Move node (±8px)'],
['Shift+Arrows', 'Move node (±40px)'],
].map(([k, v]) => (
{k}
{v}
))}
);
}
// ── LinkedNodesPanel ──────────────────────────────────────────────────────
// Shows the list of nodes linked to this one (sharing linkGroupId), plus a
// searchable picker for linking to a new node. The CURRENTLY-VIEWED node is
// treated as MASTER: when picking a target, the target's content is
// overwritten with this node's content (matches the user's spec).
function LinkedNodesPanel({ node, project, allCharts, readOnly, showPicker, onShowPicker,
onLink, onUnlink, onJump, currentChartId }) {
const [query, setQuery] = useState('');
// Sibling list (same linkGroupId, excluding self)
const siblings = (typeof findLinkedNodes === 'function' && node?.linkGroupId)
? findLinkedNodes(project, node.linkGroupId, node.id)
: [];
// Build pickable target list: every node in the project EXCEPT this one
// and any already in the same link group. Filtered by query (label/notes).
const pickable = !node ? [] : (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 (!query.trim()) return true;
const q = query.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, 40); // cap to keep the menu manageable
if (!node) return null;
return (
{/* Existing siblings */}
{siblings.length > 0 ? (
{siblings.map(({ chartId, chartTitle, node: ln }) => (
onJump && onJump(chartId, ln.id)}
title="Jump to this linked node"
style={{
display: 'flex', alignItems: 'center', gap: 6, textAlign: 'left',
background: 'var(--bg)', border: '1px solid var(--border)',
borderRadius: 7, padding: '5px 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 && (
{ e.stopPropagation(); onUnlink && onUnlink(ln.id); }}
title="Unlink this node"
style={{ background: 'none', border: 'none', cursor: 'pointer',
color: 'var(--text2)', fontSize: 13, padding: '0 2px', lineHeight: 1 }}>×
)}
))}
) : (
Not linked to other nodes.
)}
{/* Link button / picker */}
{!readOnly && !showPicker && (
onShowPicker(true)} style={{ width: '100%', textAlign: 'center' }}>
🔗 Link to existing node…
)}
{!readOnly && showPicker && (
setQuery(e.target.value)}
placeholder="Search nodes by label, notes, or chart…"
/>
⚠ This node is master — the target's content will be overwritten.
{pickable.length === 0 ? (
No matching nodes.
) : pickable.map(({ chart, node: cand }) => (
{ onLink && onLink(node.id, chart.id, cand.id); onShowPicker(false); setQuery(''); }}
style={{
textAlign: 'left', background: 'transparent', border: '1px solid transparent',
borderRadius: 6, padding: '5px 8px', cursor: 'pointer',
color: 'var(--text)', fontFamily: 'inherit', fontSize: 11,
}}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--accent)'; e.currentTarget.style.background = 'var(--bg)'; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'transparent'; e.currentTarget.style.background = 'transparent'; }}
>
{cand.label || 'Untitled'}
{chart.title}
))}
{ onShowPicker(false); setQuery(''); }} style={{ alignSelf: 'flex-end' }}>Cancel
)}
);
}
// ── Export ─────────────────────────────────────────────────────────────────
Object.assign(window, { Inspector, SettingsModal, LinkedNodesPanel });