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 }); const { name } = props.node.attrs as ScriptRoleEntity; const extension = props.extension as Node; const roles = extension.options.roles || []; const tokenRef = useRef(null); const listRef = useRef(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; // 检查是否超出底部 if (top + listRect.height > viewportHeight) { // 如果超出底部,将列表显示在token上方 top = tokenRect.top - listRect.height - 8; } // 检查是否超出右侧 if (left + listRect.width > viewportWidth) { // 如果超出右侧,将列表右对齐 left = viewportWidth - listRect.width - 8; } // 确保不会超出左侧 left = Math.max(8, left); setListPosition({ top, left }); }; // 监听窗口大小变化 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 ( setShowRoleList(false)} onMouseEnter={() => { setShowRoleList(true); // 延迟一帧执行位置更新,确保列表已渲染 requestAnimationFrame(updateListPosition); }} > {name} {showRoleList && (
{roles.map((role) => { const isSelected = role.name.toLowerCase() === name.toLowerCase(); return (
handleRoleSelect(role)} >
{role.name} {isSelected && (
)}
{role.name}
); })} {/* 旁白 */}
handleRoleSelect({ name: 'Voiceover', image_url: '', id: 'voiceover' } as ScriptRoleEntity)} >
{name === 'Voiceover' && (
)}
Voiceover
)}
) } export const CharacterTokenExtension = Node.create({ 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); }, });