- exams: update assembly (exam-paper-preview, question-bank-list, structure-editor), exam-actions, exam-assembly, exam-columns, exam-data-table, exam-form, exam-preview-utils, exam-rich-form, editor (exam-nodes-to-editor-doc, exam-rich-editor-inner, question-block, selection-toolbar), hooks (use-exam-preview-rewrite, use-exam-preview-state, use-exam-preview-tasks, use-exam-preview) - files: update data-access, use-file-batch-operations, use-file-upload - grades: update batch-grade-entry, excel-import-dialog, export-button, grade-record-form, grade-record-list, knowledge-point-mastery-chart, report-card-print-action, report-card-print-button, data-access-analytics, use-batch-grade-entry-undo, use-draft-lock - homework: update homework-assignment-form, homework-batch-grading-view, homework-grading-view, homework-scan-grading-view, homework-take-view, scan-uploader - invitation-codes: update generate-invitation-codes-dialog, invitation-codes-view, data-access - leave-requests: update leave-request-form, leave-review-dialog, data-access
771 lines
26 KiB
TypeScript
771 lines
26 KiB
TypeScript
"use client"
|
|
|
|
import React, { useCallback, useMemo, useState } from "react"
|
|
import {
|
|
DndContext,
|
|
pointerWithin,
|
|
rectIntersection,
|
|
CollisionDetection,
|
|
KeyboardSensor,
|
|
PointerSensor,
|
|
useSensor,
|
|
useSensors,
|
|
DragOverlay,
|
|
defaultDropAnimationSideEffects,
|
|
DragStartEvent,
|
|
DragOverEvent,
|
|
DragEndEvent,
|
|
DropAnimation,
|
|
MeasuringStrategy,
|
|
} from "@dnd-kit/core"
|
|
import {
|
|
arrayMove,
|
|
SortableContext,
|
|
sortableKeyboardCoordinates,
|
|
verticalListSortingStrategy,
|
|
useSortable,
|
|
} from "@dnd-kit/sortable"
|
|
import { CSS } from "@dnd-kit/utilities"
|
|
import { Button } from "@/shared/components/ui/button"
|
|
import { Input } from "@/shared/components/ui/input"
|
|
import { Label } from "@/shared/components/ui/label"
|
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/components/ui/collapsible"
|
|
import { Trash2, GripVertical, ChevronDown, ChevronRight, Calculator } from "lucide-react"
|
|
import { cn } from "@/shared/lib/utils"
|
|
import { useTranslations } from "next-intl"
|
|
import type { ExamNode } from "./selected-question-list"
|
|
import { isRecord, isQuestionContentObj, toSortableId } from "../../lib/type-guards"
|
|
|
|
// --- Types ---
|
|
|
|
type StructureEditorProps = {
|
|
items: ExamNode[]
|
|
onChange: (items: ExamNode[]) => void
|
|
onScoreChange: (id: string, score: number) => void
|
|
onGroupTitleChange: (id: string, title: string) => void
|
|
onRemove: (id: string) => void
|
|
onAddGroup: () => void
|
|
onAddSection?: () => void
|
|
}
|
|
|
|
function cloneExamNodes(nodes: ExamNode[]): ExamNode[] {
|
|
return nodes.map((node) => {
|
|
if (node.type === "group" || node.type === "section") {
|
|
return { ...node, children: cloneExamNodes(node.children || []) }
|
|
}
|
|
return { ...node }
|
|
})
|
|
}
|
|
|
|
// Safely extract a text preview from a question's content (which may be a string,
|
|
// object, or JSON string). Avoids `as` assertions by runtime narrowing.
|
|
const extractQuestionText = (raw: unknown): string => {
|
|
if (!raw) return ""
|
|
if (typeof raw === "string") {
|
|
// Content might be a JSON string or plain text
|
|
try {
|
|
const parsed: unknown = JSON.parse(raw)
|
|
if (isRecord(parsed) && typeof parsed.text === "string") {
|
|
return parsed.text
|
|
}
|
|
return raw
|
|
} catch {
|
|
return raw
|
|
}
|
|
}
|
|
if (isRecord(raw) && typeof raw.text === "string") {
|
|
return raw.text
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// --- Components ---
|
|
|
|
function SortableItem({
|
|
id,
|
|
item,
|
|
onRemove,
|
|
onScoreChange
|
|
}: {
|
|
id: string
|
|
item: ExamNode
|
|
onRemove: () => void
|
|
onScoreChange: (val: number) => void
|
|
}) {
|
|
const t = useTranslations("examHomework.exam.build")
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({ id })
|
|
|
|
const style = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
opacity: isDragging ? 0.5 : 1,
|
|
}
|
|
|
|
const rawContent = item.question?.content
|
|
const parsedContent = (() => {
|
|
if (isQuestionContentObj(rawContent)) return rawContent
|
|
if (typeof rawContent === "string") {
|
|
try {
|
|
const parsed: unknown = JSON.parse(rawContent)
|
|
if (isQuestionContentObj(parsed)) return parsed
|
|
return { text: rawContent }
|
|
} catch {
|
|
return { text: rawContent }
|
|
}
|
|
}
|
|
return { text: "" }
|
|
})()
|
|
|
|
const options = Array.isArray(parsedContent.options) ? parsedContent.options : []
|
|
|
|
return (
|
|
<div ref={setNodeRef} style={style} className={cn("group flex flex-col gap-3 rounded-md border p-3 bg-card hover:border-primary/50 transition-colors", isDragging && "ring-2 ring-primary")}>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex gap-2 items-start flex-1">
|
|
<button {...attributes} {...listeners} className="mt-1 cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground">
|
|
<GripVertical className="h-4 w-4" />
|
|
</button>
|
|
<p className="text-sm line-clamp-2 pt-0.5 select-none">
|
|
{parsedContent.text || t("questionContent")}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6 text-muted-foreground hover:text-destructive shrink-0"
|
|
onClick={onRemove}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
{(item.question?.type === "single_choice" || item.question?.type === "multiple_choice") && options.length > 0 && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-1 pl-8 text-xs text-muted-foreground">
|
|
{options.map((opt, idx) => (
|
|
<div key={opt.id ?? idx} className="flex gap-2">
|
|
<span className="font-medium">{opt.id ?? String.fromCharCode(65 + idx)}.</span>
|
|
<span>{opt.text ?? ""}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center justify-end pl-8">
|
|
<div className="flex items-center gap-2">
|
|
<Label htmlFor={`score-${item.id}`} className="text-xs text-muted-foreground">
|
|
{t("score")}
|
|
</Label>
|
|
<Input
|
|
id={`score-${item.id}`}
|
|
type="number"
|
|
min={0}
|
|
className="h-7 w-16 text-right"
|
|
value={item.score}
|
|
onChange={(e) => onScoreChange(parseInt(e.target.value, 10) || 0)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SortableGroup({
|
|
id,
|
|
item,
|
|
children,
|
|
onRemove,
|
|
onTitleChange
|
|
}: {
|
|
id: string
|
|
item: ExamNode
|
|
children: React.ReactNode
|
|
onRemove: () => void
|
|
onTitleChange: (val: string) => void
|
|
}) {
|
|
const t = useTranslations("examHomework.exam.build")
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({ id })
|
|
|
|
const [isOpen, setIsOpen] = useState(true)
|
|
|
|
const style = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
opacity: isDragging ? 0.5 : 1,
|
|
}
|
|
|
|
const childrenKey = JSON.stringify(item.children || [])
|
|
const totalScore = useMemo(() => {
|
|
const calc = (nodes: ExamNode[]): number => {
|
|
return nodes.reduce((acc, node) => {
|
|
if (node.type === 'question') return acc + (node.score || 0)
|
|
if (node.type === 'group') return acc + calc(node.children || [])
|
|
return acc
|
|
}, 0)
|
|
}
|
|
return calc(item.children || [])
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [childrenKey])
|
|
|
|
return (
|
|
<Collapsible open={isOpen} onOpenChange={setIsOpen} ref={setNodeRef} style={style} className={cn("rounded-lg border bg-muted/10 p-3 space-y-2", isDragging && "ring-2 ring-primary")}>
|
|
<div className="flex items-center gap-3">
|
|
<button {...attributes} {...listeners} className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground">
|
|
<GripVertical className="h-5 w-5" />
|
|
</button>
|
|
|
|
<CollapsibleTrigger asChild>
|
|
<Button variant="ghost" size="sm" className="p-0 h-6 w-6 hover:bg-transparent">
|
|
{isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
|
</Button>
|
|
</CollapsibleTrigger>
|
|
|
|
<Input
|
|
value={item.title || ""}
|
|
onChange={(e) => onTitleChange(e.target.value)}
|
|
placeholder={t("groupTitlePlaceholder")}
|
|
className="font-semibold h-9 bg-transparent border-transparent hover:border-input focus:bg-background flex-1"
|
|
/>
|
|
|
|
<div className="flex items-center gap-1 text-muted-foreground text-xs bg-background/50 px-2 py-1 rounded">
|
|
<Calculator className="h-3 w-3" />
|
|
<span>{totalScore} {t("pts")}</span>
|
|
</div>
|
|
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={onRemove}>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<CollapsibleContent className="pl-4 border-l-2 border-muted space-y-3 min-h-[50px] animate-in slide-in-from-top-2 fade-in duration-200">
|
|
{children}
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
)
|
|
}
|
|
|
|
function SortableSection({
|
|
id,
|
|
item,
|
|
children,
|
|
onRemove,
|
|
onTitleChange
|
|
}: {
|
|
id: string
|
|
item: ExamNode
|
|
children: React.ReactNode
|
|
onRemove: () => void
|
|
onTitleChange: (val: string) => void
|
|
}) {
|
|
const t = useTranslations("examHomework.exam.build")
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({ id })
|
|
|
|
const [isOpen, setIsOpen] = useState(true)
|
|
|
|
const style = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
opacity: isDragging ? 0.5 : 1,
|
|
}
|
|
|
|
const childrenKey = JSON.stringify(item.children || [])
|
|
const totalScore = useMemo(() => {
|
|
const calc = (nodes: ExamNode[]): number => {
|
|
return nodes.reduce((acc, node) => {
|
|
if (node.type === 'question') return acc + (node.score || 0)
|
|
if (node.type === 'group' || node.type === 'section') return acc + calc(node.children || [])
|
|
return acc
|
|
}, 0)
|
|
}
|
|
return calc(item.children || [])
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [childrenKey])
|
|
|
|
return (
|
|
<Collapsible open={isOpen} onOpenChange={setIsOpen} ref={setNodeRef} style={style} className={cn("rounded-lg border-2 border-primary/20 bg-primary/5 p-3 space-y-2", isDragging && "ring-2 ring-primary")}>
|
|
<div className="flex items-center gap-3">
|
|
<button {...attributes} {...listeners} className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground">
|
|
<GripVertical className="h-5 w-5" />
|
|
</button>
|
|
|
|
<CollapsibleTrigger asChild>
|
|
<Button variant="ghost" size="sm" className="p-0 h-6 w-6 hover:bg-transparent">
|
|
{isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
|
</Button>
|
|
</CollapsibleTrigger>
|
|
|
|
<Input
|
|
value={item.title || ""}
|
|
onChange={(e) => onTitleChange(e.target.value)}
|
|
placeholder={t("sectionTitlePlaceholder")}
|
|
className="font-bold h-9 bg-transparent border-transparent hover:border-input focus:bg-background flex-1"
|
|
/>
|
|
|
|
<div className="flex items-center gap-1 text-muted-foreground text-xs bg-background/50 px-2 py-1 rounded">
|
|
<Calculator className="h-3 w-3" />
|
|
<span>{totalScore} {t("pts")}</span>
|
|
</div>
|
|
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={onRemove}>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<CollapsibleContent className="pl-4 border-l-2 border-primary/30 space-y-3 min-h-[50px] animate-in slide-in-from-top-2 fade-in duration-200">
|
|
{children}
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
)
|
|
}
|
|
|
|
function StructureRenderer({ nodes, ...props }: {
|
|
nodes: ExamNode[]
|
|
onRemove: (id: string) => void
|
|
onScoreChange: (id: string, score: number) => void
|
|
onGroupTitleChange: (id: string, title: string) => void
|
|
}) {
|
|
const t = useTranslations("examHomework.exam.build")
|
|
// Deduplicate nodes to prevent React key errors
|
|
const nodesKey = JSON.stringify(nodes.map(n => n.id))
|
|
const uniqueNodes = useMemo(() => {
|
|
const seen = new Set()
|
|
return nodes.filter(n => {
|
|
if (seen.has(n.id)) return false
|
|
seen.add(n.id)
|
|
return true
|
|
})
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [nodesKey])
|
|
|
|
return (
|
|
<SortableContext items={uniqueNodes.map(n => n.id)} strategy={verticalListSortingStrategy}>
|
|
{uniqueNodes.map(node => (
|
|
<div key={node.id}>
|
|
{node.type === 'section' ? (
|
|
<SortableSection
|
|
id={node.id}
|
|
item={node}
|
|
onRemove={() => props.onRemove(node.id)}
|
|
onTitleChange={(val) => props.onGroupTitleChange(node.id, val)}
|
|
>
|
|
<StructureRenderer
|
|
nodes={node.children || []}
|
|
onRemove={props.onRemove}
|
|
onScoreChange={props.onScoreChange}
|
|
onGroupTitleChange={props.onGroupTitleChange}
|
|
/>
|
|
{(!node.children || node.children.length === 0) && (
|
|
<div className="text-xs text-muted-foreground italic py-2 text-center border-2 border-dashed border-muted/50 rounded">
|
|
{t("dragItemsHere")}
|
|
</div>
|
|
)}
|
|
</SortableSection>
|
|
) : node.type === 'group' ? (
|
|
<SortableGroup
|
|
id={node.id}
|
|
item={node}
|
|
onRemove={() => props.onRemove(node.id)}
|
|
onTitleChange={(val) => props.onGroupTitleChange(node.id, val)}
|
|
>
|
|
<StructureRenderer
|
|
nodes={node.children || []}
|
|
onRemove={props.onRemove}
|
|
onScoreChange={props.onScoreChange}
|
|
onGroupTitleChange={props.onGroupTitleChange}
|
|
/>
|
|
{(!node.children || node.children.length === 0) && (
|
|
<div className="text-xs text-muted-foreground italic py-2 text-center border-2 border-dashed border-muted/50 rounded">
|
|
{t("dragItemsHere")}
|
|
</div>
|
|
)}
|
|
</SortableGroup>
|
|
) : (
|
|
<SortableItem
|
|
id={node.id}
|
|
item={node}
|
|
onRemove={() => props.onRemove(node.id)}
|
|
onScoreChange={(val) => props.onScoreChange(node.id, val)}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
</SortableContext>
|
|
)
|
|
}
|
|
|
|
// --- Main Component ---
|
|
|
|
const dropAnimation: DropAnimation = {
|
|
sideEffects: defaultDropAnimationSideEffects({
|
|
styles: {
|
|
active: {
|
|
opacity: '0.5',
|
|
},
|
|
},
|
|
}),
|
|
}
|
|
|
|
export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleChange, onRemove, onAddGroup, onAddSection }: StructureEditorProps) {
|
|
const t = useTranslations("examHomework.exam.build")
|
|
const [activeId, setActiveId] = useState<string | null>(null)
|
|
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor),
|
|
useSensor(KeyboardSensor, {
|
|
coordinateGetter: sortableKeyboardCoordinates,
|
|
})
|
|
)
|
|
|
|
// Recursively find item
|
|
const findItem = useCallback((id: string, nodes: ExamNode[] = items): ExamNode | null => {
|
|
const walk = (list: ExamNode[]): ExamNode | null => {
|
|
for (const node of list) {
|
|
if (node.id === id) return node
|
|
if (node.children) {
|
|
const found = walk(node.children)
|
|
if (found) return found
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
return walk(nodes)
|
|
}, [items])
|
|
|
|
const activeItem = activeId ? findItem(activeId) : null
|
|
|
|
// DND Handlers
|
|
|
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
|
setActiveId(event.active.id as string)
|
|
}, [])
|
|
|
|
// Custom collision detection for nested sortables
|
|
const customCollisionDetection: CollisionDetection = useCallback((args) => {
|
|
// 1. First check pointer within for precise container detection
|
|
const pointerCollisions = pointerWithin(args)
|
|
|
|
// If we have pointer collisions, prioritize the most specific one (usually the smallest/innermost container)
|
|
if (pointerCollisions.length > 0) {
|
|
return pointerCollisions
|
|
}
|
|
|
|
// 2. Fallback to rect intersection for smoother sortable reordering when not directly over a container
|
|
return rectIntersection(args)
|
|
}, [])
|
|
|
|
function handleDragOver(event: DragOverEvent) {
|
|
const { active, over } = event
|
|
if (!over) return
|
|
|
|
const activeId = active.id as string
|
|
const overId = toSortableId(over.id)
|
|
|
|
if (activeId === overId) return
|
|
|
|
// Find if we are moving over a Group container
|
|
// "overId" could be a SortableItem (Question) OR a SortableGroup (Group)
|
|
|
|
const activeNode = findItem(activeId)
|
|
const overNode = findItem(overId)
|
|
|
|
if (!activeNode || !overNode) return
|
|
|
|
// CRITICAL FIX: Prevent dragging a node onto its own descendant
|
|
// This happens when dragging a group and hovering over its own children.
|
|
// If we proceed, we would remove the group (and its children) and then fail to find the child to insert next to.
|
|
const isDescendantOfActive = (childId: string): boolean => {
|
|
const check = (node: ExamNode): boolean => {
|
|
if (!node.children) return false
|
|
return node.children.some(c => c.id === childId || check(c))
|
|
}
|
|
return check(activeNode)
|
|
}
|
|
|
|
if (isDescendantOfActive(overId)) return
|
|
|
|
// Find which list the `over` item belongs to
|
|
const findContainerId = (id: string, list: ExamNode[], parentId: string = 'root'): string | undefined => {
|
|
if (list.some(i => i.id === id)) return parentId
|
|
for (const node of list) {
|
|
if (node.children) {
|
|
const res = findContainerId(id, node.children, node.id)
|
|
if (res) return res
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
const activeContainerId = findContainerId(activeId, items)
|
|
const overContainerId = findContainerId(overId, items)
|
|
|
|
// Scenario 1: Moving item into a Group/Section by hovering over the Group/Section itself
|
|
// If overNode is a Group or Section, we might want to move INTO it
|
|
if (overNode.type === 'group' || overNode.type === 'section') {
|
|
// Logic: If active item is NOT in this container already
|
|
// AND we are not trying to move a container into its own descendant (circular check)
|
|
|
|
const isDescendant = (parent: ExamNode, childId: string): boolean => {
|
|
if (!parent.children) return false
|
|
for (const c of parent.children) {
|
|
if (c.id === childId) return true
|
|
if (isDescendant(c, childId)) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// If moving a container, check if overNode is a descendant of activeNode
|
|
if ((activeNode.type === 'group' || activeNode.type === 'section') && isDescendant(activeNode, overNode.id)) {
|
|
return
|
|
}
|
|
|
|
if (activeContainerId !== overNode.id) {
|
|
// ... implementation continues ...
|
|
|
|
const newItems = cloneExamNodes(items)
|
|
|
|
// Remove active from old location
|
|
const removeRecursive = (list: ExamNode[]): ExamNode | null => {
|
|
const idx = list.findIndex(i => i.id === activeId)
|
|
if (idx !== -1) return list.splice(idx, 1)[0]
|
|
for (const node of list) {
|
|
if (node.children) {
|
|
const res = removeRecursive(node.children)
|
|
if (res) return res
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
const movedItem = removeRecursive(newItems)
|
|
if (!movedItem) return
|
|
|
|
// Insert into new Group (overNode)
|
|
// We need to find the overNode in the NEW structure (since we cloned it)
|
|
const findGroupAndInsert = (list: ExamNode[]) => {
|
|
for (const node of list) {
|
|
if (node.id === overId) {
|
|
if (!node.children) node.children = []
|
|
// Extra safety: Check if movedItem.id is already in children
|
|
if (!node.children.some(c => c.id === movedItem.id)) {
|
|
node.children.push(movedItem)
|
|
}
|
|
return true
|
|
}
|
|
if (node.children) {
|
|
if (findGroupAndInsert(node.children)) return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
findGroupAndInsert(newItems)
|
|
onChange(newItems)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Scenario 2: Moving between different lists (e.g. from Root to Group A, or Group A to Group B)
|
|
if (activeContainerId !== overContainerId) {
|
|
// FIX: If we are already inside the group we are hovering (i.e. activeContainerId IS overId),
|
|
// do not try to move "next to" the group (which would move us out).
|
|
if (activeContainerId === overId) return
|
|
|
|
// Standard Sortable Move
|
|
const newItems = cloneExamNodes(items)
|
|
|
|
const removeRecursive = (list: ExamNode[]): ExamNode | null => {
|
|
const idx = list.findIndex(i => i.id === activeId)
|
|
if (idx !== -1) return list.splice(idx, 1)[0]
|
|
for (const node of list) {
|
|
if (node.children) {
|
|
const res = removeRecursive(node.children)
|
|
if (res) return res
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
const movedItem = removeRecursive(newItems)
|
|
if (!movedItem) return
|
|
|
|
// Insert into destination list at specific index
|
|
// We need to find the destination list array and the index of `overId`
|
|
const insertRecursive = (list: ExamNode[]): boolean => {
|
|
const idx = list.findIndex(i => i.id === overId)
|
|
if (idx !== -1) {
|
|
// Insert before or after based on direction?
|
|
// Usually dnd-kit handles order if we are in same container, but cross-container we need to pick a spot.
|
|
// We'll insert at the index of `overId`.
|
|
|
|
// However, if we insert AT the index, dnd-kit might get confused if we are dragging DOWN vs UP.
|
|
// But since we are changing containers, just inserting at the target index is usually fine.
|
|
// The issue "swapping positions is not smooth" might be because we insert *at* index, displacing the target.
|
|
// Let's try to determine if we are "below" or "above" the target?
|
|
// For cross-container, simpler is better. Inserting at index is standard.
|
|
|
|
list.splice(idx, 0, movedItem)
|
|
return true
|
|
}
|
|
for (const node of list) {
|
|
if (node.children) {
|
|
if (insertRecursive(node.children)) return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
insertRecursive(newItems)
|
|
onChange(newItems)
|
|
}
|
|
}
|
|
|
|
function handleDragEnd(event: DragEndEvent) {
|
|
const { active, over } = event
|
|
setActiveId(null)
|
|
|
|
if (!over) return
|
|
|
|
const activeId = active.id as string
|
|
const overId = toSortableId(over.id)
|
|
|
|
if (activeId === overId) return
|
|
|
|
// Re-find positions in the potentially updated state
|
|
// Note: Since we mutate in DragOver, the item might already be in the new container.
|
|
// So activeContainerId might equal overContainerId now!
|
|
|
|
const findContainerId = (id: string, list: ExamNode[], parentId: string = 'root'): string | undefined => {
|
|
if (list.some(i => i.id === id)) return parentId
|
|
for (const node of list) {
|
|
if (node.children) {
|
|
const res = findContainerId(id, node.children, node.id)
|
|
if (res) return res
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
const activeContainerId = findContainerId(activeId, items)
|
|
const overContainerId = findContainerId(overId, items)
|
|
|
|
if (activeContainerId === overContainerId) {
|
|
// Same container reorder
|
|
const newItems = cloneExamNodes(items)
|
|
|
|
const getMutableList = (groupId?: string): ExamNode[] => {
|
|
if (groupId === 'root') return newItems
|
|
// Need recursive find
|
|
const findGroup = (list: ExamNode[]): ExamNode | null => {
|
|
for (const node of list) {
|
|
if (node.id === groupId) return node
|
|
if (node.children) {
|
|
const res = findGroup(node.children)
|
|
if (res) return res
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
return findGroup(newItems)?.children || []
|
|
}
|
|
|
|
const list = getMutableList(activeContainerId)
|
|
const oldIndex = list.findIndex(i => i.id === activeId)
|
|
const newIndex = list.findIndex(i => i.id === overId)
|
|
|
|
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
|
|
const moved = arrayMove(list, oldIndex, newIndex)
|
|
|
|
// Update the list reference in parent
|
|
if (activeContainerId === 'root') {
|
|
onChange(moved)
|
|
} else if (activeContainerId) {
|
|
// list is already a reference to children array if we did it right?
|
|
// getMutableList returned `group.children`. Modifying `list` directly via arrayMove returns NEW array.
|
|
// So we need to re-assign.
|
|
const group = findItem(activeContainerId, newItems)
|
|
if (group) group.children = moved
|
|
onChange(newItems)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return (
|
|
<DndContext
|
|
id="structure-editor-dnd"
|
|
sensors={sensors}
|
|
collisionDetection={customCollisionDetection}
|
|
onDragStart={handleDragStart}
|
|
onDragOver={handleDragOver}
|
|
onDragEnd={handleDragEnd}
|
|
measuring={{ droppable: { strategy: MeasuringStrategy.Always } }}
|
|
>
|
|
<div className="space-y-4">
|
|
<StructureRenderer
|
|
nodes={items}
|
|
onRemove={onRemove}
|
|
onScoreChange={onScoreChange}
|
|
onGroupTitleChange={onGroupTitleChange}
|
|
/>
|
|
|
|
<div className="flex justify-center gap-2 pt-2">
|
|
<Button variant="outline" size="sm" onClick={onAddGroup} className="border-dashed">
|
|
{t("addGroup")}
|
|
</Button>
|
|
{onAddSection && (
|
|
<Button variant="outline" size="sm" onClick={onAddSection} className="border-dashed">
|
|
{t("addSection")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<DragOverlay dropAnimation={dropAnimation}>
|
|
{activeItem ? (
|
|
activeItem.type === 'section' ? (
|
|
<div className="rounded-lg border-2 border-primary/20 bg-primary/5 p-4 shadow-lg opacity-80 w-[300px]">
|
|
<div className="flex items-center gap-3">
|
|
<GripVertical className="h-5 w-5" />
|
|
<span className="font-bold">{activeItem.title || t("section")}</span>
|
|
</div>
|
|
</div>
|
|
) : activeItem.type === 'group' ? (
|
|
<div className="rounded-lg border bg-background p-4 shadow-lg opacity-80 w-[300px]">
|
|
<div className="flex items-center gap-3">
|
|
<GripVertical className="h-5 w-5" />
|
|
<span className="font-semibold">{activeItem.title || t("group")}</span>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="rounded-md border bg-background p-3 shadow-lg opacity-80 w-[300px] flex items-center gap-3">
|
|
<GripVertical className="h-4 w-4" />
|
|
<p className="text-sm line-clamp-1">
|
|
{extractQuestionText(activeItem.question?.content) || t("question")}
|
|
</p>
|
|
</div>
|
|
)
|
|
) : null}
|
|
</DragOverlay>
|
|
</DndContext>
|
|
)
|
|
}
|