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'
|
import type { DraftStatus } from '../api/types'
|
||||||
|
|
||||||
export function Spinner({ label = 'Loading' }: { label?: string }) {
|
export function Spinner({ label = 'Loading' }: { label?: string }) {
|
||||||
@@ -67,6 +67,7 @@ export function AutoField({
|
|||||||
rows = 3,
|
rows = 3,
|
||||||
placeholder,
|
placeholder,
|
||||||
serif,
|
serif,
|
||||||
|
onContextMenu,
|
||||||
}: {
|
}: {
|
||||||
label?: string
|
label?: string
|
||||||
value: string | null | undefined
|
value: string | null | undefined
|
||||||
@@ -75,6 +76,7 @@ export function AutoField({
|
|||||||
rows?: number
|
rows?: number
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
serif?: boolean
|
serif?: boolean
|
||||||
|
onContextMenu?: (e: MouseEvent<HTMLTextAreaElement>) => void
|
||||||
}) {
|
}) {
|
||||||
const [draft, setDraft] = useState(value ?? '')
|
const [draft, setDraft] = useState(value ?? '')
|
||||||
const committed = useRef(value ?? '')
|
const committed = useRef(value ?? '')
|
||||||
@@ -109,6 +111,7 @@ export function AutoField({
|
|||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
onBlur={commit}
|
onBlur={commit}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useState, type MouseEvent } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
useChapter,
|
useChapter,
|
||||||
@@ -15,6 +15,7 @@ import { draftStatuses, type Beat, type Chapter } from '../api/types'
|
|||||||
import { AutoField, ErrorNote, Select, Spinner } from '../components/ui'
|
import { AutoField, ErrorNote, Select, Spinner } from '../components/ui'
|
||||||
import { TagChip, TagEditor } from '../components/TagEditor'
|
import { TagChip, TagEditor } from '../components/TagEditor'
|
||||||
import { CharacterChip, CharacterMultiSelect } from '../components/CharacterMultiSelect'
|
import { CharacterChip, CharacterMultiSelect } from '../components/CharacterMultiSelect'
|
||||||
|
import { useCharacterContextMenu } from '../components/CharacterContextMenu'
|
||||||
import { MarkdownEditor } from '../components/MarkdownEditor'
|
import { MarkdownEditor } from '../components/MarkdownEditor'
|
||||||
import { OpenQuestions } from '../components/OpenQuestions'
|
import { OpenQuestions } from '../components/OpenQuestions'
|
||||||
import { useHotkey } from '../keyboard/HotkeysContext'
|
import { useHotkey } from '../keyboard/HotkeysContext'
|
||||||
@@ -31,6 +32,7 @@ export default function ChapterPage() {
|
|||||||
const remove = useDeleteChapter(projectId)
|
const remove = useDeleteChapter(projectId)
|
||||||
const createBeat = useCreateBeat(chapterId, projectId)
|
const createBeat = useCreateBeat(chapterId, projectId)
|
||||||
const [tab, setTab] = useState<ChapterTab>('outline')
|
const [tab, setTab] = useState<ChapterTab>('outline')
|
||||||
|
const { handleContextMenu, menuElement } = useCharacterContextMenu(projectId)
|
||||||
|
|
||||||
useHotkey('b', 'Add beat', () => createBeat.mutate({ title: 'New beat' }), { group: 'Chapter' })
|
useHotkey('b', 'Add beat', () => createBeat.mutate({ title: 'New beat' }), { group: 'Chapter' })
|
||||||
|
|
||||||
@@ -166,6 +168,7 @@ export default function ChapterPage() {
|
|||||||
serif
|
serif
|
||||||
placeholder="What this chapter is for: where it starts, what shifts, where it leaves the reader."
|
placeholder="What this chapter is for: where it starts, what shifts, where it leaves the reader."
|
||||||
onCommit={(summary) => patch({ summary })}
|
onCommit={(summary) => patch({ summary })}
|
||||||
|
onContextMenu={(e) => handleContextMenu(e, () => {})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -174,6 +177,7 @@ export default function ChapterPage() {
|
|||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
characters={characters?.map((c) => ({ id: c.id, name: c.name })) ?? []}
|
characters={characters?.map((c) => ({ id: c.id, name: c.name })) ?? []}
|
||||||
suggestions={suggestions}
|
suggestions={suggestions}
|
||||||
|
onCharacterContextMenu={handleContextMenu}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -216,6 +220,8 @@ export default function ChapterPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<OpenQuestions projectId={projectId} scope={{ chapterId: chapter.id }} />
|
<OpenQuestions projectId={projectId} scope={{ chapterId: chapter.id }} />
|
||||||
|
|
||||||
|
{menuElement}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -225,11 +231,16 @@ function BeatTable({
|
|||||||
projectId,
|
projectId,
|
||||||
characters,
|
characters,
|
||||||
suggestions,
|
suggestions,
|
||||||
|
onCharacterContextMenu,
|
||||||
}: {
|
}: {
|
||||||
chapter: Chapter
|
chapter: Chapter
|
||||||
projectId: string
|
projectId: string
|
||||||
characters: { id: string; name: string }[]
|
characters: { id: string; name: string }[]
|
||||||
suggestions: string[]
|
suggestions: string[]
|
||||||
|
onCharacterContextMenu: (
|
||||||
|
e: MouseEvent<HTMLTextAreaElement>,
|
||||||
|
onCreated: (characterId: string) => void,
|
||||||
|
) => void
|
||||||
}) {
|
}) {
|
||||||
const update = useUpdateBeat(chapter.id, projectId)
|
const update = useUpdateBeat(chapter.id, projectId)
|
||||||
const remove = useDeleteBeat(chapter.id)
|
const remove = useDeleteBeat(chapter.id)
|
||||||
@@ -340,6 +351,11 @@ function BeatTable({
|
|||||||
serif
|
serif
|
||||||
placeholder="The event itself."
|
placeholder="The event itself."
|
||||||
onCommit={(whatHappened) => patch(beat.id, { whatHappened })}
|
onCommit={(whatHappened) => patch(beat.id, { whatHappened })}
|
||||||
|
onContextMenu={(e) =>
|
||||||
|
onCharacterContextMenu(e, (characterId) =>
|
||||||
|
patch(beat.id, { characterIds: [...beat.characters.map((c) => c.id), characterId] }),
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
@@ -351,6 +367,11 @@ function BeatTable({
|
|||||||
serif
|
serif
|
||||||
placeholder="What it sets in motion."
|
placeholder="What it sets in motion."
|
||||||
onCommit={(whatsNext) => patch(beat.id, { whatsNext })}
|
onCommit={(whatsNext) => patch(beat.id, { whatsNext })}
|
||||||
|
onContextMenu={(e) =>
|
||||||
|
onCharacterContextMenu(e, (characterId) =>
|
||||||
|
patch(beat.id, { characterIds: [...beat.characters.map((c) => c.id), characterId] }),
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ export default function ChaptersPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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">
|
<div className="mb-5 flex items-center justify-between gap-4">
|
||||||
<h2 className="text-xl font-semibold">Chapters</h2>
|
<h2 className="text-xl font-semibold">Chapters</h2>
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user