mirror of
https://github.com/OHTU-German-learning-website/OHTU-German-learning-website.git
synced 2026-10-12 19:13:51 +03:00
fix: correct dragdrop multi-category grading and fillinthegap gap alignment
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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}`}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user