Files
NextEdu/src/modules/textbooks/components/textbook-reader.tsx
2026-03-03 17:32:26 +08:00

745 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<string, Chapter>()
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<string | null>(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<HTMLDivElement>(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<KnowledgePoint | null>(null)
const [editKpDialogOpen, setEditKpDialogOpen] = useState(false)
const [isUpdatingKp, setIsUpdatingKp] = useState(false)
// Question Creation State
const [questionDialogOpen, setQuestionDialogOpen] = useState(false)
const [targetKpForQuestion, setTargetKpForQuestion] = useState<KnowledgePoint | null>(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<string, KnowledgePoint>()
for (const kp of currentChapterKPs) byId.set(kp.id, kp)
const children = new Map<string, string[]>()
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<string, number>()
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<string, { x: number; y: number }>()
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 (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12 h-full">
<div className="lg:col-span-4 lg:border-r lg:pr-6 flex flex-col min-h-0">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full">
<div className="flex items-center justify-between mb-4 px-2 shrink-0">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="chapters" className="gap-2">
<List className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="knowledge" className="gap-2" disabled={!selectedId}>
<Tag className="h-4 w-4" />
{currentChapterKPs.length > 0 && (
<Badge variant="secondary" className="ml-1 px-1 py-0 h-5 text-[10px]">{currentChapterKPs.length}</Badge>
)}
</TabsTrigger>
<TabsTrigger value="graph" className="gap-2" disabled={!selectedId}>
<Share2 className="h-4 w-4" />
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="chapters" className="flex-1 min-h-0 mt-0">
<ScrollArea className="flex-1 h-full px-2">
<div className="space-y-1 pb-4">
<ChapterSidebarList
chapters={chapters}
selectedChapterId={selectedId || undefined}
onSelectChapter={handleSelect}
textbookId={textbookId || ""}
canEdit={canEdit}
/>
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="knowledge" className="flex-1 min-h-0 mt-0">
{!selectedId ? (
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
</div>
) : currentChapterKPs.length === 0 ? (
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
</div>
) : (
<ScrollArea className="flex-1 h-full px-2">
<div className="space-y-2 pb-4">
{currentChapterKPs.map((kp) => (
<div
key={kp.id}
className={cn(
"p-3 rounded-lg border bg-card hover:bg-accent/50 transition-colors cursor-pointer",
highlightedKpId === kp.id && "border-primary bg-primary/5"
)}
onClick={() => setHighlightedKpId(kp.id)}
>
<div className="flex items-start justify-between gap-2">
<h4 className="text-sm font-medium leading-none">{kp.name}</h4>
<div className="flex items-center gap-1">
<Badge variant="outline" className="text-[10px] h-5 px-1">Lv.{kp.level}</Badge>
{canEdit && (
<div className="flex items-center gap-1 ml-1">
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
setTargetKpForQuestion(kp)
setQuestionDialogOpen(true)
}}
title="创建相关题目"
>
<PlusCircle className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
setEditingKp(kp)
setEditKpDialogOpen(true)
}}
title="编辑知识点"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-destructive"
onClick={(e) => handleDeleteKnowledgePoint(kp.id, e)}
title="删除知识点"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
)}
</div>
</div>
{kp.description && (
<p className="mt-2 text-xs text-muted-foreground line-clamp-2">
{kp.description}
</p>
)}
</div>
))}
</div>
</ScrollArea>
)}
</TabsContent>
<TabsContent value="graph" className="flex-1 min-h-0 mt-0">
{!selectedId ? (
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
</div>
) : currentChapterKPs.length === 0 ? (
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
</div>
) : (
<ScrollArea className="flex-1 h-full px-2">
<div
className="relative"
style={{ width: graphLayout.width, height: graphLayout.height }}
>
<svg
width={graphLayout.width}
height={graphLayout.height}
className="absolute inset-0"
>
{graphLayout.edges.map((edge) => (
<line
key={edge.id}
x1={edge.x1}
y1={edge.y1}
x2={edge.x2}
y2={edge.y2}
stroke="hsl(var(--border))"
strokeWidth={2}
/>
))}
</svg>
{graphLayout.nodes.map((node) => (
<button
key={node.id}
type="button"
className={cn(
"absolute rounded-lg border bg-card px-3 py-2 text-left text-sm shadow-sm hover:bg-accent/50",
highlightedKpId === node.id && "border-primary bg-primary/5"
)}
style={{ left: node.x, top: node.y, width: 160, height: 52 }}
onClick={() => setHighlightedKpId(node.id)}
>
<div className="font-medium truncate">{node.name}</div>
{node.description && (
<div className="text-[10px] text-muted-foreground truncate">
{node.description}
</div>
)}
</button>
))}
</div>
</ScrollArea>
)}
</TabsContent>
</Tabs>
</div>
<div className="lg:col-span-8 flex flex-col min-h-0 relative">
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<form action={handleCreateKnowledgePoint}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name"></Label>
<Input id="name" name="name" defaultValue={selectedText} required />
</div>
<div className="grid gap-2">
<Label htmlFor="description"></Label>
<Textarea id="description" name="description" placeholder="请输入描述..." />
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setCreateDialogOpen(false)} disabled={isCreating}>
</Button>
<Button type="submit" disabled={isCreating}>
{isCreating ? "创建中..." : "创建"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog open={editKpDialogOpen} onOpenChange={setEditKpDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<form action={handleUpdateKnowledgePoint}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="edit-name"></Label>
<Input id="edit-name" name="name" defaultValue={editingKp?.name} required />
</div>
<div className="grid gap-2">
<Label htmlFor="edit-description"></Label>
<Textarea id="edit-description" name="description" defaultValue={editingKp?.description || ""} placeholder="请输入描述..." />
</div>
<div className="space-y-2 border rounded-md p-3 bg-muted/20">
<div className="flex items-center justify-between">
<Label htmlFor="edit-anchorText" className="text-muted-foreground text-xs flex items-center gap-1">
()
</Label>
</div>
<div className="pt-2">
<Input
key={editingKp?.id} // Force re-render when kp changes
id="edit-anchorText"
name="anchorText"
defaultValue={editingKp?.anchorText || editingKp?.name}
className="text-sm font-mono"
required
/>
<p className="text-[10px] text-muted-foreground mt-1">
</p>
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditKpDialogOpen(false)} disabled={isUpdatingKp}>
</Button>
<Button type="submit" disabled={isUpdatingKp}>
{isUpdatingKp ? "保存中..." : "保存"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<CreateQuestionDialog
open={questionDialogOpen}
onOpenChange={setQuestionDialogOpen}
defaultKnowledgePointIds={targetKpForQuestion ? [targetKpForQuestion.id] : []}
defaultContent={targetKpForQuestion ? `Please explain the knowledge point: ${targetKpForQuestion.name}` : ""}
defaultType="text"
/>
{selected ? (
<>
<div className="flex items-center justify-between mb-4 pb-2 border-b px-2 shrink-0">
<h2 className="text-xl font-bold tracking-tight line-clamp-1">{selected.title}</h2>
{canEdit && (
<div className="flex gap-2">
{isEditing ? (
<>
<Button size="sm" variant="ghost" onClick={() => setIsEditing(false)} disabled={isSaving}>
</Button>
<Button size="sm" onClick={handleSaveContent} disabled={isSaving}>
<Save className="mr-2 h-4 w-4" />
{isSaving ? "保存中..." : "保存"}
</Button>
</>
) : (
<Button size="sm" variant="outline" onClick={startEditing}>
<Edit2 className="mr-2 h-4 w-4" />
</Button>
)}
</div>
)}
</div>
<ScrollArea className="flex-1 min-h-0 px-2">
{isEditing ? (
<div className="h-full">
<RichTextEditor
value={editContent}
onChange={setEditContent}
className="min-h-[500px] border-none shadow-none"
/>
</div>
) : (
<ContextMenu onOpenChange={handleContextMenuChange}>
<ContextMenuTrigger asChild>
<div
className="p-4 min-h-full"
ref={contentRef}
onPointerDown={handleContentPointerDown}
>
{selected.content ? (
<div className="prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
components={{
a: ({ href, children, ...props }) => {
if (href?.startsWith("#kp-")) {
const id = href.replace("#kp-", "")
const isHighlighted = highlightedKpId === id
return (
<span
data-kp-id={id}
className={cn(
"font-medium text-primary cursor-pointer hover:underline decoration-dashed underline-offset-4 transition-all duration-300",
isHighlighted && "bg-yellow-300 dark:bg-yellow-600 text-black dark:text-white rounded px-1 py-0.5 shadow-sm scale-110 inline-block mx-0.5 font-bold ring-2 ring-yellow-400/50 dark:ring-yellow-500/50"
)}
onClick={(e) => {
e.preventDefault()
setHighlightedKpId(id)
setActiveTab("knowledge")
}}
title="点击查看知识点详情"
>
{children}
</span>
)
}
return <a href={href} {...props}>{children}</a>
}
}}
>
{processedContent}
</ReactMarkdown>
</div>
) : (
<div className="text-muted-foreground italic py-8 text-center"></div>
)}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
disabled={!selectedText}
onClick={() => setCreateDialogOpen(true)}
>
<Plus className="mr-2 h-4 w-4" />
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)}
</ScrollArea>
</>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground px-2">
</div>
)}
</div>
</div>
)
}