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' 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 [listPosition, setListPosition] = useState({ top: 0, left: 0 }); const { name, avatar } = props.node.attrs as CharacterAttributes; 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: 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 ( setShowRoleList(false)} onMouseEnter={() => { setShowRoleList(true); // 延迟一帧执行位置更新,确保列表已渲染 requestAnimationFrame(updateListPosition); }} > {name} {showRoleList && (
{roles.map((role) => { const isSelected = role.name === name; return (
handleRoleSelect(role)} >
{role.name} {isSelected && (
)}
{role.name}
); })} {/* 旁白 */}
handleRoleSelect({ name: 'Voiceover', url: '' })} >
{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); }, });