WebVNWrite/src/components/editor/nodes/DialogueNode.tsx

213 lines
6.7 KiB
TypeScript

'use client'
import { useCallback, useMemo, useState, ChangeEvent } from 'react'
import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
import Combobox from '@/components/editor/Combobox'
import type { ComboboxItem } from '@/components/editor/Combobox'
import { useEditorContext } from '@/components/editor/EditorContext'
import NodeLockIndicator from '@/components/editor/NodeLockIndicator'
type DialogueNodeData = {
speaker?: string
characterId?: string
text: string
}
const RANDOM_COLORS = [
'#EF4444', '#F97316', '#F59E0B', '#10B981',
'#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6',
'#6366F1', '#F43F5E', '#84CC16', '#06B6D4',
]
function randomColor(): string {
return RANDOM_COLORS[Math.floor(Math.random() * RANDOM_COLORS.length)]
}
export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>) {
const { setNodes } = useReactFlow()
const { characters, onAddCharacter, nodeLocks, onNodeFocus, onNodeBlur } = useEditorContext()
const lockInfo = nodeLocks.get(id)
const isLockedByOther = !!lockInfo
const [showAddForm, setShowAddForm] = useState(false)
const [newName, setNewName] = useState('')
const [newColor, setNewColor] = useState(randomColor)
const characterItems: ComboboxItem[] = useMemo(
() =>
characters.map((c) => ({
id: c.id,
label: c.name,
color: c.color,
})),
[characters]
)
const hasInvalidReference = useMemo(() => {
if (!data.characterId) return false
return !characters.some((c) => c.id === data.characterId)
}, [data.characterId, characters])
const updateNodeData = useCallback(
(updates: Partial<DialogueNodeData>) => {
setNodes((nodes) =>
nodes.map((node) =>
node.id === id
? { ...node, data: { ...node.data, ...updates } }
: node
)
)
},
[id, setNodes]
)
const handleCharacterSelect = useCallback(
(characterId: string) => {
const character = characters.find((c) => c.id === characterId)
updateNodeData({
characterId,
speaker: character?.name || '',
})
},
[characters, updateNodeData]
)
const handleAddNew = useCallback(() => {
setShowAddForm(true)
setNewName('')
setNewColor(randomColor())
}, [])
const handleSubmitNew = useCallback(() => {
if (!newName.trim()) return
const newId = onAddCharacter(newName.trim(), newColor)
updateNodeData({
characterId: newId,
speaker: newName.trim(),
})
setShowAddForm(false)
}, [newName, newColor, onAddCharacter, updateNodeData])
const handleCancelNew = useCallback(() => {
setShowAddForm(false)
}, [])
const handleTextChange = useCallback(
(e: ChangeEvent<HTMLTextAreaElement>) => {
updateNodeData({ text: e.target.value })
},
[updateNodeData]
)
const handleFocus = useCallback(() => {
onNodeFocus(id)
}, [id, onNodeFocus])
return (
<div
className={`relative min-w-[200px] rounded-lg border-2 ${
hasInvalidReference
? 'border-orange-500 dark:border-orange-400'
: 'border-blue-500 dark:border-blue-400'
} bg-blue-50 p-3 shadow-md dark:bg-blue-950`}
onFocus={handleFocus}
onBlur={onNodeBlur}
>
{isLockedByOther && (
<NodeLockIndicator lock={lockInfo} color={lockInfo.color} />
)}
{isLockedByOther && (
<div className="absolute inset-0 z-20 flex items-center justify-center rounded-lg bg-black/10">
<span className="rounded bg-black/70 px-2 py-1 text-xs text-white">
Being edited by {lockInfo.displayName}
</span>
</div>
)}
<Handle
type="target"
position={Position.Top}
id="input"
className="!h-3 !w-3 !border-2 !border-blue-500 !bg-white dark:!bg-zinc-800"
/>
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-blue-700 dark:text-blue-300">
Dialogue
</div>
<div className="mb-2">
<Combobox
items={characterItems}
value={data.characterId}
onChange={handleCharacterSelect}
placeholder="Select speaker..."
onAddNew={handleAddNew}
/>
{hasInvalidReference && (
<div className="mt-1 text-xs text-orange-600 dark:text-orange-400">
Character not found
</div>
)}
</div>
{showAddForm && (
<div className="mb-2 rounded border border-blue-300 bg-white p-2 dark:border-blue-600 dark:bg-zinc-800">
<div className="mb-1 text-xs font-medium text-zinc-600 dark:text-zinc-300">
New character
</div>
<div className="flex items-center gap-1.5">
<input
type="color"
value={newColor}
onChange={(e) => setNewColor(e.target.value)}
className="h-6 w-6 shrink-0 cursor-pointer rounded border border-zinc-300 dark:border-zinc-600"
/>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Name"
className="w-full rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white dark:placeholder-zinc-400"
onKeyDown={(e) => {
if (e.key === 'Enter') handleSubmitNew()
if (e.key === 'Escape') handleCancelNew()
}}
autoFocus
/>
</div>
<div className="mt-1.5 flex justify-end gap-1">
<button
onClick={handleCancelNew}
className="rounded px-2 py-0.5 text-xs text-zinc-500 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700"
>
Cancel
</button>
<button
onClick={handleSubmitNew}
disabled={!newName.trim()}
className="rounded bg-blue-600 px-2 py-0.5 text-xs text-white hover:bg-blue-700 disabled:opacity-50"
>
Add
</button>
</div>
</div>
)}
<textarea
value={data.text || ''}
onChange={handleTextChange}
placeholder="Dialogue text..."
rows={3}
className="w-full resize-none rounded border border-blue-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-blue-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
/>
<Handle
type="source"
position={Position.Bottom}
id="output"
className="!h-3 !w-3 !border-2 !border-blue-500 !bg-white dark:!bg-zinc-800"
/>
</div>
)
}