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:
@@ -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
@@ -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)}>
|
||||
|
||||
Reference in New Issue
Block a user