coursecode 0.1.0
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/LICENSE +21 -0
- package/README.md +322 -0
- package/THIRD_PARTY_NOTICES.md +22 -0
- package/bin/cli.js +331 -0
- package/framework/assets/logo-coursecode-black.svg +14 -0
- package/framework/assets/logo-coursecode-white.svg +14 -0
- package/framework/assets/logo-coursecode.svg +14 -0
- package/framework/css/01-base.css +160 -0
- package/framework/css/02-layout.css +499 -0
- package/framework/css/accessibility.css +834 -0
- package/framework/css/components/accordions.css +710 -0
- package/framework/css/components/assessments.css +520 -0
- package/framework/css/components/audio-player.css +570 -0
- package/framework/css/components/badges.css +80 -0
- package/framework/css/components/breadcrumbs.css +87 -0
- package/framework/css/components/buttons.css +707 -0
- package/framework/css/components/callouts.css +1280 -0
- package/framework/css/components/cards.css +475 -0
- package/framework/css/components/carousel.css +193 -0
- package/framework/css/components/checkbox-group.css +123 -0
- package/framework/css/components/checklist.css +203 -0
- package/framework/css/components/collapse.css +96 -0
- package/framework/css/components/comparison.css +33 -0
- package/framework/css/components/content-image.css +36 -0
- package/framework/css/components/document-gallery.css +425 -0
- package/framework/css/components/dropdown.css +115 -0
- package/framework/css/components/embed-frame.css +142 -0
- package/framework/css/components/engagement.css +412 -0
- package/framework/css/components/features.css +35 -0
- package/framework/css/components/flip-cards.css +253 -0
- package/framework/css/components/footer.css +353 -0
- package/framework/css/components/forms.css +294 -0
- package/framework/css/components/hero.css +216 -0
- package/framework/css/components/images.css +528 -0
- package/framework/css/components/interactive-timeline.css +274 -0
- package/framework/css/components/intro-cards.css +30 -0
- package/framework/css/components/lightbox.css +666 -0
- package/framework/css/components/loading.css +65 -0
- package/framework/css/components/modals.css +235 -0
- package/framework/css/components/notifications.css +107 -0
- package/framework/css/components/quote.css +150 -0
- package/framework/css/components/sidebar.css +684 -0
- package/framework/css/components/slide-header.css +52 -0
- package/framework/css/components/spinner.css +62 -0
- package/framework/css/components/stats.css +44 -0
- package/framework/css/components/steps.css +232 -0
- package/framework/css/components/tables.css +90 -0
- package/framework/css/components/tabs.css +347 -0
- package/framework/css/components/timeline.css +154 -0
- package/framework/css/components/toggle.css +95 -0
- package/framework/css/components/tooltip.css +226 -0
- package/framework/css/components/video-player.css +438 -0
- package/framework/css/design-tokens.css +707 -0
- package/framework/css/framework.css +86 -0
- package/framework/css/interactions/accessibility.css +75 -0
- package/framework/css/interactions/base.css +92 -0
- package/framework/css/interactions/drag-drop.css +295 -0
- package/framework/css/interactions/fill-in-the-blank.css +236 -0
- package/framework/css/interactions/hotspots.css +69 -0
- package/framework/css/interactions/index.css +45 -0
- package/framework/css/interactions/interactive-image.css +359 -0
- package/framework/css/interactions/likert.css +126 -0
- package/framework/css/interactions/matching.css +354 -0
- package/framework/css/interactions/numeric-input.css +78 -0
- package/framework/css/interactions/sequencing.css +378 -0
- package/framework/css/interactions/true-false.css +177 -0
- package/framework/css/layouts/article.css +258 -0
- package/framework/css/layouts/base.css +30 -0
- package/framework/css/layouts/canvas.css +38 -0
- package/framework/css/layouts/focused.css +236 -0
- package/framework/css/layouts/index.css +29 -0
- package/framework/css/layouts/presentation.css +191 -0
- package/framework/css/layouts/traditional.css +52 -0
- package/framework/css/responsive.css +439 -0
- package/framework/css/utilities/accessibility-utils.css +59 -0
- package/framework/css/utilities/animations.css +419 -0
- package/framework/css/utilities/borders.css +72 -0
- package/framework/css/utilities/colors.css +76 -0
- package/framework/css/utilities/container.css +46 -0
- package/framework/css/utilities/decorative.css +442 -0
- package/framework/css/utilities/display.css +257 -0
- package/framework/css/utilities/flexbox.css +80 -0
- package/framework/css/utilities/grid.css +69 -0
- package/framework/css/utilities/icons.css +534 -0
- package/framework/css/utilities/lists.css +190 -0
- package/framework/css/utilities/spacing.css +167 -0
- package/framework/css/utilities/tables.css +81 -0
- package/framework/css/utilities/typography.css +159 -0
- package/framework/css/utilities/visibility.css +117 -0
- package/framework/docs/COURSE_AUTHORING_GUIDE.md +1773 -0
- package/framework/docs/COURSE_OUTLINE_GUIDE.md +725 -0
- package/framework/docs/COURSE_OUTLINE_TEMPLATE.md +161 -0
- package/framework/docs/DATA_MODEL.md +409 -0
- package/framework/docs/FRAMEWORK_GUIDE.md +1088 -0
- package/framework/docs/USER_GUIDE.md +583 -0
- package/framework/docs/examples/cloudflare-channel-relay.js +169 -0
- package/framework/docs/examples/cloudflare-data-worker.js +102 -0
- package/framework/docs/examples/cloudflare-error-worker.js +228 -0
- package/framework/index.html +175 -0
- package/framework/js/app/AppActions.js +410 -0
- package/framework/js/app/AppState.js +225 -0
- package/framework/js/app/AppUI.js +616 -0
- package/framework/js/assessment/AssessmentActions.js +615 -0
- package/framework/js/assessment/AssessmentFactory.js +471 -0
- package/framework/js/assessment/AssessmentState.js +322 -0
- package/framework/js/assessment/AssessmentUI.js +451 -0
- package/framework/js/automation/api-engagement.js +196 -0
- package/framework/js/automation/api-interactions.js +167 -0
- package/framework/js/automation/api.js +242 -0
- package/framework/js/automation/index.js +41 -0
- package/framework/js/components/interactions/drag-drop.js +884 -0
- package/framework/js/components/interactions/fill-in.js +535 -0
- package/framework/js/components/interactions/hotspot.js +702 -0
- package/framework/js/components/interactions/interaction-base.js +511 -0
- package/framework/js/components/interactions/likert.js +301 -0
- package/framework/js/components/interactions/matching.js +699 -0
- package/framework/js/components/interactions/multiple-choice.js +377 -0
- package/framework/js/components/interactions/numeric.js +271 -0
- package/framework/js/components/interactions/sequencing.js +423 -0
- package/framework/js/components/interactions/true-false.js +241 -0
- package/framework/js/components/ui-components/accordion.js +442 -0
- package/framework/js/components/ui-components/alert.js +88 -0
- package/framework/js/components/ui-components/audio-player.js +1193 -0
- package/framework/js/components/ui-components/callout.js +121 -0
- package/framework/js/components/ui-components/carousel.js +145 -0
- package/framework/js/components/ui-components/checkbox-group.js +87 -0
- package/framework/js/components/ui-components/checklist.js +40 -0
- package/framework/js/components/ui-components/collapse.js +114 -0
- package/framework/js/components/ui-components/comparison.js +30 -0
- package/framework/js/components/ui-components/conditional-display.js +150 -0
- package/framework/js/components/ui-components/content-image.js +41 -0
- package/framework/js/components/ui-components/dropdown.js +262 -0
- package/framework/js/components/ui-components/embed-frame.js +274 -0
- package/framework/js/components/ui-components/features.js +33 -0
- package/framework/js/components/ui-components/flip-card.js +230 -0
- package/framework/js/components/ui-components/form-validator.js +76 -0
- package/framework/js/components/ui-components/hero.js +49 -0
- package/framework/js/components/ui-components/index.js +12 -0
- package/framework/js/components/ui-components/interactive-image.js +235 -0
- package/framework/js/components/ui-components/interactive-timeline.js +285 -0
- package/framework/js/components/ui-components/intro-cards.js +35 -0
- package/framework/js/components/ui-components/lightbox.js +652 -0
- package/framework/js/components/ui-components/modal.js +386 -0
- package/framework/js/components/ui-components/notifications.js +145 -0
- package/framework/js/components/ui-components/progress.js +88 -0
- package/framework/js/components/ui-components/quote.js +41 -0
- package/framework/js/components/ui-components/stats.js +33 -0
- package/framework/js/components/ui-components/steps.js +41 -0
- package/framework/js/components/ui-components/tabs.js +255 -0
- package/framework/js/components/ui-components/timeline.js +42 -0
- package/framework/js/components/ui-components/toggle-group.js +73 -0
- package/framework/js/components/ui-components/tooltip.js +458 -0
- package/framework/js/components/ui-components/value-display.js +133 -0
- package/framework/js/components/ui-components/video-player.js +686 -0
- package/framework/js/core/component-catalog.js +121 -0
- package/framework/js/core/event-bus.js +178 -0
- package/framework/js/core/interaction-catalog.js +149 -0
- package/framework/js/dev/runtime-linter.js +1725 -0
- package/framework/js/drivers/cmi5-driver.js +768 -0
- package/framework/js/drivers/driver-factory.js +77 -0
- package/framework/js/drivers/driver-interface.js +110 -0
- package/framework/js/drivers/http-driver-base.js +241 -0
- package/framework/js/drivers/lti-driver.js +508 -0
- package/framework/js/drivers/proxy-driver.js +444 -0
- package/framework/js/drivers/scorm-12-driver.js +560 -0
- package/framework/js/drivers/scorm-2004-driver.js +775 -0
- package/framework/js/drivers/scorm-driver-base.js +112 -0
- package/framework/js/engagement/engagement-manager.js +404 -0
- package/framework/js/engagement/engagement-progress.js +191 -0
- package/framework/js/engagement/engagement-trackers.js +215 -0
- package/framework/js/engagement/requirement-strategies.js +268 -0
- package/framework/js/main.js +727 -0
- package/framework/js/managers/accessibility-manager.js +499 -0
- package/framework/js/managers/assessment-manager.js +230 -0
- package/framework/js/managers/audio-manager.js +944 -0
- package/framework/js/managers/comment-manager.js +88 -0
- package/framework/js/managers/flag-manager.js +86 -0
- package/framework/js/managers/interaction-manager.js +254 -0
- package/framework/js/managers/interaction-registry.js +96 -0
- package/framework/js/managers/objective-manager.js +423 -0
- package/framework/js/managers/score-manager.js +441 -0
- package/framework/js/managers/video-manager.js +536 -0
- package/framework/js/navigation/Breadcrumbs.js +234 -0
- package/framework/js/navigation/NavigationActions.js +1132 -0
- package/framework/js/navigation/NavigationState.js +276 -0
- package/framework/js/navigation/NavigationUI.js +574 -0
- package/framework/js/navigation/document-gallery.js +357 -0
- package/framework/js/navigation/navigation-helpers.js +175 -0
- package/framework/js/navigation/navigation-validators.js +174 -0
- package/framework/js/state/index.js +8 -0
- package/framework/js/state/lms-connection.js +482 -0
- package/framework/js/state/lms-error-utils.js +58 -0
- package/framework/js/state/state-commits.js +200 -0
- package/framework/js/state/state-domains.js +86 -0
- package/framework/js/state/state-manager.js +502 -0
- package/framework/js/state/state-validation.js +311 -0
- package/framework/js/state/transaction-log.js +41 -0
- package/framework/js/state/xapi-statement-service.js +325 -0
- package/framework/js/utilities/access-control.js +99 -0
- package/framework/js/utilities/breakpoint-manager.js +315 -0
- package/framework/js/utilities/canvas-slide.js +35 -0
- package/framework/js/utilities/conditional-display.js +388 -0
- package/framework/js/utilities/course-channel.js +214 -0
- package/framework/js/utilities/course-helpers.js +420 -0
- package/framework/js/utilities/data-reporter.js +273 -0
- package/framework/js/utilities/error-reporter.js +313 -0
- package/framework/js/utilities/hotspot-helper.js +341 -0
- package/framework/js/utilities/icons.js +348 -0
- package/framework/js/utilities/logger.js +92 -0
- package/framework/js/utilities/markdown-renderer.js +45 -0
- package/framework/js/utilities/scroll-tracker.js +68 -0
- package/framework/js/utilities/ui-initializer.js +146 -0
- package/framework/js/utilities/utilities.js +293 -0
- package/framework/js/utilities/view-manager.js +227 -0
- package/framework/js/validation/html-validators.js +422 -0
- package/framework/js/validation/scorm-validators.js +438 -0
- package/framework/js/vendor/pipwerks.js +931 -0
- package/framework/scripts/generate-narration.js +629 -0
- package/framework/scripts/tts-providers/azure-provider.js +178 -0
- package/framework/scripts/tts-providers/base-provider.js +81 -0
- package/framework/scripts/tts-providers/deepgram-provider.js +135 -0
- package/framework/scripts/tts-providers/elevenlabs-provider.js +148 -0
- package/framework/scripts/tts-providers/google-provider.js +272 -0
- package/framework/scripts/tts-providers/index.js +158 -0
- package/framework/scripts/tts-providers/openai-provider.js +143 -0
- package/framework/version.json +63 -0
- package/lib/authoring-api.js +919 -0
- package/lib/build-linter.js +450 -0
- package/lib/build-packaging.js +186 -0
- package/lib/build.js +88 -0
- package/lib/cloud.js +691 -0
- package/lib/convert.js +341 -0
- package/lib/course-parser.js +936 -0
- package/lib/course-writer.js +258 -0
- package/lib/create.js +248 -0
- package/lib/css-index.js +237 -0
- package/lib/dev.js +51 -0
- package/lib/export-content.js +1246 -0
- package/lib/headless-browser.js +413 -0
- package/lib/import.js +377 -0
- package/lib/index.js +80 -0
- package/lib/info.js +79 -0
- package/lib/interaction-formatters.js +568 -0
- package/lib/manifest/cmi5-manifest.js +63 -0
- package/lib/manifest/lti-tool-config.js +53 -0
- package/lib/manifest/manifest-factory.js +99 -0
- package/lib/manifest/scorm-12-manifest.js +61 -0
- package/lib/manifest/scorm-2004-manifest.js +94 -0
- package/lib/manifest/scorm-proxy-manifest.js +104 -0
- package/lib/manifest-parser.js +96 -0
- package/lib/mcp-prompts.js +753 -0
- package/lib/mcp-server.js +316 -0
- package/lib/narration.js +53 -0
- package/lib/pdf-structure.js +142 -0
- package/lib/preview-export.js +231 -0
- package/lib/preview-routes-api.js +662 -0
- package/lib/preview-routes-editing.js +159 -0
- package/lib/preview-routes-lms.js +230 -0
- package/lib/preview-server.js +564 -0
- package/lib/project-utils.js +269 -0
- package/lib/proxy-templates/proxy.html +68 -0
- package/lib/proxy-templates/scorm-bridge.js +112 -0
- package/lib/scaffold.js +193 -0
- package/lib/schema-extractor.js +361 -0
- package/lib/slide-source-editor.js +586 -0
- package/lib/stub-player/app-viewer.js +195 -0
- package/lib/stub-player/app.js +370 -0
- package/lib/stub-player/catalog-panel.js +312 -0
- package/lib/stub-player/config-panel.js +1303 -0
- package/lib/stub-player/content-generator.js +586 -0
- package/lib/stub-player/content-viewer.js +173 -0
- package/lib/stub-player/debug-panel.js +420 -0
- package/lib/stub-player/edit-mode.js +922 -0
- package/lib/stub-player/edit-utils.js +400 -0
- package/lib/stub-player/header-bar.js +354 -0
- package/lib/stub-player/interaction-editor.js +210 -0
- package/lib/stub-player/interactions-panel.js +565 -0
- package/lib/stub-player/lms-api.js +1094 -0
- package/lib/stub-player/login-screen.js +74 -0
- package/lib/stub-player/outline-mode.js +689 -0
- package/lib/stub-player/styles/_assessments-panel.css +245 -0
- package/lib/stub-player/styles/_base.css +89 -0
- package/lib/stub-player/styles/_catalog-icons.css +96 -0
- package/lib/stub-player/styles/_catalog-panel.css +291 -0
- package/lib/stub-player/styles/_config-panel.css +636 -0
- package/lib/stub-player/styles/_content-viewer.css +834 -0
- package/lib/stub-player/styles/_debug-panel.css +576 -0
- package/lib/stub-player/styles/_edit-mode.css +128 -0
- package/lib/stub-player/styles/_header-bar.css +343 -0
- package/lib/stub-player/styles/_interaction-editor.css +140 -0
- package/lib/stub-player/styles/_interactions-panel.css +1038 -0
- package/lib/stub-player/styles/_login-screen.css +102 -0
- package/lib/stub-player/styles/_outline-mode.css +752 -0
- package/lib/stub-player/styles.css +15 -0
- package/lib/stub-player.js +160 -0
- package/lib/test-data-reporting.js +176 -0
- package/lib/test-error-reporting.js +146 -0
- package/lib/token.js +86 -0
- package/lib/upgrade.js +257 -0
- package/lib/validation-rules.js +517 -0
- package/lib/vite-plugin-content-discovery.js +296 -0
- package/package.json +108 -0
- package/schemas/XMLSchema.dtd +402 -0
- package/schemas/adlcp_v1p3.xsd +111 -0
- package/schemas/adlnav_v1p3.xsd +61 -0
- package/schemas/adlseq_v1p3.xsd +93 -0
- package/schemas/common/anyElement.xsd +27 -0
- package/schemas/common/dataTypes.xsd +138 -0
- package/schemas/common/elementNames.xsd +767 -0
- package/schemas/common/elementTypes.xsd +786 -0
- package/schemas/common/rootElement.xsd +31 -0
- package/schemas/common/vocabTypes.xsd +345 -0
- package/schemas/common/vocabValues.xsd +257 -0
- package/schemas/datatypes.dtd +203 -0
- package/schemas/ims_xml.xsd +35 -0
- package/schemas/imscp_v1p1.xsd +368 -0
- package/schemas/imsss_v1p0.xsd +67 -0
- package/schemas/imsss_v1p0auxresource.xsd +19 -0
- package/schemas/imsss_v1p0control.xsd +20 -0
- package/schemas/imsss_v1p0delivery.xsd +17 -0
- package/schemas/imsss_v1p0limit.xsd +47 -0
- package/schemas/imsss_v1p0objective.xsd +67 -0
- package/schemas/imsss_v1p0random.xsd +16 -0
- package/schemas/imsss_v1p0rollup.xsd +46 -0
- package/schemas/imsss_v1p0seqrule.xsd +108 -0
- package/schemas/imsss_v1p0util.xsd +94 -0
- package/schemas/license.txt +17 -0
- package/schemas/lom.xsd +102 -0
- package/schemas/lomCustom.xsd +62 -0
- package/schemas/lomLoose.xsd +62 -0
- package/schemas/lomStrict.xsd +62 -0
- package/schemas/xml.xsd +81 -0
- package/template/.env.example +92 -0
- package/template/course/assets/audio/example-intro.mp3 +0 -0
- package/template/course/assets/audio/example-ui-demo--compact-player.mp3 +0 -0
- package/template/course/assets/audio/example-ui-demo--demo-modal.mp3 +0 -0
- package/template/course/assets/audio/example-ui-demo--full-player.mp3 +0 -0
- package/template/course/assets/docs/example_md_1.md +39 -0
- package/template/course/assets/docs/example_md_2.md +41 -0
- package/template/course/assets/docs/example_pdf_1_thumbnail.png +0 -0
- package/template/course/assets/docs/example_pdf_2.pdf +0 -0
- package/template/course/assets/images/course-architecture.svg +36 -0
- package/template/course/assets/images/logo.svg +14 -0
- package/template/course/assets/widgets/counter-demo.html +190 -0
- package/template/course/assets/widgets/gravity-painter.html +384 -0
- package/template/course/course-config.js +539 -0
- package/template/course/icons.js +19 -0
- package/template/course/interactions/PLUGIN_GUIDE.md +97 -0
- package/template/course/slides/example-course-structure.js +138 -0
- package/template/course/slides/example-final-exam.js +144 -0
- package/template/course/slides/example-finishing.js +127 -0
- package/template/course/slides/example-interactions-showcase.js +615 -0
- package/template/course/slides/example-preview-tour.js +129 -0
- package/template/course/slides/example-remedial.js +143 -0
- package/template/course/slides/example-summary.js +103 -0
- package/template/course/slides/example-ui-showcase.js +1805 -0
- package/template/course/slides/example-welcome.js +123 -0
- package/template/course/slides/example-workflow.js +140 -0
- package/template/course/theme.css +165 -0
- package/template/eslint.config.js +47 -0
- package/template/package.json +28 -0
- package/template/vite.config.js +339 -0
|
@@ -0,0 +1,1132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file NavigationActions.js
|
|
3
|
+
* @description Handles user interactions for course navigation and coordinates between state and UI.
|
|
4
|
+
* @author Seth
|
|
5
|
+
* @version 2.0.0
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as NavigationState from './NavigationState.js';
|
|
9
|
+
import * as NavigationUI from './NavigationUI.js';
|
|
10
|
+
import {
|
|
11
|
+
shouldBypassEngagement
|
|
12
|
+
} from './navigation-helpers.js';
|
|
13
|
+
import {
|
|
14
|
+
isSlideInSequence,
|
|
15
|
+
validateSlideAccess,
|
|
16
|
+
validateNavigationFrom
|
|
17
|
+
} from './navigation-validators.js';
|
|
18
|
+
import stateManager from '../state/index.js';
|
|
19
|
+
import * as CourseHelpers from '../utilities/course-helpers.js';
|
|
20
|
+
import * as AssessmentManager from '../managers/assessment-manager.js';
|
|
21
|
+
import * as AppActions from '../app/AppActions.js';
|
|
22
|
+
import * as AppUI from '../app/AppUI.js';
|
|
23
|
+
import { eventBus } from '../core/event-bus.js';
|
|
24
|
+
import engagementManager from '../engagement/engagement-manager.js';
|
|
25
|
+
import { logger } from '../utilities/logger.js';
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
let slides = [];
|
|
29
|
+
let menuTree = [];
|
|
30
|
+
let viewManager;
|
|
31
|
+
let assessmentConfigs = new Map();
|
|
32
|
+
let navigationLocked = false;
|
|
33
|
+
let isInitialized = false;
|
|
34
|
+
|
|
35
|
+
// Navigation queue for handling async navigation requests
|
|
36
|
+
const navigationQueue = [];
|
|
37
|
+
let isNavigating = false;
|
|
38
|
+
|
|
39
|
+
// Engagement progress handlers for current slide
|
|
40
|
+
let currentEngagementProgressHandler = null;
|
|
41
|
+
let currentEngagementCompleteHandler = null;
|
|
42
|
+
|
|
43
|
+
// ===== ERROR HANDLING =====
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Creates a standardized navigation error and emits error event.
|
|
47
|
+
* Use this for actual system errors, NOT for expected user-facing blocks.
|
|
48
|
+
* @private
|
|
49
|
+
* @param {string} operation - The operation that failed
|
|
50
|
+
* @param {string} message - Error message
|
|
51
|
+
* @param {object} context - Additional context for debugging
|
|
52
|
+
* @returns {Error} The created error object
|
|
53
|
+
*/
|
|
54
|
+
function _createNavigationError(operation, message, context = {}) {
|
|
55
|
+
const error = new Error(`Navigation failed: ${message}`);
|
|
56
|
+
logger.error(error.message, { domain: 'navigation', operation, stack: error.stack, ...context });
|
|
57
|
+
return error;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Creates a navigation block error WITHOUT emitting to error reporter.
|
|
62
|
+
* Use this for expected user-facing blocks (gating conditions, sequence exclusions)
|
|
63
|
+
* where the user is simply trying to access locked content.
|
|
64
|
+
* @private
|
|
65
|
+
* @param {string} message - User-facing message (already shown via notification)
|
|
66
|
+
* @returns {Error} The created error object
|
|
67
|
+
*/
|
|
68
|
+
function _createNavigationBlockError(message) {
|
|
69
|
+
return new Error(`Navigation blocked: ${message}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ===== INITIALIZATION =====
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Initializes the navigation actions module.
|
|
76
|
+
* Caches the course slide data and sets up event listeners for navigation controls.
|
|
77
|
+
* @param {object[]} courseSlides - The course slide configuration array from `course-config.js`.
|
|
78
|
+
* @param {object} viewManagerInstance - The view manager instance.
|
|
79
|
+
* @param {object[]} courseMenuTree - The hierarchical menu tree from getMenuTree().
|
|
80
|
+
* @param {Map} courseAssessmentConfigs - A map of assessment configurations from getAssessmentConfigs().
|
|
81
|
+
*/
|
|
82
|
+
export async function init(courseSlides, viewManagerInstance, courseMenuTree = [], courseAssessmentConfigs = new Map()) {
|
|
83
|
+
if (!courseSlides || !Array.isArray(courseSlides)) {
|
|
84
|
+
throw new Error('NavigationActions.init() requires a valid slides array');
|
|
85
|
+
}
|
|
86
|
+
if (!viewManagerInstance) {
|
|
87
|
+
throw new Error('NavigationActions.init() requires a valid viewManager instance');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
slides = courseSlides;
|
|
91
|
+
viewManager = viewManagerInstance;
|
|
92
|
+
menuTree = courseMenuTree;
|
|
93
|
+
assessmentConfigs = courseAssessmentConfigs;
|
|
94
|
+
navigationLocked = false;
|
|
95
|
+
isInitialized = true;
|
|
96
|
+
|
|
97
|
+
// Initialize NavigationState (no longer needs slides parameter)
|
|
98
|
+
NavigationState.initializeNavigationState();
|
|
99
|
+
|
|
100
|
+
// Store slides reference in NavigationState for getCurrentSlideId()
|
|
101
|
+
NavigationState.setSlidesReference(courseSlides);
|
|
102
|
+
|
|
103
|
+
// Render the menu on initialization
|
|
104
|
+
const visitedSlides = NavigationState.getVisitedSlides();
|
|
105
|
+
const accessibilityMap = checkAllSlidesAccessibility();
|
|
106
|
+
NavigationUI.renderMenu(menuTree, visitedSlides, accessibilityMap);
|
|
107
|
+
|
|
108
|
+
// Set up a single delegated event listener for all navigation controls
|
|
109
|
+
document.body.addEventListener('click', (event) => {
|
|
110
|
+
const actionTarget = event.target.closest('[data-action]');
|
|
111
|
+
if (!actionTarget) return;
|
|
112
|
+
|
|
113
|
+
const action = actionTarget.dataset.action;
|
|
114
|
+
|
|
115
|
+
if (navigationLocked) {
|
|
116
|
+
event.preventDefault();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
switch (action) {
|
|
121
|
+
case 'nav-menu-item':
|
|
122
|
+
_handleMenuClick(event);
|
|
123
|
+
break;
|
|
124
|
+
case 'nav-prev':
|
|
125
|
+
_handlePrevClick();
|
|
126
|
+
break;
|
|
127
|
+
case 'nav-next':
|
|
128
|
+
_handleNextClick();
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Re-sync navigation when state changes that might affect gating
|
|
134
|
+
// Listen for state changes in domains that affect gating (assessment_*, objectives, flags)
|
|
135
|
+
eventBus.on('state:changed', ({ domain }) => {
|
|
136
|
+
// Only re-sync if the changed domain could affect gating conditions
|
|
137
|
+
if (domain.startsWith('assessment_') || domain === 'objectives' || domain === 'flags') {
|
|
138
|
+
sync();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
eventBus.on('ui:lockCourseForExit', () => {
|
|
143
|
+
navigationLocked = true;
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Determine initial slide based on session type
|
|
147
|
+
// - RESUME (cmi.entry === 'resume'): Use cmi.location (SCORM standard bookmark)
|
|
148
|
+
// - FIRST LAUNCH (cmi.entry !== 'resume'): Use slide 0 (default start)
|
|
149
|
+
let initialSlideId = null;
|
|
150
|
+
const resumeSlideId = NavigationState.getResumeSlideId();
|
|
151
|
+
|
|
152
|
+
if (resumeSlideId) {
|
|
153
|
+
// RESUME: cmi.location contains bookmark (NavigationState already validated it's not empty)
|
|
154
|
+
logger.debug('[NavigationActions] Resume session. Using cmi.location bookmark:', resumeSlideId);
|
|
155
|
+
|
|
156
|
+
// Validate that the bookmarked slide exists in current course structure
|
|
157
|
+
const resumeSlideIndex = await CourseHelpers.getSlideIndex(resumeSlideId);
|
|
158
|
+
if (resumeSlideIndex === null || resumeSlideIndex === undefined) {
|
|
159
|
+
// Bookmark references non-existent slide (course structure changed or corrupted)
|
|
160
|
+
const errorMessage = `Invalid bookmark in cmi.location: slide "${resumeSlideId}" not found in course structure. ` +
|
|
161
|
+
'This indicates the course structure has changed since the bookmark was created.';
|
|
162
|
+
const errorContext = {
|
|
163
|
+
resumeSlideId,
|
|
164
|
+
availableSlides: slides.map(s => s.id).slice(0, 10), // First 10 for debugging
|
|
165
|
+
totalSlides: slides.length
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
if (import.meta.env.DEV) {
|
|
169
|
+
// Dev mode: FAIL FAST to help developers identify stale data issues
|
|
170
|
+
throw _createNavigationError('resume', errorMessage, errorContext);
|
|
171
|
+
} else {
|
|
172
|
+
// Production mode: Gracefully recover by starting from the beginning
|
|
173
|
+
logger.warn(`[NavigationActions] ${errorMessage} Reverting to slide 0.`);
|
|
174
|
+
eventBus.emit('state:recovered', {
|
|
175
|
+
domain: 'navigation',
|
|
176
|
+
message: errorMessage,
|
|
177
|
+
context: errorContext,
|
|
178
|
+
action: 'reverted_to_slide_0'
|
|
179
|
+
});
|
|
180
|
+
// Fall through to FIRST LAUNCH behavior
|
|
181
|
+
const initialIndex = 0;
|
|
182
|
+
NavigationState.setCurrentSlideIndex(initialIndex);
|
|
183
|
+
initialSlideId = slides[initialIndex]?.id;
|
|
184
|
+
NavigationState.clearResumeSlideId();
|
|
185
|
+
}
|
|
186
|
+
} else {
|
|
187
|
+
initialSlideId = resumeSlideId;
|
|
188
|
+
// Update internal state to match cmi.location
|
|
189
|
+
NavigationState.setCurrentSlideIndex(resumeSlideIndex);
|
|
190
|
+
|
|
191
|
+
// Clear resume flag after processing
|
|
192
|
+
NavigationState.clearResumeSlideId();
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
// FIRST LAUNCH: Start at beginning (currentSlideIndex defaults to 0 in NavigationState)
|
|
196
|
+
const initialIndex = NavigationState.getCurrentSlideIndex();
|
|
197
|
+
initialSlideId = slides[initialIndex]?.id;
|
|
198
|
+
logger.debug('[NavigationActions] First launch. Starting at slide index:', initialIndex);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!initialSlideId) {
|
|
202
|
+
throw _createNavigationError(
|
|
203
|
+
'initial-load',
|
|
204
|
+
'Could not determine initial slide. Check course-config.js structure.',
|
|
205
|
+
{ resumeSlideId, currentIndex: NavigationState.getCurrentSlideIndex() }
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
// Navigate to initial slide and set bookmark
|
|
211
|
+
// Pass updateBookmark=true to ensure cmi.location is set after initialization completes
|
|
212
|
+
// This is critical for both first launch (set initial bookmark) and resume (confirm successful navigation)
|
|
213
|
+
await goToSlide(initialSlideId, {}, true);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
// Check if this is a navigation block (gating/sequence issue on resume)
|
|
216
|
+
const isBlockError = error.message?.includes('Navigation blocked:');
|
|
217
|
+
|
|
218
|
+
if (isBlockError && resumeSlideId) {
|
|
219
|
+
// Resume attempted to navigate to a now-gated slide
|
|
220
|
+
// This should NOT happen in normal operation - it indicates:
|
|
221
|
+
// 1. Course structure changed after learner started
|
|
222
|
+
// 2. Prerequisite state was lost/corrupted
|
|
223
|
+
// 3. A bug in bookmark setting (bookmark set before gating check)
|
|
224
|
+
|
|
225
|
+
if (import.meta.env.DEV) {
|
|
226
|
+
// DEV MODE: FAIL FAST to help identify the root cause
|
|
227
|
+
// The bookmark should NEVER be set to a slide that gating would block
|
|
228
|
+
throw _createNavigationError(
|
|
229
|
+
'resume',
|
|
230
|
+
'BOOKMARK INCONSISTENCY: cmi.location="' + resumeSlideId + '" points to a gated slide. ' +
|
|
231
|
+
'This indicates either: (1) course structure changed after learner started, ' +
|
|
232
|
+
'(2) prerequisite state was lost, or (3) a bug in bookmark setting. ' +
|
|
233
|
+
'Original error: ' + error.message,
|
|
234
|
+
{
|
|
235
|
+
bookmarkedSlide: resumeSlideId,
|
|
236
|
+
blockReason: error.message
|
|
237
|
+
}
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// PRODUCTION: Gracefully recover by finding the first accessible slide
|
|
242
|
+
// BUT still report this as an error - it indicates LMS/course state corruption
|
|
243
|
+
logger.warn(`[NavigationActions] Bookmarked slide "${initialSlideId}" is now gated. Finding first accessible slide.`);
|
|
244
|
+
|
|
245
|
+
const fallbackSlide = _findFirstAccessibleSlide();
|
|
246
|
+
if (fallbackSlide) {
|
|
247
|
+
// Report this anomaly - graceful recovery doesn't mean it's not a problem
|
|
248
|
+
logger.error(`Bookmark inconsistency: cmi.location="${initialSlideId}" points to a gated slide. Recovered to "${fallbackSlide.id}".`, {
|
|
249
|
+
domain: 'navigation', operation: 'resume',
|
|
250
|
+
bookmarkedSlide: initialSlideId, fallbackSlide: fallbackSlide.id,
|
|
251
|
+
blockReason: error.message, action: 'graceful_recovery'
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
eventBus.emit('state:recovered', {
|
|
255
|
+
domain: 'navigation',
|
|
256
|
+
message: `Bookmarked slide "${initialSlideId}" is no longer accessible. Starting from "${fallbackSlide.id}".`,
|
|
257
|
+
context: {
|
|
258
|
+
originalSlide: initialSlideId,
|
|
259
|
+
fallbackSlide: fallbackSlide.id,
|
|
260
|
+
reason: 'gating-condition-on-resume'
|
|
261
|
+
},
|
|
262
|
+
action: 'reverted_to_accessible_slide'
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// Try navigating to the fallback slide
|
|
266
|
+
await goToSlide(fallbackSlide.id, {}, true);
|
|
267
|
+
logger.debug('NavigationActions initialized (with fallback to accessible slide).');
|
|
268
|
+
return; // Success with fallback
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// No accessible slides found - this is a course configuration error
|
|
272
|
+
throw _createNavigationError(
|
|
273
|
+
'initial-load',
|
|
274
|
+
'No accessible slides found. Check course gating configuration.',
|
|
275
|
+
{ originalSlide: initialSlideId }
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// For other errors, emit and re-throw
|
|
280
|
+
logger.error(error.message, { domain: 'navigation', operation: 'initial-load', stack: error.stack, slideId: initialSlideId });
|
|
281
|
+
throw error;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
logger.debug('NavigationActions initialized.');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Finds the first slide that is accessible (passes gating conditions).
|
|
289
|
+
* @private
|
|
290
|
+
* @returns {object|null} The first accessible slide, or null if none found
|
|
291
|
+
*/
|
|
292
|
+
function _findFirstAccessibleSlide() {
|
|
293
|
+
for (const slide of slides) {
|
|
294
|
+
// Check if slide is in sequence
|
|
295
|
+
if (!isSlideInSequence(slide, stateManager, assessmentConfigs)) {
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Check if slide passes gating conditions
|
|
300
|
+
const accessCheck = validateSlideAccess(slide, stateManager, assessmentConfigs);
|
|
301
|
+
if (accessCheck.allowed) {
|
|
302
|
+
return slide;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Throws an error if NavigationActions has not been initialized.
|
|
310
|
+
* @private
|
|
311
|
+
*/
|
|
312
|
+
function _requireInitialized() {
|
|
313
|
+
if (!isInitialized) {
|
|
314
|
+
throw new Error('NavigationActions not initialized. Call init() first.');
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Indicates whether NavigationActions has finished initialization.
|
|
320
|
+
* @returns {boolean}
|
|
321
|
+
*/
|
|
322
|
+
export function isReady() {
|
|
323
|
+
return isInitialized;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
// ===== ENGAGEMENT TRACKING =====
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Updates the engagement indicator UI for the current slide.
|
|
331
|
+
* Shows/hides the indicator and updates progress based on slide config.
|
|
332
|
+
* @private
|
|
333
|
+
* @param {object} slide - The current slide object
|
|
334
|
+
*/
|
|
335
|
+
function _updateEngagementIndicator(slide) {
|
|
336
|
+
if (!slide || !slide.engagement) {
|
|
337
|
+
NavigationUI.hideEngagementIndicator();
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const engagement = slide.engagement;
|
|
342
|
+
|
|
343
|
+
// Show indicator if engagement is required (showIndicator defaults to true)
|
|
344
|
+
const showIndicator = engagement.showIndicator ?? true;
|
|
345
|
+
if (engagement.required && showIndicator) {
|
|
346
|
+
const progress = engagementManager.getProgress(slide.id);
|
|
347
|
+
if (progress) {
|
|
348
|
+
NavigationUI.showEngagementIndicator(progress);
|
|
349
|
+
}
|
|
350
|
+
} else {
|
|
351
|
+
NavigationUI.hideEngagementIndicator();
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Sets up engagement tracking event listeners for the current slide.
|
|
357
|
+
* Cleans up previous listeners and attaches new ones for progress updates.
|
|
358
|
+
* @private
|
|
359
|
+
* @param {object} slide - The current slide object
|
|
360
|
+
*/
|
|
361
|
+
function _setupEngagementListeners(slide) {
|
|
362
|
+
// Clean up previous listeners if they exist
|
|
363
|
+
if (currentEngagementProgressHandler) {
|
|
364
|
+
eventBus.off('engagement:progress', currentEngagementProgressHandler);
|
|
365
|
+
}
|
|
366
|
+
if (currentEngagementCompleteHandler) {
|
|
367
|
+
eventBus.off('engagement:complete', currentEngagementCompleteHandler);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Set up listeners if engagement tracking is required (regardless of indicator visibility)
|
|
371
|
+
if (slide && slide.engagement && slide.engagement.required) {
|
|
372
|
+
// Progress handler - updates indicator and navigation state in real-time
|
|
373
|
+
currentEngagementProgressHandler = ({ slideId, progress }) => {
|
|
374
|
+
if (slideId === slide.id) {
|
|
375
|
+
// Update indicator if visible (defaults to true)
|
|
376
|
+
if (slide.engagement.showIndicator ?? true) {
|
|
377
|
+
NavigationUI.showEngagementIndicator(progress);
|
|
378
|
+
}
|
|
379
|
+
// Always sync navigation state since completion affects sidebar/buttons
|
|
380
|
+
sync();
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
// Complete handler - triggers when all requirements are met
|
|
385
|
+
currentEngagementCompleteHandler = ({ slideId }) => {
|
|
386
|
+
if (slideId === slide.id) {
|
|
387
|
+
// Trigger completion animation directly
|
|
388
|
+
NavigationUI.triggerEngagementCompleteAnimation();
|
|
389
|
+
|
|
390
|
+
// Update indicator if visible (defaults to true)
|
|
391
|
+
if (slide.engagement.showIndicator ?? true) {
|
|
392
|
+
const progress = engagementManager.getProgress(slideId);
|
|
393
|
+
if (progress) {
|
|
394
|
+
NavigationUI.showEngagementIndicator(progress);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
// Always sync navigation state to enable next button and unlock sidebar items
|
|
398
|
+
sync();
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
eventBus.on('engagement:progress', currentEngagementProgressHandler);
|
|
403
|
+
eventBus.on('engagement:complete', currentEngagementCompleteHandler);
|
|
404
|
+
|
|
405
|
+
// Time tracking is now handled by EngagementManager internally
|
|
406
|
+
// It emits engagement:progress events which we listen to above
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Resolves the next slide that should appear in the sequential flow, skipping
|
|
413
|
+
* any slides that are currently excluded by sequence rules.
|
|
414
|
+
* @private
|
|
415
|
+
* @param {number} currentIndex - Index of the current slide.
|
|
416
|
+
* @returns {{index: number|null, slide: object|null, accessCheck: {allowed: boolean, message: string|null}}}
|
|
417
|
+
*/
|
|
418
|
+
function _getNextIncludedSlideInfo(currentIndex) {
|
|
419
|
+
for (let i = currentIndex + 1; i < slides.length; i++) {
|
|
420
|
+
const candidate = slides[i];
|
|
421
|
+
if (!isSlideInSequence(candidate, stateManager, assessmentConfigs)) {
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
index: i,
|
|
427
|
+
slide: candidate,
|
|
428
|
+
accessCheck: validateSlideAccess(candidate, stateManager, assessmentConfigs),
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
index: null,
|
|
434
|
+
slide: null,
|
|
435
|
+
accessCheck: { allowed: true, message: null },
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Resolves the previous slide in the sequential flow, skipping excluded slides.
|
|
441
|
+
* @private
|
|
442
|
+
* @param {number} currentIndex - Index of the current slide.
|
|
443
|
+
* @returns {{index: number|null, slide: object|null, accessCheck: {allowed: boolean, message: string|null}}}
|
|
444
|
+
*/
|
|
445
|
+
function _getPreviousIncludedSlideInfo(currentIndex) {
|
|
446
|
+
for (let i = currentIndex - 1; i >= 0; i--) {
|
|
447
|
+
const candidate = slides[i];
|
|
448
|
+
if (!isSlideInSequence(candidate, stateManager, assessmentConfigs)) {
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return {
|
|
453
|
+
index: i,
|
|
454
|
+
slide: candidate,
|
|
455
|
+
accessCheck: validateSlideAccess(candidate, stateManager, assessmentConfigs),
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return {
|
|
460
|
+
index: null,
|
|
461
|
+
slide: null,
|
|
462
|
+
accessCheck: { allowed: true, message: null },
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Handles click events on the navigation menu, delegating to `navigateToSlide`.
|
|
469
|
+
* @private
|
|
470
|
+
* @param {Event} event - The DOM click event.
|
|
471
|
+
*/
|
|
472
|
+
function _handleMenuClick(event) {
|
|
473
|
+
event.preventDefault();
|
|
474
|
+
const target = event.target.closest('[data-action="nav-menu-item"]');
|
|
475
|
+
if (!target) return;
|
|
476
|
+
|
|
477
|
+
// Do not navigate if the item is locked
|
|
478
|
+
// Check both .locked class and aria-disabled attribute for robustness
|
|
479
|
+
const link = target.querySelector('button');
|
|
480
|
+
if (target.classList.contains('locked') || (link && link.getAttribute('aria-disabled') === 'true')) {
|
|
481
|
+
// Tooltip will show on hover to explain why it's locked
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const slideId = target.dataset.slideId;
|
|
486
|
+
if (slideId) {
|
|
487
|
+
goToSlide(slideId).catch(error => {
|
|
488
|
+
// Error is already emitted via eventBus in goToSlide,
|
|
489
|
+
// but we catch it here to prevent unhandled promise rejection
|
|
490
|
+
logger.warn('Navigation failed:', error.message);
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Handles clicks on the 'previous' button.
|
|
497
|
+
* @private
|
|
498
|
+
*/
|
|
499
|
+
function _handlePrevClick() {
|
|
500
|
+
goToPreviousAvailableSlide().catch(error => {
|
|
501
|
+
logger.warn('Navigation failed:', error.message);
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Handles clicks on the 'next' button.
|
|
507
|
+
* @private
|
|
508
|
+
*/
|
|
509
|
+
function _handleNextClick() {
|
|
510
|
+
goToNextAvailableSlide().catch(error => {
|
|
511
|
+
logger.warn('Navigation failed:', error.message);
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Navigates to a specific slide by its ID, handling all accessibility and timing logic.
|
|
519
|
+
* This is the primary and sole function for all course navigation.
|
|
520
|
+
* Uses a queue to serialize navigation requests and prevent race conditions.
|
|
521
|
+
* @param {string} slideId - The ID of the slide to navigate to.
|
|
522
|
+
* @param {object} [context={}] - An optional context object to pass to the slide's render function.
|
|
523
|
+
*/
|
|
524
|
+
export async function goToSlide(slideId, context = {}, updateBookmark = true) {
|
|
525
|
+
_requireInitialized();
|
|
526
|
+
|
|
527
|
+
// Queue the navigation request to prevent race conditions
|
|
528
|
+
return new Promise((resolve, reject) => {
|
|
529
|
+
navigationQueue.push({ slideId, context, updateBookmark, resolve, reject });
|
|
530
|
+
_processNavigationQueue();
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Processes the navigation queue, executing one navigation request at a time.
|
|
536
|
+
* @private
|
|
537
|
+
*/
|
|
538
|
+
async function _processNavigationQueue() {
|
|
539
|
+
if (isNavigating || navigationQueue.length === 0) {
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
isNavigating = true;
|
|
544
|
+
const { slideId, context, updateBookmark, resolve, reject } = navigationQueue.shift();
|
|
545
|
+
|
|
546
|
+
try {
|
|
547
|
+
await _performNavigation(slideId, context, updateBookmark);
|
|
548
|
+
resolve();
|
|
549
|
+
} catch (error) {
|
|
550
|
+
reject(error);
|
|
551
|
+
} finally {
|
|
552
|
+
isNavigating = false;
|
|
553
|
+
_processNavigationQueue(); // Process next request in queue
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Performs the actual navigation to a slide. This is the internal implementation.
|
|
559
|
+
* @private
|
|
560
|
+
* @param {string} slideId - The ID of the slide to navigate to.
|
|
561
|
+
* @param {object} [context={}] - An optional context object to pass to the slide's render function.
|
|
562
|
+
*/
|
|
563
|
+
async function _performNavigation(slideId, context = {}, updateBookmark = true) {
|
|
564
|
+
if (navigationLocked) {
|
|
565
|
+
throw _createNavigationError(
|
|
566
|
+
'goToSlide',
|
|
567
|
+
'Navigation is locked. Course is in exit process.',
|
|
568
|
+
{ slideId }
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const slideIndex = await CourseHelpers.getSlideIndex(slideId);
|
|
573
|
+
if (slideIndex === null || slideIndex === undefined) {
|
|
574
|
+
throw _createNavigationError(
|
|
575
|
+
'goToSlide',
|
|
576
|
+
`Slide "${slideId}" not found in course structure. Check that the slide ID exists in course-config.js.`,
|
|
577
|
+
{
|
|
578
|
+
slideId,
|
|
579
|
+
availableSlides: slides.map(s => s.id)
|
|
580
|
+
}
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const newSlide = slides[slideIndex];
|
|
585
|
+
if (!newSlide) {
|
|
586
|
+
throw _createNavigationError(
|
|
587
|
+
'goToSlide',
|
|
588
|
+
`Slide at index ${slideIndex} is undefined. This indicates a data consistency issue.`,
|
|
589
|
+
{ slideId, slideIndex }
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (!isSlideInSequence(newSlide, stateManager, assessmentConfigs)) {
|
|
594
|
+
const sequenceMessage = newSlide.navigation?.sequence?.message || 'This content is not available right now.';
|
|
595
|
+
AppActions.showNotification(sequenceMessage, 'info', 3000);
|
|
596
|
+
eventBus.emit('navigation:blocked', {
|
|
597
|
+
slideId: newSlide.id,
|
|
598
|
+
slideIndex,
|
|
599
|
+
message: sequenceMessage,
|
|
600
|
+
reason: 'sequence-excluded',
|
|
601
|
+
});
|
|
602
|
+
// Use block error (not system error) - user is trying to access excluded content
|
|
603
|
+
throw _createNavigationBlockError(sequenceMessage);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Check if the destination slide is accessible (gating conditions)
|
|
607
|
+
const accessCheck = validateSlideAccess(newSlide, stateManager, assessmentConfigs);
|
|
608
|
+
if (!accessCheck.allowed) {
|
|
609
|
+
AppActions.showNotification(accessCheck.message, 'info', 3000);
|
|
610
|
+
eventBus.emit('navigation:blocked', {
|
|
611
|
+
slideId: newSlide.id,
|
|
612
|
+
slideIndex: slideIndex,
|
|
613
|
+
message: accessCheck.message,
|
|
614
|
+
reason: 'gating-condition'
|
|
615
|
+
});
|
|
616
|
+
// Use block error (not system error) - user is trying to skip locked content
|
|
617
|
+
throw _createNavigationBlockError(accessCheck.message);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Announce that navigation is about to happen and mark the PREVIOUS slide as visited.
|
|
621
|
+
const previousSlideIndex = NavigationState.getCurrentSlideIndex();
|
|
622
|
+
let previousSlideId = null;
|
|
623
|
+
if (previousSlideIndex !== slideIndex) {
|
|
624
|
+
const previousSlide = slides[previousSlideIndex];
|
|
625
|
+
if (previousSlide) {
|
|
626
|
+
previousSlideId = previousSlide.id;
|
|
627
|
+
// Mark the slide we are LEAVING as visited.
|
|
628
|
+
NavigationState.addVisitedSlide(previousSlide.id);
|
|
629
|
+
sync(); // Run sync to update the UI for the slide we just left.
|
|
630
|
+
|
|
631
|
+
eventBus.emit('navigation:beforeChange', { fromSlideId: previousSlide.id });
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Update UI for the NEW slide
|
|
636
|
+
NavigationUI.setActiveItem(newSlide.id);
|
|
637
|
+
|
|
638
|
+
// Restore footer visibility before showing new slide
|
|
639
|
+
// Assessments will hide it again if needed during their initialization
|
|
640
|
+
AppUI.showFooter();
|
|
641
|
+
|
|
642
|
+
// Update current slide index BEFORE rendering so that declarative components
|
|
643
|
+
// (tabs, accordion, etc.) can correctly use getCurrentSlideId() during initialization.
|
|
644
|
+
// This ensures engagement tracking registers to the correct slide.
|
|
645
|
+
NavigationState.setCurrentSlideIndex(slideIndex);
|
|
646
|
+
|
|
647
|
+
// Show the slide view (this calls initSlide which registers interactions)
|
|
648
|
+
await viewManager.showView(newSlide.id, { ...context, fromSlide: previousSlideId });
|
|
649
|
+
|
|
650
|
+
// Reset scroll position to top of new slide
|
|
651
|
+
// Users expect to start reading from the top when navigating to a new slide
|
|
652
|
+
const contentArea = document.querySelector('main#content');
|
|
653
|
+
if (contentArea) {
|
|
654
|
+
contentArea.scrollTo(0, 0);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Check engagement completion after slide has been initialized
|
|
658
|
+
const canNavigateFrom = validateNavigationFrom(newSlide, assessmentConfigs);
|
|
659
|
+
const nextInfo = _getNextIncludedSlideInfo(slideIndex);
|
|
660
|
+
const isNextAccessible = nextInfo.accessCheck;
|
|
661
|
+
|
|
662
|
+
// Check engagement requirements (with dev mode bypass)
|
|
663
|
+
let engagementComplete = true;
|
|
664
|
+
let engagementProgress = null;
|
|
665
|
+
|
|
666
|
+
if (!shouldBypassEngagement()) {
|
|
667
|
+
const engagementEvaluation = engagementManager.evaluateRequirements(newSlide.id);
|
|
668
|
+
engagementComplete = engagementEvaluation.complete;
|
|
669
|
+
engagementProgress = engagementEvaluation.progress;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const nextBlocked = !engagementComplete || !canNavigateFrom.allowed || !isNextAccessible.allowed;
|
|
673
|
+
const nextBlockedMessage = !engagementComplete
|
|
674
|
+
? engagementProgress?.tooltip
|
|
675
|
+
: (canNavigateFrom.message || isNextAccessible.message);
|
|
676
|
+
|
|
677
|
+
NavigationUI.updateNavButtonState({
|
|
678
|
+
isFirstSlide: slideIndex === 0,
|
|
679
|
+
isLastSlide: nextInfo.slide === null,
|
|
680
|
+
nextBlocked,
|
|
681
|
+
nextBlockedMessage,
|
|
682
|
+
engagementProgress: engagementProgress?.percentage ?? null,
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
// Update header progress indicator
|
|
686
|
+
const sequentialSlides = slides.filter(s => isSlideInSequence(s, stateManager, assessmentConfigs));
|
|
687
|
+
const currentSequentialIndex = sequentialSlides.findIndex(s => s.id === slideId);
|
|
688
|
+
const visitedCount = NavigationState.getVisitedSlides().filter(id => sequentialSlides.some(s => s.id === id)).length;
|
|
689
|
+
NavigationUI.updateHeaderProgress(currentSequentialIndex >= 0 ? currentSequentialIndex : slideIndex, sequentialSlides.length, visitedCount);
|
|
690
|
+
|
|
691
|
+
// NOTE: Do NOT mark the new slide as visited here.
|
|
692
|
+
// Slides are marked as visited when the user LEAVES them,
|
|
693
|
+
// so that cmi.progress_measure accurately reflects completed content, not just entered content.
|
|
694
|
+
|
|
695
|
+
// Set up engagement indicator and listeners for the new slide
|
|
696
|
+
_setupEngagementListeners(newSlide);
|
|
697
|
+
_updateEngagementIndicator(newSlide);
|
|
698
|
+
|
|
699
|
+
// Start timer for the new slide
|
|
700
|
+
AppActions.startSessionTimer(slideId);
|
|
701
|
+
|
|
702
|
+
// Update progress measure to reflect slide visit
|
|
703
|
+
// Count only sequential slides (excludes remedial/conditional slides)
|
|
704
|
+
const totalSequentialSlides = slides.filter(s => isSlideInSequence(s, stateManager, assessmentConfigs)).length;
|
|
705
|
+
stateManager.updateProgressMeasure(totalSequentialSlides);
|
|
706
|
+
|
|
707
|
+
// Set bookmark as FINAL step after successful navigation
|
|
708
|
+
// We use slide ID as the bookmark value (unique, stable, human-readable)
|
|
709
|
+
// This is done LAST to ensure we only bookmark after we've successfully navigated
|
|
710
|
+
if (updateBookmark) {
|
|
711
|
+
try {
|
|
712
|
+
stateManager.setBookmark(newSlide.id);
|
|
713
|
+
} catch (error) {
|
|
714
|
+
// FAIL FAST, FAIL LOUD: Report via unified logger and re-throw
|
|
715
|
+
logger.error(`Failed to set bookmark: ${error.message}`, {
|
|
716
|
+
domain: 'navigation', operation: 'setBookmark', stack: error.stack,
|
|
717
|
+
slideId: newSlide.id, slideIndex
|
|
718
|
+
});
|
|
719
|
+
throw error; // Re-throw to halt navigation
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Sync navigation state after slide is fully loaded
|
|
724
|
+
// This ensures button states reflect any engagement tracking that happened during slide render
|
|
725
|
+
sync();
|
|
726
|
+
|
|
727
|
+
// Announce that navigation has completed.
|
|
728
|
+
// Include fromSlideId so xapi-statement-service can send 'experienced' statements
|
|
729
|
+
eventBus.emit('navigation:changed', { fromSlideId: previousSlideId, toSlideId: newSlide.id, slideTitle: newSlide.title || null });
|
|
730
|
+
|
|
731
|
+
// Announce that a navigation change may trigger a completion check.
|
|
732
|
+
eventBus.emit('navigation:completeCheck');
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Resets the navigation state, marking all slides as unvisited and setting the current slide to the first one.
|
|
737
|
+
* Useful for restarting the course or resetting progress.
|
|
738
|
+
*/
|
|
739
|
+
export function resetNavigation() {
|
|
740
|
+
_requireInitialized();
|
|
741
|
+
|
|
742
|
+
// Reset the internal state
|
|
743
|
+
NavigationState.setCurrentSlideIndex(0);
|
|
744
|
+
NavigationState.clearVisitedSlides();
|
|
745
|
+
|
|
746
|
+
// Update the UI to reflect the reset state
|
|
747
|
+
NavigationUI.setActiveItem(slides[0]?.id);
|
|
748
|
+
|
|
749
|
+
const firstSlide = slides[0];
|
|
750
|
+
const navigationCheck = validateNavigationFrom(firstSlide, assessmentConfigs);
|
|
751
|
+
NavigationUI.updateNavButtonState({
|
|
752
|
+
isFirstSlide: true,
|
|
753
|
+
isLastSlide: slides.length === 1,
|
|
754
|
+
nextBlocked: !navigationCheck.allowed,
|
|
755
|
+
nextBlockedMessage: navigationCheck.message,
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
// Optionally, you could also trigger a view refresh
|
|
759
|
+
viewManager.showView(slides[0]?.id);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Checks the accessibility of all slides based on their gating conditions AND engagement requirements.
|
|
764
|
+
* A slide is inaccessible if:
|
|
765
|
+
* 1. It's excluded by sequence rules
|
|
766
|
+
* 2. Its gating conditions are not met
|
|
767
|
+
* 3. Any previous slide in the sequence has incomplete engagement requirements
|
|
768
|
+
* @returns {Map<string, {allowed: boolean, message: string|null}>} A map of slide accessibility states.
|
|
769
|
+
*/
|
|
770
|
+
export function checkAllSlidesAccessibility() {
|
|
771
|
+
_requireInitialized();
|
|
772
|
+
|
|
773
|
+
const accessibilityMap = new Map();
|
|
774
|
+
const visitedSlides = NavigationState.getVisitedSlides();
|
|
775
|
+
const currentIndex = NavigationState.getCurrentSlideIndex();
|
|
776
|
+
const currentSlide = slides[currentIndex];
|
|
777
|
+
|
|
778
|
+
// Build the accessibility map
|
|
779
|
+
slides.forEach((slide, index) => {
|
|
780
|
+
const include = isSlideInSequence(slide, stateManager, assessmentConfigs);
|
|
781
|
+
|
|
782
|
+
if (!include) {
|
|
783
|
+
accessibilityMap.set(slide.id, {
|
|
784
|
+
allowed: false,
|
|
785
|
+
message: slide.navigation?.sequence?.message || 'This content is not available right now.'
|
|
786
|
+
});
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// Check gating conditions
|
|
791
|
+
const accessCheck = validateSlideAccess(slide, stateManager, assessmentConfigs);
|
|
792
|
+
if (!accessCheck.allowed) {
|
|
793
|
+
accessibilityMap.set(slide.id, accessCheck);
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Current slide is always accessible (we're already on it)
|
|
798
|
+
if (currentSlide && slide.id === currentSlide.id) {
|
|
799
|
+
accessibilityMap.set(slide.id, { allowed: true, message: null });
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Check if any PREVIOUS slide (visited or not) has required engagement
|
|
804
|
+
// If a slide hasn't been visited but has required engagement, it blocks forward navigation
|
|
805
|
+
// Check previous engagement (with dev mode bypass)
|
|
806
|
+
let hasIncompleteEngagement = false;
|
|
807
|
+
let incompleteSlideTitle = null;
|
|
808
|
+
|
|
809
|
+
if (!shouldBypassEngagement()) {
|
|
810
|
+
for (let i = 0; i < index; i++) {
|
|
811
|
+
const previousSlide = slides[i];
|
|
812
|
+
|
|
813
|
+
// Only check slides that are in sequence
|
|
814
|
+
if (isSlideInSequence(previousSlide, stateManager, assessmentConfigs)) {
|
|
815
|
+
// Check if this slide has required engagement
|
|
816
|
+
const slideEngagement = previousSlide.engagement;
|
|
817
|
+
const hasRequiredEngagement = slideEngagement && slideEngagement.required === true;
|
|
818
|
+
|
|
819
|
+
if (hasRequiredEngagement) {
|
|
820
|
+
// If visited, check if engagement is complete
|
|
821
|
+
if (visitedSlides.includes(previousSlide.id)) {
|
|
822
|
+
const evaluation = engagementManager.evaluateRequirements(previousSlide.id);
|
|
823
|
+
if (!evaluation.complete) {
|
|
824
|
+
hasIncompleteEngagement = true;
|
|
825
|
+
incompleteSlideTitle = previousSlide.title || previousSlide.id;
|
|
826
|
+
break;
|
|
827
|
+
}
|
|
828
|
+
} else {
|
|
829
|
+
// Not visited but has required engagement - blocks forward navigation
|
|
830
|
+
hasIncompleteEngagement = true;
|
|
831
|
+
incompleteSlideTitle = previousSlide.title || previousSlide.id;
|
|
832
|
+
break;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
if (hasIncompleteEngagement) {
|
|
840
|
+
const message = incompleteSlideTitle
|
|
841
|
+
? `Complete all required content in "${incompleteSlideTitle}" before continuing.`
|
|
842
|
+
: 'Complete all required content on previous slides before continuing.';
|
|
843
|
+
accessibilityMap.set(slide.id, {
|
|
844
|
+
allowed: false,
|
|
845
|
+
message
|
|
846
|
+
});
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// Slide is accessible
|
|
851
|
+
accessibilityMap.set(slide.id, { allowed: true, message: null });
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
return accessibilityMap;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Manually triggers a sync of the navigation state with the current data.
|
|
859
|
+
* This can be used after bulk updates to the state or slides configuration.
|
|
860
|
+
*/
|
|
861
|
+
export function sync() {
|
|
862
|
+
_requireInitialized();
|
|
863
|
+
|
|
864
|
+
const currentIndex = NavigationState.getCurrentSlideIndex();
|
|
865
|
+
const currentSlide = slides[currentIndex];
|
|
866
|
+
|
|
867
|
+
// Re-evaluate all slides' accessibility and update the UI accordingly
|
|
868
|
+
const accessibilityMap = checkAllSlidesAccessibility();
|
|
869
|
+
for (const [slideId, accessCheck] of accessibilityMap.entries()) {
|
|
870
|
+
if (accessCheck.allowed) {
|
|
871
|
+
NavigationUI.markAsUnlocked(slideId);
|
|
872
|
+
} else {
|
|
873
|
+
NavigationUI.markAsLocked(slideId);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// Update section locked states based on child accessibility
|
|
878
|
+
NavigationUI.updateSectionStates(accessibilityMap);
|
|
879
|
+
|
|
880
|
+
// Determine and update the 'visited' status for all slides
|
|
881
|
+
const visitedSlides = NavigationState.getVisitedSlides();
|
|
882
|
+
slides.forEach(slide => {
|
|
883
|
+
const hasBeenVisited = visitedSlides.includes(slide.id);
|
|
884
|
+
|
|
885
|
+
if (slide.type === 'assessment') {
|
|
886
|
+
const config = assessmentConfigs.get(slide.assessmentId);
|
|
887
|
+
const requirements = config?.completionRequirements;
|
|
888
|
+
let requirementsMet = false;
|
|
889
|
+
|
|
890
|
+
// Only check requirements if they are defined in the assessment's config
|
|
891
|
+
if (requirements) {
|
|
892
|
+
requirementsMet = AssessmentManager.meetsCompletionRequirements(slide.assessmentId, requirements);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// Mark as "visited" (i.e., show checkmark) only if visited AND requirements are met.
|
|
896
|
+
// If an assessment has no requirements, it cannot get a checkmark.
|
|
897
|
+
if (hasBeenVisited && requirementsMet) {
|
|
898
|
+
NavigationUI.markAsVisited(slide.id);
|
|
899
|
+
} else {
|
|
900
|
+
NavigationUI.markAsUnvisited(slide.id);
|
|
901
|
+
}
|
|
902
|
+
} else {
|
|
903
|
+
// For regular slides, mark as visited if simply visited
|
|
904
|
+
if (hasBeenVisited) {
|
|
905
|
+
NavigationUI.markAsVisited(slide.id);
|
|
906
|
+
} else {
|
|
907
|
+
NavigationUI.markAsUnvisited(slide.id);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
// Check both if we can leave the current slide and if the next slide is accessible
|
|
913
|
+
const canNavigateFrom = validateNavigationFrom(currentSlide, assessmentConfigs);
|
|
914
|
+
const nextInfo = _getNextIncludedSlideInfo(currentIndex);
|
|
915
|
+
const isNextAccessible = nextInfo.accessCheck;
|
|
916
|
+
|
|
917
|
+
// Check engagement requirements (with dev mode bypass)
|
|
918
|
+
let engagementComplete = true;
|
|
919
|
+
let engagementProgress = null;
|
|
920
|
+
|
|
921
|
+
if (!shouldBypassEngagement()) {
|
|
922
|
+
const engagementEvaluation = engagementManager.evaluateRequirements(currentSlide.id);
|
|
923
|
+
engagementComplete = engagementEvaluation.complete;
|
|
924
|
+
engagementProgress = engagementEvaluation.progress;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
const isFirstSlide = currentIndex === 0;
|
|
928
|
+
const isLastSlide = nextInfo.slide === null;
|
|
929
|
+
const nextBlocked = !engagementComplete || !canNavigateFrom.allowed || !isNextAccessible.allowed;
|
|
930
|
+
const nextBlockedMessage = !engagementComplete
|
|
931
|
+
? engagementProgress?.tooltip
|
|
932
|
+
: (canNavigateFrom.message || isNextAccessible.message);
|
|
933
|
+
|
|
934
|
+
// Update navigation button states with blocking information
|
|
935
|
+
NavigationUI.updateNavButtonState({
|
|
936
|
+
isFirstSlide,
|
|
937
|
+
isLastSlide,
|
|
938
|
+
nextBlocked,
|
|
939
|
+
nextBlockedMessage,
|
|
940
|
+
engagementProgress: engagementProgress?.percentage ?? null,
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
// Update header progress indicator
|
|
944
|
+
const sequentialSlides = slides.filter(s => isSlideInSequence(s, stateManager, assessmentConfigs));
|
|
945
|
+
const currentSequentialIndex = sequentialSlides.findIndex(s => s.id === currentSlide.id);
|
|
946
|
+
const visitedCount = NavigationState.getVisitedSlides().filter(id => sequentialSlides.some(s => s.id === id)).length;
|
|
947
|
+
NavigationUI.updateHeaderProgress(currentSequentialIndex >= 0 ? currentSequentialIndex : currentIndex, sequentialSlides.length, visitedCount);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Moves to the next sequential slide if available.
|
|
952
|
+
* Checks for navigation.controls.nextTarget or exitTarget overrides before using sequential navigation.
|
|
953
|
+
*/
|
|
954
|
+
export async function goToNextAvailableSlide() {
|
|
955
|
+
_requireInitialized();
|
|
956
|
+
|
|
957
|
+
if (navigationLocked) {
|
|
958
|
+
throw _createNavigationError(
|
|
959
|
+
'goToNextAvailableSlide',
|
|
960
|
+
'Navigation is locked. Course is in exit process.',
|
|
961
|
+
{}
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
const currentIndex = NavigationState.getCurrentSlideIndex();
|
|
966
|
+
const currentSlide = slides[currentIndex];
|
|
967
|
+
|
|
968
|
+
// Check engagement requirements (with dev mode bypass)
|
|
969
|
+
if (!shouldBypassEngagement()) {
|
|
970
|
+
const evaluation = engagementManager.evaluateRequirements(currentSlide.id);
|
|
971
|
+
|
|
972
|
+
if (!evaluation.complete) {
|
|
973
|
+
// Don't show notification - the tooltip on the disabled button already shows the message
|
|
974
|
+
eventBus.emit('navigation:blocked', {
|
|
975
|
+
reason: 'engagement_incomplete',
|
|
976
|
+
slideId: currentSlide.id,
|
|
977
|
+
unmetRequirements: evaluation.unmetRequirements
|
|
978
|
+
});
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const navigationCheck = validateNavigationFrom(currentSlide, assessmentConfigs);
|
|
984
|
+
if (!navigationCheck.allowed) {
|
|
985
|
+
AppActions.showNotification(navigationCheck.message, 'warning', 3000);
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// Check for custom navigation targets (nextTarget or exitTarget)
|
|
990
|
+
const controls = currentSlide.navigation?.controls;
|
|
991
|
+
const targetSlideId = controls?.nextTarget || controls?.exitTarget;
|
|
992
|
+
|
|
993
|
+
if (targetSlideId) {
|
|
994
|
+
// Custom target specified - navigate directly to it
|
|
995
|
+
await goToSlide(targetSlideId);
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// Default sequential navigation
|
|
1000
|
+
const { slide } = _getNextIncludedSlideInfo(currentIndex);
|
|
1001
|
+
if (!slide) {
|
|
1002
|
+
return; // Already at the end of the active sequence
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
await goToSlide(slide.id);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* Moves to the previous sequential slide if available.
|
|
1010
|
+
* Checks for navigation.controls.previousTarget override before using sequential navigation.
|
|
1011
|
+
*/
|
|
1012
|
+
export async function goToPreviousAvailableSlide() {
|
|
1013
|
+
_requireInitialized();
|
|
1014
|
+
|
|
1015
|
+
if (navigationLocked) {
|
|
1016
|
+
throw _createNavigationError(
|
|
1017
|
+
'goToPreviousAvailableSlide',
|
|
1018
|
+
'Navigation is locked. Course is in exit process.',
|
|
1019
|
+
{}
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
const currentIndex = NavigationState.getCurrentSlideIndex();
|
|
1024
|
+
const currentSlide = slides[currentIndex];
|
|
1025
|
+
|
|
1026
|
+
// Check for custom navigation target (previousTarget)
|
|
1027
|
+
const controls = currentSlide.navigation?.controls;
|
|
1028
|
+
const targetSlideId = controls?.previousTarget;
|
|
1029
|
+
|
|
1030
|
+
if (targetSlideId) {
|
|
1031
|
+
// Custom target specified - navigate directly to it
|
|
1032
|
+
await goToSlide(targetSlideId);
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// Default sequential navigation
|
|
1037
|
+
const { slide } = _getPreviousIncludedSlideInfo(currentIndex);
|
|
1038
|
+
if (!slide) {
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
await goToSlide(slide.id);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* Returns data about the next slide in the active sequence relative to the current slide.
|
|
1047
|
+
* @returns {{index: number|null, slide: object|null, accessCheck: {allowed: boolean, message: string|null}}}
|
|
1048
|
+
*/
|
|
1049
|
+
export function getNextSequentialSlideInfo() {
|
|
1050
|
+
_requireInitialized();
|
|
1051
|
+
|
|
1052
|
+
const currentIndex = NavigationState.getCurrentSlideIndex();
|
|
1053
|
+
return _getNextIncludedSlideInfo(currentIndex);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Returns data about the previous slide in the active sequence relative to the current slide.
|
|
1058
|
+
* @returns {{index: number|null, slide: object|null, accessCheck: {allowed: boolean, message: string|null}}}
|
|
1059
|
+
*/
|
|
1060
|
+
export function getPreviousSequentialSlideInfo() {
|
|
1061
|
+
_requireInitialized();
|
|
1062
|
+
|
|
1063
|
+
const currentIndex = NavigationState.getCurrentSlideIndex();
|
|
1064
|
+
return _getPreviousIncludedSlideInfo(currentIndex);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* Gets the list of all slides in the course.
|
|
1070
|
+
* @returns {object[]} The array of course slides.
|
|
1071
|
+
*/
|
|
1072
|
+
export function getAllSlides() {
|
|
1073
|
+
_requireInitialized();
|
|
1074
|
+
return slides;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/**
|
|
1078
|
+
* Gets the current slide object.
|
|
1079
|
+
* @returns {object|null} The current slide, or null if not found.
|
|
1080
|
+
*/
|
|
1081
|
+
export function getCurrentSlide() {
|
|
1082
|
+
_requireInitialized();
|
|
1083
|
+
const currentIndex = NavigationState.getCurrentSlideIndex();
|
|
1084
|
+
return slides[currentIndex] || null;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* Gets the ID of the current slide.
|
|
1089
|
+
* @returns {string|null} The ID of the current slide, or null if not found.
|
|
1090
|
+
*/
|
|
1091
|
+
export function getCurrentSlideId() {
|
|
1092
|
+
_requireInitialized();
|
|
1093
|
+
const currentSlide = getCurrentSlide();
|
|
1094
|
+
return currentSlide ? currentSlide.id : null;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Gets the menu tree structure for the course.
|
|
1099
|
+
* @returns {object[]} The hierarchical menu tree array.
|
|
1100
|
+
*/
|
|
1101
|
+
export function getMenuTree() {
|
|
1102
|
+
_requireInitialized();
|
|
1103
|
+
return menuTree;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* Checks if the user is currently on the last slide.
|
|
1108
|
+
* @returns {boolean} True if on the last slide, false otherwise.
|
|
1109
|
+
*/
|
|
1110
|
+
export function isOnLastSlide() {
|
|
1111
|
+
_requireInitialized();
|
|
1112
|
+
const nextInfo = getNextSequentialSlideInfo();
|
|
1113
|
+
return nextInfo.slide === null;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* @module NavigationActions
|
|
1118
|
+
* This module manages the navigation actions within the course player,
|
|
1119
|
+
* handling user interactions and coordinating between the application state and the UI.
|
|
1120
|
+
*
|
|
1121
|
+
* @example
|
|
1122
|
+
* import { NavigationActions } from 'path/to/NavigationActions';
|
|
1123
|
+
*
|
|
1124
|
+
* // Initialize the navigation actions with course data
|
|
1125
|
+
* NavigationActions.init(courseSlides, viewManagerInstance, courseMenuTree);
|
|
1126
|
+
*
|
|
1127
|
+
* // Manually navigate to a specific slide
|
|
1128
|
+
* NavigationActions.goToSlide('welcome-slide');
|
|
1129
|
+
*
|
|
1130
|
+
* // Reset the navigation state (e.g., on course restart)
|
|
1131
|
+
* NavigationActions.resetNavigation();
|
|
1132
|
+
*/
|