Add dashboard back-link to chapters list; add character via text selection

Chapters page now links back to the project dashboard. Right-clicking
highlighted text in the chapter summary or a beat's What happened /
What's next fields offers "Add Character", creating a character from
the selection and attaching it to the beat when applicable.
This commit is contained in:
James Wampler
2026-08-12 17:20:56 -07:00
parent 9a83a589c0
commit ffb476a81a
4 changed files with 91 additions and 2 deletions
@@ -0,0 +1,59 @@
import { useState, type MouseEvent } from 'react'
import { useCreateCharacter } from '../api/hooks'
type MenuState = {
x: number
y: number
text: string
onCreated: (characterId: string) => void
}
export function useCharacterContextMenu(projectId: string) {
const [menu, setMenu] = useState<MenuState | null>(null)
const createCharacter = useCreateCharacter(projectId)
const handleContextMenu = (
e: MouseEvent<HTMLTextAreaElement>,
onCreated: (characterId: string) => void,
) => {
const target = e.currentTarget
const text = target.value.slice(target.selectionStart, target.selectionEnd).trim()
if (!text) return
e.preventDefault()
setMenu({ x: e.clientX, y: e.clientY, text, onCreated })
}
const close = () => setMenu(null)
const menuElement = menu && (
<>
<div
className="fixed inset-0 z-40"
onClick={close}
onContextMenu={(e) => {
e.preventDefault()
close()
}}
/>
<div
className="card fixed z-50 p-1 text-sm shadow-xl"
style={{ left: menu.x, top: menu.y }}
>
<button
className="block w-full rounded px-3 py-1.5 text-left hover:brightness-110"
onClick={() => {
createCharacter.mutate(
{ name: menu.text },
{ onSuccess: (character) => menu.onCreated(character.id) },
)
close()
}}
>
Add Character {menu.text}
</button>
</div>
</>
)
return { handleContextMenu, menuElement }
}
+4 -1
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react'
import type { DraftStatus } from '../api/types'
export function Spinner({ label = 'Loading' }: { label?: string }) {
@@ -67,6 +67,7 @@ export function AutoField({
rows = 3,
placeholder,
serif,
onContextMenu,
}: {
label?: string
value: string | null | undefined
@@ -75,6 +76,7 @@ export function AutoField({
rows?: number
placeholder?: string
serif?: boolean
onContextMenu?: (e: MouseEvent<HTMLTextAreaElement>) => void
}) {
const [draft, setDraft] = useState(value ?? '')
const committed = useRef(value ?? '')
@@ -109,6 +111,7 @@ export function AutoField({
placeholder={placeholder}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onContextMenu={onContextMenu}
/>
) : (
<input
+22 -1
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, type MouseEvent } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import {
useChapter,
@@ -15,6 +15,7 @@ import { draftStatuses, type Beat, type Chapter } from '../api/types'
import { AutoField, ErrorNote, Select, Spinner } from '../components/ui'
import { TagChip, TagEditor } from '../components/TagEditor'
import { CharacterChip, CharacterMultiSelect } from '../components/CharacterMultiSelect'
import { useCharacterContextMenu } from '../components/CharacterContextMenu'
import { MarkdownEditor } from '../components/MarkdownEditor'
import { OpenQuestions } from '../components/OpenQuestions'
import { useHotkey } from '../keyboard/HotkeysContext'
@@ -31,6 +32,7 @@ export default function ChapterPage() {
const remove = useDeleteChapter(projectId)
const createBeat = useCreateBeat(chapterId, projectId)
const [tab, setTab] = useState<ChapterTab>('outline')
const { handleContextMenu, menuElement } = useCharacterContextMenu(projectId)
useHotkey('b', 'Add beat', () => createBeat.mutate({ title: 'New beat' }), { group: 'Chapter' })
@@ -166,6 +168,7 @@ export default function ChapterPage() {
serif
placeholder="What this chapter is for: where it starts, what shifts, where it leaves the reader."
onCommit={(summary) => patch({ summary })}
onContextMenu={(e) => handleContextMenu(e, () => {})}
/>
</div>
@@ -174,6 +177,7 @@ export default function ChapterPage() {
projectId={projectId}
characters={characters?.map((c) => ({ id: c.id, name: c.name })) ?? []}
suggestions={suggestions}
onCharacterContextMenu={handleContextMenu}
/>
<button
@@ -216,6 +220,8 @@ export default function ChapterPage() {
</section>
<OpenQuestions projectId={projectId} scope={{ chapterId: chapter.id }} />
{menuElement}
</div>
)
}
@@ -225,11 +231,16 @@ function BeatTable({
projectId,
characters,
suggestions,
onCharacterContextMenu,
}: {
chapter: Chapter
projectId: string
characters: { id: string; name: string }[]
suggestions: string[]
onCharacterContextMenu: (
e: MouseEvent<HTMLTextAreaElement>,
onCreated: (characterId: string) => void,
) => void
}) {
const update = useUpdateBeat(chapter.id, projectId)
const remove = useDeleteBeat(chapter.id)
@@ -340,6 +351,11 @@ function BeatTable({
serif
placeholder="The event itself."
onCommit={(whatHappened) => patch(beat.id, { whatHappened })}
onContextMenu={(e) =>
onCharacterContextMenu(e, (characterId) =>
patch(beat.id, { characterIds: [...beat.characters.map((c) => c.id), characterId] }),
)
}
/>
</td>
@@ -351,6 +367,11 @@ function BeatTable({
serif
placeholder="What it sets in motion."
onCommit={(whatsNext) => patch(beat.id, { whatsNext })}
onContextMenu={(e) =>
onCharacterContextMenu(e, (characterId) =>
patch(beat.id, { characterIds: [...beat.characters.map((c) => c.id), characterId] }),
)
}
/>
</td>
@@ -16,6 +16,12 @@ export default function ChaptersPage() {
return (
<div>
<div className="mb-4">
<Link to={`/projects/${projectId}`} className="text-sm muted hover:underline">
Dashboard
</Link>
</div>
<div className="mb-5 flex items-center justify-between gap-4">
<h2 className="text-xl font-semibold">Chapters</h2>
<button