Field notebook: capture NPC lines, tear pages onto the board
A dialogue line gains a "✎ Note this" capture that saves it to a per-playthrough notebook (migration 035 + notebook endpoints). The inventory notebook lists captured pages in a handwriting font (Google "Reenie Beanie") and "✂ Tear to board" drops a page onto the current board as a note exhibit (reusing addNote), then removes the page. Board notes render handwritten too. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+5
-4
@@ -214,8 +214,8 @@ export function App() {
|
||||
setOpenDoc(null); setSelected(ev.id); setRecentlyCreatedExhibitId(ev.id); setStatus('EVIDENCE EXTRACTED · PROVENANCE ATTACHED')
|
||||
}
|
||||
|
||||
const addNote = () => {
|
||||
const content = window.prompt('What do you think this evidence means?')?.trim()
|
||||
const addNote = (preset?: string) => {
|
||||
const content = typeof preset === 'string' ? preset : window.prompt('What do you think this evidence means?')?.trim()
|
||||
if (!content || !caseState) return
|
||||
const { viewport } = caseState
|
||||
const position = nextOpenBoardPosition(caseState.exhibits, { x: Math.max(100, (500 - viewport.x) / viewport.zoom), y: Math.max(100, (330 - viewport.y) / viewport.zoom) }, { width: 108 })
|
||||
@@ -543,7 +543,8 @@ export function App() {
|
||||
const timelineView = caseState.views.find((view): view is TimelineView => view.type === 'timeline')
|
||||
return <main className="desktop">
|
||||
{inventoryOpen && activePlaythroughId && <Suspense fallback={null}>
|
||||
<Inventory session={{ playthroughId: activePlaythroughId, onConnect: () => window.location.assign('/?resume=1') }} onClose={() => setInventoryOpen(false)} />
|
||||
<Inventory session={{ playthroughId: activePlaythroughId, onConnect: () => window.location.assign('/?resume=1') }}
|
||||
onTearToBoard={text => { addNote(text); setInventoryOpen(false) }} onClose={() => setInventoryOpen(false)} />
|
||||
</Suspense>}
|
||||
<header className="menubar">
|
||||
<div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div>
|
||||
@@ -608,7 +609,7 @@ export function App() {
|
||||
<button className={boardTool === 'move' ? 'active' : ''} title="Move widgets" onClick={() => setBoardTool('move')}><MousePointer2 size={17}/> MOVE</button>
|
||||
<button className={boardTool === 'hand' ? 'active' : ''} title="Pan board (middle mouse always works)" onClick={() => setBoardTool('hand')}><Hand size={17}/> HAND</button>
|
||||
<span />
|
||||
<button onClick={addNote}><NotebookPen size={17}/> NEW NOTE</button>
|
||||
<button onClick={() => addNote()}><NotebookPen size={17}/> NEW NOTE</button>
|
||||
<button onClick={addEvent}><CalendarClock size={17}/> NEW EVENT</button>
|
||||
<button onClick={addParty}><UserRound size={17}/> NEW PARTY</button>
|
||||
<button className={`thread-tool ${linkFrom ? 'active' : ''}`} aria-label="Red thread" title={linkFrom ? 'Cancel red thread' : selected ? 'Connect selected exhibit with red thread' : 'Select an exhibit first'} disabled={!selected} onClick={toggleThreadTool}><Link2 size={18}/></button>
|
||||
|
||||
+23
-6
@@ -85,13 +85,13 @@ function ToolRack({ index }: { index: number }) {
|
||||
return <div ref={stageRef} className="inv-stage" />
|
||||
}
|
||||
|
||||
export function Inventory({ onClose, session }: { onClose?: () => void; session?: PhoneSession }) {
|
||||
export function Inventory({ onClose, session, onTearToBoard }: { onClose?: () => void; session?: PhoneSession; onTearToBoard?: (text: string) => void }) {
|
||||
const [index, setIndex] = useState(0)
|
||||
const [active, setActive] = useState<string | null>(null)
|
||||
|
||||
if (active) return <div className="inv-tool">
|
||||
<button className="inv-back" onClick={() => setActive(null)}>‹ TOOLS</button>
|
||||
{active === 'phone' ? <PhonePreview session={session} /> : <NotebookTool />}
|
||||
{active === 'phone' ? <PhonePreview session={session} /> : <NotebookTool playthroughId={session?.playthroughId} onTear={onTearToBoard} />}
|
||||
</div>
|
||||
|
||||
return <div className="inv-backdrop">
|
||||
@@ -108,13 +108,30 @@ export function Inventory({ onClose, session }: { onClose?: () => void; session?
|
||||
</div>
|
||||
}
|
||||
|
||||
// Placeholder notebook tool — a lined page you can scribble on (not yet persisted).
|
||||
function NotebookTool() {
|
||||
const [text, setText] = useState('')
|
||||
// Field notebook: the lines captured from NPCs during play. A page can be torn off
|
||||
// onto the board as a note (when a board tear handler is available).
|
||||
function NotebookTool({ playthroughId, onTear }: { playthroughId?: string; onTear?: (text: string) => void }) {
|
||||
const [pages, setPages] = useState<{ id: string; text: string }[]>([])
|
||||
useEffect(() => {
|
||||
if (!playthroughId) return
|
||||
fetch(`/api/playthroughs/${playthroughId}/notebook`).then(r => r.ok ? r.json() : []).then(setPages).catch(() => {})
|
||||
}, [playthroughId])
|
||||
const remove = async (id: string) => {
|
||||
if (playthroughId) await fetch(`/api/playthroughs/${playthroughId}/notebook/${id}`, { method: 'DELETE' })
|
||||
setPages(list => list.filter(page => page.id !== id))
|
||||
}
|
||||
const tear = (page: { id: string; text: string }) => { onTear?.(page.text); void remove(page.id) }
|
||||
return <div className="notebook">
|
||||
<div className="notebook-page">
|
||||
<div className="notebook-head">FIELD NOTEBOOK</div>
|
||||
<textarea value={text} onChange={e => setText(e.target.value)} placeholder="Jot a lead…" spellCheck={false} />
|
||||
{!pages.length && <p className="notebook-empty">Nothing noted yet. During a conversation, use “✎ Note this” to jot down what someone says.</p>}
|
||||
{pages.map(page => <div key={page.id} className="notebook-note">
|
||||
<p>{page.text}</p>
|
||||
<div className="notebook-note-actions">
|
||||
{onTear && <button onClick={() => tear(page)} title="Tear off onto the board">✂ Tear to board</button>}
|
||||
<button className="ghost" onClick={() => void remove(page.id)} title="Discard">×</button>
|
||||
</div>
|
||||
</div>)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
+4
-2
@@ -81,7 +81,7 @@ export function CutsceneHost({ componentKey, label, onComplete }: { componentKey
|
||||
|
||||
// Walk a dialogue node's utterance tree: play NPC lines, present player options at a
|
||||
// branch, follow a chosen option to the next line or out through its exit terminal.
|
||||
export function DialoguePlayer({ node, onExit, onAward, inline, startId }: { node: { utterances: RuntimeUtterance[]; rootId: string | null }; onExit: (terminalKey?: string) => void; onAward?: (utteranceId: string) => void; inline?: boolean; startId?: string | null }) {
|
||||
export function DialoguePlayer({ node, onExit, onAward, onCapture, inline, startId }: { node: { utterances: RuntimeUtterance[]; rootId: string | null }; onExit: (terminalKey?: string) => void; onAward?: (utteranceId: string) => void; onCapture?: (text: string, utteranceId: string) => void; inline?: boolean; startId?: string | null }) {
|
||||
const byId = useMemo(() => new Map(node.utterances.map(u => [u.id, u])), [node.utterances])
|
||||
const [currentId, setCurrentId] = useState<string | null>(startId ?? node.rootId)
|
||||
const onAwardRef = useRef(onAward)
|
||||
@@ -149,7 +149,9 @@ export function DialoguePlayer({ node, onExit, onAward, inline, startId }: { nod
|
||||
<div className="dialogue-scrim" aria-hidden />
|
||||
<div className="dialogue-box">
|
||||
<div className="dialogue-panel">
|
||||
<div className="dialogue-speaker"><strong>{current.speaker.name}</strong>{current.speaker.role && <em>{current.speaker.role}</em>}</div>
|
||||
<div className="dialogue-speaker"><strong>{current.speaker.name}</strong>{current.speaker.role && <em>{current.speaker.role}</em>}
|
||||
{onCapture && !inline && current.utterer === 'npc' && done && <button className="dialogue-capture" title="Copy to notebook" onClick={event => { event.stopPropagation(); onCapture(current.text, current.id) }}>✎ Note this</button>}
|
||||
</div>
|
||||
<p className="dialogue-text">{fullText.slice(0, charCount)}<span className="dialogue-caret" aria-hidden>{done ? '' : '▍'}</span></p>
|
||||
{showChoices
|
||||
? <div className="dialogue-choices">{options.map(option => <button key={option.id} onClick={event => { event.stopPropagation(); pick(option) }}>{option.text || '(choice)'}</button>)}</div>
|
||||
|
||||
+2
-1
@@ -103,6 +103,7 @@ export function Play() {
|
||||
if (node.kind === 'merit') return <MeritHost componentKey={node.componentKey} label={node.label} awardsFlag={node.awardsFlag} onComplete={() => { void advance() }} />
|
||||
if (node.kind === 'dialogue' && node.utterances) return <DialoguePlayer node={{ utterances: node.utterances, rootId: node.rootId ?? null }}
|
||||
onExit={terminalKey => { void advance(terminalKey) }}
|
||||
onAward={utteranceId => { if (state) void fetch(`/api/playthroughs/${state.playthrough.id}/utterances/${utteranceId}/reach`, { method: 'POST' }) }} />
|
||||
onAward={utteranceId => { if (state) void fetch(`/api/playthroughs/${state.playthrough.id}/utterances/${utteranceId}/reach`, { method: 'POST' }) }}
|
||||
onCapture={(text, utteranceId) => { if (state) void fetch(`/api/playthroughs/${state.playthrough.id}/notebook`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text, utteranceId }) }) }} />
|
||||
return <div className="boot"><div className="seal">GU</div><small>{node.label}</small></div>
|
||||
}
|
||||
|
||||
+12
-2
@@ -787,6 +787,16 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.inv-back:hover { border-color: #cdea6a; }
|
||||
/* notebook tool (placeholder) */
|
||||
.notebook { position: fixed; inset: 0; z-index: 500; display: grid; place-items: center; background: radial-gradient(120% 90% at 50% 30%, #1c1a12 0%, #0a0906 75%); }
|
||||
.notebook-page { width: min(560px, 88vw); height: min(72vh, 720px); background: repeating-linear-gradient(#f4ecd6 0 30px, #e3d8b8 30px 31px); box-shadow: 0 20px 60px #0009; padding: 26px 30px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.notebook-page { width: min(560px, 88vw); height: min(72vh, 720px); background: repeating-linear-gradient(#f4ecd6 0 30px, #e3d8b8 30px 31px); box-shadow: 0 20px 60px #0009; padding: 26px 30px; display: flex; flex-direction: column; gap: 12px; overflow-y: auto; }
|
||||
.notebook-head { font: 700 13px IBM Plex Mono, monospace; letter-spacing: 3px; color: #6a5a3a; }
|
||||
.notebook-page textarea { flex: 1; background: transparent; border: none; outline: none; resize: none; font: 16px/31px "Courier New", monospace; color: #3a3020; }
|
||||
.notebook-empty { font: 22px/1.3 "Reenie Beanie", cursive; color: #8a7a52; }
|
||||
.notebook-note { border-bottom: 1px dashed #c8b98f; padding-bottom: 8px; }
|
||||
.notebook-note p { margin: 0 0 6px; font: 27px/30px "Reenie Beanie", cursive; color: #26356b; }
|
||||
.notebook-note-actions { display: flex; gap: 8px; }
|
||||
.notebook-note-actions button { font: 11px IBM Plex Mono, monospace; letter-spacing: 1px; background: #e8dcbc; border: 1px solid #c0ad7f; color: #5a4a2a; padding: 3px 9px; cursor: pointer; }
|
||||
.notebook-note-actions button.ghost { background: transparent; border-color: transparent; color: #a08a5a; }
|
||||
.notebook-note-actions button:hover { border-color: #6a5a3a; }
|
||||
.dialogue-capture { margin-left: 12px; font: 10px IBM Plex Mono, monospace; letter-spacing: 1px; background: #0a211de0; border: 1px solid #6f8f85; color: #cdea6a; padding: 2px 8px; cursor: pointer; vertical-align: middle; }
|
||||
.dialogue-capture:hover { border-color: #cdea6a; color: #eafaa0; }
|
||||
/* A note torn to the board reads as handwriting too. */
|
||||
.evidence-card.note .card-content p { font-family: "Reenie Beanie", cursive; font-size: 19px; line-height: 1.05; color: #26356b; }
|
||||
|
||||
Reference in New Issue
Block a user