From bc5d05e90a5e838a4bc3f99336b4a7845ca087d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miralles=20Sol=C3=A9?= <46744682B@gcb.intranet.gencat.cat> Date: Sat, 4 Apr 2026 19:18:21 +0200 Subject: [PATCH 01/12] feat(export): add SCORM 1.2 export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new export option that generates a SCORM 1.2-compliant ZIP package uploadable to any LMS (Moodle, Blackboard, SCORM Cloud, etc.). What is exported: - Slide scenes → HTML SCO with absolutely-positioned CSS canvas, responsive scaling via transform:scale(), and auto-playing TTS narration audio - Quiz scenes (single/multiple choice only) → HTML SCO with SCORM scoring (cmi.core.score.*, cmi.interactions.*), visual feedback, and pass/fail tracking - Interactive scenes with embedded HTML → HTML SCO wrapping content in an isolated + + + + +`; +} diff --git a/lib/export/scorm/manifest.ts b/lib/export/scorm/manifest.ts new file mode 100644 index 000000000..2b330db33 --- /dev/null +++ b/lib/export/scorm/manifest.ts @@ -0,0 +1,72 @@ +export interface ScoEntry { + id: string; // e.g. "scene_01" + title: string; + href: string; // e.g. "scos/scene_01.html" + isQuiz: boolean; + assetHrefs: string[]; // all asset paths referenced by this SCO +} + +const PASS_SCORE = 80; + +/** + * Builds the imsmanifest.xml string for a SCORM 1.2 package. + */ +export function buildManifest( + courseId: string, + courseTitle: string, + scos: ScoEntry[], +): string { + const escXml = (s: string) => + s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + + const orgId = `${courseId}_org`; + + const items = scos + .map( + (sco) => ` + + ${escXml(sco.title)}${sco.isQuiz ? `\n ${PASS_SCORE}` : ''} + `, + ) + .join(''); + + const resources = scos + .map((sco) => { + const files = [``, ``]; + for (const asset of sco.assetHrefs) { + files.push(``); + } + return ` + + ${files.join('\n ')} + `; + }) + .join(''); + + return ` + + + + ADL SCORM + 1.2 + + + + + ${escXml(courseTitle)}${items} + + + + ${resources} + + +`; +} diff --git a/lib/export/scorm/quiz-sco.ts b/lib/export/scorm/quiz-sco.ts new file mode 100644 index 000000000..bf6d97ba3 --- /dev/null +++ b/lib/export/scorm/quiz-sco.ts @@ -0,0 +1,235 @@ +import type { Scene, QuizContent, QuizQuestion } from '@/lib/types/stage'; + +export interface QuizScoOptions { + scene: Scene; + sceneIndex: number; + totalScenes: number; + allScoHrefs: string[]; +} + +const PASS_THRESHOLD = 80; + +function escHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function renderQuestion(q: QuizQuestion, idx: number): string { + // short_answer questions are excluded from SCORM export + if (q.type === 'short_answer' || !q.options?.length) return ''; + + const inputType = q.type === 'multiple' ? 'checkbox' : 'radio'; + const typeLabel = q.type === 'multiple' ? ' (select all that apply)' : ''; + + const options = q.options + .map( + (opt) => ` + `, + ) + .join(''); + + const analysis = q.analysis + ? `
${escHtml(q.analysis)}
` + : ''; + + return ` +
+
+ ${idx + 1}. ${escHtml(q.question)}${typeLabel} +
+
+ ${options} +
+ ${analysis} +
`; +} + +/** + * Builds the full HTML string for a quiz SCO page. + * + * Only single and multiple choice questions are exported. + * Short-answer questions are skipped. + * SCORM scoring: score.raw = (correct / total) * 100, pass threshold = 80. + */ +export function buildQuizSco(opts: QuizScoOptions): string { + const { scene, sceneIndex, totalScenes, allScoHrefs } = opts; + + if (scene.content.type !== 'quiz') return ''; + const content = scene.content as QuizContent; + + // Only exportable questions: single or multiple with options and answers + 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)).join(''); + + const myHref = allScoHrefs[sceneIndex]; + const hasPrev = sceneIndex > 0; + const hasNext = sceneIndex < totalScenes - 1; + + // Serialise question data for the JS runtime + const questionsJson = JSON.stringify( + exportable.map((q) => ({ + id: q.id, + type: q.type, + answer: q.answer ?? [], + })), + ); + + return ` + + + + + ${escHtml(scene.title)} + + + + +
+

