Make the dialogue/utterance editor vertical, matching the mystery graph

Input on top, output on the bottom, exit sinks in a row below; wires flow
downward. Cards auto-expand, so their heights are measured (offsetHeight) to
place the bottom output port. Seed and Tab-created utterances now stack downward.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 17:59:17 +02:00
co-authored by Claude Opus 4.8
parent aa789bbadb
commit 3b72f38d00
3 changed files with 34 additions and 16 deletions
+1 -1
View File
@@ -280,7 +280,7 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
}
const utteranceId = randomUUID(); created.push(utteranceId)
await client.query('INSERT INTO osint.utterances (id,node_id,utterer,npc_id,pose_key,text,xpos,ypos,sort_order) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)',
[utteranceId, nodeIds.get(node.key), utterance.utterer || 'npc', npcId, utterance.pose || null, utterance.text, 40 + index * 250, 60, index])
[utteranceId, nodeIds.get(node.key), utterance.utterer || 'npc', npcId, utterance.pose || null, utterance.text, 60, 60 + index * 120, index])
}
// Chain via parent: each line follows the previous one (one child = linear).
for (let i = 1; i < created.length; i++)
+4
View File
@@ -544,3 +544,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.gout { position: relative; flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 3px; padding-bottom: 9px; }
.gout-label { font: 9px IBM Plex Mono; color: #b6c6c0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
.gout .gport { position: absolute; bottom: -7px; left: 50%; top: auto; right: auto; transform: translateX(-50%); }
/* Utterance canvas vertical flow: input on top, output on the bottom */
.uinput { top: -6px; bottom: auto; left: 50%; right: auto; transform: translateX(-50%); }
.uport.flow { bottom: -7px; top: auto; left: 50%; right: auto; transform: translateX(-50%); }
+29 -15
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState, type ReactElement } from 'react'
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactElement } from 'react'
type Utterer = 'npc' | 'player'
type Utterance = {
@@ -9,8 +9,8 @@ type Utterance = {
type Terminal = { id: string; terminalKey: string; label: string }
type Npc = { id: string; name: string; poses: { poseKey: string; url: string }[] }
const UW = 220, PORT_Y = 24, SINK_W = 150
const sinkY = (index: number) => 30 + index * 74
// 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<T>(url: string, method: string, body?: unknown): Promise<T> {
const response = await fetch(url, { method, headers: body ? { 'content-type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined })
@@ -29,6 +29,10 @@ export function UtteranceCanvas({ nodeId, nodeLabel, terminals, onClose, setStat
const canvasRef = useRef<HTMLDivElement>(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<string, HTMLDivElement>())
const [heights, setHeights] = useState<Record<string, number>>({})
const reload = useCallback(async () => {
try { setUtterances(await api<Utterance[]>(`/api/admin/story-nodes/${nodeId}/utterances`, 'GET')) }
@@ -36,6 +40,13 @@ export function UtteranceCanvas({ nodeId, nodeLabel, terminals, onClose, setStat
}, [nodeId, setStatus])
useEffect(() => { void reload(); api<Npc[]>('/api/admin/npcs', 'GET').then(setNpcs).catch(() => {}) }, [reload])
useLayoutEffect(() => {
const next: Record<string, number> = {}
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<Array<() => Promise<void>>>([])
const pushUndo = (fn: () => Promise<void>) => { undoRef.current.push(fn); if (undoRef.current.length > 40) undoRef.current.shift() }
@@ -62,7 +73,7 @@ export function UtteranceCanvas({ nodeId, nodeLabel, terminals, onClose, setStat
void (async () => {
try {
const created = await api<Utterance>(`/api/admin/story-nodes/${nodeId}/utterances`, 'POST',
{ utterer: 'player', xpos: Math.round(parent.xpos + 270), ypos: Math.round(parent.ypos + siblings.length * 92), text: '' })
{ 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 })
@@ -133,14 +144,16 @@ export function UtteranceCanvas({ nodeId, nodeLabel, terminals, onClose, setStat
const byId = new Map(utterances.map(u => [u.id, u]))
const childCount = new Map<string, number>()
for (const u of utterances) if (u.parentUtteranceId) childCount.set(u.parentUtteranceId, (childCount.get(u.parentUtteranceId) || 0) + 1)
// Dock the exit sinks to the right of the utterances so flow reads left-to-right.
const sinkX = Math.max(560, ...utterances.map(u => u.xpos + UW + 90))
const npcName = (id: string | null) => npcs.find(n => n.id === id)?.name || 'NPC'
const selected = utterances.find(u => u.id === selectedId) || null
const flowPort = (u: Utterance) => ({ x: u.xpos + UW, y: u.ypos + PORT_Y })
const cardInput = (u: Utterance) => ({ x: u.xpos, y: u.ypos + PORT_Y })
const sinkInput = (i: number) => ({ x: sinkX, y: sinkY(i) + 20 })
const curve = (a: { x: number; y: number }, b: { x: number; y: number }) => { const dx = Math.max(30, Math.abs(b.x - a.x) / 2); return `M${a.x},${a.y} C${a.x + dx},${a.y} ${b.x - dx},${b.y} ${b.x},${b.y}` }
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 <div className="utterance-overlay">
<div className="graph-toolbar">
@@ -165,19 +178,20 @@ export function UtteranceCanvas({ nodeId, nodeLabel, terminals, onClose, setStat
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(flowPort(parent), cardInput(u)), cls, () => link(u.id, { parentUtteranceId: null }, { parentUtteranceId: parent.id }))
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(flowPort(u), sinkInput(idx)), 'exit', () => link(u.id, { terminalId: null }, { terminalId: u.terminalId })) }
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
})}
</svg>
{terminals.map((t, i) => <div key={t.id} className="usink" style={{ left: sinkX, top: sinkY(i), width: SINK_W }} onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); targetSink(t.id) }}>
{terminals.map((t, i) => { const p = sinkPos(i); return <div key={t.id} className="usink" style={{ left: p.x, top: p.y, width: SINK_W }} onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); targetSink(t.id) }}>
<div className="uinput" />
<span className="usink-label"> {t.label || t.terminalKey}</span>
</div>)}
</div> })}
{utterances.map(u => <div key={u.id} className={`ucard u-${u.utterer}${u.id === selectedId ? ' selected' : ''}`} style={{ left: u.xpos, top: u.ypos, width: UW }}
{utterances.map(u => <div key={u.id} ref={el => { 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) }}>
<div className="uinput" />
<div className="ucard-head" onPointerDown={e => onCardPointerDown(e, u)}>