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, title text NOT NULL,
category text NOT NULL, category text NOT NULL,
target_words 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 ALTER TABLE public.click_exercises
+2 -1
View File
@@ -50,7 +50,8 @@ CREATE TABLE public.click_exercises (
title text NOT NULL, title text NOT NULL,
category text NOT NULL, category text NOT NULL,
target_words 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 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 { .exercise-click {
display: flex; display: block;
justify-content: space-between; }
.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 { .click-input {
width: 60%; width: 100%;
font: inherit;
}
.click-form-actions {
display: flex;
justify-content: flex-start;
} }
.error-message { .error-message {
@@ -8,6 +8,17 @@ import { Button } from "@/components/ui/button";
import useQuery from "@/shared/hooks/useQuery"; import useQuery from "@/shared/hooks/useQuery";
import "./click.css"; import "./click.css";
import WordSelectionExercise from "@/components/ui/click/click.js"; 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() { export default function CreateExercise() {
const router = useRouter(); const router = useRouter();
@@ -18,6 +29,7 @@ export default function CreateExercise() {
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [targetCategory, setTargetCategory] = useState(""); const [targetCategory, setTargetCategory] = useState("");
const [allWordsHtml, setAllWordsHtml] = useState("");
const [allWordsText, setAllWordsText] = useState(""); const [allWordsText, setAllWordsText] = useState("");
const [selectedWords, setSelectedWords] = useState([]); const [selectedWords, setSelectedWords] = useState([]);
const [previewMode, setPreviewMode] = useState(false); const [previewMode, setPreviewMode] = useState(false);
@@ -45,15 +57,27 @@ export default function CreateExercise() {
const text = hasExplicitSpacing const text = hasExplicitSpacing
? storedWords.join("") ? storedWords.join("")
: storedWords.join(" "); : storedWords.join(" ");
const sourceHtml =
typeof exerciseData.source_html === "string"
? exerciseData.source_html
: text
? `<p>${escapeHtml(text)}</p>`
: "";
setTitle(exerciseData.title || ""); setTitle(exerciseData.title || "");
setTargetCategory(exerciseData.category || ""); setTargetCategory(exerciseData.category || "");
setAllWordsHtml(sourceHtml);
setAllWordsText(text); setAllWordsText(text);
setSelectedWords( setSelectedWords(
Array.isArray(exerciseData.target_words) ? exerciseData.target_words : [] Array.isArray(exerciseData.target_words) ? exerciseData.target_words : []
); );
}, [exerciseData, isEditMode]); }, [exerciseData, isEditMode]);
const handleEditorContentChange = (html) => {
setAllWordsHtml(html);
setAllWordsText(htmlToPlainText(html));
};
// Preserve user-entered spacing and line breaks so preview and saved exercise match. // Preserve user-entered spacing and line breaks so preview and saved exercise match.
const allWords = const allWords =
allWordsText.replace(/\r\n/g, "\n").match(/[^\S\n]+|\n|[^\s]+/g) ?? []; allWordsText.replace(/\r\n/g, "\n").match(/[^\S\n]+|\n|[^\s]+/g) ?? [];
@@ -74,6 +98,7 @@ export default function CreateExercise() {
targetCategory, targetCategory,
targetWords: selectedWords, targetWords: selectedWords,
allWords, allWords,
sourceHtml: allWordsHtml,
}), }),
}); });
@@ -87,6 +112,7 @@ export default function CreateExercise() {
targetCategory, targetCategory,
targetWords: selectedWords, targetWords: selectedWords,
allWords, allWords,
sourceHtml: allWordsHtml,
}); });
setSubmitted(true); setSubmitted(true);
@@ -139,7 +165,7 @@ export default function CreateExercise() {
} }
return ( return (
<div> <div className="click-admin-page">
<h1> <h1>
{isEditMode {isEditMode
? "Wortauswahl-Übung bearbeiten" ? "Wortauswahl-Übung bearbeiten"
@@ -148,9 +174,9 @@ export default function CreateExercise() {
{submitted ? ( {submitted ? (
successMessage() // Show only the success message if submitted successMessage() // Show only the success message if submitted
) : !previewMode ? ( ) : !previewMode ? (
<form onSubmit={handlePreview}> <form onSubmit={handlePreview} className="click-form">
<Column gap="md"> <Column gap="md">
<Container className="exercise-click"> <Container className="exercise-click click-block">
<label>Übungstitel</label> <label>Übungstitel</label>
<input <input
className="click-input" className="click-input"
@@ -162,7 +188,7 @@ export default function CreateExercise() {
/> />
</Container> </Container>
<Container className="exercise-click"> <Container className="exercise-click click-block">
<label>Zielkategorie</label> <label>Zielkategorie</label>
<input <input
className="click-input" className="click-input"
@@ -174,21 +200,20 @@ export default function CreateExercise() {
/> />
</Container> </Container>
<Container className="exercise-click"> <Container className="exercise-click click-block">
<label>Übungstext</label> <label>Übungstext</label>
<textarea <Editor
className="click-input" key="click-editor"
value={allWordsText} defaultContent={allWordsHtml}
onChange={(e) => setAllWordsText(e.target.value)} updateEditorContent={handleEditorContentChange}
placeholder="Z. B. Ich mag laufen und schwimmen."
rows={4}
required
/> />
</Container> </Container>
</Column> </Column>
<Button size="md" type="submit"> <Container className="click-form-actions">
Zielwörter auswählen <Button size="md" type="submit">
</Button> Zielwörter auswählen
</Button>
</Container>
</form> </form>
) : ( ) : (
<div> <div>
@@ -205,6 +230,7 @@ export default function CreateExercise() {
targetCategory={targetCategory} targetCategory={targetCategory}
targetWords={selectedWords} targetWords={selectedWords}
allWords={allWords} allWords={allWords}
sourceHtml={allWordsHtml}
isPreviewMode={true} isPreviewMode={true}
onSelectionChange={(updatedSelectedWords) => onSelectionChange={(updatedSelectedWords) =>
setSelectedWords(updatedSelectedWords) setSelectedWords(updatedSelectedWords)
@@ -53,6 +53,22 @@
background: var(--bg1); 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 { .fitg-answer-row {
border: 1px dashed var(--bg4); border: 1px dashed var(--bg4);
border-radius: var(--u-xs); border-radius: var(--u-xs);
@@ -65,4 +81,4 @@
border: 1px solid var(--tertiary2); border: 1px solid var(--tertiary2);
padding: var(--u-sm); padding: var(--u-sm);
border-radius: var(--u-xs); 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) { function escapeHtml(value) {
return String(value || "") return String(value || "")
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
@@ -69,6 +138,35 @@ export default function CreateFillInTheGapExercisePage() {
const tokens = useMemo(() => tokenizeText(text), [text]); 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(() => { useEffect(() => {
if (!isEditMode || !exerciseData) { if (!isEditMode || !exerciseData) {
return; return;
@@ -105,22 +203,24 @@ export default function CreateFillInTheGapExercisePage() {
return; return;
} }
const nextTokens = tokenizeText(normalizedText);
const remappedGaps = remapGapsToTokens(gaps, nextTokens);
setTextHtml(html); setTextHtml(html);
setText(normalizedText); setText(normalizedText);
setSelectedIndices([]); setGaps(remappedGaps);
if (step === "configure") { setSelectedIndices(remappedGaps.map((gap) => gap.tokenIndex));
if (step === "configure" && remappedGaps.length === 0) {
setStep("select"); setStep("select");
setGaps([]);
} }
}; };
const toggleGapIndex = (index) => { const toggleGapIndex = (index) => {
setSelectedIndices((current) => { const nextSelected = selectedIndices.includes(index)
if (current.includes(index)) { ? selectedIndices.filter((item) => item !== index)
return current.filter((item) => item !== index); : [...selectedIndices, index];
}
return [...current, index].sort((a, b) => a - b); syncGapsWithSelection(nextSelected);
});
}; };
const moveToGapConfiguration = () => { const moveToGapConfiguration = () => {
@@ -138,13 +238,7 @@ export default function CreateFillInTheGapExercisePage() {
return; return;
} }
const mappedGaps = selectedIndices.map((tokenIndex) => ({ syncGapsWithSelection(selectedIndices);
tokenIndex,
tokenText: tokens[tokenIndex]?.word || tokens[tokenIndex]?.raw || "",
answers: createDefaultAnswers(),
}));
setGaps(mappedGaps);
setStep("configure"); setStep("configure");
}; };
@@ -192,6 +286,13 @@ export default function CreateFillInTheGapExercisePage() {
}); });
}; };
const removeGap = (tokenIndex) => {
const nextSelected = selectedIndices.filter(
(index) => index !== tokenIndex
);
syncGapsWithSelection(nextSelected);
};
const validateConfiguration = () => { const validateConfiguration = () => {
if (gaps.length === 0) { if (gaps.length === 0) {
return "Bitte wähle mindestens eine Lücke aus."; return "Bitte wähle mindestens eine Lücke aus.";
@@ -300,9 +401,13 @@ export default function CreateFillInTheGapExercisePage() {
/> />
</Container> </Container>
{tokens.length > 0 && step === "select" && ( {tokens.length > 0 && (
<Container className="fitg-block"> <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> <p className="fitg-help-text">Ausgewählt: {selectedIndices.length}</p>
<div className="fitg-token-grid"> <div className="fitg-token-grid">
{tokens.map((token, index) => { {tokens.map((token, index) => {
@@ -340,9 +445,20 @@ export default function CreateFillInTheGapExercisePage() {
key={`${gap.tokenIndex}-${gap.tokenText}`} key={`${gap.tokenIndex}-${gap.tokenText}`}
className="fitg-gap-card" className="fitg-gap-card"
> >
<h4> <Row justify="space-between" align="center" wrap="wrap">
Lücke {gapIndex + 1}: "{gap.tokenText}" <h4>
</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"> <Column gap="md">
{gap.answers.map((answer, answerIndex) => ( {gap.answers.map((answer, answerIndex) => (
@@ -448,7 +564,7 @@ export default function CreateFillInTheGapExercisePage() {
variant="outline" variant="outline"
onClick={() => setStep("select")} onClick={() => setStep("select")}
> >
Zurück zur Lückenauswahl Wörter auswählen/ändern
</Button> </Button>
<Button type="button" onClick={handleSave} disabled={saving}> <Button type="button" onClick={handleSave} disabled={saving}>
{saving {saving
@@ -1,6 +1,14 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { DB } from "@/backend/db"; import { DB } from "@/backend/db";
import { withAuth } from "@/backend/middleware/withAuth"; 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 }) { function validatePayload({ title, targetCategory, targetWords, allWords }) {
if (!title || !targetCategory || !targetWords || !allWords) { if (!title || !targetCategory || !targetWords || !allWords) {
@@ -44,7 +52,7 @@ export const GET = withAuth(
const { click_id } = await params; const { click_id } = await params;
const result = await DB.pool( 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 FROM click_exercises
WHERE id = $1`, WHERE id = $1`,
[click_id] [click_id]
@@ -77,7 +85,8 @@ export const PUT = withAuth(
try { try {
const { click_id } = await params; const { click_id } = await params;
const body = await request.json(); 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({ const validationError = validatePayload({
title, title,
@@ -127,9 +136,17 @@ export const PUT = withAuth(
category = $2, category = $2,
target_words = $3, target_words = $3,
all_words = $4, all_words = $4,
source_html = $5,
updated_at = NOW() updated_at = NOW()
WHERE id = $5`, WHERE id = $6`,
[title, targetCategory, targetWords, allWords, click_id] [
title,
targetCategory,
targetWords,
allWords,
sanitizedSourceHtml,
click_id,
]
); );
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
@@ -1,10 +1,19 @@
import { DB } from "@/backend/db"; import { DB } from "@/backend/db";
import { withAuth } from "@/backend/middleware/withAuth"; 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( export const POST = withAuth(
async (request) => { async (request) => {
const json = await request.json(); 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) { if (!title || !targetCategory || !targetWords || !allWords) {
return Response.json( return Response.json(
@@ -51,9 +60,9 @@ export const POST = withAuth(
); );
} }
const id = await DB.pool( const id = await DB.pool(
`INSERT INTO click_exercises (title, category, target_words, all_words) `INSERT INTO click_exercises (title, category, target_words, all_words, source_html)
VALUES ($1, $2, $3, $4) returning id`, VALUES ($1, $2, $3, $4, $5) returning id`,
[title, targetCategory, targetWords, allWords] [title, targetCategory, targetWords, allWords, sanitizedSourceHtml]
); );
return Response.json({ id: id.rows[0].id }, { status: 201 }); 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." "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 () => { it("should return 409 if exercise with the same title already exists", async () => {
const admin = await TestFactory.user({ is_admin: true }); const admin = await TestFactory.user({ is_admin: true });
const { mockPost } = useTestRequest(admin); const { mockPost } = useTestRequest(admin);
+25 -7
View File
@@ -387,7 +387,7 @@ h1 {
h2 { h2 {
font-size: 1.2em; font-size: 1.2em;
font-weight: bold; font-weight: bold;
color: var(--tertiary1); color: var(--fg1);
margin-bottom: 15px; margin-bottom: 15px;
} }
@@ -490,22 +490,35 @@ label {
} }
.rendered-html p { .rendered-html p {
margin-bottom: 1em; margin: 0;
white-space: pre-wrap; white-space: pre-wrap;
} }
.rendered-html p:last-child { .rendered-html h1,
margin-bottom: 0; .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 { .rendered-html li {
margin-bottom: 0.35em; line-height: inherit;
} }
.rendered-html ul, .rendered-html ul,
.rendered-html ol { .rendered-html ol {
padding-left: 2rem; padding-left: 1.5em;
margin-bottom: 1em; margin: 0;
} }
.rendered-html ul { .rendered-html ul {
@@ -669,4 +682,9 @@ label {
height: 14px; height: 14px;
right: -7px; right: -7px;
bottom: -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} targetCategory={exercise.category}
targetWords={exercise.target_words} targetWords={exercise.target_words}
allWords={exercise.all_words} allWords={exercise.all_words}
sourceHtml={exercise.source_html}
previousAnswers={data.userAnswers?.answer || []} previousAnswers={data.userAnswers?.answer || []}
isPreviewMode={false} isPreviewMode={false}
onSubmit={handleSubmit} // Pass the submit handler as a prop onSubmit={handleSubmit} // Pass the submit handler as a prop
@@ -11,9 +11,9 @@
} }
.chapter-page-title, .chapter-page-title,
.chapter-rendered-content h1, .chapter-rendered-content>h1,
.chapter-rendered-content h2, .chapter-rendered-content>h2,
.chapter-rendered-content h3 { .chapter-rendered-content>h3 {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
margin-top: 1.5rem; margin-top: 1.5rem;
margin-bottom: 1rem; margin-bottom: 1rem;
@@ -22,44 +22,22 @@
} }
.chapter-page-title, .chapter-page-title,
.chapter-rendered-content h1 { .chapter-rendered-content>h1 {
font-size: 2rem; font-size: 2rem;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
color: var(--fg1); color: var(--fg1);
} }
.chapter-rendered-content h2 { .chapter-rendered-content>h2 {
font-size: 1.75rem; font-size: 1.75rem;
color: var(--fg1); color: var(--fg1);
} }
.chapter-rendered-content h3 { .chapter-rendered-content>h3 {
font-size: 1.35rem; font-size: 1.35rem;
color: var(--fg1); 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 { .chapter-content {
max-width: 1400px; max-width: 1400px;
} }
@@ -76,28 +54,34 @@ a {
font-size: 2.5em; 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 { .chapter-rendered-content {
width: 100%; width: 100%;
display: block; 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,
.chapter-rendered-content .rendered-html { .chapter-rendered-content .rendered-html {
width: 100%; width: 100%;
+1 -1
View File
@@ -76,7 +76,7 @@ export default function TalkbackPage() {
return ( return (
<section className="talkback-container"> <section className="talkback-container">
<div className="talkback-box"> <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>} {error && <p className="error">{error}</p>}
+85 -82
View File
@@ -1,83 +1,86 @@
.talkback-container { .talkback-container {
display: flex; display: flex;
justify-content: center; justify-content: center;
flex-direction: column; flex-direction: column;
flex-grow: 1; flex-grow: 1;
align-items: center; align-items: center;
padding-top: 10px; padding-top: 10px;
} }
.talkback-box { .talkback-box {
background-color: var(--bg3); background-color: var(--bg3);
border-radius: var(--radius-2xl); border-radius: var(--radius-2xl);
padding: var(--u-3xl); padding: var(--u-xl);
box-shadow: 0 10px 24px rgb(0 0 0 / 10%); box-shadow: 0 10px 24px rgb(0 0 0 / 10%);
width: max-content; width: 570px;
max-width: fit-content; }
}
.auth-box {
.auth-box { width: 100%;
width: 100%; max-width: max-content;
max-width: max-content; background-color: var(--bg3);
background-color: var(--bg3); border-radius: var(--radius-2xl);
border-radius: var(--radius-2xl); padding: var(--u-xl);
padding: var(--u-xl); display: flex;
display: flex; flex-direction: column;
flex-direction: column; gap: var(--u-lg);
gap: var(--u-lg); }
}
.talkback-form {
.talkback-form { display: flex;
display: flex; flex-direction: column;
flex-direction: column; gap: var(--u-lg);
gap: var(--u-lg); }
}
.auth-title {
.auth-title { text-align: left;
text-align: left; margin-bottom: var(--u-lg);
margin-bottom: var(--u-lg); color: var(--fg1);
color: var(--fg1); }
}
.talkback-message-input {
.talkback-message-input { resize: none;
resize: none; min-height: 220px;
min-height: 220px; max-width: 100%;
max-width: 100%; }
}
.form-group {
.form-group { display: flex;
display: flex; flex-direction: column;
flex-direction: column; gap: 0.2rem;
gap: 0.2rem; }
}
.form-group label {
.form-label { margin-top: 0;
display: block; }
margin-bottom: var(--u-md);
} .form-label {
display: block;
.form-input { /* margin-bottom: var(--u-md); */
width: 100%; }
padding: var(--u-md);
border-radius: var(--radius-sm); .form-input {
} width: 100%;
padding: var(--u-md);
.form-button { border-radius: var(--radius-sm);
margin-top: var(--u-2xl); }
}
.form-button {
.error { margin-top: var(--u-2xl);
text-align: center; }
margin-bottom: 1.5rem;
} .error {
text-align: center;
@media (max-width: 768px) { margin-bottom: 1.5rem;
.talkback-container { }
padding: var(--u-lg) var(--u-md) var(--u-2xl);
} @media (max-width: 768px) {
.talkback-container {
.talkback-box { padding: var(--u-lg) var(--u-md) var(--u-2xl);
padding: var(--u-xl); }
border-radius: var(--radius-xl);
} .talkback-box {
padding: var(--u-xl);
border-radius: var(--radius-xl);
}
} }
@@ -5,6 +5,8 @@
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border-radius: 0.25rem; border-radius: 0.25rem;
cursor: pointer; cursor: pointer;
font: inherit;
line-height: inherit;
transition: transition:
background-color 0.1s ease-in-out, background-color 0.1s ease-in-out,
border-color 0.1s ease-in-out, border-color 0.1s ease-in-out,
@@ -138,6 +140,12 @@
border: none; border: none;
background-color: transparent; background-color: transparent;
width: fit-content; width: fit-content;
color: inherit;
font: inherit;
line-height: inherit;
text-decoration: inherit;
text-decoration-color: inherit;
text-decoration-thickness: inherit;
&:hover { &:hover {
background-color: var(--bg2); background-color: var(--bg2);
+83 -3
View File
@@ -3,9 +3,11 @@
} }
.word-container { .word-container {
line-height: 2.2; font-size: 1.125rem;
line-height: 1.7;
padding: 12px 15px;
white-space: pre-wrap; white-space: pre-wrap;
background: var(--bg3); background: var(--bg1);
} }
.word-punct { .word-punct {
@@ -19,5 +21,83 @@
.word-line-break { .word-line-break {
display: block; 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 { Button } from "../button";
import { Container } from "../layout/container"; import { Container } from "../layout/container";
import { Column } from "@/components/ui/layout/container"; import { Column } from "@/components/ui/layout/container";
import parse from "html-react-parser";
import "./click.css"; import "./click.css";
const WORD_REGEX = /\p{L}+(?:['’-]\p{L}+)*/gu; const WORD_REGEX = /\p{L}+(?:['’-]\p{L}+)*/gu;
@@ -38,11 +39,77 @@ const splitTokenToParts = (token) => {
const isWhitespaceToken = (token) => /^[^\S\n]+$/u.test(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 = ({ const WordSelectionExercise = ({
title, title,
targetCategory, targetCategory,
targetWords, targetWords,
allWords, allWords,
sourceHtml,
previousAnswers, previousAnswers,
onSelectionChange, onSelectionChange,
onSubmit, onSubmit,
@@ -56,7 +123,16 @@ const WordSelectionExercise = ({
const [selectedSlotKeys, setSelectedSlotKeys] = useState([]); const [selectedSlotKeys, setSelectedSlotKeys] = useState([]);
const [message, setMessage] = 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 = []; const slots = [];
allWords?.forEach((token, tokenIndex) => { allWords?.forEach((token, tokenIndex) => {
@@ -76,12 +152,63 @@ const WordSelectionExercise = ({
return slots; return slots;
}, [allWords]); }, [allWords]);
const wordSlots = useMemo(
() =>
htmlSlotsData.slots.length > 0 ? htmlSlotsData.slots : fallbackWordSlots,
[htmlSlotsData.slots, fallbackWordSlots]
);
const slotToWord = useMemo( const slotToWord = useMemo(
() => () =>
Object.fromEntries(wordSlots.map(({ slotKey, word }) => [slotKey, word])), Object.fromEntries(wordSlots.map(({ slotKey, word }) => [slotKey, word])),
[wordSlots] [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) => { const handleWordClick = (slotKey) => {
if (isSubmitted && !isPreviewMode) return; if (isSubmitted && !isPreviewMode) return;
@@ -95,31 +222,33 @@ const WordSelectionExercise = ({
setSelectedSlotKeys(updatedKeys); setSelectedSlotKeys(updatedKeys);
if (isPreviewMode && onSelectionChange) { if (isPreviewMode && onSelectionChange) {
onSelectionChange( // In preview/admin mode we persist exact selected occurrences.
updatedKeys.map((key) => slotToWord[key]).filter(Boolean) onSelectionChange(updatedKeys);
);
} }
}; };
const checkAnswers = () => { const checkAnswers = () => {
if (isPreviewMode) return; if (isPreviewMode) return;
const selectedWordValues = selectedSlotKeys const selectedSet = new Set(selectedSlotKeys);
.map((key) => slotToWord[key]) const incorrectSelections = selectedSlotKeys.filter(
.filter(Boolean); (slotKey) => !targetSlotKeySet.has(slotKey)
const correctAnswers = targetWords;
const incorrectSelections = selectedWordValues.filter(
(word) => !correctAnswers.includes(word)
); );
const missedCorrectAnswers = correctAnswers.filter( const missedCorrectAnswers = targetSlotKeys.filter(
(word) => !selectedWordValues.includes(word) (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( const score = Math.round(
((correctAnswers.length - ((totalCorrectTargets -
missedCorrectAnswers.length - missedCorrectAnswers.length -
incorrectSelections.length) / incorrectSelections.length) /
correctAnswers.length) * totalCorrectTargets) *
100 100
); );
@@ -135,7 +264,11 @@ const WordSelectionExercise = ({
feedbackMessage = "Weiter üben! Punktzahl: " + score + "%"; 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 = () => { const resetExercise = () => {
@@ -150,35 +283,46 @@ const WordSelectionExercise = ({
return; return;
} }
const needed = {}; const availableSlotKeys = new Set(wordSlots.map(({ slotKey }) => slotKey));
previousAnswers.forEach((w) => { const previousAreSlotKeys = previousAnswers.every((answer) =>
needed[w] = (needed[w] || 0) + 1; availableSlotKeys.has(answer)
}); );
const used = {}; let restoredKeys = [];
const restoredKeys = [];
wordSlots.forEach(({ slotKey, word }) => { if (previousAreSlotKeys) {
const remaining = (needed[word] || 0) - (used[word] || 0); restoredKeys = previousAnswers.filter((answer) =>
if (remaining > 0) { availableSlotKeys.has(answer)
restoredKeys.push(slotKey); );
used[word] = (used[word] || 0) + 1; } 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); setSelectedSlotKeys(restoredKeys);
}; };
const getTokenStyle = (word, slotKey) => { const getTokenStyle = (_word, slotKey) => {
if (selectedSlotKeys.includes(slotKey)) { if (selectedSlotKeys.includes(slotKey)) {
if (isSubmitted && !isPreviewMode) { if (isSubmitted && !isPreviewMode) {
return targetWords.includes(word) return targetSlotKeySet.has(slotKey)
? { backgroundColor: "var(--green)" } ? { backgroundColor: "var(--green)" }
: { backgroundColor: "var(--red)" }; : { backgroundColor: "var(--red)" };
} }
return { backgroundColor: "var(--click-selected-bg, var(--blue))" }; return { backgroundColor: "var(--click-selected-bg, var(--blue))" };
} else { } else {
if (isSubmitted && targetWords.includes(word) && !isPreviewMode) { if (isSubmitted && targetSlotKeySet.has(slotKey) && !isPreviewMode) {
return { return {
backgroundColor: "var(--click-missed-bg, var(--yellow))", backgroundColor: "var(--click-missed-bg, var(--yellow))",
border: "1px solid var(--click-missed-border, var(--yellow-border))", border: "1px solid var(--click-missed-border, var(--yellow-border))",
@@ -189,7 +333,7 @@ const WordSelectionExercise = ({
const renderToken = (token, tokenIndex) => { const renderToken = (token, tokenIndex) => {
if (token === "\n") { if (token === "\n") {
return <span key={`${tokenIndex}-nl`} className="word-line-break" />; return <br key={`${tokenIndex}-nl`} />;
} }
if (isWhitespaceToken(token)) { if (isWhitespaceToken(token)) {
@@ -229,14 +373,47 @@ const WordSelectionExercise = ({
return ( return (
<Column gap="md"> <Column gap="md">
<h1>{title}</h1> <h1>{title}</h1>
<i>{`Wähle alle ${targetCategory} aus dem untenstehenden Text aus.`}</i> <div className="click-instruction">
<Container className="word-container"> <i>{`Wähle alle ${targetCategory} aus dem untenstehenden Text aus.`}</i>
{allWords?.map((token, index) => ( </div>
<React.Fragment key={index}> {htmlSlotsData.slots.length > 0 ? (
{renderToken(token, index)} <Container className="word-container click-source-html">
</React.Fragment> <div className="rendered-html click-rendered-content">
))} {parse(htmlSlotsData.html, {
</Container> 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 && ( {feedback && !isPreviewMode && (
<> <>
<div>{feedback}</div> <div>{feedback}</div>
@@ -24,6 +24,186 @@ const ClientEditor = (props) => {
useEffect(() => { useEffect(() => {
let quill; 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 load = async () => {
const Quill = (await import("quill")).default; const Quill = (await import("quill")).default;
const TableBetter = (await import("quill-table-better")).default; const TableBetter = (await import("quill-table-better")).default;
@@ -112,6 +292,7 @@ const ClientEditor = (props) => {
updateEditorContentRef.current?.(html); updateEditorContentRef.current?.(html);
}); });
quillRef.current = quill; quillRef.current = quill;
setupTablePropertiesRepositioning();
if (props.defaultContent) { if (props.defaultContent) {
setHtmlContent(quill, props.defaultContent); setHtmlContent(quill, props.defaultContent);
} }
@@ -120,6 +301,10 @@ const ClientEditor = (props) => {
load(); load();
return () => { return () => {
tableFormObserver?.disconnect();
tableFormObserver = null;
detachRepositionListeners?.();
detachRepositionListeners = null;
if (containerRef.current) { if (containerRef.current) {
containerRef.current.innerHTML = ""; containerRef.current.innerHTML = "";
} }
+1 -1
View File
@@ -115,7 +115,7 @@ function StudentSideBar() {
Glossar Glossar
</Link> </Link>
<Link className={styles.sidebarLink} href="/talkback"> <Link className={styles.sidebarLink} href="/talkback">
Rückmeldekanal - Feedback geben Brauchen Sie Hilfe?
</Link> </Link>
<SidebarGroup <SidebarGroup
title="Links zu anderen Webseiten" title="Links zu anderen Webseiten"