"use client" import { useEffect, useState } from "react" import type { Editor } from "@tiptap/react" import { Box, Brackets, CircleSlash, FileText, Heading, Layers, Underline, } from "lucide-react" import { cn } from "@/shared/lib/utils" import { Button } from "@/shared/components/ui/button" import type { QuestionBlockType } from "./extensions/question-block" interface SelectionToolbarProps { editor: Editor | null /** 当前选区所在题目块类型(用于显示状态) */ activeQuestionType?: QuestionBlockType className?: string } interface ToolbarButtonProps { onClick: () => void icon: React.ElementType label: string active?: boolean } function ToolbarButton({ onClick, icon: Icon, label, active }: ToolbarButtonProps) { return ( ) } /** * 选区标记浮层 —— 选中文本时浮现,提供"标记为题目/分组/加点字/填空"等快捷操作。 * 跟随选区位置定位,使用 getBoundingClientRect 计算坐标。 */ export function SelectionToolbar({ editor, activeQuestionType, className, }: SelectionToolbarProps) { const [coords, setCoords] = useState<{ top: number; left: number } | null>(null) const [hasSelection, setHasSelection] = useState(false) useEffect(() => { if (!editor) return const updatePosition = () => { const { from, to, empty } = editor.state.selection if (empty || from === to) { setCoords(null) setHasSelection(false) return } // 检查选区是否在 questionBlock/groupBlock 内部 const $from = editor.state.selection.$from const depth = $from.depth let inBlock = false for (let d = depth; d > 0; d--) { const node = $from.node(d) if (node.type.name === "questionBlock" || node.type.name === "groupBlock") { inBlock = true break } } // 即使不在块内也允许显示(可标记为题目/分组) void inBlock const view = editor.view const start = view.coordsAtPos(from) const end = view.coordsAtPos(to) const top = Math.min(start.top, end.top) - 44 // 浮在选区上方 const left = (start.left + end.right) / 2 setCoords({ top, left }) setHasSelection(true) } editor.on("selectionUpdate", updatePosition) editor.on("blur", () => { // 延迟以允许点击工具栏按钮 setTimeout(() => { if (!editor.isFocused) { setCoords(null) setHasSelection(false) } }, 200) }) return () => { editor.off("selectionUpdate", updatePosition) } }, [editor]) if (!editor || !hasSelection || !coords) return null const { from, to, empty } = editor.state.selection const hasTextSelection = !empty && from !== to const insertQuestion = (type: QuestionBlockType) => { const chain = editor.chain().focus() if (hasTextSelection) { // 选中文本时:包裹为题目块 chain.wrapInQuestion({ type, score: type === "composite" ? 5 : 2 }).run() } else { // 未选中文本时:插入空题目块 chain.insertQuestion({ type, score: type === "composite" ? 5 : 2 }).run() } } const insertGroup = () => { const chain = editor.chain().focus() if (hasTextSelection) { chain.wrapInGroup("一、选择题", "").run() } else { chain.insertGroup("一、选择题", "").run() } } const insertSection = () => { const chain = editor.chain().focus() if (hasTextSelection) { chain.wrapInSection("第Ⅰ卷 选择题", 1).run() } else { chain.insertSection("第Ⅰ卷 选择题", 1).run() } } const toggleDotted = () => { editor.chain().focus().toggleDotted().run() } const insertBlank = () => { editor.chain().focus().insertBlank().run() } const insertImage = () => { // 触发隐藏的文件输入 const input = document.createElement("input") input.type = "file" input.accept = "image/*" input.onchange = async () => { const file = input.files?.[0] if (!file) return const formData = new FormData() formData.append("file", file) formData.append("targetType", "exam") try { const res = await fetch("/api/upload", { method: "POST", body: formData }) const data = await res.json() if (data?.success && data?.url) { editor.chain().focus().setImage({ src: data.url, fileId: data.id }).run() } } catch (e) { console.error("[SelectionToolbar] upload failed", e) } } input.click() } return (
insertQuestion("single_choice")} icon={CircleSlash} label="单选" active={activeQuestionType === "single_choice"} /> insertQuestion("text")} icon={FileText} label="填空/简答" active={activeQuestionType === "text"} /> insertQuestion("composite")} icon={Brackets} label="复合" active={activeQuestionType === "composite"} />
) }