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