"use client" import { useMemo, useState, useEffect, useRef } from "react" import ReactMarkdown from "react-markdown" import remarkBreaks from "remark-breaks" import remarkGfm from "remark-gfm" import { useQueryState, parseAsString } from "nuqs" import { Tag, List, Plus, Edit2, Save, Trash2, Pencil, PlusCircle, Share2 } from "lucide-react" import { toast } from "sonner" import type { Chapter, KnowledgePoint } from "../types" import { createKnowledgePointAction, updateChapterContentAction, deleteKnowledgePointAction, updateKnowledgePointAction } from "../actions" import { CreateQuestionDialog } from "@/modules/questions/components/create-question-dialog" import { cn } from "@/shared/lib/utils" import { ScrollArea } from "@/shared/components/ui/scroll-area" import { Button } from "@/shared/components/ui/button" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs" import { Badge } from "@/shared/components/ui/badge" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/shared/components/ui/dialog" import { Input } from "@/shared/components/ui/input" import { Label } from "@/shared/components/ui/label" import { Textarea } from "@/shared/components/ui/textarea" import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, } from "@/shared/components/ui/context-menu" import { ChapterSidebarList } from "./chapter-sidebar-list" import { RichTextEditor } from "@/shared/components/ui/rich-text-editor" function buildChapterIndex(chapters: Chapter[]) { const index = new Map() const walk = (nodes: Chapter[]) => { for (const node of nodes) { index.set(node.id, node) if (node.children && node.children.length > 0) walk(node.children) } } walk(chapters) return index } export function TextbookReader({ chapters, knowledgePoints = [], canEdit = false, textbookId }: { chapters: Chapter[]; knowledgePoints?: KnowledgePoint[]; canEdit?: boolean; textbookId?: string }) { const [chapterId, setChapterId] = useQueryState("chapterId", parseAsString.withDefault("")) const [activeTab, setActiveTab] = useState("chapters") const [highlightedKpId, setHighlightedKpId] = useState(null) // Selection & Creation State const [selectedText, setSelectedText] = useState("") const selectionRef = useRef("") // Store selection temporarily to avoid re-renders on pointer down const [createDialogOpen, setCreateDialogOpen] = useState(false) const [isCreating, setIsCreating] = useState(false) const contentRef = useRef(null) // Editing State const [isEditing, setIsEditing] = useState(false) const [editContent, setEditContent] = useState("") const [isSaving, setIsSaving] = useState(false) // Knowledge Point Edit State const [editingKp, setEditingKp] = useState(null) const [editKpDialogOpen, setEditKpDialogOpen] = useState(false) const [isUpdatingKp, setIsUpdatingKp] = useState(false) // Question Creation State const [questionDialogOpen, setQuestionDialogOpen] = useState(false) const [targetKpForQuestion, setTargetKpForQuestion] = useState(null) const index = useMemo(() => buildChapterIndex(chapters), [chapters]) const selected = chapterId ? index.get(chapterId) ?? null : null const selectedId = selected?.id ?? null const handleSelect = (chapter: Chapter) => { setChapterId(chapter.id) setIsEditing(false) } // Handle Text Selection via Context Menu // We capture selection on PointerDown (Right Click) to ensure we get the state before any context menu logic runs. // Using onContextMenu directly caused conflicts with Radix UI's ContextMenuTrigger in some cases. const handleContentPointerDown = (e: React.PointerEvent) => { // Only capture on right click (button 2) if (e.button !== 2) return const selection = window.getSelection() if (!selection || selection.isCollapsed) { selectionRef.current = "" return } // Check if selection is within content area if (contentRef.current && contentRef.current.contains(selection.anchorNode)) { // Store in ref, don't trigger re-render yet selectionRef.current = selection.toString().trim() } else { selectionRef.current = "" } } const handleContextMenuChange = (open: boolean) => { if (!open) return // When menu opens, sync ref to state to update UI if (selectionRef.current) { setSelectedText(selectionRef.current) } else { // Fallback: If pointer down didn't capture (e.g. keyboard), try now const selection = window.getSelection() if (selection && !selection.isCollapsed && contentRef.current && contentRef.current.contains(selection.anchorNode)) { const text = selection.toString().trim() selectionRef.current = text setSelectedText(text) } else { setSelectedText("") } } } const handleCreateKnowledgePoint = async (formData: FormData) => { if (!selectedId || !selected) return setIsCreating(true) try { const result = await createKnowledgePointAction( selectedId, selected.textbookId, null, formData ) if (result.success) { toast.success("知识点已创建") setCreateDialogOpen(false) setActiveTab("knowledge") // Clear selection window.getSelection()?.removeAllRanges() setSelectedText("") } else { toast.error(result.message || "创建知识点失败") } } catch { toast.error("发生错误") } finally { setIsCreating(false) } } const handleSaveContent = async () => { if (!selectedId || !textbookId) return setIsSaving(true) const result = await updateChapterContentAction(selectedId, editContent, textbookId) setIsSaving(false) if (result.success) { toast.success(result.message) setIsEditing(false) // Optimistic update might be tricky here without full reload, but let's assume parent revalidates or we rely on router refresh // For now, we manually update the local state if needed, but since we use `chapters` prop which comes from server, // we ideally want to trigger a refresh. // However, for this component, we can just let the user see the new content if we render `editContent` or rely on props update. // But `chapters` prop won't update automatically unless we router.refresh(). // Let's rely on the fact that `selected` comes from `chapters` which might be stale until refresh. // A full solution would use `router.refresh()`. // For now, we can update the `selected.content` in place? No, it's a prop. // We will rely on router refresh in the parent or just simple UI feedback. // Actually, let's trigger a router refresh if possible, but we don't have router here. // We'll just exit edit mode. The content might look old until refresh. // To fix this, we can locally override content. if (selected) selected.content = editContent } else { toast.error(result.message) } } const startEditing = () => { if (selected) { setEditContent(selected.content || "") setIsEditing(true) } } const handleDeleteKnowledgePoint = async (kpId: string, e: React.MouseEvent) => { e.stopPropagation() if (!confirm("确定要删除这个知识点吗?")) return if (!textbookId) return try { const result = await deleteKnowledgePointAction(kpId, textbookId) if (result.success) { toast.success(result.message) if (highlightedKpId === kpId) { setHighlightedKpId(null) } } else { toast.error(result.message) } } catch { toast.error("删除失败") } } const handleUpdateKnowledgePoint = async (formData: FormData) => { if (!editingKp || !textbookId) return setIsUpdatingKp(true) try { const result = await updateKnowledgePointAction(editingKp.id, textbookId, null, formData) if (result.success) { toast.success(result.message) setEditKpDialogOpen(false) setEditingKp(null) } else { toast.error(result.message) } } catch { toast.error("更新失败") } finally { setIsUpdatingKp(false) } } // Filter KPs for the current chapter const currentChapterKPs = useMemo(() => { if (!selectedId) return [] return knowledgePoints.filter(kp => kp.chapterId === selectedId) }, [knowledgePoints, selectedId]) const graphLayout = useMemo(() => { if (currentChapterKPs.length === 0) { return { nodes: [], edges: [], width: 0, height: 0 } } const byId = new Map() for (const kp of currentChapterKPs) byId.set(kp.id, kp) const children = new Map() const roots: string[] = [] for (const kp of currentChapterKPs) { if (kp.parentId && byId.has(kp.parentId)) { const arr = children.get(kp.parentId) ?? [] arr.push(kp.id) children.set(kp.parentId, arr) } else { roots.push(kp.id) } } const levelMap = new Map() const levels: string[][] = [] const queue = [...roots].map((id) => ({ id, level: 0 })) if (queue.length === 0) { for (const kp of currentChapterKPs) queue.push({ id: kp.id, level: 0 }) } while (queue.length > 0) { const item = queue.shift() if (!item) continue if (levelMap.has(item.id)) continue levelMap.set(item.id, item.level) if (!levels[item.level]) levels[item.level] = [] levels[item.level].push(item.id) const kids = children.get(item.id) ?? [] for (const kid of kids) { if (!levelMap.has(kid)) queue.push({ id: kid, level: item.level + 1 }) } } for (const kp of currentChapterKPs) { if (!levelMap.has(kp.id)) { const level = levels.length levelMap.set(kp.id, level) if (!levels[level]) levels[level] = [] levels[level].push(kp.id) } } const nodeWidth = 160 const nodeHeight = 52 const gapX = 40 const gapY = 90 const maxCount = Math.max(...levels.map((l) => l.length), 1) const width = maxCount * (nodeWidth + gapX) + gapX const height = levels.length * (nodeHeight + gapY) + gapY const positions = new Map() levels.forEach((ids, level) => { ids.forEach((id, index) => { const x = gapX + index * (nodeWidth + gapX) const y = gapY + level * (nodeHeight + gapY) positions.set(id, { x, y }) }) }) const nodes = currentChapterKPs.map((kp) => { const pos = positions.get(kp.id) ?? { x: gapX, y: gapY } return { ...kp, x: pos.x, y: pos.y } }) const edges = currentChapterKPs .filter((kp) => kp.parentId && positions.has(kp.parentId)) .map((kp) => { const parentPos = positions.get(kp.parentId as string)! const childPos = positions.get(kp.id)! return { id: `${kp.parentId}-${kp.id}`, x1: parentPos.x + nodeWidth / 2, y1: parentPos.y + nodeHeight, x2: childPos.x + nodeWidth / 2, y2: childPos.y, } }) return { nodes, edges, width, height } }, [currentChapterKPs]) // Pre-process content to mark knowledge points const processedContent = useMemo(() => { if (!selected?.content) return "" let content = selected.content // Sort KPs by name length descending to handle overlapping names const sortedKPs = [...currentChapterKPs].sort((a, b) => b.name.length - a.name.length) // We use a temporary replacement strategy to avoid nested replacements // This is simple but works for most cases // We replace "Name" with "[Name](kp://id)" for (const kp of sortedKPs) { // Escape regex special characters const escapedName = kp.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Case insensitive match, but preserve original text casing // We use a simplified lookahead to avoid replacing inside existing links if possible, // but perfect markdown parsing is hard with regex. // For now, we assume KPs don't overlap in a way that breaks things often. const regex = new RegExp(`(${escapedName})`, 'gi') // We only replace if not already part of a link (simplified check) // A robust parser would be better, but regex is acceptable for this level content = content.replace(regex, `[$1](#kp-${kp.id})`) } return content }, [selected?.content, currentChapterKPs]) // Scroll to highlighted KP useEffect(() => { if (highlightedKpId) { // Find first element by data attribute const el = document.querySelector(`[data-kp-id="${highlightedKpId}"]`) if (el) { el.scrollIntoView({ behavior: "smooth", block: "center" }) // Add temporary highlight effect el.classList.add("ring-2", "ring-primary", "ring-offset-2") setTimeout(() => { el.classList.remove("ring-2", "ring-primary", "ring-offset-2") }, 2000) } } }, [highlightedKpId]) return (
章节目录 知识点 {currentChapterKPs.length > 0 && ( {currentChapterKPs.length} )} 图谱
{!selectedId ? (
请选择一个章节查看知识点。
) : currentChapterKPs.length === 0 ? (
该章节暂无知识点。
) : (
{currentChapterKPs.map((kp) => (
setHighlightedKpId(kp.id)} >

{kp.name}

Lv.{kp.level} {canEdit && (
)}
{kp.description && (

{kp.description}

)}
))}
)}
{!selectedId ? (
请选择一个章节查看知识图谱。
) : currentChapterKPs.length === 0 ? (
该章节暂无知识点。
) : (
{graphLayout.edges.map((edge) => ( ))} {graphLayout.nodes.map((node) => ( ))}
)}
添加知识点 从选中的文本创建知识点。