diff --git a/gradesa/src/app/api/exercises/dragdrop/[dnd_id]/route.js b/gradesa/src/app/api/exercises/dragdrop/[dnd_id]/route.js index 17627c6..23e6179 100644 --- a/gradesa/src/app/api/exercises/dragdrop/[dnd_id]/route.js +++ b/gradesa/src/app/api/exercises/dragdrop/[dnd_id]/route.js @@ -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] ); diff --git a/gradesa/src/app/grammar/exercises/fillinthegap/[exercise_id]/page.js b/gradesa/src/app/grammar/exercises/fillinthegap/[exercise_id]/page.js index 212b3ca..ab22240 100644 --- a/gradesa/src/app/grammar/exercises/fillinthegap/[exercise_id]/page.js +++ b/gradesa/src/app/grammar/exercises/fillinthegap/[exercise_id]/page.js @@ -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/
start a new +// run, so a word split across inline tags (e.g. ) 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; diff --git a/gradesa/src/components/ui/dragdrop/area.js b/gradesa/src/components/ui/dragdrop/area.js index e0facf2..2029e73 100644 --- a/gradesa/src/components/ui/dragdrop/area.js +++ b/gradesa/src/components/ui/dragdrop/area.js @@ -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
Lädt...
; if (error) return
Fehler: {error}
; - const allAcceptedTypes = [...new Set(allWords.map((word) => word.type))]; + const allAcceptedTypes = [...new Set(allWords.flatMap((word) => word.types))]; return (
@@ -162,10 +164,10 @@ const Area = ({ exerciseID }) => { gap: "var(--u-md)", }} > - {visibleWords.map(({ name, type }, index) => ( + {visibleWords.map(({ name, types }, index) => ( diff --git a/gradesa/src/components/ui/dragdrop/dustbin.js b/gradesa/src/components/ui/dragdrop/dustbin.js index d586cfa..7fe2e0f 100644 --- a/gradesa/src/components/ui/dragdrop/dustbin.js +++ b/gradesa/src/components/ui/dragdrop/dustbin.js @@ -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 (
({ - 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 (