// fc-expanded.jsx — ExpandedNodeView + PollSection
const { useState, useRef, useEffect, useCallback } = React;
// ── PollSection ────────────────────────────────────────────────────────────
function PollSection({ node, readOnly, onUpdatePoll }) {
const poll = node.poll || null;
// Linked nodes share the same poll (poll is a synced field), so scope the
// per-browser "already voted" guard to the link group when there is one —
// otherwise the same user could vote once per linked copy and inflate counts.
const voteScope = node.linkGroupId || node.id;
const localKey = `fc_poll_vote_${voteScope}`;
const myVote = (() => { try { return localStorage.getItem(localKey); } catch { return null; } })();
const hasVoted = !!myVote;
const [editing, setEditing] = useState(false);
const [draftQ, setDraftQ] = useState('');
const [draftOpts, setDraftOpts] = useState([]);
function startCreate() {
setDraftQ('');
setDraftOpts([{ id: uid('opt'), text: '' }, { id: uid('opt'), text: '' }]);
setEditing(true);
}
function startEdit() {
if (!poll) return;
setDraftQ(poll.question);
setDraftOpts(poll.options.map(o => ({ ...o })));
setEditing(true);
}
function savePoll() {
const question = draftQ.trim();
const options = draftOpts.filter(o => o.text.trim()).map(o => ({ ...o, text: o.text.trim() }));
if (!question || options.length < 2) return;
onUpdatePoll(node.id, { question, options, votes: poll?.votes || {} });
setEditing(false);
}
function deletePoll() {
if (!confirm('Delete this poll? All votes will be lost.')) return;
onUpdatePoll(node.id, null);
try { localStorage.removeItem(localKey); } catch {}
}
function castVote(optionId) {
if (hasVoted || !poll) return;
const newVotes = { ...(poll.votes || {}), [optionId]: ((poll.votes || {})[optionId] || 0) + 1 };
onUpdatePoll(node.id, { ...poll, votes: newVotes });
try { localStorage.setItem(localKey, optionId); } catch {}
}
function retractVote() {
if (!hasVoted || !poll) return;
const prev = (poll.votes || {})[myVote] || 0;
const newVotes = { ...(poll.votes || {}), [myVote]: Math.max(0, prev - 1) };
onUpdatePoll(node.id, { ...poll, votes: newVotes });
try { localStorage.removeItem(localKey); } catch {}
}
const totalVotes = poll ? Object.values(poll.votes || {}).reduce((s, v) => s + v, 0) : 0;
if (editing) {
return (
{poll ? 'Edit Poll' : 'Create Poll'}
setDraftQ(e.target.value)} placeholder="Poll question…" />
{draftOpts.map((opt, i) => (
{i + 1}.
setDraftOpts(prev => prev.map((o, j) => j === i ? { ...o, text: e.target.value } : o))}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); setDraftOpts(prev => [...prev, { id: uid('opt'), text: '' }]); } }}
placeholder={`Option ${i + 1}…`}
style={{ flex: 1 }}
/>
{draftOpts.length > 2 && (
)}
))}
o.text.trim()).length < 2} onClick={savePoll} style={{ flex: 1 }}>Save Poll
setEditing(false)}>Cancel
);
}
if (!poll) {
if (readOnly) return null;
return ◉ Add poll to this node;
}
return (
◉ {poll.question}
{!readOnly && (
)}
{poll.options.map(opt => {
const votes = (poll.votes || {})[opt.id] || 0;
const pct = totalVotes > 0 ? Math.round((votes / totalVotes) * 100) : 0;
const isMyV = myVote === opt.id;
const canVote = !hasVoted;
return (
canVote && castVote(opt.id)}
style={{
position: 'relative', overflow: 'hidden',
background: isMyV ? 'var(--accent-muted)' : 'var(--bg)',
border: `1.5px solid ${isMyV ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 9, padding: '9px 12px',
cursor: canVote ? 'pointer' : 'default', transition: 'all 0.12s',
}}
onMouseEnter={e => { if (canVote) e.currentTarget.style.borderColor = 'var(--accent)'; }}
onMouseLeave={e => { if (canVote && !isMyV) e.currentTarget.style.borderColor = 'var(--border)'; }}
>
{hasVoted && (
)}
{isMyV && ✓}
{opt.text}
{hasVoted && (
{pct}% ({votes})
)}
);
})}
{totalVotes} vote{totalVotes !== 1 ? 's' : ''}
{hasVoted
?
: Click an option to vote
}
);
}
// ── ImageGrid ──────────────────────────────────────────────────────────────
function ImageGrid({ images, readOnly, nodeId, onAdd, onRemove, onOpenLightbox }) {
const fileRef = useRef(null);
const [dragOver, setDragOver] = useState(false);
async function handleFiles(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);
});
onAdd(nodeId, dataUrl);
} catch (err) {
if (err?.message === 'too_large') alert(`"${file.name}" too large (max 4 MB)`);
}
}
}
function handleDrop(e) {
e.preventDefault(); setDragOver(false);
const files = [...(e.dataTransfer.files || [])].filter(f => f.type.startsWith('image/'));
if (files.length) handleFiles(files);
}
return (
Images ({images.length})
{!readOnly && (
<>
fileRef.current?.click()}>+ Add image
{ handleFiles(e.target.files); e.target.value = ''; }} />
>
)}
{images.length > 0 ? (
{images.map((src, i) => (
onOpenLightbox(i)}>

{!readOnly && (
)}
))}
{/* Drop zone tile */}
{!readOnly && (
{ e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
onClick={() => fileRef.current?.click()}
style={{
paddingBottom: '75%', borderRadius: 8, position: 'relative',
border: `1.5px dashed ${dragOver ? 'var(--accent)' : 'var(--border)'}`,
background: dragOver ? 'var(--accent-muted)' : 'transparent',
cursor: 'pointer', transition: 'all 0.15s',
}}>
+
)}
) : (
{ e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
onClick={() => !readOnly && fileRef.current?.click()}
style={{
border: `1.5px dashed ${dragOver ? 'var(--accent)' : 'var(--border)'}`,
background: dragOver ? 'var(--accent-muted)' : 'transparent',
borderRadius: 10, padding: '28px 16px', textAlign: 'center',
color: 'var(--text2)', fontSize: 12, lineHeight: 1.6,
cursor: readOnly ? 'default' : 'pointer', transition: 'all 0.15s',
}}
>
{readOnly ? 'No images attached' : (dragOver ? 'Drop to upload' : 'Drop images here, paste (Ctrl+V), or click to upload')}
)}
);
}
// ── Lightbox ───────────────────────────────────────────────────────────────
function Lightbox({ images, startIndex, onClose }) {
const [idx, setIdx] = useState(startIndex);
useEffect(() => {
function onKey(e) {
if (e.key === 'Escape') onClose();
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') setIdx(i => Math.min(images.length - 1, i + 1));
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') setIdx(i => Math.max(0, i - 1));
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [images.length, onClose]);
if (!images.length) return null;
return (
e.stopPropagation()}>
{idx + 1} / {images.length}
);
}
// ── PollsModal ─────────────────────────────────────────────────────────────
// Lists every poll across every chart, split into "To Vote" and "Voted" tabs.
// Clicking a poll opens its node in the expanded view.
function PollsModal({ project, onClose, onOpenNode }) {
const [tab, setTab] = useState('todo'); // 'todo' | 'voted'
// Gather every poll-bearing node from every chart
const allPolls = [];
(project?.charts || []).forEach(c => {
(c.nodes || []).forEach(n => {
if (n.poll) allPolls.push({ chart: c, node: n });
});
});
function myVoteFor(node) {
// Mirror PollSection's link-group-aware vote scope.
const scope = node.linkGroupId || node.id;
try { return localStorage.getItem(`fc_poll_vote_${scope}`); } catch { return null; }
}
const todoPolls = allPolls.filter(p => !myVoteFor(p.node));
const votedPolls = allPolls.filter(p => myVoteFor(p.node));
const list = tab === 'todo' ? todoPolls : votedPolls;
return (
{ if (e.target === e.currentTarget) onClose(); }}
>
{/* Header */}
◉ Polls
{allPolls.length} total
{/* Tab strip */}
{[
{ id: 'todo', label: 'To Vote', count: todoPolls.length, color: '#f59e0b' },
{ id: 'voted', label: 'Voted', count: votedPolls.length, color: '#22c55e' },
].map(t => {
const active = tab === t.id;
return (
);
})}
{/* List */}
{list.length === 0 ? (
◉
{tab === 'todo' ? 'No polls waiting' : 'No polls voted yet'}
{tab === 'todo' ? 'You\'ve voted on every poll. Nice.' : 'Cast a vote and it\'ll show up here.'}
) : list.map(({ chart, node }) => {
const poll = node.poll;
const myVote = myVoteFor(node);
const myOpt = myVote ? poll.options.find(o => o.id === myVote) : null;
const totalVotes = Object.values(poll.votes || {}).reduce((s, v) => s + v, 0);
const leading = poll.options.reduce((best, o) => {
const v = (poll.votes || {})[o.id] || 0;
return v > best.v ? { o, v } : best;
}, { o: null, v: -1 });
return (
);
})}
);
}
// ── ExpandedNodeView ───────────────────────────────────────────────────────
function ExpandedNodeView({ node, chart, nodeTypes, statuses, readOnly, onClose,
onUpdateNode, onUpdatePoll, onDeleteNode, onOpenSubChart, onCreateSubChart,
onAddImage, onRemoveImage }) {
if (!node) return null;
const type = nodeTypes.find(t => t.id === node.typeId) || { color: '#94a3b8', name: 'Generic' };
const [lightboxIdx, setLightboxIdx] = useState(null);
const [detailsMode, setDetailsMode] = useState('edit'); // 'edit' | 'preview'
const [tagInput, setTagInput] = useState('');
// ── Local mirrors for fast-typing-friendly text fields ──
// Controlled inputs were reverting on remote sync echos. We keep a local
// copy that only adopts the incoming value when:
// (a) the node id changes, or
// (b) the incoming value differs AND the field isn't currently focused.
// Edits flush to the parent on each keystroke (same as before) so live-sync
// and undo/redo still work — but our display value never goes backwards mid-typing.
const [localLabel, setLocalLabel] = useState(node.label || '');
const [localNotes, setLocalNotes] = useState(node.notes || '');
const [localDetails, setLocalDetails] = useState(node.details || '');
const labelRef = useRef(null);
const notesRef = useRef(null);
const detailsRef = useRef(null);
const lastNodeIdRef = useRef(node.id);
useEffect(() => {
const isNewNode = lastNodeIdRef.current !== node.id;
lastNodeIdRef.current = node.id;
const incomingLabel = node.label || '';
const incomingNotes = node.notes || '';
const incomingDetails = node.details || '';
const active = document.activeElement;
if (isNewNode || (incomingLabel !== localLabel && active !== labelRef.current)) setLocalLabel(incomingLabel);
if (isNewNode || (incomingNotes !== localNotes && active !== notesRef.current)) setLocalNotes(incomingNotes);
if (isNewNode || (incomingDetails !== localDetails && active !== detailsRef.current)) setLocalDetails(incomingDetails);
}, [node.id, node.label, node.notes, node.details]);
function upNode(changes) { onUpdateNode(node.id, changes); }
function addTag(e) {
if (e.key !== 'Enter' && e.key !== ',') return;
e.preventDefault();
const val = tagInput.trim().replace(/^#/, '').toLowerCase();
if (!val) return;
const cur = node.tags || [];
if (!cur.includes(val)) upNode({ tags: [...cur, val] });
setTagInput('');
}
async function handlePaste(e) {
const items = e.clipboardData?.items || [];
for (const item of items) {
if (item.type.startsWith('image/')) {
const blob = item.getAsFile();
if (!blob) continue;
try {
const dataUrl = (typeof readAndCompressImageFile === 'function')
? await readAndCompressImageFile(blob)
: await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error('Read failed'));
reader.readAsDataURL(blob);
});
onAddImage(node.id, dataUrl);
} catch (err) {
if (err?.message === 'too_large') alert('Pasted image too large (max 4 MB)');
}
}
}
}
useEffect(() => {
function onKey(e) { if (e.key === 'Escape' && lightboxIdx === null) onClose(); }
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, lightboxIdx]);
const detailsWords = (localDetails || '').trim().split(/\s+/).filter(Boolean).length;
const detailsChars = (localDetails || '').length;
// Simple markdown-style preview: bold **x**, italic *x*, headings ## x, code `x`
function renderPreview(text) {
if (!text) return No details yet.;
const lines = text.split('\n');
return lines.map((line, i) => {
let el;
if (/^## (.+)/.test(line)) el = {line.replace(/^## /, '')};
else if (/^# (.+)/.test(line)) el = {line.replace(/^# /, '')};
else if (/^- (.+)/.test(line)) el = • {line.replace(/^- /, '')}
;
else if (line.trim() === '') el = ;
else el = {line}
;
return {el};
});
}
return (
{ if (e.target === e.currentTarget) onClose(); }}
onPaste={handlePaste}
>
e.stopPropagation()}
>
{/* Header */}
{type.name}
{(node.tags || []).map((tag, i) => (
#{tag}
))}
Double-click node · Enter to open
✕
{/* Body */}
{/* Left column */}
{/* Title */}
{ setLocalLabel(e.target.value); upNode({ label: e.target.value }); }}
placeholder="Title…"
style={{
background: 'transparent', border: 'none', outline: 'none',
fontSize: 26, fontWeight: 800, color: 'var(--text)', fontFamily: 'inherit',
padding: '2px 0', borderBottom: '1.5px solid transparent', width: '100%',
}}
onFocus={e => { e.currentTarget.style.borderBottomColor = 'var(--border)'; }}
onBlur={e => { e.currentTarget.style.borderBottomColor = 'transparent'; }}
/>
{/* Summary / notes */}
Summary
{/* Details with preview toggle */}
Details
{['edit', 'preview'].map(m => (
))}
{detailsMode === 'edit' ? (
{/* Poll */}
{/* Images */}
{/* Right sidebar */}
{/* Lightbox */}
{lightboxIdx !== null && (
setLightboxIdx(null)}
/>
)}
);
}
// ── Export ─────────────────────────────────────────────────────────────────
Object.assign(window, { ExpandedNodeView, PollSection, PollsModal });