${escHtml(scene.title)}

+ ${questionsHtml.trim() || '

No gradable questions in this section.

'} +
+ +
+ + + + + +`; +} 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..653d03a4a --- /dev/null +++ b/lib/export/scorm/slide-sco.ts @@ -0,0 +1,452 @@ +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 SlideScoOptions { + scene: Scene; + sceneIndex: number; + totalScenes: number; + allScoHrefs: string[]; + assetMap: AssetMap; + includeVideos: boolean; +} + +// ── Helpers ── + +function escHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function resolvedSrc(src: string, assetMap: AssetMap): string { + // If the src was collected as an asset, return the relative ZIP path + const stored = assetMap.get(src); + if (stored) return `../${stored}`; + // Media placeholder that was resolved + if (isMediaPlaceholder(src)) { + const task = useMediaGenerationStore.getState().tasks[src]; + if (task?.objectUrl) { + const stored2 = assetMap.get(task.objectUrl); + if (stored2) return `../${stored2}`; + } + return ''; // not available + } + // Absolute URL or data URI — use directly + return src; +} + +function buildBackgroundCss(bg: SlideBackground | undefined, assetMap: AssetMap): string { + if (!bg) return ''; + if (bg.type === 'solid' && bg.color) return `background-color: ${bg.color};`; + if (bg.type === 'image' && bg.image?.src) { + const src = resolvedSrc(bg.image.src, assetMap); + if (!src) return ''; + const size = bg.image.size === 'repeat' ? 'auto' : bg.image.size; + const repeat = bg.image.size === 'repeat' ? 'repeat' : 'no-repeat'; + return `background-image: url('${src}'); background-size: ${size}; background-repeat: ${repeat}; background-position: center;`; + } + if (bg.type === 'gradient' && bg.gradient) { + return `background: ${buildGradientCss(bg.gradient)};`; + } + return ''; +} + +function buildGradientCss(g: Gradient): string { + const stops = g.colors.map((c) => `${c.color} ${c.pos}%`).join(', '); + if (g.type === 'radial') return `radial-gradient(circle, ${stops})`; + return `linear-gradient(${g.rotate}deg, ${stops})`; +} + +function elStyle(el: PPTElement & { rotate: number }): string { + return `position:absolute;left:${el.left}px;top:${el.top}px;width:${el.width}px;height:${el.height}px;${el.rotate ? `transform:rotate(${el.rotate}deg);` : ''}`; +} + +// ── Element renderers ── + +function renderText(el: PPTTextElement): string { + const fill = el.fill ? `background:${el.fill};` : ''; + const opacity = el.opacity !== undefined ? `opacity:${el.opacity};` : ''; + const lineH = el.lineHeight ?? 1.5; + const wordSp = el.wordSpace ?? 0; + const overflow = el.vertical ? 'writing-mode:vertical-rl;' : ''; + return `
${el.content}
`; +} + +function renderImage(el: PPTImageElement, assetMap: AssetMap): string { + const src = resolvedSrc(el.src, assetMap); + if (!src) return ''; + + const filters: string[] = []; + if (el.filters) { + if (el.filters.blur) filters.push(`blur(${el.filters.blur})`); + if (el.filters.brightness) filters.push(`brightness(${el.filters.brightness})`); + if (el.filters.contrast) filters.push(`contrast(${el.filters.contrast})`); + if (el.filters.grayscale) filters.push(`grayscale(${el.filters.grayscale})`); + if (el.filters.saturate) filters.push(`saturate(${el.filters.saturate})`); + if (el.filters['hue-rotate']) filters.push(`hue-rotate(${el.filters['hue-rotate']})`); + if (el.filters.sepia) filters.push(`sepia(${el.filters.sepia})`); + if (el.filters.invert) filters.push(`invert(${el.filters.invert})`); + if (el.filters.opacity) filters.push(`opacity(${el.filters.opacity})`); + } + const filterCss = filters.length ? `filter:${filters.join(' ')};` : ''; + + let clipCss = ''; + let borderRadius = ''; + if (el.clip) { + const [[x1, y1], [x2, y2]] = el.clip.range; + clipCss = `clip-path:inset(${y1}% ${100 - x2}% ${100 - y2}% ${x1}%);`; + if (el.clip.shape === 'ellipse') borderRadius = 'border-radius:50%;'; + } + + const flipH = el.flipH ? 'scaleX(-1)' : ''; + const flipV = el.flipV ? 'scaleY(-1)' : ''; + const flipCss = flipH || flipV ? `transform-origin:center;${el.rotate ? `transform:rotate(${el.rotate}deg) ${flipH} ${flipV};` : `transform:${flipH} ${flipV};`}` : ''; + + const colorMask = el.colorMask + ? `
` + : ''; + + const base = el.rotate ? `transform:rotate(${el.rotate}deg);` : ''; + const style = `position:absolute;left:${el.left}px;top:${el.top}px;width:${el.width}px;height:${el.height}px;${flipCss || base}overflow:hidden;${borderRadius}`; + + return `
${colorMask}
`; +} + +function renderShape(el: PPTShapeElement): string { + const [vw, vh] = el.viewBox; + const fill = el.gradient ? buildGradientCss(el.gradient) : el.fill; + const opacity = el.opacity !== undefined ? `opacity:${el.opacity};` : ''; + const flipH = el.flipH ? 'scale(-1,1)' : ''; + const flipV = el.flipV ? 'scale(1,-1)' : ''; + const transform = (flipH || flipV) ? ` transform="${flipH || flipV}"` : ''; + const outline = el.outline?.color && el.outline.width + ? `stroke="${el.outline.color}" stroke-width="${el.outline.width}" stroke-dasharray="${el.outline.style === 'dashed' ? '8 4' : el.outline.style === 'dotted' ? '2 4' : 'none'}"` + : 'stroke="none"'; + + let textOverlay = ''; + if (el.text?.content) { + const t = el.text; + textOverlay = `
${t.content}
`; + } + + return `
+ + + ${textOverlay} +
`; +} + +function renderLine(el: PPTLineElement): string { + const [sx, sy] = el.start; + const [ex, ey] = el.end; + const minX = Math.min(sx, ex); + const minY = Math.min(sy, ey); + const maxX = Math.max(sx, ex); + const maxY = Math.max(sy, ey); + const w = maxX - minX || 2; + const h = maxY - minY || 2; + const dash = el.style === 'dashed' ? 'stroke-dasharray="8 4"' : el.style === 'dotted' ? 'stroke-dasharray="2 4"' : ''; + return `
+ + + +
`; +} + +function renderChart(el: PPTChartElement): string { + // NOTE: ECharts cannot render without a DOM/canvas at build time. + // We emit a data table as a SCORM-compatible fallback. + // Future enhancement: pre-render to SVG using headless ECharts. + const { labels, legends, series } = el.data; + const fill = el.fill ? `background:${el.fill};` : ''; + + let rows = `${labels.map((l) => `${escHtml(l)}`).join('')}`; + for (let i = 0; i < legends.length; i++) { + rows += `${escHtml(legends[i])}${(series[i] ?? []).map((v) => `${v}`).join('')}`; + } + + return `
+ + ${rows} +
+
`; +} + +function renderTable(el: PPTTableElement): string { + let colGroup = ''; + for (const w of el.colWidths) { + colGroup += ``; + } + colGroup += ''; + + let tbody = ''; + for (let ri = 0; ri < el.data.length; ri++) { + tbody += ''; + for (const cell of el.data[ri]) { + if (!cell) continue; + const s = cell.style ?? {}; + const css = [ + s.bold ? 'font-weight:bold' : '', + s.em ? 'font-style:italic' : '', + s.underline ? 'text-decoration:underline' : '', + s.strikethrough ? 'text-decoration:line-through' : '', + s.color ? `color:${s.color}` : '', + s.backcolor ? `background:${s.backcolor}` : '', + s.fontsize ? `font-size:${s.fontsize}` : '', + s.align ? `text-align:${s.align}` : '', + ] + .filter(Boolean) + .join(';'); + const cs = cell.colspan > 1 ? ` colspan="${cell.colspan}"` : ''; + const rs = cell.rowspan > 1 ? ` rowspan="${cell.rowspan}"` : ''; + tbody += `${cell.text}`; + } + tbody += ''; + } + tbody += ''; + + return `
+ ${colGroup}${tbody}
+
`; +} + +function renderLatex(el: PPTLatexElement): string { + if (el.html) { + return `
${el.html}
`; + } + // Legacy SVG path fallback + if (el.path && el.viewBox) { + const [vw, vh] = el.viewBox; + return `
+ + + +
`; + } + return ''; +} + +function renderVideo(el: PPTVideoElement, assetMap: AssetMap, includeVideos: boolean): string { + if (includeVideos) { + const src = resolvedSrc(el.src, assetMap); + if (!src) return ''; + const posterSrc = el.poster ? resolvedSrc(el.poster, assetMap) : ''; + const posterAttr = posterSrc ? ` poster="${posterSrc}"` : ''; + return `
`; + } else { + // Show poster image instead of video + const posterSrc = + el.poster + ? resolvedSrc(el.poster, assetMap) + : (() => { + // Fallback: check media generation store poster via original src + if (isMediaPlaceholder(el.src)) { + const task = useMediaGenerationStore.getState().tasks[el.src]; + return task?.poster ? resolvedSrc(task.poster, assetMap) : ''; + } + return ''; + })(); + if (!posterSrc) return ''; // no poster available, skip element + return `
`; + } +} + +function renderAudio(el: PPTAudioElement, assetMap: AssetMap): string { + const src = resolvedSrc(el.src, assetMap); + if (!src) return ''; + return `
`; +} + +function renderElement(el: PPTElement, assetMap: AssetMap, includeVideos: boolean): string { + switch (el.type) { + case 'text': return renderText(el as PPTTextElement); + case 'image': return renderImage(el as PPTImageElement, assetMap); + case 'shape': return renderShape(el as PPTShapeElement); + case 'line': return renderLine(el as PPTLineElement); + case 'chart': return renderChart(el as PPTChartElement); + case 'table': return renderTable(el as PPTTableElement); + case 'latex': return renderLatex(el as PPTLatexElement); + case 'video': return renderVideo(el as PPTVideoElement, assetMap, includeVideos); + case 'audio': return renderAudio(el as PPTAudioElement, assetMap); + default: return ''; + } +} + +// ── Check if any latex element is present (need KaTeX CSS) ── + +function hasLatexWithHtml(els: PPTElement[]): boolean { + return els.some((el) => el.type === 'latex' && (el as PPTLatexElement).html); +} + +// ── Build narration audio tags and chain script ── + +function buildNarrationHtml(scene: Scene, assetMap: AssetMap): { tags: string; script: string } { + if (!scene.actions) return { tags: '', script: '' }; + + const speechUrls: string[] = []; + for (const action of scene.actions) { + if (action.type === 'speech') { + const speech = action as SpeechAction; + if (speech.audioUrl) { + const stored = assetMap.get(speech.audioUrl); + if (stored) speechUrls.push(`../${stored}`); + } + } + } + + if (speechUrls.length === 0) return { tags: '', script: '' }; + + const tags = speechUrls + .map((url, i) => ``) + .join('\n'); + + const script = ` +var _narrIdx = 0; +var _narrUrls = ${JSON.stringify(speechUrls)}; +function _playNarr(i) { + if (i >= _narrUrls.length) return; + var a = document.getElementById('narr_' + i); + if (!a) return; + a.onended = function() { _playNarr(i + 1); }; + a.play().catch(function(){}); +} +// Auto-play narration when page loads (requires user interaction policy may block) +document.addEventListener('DOMContentLoaded', function() { _playNarr(0); }); +`; + + return { tags, script }; +} + +// ── Main ── + +/** + * Builds the full HTML string for a slide SCO page. + * + * The slide canvas is rendered as absolutely-positioned elements inside a + * 960×(960*viewportRatio) container that scales responsively via CSS transform. + * TTS narration audio plays automatically on load. + * SCORM completion is set immediately on page load (slides are self-completing). + */ +export function buildSlideSco(opts: SlideScoOptions): string { + const { scene, sceneIndex, totalScenes, allScoHrefs, assetMap, includeVideos } = opts; + + if (scene.content.type !== 'slide') return ''; + const canvas = (scene.content as SlideContent).canvas; + + const canvasW = canvas.viewportSize ?? 960; + const canvasH = Math.round(canvasW * (canvas.viewportRatio ?? 0.5625)); + const bgCss = buildBackgroundCss(canvas.background, assetMap); + const bgFallback = canvas.theme?.backgroundColor ?? '#ffffff'; + + const elements = canvas.elements + .map((el) => renderElement(el, assetMap, includeVideos)) + .filter(Boolean) + .join('\n'); + + const { tags: narrTags, script: narrScript } = buildNarrationHtml(scene, assetMap); + + const needsKatex = hasLatexWithHtml(canvas.elements); + // Minimal KaTeX CSS — inlined to ensure offline use (no CDN dependency) + const katexCss = needsKatex + ? `` + : ''; + + const myHref = allScoHrefs[sceneIndex]; + const hasPrev = sceneIndex > 0; + const hasNext = sceneIndex < totalScenes - 1; + + const navButtons = ` +`; + + return ` + + + + + ${escHtml(scene.title)} + + ${katexCss} + + + +
+
+ ${elements} +
+ ${navButtons} +
+${narrTags} + + +`; +} diff --git a/lib/export/scorm/use-export-scorm.ts b/lib/export/scorm/use-export-scorm.ts new file mode 100644 index 000000000..271875f05 --- /dev/null +++ b/lib/export/scorm/use-export-scorm.ts @@ -0,0 +1,186 @@ +'use client'; + +import { useState, useCallback, useRef } from 'react'; +import { saveAs } from 'file-saver'; +import { toast } from 'sonner'; + +import { useStageStore } from '@/lib/store'; +import { useI18n } from '@/lib/hooks/use-i18n'; +import { createLogger } from '@/lib/logger'; +import type { Scene, InteractiveContent } from '@/lib/types/stage'; + +import { collectAssets, getSceneAssetHrefs } from './asset-collector'; +import { buildManifest, type ScoEntry } from './manifest'; +import { getScormBridgeJs } from './scorm-bridge'; +import { buildSlideSco } from './slide-sco'; +import { buildQuizSco } from './quiz-sco'; +import { buildInteractiveSco } from './interactive-sco'; + +const log = createLogger('ExportSCORM'); + +export interface ScormExportOptions { + includeVideos: boolean; +} + +function sanitizeId(name: string): string { + return name.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64) || 'course'; +} + +function pad2(n: number): string { + return String(n + 1).padStart(2, '0'); +} + +function isExportableScene(scene: Scene): boolean { + if (scene.content.type === 'pbl') return false; + if (scene.content.type === 'interactive') { + return Boolean((scene.content as InteractiveContent).html); + } + return true; +} + +/** + * React hook for SCORM 1.2 export. + * + * Mirrors the useExportPPTX pattern: guard against concurrent exports, + * show toast on success/failure, lazy-import JSZip for code splitting. + * + * What gets exported: + * - slide scenes → HTML SCO with absolutepositioned canvas + TTS narration + * - quiz scenes → HTML SCO with single/multiple choice questions + SCORM scoring + * - interactive scenes (with html) → HTML SCO wrapping content in an iframe + * + * Excluded: pbl scenes, interactive scenes without html, + * whiteboards, multi-agent chat, short-answer questions. + */ +export function useExportScorm(): { + exporting: boolean; + exportScorm: (options: ScormExportOptions) => void; +} { + const [exporting, setExporting] = useState(false); + const exportingRef = useRef(false); + const { t } = useI18n(); + + const scenes = useStageStore((s) => s.scenes); + const stage = useStageStore((s) => s.stage); + + const withExportGuard = useCallback( + (action: () => Promise) => { + if (exportingRef.current) return; + exportingRef.current = true; + setExporting(true); + setTimeout(async () => { + try { + await action(); + } catch (err) { + log.error('SCORM export failed:', err); + toast.error(t('export.exportFailed')); + } finally { + exportingRef.current = false; + setExporting(false); + } + }, 100); + }, + [t], + ); + + const exportScorm = useCallback( + (options: ScormExportOptions) => { + withExportGuard(async () => { + const JSZip = (await import('jszip')).default; + const zip = new JSZip(); + + const fileName = stage?.name || 'course'; + const courseId = sanitizeId(fileName); + + // 1. Filter to exportable scenes only + const exportableScenes = scenes.filter(isExportableScene); + + if (exportableScenes.length === 0) { + toast.error(t('export.exportFailed')); + return; + } + + // 2. Build SCO href list (used in every SCO for navigation) + const allScoHrefs = exportableScenes.map( + (_, i) => `scos/scene_${pad2(i)}.html`, + ); + + // 3. Collect all media assets (fetch blobs, resolve placeholders) + const assetMap = await collectAssets(exportableScenes, options.includeVideos); + + // 4. Write asset blobs into ZIP + for (const entry of assetMap.entries()) { + zip.file(entry.zipPath, entry.blob); + } + + // 5. Build SCO HTML pages + collect ScoEntry metadata for manifest + const scoEntries: ScoEntry[] = []; + + for (let i = 0; i < exportableScenes.length; i++) { + const scene = exportableScenes[i]; + const href = allScoHrefs[i]; + const sceneId = `scene_${pad2(i)}`; + let html = ''; + + if (scene.content.type === 'slide') { + html = buildSlideSco({ + scene, + sceneIndex: i, + totalScenes: exportableScenes.length, + allScoHrefs, + assetMap, + includeVideos: options.includeVideos, + }); + } else if (scene.content.type === 'quiz') { + html = buildQuizSco({ + scene, + sceneIndex: i, + totalScenes: exportableScenes.length, + allScoHrefs, + }); + } else if (scene.content.type === 'interactive') { + html = buildInteractiveSco({ + scene, + sceneIndex: i, + totalScenes: exportableScenes.length, + allScoHrefs, + }); + } + + if (!html) continue; + + zip.file(href, html); + + const assetHrefs = getSceneAssetHrefs(scene, i, assetMap, options.includeVideos); + + scoEntries.push({ + id: sceneId, + title: scene.title, + href, + isQuiz: scene.content.type === 'quiz', + assetHrefs, + }); + } + + // 6. Write scorm_bridge.js + zip.file('scorm_bridge.js', getScormBridgeJs()); + + // 7. Write imsmanifest.xml + const manifest = buildManifest(courseId, stage?.name ?? fileName, scoEntries); + zip.file('imsmanifest.xml', manifest); + + // 8. Generate ZIP and trigger download + const zipBlob = await zip.generateAsync({ + type: 'blob', + compression: 'DEFLATE', + compressionOptions: { level: 6 }, + }); + saveAs(zipBlob, `${fileName}_scorm.zip`); + toast.success(t('export.exportSuccess')); + }); + }, + [withExportGuard, scenes, stage, t], + ); + + return { exporting, exportScorm }; +} diff --git a/lib/i18n/common.ts b/lib/i18n/common.ts index 1bceb5d61..731e3ccde 100644 --- a/lib/i18n/common.ts +++ b/lib/i18n/common.ts @@ -36,6 +36,13 @@ export const commonZhCN = { exporting: '正在导出...', exportSuccess: '导出成功', exportFailed: '导出失败', + scorm: '导出 SCORM 1.2', + scormDesc: '用于 LMS 的标准化课程包', + scormDialogTitle: '导出 SCORM 1.2', + scormDialogDesc: '请选择视频处理方式', + scormIncludeVideos: '包含视频(完整包,文件较大)', + scormReplacePoster: '用封面图替换视频(文件更小)', + scormExport: '导出', }, } as const; @@ -77,5 +84,12 @@ export const commonEnUS = { exporting: 'Exporting...', exportSuccess: 'Export successful', exportFailed: 'Export failed', + scorm: 'Export SCORM 1.2', + scormDesc: 'Standardized course package for LMS', + scormDialogTitle: 'Export SCORM 1.2', + scormDialogDesc: 'Choose how to handle video content', + scormIncludeVideos: 'Include videos (complete package, may be large)', + scormReplacePoster: 'Replace videos with poster images (smaller download)', + scormExport: 'Export', }, } as const; From 51d2af5a0d61e1da07b251a598ecdbdcf7ccee84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miralles=20Sol=C3=A9?= <46744682B@gcb.intranet.gencat.cat> Date: Sat, 4 Apr 2026 19:45:40 +0200 Subject: [PATCH 02/12] fix(scorm): redesign to single-SCO architecture to fix LMS compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - All scenes now rendered as
elements in a single index.html instead of separate scos/scene_XX.html files per scene (fixes broken navigation in LMS iframes that blocked window.parent.location.href) - Slide scaling uses .slide-scaler wrapper sized to visual dimensions so flex centering works correctly (fixes deformed slide layout) - TTS audio URLs are fetched as blobs and included in ZIP under assets/ - SCORM completion only fires on last scene (not on every scene load) - Nav bar with Prev/Next + narration Play/Pause/Restart controls embedded in single page; Next button gated on quiz submission - course-builder.ts assembles all section fragments into index.html - manifest.ts rewritten for single-SCO (one item → index.html) - data-title attributes added to all section types for nav bar display Co-Authored-By: Claude Sonnet 4.6 --- lib/export/scorm/course-builder.ts | 452 +++++++++++++++++++++++++++ lib/export/scorm/interactive-sco.ts | 116 ++----- lib/export/scorm/manifest.ts | 71 ++--- lib/export/scorm/quiz-sco.ts | 293 +++++++---------- lib/export/scorm/slide-sco.ts | 407 ++++++++++-------------- lib/export/scorm/use-export-scorm.ts | 104 +++--- 6 files changed, 829 insertions(+), 614 deletions(-) create mode 100644 lib/export/scorm/course-builder.ts diff --git a/lib/export/scorm/course-builder.ts b/lib/export/scorm/course-builder.ts new file mode 100644 index 000000000..e344bb396 --- /dev/null +++ b/lib/export/scorm/course-builder.ts @@ -0,0 +1,452 @@ +/** + * course-builder.ts + * Assembles all scene fragments into a single SCORM 1.2 SCO HTML file (index.html). + * + * Architecture: single SCO with internal JS navigation. + * - All scenes are
elements; only the current one is visible. + * - Navigation (Prev/Next) is handled entirely in JS. + * - Slide canvases scale responsively via transform:scale() with a wrapper + * that tracks the visual (scaled) dimensions so flex centering works correctly. + * - Narration audio plays automatically on slide load; user can pause/resume/restart. + * - SCORM completion is set only when the user reaches the last scene. + * - Quiz sections call window.onQuizSubmitted(sceneIdx, score) on submit. + */ +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; + sections: Array<{ html: string; meta: SceneMeta }>; + needsKatex: boolean; +} + +const NAV_HEIGHT = 52; // px — fixed bottom nav bar height + +/** Serialise scene metadata for the JS runtime (avoids any TS types in output) */ +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 + ? `` + : ''; + + return ` + + + + + ${courseName.replace(/</g, '<').replace(/>/g, '>')} + + ${katexLink} + + + + + +${sceneSections} + + + + + + +`; +} diff --git a/lib/export/scorm/interactive-sco.ts b/lib/export/scorm/interactive-sco.ts index 423d71f43..dc0eede83 100644 --- a/lib/export/scorm/interactive-sco.ts +++ b/lib/export/scorm/interactive-sco.ts @@ -1,94 +1,44 @@ +/** + * interactive-sco.ts + * Builds the HTML fragment (a
element) for an interactive scene. + * The interactive HTML is embedded in an isolated - - - - -`; + const sceneId = `scene-${sceneIndex}`; + + // Escape HTML for srcdoc attribute (must escape " and &) + const srcdoc = (content.html ?? '').replace(/&/g, '&').replace(/"/g, '"'); + + const html = ``; + + return { + html, + meta: { type: 'interactive', sceneId }, + }; } diff --git a/lib/export/scorm/manifest.ts b/lib/export/scorm/manifest.ts index 2b330db33..8245f83c5 100644 --- a/lib/export/scorm/manifest.ts +++ b/lib/export/scorm/manifest.ts @@ -1,50 +1,34 @@ -export interface ScoEntry { - id: string; // e.g. "scene_01" - title: string; - href: string; // e.g. "scos/scene_01.html" - isQuiz: boolean; - assetHrefs: string[]; // all asset paths referenced by this SCO +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; } -const PASS_SCORE = 80; - /** - * Builds the imsmanifest.xml string for a SCORM 1.2 package. + * 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( - courseId: string, - courseTitle: string, - scos: ScoEntry[], -): string { +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 items = scos - .map( - (sco) => ` - - ${escXml(sco.title)}${sco.isQuiz ? `\n ${PASS_SCORE}` : ''} - `, - ) - .join(''); + const masteryLine = + masteryScore !== undefined + ? `\n ${masteryScore}` + : ''; - const resources = scos - .map((sco) => { - const files = [``, ``]; - for (const asset of sco.assetHrefs) { - files.push(``); - } - return ` - - ${files.join('\n ')} - `; - }) - .join(''); + const fileEntries = [ + ``, + ``, + ...assetHrefs.map((h) => ``), + ].join('\n '); return ` - ${escXml(courseTitle)}${items} + ${escXml(courseTitle)} + + ${escXml(courseTitle)}${masteryLine} + - ${resources} + + + ${fileEntries} + `; diff --git a/lib/export/scorm/quiz-sco.ts b/lib/export/scorm/quiz-sco.ts index bf6d97ba3..f93a69b62 100644 --- a/lib/export/scorm/quiz-sco.ts +++ b/lib/export/scorm/quiz-sco.ts @@ -1,13 +1,22 @@ +/** + * quiz-sco.ts + * Builds the HTML fragment (a
element) for a quiz scene. + * Only single/multiple choice questions are exported (short_answer excluded). + */ import type { Scene, QuizContent, QuizQuestion } from '@/lib/types/stage'; -export interface QuizScoOptions { - scene: Scene; - sceneIndex: number; - totalScenes: number; - allScoHrefs: string[]; +export const QUIZ_PASS_THRESHOLD = 80; + +export interface QuizSceneMeta { + type: 'quiz'; + sceneId: string; + questionCount: number; // exportable questions count } -const PASS_THRESHOLD = 80; +export interface QuizSectionResult { + html: string; + meta: QuizSceneMeta; +} function escHtml(s: string): string { return s @@ -18,11 +27,9 @@ function escHtml(s: string): string { } function renderQuestion(q: QuizQuestion, idx: number): string { - // short_answer questions are excluded from SCORM export if (q.type === 'short_answer' || !q.options?.length) return ''; - const inputType = q.type === 'multiple' ? 'checkbox' : 'radio'; - const typeLabel = q.type === 'multiple' ? ' (select all that apply)' : ''; + const multiHint = q.type === 'multiple' ? ' (select all that apply)' : ''; const options = q.options .map( @@ -39,197 +46,117 @@ function renderQuestion(q: QuizQuestion, idx: number): string { ? `
${escHtml(q.analysis)}
` : ''; - return ` -
-
- ${idx + 1}. ${escHtml(q.question)}${typeLabel} -
-
- ${options} -
+
${idx + 1}. ${escHtml(q.question)}${multiHint}
+
${options}
${analysis}
`; } /** - * Builds the full HTML string for a quiz SCO page. - * - * Only single and multiple choice questions are exported. - * Short-answer questions are skipped. - * SCORM scoring: score.raw = (correct / total) * 100, pass threshold = 80. + * 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 buildQuizSco(opts: QuizScoOptions): string { - const { scene, sceneIndex, totalScenes, allScoHrefs } = opts; - - if (scene.content.type !== 'quiz') return ''; +export function buildQuizSection(scene: Scene, sceneIndex: number): QuizSectionResult { const content = scene.content as QuizContent; + const sceneId = `scene-${sceneIndex}`; - // Only exportable questions: single or multiple with options and answers 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)).join(''); + const questionsHtml = exportable.map((q, i) => renderQuestion(q, i)).join('\n'); - const myHref = allScoHrefs[sceneIndex]; - const hasPrev = sceneIndex > 0; - const hasNext = sceneIndex < totalScenes - 1; - - // Serialise question data for the JS runtime + // Serialize question data for inline JS const questionsJson = JSON.stringify( - exportable.map((q) => ({ - id: q.id, - type: q.type, - answer: q.answer ?? [], - })), + exportable.map((q) => ({ id: q.id, type: q.type, answer: q.answer ?? [] })), ); - return ` - - - - - ${escHtml(scene.title)} - - - - -
-

${escHtml(scene.title)}

- ${questionsHtml.trim() || '

No gradable questions in this section.

'} -
- -
- - - - - -`; + })(); + +
`; + + return { + html, + meta: { type: 'quiz', sceneId, questionCount: exportable.length }, + }; } diff --git a/lib/export/scorm/slide-sco.ts b/lib/export/scorm/slide-sco.ts index 653d03a4a..54c2e3eee 100644 --- a/lib/export/scorm/slide-sco.ts +++ b/lib/export/scorm/slide-sco.ts @@ -1,3 +1,8 @@ +/** + * 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, @@ -17,13 +22,21 @@ import type { SpeechAction } from '@/lib/types/action'; import { isMediaPlaceholder, useMediaGenerationStore } from '@/lib/store/media-generation'; import type { AssetMap } from './asset-collector'; -export interface SlideScoOptions { - scene: Scene; - sceneIndex: number; - totalScenes: number; - allScoHrefs: string[]; - assetMap: AssetMap; - includeVideos: boolean; +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