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:
@@ -280,7 +280,7 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
|
|||||||
}
|
}
|
||||||
const utteranceId = randomUUID(); created.push(utteranceId)
|
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)',
|
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).
|
// Chain via parent: each line follows the previous one (one child = linear).
|
||||||
for (let i = 1; i < created.length; i++)
|
for (let i = 1; i < created.length; i++)
|
||||||
|
|||||||
@@ -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 { 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-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%); }
|
.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
@@ -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 Utterer = 'npc' | 'player'
|
||||||
type Utterance = {
|
type Utterance = {
|
||||||
@@ -9,8 +9,8 @@ type Utterance = {
|
|||||||
type Terminal = { id: string; terminalKey: string; label: string }
|
type Terminal = { id: string; terminalKey: string; label: string }
|
||||||
type Npc = { id: string; name: string; poses: { poseKey: string; url: string }[] }
|
type Npc = { id: string; name: string; poses: { poseKey: string; url: string }[] }
|
||||||
|
|
||||||
const UW = 220, PORT_Y = 24, SINK_W = 150
|
// Vertical layout: input on top, output on the bottom; exit sinks in a row below.
|
||||||
const sinkY = (index: number) => 30 + index * 74
|
const UW = 220, UH_DEFAULT = 72, SINK_W = 150
|
||||||
|
|
||||||
async function api<T>(url: string, method: string, body?: unknown): Promise<T> {
|
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 })
|
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 canvasRef = useRef<HTMLDivElement>(null)
|
||||||
const drag = useRef<{ id: string; startX: number; startY: number; origX: number; origY: number } | 'pan' | null>(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)
|
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 () => {
|
const reload = useCallback(async () => {
|
||||||
try { setUtterances(await api<Utterance[]>(`/api/admin/story-nodes/${nodeId}/utterances`, 'GET')) }
|
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])
|
}, [nodeId, setStatus])
|
||||||
useEffect(() => { void reload(); api<Npc[]>('/api/admin/npcs', 'GET').then(setNpcs).catch(() => {}) }, [reload])
|
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).
|
// Undo stack of inverse operations (connection edits, Tab creation).
|
||||||
const undoRef = useRef<Array<() => Promise<void>>>([])
|
const undoRef = useRef<Array<() => Promise<void>>>([])
|
||||||
const pushUndo = (fn: () => Promise<void>) => { undoRef.current.push(fn); if (undoRef.current.length > 40) undoRef.current.shift() }
|
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 () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const created = await api<Utterance>(`/api/admin/story-nodes/${nodeId}/utterances`, 'POST',
|
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 })
|
await api(`/api/admin/utterances/${created.id}`, 'PATCH', { parentUtteranceId: parent.id })
|
||||||
// 2+ children ⇒ player options; a lone child stays a linear NPC next line.
|
// 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 })
|
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 byId = new Map(utterances.map(u => [u.id, u]))
|
||||||
const childCount = new Map<string, number>()
|
const childCount = new Map<string, number>()
|
||||||
for (const u of utterances) if (u.parentUtteranceId) childCount.set(u.parentUtteranceId, (childCount.get(u.parentUtteranceId) || 0) + 1)
|
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 npcName = (id: string | null) => npcs.find(n => n.id === id)?.name || 'NPC'
|
||||||
const selected = utterances.find(u => u.id === selectedId) || null
|
const selected = utterances.find(u => u.id === selectedId) || null
|
||||||
const flowPort = (u: Utterance) => ({ x: u.xpos + UW, y: u.ypos + PORT_Y })
|
const cardH = (u: Utterance) => heights[u.id] ?? UH_DEFAULT
|
||||||
const cardInput = (u: Utterance) => ({ x: u.xpos, y: u.ypos + PORT_Y })
|
const topPort = (u: Utterance) => ({ x: u.xpos + UW / 2, y: u.ypos })
|
||||||
const sinkInput = (i: number) => ({ x: sinkX, y: sinkY(i) + 20 })
|
const bottomPort = (u: Utterance) => ({ x: u.xpos + UW / 2, y: u.ypos + cardH(u) })
|
||||||
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}` }
|
// 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">
|
return <div className="utterance-overlay">
|
||||||
<div className="graph-toolbar">
|
<div className="graph-toolbar">
|
||||||
@@ -165,19 +178,20 @@ export function UtteranceCanvas({ nodeId, nodeLabel, terminals, onClose, setStat
|
|||||||
if (u.parentUtteranceId && byId.has(u.parentUtteranceId)) {
|
if (u.parentUtteranceId && byId.has(u.parentUtteranceId)) {
|
||||||
const parent = byId.get(u.parentUtteranceId)!
|
const parent = byId.get(u.parentUtteranceId)!
|
||||||
const cls = (childCount.get(parent.id) || 0) >= 2 ? 'option' : ''
|
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
|
return wires
|
||||||
})}
|
})}
|
||||||
</svg>
|
</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" />
|
<div className="uinput" />
|
||||||
<span className="usink-label">⇥ {t.label || t.terminalKey}</span>
|
<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) }}>
|
onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); targetCard(u) }}>
|
||||||
<div className="uinput" />
|
<div className="uinput" />
|
||||||
<div className="ucard-head" onPointerDown={e => onCardPointerDown(e, u)}>
|
<div className="ucard-head" onPointerDown={e => onCardPointerDown(e, u)}>
|
||||||
|
|||||||
Reference in New Issue
Block a user