forked from 77media/video-flow
137 lines
3.9 KiB
TypeScript
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);
|
|
},
|
|
}); |