mirror of
https://github.com/OHTU-German-learning-website/OHTU-German-learning-website.git
synced 2026-10-12 19:13:51 +03:00
Merge pull request #501 from erzabytyci/user-tiers
Fix Click function and add rich editor, fix editor, improve Gap edit functionality
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE public.click_exercises
|
||||
ADD COLUMN IF NOT EXISTS source_html text;
|
||||
@@ -85,7 +85,8 @@ CREATE TABLE public.click_exercises (
|
||||
title text NOT NULL,
|
||||
category text NOT NULL,
|
||||
target_words text[] NOT NULL,
|
||||
all_words text[] NOT NULL
|
||||
all_words text[] NOT NULL,
|
||||
source_html text
|
||||
);
|
||||
|
||||
ALTER TABLE public.click_exercises
|
||||
|
||||
+2
-1
@@ -50,7 +50,8 @@ CREATE TABLE public.click_exercises (
|
||||
title text NOT NULL,
|
||||
category text NOT NULL,
|
||||
target_words text[] NOT NULL,
|
||||
all_words text[] NOT NULL
|
||||
all_words text[] NOT NULL,
|
||||
source_html text
|
||||
);
|
||||
|
||||
ALTER TABLE public.click_exercises
|
||||
|
||||
@@ -1,10 +1,38 @@
|
||||
.click-admin-page {
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
.click-form {
|
||||
display: grid;
|
||||
gap: var(--u-md);
|
||||
}
|
||||
|
||||
.exercise-click {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.click-block {
|
||||
padding: var(--u-md);
|
||||
border: 1px solid var(--bg3);
|
||||
border-radius: var(--u-sm);
|
||||
background: var(--bg2);
|
||||
}
|
||||
|
||||
.click-block label {
|
||||
display: block;
|
||||
margin-bottom: var(--u-xs);
|
||||
color: var(--fg2);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.click-input {
|
||||
width: 60%;
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.click-form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
|
||||
@@ -8,6 +8,17 @@ import { Button } from "@/components/ui/button";
|
||||
import useQuery from "@/shared/hooks/useQuery";
|
||||
import "./click.css";
|
||||
import WordSelectionExercise from "@/components/ui/click/click.js";
|
||||
import Editor from "@/components/ui/editor";
|
||||
import { htmlToPlainText } from "@/shared/utils/normalizeEditorText";
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export default function CreateExercise() {
|
||||
const router = useRouter();
|
||||
@@ -18,6 +29,7 @@ export default function CreateExercise() {
|
||||
const [error, setError] = useState(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [targetCategory, setTargetCategory] = useState("");
|
||||
const [allWordsHtml, setAllWordsHtml] = useState("");
|
||||
const [allWordsText, setAllWordsText] = useState("");
|
||||
const [selectedWords, setSelectedWords] = useState([]);
|
||||
const [previewMode, setPreviewMode] = useState(false);
|
||||
@@ -45,15 +57,27 @@ export default function CreateExercise() {
|
||||
const text = hasExplicitSpacing
|
||||
? storedWords.join("")
|
||||
: storedWords.join(" ");
|
||||
const sourceHtml =
|
||||
typeof exerciseData.source_html === "string"
|
||||
? exerciseData.source_html
|
||||
: text
|
||||
? `<p>${escapeHtml(text)}</p>`
|
||||
: "";
|
||||
|
||||
setTitle(exerciseData.title || "");
|
||||
setTargetCategory(exerciseData.category || "");
|
||||
setAllWordsHtml(sourceHtml);
|
||||
setAllWordsText(text);
|
||||
setSelectedWords(
|
||||
Array.isArray(exerciseData.target_words) ? exerciseData.target_words : []
|
||||
);
|
||||
}, [exerciseData, isEditMode]);
|
||||
|
||||
const handleEditorContentChange = (html) => {
|
||||
setAllWordsHtml(html);
|
||||
setAllWordsText(htmlToPlainText(html));
|
||||
};
|
||||
|
||||
// Preserve user-entered spacing and line breaks so preview and saved exercise match.
|
||||
const allWords =
|
||||
allWordsText.replace(/\r\n/g, "\n").match(/[^\S\n]+|\n|[^\s]+/g) ?? [];
|
||||
@@ -74,6 +98,7 @@ export default function CreateExercise() {
|
||||
targetCategory,
|
||||
targetWords: selectedWords,
|
||||
allWords,
|
||||
sourceHtml: allWordsHtml,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -87,6 +112,7 @@ export default function CreateExercise() {
|
||||
targetCategory,
|
||||
targetWords: selectedWords,
|
||||
allWords,
|
||||
sourceHtml: allWordsHtml,
|
||||
});
|
||||
|
||||
setSubmitted(true);
|
||||
@@ -139,7 +165,7 @@ export default function CreateExercise() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="click-admin-page">
|
||||
<h1>
|
||||
{isEditMode
|
||||
? "Wortauswahl-Übung bearbeiten"
|
||||
@@ -148,9 +174,9 @@ export default function CreateExercise() {
|
||||
{submitted ? (
|
||||
successMessage() // Show only the success message if submitted
|
||||
) : !previewMode ? (
|
||||
<form onSubmit={handlePreview}>
|
||||
<form onSubmit={handlePreview} className="click-form">
|
||||
<Column gap="md">
|
||||
<Container className="exercise-click">
|
||||
<Container className="exercise-click click-block">
|
||||
<label>Übungstitel</label>
|
||||
<input
|
||||
className="click-input"
|
||||
@@ -162,7 +188,7 @@ export default function CreateExercise() {
|
||||
/>
|
||||
</Container>
|
||||
|
||||
<Container className="exercise-click">
|
||||
<Container className="exercise-click click-block">
|
||||
<label>Zielkategorie</label>
|
||||
<input
|
||||
className="click-input"
|
||||
@@ -174,21 +200,20 @@ export default function CreateExercise() {
|
||||
/>
|
||||
</Container>
|
||||
|
||||
<Container className="exercise-click">
|
||||
<Container className="exercise-click click-block">
|
||||
<label>Übungstext</label>
|
||||
<textarea
|
||||
className="click-input"
|
||||
value={allWordsText}
|
||||
onChange={(e) => setAllWordsText(e.target.value)}
|
||||
placeholder="Z. B. Ich mag laufen und schwimmen."
|
||||
rows={4}
|
||||
required
|
||||
<Editor
|
||||
key="click-editor"
|
||||
defaultContent={allWordsHtml}
|
||||
updateEditorContent={handleEditorContentChange}
|
||||
/>
|
||||
</Container>
|
||||
</Column>
|
||||
<Button size="md" type="submit">
|
||||
Zielwörter auswählen
|
||||
</Button>
|
||||
<Container className="click-form-actions">
|
||||
<Button size="md" type="submit">
|
||||
Zielwörter auswählen
|
||||
</Button>
|
||||
</Container>
|
||||
</form>
|
||||
) : (
|
||||
<div>
|
||||
@@ -205,6 +230,7 @@ export default function CreateExercise() {
|
||||
targetCategory={targetCategory}
|
||||
targetWords={selectedWords}
|
||||
allWords={allWords}
|
||||
sourceHtml={allWordsHtml}
|
||||
isPreviewMode={true}
|
||||
onSelectionChange={(updatedSelectedWords) =>
|
||||
setSelectedWords(updatedSelectedWords)
|
||||
|
||||
@@ -53,6 +53,22 @@
|
||||
background: var(--bg1);
|
||||
}
|
||||
|
||||
.fitg-gap-remove {
|
||||
border: 1px solid var(--tertiary2);
|
||||
background: transparent;
|
||||
color: var(--tertiary2);
|
||||
border-radius: 999px;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.fitg-gap-remove:hover {
|
||||
background: var(--tertiary1);
|
||||
}
|
||||
|
||||
.fitg-answer-row {
|
||||
border: 1px dashed var(--bg4);
|
||||
border-radius: var(--u-xs);
|
||||
@@ -65,4 +81,4 @@
|
||||
border: 1px solid var(--tertiary2);
|
||||
padding: var(--u-sm);
|
||||
border-radius: var(--u-xs);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,75 @@ function createDefaultAnswers() {
|
||||
];
|
||||
}
|
||||
|
||||
function normalizeTokenKey(value) {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function takeClosestIndex(indices, targetIndex) {
|
||||
if (!indices.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let bestArrayIndex = 0;
|
||||
let bestDistance = Math.abs(indices[0] - targetIndex);
|
||||
|
||||
for (let i = 1; i < indices.length; i++) {
|
||||
const distance = Math.abs(indices[i] - targetIndex);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
bestArrayIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
const [chosen] = indices.splice(bestArrayIndex, 1);
|
||||
return chosen;
|
||||
}
|
||||
|
||||
function remapGapsToTokens(existingGaps, nextTokens) {
|
||||
const indicesByWord = new Map();
|
||||
|
||||
nextTokens.forEach((token, index) => {
|
||||
const key = normalizeTokenKey(token.word || token.raw);
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!indicesByWord.has(key)) {
|
||||
indicesByWord.set(key, []);
|
||||
}
|
||||
indicesByWord.get(key).push(index);
|
||||
});
|
||||
|
||||
const remapped = [];
|
||||
const orderedGaps = [...existingGaps].sort(
|
||||
(a, b) => a.tokenIndex - b.tokenIndex
|
||||
);
|
||||
|
||||
orderedGaps.forEach((gap) => {
|
||||
const key = normalizeTokenKey(gap.tokenText);
|
||||
const indices = indicesByWord.get(key) || [];
|
||||
if (!indices.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextIndex = takeClosestIndex(indices, gap.tokenIndex);
|
||||
if (nextIndex === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
remapped.push({
|
||||
...gap,
|
||||
tokenIndex: nextIndex,
|
||||
tokenText:
|
||||
nextTokens[nextIndex]?.word || nextTokens[nextIndex]?.raw || key,
|
||||
});
|
||||
});
|
||||
|
||||
return remapped.sort((a, b) => a.tokenIndex - b.tokenIndex);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || "")
|
||||
.replace(/&/g, "&")
|
||||
@@ -69,6 +138,35 @@ export default function CreateFillInTheGapExercisePage() {
|
||||
|
||||
const tokens = useMemo(() => tokenizeText(text), [text]);
|
||||
|
||||
const syncGapsWithSelection = (nextSelectedIndices) => {
|
||||
const normalized = [...new Set(nextSelectedIndices)].sort((a, b) => a - b);
|
||||
|
||||
setSelectedIndices(normalized);
|
||||
setGaps((current) =>
|
||||
normalized.map((tokenIndex) => {
|
||||
const existingGap = current.find(
|
||||
(gap) => gap.tokenIndex === tokenIndex
|
||||
);
|
||||
|
||||
if (existingGap) {
|
||||
return {
|
||||
...existingGap,
|
||||
tokenText:
|
||||
tokens[tokenIndex]?.word ||
|
||||
tokens[tokenIndex]?.raw ||
|
||||
existingGap.tokenText,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tokenIndex,
|
||||
tokenText: tokens[tokenIndex]?.word || tokens[tokenIndex]?.raw || "",
|
||||
answers: createDefaultAnswers(),
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditMode || !exerciseData) {
|
||||
return;
|
||||
@@ -105,22 +203,24 @@ export default function CreateFillInTheGapExercisePage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTokens = tokenizeText(normalizedText);
|
||||
const remappedGaps = remapGapsToTokens(gaps, nextTokens);
|
||||
|
||||
setTextHtml(html);
|
||||
setText(normalizedText);
|
||||
setSelectedIndices([]);
|
||||
if (step === "configure") {
|
||||
setGaps(remappedGaps);
|
||||
setSelectedIndices(remappedGaps.map((gap) => gap.tokenIndex));
|
||||
if (step === "configure" && remappedGaps.length === 0) {
|
||||
setStep("select");
|
||||
setGaps([]);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleGapIndex = (index) => {
|
||||
setSelectedIndices((current) => {
|
||||
if (current.includes(index)) {
|
||||
return current.filter((item) => item !== index);
|
||||
}
|
||||
return [...current, index].sort((a, b) => a - b);
|
||||
});
|
||||
const nextSelected = selectedIndices.includes(index)
|
||||
? selectedIndices.filter((item) => item !== index)
|
||||
: [...selectedIndices, index];
|
||||
|
||||
syncGapsWithSelection(nextSelected);
|
||||
};
|
||||
|
||||
const moveToGapConfiguration = () => {
|
||||
@@ -138,13 +238,7 @@ export default function CreateFillInTheGapExercisePage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const mappedGaps = selectedIndices.map((tokenIndex) => ({
|
||||
tokenIndex,
|
||||
tokenText: tokens[tokenIndex]?.word || tokens[tokenIndex]?.raw || "",
|
||||
answers: createDefaultAnswers(),
|
||||
}));
|
||||
|
||||
setGaps(mappedGaps);
|
||||
syncGapsWithSelection(selectedIndices);
|
||||
setStep("configure");
|
||||
};
|
||||
|
||||
@@ -192,6 +286,13 @@ export default function CreateFillInTheGapExercisePage() {
|
||||
});
|
||||
};
|
||||
|
||||
const removeGap = (tokenIndex) => {
|
||||
const nextSelected = selectedIndices.filter(
|
||||
(index) => index !== tokenIndex
|
||||
);
|
||||
syncGapsWithSelection(nextSelected);
|
||||
};
|
||||
|
||||
const validateConfiguration = () => {
|
||||
if (gaps.length === 0) {
|
||||
return "Bitte wähle mindestens eine Lücke aus.";
|
||||
@@ -300,9 +401,13 @@ export default function CreateFillInTheGapExercisePage() {
|
||||
/>
|
||||
</Container>
|
||||
|
||||
{tokens.length > 0 && step === "select" && (
|
||||
{tokens.length > 0 && (
|
||||
<Container className="fitg-block">
|
||||
<h3>Wörter anklicken, die als Lücken markiert werden sollen</h3>
|
||||
<h3>
|
||||
{step === "configure"
|
||||
? "Wörter anklicken, um Lücken hinzuzufügen/zu entfernen"
|
||||
: "Wörter anklicken, die als Lücken markiert werden sollen"}
|
||||
</h3>
|
||||
<p className="fitg-help-text">Ausgewählt: {selectedIndices.length}</p>
|
||||
<div className="fitg-token-grid">
|
||||
{tokens.map((token, index) => {
|
||||
@@ -340,9 +445,20 @@ export default function CreateFillInTheGapExercisePage() {
|
||||
key={`${gap.tokenIndex}-${gap.tokenText}`}
|
||||
className="fitg-gap-card"
|
||||
>
|
||||
<h4>
|
||||
Lücke {gapIndex + 1}: "{gap.tokenText}"
|
||||
</h4>
|
||||
<Row justify="space-between" align="center" wrap="wrap">
|
||||
<h4>
|
||||
Lücke {gapIndex + 1}: "{gap.tokenText}"
|
||||
</h4>
|
||||
<button
|
||||
type="button"
|
||||
className="fitg-gap-remove"
|
||||
onClick={() => removeGap(gap.tokenIndex)}
|
||||
aria-label={`Lücke ${gapIndex + 1} entfernen`}
|
||||
title="Lücke entfernen"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</Row>
|
||||
|
||||
<Column gap="md">
|
||||
{gap.answers.map((answer, answerIndex) => (
|
||||
@@ -448,7 +564,7 @@ export default function CreateFillInTheGapExercisePage() {
|
||||
variant="outline"
|
||||
onClick={() => setStep("select")}
|
||||
>
|
||||
Zurück zur Lückenauswahl
|
||||
Wörter auswählen/ändern
|
||||
</Button>
|
||||
<Button type="button" onClick={handleSave} disabled={saving}>
|
||||
{saving
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { DB } from "@/backend/db";
|
||||
import { withAuth } from "@/backend/middleware/withAuth";
|
||||
import DOMPurify from "dompurify";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
function sanitizeHtml(html) {
|
||||
const window = new JSDOM("").window;
|
||||
const purify = DOMPurify(window);
|
||||
return purify.sanitize(String(html || ""), { ADD_ATTR: ["target"] });
|
||||
}
|
||||
|
||||
function validatePayload({ title, targetCategory, targetWords, allWords }) {
|
||||
if (!title || !targetCategory || !targetWords || !allWords) {
|
||||
@@ -44,7 +52,7 @@ export const GET = withAuth(
|
||||
const { click_id } = await params;
|
||||
|
||||
const result = await DB.pool(
|
||||
`SELECT id, title, category, target_words, all_words, created_at, updated_at
|
||||
`SELECT id, title, category, target_words, all_words, source_html, created_at, updated_at
|
||||
FROM click_exercises
|
||||
WHERE id = $1`,
|
||||
[click_id]
|
||||
@@ -77,7 +85,8 @@ export const PUT = withAuth(
|
||||
try {
|
||||
const { click_id } = await params;
|
||||
const body = await request.json();
|
||||
const { title, targetCategory, targetWords, allWords } = body;
|
||||
const { title, targetCategory, targetWords, allWords, sourceHtml } = body;
|
||||
const sanitizedSourceHtml = sanitizeHtml(sourceHtml || "");
|
||||
|
||||
const validationError = validatePayload({
|
||||
title,
|
||||
@@ -127,9 +136,17 @@ export const PUT = withAuth(
|
||||
category = $2,
|
||||
target_words = $3,
|
||||
all_words = $4,
|
||||
source_html = $5,
|
||||
updated_at = NOW()
|
||||
WHERE id = $5`,
|
||||
[title, targetCategory, targetWords, allWords, click_id]
|
||||
WHERE id = $6`,
|
||||
[
|
||||
title,
|
||||
targetCategory,
|
||||
targetWords,
|
||||
allWords,
|
||||
sanitizedSourceHtml,
|
||||
click_id,
|
||||
]
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { DB } from "@/backend/db";
|
||||
import { withAuth } from "@/backend/middleware/withAuth";
|
||||
import DOMPurify from "dompurify";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
function sanitizeHtml(html) {
|
||||
const window = new JSDOM("").window;
|
||||
const purify = DOMPurify(window);
|
||||
return purify.sanitize(String(html || ""), { ADD_ATTR: ["target"] });
|
||||
}
|
||||
|
||||
export const POST = withAuth(
|
||||
async (request) => {
|
||||
const json = await request.json();
|
||||
const { title, targetCategory, targetWords, allWords } = json;
|
||||
const { title, targetCategory, targetWords, allWords, sourceHtml } = json;
|
||||
const sanitizedSourceHtml = sanitizeHtml(sourceHtml || "");
|
||||
|
||||
if (!title || !targetCategory || !targetWords || !allWords) {
|
||||
return Response.json(
|
||||
@@ -51,9 +60,9 @@ export const POST = withAuth(
|
||||
);
|
||||
}
|
||||
const id = await DB.pool(
|
||||
`INSERT INTO click_exercises (title, category, target_words, all_words)
|
||||
VALUES ($1, $2, $3, $4) returning id`,
|
||||
[title, targetCategory, targetWords, allWords]
|
||||
`INSERT INTO click_exercises (title, category, target_words, all_words, source_html)
|
||||
VALUES ($1, $2, $3, $4, $5) returning id`,
|
||||
[title, targetCategory, targetWords, allWords, sanitizedSourceHtml]
|
||||
);
|
||||
return Response.json({ id: id.rows[0].id }, { status: 201 });
|
||||
},
|
||||
|
||||
@@ -209,6 +209,55 @@ describe("create click_exercises API", () => {
|
||||
"Es müssen zwischen 1 und 1000 Wörter vorhanden sein."
|
||||
);
|
||||
});
|
||||
it("should store sourceHtml in the database when provided", async () => {
|
||||
const admin = await TestFactory.user({ is_admin: true });
|
||||
const { mockPost } = useTestRequest(admin);
|
||||
|
||||
const sourceHtml = "<p>Die Kinder <b>laufen</b> schnell.</p>";
|
||||
|
||||
const response = await POST(
|
||||
mockPost("api/exercises/create/click", {
|
||||
title: "Verben identifizieren",
|
||||
targetCategory: "Verben",
|
||||
targetWords: ["laufen"],
|
||||
allWords: ["Die", "Kinder", "laufen", "schnell"],
|
||||
sourceHtml,
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
const json = await response.json();
|
||||
|
||||
const exercise = await DB.pool(
|
||||
`SELECT source_html FROM click_exercises WHERE id = $1`,
|
||||
[json.id]
|
||||
);
|
||||
expect(exercise.rows[0].source_html).toBe(sourceHtml);
|
||||
});
|
||||
it("should sanitize malicious HTML in sourceHtml", async () => {
|
||||
const admin = await TestFactory.user({ is_admin: true });
|
||||
const { mockPost } = useTestRequest(admin);
|
||||
|
||||
const response = await POST(
|
||||
mockPost("api/exercises/create/click", {
|
||||
title: "Verben identifizieren",
|
||||
targetCategory: "Verben",
|
||||
targetWords: ["laufen"],
|
||||
allWords: ["Die", "Kinder", "laufen"],
|
||||
sourceHtml: '<p>Hallo</p><script>alert("xss")</script>',
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
const json = await response.json();
|
||||
|
||||
const exercise = await DB.pool(
|
||||
`SELECT source_html FROM click_exercises WHERE id = $1`,
|
||||
[json.id]
|
||||
);
|
||||
expect(exercise.rows[0].source_html).not.toContain("<script>");
|
||||
expect(exercise.rows[0].source_html).toContain("<p>Hallo</p>");
|
||||
});
|
||||
it("should return 409 if exercise with the same title already exists", async () => {
|
||||
const admin = await TestFactory.user({ is_admin: true });
|
||||
const { mockPost } = useTestRequest(admin);
|
||||
|
||||
@@ -387,7 +387,7 @@ h1 {
|
||||
h2 {
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
color: var(--tertiary1);
|
||||
color: var(--fg1);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
@@ -490,22 +490,35 @@ label {
|
||||
}
|
||||
|
||||
.rendered-html p {
|
||||
margin-bottom: 1em;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.rendered-html p:last-child {
|
||||
margin-bottom: 0;
|
||||
.rendered-html h1,
|
||||
.rendered-html h2,
|
||||
.rendered-html h3,
|
||||
.rendered-html h4,
|
||||
.rendered-html h5,
|
||||
.rendered-html h6,
|
||||
.rendered-html li {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rendered-html h1,
|
||||
.rendered-html h2,
|
||||
.rendered-html h3,
|
||||
.rendered-html h4,
|
||||
.rendered-html h5,
|
||||
.rendered-html h6,
|
||||
.rendered-html p,
|
||||
.rendered-html li {
|
||||
margin-bottom: 0.35em;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.rendered-html ul,
|
||||
.rendered-html ol {
|
||||
padding-left: 2rem;
|
||||
margin-bottom: 1em;
|
||||
padding-left: 1.5em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rendered-html ul {
|
||||
@@ -669,4 +682,9 @@ label {
|
||||
height: 14px;
|
||||
right: -7px;
|
||||
bottom: -7px;
|
||||
}
|
||||
|
||||
.ql-table-dropdown-label,
|
||||
.ql-table-dropdown-properties-label {
|
||||
color: var(--bg1);
|
||||
}
|
||||
@@ -60,6 +60,7 @@ export default function StudentExercisePage() {
|
||||
targetCategory={exercise.category}
|
||||
targetWords={exercise.target_words}
|
||||
allWords={exercise.all_words}
|
||||
sourceHtml={exercise.source_html}
|
||||
previousAnswers={data.userAnswers?.answer || []}
|
||||
isPreviewMode={false}
|
||||
onSubmit={handleSubmit} // Pass the submit handler as a prop
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
}
|
||||
|
||||
.chapter-page-title,
|
||||
.chapter-rendered-content h1,
|
||||
.chapter-rendered-content h2,
|
||||
.chapter-rendered-content h3 {
|
||||
.chapter-rendered-content>h1,
|
||||
.chapter-rendered-content>h2,
|
||||
.chapter-rendered-content>h3 {
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
@@ -22,44 +22,22 @@
|
||||
}
|
||||
|
||||
.chapter-page-title,
|
||||
.chapter-rendered-content h1 {
|
||||
.chapter-rendered-content>h1 {
|
||||
font-size: 2rem;
|
||||
padding-bottom: 0.5rem;
|
||||
color: var(--fg1);
|
||||
}
|
||||
|
||||
.chapter-rendered-content h2 {
|
||||
.chapter-rendered-content>h2 {
|
||||
font-size: 1.75rem;
|
||||
color: var(--fg1);
|
||||
}
|
||||
|
||||
.chapter-rendered-content h3 {
|
||||
.chapter-rendered-content>h3 {
|
||||
font-size: 1.35rem;
|
||||
color: var(--fg1);
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1.25rem;
|
||||
line-height: 1.6;
|
||||
color: var(--fg2);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
li {
|
||||
color: var(--fg2);
|
||||
margin: 0.5rem 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--primary2);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.chapter-content {
|
||||
max-width: 1400px;
|
||||
}
|
||||
@@ -76,28 +54,34 @@ a {
|
||||
font-size: 2.5em;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
table td,
|
||||
table th {
|
||||
border: 1px solid #cbd5e1;
|
||||
padding: 8px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
table th {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chapter-rendered-content {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.chapter-rendered-content .rendered-html {
|
||||
color: var(--fg2);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.chapter-rendered-content .rendered-html a {
|
||||
color: var(--primary2);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.chapter-rendered-content .rendered-html table td,
|
||||
.chapter-rendered-content .rendered-html table th {
|
||||
border: none;
|
||||
padding: 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.chapter-rendered-content .rendered-html table img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.chapter-rendered-content .rendered-html,
|
||||
.chapter-rendered-content .rendered-html {
|
||||
width: 100%;
|
||||
|
||||
@@ -76,7 +76,7 @@ export default function TalkbackPage() {
|
||||
return (
|
||||
<section className="talkback-container">
|
||||
<div className="talkback-box">
|
||||
<h1 className="auth-title">Rückmeldekanal - Feedback geben</h1>
|
||||
<h1 className="auth-title">Brauchen Sie Hilfe?</h1>
|
||||
|
||||
{error && <p className="error">{error}</p>}
|
||||
|
||||
|
||||
@@ -1,83 +1,86 @@
|
||||
.talkback-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
align-items: center;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.talkback-box {
|
||||
background-color: var(--bg3);
|
||||
border-radius: var(--radius-2xl);
|
||||
padding: var(--u-3xl);
|
||||
box-shadow: 0 10px 24px rgb(0 0 0 / 10%);
|
||||
width: max-content;
|
||||
max-width: fit-content;
|
||||
}
|
||||
|
||||
.auth-box {
|
||||
width: 100%;
|
||||
max-width: max-content;
|
||||
background-color: var(--bg3);
|
||||
border-radius: var(--radius-2xl);
|
||||
padding: var(--u-xl);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--u-lg);
|
||||
}
|
||||
|
||||
.talkback-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--u-lg);
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
text-align: left;
|
||||
margin-bottom: var(--u-lg);
|
||||
color: var(--fg1);
|
||||
}
|
||||
|
||||
.talkback-message-input {
|
||||
resize: none;
|
||||
min-height: 220px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: var(--u-md);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: var(--u-md);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.form-button {
|
||||
margin-top: var(--u-2xl);
|
||||
}
|
||||
|
||||
.error {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.talkback-container {
|
||||
padding: var(--u-lg) var(--u-md) var(--u-2xl);
|
||||
}
|
||||
|
||||
.talkback-box {
|
||||
padding: var(--u-xl);
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
.talkback-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
align-items: center;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.talkback-box {
|
||||
background-color: var(--bg3);
|
||||
border-radius: var(--radius-2xl);
|
||||
padding: var(--u-xl);
|
||||
box-shadow: 0 10px 24px rgb(0 0 0 / 10%);
|
||||
width: 570px;
|
||||
}
|
||||
|
||||
.auth-box {
|
||||
width: 100%;
|
||||
max-width: max-content;
|
||||
background-color: var(--bg3);
|
||||
border-radius: var(--radius-2xl);
|
||||
padding: var(--u-xl);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--u-lg);
|
||||
}
|
||||
|
||||
.talkback-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--u-lg);
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
text-align: left;
|
||||
margin-bottom: var(--u-lg);
|
||||
color: var(--fg1);
|
||||
}
|
||||
|
||||
.talkback-message-input {
|
||||
resize: none;
|
||||
min-height: 220px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
/* margin-bottom: var(--u-md); */
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: var(--u-md);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.form-button {
|
||||
margin-top: var(--u-2xl);
|
||||
}
|
||||
|
||||
.error {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.talkback-container {
|
||||
padding: var(--u-lg) var(--u-md) var(--u-2xl);
|
||||
}
|
||||
|
||||
.talkback-box {
|
||||
padding: var(--u-xl);
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
line-height: inherit;
|
||||
transition:
|
||||
background-color 0.1s ease-in-out,
|
||||
border-color 0.1s ease-in-out,
|
||||
@@ -138,6 +140,12 @@
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
width: fit-content;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
line-height: inherit;
|
||||
text-decoration: inherit;
|
||||
text-decoration-color: inherit;
|
||||
text-decoration-thickness: inherit;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg2);
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
}
|
||||
|
||||
.word-container {
|
||||
line-height: 2.2;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.7;
|
||||
padding: 12px 15px;
|
||||
white-space: pre-wrap;
|
||||
background: var(--bg3);
|
||||
background: var(--bg1);
|
||||
}
|
||||
|
||||
.word-punct {
|
||||
@@ -19,5 +21,83 @@
|
||||
|
||||
.word-line-break {
|
||||
display: block;
|
||||
height: 0.75rem;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.click-instruction {
|
||||
background: var(--bg3);
|
||||
color: var(--fg1);
|
||||
padding: 0.3em 1em;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-md);
|
||||
}
|
||||
|
||||
.click-source-html {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.click-rendered-content {
|
||||
/* Match Quill editor text box metrics so saved wrapping mirrors editing view. */
|
||||
padding: 12px 15px;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.7;
|
||||
color: var(--fg1);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.click-rendered-content br {
|
||||
display: block;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.click-rendered-content :where(p, li, strong, em, span, a, h1, h2, h3, h4, h5, h6):not([style*="color"]) {
|
||||
color: var(--fg1);
|
||||
}
|
||||
|
||||
.click-rendered-content p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.click-rendered-content ul,
|
||||
.click-rendered-content ol {
|
||||
margin: 0;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.click-rendered-content li {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
.click-rendered-content li::marker {
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.click-rendered-content .ql-indent-1 {
|
||||
padding-left: 3em;
|
||||
}
|
||||
|
||||
.click-rendered-content .ql-indent-2 {
|
||||
padding-left: 6em;
|
||||
}
|
||||
|
||||
.click-rendered-content .ql-indent-3 {
|
||||
padding-left: 9em;
|
||||
}
|
||||
|
||||
.click-rendered-content .ql-indent-4 {
|
||||
padding-left: 12em;
|
||||
}
|
||||
|
||||
.rendered-html ul,
|
||||
.rendered-html ol {
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.rendered-html li[data-list="bullet"] {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.rendered-html li[data-list="ordered"] {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import React, { useMemo, useState } from "react";
|
||||
import { Button } from "../button";
|
||||
import { Container } from "../layout/container";
|
||||
import { Column } from "@/components/ui/layout/container";
|
||||
import parse from "html-react-parser";
|
||||
import "./click.css";
|
||||
|
||||
const WORD_REGEX = /\p{L}+(?:['’-]\p{L}+)*/gu;
|
||||
@@ -38,11 +39,77 @@ const splitTokenToParts = (token) => {
|
||||
|
||||
const isWhitespaceToken = (token) => /^[^\S\n]+$/u.test(token);
|
||||
|
||||
function buildHtmlWithWordSlots(sourceHtml) {
|
||||
if (!sourceHtml || typeof window === "undefined") {
|
||||
return { html: "", slots: [] };
|
||||
}
|
||||
|
||||
const doc = new window.DOMParser().parseFromString(
|
||||
`<div id="click-root">${sourceHtml}</div>`,
|
||||
"text/html"
|
||||
);
|
||||
const root = doc.getElementById("click-root");
|
||||
|
||||
if (!root) {
|
||||
return { html: "", slots: [] };
|
||||
}
|
||||
|
||||
const walker = doc.createTreeWalker(root, window.NodeFilter.SHOW_TEXT);
|
||||
const textNodes = [];
|
||||
let current;
|
||||
|
||||
while ((current = walker.nextNode())) {
|
||||
textNodes.push(current);
|
||||
}
|
||||
|
||||
let wordIndex = 0;
|
||||
const slots = [];
|
||||
|
||||
for (const textNode of textNodes) {
|
||||
const rawText = textNode.nodeValue || "";
|
||||
const chunks = rawText.match(/\s+|[^\s]+/g) || [];
|
||||
const fragment = doc.createDocumentFragment();
|
||||
|
||||
for (const chunk of chunks) {
|
||||
if (/^\s+$/.test(chunk)) {
|
||||
fragment.appendChild(doc.createTextNode(chunk));
|
||||
continue;
|
||||
}
|
||||
|
||||
const parts = splitTokenToParts(chunk);
|
||||
for (const part of parts) {
|
||||
if (part.type === "word") {
|
||||
const slotKey = `w-${wordIndex}`;
|
||||
const wordNode = doc.createElement("click-word");
|
||||
|
||||
wordNode.setAttribute("data-slot-key", slotKey);
|
||||
wordNode.setAttribute("data-word", part.value);
|
||||
wordNode.textContent = part.value;
|
||||
|
||||
slots.push({
|
||||
slotKey,
|
||||
word: part.value,
|
||||
});
|
||||
wordIndex += 1;
|
||||
fragment.appendChild(wordNode);
|
||||
} else {
|
||||
fragment.appendChild(doc.createTextNode(part.value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
textNode.parentNode?.replaceChild(fragment, textNode);
|
||||
}
|
||||
|
||||
return { html: root.innerHTML, slots };
|
||||
}
|
||||
|
||||
const WordSelectionExercise = ({
|
||||
title,
|
||||
targetCategory,
|
||||
targetWords,
|
||||
allWords,
|
||||
sourceHtml,
|
||||
previousAnswers,
|
||||
onSelectionChange,
|
||||
onSubmit,
|
||||
@@ -56,7 +123,16 @@ const WordSelectionExercise = ({
|
||||
const [selectedSlotKeys, setSelectedSlotKeys] = useState([]);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
const wordSlots = useMemo(() => {
|
||||
const normalizedSourceHtml = useMemo(() => {
|
||||
return String(sourceHtml || "");
|
||||
}, [sourceHtml]);
|
||||
|
||||
const htmlSlotsData = useMemo(
|
||||
() => buildHtmlWithWordSlots(normalizedSourceHtml),
|
||||
[normalizedSourceHtml]
|
||||
);
|
||||
|
||||
const fallbackWordSlots = useMemo(() => {
|
||||
const slots = [];
|
||||
|
||||
allWords?.forEach((token, tokenIndex) => {
|
||||
@@ -76,12 +152,63 @@ const WordSelectionExercise = ({
|
||||
return slots;
|
||||
}, [allWords]);
|
||||
|
||||
const wordSlots = useMemo(
|
||||
() =>
|
||||
htmlSlotsData.slots.length > 0 ? htmlSlotsData.slots : fallbackWordSlots,
|
||||
[htmlSlotsData.slots, fallbackWordSlots]
|
||||
);
|
||||
|
||||
const slotToWord = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(wordSlots.map(({ slotKey, word }) => [slotKey, word])),
|
||||
[wordSlots]
|
||||
);
|
||||
|
||||
const targetSlotKeys = useMemo(() => {
|
||||
const rawTargets = Array.isArray(targetWords) ? targetWords : [];
|
||||
const slotKeySet = new Set(wordSlots.map(({ slotKey }) => slotKey));
|
||||
|
||||
// New format: target words are stored as unique slot keys.
|
||||
const allAreSlotKeys =
|
||||
rawTargets.length > 0 &&
|
||||
rawTargets.every((target) => slotKeySet.has(target));
|
||||
if (allAreSlotKeys) {
|
||||
return rawTargets;
|
||||
}
|
||||
|
||||
// Legacy format: map target words to the first unmatched occurrences.
|
||||
const needed = {};
|
||||
rawTargets.forEach((word) => {
|
||||
needed[word] = (needed[word] || 0) + 1;
|
||||
});
|
||||
|
||||
const used = {};
|
||||
const mappedKeys = [];
|
||||
|
||||
wordSlots.forEach(({ slotKey, word }) => {
|
||||
const remaining = (needed[word] || 0) - (used[word] || 0);
|
||||
if (remaining > 0) {
|
||||
mappedKeys.push(slotKey);
|
||||
used[word] = (used[word] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
return mappedKeys;
|
||||
}, [targetWords, wordSlots]);
|
||||
|
||||
const targetSlotKeySet = useMemo(
|
||||
() => new Set(targetSlotKeys),
|
||||
[targetSlotKeys]
|
||||
);
|
||||
|
||||
const usesSlotKeyTargets = useMemo(() => {
|
||||
const rawTargets = Array.isArray(targetWords) ? targetWords : [];
|
||||
return (
|
||||
rawTargets.length > 0 &&
|
||||
rawTargets.every((target) => targetSlotKeySet.has(target))
|
||||
);
|
||||
}, [targetWords, targetSlotKeySet]);
|
||||
|
||||
const handleWordClick = (slotKey) => {
|
||||
if (isSubmitted && !isPreviewMode) return;
|
||||
|
||||
@@ -95,31 +222,33 @@ const WordSelectionExercise = ({
|
||||
setSelectedSlotKeys(updatedKeys);
|
||||
|
||||
if (isPreviewMode && onSelectionChange) {
|
||||
onSelectionChange(
|
||||
updatedKeys.map((key) => slotToWord[key]).filter(Boolean)
|
||||
);
|
||||
// In preview/admin mode we persist exact selected occurrences.
|
||||
onSelectionChange(updatedKeys);
|
||||
}
|
||||
};
|
||||
|
||||
const checkAnswers = () => {
|
||||
if (isPreviewMode) return;
|
||||
|
||||
const selectedWordValues = selectedSlotKeys
|
||||
.map((key) => slotToWord[key])
|
||||
.filter(Boolean);
|
||||
const correctAnswers = targetWords;
|
||||
const incorrectSelections = selectedWordValues.filter(
|
||||
(word) => !correctAnswers.includes(word)
|
||||
const selectedSet = new Set(selectedSlotKeys);
|
||||
const incorrectSelections = selectedSlotKeys.filter(
|
||||
(slotKey) => !targetSlotKeySet.has(slotKey)
|
||||
);
|
||||
const missedCorrectAnswers = correctAnswers.filter(
|
||||
(word) => !selectedWordValues.includes(word)
|
||||
const missedCorrectAnswers = targetSlotKeys.filter(
|
||||
(slotKey) => !selectedSet.has(slotKey)
|
||||
);
|
||||
const totalCorrectTargets = targetSlotKeys.length;
|
||||
|
||||
if (totalCorrectTargets === 0) {
|
||||
onSubmit([], 0, "Diese Übung enthält keine markierten Zielwörter.");
|
||||
return;
|
||||
}
|
||||
|
||||
const score = Math.round(
|
||||
((correctAnswers.length -
|
||||
((totalCorrectTargets -
|
||||
missedCorrectAnswers.length -
|
||||
incorrectSelections.length) /
|
||||
correctAnswers.length) *
|
||||
totalCorrectTargets) *
|
||||
100
|
||||
);
|
||||
|
||||
@@ -135,7 +264,11 @@ const WordSelectionExercise = ({
|
||||
feedbackMessage = "Weiter üben! Punktzahl: " + score + "%";
|
||||
}
|
||||
|
||||
onSubmit(selectedWordValues, score, feedbackMessage);
|
||||
const selectedPayload = usesSlotKeyTargets
|
||||
? selectedSlotKeys
|
||||
: selectedSlotKeys.map((key) => slotToWord[key]).filter(Boolean);
|
||||
|
||||
onSubmit(selectedPayload, score, feedbackMessage);
|
||||
};
|
||||
|
||||
const resetExercise = () => {
|
||||
@@ -150,35 +283,46 @@ const WordSelectionExercise = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const needed = {};
|
||||
previousAnswers.forEach((w) => {
|
||||
needed[w] = (needed[w] || 0) + 1;
|
||||
});
|
||||
const availableSlotKeys = new Set(wordSlots.map(({ slotKey }) => slotKey));
|
||||
const previousAreSlotKeys = previousAnswers.every((answer) =>
|
||||
availableSlotKeys.has(answer)
|
||||
);
|
||||
|
||||
const used = {};
|
||||
const restoredKeys = [];
|
||||
let restoredKeys = [];
|
||||
|
||||
wordSlots.forEach(({ slotKey, word }) => {
|
||||
const remaining = (needed[word] || 0) - (used[word] || 0);
|
||||
if (remaining > 0) {
|
||||
restoredKeys.push(slotKey);
|
||||
used[word] = (used[word] || 0) + 1;
|
||||
}
|
||||
});
|
||||
if (previousAreSlotKeys) {
|
||||
restoredKeys = previousAnswers.filter((answer) =>
|
||||
availableSlotKeys.has(answer)
|
||||
);
|
||||
} else {
|
||||
const needed = {};
|
||||
previousAnswers.forEach((word) => {
|
||||
needed[word] = (needed[word] || 0) + 1;
|
||||
});
|
||||
|
||||
const used = {};
|
||||
wordSlots.forEach(({ slotKey, word }) => {
|
||||
const remaining = (needed[word] || 0) - (used[word] || 0);
|
||||
if (remaining > 0) {
|
||||
restoredKeys.push(slotKey);
|
||||
used[word] = (used[word] || 0) + 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setSelectedSlotKeys(restoredKeys);
|
||||
};
|
||||
|
||||
const getTokenStyle = (word, slotKey) => {
|
||||
const getTokenStyle = (_word, slotKey) => {
|
||||
if (selectedSlotKeys.includes(slotKey)) {
|
||||
if (isSubmitted && !isPreviewMode) {
|
||||
return targetWords.includes(word)
|
||||
return targetSlotKeySet.has(slotKey)
|
||||
? { backgroundColor: "var(--green)" }
|
||||
: { backgroundColor: "var(--red)" };
|
||||
}
|
||||
return { backgroundColor: "var(--click-selected-bg, var(--blue))" };
|
||||
} else {
|
||||
if (isSubmitted && targetWords.includes(word) && !isPreviewMode) {
|
||||
if (isSubmitted && targetSlotKeySet.has(slotKey) && !isPreviewMode) {
|
||||
return {
|
||||
backgroundColor: "var(--click-missed-bg, var(--yellow))",
|
||||
border: "1px solid var(--click-missed-border, var(--yellow-border))",
|
||||
@@ -189,7 +333,7 @@ const WordSelectionExercise = ({
|
||||
|
||||
const renderToken = (token, tokenIndex) => {
|
||||
if (token === "\n") {
|
||||
return <span key={`${tokenIndex}-nl`} className="word-line-break" />;
|
||||
return <br key={`${tokenIndex}-nl`} />;
|
||||
}
|
||||
|
||||
if (isWhitespaceToken(token)) {
|
||||
@@ -229,14 +373,47 @@ const WordSelectionExercise = ({
|
||||
return (
|
||||
<Column gap="md">
|
||||
<h1>{title}</h1>
|
||||
<i>{`Wähle alle ${targetCategory} aus dem untenstehenden Text aus.`}</i>
|
||||
<Container className="word-container">
|
||||
{allWords?.map((token, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{renderToken(token, index)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Container>
|
||||
<div className="click-instruction">
|
||||
<i>{`Wähle alle ${targetCategory} aus dem untenstehenden Text aus.`}</i>
|
||||
</div>
|
||||
{htmlSlotsData.slots.length > 0 ? (
|
||||
<Container className="word-container click-source-html">
|
||||
<div className="rendered-html click-rendered-content">
|
||||
{parse(htmlSlotsData.html, {
|
||||
replace(domNode) {
|
||||
if (
|
||||
domNode?.type === "tag" &&
|
||||
domNode.name === "click-word" &&
|
||||
domNode.attribs?.["data-slot-key"]
|
||||
) {
|
||||
const slotKey = domNode.attribs["data-slot-key"];
|
||||
const word = domNode.attribs["data-word"] || "";
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={slotKey}
|
||||
onClick={() => handleWordClick(slotKey)}
|
||||
variant="click"
|
||||
style={getTokenStyle(word, slotKey)}
|
||||
>
|
||||
{word}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
</Container>
|
||||
) : (
|
||||
<Container className="word-container">
|
||||
{allWords?.map((token, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{renderToken(token, index)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Container>
|
||||
)}
|
||||
{feedback && !isPreviewMode && (
|
||||
<>
|
||||
<div>{feedback}</div>
|
||||
|
||||
@@ -24,6 +24,186 @@ const ClientEditor = (props) => {
|
||||
|
||||
useEffect(() => {
|
||||
let quill;
|
||||
let tableFormObserver;
|
||||
let detachRepositionListeners;
|
||||
let lastTableMenuRect = null;
|
||||
let rafRepositionId = null;
|
||||
|
||||
const getActiveTable = () => {
|
||||
if (!quill) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tableBetterModule = quill.getModule("table-better");
|
||||
const tableFromModule = tableBetterModule?.tableMenus?.table;
|
||||
if (tableFromModule instanceof HTMLElement) {
|
||||
return tableFromModule;
|
||||
}
|
||||
|
||||
const selectedCell = quill.root.querySelector(
|
||||
"td.ql-cell-focused, td.ql-cell-selected, th.ql-cell-focused, th.ql-cell-selected"
|
||||
);
|
||||
if (selectedCell instanceof HTMLElement) {
|
||||
return selectedCell.closest("table");
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getAnchorRect = () => {
|
||||
const liveMenus = quill?.container?.querySelector(
|
||||
".ql-table-menus-container"
|
||||
);
|
||||
if (liveMenus instanceof HTMLElement) {
|
||||
const rect = liveMenus.getBoundingClientRect();
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
lastTableMenuRect = rect;
|
||||
return rect;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastTableMenuRect) {
|
||||
return lastTableMenuRect;
|
||||
}
|
||||
|
||||
const table = getActiveTable();
|
||||
if (table instanceof HTMLElement) {
|
||||
return table.getBoundingClientRect();
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const repositionTablePropertiesForm = () => {
|
||||
if (!quill) {
|
||||
return;
|
||||
}
|
||||
|
||||
const form = quill.container.querySelector(".ql-table-properties-form");
|
||||
if (!(form instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const anchorRect = getAnchorRect();
|
||||
if (!anchorRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formRect = form.getBoundingClientRect();
|
||||
const viewportPadding = 8;
|
||||
|
||||
let nextTop = anchorRect.bottom + 10;
|
||||
const nextTopIfAbove = anchorRect.top - formRect.height - 10;
|
||||
if (nextTop + formRect.height > window.innerHeight - viewportPadding) {
|
||||
nextTop = nextTopIfAbove;
|
||||
}
|
||||
if (nextTop < viewportPadding) {
|
||||
nextTop = viewportPadding;
|
||||
}
|
||||
|
||||
let nextLeft = anchorRect.left + (anchorRect.width - formRect.width) / 2;
|
||||
const maxLeft = Math.max(
|
||||
viewportPadding,
|
||||
window.innerWidth - formRect.width - viewportPadding
|
||||
);
|
||||
nextLeft = Math.min(Math.max(viewportPadding, nextLeft), maxLeft);
|
||||
|
||||
form.style.position = "fixed";
|
||||
form.style.left = `${Math.round(nextLeft)}px`;
|
||||
form.style.top = `${Math.round(nextTop)}px`;
|
||||
form.style.margin = "0";
|
||||
form.style.zIndex = "9999";
|
||||
form.classList.add("ql-table-triangle-none");
|
||||
};
|
||||
|
||||
const scheduleReposition = () => {
|
||||
if (rafRepositionId !== null) {
|
||||
return;
|
||||
}
|
||||
rafRepositionId = requestAnimationFrame(() => {
|
||||
rafRepositionId = null;
|
||||
repositionTablePropertiesForm();
|
||||
});
|
||||
};
|
||||
|
||||
const setupTablePropertiesRepositioning = () => {
|
||||
if (!quill?.container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onWindowResize = () => scheduleReposition();
|
||||
const onEditorScroll = () => scheduleReposition();
|
||||
const onEditorClick = () => scheduleReposition();
|
||||
const onEditorPointerDown = (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const menus = target.closest(".ql-table-menus-container");
|
||||
if (menus instanceof HTMLElement) {
|
||||
const rect = menus.getBoundingClientRect();
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
lastTableMenuRect = rect;
|
||||
}
|
||||
}
|
||||
};
|
||||
const onEditorClickCapture = (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// quill-table-better has click handlers that call getAttribute on possibly null nodes.
|
||||
// Block only invalid clicks before plugin bubbling listeners execute.
|
||||
const checkContainer = target.closest(".ql-table-check-container");
|
||||
if (checkContainer && !target.closest("span.ql-table-tooltip-hover")) {
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", onWindowResize);
|
||||
quill.root.addEventListener("scroll", onEditorScroll, true);
|
||||
quill.root.addEventListener("click", onEditorClick, true);
|
||||
quill.container.addEventListener(
|
||||
"pointerdown",
|
||||
onEditorPointerDown,
|
||||
true
|
||||
);
|
||||
quill.container.addEventListener("click", onEditorClickCapture, true);
|
||||
|
||||
tableFormObserver = new MutationObserver(() => {
|
||||
if (quill.container.querySelector(".ql-table-properties-form")) {
|
||||
scheduleReposition();
|
||||
}
|
||||
});
|
||||
|
||||
tableFormObserver.observe(quill.container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
detachRepositionListeners = () => {
|
||||
window.removeEventListener("resize", onWindowResize);
|
||||
quill.root.removeEventListener("scroll", onEditorScroll, true);
|
||||
quill.root.removeEventListener("click", onEditorClick, true);
|
||||
quill.container.removeEventListener(
|
||||
"pointerdown",
|
||||
onEditorPointerDown,
|
||||
true
|
||||
);
|
||||
quill.container.removeEventListener(
|
||||
"click",
|
||||
onEditorClickCapture,
|
||||
true
|
||||
);
|
||||
if (rafRepositionId !== null) {
|
||||
cancelAnimationFrame(rafRepositionId);
|
||||
rafRepositionId = null;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
const Quill = (await import("quill")).default;
|
||||
const TableBetter = (await import("quill-table-better")).default;
|
||||
@@ -112,6 +292,7 @@ const ClientEditor = (props) => {
|
||||
updateEditorContentRef.current?.(html);
|
||||
});
|
||||
quillRef.current = quill;
|
||||
setupTablePropertiesRepositioning();
|
||||
if (props.defaultContent) {
|
||||
setHtmlContent(quill, props.defaultContent);
|
||||
}
|
||||
@@ -120,6 +301,10 @@ const ClientEditor = (props) => {
|
||||
load();
|
||||
|
||||
return () => {
|
||||
tableFormObserver?.disconnect();
|
||||
tableFormObserver = null;
|
||||
detachRepositionListeners?.();
|
||||
detachRepositionListeners = null;
|
||||
if (containerRef.current) {
|
||||
containerRef.current.innerHTML = "";
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ function StudentSideBar() {
|
||||
Glossar
|
||||
</Link>
|
||||
<Link className={styles.sidebarLink} href="/talkback">
|
||||
Rückmeldekanal - Feedback geben
|
||||
Brauchen Sie Hilfe?
|
||||
</Link>
|
||||
<SidebarGroup
|
||||
title="Links zu anderen Webseiten"
|
||||
|
||||
Reference in New Issue
Block a user