284 lines
10 KiB
TypeScript
284 lines
10 KiB
TypeScript
import React, { useCallback, useMemo, useReducer, useEffect, ReactNode } from "react";
|
|
import {
|
|
DndContext,
|
|
DragEndEvent,
|
|
DragStartEvent,
|
|
MeasuringStrategy,
|
|
PointerSensor,
|
|
useSensor,
|
|
useSensors,
|
|
} from "@dnd-kit/core";
|
|
import {
|
|
restrictToWindowEdges,
|
|
snapCenterToCursor,
|
|
} from "@dnd-kit/modifiers";
|
|
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
|
|
import Header from "../../Shared/Header";
|
|
import Alert, { AlertItem } from "../Shared/Alert";
|
|
import clsx from "clsx";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Blank, DropZone } from "./DragNDrop";
|
|
import { getTextSegments, BlankState, BlanksState, BlanksAction, BlankToken } from "./BlanksReducer";
|
|
import PromptEdit from "../Shared/PromptEdit";
|
|
import { Difficulty } from "@/interfaces/exam";
|
|
|
|
|
|
interface Props {
|
|
title?: string;
|
|
initialText: string;
|
|
description: string;
|
|
difficulty?: Difficulty;
|
|
saveDifficulty: (difficulty: Difficulty) => void;
|
|
state: BlanksState;
|
|
module: string;
|
|
editing: boolean;
|
|
showBlankBank: boolean;
|
|
alerts: AlertItem[];
|
|
prompt: string;
|
|
updatePrompt: (prompt: string) => void;
|
|
setEditing: React.Dispatch<React.SetStateAction<boolean>>;
|
|
blanksDispatcher: React.Dispatch<BlanksAction>
|
|
onBlankSelect?: (blankId: number | null) => void;
|
|
onBlankRemove: (blankId: number) => void;
|
|
onSave: () => void;
|
|
onDiscard: () => void;
|
|
onDelete: () => void;
|
|
onPractice: () => void;
|
|
isEvaluationEnabled?: boolean;
|
|
children: ReactNode;
|
|
}
|
|
|
|
const BlanksEditor: React.FC<Props> = ({
|
|
title = "Fill Blanks",
|
|
initialText,
|
|
description,
|
|
difficulty,
|
|
saveDifficulty,
|
|
state,
|
|
editing,
|
|
module,
|
|
children,
|
|
showBlankBank = true,
|
|
alerts,
|
|
blanksDispatcher,
|
|
onBlankSelect,
|
|
onBlankRemove,
|
|
onSave,
|
|
onDiscard,
|
|
onDelete,
|
|
onPractice,
|
|
isEvaluationEnabled,
|
|
setEditing,
|
|
prompt,
|
|
updatePrompt
|
|
}) => {
|
|
|
|
useEffect(() => {
|
|
const tokens = getTextSegments(initialText);
|
|
const initialBlanks = tokens.reduce((acc, token, idx) => {
|
|
if (token.type === 'blank') {
|
|
acc.push({ id: token.id, position: idx });
|
|
}
|
|
return acc;
|
|
}, [] as BlankState[]);
|
|
|
|
blanksDispatcher({ type: "SET_TEXT", payload: initialText });
|
|
blanksDispatcher({ type: "SET_BLANKS", payload: initialBlanks });
|
|
}, [initialText, blanksDispatcher]);
|
|
|
|
const tokens = useMemo(() => {
|
|
return getTextSegments(state.text || "");
|
|
}, [state.text]);
|
|
|
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
|
blanksDispatcher({ type: "SET_DRAGGED_ITEM", payload: event.active.id.toString() });
|
|
}, [blanksDispatcher]);
|
|
|
|
const handleDragEnd = useCallback(
|
|
(event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
if (!over) return;
|
|
|
|
const blankId = parseInt(active.id.toString().split("-").pop() || "");
|
|
const dropIndex = parseInt(over.id.toString().split("-")[1]);
|
|
|
|
blanksDispatcher({
|
|
type: "MOVE_BLANK",
|
|
payload: { blankId, newPosition: dropIndex },
|
|
});
|
|
|
|
blanksDispatcher({ type: "SET_DRAGGED_ITEM", payload: null });
|
|
},
|
|
[blanksDispatcher]
|
|
);
|
|
|
|
const handleTextChange = useCallback(
|
|
(newText: string) => {
|
|
const processedText = newText.replace(/\[(\d+)\]/g, "{{$1}}");
|
|
|
|
const existingBlankIds = getTextSegments(state.text)
|
|
.filter(token => token.type === 'blank')
|
|
.map(token => (token as BlankToken).id);
|
|
|
|
const newBlankIds = getTextSegments(processedText)
|
|
.filter(token => token.type === 'blank')
|
|
.map(token => (token as BlankToken).id);
|
|
|
|
const removedBlankIds = existingBlankIds.filter(id => !newBlankIds.includes(id));
|
|
|
|
removedBlankIds.forEach(id => {
|
|
onBlankRemove(id);
|
|
});
|
|
|
|
blanksDispatcher({ type: "SET_TEXT", payload: processedText });
|
|
},
|
|
[blanksDispatcher, state.text, onBlankRemove]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (onBlankSelect !== undefined) onBlankSelect(state.selectedBlankId);
|
|
}, [state.selectedBlankId, onBlankSelect]);
|
|
|
|
const handleBlankSelect = (blankId: number) => {
|
|
blanksDispatcher({
|
|
type: "SELECT_BLANK",
|
|
payload: blankId === state.selectedBlankId ? null : blankId,
|
|
});
|
|
};
|
|
|
|
const handleBlankRemove = useCallback((blankId: number) => {
|
|
onBlankRemove(blankId);
|
|
blanksDispatcher({ type: "REMOVE_BLANK", payload: blankId });
|
|
}, [blanksDispatcher, onBlankRemove]);
|
|
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, {
|
|
activationConstraint: {
|
|
distance: 4,
|
|
tolerance: 5,
|
|
},
|
|
})
|
|
);
|
|
|
|
const modifiers = [snapCenterToCursor, restrictToWindowEdges];
|
|
|
|
const measuring = {
|
|
droppable: {
|
|
strategy: MeasuringStrategy.Always,
|
|
},
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4 p-4">
|
|
<Header
|
|
title={title}
|
|
description={description}
|
|
editing={editing}
|
|
difficulty={difficulty}
|
|
saveDifficulty={saveDifficulty}
|
|
handleSave={onSave}
|
|
handleDelete={onDelete}
|
|
handleDiscard={onDiscard}
|
|
handlePractice={onPractice}
|
|
isEvaluationEnabled={isEvaluationEnabled}
|
|
/>
|
|
{alerts.length > 0 && <Alert alerts={alerts} />}
|
|
<PromptEdit value={prompt} onChange={(text: string) => updatePrompt(text)} />
|
|
<Card>
|
|
<CardContent className="p-4 text-white font-semibold flex gap-2">
|
|
<button
|
|
onClick={() => blanksDispatcher({ type: "ADD_BLANK" })}
|
|
className={`px-3 py-1.5 bg-ielts-${module} rounded-md hover:bg-ielts-${module}/50 transition-colors`}
|
|
>
|
|
Add Blank
|
|
</button>
|
|
<button
|
|
onClick={() => blanksDispatcher({ type: "TOGGLE_EDIT_MODE" })}
|
|
className={clsx(
|
|
"px-3 py-1.5 rounded-md transition-colors",
|
|
`bg-ielts-${module} text-white hover:bg-ielts-${module}/50`
|
|
)}
|
|
>
|
|
{state.textMode ? "Drag Mode" : "Text Mode"}
|
|
</button>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<DndContext
|
|
sensors={sensors}
|
|
onDragStart={handleDragStart}
|
|
onDragEnd={handleDragEnd}
|
|
modifiers={modifiers}
|
|
measuring={measuring}
|
|
>
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
{state.textMode ? (
|
|
<AutoExpandingTextArea
|
|
value={state.text.replace(/{{(\d+)}}/g, "[$1]")}
|
|
onChange={(text) => { handleTextChange(text); if (!editing) setEditing(true) }}
|
|
className="w-full h-full min-h-[200px] p-2 bg-white border rounded-md"
|
|
placeholder="Enter text here. Use [1], [2], etc. for blanks..."
|
|
/>
|
|
) : (
|
|
<div className="leading-relaxed p-4">
|
|
{tokens.map((token, index) => {
|
|
const isWordToken = token.type === 'text' && !token.isWhitespace;
|
|
const showDropZone = isWordToken || token.type === 'blank';
|
|
|
|
return (
|
|
<React.Fragment key={index}>
|
|
{showDropZone && <DropZone index={index} module={module} />}
|
|
{token.type === 'blank' ? (
|
|
<Blank
|
|
id={token.id}
|
|
module={module}
|
|
variant="text"
|
|
isSelected={token.id === state.selectedBlankId}
|
|
isDragging={state.draggedItemId === `text-blank-${token.id}`}
|
|
/>
|
|
) : token.isLineBreak ? (
|
|
<br />
|
|
) : (
|
|
<span className="select-none">{token.content}</span>
|
|
)}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
{tokens.length > 0 &&
|
|
tokens[tokens.length - 1].type === 'text' && (
|
|
<DropZone index={tokens.length} module={module} />
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
|
|
{(!state.textMode && showBlankBank) && (
|
|
<Card>
|
|
<CardContent className="flex flex-wrap gap-2 p-4">
|
|
{state.blanks.map(blank => (
|
|
<Blank
|
|
key={blank.id}
|
|
id={blank.id}
|
|
module={module}
|
|
variant="bank"
|
|
isSelected={blank.id === state.selectedBlankId}
|
|
isDragging={state.draggedItemId === `bank-blank-${blank.id}`}
|
|
onSelect={handleBlankSelect}
|
|
onRemove={handleBlankRemove}
|
|
disabled={state.textMode}
|
|
/>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
{children}
|
|
</DndContext>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default BlanksEditor;
|