import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactElement } from 'react' import { DialoguePreview } from './narrative' type Utterer = 'npc' | 'player' type Utterance = { id: string; nodeId: string; utterer: Utterer; npcId: string | null; poseKey: string | null; text: string parentUtteranceId: string | null; terminalId: string | null xpos: number; ypos: number; sortOrder: number } type Terminal = { id: string; terminalKey: string; label: string } type Npc = { id: string; name: string; poses: { poseKey: string; url: string }[] } // Vertical layout: input on top, output on the bottom; exit sinks in a row below. const UW = 220, UH_DEFAULT = 72, SINK_W = 150 async function api(url: string, method: string, body?: unknown): Promise { const response = await fetch(url, { method, headers: body ? { 'content-type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined }) if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || `Request failed (${response.status})`) return response.json().catch(() => ({} as T)) } export function UtteranceCanvas({ nodeId, nodeLabel, terminals, onClose, setStatus }: { nodeId: string; nodeLabel: string; terminals: Terminal[]; onClose: () => void; setStatus: (message: string) => void }) { const [utterances, setUtterances] = useState([]) const [npcs, setNpcs] = useState([]) const [view, setView] = useState({ x: 40, y: 40, zoom: 1 }) const [selectedId, setSelectedId] = useState(null) const [wiringFrom, setWiringFrom] = useState<{ id: string } | null>(null) const canvasRef = useRef(null) const drag = useRef<{ id: string; startX: number; startY: number; origX: number; origY: number } | 'pan' | null>(null) const panRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null) // Cards auto-expand to their text, so measure heights to place the bottom output // port (offsetHeight is layout px, unaffected by the canvas's transform: scale). const cardRefs = useRef(new Map()) const [heights, setHeights] = useState>({}) const [revision, setRevision] = useState(0) const reload = useCallback(async () => { try { setUtterances(await api(`/api/admin/story-nodes/${nodeId}/utterances`, 'GET')); setRevision(r => r + 1) } catch (error) { setStatus(String((error as Error).message || error)) } }, [nodeId, setStatus]) useEffect(() => { void reload(); api('/api/admin/npcs', 'GET').then(setNpcs).catch(() => {}) }, [reload]) useLayoutEffect(() => { const next: Record = {} let changed = Object.keys(heights).length !== cardRefs.current.size for (const [id, el] of cardRefs.current) { next[id] = el.offsetHeight; if (heights[id] !== next[id]) changed = true } if (changed) setHeights(next) }, [utterances, heights]) // Undo stack of inverse operations (connection edits, Tab creation). const undoRef = useRef Promise>>([]) const pushUndo = (fn: () => Promise) => { undoRef.current.push(fn); if (undoRef.current.length > 40) undoRef.current.shift() } const doUndo = async () => { const fn = undoRef.current.pop() if (!fn) { setStatus('Nothing to undo'); return } try { await fn(); await reload() } catch (error) { setStatus(String((error as Error).message || error)) } } // Keyboard (ignored while typing in a field): Ctrl/Cmd+Z undoes; Tab adds a child under // the selected utterance (one child = linear, a second makes them player options); // 1/2 set the selected utterance's speaker. const keyActionRef = useRef((_event: KeyboardEvent) => {}) keyActionRef.current = (event: KeyboardEvent) => { const tag = (document.activeElement?.tagName || '').toLowerCase() if (tag === 'input' || tag === 'textarea' || tag === 'select') return if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') { event.preventDefault(); void doUndo(); return } if (!selectedId) return if (event.key === 'Tab') { event.preventDefault() const parent = utterances.find(u => u.id === selectedId) if (!parent) return const siblings = utterances.filter(u => u.parentUtteranceId === parent.id) void (async () => { try { const created = await api(`/api/admin/story-nodes/${nodeId}/utterances`, 'POST', { utterer: 'player', xpos: Math.round(parent.xpos + siblings.length * 240), ypos: Math.round(parent.ypos + 130), text: '' }) await api(`/api/admin/utterances/${created.id}`, 'PATCH', { parentUtteranceId: parent.id }) // 2+ children ⇒ player options; a lone child stays a linear NPC next line. if (siblings.length + 1 >= 2) for (const child of [...siblings, created]) await api(`/api/admin/utterances/${child.id}`, 'PATCH', { utterer: 'player', npcId: null }) else await api(`/api/admin/utterances/${created.id}`, 'PATCH', { utterer: 'npc' }) pushUndo(() => api(`/api/admin/utterances/${created.id}`, 'DELETE')) await reload(); setSelectedId(parent.id) // keep the parent selected to add more options } catch (error) { setStatus(String((error as Error).message || error)) } })() } else if (event.key === '1') void patch(selectedId, { utterer: 'npc' }) else if (event.key === '2') void patch(selectedId, { utterer: 'player', npcId: null }) } useEffect(() => { const handler = (event: KeyboardEvent) => keyActionRef.current(event) window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) }, []) const patch = async (id: string, body: Record, silent = false) => { try { await api(`/api/admin/utterances/${id}`, 'PATCH', body); if (!silent) await reload() } catch (error) { setStatus(String((error as Error).message || error)) } } const add = async (utterer: Utterer) => { const rect = canvasRef.current?.getBoundingClientRect() const cx = ((rect ? rect.width / 2 : 250) - view.x) / view.zoom, cy = ((rect ? rect.height / 2 : 200) - view.y) / view.zoom try { const u = await api(`/api/admin/story-nodes/${nodeId}/utterances`, 'POST', { utterer, xpos: Math.round(cx), ypos: Math.round(cy) }); await reload(); setSelectedId(u.id) } catch (error) { setStatus(String((error as Error).message || error)) } } const onBgPointerDown = (event: React.PointerEvent) => { if (wiringFrom) { setWiringFrom(null); return } panRef.current = { startX: event.clientX, startY: event.clientY, origX: view.x, origY: view.y }; drag.current = 'pan'; setSelectedId(null) } const onCardPointerDown = (event: React.PointerEvent, u: Utterance) => { ;(event.target as HTMLElement).setPointerCapture?.(event.pointerId) drag.current = { id: u.id, startX: event.clientX, startY: event.clientY, origX: u.xpos, origY: u.ypos }; setSelectedId(u.id) } const onPointerMove = (event: React.PointerEvent) => { if (drag.current === 'pan' && panRef.current) { const p = panRef.current; setView(v => ({ ...v, x: p.origX + event.clientX - p.startX, y: p.origY + event.clientY - p.startY })); return } if (drag.current && drag.current !== 'pan') { const d = drag.current; setUtterances(list => list.map(u => u.id === d.id ? { ...u, xpos: d.origX + (event.clientX - d.startX) / view.zoom, ypos: d.origY + (event.clientY - d.startY) / view.zoom } : u)) } } const onPointerUp = async () => { const state = drag.current; drag.current = null; panRef.current = null if (state && state !== 'pan') { const u = utterances.find(x => x.id === state.id); if (u) await patch(u.id, { xpos: Math.round(u.xpos), ypos: Math.round(u.ypos) }, true) } } const onWheel = (event: React.WheelEvent) => { const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return const px = event.clientX - rect.left, py = event.clientY - rect.top, factor = event.deltaY < 0 ? 1.1 : 1 / 1.1 setView(v => { const zoom = Math.min(2, Math.max(0.35, v.zoom * factor)); return { zoom, x: px - (px - v.x) * (zoom / v.zoom), y: py - (py - v.y) * (zoom / v.zoom) } }) } // A card's children are what come after it: dragging its port to another card makes // that card a child. One child ⇒ solid (linear next line); two or more ⇒ dotted // (player options). A card can instead exit the node by wiring to a terminal sink. const link = (id: string, body: Record, undoBody: Record) => { pushUndo(() => api(`/api/admin/utterances/${id}`, 'PATCH', undoBody)); void patch(id, body) } const targetCard = (u: Utterance) => { if (!wiringFrom) { setSelectedId(u.id); return } const source = wiringFrom.id; setWiringFrom(null) if (source === u.id) return link(u.id, { parentUtteranceId: source }, { parentUtteranceId: u.parentUtteranceId }) } const targetSink = (terminalId: string) => { const source = wiringFrom ? utterances.find(x => x.id === wiringFrom.id) : null; setWiringFrom(null) if (source) link(source.id, { terminalId }, { terminalId: source.terminalId }) } const byId = new Map(utterances.map(u => [u.id, u])) const childCount = new Map() for (const u of utterances) if (u.parentUtteranceId) childCount.set(u.parentUtteranceId, (childCount.get(u.parentUtteranceId) || 0) + 1) const npcName = (id: string | null) => npcs.find(n => n.id === id)?.name || 'NPC' const selected = utterances.find(u => u.id === selectedId) || null const cardH = (u: Utterance) => heights[u.id] ?? UH_DEFAULT const topPort = (u: Utterance) => ({ x: u.xpos + UW / 2, y: u.ypos }) const bottomPort = (u: Utterance) => ({ x: u.xpos + UW / 2, y: u.ypos + cardH(u) }) // Exit sinks dock in a row below the utterances so flow reads top-to-bottom. const sinkRowY = (utterances.length ? Math.max(...utterances.map(u => u.ypos + cardH(u))) : 120) + 56 const sinkPos = (i: number) => ({ x: 40 + i * (SINK_W + 24), y: sinkRowY }) const sinkInput = (i: number) => ({ x: sinkPos(i).x + SINK_W / 2, y: sinkRowY }) const curve = (a: { x: number; y: number }, b: { x: number; y: number }) => { const dy = Math.max(30, Math.abs(b.y - a.y) / 2); return `M${a.x},${a.y} C${a.x},${a.y + dy} ${b.x},${b.y - dy} ${b.x},${b.y}` } return
{nodeLabel || 'Dialogue'} · utterances Add: {wiringFrom && Click the next card (2+ ⇒ options) or an exit ⇥ · click empty to cancel} {Math.round(view.zoom * 100)}%
{utterances.flatMap(u => { const wires: ReactElement[] = [] const wire = (key: string, d: string, cls: string, onDelete: () => void) => { wires.push( { event.stopPropagation(); onDelete() }} />) wires.push() } if (u.parentUtteranceId && byId.has(u.parentUtteranceId)) { const parent = byId.get(u.parentUtteranceId)! const cls = (childCount.get(parent.id) || 0) >= 2 ? 'option' : '' wire(u.id + 'p', curve(bottomPort(parent), topPort(u)), cls, () => link(u.id, { parentUtteranceId: null }, { parentUtteranceId: parent.id })) } if (u.terminalId) { const idx = terminals.findIndex(t => t.id === u.terminalId); if (idx >= 0) wire(u.id + 't', curve(bottomPort(u), sinkInput(idx)), 'exit', () => link(u.id, { terminalId: null }, { terminalId: u.terminalId })) } return wires })} {terminals.map((t, i) => { const p = sinkPos(i); return
e.stopPropagation()} onClick={e => { e.stopPropagation(); targetSink(t.id) }}>
⇥ {t.label || t.terminalKey}
})} {utterances.map(u =>
{ if (el) cardRefs.current.set(u.id, el); else cardRefs.current.delete(u.id) }} className={`ucard u-${u.utterer}${u.id === selectedId ? ' selected' : ''}`} style={{ left: u.xpos, top: u.ypos, width: UW }} onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); targetCard(u) }}>
onCardPointerDown(e, u)}> {u.utterer === 'npc' ? npcName(u.npcId) : 'PLAYER'} {u.utterer === 'npc' && u.poseKey && {u.poseKey}}
{u.text || (empty)}
)}
{utterances.length === 0 &&
No utterances yet — add an NPC line or player choice.
}
event.stopPropagation()}>
{selected &&