coursecode 0.1.57 → 0.1.58
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/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/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/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 +131 -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-packaging.js +47 -3
- package/lib/build.js +1 -1
- 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/vite.config.js +50 -22
|
@@ -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
|
}
|
|
@@ -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}`);
|