|
| 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 | +} |
0 commit comments