213 lines
6.7 KiB
TypeScript
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>
|
|
)
|
|
}
|