/* dragdrop.jsx * * * * Renders a drag-and-drop question matching the schema in * docs/DRAGDROP_SCHEMA.md. Items appear in a tray below an SVG diagram * with labeled drop zones. User drags items onto zones; pointer events * mean it works on both mouse and touch. * * Why pointer events, not HTML5 drag-and-drop: * HTML5 drag-and-drop doesn't fire on touch devices. Mobile users * would be locked out. Pointer events (pointerdown/move/up) unify * mouse, touch, and stylus into one API, supported by all modern * browsers (Safari finally fixed it in 13.1, 2020). * * State model: * placements: { [itemId]: zoneId | null } — current placement of each item * dragging: { itemId, dx, dy, pointerId } — when a drag is in progress * submitted: bool — has the user clicked Submit * * Coordinate spaces: * - SVG diagram uses viewBox from question.diagram.viewBox (default "0 0 500 400") * - Item positions while dragging are in client pixel coordinates, * then converted to SVG coords for hit-testing against zones. * * Grading: * - On submit, compare placements against question.ans. * - "partial" mode: count correct placements / total items. * - "all-or-nothing" mode: 1 if all correct, else 0. * - After submit, zones show green/red outline based on contents. * - Wrong placements may show an inline explanation from question.wrong[item:zone]. */ (function () { "use strict"; // ──────────────── helpers ──────────────── // Hit-test: given client (x, y) and an SVG element + zone rect, // return zoneId if the point is inside, else null. function hitTestZone(svg, zones, clientX, clientY) { if (!svg) return null; const pt = svg.createSVGPoint(); pt.x = clientX; pt.y = clientY; const ctm = svg.getScreenCTM(); if (!ctm) return null; const local = pt.matrixTransform(ctm.inverse()); for (const z of zones) { if ( local.x >= z.x && local.x <= z.x + z.w && local.y >= z.y && local.y <= z.y + z.h ) { return z.id; } } return null; } // Compute correct/incorrect lookup once placements + ans are known. function gradePlacements(placements, ans, grade) { let correct = 0; const total = Object.keys(ans).length; const perItem = {}; // itemId -> "correct" | "wrong" | "empty" for (const itemId of Object.keys(ans)) { const expected = ans[itemId]; const placed = placements[itemId]; if (placed == null) perItem[itemId] = "empty"; else if (placed === expected) { perItem[itemId] = "correct"; correct++; } else { perItem[itemId] = "wrong"; } } const score = grade === "all-or-nothing" ? (correct === total ? 1 : 0) : (total === 0 ? 0 : correct / total); return { score, correct, total, perItem }; } // ──────────────── DragDropQuestion ──────────────── function DragDropQuestion(props) { const q = props.question; const onSubmit = props.onSubmit || (() => {}); const onReveal = props.onReveal || (() => {}); // Defensive: validate the question shape so a malformed entry doesn't crash. if (!q || !q.diagram || !Array.isArray(q.diagram.zones) || !Array.isArray(q.items) || !q.ans) { return React.createElement( "div", { className: "dd-error" }, "Malformed drag-and-drop question. Check the question data." ); } const viewBox = q.diagram.viewBox || "0 0 500 400"; const zones = q.diagram.zones; const items = q.items; const grade = q.grade || "partial"; // placements[itemId] = zoneId or null. Initialized empty. const [placements, setPlacements] = React.useState(() => { const init = {}; for (const it of items) init[it.id] = null; return init; }); const [submitted, setSubmitted] = React.useState(false); const [dragging, setDragging] = React.useState(null); // dragging shape: { itemId, pointerId, clientX, clientY, overZoneId } const svgRef = React.useRef(null); // ──── Pointer event handlers ──── function handlePointerDown(e, itemId) { if (submitted) return; // Only respond to primary pointer (left mouse, first touch). if (e.button != null && e.button !== 0) return; e.preventDefault(); // Capture the pointer so we keep getting events even if the cursor // leaves the item element. try { e.currentTarget.setPointerCapture(e.pointerId); } catch (_) {} setDragging({ itemId, pointerId: e.pointerId, clientX: e.clientX, clientY: e.clientY, overZoneId: hitTestZone(svgRef.current, zones, e.clientX, e.clientY), }); } function handlePointerMove(e) { if (!dragging || dragging.pointerId !== e.pointerId) return; const over = hitTestZone(svgRef.current, zones, e.clientX, e.clientY); setDragging({ ...dragging, clientX: e.clientX, clientY: e.clientY, overZoneId: over, }); } function handlePointerUp(e) { if (!dragging || dragging.pointerId !== e.pointerId) return; const drop = hitTestZone(svgRef.current, zones, e.clientX, e.clientY); if (drop) { // Place item in zone. (Allow multiple items per zone — we don't // enforce one-per-zone since a question might intentionally need it.) setPlacements((prev) => ({ ...prev, [dragging.itemId]: drop })); } // Releasing outside any zone: return item to tray (placement = null). else { setPlacements((prev) => ({ ...prev, [dragging.itemId]: null })); } try { e.currentTarget.releasePointerCapture(e.pointerId); } catch (_) {} setDragging(null); } function handlePointerCancel() { // OS interrupted the drag (e.g., notification on mobile). Treat // as cancel — don't change placement, just clear drag state. setDragging(null); } // Reset placements without re-mounting. Useful in a "Try again" flow. function handleReset() { const init = {}; for (const it of items) init[it.id] = null; setPlacements(init); setSubmitted(false); } function handleSubmit() { if (submitted) return; const result = gradePlacements(placements, q.ans, grade); setSubmitted(true); onSubmit(result, placements); onReveal(result, placements); } // ──── Derived state ──── // How many items are placed somewhere (vs in the tray)? const placedCount = items.filter((it) => placements[it.id] != null).length; const allPlaced = placedCount === items.length; // After submit, compute grading details for visual feedback. const result = submitted ? gradePlacements(placements, q.ans, grade) : null; // Items currently in each zone, indexed by zone id, for SVG rendering. const itemsByZone = {}; for (const z of zones) itemsByZone[z.id] = []; for (const it of items) { const placed = placements[it.id]; if (placed && itemsByZone[placed]) itemsByZone[placed].push(it); } // ──── Render ──── return React.createElement( "div", { className: "dd-question", onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, onPointerCancel: handlePointerCancel }, // Stem React.createElement("div", { className: "dd-stem" }, q.stem), // Diagram React.createElement( "div", { className: "dd-diagram-wrap" }, React.createElement( "svg", { ref: svgRef, className: "dd-diagram", viewBox: viewBox, preserveAspectRatio: "xMidYMid meet", }, // Render each zone zones.map((z) => { // Color outline based on grading state, if submitted let zoneClass = "dd-zone"; if (dragging && dragging.overZoneId === z.id) zoneClass += " dd-zone-hover"; if (submitted) { // Determine if this zone's contents are correct. // Zone is correct if every item in it is placed there per ans. const containedItems = itemsByZone[z.id]; const allCorrect = containedItems.length > 0 && containedItems.every((it) => q.ans[it.id] === z.id); const anyWrong = containedItems.some((it) => q.ans[it.id] !== z.id); if (allCorrect) zoneClass += " dd-zone-correct"; else if (anyWrong) zoneClass += " dd-zone-wrong"; } return React.createElement( "g", { key: z.id, className: zoneClass }, React.createElement("rect", { x: z.x, y: z.y, width: z.w, height: z.h, rx: 4, ry: 4, className: "dd-zone-rect", }), React.createElement( "text", { x: z.x + z.w / 2, y: z.y + 18, textAnchor: "middle", className: "dd-zone-label", }, z.label ), // Render items placed in this zone, stacked vertically below the label itemsByZone[z.id].map((it, i) => { const itemY = z.y + 36 + i * 22; // Skip rendering if it would overflow the zone height if (itemY + 18 > z.y + z.h) return null; let itemClass = "dd-zoneitem"; if (submitted) { itemClass += q.ans[it.id] === z.id ? " dd-zoneitem-correct" : " dd-zoneitem-wrong"; } return React.createElement( "g", { key: it.id, className: itemClass, onPointerDown: submitted ? undefined : (e) => handlePointerDown(e, it.id), }, React.createElement("rect", { x: z.x + 4, y: itemY, width: z.w - 8, height: 18, rx: 2, ry: 2, }), React.createElement( "text", { x: z.x + z.w / 2, y: itemY + 13, textAnchor: "middle", className: "dd-zoneitem-text", }, it.t ) ); }) ); }) ) ), // Tray (unplaced items) React.createElement( "div", { className: "dd-tray" }, React.createElement( "div", { className: "dd-tray-label" }, submitted ? "Items" : (allPlaced ? "All items placed" : `Drag items to a zone (${placedCount}/${items.length} placed)`) ), React.createElement( "div", { className: "dd-tray-items" }, items .filter((it) => placements[it.id] == null) .map((it) => React.createElement( "div", { key: it.id, className: "dd-trayitem" + (dragging && dragging.itemId === it.id ? " dd-trayitem-dragging" : ""), onPointerDown: submitted ? undefined : (e) => handlePointerDown(e, it.id), }, it.t ) ) ) ), // Action buttons React.createElement( "div", { className: "dd-actions" }, !submitted && React.createElement( "button", { className: "btn btn-primary", onClick: handleSubmit, disabled: !allPlaced, }, allPlaced ? "Submit answer" : `Place all items (${placedCount}/${items.length})` ), submitted && React.createElement( "button", { className: "btn", onClick: handleReset }, "Try again" ) ), // Feedback (after submit) submitted && React.createElement( "div", { className: "dd-feedback" }, React.createElement( "div", { className: result.score === 1 ? "dd-result dd-result-pass" : (result.score >= 0.5 ? "dd-result dd-result-partial" : "dd-result dd-result-fail") }, grade === "all-or-nothing" ? (result.correct === result.total ? "Correct" : `${result.correct} of ${result.total} placements correct (need all)`) : `${result.correct} of ${result.total} correct (${Math.round(result.score * 100)}%)` ), q.exp && React.createElement("div", { className: "dd-exp" }, q.exp), // Per-misplacement explanations items.map((it) => { const placed = placements[it.id]; if (!placed || placed === q.ans[it.id]) return null; const key = `${it.id}:${placed}`; const note = q.wrong && q.wrong[key]; if (!note) return null; return React.createElement( "div", { key: it.id, className: "dd-wrongnote" }, React.createElement("strong", null, `"${it.t}" → ${zones.find(z => z.id === placed)?.label || placed}: `), note ); }) ), // Floating ghost of the dragged item, follows cursor dragging && (() => { // Render outside the SVG, in fixed positioning over the page. // Center the ghost on the cursor for both mouse and touch. const it = items.find((x) => x.id === dragging.itemId); if (!it) return null; return React.createElement( "div", { className: "dd-ghost", style: { left: dragging.clientX + "px", top: dragging.clientY + "px", }, }, it.t ); })() ); } window.DragDropQuestion = DragDropQuestion; })();