Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
1a193aa
Start documenting initial idea.
SilasBerger Jan 27, 2026
d983bcc
Remove unnecessary import.
SilasBerger Jan 27, 2026
4855a91
Rework syntax.
SilasBerger Jan 27, 2026
3a8868a
Implement basic radio buttons.
SilasBerger Jan 27, 2026
e122ed6
Add support for MC.
SilasBerger Jan 27, 2026
f8e5c67
Draft new syntax.
SilasBerger Jan 27, 2026
79272e8
Tweak syntax examples.
SilasBerger Jan 28, 2026
8680d05
Work on plugin.
SilasBerger Jan 28, 2026
6a63d4c
Cleanup.
SilasBerger Jan 28, 2026
ae459ac
Make MDX plugin not suck.
SilasBerger Jan 28, 2026
c180085
Enumerate options.
SilasBerger Jan 28, 2026
ba2d47c
Implement saving.
SilasBerger Jan 29, 2026
6924fff
Show save icon.
SilasBerger Jan 29, 2026
fe9a647
Run formatter.
SilasBerger Jan 29, 2026
459e625
Start integrating Quiz.
SilasBerger Jan 29, 2026
367bc13
Start adding Quiz.
SilasBerger Feb 2, 2026
0b9b522
Fix prop passing.
SilasBerger Feb 2, 2026
91e5df4
Inject doc from quiz.
SilasBerger Feb 2, 2026
27a78bd
Prevent redundant visit of questions in quiz.
SilasBerger Feb 2, 2026
d4c791d
Format.
SilasBerger Feb 2, 2026
e45daed
Improve save icon handling.
SilasBerger Feb 2, 2026
53da651
Cleanup.
SilasBerger Feb 2, 2026
587a307
Implement deleting answer.
SilasBerger Feb 2, 2026
ebd1a38
Add support for true/false answer.
SilasBerger Feb 2, 2026
30392aa
Cleanup.
SilasBerger Feb 2, 2026
950cba5
Add styling and support for question title.
SilasBerger Feb 2, 2026
0930e44
Improve visuals.
SilasBerger Feb 2, 2026
c7ff69c
Add option randomization.
SilasBerger Feb 3, 2026
e732a4b
Implement quiz randomization.
SilasBerger Feb 3, 2026
64cfacc
Cleanup and fixes.
SilasBerger Feb 3, 2026
ab16f82
Clean up.
SilasBerger Feb 5, 2026
2d94413
Start working on component cleanup.
SilasBerger Feb 6, 2026
9913b00
Slim context further.
SilasBerger Feb 6, 2026
67ee1d9
Reduce choice answer props to minimum.
SilasBerger Feb 6, 2026
ebab0da
Fix non-randomization of true/false options.
SilasBerger Feb 6, 2026
04b897f
Hide delete btn behind hover.
SilasBerger Feb 6, 2026
f34f4b5
Make fade a little snappier.
SilasBerger Feb 6, 2026
06073bb
Use canEdit, fix some bugs, clean up.
SilasBerger Feb 6, 2026
2f98410
Use card style.
SilasBerger Feb 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/api/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ import type DocumentStore from '@tdev-stores/DocumentStore';
import iDocumentContainer from '@tdev-models/iDocumentContainer';
import iViewStore from '@tdev-stores/ViewStores/iViewStore';
import Code from '@tdev-models/documents/Code';
import ChoiceAnswer, {
ChoiceAnswerChoices,
ChoiceAnswerOptionOrders,
ChoiceAnswerQuestionOrder
} from '@tdev-models/documents/ChoiceAnswer';

