Skip to content

Commit ace7ef4

Browse files
authored
Merge pull request #465 from isaacphysics/feature/cloze-dnd
Cloze drag and drop questions
2 parents bfb811e + ebbfe87 commit ace7ef4

File tree

11 files changed

+453
-3
lines changed

11 files changed

+453
-3
lines changed

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/IsaacApiTypes.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,10 @@ export interface IsaacParsonsQuestionDTO extends IsaacItemQuestionDTO {
134134
disableIndentation?: boolean;
135135
}
136136

137+
export interface IsaacClozeQuestionDTO extends IsaacItemQuestionDTO {
138+
withReplacement?: boolean;
139+
}
140+
137141
export interface IsaacPodDTO extends ContentDTO {
138142
image?: ImageDTO;
139143
url?: string;

src/IsaacAppTypes.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
Difficulty,
1111
GameboardDTO,
1212
GameboardItem,
13+
ItemDTO,
1314
QuizFeedbackMode,
1415
RegisteredUserDTO,
1516
ResultsWrapper,
@@ -702,6 +703,7 @@ export const AccordionSectionContext = React.createContext<{id: string | undefin
702703
{id: undefined, clientId: "unknown", open: /* null is a meaningful default state for IsaacVideo */ null}
703704
);
704705
export const QuestionContext = React.createContext<string | undefined>(undefined);
706+
export const ClozeDropRegionContext = React.createContext<{register: (id: string, index: number) => void, questionPartId: string} | undefined>(undefined);
705707

706708
export interface AppAssignmentProgress {
707709
user: ApiTypes.UserSummaryDTO;
@@ -964,3 +966,7 @@ export interface AppQuizAssignment extends ApiTypes.QuizAssignmentDTO {
964966
}
965967

966968
export const QuizFeedbackModes: QuizFeedbackMode[] = ["NONE", "OVERALL_MARK", "SECTION_MARKS", "DETAILED_FEEDBACK"];
969+
970+
export interface ClozeItemDTO extends ItemDTO {
971+
replacementId?: string;
972+
}
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
import React, {RefObject, useContext, useEffect, useRef, useState} from "react";
2+
import * as RS from "reactstrap";
3+
import {Label} from "reactstrap";
4+
import {
5+
IsaacClozeQuestionDTO,
6+
ItemChoiceDTO,
7+
ItemDTO
8+
} from "../../../IsaacApiTypes";
9+
import {useDispatch, useSelector} from "react-redux";
10+
import {selectors} from "../../state/selectors";
11+
import {selectQuestionPart} from "../../services/questions";
12+
import {IsaacContentValueOrChildren} from "./IsaacContentValueOrChildren";
13+
import {
14+
DragDropContext,
15+
Draggable,
16+
DragStart,
17+
DragUpdate,
18+
Droppable,
19+
DropResult,
20+
ResponderProvided
21+
} from "react-beautiful-dnd";
22+
import ReactDOM from 'react-dom';
23+
import {ClozeDropRegionContext, ClozeItemDTO} from "../../../IsaacAppTypes";
24+
import {setCurrentAttempt} from "../../state/actions";
25+
import uuid from "uuid";
26+
import {Item} from "../../services/select";
27+
28+
function Item({item}: {item: ItemDTO}) {
29+
return <RS.Badge className="m-2 p-2">
30+
<IsaacContentValueOrChildren value={item.value} encoding={item.encoding || "html"}>
31+
{item.children}
32+
</IsaacContentValueOrChildren>
33+
</RS.Badge>;
34+
}
35+
36+
interface InlineDropRegionProps {
37+
id: string; item?: ClozeItemDTO; contentHolder: RefObject<HTMLDivElement>; readonly?: boolean; updateAttempt: (dropResult : DropResult) => void; showBorder: boolean;
38+
}
39+
function InlineDropRegion({id, item, contentHolder, readonly, updateAttempt, showBorder}: InlineDropRegionProps) {
40+
41+
function clearInlineDropZone() {
42+
updateAttempt({source: {droppableId: id, index: 0}, draggableId: (item?.replacementId as string)} as DropResult);
43+
}
44+
45+
const droppableTarget = contentHolder.current?.querySelector(`#${id}`);
46+
if (droppableTarget) {
47+
return ReactDOM.createPortal(
48+
<div style={{minHeight: "inherit", position: "relative", margin: "2px"}}>
49+
<Droppable droppableId={id} isDropDisabled={readonly} direction="vertical" >
50+
{(provided, snapshot) => <div
51+
ref={provided.innerRef} {...provided.droppableProps}
52+
className={`d-flex justify-content-center align-items-center bg-grey rounded w-100 overflow-hidden ${showBorder && "border border-dark"}`}
53+
style={{minHeight: "inherit"}}
54+
>
55+
{item && <Draggable key={item.replacementId} draggableId={item?.replacementId as string} index={0} isDragDisabled={true}>
56+
{(provided, snapshot) =>
57+
<div
58+
className={"cloze-draggable mr-4"} ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}
59+
>
60+
<Item item={item}/>
61+
</div>
62+
}
63+
</Draggable>}
64+
{!item && "\u00A0"}
65+
</div>}
66+
</Droppable>
67+
{item && <button aria-label={"Clear drop zone"} className={"cloze-inline-clear"} onClick={clearInlineDropZone}>
68+
<svg height="20" width="20" viewBox="0 0 20 20" aria-hidden="true" focusable="false"
69+
className="cloze-clear-cross">
70+
<path d="M14.348 14.849c-0.469 0.469-1.229 0.469-1.697 0l-2.651-3.030-2.651 3.029c-0.469 0.469-1.229 0.469-1.697 0-0.469-0.469-0.469-1.229 0-1.697l2.758-3.15-2.759-3.152c-0.469-0.469-0.469-1.228 0-1.697s1.228-0.469 1.697 0l2.652 3.031 2.651-3.031c0.469-0.469 1.228-0.469 1.697 0s0.469 1.229 0 1.697l-2.758 3.152 2.758 3.15c0.469 0.469 0.469 1.229 0 1.698z"/>
71+
</svg>
72+
</button>}
73+
</div>,
74+
droppableTarget);
75+
}
76+
return null;
77+
}
78+
79+
// Matches: [drop-zone], [drop-zone|w-50], [drop-zone|h-50] or [drop-zone|w-50h-200]
80+
const dropZoneRegex = /\[drop-zone(?<params>\|(?<width>w-\d+?)?(?<height>h-\d+?)?)?]/g;
81+
82+
export function useClozeDropRegionsInHtml(html: string): string {
83+
const dropRegionContext = useContext(ClozeDropRegionContext);
84+
if (dropRegionContext && dropRegionContext.questionPartId) {
85+
let index = 0;
86+
html = html.replace(dropZoneRegex, (matchingString, params, widthMatch, heightMatch, offset) => {
87+
const dropId = `drop-region-${dropRegionContext.questionPartId}-${offset}`;
88+
dropRegionContext.register(dropId, index++); // also increments index
89+
const minWidth = widthMatch ? widthMatch.slice("w-".length) + "px" : "100px";
90+
const minHeight = heightMatch ? heightMatch.slice("h-".length) + "px" : "auto";
91+
return `<div id="${dropId}" class="d-inline-block" style="min-width: ${minWidth}; min-height: ${minHeight}"></div>`;
92+
});
93+
}
94+
return html;
95+
}
96+
97+
export function IsaacClozeQuestion({doc, questionId, readonly}: {doc: IsaacClozeQuestionDTO; questionId: string; readonly?: boolean}) {
98+
const dispatch = useDispatch();
99+
const pageQuestions = useSelector(selectors.questions.getQuestions);
100+
const questionPart = selectQuestionPart(pageQuestions, questionId);
101+
const currentAttempt = questionPart?.currentAttempt as (ItemChoiceDTO | undefined);
102+
const cssFriendlyQuestionPartId = questionPart?.id?.replaceAll("|", "-") ?? ""; // Maybe we should clean up IDs more?
103+
const questionContentRef = useRef<HTMLDivElement>(null);
104+
const withReplacement = doc.withReplacement ?? false;
105+
106+
const itemsSection = `${cssFriendlyQuestionPartId}-items-section`;
107+
108+
const [nonSelectedItems, setNonSelectedItems] = useState<ClozeItemDTO[]>(doc.items ? [...doc.items].map(x => ({...x, replacementId: x.id})) : []);
109+
110+
const registeredDropRegionIDs = useRef<string[]>([]).current;
111+
const [inlineDropValues, setInlineDropValues] = useState<(ClozeItemDTO | undefined)[]>(() => currentAttempt?.items || []);
112+
113+
const [borderMap, setBorderMap] = useState<{[dropId: string]: boolean}>({});
114+
115+
useEffect(() => {
116+
if (currentAttempt?.items) {
117+
const idvs = currentAttempt.items as (ClozeItemDTO | undefined)[];
118+
setInlineDropValues(registeredDropRegionIDs.map((_, i) => idvs[i] ? {...idvs[i], replacementId: `${idvs[i]?.id}-${uuid.v4()}`} : undefined));
119+
120+
// If the question allows duplicates, then the items in the non-selected item section should never change
121+
// (apart from on question load - this case is handled in the initial state of nonSelectedItems)
122+
if (!withReplacement) {
123+
setNonSelectedItems(nonSelectedItems.filter(i => !currentAttempt.items?.map(si => si?.id).includes(i.id)).map(x => ({...x, replacementId: x.id})) || []);
124+
}
125+
}
126+
}, [currentAttempt]);
127+
128+
function registerInlineDropRegion(dropRegionId: string) {
129+
if (!registeredDropRegionIDs.includes(dropRegionId)) {
130+
registeredDropRegionIDs.push(dropRegionId);
131+
setInlineDropValues(registeredDropRegionIDs.map(() => undefined));
132+
setBorderMap(registeredDropRegionIDs.reduce((dict: {[dropId: string]: boolean}, id) => Object.assign(dict, {[id]: false}), {}));
133+
}
134+
}
135+
136+
function fixInlineZoneOnStartDrag({source}: DragStart, provided: ResponderProvided) {
137+
fixInlineZones({destination: source} as DragUpdate, provided);
138+
}
139+
140+
// This is run on drag update to highlight the droppable that the user is dragging over
141+
// this gives more control over when a droppable is highlighted
142+
function fixInlineZones({destination}: DragUpdate, provided: ResponderProvided) {
143+
registeredDropRegionIDs.map((dropId, i) => {
144+
const destinationDropIndex = destination ? registeredDropRegionIDs.indexOf(dropId) : -1;
145+
const destinationDragIndex = destination?.index ?? -1;
146+
147+
borderMap[dropId] = (dropId === destination?.droppableId && destinationDropIndex !== -1 && destinationDragIndex === 0);
148+
});
149+
// Tell React about the changes to borderMap
150+
setBorderMap({...borderMap});
151+
}
152+
153+
// Run after a drag action ends
154+
function updateAttempt({source, destination, draggableId}: DropResult, provided: ResponderProvided) {
155+
156+
// Make sure borders are removed, since drag has ended
157+
fixInlineZones({destination: undefined} as DragUpdate, provided);
158+
159+
if (source.droppableId === destination?.droppableId && source.index === destination?.index) return; // No change
160+
161+
if (!destination) return; // Drag had no destination
162+
163+
const inlineDropIndex = (id : string) => registeredDropRegionIDs.indexOf(id)
164+
165+
const nsis = [...nonSelectedItems];
166+
const idvs = [...inlineDropValues];
167+
168+
// The item that's being dragged (this is worked out below in each case)
169+
let item : ClozeItemDTO;
170+
// A callback to put an item back into the source of the drag (if needed)
171+
let replaceSource : (itemToReplace: ClozeItemDTO | undefined) => void = () => undefined;
172+
// Whether the inline drop zones were updated or not
173+
let update = false;
174+
175+
// Check source of drag:
176+
if (source.droppableId === itemsSection) {
177+
// Drag was from items section
178+
item = nonSelectedItems[source.index];
179+
if (!withReplacement || destination.droppableId === itemsSection) {
180+
nsis.splice(source.index, 1);
181+
replaceSource = (itemToReplace) => itemToReplace && nsis.splice(source.index, 0, itemToReplace);
182+
}
183+
} else {
184+
// Drag was from inline drop section
185+
// When splicing inline drop values, you always need to delete and replace
186+
const sourceDropIndex = inlineDropIndex(source.droppableId);
187+
if (sourceDropIndex !== -1) {
188+
const maybeItem = idvs[sourceDropIndex]; // This nastiness is to appease typescript
189+
if (maybeItem) {
190+
item = maybeItem;
191+
idvs.splice(sourceDropIndex, 1, undefined);
192+
replaceSource = (itemToReplace) => idvs.splice(sourceDropIndex, 1, itemToReplace);
193+
update = true;
194+
} else {
195+
return;
196+
}
197+
} else {
198+
return;
199+
}
200+
}
201+
202+
// Check destination of drag:
203+
if (destination.droppableId === itemsSection) {
204+
// Drop is into items section
205+
if (!withReplacement || source.droppableId === itemsSection) {
206+
nsis.splice(destination.index, 0, item);
207+
} else {
208+
nsis.splice(nsis.findIndex((x) => x.id === item.id), 1);
209+
nsis.splice(destination.index, 0, item);
210+
}
211+
} else {
212+
// Drop is into inline drop section
213+
const destinationDropIndex = inlineDropIndex(destination.droppableId);
214+
if (destinationDropIndex !== -1 && destination.index === 0) {
215+
replaceSource(idvs[destinationDropIndex]);
216+
idvs.splice(destinationDropIndex, 1, withReplacement ? {...item, replacementId: item.id + uuid.v4()} : item);
217+
} else {
218+
replaceSource(item);
219+
}
220+
update = true;
221+
}
222+
223+
// Update draggable lists every time a successful drag ends
224+
setInlineDropValues(idvs);
225+
setNonSelectedItems(nsis);
226+
227+
if (update) {
228+
// Update attempt since an inline drop zone changed
229+
const itemChoice: ItemChoiceDTO = {
230+
type: "itemChoice",
231+
items: idvs.map(x => {
232+
if (x) {
233+
const {replacementId, ...itemDto} = x;
234+
return itemDto as ItemDTO;
235+
}
236+
// Really, items should be a list of type (ItemDTO | undefined), but this is a workaround
237+
return x as unknown as ItemDTO;
238+
})
239+
};
240+
dispatch(setCurrentAttempt(questionId, itemChoice));
241+
}
242+
}
243+
244+
return <div ref={questionContentRef} className="question-content cloze-question">
245+
<ClozeDropRegionContext.Provider value={{questionPartId: cssFriendlyQuestionPartId, register: registerInlineDropRegion}}>
246+
<DragDropContext onDragStart={fixInlineZoneOnStartDrag} onDragEnd={updateAttempt} onDragUpdate={fixInlineZones}>
247+
<IsaacContentValueOrChildren value={doc.value} encoding={doc.encoding}>
248+
{doc.children}
249+
</IsaacContentValueOrChildren>
250+
251+
{/* Items section */}
252+
<Label htmlFor="non-selected-items" className="mt-3">Items: </Label>
253+
<Droppable droppableId={itemsSection} direction="horizontal" isDropDisabled={readonly}>
254+
{(provided, snapshot) => <div
255+
ref={provided.innerRef} {...provided.droppableProps} id="non-selected-items"
256+
className={`d-flex overflow-auto rounded p-2 mb-3 bg-grey ${snapshot.isDraggingOver ? "border border-dark" : ""}`}
257+
>
258+
{nonSelectedItems.map((item, i) => <Draggable key={item.replacementId} draggableId={item.replacementId || `${i}`} index={i}>
259+
{(provided) =>
260+
<div className={"cloze-draggable"} ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}>
261+
<Item item={item} />
262+
</div>
263+
}
264+
</Draggable>)}
265+
{nonSelectedItems.length === 0 && "\u00A0"}
266+
{provided.placeholder}
267+
</div>}
268+
</Droppable>
269+
270+
{/* Inline droppables rendered for each registered drop region */}
271+
{registeredDropRegionIDs.map((dropRegionId, index) =>
272+
<InlineDropRegion
273+
key={dropRegionId} contentHolder={questionContentRef} readonly={readonly}
274+
id={dropRegionId} item={inlineDropValues[index]} updateAttempt={(dropResult) => {
275+
updateAttempt({...dropResult, destination: {droppableId: itemsSection, index: nonSelectedItems.length}},{announce: (_) => {return;}});
276+
}}
277+
showBorder={borderMap[dropRegionId]}
278+
/>
279+
)}
280+
</DragDropContext>
281+
</ClozeDropRegionContext.Provider>
282+
</div>;
283+
}

