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,
|
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
@@ -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, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/\"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
@@ -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, "&")
|
.replace(/&/g, "&")
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -670,3 +683,8 @@ label {
|
|||||||
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%;
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,9 @@
|
|||||||
.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 {
|
||||||
@@ -51,9 +50,13 @@
|
|||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.form-label {
|
.form-label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: var(--u-md);
|
/* margin-bottom: var(--u-md); */
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input {
|
.form-input {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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 = "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user