video-flow-b/components/ui/shot-editor/CharacterToken.tsx

137 lines
3.9 KiB
TypeScript

import { Node, mergeAttributes } from '@tiptap/core'
import { ReactNodeViewRenderer, NodeViewWrapper, ReactNodeViewProps } from '@tiptap/react'
import { motion, AnimatePresence } from 'framer-motion'
import { useState } from 'react'
import { Check } from 'lucide-react'
interface CharacterAttributes {
id: string | null;
name: string;
avatar: string;
gender: string;
age: string;
}
interface Role {
name: string;
url: string;
}
interface CharacterTokenOptions {
roles?: Role[];
}
export function CharacterToken(props: ReactNodeViewProps) {
const [showRoleList, setShowRoleList] = useState(false)
const { name, avatar } = props.node.attrs as CharacterAttributes
const extension = props.extension as Node<CharacterTokenOptions>
const roles = extension.options.roles || []
const handleRoleSelect = (role: Role) => {
const { editor } = props;
const pos = props.getPos();
if (typeof pos === 'number') {
const { tr } = editor.state;
tr.setNodeMarkup(pos, undefined, {
...props.node.attrs,
name: role.name,
avatar: role.url,
});
editor.view.dispatch(tr);
}
setShowRoleList(false);
}
return (
<NodeViewWrapper
as="span"
data-alt="character-token"
contentEditable={false}
className="relative inline text-blue-400 cursor-pointer hover:text-blue-300 transition-colors duration-200"
onMouseLeave={() => setShowRoleList(false)}
onMouseEnter={() => setShowRoleList(true)}
>
{name}
<AnimatePresence>
{showRoleList && (
<motion.div
data-alt="role-list"
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
transition={{ duration: 0.2 }}
className="absolute top-full left-0 mt-2 w-64 rounded-lg backdrop-blur-md bg-white/10 border border-white/20 p-2 z-50"
>
<div className="space-y-1">
{roles.map((role) => {
const isSelected = role.name === name;
return (
<div
key={role.name}
data-alt="role-item"
className={`flex items-center gap-3 p-2 rounded-lg cursor-pointer transition-colors duration-200
${isSelected ? 'bg-blue-500/20 text-blue-400' : 'hover:bg-white/5 text-gray-200'}`}
onClick={() => handleRoleSelect(role)}
>
<div className="relative">
<img
src={role.url}
alt={role.name}
className={`w-10 h-10 rounded-full border transition-all duration-200
${isSelected ? 'border-blue-400 border-2' : 'border-white/20'}`}
/>
{isSelected && (
<div className="absolute -top-1 -right-1 bg-blue-500 rounded-full p-0.5">
<Check className="w-3 h-3 text-white" />
</div>
)}
</div>
<span className="flex-1">{role.name}</span>
</div>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
</NodeViewWrapper>
)
}
export const CharacterTokenExtension = Node.create<CharacterTokenOptions>({
name: 'characterToken',
group: 'inline',
inline: true,
atom: true,
addOptions() {
return {
roles: [],
}
},
addAttributes() {
return {
id: { default: null },
name: { default: '' },
gender: { default: '' },
age: { default: '' },
avatar: { default: '' },
};
},
parseHTML() {
return [{ tag: 'character-token' }];
},
renderHTML({ HTMLAttributes }) {
return ['character-token', mergeAttributes(HTMLAttributes)];
},
addNodeView() {
return ReactNodeViewRenderer(CharacterToken);
},
});