video-flow-b/components/ui/shot-editor/CharacterToken.tsx
2025-08-18 21:42:26 +08:00

214 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Node, mergeAttributes } from '@tiptap/core'
import { ReactNodeViewRenderer, NodeViewWrapper, ReactNodeViewProps } from '@tiptap/react'
import { motion, AnimatePresence } from 'framer-motion'
import { useState, useRef, useEffect } from 'react'
import { Check, CircleUserRound } from 'lucide-react'
import { ScriptRoleEntity } from '@/app/service/domain/Entities';
interface CharacterTokenOptions {
roles?: ScriptRoleEntity[];
}
export function CharacterToken(props: ReactNodeViewProps) {
const [showRoleList, setShowRoleList] = useState(false);
const [listPosition, setListPosition] = useState({ top: 0, left: 0, bottom: 0 });
const { name } = props.node.attrs as ScriptRoleEntity;
const extension = props.extension as Node<CharacterTokenOptions>;
const roles = extension.options.roles || [];
const tokenRef = useRef<HTMLSpanElement>(null);
const listRef = useRef<HTMLDivElement>(null);
// 计算下拉列表的位置
const updateListPosition = () => {
if (!tokenRef.current || !listRef.current) return;
const tokenRect = tokenRef.current.getBoundingClientRect();
const listRect = listRef.current.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
// 计算理想的顶部位置在token下方
let top = tokenRect.bottom + 8; // 8px 间距
let left = tokenRect.left;
let bottom = tokenRect.top;
// 检查是否超出底部
if (top + listRect.height > viewportHeight) {
// 如果超出底部将列表显示在token上方
top = tokenRect.top - listRect.height - 8;
}
// 检查是否超出右侧
if (left + listRect.width > viewportWidth) {
// 如果超出右侧,将列表右对齐
left = viewportWidth - listRect.width - 8;
}
// 检查是否超出顶部
if (bottom - listRect.height < 0) {
// 如果超出顶部将列表显示在token下方
bottom = tokenRect.bottom + 8;
}
// 确保不会超出左侧
left = Math.max(8, left);
// 确保不会超顶部
top = Math.max(0, top);
setListPosition({ top, left, bottom });
};
// 监听窗口大小变化
useEffect(() => {
if (showRoleList) {
updateListPosition();
window.addEventListener('resize', updateListPosition);
window.addEventListener('scroll', updateListPosition);
return () => {
window.removeEventListener('resize', updateListPosition);
window.removeEventListener('scroll', updateListPosition);
};
}
}, [showRoleList]);
const handleRoleSelect = (role: ScriptRoleEntity) => {
const { editor } = props;
const pos = props.getPos();
if (typeof pos === 'number') {
const { tr } = editor.state;
tr.setNodeMarkup(pos, undefined, {
...props.node.attrs,
id: role.id,
name: role.name,
image_url: role.image_url,
});
editor.view.dispatch(tr);
}
setShowRoleList(false);
}
return (
<NodeViewWrapper
as="span"
ref={tokenRef}
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);
// 延迟一帧执行位置更新,确保列表已渲染
requestAnimationFrame(updateListPosition);
}}
>
{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 }}
ref={listRef}
className="fixed w-64 rounded-lg backdrop-blur-md bg-white/10 border border-white/20 p-2 z-[51] overflow-y-auto"
style={{
top: listPosition.top,
left: listPosition.left
}}
>
<div className="space-y-1">
{roles.map((role) => {
const isSelected = role.name.toLowerCase() === name.toLowerCase();
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.image_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
data-alt="role-item"
className={`flex items-center gap-3 p-2 rounded-lg cursor-pointer transition-colors duration-200
${name === 'Voiceover' ? 'bg-blue-500/20 text-blue-400' : 'hover:bg-white/5 text-gray-200'}`}
onClick={() => handleRoleSelect({ name: 'Voiceover', image_url: '', id: 'voiceover' } as ScriptRoleEntity)}
>
<div className="relative">
<CircleUserRound
className={`w-10 h-10 rounded-full border transition-all duration-200`}
/>
{name === 'Voiceover' && (
<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">Voiceover</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);
},
});