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:
erzabytyci
2026-05-13 11:08:15 +03:00
committed by GitHub
20 changed files with 951 additions and 230 deletions
@@ -0,0 +1,2 @@
ALTER TABLE public.click_exercises
ADD COLUMN IF NOT EXISTS source_html text;
+2 -1
View File
@@ -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
View File
@@ -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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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, "&amp;")
@@ -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);
+25 -7
View File
@@ -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%;
+1 -1
View File
@@ -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>}
+85 -82
View File
@@ -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);
+83 -3
View File
@@ -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;
}
+217 -40
View File
@@ -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 = "";
}
+1 -1
View File
@@ -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"