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
|
@@ -51,21 +51,14 @@ export function init(root, _options = {}) {
|
|
|
51
51
|
const currentSlideId = NavigationState.getCurrentSlideId();
|
|
52
52
|
const imageId = container.id || `interactive-image-${Math.random().toString(36).substr(2, 9)}`;
|
|
53
53
|
const accordionId = container.dataset.accordionId;
|
|
54
|
-
|
|
55
|
-
// Register with EngagementManager if on a slide
|
|
56
|
-
if (currentSlideId) {
|
|
57
|
-
const hotspotIds = hotspots.map(h => h.dataset.hotspotId);
|
|
58
|
-
if (typeof engagementManager.registerInteractiveImage === 'function') {
|
|
59
|
-
engagementManager.registerInteractiveImage(currentSlideId, hotspotIds);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
54
|
+
const cleanups = [];
|
|
62
55
|
|
|
63
56
|
// Setup Accordion Integration if ID is provided
|
|
64
57
|
let accordionElement = null;
|
|
65
58
|
if (accordionId) {
|
|
66
59
|
accordionElement = document.getElementById(accordionId);
|
|
67
60
|
if (accordionElement) {
|
|
68
|
-
setupAccordionIntegration(
|
|
61
|
+
cleanups.push(setupAccordionIntegration(hotspots, accordionElement, accordionId));
|
|
69
62
|
} else {
|
|
70
63
|
logger.warn(`[InteractiveImage] Accordion #${accordionId} not found for image ${imageId}`);
|
|
71
64
|
}
|
|
@@ -113,21 +106,30 @@ export function init(root, _options = {}) {
|
|
|
113
106
|
}
|
|
114
107
|
};
|
|
115
108
|
|
|
116
|
-
|
|
117
|
-
hotspot.addEventListener('keydown', (e) => {
|
|
109
|
+
const handleKeydown = (e) => {
|
|
118
110
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
119
111
|
handleClick(e);
|
|
120
112
|
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
hotspot.addEventListener('click', handleClick);
|
|
116
|
+
hotspot.addEventListener('keydown', handleKeydown);
|
|
117
|
+
cleanups.push(() => {
|
|
118
|
+
hotspot.removeEventListener('click', handleClick);
|
|
119
|
+
hotspot.removeEventListener('keydown', handleKeydown);
|
|
121
120
|
});
|
|
122
121
|
});
|
|
123
122
|
|
|
124
123
|
logger.debug(`[InteractiveImage] Initialized ${hotspots.length} hotspots for ${imageId}`);
|
|
124
|
+
return {
|
|
125
|
+
destroy: () => cleanups.forEach(cleanup => cleanup())
|
|
126
|
+
};
|
|
125
127
|
}
|
|
126
128
|
|
|
127
129
|
/**
|
|
128
130
|
* Sets up bi-directional sync between image hotspots and accordion panels.
|
|
129
131
|
*/
|
|
130
|
-
function setupAccordionIntegration(
|
|
132
|
+
function setupAccordionIntegration(hotspots, accordionElement, accordionId) {
|
|
131
133
|
// Map hotspots by ID for quick access
|
|
132
134
|
const hotspotMap = new Map(hotspots.map(h => [h.dataset.hotspotId, h]));
|
|
133
135
|
|
|
@@ -156,25 +158,23 @@ function setupAccordionIntegration(imageContainer, hotspots, accordionElement, a
|
|
|
156
158
|
|
|
157
159
|
// 2. Hover Effects (Accordion Button -> Image Hotspot)
|
|
158
160
|
const buttons = accordionElement.querySelectorAll('[data-panel]');
|
|
161
|
+
const buttonCleanups = [];
|
|
159
162
|
buttons.forEach(btn => {
|
|
160
163
|
const panelId = btn.dataset.panel;
|
|
161
164
|
const hotspot = hotspotMap.get(panelId);
|
|
162
165
|
|
|
163
166
|
if (hotspot) {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
btn.addEventListener('
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
btn.addEventListener('blur', () => {
|
|
177
|
-
hotspot.classList.remove('highlighted');
|
|
167
|
+
const highlight = () => hotspot.classList.add('highlighted');
|
|
168
|
+
const unhighlight = () => hotspot.classList.remove('highlighted');
|
|
169
|
+
btn.addEventListener('mouseenter', highlight);
|
|
170
|
+
btn.addEventListener('mouseleave', unhighlight);
|
|
171
|
+
btn.addEventListener('focus', highlight);
|
|
172
|
+
btn.addEventListener('blur', unhighlight);
|
|
173
|
+
buttonCleanups.push(() => {
|
|
174
|
+
btn.removeEventListener('mouseenter', highlight);
|
|
175
|
+
btn.removeEventListener('mouseleave', unhighlight);
|
|
176
|
+
btn.removeEventListener('focus', highlight);
|
|
177
|
+
btn.removeEventListener('blur', unhighlight);
|
|
178
178
|
});
|
|
179
179
|
}
|
|
180
180
|
});
|
|
@@ -196,40 +196,16 @@ function setupAccordionIntegration(imageContainer, hotspots, accordionElement, a
|
|
|
196
196
|
}
|
|
197
197
|
};
|
|
198
198
|
|
|
199
|
-
// Component lifecycle cleanup using MutationObserver
|
|
200
|
-
// When the container is removed from DOM, we automatically clean up all listeners
|
|
201
199
|
const cleanupListeners = () => {
|
|
202
200
|
document.removeEventListener('click', safeHandleOutsideClick);
|
|
203
201
|
eventBus.off('accordion:toggled', handleAccordionToggle);
|
|
204
|
-
|
|
205
|
-
observer.disconnect();
|
|
206
|
-
}
|
|
202
|
+
buttonCleanups.forEach(cleanup => cleanup());
|
|
207
203
|
};
|
|
208
204
|
|
|
209
205
|
const safeHandleOutsideClick = (e) => {
|
|
210
|
-
if (!document.body.contains(imageContainer)) {
|
|
211
|
-
cleanupListeners();
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
206
|
handleOutsideClick(e);
|
|
215
207
|
};
|
|
216
208
|
|
|
217
|
-
// Use MutationObserver to detect when container is removed from DOM
|
|
218
|
-
const observer = new MutationObserver((mutations) => {
|
|
219
|
-
for (const mutation of mutations) {
|
|
220
|
-
for (const node of mutation.removedNodes) {
|
|
221
|
-
if (node === imageContainer || node.contains?.(imageContainer)) {
|
|
222
|
-
cleanupListeners();
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
// Observe parent for child removals (handles SPA navigation cleanup)
|
|
230
|
-
if (imageContainer.parentNode) {
|
|
231
|
-
observer.observe(imageContainer.parentNode, { childList: true });
|
|
232
|
-
}
|
|
233
|
-
|
|
234
209
|
document.addEventListener('click', safeHandleOutsideClick);
|
|
210
|
+
return cleanupListeners;
|
|
235
211
|
}
|
|
@@ -65,7 +65,7 @@ export const schema = {
|
|
|
65
65
|
export const metadata = {
|
|
66
66
|
category: 'ui-component',
|
|
67
67
|
cssFile: 'components/lightbox.css',
|
|
68
|
-
engagementTracking: '
|
|
68
|
+
engagementTracking: 'viewAllLightboxes',
|
|
69
69
|
emitsEvents: ['lightbox:opened', 'lightbox:closed']
|
|
70
70
|
};
|
|
71
71
|
|
|
@@ -75,6 +75,7 @@ import { logger } from '../../utilities/logger.js';
|
|
|
75
75
|
import { escapeHTML } from '../../utilities/utilities.js';
|
|
76
76
|
import { fetchAndRenderMarkdown } from '../../utilities/markdown-renderer.js';
|
|
77
77
|
import engagementManager from '../../engagement/engagement-manager.js';
|
|
78
|
+
import { resolvePortableAssetUrl } from '../../utilities/portable-assets.js';
|
|
78
79
|
|
|
79
80
|
// Lightbox state
|
|
80
81
|
let lightboxElement = null;
|
|
@@ -91,6 +92,8 @@ let currentMediaType = 'image'; // 'image' | 'video' | 'markdown' | 'pdf'
|
|
|
91
92
|
*/
|
|
92
93
|
function _resolvePath(src) {
|
|
93
94
|
if (!src) return src;
|
|
95
|
+
const portableSrc = resolvePortableAssetUrl(src);
|
|
96
|
+
if (portableSrc !== src) return portableSrc;
|
|
94
97
|
// Already absolute URL or protocol-relative
|
|
95
98
|
if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//')) {
|
|
96
99
|
return src;
|
|
@@ -104,7 +107,7 @@ function _resolvePath(src) {
|
|
|
104
107
|
return src;
|
|
105
108
|
}
|
|
106
109
|
// Otherwise, assume relative to course/ directory
|
|
107
|
-
return `./course/${src}
|
|
110
|
+
return resolvePortableAssetUrl(`./course/${src}`);
|
|
108
111
|
}
|
|
109
112
|
|
|
110
113
|
/**
|
|
@@ -129,6 +132,7 @@ function getTriggerSrc(trigger) {
|
|
|
129
132
|
*/
|
|
130
133
|
function detectPDF(url) {
|
|
131
134
|
if (!url) return null;
|
|
135
|
+
if (url.startsWith('data:application/pdf')) return { type: 'pdf' };
|
|
132
136
|
if (/\.pdf($|\?|#)/i.test(url)) return { type: 'pdf' };
|
|
133
137
|
return null;
|
|
134
138
|
}
|
|
@@ -140,6 +144,7 @@ function detectPDF(url) {
|
|
|
140
144
|
*/
|
|
141
145
|
function detectMarkdown(url) {
|
|
142
146
|
if (!url) return null;
|
|
147
|
+
if (url.startsWith('data:text/markdown')) return { type: 'markdown' };
|
|
143
148
|
if (/\.md($|\?|#)/i.test(url)) return { type: 'markdown' };
|
|
144
149
|
return null;
|
|
145
150
|
}
|
|
@@ -182,6 +187,7 @@ function detectVimeo(url) {
|
|
|
182
187
|
*/
|
|
183
188
|
function detectNativeVideo(url) {
|
|
184
189
|
if (!url) return null;
|
|
190
|
+
if (url.startsWith('data:video/')) return { type: 'native' };
|
|
185
191
|
const videoExtensions = /\.(mp4|webm|ogg|mov|m4v)($|\?)/i;
|
|
186
192
|
if (videoExtensions.test(url)) return { type: 'native' };
|
|
187
193
|
return null;
|
|
@@ -211,10 +217,11 @@ export function init(element) {
|
|
|
211
217
|
createLightboxElement();
|
|
212
218
|
}
|
|
213
219
|
|
|
214
|
-
|
|
220
|
+
const handleClick = (e) => {
|
|
215
221
|
e.preventDefault();
|
|
216
222
|
openFromTrigger(element);
|
|
217
|
-
}
|
|
223
|
+
};
|
|
224
|
+
element.addEventListener('click', handleClick);
|
|
218
225
|
|
|
219
226
|
const src = getTriggerSrc(element);
|
|
220
227
|
const mediaType = getMediaType(src);
|
|
@@ -239,6 +246,14 @@ export function init(element) {
|
|
|
239
246
|
if (subtitle) {
|
|
240
247
|
wrapTriggerWithSubtitle(element, subtitle);
|
|
241
248
|
}
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
destroy: () => {
|
|
252
|
+
element.removeEventListener('click', handleClick);
|
|
253
|
+
delete element.dataset.lightboxInitialized;
|
|
254
|
+
if (isOpen) close();
|
|
255
|
+
}
|
|
256
|
+
};
|
|
242
257
|
}
|
|
243
258
|
|
|
244
259
|
/**
|
|
@@ -649,4 +664,3 @@ function navigate(direction) {
|
|
|
649
664
|
export function isVisible() {
|
|
650
665
|
return isOpen;
|
|
651
666
|
}
|
|
652
|
-
|
|
@@ -40,6 +40,7 @@ import * as NavigationState from '../../navigation/NavigationState.js';
|
|
|
40
40
|
import * as AudioPlayer from './audio-player.js';
|
|
41
41
|
import { eventBus } from '../../core/event-bus.js';
|
|
42
42
|
import { logger } from '../../utilities/logger.js';
|
|
43
|
+
import { normalizeCompletionThreshold } from '../../utilities/media-utils.js';
|
|
43
44
|
import { renderCompactPlayer } from './audio-player.js';
|
|
44
45
|
|
|
45
46
|
// Schema for validation, linting, and AI-assisted authoring
|
|
@@ -144,7 +145,7 @@ export function setup() {
|
|
|
144
145
|
* @param {HTMLElement} trigger
|
|
145
146
|
*/
|
|
146
147
|
export function init(trigger) {
|
|
147
|
-
|
|
148
|
+
const handleClick = () => {
|
|
148
149
|
const title = trigger.dataset.title || 'Modal';
|
|
149
150
|
let body = trigger.dataset.body || '';
|
|
150
151
|
let footer = trigger.dataset.footer || '<button class="btn btn-secondary" data-action="close-modal">Close</button>';
|
|
@@ -183,7 +184,7 @@ export function init(trigger) {
|
|
|
183
184
|
src: audioSrc,
|
|
184
185
|
autoplay: trigger.dataset.audioAutoplay === 'true',
|
|
185
186
|
required: trigger.dataset.audioRequired === 'true',
|
|
186
|
-
completionThreshold:
|
|
187
|
+
completionThreshold: normalizeCompletionThreshold(trigger.dataset.audioThreshold)
|
|
187
188
|
} : null;
|
|
188
189
|
|
|
189
190
|
// Get modal ID for tracking (from trigger id or generate one)
|
|
@@ -200,7 +201,15 @@ export function init(trigger) {
|
|
|
200
201
|
closeOnEscape: trigger.dataset.closeOnEscape !== 'false'
|
|
201
202
|
}
|
|
202
203
|
});
|
|
203
|
-
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
trigger.addEventListener('click', handleClick);
|
|
207
|
+
return {
|
|
208
|
+
destroy: () => {
|
|
209
|
+
trigger.removeEventListener('click', handleClick);
|
|
210
|
+
if (isVisible()) hide();
|
|
211
|
+
}
|
|
212
|
+
};
|
|
204
213
|
}
|
|
205
214
|
|
|
206
215
|
/**
|
|
@@ -275,13 +284,15 @@ export async function show({ title, body, footer = '', modalId = null, config =
|
|
|
275
284
|
// (enforced by runtime-linter - a slide cannot have both)
|
|
276
285
|
if (audioManager.isReady() && audio && audio.src) {
|
|
277
286
|
const audioContextId = `modal-${currentModalId}`;
|
|
287
|
+
let modalAudioLoaded = false;
|
|
278
288
|
try {
|
|
279
289
|
await audioManager.load({
|
|
280
290
|
src: audio.src,
|
|
281
291
|
autoplay: audio.autoplay === true,
|
|
282
292
|
required: audio.required || false,
|
|
283
|
-
completionThreshold: audio.completionThreshold
|
|
293
|
+
completionThreshold: normalizeCompletionThreshold(audio.completionThreshold)
|
|
284
294
|
}, audioContextId, 'modal');
|
|
295
|
+
modalAudioLoaded = true;
|
|
285
296
|
|
|
286
297
|
logger.debug(`[Modal] Loaded modal audio: ${audioContextId}`);
|
|
287
298
|
|
|
@@ -290,11 +301,13 @@ export async function show({ title, body, footer = '', modalId = null, config =
|
|
|
290
301
|
AudioPlayer.initAudioControlsInContainer(modalFooter);
|
|
291
302
|
}
|
|
292
303
|
} catch (err) {
|
|
293
|
-
|
|
304
|
+
if (err.name !== 'AbortError') {
|
|
305
|
+
logger.warn('[Modal] Failed to load modal audio:', err.message);
|
|
306
|
+
}
|
|
294
307
|
}
|
|
295
308
|
|
|
296
309
|
// If audio is required, listen for completion
|
|
297
|
-
if (audio.required) {
|
|
310
|
+
if (audio.required && modalAudioLoaded) {
|
|
298
311
|
audioCompletedHandler = ({ contextId }) => {
|
|
299
312
|
if (contextId === audioContextId && currentSlideId) {
|
|
300
313
|
engagementManager.trackModalAudioComplete(currentSlideId, currentModalId);
|
|
@@ -383,4 +396,4 @@ export function hide() {
|
|
|
383
396
|
activeConfig = {};
|
|
384
397
|
|
|
385
398
|
announceToScreenReader('Modal closed');
|
|
386
|
-
}
|
|
399
|
+
}
|
|
@@ -60,16 +60,21 @@ export function setup() {
|
|
|
60
60
|
* Initializes declarative notification triggers in a container using event delegation.
|
|
61
61
|
* @param {HTMLElement} container
|
|
62
62
|
*/
|
|
63
|
-
export function
|
|
63
|
+
export function initNotificationTriggers(container) {
|
|
64
64
|
// Use event delegation to handle dynamically rendered content
|
|
65
|
-
|
|
65
|
+
const handleClick = (event) => {
|
|
66
66
|
const trigger = event.target.closest('[data-action="show-notification"]');
|
|
67
67
|
if (trigger) {
|
|
68
68
|
const type = trigger.dataset.type || 'info';
|
|
69
69
|
const message = trigger.dataset.message || 'Notification';
|
|
70
70
|
showNotification(message, type);
|
|
71
71
|
}
|
|
72
|
-
}
|
|
72
|
+
};
|
|
73
|
+
container.addEventListener('click', handleClick);
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
destroy: () => container.removeEventListener('click', handleClick)
|
|
77
|
+
};
|
|
73
78
|
}
|
|
74
79
|
|
|
75
80
|
export function showNotification(message, type = 'info', duration = 5000, options = {}) {
|
|
@@ -54,6 +54,8 @@ import engagementManager from '../../engagement/engagement-manager.js';
|
|
|
54
54
|
import * as NavigationState from '../../navigation/NavigationState.js';
|
|
55
55
|
import { logger } from '../../utilities/logger.js';
|
|
56
56
|
import { iconManager } from '../../utilities/icons.js';
|
|
57
|
+
import { normalizeCompletionThreshold } from '../../utilities/media-utils.js';
|
|
58
|
+
import { resolvePortableAssetUrl } from '../../utilities/portable-assets.js';
|
|
57
59
|
|
|
58
60
|
/** @type {Map<string, VideoPlayer>} Active player instances */
|
|
59
61
|
const playerInstances = new Map();
|
|
@@ -131,10 +133,12 @@ function detectExternalVideo(url) {
|
|
|
131
133
|
* @returns {string} Resolved path
|
|
132
134
|
*/
|
|
133
135
|
function resolvePath(src) {
|
|
136
|
+
const portableSrc = resolvePortableAssetUrl(src);
|
|
137
|
+
if (portableSrc !== src) return portableSrc;
|
|
134
138
|
if (src.startsWith('http') || src.startsWith('/') || src.startsWith('./')) {
|
|
135
139
|
return src;
|
|
136
140
|
}
|
|
137
|
-
return `./course/assets/${src}
|
|
141
|
+
return resolvePortableAssetUrl(`./course/assets/${src}`);
|
|
138
142
|
}
|
|
139
143
|
|
|
140
144
|
/**
|
|
@@ -148,7 +152,7 @@ class VideoPlayer {
|
|
|
148
152
|
this.posterSrc = container.dataset.videoPoster;
|
|
149
153
|
this.captionsSrc = container.dataset.videoCaptions;
|
|
150
154
|
this.required = container.dataset.videoRequired === 'true';
|
|
151
|
-
this.threshold =
|
|
155
|
+
this.threshold = normalizeCompletionThreshold(container.dataset.videoThreshold);
|
|
152
156
|
this.autoplay = container.dataset.videoAutoplay === 'true';
|
|
153
157
|
|
|
154
158
|
if (!this.videoId) {
|
|
@@ -167,6 +171,18 @@ class VideoPlayer {
|
|
|
167
171
|
this.iframe = null; // External video iframe
|
|
168
172
|
this.elements = {};
|
|
169
173
|
this.eventHandlers = {};
|
|
174
|
+
this.domEventHandlers = {
|
|
175
|
+
click: this._handleClick.bind(this),
|
|
176
|
+
progressKeydown: this._handleProgressKeydown.bind(this),
|
|
177
|
+
progressMousedown: this._handleProgressMousedown.bind(this),
|
|
178
|
+
fullscreenChange: this._handleFullscreenChange.bind(this),
|
|
179
|
+
videoDoubleClick: () => this._toggleFullscreen(),
|
|
180
|
+
overlayClick: (event) => {
|
|
181
|
+
event.stopPropagation();
|
|
182
|
+
this._togglePlayPause();
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
this.dragCleanup = null;
|
|
170
186
|
this.isFullscreen = false;
|
|
171
187
|
|
|
172
188
|
this._render();
|
|
@@ -311,35 +327,32 @@ class VideoPlayer {
|
|
|
311
327
|
// External videos use platform's native controls
|
|
312
328
|
if (this.isExternal) {
|
|
313
329
|
// Only fullscreen detection for wrapper
|
|
314
|
-
document.addEventListener('fullscreenchange', this.
|
|
315
|
-
document.addEventListener('webkitfullscreenchange', this.
|
|
330
|
+
document.addEventListener('fullscreenchange', this.domEventHandlers.fullscreenChange);
|
|
331
|
+
document.addEventListener('webkitfullscreenchange', this.domEventHandlers.fullscreenChange);
|
|
316
332
|
return;
|
|
317
333
|
}
|
|
318
334
|
|
|
319
335
|
// Control bar click delegation
|
|
320
|
-
this.container.addEventListener('click', this.
|
|
336
|
+
this.container.addEventListener('click', this.domEventHandlers.click);
|
|
321
337
|
|
|
322
338
|
// Progress bar interactions
|
|
323
339
|
if (this.elements.progressBar) {
|
|
324
|
-
this.elements.progressBar.addEventListener('keydown', this.
|
|
325
|
-
this.elements.progressBar.addEventListener('mousedown', this.
|
|
340
|
+
this.elements.progressBar.addEventListener('keydown', this.domEventHandlers.progressKeydown);
|
|
341
|
+
this.elements.progressBar.addEventListener('mousedown', this.domEventHandlers.progressMousedown);
|
|
326
342
|
}
|
|
327
343
|
|
|
328
344
|
// Fullscreen change detection
|
|
329
|
-
document.addEventListener('fullscreenchange', this.
|
|
330
|
-
document.addEventListener('webkitfullscreenchange', this.
|
|
345
|
+
document.addEventListener('fullscreenchange', this.domEventHandlers.fullscreenChange);
|
|
346
|
+
document.addEventListener('webkitfullscreenchange', this.domEventHandlers.fullscreenChange);
|
|
331
347
|
|
|
332
348
|
// Double-click video to toggle fullscreen
|
|
333
349
|
if (this.video) {
|
|
334
|
-
this.video.addEventListener('dblclick',
|
|
350
|
+
this.video.addEventListener('dblclick', this.domEventHandlers.videoDoubleClick);
|
|
335
351
|
}
|
|
336
352
|
|
|
337
353
|
// Click overlay to play
|
|
338
354
|
if (this.elements.overlay) {
|
|
339
|
-
this.elements.overlay.addEventListener('click',
|
|
340
|
-
e.stopPropagation();
|
|
341
|
-
this._togglePlayPause();
|
|
342
|
-
});
|
|
355
|
+
this.elements.overlay.addEventListener('click', this.domEventHandlers.overlayClick);
|
|
343
356
|
}
|
|
344
357
|
}
|
|
345
358
|
|
|
@@ -447,8 +460,11 @@ class VideoPlayer {
|
|
|
447
460
|
_handleProgressMousedown(event) {
|
|
448
461
|
if (!this.video || !this.video.duration) return;
|
|
449
462
|
|
|
463
|
+
this.dragCleanup?.();
|
|
464
|
+
|
|
450
465
|
const progressBar = this.elements.progressBar;
|
|
451
466
|
const rect = progressBar.getBoundingClientRect();
|
|
467
|
+
if (rect.width <= 0) return;
|
|
452
468
|
|
|
453
469
|
const seek = (clientX) => {
|
|
454
470
|
const pct = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
|
|
@@ -458,15 +474,19 @@ class VideoPlayer {
|
|
|
458
474
|
seek(event.clientX);
|
|
459
475
|
|
|
460
476
|
const onMouseMove = (e) => seek(e.clientX);
|
|
461
|
-
const
|
|
477
|
+
const cleanupDrag = () => {
|
|
462
478
|
document.removeEventListener('mousemove', onMouseMove);
|
|
463
|
-
document.removeEventListener('mouseup',
|
|
479
|
+
document.removeEventListener('mouseup', cleanupDrag);
|
|
464
480
|
progressBar.classList.remove('dragging');
|
|
481
|
+
if (this.dragCleanup === cleanupDrag) {
|
|
482
|
+
this.dragCleanup = null;
|
|
483
|
+
}
|
|
465
484
|
};
|
|
466
485
|
|
|
467
486
|
progressBar.classList.add('dragging');
|
|
468
487
|
document.addEventListener('mousemove', onMouseMove);
|
|
469
|
-
document.addEventListener('mouseup',
|
|
488
|
+
document.addEventListener('mouseup', cleanupDrag);
|
|
489
|
+
this.dragCleanup = cleanupDrag;
|
|
470
490
|
}
|
|
471
491
|
|
|
472
492
|
_attachToManager() {
|
|
@@ -605,6 +625,8 @@ class VideoPlayer {
|
|
|
605
625
|
}
|
|
606
626
|
|
|
607
627
|
destroy() {
|
|
628
|
+
this.dragCleanup?.();
|
|
629
|
+
|
|
608
630
|
// Unsubscribe from events
|
|
609
631
|
eventBus.off('video:loaded', this.eventHandlers.loaded);
|
|
610
632
|
eventBus.off('video:play', this.eventHandlers.play);
|
|
@@ -616,11 +638,15 @@ class VideoPlayer {
|
|
|
616
638
|
// Detach from manager
|
|
617
639
|
if (this.video) {
|
|
618
640
|
videoManager.detachVideo(this.video);
|
|
641
|
+
this.video.removeEventListener('dblclick', this.domEventHandlers.videoDoubleClick);
|
|
619
642
|
}
|
|
620
643
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
644
|
+
this.container.removeEventListener('click', this.domEventHandlers.click);
|
|
645
|
+
this.elements.progressBar?.removeEventListener('keydown', this.domEventHandlers.progressKeydown);
|
|
646
|
+
this.elements.progressBar?.removeEventListener('mousedown', this.domEventHandlers.progressMousedown);
|
|
647
|
+
this.elements.overlay?.removeEventListener('click', this.domEventHandlers.overlayClick);
|
|
648
|
+
document.removeEventListener('fullscreenchange', this.domEventHandlers.fullscreenChange);
|
|
649
|
+
document.removeEventListener('webkitfullscreenchange', this.domEventHandlers.fullscreenChange);
|
|
624
650
|
|
|
625
651
|
playerInstances.delete(this.videoId);
|
|
626
652
|
logger.debug(`[VideoPlayer] Destroyed: ${this.videoId}`);
|
|
@@ -137,16 +137,16 @@ class EventBus {
|
|
|
137
137
|
|
|
138
138
|
// Create a copy of listeners to avoid issues if listeners modify the array
|
|
139
139
|
const listeners = [...this.events[event]];
|
|
140
|
-
const onceListeners = [];
|
|
141
140
|
|
|
142
141
|
listeners.forEach(listener => {
|
|
142
|
+
// Remove one-shot listeners before invocation so thrown callbacks and
|
|
143
|
+
// re-entrant emits cannot execute them more than once.
|
|
144
|
+
if (listener.once) {
|
|
145
|
+
this.off(event, listener.id);
|
|
146
|
+
}
|
|
147
|
+
|
|
143
148
|
try {
|
|
144
149
|
listener.callback(data);
|
|
145
|
-
|
|
146
|
-
// Track once listeners for removal
|
|
147
|
-
if (listener.once) {
|
|
148
|
-
onceListeners.push(listener.id);
|
|
149
|
-
}
|
|
150
150
|
} catch (error) {
|
|
151
151
|
// Log the error but don't break other listeners — use safeStringify
|
|
152
152
|
// to prevent a secondary cascade from unserializable error objects
|
|
@@ -154,8 +154,6 @@ class EventBus {
|
|
|
154
154
|
}
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
-
// Remove once listeners
|
|
158
|
-
onceListeners.forEach(id => this.off(event, id));
|
|
159
157
|
} finally {
|
|
160
158
|
if (isErrorEvent) {
|
|
161
159
|
this._emittingError = false;
|
|
@@ -177,37 +175,42 @@ class EventBus {
|
|
|
177
175
|
const hasListeners = Boolean(this.events[event]?.length);
|
|
178
176
|
if (!isErrorEvent && !hasListeners) return false;
|
|
179
177
|
|
|
178
|
+
if (isErrorEvent && this._emittingError) {
|
|
179
|
+
logger.warn(`[EventBus] Suppressed recursive error event: ${event}`);
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
180
183
|
if (isErrorEvent) {
|
|
181
|
-
if (this._emittingError) {
|
|
182
|
-
logger.warn(`[EventBus] Suppressed recursive error event: ${event}`);
|
|
183
|
-
return false;
|
|
184
|
-
}
|
|
185
184
|
this._emittingError = true;
|
|
186
|
-
logger.error(`[EventBus Error] ${event}:`, safeStringify(data));
|
|
187
|
-
this._emittingError = false;
|
|
188
185
|
}
|
|
189
186
|
|
|
190
|
-
|
|
187
|
+
try {
|
|
188
|
+
if (isErrorEvent) {
|
|
189
|
+
logger.error(`[EventBus Error] ${event}:`, safeStringify(data));
|
|
190
|
+
}
|
|
191
191
|
|
|
192
|
-
|
|
193
|
-
const onceListeners = [];
|
|
192
|
+
if (!hasListeners) return false;
|
|
194
193
|
|
|
195
|
-
|
|
196
|
-
try {
|
|
197
|
-
await listener.callback(data);
|
|
194
|
+
const listeners = [...this.events[event]];
|
|
198
195
|
|
|
196
|
+
for (const listener of listeners) {
|
|
199
197
|
if (listener.once) {
|
|
200
|
-
|
|
198
|
+
this.off(event, listener.id);
|
|
201
199
|
}
|
|
202
|
-
} catch (error) {
|
|
203
|
-
logger.error(`[EventBus] Error in async listener for '${event}':`, error);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
200
|
|
|
207
|
-
|
|
208
|
-
|
|
201
|
+
try {
|
|
202
|
+
await listener.callback(data);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
logger.error(`[EventBus] Error in async listener for '${event}':`, error);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
209
207
|
|
|
210
|
-
|
|
208
|
+
return true;
|
|
209
|
+
} finally {
|
|
210
|
+
if (isErrorEvent) {
|
|
211
|
+
this._emittingError = false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
211
214
|
}
|
|
212
215
|
|
|
213
216
|
/**
|
|
@@ -42,7 +42,7 @@ const DYNAMIC_CLASSES = new Set([
|
|
|
42
42
|
// Component-internal classes — styled via [data-component] selectors in individual component CSS files
|
|
43
43
|
'intro-card', 'card-icon',
|
|
44
44
|
// Interaction-internal classes — used by interaction JS for DOM structure
|
|
45
|
-
'drag-drop', 'matching-items', 'matching-targets',
|
|
45
|
+
'drag-drop', 'matching-items', 'matching-targets', 'image-container', 'hotspot-feedback',
|
|
46
46
|
// Slide-specific JS selectors — queried by slide scripts for event binding
|
|
47
47
|
'resources', 'complete-remedial-btn',
|
|
48
48
|
]);
|
|
@@ -833,6 +833,7 @@ function validateVisualLayout(slideId, renderedContent, errors, warnings) {
|
|
|
833
833
|
const textElements = renderedContent.querySelectorAll('p, span:not(.accordion-icon), li, a, h1, h2, h3, h4, h5, h6, button');
|
|
834
834
|
for (const el of textElements) {
|
|
835
835
|
if (isLintIgnored(el, 'contrast')) continue;
|
|
836
|
+
if (el.classList.contains('sr-only') || el.closest('.sr-only')) continue;
|
|
836
837
|
// Skip elements that are not visible or have no text
|
|
837
838
|
if (el.offsetParent === null || el.textContent.trim() === '') continue;
|
|
838
839
|
|
|
@@ -1042,7 +1043,8 @@ function validateTextProximityToVisualElements(slideId, renderedContent, warning
|
|
|
1042
1043
|
'dropdown', 'dropdown-menu', 'dropdown-item',
|
|
1043
1044
|
'table', // Tables manage their own cell spacing
|
|
1044
1045
|
'step-number', 'step-content', 'step', // Pattern-steps elements have intentional circular styling
|
|
1045
|
-
'btn-link' // Link-styled button intentionally has minimal padding
|
|
1046
|
+
'btn-link', // Link-styled button intentionally has minimal padding
|
|
1047
|
+
'hotspot-area', 'interactive-image-hotspot'
|
|
1046
1048
|
]);
|
|
1047
1049
|
|
|
1048
1050
|
// HTML elements that manage their own spacing (exclude from checks)
|
|
@@ -1171,6 +1173,8 @@ function validateElementOverlap(slideId, renderedContent, warnings) {
|
|
|
1171
1173
|
const absoluteElements = renderedContent.querySelectorAll('[style*="position:absolute"], [style*="position: absolute"]');
|
|
1172
1174
|
for (const el of absoluteElements) {
|
|
1173
1175
|
if (el.offsetParent === null) continue;
|
|
1176
|
+
// Hotspots intentionally overlap their background image and sometimes each other.
|
|
1177
|
+
if (el.matches('[data-hotspot-id], .hotspot-area, .interactive-image-hotspot')) continue;
|
|
1174
1178
|
|
|
1175
1179
|
const rect = el.getBoundingClientRect();
|
|
1176
1180
|
const elText = el.textContent.trim().substring(0, 30);
|