feat(exams,homework): add rich text exam editor and scan-based grading
- Add Tiptap-based rich text editor with custom extensions (dotted-mark, blank-node, image-node, group-block, question-block) for exam creation - Add AI auto-marking action to convert pasted exam text to structured editor doc - Add resizable split-panel layout for editor + live preview - Add student scan upload (photo of paper answers) with drag-drop and reorder - Add scan image viewer with zoom/rotate/fullscreen for teachers - Add scan grading view with side-by-side questions and scan images - Add /teacher/exams/new and /teacher/homework/submissions/[id]/scan-grading routes - Fix getScansAction to support both teacher (HOMEWORK_GRADE) and student (HOMEWORK_SUBMIT) permission scopes - Add i18n keys for rich editor, scan upload, and scan grading (zh-CN/en) - Sync architecture diagrams (004/005) with new modules, routes, and deps
This commit is contained in:
78
src/shared/components/ui/resizable-panel.tsx
Normal file
78
src/shared/components/ui/resizable-panel.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
interface ResizablePanelProps {
|
||||
/** 左侧最小宽度百分比 */
|
||||
minLeft?: number
|
||||
/** 右侧最小宽度百分比 */
|
||||
minRight?: number
|
||||
/** 初始左侧宽度百分比 */
|
||||
initialLeft?: number
|
||||
left: ReactNode
|
||||
right: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 可拖拽分栏容器(左右两栏,中间分隔条可拖拽调整宽度)。
|
||||
* 自实现,无新依赖。用于试卷编辑器左编辑右预览、阅卷式批改左题目右图片等场景。
|
||||
*/
|
||||
export function ResizablePanel({
|
||||
minLeft = 20,
|
||||
minRight = 20,
|
||||
initialLeft = 50,
|
||||
left,
|
||||
right,
|
||||
className,
|
||||
}: ResizablePanelProps) {
|
||||
const [leftPct, setLeftPct] = useState(initialLeft)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const draggingRef = useRef(false)
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
e.preventDefault()
|
||||
draggingRef.current = true
|
||||
document.body.style.cursor = "col-resize"
|
||||
document.body.style.userSelect = "none"
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (e: PointerEvent) => {
|
||||
if (!draggingRef.current || !containerRef.current) return
|
||||
const rect = containerRef.current.getBoundingClientRect()
|
||||
const pct = ((e.clientX - rect.left) / rect.width) * 100
|
||||
const clamped = Math.min(100 - minRight, Math.max(minLeft, pct))
|
||||
setLeftPct(clamped)
|
||||
}
|
||||
const onUp = () => {
|
||||
draggingRef.current = false
|
||||
document.body.style.cursor = ""
|
||||
document.body.style.userSelect = ""
|
||||
}
|
||||
window.addEventListener("pointermove", onMove)
|
||||
window.addEventListener("pointerup", onUp)
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", onMove)
|
||||
window.removeEventListener("pointerup", onUp)
|
||||
}
|
||||
}, [minLeft, minRight])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cn("flex h-full w-full", className)}>
|
||||
<div style={{ width: `${leftPct}%` }} className="h-full min-w-0 overflow-hidden">
|
||||
{left}
|
||||
</div>
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
onPointerDown={onPointerDown}
|
||||
className="w-1.5 shrink-0 cursor-col-resize bg-border transition-colors hover:bg-primary/40"
|
||||
/>
|
||||
<div style={{ width: `${100 - leftPct}%` }} className="h-full min-w-0 overflow-hidden">
|
||||
{right}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user