coursecode 0.1.57 → 0.1.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -1
- package/bin/cli.js +16 -0
- package/framework/css/components/accordions.css +117 -31
- package/framework/css/layouts/article.css +7 -0
- package/framework/docs/COURSE_AUTHORING_GUIDE.md +4 -2
- package/framework/docs/FRAMEWORK_GUIDE.md +25 -7
- package/framework/docs/USER_GUIDE.md +14 -0
- package/framework/js/app/AppActions.js +17 -23
- package/framework/js/app/AppUI.js +7 -4
- package/framework/js/assessment/AssessmentActions.js +16 -15
- package/framework/js/assessment/AssessmentFactory.js +44 -15
- package/framework/js/automation/api-engagement.js +6 -3
- package/framework/js/components/interactions/drag-drop.js +4 -4
- package/framework/js/components/interactions/fill-in.js +7 -4
- package/framework/js/components/interactions/hotspot.js +2 -2
- package/framework/js/components/interactions/matching.js +4 -3
- package/framework/js/components/ui-components/audio-player.js +30 -8
- package/framework/js/components/ui-components/collapse.js +4 -2
- package/framework/js/components/ui-components/dropdown.js +7 -3
- package/framework/js/components/ui-components/embed-frame.js +5 -1
- package/framework/js/components/ui-components/flip-card.js +12 -6
- package/framework/js/components/ui-components/interactive-image.js +28 -52
- package/framework/js/components/ui-components/lightbox.js +19 -5
- package/framework/js/components/ui-components/modal.js +20 -7
- package/framework/js/components/ui-components/notifications.js +8 -3
- package/framework/js/components/ui-components/video-player.js +46 -20
- package/framework/js/core/event-bus.js +31 -28
- package/framework/js/dev/runtime-linter.js +6 -2
- package/framework/js/drivers/cmi5-driver.js +186 -83
- package/framework/js/drivers/driver-factory.js +7 -1
- package/framework/js/drivers/driver-interface.js +1 -1
- package/framework/js/drivers/http-driver-base.js +6 -0
- package/framework/js/drivers/lti-driver.js +112 -47
- package/framework/js/drivers/proxy-driver.js +347 -49
- package/framework/js/drivers/scorm-12-driver.js +213 -130
- package/framework/js/drivers/scorm-2004-driver.js +32 -19
- package/framework/js/drivers/scorm-driver-base.js +12 -0
- package/framework/js/drivers/standalone-driver.js +139 -0
- package/framework/js/engagement/engagement-trackers.js +38 -13
- package/framework/js/engagement/requirement-strategies.js +5 -1
- package/framework/js/main.js +7 -0
- package/framework/js/managers/assessment-manager.js +16 -1
- package/framework/js/managers/audio-manager.js +50 -16
- package/framework/js/managers/flag-manager.js +5 -1
- package/framework/js/managers/objective-manager.js +15 -7
- package/framework/js/managers/score-manager.js +27 -6
- package/framework/js/managers/video-manager.js +149 -70
- package/framework/js/navigation/NavigationActions.js +18 -4
- package/framework/js/navigation/document-gallery.js +7 -2
- package/framework/js/navigation/navigation-validators.js +3 -2
- package/framework/js/state/lms-connection.js +41 -16
- package/framework/js/state/state-commits.js +10 -5
- package/framework/js/state/state-manager.js +95 -9
- package/framework/js/state/state-validation.js +44 -11
- package/framework/js/utilities/course-helpers.js +3 -2
- package/framework/js/utilities/media-utils.js +28 -0
- package/framework/js/utilities/portable-assets.js +151 -0
- package/framework/js/utilities/ui-initializer.js +79 -5
- package/framework/js/utilities/utilities.js +32 -20
- package/framework/js/utilities/view-manager.js +16 -1
- package/framework/js/validation/scorm-validators.js +69 -0
- package/framework/version.json +2 -2
- package/lib/authoring-api.js +37 -18
- package/lib/build-linter.js +192 -2
- package/lib/build-packaging.js +47 -3
- package/lib/build.js +69 -8
- package/lib/cloud.js +40 -11
- package/lib/convert.js +30 -2
- package/lib/create.js +5 -3
- package/lib/dev.js +1 -1
- package/lib/headless-browser.js +66 -14
- package/lib/manifest/cmi5-manifest.js +26 -4
- package/lib/manifest/lti-tool-config.js +7 -2
- package/lib/manifest/manifest-factory.d.ts +1 -1
- package/lib/manifest/manifest-factory.js +3 -2
- package/lib/manifest/scorm-12-manifest.js +2 -2
- package/lib/manifest/scorm-2004-manifest.js +3 -28
- package/lib/manifest/scorm-proxy-manifest.js +37 -32
- package/lib/manifest/xml-utils.js +10 -0
- package/lib/narration.js +2 -2
- package/lib/portable-html.js +196 -0
- package/lib/preview-export.js +1 -1
- package/lib/preview-server.js +27 -6
- package/lib/project-utils.js +9 -1
- package/lib/proxy-templates/proxy.html +4 -1
- package/lib/proxy-templates/scorm-bridge.js +82 -10
- package/lib/scaffold.js +9 -4
- package/lib/stub-player/lms-api.js +63 -8
- package/package.json +5 -1
- package/schemas/adlcp_rootv1p2.xsd +110 -0
- package/schemas/coursecode_scorm12_package.xsd +5 -0
- package/schemas/coursecode_scorm2004_package.xsd +8 -0
- package/schemas/imscp_rootv1p1p2.xsd +304 -0
- package/template/course/slides/example-ui-showcase.js +1 -1
- package/template/vite.config.js +50 -22
|
@@ -70,7 +70,7 @@ export function createAssessmentActions(stateManager, uiManager, questionInstanc
|
|
|
70
70
|
await stateManager.setAttemptNumber(attemptNumber);
|
|
71
71
|
|
|
72
72
|
_manageFooterVisibility('question');
|
|
73
|
-
uiManager.showView('question');
|
|
73
|
+
await uiManager.showView('question');
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
async function handlePrev() {
|
|
@@ -79,7 +79,7 @@ export function createAssessmentActions(stateManager, uiManager, questionInstanc
|
|
|
79
79
|
const currentIndex = stateManager.getCurrentQuestionIndex();
|
|
80
80
|
if (currentIndex > 0) {
|
|
81
81
|
await stateManager.setCurrentQuestionIndex(currentIndex - 1);
|
|
82
|
-
uiManager.showView('question');
|
|
82
|
+
await uiManager.showView('question');
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
|
|
@@ -94,7 +94,7 @@ export function createAssessmentActions(stateManager, uiManager, questionInstanc
|
|
|
94
94
|
await stateManager.updateSession({ reviewReached: true });
|
|
95
95
|
await stateManager.setCurrentView('review');
|
|
96
96
|
_manageFooterVisibility('review');
|
|
97
|
-
uiManager.showView('review');
|
|
97
|
+
await uiManager.showView('review');
|
|
98
98
|
} else {
|
|
99
99
|
// FIX: Route through handleSubmit to ensure unanswered checks are performed
|
|
100
100
|
// previously called submitAssessment() directly, bypassing validation
|
|
@@ -102,7 +102,7 @@ export function createAssessmentActions(stateManager, uiManager, questionInstanc
|
|
|
102
102
|
}
|
|
103
103
|
} else {
|
|
104
104
|
await stateManager.setCurrentQuestionIndex(currentIndex + 1);
|
|
105
|
-
uiManager.showView('question');
|
|
105
|
+
await uiManager.showView('question');
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|
|
@@ -119,21 +119,21 @@ export function createAssessmentActions(stateManager, uiManager, questionInstanc
|
|
|
119
119
|
await stateManager.setCurrentView('question');
|
|
120
120
|
await stateManager.setCurrentQuestionIndex(index);
|
|
121
121
|
_manageFooterVisibility('question');
|
|
122
|
-
uiManager.showView('question');
|
|
122
|
+
await uiManager.showView('question');
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
async function handleBackToQuestions() {
|
|
126
126
|
await _forceSaveCurrentResponse();
|
|
127
127
|
await stateManager.setCurrentView('question');
|
|
128
128
|
_manageFooterVisibility('question');
|
|
129
|
-
uiManager.showView('question');
|
|
129
|
+
await uiManager.showView('question');
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
async function handleJumpToReview() {
|
|
133
133
|
await _forceSaveCurrentResponse();
|
|
134
134
|
await stateManager.setCurrentView('review');
|
|
135
135
|
_manageFooterVisibility('review');
|
|
136
|
-
uiManager.showView('review');
|
|
136
|
+
await uiManager.showView('review');
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
async function handleSubmit() {
|
|
@@ -242,14 +242,14 @@ export function createAssessmentActions(stateManager, uiManager, questionInstanc
|
|
|
242
242
|
// This will cause slide to create NEW assessment instance
|
|
243
243
|
const currentSlideId = getCurrentSlideId();
|
|
244
244
|
if (currentSlideId) {
|
|
245
|
-
goToSlide(currentSlideId, { refreshAssessment: true });
|
|
245
|
+
await goToSlide(currentSlideId, { refreshAssessment: true });
|
|
246
246
|
return; // Exit - new instance will be created
|
|
247
247
|
}
|
|
248
248
|
}
|
|
249
249
|
|
|
250
250
|
// Simple reset - same questions
|
|
251
251
|
_manageFooterVisibility('intro');
|
|
252
|
-
uiManager.showView('intro');
|
|
252
|
+
await uiManager.showView('intro');
|
|
253
253
|
}
|
|
254
254
|
|
|
255
255
|
function handleRestartCourse() {
|
|
@@ -353,7 +353,7 @@ export function createAssessmentActions(stateManager, uiManager, questionInstanc
|
|
|
353
353
|
throw new Error(errorMessage);
|
|
354
354
|
}
|
|
355
355
|
|
|
356
|
-
goToSlide(firstRemedialSlide, {
|
|
356
|
+
await goToSlide(firstRemedialSlide, {
|
|
357
357
|
fromAssessment: config.id,
|
|
358
358
|
remedialReview: true
|
|
359
359
|
});
|
|
@@ -413,11 +413,6 @@ export function createAssessmentActions(stateManager, uiManager, questionInstanc
|
|
|
413
413
|
const displayData = _prepareResultsDisplayData(finalResults);
|
|
414
414
|
|
|
415
415
|
await stateManager.setCurrentView('results');
|
|
416
|
-
_manageFooterVisibility('results');
|
|
417
|
-
uiManager.showView('results', displayData);
|
|
418
|
-
|
|
419
|
-
// Keep full results in memory for this session only (not persisted)
|
|
420
|
-
// This allows detailed review immediately after submission
|
|
421
416
|
|
|
422
417
|
// Automatically set linked objective if configured
|
|
423
418
|
if (config.assessmentObjective) {
|
|
@@ -446,6 +441,12 @@ export function createAssessmentActions(stateManager, uiManager, questionInstanc
|
|
|
446
441
|
// Assessment submission is a critical action - don't rely on debounce
|
|
447
442
|
await globalStateManager.flush();
|
|
448
443
|
|
|
444
|
+
// Only transition the UI after the assessment outcome, objective, and
|
|
445
|
+
// course score are durably committed. A rendering failure must never
|
|
446
|
+
// prevent the LMS from receiving the learner's submitted result.
|
|
447
|
+
_manageFooterVisibility('results');
|
|
448
|
+
await uiManager.showView('results', displayData);
|
|
449
|
+
|
|
449
450
|
if (typeof config.onComplete === 'function') {
|
|
450
451
|
config.onComplete(finalResults);
|
|
451
452
|
}
|
|
@@ -231,15 +231,37 @@ export function createAssessmentInstance(config) {
|
|
|
231
231
|
});
|
|
232
232
|
throw error;
|
|
233
233
|
} else {
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
|
|
234
|
+
// Index-keyed responses cannot be safely paired with a
|
|
235
|
+
// filtered question list. Restart only the in-progress
|
|
236
|
+
// session and preserve the learner's submitted summaries.
|
|
237
|
+
logger.warn(`[AssessmentFactory] ${errorMessage} Restarting the in-progress attempt safely.`);
|
|
237
238
|
eventBus.emit('state:recovered', {
|
|
238
239
|
domain: 'assessment',
|
|
239
240
|
message: errorMessage,
|
|
240
241
|
context: { assessmentId, missingIds },
|
|
241
|
-
action: '
|
|
242
|
+
action: 'restarted_changed_assessment'
|
|
242
243
|
});
|
|
244
|
+
|
|
245
|
+
const replacementQuestions = _selectNewQuestions();
|
|
246
|
+
const existingSession = assessmentState.getSession() || {};
|
|
247
|
+
assessmentState.updateSession({
|
|
248
|
+
...existingSession,
|
|
249
|
+
currentView: 'intro',
|
|
250
|
+
currentQuestionIndex: 0,
|
|
251
|
+
startTime: null,
|
|
252
|
+
submitted: false,
|
|
253
|
+
responses: {},
|
|
254
|
+
selectedQuestions: replacementQuestions.map(question => question.id),
|
|
255
|
+
reviewReached: false
|
|
256
|
+
}, 'assessment-state:course-update').catch(error => {
|
|
257
|
+
logger.error(`Failed to persist safe assessment restart: ${error.message}`, {
|
|
258
|
+
domain: 'assessment',
|
|
259
|
+
operation: 'recover-changed-questions',
|
|
260
|
+
assessmentId,
|
|
261
|
+
stack: error.stack
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
return replacementQuestions;
|
|
243
265
|
}
|
|
244
266
|
}
|
|
245
267
|
|
|
@@ -248,22 +270,19 @@ export function createAssessmentInstance(config) {
|
|
|
248
270
|
.filter(q => q !== undefined); // Filter out any missing questions
|
|
249
271
|
}
|
|
250
272
|
|
|
273
|
+
return _selectNewQuestions();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function _selectNewQuestions() {
|
|
251
277
|
if (config.questionBanks && Array.isArray(config.questionBanks) && config.questionBanks.length > 0) {
|
|
252
|
-
// New session with banks: select and optionally randomize
|
|
253
278
|
let selected = _selectQuestionsFromBanks(config.questionBanks);
|
|
254
|
-
|
|
255
|
-
if (config.settings?.randomizeQuestions) {
|
|
256
|
-
selected = shuffleArray(selected);
|
|
257
|
-
}
|
|
258
|
-
|
|
279
|
+
if (config.settings?.randomizeQuestions) selected = shuffleArray(selected);
|
|
259
280
|
return selected;
|
|
260
281
|
}
|
|
261
282
|
|
|
262
|
-
// Direct mode: use questions array as-is
|
|
263
283
|
if (config.settings?.randomizeQuestions && Array.isArray(config.questions)) {
|
|
264
284
|
return shuffleArray(config.questions);
|
|
265
285
|
}
|
|
266
|
-
|
|
267
286
|
return config.questions || [];
|
|
268
287
|
}
|
|
269
288
|
|
|
@@ -320,10 +339,20 @@ export function createAssessmentInstance(config) {
|
|
|
320
339
|
// Resolve active questions for this session
|
|
321
340
|
activeQuestions = _resolveActiveQuestions();
|
|
322
341
|
|
|
323
|
-
// Persist
|
|
324
|
-
|
|
342
|
+
// Persist every randomized order, including direct question arrays.
|
|
343
|
+
// Responses are keyed by index, so resume must reconstruct the exact
|
|
344
|
+
// same question order or answers can be scored against another item.
|
|
345
|
+
const requiresPersistedOrder = Boolean(
|
|
346
|
+
config.questionBanks || config.settings?.randomizeQuestions
|
|
347
|
+
);
|
|
348
|
+
if (requiresPersistedOrder && !session?.selectedQuestions) {
|
|
325
349
|
assessmentState.setSelectedQuestions(activeQuestions).catch(error => {
|
|
326
|
-
logger.error(`Failed to persist selected questions: ${error.message}`, {
|
|
350
|
+
logger.error(`Failed to persist selected questions: ${error.message}`, {
|
|
351
|
+
domain: 'assessment',
|
|
352
|
+
operation: 'persist-selected-questions',
|
|
353
|
+
assessmentId,
|
|
354
|
+
stack: error.stack
|
|
355
|
+
});
|
|
327
356
|
});
|
|
328
357
|
}
|
|
329
358
|
|
|
@@ -61,7 +61,7 @@ export function createEngagementMethods(logTrace) {
|
|
|
61
61
|
if (!slideId) {
|
|
62
62
|
throw new Error('CourseCodeAutomation: No active slide');
|
|
63
63
|
}
|
|
64
|
-
if (
|
|
64
|
+
if (!Number.isFinite(percentage) || percentage < 0 || percentage > 100) {
|
|
65
65
|
throw new Error('CourseCodeAutomation: Scroll depth must be a number between 0 and 100');
|
|
66
66
|
}
|
|
67
67
|
engagementManager.trackScrollDepth(slideId, percentage);
|
|
@@ -143,7 +143,7 @@ export function createEngagementMethods(logTrace) {
|
|
|
143
143
|
const currentSlideId = NavigationState.getCurrentSlideId();
|
|
144
144
|
|
|
145
145
|
if (state.duration > 0) {
|
|
146
|
-
const targetPosition = state.duration *
|
|
146
|
+
const targetPosition = state.duration * state.completionThreshold;
|
|
147
147
|
audioManager.seek(targetPosition);
|
|
148
148
|
}
|
|
149
149
|
|
|
@@ -163,7 +163,10 @@ export function createEngagementMethods(logTrace) {
|
|
|
163
163
|
case 'tab':
|
|
164
164
|
case 'standalone':
|
|
165
165
|
if (currentSlideId) {
|
|
166
|
-
|
|
166
|
+
const audioId = state.contextType === 'standalone'
|
|
167
|
+
? state.contextId.replace(/^standalone-/, '')
|
|
168
|
+
: state.contextId;
|
|
169
|
+
engagementManager.trackStandaloneAudioComplete(currentSlideId, audioId);
|
|
167
170
|
}
|
|
168
171
|
break;
|
|
169
172
|
}
|
|
@@ -77,8 +77,7 @@ export const schema = {
|
|
|
77
77
|
description: 'Draggable items',
|
|
78
78
|
itemSchema: {
|
|
79
79
|
id: { type: 'string', required: true },
|
|
80
|
-
|
|
81
|
-
correctZone: { type: 'string', required: true }
|
|
80
|
+
content: { type: 'string', required: true }
|
|
82
81
|
}
|
|
83
82
|
},
|
|
84
83
|
dropZones: {
|
|
@@ -88,7 +87,9 @@ export const schema = {
|
|
|
88
87
|
description: 'Drop target zones',
|
|
89
88
|
itemSchema: {
|
|
90
89
|
id: { type: 'string', required: true },
|
|
91
|
-
label: { type: 'string', required: true }
|
|
90
|
+
label: { type: 'string', required: true },
|
|
91
|
+
accepts: { type: 'array', required: true, minItems: 1 },
|
|
92
|
+
maxItems: { type: 'number' }
|
|
92
93
|
}
|
|
93
94
|
}
|
|
94
95
|
}
|
|
@@ -881,4 +882,3 @@ function removeItemFromZone(container, itemId) {
|
|
|
881
882
|
// Update state
|
|
882
883
|
delete state.placements[itemId];
|
|
883
884
|
}
|
|
884
|
-
|
|
@@ -137,10 +137,13 @@ export const schema = {
|
|
|
137
137
|
type: 'object',
|
|
138
138
|
required: true,
|
|
139
139
|
description: 'Map of blank IDs to their correct answers',
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
140
|
+
mapValueSchema: {
|
|
141
|
+
type: 'object',
|
|
142
|
+
valueSchema: {
|
|
143
|
+
correct: { type: ['string', 'array'], required: true },
|
|
144
|
+
typoTolerance: { type: 'number', default: 0 },
|
|
145
|
+
hint: { type: 'string' }
|
|
146
|
+
}
|
|
144
147
|
}
|
|
145
148
|
},
|
|
146
149
|
template: {
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
registerCoreInteraction
|
|
11
11
|
} from './interaction-base.js';
|
|
12
12
|
import { logger } from '../../utilities/logger.js';
|
|
13
|
+
import { resolveCourseAssetUrl } from '../../utilities/portable-assets.js';
|
|
13
14
|
|
|
14
15
|
// Default appearance themes for hotspots
|
|
15
16
|
const HOTSPOT_DEFAULTS = {
|
|
@@ -282,7 +283,7 @@ export function createHotspotQuestion(config) {
|
|
|
282
283
|
<div class="hotspot-container">
|
|
283
284
|
<div class="image-container relative">
|
|
284
285
|
<img
|
|
285
|
-
src="${image.src}"
|
|
286
|
+
src="${resolveCourseAssetUrl(image.src)}"
|
|
286
287
|
alt="${image.alt || ''}"
|
|
287
288
|
class="w-full h-auto"
|
|
288
289
|
id="${id}_image"
|
|
@@ -699,4 +700,3 @@ function toNumber(value, fallback = 0) {
|
|
|
699
700
|
}
|
|
700
701
|
return 0;
|
|
701
702
|
}
|
|
702
|
-
|
|
@@ -94,10 +94,11 @@ export const schema = {
|
|
|
94
94
|
type: 'array',
|
|
95
95
|
required: true,
|
|
96
96
|
minItems: 2,
|
|
97
|
-
description: '
|
|
97
|
+
description: 'Items and their matching target text',
|
|
98
98
|
itemSchema: {
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
id: { type: 'string', required: true },
|
|
100
|
+
text: { type: 'string', required: true },
|
|
101
|
+
match: { type: 'string', required: true }
|
|
101
102
|
}
|
|
102
103
|
},
|
|
103
104
|
feedbackMode: {
|
|
@@ -60,6 +60,7 @@ import engagementManager from '../../engagement/engagement-manager.js';
|
|
|
60
60
|
import * as NavigationState from '../../navigation/NavigationState.js';
|
|
61
61
|
import { logger } from '../../utilities/logger.js';
|
|
62
62
|
import { iconManager } from '../../utilities/icons.js';
|
|
63
|
+
import { normalizeCompletionThreshold } from '../../utilities/media-utils.js';
|
|
63
64
|
|
|
64
65
|
|
|
65
66
|
/** @type {HTMLElement|null} */
|
|
@@ -351,6 +352,7 @@ function _handleProgressMousedown(event) {
|
|
|
351
352
|
|
|
352
353
|
const progressBar = elements.progressBar;
|
|
353
354
|
const rect = progressBar.getBoundingClientRect();
|
|
355
|
+
if (rect.width <= 0) return;
|
|
354
356
|
|
|
355
357
|
const seek = (clientX) => {
|
|
356
358
|
const percentage = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
|
|
@@ -823,7 +825,7 @@ class StandaloneAudioPlayer {
|
|
|
823
825
|
this.audioSrc = container.dataset.audioSrc;
|
|
824
826
|
this.required = container.dataset.audioRequired === 'true';
|
|
825
827
|
this.compact = container.dataset.audioCompact === 'true';
|
|
826
|
-
this.threshold =
|
|
828
|
+
this.threshold = normalizeCompletionThreshold(container.dataset.audioThreshold);
|
|
827
829
|
|
|
828
830
|
if (!this.audioId) {
|
|
829
831
|
throw new Error('[AudioPlayer] Standalone player requires data-audio-id');
|
|
@@ -836,6 +838,12 @@ class StandaloneAudioPlayer {
|
|
|
836
838
|
this.isActive = false;
|
|
837
839
|
this.eventHandlers = {};
|
|
838
840
|
this.elements = {};
|
|
841
|
+
this.domEventHandlers = {
|
|
842
|
+
click: this._handleClick.bind(this),
|
|
843
|
+
progressKeydown: this._handleProgressKeydown.bind(this),
|
|
844
|
+
progressMousedown: this._handleProgressMousedown.bind(this)
|
|
845
|
+
};
|
|
846
|
+
this.dragCleanup = null;
|
|
839
847
|
|
|
840
848
|
this._render();
|
|
841
849
|
this._cacheElements();
|
|
@@ -867,11 +875,11 @@ class StandaloneAudioPlayer {
|
|
|
867
875
|
}
|
|
868
876
|
|
|
869
877
|
_setupEventListeners() {
|
|
870
|
-
this.container.addEventListener('click', this.
|
|
878
|
+
this.container.addEventListener('click', this.domEventHandlers.click);
|
|
871
879
|
|
|
872
880
|
if (this.elements.progressBar) {
|
|
873
|
-
this.elements.progressBar.addEventListener('keydown', this.
|
|
874
|
-
this.elements.progressBar.addEventListener('mousedown', this.
|
|
881
|
+
this.elements.progressBar.addEventListener('keydown', this.domEventHandlers.progressKeydown);
|
|
882
|
+
this.elements.progressBar.addEventListener('mousedown', this.domEventHandlers.progressMousedown);
|
|
875
883
|
}
|
|
876
884
|
}
|
|
877
885
|
|
|
@@ -915,7 +923,9 @@ class StandaloneAudioPlayer {
|
|
|
915
923
|
completionThreshold: this.threshold
|
|
916
924
|
}, this.contextId, 'standalone');
|
|
917
925
|
} catch (error) {
|
|
918
|
-
|
|
926
|
+
if (error.name !== 'AbortError') {
|
|
927
|
+
logger.error(`[AudioPlayer] Failed to load ${this.audioId}:`, error.message);
|
|
928
|
+
}
|
|
919
929
|
}
|
|
920
930
|
}
|
|
921
931
|
|
|
@@ -939,8 +949,11 @@ class StandaloneAudioPlayer {
|
|
|
939
949
|
_handleProgressMousedown(event) {
|
|
940
950
|
if (!this.isActive || !audioManager.hasAudio()) return;
|
|
941
951
|
|
|
952
|
+
this.dragCleanup?.();
|
|
953
|
+
|
|
942
954
|
const progressBar = this.elements.progressBar;
|
|
943
955
|
const rect = progressBar.getBoundingClientRect();
|
|
956
|
+
if (rect.width <= 0) return;
|
|
944
957
|
|
|
945
958
|
const seek = (clientX) => {
|
|
946
959
|
const pct = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
|
|
@@ -950,15 +963,19 @@ class StandaloneAudioPlayer {
|
|
|
950
963
|
seek(event.clientX);
|
|
951
964
|
|
|
952
965
|
const onMouseMove = (e) => seek(e.clientX);
|
|
953
|
-
const
|
|
966
|
+
const cleanupDrag = () => {
|
|
954
967
|
document.removeEventListener('mousemove', onMouseMove);
|
|
955
|
-
document.removeEventListener('mouseup',
|
|
968
|
+
document.removeEventListener('mouseup', cleanupDrag);
|
|
956
969
|
progressBar.classList.remove('dragging');
|
|
970
|
+
if (this.dragCleanup === cleanupDrag) {
|
|
971
|
+
this.dragCleanup = null;
|
|
972
|
+
}
|
|
957
973
|
};
|
|
958
974
|
|
|
959
975
|
progressBar.classList.add('dragging');
|
|
960
976
|
document.addEventListener('mousemove', onMouseMove);
|
|
961
|
-
document.addEventListener('mouseup',
|
|
977
|
+
document.addEventListener('mouseup', cleanupDrag);
|
|
978
|
+
this.dragCleanup = cleanupDrag;
|
|
962
979
|
}
|
|
963
980
|
|
|
964
981
|
_subscribeToAudioEvents() {
|
|
@@ -1111,6 +1128,11 @@ class StandaloneAudioPlayer {
|
|
|
1111
1128
|
}
|
|
1112
1129
|
|
|
1113
1130
|
destroy() {
|
|
1131
|
+
this.dragCleanup?.();
|
|
1132
|
+
this.container.removeEventListener('click', this.domEventHandlers.click);
|
|
1133
|
+
this.elements.progressBar?.removeEventListener('keydown', this.domEventHandlers.progressKeydown);
|
|
1134
|
+
this.elements.progressBar?.removeEventListener('mousedown', this.domEventHandlers.progressMousedown);
|
|
1135
|
+
|
|
1114
1136
|
eventBus.off('audio:loadStart', this.eventHandlers.loadStart);
|
|
1115
1137
|
eventBus.off('audio:loaded', this.eventHandlers.loaded);
|
|
1116
1138
|
eventBus.off('audio:unloaded', this.eventHandlers.unloaded);
|
|
@@ -90,11 +90,12 @@ export function init(container) {
|
|
|
90
90
|
};
|
|
91
91
|
|
|
92
92
|
// Handle transition end to set display: none for accessibility and performance
|
|
93
|
-
|
|
93
|
+
const handleTransitionEnd = () => {
|
|
94
94
|
if (trigger.getAttribute('aria-expanded') === 'false') {
|
|
95
95
|
panel.style.display = 'none';
|
|
96
96
|
}
|
|
97
|
-
}
|
|
97
|
+
};
|
|
98
|
+
panel.addEventListener('transitionend', handleTransitionEnd);
|
|
98
99
|
|
|
99
100
|
// Ensure panel is correctly displayed or hidden on init
|
|
100
101
|
if (trigger.getAttribute('aria-expanded') === 'false') {
|
|
@@ -109,6 +110,7 @@ export function init(container) {
|
|
|
109
110
|
return {
|
|
110
111
|
destroy: () => {
|
|
111
112
|
trigger.removeEventListener('click', toggle);
|
|
113
|
+
panel.removeEventListener('transitionend', handleTransitionEnd);
|
|
112
114
|
}
|
|
113
115
|
};
|
|
114
116
|
}
|
|
@@ -127,7 +127,7 @@ export function init(root, options = {}) {
|
|
|
127
127
|
});
|
|
128
128
|
|
|
129
129
|
// Delegated event listener for the entire dropdown
|
|
130
|
-
|
|
130
|
+
const handleClick = (event) => {
|
|
131
131
|
const actionTarget = event.target.closest('[data-action]');
|
|
132
132
|
if (!actionTarget) return;
|
|
133
133
|
|
|
@@ -145,13 +145,17 @@ export function init(root, options = {}) {
|
|
|
145
145
|
selectDropdownItem(dropdownId, itemIndex);
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
|
-
}
|
|
148
|
+
};
|
|
149
149
|
|
|
150
150
|
// Keyboard navigation for the menu
|
|
151
|
-
|
|
151
|
+
const handleKeydown = (event) => handleDropdownKeyboard(dropdownId, event);
|
|
152
|
+
dropdown.addEventListener('click', handleClick);
|
|
153
|
+
menu.addEventListener('keydown', handleKeydown);
|
|
152
154
|
|
|
153
155
|
return {
|
|
154
156
|
destroy: () => {
|
|
157
|
+
dropdown.removeEventListener('click', handleClick);
|
|
158
|
+
menu.removeEventListener('keydown', handleKeydown);
|
|
155
159
|
dropdowns.delete(dropdownId);
|
|
156
160
|
}
|
|
157
161
|
};
|
|
@@ -51,6 +51,7 @@ export const metadata = {
|
|
|
51
51
|
import { logger } from '../../utilities/logger.js';
|
|
52
52
|
import { eventBus } from '../../core/event-bus.js';
|
|
53
53
|
import flagManager from '../../managers/flag-manager.js';
|
|
54
|
+
import { resolvePortableAssetUrl } from '../../utilities/portable-assets.js';
|
|
54
55
|
|
|
55
56
|
/**
|
|
56
57
|
* Initializes an embed-frame component.
|
|
@@ -254,6 +255,9 @@ function handleReadyMessage(embedId) {
|
|
|
254
255
|
* @returns {string} Resolved path
|
|
255
256
|
*/
|
|
256
257
|
function _resolvePath(src) {
|
|
258
|
+
const portableSrc = resolvePortableAssetUrl(src);
|
|
259
|
+
if (portableSrc !== src) return portableSrc;
|
|
260
|
+
|
|
257
261
|
// Already absolute URL or protocol-relative
|
|
258
262
|
if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//')) {
|
|
259
263
|
return src;
|
|
@@ -270,5 +274,5 @@ function _resolvePath(src) {
|
|
|
270
274
|
}
|
|
271
275
|
|
|
272
276
|
// Otherwise, assume relative to course/assets/
|
|
273
|
-
return `./course/${src}
|
|
277
|
+
return resolvePortableAssetUrl(`./course/${src}`);
|
|
274
278
|
}
|
|
@@ -218,13 +218,19 @@ export function init(element) {
|
|
|
218
218
|
announceToScreenReader(action);
|
|
219
219
|
};
|
|
220
220
|
|
|
221
|
-
|
|
222
|
-
element.addEventListener('click', toggleFlip);
|
|
223
|
-
|
|
224
|
-
// Keyboard listener (Enter or Space)
|
|
225
|
-
element.addEventListener('keydown', (e) => {
|
|
221
|
+
const handleKeydown = (e) => {
|
|
226
222
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
227
223
|
toggleFlip(e);
|
|
228
224
|
}
|
|
229
|
-
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
element.addEventListener('click', toggleFlip);
|
|
228
|
+
element.addEventListener('keydown', handleKeydown);
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
destroy: () => {
|
|
232
|
+
element.removeEventListener('click', toggleFlip);
|
|
233
|
+
element.removeEventListener('keydown', handleKeydown);
|
|
234
|
+
}
|
|
235
|
+
};
|
|
230
236
|
}
|