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