src/app/components/elements/TrustedHtml.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {AppState} from "../../state/reducers";
55
import {useSelector} from "react-redux";
66
import {selectors} from "../../state/selectors";
77
import {katexify} from "./LaTeX";
8+
import {useClozeDropRegionsInHtml} from "../content/IsaacClozeQuestion";
89

910
const htmlDom = document.createElement("html");
1011
function manipulateHtml(html: string) {
@@ -42,6 +43,7 @@ export const TrustedHtml = ({html, span, className}: {html: string; span?: boole
4243
const figureNumbers = useContext(FigureNumberingContext);
4344

4445
html = manipulateHtml(katexify(html, user, booleanNotation, screenReaderHoverText, figureNumbers));
46+
html = useClozeDropRegionsInHtml(html);
4547

4648
const ElementType = span ? "span" : "div";
4749
return <ElementType className={className} dangerouslySetInnerHTML={{__html: html}} />;

src/app/components/elements/TrustedMarkdown.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,4 +121,4 @@ export const TrustedMarkdown = ({markdown}: {markdown: string}) => {
121121
<TrustedHtml html={html} />
122122
{tooltips}
123123
</div>;
124-
};
124+
};

src/app/components/pages/MyProgress.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export const siteSpecific = {
2929
[SITE.PHY]: {
3030
questionTypeStatsList: [
3131
"isaacMultiChoiceQuestion", "isaacNumericQuestion", "isaacSymbolicQuestion", "isaacSymbolicChemistryQuestion"
32+
// TODO isaacClozeQuestion when it exists
3233
],
3334
questionTagsStatsList: [
3435
"maths_book", "physics_skills_14", "physics_skills_19", "phys_book_gcse", "chemistry_16"
@@ -40,6 +41,7 @@ export const siteSpecific = {
4041
questionTypeStatsList: [
4142
"isaacMultiChoiceQuestion", "isaacItemQuestion", "isaacParsonsQuestion", "isaacNumericQuestion",
4243
"isaacStringMatchQuestion", "isaacFreeTextQuestion", "isaacSymbolicLogicQuestion"
44+
// TODO isaacClozeQuestion when it exists
4345
],
4446
questionTagsStatsList: [] as string[],
4547
typeColWidth: "col-lg-4",

0 commit comments

Comments
 (0)