// 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)}> )} {/* ── Node section ── */} {node ? ( <> {/* Connection summary */}
{parentCount}
parents
{childCount}
children
{(node.images || []).length}
images
upNode({ label: e.target.value })} /> upNode({ notes: e.target.value })} /> upNode({ typeId: e.target.value })}> {nodeTypes.map(t => )} upNode({ status: e.target.value })}> {statuses.map(s => )} {/* 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 && ( )} ); })}
)}
{/* Assignees */} {!readOnly && ( setAssigneeInput(e.target.value)} onKeyDown={addAssignee} placeholder="Name, press Enter" /> )} {(node.assignees || []).length > 0 && (
{(node.assignees || []).map((a, i) => ( {a} {!readOnly && ( )} ))}
)}
{/* 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 && (
setNewType(p => ({ ...p, name: e.target.value }))} onKeyDown={e => { if (e.key === 'Enter') submitNewType(); if (e.key === 'Escape') setShowTypeForm(false); }} placeholder="Type name" />
setNewType(p => ({ ...p, color: e.target.value }))} style={{ width: 36, height: 28, border: '1px solid var(--border)', borderRadius: 6, cursor: 'pointer', background: 'none', padding: 2 }} /> Create
)} )}
{/* ── Statuses section ── */}
{statuses.map(s => ( 1} onDelete={() => onDeleteStatus(s.id)} /> ))}
{!readOnly && ( <> setShowStatusForm(v => !v)} style={{ width: '100%', textAlign: 'center' }}> {showStatusForm ? 'Cancel' : '+ Add status'} {showStatusForm && (
setNewStatus(p => ({ ...p, label: e.target.value }))} onKeyDown={e => { if (e.key === 'Enter') submitNewStatus(); if (e.key === 'Escape') setShowStatusForm(false); }} placeholder="Status label" />
setNewStatus(p => ({ ...p, color: e.target.value }))} style={{ width: 36, height: 28, border: '1px solid var(--border)', borderRadius: 6, cursor: 'pointer', background: 'none', padding: 2 }} /> Create
)} )}
); } // ── 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.

{ setMyColor(e.target.value); onUpdateUser({ color: e.target.value }); }} style={{ width: 36, height: 30, border: '1px solid var(--border)', borderRadius: 6, cursor: 'pointer', background: 'none', padding: 2 }} title="Pick your colour" /> setMyName(e.target.value)} onBlur={() => { if (myName.trim()) onUpdateUser({ name: myName.trim() }); }} onKeyDown={e => { if (e.key === 'Enter') { e.currentTarget.blur(); } }} placeholder="Your name" style={{ flex: 1 }} />
{(myName || myUser.name || '??').split(/\s+/).map(s => s[0]).join('').slice(0, 2).toUpperCase()}
)}
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

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 }) => ( )} ))}
) : (
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 }) => ( ))}
{ onShowPicker(false); setQuery(''); }} style={{ alignSelf: 'flex-end' }}>Cancel
)}
); } // ── Export ───────────────────────────────────────────────────────────────── Object.assign(window, { Inspector, SettingsModal, LinkedNodesPanel });