- 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
79 lines
2.4 KiB
TypeScript
79 lines
2.4 KiB
TypeScript
"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>
|
|
)
|
|
}
|