Files
openmaic/OpenMAIC/components/edit/surfaces/quiz/quiz-edit-ops.ts
2026-08-16 14:58:47 +08:00

301 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Pure, side-effect-free editing operations for the quiz content surface.
*
* Two layers live here:
* 1. A snapshot-based undo/redo history primitive (`QuizEditHistory`),
* mirroring the slide surface's `slide-ops` history shape so the
* quiz-edit-session store can reuse the same idiom.
* 2. Structured mutations over `QuizContent` — every helper takes a
* `QuizContent` and returns a NEW `QuizContent` (referential change is
* the "real edit" signal the EditShell's `surfaceStateEqual` compares).
*
* **Option model.** A `QuizOption.value` doubles as the option's displayed
* badge letter (QuizView renders `opt.value` as the A/B/C chip) AND the key
* stored in `QuizQuestion.answer`. To keep the displayed letters sequential
* regardless of insert / delete / reorder while never corrupting `answer`,
* choice-option mutations round-trip through a position-independent
* intermediate (`OptionRow { label, correct }`): edits act on rows, then
* `fromRows` re-derives `value = LETTERS[index]` and rebuilds `answer` from
* the per-row `correct` flag. Reordering options therefore never needs an
* explicit answer remap — correctness travels with the row.
*/
import type { QuizContent, QuizQuestion, QuizQuestionType } from '@/lib/types/stage';
import { createElementId } from '@/lib/edit/element-id';
// ---------------------------------------------------------------------------
// History primitive (snapshot-based, mirrors slide-ops shape)
// ---------------------------------------------------------------------------
export interface QuizEditHistory {
past: QuizContent[];
present: QuizContent;
future: QuizContent[];
}
export function createQuizEditHistory(present: QuizContent): QuizEditHistory {
return { past: [], present, future: [] };
}
/**
* Fold a new content snapshot in as a fresh undo step. A no-op (identical
* reference) is dropped so callers can commit unconditionally without
* polluting the undo stack. Committing clears the redo branch.
*/
export function commitQuizContent(history: QuizEditHistory, next: QuizContent): QuizEditHistory {
if (next === history.present) return history;
return { past: [...history.past, history.present], present: next, future: [] };
}
export function undoQuiz(history: QuizEditHistory): QuizEditHistory {
if (history.past.length === 0) return history;
const previous = history.past[history.past.length - 1];
return {
past: history.past.slice(0, -1),
present: previous,
future: [history.present, ...history.future],
};
}
export function redoQuiz(history: QuizEditHistory): QuizEditHistory {
if (history.future.length === 0) return history;
const next = history.future[0];
return {
past: [...history.past, history.present],
present: next,
future: history.future.slice(1),
};
}
// ---------------------------------------------------------------------------
// Option-row intermediate
// ---------------------------------------------------------------------------
const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
/** Max options a choice question may hold (A–Z). */
export const MAX_OPTIONS = LETTERS.length;
export interface OptionRow {
label: string;
correct: boolean;
}
export function optionLetter(index: number): string {
return LETTERS[index] ?? `#${index + 1}`;
}
export function toRows(q: QuizQuestion): OptionRow[] {
const answer = q.answer ?? [];
return (q.options ?? []).map((o) => ({ label: o.label, correct: answer.includes(o.value) }));
}
/** Re-derive `options` (value = positional letter) + `answer` from rows. */
export function fromRows(rows: OptionRow[]): Pick<QuizQuestion, 'options' | 'answer'> {
const options = rows.map((r, i) => ({ label: r.label, value: optionLetter(i) }));
const answer = rows
.map((r, i) => ({ correct: r.correct, value: optionLetter(i) }))
.filter((r) => r.correct)
.map((r) => r.value);
return { options, answer };
}
// ---------------------------------------------------------------------------
// Question factories
// ---------------------------------------------------------------------------
export function isChoice(type: QuizQuestionType): boolean {
return type === 'single' || type === 'multiple';
}
/** A new blank question of the given type with sensible defaults. */
export function createBlankQuestion(
type: QuizQuestionType,
id = createElementId('q'),
): QuizQuestion {
if (isChoice(type)) {
return {
id,
type,
question: '',
options: [
{ label: '', value: 'A' },
{ label: '', value: 'B' },
],
answer: [],
points: 1,
};
}
return {
id,
type: 'short_answer',
question: '',
points: 1,
hasAnswer: false,
};
}
// ---------------------------------------------------------------------------
// Question-list mutations
// ---------------------------------------------------------------------------
function mapQuestion(
content: QuizContent,
id: string,
fn: (q: QuizQuestion) => QuizQuestion,
): QuizContent {
return {
...content,
questions: content.questions.map((q) => (q.id === id ? fn(q) : q)),
};
}
export function addQuestion(
content: QuizContent,
type: QuizQuestionType,
id?: string,
): QuizContent {
return { ...content, questions: [...content.questions, createBlankQuestion(type, id)] };
}
export function deleteQuestion(content: QuizContent, id: string): QuizContent {
return { ...content, questions: content.questions.filter((q) => q.id !== id) };
}
/** Reorder questions to match the given id order. Unknown / missing ids are ignored. */
export function reorderQuestions(content: QuizContent, orderedIds: readonly string[]): QuizContent {
const byId = new Map(content.questions.map((q) => [q.id, q]));
const next: QuizQuestion[] = [];
for (const id of orderedIds) {
const q = byId.get(id);
if (q) {
next.push(q);
byId.delete(id);
}
}
// Preserve any questions not named in orderedIds (defensive), in original order.
for (const q of content.questions) if (byId.has(q.id)) next.push(q);
return { ...content, questions: next };
}
/** Patch scalar question fields (question text, analysis, commentPrompt, points, hasAnswer). */
export function updateQuestion(
content: QuizContent,
id: string,
patch: Partial<
Pick<QuizQuestion, 'question' | 'analysis' | 'commentPrompt' | 'points' | 'hasAnswer'>
>,
): QuizContent {
return mapQuestion(content, id, (q) => ({ ...q, ...patch }));
}
/**
* Switch a question's type, applying the structural transition:
* choice → short_answer : drop options + answer, seed grading fields.
* short_answer → choice : seed two blank options, empty answer.
* single ↔ multiple : keep options; collapsing to single keeps only
* the first correct answer.
*/
export function setQuestionType(
content: QuizContent,
id: string,
type: QuizQuestionType,
): QuizContent {
return mapQuestion(content, id, (q) => {
if (q.type === type) return q;
if (!isChoice(type)) {
// → short_answer
const { options: _o, answer: _a, ...rest } = q;
return { ...rest, type: 'short_answer', hasAnswer: q.hasAnswer ?? false };
}
if (!isChoice(q.type)) {
// short_answer → choice
const { commentPrompt: _c, hasAnswer: _h, ...rest } = q;
return {
...rest,
type,
options: [
{ label: '', value: 'A' },
{ label: '', value: 'B' },
],
answer: [],
};
}
// single ↔ multiple: keep options; single keeps at most one correct.
const answer = type === 'single' ? (q.answer ?? []).slice(0, 1) : (q.answer ?? []);
return { ...q, type, answer };
});
}
// ---------------------------------------------------------------------------
// Option mutations (choice questions only; no-op on short_answer)
// ---------------------------------------------------------------------------
function mapRows(
content: QuizContent,
id: string,
fn: (rows: OptionRow[]) => OptionRow[],
): QuizContent {
return mapQuestion(content, id, (q) => {
if (!isChoice(q.type)) return q;
return { ...q, ...fromRows(fn(toRows(q))) };
});
}
export function addOption(content: QuizContent, id: string): QuizContent {
return mapRows(content, id, (rows) =>
rows.length >= MAX_OPTIONS ? rows : [...rows, { label: '', correct: false }],
);
}
export function deleteOption(content: QuizContent, id: string, index: number): QuizContent {
return mapRows(content, id, (rows) => rows.filter((_, i) => i !== index));
}
export function updateOptionLabel(
content: QuizContent,
id: string,
index: number,
label: string,
): QuizContent {
return mapRows(content, id, (rows) => rows.map((r, i) => (i === index ? { ...r, label } : r)));
}
export function reorderOptions(
content: QuizContent,
id: string,
fromIndex: number,
toIndex: number,
): QuizContent {
return mapRows(content, id, (rows) => {
if (fromIndex === toIndex) return rows;
if (fromIndex < 0 || fromIndex >= rows.length) return rows;
if (toIndex < 0 || toIndex >= rows.length) return rows;
const next = rows.slice();
const [moved] = next.splice(fromIndex, 1);
next.splice(toIndex, 0, moved);
return next;
});
}
/**
* Toggle an option's correctness. For `single`, selecting an option makes it
* the sole correct answer (radio semantics). For `multiple`, it flips that
* option independently (checkbox semantics).
*/
export function toggleCorrect(content: QuizContent, id: string, index: number): QuizContent {
return mapQuestion(content, id, (q) => {
if (!isChoice(q.type)) return q;
const rows = toRows(q);
if (index < 0 || index >= rows.length) return q;
const next =
q.type === 'single'
? rows.map((r, i) => ({ ...r, correct: i === index }))
: rows.map((r, i) => (i === index ? { ...r, correct: !r.correct } : r));
return { ...q, ...fromRows(next) };
});
}