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,884 @@
|
|
|
1
|
+
import { logger } from '../../utilities/logger.js';
|
|
2
|
+
import {
|
|
3
|
+
validateAgainstSchema,
|
|
4
|
+
createInteractionEventHandler,
|
|
5
|
+
renderInteractionControls,
|
|
6
|
+
displayFeedback,
|
|
7
|
+
clearFeedback,
|
|
8
|
+
normalizeInitialResponse,
|
|
9
|
+
validateContainer,
|
|
10
|
+
escapeCssSelector,
|
|
11
|
+
registerCoreInteraction,
|
|
12
|
+
parseResponse
|
|
13
|
+
} from './interaction-base.js';
|
|
14
|
+
|
|
15
|
+
// Metadata for drag-drop interaction type
|
|
16
|
+
export const metadata = {
|
|
17
|
+
creator: 'createDragDropQuestion',
|
|
18
|
+
scormType: 'other',
|
|
19
|
+
showCheckAnswer: true,
|
|
20
|
+
isAnswered: (response) => {
|
|
21
|
+
if (!response || typeof response !== 'object') return false;
|
|
22
|
+
return Object.keys(response).length > 0;
|
|
23
|
+
},
|
|
24
|
+
getCorrectAnswer: (config) => {
|
|
25
|
+
const correctPlacements = {};
|
|
26
|
+
if (config.dropZones && Array.isArray(config.dropZones)) {
|
|
27
|
+
config.dropZones.forEach(zone => {
|
|
28
|
+
if (zone.accepts && Array.isArray(zone.accepts)) {
|
|
29
|
+
zone.accepts.forEach(itemId => {
|
|
30
|
+
correctPlacements[itemId] = zone.id;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return JSON.stringify(correctPlacements);
|
|
36
|
+
},
|
|
37
|
+
formatCorrectAnswer: (_question, _correctAnswer) => {
|
|
38
|
+
return '<p class="correct-item">See correct placements above</p>';
|
|
39
|
+
},
|
|
40
|
+
formatUserResponse: (question, response) => {
|
|
41
|
+
try {
|
|
42
|
+
const placements = typeof response === 'string' ? JSON.parse(response) : response;
|
|
43
|
+
const count = Object.keys(placements).length;
|
|
44
|
+
return `<p class="response-item">${count} item(s) placed</p>`;
|
|
45
|
+
} catch (_error) {
|
|
46
|
+
return `<p class="response-item">${response}</p>`;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Schema for validation, linting, and AI-assisted authoring
|
|
52
|
+
export const schema = {
|
|
53
|
+
type: 'drag-drop',
|
|
54
|
+
description: 'Drag items into categorized drop zones',
|
|
55
|
+
scormType: 'matching',
|
|
56
|
+
example: `<div class="interaction drag-drop" data-interaction-id="demo-dd">
|
|
57
|
+
<div class="question-prompt"><h3>Match items to their categories</h3></div>
|
|
58
|
+
<div class="drag-drop-container">
|
|
59
|
+
<div class="drag-items"><h4>Drag these items:</h4>
|
|
60
|
+
<div class="drag-item" draggable="true" data-item-id="a">HTML</div>
|
|
61
|
+
<div class="drag-item" draggable="true" data-item-id="b">CSS</div>
|
|
62
|
+
<div class="drag-item" draggable="true" data-item-id="c">JavaScript</div>
|
|
63
|
+
</div>
|
|
64
|
+
<div class="drop-zones"><h4>Drop into correct zones:</h4>
|
|
65
|
+
<div class="drop-zone" data-zone-id="structure"><div class="zone-label">Structure</div><div class="zone-content"></div></div>
|
|
66
|
+
<div class="drop-zone" data-zone-id="style"><div class="zone-label">Styling</div><div class="zone-content"></div></div>
|
|
67
|
+
<div class="drop-zone" data-zone-id="behavior"><div class="zone-label">Behavior</div><div class="zone-content"></div></div>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
<div class="interaction-controls"><button class="btn btn-primary" disabled>Check Answer</button></div>
|
|
71
|
+
</div>`,
|
|
72
|
+
properties: {
|
|
73
|
+
items: {
|
|
74
|
+
type: 'array',
|
|
75
|
+
required: true,
|
|
76
|
+
minItems: 1,
|
|
77
|
+
description: 'Draggable items',
|
|
78
|
+
itemSchema: {
|
|
79
|
+
id: { type: 'string', required: true },
|
|
80
|
+
text: { type: 'string', required: true },
|
|
81
|
+
correctZone: { type: 'string', required: true }
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
dropZones: {
|
|
85
|
+
type: 'array',
|
|
86
|
+
required: true,
|
|
87
|
+
minItems: 1,
|
|
88
|
+
description: 'Drop target zones',
|
|
89
|
+
itemSchema: {
|
|
90
|
+
id: { type: 'string', required: true },
|
|
91
|
+
label: { type: 'string', required: true }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export function createDragDropQuestion(config) {
|
|
98
|
+
validateAgainstSchema(config, schema);
|
|
99
|
+
|
|
100
|
+
const { id, prompt, items, dropZones, controlled = false } = config;
|
|
101
|
+
|
|
102
|
+
// Validate items and dropZones arrays
|
|
103
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
104
|
+
throw new Error(`Drag-drop question "${id}" must have at least one item`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!Array.isArray(dropZones) || dropZones.length === 0) {
|
|
108
|
+
throw new Error(`Drag-drop question "${id}" must have at least one drop zone`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let _container = null;
|
|
112
|
+
|
|
113
|
+
const questionObj = {
|
|
114
|
+
id,
|
|
115
|
+
type: 'drag-drop',
|
|
116
|
+
|
|
117
|
+
render: (container, initialResponse = null) => {
|
|
118
|
+
validateContainer(container, id);
|
|
119
|
+
_container = container;
|
|
120
|
+
|
|
121
|
+
// Parse initial response as object
|
|
122
|
+
const initialValue = normalizeInitialResponse(initialResponse);
|
|
123
|
+
const initialPlacements = parseResponse(initialValue, 'object') || {};
|
|
124
|
+
|
|
125
|
+
let html = `
|
|
126
|
+
<div class="interaction drag-drop" data-interaction-id="${id}">
|
|
127
|
+
<div class="question-prompt">
|
|
128
|
+
<h3>${prompt}</h3>
|
|
129
|
+
</div>
|
|
130
|
+
<div class="drag-drop-container">
|
|
131
|
+
<div class="drag-items" data-droppable="true">
|
|
132
|
+
<h4>Drag these items:</h4>
|
|
133
|
+
`;
|
|
134
|
+
|
|
135
|
+
items.forEach((item, index) => {
|
|
136
|
+
const isPlaced = initialPlacements[item.id] !== undefined;
|
|
137
|
+
|
|
138
|
+
html += `
|
|
139
|
+
<div
|
|
140
|
+
class="drag-item${isPlaced ? ' hidden' : ''}"
|
|
141
|
+
draggable="true"
|
|
142
|
+
data-item-id="${item.id}"
|
|
143
|
+
data-index="${index}"
|
|
144
|
+
tabindex="0"
|
|
145
|
+
role="button"
|
|
146
|
+
aria-grabbed="false"
|
|
147
|
+
data-testid="${id}-drag-item-${item.id}"
|
|
148
|
+
>
|
|
149
|
+
${item.content}
|
|
150
|
+
</div>
|
|
151
|
+
`;
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
html += `
|
|
155
|
+
</div>
|
|
156
|
+
<div class="drop-zones">
|
|
157
|
+
<h4>Drop into correct zones:</h4>
|
|
158
|
+
`;
|
|
159
|
+
|
|
160
|
+
dropZones.forEach((zone) => {
|
|
161
|
+
const placedItemIds = Object.keys(initialPlacements).filter(itemId => initialPlacements[itemId] === zone.id);
|
|
162
|
+
const maxItems = zone.maxItems || 1; // Default to 1 if not specified
|
|
163
|
+
|
|
164
|
+
html += `
|
|
165
|
+
<div
|
|
166
|
+
class="drop-zone"
|
|
167
|
+
data-zone-id="${zone.id}"
|
|
168
|
+
data-accepts="${zone.accepts.join(',')}"
|
|
169
|
+
data-max-items="${maxItems}"
|
|
170
|
+
role="region"
|
|
171
|
+
aria-label="${zone.label}"
|
|
172
|
+
tabindex="0"
|
|
173
|
+
data-testid="${id}-drop-zone-${zone.id}"
|
|
174
|
+
>
|
|
175
|
+
<div class="zone-label">${zone.label}</div>
|
|
176
|
+
<div class="zone-content">`;
|
|
177
|
+
|
|
178
|
+
// Add placed items to the zone
|
|
179
|
+
placedItemIds.forEach(itemId => {
|
|
180
|
+
const item = items.find(i => i.id === itemId);
|
|
181
|
+
if (item) {
|
|
182
|
+
html += `
|
|
183
|
+
<div class="drag-item dropped" data-item-id="${item.id}" draggable="true" data-testid="${id}-dropped-item-${item.id}">
|
|
184
|
+
${item.content}
|
|
185
|
+
<button type="button" class="remove-item" data-action="remove-item" data-item-id="${item.id}" aria-label="Remove ${item.content}" title="Remove this item" data-testid="${id}-remove-${item.id}">×</button>
|
|
186
|
+
</div>
|
|
187
|
+
`;
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
html += `
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
`;
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
html += `
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
${renderInteractionControls(id, controlled)}
|
|
201
|
+
<div class="overall-feedback" id="${id}_overall_feedback" aria-live="polite"></div>
|
|
202
|
+
</div>
|
|
203
|
+
`;
|
|
204
|
+
|
|
205
|
+
container.innerHTML = html;
|
|
206
|
+
|
|
207
|
+
// Setup drag-drop interaction
|
|
208
|
+
setupDragDropInteraction(container, questionObj, initialPlacements);
|
|
209
|
+
|
|
210
|
+
// Attach event handler only in uncontrolled mode
|
|
211
|
+
if (!controlled) {
|
|
212
|
+
const correctPattern = JSON.stringify(dropZones.reduce((acc, zone) => {
|
|
213
|
+
zone.accepts.forEach(itemId => acc[itemId] = zone.id);
|
|
214
|
+
return acc;
|
|
215
|
+
}, {}));
|
|
216
|
+
|
|
217
|
+
container.addEventListener('click', createInteractionEventHandler(questionObj, {
|
|
218
|
+
...config,
|
|
219
|
+
scormType: 'other',
|
|
220
|
+
correctPattern
|
|
221
|
+
}));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Add direct event listeners to remove buttons to prevent drag interference
|
|
225
|
+
const handleRemoveClick = (e) => {
|
|
226
|
+
if (e.target.classList.contains('remove-item')) {
|
|
227
|
+
e.stopPropagation();
|
|
228
|
+
e.preventDefault();
|
|
229
|
+
const itemId = e.target.dataset.itemId;
|
|
230
|
+
if (itemId) {
|
|
231
|
+
removeItemFromZone(container, itemId);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// Listen on multiple events to ensure reliability
|
|
237
|
+
container.addEventListener('click', handleRemoveClick, true);
|
|
238
|
+
container.addEventListener('mousedown', handleRemoveClick, true);
|
|
239
|
+
container.addEventListener('touchstart', handleRemoveClick, { capture: true, passive: false });
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
evaluate: (placements) => {
|
|
243
|
+
if (!placements || typeof placements !== 'object') {
|
|
244
|
+
return {
|
|
245
|
+
score: 0,
|
|
246
|
+
correct: false,
|
|
247
|
+
results: [],
|
|
248
|
+
response: JSON.stringify({}),
|
|
249
|
+
error: 'Invalid placements format'
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
let correct = 0;
|
|
254
|
+
const results = [];
|
|
255
|
+
|
|
256
|
+
Object.entries(placements).forEach(([itemId, zoneId]) => {
|
|
257
|
+
const zone = dropZones.find(z => z.id === zoneId);
|
|
258
|
+
const isCorrect = zone && zone.accepts.includes(itemId);
|
|
259
|
+
if (isCorrect) correct++;
|
|
260
|
+
results.push({ itemId, zoneId, correct: isCorrect });
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
score: correct / items.length,
|
|
265
|
+
correct: correct === items.length,
|
|
266
|
+
results,
|
|
267
|
+
response: JSON.stringify(placements)
|
|
268
|
+
};
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
checkAnswer: () => {
|
|
272
|
+
validateContainer(_container, id);
|
|
273
|
+
|
|
274
|
+
const placements = questionObj.getResponse();
|
|
275
|
+
const evaluation = questionObj.evaluate(placements);
|
|
276
|
+
|
|
277
|
+
if (evaluation.correct) {
|
|
278
|
+
displayFeedback(_container, id, 'Excellent! All items are in the correct zones.', 'correct');
|
|
279
|
+
} else {
|
|
280
|
+
displayFeedback(_container, id, `${Math.round(evaluation.score * 100)}% correct. Review your placements.`, 'incorrect');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return evaluation;
|
|
284
|
+
},
|
|
285
|
+
|
|
286
|
+
reset: () => {
|
|
287
|
+
validateContainer(_container, id);
|
|
288
|
+
|
|
289
|
+
const dragItems = _container.querySelectorAll('.drag-item');
|
|
290
|
+
dragItems.forEach(item => {
|
|
291
|
+
item.style.display = '';
|
|
292
|
+
item.classList.remove('keyboard-selected', 'dropped');
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const zoneContents = _container.querySelectorAll('.drop-zone .zone-content');
|
|
296
|
+
zoneContents.forEach(zone => zone.innerHTML = '');
|
|
297
|
+
|
|
298
|
+
clearFeedback(_container, id);
|
|
299
|
+
|
|
300
|
+
// Reset internal state
|
|
301
|
+
const state = _container._dragDropState;
|
|
302
|
+
if (state) {
|
|
303
|
+
state.placements = {};
|
|
304
|
+
state.selectedForDrop = null;
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
|
|
308
|
+
getResponse: () => {
|
|
309
|
+
validateContainer(_container, id);
|
|
310
|
+
|
|
311
|
+
const placements = {};
|
|
312
|
+
const zones = _container.querySelectorAll('.drop-zone');
|
|
313
|
+
|
|
314
|
+
zones.forEach(zone => {
|
|
315
|
+
const droppedItems = zone.querySelectorAll('.drag-item.dropped');
|
|
316
|
+
droppedItems.forEach(item => {
|
|
317
|
+
placements[item.dataset.itemId] = zone.dataset.zoneId;
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
return placements;
|
|
322
|
+
},
|
|
323
|
+
|
|
324
|
+
setResponse: (placements) => {
|
|
325
|
+
validateContainer(_container, id);
|
|
326
|
+
|
|
327
|
+
if (!placements || typeof placements !== 'object') {
|
|
328
|
+
throw new Error(`setResponse expects an object for drag-drop question "${id}"`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Reset to clean state
|
|
332
|
+
const dragItems = _container.querySelectorAll('.drag-item');
|
|
333
|
+
dragItems.forEach(item => item.style.display = '');
|
|
334
|
+
|
|
335
|
+
const zones = _container.querySelectorAll('.drop-zone .zone-content');
|
|
336
|
+
zones.forEach(zone => zone.innerHTML = '');
|
|
337
|
+
|
|
338
|
+
// Apply placements
|
|
339
|
+
Object.keys(placements).forEach(itemId => {
|
|
340
|
+
const zoneId = placements[itemId];
|
|
341
|
+
const item = _container.querySelector(`.drag-item[data-item-id="${escapeCssSelector(itemId)}"]`);
|
|
342
|
+
const zone = _container.querySelector(`.drop-zone[data-zone-id="${escapeCssSelector(zoneId)}"] .zone-content`);
|
|
343
|
+
|
|
344
|
+
if (item && zone) {
|
|
345
|
+
const clonedItem = item.cloneNode(true);
|
|
346
|
+
clonedItem.classList.add('dropped');
|
|
347
|
+
clonedItem.draggable = false;
|
|
348
|
+
zone.appendChild(clonedItem);
|
|
349
|
+
item.style.display = 'none';
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// Update internal state
|
|
354
|
+
const state = _container._dragDropState;
|
|
355
|
+
if (state) {
|
|
356
|
+
state.placements = { ...placements };
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
|
|
360
|
+
getCorrectAnswer: () => {
|
|
361
|
+
const correctPlacements = {};
|
|
362
|
+
dropZones.forEach(zone => {
|
|
363
|
+
if (zone.accepts && Array.isArray(zone.accepts)) {
|
|
364
|
+
zone.accepts.forEach(itemId => {
|
|
365
|
+
correctPlacements[itemId] = zone.id;
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
return correctPlacements;
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
// For uncontrolled interactions, register with the central registry for lifecycle mgmt
|
|
374
|
+
if (!controlled) {
|
|
375
|
+
registerCoreInteraction(config, questionObj);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return questionObj;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Sets up drag-drop interaction with native HTML5 drag-and-drop, touch, and keyboard support
|
|
383
|
+
*/
|
|
384
|
+
function setupDragDropInteraction(container, questionObj, initialPlacements = {}) {
|
|
385
|
+
// Store state on container element
|
|
386
|
+
container._dragDropState = {
|
|
387
|
+
placements: { ...initialPlacements },
|
|
388
|
+
draggedElement: null,
|
|
389
|
+
selectedForDrop: null,
|
|
390
|
+
// Touch-specific state
|
|
391
|
+
touchDragElement: null,
|
|
392
|
+
touchClone: null,
|
|
393
|
+
touchStartX: 0,
|
|
394
|
+
touchStartY: 0,
|
|
395
|
+
touchOffsetX: 0,
|
|
396
|
+
touchOffsetY: 0
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const state = container._dragDropState;
|
|
400
|
+
|
|
401
|
+
// Lock the drag-items area height to prevent shrinking when items are removed
|
|
402
|
+
const dragItemsArea = container.querySelector('.drag-items');
|
|
403
|
+
if (dragItemsArea) {
|
|
404
|
+
// Use requestAnimationFrame to ensure layout is complete before measuring
|
|
405
|
+
requestAnimationFrame(() => {
|
|
406
|
+
const currentHeight = dragItemsArea.offsetHeight;
|
|
407
|
+
dragItemsArea.style.minHeight = `${currentHeight}px`;
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Prevent drag from starting when clicking remove buttons
|
|
412
|
+
const preventDragOnButton = (e) => {
|
|
413
|
+
if (e.target.classList.contains('remove-item')) {
|
|
414
|
+
e.stopPropagation();
|
|
415
|
+
// Temporarily disable draggable on the parent
|
|
416
|
+
const dragItem = e.target.closest('.drag-item');
|
|
417
|
+
if (dragItem) {
|
|
418
|
+
dragItem.draggable = false;
|
|
419
|
+
setTimeout(() => { dragItem.draggable = true; }, 100);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
container.addEventListener('mousedown', preventDragOnButton, true);
|
|
425
|
+
container.addEventListener('touchstart', preventDragOnButton, { capture: true, passive: false });
|
|
426
|
+
|
|
427
|
+
// Setup drag-and-drop event listeners
|
|
428
|
+
const dragItems = container.querySelectorAll('.drag-item');
|
|
429
|
+
dragItems.forEach(item => {
|
|
430
|
+
item.addEventListener('dragstart', (e) => {
|
|
431
|
+
// Prevent drag if clicking on remove button
|
|
432
|
+
if (e.target.classList.contains('remove-item') || e.target.closest('.remove-item')) {
|
|
433
|
+
e.preventDefault();
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
state.draggedElement = e.currentTarget;
|
|
437
|
+
e.currentTarget.setAttribute('aria-grabbed', 'true');
|
|
438
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
439
|
+
e.dataTransfer.setData('text/plain', e.currentTarget.dataset.itemId);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
item.addEventListener('dragend', (e) => {
|
|
443
|
+
e.target.setAttribute('aria-grabbed', 'false');
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// Keyboard support for items
|
|
447
|
+
item.addEventListener('keydown', (e) => {
|
|
448
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
449
|
+
e.preventDefault();
|
|
450
|
+
container.querySelectorAll('.drag-item').forEach(i => i.classList.remove('keyboard-selected'));
|
|
451
|
+
state.selectedForDrop = e.currentTarget;
|
|
452
|
+
e.currentTarget.classList.add('keyboard-selected');
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// Touch support for items
|
|
457
|
+
setupTouchDragForItem(item, container, state);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
// Setup drop zones
|
|
461
|
+
const dropZones = container.querySelectorAll('.drop-zone');
|
|
462
|
+
dropZones.forEach(zone => {
|
|
463
|
+
zone.addEventListener('dragover', (e) => {
|
|
464
|
+
e.preventDefault();
|
|
465
|
+
const zoneContent = e.currentTarget.querySelector('.zone-content');
|
|
466
|
+
const maxItems = parseInt(e.currentTarget.dataset.maxItems) || 1;
|
|
467
|
+
const currentItems = zoneContent ? zoneContent.querySelectorAll('.drag-item.dropped').length : 0;
|
|
468
|
+
const isFull = currentItems >= maxItems;
|
|
469
|
+
|
|
470
|
+
e.dataTransfer.dropEffect = isFull ? 'none' : 'move';
|
|
471
|
+
e.currentTarget.classList.add('drag-over');
|
|
472
|
+
e.currentTarget.classList.toggle('zone-full', isFull);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
zone.addEventListener('dragleave', (e) => {
|
|
476
|
+
e.currentTarget.classList.remove('drag-over', 'zone-full');
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
zone.addEventListener('drop', (e) => {
|
|
480
|
+
e.preventDefault();
|
|
481
|
+
e.currentTarget.classList.remove('drag-over', 'zone-full');
|
|
482
|
+
if (state.draggedElement) {
|
|
483
|
+
performDrop(container, state, state.draggedElement, e.currentTarget);
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
// Keyboard support for zones
|
|
488
|
+
zone.addEventListener('keydown', (e) => {
|
|
489
|
+
if ((e.key === 'Enter' || e.key === ' ') && state.selectedForDrop) {
|
|
490
|
+
e.preventDefault();
|
|
491
|
+
performDrop(container, state, state.selectedForDrop, e.currentTarget);
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
// Setup drag items area as a drop zone (to drag items back)
|
|
497
|
+
if (dragItemsArea) {
|
|
498
|
+
dragItemsArea.addEventListener('dragover', (e) => {
|
|
499
|
+
e.preventDefault();
|
|
500
|
+
e.dataTransfer.dropEffect = 'move';
|
|
501
|
+
e.currentTarget.classList.add('drag-over');
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
dragItemsArea.addEventListener('dragleave', (e) => {
|
|
505
|
+
e.currentTarget.classList.remove('drag-over');
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
dragItemsArea.addEventListener('drop', (e) => {
|
|
509
|
+
e.preventDefault();
|
|
510
|
+
e.currentTarget.classList.remove('drag-over');
|
|
511
|
+
if (state.draggedElement && state.draggedElement.classList.contains('dropped')) {
|
|
512
|
+
const itemId = state.draggedElement.dataset.itemId;
|
|
513
|
+
removeItemFromZone(container, itemId);
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
// Keyboard support to return items
|
|
518
|
+
dragItemsArea.addEventListener('keydown', (e) => {
|
|
519
|
+
if ((e.key === 'Enter' || e.key === ' ') && state.selectedForDrop && state.selectedForDrop.classList.contains('dropped')) {
|
|
520
|
+
e.preventDefault();
|
|
521
|
+
const itemId = state.selectedForDrop.dataset.itemId;
|
|
522
|
+
removeItemFromZone(container, itemId);
|
|
523
|
+
state.selectedForDrop = null;
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Performs a drop operation, moving an item to a zone
|
|
531
|
+
*/
|
|
532
|
+
function performDrop(container, state, item, zone) {
|
|
533
|
+
const itemId = item.dataset.itemId;
|
|
534
|
+
const zoneId = zone.dataset.zoneId;
|
|
535
|
+
const zoneContent = zone.querySelector('.zone-content');
|
|
536
|
+
|
|
537
|
+
if (!zoneContent) {
|
|
538
|
+
throw new Error('Drop zone content element not found');
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Check max items limit
|
|
542
|
+
const maxItems = parseInt(zone.dataset.maxItems) || 1;
|
|
543
|
+
const currentItems = zoneContent.querySelectorAll('.drag-item.dropped');
|
|
544
|
+
|
|
545
|
+
if (currentItems.length >= maxItems) {
|
|
546
|
+
// Zone is full - don't allow drop
|
|
547
|
+
logger.warn(`Zone "${zoneId}" is full (max: ${maxItems})`);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// If the item is already in a zone, remove it from that zone first
|
|
552
|
+
if (item.classList.contains('dropped')) {
|
|
553
|
+
const currentZone = item.closest('.drop-zone');
|
|
554
|
+
if (currentZone) {
|
|
555
|
+
const _oldZoneId = currentZone.dataset.zoneId;
|
|
556
|
+
delete state.placements[itemId];
|
|
557
|
+
}
|
|
558
|
+
item.remove();
|
|
559
|
+
|
|
560
|
+
// Show the original item in the items area temporarily
|
|
561
|
+
const originalItem = container.querySelector(`.drag-items .drag-item[data-item-id="${escapeCssSelector(itemId)}"]`);
|
|
562
|
+
if (originalItem) {
|
|
563
|
+
originalItem.style.display = '';
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Add new item to zone (don't clear existing items)
|
|
568
|
+
const clonedItem = item.cloneNode(true);
|
|
569
|
+
clonedItem.classList.add('dropped');
|
|
570
|
+
clonedItem.classList.remove('keyboard-selected');
|
|
571
|
+
clonedItem.draggable = true;
|
|
572
|
+
|
|
573
|
+
// Add remove button if not already present
|
|
574
|
+
if (!clonedItem.querySelector('.remove-item')) {
|
|
575
|
+
const removeBtn = document.createElement('button');
|
|
576
|
+
removeBtn.type = 'button';
|
|
577
|
+
removeBtn.className = 'remove-item';
|
|
578
|
+
removeBtn.dataset.action = 'remove-item';
|
|
579
|
+
removeBtn.dataset.itemId = itemId;
|
|
580
|
+
removeBtn.setAttribute('aria-label', `Remove ${item.textContent || item.innerText}`);
|
|
581
|
+
removeBtn.setAttribute('title', 'Remove this item');
|
|
582
|
+
removeBtn.textContent = '×';
|
|
583
|
+
clonedItem.appendChild(removeBtn);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
zoneContent.appendChild(clonedItem);
|
|
587
|
+
|
|
588
|
+
// Setup drag listeners for the cloned item
|
|
589
|
+
setupDragListenersForItem(clonedItem, container, state);
|
|
590
|
+
|
|
591
|
+
// Update state
|
|
592
|
+
state.placements[itemId] = zoneId;
|
|
593
|
+
item.style.display = 'none';
|
|
594
|
+
state.selectedForDrop = null;
|
|
595
|
+
state.draggedElement = null;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Setup drag event listeners for an item (used for items in zones)
|
|
600
|
+
*/
|
|
601
|
+
function setupDragListenersForItem(item, container, state) {
|
|
602
|
+
item.addEventListener('dragstart', (e) => {
|
|
603
|
+
// Prevent drag if clicking on remove button
|
|
604
|
+
if (e.target.classList.contains('remove-item') || e.target.closest('.remove-item')) {
|
|
605
|
+
e.preventDefault();
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
state.draggedElement = e.currentTarget;
|
|
609
|
+
e.currentTarget.setAttribute('aria-grabbed', 'true');
|
|
610
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
611
|
+
e.dataTransfer.setData('text/plain', e.currentTarget.dataset.itemId);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
item.addEventListener('dragend', (e) => {
|
|
615
|
+
e.target.setAttribute('aria-grabbed', 'false');
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
item.addEventListener('keydown', (e) => {
|
|
619
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
620
|
+
e.preventDefault();
|
|
621
|
+
container.querySelectorAll('.drag-item').forEach(i => i.classList.remove('keyboard-selected'));
|
|
622
|
+
state.selectedForDrop = e.currentTarget;
|
|
623
|
+
e.currentTarget.classList.add('keyboard-selected');
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// Touch support for items in zones
|
|
628
|
+
setupTouchDragForItem(item, container, state);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Sets up touch drag support for a drag item
|
|
633
|
+
*/
|
|
634
|
+
function setupTouchDragForItem(item, container, state) {
|
|
635
|
+
let touchTimeout = null;
|
|
636
|
+
let hasMoved = false;
|
|
637
|
+
|
|
638
|
+
item.addEventListener('touchstart', (e) => {
|
|
639
|
+
// Prevent touch drag if touching remove button
|
|
640
|
+
if (e.target.classList.contains('remove-item') || e.target.closest('.remove-item')) {
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const touch = e.touches[0];
|
|
645
|
+
state.touchStartX = touch.clientX;
|
|
646
|
+
state.touchStartY = touch.clientY;
|
|
647
|
+
hasMoved = false;
|
|
648
|
+
|
|
649
|
+
// Store the original item
|
|
650
|
+
state.touchDragElement = item;
|
|
651
|
+
|
|
652
|
+
// Calculate offset from touch point to item top-left
|
|
653
|
+
const rect = item.getBoundingClientRect();
|
|
654
|
+
state.touchOffsetX = touch.clientX - rect.left;
|
|
655
|
+
state.touchOffsetY = touch.clientY - rect.top;
|
|
656
|
+
|
|
657
|
+
// Start drag after a short delay to distinguish from scroll
|
|
658
|
+
touchTimeout = setTimeout(() => {
|
|
659
|
+
if (!hasMoved) {
|
|
660
|
+
startTouchDrag(item, container, state, touch);
|
|
661
|
+
}
|
|
662
|
+
}, 150);
|
|
663
|
+
}, { passive: false });
|
|
664
|
+
|
|
665
|
+
item.addEventListener('touchmove', (e) => {
|
|
666
|
+
if (!state.touchDragElement) return;
|
|
667
|
+
|
|
668
|
+
const touch = e.touches[0];
|
|
669
|
+
const moveX = Math.abs(touch.clientX - state.touchStartX);
|
|
670
|
+
const moveY = Math.abs(touch.clientY - state.touchStartY);
|
|
671
|
+
|
|
672
|
+
// If moved more than threshold, consider it a drag
|
|
673
|
+
if (moveX > 10 || moveY > 10) {
|
|
674
|
+
hasMoved = true;
|
|
675
|
+
if (touchTimeout) {
|
|
676
|
+
clearTimeout(touchTimeout);
|
|
677
|
+
touchTimeout = null;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Start drag if not already started
|
|
681
|
+
if (!state.touchClone) {
|
|
682
|
+
startTouchDrag(item, container, state, touch);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Move the clone if dragging
|
|
687
|
+
if (state.touchClone) {
|
|
688
|
+
e.preventDefault();
|
|
689
|
+
moveTouchClone(state, touch);
|
|
690
|
+
updateTouchDropTargetHighlight(container, touch);
|
|
691
|
+
}
|
|
692
|
+
}, { passive: false });
|
|
693
|
+
|
|
694
|
+
item.addEventListener('touchend', (e) => {
|
|
695
|
+
if (touchTimeout) {
|
|
696
|
+
clearTimeout(touchTimeout);
|
|
697
|
+
touchTimeout = null;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (state.touchClone) {
|
|
701
|
+
const touch = e.changedTouches[0];
|
|
702
|
+
completeTouchDrop(container, state, touch);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Reset touch state
|
|
706
|
+
state.touchDragElement = null;
|
|
707
|
+
hasMoved = false;
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
item.addEventListener('touchcancel', () => {
|
|
711
|
+
if (touchTimeout) {
|
|
712
|
+
clearTimeout(touchTimeout);
|
|
713
|
+
touchTimeout = null;
|
|
714
|
+
}
|
|
715
|
+
cancelTouchDrag(container, state);
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Starts the touch drag by creating a visual clone
|
|
721
|
+
*/
|
|
722
|
+
function startTouchDrag(item, container, state, touch) {
|
|
723
|
+
// Prevent default to stop scrolling
|
|
724
|
+
item.setAttribute('aria-grabbed', 'true');
|
|
725
|
+
item.classList.add('touch-dragging');
|
|
726
|
+
|
|
727
|
+
// Create a clone for visual feedback
|
|
728
|
+
const clone = item.cloneNode(true);
|
|
729
|
+
clone.classList.add('touch-drag-clone');
|
|
730
|
+
clone.style.position = 'fixed';
|
|
731
|
+
clone.style.zIndex = '10000';
|
|
732
|
+
clone.style.pointerEvents = 'none';
|
|
733
|
+
clone.style.width = `${item.offsetWidth}px`;
|
|
734
|
+
clone.style.opacity = '0.9';
|
|
735
|
+
clone.style.transform = 'scale(1.05)';
|
|
736
|
+
clone.style.boxShadow = '0 8px 24px rgba(0, 0, 0, 0.3)';
|
|
737
|
+
|
|
738
|
+
// Position clone at touch point
|
|
739
|
+
clone.style.left = `${touch.clientX - state.touchOffsetX}px`;
|
|
740
|
+
clone.style.top = `${touch.clientY - state.touchOffsetY}px`;
|
|
741
|
+
|
|
742
|
+
document.body.appendChild(clone);
|
|
743
|
+
state.touchClone = clone;
|
|
744
|
+
|
|
745
|
+
// Dim the original item
|
|
746
|
+
item.style.opacity = '0.4';
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Moves the touch clone to follow the finger
|
|
751
|
+
*/
|
|
752
|
+
function moveTouchClone(state, touch) {
|
|
753
|
+
if (state.touchClone) {
|
|
754
|
+
state.touchClone.style.left = `${touch.clientX - state.touchOffsetX}px`;
|
|
755
|
+
state.touchClone.style.top = `${touch.clientY - state.touchOffsetY}px`;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Updates drop target highlight during touch drag
|
|
761
|
+
*/
|
|
762
|
+
function updateTouchDropTargetHighlight(container, touch) {
|
|
763
|
+
// Remove existing highlights
|
|
764
|
+
container.querySelectorAll('.drop-zone.drag-over, .drag-items.drag-over').forEach(el => {
|
|
765
|
+
el.classList.remove('drag-over', 'zone-full');
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
// Find element under touch point
|
|
769
|
+
const elementUnder = document.elementFromPoint(touch.clientX, touch.clientY);
|
|
770
|
+
if (!elementUnder) return;
|
|
771
|
+
|
|
772
|
+
// Check if over a drop zone
|
|
773
|
+
const dropZone = elementUnder.closest('.drop-zone');
|
|
774
|
+
if (dropZone && container.contains(dropZone)) {
|
|
775
|
+
const zoneContent = dropZone.querySelector('.zone-content');
|
|
776
|
+
const maxItems = parseInt(dropZone.dataset.maxItems) || 1;
|
|
777
|
+
const currentItems = zoneContent ? zoneContent.querySelectorAll('.drag-item.dropped').length : 0;
|
|
778
|
+
const isFull = currentItems >= maxItems;
|
|
779
|
+
|
|
780
|
+
dropZone.classList.add('drag-over');
|
|
781
|
+
if (isFull) {
|
|
782
|
+
dropZone.classList.add('zone-full');
|
|
783
|
+
}
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// Check if over the drag items area (for returning items)
|
|
788
|
+
const dragItemsArea = elementUnder.closest('.drag-items');
|
|
789
|
+
if (dragItemsArea && container.contains(dragItemsArea)) {
|
|
790
|
+
dragItemsArea.classList.add('drag-over');
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Completes the touch drop operation
|
|
796
|
+
*/
|
|
797
|
+
function completeTouchDrop(container, state, touch) {
|
|
798
|
+
// Remove highlights
|
|
799
|
+
container.querySelectorAll('.drop-zone.drag-over, .drag-items.drag-over').forEach(el => {
|
|
800
|
+
el.classList.remove('drag-over', 'zone-full');
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
// Remove clone
|
|
804
|
+
if (state.touchClone) {
|
|
805
|
+
state.touchClone.remove();
|
|
806
|
+
state.touchClone = null;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// Reset original item appearance
|
|
810
|
+
if (state.touchDragElement) {
|
|
811
|
+
state.touchDragElement.style.opacity = '';
|
|
812
|
+
state.touchDragElement.setAttribute('aria-grabbed', 'false');
|
|
813
|
+
state.touchDragElement.classList.remove('touch-dragging');
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Find element under touch point
|
|
817
|
+
const elementUnder = document.elementFromPoint(touch.clientX, touch.clientY);
|
|
818
|
+
if (!elementUnder || !state.touchDragElement) return;
|
|
819
|
+
|
|
820
|
+
// Check if dropped on a drop zone
|
|
821
|
+
const dropZone = elementUnder.closest('.drop-zone');
|
|
822
|
+
if (dropZone && container.contains(dropZone)) {
|
|
823
|
+
performDrop(container, state, state.touchDragElement, dropZone);
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// Check if dropped on drag items area (returning an item)
|
|
828
|
+
const dragItemsArea = elementUnder.closest('.drag-items');
|
|
829
|
+
if (dragItemsArea && container.contains(dragItemsArea)) {
|
|
830
|
+
if (state.touchDragElement.classList.contains('dropped')) {
|
|
831
|
+
const itemId = state.touchDragElement.dataset.itemId;
|
|
832
|
+
removeItemFromZone(container, itemId);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Cancels a touch drag operation
|
|
839
|
+
*/
|
|
840
|
+
function cancelTouchDrag(container, state) {
|
|
841
|
+
// Remove highlights
|
|
842
|
+
container.querySelectorAll('.drop-zone.drag-over, .drag-items.drag-over').forEach(el => {
|
|
843
|
+
el.classList.remove('drag-over', 'zone-full');
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
// Remove clone
|
|
847
|
+
if (state.touchClone) {
|
|
848
|
+
state.touchClone.remove();
|
|
849
|
+
state.touchClone = null;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Reset original item appearance
|
|
853
|
+
if (state.touchDragElement) {
|
|
854
|
+
state.touchDragElement.style.opacity = '';
|
|
855
|
+
state.touchDragElement.setAttribute('aria-grabbed', 'false');
|
|
856
|
+
state.touchDragElement.classList.remove('touch-dragging');
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
state.touchDragElement = null;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Removes an item from a drop zone and returns it to the items area
|
|
864
|
+
*/
|
|
865
|
+
function removeItemFromZone(container, itemId) {
|
|
866
|
+
const state = container._dragDropState;
|
|
867
|
+
if (!state) return;
|
|
868
|
+
|
|
869
|
+
// Find the dropped item in a zone
|
|
870
|
+
const droppedItem = container.querySelector(`.drop-zone .drag-item[data-item-id="${escapeCssSelector(itemId)}"]`);
|
|
871
|
+
if (droppedItem) {
|
|
872
|
+
droppedItem.remove();
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// Show the original item in the items area
|
|
876
|
+
const originalItem = container.querySelector(`.drag-items .drag-item[data-item-id="${escapeCssSelector(itemId)}"]`);
|
|
877
|
+
if (originalItem) {
|
|
878
|
+
originalItem.style.display = '';
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// Update state
|
|
882
|
+
delete state.placements[itemId];
|
|
883
|
+
}
|
|
884
|
+
|