fix: correct dragdrop multi-category grading and fillinthegap gap alignment

This commit is contained in:
Erza Bytyci
2026-09-26 17:50:19 +00:00
parent d9aec41b30
commit 87378e49f8
5 changed files with 140 additions and 44 deletions
@@ -37,17 +37,18 @@ export const GET = withAuth(async (request, { params }) => {
[dnd_id]
);
// Fetch words with their correct categories
// Fetch words with all of their correct categories (a word can belong to more than one group)
const words = await DB.pool(
`
SELECT
dw.id,
dw.word as name,
dc.category as type
ARRAY_AGG(DISTINCT dc.category) as types
FROM draggable_words dw
JOIN word_category_mappings wcm ON dw.id = wcm.word_id
JOIN dnd_categories dc ON wcm.category_id = dc.id
WHERE wcm.exercise_id = $1
GROUP BY dw.id, dw.word
`,
[dnd_id]
);
@@ -40,6 +40,80 @@ function tokenizeRaw(raw) {
};
}
// Same block set as normalizeEditorText's BLOCK_BREAK_TAGS (minus "br", handled separately below).
const BLOCK_ANCESTOR_TAGS = new Set([
"p",
"div",
"li",
"ul",
"ol",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"tr",
"table",
]);
function getBlockAncestor(node, root) {
let el = node.parentNode;
while (el && el !== root) {
if (
el.nodeType === 1 &&
BLOCK_ANCESTOR_TAGS.has(el.tagName.toLowerCase())
) {
return el;
}
el = el.parentNode;
}
return root;
}
// Groups text nodes into runs of contiguous prose: only block tags/<br> start a new
// run, so a word split across inline tags (e.g. <strong>) still tokenizes as one word.
function groupTextNodesIntoRuns(doc, root, NodeFilter) {
const walker = doc.createTreeWalker(
root,
NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT,
{
acceptNode(node) {
if (node.nodeType === 3) return NodeFilter.FILTER_ACCEPT;
if (node.nodeType === 1 && node.tagName === "BR")
return NodeFilter.FILTER_ACCEPT;
return NodeFilter.FILTER_SKIP;
},
}
);
const runs = [];
let currentRun = [];
let currentBlock = null;
let node;
while ((node = walker.nextNode())) {
if (node.nodeType === 1) {
if (currentRun.length) runs.push(currentRun);
currentRun = [];
currentBlock = null;
continue;
}
const block = getBlockAncestor(node, root);
if (currentRun.length && block === currentBlock) {
currentRun.push(node);
} else {
if (currentRun.length) runs.push(currentRun);
currentRun = [node];
currentBlock = block;
}
}
if (currentRun.length) runs.push(currentRun);
return runs;
}
function buildHtmlWithGapPlaceholders(sourceHtml, gapsByTokenIndex) {
if (!sourceHtml || typeof window === "undefined") {
return "";
@@ -55,44 +129,61 @@ function buildHtmlWithGapPlaceholders(sourceHtml, gapsByTokenIndex) {
return sourceHtml;
}
const walker = doc.createTreeWalker(root, window.NodeFilter.SHOW_TEXT);
const textNodes = [];
let current;
while ((current = walker.nextNode())) {
textNodes.push(current);
}
const runs = groupTextNodesIntoRuns(doc, root, window.NodeFilter);
let tokenIndex = 0;
for (const textNode of textNodes) {
const rawText = textNode.nodeValue || "";
const chunks = rawText.match(/\s+|[^\s]+/g) || [];
const fragment = doc.createDocumentFragment();
for (const nodes of runs) {
// Concatenate the run's text so a word split across inline tags is one token.
const boundaries = [];
let offset = 0;
for (const node of nodes) {
const length = (node.nodeValue || "").length;
boundaries.push({ node, start: offset, end: offset + length });
offset += length;
}
const runText = nodes.map((node) => node.nodeValue || "").join("");
const chunks = runText.match(/\s+|[^\s]+/g) || [];
const fragments = new Map(
nodes.map((node) => [node, doc.createDocumentFragment()])
);
const appendAt = (absOffset, contentNode) => {
const boundary =
boundaries.find((b) => absOffset >= b.start && absOffset < b.end) ||
boundaries[boundaries.length - 1];
fragments.get(boundary.node).appendChild(contentNode);
};
let cursor = 0;
for (const chunk of chunks) {
const chunkStart = cursor;
cursor += chunk.length;
if (/^\s+$/.test(chunk)) {
fragment.appendChild(doc.createTextNode(chunk));
appendAt(chunkStart, doc.createTextNode(chunk));
continue;
}
const tokenParts = tokenizeRaw(chunk);
const gap = gapsByTokenIndex.get(tokenIndex);
let partOffset = chunkStart;
if (gap && tokenParts.word) {
if (tokenParts.prefix) {
fragment.appendChild(doc.createTextNode(tokenParts.prefix));
appendAt(partOffset, doc.createTextNode(tokenParts.prefix));
partOffset += tokenParts.prefix.length;
}
const gapNode = doc.createElement("fitg-gap");
gapNode.setAttribute("data-gap-id", String(gap.id));
fragment.appendChild(gapNode);
appendAt(partOffset, gapNode);
partOffset += tokenParts.word.length;
if (tokenParts.suffix) {
fragment.appendChild(doc.createTextNode(tokenParts.suffix));
appendAt(partOffset, doc.createTextNode(tokenParts.suffix));
}
} else {
fragment.appendChild(doc.createTextNode(chunk));
appendAt(chunkStart, doc.createTextNode(chunk));
}
if (tokenParts.word) {
@@ -100,7 +191,9 @@ function buildHtmlWithGapPlaceholders(sourceHtml, gapsByTokenIndex) {
}
}
textNode.parentNode?.replaceChild(fragment, textNode);
for (const node of nodes) {
node.parentNode?.replaceChild(fragments.get(node), node);
}
}
return root.innerHTML;
+8 -6
View File
@@ -82,7 +82,9 @@ const Area = ({ exerciseID }) => {
const allBoxesPlaced = totalDroppedItems === allWords.length;
const allItemsCorrect = updatedDustbins.every((dustbin) =>
dustbin.droppedItems.every((item) => item.type === dustbin.accepts[0])
dustbin.droppedItems.every((item) =>
item.types.includes(dustbin.accepts[0])
)
);
return allBoxesPlaced && allItemsCorrect;
@@ -90,7 +92,7 @@ const Area = ({ exerciseID }) => {
const handleDrop = useCallback(
(index, item) => {
const { name, type } = item;
const { name, types } = item;
if (droppedBoxNames.includes(name)) return;
@@ -98,7 +100,7 @@ const Area = ({ exerciseID }) => {
if (binIndex === index) {
return {
...bin,
droppedItems: [...bin.droppedItems, { name, type }],
droppedItems: [...bin.droppedItems, { name, types }],
};
}
return bin;
@@ -150,7 +152,7 @@ const Area = ({ exerciseID }) => {
if (isLoading) return <div>Lädt...</div>;
if (error) return <div>Fehler: {error}</div>;
const allAcceptedTypes = [...new Set(allWords.map((word) => word.type))];
const allAcceptedTypes = [...new Set(allWords.flatMap((word) => word.types))];
return (
<div>
@@ -162,10 +164,10 @@ const Area = ({ exerciseID }) => {
gap: "var(--u-md)",
}}
>
{visibleWords.map(({ name, type }, index) => (
{visibleWords.map(({ name, types }, index) => (
<WordBox
name={name}
type={type}
types={types}
isDropped={isDropped(name)}
key={`${name}-${index}`}
/>
+13 -14
View File
@@ -38,20 +38,19 @@ export const Dustbin = memo(function Dustbin({
{droppedItems.map((item, index) => {
let itemStyle = { color: `var(--${color})` };
if (isSubmitted) {
itemStyle =
item.type === correctType
? {
backgroundColor: "#b3f0b3",
color: "#1a5c1a",
borderRadius: "4px",
padding: "2px 6px",
}
: {
backgroundColor: "#ffb3b3",
color: "#7a0000",
borderRadius: "4px",
padding: "2px 6px",
};
itemStyle = item.types.includes(correctType)
? {
backgroundColor: "#b3f0b3",
color: "#1a5c1a",
borderRadius: "4px",
padding: "2px 6px",
}
: {
backgroundColor: "#ffb3b3",
color: "#7a0000",
borderRadius: "4px",
padding: "2px 6px",
};
}
return (
<div
@@ -2,17 +2,18 @@ import { memo } from "react";
import { useDrag } from "react-dnd";
import { wordbox } from "./dragdrop.css";
export const WordBox = memo(function WordBox({ name, type, isDropped }) {
export const WordBox = memo(function WordBox({ name, types, isDropped }) {
const [{ opacity }, drag] = useDrag(
() => ({
type,
item: { name, type },
// drag type only needs to match a dustbin's accept list; grading uses `types` instead
type: types[0],
item: { name, types },
canDrag: !isDropped,
collect: (monitor) => ({
opacity: monitor.isDragging() ? 0.4 : 1,
}),
}),
[name, type, isDropped]
[name, types, isDropped]
);
return (
<div