chore: establish learning module baseline
This commit is contained in:
commit
2d04197f3f
2409 files changed
+533050
No files matched your search
@@ -0,0 +1,203 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import type { EditorHint, SurfaceState } from '@/lib/edit/scene-editor-surface';
|
||||
import { useI18n } from '@/lib/hooks/use-i18n';
|
||||
import { useStageStore } from '@/lib/store/stage';
|
||||
import type { QuizContent, QuizQuestionType } from '@/lib/types/stage';
|
||||
import {
|
||||
addOption,
|
||||
addQuestion,
|
||||
deleteOption,
|
||||
deleteQuestion,
|
||||
isChoice,
|
||||
reorderOptions,
|
||||
reorderQuestions,
|
||||
setQuestionType,
|
||||
toggleCorrect,
|
||||
updateOptionLabel,
|
||||
updateQuestion,
|
||||
} from './quiz-edit-ops';
|
||||
import { useQuizEditSession } from './quiz-edit-session';
|
||||
|
||||
/**
|
||||
* The quiz form is a self-contained structured editor: it owns its own
|
||||
* expansion state internally and contributes no canvas-style selection to the
|
||||
* chrome. `undefined` is the honest selection type.
|
||||
*/
|
||||
export type QuizSelection = undefined;
|
||||
|
||||
const EMPTY_QUIZ: QuizContent = { type: 'quiz', questions: [] };
|
||||
|
||||
function currentQuizContent(sceneId: string): QuizContent | null {
|
||||
const scene = useStageStore.getState().scenes.find((s) => s.id === sceneId);
|
||||
return scene && scene.type === 'quiz' ? (scene.content as QuizContent) : null;
|
||||
}
|
||||
|
||||
/** The working content: in-memory session present, else the canonical scene, else empty. */
|
||||
function resolvePresent(): QuizContent {
|
||||
const { history, sceneId } = useQuizEditSession.getState();
|
||||
return history?.present ?? (sceneId ? currentQuizContent(sceneId) : null) ?? EMPTY_QUIZ;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bound mutations. Each reads `present` via getState (a stable module closure,
|
||||
// never a per-render capture) so the EditShell's `surfaceStateEqual` — which
|
||||
// does NOT compare callbacks — stays correct without special-casing.
|
||||
//
|
||||
// `commit` = discrete edit → its own undo step.
|
||||
// `commitText` = coalesced text edit → consecutive same-key edits fold into
|
||||
// one step (typing a field), while every keystroke writes
|
||||
// through to the store.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type QuestionTextPatch = Parameters<typeof updateQuestion>[2];
|
||||
|
||||
export function addQuizQuestion(type: QuizQuestionType): void {
|
||||
useQuizEditSession.getState().commit(addQuestion(resolvePresent(), type));
|
||||
}
|
||||
export function deleteQuizQuestion(id: string): void {
|
||||
useQuizEditSession.getState().commit(deleteQuestion(resolvePresent(), id));
|
||||
}
|
||||
export function reorderQuizQuestions(orderedIds: readonly string[]): void {
|
||||
useQuizEditSession.getState().commit(reorderQuestions(resolvePresent(), orderedIds));
|
||||
}
|
||||
export function setQuizQuestionType(id: string, type: QuizQuestionType): void {
|
||||
useQuizEditSession.getState().commit(setQuestionType(resolvePresent(), id, type));
|
||||
}
|
||||
/** Coalesced text/number patch (question / analysis / commentPrompt / points). */
|
||||
export function typeQuizQuestion(id: string, patch: QuestionTextPatch, coalesceKey: string): void {
|
||||
useQuizEditSession
|
||||
.getState()
|
||||
.commitText(updateQuestion(resolvePresent(), id, patch), coalesceKey);
|
||||
}
|
||||
export function addQuizOption(id: string): void {
|
||||
useQuizEditSession.getState().commit(addOption(resolvePresent(), id));
|
||||
}
|
||||
export function deleteQuizOption(id: string, index: number): void {
|
||||
useQuizEditSession.getState().commit(deleteOption(resolvePresent(), id, index));
|
||||
}
|
||||
export function typeQuizOptionLabel(id: string, index: number, label: string): void {
|
||||
useQuizEditSession
|
||||
.getState()
|
||||
.commitText(updateOptionLabel(resolvePresent(), id, index, label), `${id}:opt:${index}`);
|
||||
}
|
||||
export function reorderQuizOptions(id: string, from: number, to: number): void {
|
||||
useQuizEditSession.getState().commit(reorderOptions(resolvePresent(), id, from, to));
|
||||
}
|
||||
export function toggleQuizCorrect(id: string, index: number): void {
|
||||
useQuizEditSession.getState().commit(toggleCorrect(resolvePresent(), id, index));
|
||||
}
|
||||
|
||||
/** Max validation hints shown at once so the HintRail stays readable. */
|
||||
const MAX_HINTS = 5;
|
||||
|
||||
/**
|
||||
* Authoring validation surfaced through the chrome's reserved `hints` slot:
|
||||
* one hint per problematic question (highest-priority issue only), so the
|
||||
* author sees what still needs fixing before the quiz is playable. A choice
|
||||
* question with no correct answer is a warning (it always scores incorrect in
|
||||
* playback); blank text / options are gentler suggestions.
|
||||
*/
|
||||
export function buildQuizHints(
|
||||
content: QuizContent,
|
||||
t: (k: string, o?: Record<string, unknown>) => string,
|
||||
): EditorHint[] {
|
||||
const hints: EditorHint[] = [];
|
||||
content.questions.forEach((q, i) => {
|
||||
if (hints.length >= MAX_HINTS) return;
|
||||
const n = i + 1;
|
||||
const choice = isChoice(q.type);
|
||||
let issue: { severity: EditorHint['severity']; key: string } | null = null;
|
||||
if (!q.question.trim()) {
|
||||
issue = { severity: 'suggestion', key: 'emptyText' };
|
||||
} else if (choice && (q.options?.length ?? 0) < 2) {
|
||||
issue = { severity: 'warning', key: 'fewOptions' };
|
||||
} else if (choice && (q.options ?? []).some((o) => !o.label.trim())) {
|
||||
issue = { severity: 'suggestion', key: 'emptyOption' };
|
||||
} else if (choice && (q.answer?.length ?? 0) === 0) {
|
||||
issue = { severity: 'warning', key: 'noCorrect' };
|
||||
}
|
||||
if (issue) {
|
||||
hints.push({
|
||||
id: q.id,
|
||||
severity: issue.severity,
|
||||
message: t(`edit.quiz.hint.${issue.key}`, { n }),
|
||||
});
|
||||
}
|
||||
});
|
||||
return hints;
|
||||
}
|
||||
|
||||
/**
|
||||
* The resolved quiz content the form reads: in-memory session present (once
|
||||
* seeded, ref-stable until a commit), else the canonical stage scene as a
|
||||
* pre-seed fallback, else an empty quiz.
|
||||
*/
|
||||
export function useResolvedQuizContent(): QuizContent {
|
||||
const history = useQuizEditSession((s) => s.history);
|
||||
// Fall back to the canonical current quiz scene so the form has its content
|
||||
// on the very first render — before the session seeds in an effect. (`seed`
|
||||
// adopts this exact object, so the ref is stable across the seed and the
|
||||
// chrome's `surfaceStateEqual` doesn't see a spurious change.) Reading it
|
||||
// from the stage store also keeps the form's question-id set stable from the
|
||||
// first populated render, so the "auto-expand a newly added question" logic
|
||||
// in QuizForm doesn't fire for the seeded questions.
|
||||
const sceneContent = useStageStore((s) => {
|
||||
const scene = s.scenes.find((x) => x.id === s.currentSceneId) ?? null;
|
||||
return scene && scene.type === 'quiz' ? (scene.content as QuizContent) : null;
|
||||
});
|
||||
return history?.present ?? sceneContent ?? EMPTY_QUIZ;
|
||||
}
|
||||
|
||||
/**
|
||||
* The quiz surface's `useSurfaceState`. Pure read over the shared session
|
||||
* store. No selection model, no floating actions — the structured form owns
|
||||
* all of its editing affordances inline; the chrome only contributes undo/redo.
|
||||
*/
|
||||
export function useQuizSurfaceState(): SurfaceState<QuizContent, QuizSelection> {
|
||||
const { t } = useI18n();
|
||||
const history = useQuizEditSession((s) => s.history);
|
||||
const content = useResolvedQuizContent();
|
||||
|
||||
return {
|
||||
content,
|
||||
selection: undefined,
|
||||
hasSelection: false,
|
||||
history: {
|
||||
canUndo: !!history && history.past.length > 0,
|
||||
canRedo: !!history && history.future.length > 0,
|
||||
undo: () => useQuizEditSession.getState().undo(),
|
||||
redo: () => useQuizEditSession.getState().redo(),
|
||||
},
|
||||
insertItems: [],
|
||||
floatingActions: [],
|
||||
commands: [],
|
||||
hints: buildQuizHints(content, t),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds the quiz-edit session from the active quiz scene and tears it down on
|
||||
* exit — mirrors `useSlideCanvasController`'s lifecycle, minus the canvas /
|
||||
* gesture tracking. Used by QuizForm (the SurfaceComponent). Returns the
|
||||
* resolved quiz scene id ('' when the current scene isn't a quiz).
|
||||
*/
|
||||
export function useQuizSurfaceLifecycle(): string {
|
||||
const sceneId = useStageStore((s) => {
|
||||
const scene = s.scenes.find((x) => x.id === s.currentSceneId) ?? null;
|
||||
return scene && scene.type === 'quiz' ? scene.id : '';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!sceneId) return;
|
||||
const content = currentQuizContent(sceneId);
|
||||
if (content && useQuizEditSession.getState().sceneId !== sceneId) {
|
||||
useQuizEditSession.getState().seed(sceneId, content);
|
||||
}
|
||||
}, [sceneId]);
|
||||
|
||||
useEffect(() => () => useQuizEditSession.getState().end(), []);
|
||||
|
||||
return sceneId;
|
||||
}
|
||||
Reference in new issue
Block a user