diff --git a/components/header.tsx b/components/header.tsx index 5a61ec96f..f34eb3c57 100644 --- a/components/header.tsx +++ b/components/header.tsx @@ -10,16 +10,19 @@ import { Download, FileDown, Package, + BookOpen, } from 'lucide-react'; import { useI18n } from '@/lib/hooks/use-i18n'; import { useTheme } from '@/lib/hooks/use-theme'; import { useState, useEffect, useRef, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { SettingsDialog } from './settings'; +import { ScormExportDialog } from './scorm-export-dialog'; import { cn } from '@/lib/utils'; import { useStageStore } from '@/lib/store/stage'; import { useMediaGenerationStore } from '@/lib/store/media-generation'; import { useExportPPTX } from '@/lib/export/use-export-pptx'; +import { useExportScorm } from '@/lib/export/scorm'; interface HeaderProps { readonly currentSceneTitle: string; @@ -35,13 +38,17 @@ export function Header({ currentSceneTitle }: HeaderProps) { // Export const { exporting: isExporting, exportPPTX, exportResourcePack } = useExportPPTX(); + const { exporting: isExportingScorm, exportScorm } = useExportScorm(); const [exportMenuOpen, setExportMenuOpen] = useState(false); + const [scormDialogOpen, setScormDialogOpen] = useState(false); const exportRef = useRef(null); const scenes = useStageStore((s) => s.scenes); const generatingOutlines = useStageStore((s) => s.generatingOutlines); const failedOutlines = useStageStore((s) => s.failedOutlines); const mediaTasks = useMediaGenerationStore((s) => s.tasks); + const anyExporting = isExporting || isExportingScorm; + const canExport = scenes.length > 0 && generatingOutlines.length === 0 && @@ -222,31 +229,31 @@ export function Header({ currentSceneTitle }: HeaderProps) {
{exportMenuOpen && ( -
+
+
+
)}
+ exportScorm(opts)} + /> ); } diff --git a/components/scorm-export-dialog.tsx b/components/scorm-export-dialog.tsx new file mode 100644 index 000000000..aaa13c4c8 --- /dev/null +++ b/components/scorm-export-dialog.tsx @@ -0,0 +1,104 @@ +'use client'; + +import { useState } from 'react'; +import { BookOpen } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { useI18n } from '@/lib/hooks/use-i18n'; +import type { ScormExportOptions } from '@/lib/export/scorm'; + +interface ScormExportDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: (options: ScormExportOptions) => void; +} + +export function ScormExportDialog({ open, onOpenChange, onConfirm }: ScormExportDialogProps) { + const { t } = useI18n(); + const [includeVideos, setIncludeVideos] = useState(false); + + function handleConfirm() { + onOpenChange(false); + onConfirm({ includeVideos }); + } + + return ( + + + +
+ + {t('export.scormDialogTitle')} +
+ {t('export.scormDialogDesc')} +
+ +
+ + + +
+ + + + + +
+
+ ); +} diff --git a/lib/export/scorm/asset-collector.ts b/lib/export/scorm/asset-collector.ts new file mode 100644 index 000000000..5f57f5022 --- /dev/null +++ b/lib/export/scorm/asset-collector.ts @@ -0,0 +1,263 @@ +import { isMediaPlaceholder, useMediaGenerationStore } from '@/lib/store/media-generation'; +import type { Scene, SlideContent } from '@/lib/types/stage'; +import type { SpeechAction } from '@/lib/types/action'; +import { db } from '@/lib/utils/database'; + +export interface AssetEntry { + zipPath: string; + blob: Blob; + mimeType: string; +} + +export interface AssetMap { + /** Returns the ZIP-relative path for a given original src, or undefined if not collected */ + get(src: string): string | undefined; + /** All collected assets */ + entries(): AssetEntry[]; +} + +// ── Internal map implementation ── + +class AssetMapImpl implements AssetMap { + private _srcToZipPath = new Map(); + private _entries: AssetEntry[] = []; + + set(src: string, zipPath: string, blob: Blob, mimeType: string) { + if (this._srcToZipPath.has(src)) return; // deduplicate + this._srcToZipPath.set(src, zipPath); + this._entries.push({ zipPath, blob, mimeType }); + } + + get(src: string): string | undefined { + return this._srcToZipPath.get(src); + } + + entries(): AssetEntry[] { + return this._entries; + } +} + +// ── Fetch helpers ── + +function dataUriToBlob(dataUri: string): { blob: Blob; mimeType: string } { + const [header, b64] = dataUri.split(','); + const mimeType = header.match(/:(.*?);/)?.[1] ?? 'application/octet-stream'; + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return { blob: new Blob([bytes], { type: mimeType }), mimeType }; +} + +async function fetchToBlob(src: string): Promise<{ blob: Blob; mimeType: string } | null> { + try { + if (src.startsWith('data:')) { + return dataUriToBlob(src); + } + const resp = await fetch(src); + if (!resp.ok) return null; + const blob = await resp.blob(); + return { blob, mimeType: blob.type || 'application/octet-stream' }; + } catch { + return null; + } +} + +function mimeToExt(mimeType: string, fallback = 'bin'): string { + const map: Record = { + 'image/jpeg': 'jpg', + 'image/jpg': 'jpg', + 'image/png': 'png', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'image/svg+xml': 'svg', + 'audio/mpeg': 'mp3', + 'audio/mp3': 'mp3', + 'audio/wav': 'wav', + 'audio/ogg': 'ogg', + 'audio/aac': 'aac', + 'video/mp4': 'mp4', + 'video/webm': 'webm', + 'video/ogg': 'ogv', + }; + return map[mimeType] ?? fallback; +} + +function pad2(n: number): string { + return String(n + 1).padStart(2, '0'); +} + +// ── Resolve a src that may be a media placeholder ── + +function resolveSrc(src: string): { src: string; poster?: string } | null { + if (!isMediaPlaceholder(src)) return { src }; + const task = useMediaGenerationStore.getState().tasks[src]; + if (task?.status === 'done' && task.objectUrl) { + return { src: task.objectUrl, poster: task.poster }; + } + return null; // not ready, skip +} + +// ── Main export ── + +/** + * Collects all media assets from exportable scenes into an AssetMap. + * + * - Slide scenes: image/video/audio element srcs + background image + TTS audio + * - Quiz/Interactive scenes: no media assets + * - When includeVideos=false, video elements are replaced by their poster image. + * If no poster is available, the video element is skipped entirely. + * + * Returns an AssetMap that maps original src → zipPath, and iterable AssetEntry[]. + */ +export async function collectAssets( + scenes: Scene[], + includeVideos: boolean, +): Promise { + const map = new AssetMapImpl(); + + const add = async ( + originalSrc: string, + category: 'images' | 'audio' | 'videos', + sceneIdx: number, + suffix: string, + ) => { + if (!originalSrc || map.get(originalSrc) !== undefined) return; // already queued or empty + const result = await fetchToBlob(originalSrc); + if (!result) return; + const ext = mimeToExt(result.mimeType, category === 'audio' ? 'mp3' : category === 'videos' ? 'mp4' : 'png'); + const zipPath = `assets/${category}/scene_${pad2(sceneIdx)}_${suffix}.${ext}`; + map.set(originalSrc, zipPath, result.blob, result.mimeType); + }; + + for (let i = 0; i < scenes.length; i++) { + const scene = scenes[i]; + + // ── Slide scenes ── + if (scene.content.type === 'slide') { + const canvas = (scene.content as SlideContent).canvas; + + // Background image + if (canvas.background?.type === 'image' && canvas.background.image?.src) { + await add(canvas.background.image.src, 'images', i, 'bg'); + } + + // Elements + let elIdx = 0; + for (const el of canvas.elements) { + const elSuffix = `el_${el.id}`; + + if (el.type === 'image') { + const resolved = resolveSrc(el.src); + if (resolved) await add(resolved.src, 'images', i, elSuffix); + + } else if (el.type === 'video') { + const resolved = resolveSrc(el.src); + const resolvedPoster = el.poster ? resolveSrc(el.poster) : null; + + if (includeVideos && resolved) { + await add(resolved.src, 'videos', i, elSuffix); + // Also collect poster (used as fallback thumbnail in HTML) + const posterSrc = resolvedPoster?.src ?? resolved.poster; + if (posterSrc) await add(posterSrc, 'images', i, `${elSuffix}_poster`); + } else { + // Replace video with poster image + const posterSrc = + resolvedPoster?.src ?? + (resolved?.poster) ?? + // Also check media generation store poster + (isMediaPlaceholder(el.src) + ? useMediaGenerationStore.getState().tasks[el.src]?.poster + : undefined); + if (posterSrc) await add(posterSrc, 'images', i, `${elSuffix}_poster`); + } + + } else if (el.type === 'audio') { + const resolved = resolveSrc(el.src); + if (resolved) await add(resolved.src, 'audio', i, elSuffix); + } + + elIdx++; + } + + // TTS narration from SpeechActions + // Primary: use server-hosted audioUrl (if set). Fallback: read blob from IndexedDB by audioId. + if (scene.actions) { + let speechIdx = 0; + for (const action of scene.actions) { + if (action.type === 'speech') { + const speech = action as SpeechAction; + if (speech.audioUrl) { + await add(speech.audioUrl, 'audio', i, `speech_${speechIdx}`); + speechIdx++; + } else if (speech.audioId) { + try { + const record = await db.audioFiles.get(speech.audioId); + if (record) { + const ext = record.format || 'mp3'; + const mimeType = ext === 'mp3' ? 'audio/mpeg' : `audio/${ext}`; + const zipPath = `assets/audio/scene_${pad2(i)}_speech_${speechIdx}.${ext}`; + map.set(`idb:${speech.audioId}`, zipPath, record.blob, mimeType); + speechIdx++; + } + } catch { + // IndexedDB access failed — skip this audio + } + } + } + } + } + } + + // Quiz and Interactive scenes have no media assets to collect + } + + return map; +} + +/** + * Returns all ZIP asset paths referenced by a specific scene. + * Used to populate entries in imsmanifest.xml. + */ +export function getSceneAssetHrefs(scene: Scene, sceneIdx: number, assetMap: AssetMap, includeVideos: boolean): string[] { + const hrefs: string[] = []; + + if (scene.content.type !== 'slide') return hrefs; + + const canvas = (scene.content as SlideContent).canvas; + + const collect = (src: string) => { + if (!src) return; + const resolved = resolveSrc(src); + if (resolved) { + const p = assetMap.get(resolved.src); + if (p) hrefs.push(p); + } + }; + + if (canvas.background?.type === 'image' && canvas.background.image?.src) { + collect(canvas.background.image.src); + } + + for (const el of canvas.elements) { + if (el.type === 'image') collect(el.src); + else if (el.type === 'video') { + if (includeVideos) { + collect(el.src); + if (el.poster) collect(el.poster); + } else if (el.poster) { + collect(el.poster); + } + } else if (el.type === 'audio') collect(el.src); + } + + if (scene.actions) { + for (const action of scene.actions) { + if (action.type === 'speech') { + const speech = action as SpeechAction; + if (speech.audioUrl) collect(speech.audioUrl); + } + } + } + + return [...new Set(hrefs)]; +} diff --git a/lib/export/scorm/course-builder.ts b/lib/export/scorm/course-builder.ts new file mode 100644 index 000000000..7a4b40fd8 --- /dev/null +++ b/lib/export/scorm/course-builder.ts @@ -0,0 +1,702 @@ +/** + * course-builder.ts + * Assembles all scene fragments into a single SCORM 1.2 SCO (index.html). + * + * Layout: + * - Fixed 240px left sidebar with scene list and progress indicator + * - Scene area fills the remaining right space (position:fixed, left:240px) + * - Fixed 52px nav bar at the bottom (also right of sidebar) + * + * CSS: all class names prefixed with "om-" to avoid conflicts with LMS stylesheets + * (Bootstrap, Moodle, etc. commonly define .scene, .option-label, .submit-btn, etc.) + */ +import type { SlideSceneMeta } from './slide-sco'; +import type { QuizSceneMeta } from './quiz-sco'; +import type { InteractiveSceneMeta } from './interactive-sco'; +import { QUIZ_PASS_THRESHOLD } from './quiz-sco'; + +export type SceneMeta = SlideSceneMeta | QuizSceneMeta | InteractiveSceneMeta; + +export interface CourseHtmlOptions { + courseName: string; + /** Each section includes the pre-built HTML fragment, its metadata, and the scene title. */ + sections: Array<{ html: string; meta: SceneMeta; title: string }>; + needsKatex: boolean; +} + +const SIDEBAR_W = 240; // px +const NAV_H = 52; // px + +function escHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function serializeMeta(metas: SceneMeta[]): string { + return JSON.stringify( + metas.map((m) => { + if (m.type === 'slide') { + return { + type: 'slide', id: m.sceneId, + canvasId: m.canvasId, scalerId: m.scalerId, + cw: m.cw, ch: m.ch, narrIds: m.narrIds, + }; + } + if (m.type === 'quiz') { + return { type: 'quiz', id: m.sceneId, questionCount: m.questionCount }; + } + return { type: 'interactive', id: m.sceneId }; + }), + ); +} + +export function buildCourseHtml(opts: CourseHtmlOptions): string { + const { courseName, sections, needsKatex } = opts; + const metas = sections.map((s) => s.meta); + const totalScenes = sections.length; + const hasQuiz = metas.some((m) => m.type === 'quiz'); + + const sceneSections = sections.map((s) => s.html).join('\n\n'); + const metasJson = serializeMeta(metas); + + const katexLink = needsKatex + ? `` + : ''; + + const sidebarItems = sections + .map((s, i) => `
  • + + ${escHtml(s.title)} +
  • `) + .join('\n'); + + return ` + + + + + ${escHtml(courseName)} + + ${katexLink} + + + + + + + + +${sceneSections} + + + + + + +`; +} diff --git a/lib/export/scorm/index.ts b/lib/export/scorm/index.ts new file mode 100644 index 000000000..a735b8132 --- /dev/null +++ b/lib/export/scorm/index.ts @@ -0,0 +1,2 @@ +export { useExportScorm } from './use-export-scorm'; +export type { ScormExportOptions } from './use-export-scorm'; diff --git a/lib/export/scorm/interactive-sco.ts b/lib/export/scorm/interactive-sco.ts new file mode 100644 index 000000000..f3592fb95 --- /dev/null +++ b/lib/export/scorm/interactive-sco.ts @@ -0,0 +1,44 @@ +/** + * interactive-sco.ts + * Builds the HTML fragment (a
    element) for an interactive scene. + * The interactive HTML is embedded in an isolated +
    `; + + return { + html, + meta: { type: 'interactive', sceneId }, + }; +} diff --git a/lib/export/scorm/manifest.ts b/lib/export/scorm/manifest.ts new file mode 100644 index 000000000..8245f83c5 --- /dev/null +++ b/lib/export/scorm/manifest.ts @@ -0,0 +1,65 @@ +export interface ScormManifestOptions { + courseId: string; + courseTitle: string; + /** All asset zip paths referenced by the single SCO */ + assetHrefs: string[]; + /** Pass threshold (0-100). Set only if course has quiz scenes. */ + masteryScore?: number; +} + +/** + * Builds the imsmanifest.xml string for a single-SCO SCORM 1.2 package. + * All course content lives in one index.html file at the package root. + */ +export function buildManifest(opts: ScormManifestOptions): string { + const { courseId, courseTitle, assetHrefs, masteryScore } = opts; + + const escXml = (s: string) => + s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + + const orgId = `${courseId}_org`; + + const masteryLine = + masteryScore !== undefined + ? `\n ${masteryScore}` + : ''; + + const fileEntries = [ + ``, + ``, + ...assetHrefs.map((h) => ``), + ].join('\n '); + + return ` + + + + ADL SCORM + 1.2 + + + + + ${escXml(courseTitle)} + + ${escXml(courseTitle)}${masteryLine} + + + + + + + ${fileEntries} + + + +`; +} diff --git a/lib/export/scorm/quiz-sco.ts b/lib/export/scorm/quiz-sco.ts new file mode 100644 index 000000000..48285fd94 --- /dev/null +++ b/lib/export/scorm/quiz-sco.ts @@ -0,0 +1,222 @@ +/** + * quiz-sco.ts + * Builds the HTML fragment (a
    element) for a quiz scene. + * Only single/multiple choice questions are exported (short_answer excluded). + * CSS classes use the "om-" prefix to avoid conflicts with LMS stylesheets. + */ +import type { Scene, QuizContent, QuizQuestion } from '@/lib/types/stage'; + +export const QUIZ_PASS_THRESHOLD = 80; + +export interface QuizSceneMeta { + type: 'quiz'; + sceneId: string; + questionCount: number; // exportable questions count +} + +export interface QuizSectionResult { + html: string; + meta: QuizSceneMeta; +} + +function escHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function renderQuestion(q: QuizQuestion, idx: number, sceneIndex: number): string { + if (q.type === 'short_answer' || !q.options?.length) return ''; + const inputType = q.type === 'multiple' ? 'checkbox' : 'radio'; + const multiHint = q.type === 'multiple' ? ' (select all that apply)' : ''; + // Prefix IDs with sceneIndex to avoid conflicts when multiple quizzes share question IDs + const blockId = `qblock_${sceneIndex}_${escHtml(q.id)}`; + const analysisId = `analysis_${sceneIndex}_${escHtml(q.id)}`; + + const options = q.options + .map( + (opt) => ` + `, + ) + .join(''); + + const analysis = q.analysis + ? `
    ${escHtml(q.analysis)}
    ` + : ''; + + return `
    +
    ${idx + 1}. ${escHtml(q.question)}${multiHint}
    +
    ${options}
    + ${analysis} +
    `; +} + +/** + * Builds the HTML
    fragment for a quiz scene. + * Scoring is reported via the global onQuizSubmitted(sceneIdx, score) callback + * defined in course-builder.ts. + */ +export function buildQuizSection(scene: Scene, sceneIndex: number): QuizSectionResult { + const content = scene.content as QuizContent; + const sceneId = `scene-${sceneIndex}`; + + const exportable = content.questions.filter( + (q) => (q.type === 'single' || q.type === 'multiple') && q.options?.length && q.answer?.length, + ); + + const questionsHtml = exportable.map((q, i) => renderQuestion(q, i, sceneIndex)).join('\n'); + + // Serialize question data for inline JS + const questionsJson = JSON.stringify( + exportable.map((q) => ({ id: q.id, type: q.type, answer: q.answer ?? [] })), + ); + + const noQuestions = exportable.length === 0; + + const html = ``; + + return { + html, + meta: { type: 'quiz', sceneId, questionCount: exportable.length }, + }; +} diff --git a/lib/export/scorm/scorm-bridge.ts b/lib/export/scorm/scorm-bridge.ts new file mode 100644 index 000000000..5ec7499e5 --- /dev/null +++ b/lib/export/scorm/scorm-bridge.ts @@ -0,0 +1,130 @@ +/** + * Returns the scorm_bridge.js source string — a self-contained SCORM 1.2 API + * wrapper embedded verbatim into every SCORM package. + */ +export function getScormBridgeJs(): string { + return ` +(function (root) { + 'use strict'; + + var API = null; + + function findAPI(win) { + var attempts = 0; + while (win.API == null && win.parent != null && win.parent !== win) { + attempts++; + if (attempts > 7) return null; + win = win.parent; + } + return win.API || null; + } + + function getAPI() { + if (API) return API; + API = findAPI(window); + if (!API && window.opener) API = findAPI(window.opener); + return API; + } + + root.SCORM = { + initialized: false, + + init: function () { + var api = getAPI(); + if (!api) { + // No LMS present — running standalone, silently continue + this.initialized = true; + return true; + } + var result = api.LMSInitialize(''); + this.initialized = (result === 'true' || result === true); + return this.initialized; + }, + + finish: function () { + var api = getAPI(); + if (!api || !this.initialized) return; + api.LMSFinish(''); + this.initialized = false; + }, + + getValue: function (key) { + var api = getAPI(); + if (!api || !this.initialized) return ''; + return api.LMSGetValue(key) || ''; + }, + + setValue: function (key, value) { + var api = getAPI(); + if (!api || !this.initialized) return; + api.LMSSetValue(key, String(value)); + }, + + commit: function () { + var api = getAPI(); + if (!api || !this.initialized) return; + api.LMSCommit(''); + }, + + // ── High-level helpers ── + + setCompleted: function () { + this.setValue('cmi.core.lesson_status', 'completed'); + this.commit(); + }, + + setPassed: function () { + this.setValue('cmi.core.lesson_status', 'passed'); + this.commit(); + }, + + setFailed: function () { + this.setValue('cmi.core.lesson_status', 'failed'); + this.commit(); + }, + + setScore: function (raw, min, max) { + this.setValue('cmi.core.score.raw', raw); + this.setValue('cmi.core.score.min', min); + this.setValue('cmi.core.score.max', max); + this.commit(); + }, + + // Log a single interaction (question response) + // index: 0-based interaction index + // type: 'choice' for single/multiple select + // response: student answer string (values joined by '[,]' for multiple) + // correct: correct answer pattern string + // result: 'correct' | 'wrong' + logInteraction: function (index, id, type, response, correct, result) { + var pre = 'cmi.interactions.' + index + '.'; + this.setValue(pre + 'id', id); + this.setValue(pre + 'type', type); + this.setValue(pre + 'student_response', response); + this.setValue(pre + 'correct_responses.0.pattern', correct); + this.setValue(pre + 'result', result); + }, + + // ── Navigation ── + // sceneHrefs: JSON array of all SCO href strings (relative to ZIP root) + // currentHref: this SCO's href (e.g. 'scos/scene_01.html') + // direction: 'next' | 'prev' + navigate: function (sceneHrefs, currentHref, direction) { + var idx = sceneHrefs.indexOf(currentHref); + var next = direction === 'next' ? idx + 1 : idx - 1; + if (next < 0 || next >= sceneHrefs.length) return; + this.finish(); + // Try parent frame first (LMS frameset), fall back to self + if (window.parent && window.parent !== window) { + try { + window.parent.location.href = sceneHrefs[next]; + return; + } catch (e) { /* cross-origin, fall through */ } + } + window.location.href = sceneHrefs[next]; + } + }; + +})(window); +`.trim(); +} diff --git a/lib/export/scorm/slide-sco.ts b/lib/export/scorm/slide-sco.ts new file mode 100644 index 000000000..6425b316a --- /dev/null +++ b/lib/export/scorm/slide-sco.ts @@ -0,0 +1,383 @@ +/** + * slide-sco.ts + * Builds the HTML fragment (a
    element) for a slide scene. + * Assets use paths relative to the ZIP root (no "../"). + */ +import type { Scene, SlideContent } from '@/lib/types/stage'; +import type { + PPTElement, + PPTTextElement, + PPTImageElement, + PPTShapeElement, + PPTLineElement, + PPTChartElement, + PPTTableElement, + PPTLatexElement, + PPTVideoElement, + PPTAudioElement, + SlideBackground, + Gradient, +} from '@/lib/types/slides'; +import type { SpeechAction } from '@/lib/types/action'; +import { isMediaPlaceholder, useMediaGenerationStore } from '@/lib/store/media-generation'; +import type { AssetMap } from './asset-collector'; + +export interface SlideSceneMeta { + type: 'slide'; + sceneId: string; // e.g. "scene-0" + canvasId: string; // e.g. "canvas-0" + scalerId: string; // e.g. "scaler-0" + cw: number; // canvas width px + ch: number; // canvas height px + narrIds: string[]; // IDs of