From 3b72f38d0083121d9a74eb458ed687b27afd8678 Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Tue, 18 Aug 2026 17:59:17 +0200 Subject: [PATCH] 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 --- server/storyGraphRepository.ts | 2 +- src/styles.css | 4 ++++ src/utteranceCanvas.tsx | 44 ++++++++++++++++++++++------------ 3 files changed, 34 insertions(+), 16 deletions(-) diff --git a/server/storyGraphRepository.ts b/server/storyGraphRepository.ts index c9c8bac..413b928 100644 --- a/server/storyGraphRepository.ts +++ b/server/storyGraphRepository.ts @@ -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++) diff --git a/src/styles.css b/src/styles.css index 1cf3b92..ebf4730 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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%); } diff --git a/src/utteranceCanvas.tsx b/src/utteranceCanvas.tsx index 3c0e62b..dc683b8 100644 --- a/src/utteranceCanvas.tsx +++ b/src/utteranceCanvas.tsx @@ -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(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 }) @@ -29,6 +29,10 @@ export function UtteranceCanvas({ nodeId, nodeLabel, terminals, onClose, setStat 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 reload = useCallback(async () => { try { setUtterances(await api(`/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('/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() } @@ -62,7 +73,7 @@ export function UtteranceCanvas({ nodeId, nodeLabel, terminals, onClose, setStat void (async () => { try { const created = await api(`/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() 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
@@ -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 })} - {terminals.map((t, i) =>
e.stopPropagation()} onClick={e => { e.stopPropagation(); targetSink(t.id) }}> + {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)}>