export enum Access {
RO_DocumentRoot = 'RO_DocumentRoot',
Expand All @@ -40,6 +45,12 @@ export interface StringData {
text: string;
}

export interface ChoiceAnswerData {
choices: ChoiceAnswerChoices;
optionOrders: ChoiceAnswerOptionOrders;
questionOrder: ChoiceAnswerQuestionOrder | null;
}

export interface QuillV2Data {
delta: Delta;
}
Expand Down Expand Up @@ -119,6 +130,7 @@ export interface TypeDataMapping extends ContainerTypeDataMapping {
// TODO: rename to `code_version`?
['script_version']: ScriptVersionData;
['string']: StringData;
['choice_answer']: ChoiceAnswerData;
['quill_v2']: QuillV2Data;
['solution']: SolutionData;
['dir']: DirData;
Expand Down Expand Up @@ -148,6 +160,7 @@ export interface TypeModelMapping extends ContainerTypeModelMapping {
// TODO: rename to `code_version`?
['script_version']: ScriptVersion;
['string']: String;
['choice_answer']: ChoiceAnswer;
['quill_v2']: QuillV2;
['solution']: Solution;
['dir']: Directory;
Expand Down
82 changes: 82 additions & 0 deletions src/components/documents/ChoiceAnswer/Quiz.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { useFirstMainDocument } from '@tdev-hooks/useFirstMainDocument';
import { ModelMeta } from '@tdev-models/documents/ChoiceAnswer';
import { observer } from 'mobx-react-lite';
import React from 'react';
import ChoiceAnswerDocument from '@tdev-models/documents/ChoiceAnswer';
import UnknownDocumentType from '@tdev-components/shared/Alert/UnknownDocumentType';
import { isBrowser } from 'es-toolkit';
import Loader from '@tdev-components/Loader';
import { createRandomOrderMap } from './helpers';
import styles from './styles.module.scss';

interface Props {
id: string;
readonly?: boolean;
hideQuestionNumbers?: boolean;
randomizeOptions?: boolean;
randomizeQuestions?: boolean;
numQuestions: number;
children?: React.ReactNode[];
}

export const QuizContext = React.createContext({
id: '',
readonly: false,
hideQuestionNumbers: false,
randomizeQuestions: false,
questionOrder: null,
randomizeOptions: false,
focussedQuestion: 0,
doc: null
} as {
id: string;
readonly?: boolean;
hideQuestionNumbers?: boolean;
randomizeQuestions?: boolean;
questionOrder: { [originalQuestionIndex: number]: number } | null;
randomizeOptions?: boolean;
focussedQuestion: number;
setFocussedQuestion?: (index: number) => void;
doc: ChoiceAnswerDocument | null;
});

const Quiz = observer((props: Props) => {
const [meta] = React.useState(new ModelMeta(props));
const doc = useFirstMainDocument(props.id, meta);

const [focussedQuestion, setFocussedQuestion] = React.useState(0);

React.useEffect(() => {
if (props.randomizeQuestions && !doc?.data.questionOrder) {
doc?.updateQuestionOrder(createRandomOrderMap(props.numQuestions));
}
}, [props.randomizeQuestions, doc, props.numQuestions]);

if (!doc) {
return <UnknownDocumentType type={meta.type} />;
}

if (!isBrowser) {
return <Loader />;
}

return (
<QuizContext.Provider
value={{
doc,
id: props.id,
readonly: props.readonly,
hideQuestionNumbers: props.hideQuestionNumbers,
randomizeQuestions: props.randomizeQuestions,
questionOrder: doc.data.questionOrder,
randomizeOptions: props.randomizeOptions,
focussedQuestion: focussedQuestion,
setFocussedQuestion: setFocussedQuestion
}}
>
<div className={styles.quizContainer}>{props.children}</div>
</QuizContext.Provider>
);
});

export default Quiz;
17 changes: 17 additions & 0 deletions src/components/documents/ChoiceAnswer/TrueFalseAnswer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { observer } from 'mobx-react-lite';
import ChoiceAnswer, { ChoiceAnswerProps } from '.';
import React from 'react';

const TrueFalseAnswer = observer((props: ChoiceAnswerProps) => {
return (
<ChoiceAnswer {...props} randomizeOptions={false}>
<ChoiceAnswer.Before>{props.children}</ChoiceAnswer.Before>
<ChoiceAnswer.Options>
<ChoiceAnswer.Option optionIndex={0}>Richtig</ChoiceAnswer.Option>
<ChoiceAnswer.Option optionIndex={1}>Falsch</ChoiceAnswer.Option>
</ChoiceAnswer.Options>
</ChoiceAnswer>
);
});

export default TrueFalseAnswer;
15 changes: 15 additions & 0 deletions src/components/documents/ChoiceAnswer/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import _ from 'es-toolkit/compat';

const range = (numItems: number): number[] => {
return Array.from({ length: numItems }, (_, i) => i);
};

export const createRandomOrderMap = (numOptions: number): { [originalIndex: number]: number } => {
const originalIndices = range(numOptions);
const shuffledIndices = _.shuffle(originalIndices);
const randomIndexMap: { [originalIndex: number]: number } = {};
originalIndices.forEach((originalIndex, i) => {
randomIndexMap[originalIndex] = shuffledIndices[i];
});
return randomIndexMap;
};
215 changes: 215 additions & 0 deletions src/components/documents/ChoiceAnswer/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import { useFirstMainDocument } from '@tdev-hooks/useFirstMainDocument';
import ChoiceAnswerDocument, { ModelMeta } from '@tdev-models/documents/ChoiceAnswer';
import { observer } from 'mobx-react-lite';
import React from 'react';
import clsx from 'clsx';
import styles from './styles.module.scss';
import SyncStatus from '@tdev-components/SyncStatus';
import UnknownDocumentType from '@tdev-components/shared/Alert/UnknownDocumentType';
import Loader from '@tdev-components/Loader';
import useIsBrowser from '@docusaurus/useIsBrowser';
import { QuizContext } from './Quiz';
import Button from '@tdev-components/shared/Button';
import { mdiTrashCanOutline } from '@mdi/js';
import _ from 'es-toolkit/compat';
import { createRandomOrderMap } from './helpers';

export interface ChoiceAnswerProps {
id: string;
title?: string;
questionIndex?: number;
inQuiz?: boolean;
multiple?: boolean;
randomizeOptions?: boolean;
numOptions: number;
readonly?: boolean;
children: React.ReactNode;
}

interface ThinWrapperProps {
children: React.ReactNode;
}

interface OptionProps {
children: React.ReactNode;
optionIndex: number;
}

type ChoiceAnswerSubComponents = {
Before: React.FC<ThinWrapperProps>;
Options: React.FC<ThinWrapperProps>;
Option: React.FC<OptionProps>;
After: React.FC<ThinWrapperProps>;
};

const ChoiceAnswerContext = React.createContext({
doc: undefined,
questionIndex: 0,
multiple: false,
randomizeOptions: false,
onChange: () => {}
} as {
doc?: ChoiceAnswerDocument;
questionIndex: number;
multiple?: boolean;
randomizeOptions?: boolean;
onChange: (optionIndex: number, checked: boolean) => void;
});

const ChoiceAnswer = observer((props: ChoiceAnswerProps) => {
const parentProps = React.useContext(QuizContext);
const [meta] = React.useState(new ModelMeta(props));
const doc = props.inQuiz ? parentProps.doc : useFirstMainDocument(props.id, meta);
const questionIndex = props.questionIndex ?? 0;
const randomizeOptions =
props.randomizeOptions !== undefined ? props.randomizeOptions : parentProps.randomizeOptions;
const isBrowser = useIsBrowser();

React.useEffect(() => {
if (randomizeOptions && !doc?.data.optionOrders?.[questionIndex]) {
doc?.updateOptionOrders({
...doc.data.optionOrders,
[questionIndex]: createRandomOrderMap(props.numOptions)
});
}
}, [randomizeOptions, doc, questionIndex, props.numOptions]);

if (!doc) {
return <UnknownDocumentType type={meta.type} />;
}

if (!isBrowser) {
return <Loader />;
}

const childrenArray = React.Children.toArray(props.children);
const beforeBlock = childrenArray.find(
(child) => React.isValidElement(child) && child.type === ChoiceAnswer.Before
);
const optionsBlock = childrenArray.find(
(child) => React.isValidElement(child) && child.type === ChoiceAnswer.Options
);
const afterBlock = childrenArray.find(
(child) => React.isValidElement(child) && child.type === ChoiceAnswer.After
);

const onOptionChange = (optionIndex: number, checked: boolean) => {
parentProps.setFocussedQuestion?.(questionIndex);
if (props.multiple) {
doc?.updateMultipleChoiceSelection(questionIndex, optionIndex, checked);
} else {
checked
? doc?.updateSingleChoiceSelection(questionIndex, optionIndex)
: doc?.resetAnswer(questionIndex);
}
};

const questionOrder =
parentProps.randomizeQuestions && parentProps.questionOrder
? parentProps.questionOrder[questionIndex]
: questionIndex;

const questionNumberToDisplay =
(parentProps.randomizeQuestions
? (parentProps.questionOrder?.[questionIndex] ?? questionIndex)
: questionIndex) + 1;
const title =
props.inQuiz && !parentProps.hideQuestionNumbers
? props.title
? `Frage ${questionNumberToDisplay} – ${props.title}`
: `Frage ${questionNumberToDisplay}`
: props.title;

const syncStatus = parentProps.focussedQuestion === questionIndex && (
<SyncStatus className={styles.syncStatus} model={doc} size={0.7} />
);

return (
<div className={clsx('card', styles.choiceAnswerContainer)} style={{ order: questionOrder }}>
{title && (
<div className={clsx('card__header', styles.header)}>
<span className={clsx(styles.title)}>{title}</span>
{syncStatus}
</div>
)}
{!title && syncStatus}

<div className={clsx('card__body')}>
{beforeBlock}
<ChoiceAnswerContext.Provider
value={{
doc: doc,
questionIndex: questionIndex,
multiple: props.multiple,
randomizeOptions: randomizeOptions,
onChange: onOptionChange
}}
>
<div className={styles.optionsBlock}>{optionsBlock}</div>
</ChoiceAnswerContext.Provider>
{afterBlock}
</div>
</div>
);
}) as React.FC<ChoiceAnswerProps> & ChoiceAnswerSubComponents;

ChoiceAnswer.Option = observer(({ optionIndex, children }: OptionProps) => {
const { doc, questionIndex, multiple, randomizeOptions, onChange } =
React.useContext(ChoiceAnswerContext);

const optionId = React.useId();

const isChecked = !!doc?.choices[questionIndex]?.includes(optionIndex);

const optionOrder = React.useMemo(
() =>
randomizeOptions && doc?.optionOrders[questionIndex] !== undefined
? doc?.optionOrders[questionIndex][optionIndex]
: optionIndex,
[doc?.optionOrders[questionIndex], questionIndex, optionIndex]
);

return (
<div
key={optionId}
className={clsx(styles.choiceAnswerOptionContainer)}
style={{
order: optionOrder
}}
>
<input
type={multiple ? 'checkbox' : 'radio'}
id={optionId}
name={optionId} // Use a radioGroup name here to make sure keyboard navigation still works.
value={optionId}
onChange={(e) => onChange(optionIndex, e.target.checked)}
checked={isChecked}
disabled={!doc?.canEdit}
/>
<label htmlFor={optionId}>{children}</label>
{!multiple && doc?.canEdit && isChecked && (
<Button
text="Löschen"
color="danger"
icon={mdiTrashCanOutline}
iconSide="left"
size={0.7}
onClick={() => onChange(optionIndex, false)}
className={clsx(styles.btnDeleteAnswer)}
/>
)}
</div>
);
});

ChoiceAnswer.Before = ({ children }: { children: React.ReactNode }) => {
return <>{children}</>;
};
ChoiceAnswer.Options = ({ children }: { children: React.ReactNode }) => {
return <div className={clsx(styles.optionsContainer)}>{children}</div>;
};
ChoiceAnswer.After = ({ children }: { children: React.ReactNode }) => {
return <>{children}</>;
};

export default ChoiceAnswer;
Loading